Compare commits

...

8 Commits

Author SHA1 Message Date
dependabot[bot]
c612e8bfb3
Merge 4dd0fee692 into 3a85ad0896 2026-09-26 08:34:12 +08:00
Kingkor Roy Tirtho
3a85ad0896 feat(update): implement update checker with dialog for available updates 2026-09-25 22:39:33 +06:00
Kingkor Roy Tirtho
046c17d3df feat(autocomplete): simplify focus management and remove unused key event handling 2026-09-25 21:39:08 +06:00
Kingkor Roy Tirtho
2713e63902 feat(search): enhance LazyRow with drag scrolling functionality 2026-09-25 21:14:34 +06:00
Kingkor Roy Tirtho
86ec117cfd feat(navigation): refactor sidebar and bottombar navigation structure with new tab and group models 2026-09-25 21:07:19 +06:00
Kingkor Roy Tirtho
78168d269c feat: implement shuffle play functionality for albums, playlists, and saved tracks 2026-09-25 18:47:29 +06:00
Kingkor Roy Tirtho
50c3b04eb1 feat: refactor track source management to support plugin-specific caching and retrieval 2026-09-25 18:29:08 +06:00
dependabot[bot]
4dd0fee692
chore(deps): bump actions/setup-java from 4 to 5
Bumps [actions/setup-java](https://github.com/actions/setup-java) from 4 to 5.
- [Release notes](https://github.com/actions/setup-java/releases)
- [Commits](https://github.com/actions/setup-java/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/setup-java
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-10 05:08:50 +00:00
40 changed files with 1663 additions and 397 deletions

View File

@ -60,7 +60,7 @@ jobs:
path: vlcj-bundler path: vlcj-bundler
- name: Set up JDK 21 - name: Set up JDK 21
uses: actions/setup-java@v4 uses: actions/setup-java@v5
with: with:
distribution: temurin distribution: temurin
java-version: 21 java-version: 21
@ -112,7 +112,7 @@ jobs:
path: ~/.m2/repository/ path: ~/.m2/repository/
- name: Set up JDK 21 - name: Set up JDK 21
uses: actions/setup-java@v4 uses: actions/setup-java@v5
with: with:
distribution: temurin distribution: temurin
java-version: 21 java-version: 21
@ -179,7 +179,7 @@ jobs:
sudo apt-get install -y rpm fuse sudo apt-get install -y rpm fuse
- name: Set up JDK 21 - name: Set up JDK 21
uses: actions/setup-java@v4 uses: actions/setup-java@v5
with: with:
distribution: temurin distribution: temurin
java-version: 21 java-version: 21
@ -296,7 +296,7 @@ jobs:
path: ~/.m2/repository/ path: ~/.m2/repository/
- name: Set up JDK 21 - name: Set up JDK 21
uses: actions/setup-java@v4 uses: actions/setup-java@v5
with: with:
distribution: temurin distribution: temurin
java-version: 21 java-version: 21
@ -342,7 +342,7 @@ jobs:
path: ~/.m2/repository/ path: ~/.m2/repository/
- name: Set up JDK 21 - name: Set up JDK 21
uses: actions/setup-java@v4 uses: actions/setup-java@v5
with: with:
distribution: temurin distribution: temurin
java-version: 21 java-version: 21

View File

@ -20,6 +20,7 @@ import gobley.gradle.cargo.dsl.jvm
import dev.nucleusframework.desktop.application.dsl.TargetFormat import dev.nucleusframework.desktop.application.dsl.TargetFormat
import org.jetbrains.compose.reload.gradle.ComposeHotRun import org.jetbrains.compose.reload.gradle.ComposeHotRun
import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.gradle.api.tasks.Copy
import java.io.FileInputStream import java.io.FileInputStream
import java.util.Properties import java.util.Properties
@ -39,11 +40,53 @@ plugins {
kotlin("plugin.atomicfu") version libs.versions.kotlin kotlin("plugin.atomicfu") version libs.versions.kotlin
} }
val bundleId = "dev.krtirtho.spotube"
val version = "6.0.0"
val buildNumber = 46
val targetName = "Spotube"
val homePageUrl = "https://spotube.cc"
val repositoryUrl = "https://github.com/team-spotube/spotube"
val appVersionName = version
val appBuildNumber = buildNumber
val appRepositoryUrl = repositoryUrl
val githubRepositoryPath = repositoryUrl.removePrefix("https://github.com/")
val latestReleaseApiUrl = "https://api.github.com/repos/$githubRepositoryPath/releases/latest"
val generatedAppInfoDir = layout.buildDirectory.dir("generated/appBuildInfo/commonMain/kotlin")
val generateAppBuildInfo by tasks.registering(Copy::class) {
description = "Generate AppBuildInfo.kt"
from(layout.projectDirectory.file("src/commonMain/templates/AppBuildInfo.kt.template"))
into(layout.buildDirectory.dir("generated/appBuildInfo/commonMain/kotlin/dev/krtirtho/spotube"))
rename("AppBuildInfo.kt.template", "AppBuildInfo.kt")
expand(
mapOf(
"appVersion" to appVersionName,
"appBuildNumber" to appBuildNumber,
"repositoryUrl" to appRepositoryUrl,
"latestReleaseApiUrl" to latestReleaseApiUrl,
)
)
inputs.properties(
mapOf(
"appVersion" to appVersionName,
"appBuildNumber" to appBuildNumber,
"repositoryUrl" to appRepositoryUrl,
"latestReleaseApiUrl" to latestReleaseApiUrl,
)
)
}
tasks.configureEach {
if (name.startsWith("compile") && name.contains("Kotlin")) {
dependsOn(generateAppBuildInfo)
}
}
vlcjBundler { vlcjBundler {
packageName = "dev.krtirtho.spotube.core.generated" // choose a different package packageName = "$bundleId.core.generated" // choose a different package
objectName = "VLCBundleLoaderGenerated" // or rename the object objectName = "VLCBundleLoaderGenerated" // or rename the object
} }
kotlin { kotlin {
// Note: For Android application modules, androidTarget() is still required as of AGP 8.x. // Note: For Android application modules, androidTarget() is still required as of AGP 8.x.
// The deprecation warning is expected. For libraries, use the androidKotlinMultiplatformLibrary plugin instead. // The deprecation warning is expected. For libraries, use the androidKotlinMultiplatformLibrary plugin instead.
@ -68,6 +111,7 @@ kotlin {
sourceSets { sourceSets {
val commonMain by getting { val commonMain by getting {
kotlin.srcDir(generatedAppInfoDir)
dependencies { dependencies {
// Project dependencies // Project dependencies
implementation(project(":plugin_interfaces")) implementation(project(":plugin_interfaces"))
@ -172,6 +216,10 @@ kotlin {
implementation(libs.mqtt.x.models) implementation(libs.mqtt.x.models)
implementation(libs.mqtt.buffer) implementation(libs.mqtt.buffer)
implementation(libs.mqtt.buffer.codec) implementation(libs.mqtt.buffer.codec)
// Markdown renderer
implementation(libs.multiplatform.markdown.renderer)
implementation(libs.multiplatform.markdown.renderer.m3)
} }
} }
commonTest.dependencies { commonTest.dependencies {
@ -249,11 +297,11 @@ android {
compileSdk = libs.versions.android.compileSdk.get().toInt() compileSdk = libs.versions.android.compileSdk.get().toInt()
defaultConfig { defaultConfig {
applicationId = "dev.krtirtho.spotube" applicationId = bundleId
minSdk = libs.versions.android.minSdk.get().toInt() minSdk = libs.versions.android.minSdk.get().toInt()
targetSdk = libs.versions.android.targetSdk.get().toInt() targetSdk = libs.versions.android.targetSdk.get().toInt()
versionCode = 1 versionCode = buildNumber
versionName = project.findProperty("versionName") as String? ?: "1.0" versionName = version
} }
packaging { packaging {
resources { resources {
@ -312,8 +360,8 @@ nucleus.application {
) )
nativeDistributions { nativeDistributions {
packageName = "dev.krtirtho.spotube" packageName = bundleId
packageVersion = project.findProperty("versionName") as String? ?: "6.0.0" packageVersion = version
licenseFile = project.file("../LICENSE") licenseFile = project.file("../LICENSE")
appResourcesRootDir.set(vlcjBundler.vlcNativesDirectory) appResourcesRootDir.set(vlcjBundler.vlcNativesDirectory)
@ -329,7 +377,7 @@ nucleus.application {
modules("jdk.unsupported") modules("jdk.unsupported")
homepage = "https://spotube.cc" homepage = homePageUrl
linux { linux {
modules("jdk.security.auth") modules("jdk.security.auth")

View File

@ -22,74 +22,114 @@ import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
import dev.krtirtho.spotube.core.ui.base.rememberBaseUITheme
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.ui.NavDisplay import androidx.navigation3.ui.NavDisplay
import com.mikepenz.markdown.m3.Markdown
import dev.krtirtho.spotube.core.navigation.Navigator import dev.krtirtho.spotube.core.navigation.Navigator
import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES
import dev.krtirtho.spotube.core.navigation.rememberNavigationState import dev.krtirtho.spotube.core.navigation.rememberNavigationState
import dev.krtirtho.spotube.core.navigation.toEntries import dev.krtirtho.spotube.core.navigation.toEntries
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
import dev.krtirtho.spotube.core.ui.base.OutlineButton
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
import dev.krtirtho.spotube.core.ui.base.ThemedDialog
import dev.krtirtho.spotube.core.ui.base.rememberBaseUITheme
import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope
import dev.krtirtho.spotube.core.ui.theming.SpotubeTheme import dev.krtirtho.spotube.core.ui.theming.SpotubeTheme
import dev.krtirtho.spotube.modules.library.LibraryTab
import dev.krtirtho.spotube.modules.settings.SettingsRepository import dev.krtirtho.spotube.modules.settings.SettingsRepository
import dev.krtirtho.spotube.modules.settings.UserSettings import dev.krtirtho.spotube.modules.settings.UserSettings
import dev.krtirtho.spotube.modules.shell.AppShell import dev.krtirtho.spotube.modules.shell.AppShell
import dev.krtirtho.spotube.modules.update.UpdateCheckerViewModel
import dev.krtirtho.spotube.modules.webview.WebViewScreen import dev.krtirtho.spotube.modules.webview.WebViewScreen
import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxCd
import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive
import dev.krtirtho.spotube.resources.iconsax.IconsaxHome import dev.krtirtho.spotube.resources.iconsax.IconsaxHome
import dev.krtirtho.spotube.resources.iconsax.IconsaxHomeBroken import dev.krtirtho.spotube.resources.iconsax.IconsaxMirrorScreenRegular
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicDashboard
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicLibrary import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicLibrary
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicLibraryOutline
import dev.krtirtho.spotube.resources.iconsax.IconsaxSearch import dev.krtirtho.spotube.resources.iconsax.IconsaxSearch
import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken
import dev.krtirtho.spotube.resources.iconsax.IconsaxSetting2 import dev.krtirtho.spotube.resources.iconsax.IconsaxSetting2
import dev.krtirtho.spotube.resources.iconsax.IconsaxSettingTwotone import dev.krtirtho.spotube.resources.iconsax.IconsaxSoundTwotone
import dev.krtirtho.spotube.resources.iconsax.User
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import org.koin.compose.koinInject import org.koin.compose.koinInject
import org.koin.compose.navigation3.koinEntryProvider import org.koin.compose.navigation3.koinEntryProvider
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.annotation.KoinExperimentalAPI import org.koin.core.annotation.KoinExperimentalAPI
data class TabItem( interface NavigationItem {
val title: String, data class Tab<T : Any>(
val icon: ImageVector, val title: String, val icon: ImageVector, val route: Routes,
val activeIcon: ImageVector, val data: T? = null
val route: Routes ) : NavigationItem
data class Group<T : Any>(
val title: String, val items: List<Tab<T>>
) : NavigationItem
}
private val tabs = listOf<NavigationItem>(
NavigationItem.Tab<Any>("Home", Iconsax.IconsaxHome, Routes.Home),
NavigationItem.Tab<Any>("Search", Iconsax.IconsaxSearch, Routes.Search),
) )
val tabs = listOf( val sidebarTabs = tabs + listOf(
TabItem( NavigationItem.Group(
"Home",
Iconsax.IconsaxHomeBroken,
Iconsax.IconsaxHome,
Routes.Home
),
TabItem(
"Search",
Iconsax.IconsaxSearchBroken,
Iconsax.IconsaxSearch,
Routes.Search
),
TabItem(
"Library", "Library",
Iconsax.IconsaxMusicLibraryOutline, items = listOf(
Iconsax.IconsaxMusicLibrary, NavigationItem.Tab(
Routes.Library "Playlists",
Iconsax.IconsaxMusicDashboard,
Routes.Library,
LibraryTab.Playlists
), ),
TabItem( NavigationItem.Tab("Albums", Iconsax.IconsaxCd, Routes.Library, LibraryTab.Albums),
"Settings", NavigationItem.Tab("Artists", Iconsax.User, Routes.Library, LibraryTab.Artists),
Iconsax.IconsaxSettingTwotone,
Iconsax.IconsaxSetting2,
Routes.Settings
) )
),
NavigationItem.Group(
"On Device",
items = listOf(
NavigationItem.Tab(
"Local Tracks",
Iconsax.IconsaxMusicLibrary,
Routes.Library,
LibraryTab.LocalTracks
),
NavigationItem.Tab(
"Downloads",
Iconsax.IconsaxDirectboxReceive,
Routes.Library,
LibraryTab.Downloads
),
)
),
NavigationItem.Group(
"Connect",
items = listOf(
NavigationItem.Tab("Devices", Iconsax.IconsaxMirrorScreenRegular, Routes.Devices),
NavigationItem.Tab("Group Jam", Iconsax.IconsaxSoundTwotone, Routes.Jam),
),
),
)
val bottombarTabs = tabs + listOf(
NavigationItem.Tab<Any>("Library", Iconsax.IconsaxMusicLibrary, Routes.Library),
NavigationItem.Tab("Settings", Iconsax.IconsaxSetting2, Routes.Settings)
) )
@OptIn( @OptIn(
@ -103,10 +143,11 @@ fun App(
) { ) {
val settingsRepository: SettingsRepository = koinInject<SettingsRepository>() val settingsRepository: SettingsRepository = koinInject<SettingsRepository>()
val userSettings by settingsRepository.userSettings.collectAsStateWithLifecycle(initialValue = UserSettings()) val userSettings by settingsRepository.userSettings.collectAsStateWithLifecycle(initialValue = UserSettings())
val updateCheckerViewModel: UpdateCheckerViewModel = koinViewModel()
val availableUpdate by updateCheckerViewModel.availableUpdate.collectAsStateWithLifecycle()
val navigationState = rememberNavigationState( val navigationState = rememberNavigationState(
startRoute = Routes.Home, startRoute = Routes.Home, topLevelRoutes = TOP_LEVEL_ROUTES
topLevelRoutes = TOP_LEVEL_ROUTES
) )
val navigator = remember { val navigator = remember {
Navigator(navigationState) Navigator(navigationState)
@ -141,6 +182,43 @@ fun App(
} }
} }
content() content()
if (availableUpdate != null) {
val update = availableUpdate!!
ThemedDialog(
onDismissRequest = updateCheckerViewModel::dismissUpdate,
title = {
Text(
text = "Spotube ${update.tag} is available",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
)
},
actions = {
OutlineButton(onClick = updateCheckerViewModel::ignoreUpdate) {
Text("Ignore")
}
PrimaryButton(
onClick = {
runCatching { openUrlInBrowser("https://spotube.cc/downloads/") }
updateCheckerViewModel.dismissUpdate()
},
) {
Text("Update")
}
},
) {
if (update.releaseNotesMarkdown.isNotBlank()) {
SelectionContainer {
Markdown(
content = update.releaseNotesMarkdown,
modifier = Modifier.padding(bottom = 8.dp),
)
}
} else {
Text("See what's new in this release on the Spotube downloads page.")
}
}
}
} }
} }
} }

View File

@ -37,6 +37,7 @@ import dev.krtirtho.spotube.core.server.AlternativeTracksRepository
import dev.krtirtho.spotube.core.server.CacheManager import dev.krtirtho.spotube.core.server.CacheManager
import dev.krtirtho.spotube.core.server.LocalServer import dev.krtirtho.spotube.core.server.LocalServer
import dev.krtirtho.spotube.core.server.MatchedTracksRepository import dev.krtirtho.spotube.core.server.MatchedTracksRepository
import dev.krtirtho.spotube.core.server.TrackSourceRepository
import dev.krtirtho.spotube.core.server.StreamingUrlRepository import dev.krtirtho.spotube.core.server.StreamingUrlRepository
import dev.krtirtho.spotube.core.webview.WebViewController import dev.krtirtho.spotube.core.webview.WebViewController
import dev.krtirtho.spotube.modules.album.AlbumRepository import dev.krtirtho.spotube.modules.album.AlbumRepository
@ -64,6 +65,7 @@ import dev.krtirtho.spotube.modules.library.playlist.LibraryPlaylistsViewModel
import dev.krtirtho.spotube.modules.lyrics.LyricsViewModel import dev.krtirtho.spotube.modules.lyrics.LyricsViewModel
import dev.krtirtho.spotube.modules.playlist.PlaylistRepository import dev.krtirtho.spotube.modules.playlist.PlaylistRepository
import dev.krtirtho.spotube.modules.playlist.PlaylistViewModel import dev.krtirtho.spotube.modules.playlist.PlaylistViewModel
import dev.krtirtho.spotube.modules.plugin.AudioPluginSource
import dev.krtirtho.spotube.modules.plugin.PluginManager import dev.krtirtho.spotube.modules.plugin.PluginManager
import dev.krtirtho.spotube.modules.plugin.PluginProvider import dev.krtirtho.spotube.modules.plugin.PluginProvider
import dev.krtirtho.spotube.modules.plugin.PluginViewModel import dev.krtirtho.spotube.modules.plugin.PluginViewModel
@ -71,8 +73,10 @@ import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel
import dev.krtirtho.spotube.modules.search.SearchRepository import dev.krtirtho.spotube.modules.search.SearchRepository
import dev.krtirtho.spotube.modules.search.SearchScreenViewModel import dev.krtirtho.spotube.modules.search.SearchScreenViewModel
import dev.krtirtho.spotube.modules.update.UpdateCheckerViewModel
import dev.krtirtho.spotube.modules.settings.SettingsProvider import dev.krtirtho.spotube.modules.settings.SettingsProvider
import dev.krtirtho.spotube.modules.settings.SettingsRepository import dev.krtirtho.spotube.modules.settings.SettingsRepository
import dev.krtirtho.spotube.modules.settings.UserSettingsSource
import dev.krtirtho.spotube.modules.settings.SettingsViewModel import dev.krtirtho.spotube.modules.settings.SettingsViewModel
import dev.krtirtho.spotube.modules.settings.JamSettingsViewModel import dev.krtirtho.spotube.modules.settings.JamSettingsViewModel
import dev.krtirtho.spotube.modules.shell.AppShellViewModel import dev.krtirtho.spotube.modules.shell.AppShellViewModel
@ -122,13 +126,17 @@ val sharedModules = module {
viewModelOf(::LibraryLocalTracksViewModel) viewModelOf(::LibraryLocalTracksViewModel)
// Plugin system // Plugin system
singleOf(::PluginManager) { bind<PluginProvider>() } singleOf(::PluginManager) {
bind<PluginProvider>()
bind<AudioPluginSource>()
}
viewModelOf(::PluginViewModel) viewModelOf(::PluginViewModel)
// Settings // Settings
singleOf(::SettingsRepository) singleOf(::SettingsRepository) { bind<UserSettingsSource>() }
viewModelOf(::SettingsViewModel) { bind<SettingsProvider>() } viewModelOf(::SettingsViewModel) { bind<SettingsProvider>() }
viewModelOf(::JamSettingsViewModel) viewModelOf(::JamSettingsViewModel)
viewModelOf(::UpdateCheckerViewModel)
// Downloads // Downloads
singleOf(::DownloadManager) singleOf(::DownloadManager)
@ -226,8 +234,15 @@ val sharedModules = module {
blacklistRepository = get(), blacklistRepository = get(),
) )
} }
singleOf(::MatchedTracksRepository) singleOf(::MatchedTracksRepository) { bind<TrackSourceRepository>() }
singleOf(::StreamingUrlRepository) single {
StreamingUrlRepository(
pluginManager = get(),
audioPlayerQueue = get(),
matchedTracksRepository = get(),
settingsRepository = get(),
)
}
singleOf(::CacheManager) singleOf(::CacheManager)
singleOf(::AlternativeTracksRepository) singleOf(::AlternativeTracksRepository)
singleOf(::LocalServer) withOptions { singleOf(::LocalServer) withOptions {

View File

@ -36,9 +36,9 @@ class CollectionPlaybackHelper(
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
private val blacklistRepository: BlacklistRepository, private val blacklistRepository: BlacklistRepository,
) { ) {
suspend fun playAlbum(albumId: String) { suspend fun playAlbum(albumId: String, shuffle: Boolean = false) {
if (audioPlayerQueue.isAlbumPlaying(albumId)) return if (!shuffle && audioPlayerQueue.isAlbumPlaying(albumId)) return
val entries = fetchAllAlbumTracks(albumId) val entries = fetchAllAlbumTracks(albumId).let { if (shuffle) it.shuffled() else it }
if (entries.isNotEmpty()) { if (entries.isNotEmpty()) {
audioPlayerQueue.load( audioPlayerQueue.load(
entries = entries, entries = entries,
@ -91,9 +91,9 @@ class CollectionPlaybackHelper(
) )
} }
suspend fun playPlaylist(playlistId: String) { suspend fun playPlaylist(playlistId: String, shuffle: Boolean = false) {
if (audioPlayerQueue.isPlaylistPlaying(playlistId)) return if (!shuffle && audioPlayerQueue.isPlaylistPlaying(playlistId)) return
val entries = fetchAllPlaylistTracks(playlistId) val entries = fetchAllPlaylistTracks(playlistId).let { if (shuffle) it.shuffled() else it }
if (entries.isNotEmpty()) { if (entries.isNotEmpty()) {
audioPlayerQueue.load( audioPlayerQueue.load(
entries = entries, entries = entries,
@ -146,9 +146,9 @@ class CollectionPlaybackHelper(
) )
} }
suspend fun playSavedTracks() { suspend fun playSavedTracks(shuffle: Boolean = false) {
if (audioPlayerQueue.isSavedTracksPlaying()) return if (!shuffle && audioPlayerQueue.isSavedTracksPlaying()) return
val entries = fetchAllSavedTracks() val entries = fetchAllSavedTracks().let { if (shuffle) it.shuffled() else it }
if (entries.isNotEmpty()) { if (entries.isNotEmpty()) {
audioPlayerQueue.load( audioPlayerQueue.load(
entries = entries, entries = entries,

View File

@ -172,7 +172,7 @@ class RemoteControlHandler(
private suspend fun handleCommand(session: WebSocketServerSession, envelope: CommandEnvelope) { private suspend fun handleCommand(session: WebSocketServerSession, envelope: CommandEnvelope) {
when (val command = envelope.command) { when (val command = envelope.command) {
is RemoteControlCommand.Play -> { is RemoteControlCommand.Play -> {
handleCollectionSource(command.source, RemoteCollectionAction.Play) handleCollectionSource(command.source, RemoteCollectionAction.Play, command.shuffle)
} }
is RemoteControlCommand.Pause -> { is RemoteControlCommand.Pause -> {
audioPlayer.pause() audioPlayer.pause()
@ -297,13 +297,17 @@ class RemoteControlHandler(
* Resolves a `spotube://` collection source URI (playlist/album/artist top * Resolves a `spotube://` collection source URI (playlist/album/artist top
* tracks/saved tracks) and applies the requested action on the remote queue. * tracks/saved tracks) and applies the requested action on the remote queue.
*/ */
private suspend fun handleCollectionSource(source: String, action: RemoteCollectionAction) { private suspend fun handleCollectionSource(
source: String,
action: RemoteCollectionAction,
shuffle: Boolean = false,
) {
logger.d { "Remote collection $action for source: $source" } logger.d { "Remote collection $action for source: $source" }
when { when {
source.startsWith(COLLECTION_PLAYLIST_PREFIX) -> { source.startsWith(COLLECTION_PLAYLIST_PREFIX) -> {
val id = source.removePrefix(COLLECTION_PLAYLIST_PREFIX) val id = source.removePrefix(COLLECTION_PLAYLIST_PREFIX)
when (action) { when (action) {
RemoteCollectionAction.Play -> collectionPlaybackHelper.playPlaylist(id) RemoteCollectionAction.Play -> collectionPlaybackHelper.playPlaylist(id, shuffle)
RemoteCollectionAction.AddToQueue -> collectionPlaybackHelper.addPlaylistToQueue(id) RemoteCollectionAction.AddToQueue -> collectionPlaybackHelper.addPlaylistToQueue(id)
RemoteCollectionAction.PlayNext -> collectionPlaybackHelper.playPlaylistNext(id) RemoteCollectionAction.PlayNext -> collectionPlaybackHelper.playPlaylistNext(id)
} }
@ -312,7 +316,7 @@ class RemoteControlHandler(
source.startsWith(COLLECTION_ALBUM_PREFIX) -> { source.startsWith(COLLECTION_ALBUM_PREFIX) -> {
val id = source.removePrefix(COLLECTION_ALBUM_PREFIX) val id = source.removePrefix(COLLECTION_ALBUM_PREFIX)
when (action) { when (action) {
RemoteCollectionAction.Play -> collectionPlaybackHelper.playAlbum(id) RemoteCollectionAction.Play -> collectionPlaybackHelper.playAlbum(id, shuffle)
RemoteCollectionAction.AddToQueue -> collectionPlaybackHelper.addAlbumToQueue(id) RemoteCollectionAction.AddToQueue -> collectionPlaybackHelper.addAlbumToQueue(id)
RemoteCollectionAction.PlayNext -> collectionPlaybackHelper.playAlbumNext(id) RemoteCollectionAction.PlayNext -> collectionPlaybackHelper.playAlbumNext(id)
} }
@ -329,7 +333,7 @@ class RemoteControlHandler(
source == COLLECTION_SAVED_TRACKS -> { source == COLLECTION_SAVED_TRACKS -> {
when (action) { when (action) {
RemoteCollectionAction.Play -> collectionPlaybackHelper.playSavedTracks() RemoteCollectionAction.Play -> collectionPlaybackHelper.playSavedTracks(shuffle)
RemoteCollectionAction.AddToQueue -> collectionPlaybackHelper.addSavedTracksToQueue() RemoteCollectionAction.AddToQueue -> collectionPlaybackHelper.addSavedTracksToQueue()
RemoteCollectionAction.PlayNext -> { RemoteCollectionAction.PlayNext -> {
// Saved tracks "play next" is not supported; add to queue instead // Saved tracks "play next" is not supported; add to queue instead

View File

@ -25,7 +25,7 @@ import kotlinx.serialization.Serializable
sealed class RemoteControlCommand { sealed class RemoteControlCommand {
@Serializable @Serializable
@SerialName("play") @SerialName("play")
data class Play(val source: String) : RemoteControlCommand() data class Play(val source: String, val shuffle: Boolean = false) : RemoteControlCommand()
@Serializable @Serializable
@SerialName("pause") @SerialName("pause")

View File

@ -64,6 +64,7 @@ sealed interface PlaybackDestinationRequest {
val type: RemoteCollectionType, val type: RemoteCollectionType,
val id: String, val id: String,
val startTrack: MetadataTrack? = null, val startTrack: MetadataTrack? = null,
val shuffle: Boolean = false,
) : PlaybackDestinationRequest ) : PlaybackDestinationRequest
data class Track( data class Track(
@ -112,8 +113,9 @@ class RemotePlaybackController(
id: String, id: String,
title: String, title: String,
startTrack: MetadataTrack? = null, startTrack: MetadataTrack? = null,
shuffle: Boolean = false,
) { ) {
request(PlaybackDestinationRequest.Collection(title, PlaybackDestinationAction.Play, type, id, startTrack)) request(PlaybackDestinationRequest.Collection(title, PlaybackDestinationAction.Play, type, id, startTrack, shuffle))
} }
fun requestCollectionAddToQueue(type: RemoteCollectionType, id: String, title: String) { fun requestCollectionAddToQueue(type: RemoteCollectionType, id: String, title: String) {
@ -239,7 +241,7 @@ class RemotePlaybackController(
if (startTrack != null) { if (startTrack != null) {
collectionPlaybackHelper.playPlaylistFromTrack(request.id, startTrack) collectionPlaybackHelper.playPlaylistFromTrack(request.id, startTrack)
} else { } else {
collectionPlaybackHelper.playPlaylist(request.id) collectionPlaybackHelper.playPlaylist(request.id, request.shuffle)
} }
} }
@ -252,7 +254,7 @@ class RemotePlaybackController(
if (startTrack != null) { if (startTrack != null) {
collectionPlaybackHelper.playAlbumFromTrack(request.id, startTrack) collectionPlaybackHelper.playAlbumFromTrack(request.id, startTrack)
} else { } else {
collectionPlaybackHelper.playAlbum(request.id) collectionPlaybackHelper.playAlbum(request.id, request.shuffle)
} }
} }
@ -271,7 +273,7 @@ class RemotePlaybackController(
if (startTrack != null) { if (startTrack != null) {
collectionPlaybackHelper.playSavedTracksFromTrack(startTrack) collectionPlaybackHelper.playSavedTracksFromTrack(startTrack)
} else { } else {
collectionPlaybackHelper.playSavedTracks() collectionPlaybackHelper.playSavedTracks(request.shuffle)
} }
} }
@ -346,7 +348,7 @@ class RemotePlaybackController(
RemoteCollectionType.SavedTracks -> "spotube://saved_tracks" RemoteCollectionType.SavedTracks -> "spotube://saved_tracks"
} }
when (request.action) { when (request.action) {
PlaybackDestinationAction.Play -> RemoteControlCommand.Play(source) PlaybackDestinationAction.Play -> RemoteControlCommand.Play(source, request.shuffle)
PlaybackDestinationAction.AddToQueue -> RemoteControlCommand.AddToQueue(source) PlaybackDestinationAction.AddToQueue -> RemoteControlCommand.AddToQueue(source)
PlaybackDestinationAction.PlayNext -> RemoteControlCommand.PlayNext(source) PlaybackDestinationAction.PlayNext -> RemoteControlCommand.PlayNext(source)
} }

View File

@ -21,12 +21,12 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioSource
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.modules.plugin.PluginManager import dev.krtirtho.spotube.modules.plugin.AudioPluginSource
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
class AlternativeTracksRepository( class AlternativeTracksRepository(
private val pluginManager: PluginManager, private val pluginManager: AudioPluginSource,
private val matchedTracksRepository: MatchedTracksRepository, private val matchedTracksRepository: TrackSourceRepository,
private val streamingUrlRepository: StreamingUrlRepository, private val streamingUrlRepository: StreamingUrlRepository,
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
private val cacheManager: CacheManager, private val cacheManager: CacheManager,
@ -36,10 +36,6 @@ class AlternativeTracksRepository(
suspend fun resolveAlternatives(track: MetadataTrack): List<AudioSource> { suspend fun resolveAlternatives(track: MetadataTrack): List<AudioSource> {
val trackId = track.id val trackId = track.id
streamingUrlRepository.getCachedAlternatives(trackId)?.let { cached ->
logger.v { "Using cached alternatives for track $trackId" }
return cached
}
val audioPlugin = pluginManager.selectedAudioPlugin.value val audioPlugin = pluginManager.selectedAudioPlugin.value
if (audioPlugin == null) { if (audioPlugin == null) {
@ -47,6 +43,11 @@ class AlternativeTracksRepository(
return emptyList() return emptyList()
} }
streamingUrlRepository.getCachedAlternatives(trackId, audioPlugin.pluginId)?.let { cached ->
logger.v { "Using cached alternatives for track $trackId" }
return cached
}
val sources = runCatching { val sources = runCatching {
audioPlugin.use { audioAPI.getStreamsByTrack(track) } audioPlugin.use { audioAPI.getStreamsByTrack(track) }
}.getOrElse { throwable -> }.getOrElse { throwable ->
@ -59,24 +60,30 @@ class AlternativeTracksRepository(
return emptyList() return emptyList()
} }
streamingUrlRepository.cacheAlternatives(trackId, sources) streamingUrlRepository.cacheAlternatives(trackId, sources, audioPlugin.pluginId)
logger.d { "Resolved ${sources.size} alternative sources for track $trackId" } logger.d { "Resolved ${sources.size} alternative sources for track $trackId" }
return sources return sources
} }
suspend fun getActiveSourceId(track: MetadataTrack): String? { suspend fun getActiveSourceId(track: MetadataTrack): String? {
return matchedTracksRepository.getTrackSource(track)?.id val audioPlugin = pluginManager.selectedAudioPlugin.value ?: return null
return matchedTracksRepository.getTrackSource(track, audioPlugin.pluginId)?.id
} }
suspend fun selectAlternative(track: MetadataTrack, source: AudioSource) { suspend fun selectAlternative(track: MetadataTrack, source: AudioSource) {
val trackId = track.id val trackId = track.id
val audioPlugin = pluginManager.selectedAudioPlugin.value
if (audioPlugin == null) {
logger.w { "No audio plugin selected while selecting alternative for track $trackId" }
return
}
val basic = when (source) { val basic = when (source) {
is AudioSource.Streamed -> source.toBasic() is AudioSource.Streamed -> source.toBasic()
is AudioSource.Basic -> source is AudioSource.Basic -> source
} }
logger.i { "Selecting alternative source for track $trackId: ${basic.id} (${basic.title})" } logger.i { "Selecting alternative source for track $trackId: ${basic.id} (${basic.title})" }
matchedTracksRepository.saveTrackSource(track, basic) matchedTracksRepository.saveTrackSource(track, basic, audioPlugin.pluginId)
streamingUrlRepository.invalidateCachedStreamUrl(trackId) streamingUrlRepository.invalidateCachedStreamUrl(trackId)
streamingUrlRepository.invalidateCachedAlternatives(trackId) streamingUrlRepository.invalidateCachedAlternatives(trackId)
cacheManager.invalidateCacheEntry(trackId) cacheManager.invalidateCacheEntry(trackId)

View File

@ -24,23 +24,53 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.db.Database import dev.krtirtho.spotube.core.db.Database
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
class MatchedTracksRepository(private val database: Database) { /**
suspend fun getTrackSource(track: MetadataTrack): AudioSource.Basic? { * A matched [AudioSource.Basic] tagged with the id of the audio plugin that produced it.
* The [pluginId] tag lets [MatchedTracksRepository] detect and discard matches that were
* made by a plugin the user is no longer using, since a source id from one plugin (e.g. a
* YouTube video id) is meaningless - or worse, silently wrong - when handed to a different
* plugin's `getStreamsOfAudioSource`.
*/
@Serializable
data class PluginTaggedAudioSource(
val pluginId: String,
val source: AudioSource.Basic,
)
/** Persists (and looks up) the audio source matched to a [MetadataTrack] by a given plugin. */
interface TrackSourceRepository {
suspend fun getTrackSource(track: MetadataTrack, pluginId: String): AudioSource.Basic?
suspend fun saveTrackSource(track: MetadataTrack, source: AudioSource.Basic, pluginId: String)
}
class MatchedTracksRepository(private val database: Database) : TrackSourceRepository {
/**
* Returns the previously matched source for [track], but only if it was produced by
* [pluginId]. A match saved by a different (or since-removed) plugin is treated as a
* cache miss rather than being handed to the wrong plugin.
*/
override suspend fun getTrackSource(track: MetadataTrack, pluginId: String): AudioSource.Basic? {
return database.matchedTracksDataStore.data.map { prefs -> return database.matchedTracksDataStore.data.map { prefs ->
val json = prefs[stringPreferencesKey(track.id)] val json = prefs[stringPreferencesKey(track.id)] ?: return@map null
if (json != null) { val tagged = try {
Json.decodeFromString<AudioSource.Basic>(json as String) Json.decodeFromString<PluginTaggedAudioSource>(json)
} else { } catch (_: Exception) {
// Legacy entries stored before plugin-tagging was introduced; treat as
// unverifiable and force a fresh match instead of risking a wrong plugin.
return@map null return@map null
} }
if (tagged.pluginId != pluginId) return@map null
tagged.source
}.first() }.first()
} }
suspend fun saveTrackSource(track: MetadataTrack, source: AudioSource.Basic) { override suspend fun saveTrackSource(track: MetadataTrack, source: AudioSource.Basic, pluginId: String) {
database.matchedTracksDataStore.edit { prefs -> database.matchedTracksDataStore.edit { prefs ->
prefs[stringPreferencesKey(track.id)] = Json.encodeToString(source) prefs[stringPreferencesKey(track.id)] =
Json.encodeToString(PluginTaggedAudioSource(pluginId, source))
} }
} }
} }

View File

@ -26,8 +26,8 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.modules.plugin.PluginManager import dev.krtirtho.spotube.modules.plugin.AudioPluginSource
import dev.krtirtho.spotube.modules.settings.SettingsRepository import dev.krtirtho.spotube.modules.settings.UserSettingsSource
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
@ -45,6 +45,15 @@ data class CachedStreamUrl(
val selectedStream: AudioStream? = null, val selectedStream: AudioStream? = null,
val preferredFormat: AudioFormat, val preferredFormat: AudioFormat,
val preferredQuality: AudioQuality, val preferredQuality: AudioQuality,
// The id of the audio plugin that produced this entry. Guards against a stale entry
// (e.g. a YouTube video URL) being served after the user switches to a different
// audio plugin, which would otherwise play back completely unrelated content.
val pluginId: String,
)
private data class CachedAlternatives(
val pluginId: String,
val sources: List<AudioSource>,
) )
data class StreamInfo( data class StreamInfo(
@ -55,21 +64,18 @@ data class StreamInfo(
) )
class StreamingUrlRepository( class StreamingUrlRepository(
private val pluginManager: PluginManager, private val pluginManager: AudioPluginSource,
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
private val matchedTracksRepository: MatchedTracksRepository, private val matchedTracksRepository: TrackSourceRepository,
private val settingsRepository: SettingsRepository, private val settingsRepository: UserSettingsSource,
private val streamUrlCacheTtlMs: Long = 30.seconds.inWholeMilliseconds,
) : KoinComponent { ) : KoinComponent {
companion object {
private val STREAM_URL_CACHE_TTL_MS = 30.seconds.inWholeMilliseconds
}
private val logger by injectLogger<StreamingUrlRepository>() private val logger by injectLogger<StreamingUrlRepository>()
private val streamUrlCacheMutex = Mutex() private val streamUrlCacheMutex = Mutex()
private val streamUrlCache = mutableMapOf<String, CachedStreamUrl>() private val streamUrlCache = mutableMapOf<String, CachedStreamUrl>()
private val alternativesCacheMutex = Mutex() private val alternativesCacheMutex = Mutex()
private val alternativesCache = mutableMapOf<String, List<AudioSource>>() private val alternativesCache = mutableMapOf<String, CachedAlternatives>()
suspend fun resolveStreamInfo( suspend fun resolveStreamInfo(
trackId: String, trackId: String,
@ -95,19 +101,20 @@ class StreamingUrlRepository(
val preferredFormat = settings.streamingMusicFormat val preferredFormat = settings.streamingMusicFormat
val preferredQuality = settings.streamingMusicQuality val preferredQuality = settings.streamingMusicQuality
if (!forceRefresh) {
getCachedStreamUrl(trackId, preferredFormat, preferredQuality)?.let { cached ->
logger.v { "Using cached stream URL for track $trackId" }
return cached
}
}
val audioPlugin = pluginManager.selectedAudioPlugin.value val audioPlugin = pluginManager.selectedAudioPlugin.value
if (audioPlugin == null) { if (audioPlugin == null) {
logger.w { "No audio plugin selected while resolving stream for track $trackId" } logger.w { "No audio plugin selected while resolving stream for track $trackId" }
return null return null
} }
val source = matchedTracksRepository.getTrackSource(track) if (!forceRefresh) {
getCachedStreamUrl(trackId, preferredFormat, preferredQuality, audioPlugin.pluginId)?.let { cached ->
logger.v { "Using cached stream URL for track $trackId" }
return cached
}
}
val source = matchedTracksRepository.getTrackSource(track, audioPlugin.pluginId)
if (source != null) { if (source != null) {
logger.d { "Attempting stream resolution for track $trackId using cached source" } logger.d { "Attempting stream resolution for track $trackId using cached source" }
@ -130,7 +137,7 @@ class StreamingUrlRepository(
) )
if (resolvedStream != null) { if (resolvedStream != null) {
val info = resolvedStream.toStreamInfo() val info = resolvedStream.toStreamInfo()
cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream) cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId)
return info return info
} }
} }
@ -153,19 +160,19 @@ class StreamingUrlRepository(
return null return null
} }
cacheAlternatives(trackId, sources) cacheAlternatives(trackId, sources, audioPlugin.pluginId)
val stream = runCatching { val stream = runCatching {
audioPlugin.use { audioPlugin.use {
sources.firstNotNullOfOrNull { src -> sources.firstNotNullOfOrNull { src ->
when (src) { when (src) {
is AudioSource.Streamed -> { is AudioSource.Streamed -> {
matchedTracksRepository.saveTrackSource(track, src.toBasic()) matchedTracksRepository.saveTrackSource(track, src.toBasic(), audioPlugin.pluginId)
src src
} }
is AudioSource.Basic -> { is AudioSource.Basic -> {
matchedTracksRepository.saveTrackSource(track, src) matchedTracksRepository.saveTrackSource(track, src, audioPlugin.pluginId)
audioAPI.getStreamsOfAudioSource(src) audioAPI.getStreamsOfAudioSource(src)
.firstOrNull() .firstOrNull()
} }
@ -190,7 +197,7 @@ class StreamingUrlRepository(
) )
if (resolvedStream != null) { if (resolvedStream != null) {
val info = resolvedStream.toStreamInfo() val info = resolvedStream.toStreamInfo()
cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream) cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId)
return info return info
} }
return null return null
@ -207,10 +214,15 @@ class StreamingUrlRepository(
trackId: String, trackId: String,
preferredFormat: AudioFormat, preferredFormat: AudioFormat,
preferredQuality: AudioQuality, preferredQuality: AudioQuality,
pluginId: String,
): StreamInfo? { ): StreamInfo? {
val now = Clock.System.now().toEpochMilliseconds() val now = Clock.System.now().toEpochMilliseconds()
return streamUrlCacheMutex.withLock { return streamUrlCacheMutex.withLock {
val cached = streamUrlCache[trackId] ?: return@withLock null val cached = streamUrlCache[trackId] ?: return@withLock null
if (cached.pluginId != pluginId) {
logger.v { "Cached stream URL for track $trackId belongs to a different plugin; ignoring" }
return@withLock null
}
if (cached.preferredFormat != preferredFormat || cached.preferredQuality != preferredQuality) { if (cached.preferredFormat != preferredFormat || cached.preferredQuality != preferredQuality) {
return@withLock null return@withLock null
} }
@ -229,8 +241,9 @@ class StreamingUrlRepository(
preferredQuality: AudioQuality, preferredQuality: AudioQuality,
source: AudioSource.Streamed, source: AudioSource.Streamed,
selectedStream: AudioStream, selectedStream: AudioStream,
pluginId: String,
) { ) {
val expiresAtMs = Clock.System.now().toEpochMilliseconds() + STREAM_URL_CACHE_TTL_MS val expiresAtMs = Clock.System.now().toEpochMilliseconds() + streamUrlCacheTtlMs
streamUrlCacheMutex.withLock { streamUrlCacheMutex.withLock {
streamUrlCache[trackId] = CachedStreamUrl( streamUrlCache[trackId] = CachedStreamUrl(
url = info.url, url = info.url,
@ -242,6 +255,7 @@ class StreamingUrlRepository(
selectedStream = selectedStream, selectedStream = selectedStream,
preferredFormat = preferredFormat, preferredFormat = preferredFormat,
preferredQuality = preferredQuality, preferredQuality = preferredQuality,
pluginId = pluginId,
) )
} }
} }
@ -257,15 +271,21 @@ class StreamingUrlRepository(
// Stream metadata is part of the same URL-cache entry and is removed with it. // Stream metadata is part of the same URL-cache entry and is removed with it.
} }
suspend fun getCachedAlternatives(trackId: String): List<AudioSource>? { /** Returns cached alternatives only if they were produced by [pluginId]. */
suspend fun getCachedAlternatives(trackId: String, pluginId: String): List<AudioSource>? {
return alternativesCacheMutex.withLock { return alternativesCacheMutex.withLock {
alternativesCache[trackId] val cached = alternativesCache[trackId] ?: return@withLock null
if (cached.pluginId != pluginId) {
logger.v { "Cached alternatives for track $trackId belong to a different plugin; ignoring" }
return@withLock null
}
cached.sources
} }
} }
suspend fun cacheAlternatives(trackId: String, sources: List<AudioSource>) { suspend fun cacheAlternatives(trackId: String, sources: List<AudioSource>, pluginId: String) {
alternativesCacheMutex.withLock { alternativesCacheMutex.withLock {
alternativesCache[trackId] = sources alternativesCache[trackId] = CachedAlternatives(pluginId, sources)
logger.v { "Cached ${sources.size} alternative sources for track $trackId" } logger.v { "Cached ${sources.size} alternative sources for track $trackId" }
} }
} }

View File

@ -28,7 +28,6 @@ import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -70,7 +69,6 @@ import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
@ -173,11 +171,9 @@ fun <T> AutocompleteTextField(
val isHovered by interactionSource.collectIsHoveredAsState() val isHovered by interactionSource.collectIsHoveredAsState()
var isMenuOpen by remember { mutableStateOf(false) } var isMenuOpen by remember { mutableStateOf(false) }
var isMovingFocusToPopup by remember { mutableStateOf(false) }
var selectedIndex by remember { mutableIntStateOf(-1) } var selectedIndex by remember { mutableIntStateOf(-1) }
var textFieldSize by remember { mutableStateOf(IntSize.Zero) } var textFieldSize by remember { mutableStateOf(IntSize.Zero) }
val menuListState = rememberLazyListState() val menuListState = rememberLazyListState()
val popupFocusRequester = remember { FocusRequester() }
val textFieldFocusRequester = remember { FocusRequester() } val textFieldFocusRequester = remember { FocusRequester() }
val menuExpanded = isMenuOpen && items.isNotEmpty() val menuExpanded = isMenuOpen && items.isNotEmpty()
@ -185,7 +181,6 @@ fun <T> AutocompleteTextField(
LaunchedEffect(isFocused) { LaunchedEffect(isFocused) {
if (isFocused) { if (isFocused) {
isMenuOpen = true isMenuOpen = true
isMovingFocusToPopup = false
} }
} }
@ -291,7 +286,7 @@ fun <T> AutocompleteTextField(
) )
.focusRequester(textFieldFocusRequester) .focusRequester(textFieldFocusRequester)
.onFocusChanged { state -> .onFocusChanged { state ->
if (!state.isFocused && !isMovingFocusToPopup) { if (!state.isFocused) {
isMenuOpen = false isMenuOpen = false
selectedIndex = -1 selectedIndex = -1
} }
@ -303,8 +298,6 @@ fun <T> AutocompleteTextField(
if (!menuExpanded) return@onPreviewKeyEvent false if (!menuExpanded) return@onPreviewKeyEvent false
when (event.key) { when (event.key) {
Key.DirectionDown -> { Key.DirectionDown -> {
isMovingFocusToPopup = true
popupFocusRequester.requestFocus()
navigate(1) navigate(1)
true true
} }
@ -374,7 +367,7 @@ fun <T> AutocompleteTextField(
Popup( Popup(
alignment = Alignment.TopStart, alignment = Alignment.TopStart,
offset = IntOffset(0, textFieldHeightPx + menuOffsetPx), offset = IntOffset(0, textFieldHeightPx + menuOffsetPx),
properties = PopupProperties(focusable = true), properties = PopupProperties(focusable = false),
onDismissRequest = { onDismissRequest = {
selectedIndex = -1 selectedIndex = -1
isMenuOpen = false isMenuOpen = false
@ -386,45 +379,7 @@ fun <T> AutocompleteTextField(
.heightIn(max = menuMaxHeight) .heightIn(max = menuMaxHeight)
.shadow(resolvedMenuTheme.shadowElevation, resolvedMenuTheme.shape) .shadow(resolvedMenuTheme.shadowElevation, resolvedMenuTheme.shape)
.background(resolvedMenuTheme.background, resolvedMenuTheme.shape) .background(resolvedMenuTheme.background, resolvedMenuTheme.shape)
.clip(resolvedMenuTheme.shape) .clip(resolvedMenuTheme.shape),
.focusRequester(popupFocusRequester)
.focusable()
.onFocusChanged { state ->
if (state.isFocused) {
isMovingFocusToPopup = false
}
}
.onKeyEvent { event ->
when (event.key) {
Key.DirectionDown -> {
navigate(1); true
}
Key.DirectionUp -> {
navigate(-1); true
}
Key.Enter -> {
if (selectedIndex in items.indices) {
onItemSelected(items[selectedIndex])
selectedIndex = -1
isMenuOpen = false
textFieldFocusRequester.requestFocus()
return@onKeyEvent true
}
false
}
Key.Escape -> {
selectedIndex = -1
isMenuOpen = false
textFieldFocusRequester.requestFocus()
true
}
else -> false
}
},
) { ) {
LazyColumn( LazyColumn(
state = menuListState, state = menuListState,

View File

@ -41,6 +41,7 @@ class BuiltInPluginService(
private val pluginInfo: PluginEntry, private val pluginInfo: PluginEntry,
) : PluginService, KoinComponent { ) : PluginService, KoinComponent {
override val pluginId: String = pluginInfo.id
private val loggedInStateFlow = MutableStateFlow(false) private val loggedInStateFlow = MutableStateFlow(false)
override val loggedInFlow = loggedInStateFlow.asStateFlow() override val loggedInFlow = loggedInStateFlow.asStateFlow()
val servicesRegistry = mutableMapOf<KClass<*>, ZiplineService>() val servicesRegistry = mutableMapOf<KClass<*>, ZiplineService>()

View File

@ -52,6 +52,7 @@ class PluginServiceScope(private val registry: Map<KClass<*>, ZiplineService>) {
} }
interface PluginService { interface PluginService {
val pluginId: String
val loggedInFlow: StateFlow<Boolean> val loggedInFlow: StateFlow<Boolean>
suspend fun start() suspend fun start()

View File

@ -105,6 +105,8 @@ open class ZiplinePluginService(
private val pluginInfo: PluginEntry, private val pluginInfo: PluginEntry,
) : PluginService, KoinComponent { ) : PluginService, KoinComponent {
override val pluginId: String = pluginInfo.id
// QuickJS compile() uses deep C-level recursion on the native thread stack. // QuickJS compile() uses deep C-level recursion on the native thread stack.
// Zipline.create() sets maxStackSize to only 6 MiB, but compiling large JS modules // Zipline.create() sets maxStackSize to only 6 MiB, but compiling large JS modules
// (e.g. kotlin-stdlib at ~491 KB) can exceed that during AST parsing. // (e.g. kotlin-stdlib at ~491 KB) can exceed that during AST parsing.

View File

@ -78,7 +78,7 @@ fun AlbumScreen(
} }
}, },
onPlay = viewModel::playAlbum, onPlay = viewModel::playAlbum,
onShufflePlay = {}, onShufflePlay = viewModel::shufflePlayAlbum,
onAddToQueue = viewModel::addAlbumToQueue, onAddToQueue = viewModel::addAlbumToQueue,
isPlaying = isAlbumPlaying, isPlaying = isAlbumPlaying,
isFollowing = savedAlbumIds.contains(albumId), isFollowing = savedAlbumIds.contains(albumId),

View File

@ -224,6 +224,11 @@ class AlbumViewModel(
remotePlaybackController.requestCollectionPlay(RemoteCollectionType.Album, albumId, title) remotePlaybackController.requestCollectionPlay(RemoteCollectionType.Album, albumId, title)
} }
fun shufflePlayAlbum() {
val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album"
remotePlaybackController.requestCollectionPlay(RemoteCollectionType.Album, albumId, title, shuffle = true)
}
fun addAlbumToQueue() { fun addAlbumToQueue() {
val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album" val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album"
remotePlaybackController.requestCollectionAddToQueue(RemoteCollectionType.Album, albumId, title) remotePlaybackController.requestCollectionAddToQueue(RemoteCollectionType.Album, albumId, title)

View File

@ -38,10 +38,16 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TooltipAnchorPosition
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -81,6 +87,7 @@ import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
import dev.krtirtho.spotube.core.ui.component.dragScrollable import dev.krtirtho.spotube.core.ui.component.dragScrollable
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay
@ -134,7 +141,8 @@ fun ArtistScreen(
state = listState, state = listState,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(innerPadding), .padding(innerPadding)
.padding(bottom = LocalAppShellBottomInset.current + 24.dp),
verticalArrangement = Arrangement.spacedBy(24.dp), verticalArrangement = Arrangement.spacedBy(24.dp),
) { ) {
item { item {
@ -495,6 +503,7 @@ private fun ArtistMeta(
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun ArtistHeaderActions( private fun ArtistHeaderActions(
isSaved: Boolean, isSaved: Boolean,
@ -508,7 +517,12 @@ private fun ArtistHeaderActions(
) { ) {
if (isSaved) { if (isSaved) {
SecondaryButton(onClick = onFollowClick) { SecondaryButton(onClick = onFollowClick) {
Text("Following", modifier = Modifier.width(65.dp), textAlign = TextAlign.Center) Text(
"Following",
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
} }
} else { } else {
PrimaryButton(onClick = onFollowClick) { PrimaryButton(onClick = onFollowClick) {
@ -516,6 +530,15 @@ private fun ArtistHeaderActions(
} }
} }
TooltipBox(
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
tooltip = {
PlainTooltip {
Text(if (isBlacklisted) "Remove from blacklist" else "Add to blacklist")
}
},
state = rememberTooltipState()
) {
if (isBlacklisted) { if (isBlacklisted) {
SecondaryIconButton(onClick = onBlacklistClick) { SecondaryIconButton(onClick = onBlacklistClick) {
Icon( Icon(
@ -534,6 +557,7 @@ private fun ArtistHeaderActions(
} }
} }
} }
}
@Composable @Composable
private fun TopTracksHeader( private fun TopTracksHeader(

View File

@ -54,6 +54,8 @@ import androidx.compose.ui.unit.sp
import compose.icons.FeatherIcons import compose.icons.FeatherIcons
import compose.icons.feathericons.X import compose.icons.feathericons.X
import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsLine import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsLine
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4 import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -70,18 +72,12 @@ fun LyricsScreen(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
Scaffold( Scaffold(
modifier = modifier, topBar = {
) { innerPadding -> ApplicationMainBar(
Column( backButton = onClose == null,
modifier = Modifier title = {
.fillMaxSize()
.padding(innerPadding)
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(16.dp))
Box( Box(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
) { ) {
if (onClose != null) { if (onClose != null) {
IconButton( IconButton(
@ -114,9 +110,19 @@ fun LyricsScreen(
) )
} }
} }
}
Spacer(modifier = Modifier.height(24.dp)) )
},
modifier = modifier,
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(horizontal = 16.dp)
.padding(bottom = LocalAppShellBottomInset.current + 24.dp)
) {
Spacer(modifier = Modifier.height(16.dp))
when { when {
uiState.isLoading -> { uiState.isLoading -> {
Box( Box(

View File

@ -111,7 +111,7 @@ fun PlaylistScreen(
ownerImageURL = ownerImageURL, ownerImageURL = ownerImageURL,
onOwnerClick = {}, onOwnerClick = {},
onPlay = viewModel::playPlaylist, onPlay = viewModel::playPlaylist,
onShufflePlay = {}, onShufflePlay = viewModel::shufflePlayPlaylist,
onAddToQueue = viewModel::addPlaylistToQueue, onAddToQueue = viewModel::addPlaylistToQueue,
isPlaying = isPlaying, isPlaying = isPlaying,
isFollowing = savedPlaylistIds.contains(playlistId), isFollowing = savedPlaylistIds.contains(playlistId),

View File

@ -230,6 +230,11 @@ class PlaylistViewModel(
remotePlaybackController.requestCollectionPlay(RemoteCollectionType.Playlist, playlistId, title) remotePlaybackController.requestCollectionPlay(RemoteCollectionType.Playlist, playlistId, title)
} }
fun shufflePlayPlaylist() {
val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist"
remotePlaybackController.requestCollectionPlay(RemoteCollectionType.Playlist, playlistId, title, shuffle = true)
}
fun addPlaylistToQueue() { fun addPlaylistToQueue() {
val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist" val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist"
remotePlaybackController.requestCollectionAddToQueue(RemoteCollectionType.Playlist, playlistId, title) remotePlaybackController.requestCollectionAddToQueue(RemoteCollectionType.Playlist, playlistId, title)

View File

@ -69,11 +69,18 @@ interface PluginProvider {
val selectedMetadataPlugin: StateFlow<PluginService?> val selectedMetadataPlugin: StateFlow<PluginService?>
} }
/** Narrow read-only view of the active audio plugin, used by consumers that only need to
* key caches or make calls against the currently selected audio plugin without depending
* on the full [PluginManager] (which also owns plugin installation/lifecycle machinery). */
interface AudioPluginSource {
val selectedAudioPlugin: StateFlow<PluginService?>
}
class PluginManager( class PluginManager(
val database: Database, val database: Database,
val paths: Paths, val paths: Paths,
) : KoinComponent, PluginProvider { ) : KoinComponent, PluginProvider, AudioPluginSource {
private val logger by injectLogger<PluginManager>() private val logger by injectLogger<PluginManager>()
private val pluginExceptionHandler = CoroutineExceptionHandler { _, exception -> private val pluginExceptionHandler = CoroutineExceptionHandler { _, exception ->
logger.e(exception) { "Plugin runtime threw an unhandled exception. Intercepted safely." } logger.e(exception) { "Plugin runtime threw an unhandled exception. Intercepted safely." }
@ -245,7 +252,7 @@ class PluginManager(
override val selectedMetadataPlugin = override val selectedMetadataPlugin =
filterSelectedPluginByType(PluginAbility.METADATA) filterSelectedPluginByType(PluginAbility.METADATA)
val selectedAudioPlugin = override val selectedAudioPlugin =
filterSelectedPluginByType(PluginAbility.AUDIO) filterSelectedPluginByType(PluginAbility.AUDIO)
val selectedLyricsPlugin = val selectedLyricsPlugin =
filterSelectedPluginByType(PluginAbility.LYRICS) filterSelectedPluginByType(PluginAbility.LYRICS)

View File

@ -70,7 +70,7 @@ fun SavedTracksScreen(
ownerImageURL = null, ownerImageURL = null,
onOwnerClick = {}, onOwnerClick = {},
onPlay = viewModel::playSavedTracks, onPlay = viewModel::playSavedTracks,
onShufflePlay = {}, onShufflePlay = viewModel::shufflePlaySavedTracks,
onAddToQueue = viewModel::addSavedTracksToQueue, onAddToQueue = viewModel::addSavedTracksToQueue,
isPlaying = isPlaying, isPlaying = isPlaying,
isFollowing = false, isFollowing = false,

View File

@ -206,6 +206,15 @@ class SavedTracksViewModel(
remotePlaybackController.requestCollectionPlay(RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, "Saved Tracks") remotePlaybackController.requestCollectionPlay(RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, "Saved Tracks")
} }
fun shufflePlaySavedTracks() {
remotePlaybackController.requestCollectionPlay(
RemoteCollectionType.SavedTracks,
SAVED_TRACKS_COLLECTION_ID,
"Saved Tracks",
shuffle = true,
)
}
fun addSavedTracksToQueue() { fun addSavedTracksToQueue() {
remotePlaybackController.requestCollectionAddToQueue(RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, "Saved Tracks") remotePlaybackController.requestCollectionAddToQueue(RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, "Saved Tracks")
} }

View File

@ -39,18 +39,19 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
@ -84,11 +85,10 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUser import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUser
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.jam.JamRole import dev.krtirtho.spotube.core.jam.JamRole
import dev.krtirtho.spotube.core.jam.JamRoomService import dev.krtirtho.spotube.core.jam.JamRoomService
import org.koin.compose.koinInject import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.remote.RemotePlaybackController import dev.krtirtho.spotube.core.remote.RemotePlaybackController
import dev.krtirtho.spotube.core.share.ShareService import dev.krtirtho.spotube.core.share.ShareService
import dev.krtirtho.spotube.core.ui.base.AutocompleteTextField import dev.krtirtho.spotube.core.ui.base.AutocompleteTextField
@ -103,6 +103,7 @@ import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
import dev.krtirtho.spotube.core.ui.component.TrackOptionsState import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
import dev.krtirtho.spotube.core.ui.component.UserCard import dev.krtirtho.spotube.core.ui.component.UserCard
import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
import dev.krtirtho.spotube.core.ui.component.dragScrollable
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
@ -624,8 +625,10 @@ private fun SearchTabs(
selectedType: MetadataSupportedSearchType?, selectedType: MetadataSupportedSearchType?,
onTabSelected: (MetadataSupportedSearchType) -> Unit, onTabSelected: (MetadataSupportedSearchType) -> Unit,
) { ) {
val listState = rememberLazyListState()
LazyRow( LazyRow(
modifier = Modifier.fillMaxWidth(), state = listState,
modifier = Modifier.fillMaxWidth().dragScrollable(listState),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 4.dp), contentPadding = PaddingValues(horizontal = 16.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp) horizontalArrangement = Arrangement.spacedBy(6.dp)
) { ) {

View File

@ -87,6 +87,7 @@ data class UserSettings(
// Updates // Updates
val autoCheckForUpdates: Boolean = true, val autoCheckForUpdates: Boolean = true,
val ignoredUpdateVersion: String? = null,
) )
/** /**

View File

@ -27,14 +27,21 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
class SettingsRepository(private val database: Database) { /** Narrow read-only view over the current [UserSettings], for consumers (like
* [dev.krtirtho.spotube.core.server.StreamingUrlRepository]) that only need to read the
* user's streaming preferences without depending on the full [SettingsRepository]. */
interface UserSettingsSource {
val userSettings: StateFlow<UserSettings>
}
class SettingsRepository(private val database: Database) : UserSettingsSource {
companion object { companion object {
private val SETTINGS_KEY = stringPreferencesKey("user_settings") private val SETTINGS_KEY = stringPreferencesKey("user_settings")
} }
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val userSettings: StateFlow<UserSettings> = database.settingsDataStore.data.map { prefs -> override val userSettings: StateFlow<UserSettings> = database.settingsDataStore.data.map { prefs ->
val json = prefs[SETTINGS_KEY] val json = prefs[SETTINGS_KEY]
if (json != null) { if (json != null) {
Json.decodeFromString<UserSettings>(json as String) Json.decodeFromString<UserSettings>(json as String)
@ -52,4 +59,10 @@ class SettingsRepository(private val database: Database) {
prefs[SETTINGS_KEY] = Json.encodeToString(newSettings) prefs[SETTINGS_KEY] = Json.encodeToString(newSettings)
} }
} }
suspend fun getCurrentSettings(): UserSettings {
val preferences = database.settingsDataStore.data.first()
val settingsJson = preferences[SETTINGS_KEY] ?: return UserSettings()
return Json.decodeFromString<UserSettings>(settingsJson)
}
} }

View File

@ -42,11 +42,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.krtirtho.spotube.NavigationItem
import dev.krtirtho.spotube.bottombarTabs
import dev.krtirtho.spotube.core.navigation.NavigationState import dev.krtirtho.spotube.core.navigation.NavigationState
import dev.krtirtho.spotube.core.navigation.Navigator import dev.krtirtho.spotube.core.navigation.Navigator
import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.modules.downloads.DownloadBadgeIndicator import dev.krtirtho.spotube.modules.downloads.DownloadBadgeIndicator
import dev.krtirtho.spotube.tabs
@Composable @Composable
fun AppBottombar( fun AppBottombar(
@ -68,8 +69,9 @@ fun AppBottombar(
horizontalArrangement = Arrangement.SpaceEvenly, horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
tabs.forEach { (label, icon, activeIcon, screen) -> bottombarTabs.filterIsInstance<NavigationItem.Tab<*>>()
val selected = navigationState.topLevelRoute == screen .forEach { tab ->
val selected = navigationState.topLevelRoute == tab.route
val labelColor = if (selected) { val labelColor = if (selected) {
MaterialTheme.colorScheme.primary MaterialTheme.colorScheme.primary
} else { } else {
@ -79,7 +81,7 @@ fun AppBottombar(
Column( Column(
modifier = Modifier modifier = Modifier
.clip(CircleShape) .clip(CircleShape)
.clickable { navigator.navigate(screen) } .clickable { navigator.navigate(tab.route) }
.padding(8.dp) .padding(8.dp)
.size(56.dp), .size(56.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
@ -87,13 +89,13 @@ fun AppBottombar(
) { ) {
Box { Box {
Icon( Icon(
imageVector = activeIcon, imageVector = tab.icon,
contentDescription = label, contentDescription = tab.title,
tint = if (selected) MaterialTheme.colorScheme.primary tint = if (selected) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant, else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp) modifier = Modifier.size(20.dp)
) )
if (screen == Routes.Library) { if (tab.route == Routes.Library) {
DownloadBadgeIndicator( DownloadBadgeIndicator(
modifier = Modifier.align(Alignment.TopEnd) modifier = Modifier.align(Alignment.TopEnd)
) )
@ -101,7 +103,7 @@ fun AppBottombar(
} }
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = label, text = tab.title,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
fontWeight = if (selected) FontWeight.Medium else FontWeight.Normal, fontWeight = if (selected) FontWeight.Medium else FontWeight.Normal,
color = labelColor, color = labelColor,

View File

@ -29,12 +29,16 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -55,6 +59,7 @@ import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.blur.HazeColorEffect import dev.chrisbanes.haze.blur.HazeColorEffect
import dev.chrisbanes.haze.blur.blurEffect import dev.chrisbanes.haze.blur.blurEffect
import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeEffect
import dev.krtirtho.spotube.NavigationItem
import dev.krtirtho.spotube.core.navigation.NavigationState import dev.krtirtho.spotube.core.navigation.NavigationState
import dev.krtirtho.spotube.core.navigation.Navigator import dev.krtirtho.spotube.core.navigation.Navigator
import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.navigation.Routes
@ -64,15 +69,15 @@ import dev.krtirtho.spotube.core.ui.base.OutlineButton
import dev.krtirtho.spotube.core.ui.base.SecondaryButton import dev.krtirtho.spotube.core.ui.base.SecondaryButton
import dev.krtirtho.spotube.core.ui.base.copyPadding import dev.krtirtho.spotube.core.ui.base.copyPadding
import dev.krtirtho.spotube.core.ui.base.copyShape import dev.krtirtho.spotube.core.ui.base.copyShape
import dev.krtirtho.spotube.core.ui.component.VerticalScrollbar
import dev.krtirtho.spotube.modules.downloads.DownloadBadgeIndicator import dev.krtirtho.spotube.modules.downloads.DownloadBadgeIndicator
import dev.krtirtho.spotube.modules.library.LibraryState import dev.krtirtho.spotube.modules.library.LibraryState
import dev.krtirtho.spotube.modules.library.LibraryTab import dev.krtirtho.spotube.modules.library.LibraryTab
import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen import dev.krtirtho.spotube.resources.iconsax.IconsaxSetting2
import dev.krtirtho.spotube.resources.iconsax.IconsaxSidebarLeftBroken import dev.krtirtho.spotube.resources.iconsax.IconsaxSidebarLeftBroken
import dev.krtirtho.spotube.resources.iconsax.IconsaxSidebarRightBroken import dev.krtirtho.spotube.resources.iconsax.IconsaxSidebarRightBroken
import dev.krtirtho.spotube.resources.iconsax.User import dev.krtirtho.spotube.sidebarTabs
import dev.krtirtho.spotube.tabs
import org.jetbrains.compose.resources.Font import org.jetbrains.compose.resources.Font
import org.koin.compose.koinInject import org.koin.compose.koinInject
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
@ -90,6 +95,7 @@ fun AppSidebar(
val width by animateDpAsState(targetValue = if (expanded) 236.dp else 86.dp) val width by animateDpAsState(targetValue = if (expanded) 236.dp else 86.dp)
val currentLibraryTab by libraryState.currentTab.collectAsState() val currentLibraryTab by libraryState.currentTab.collectAsState()
val surfaceTint = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f) val surfaceTint = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f)
val listState = rememberLazyListState()
Column( Column(
modifier = modifier modifier = modifier
@ -139,62 +145,74 @@ fun AppSidebar(
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
tabs.forEach { (label, icon, activeIcon, screen) -> Box(modifier = Modifier.fillMaxSize()) {
val selected = navigationState.topLevelRoute == screen LazyColumn(state = listState) {
items(sidebarTabs.size) { index ->
if (screen == Routes.Library) { when (val tab = sidebarTabs[index]) {
AnimatedVisibility(visible = expanded) { is NavigationItem.Group<*> -> {
androidx.compose.animation.AnimatedVisibility(visible = expanded) {
Text( Text(
text = "LIBRARY", text = tab.title.uppercase(),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 8.dp) .padding(horizontal = 24.dp, vertical = 8.dp)
) )
} }
tab.items.forEach {
val selected = navigationState.topLevelRoute == it.route
SidebarItem(
label = it.title,
activeIcon = it.icon,
onClick = {
if (it.route == Routes.Library) {
libraryState.currentTab.value =
it.data as LibraryTab
}
navigator.navigate(it.route)
},
selected = if (it.route == Routes.Library) selected && currentLibraryTab == it.data else selected,
expanded = expanded,
showDownloadBadge = it.route == Routes.Library
&& it.data == LibraryTab.Downloads
)
}
LibraryTab.entries.forEach { tab -> }
val isSubSelected = selected && currentLibraryTab == tab
is NavigationItem.Tab<*> -> {
val selected = navigationState.topLevelRoute == tab.route
SidebarItem( SidebarItem(
label = tab.title, label = tab.title,
activeIcon = tab.icon, activeIcon = tab.icon,
onClick = { onClick = {
libraryState.currentTab.value = tab navigator.navigate(tab.route)
navigator.navigate(screen)
}, },
selected = isSubSelected,
expanded = expanded,
showDownloadBadge = tab == LibraryTab.Downloads,
)
}
} else {
SidebarItem(
label = label,
activeIcon = activeIcon,
onClick = { navigator.navigate(screen) },
selected = selected, selected = selected,
expanded = expanded, expanded = expanded,
) )
} }
} }
} }
}
VerticalScrollbar(
listState = listState,
modifier = Modifier
.align(Alignment.CenterEnd)
.fillMaxHeight()
)
}
}
HorizontalDivider()
SidebarItem( SidebarItem(
label = "Devices", label = "Settings",
activeIcon = Iconsax.IconsaxMirroringScreen, activeIcon = Iconsax.IconsaxSetting2,
onClick = { navigator.navigate(Routes.Devices) }, onClick = {
selected = false, navigator.navigate(Routes.Settings)
},
selected = navigationState.topLevelRoute == Routes.Settings,
expanded = expanded, expanded = expanded,
) )
SidebarItem(
label = "Group Jam",
activeIcon = Iconsax.User,
onClick = { navigator.navigate(Routes.Jam) },
selected = false,
expanded = expanded,
)
Spacer(modifier = Modifier.height(120.dp)) Spacer(modifier = Modifier.height(120.dp))
} }
} }
@ -209,7 +227,7 @@ fun SidebarItem(
showDownloadBadge: Boolean = false, showDownloadBadge: Boolean = false,
) { ) {
val itemContent: @Composable RowScope.() -> Unit = { val itemContent: @Composable RowScope.() -> Unit = {
Box(modifier = Modifier.size(20.dp)) { Box(modifier = Modifier.size(16.dp)) {
Icon( Icon(
imageVector = activeIcon, imageVector = activeIcon,
contentDescription = label, contentDescription = label,
@ -231,7 +249,7 @@ fun SidebarItem(
maxLines = 1, maxLines = 1,
softWrap = false, softWrap = false,
style = LocalTextStyle.current.copy( style = LocalTextStyle.current.copy(
fontSize = MaterialTheme.typography.labelLarge.fontSize, fontSize = MaterialTheme.typography.labelMedium.fontSize,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium, fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
color = if (selected) { color = if (selected) {
MaterialTheme.colorScheme.onSecondaryContainer MaterialTheme.colorScheme.onSecondaryContainer
@ -246,8 +264,8 @@ fun SidebarItem(
val buttonModifier = Modifier val buttonModifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 10.dp, vertical = 4.dp) .padding(horizontal = 7.dp, vertical = 2.dp)
val contentPadding = PaddingValues(horizontal = 7.dp, vertical = 6.dp) val contentPadding = PaddingValues(horizontal = 5.dp)
if (selected) { if (selected) {
SecondaryButton( SecondaryButton(
@ -255,7 +273,7 @@ fun SidebarItem(
modifier = buttonModifier, modifier = buttonModifier,
theme = LocalBaseUITheme.current.buttons.secondary theme = LocalBaseUITheme.current.buttons.secondary
.copyPadding(contentPadding) .copyPadding(contentPadding)
.copyShape(RoundedCornerShape(8.dp)), .copyShape(RoundedCornerShape(5.dp)),
content = itemContent, content = itemContent,
) )
} else { } else {
@ -264,7 +282,7 @@ fun SidebarItem(
modifier = buttonModifier, modifier = buttonModifier,
theme = LocalBaseUITheme.current.buttons.outline theme = LocalBaseUITheme.current.buttons.outline
.copyPadding(contentPadding) .copyPadding(contentPadding)
.copyShape(RoundedCornerShape(8.dp)), .copyShape(RoundedCornerShape(5.dp)),
hoverOnly = true, hoverOnly = true,
content = itemContent, content = itemContent,
) )

View File

@ -27,10 +27,11 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.server.CacheEntry import dev.krtirtho.spotube.core.server.CacheEntry
import dev.krtirtho.spotube.core.server.CacheManager 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.StreamingUrlRepository
import dev.krtirtho.spotube.core.server.TrackSourceRepository
import dev.krtirtho.spotube.core.server.normalizeManifestUrl import dev.krtirtho.spotube.core.server.normalizeManifestUrl
import dev.krtirtho.spotube.core.server.selectPreferredAudioStream import dev.krtirtho.spotube.core.server.selectPreferredAudioStream
import dev.krtirtho.spotube.modules.plugin.AudioPluginSource
import dev.krtirtho.spotube.modules.settings.SettingsRepository import dev.krtirtho.spotube.modules.settings.SettingsRepository
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -72,10 +73,11 @@ data class PlayerOptionsUiState(
class PlayerOptionsViewModel( class PlayerOptionsViewModel(
private val audioPlayer: AudioPlayerInterface, private val audioPlayer: AudioPlayerInterface,
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
private val matchedTracksRepository: MatchedTracksRepository, private val matchedTracksRepository: TrackSourceRepository,
private val streamingUrlRepository: StreamingUrlRepository, private val streamingUrlRepository: StreamingUrlRepository,
private val cacheManager: CacheManager, private val cacheManager: CacheManager,
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
private val pluginManager: AudioPluginSource,
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(PlayerOptionsUiState()) private val _uiState = MutableStateFlow(PlayerOptionsUiState())
val uiState: StateFlow<PlayerOptionsUiState> = _uiState.asStateFlow() val uiState: StateFlow<PlayerOptionsUiState> = _uiState.asStateFlow()
@ -155,7 +157,8 @@ class PlayerOptionsViewModel(
runCatching { runCatching {
val settings = settingsRepository.userSettings.value val settings = settingsRepository.userSettings.value
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val matchedSource = matchedTracksRepository.getTrackSource(track) val pluginId = pluginManager.selectedAudioPlugin.value?.pluginId
val matchedSource = pluginId?.let { matchedTracksRepository.getTrackSource(track, it) }
val cachedStream = streamingUrlRepository.getCachedStreamUrlEntry(track.id) val cachedStream = streamingUrlRepository.getCachedStreamUrlEntry(track.id)
val localCacheEntry = if (settings.enableMusicCaching) { val localCacheEntry = if (settings.enableMusicCaching) {
cacheManager.findCachedEntry(track.id)?.second cacheManager.findCachedEntry(track.id)?.second

View File

@ -0,0 +1,124 @@
/*
* 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.update
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import co.touchlab.kermit.Logger
import dev.krtirtho.spotube.AppBuildInfo
import dev.krtirtho.spotube.modules.settings.SettingsRepository
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.http.HttpHeaders
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import net.swiftzer.semver.SemVer
data class AvailableUpdate(
val tag: String,
val releaseNotesMarkdown: String,
)
class UpdateCheckerViewModel(
private val settingsRepository: SettingsRepository,
) : ViewModel() {
private val logger = Logger.withTag("UpdateChecker")
private val httpClient = HttpClient(CIO) {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
}
private val _availableUpdate = MutableStateFlow<AvailableUpdate?>(null)
val availableUpdate: StateFlow<AvailableUpdate?> = _availableUpdate.asStateFlow()
init {
viewModelScope.launch {
checkForUpdate()
}
}
private suspend fun checkForUpdate() {
try {
// Read directly from DataStore so the check honors persisted preferences,
// rather than the repository StateFlow's default value during startup.
val settings = settingsRepository.getCurrentSettings()
if (!settings.autoCheckForUpdates) return
val release = httpClient.get(AppBuildInfo.LATEST_RELEASE_API_URL) {
header(HttpHeaders.Accept, "application/vnd.github+json")
header(HttpHeaders.UserAgent, "Spotube/${AppBuildInfo.VERSION}")
}.body<GitHubRelease>()
val latestVersion = parseVersion(release.tagName.removePrefix("v"))
?: return logger.w { "Ignoring release with invalid SemVer tag: ${release.tagName}" }
val currentVersion = parseVersion(AppBuildInfo.VERSION)
?: return logger.w { "Ignoring update check for invalid app version: ${AppBuildInfo.VERSION}" }
if (latestVersion > currentVersion && release.tagName != settings.ignoredUpdateVersion) {
_availableUpdate.value = AvailableUpdate(
tag = release.tagName,
releaseNotesMarkdown = release.body.orEmpty(),
)
}
} catch (e: Exception) {
logger.w(e) { "Failed to check for app updates" }
}
}
fun ignoreUpdate() {
val update = _availableUpdate.value ?: return
_availableUpdate.value = null
viewModelScope.launch {
runCatching {
val settings = settingsRepository.getCurrentSettings()
settingsRepository.updateSettings(settings.copy(ignoredUpdateVersion = update.tag))
}.onFailure { e ->
logger.e(e) { "Failed to persist ignored app version" }
}
}
}
fun dismissUpdate() {
_availableUpdate.value = null
}
private fun parseVersion(version: String): SemVer? = runCatching {
SemVer.parse(version)
}.getOrNull()
override fun onCleared() {
httpClient.close()
}
}
@Serializable
private data class GitHubRelease(
@SerialName("tag_name") val tagName: String,
val body: String? = null,
)

View File

@ -0,0 +1,89 @@
package dev.krtirtho.spotube.resources.iconsax
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.PathData
import androidx.compose.ui.graphics.vector.group
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
val Iconsax.IconsaxMirrorScreenRegular: ImageVector
get() {
if (_IconsaxMirrorScreenRegular != null) {
return _IconsaxMirrorScreenRegular!!
}
_IconsaxMirrorScreenRegular = ImageVector.Builder(
name = "IconsaxMirrorScreenRegular",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f
).apply {
group(
clipPathData = PathData {
moveTo(0f, 0f)
horizontalLineToRelative(24f)
verticalLineToRelative(24f)
horizontalLineToRelative(-24f)
close()
}
) {
path(
stroke = SolidColor(Color.White),
strokeLineWidth = 1.5f,
strokeLineCap = StrokeCap.Round,
strokeLineJoin = StrokeJoin.Round
) {
moveTo(2f, 9f)
verticalLineTo(8f)
curveTo(2f, 5f, 4f, 3f, 7f, 3f)
horizontalLineTo(17f)
curveTo(20f, 3f, 22f, 5f, 22f, 8f)
verticalLineTo(16f)
curveTo(22f, 19f, 20f, 21f, 17f, 21f)
horizontalLineTo(16f)
}
path(
fillAlpha = 0.4f,
stroke = SolidColor(Color.White),
strokeAlpha = 0.4f,
strokeLineWidth = 1.5f,
strokeLineCap = StrokeCap.Round,
strokeLineJoin = StrokeJoin.Round
) {
moveTo(3.69f, 11.711f)
curveTo(8.31f, 12.301f, 11.7f, 15.701f, 12.3f, 20.321f)
}
path(
fillAlpha = 0.4f,
stroke = SolidColor(Color.White),
strokeAlpha = 0.4f,
strokeLineWidth = 1.5f,
strokeLineCap = StrokeCap.Round,
strokeLineJoin = StrokeJoin.Round
) {
moveTo(2.62f, 15.07f)
curveTo(6.01f, 15.5f, 8.5f, 18f, 8.94f, 21.39f)
}
path(
fillAlpha = 0.4f,
stroke = SolidColor(Color.White),
strokeAlpha = 0.4f,
strokeLineWidth = 1.5f,
strokeLineCap = StrokeCap.Round,
strokeLineJoin = StrokeJoin.Round
) {
moveTo(1.98f, 18.859f)
curveTo(3.67f, 19.079f, 4.92f, 20.319f, 5.14f, 22.019f)
}
}
}.build()
return _IconsaxMirrorScreenRegular!!
}
@Suppress("ObjectPropertyName")
private var _IconsaxMirrorScreenRegular: ImageVector? = null

View File

@ -1,20 +1,3 @@
/*
* 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.resources.iconsax package dev.krtirtho.spotube.resources.iconsax
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@ -56,11 +39,7 @@ val Iconsax.IconsaxSound: ImageVector
curveTo(3.75f, 16.16f, 3.41f, 16.5f, 3f, 16.5f) curveTo(3.75f, 16.16f, 3.41f, 16.5f, 3f, 16.5f)
close() close()
} }
path( path(fill = SolidColor(Color.White)) {
fill = SolidColor(Color.White),
fillAlpha = 0.4f,
strokeAlpha = 0.4f
) {
moveTo(7.5f, 19f) moveTo(7.5f, 19f)
curveTo(7.09f, 19f, 6.75f, 18.66f, 6.75f, 18.25f) curveTo(7.09f, 19f, 6.75f, 18.66f, 6.75f, 18.25f)
verticalLineTo(5.75f) verticalLineTo(5.75f)
@ -80,11 +59,7 @@ val Iconsax.IconsaxSound: ImageVector
curveTo(12.75f, 21.16f, 12.41f, 21.5f, 12f, 21.5f) curveTo(12.75f, 21.16f, 12.41f, 21.5f, 12f, 21.5f)
close() close()
} }
path( path(fill = SolidColor(Color.White)) {
fill = SolidColor(Color.White),
fillAlpha = 0.4f,
strokeAlpha = 0.4f
) {
moveTo(16.5f, 19f) moveTo(16.5f, 19f)
curveTo(16.09f, 19f, 15.75f, 18.66f, 15.75f, 18.25f) curveTo(16.09f, 19f, 15.75f, 18.66f, 15.75f, 18.25f)
verticalLineTo(5.75f) verticalLineTo(5.75f)

View File

@ -0,0 +1,97 @@
package dev.krtirtho.spotube.resources.iconsax
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.PathData
import androidx.compose.ui.graphics.vector.group
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
val Iconsax.IconsaxSoundTwotone: ImageVector
get() {
if (_IconsaxSoundTwotone != null) {
return _IconsaxSoundTwotone!!
}
_IconsaxSoundTwotone = ImageVector.Builder(
name = "IconsaxSoundTwotone",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f
).apply {
group(
clipPathData = PathData {
moveTo(0f, 0f)
horizontalLineToRelative(24f)
verticalLineToRelative(24f)
horizontalLineToRelative(-24f)
close()
}
) {
path(fill = SolidColor(Color.White)) {
moveTo(3f, 16.5f)
curveTo(2.59f, 16.5f, 2.25f, 16.16f, 2.25f, 15.75f)
verticalLineTo(8.25f)
curveTo(2.25f, 7.84f, 2.59f, 7.5f, 3f, 7.5f)
curveTo(3.41f, 7.5f, 3.75f, 7.84f, 3.75f, 8.25f)
verticalLineTo(15.75f)
curveTo(3.75f, 16.16f, 3.41f, 16.5f, 3f, 16.5f)
close()
}
path(
fill = SolidColor(Color.White),
fillAlpha = 0.4f,
strokeAlpha = 0.4f
) {
moveTo(7.5f, 19f)
curveTo(7.09f, 19f, 6.75f, 18.66f, 6.75f, 18.25f)
verticalLineTo(5.75f)
curveTo(6.75f, 5.34f, 7.09f, 5f, 7.5f, 5f)
curveTo(7.91f, 5f, 8.25f, 5.34f, 8.25f, 5.75f)
verticalLineTo(18.25f)
curveTo(8.25f, 18.66f, 7.91f, 19f, 7.5f, 19f)
close()
}
path(fill = SolidColor(Color.White)) {
moveTo(12f, 21.5f)
curveTo(11.59f, 21.5f, 11.25f, 21.16f, 11.25f, 20.75f)
verticalLineTo(3.25f)
curveTo(11.25f, 2.84f, 11.59f, 2.5f, 12f, 2.5f)
curveTo(12.41f, 2.5f, 12.75f, 2.84f, 12.75f, 3.25f)
verticalLineTo(20.75f)
curveTo(12.75f, 21.16f, 12.41f, 21.5f, 12f, 21.5f)
close()
}
path(
fill = SolidColor(Color.White),
fillAlpha = 0.4f,
strokeAlpha = 0.4f
) {
moveTo(16.5f, 19f)
curveTo(16.09f, 19f, 15.75f, 18.66f, 15.75f, 18.25f)
verticalLineTo(5.75f)
curveTo(15.75f, 5.34f, 16.09f, 5f, 16.5f, 5f)
curveTo(16.91f, 5f, 17.25f, 5.34f, 17.25f, 5.75f)
verticalLineTo(18.25f)
curveTo(17.25f, 18.66f, 16.91f, 19f, 16.5f, 19f)
close()
}
path(fill = SolidColor(Color.White)) {
moveTo(21f, 16.5f)
curveTo(20.59f, 16.5f, 20.25f, 16.16f, 20.25f, 15.75f)
verticalLineTo(8.25f)
curveTo(20.25f, 7.84f, 20.59f, 7.5f, 21f, 7.5f)
curveTo(21.41f, 7.5f, 21.75f, 7.84f, 21.75f, 8.25f)
verticalLineTo(15.75f)
curveTo(21.75f, 16.16f, 21.41f, 16.5f, 21f, 16.5f)
close()
}
}
}.build()
return _IconsaxSoundTwotone!!
}
@Suppress("ObjectPropertyName")
private var _IconsaxSoundTwotone: ImageVector? = null

View File

@ -0,0 +1,25 @@
/*
* 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
internal object AppBuildInfo {
const val VERSION = "${appVersion}"
const val BUILD_NUMBER = ${appBuildNumber}
const val REPOSITORY_URL = "${repositoryUrl}"
const val LATEST_RELEASE_API_URL = "${latestReleaseApiUrl}"
}

View File

@ -33,7 +33,8 @@ class FakeMetadataTrackAPI(
} }
class FakePluginService( class FakePluginService(
private val metadataTrackAPI: MetadataTrackAPI = FakeMetadataTrackAPI() private val metadataTrackAPI: MetadataTrackAPI = FakeMetadataTrackAPI(),
override val pluginId: String = "fake-plugin",
) : PluginService { ) : PluginService {
override val loggedInFlow: StateFlow<Boolean> = MutableStateFlow(true) override val loggedInFlow: StateFlow<Boolean> = MutableStateFlow(true)

View File

@ -0,0 +1,556 @@
package dev.krtirtho.spotube.core.server
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioQuality
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.audio.StreamProtocol
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.modules.settings.UserSettings
import co.touchlab.kermit.Logger
import kotlinx.coroutines.test.runTest
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import org.koin.dsl.module
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Behavioral / caching tests for [StreamingUrlRepository]: the in-memory stream-url cache,
* the matched-source lookup, and the interaction between the two when the active audio
* plugin changes.
*
* These specifically target the "wrong track plays" class of bug: a stale match or cached
* URL from one plugin (or one track) being silently reused for a different plugin/track.
*/
class StreamingUrlRepositoryBehaviorTest {
@BeforeTest
fun setup() {
startKoin {
modules(module {
factory { (tag: String?) -> Logger.withTag(tag ?: "test") }
})
}
}
@AfterTest
fun teardown() {
stopKoin()
}
private fun track(id: String, title: String = "Track $id") = MetadataTrack(
id = id,
title = title,
durationMs = 200_000,
trackNumber = 1,
discNumber = 1,
artists = emptyList(),
album = null,
thumbnails = null,
explicit = false,
popularity = null,
isrcCode = null,
externalUri = null,
)
private fun basicSource(id: String, confidence: Float = 1f) = AudioSource.Basic(
id = id,
title = "source-$id",
artist = null,
album = null,
thumbnails = emptyList(),
externalUri = null,
confidence = confidence,
)
private fun streamedSource(
sourceId: String,
streams: List<AudioStream>,
confidence: Float = 1f,
) = AudioSource.Streamed(
id = sourceId,
title = "source-$sourceId",
artist = null,
album = null,
thumbnails = emptyList(),
externalUri = null,
confidence = confidence,
streams = streams,
)
private fun stream(url: String, bitrate: Int = 256_000) = AudioStream.Lossy(
url = url,
codec = "opus",
container = "webm",
protocol = StreamProtocol.PROGRESSIVE,
bitrate = bitrate,
)
private class Harness(cacheTtlMs: Long = 30_000) {
val queue = FakeStreamingAudioPlayerQueue()
val matchedTracksRepository = FakeTrackSourceRepository()
val settings = FakeUserSettingsSource()
val pluginSource = FakeAudioPluginSource()
val repository = StreamingUrlRepository(
pluginManager = pluginSource,
audioPlayerQueue = queue,
matchedTracksRepository = matchedTracksRepository,
settingsRepository = settings,
streamUrlCacheTtlMs = cacheTtlMs,
)
fun selectPlugin(pluginId: String, audioAPI: FakeAudioAPI = FakeAudioAPI()): FakeAudioPluginService {
val service = FakeAudioPluginService(pluginId, audioAPI)
pluginSource.setPlugin(service)
return service
}
}
// ---------- basic resolution ----------
@Test
fun `resolves and returns a stream via track lookup when nothing is cached`() = runTest {
val h = Harness()
val t = track("t1")
val plugin = h.selectPlugin("pluginA")
plugin.audioAPI.onGetStreamsByTrack = {
listOf(streamedSource("src1", listOf(stream("https://example.com/a.opus"))))
}
val info = h.repository.resolveStreamInfo(t)
assertNotNull(info)
assertEquals("https://example.com/a.opus", info.url)
}
@Test
fun `returns null when no audio plugin is selected`() = runTest {
val h = Harness()
val info = h.repository.resolveStreamInfo(track("t1"))
assertNull(info)
}
@Test
fun `returns null when the plugin yields no sources`() = runTest {
val h = Harness()
val plugin = h.selectPlugin("pluginA")
plugin.audioAPI.onGetStreamsByTrack = { emptyList() }
assertNull(h.repository.resolveStreamInfo(track("t1")))
}
@Test
fun `resolveStreamInfo by trackId looks up the matching queue entry`() = runTest {
val h = Harness()
val t = track("t1")
h.queue.setQueue(listOf(QueueEntry.StreamingTrack(track = t, url = "")))
val plugin = h.selectPlugin("pluginA")
plugin.audioAPI.onGetStreamsByTrack = {
listOf(streamedSource("src1", listOf(stream("https://example.com/a.opus"))))
}
val info = h.repository.resolveStreamInfo(trackId = "t1")
assertNotNull(info)
assertEquals("https://example.com/a.opus", info.url)
}
@Test
fun `resolveStreamInfo by trackId returns null when track is not queued`() = runTest {
val h = Harness()
h.selectPlugin("pluginA")
assertNull(h.repository.resolveStreamInfo(trackId = "not-queued"))
}
// ---------- in-memory URL cache ----------
@Test
fun `second resolution for the same track reuses the cache instead of calling the plugin again`() = runTest {
val h = Harness()
val t = track("t1")
val plugin = h.selectPlugin("pluginA")
plugin.audioAPI.onGetStreamsByTrack = {
listOf(streamedSource("src1", listOf(stream("https://example.com/a.opus"))))
}
h.repository.resolveStreamInfo(t)
h.repository.resolveStreamInfo(t)
assertEquals(1, plugin.audioAPI.getStreamsByTrackCallCount)
}
@Test
fun `forceRefresh bypasses the cache and re-resolves`() = runTest {
val h = Harness()
val t = track("t1")
val plugin = h.selectPlugin("pluginA")
var counter = 0
plugin.audioAPI.onGetStreamsByTrack = {
counter++
listOf(streamedSource("src$counter", listOf(stream("https://example.com/$counter.opus"))))
}
val first = h.repository.resolveStreamInfo(t)
val second = h.repository.resolveStreamInfo(t, forceRefresh = true)
assertEquals("https://example.com/1.opus", first?.url)
assertEquals("https://example.com/2.opus", second?.url)
assertEquals(2, plugin.audioAPI.getStreamsByTrackCallCount)
}
@Test
fun `changing the preferred format invalidates the cached url`() = runTest {
val h = Harness()
val t = track("t1")
val plugin = h.selectPlugin("pluginA")
plugin.audioAPI.onGetStreamsByTrack = {
listOf(
streamedSource(
"src1",
listOf(
stream("https://example.com/opus.webm"),
AudioStream.Lossy(
url = "https://example.com/aac.mp4",
codec = "aac",
container = "mp4",
bitrate = 256_000,
),
)
)
)
}
val first = h.repository.resolveStreamInfo(t)
assertEquals("https://example.com/opus.webm", first?.url)
h.settings.update {
copy(streamingMusicFormat = streamingMusicFormat.copy(codec = "aac", container = "mp4"))
}
val second = h.repository.resolveStreamInfo(t)
assertEquals("https://example.com/aac.mp4", second?.url)
// Preference change forced a second real lookup rather than serving the stale URL.
assertEquals(2, plugin.audioAPI.getStreamsByTrackCallCount)
}
@Test
fun `changing the preferred quality invalidates the cached url`() = runTest {
val h = Harness()
val t = track("t1")
val plugin = h.selectPlugin("pluginA")
plugin.audioAPI.onGetStreamsByTrack = {
listOf(
streamedSource(
"src1",
listOf(stream("https://example.com/128.opus", 128_000), stream("https://example.com/320.opus", 320_000))
)
)
}
h.settings.update { copy(streamingMusicQuality = AudioQuality.Lossy(128_000)) }
val first = h.repository.resolveStreamInfo(t)
assertEquals("https://example.com/128.opus", first?.url)
h.settings.update { copy(streamingMusicQuality = AudioQuality.Lossy(320_000)) }
val second = h.repository.resolveStreamInfo(t)
assertEquals("https://example.com/320.opus", second?.url)
}
@Test
fun `cached url expires after the configured ttl`() = kotlinx.coroutines.runBlocking {
val h = Harness(cacheTtlMs = 10)
val t = track("t1")
val plugin = h.selectPlugin("pluginA")
var counter = 0
plugin.audioAPI.onGetStreamsByTrack = {
counter++
listOf(streamedSource("src$counter", listOf(stream("https://example.com/$counter.opus"))))
}
h.repository.resolveStreamInfo(t)
kotlinx.coroutines.delay(50)
h.repository.resolveStreamInfo(t)
assertEquals(2, plugin.audioAPI.getStreamsByTrackCallCount)
}
@Test
fun `invalidateCachedStreamUrl removes the cached entry unconditionally when no url is given`() = runTest {
val h = Harness()
val t = track("t1")
val plugin = h.selectPlugin("pluginA")
plugin.audioAPI.onGetStreamsByTrack = {
listOf(streamedSource("src1", listOf(stream("https://example.com/a.opus"))))
}
h.repository.resolveStreamInfo(t)
h.repository.invalidateCachedStreamUrl(t.id)
h.repository.resolveStreamInfo(t)
assertEquals(2, plugin.audioAPI.getStreamsByTrackCallCount)
}
@Test
fun `invalidateCachedStreamUrl with a mismatching url is a no-op`() = runTest {
val h = Harness()
val t = track("t1")
val plugin = h.selectPlugin("pluginA")
plugin.audioAPI.onGetStreamsByTrack = {
listOf(streamedSource("src1", listOf(stream("https://example.com/a.opus"))))
}
h.repository.resolveStreamInfo(t)
h.repository.invalidateCachedStreamUrl(t.id, url = "https://not-the-cached-url")
h.repository.resolveStreamInfo(t)
// Cache entry survived because the supplied url didn't match; plugin isn't hit again.
assertEquals(1, plugin.audioAPI.getStreamsByTrackCallCount)
}
// ---------- matched-source fast path ----------
@Test
fun `a previously matched source from the same plugin is reused via getStreamsOfAudioSource`() = runTest {
val h = Harness()
val t = track("t1")
val plugin = h.selectPlugin("pluginA")
h.matchedTracksRepository.seed(t, basicSource("matched-1"), pluginId = "pluginA")
plugin.audioAPI.onGetStreamsOfAudioSource = {
listOf(streamedSource(it.id, listOf(stream("https://example.com/matched.opus"))))
}
plugin.audioAPI.onGetStreamsByTrack = {
error("should not fall back to track lookup when a matched source resolves")
}
val info = h.repository.resolveStreamInfo(t)
assertEquals("https://example.com/matched.opus", info?.url)
assertEquals(1, plugin.audioAPI.getStreamsOfAudioSourceCallCount)
assertEquals(0, plugin.audioAPI.getStreamsByTrackCallCount)
}
@Test
fun `THE BUG - a source matched by a previous plugin is never handed to a newly selected plugin`() = runTest {
val h = Harness()
val t = track("t1")
// The user matched this track while "pluginA" (e.g. a YouTube-backed plugin) was active.
// "yt-video-123" is meaningful *only* to pluginA.
h.matchedTracksRepository.seed(t, basicSource("yt-video-123"), pluginId = "pluginA")
// The user then switches the active audio plugin to "pluginB" (a different backend).
val pluginB = h.selectPlugin("pluginB")
pluginB.audioAPI.onGetStreamsOfAudioSource = {
error(
"pluginB must never receive a source id matched by a different plugin " +
"(id=${it.id}); this is exactly how playback of the wrong track happens."
)
}
pluginB.audioAPI.onGetStreamsByTrack = {
listOf(streamedSource("pluginB-native-source", listOf(stream("https://example.com/correct.opus"))))
}
val info = h.repository.resolveStreamInfo(t)
// pluginB never saw the stale "yt-video-123" id...
assertEquals(0, pluginB.audioAPI.getStreamsOfAudioSourceCallCount)
// ...and instead correctly fell back to a fresh, pluginB-native track lookup.
assertEquals(1, pluginB.audioAPI.getStreamsByTrackCallCount)
assertEquals("https://example.com/correct.opus", info?.url)
}
@Test
fun `a cached stream url from a previous plugin is not served after switching plugins`() = runTest {
val h = Harness()
val t = track("t1")
val pluginA = h.selectPlugin("pluginA")
pluginA.audioAPI.onGetStreamsByTrack = {
listOf(streamedSource("srcA", listOf(stream("https://example.com/pluginA.opus"))))
}
val first = h.repository.resolveStreamInfo(t)
assertEquals("https://example.com/pluginA.opus", first?.url)
val pluginB = h.selectPlugin("pluginB")
pluginB.audioAPI.onGetStreamsByTrack = {
listOf(streamedSource("srcB", listOf(stream("https://example.com/pluginB.opus"))))
}
val second = h.repository.resolveStreamInfo(t)
// Must re-resolve via pluginB rather than replaying pluginA's cached URL.
assertEquals("https://example.com/pluginB.opus", second?.url)
assertEquals(1, pluginB.audioAPI.getStreamsByTrackCallCount)
}
@Test
fun `matched source is saved and tagged with the resolving plugin id`() = runTest {
val h = Harness()
val t = track("t1")
val plugin = h.selectPlugin("pluginA")
plugin.audioAPI.onGetStreamsByTrack = {
listOf(streamedSource("src1", listOf(stream("https://example.com/a.opus"))))
}
h.repository.resolveStreamInfo(t)
val saved = h.matchedTracksRepository.rawEntryFor(t.id)
assertNotNull(saved)
assertEquals("pluginA", saved.pluginId)
assertEquals("src1", saved.source.id)
}
@Test
fun `matched source is looked up but ignored when the format preference has no matching stream`() = runTest {
val h = Harness()
val t = track("t1")
val plugin = h.selectPlugin("pluginA")
h.matchedTracksRepository.seed(t, basicSource("matched-1"), pluginId = "pluginA")
// Matched source resolves to a stream, so preferred-format mismatch is irrelevant here;
// this documents that selection still happens on top of the matched-source stream list.
plugin.audioAPI.onGetStreamsOfAudioSource = {
listOf(streamedSource(it.id, listOf(stream("https://example.com/only.opus"))))
}
val info = h.repository.resolveStreamInfo(t)
assertEquals("https://example.com/only.opus", info?.url)
}
@Test
fun `falls back to track lookup when the matched source resolves no streams`() = runTest {
val h = Harness()
val t = track("t1")
val plugin = h.selectPlugin("pluginA")
h.matchedTracksRepository.seed(t, basicSource("matched-1"), pluginId = "pluginA")
plugin.audioAPI.onGetStreamsOfAudioSource = { emptyList() }
plugin.audioAPI.onGetStreamsByTrack = {
listOf(streamedSource("fresh", listOf(stream("https://example.com/fresh.opus"))))
}
val info = h.repository.resolveStreamInfo(t)
assertEquals("https://example.com/fresh.opus", info?.url)
assertEquals(1, plugin.audioAPI.getStreamsOfAudioSourceCallCount)
assertEquals(1, plugin.audioAPI.getStreamsByTrackCallCount)
}
@Test
fun `track lookup persists a Basic source by resolving its streams before caching`() = runTest {
val h = Harness()
val t = track("t1")
val plugin = h.selectPlugin("pluginA")
plugin.audioAPI.onGetStreamsByTrack = { listOf(basicSource("basic-1")) }
plugin.audioAPI.onGetStreamsOfAudioSource = {
listOf(streamedSource(it.id, listOf(stream("https://example.com/resolved.opus"))))
}
val info = h.repository.resolveStreamInfo(t)
assertEquals("https://example.com/resolved.opus", info?.url)
assertEquals("basic-1", h.matchedTracksRepository.rawEntryFor(t.id)?.source?.id)
}
@Test
fun `distinct tracks never share cache entries even with concurrent resolution`() = runTest {
val h = Harness()
val plugin = h.selectPlugin("pluginA")
plugin.audioAPI.onGetStreamsByTrack = { t ->
listOf(streamedSource("src-${t.id}", listOf(stream("https://example.com/${t.id}.opus"))))
}
val t1 = track("t1")
val t2 = track("t2")
val info1 = h.repository.resolveStreamInfo(t1)
val info2 = h.repository.resolveStreamInfo(t2)
assertEquals("https://example.com/t1.opus", info1?.url)
assertEquals("https://example.com/t2.opus", info2?.url)
assertTrue(info1?.url != info2?.url)
}
// ---------- alternatives cache ----------
@Test
fun `getCachedAlternatives returns null before anything is cached`() = runTest {
val h = Harness()
assertNull(h.repository.getCachedAlternatives("t1", "pluginA"))
}
@Test
fun `alternatives resolved via track lookup are cached and retrievable`() = runTest {
val h = Harness()
val t = track("t1")
val plugin = h.selectPlugin("pluginA")
val candidates = listOf(basicSource("alt-1"), basicSource("alt-2"))
plugin.audioAPI.onGetStreamsByTrack = { candidates }
plugin.audioAPI.onGetStreamsOfAudioSource = {
listOf(streamedSource(it.id, listOf(stream("https://example.com/${it.id}.opus"))))
}
h.repository.resolveStreamInfo(t)
val cached = h.repository.getCachedAlternatives(t.id, "pluginA")
assertNotNull(cached)
assertEquals(2, cached.size)
}
@Test
fun `alternatives cached by one plugin are not visible to a different plugin`() = runTest {
val h = Harness()
val t = track("t1")
val pluginA = h.selectPlugin("pluginA")
pluginA.audioAPI.onGetStreamsByTrack = { listOf(basicSource("alt-1")) }
pluginA.audioAPI.onGetStreamsOfAudioSource = {
listOf(streamedSource(it.id, listOf(stream("https://example.com/a.opus"))))
}
h.repository.resolveStreamInfo(t)
assertNotNull(h.repository.getCachedAlternatives(t.id, "pluginA"))
assertNull(h.repository.getCachedAlternatives(t.id, "pluginB"))
}
@Test
fun `cacheAlternatives followed by invalidateCachedAlternatives clears the entry`() = runTest {
val h = Harness()
h.repository.cacheAlternatives("t1", listOf(basicSource("alt-1")), "pluginA")
assertNotNull(h.repository.getCachedAlternatives("t1", "pluginA"))
h.repository.invalidateCachedAlternatives("t1")
assertNull(h.repository.getCachedAlternatives("t1", "pluginA"))
}
// ---------- getCachedStreamUrlEntry ----------
@Test
fun `getCachedStreamUrlEntry returns null when nothing has been cached`() = runTest {
val h = Harness()
assertNull(h.repository.getCachedStreamUrlEntry("t1"))
}
@Test
fun `getCachedStreamUrlEntry never fetches - it only reflects the existing cache`() = runTest {
val h = Harness()
val t = track("t1")
val plugin = h.selectPlugin("pluginA")
plugin.audioAPI.onGetStreamsByTrack = {
listOf(streamedSource("src1", listOf(stream("https://example.com/a.opus"))))
}
assertNull(h.repository.getCachedStreamUrlEntry(t.id))
h.repository.resolveStreamInfo(t)
val entry = h.repository.getCachedStreamUrlEntry(t.id)
assertNotNull(entry)
assertEquals("https://example.com/a.opus", entry.url)
assertEquals("pluginA", entry.pluginId)
// Fetching the entry doesn't call the plugin again.
assertEquals(1, plugin.audioAPI.getStreamsByTrackCallCount)
}
}

View File

@ -0,0 +1,137 @@
package dev.krtirtho.spotube.core.server
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioFormat
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioSource
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.zipline.PluginService
import dev.krtirtho.spotube.core.zipline.PluginServiceScope
import dev.krtirtho.spotube.modules.plugin.AudioPluginSource
import dev.krtirtho.spotube.modules.settings.UserSettings
import dev.krtirtho.spotube.modules.settings.UserSettingsSource
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/** In-memory [AudioAPI] whose behaviour for each call is fully scripted by the test. */
class FakeAudioAPI(
override val supportedQualities: List<AudioFormat> = emptyList(),
var onGetStreamsByTrack: suspend (MetadataTrack) -> List<AudioSource> = { emptyList() },
var onGetStreamsOfAudioSource: suspend (AudioSource.Basic) -> List<AudioSource.Streamed> = { emptyList() },
) : AudioAPI {
var getStreamsByTrackCallCount = 0
var getStreamsOfAudioSourceCallCount = 0
val getStreamsOfAudioSourceCalls = mutableListOf<AudioSource.Basic>()
override suspend fun getStreamsByTrack(track: MetadataTrack): List<AudioSource> {
getStreamsByTrackCallCount++
return onGetStreamsByTrack(track)
}
override suspend fun getStreamsOfAudioSource(source: AudioSource.Basic): List<AudioSource.Streamed> {
getStreamsOfAudioSourceCallCount++
getStreamsOfAudioSourceCalls.add(source)
return onGetStreamsOfAudioSource(source)
}
}
/** Minimal [PluginService] that only ever exposes an [AudioAPI], identified by [pluginId]. */
class FakeAudioPluginService(
override val pluginId: String,
val audioAPI: FakeAudioAPI = FakeAudioAPI(),
) : PluginService {
override val loggedInFlow: StateFlow<Boolean> = MutableStateFlow(true)
override suspend fun start() = Unit
override suspend fun stop() = Unit
override suspend fun <T> use(block: suspend PluginServiceScope.() -> T): T {
val scope = PluginServiceScope(mapOf(AudioAPI::class to audioAPI))
return scope.block()
}
}
class FakeAudioPluginSource(
initialPlugin: PluginService? = null
) : AudioPluginSource {
private val _selectedAudioPlugin = MutableStateFlow(initialPlugin)
override val selectedAudioPlugin: StateFlow<PluginService?> = _selectedAudioPlugin
fun setPlugin(service: PluginService?) {
_selectedAudioPlugin.value = service
}
}
/** In-memory [TrackSourceRepository], mirroring the plugin-scoped persistence contract of
* the real [MatchedTracksRepository] without touching disk. */
class FakeTrackSourceRepository : TrackSourceRepository {
private val store = mutableMapOf<String, PluginTaggedAudioSource>()
var getCallCount = 0
var saveCallCount = 0
override suspend fun getTrackSource(track: MetadataTrack, pluginId: String): AudioSource.Basic? {
getCallCount++
val tagged = store[track.id] ?: return null
if (tagged.pluginId != pluginId) return null
return tagged.source
}
override suspend fun saveTrackSource(track: MetadataTrack, source: AudioSource.Basic, pluginId: String) {
saveCallCount++
store[track.id] = PluginTaggedAudioSource(pluginId, source)
}
fun rawEntryFor(trackId: String): PluginTaggedAudioSource? = store[trackId]
fun seed(track: MetadataTrack, source: AudioSource.Basic, pluginId: String) {
store[track.id] = PluginTaggedAudioSource(pluginId, source)
}
}
class FakeUserSettingsSource(initial: UserSettings = UserSettings()) : UserSettingsSource {
private val _userSettings = MutableStateFlow(initial)
override val userSettings: StateFlow<UserSettings> = _userSettings
fun update(transform: UserSettings.() -> UserSettings) {
_userSettings.value = _userSettings.value.transform()
}
}
/** Bare-bones [AudioPlayerQueue] that only backs [queueFlow]; every other member is unused
* by [StreamingUrlRepository] and throws if accidentally exercised. */
class FakeStreamingAudioPlayerQueue : AudioPlayerQueue {
private val _queueFlow = MutableStateFlow<List<QueueEntry>>(emptyList())
override val queueFlow: StateFlow<List<QueueEntry>> = _queueFlow
override val currentQueueEntryFlow: StateFlow<QueueEntry?> = MutableStateFlow(null)
override val currentCollectionEntryFlow: StateFlow<QueueCollectionEntry?> = MutableStateFlow(null)
override val collectionHistoryFlow: StateFlow<List<QueueCollectionEntry>> = MutableStateFlow(emptyList())
fun setQueue(entries: List<QueueEntry>) {
_queueFlow.value = entries
}
override suspend fun load(
entries: List<QueueEntry>,
autoPlay: Boolean,
startPosition: Int,
collectionEntry: QueueCollectionEntry?,
) = error("not used")
override suspend fun addToQueue(entry: QueueEntry) = error("not used")
override suspend fun addAllToQueue(entries: List<QueueEntry>, collectionEntry: QueueCollectionEntry?) =
error("not used")
override suspend fun addAllAfterCurrent(entries: List<QueueEntry>) = error("not used")
override suspend fun removeFromQueue(entry: QueueEntry) = error("not used")
override suspend fun removeFromQueueByMediaUrl(mediaUrl: String) = error("not used")
override suspend fun move(fromIndex: Int, toIndex: Int) = error("not used")
override suspend fun jumpTo(index: Int, autoPlay: Boolean) = error("not used")
override suspend fun reloadCurrent() = error("not used")
override suspend fun clear() = error("not used")
override suspend fun getQueue(): List<QueueEntry> = _queueFlow.value
override suspend fun getCurrentQueueEntry(): QueueEntry? = null
override suspend fun getCurrentCollectionEntry(): QueueCollectionEntry? = null
override suspend fun getCollectionHistory(): List<QueueCollectionEntry> = emptyList()
}

View File

@ -26,5 +26,5 @@ toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/402983f310a88ac6
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/e50b80b5a11d194a898bc3e6211b7c4b/redirect toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/e50b80b5a11d194a898bc3e6211b7c4b/redirect
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/476d3c08f4989328dee56d22e202d98d/redirect toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/476d3c08f4989328dee56d22e202d98d/redirect
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/5a88b04b5e582b332d2e6bc12b45f1b9/redirect toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/5a88b04b5e582b332d2e6bc12b45f1b9/redirect
toolchainVendor=ADOPTIUM toolchainVendor=JETBRAINS
toolchainVersion=21 toolchainVersion=21

View File

@ -46,8 +46,9 @@ kotlinx-serialization-json = "1.11.0"
materialKolor = "4.1.1" materialKolor = "4.1.1"
mqttClient = "2.1.1" mqttClient = "2.1.1"
mqttBuffer = "6.30.8" mqttBuffer = "6.30.8"
multiplatformMarkdownRenderer = "0.45.0"
murmurhash = "0.4.2" murmurhash = "0.4.2"
newpipeextractor = "v0.26.2" newpipeextractor = "v0.26.5"
newpipeExtractorKmp = "1.3.0" newpipeExtractorKmp = "1.3.0"
nucleusNucleusApplication = "2.4.6" nucleusNucleusApplication = "2.4.6"
semver = "2.1.0" semver = "2.1.0"
@ -128,6 +129,8 @@ mqtt-x-models = { module = "com.ditchoom:mqtt-5-models", version.ref = "mqttClie
mqtt-client = { module = "com.ditchoom:mqtt-client", version.ref = "mqttClient" } mqtt-client = { module = "com.ditchoom:mqtt-client", version.ref = "mqttClient" }
mqtt-buffer = { module = "com.ditchoom:buffer", version.ref = "mqttBuffer" } mqtt-buffer = { module = "com.ditchoom:buffer", version.ref = "mqttBuffer" }
mqtt-buffer-codec = { module = "com.ditchoom:buffer-codec", version.ref = "mqttBuffer" } mqtt-buffer-codec = { module = "com.ditchoom:buffer-codec", version.ref = "mqttBuffer" }
multiplatform-markdown-renderer = { module = "com.mikepenz:multiplatform-markdown-renderer", version.ref = "multiplatformMarkdownRenderer" }
multiplatform-markdown-renderer-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "multiplatformMarkdownRenderer" }
murmurhash = { module = "com.goncalossilva:murmurhash", version.ref = "murmurhash" } murmurhash = { module = "com.goncalossilva:murmurhash", version.ref = "murmurhash" }
newpipe-extractor-kmp = { module = "io.github.yushosei:newpipe-extractor-kmp", version.ref = "newpipeExtractorKmp" } newpipe-extractor-kmp = { module = "io.github.yushosei:newpipe-extractor-kmp", version.ref = "newpipeExtractorKmp" }
newpipeextractor = { module = "com.github.teamnewpipe:NewPipeExtractor", version.ref = "newpipeextractor" } newpipeextractor = { module = "com.github.teamnewpipe:NewPipeExtractor", version.ref = "newpipeextractor" }