mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
Compare commits
8 Commits
b35ca20797
...
501f1259d4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
501f1259d4 | ||
|
|
3a85ad0896 | ||
|
|
046c17d3df | ||
|
|
2713e63902 | ||
|
|
86ec117cfd | ||
|
|
78168d269c | ||
|
|
50c3b04eb1 | ||
|
|
d48b0a2f65 |
2
.github/workflows/spotube-release-binary.yml
vendored
2
.github/workflows/spotube-release-binary.yml
vendored
@ -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 }}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
@ -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 } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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" }
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>()
|
||||
|
||||
@ -52,6 +52,7 @@ class PluginServiceScope(private val registry: Map<KClass<*>, ZiplineService>) {
|
||||
}
|
||||
|
||||
interface PluginService {
|
||||
val pluginId: String
|
||||
val loggedInFlow: StateFlow<Boolean>
|
||||
|
||||
suspend fun start()
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -78,7 +78,7 @@ fun AlbumScreen(
|
||||
}
|
||||
},
|
||||
onPlay = viewModel::playAlbum,
|
||||
onShufflePlay = {},
|
||||
onShufflePlay = viewModel::shufflePlayAlbum,
|
||||
onAddToQueue = viewModel::addAlbumToQueue,
|
||||
isPlaying = isAlbumPlaying,
|
||||
isFollowing = savedAlbumIds.contains(albumId),
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -70,7 +70,7 @@ fun SavedTracksScreen(
|
||||
ownerImageURL = null,
|
||||
onOwnerClick = {},
|
||||
onPlay = viewModel::playSavedTracks,
|
||||
onShufflePlay = {},
|
||||
onShufflePlay = viewModel::shufflePlaySavedTracks,
|
||||
onAddToQueue = viewModel::addSavedTracksToQueue,
|
||||
isPlaying = isPlaying,
|
||||
isFollowing = false,
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
) {
|
||||
|
||||
@ -87,6 +87,7 @@ data class UserSettings(
|
||||
|
||||
// Updates
|
||||
val autoCheckForUpdates: Boolean = true,
|
||||
val ignoredUpdateVersion: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
)
|
||||
@ -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
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
25
composeApp/src/commonMain/templates/AppBuildInfo.kt.template
Normal file
25
composeApp/src/commonMain/templates/AppBuildInfo.kt.template
Normal 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}"
|
||||
}
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
@ -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
|
||||
@ -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" }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user