Compare commits

...

8 Commits

Author SHA1 Message Date
dependabot[bot]
501f1259d4
Merge d48b0a2f65 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]
d48b0a2f65
chore(deps): bump softprops/action-gh-release from 2 to 3
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2 to 3.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/v2...v3)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-09 23:42:25 +00:00
40 changed files with 1659 additions and 393 deletions

View File

@ -425,7 +425,7 @@ jobs:
done
- name: Create Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ steps.vars.outputs.tag }}
name: ${{ steps.vars.outputs.release_name }}

View File

@ -20,6 +20,7 @@ import gobley.gradle.cargo.dsl.jvm
import dev.nucleusframework.desktop.application.dsl.TargetFormat
import org.jetbrains.compose.reload.gradle.ComposeHotRun
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.gradle.api.tasks.Copy
import java.io.FileInputStream
import java.util.Properties
@ -39,11 +40,53 @@ plugins {
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 {
packageName = "dev.krtirtho.spotube.core.generated" // choose a different package
packageName = "$bundleId.core.generated" // choose a different package
objectName = "VLCBundleLoaderGenerated" // or rename the object
}
kotlin {
// 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.
@ -68,6 +111,7 @@ kotlin {
sourceSets {
val commonMain by getting {
kotlin.srcDir(generatedAppInfoDir)
dependencies {
// Project dependencies
implementation(project(":plugin_interfaces"))
@ -172,6 +216,10 @@ kotlin {
implementation(libs.mqtt.x.models)
implementation(libs.mqtt.buffer)
implementation(libs.mqtt.buffer.codec)
// Markdown renderer
implementation(libs.multiplatform.markdown.renderer)
implementation(libs.multiplatform.markdown.renderer.m3)
}
}
commonTest.dependencies {
@ -249,11 +297,11 @@ android {
compileSdk = libs.versions.android.compileSdk.get().toInt()
defaultConfig {
applicationId = "dev.krtirtho.spotube"
applicationId = bundleId
minSdk = libs.versions.android.minSdk.get().toInt()
targetSdk = libs.versions.android.targetSdk.get().toInt()
versionCode = 1
versionName = project.findProperty("versionName") as String? ?: "1.0"
versionCode = buildNumber
versionName = version
}
packaging {
resources {
@ -312,8 +360,8 @@ nucleus.application {
)
nativeDistributions {
packageName = "dev.krtirtho.spotube"
packageVersion = project.findProperty("versionName") as String? ?: "6.0.0"
packageName = bundleId
packageVersion = version
licenseFile = project.file("../LICENSE")
appResourcesRootDir.set(vlcjBundler.vlcNativesDirectory)
@ -329,7 +377,7 @@ nucleus.application {
modules("jdk.unsupported")
homepage = "https://spotube.cc"
homepage = homePageUrl
linux {
modules("jdk.security.auth")
@ -371,4 +419,4 @@ cargo {
builds.jvm {
embedRustLibrary = (rustTarget == GobleyHost.current.rustTarget)
}
}
}

View File

@ -22,74 +22,114 @@ import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
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.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.ui.NavDisplay
import com.mikepenz.markdown.m3.Markdown
import dev.krtirtho.spotube.core.navigation.Navigator
import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES
import dev.krtirtho.spotube.core.navigation.rememberNavigationState
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.theming.SpotubeTheme
import dev.krtirtho.spotube.modules.library.LibraryTab
import dev.krtirtho.spotube.modules.settings.SettingsRepository
import dev.krtirtho.spotube.modules.settings.UserSettings
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.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.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.IconsaxMusicLibraryOutline
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.IconsaxSettingTwotone
import dev.krtirtho.spotube.resources.iconsax.IconsaxSoundTwotone
import dev.krtirtho.spotube.resources.iconsax.User
import kotlinx.coroutines.ExperimentalCoroutinesApi
import org.koin.compose.koinInject
import org.koin.compose.navigation3.koinEntryProvider
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.annotation.KoinExperimentalAPI
data class TabItem(
val title: String,
val icon: ImageVector,
val activeIcon: ImageVector,
val route: Routes
interface NavigationItem {
data class Tab<T : Any>(
val title: String, val icon: ImageVector, val route: Routes,
val data: T? = null
) : 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(
TabItem(
"Home",
Iconsax.IconsaxHomeBroken,
Iconsax.IconsaxHome,
Routes.Home
),
TabItem(
"Search",
Iconsax.IconsaxSearchBroken,
Iconsax.IconsaxSearch,
Routes.Search
),
TabItem(
val sidebarTabs = tabs + listOf(
NavigationItem.Group(
"Library",
Iconsax.IconsaxMusicLibraryOutline,
Iconsax.IconsaxMusicLibrary,
Routes.Library
items = listOf(
NavigationItem.Tab(
"Playlists",
Iconsax.IconsaxMusicDashboard,
Routes.Library,
LibraryTab.Playlists
),
NavigationItem.Tab("Albums", Iconsax.IconsaxCd, Routes.Library, LibraryTab.Albums),
NavigationItem.Tab("Artists", Iconsax.User, Routes.Library, LibraryTab.Artists),
)
),
TabItem(
"Settings",
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(
@ -103,10 +143,11 @@ fun App(
) {
val settingsRepository: SettingsRepository = koinInject<SettingsRepository>()
val userSettings by settingsRepository.userSettings.collectAsStateWithLifecycle(initialValue = UserSettings())
val updateCheckerViewModel: UpdateCheckerViewModel = koinViewModel()
val availableUpdate by updateCheckerViewModel.availableUpdate.collectAsStateWithLifecycle()
val navigationState = rememberNavigationState(
startRoute = Routes.Home,
topLevelRoutes = TOP_LEVEL_ROUTES
startRoute = Routes.Home, topLevelRoutes = TOP_LEVEL_ROUTES
)
val navigator = remember {
Navigator(navigationState)
@ -141,6 +182,43 @@ fun App(
}
}
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.LocalServer
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.webview.WebViewController
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.playlist.PlaylistRepository
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.PluginProvider
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.search.SearchRepository
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.SettingsRepository
import dev.krtirtho.spotube.modules.settings.UserSettingsSource
import dev.krtirtho.spotube.modules.settings.SettingsViewModel
import dev.krtirtho.spotube.modules.settings.JamSettingsViewModel
import dev.krtirtho.spotube.modules.shell.AppShellViewModel
@ -122,13 +126,17 @@ val sharedModules = module {
viewModelOf(::LibraryLocalTracksViewModel)
// Plugin system
singleOf(::PluginManager) { bind<PluginProvider>() }
singleOf(::PluginManager) {
bind<PluginProvider>()
bind<AudioPluginSource>()
}
viewModelOf(::PluginViewModel)
// Settings
singleOf(::SettingsRepository)
singleOf(::SettingsRepository) { bind<UserSettingsSource>() }
viewModelOf(::SettingsViewModel) { bind<SettingsProvider>() }
viewModelOf(::JamSettingsViewModel)
viewModelOf(::UpdateCheckerViewModel)
// Downloads
singleOf(::DownloadManager)
@ -226,8 +234,15 @@ val sharedModules = module {
blacklistRepository = get(),
)
}
singleOf(::MatchedTracksRepository)
singleOf(::StreamingUrlRepository)
singleOf(::MatchedTracksRepository) { bind<TrackSourceRepository>() }
single {
StreamingUrlRepository(
pluginManager = get(),
audioPlayerQueue = get(),
matchedTracksRepository = get(),
settingsRepository = get(),
)
}
singleOf(::CacheManager)
singleOf(::AlternativeTracksRepository)
singleOf(::LocalServer) withOptions {

View File

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

View File

@ -172,7 +172,7 @@ class RemoteControlHandler(
private suspend fun handleCommand(session: WebSocketServerSession, envelope: CommandEnvelope) {
when (val command = envelope.command) {
is RemoteControlCommand.Play -> {
handleCollectionSource(command.source, RemoteCollectionAction.Play)
handleCollectionSource(command.source, RemoteCollectionAction.Play, command.shuffle)
}
is RemoteControlCommand.Pause -> {
audioPlayer.pause()
@ -297,13 +297,17 @@ class RemoteControlHandler(
* Resolves a `spotube://` collection source URI (playlist/album/artist top
* 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" }
when {
source.startsWith(COLLECTION_PLAYLIST_PREFIX) -> {
val id = source.removePrefix(COLLECTION_PLAYLIST_PREFIX)
when (action) {
RemoteCollectionAction.Play -> collectionPlaybackHelper.playPlaylist(id)
RemoteCollectionAction.Play -> collectionPlaybackHelper.playPlaylist(id, shuffle)
RemoteCollectionAction.AddToQueue -> collectionPlaybackHelper.addPlaylistToQueue(id)
RemoteCollectionAction.PlayNext -> collectionPlaybackHelper.playPlaylistNext(id)
}
@ -312,7 +316,7 @@ class RemoteControlHandler(
source.startsWith(COLLECTION_ALBUM_PREFIX) -> {
val id = source.removePrefix(COLLECTION_ALBUM_PREFIX)
when (action) {
RemoteCollectionAction.Play -> collectionPlaybackHelper.playAlbum(id)
RemoteCollectionAction.Play -> collectionPlaybackHelper.playAlbum(id, shuffle)
RemoteCollectionAction.AddToQueue -> collectionPlaybackHelper.addAlbumToQueue(id)
RemoteCollectionAction.PlayNext -> collectionPlaybackHelper.playAlbumNext(id)
}
@ -329,7 +333,7 @@ class RemoteControlHandler(
source == COLLECTION_SAVED_TRACKS -> {
when (action) {
RemoteCollectionAction.Play -> collectionPlaybackHelper.playSavedTracks()
RemoteCollectionAction.Play -> collectionPlaybackHelper.playSavedTracks(shuffle)
RemoteCollectionAction.AddToQueue -> collectionPlaybackHelper.addSavedTracksToQueue()
RemoteCollectionAction.PlayNext -> {
// Saved tracks "play next" is not supported; add to queue instead
@ -467,4 +471,4 @@ enum class ConnectionRequestResponse {
Allow,
AllowAlways,
Deny,
}
}

View File

@ -25,7 +25,7 @@ import kotlinx.serialization.Serializable
sealed class RemoteControlCommand {
@Serializable
@SerialName("play")
data class Play(val source: String) : RemoteControlCommand()
data class Play(val source: String, val shuffle: Boolean = false) : RemoteControlCommand()
@Serializable
@SerialName("pause")
@ -147,4 +147,4 @@ data class RemoteQueueEntry(
val album: String?,
val coverUrl: String?,
val durationMs: Long,
)
)

View File

@ -64,6 +64,7 @@ sealed interface PlaybackDestinationRequest {
val type: RemoteCollectionType,
val id: String,
val startTrack: MetadataTrack? = null,
val shuffle: Boolean = false,
) : PlaybackDestinationRequest
data class Track(
@ -112,8 +113,9 @@ class RemotePlaybackController(
id: String,
title: String,
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) {
@ -239,7 +241,7 @@ class RemotePlaybackController(
if (startTrack != null) {
collectionPlaybackHelper.playPlaylistFromTrack(request.id, startTrack)
} else {
collectionPlaybackHelper.playPlaylist(request.id)
collectionPlaybackHelper.playPlaylist(request.id, request.shuffle)
}
}
@ -252,7 +254,7 @@ class RemotePlaybackController(
if (startTrack != null) {
collectionPlaybackHelper.playAlbumFromTrack(request.id, startTrack)
} else {
collectionPlaybackHelper.playAlbum(request.id)
collectionPlaybackHelper.playAlbum(request.id, request.shuffle)
}
}
@ -271,7 +273,7 @@ class RemotePlaybackController(
if (startTrack != null) {
collectionPlaybackHelper.playSavedTracksFromTrack(startTrack)
} else {
collectionPlaybackHelper.playSavedTracks()
collectionPlaybackHelper.playSavedTracks(request.shuffle)
}
}
@ -346,7 +348,7 @@ class RemotePlaybackController(
RemoteCollectionType.SavedTracks -> "spotube://saved_tracks"
}
when (request.action) {
PlaybackDestinationAction.Play -> RemoteControlCommand.Play(source)
PlaybackDestinationAction.Play -> RemoteControlCommand.Play(source, request.shuffle)
PlaybackDestinationAction.AddToQueue -> RemoteControlCommand.AddToQueue(source)
PlaybackDestinationAction.PlayNext -> RemoteControlCommand.PlayNext(source)
}
@ -385,4 +387,4 @@ class RemotePlaybackController(
album?.id == other.album?.id &&
artists.map { it.id.ifBlank { it.name } } == other.artists.map { it.id.ifBlank { it.name } }
}
}
}

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.spotube.core.audioplayer.AudioPlayerQueue
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
class AlternativeTracksRepository(
private val pluginManager: PluginManager,
private val matchedTracksRepository: MatchedTracksRepository,
private val pluginManager: AudioPluginSource,
private val matchedTracksRepository: TrackSourceRepository,
private val streamingUrlRepository: StreamingUrlRepository,
private val audioPlayerQueue: AudioPlayerQueue,
private val cacheManager: CacheManager,
@ -36,10 +36,6 @@ class AlternativeTracksRepository(
suspend fun resolveAlternatives(track: MetadataTrack): List<AudioSource> {
val trackId = track.id
streamingUrlRepository.getCachedAlternatives(trackId)?.let { cached ->
logger.v { "Using cached alternatives for track $trackId" }
return cached
}
val audioPlugin = pluginManager.selectedAudioPlugin.value
if (audioPlugin == null) {
@ -47,6 +43,11 @@ class AlternativeTracksRepository(
return emptyList()
}
streamingUrlRepository.getCachedAlternatives(trackId, audioPlugin.pluginId)?.let { cached ->
logger.v { "Using cached alternatives for track $trackId" }
return cached
}
val sources = runCatching {
audioPlugin.use { audioAPI.getStreamsByTrack(track) }
}.getOrElse { throwable ->
@ -59,24 +60,30 @@ class AlternativeTracksRepository(
return emptyList()
}
streamingUrlRepository.cacheAlternatives(trackId, sources)
streamingUrlRepository.cacheAlternatives(trackId, sources, audioPlugin.pluginId)
logger.d { "Resolved ${sources.size} alternative sources for track $trackId" }
return sources
}
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) {
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) {
is AudioSource.Streamed -> source.toBasic()
is AudioSource.Basic -> source
}
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.invalidateCachedAlternatives(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 kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.serialization.Serializable
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 ->
val json = prefs[stringPreferencesKey(track.id)]
if (json != null) {
Json.decodeFromString<AudioSource.Basic>(json as String)
} else {
val json = prefs[stringPreferencesKey(track.id)] ?: return@map null
val tagged = try {
Json.decodeFromString<PluginTaggedAudioSource>(json)
} 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
}
if (tagged.pluginId != pluginId) return@map null
tagged.source
}.first()
}
suspend fun saveTrackSource(track: MetadataTrack, source: AudioSource.Basic) {
override suspend fun saveTrackSource(track: MetadataTrack, source: AudioSource.Basic, pluginId: String) {
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.QueueEntry
import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.modules.plugin.PluginManager
import dev.krtirtho.spotube.modules.settings.SettingsRepository
import dev.krtirtho.spotube.modules.plugin.AudioPluginSource
import dev.krtirtho.spotube.modules.settings.UserSettingsSource
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.koin.core.component.KoinComponent
@ -45,6 +45,15 @@ data class CachedStreamUrl(
val selectedStream: AudioStream? = null,
val preferredFormat: AudioFormat,
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(
@ -55,21 +64,18 @@ data class StreamInfo(
)
class StreamingUrlRepository(
private val pluginManager: PluginManager,
private val pluginManager: AudioPluginSource,
private val audioPlayerQueue: AudioPlayerQueue,
private val matchedTracksRepository: MatchedTracksRepository,
private val settingsRepository: SettingsRepository,
private val matchedTracksRepository: TrackSourceRepository,
private val settingsRepository: UserSettingsSource,
private val streamUrlCacheTtlMs: Long = 30.seconds.inWholeMilliseconds,
) : KoinComponent {
companion object {
private val STREAM_URL_CACHE_TTL_MS = 30.seconds.inWholeMilliseconds
}
private val logger by injectLogger<StreamingUrlRepository>()
private val streamUrlCacheMutex = Mutex()
private val streamUrlCache = mutableMapOf<String, CachedStreamUrl>()
private val alternativesCacheMutex = Mutex()
private val alternativesCache = mutableMapOf<String, List<AudioSource>>()
private val alternativesCache = mutableMapOf<String, CachedAlternatives>()
suspend fun resolveStreamInfo(
trackId: String,
@ -95,19 +101,20 @@ class StreamingUrlRepository(
val preferredFormat = settings.streamingMusicFormat
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
if (audioPlugin == null) {
logger.w { "No audio plugin selected while resolving stream for track $trackId" }
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) {
logger.d { "Attempting stream resolution for track $trackId using cached source" }
@ -130,7 +137,7 @@ class StreamingUrlRepository(
)
if (resolvedStream != null) {
val info = resolvedStream.toStreamInfo()
cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream)
cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId)
return info
}
}
@ -153,19 +160,19 @@ class StreamingUrlRepository(
return null
}
cacheAlternatives(trackId, sources)
cacheAlternatives(trackId, sources, audioPlugin.pluginId)
val stream = runCatching {
audioPlugin.use {
sources.firstNotNullOfOrNull { src ->
when (src) {
is AudioSource.Streamed -> {
matchedTracksRepository.saveTrackSource(track, src.toBasic())
matchedTracksRepository.saveTrackSource(track, src.toBasic(), audioPlugin.pluginId)
src
}
is AudioSource.Basic -> {
matchedTracksRepository.saveTrackSource(track, src)
matchedTracksRepository.saveTrackSource(track, src, audioPlugin.pluginId)
audioAPI.getStreamsOfAudioSource(src)
.firstOrNull()
}
@ -190,7 +197,7 @@ class StreamingUrlRepository(
)
if (resolvedStream != null) {
val info = resolvedStream.toStreamInfo()
cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream)
cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId)
return info
}
return null
@ -207,10 +214,15 @@ class StreamingUrlRepository(
trackId: String,
preferredFormat: AudioFormat,
preferredQuality: AudioQuality,
pluginId: String,
): StreamInfo? {
val now = Clock.System.now().toEpochMilliseconds()
return streamUrlCacheMutex.withLock {
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) {
return@withLock null
}
@ -229,8 +241,9 @@ class StreamingUrlRepository(
preferredQuality: AudioQuality,
source: AudioSource.Streamed,
selectedStream: AudioStream,
pluginId: String,
) {
val expiresAtMs = Clock.System.now().toEpochMilliseconds() + STREAM_URL_CACHE_TTL_MS
val expiresAtMs = Clock.System.now().toEpochMilliseconds() + streamUrlCacheTtlMs
streamUrlCacheMutex.withLock {
streamUrlCache[trackId] = CachedStreamUrl(
url = info.url,
@ -242,6 +255,7 @@ class StreamingUrlRepository(
selectedStream = selectedStream,
preferredFormat = preferredFormat,
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.
}
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 {
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 {
alternativesCache[trackId] = sources
alternativesCache[trackId] = CachedAlternatives(pluginId, sources)
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.collectIsFocusedAsState
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.KeyEvent
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.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
@ -173,11 +171,9 @@ fun <T> AutocompleteTextField(
val isHovered by interactionSource.collectIsHoveredAsState()
var isMenuOpen by remember { mutableStateOf(false) }
var isMovingFocusToPopup by remember { mutableStateOf(false) }
var selectedIndex by remember { mutableIntStateOf(-1) }
var textFieldSize by remember { mutableStateOf(IntSize.Zero) }
val menuListState = rememberLazyListState()
val popupFocusRequester = remember { FocusRequester() }
val textFieldFocusRequester = remember { FocusRequester() }
val menuExpanded = isMenuOpen && items.isNotEmpty()
@ -185,7 +181,6 @@ fun <T> AutocompleteTextField(
LaunchedEffect(isFocused) {
if (isFocused) {
isMenuOpen = true
isMovingFocusToPopup = false
}
}
@ -291,7 +286,7 @@ fun <T> AutocompleteTextField(
)
.focusRequester(textFieldFocusRequester)
.onFocusChanged { state ->
if (!state.isFocused && !isMovingFocusToPopup) {
if (!state.isFocused) {
isMenuOpen = false
selectedIndex = -1
}
@ -303,8 +298,6 @@ fun <T> AutocompleteTextField(
if (!menuExpanded) return@onPreviewKeyEvent false
when (event.key) {
Key.DirectionDown -> {
isMovingFocusToPopup = true
popupFocusRequester.requestFocus()
navigate(1)
true
}
@ -374,7 +367,7 @@ fun <T> AutocompleteTextField(
Popup(
alignment = Alignment.TopStart,
offset = IntOffset(0, textFieldHeightPx + menuOffsetPx),
properties = PopupProperties(focusable = true),
properties = PopupProperties(focusable = false),
onDismissRequest = {
selectedIndex = -1
isMenuOpen = false
@ -386,45 +379,7 @@ fun <T> AutocompleteTextField(
.heightIn(max = menuMaxHeight)
.shadow(resolvedMenuTheme.shadowElevation, resolvedMenuTheme.shape)
.background(resolvedMenuTheme.background, 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
}
},
.clip(resolvedMenuTheme.shape),
) {
LazyColumn(
state = menuListState,

View File

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

View File

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

View File

@ -105,6 +105,8 @@ open class ZiplinePluginService(
private val pluginInfo: PluginEntry,
) : PluginService, KoinComponent {
override val pluginId: String = pluginInfo.id
// 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
// (e.g. kotlin-stdlib at ~491 KB) can exceed that during AST parsing.

View File

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

View File

@ -224,6 +224,11 @@ class AlbumViewModel(
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() {
val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album"
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.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.Scaffold
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.LaunchedEffect
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.misc.SkeletonTree
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.IconsaxAddSquare
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay
@ -134,7 +141,8 @@ fun ArtistScreen(
state = listState,
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
.padding(innerPadding)
.padding(bottom = LocalAppShellBottomInset.current + 24.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
item {
@ -495,6 +503,7 @@ private fun ArtistMeta(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ArtistHeaderActions(
isSaved: Boolean,
@ -508,7 +517,12 @@ private fun ArtistHeaderActions(
) {
if (isSaved) {
SecondaryButton(onClick = onFollowClick) {
Text("Following", modifier = Modifier.width(65.dp), textAlign = TextAlign.Center)
Text(
"Following",
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
} else {
PrimaryButton(onClick = onFollowClick) {
@ -516,20 +530,30 @@ private fun ArtistHeaderActions(
}
}
if (isBlacklisted) {
SecondaryIconButton(onClick = onBlacklistClick) {
Icon(
imageVector = Iconsax.IconsaxUserRemove,
contentDescription = "Remove from blacklist",
tint = MaterialTheme.colorScheme.error,
)
}
} else {
SecondaryIconButton(onClick = onBlacklistClick) {
Icon(
imageVector = Iconsax.IconsaxUserRemove,
contentDescription = "Add to blacklist",
)
TooltipBox(
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
tooltip = {
PlainTooltip {
Text(if (isBlacklisted) "Remove from blacklist" else "Add to blacklist")
}
},
state = rememberTooltipState()
) {
if (isBlacklisted) {
SecondaryIconButton(onClick = onBlacklistClick) {
Icon(
imageVector = Iconsax.IconsaxUserRemove,
contentDescription = "Remove from blacklist",
tint = MaterialTheme.colorScheme.error,
)
}
} else {
SecondaryIconButton(onClick = onBlacklistClick) {
Icon(
imageVector = Iconsax.IconsaxUserRemove,
contentDescription = "Add to blacklist",
)
}
}
}
}

View File

@ -54,6 +54,8 @@ import androidx.compose.ui.unit.sp
import compose.icons.FeatherIcons
import compose.icons.feathericons.X
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.IconsaxArrowDown4
import kotlinx.coroutines.launch
@ -70,6 +72,47 @@ fun LyricsScreen(
val scope = rememberCoroutineScope()
Scaffold(
topBar = {
ApplicationMainBar(
backButton = onClose == null,
title = {
Box(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
) {
if (onClose != null) {
IconButton(
onClick = onClose,
modifier = Modifier.align(Alignment.CenterStart)
) {
Icon(
imageVector = Iconsax.IconsaxArrowDown4,
contentDescription = "Close"
)
}
}
Row(
modifier = Modifier
.align(Alignment.Center)
.clip(RoundedCornerShape(50.dp))
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
.padding(10.dp, 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
LyricsModeTab(
label = "Synced",
isSelected = uiState.mode == LyricType.SYNCED,
onClick = { viewModel.setMode(LyricType.SYNCED) }
)
LyricsModeTab(
label = "Plain",
isSelected = uiState.mode == LyricType.STATIC,
onClick = { viewModel.setMode(LyricType.STATIC) }
)
}
}
}
)
},
modifier = modifier,
) { innerPadding ->
Column(
@ -77,46 +120,9 @@ fun LyricsScreen(
.fillMaxSize()
.padding(innerPadding)
.padding(horizontal = 16.dp)
.padding(bottom = LocalAppShellBottomInset.current + 24.dp)
) {
Spacer(modifier = Modifier.height(16.dp))
Box(
modifier = Modifier.fillMaxWidth(),
) {
if (onClose != null) {
IconButton(
onClick = onClose,
modifier = Modifier.align(Alignment.CenterStart)
) {
Icon(
imageVector = Iconsax.IconsaxArrowDown4,
contentDescription = "Close"
)
}
}
Row(
modifier = Modifier
.align(Alignment.Center)
.clip(RoundedCornerShape(50.dp))
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
.padding(10.dp, 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
LyricsModeTab(
label = "Synced",
isSelected = uiState.mode == LyricType.SYNCED,
onClick = { viewModel.setMode(LyricType.SYNCED) }
)
LyricsModeTab(
label = "Plain",
isSelected = uiState.mode == LyricType.STATIC,
onClick = { viewModel.setMode(LyricType.STATIC) }
)
}
}
Spacer(modifier = Modifier.height(24.dp))
when {
uiState.isLoading -> {
Box(

View File

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

View File

@ -230,6 +230,11 @@ class PlaylistViewModel(
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() {
val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist"
remotePlaybackController.requestCollectionAddToQueue(RemoteCollectionType.Playlist, playlistId, title)

View File

@ -69,11 +69,18 @@ interface PluginProvider {
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(
val database: Database,
val paths: Paths,
) : KoinComponent, PluginProvider {
) : KoinComponent, PluginProvider, AudioPluginSource {
private val logger by injectLogger<PluginManager>()
private val pluginExceptionHandler = CoroutineExceptionHandler { _, exception ->
logger.e(exception) { "Plugin runtime threw an unhandled exception. Intercepted safely." }
@ -245,7 +252,7 @@ class PluginManager(
override val selectedMetadataPlugin =
filterSelectedPluginByType(PluginAbility.METADATA)
val selectedAudioPlugin =
override val selectedAudioPlugin =
filterSelectedPluginByType(PluginAbility.AUDIO)
val selectedLyricsPlugin =
filterSelectedPluginByType(PluginAbility.LYRICS)

View File

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

View File

@ -206,6 +206,15 @@ class SavedTracksViewModel(
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() {
remotePlaybackController.requestCollectionAddToQueue(RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, "Saved Tracks")
}
@ -377,4 +386,4 @@ fun rememberIsSavedTracks(
}
return state
}
}

View File

@ -39,18 +39,19 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
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.spotube.core.audioplayer.AudioPlayerQueue
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.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.share.ShareService
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.UserCard
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.modules.blacklist.BlacklistRepository
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
@ -624,8 +625,10 @@ private fun SearchTabs(
selectedType: MetadataSupportedSearchType?,
onTabSelected: (MetadataSupportedSearchType) -> Unit,
) {
val listState = rememberLazyListState()
LazyRow(
modifier = Modifier.fillMaxWidth(),
state = listState,
modifier = Modifier.fillMaxWidth().dragScrollable(listState),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {

View File

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

View File

@ -27,14 +27,21 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.*
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 {
private val SETTINGS_KEY = stringPreferencesKey("user_settings")
}
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]
if (json != null) {
Json.decodeFromString<UserSettings>(json as String)
@ -52,4 +59,10 @@ class SettingsRepository(private val database: Database) {
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.text.font.FontWeight
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.Navigator
import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.modules.downloads.DownloadBadgeIndicator
import dev.krtirtho.spotube.tabs
@Composable
fun AppBottombar(
@ -68,46 +69,47 @@ fun AppBottombar(
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
tabs.forEach { (label, icon, activeIcon, screen) ->
val selected = navigationState.topLevelRoute == screen
val labelColor = if (selected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
Column(
modifier = Modifier
.clip(CircleShape)
.clickable { navigator.navigate(screen) }
.padding(8.dp)
.size(56.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Box {
Icon(
imageVector = activeIcon,
contentDescription = label,
tint = if (selected) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp)
)
if (screen == Routes.Library) {
DownloadBadgeIndicator(
modifier = Modifier.align(Alignment.TopEnd)
)
}
bottombarTabs.filterIsInstance<NavigationItem.Tab<*>>()
.forEach { tab ->
val selected = navigationState.topLevelRoute == tab.route
val labelColor = if (selected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
Column(
modifier = Modifier
.clip(CircleShape)
.clickable { navigator.navigate(tab.route) }
.padding(8.dp)
.size(56.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Box {
Icon(
imageVector = tab.icon,
contentDescription = tab.title,
tint = if (selected) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp)
)
if (tab.route == Routes.Library) {
DownloadBadgeIndicator(
modifier = Modifier.align(Alignment.TopEnd)
)
}
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = tab.title,
style = MaterialTheme.typography.labelSmall,
fontWeight = if (selected) FontWeight.Medium else FontWeight.Normal,
color = labelColor,
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
fontWeight = if (selected) FontWeight.Medium else FontWeight.Normal,
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.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
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.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle
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.blurEffect
import dev.chrisbanes.haze.hazeEffect
import dev.krtirtho.spotube.NavigationItem
import dev.krtirtho.spotube.core.navigation.NavigationState
import dev.krtirtho.spotube.core.navigation.Navigator
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.copyPadding
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.library.LibraryState
import dev.krtirtho.spotube.modules.library.LibraryTab
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.IconsaxSidebarRightBroken
import dev.krtirtho.spotube.resources.iconsax.User
import dev.krtirtho.spotube.tabs
import dev.krtirtho.spotube.sidebarTabs
import org.jetbrains.compose.resources.Font
import org.koin.compose.koinInject
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 currentLibraryTab by libraryState.currentTab.collectAsState()
val surfaceTint = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f)
val listState = rememberLazyListState()
Column(
modifier = modifier
@ -106,95 +112,107 @@ fun AppSidebar(
horizontalAlignment = Alignment.CenterHorizontally
) {
Column(modifier = Modifier.weight(1f)) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(14.dp),
horizontalArrangement = if (expanded) Arrangement.SpaceBetween else Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
AnimatedVisibility(visible = expanded, enter = fadeIn(), exit = fadeOut()) {
Text(
text = "Spotube",
style = MaterialTheme.typography.headlineLarge.copy(
fontFamily = FontFamily(
listOf(
Font(Res.font.cookie_regular, weight = FontWeight.Normal)
)
)
),
color = MaterialTheme.colorScheme.onSurface
)
}
GhostIconButton(
onClick = { expanded = !expanded }
Row(
modifier = Modifier
.fillMaxWidth()
.padding(14.dp),
horizontalArrangement = if (expanded) Arrangement.SpaceBetween else Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = if (expanded) Iconsax.IconsaxSidebarLeftBroken else Iconsax.IconsaxSidebarRightBroken,
contentDescription = if (expanded) "Collapse sidebar" else "Expand sidebar",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(modifier = Modifier.height(8.dp))
tabs.forEach { (label, icon, activeIcon, screen) ->
val selected = navigationState.topLevelRoute == screen
if (screen == Routes.Library) {
AnimatedVisibility(visible = expanded) {
AnimatedVisibility(visible = expanded, enter = fadeIn(), exit = fadeOut()) {
Text(
text = "LIBRARY",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 8.dp)
text = "Spotube",
style = MaterialTheme.typography.headlineLarge.copy(
fontFamily = FontFamily(
listOf(
Font(Res.font.cookie_regular, weight = FontWeight.Normal)
)
)
),
color = MaterialTheme.colorScheme.onSurface
)
}
LibraryTab.entries.forEach { tab ->
val isSubSelected = selected && currentLibraryTab == tab
SidebarItem(
label = tab.title,
activeIcon = tab.icon,
onClick = {
libraryState.currentTab.value = tab
navigator.navigate(screen)
},
selected = isSubSelected,
expanded = expanded,
showDownloadBadge = tab == LibraryTab.Downloads,
GhostIconButton(
onClick = { expanded = !expanded }
) {
Icon(
imageVector = if (expanded) Iconsax.IconsaxSidebarLeftBroken else Iconsax.IconsaxSidebarRightBroken,
contentDescription = if (expanded) "Collapse sidebar" else "Expand sidebar",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
SidebarItem(
label = label,
activeIcon = activeIcon,
onClick = { navigator.navigate(screen) },
selected = selected,
expanded = expanded,
}
Spacer(modifier = Modifier.height(8.dp))
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(state = listState) {
items(sidebarTabs.size) { index ->
when (val tab = sidebarTabs[index]) {
is NavigationItem.Group<*> -> {
androidx.compose.animation.AnimatedVisibility(visible = expanded) {
Text(
text = tab.title.uppercase(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth()
.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
)
}
}
is NavigationItem.Tab<*> -> {
val selected = navigationState.topLevelRoute == tab.route
SidebarItem(
label = tab.title,
activeIcon = tab.icon,
onClick = {
navigator.navigate(tab.route)
},
selected = selected,
expanded = expanded,
)
}
}
}
}
VerticalScrollbar(
listState = listState,
modifier = Modifier
.align(Alignment.CenterEnd)
.fillMaxHeight()
)
}
}
}
HorizontalDivider()
SidebarItem(
label = "Devices",
activeIcon = Iconsax.IconsaxMirroringScreen,
onClick = { navigator.navigate(Routes.Devices) },
selected = false,
label = "Settings",
activeIcon = Iconsax.IconsaxSetting2,
onClick = {
navigator.navigate(Routes.Settings)
},
selected = navigationState.topLevelRoute == Routes.Settings,
expanded = expanded,
)
SidebarItem(
label = "Group Jam",
activeIcon = Iconsax.User,
onClick = { navigator.navigate(Routes.Jam) },
selected = false,
expanded = expanded,
)
Spacer(modifier = Modifier.height(120.dp))
}
}
@ -209,7 +227,7 @@ fun SidebarItem(
showDownloadBadge: Boolean = false,
) {
val itemContent: @Composable RowScope.() -> Unit = {
Box(modifier = Modifier.size(20.dp)) {
Box(modifier = Modifier.size(16.dp)) {
Icon(
imageVector = activeIcon,
contentDescription = label,
@ -231,7 +249,7 @@ fun SidebarItem(
maxLines = 1,
softWrap = false,
style = LocalTextStyle.current.copy(
fontSize = MaterialTheme.typography.labelLarge.fontSize,
fontSize = MaterialTheme.typography.labelMedium.fontSize,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
color = if (selected) {
MaterialTheme.colorScheme.onSecondaryContainer
@ -246,8 +264,8 @@ fun SidebarItem(
val buttonModifier = Modifier
.fillMaxWidth()
.padding(horizontal = 10.dp, vertical = 4.dp)
val contentPadding = PaddingValues(horizontal = 7.dp, vertical = 6.dp)
.padding(horizontal = 7.dp, vertical = 2.dp)
val contentPadding = PaddingValues(horizontal = 5.dp)
if (selected) {
SecondaryButton(
@ -255,7 +273,7 @@ fun SidebarItem(
modifier = buttonModifier,
theme = LocalBaseUITheme.current.buttons.secondary
.copyPadding(contentPadding)
.copyShape(RoundedCornerShape(8.dp)),
.copyShape(RoundedCornerShape(5.dp)),
content = itemContent,
)
} else {
@ -264,7 +282,7 @@ fun SidebarItem(
modifier = buttonModifier,
theme = LocalBaseUITheme.current.buttons.outline
.copyPadding(contentPadding)
.copyShape(RoundedCornerShape(8.dp)),
.copyShape(RoundedCornerShape(5.dp)),
hoverOnly = true,
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.server.CacheEntry
import dev.krtirtho.spotube.core.server.CacheManager
import dev.krtirtho.spotube.core.server.MatchedTracksRepository
import dev.krtirtho.spotube.core.server.StreamingUrlRepository
import dev.krtirtho.spotube.core.server.TrackSourceRepository
import dev.krtirtho.spotube.core.server.normalizeManifestUrl
import dev.krtirtho.spotube.core.server.selectPreferredAudioStream
import dev.krtirtho.spotube.modules.plugin.AudioPluginSource
import dev.krtirtho.spotube.modules.settings.SettingsRepository
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
@ -72,10 +73,11 @@ data class PlayerOptionsUiState(
class PlayerOptionsViewModel(
private val audioPlayer: AudioPlayerInterface,
private val audioPlayerQueue: AudioPlayerQueue,
private val matchedTracksRepository: MatchedTracksRepository,
private val matchedTracksRepository: TrackSourceRepository,
private val streamingUrlRepository: StreamingUrlRepository,
private val cacheManager: CacheManager,
private val settingsRepository: SettingsRepository,
private val pluginManager: AudioPluginSource,
) : ViewModel() {
private val _uiState = MutableStateFlow(PlayerOptionsUiState())
val uiState: StateFlow<PlayerOptionsUiState> = _uiState.asStateFlow()
@ -155,7 +157,8 @@ class PlayerOptionsViewModel(
runCatching {
val settings = settingsRepository.userSettings.value
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 localCacheEntry = if (settings.enableMusicCaching) {
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
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)
close()
}
path(
fill = SolidColor(Color.White),
fillAlpha = 0.4f,
strokeAlpha = 0.4f
) {
path(fill = SolidColor(Color.White)) {
moveTo(7.5f, 19f)
curveTo(7.09f, 19f, 6.75f, 18.66f, 6.75f, 18.25f)
verticalLineTo(5.75f)
@ -80,11 +59,7 @@ val Iconsax.IconsaxSound: ImageVector
curveTo(12.75f, 21.16f, 12.41f, 21.5f, 12f, 21.5f)
close()
}
path(
fill = SolidColor(Color.White),
fillAlpha = 0.4f,
strokeAlpha = 0.4f
) {
path(fill = SolidColor(Color.White)) {
moveTo(16.5f, 19f)
curveTo(16.09f, 19f, 15.75f, 18.66f, 15.75f, 18.25f)
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(
private val metadataTrackAPI: MetadataTrackAPI = FakeMetadataTrackAPI()
private val metadataTrackAPI: MetadataTrackAPI = FakeMetadataTrackAPI(),
override val pluginId: String = "fake-plugin",
) : PluginService {
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.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
toolchainVendor=ADOPTIUM
toolchainVersion=21
toolchainVendor=JETBRAINS
toolchainVersion=21

View File

@ -46,8 +46,9 @@ kotlinx-serialization-json = "1.11.0"
materialKolor = "4.1.1"
mqttClient = "2.1.1"
mqttBuffer = "6.30.8"
multiplatformMarkdownRenderer = "0.45.0"
murmurhash = "0.4.2"
newpipeextractor = "v0.26.2"
newpipeextractor = "v0.26.5"
newpipeExtractorKmp = "1.3.0"
nucleusNucleusApplication = "2.4.6"
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-buffer = { module = "com.ditchoom:buffer", 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" }
newpipe-extractor-kmp = { module = "io.github.yushosei:newpipe-extractor-kmp", version.ref = "newpipeExtractorKmp" }
newpipeextractor = { module = "com.github.teamnewpipe:NewPipeExtractor", version.ref = "newpipeextractor" }