mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-06 11:14:00 +00:00
Compare commits
8 Commits
53aa468fa3
...
c612e8bfb3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c612e8bfb3 | ||
|
|
3a85ad0896 | ||
|
|
046c17d3df | ||
|
|
2713e63902 | ||
|
|
86ec117cfd | ||
|
|
78168d269c | ||
|
|
50c3b04eb1 | ||
|
|
4dd0fee692 |
10
.github/workflows/spotube-release-binary.yml
vendored
10
.github/workflows/spotube-release-binary.yml
vendored
@ -60,7 +60,7 @@ jobs:
|
|||||||
path: vlcj-bundler
|
path: vlcj-bundler
|
||||||
|
|
||||||
- name: Set up JDK 21
|
- name: Set up JDK 21
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v5
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: 21
|
java-version: 21
|
||||||
@ -112,7 +112,7 @@ jobs:
|
|||||||
path: ~/.m2/repository/
|
path: ~/.m2/repository/
|
||||||
|
|
||||||
- name: Set up JDK 21
|
- name: Set up JDK 21
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v5
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: 21
|
java-version: 21
|
||||||
@ -179,7 +179,7 @@ jobs:
|
|||||||
sudo apt-get install -y rpm fuse
|
sudo apt-get install -y rpm fuse
|
||||||
|
|
||||||
- name: Set up JDK 21
|
- name: Set up JDK 21
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v5
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: 21
|
java-version: 21
|
||||||
@ -296,7 +296,7 @@ jobs:
|
|||||||
path: ~/.m2/repository/
|
path: ~/.m2/repository/
|
||||||
|
|
||||||
- name: Set up JDK 21
|
- name: Set up JDK 21
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v5
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: 21
|
java-version: 21
|
||||||
@ -342,7 +342,7 @@ jobs:
|
|||||||
path: ~/.m2/repository/
|
path: ~/.m2/repository/
|
||||||
|
|
||||||
- name: Set up JDK 21
|
- name: Set up JDK 21
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v5
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: 21
|
java-version: 21
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import gobley.gradle.cargo.dsl.jvm
|
|||||||
import dev.nucleusframework.desktop.application.dsl.TargetFormat
|
import dev.nucleusframework.desktop.application.dsl.TargetFormat
|
||||||
import org.jetbrains.compose.reload.gradle.ComposeHotRun
|
import org.jetbrains.compose.reload.gradle.ComposeHotRun
|
||||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||||
|
import org.gradle.api.tasks.Copy
|
||||||
import java.io.FileInputStream
|
import java.io.FileInputStream
|
||||||
import java.util.Properties
|
import java.util.Properties
|
||||||
|
|
||||||
@ -39,11 +40,53 @@ plugins {
|
|||||||
kotlin("plugin.atomicfu") version libs.versions.kotlin
|
kotlin("plugin.atomicfu") version libs.versions.kotlin
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val bundleId = "dev.krtirtho.spotube"
|
||||||
|
val version = "6.0.0"
|
||||||
|
val buildNumber = 46
|
||||||
|
val targetName = "Spotube"
|
||||||
|
val homePageUrl = "https://spotube.cc"
|
||||||
|
val repositoryUrl = "https://github.com/team-spotube/spotube"
|
||||||
|
val appVersionName = version
|
||||||
|
val appBuildNumber = buildNumber
|
||||||
|
val appRepositoryUrl = repositoryUrl
|
||||||
|
val githubRepositoryPath = repositoryUrl.removePrefix("https://github.com/")
|
||||||
|
val latestReleaseApiUrl = "https://api.github.com/repos/$githubRepositoryPath/releases/latest"
|
||||||
|
val generatedAppInfoDir = layout.buildDirectory.dir("generated/appBuildInfo/commonMain/kotlin")
|
||||||
|
val generateAppBuildInfo by tasks.registering(Copy::class) {
|
||||||
|
description = "Generate AppBuildInfo.kt"
|
||||||
|
from(layout.projectDirectory.file("src/commonMain/templates/AppBuildInfo.kt.template"))
|
||||||
|
into(layout.buildDirectory.dir("generated/appBuildInfo/commonMain/kotlin/dev/krtirtho/spotube"))
|
||||||
|
rename("AppBuildInfo.kt.template", "AppBuildInfo.kt")
|
||||||
|
expand(
|
||||||
|
mapOf(
|
||||||
|
"appVersion" to appVersionName,
|
||||||
|
"appBuildNumber" to appBuildNumber,
|
||||||
|
"repositoryUrl" to appRepositoryUrl,
|
||||||
|
"latestReleaseApiUrl" to latestReleaseApiUrl,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
inputs.properties(
|
||||||
|
mapOf(
|
||||||
|
"appVersion" to appVersionName,
|
||||||
|
"appBuildNumber" to appBuildNumber,
|
||||||
|
"repositoryUrl" to appRepositoryUrl,
|
||||||
|
"latestReleaseApiUrl" to latestReleaseApiUrl,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.configureEach {
|
||||||
|
if (name.startsWith("compile") && name.contains("Kotlin")) {
|
||||||
|
dependsOn(generateAppBuildInfo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
vlcjBundler {
|
vlcjBundler {
|
||||||
packageName = "dev.krtirtho.spotube.core.generated" // choose a different package
|
packageName = "$bundleId.core.generated" // choose a different package
|
||||||
objectName = "VLCBundleLoaderGenerated" // or rename the object
|
objectName = "VLCBundleLoaderGenerated" // or rename the object
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
// Note: For Android application modules, androidTarget() is still required as of AGP 8.x.
|
// Note: For Android application modules, androidTarget() is still required as of AGP 8.x.
|
||||||
// The deprecation warning is expected. For libraries, use the androidKotlinMultiplatformLibrary plugin instead.
|
// The deprecation warning is expected. For libraries, use the androidKotlinMultiplatformLibrary plugin instead.
|
||||||
@ -68,6 +111,7 @@ kotlin {
|
|||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
val commonMain by getting {
|
val commonMain by getting {
|
||||||
|
kotlin.srcDir(generatedAppInfoDir)
|
||||||
dependencies {
|
dependencies {
|
||||||
// Project dependencies
|
// Project dependencies
|
||||||
implementation(project(":plugin_interfaces"))
|
implementation(project(":plugin_interfaces"))
|
||||||
@ -172,6 +216,10 @@ kotlin {
|
|||||||
implementation(libs.mqtt.x.models)
|
implementation(libs.mqtt.x.models)
|
||||||
implementation(libs.mqtt.buffer)
|
implementation(libs.mqtt.buffer)
|
||||||
implementation(libs.mqtt.buffer.codec)
|
implementation(libs.mqtt.buffer.codec)
|
||||||
|
|
||||||
|
// Markdown renderer
|
||||||
|
implementation(libs.multiplatform.markdown.renderer)
|
||||||
|
implementation(libs.multiplatform.markdown.renderer.m3)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
commonTest.dependencies {
|
commonTest.dependencies {
|
||||||
@ -249,11 +297,11 @@ android {
|
|||||||
compileSdk = libs.versions.android.compileSdk.get().toInt()
|
compileSdk = libs.versions.android.compileSdk.get().toInt()
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "dev.krtirtho.spotube"
|
applicationId = bundleId
|
||||||
minSdk = libs.versions.android.minSdk.get().toInt()
|
minSdk = libs.versions.android.minSdk.get().toInt()
|
||||||
targetSdk = libs.versions.android.targetSdk.get().toInt()
|
targetSdk = libs.versions.android.targetSdk.get().toInt()
|
||||||
versionCode = 1
|
versionCode = buildNumber
|
||||||
versionName = project.findProperty("versionName") as String? ?: "1.0"
|
versionName = version
|
||||||
}
|
}
|
||||||
packaging {
|
packaging {
|
||||||
resources {
|
resources {
|
||||||
@ -312,8 +360,8 @@ nucleus.application {
|
|||||||
)
|
)
|
||||||
|
|
||||||
nativeDistributions {
|
nativeDistributions {
|
||||||
packageName = "dev.krtirtho.spotube"
|
packageName = bundleId
|
||||||
packageVersion = project.findProperty("versionName") as String? ?: "6.0.0"
|
packageVersion = version
|
||||||
licenseFile = project.file("../LICENSE")
|
licenseFile = project.file("../LICENSE")
|
||||||
|
|
||||||
appResourcesRootDir.set(vlcjBundler.vlcNativesDirectory)
|
appResourcesRootDir.set(vlcjBundler.vlcNativesDirectory)
|
||||||
@ -329,7 +377,7 @@ nucleus.application {
|
|||||||
|
|
||||||
modules("jdk.unsupported")
|
modules("jdk.unsupported")
|
||||||
|
|
||||||
homepage = "https://spotube.cc"
|
homepage = homePageUrl
|
||||||
|
|
||||||
linux {
|
linux {
|
||||||
modules("jdk.security.auth")
|
modules("jdk.security.auth")
|
||||||
@ -371,4 +419,4 @@ cargo {
|
|||||||
builds.jvm {
|
builds.jvm {
|
||||||
embedRustLibrary = (rustTarget == GobleyHost.current.rustTarget)
|
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.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.rememberBaseUITheme
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.navigation3.ui.NavDisplay
|
import androidx.navigation3.ui.NavDisplay
|
||||||
|
import com.mikepenz.markdown.m3.Markdown
|
||||||
import dev.krtirtho.spotube.core.navigation.Navigator
|
import dev.krtirtho.spotube.core.navigation.Navigator
|
||||||
import dev.krtirtho.spotube.core.navigation.Routes
|
import dev.krtirtho.spotube.core.navigation.Routes
|
||||||
import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES
|
import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES
|
||||||
import dev.krtirtho.spotube.core.navigation.rememberNavigationState
|
import dev.krtirtho.spotube.core.navigation.rememberNavigationState
|
||||||
import dev.krtirtho.spotube.core.navigation.toEntries
|
import dev.krtirtho.spotube.core.navigation.toEntries
|
||||||
|
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
|
||||||
|
import dev.krtirtho.spotube.core.ui.base.OutlineButton
|
||||||
|
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
|
||||||
|
import dev.krtirtho.spotube.core.ui.base.ThemedDialog
|
||||||
|
import dev.krtirtho.spotube.core.ui.base.rememberBaseUITheme
|
||||||
import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope
|
import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope
|
||||||
import dev.krtirtho.spotube.core.ui.theming.SpotubeTheme
|
import dev.krtirtho.spotube.core.ui.theming.SpotubeTheme
|
||||||
|
import dev.krtirtho.spotube.modules.library.LibraryTab
|
||||||
import dev.krtirtho.spotube.modules.settings.SettingsRepository
|
import dev.krtirtho.spotube.modules.settings.SettingsRepository
|
||||||
import dev.krtirtho.spotube.modules.settings.UserSettings
|
import dev.krtirtho.spotube.modules.settings.UserSettings
|
||||||
import dev.krtirtho.spotube.modules.shell.AppShell
|
import dev.krtirtho.spotube.modules.shell.AppShell
|
||||||
|
import dev.krtirtho.spotube.modules.update.UpdateCheckerViewModel
|
||||||
import dev.krtirtho.spotube.modules.webview.WebViewScreen
|
import dev.krtirtho.spotube.modules.webview.WebViewScreen
|
||||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||||
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxCd
|
||||||
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxHome
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxHome
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxHomeBroken
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxMirrorScreenRegular
|
||||||
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicDashboard
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicLibrary
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicLibrary
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicLibraryOutline
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxSearch
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxSearch
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxSetting2
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxSetting2
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxSettingTwotone
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxSoundTwotone
|
||||||
|
import dev.krtirtho.spotube.resources.iconsax.User
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
import org.koin.compose.navigation3.koinEntryProvider
|
import org.koin.compose.navigation3.koinEntryProvider
|
||||||
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
import org.koin.core.annotation.KoinExperimentalAPI
|
import org.koin.core.annotation.KoinExperimentalAPI
|
||||||
|
|
||||||
data class TabItem(
|
interface NavigationItem {
|
||||||
val title: String,
|
data class Tab<T : Any>(
|
||||||
val icon: ImageVector,
|
val title: String, val icon: ImageVector, val route: Routes,
|
||||||
val activeIcon: ImageVector,
|
val data: T? = null
|
||||||
val route: Routes
|
) : NavigationItem
|
||||||
|
|
||||||
|
data class Group<T : Any>(
|
||||||
|
val title: String, val items: List<Tab<T>>
|
||||||
|
) : NavigationItem
|
||||||
|
}
|
||||||
|
|
||||||
|
private val tabs = listOf<NavigationItem>(
|
||||||
|
NavigationItem.Tab<Any>("Home", Iconsax.IconsaxHome, Routes.Home),
|
||||||
|
NavigationItem.Tab<Any>("Search", Iconsax.IconsaxSearch, Routes.Search),
|
||||||
)
|
)
|
||||||
|
|
||||||
val tabs = listOf(
|
val sidebarTabs = tabs + listOf(
|
||||||
TabItem(
|
NavigationItem.Group(
|
||||||
"Home",
|
|
||||||
Iconsax.IconsaxHomeBroken,
|
|
||||||
Iconsax.IconsaxHome,
|
|
||||||
Routes.Home
|
|
||||||
),
|
|
||||||
TabItem(
|
|
||||||
"Search",
|
|
||||||
Iconsax.IconsaxSearchBroken,
|
|
||||||
Iconsax.IconsaxSearch,
|
|
||||||
Routes.Search
|
|
||||||
),
|
|
||||||
TabItem(
|
|
||||||
"Library",
|
"Library",
|
||||||
Iconsax.IconsaxMusicLibraryOutline,
|
items = listOf(
|
||||||
Iconsax.IconsaxMusicLibrary,
|
NavigationItem.Tab(
|
||||||
Routes.Library
|
"Playlists",
|
||||||
|
Iconsax.IconsaxMusicDashboard,
|
||||||
|
Routes.Library,
|
||||||
|
LibraryTab.Playlists
|
||||||
|
),
|
||||||
|
NavigationItem.Tab("Albums", Iconsax.IconsaxCd, Routes.Library, LibraryTab.Albums),
|
||||||
|
NavigationItem.Tab("Artists", Iconsax.User, Routes.Library, LibraryTab.Artists),
|
||||||
|
)
|
||||||
),
|
),
|
||||||
TabItem(
|
NavigationItem.Group(
|
||||||
"Settings",
|
"On Device",
|
||||||
Iconsax.IconsaxSettingTwotone,
|
items = listOf(
|
||||||
Iconsax.IconsaxSetting2,
|
NavigationItem.Tab(
|
||||||
Routes.Settings
|
"Local Tracks",
|
||||||
)
|
Iconsax.IconsaxMusicLibrary,
|
||||||
|
Routes.Library,
|
||||||
|
LibraryTab.LocalTracks
|
||||||
|
),
|
||||||
|
NavigationItem.Tab(
|
||||||
|
"Downloads",
|
||||||
|
Iconsax.IconsaxDirectboxReceive,
|
||||||
|
Routes.Library,
|
||||||
|
LibraryTab.Downloads
|
||||||
|
),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
NavigationItem.Group(
|
||||||
|
"Connect",
|
||||||
|
items = listOf(
|
||||||
|
NavigationItem.Tab("Devices", Iconsax.IconsaxMirrorScreenRegular, Routes.Devices),
|
||||||
|
NavigationItem.Tab("Group Jam", Iconsax.IconsaxSoundTwotone, Routes.Jam),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val bottombarTabs = tabs + listOf(
|
||||||
|
NavigationItem.Tab<Any>("Library", Iconsax.IconsaxMusicLibrary, Routes.Library),
|
||||||
|
NavigationItem.Tab("Settings", Iconsax.IconsaxSetting2, Routes.Settings)
|
||||||
)
|
)
|
||||||
|
|
||||||
@OptIn(
|
@OptIn(
|
||||||
@ -103,10 +143,11 @@ fun App(
|
|||||||
) {
|
) {
|
||||||
val settingsRepository: SettingsRepository = koinInject<SettingsRepository>()
|
val settingsRepository: SettingsRepository = koinInject<SettingsRepository>()
|
||||||
val userSettings by settingsRepository.userSettings.collectAsStateWithLifecycle(initialValue = UserSettings())
|
val userSettings by settingsRepository.userSettings.collectAsStateWithLifecycle(initialValue = UserSettings())
|
||||||
|
val updateCheckerViewModel: UpdateCheckerViewModel = koinViewModel()
|
||||||
|
val availableUpdate by updateCheckerViewModel.availableUpdate.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val navigationState = rememberNavigationState(
|
val navigationState = rememberNavigationState(
|
||||||
startRoute = Routes.Home,
|
startRoute = Routes.Home, topLevelRoutes = TOP_LEVEL_ROUTES
|
||||||
topLevelRoutes = TOP_LEVEL_ROUTES
|
|
||||||
)
|
)
|
||||||
val navigator = remember {
|
val navigator = remember {
|
||||||
Navigator(navigationState)
|
Navigator(navigationState)
|
||||||
@ -141,6 +182,43 @@ fun App(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
content()
|
content()
|
||||||
|
if (availableUpdate != null) {
|
||||||
|
val update = availableUpdate!!
|
||||||
|
ThemedDialog(
|
||||||
|
onDismissRequest = updateCheckerViewModel::dismissUpdate,
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = "Spotube ${update.tag} is available",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
OutlineButton(onClick = updateCheckerViewModel::ignoreUpdate) {
|
||||||
|
Text("Ignore")
|
||||||
|
}
|
||||||
|
PrimaryButton(
|
||||||
|
onClick = {
|
||||||
|
runCatching { openUrlInBrowser("https://spotube.cc/downloads/") }
|
||||||
|
updateCheckerViewModel.dismissUpdate()
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text("Update")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
if (update.releaseNotesMarkdown.isNotBlank()) {
|
||||||
|
SelectionContainer {
|
||||||
|
Markdown(
|
||||||
|
content = update.releaseNotesMarkdown,
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text("See what's new in this release on the Spotube downloads page.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,6 +37,7 @@ import dev.krtirtho.spotube.core.server.AlternativeTracksRepository
|
|||||||
import dev.krtirtho.spotube.core.server.CacheManager
|
import dev.krtirtho.spotube.core.server.CacheManager
|
||||||
import dev.krtirtho.spotube.core.server.LocalServer
|
import dev.krtirtho.spotube.core.server.LocalServer
|
||||||
import dev.krtirtho.spotube.core.server.MatchedTracksRepository
|
import dev.krtirtho.spotube.core.server.MatchedTracksRepository
|
||||||
|
import dev.krtirtho.spotube.core.server.TrackSourceRepository
|
||||||
import dev.krtirtho.spotube.core.server.StreamingUrlRepository
|
import dev.krtirtho.spotube.core.server.StreamingUrlRepository
|
||||||
import dev.krtirtho.spotube.core.webview.WebViewController
|
import dev.krtirtho.spotube.core.webview.WebViewController
|
||||||
import dev.krtirtho.spotube.modules.album.AlbumRepository
|
import dev.krtirtho.spotube.modules.album.AlbumRepository
|
||||||
@ -64,6 +65,7 @@ import dev.krtirtho.spotube.modules.library.playlist.LibraryPlaylistsViewModel
|
|||||||
import dev.krtirtho.spotube.modules.lyrics.LyricsViewModel
|
import dev.krtirtho.spotube.modules.lyrics.LyricsViewModel
|
||||||
import dev.krtirtho.spotube.modules.playlist.PlaylistRepository
|
import dev.krtirtho.spotube.modules.playlist.PlaylistRepository
|
||||||
import dev.krtirtho.spotube.modules.playlist.PlaylistViewModel
|
import dev.krtirtho.spotube.modules.playlist.PlaylistViewModel
|
||||||
|
import dev.krtirtho.spotube.modules.plugin.AudioPluginSource
|
||||||
import dev.krtirtho.spotube.modules.plugin.PluginManager
|
import dev.krtirtho.spotube.modules.plugin.PluginManager
|
||||||
import dev.krtirtho.spotube.modules.plugin.PluginProvider
|
import dev.krtirtho.spotube.modules.plugin.PluginProvider
|
||||||
import dev.krtirtho.spotube.modules.plugin.PluginViewModel
|
import dev.krtirtho.spotube.modules.plugin.PluginViewModel
|
||||||
@ -71,8 +73,10 @@ import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
|
|||||||
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel
|
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel
|
||||||
import dev.krtirtho.spotube.modules.search.SearchRepository
|
import dev.krtirtho.spotube.modules.search.SearchRepository
|
||||||
import dev.krtirtho.spotube.modules.search.SearchScreenViewModel
|
import dev.krtirtho.spotube.modules.search.SearchScreenViewModel
|
||||||
|
import dev.krtirtho.spotube.modules.update.UpdateCheckerViewModel
|
||||||
import dev.krtirtho.spotube.modules.settings.SettingsProvider
|
import dev.krtirtho.spotube.modules.settings.SettingsProvider
|
||||||
import dev.krtirtho.spotube.modules.settings.SettingsRepository
|
import dev.krtirtho.spotube.modules.settings.SettingsRepository
|
||||||
|
import dev.krtirtho.spotube.modules.settings.UserSettingsSource
|
||||||
import dev.krtirtho.spotube.modules.settings.SettingsViewModel
|
import dev.krtirtho.spotube.modules.settings.SettingsViewModel
|
||||||
import dev.krtirtho.spotube.modules.settings.JamSettingsViewModel
|
import dev.krtirtho.spotube.modules.settings.JamSettingsViewModel
|
||||||
import dev.krtirtho.spotube.modules.shell.AppShellViewModel
|
import dev.krtirtho.spotube.modules.shell.AppShellViewModel
|
||||||
@ -122,13 +126,17 @@ val sharedModules = module {
|
|||||||
viewModelOf(::LibraryLocalTracksViewModel)
|
viewModelOf(::LibraryLocalTracksViewModel)
|
||||||
|
|
||||||
// Plugin system
|
// Plugin system
|
||||||
singleOf(::PluginManager) { bind<PluginProvider>() }
|
singleOf(::PluginManager) {
|
||||||
|
bind<PluginProvider>()
|
||||||
|
bind<AudioPluginSource>()
|
||||||
|
}
|
||||||
viewModelOf(::PluginViewModel)
|
viewModelOf(::PluginViewModel)
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
singleOf(::SettingsRepository)
|
singleOf(::SettingsRepository) { bind<UserSettingsSource>() }
|
||||||
viewModelOf(::SettingsViewModel) { bind<SettingsProvider>() }
|
viewModelOf(::SettingsViewModel) { bind<SettingsProvider>() }
|
||||||
viewModelOf(::JamSettingsViewModel)
|
viewModelOf(::JamSettingsViewModel)
|
||||||
|
viewModelOf(::UpdateCheckerViewModel)
|
||||||
|
|
||||||
// Downloads
|
// Downloads
|
||||||
singleOf(::DownloadManager)
|
singleOf(::DownloadManager)
|
||||||
@ -226,8 +234,15 @@ val sharedModules = module {
|
|||||||
blacklistRepository = get(),
|
blacklistRepository = get(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
singleOf(::MatchedTracksRepository)
|
singleOf(::MatchedTracksRepository) { bind<TrackSourceRepository>() }
|
||||||
singleOf(::StreamingUrlRepository)
|
single {
|
||||||
|
StreamingUrlRepository(
|
||||||
|
pluginManager = get(),
|
||||||
|
audioPlayerQueue = get(),
|
||||||
|
matchedTracksRepository = get(),
|
||||||
|
settingsRepository = get(),
|
||||||
|
)
|
||||||
|
}
|
||||||
singleOf(::CacheManager)
|
singleOf(::CacheManager)
|
||||||
singleOf(::AlternativeTracksRepository)
|
singleOf(::AlternativeTracksRepository)
|
||||||
singleOf(::LocalServer) withOptions {
|
singleOf(::LocalServer) withOptions {
|
||||||
|
|||||||
@ -36,9 +36,9 @@ class CollectionPlaybackHelper(
|
|||||||
private val audioPlayerQueue: AudioPlayerQueue,
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
private val blacklistRepository: BlacklistRepository,
|
private val blacklistRepository: BlacklistRepository,
|
||||||
) {
|
) {
|
||||||
suspend fun playAlbum(albumId: String) {
|
suspend fun playAlbum(albumId: String, shuffle: Boolean = false) {
|
||||||
if (audioPlayerQueue.isAlbumPlaying(albumId)) return
|
if (!shuffle && audioPlayerQueue.isAlbumPlaying(albumId)) return
|
||||||
val entries = fetchAllAlbumTracks(albumId)
|
val entries = fetchAllAlbumTracks(albumId).let { if (shuffle) it.shuffled() else it }
|
||||||
if (entries.isNotEmpty()) {
|
if (entries.isNotEmpty()) {
|
||||||
audioPlayerQueue.load(
|
audioPlayerQueue.load(
|
||||||
entries = entries,
|
entries = entries,
|
||||||
@ -91,9 +91,9 @@ class CollectionPlaybackHelper(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun playPlaylist(playlistId: String) {
|
suspend fun playPlaylist(playlistId: String, shuffle: Boolean = false) {
|
||||||
if (audioPlayerQueue.isPlaylistPlaying(playlistId)) return
|
if (!shuffle && audioPlayerQueue.isPlaylistPlaying(playlistId)) return
|
||||||
val entries = fetchAllPlaylistTracks(playlistId)
|
val entries = fetchAllPlaylistTracks(playlistId).let { if (shuffle) it.shuffled() else it }
|
||||||
if (entries.isNotEmpty()) {
|
if (entries.isNotEmpty()) {
|
||||||
audioPlayerQueue.load(
|
audioPlayerQueue.load(
|
||||||
entries = entries,
|
entries = entries,
|
||||||
@ -146,9 +146,9 @@ class CollectionPlaybackHelper(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun playSavedTracks() {
|
suspend fun playSavedTracks(shuffle: Boolean = false) {
|
||||||
if (audioPlayerQueue.isSavedTracksPlaying()) return
|
if (!shuffle && audioPlayerQueue.isSavedTracksPlaying()) return
|
||||||
val entries = fetchAllSavedTracks()
|
val entries = fetchAllSavedTracks().let { if (shuffle) it.shuffled() else it }
|
||||||
if (entries.isNotEmpty()) {
|
if (entries.isNotEmpty()) {
|
||||||
audioPlayerQueue.load(
|
audioPlayerQueue.load(
|
||||||
entries = entries,
|
entries = entries,
|
||||||
|
|||||||
@ -172,7 +172,7 @@ class RemoteControlHandler(
|
|||||||
private suspend fun handleCommand(session: WebSocketServerSession, envelope: CommandEnvelope) {
|
private suspend fun handleCommand(session: WebSocketServerSession, envelope: CommandEnvelope) {
|
||||||
when (val command = envelope.command) {
|
when (val command = envelope.command) {
|
||||||
is RemoteControlCommand.Play -> {
|
is RemoteControlCommand.Play -> {
|
||||||
handleCollectionSource(command.source, RemoteCollectionAction.Play)
|
handleCollectionSource(command.source, RemoteCollectionAction.Play, command.shuffle)
|
||||||
}
|
}
|
||||||
is RemoteControlCommand.Pause -> {
|
is RemoteControlCommand.Pause -> {
|
||||||
audioPlayer.pause()
|
audioPlayer.pause()
|
||||||
@ -297,13 +297,17 @@ class RemoteControlHandler(
|
|||||||
* Resolves a `spotube://` collection source URI (playlist/album/artist top
|
* Resolves a `spotube://` collection source URI (playlist/album/artist top
|
||||||
* tracks/saved tracks) and applies the requested action on the remote queue.
|
* tracks/saved tracks) and applies the requested action on the remote queue.
|
||||||
*/
|
*/
|
||||||
private suspend fun handleCollectionSource(source: String, action: RemoteCollectionAction) {
|
private suspend fun handleCollectionSource(
|
||||||
|
source: String,
|
||||||
|
action: RemoteCollectionAction,
|
||||||
|
shuffle: Boolean = false,
|
||||||
|
) {
|
||||||
logger.d { "Remote collection $action for source: $source" }
|
logger.d { "Remote collection $action for source: $source" }
|
||||||
when {
|
when {
|
||||||
source.startsWith(COLLECTION_PLAYLIST_PREFIX) -> {
|
source.startsWith(COLLECTION_PLAYLIST_PREFIX) -> {
|
||||||
val id = source.removePrefix(COLLECTION_PLAYLIST_PREFIX)
|
val id = source.removePrefix(COLLECTION_PLAYLIST_PREFIX)
|
||||||
when (action) {
|
when (action) {
|
||||||
RemoteCollectionAction.Play -> collectionPlaybackHelper.playPlaylist(id)
|
RemoteCollectionAction.Play -> collectionPlaybackHelper.playPlaylist(id, shuffle)
|
||||||
RemoteCollectionAction.AddToQueue -> collectionPlaybackHelper.addPlaylistToQueue(id)
|
RemoteCollectionAction.AddToQueue -> collectionPlaybackHelper.addPlaylistToQueue(id)
|
||||||
RemoteCollectionAction.PlayNext -> collectionPlaybackHelper.playPlaylistNext(id)
|
RemoteCollectionAction.PlayNext -> collectionPlaybackHelper.playPlaylistNext(id)
|
||||||
}
|
}
|
||||||
@ -312,7 +316,7 @@ class RemoteControlHandler(
|
|||||||
source.startsWith(COLLECTION_ALBUM_PREFIX) -> {
|
source.startsWith(COLLECTION_ALBUM_PREFIX) -> {
|
||||||
val id = source.removePrefix(COLLECTION_ALBUM_PREFIX)
|
val id = source.removePrefix(COLLECTION_ALBUM_PREFIX)
|
||||||
when (action) {
|
when (action) {
|
||||||
RemoteCollectionAction.Play -> collectionPlaybackHelper.playAlbum(id)
|
RemoteCollectionAction.Play -> collectionPlaybackHelper.playAlbum(id, shuffle)
|
||||||
RemoteCollectionAction.AddToQueue -> collectionPlaybackHelper.addAlbumToQueue(id)
|
RemoteCollectionAction.AddToQueue -> collectionPlaybackHelper.addAlbumToQueue(id)
|
||||||
RemoteCollectionAction.PlayNext -> collectionPlaybackHelper.playAlbumNext(id)
|
RemoteCollectionAction.PlayNext -> collectionPlaybackHelper.playAlbumNext(id)
|
||||||
}
|
}
|
||||||
@ -329,7 +333,7 @@ class RemoteControlHandler(
|
|||||||
|
|
||||||
source == COLLECTION_SAVED_TRACKS -> {
|
source == COLLECTION_SAVED_TRACKS -> {
|
||||||
when (action) {
|
when (action) {
|
||||||
RemoteCollectionAction.Play -> collectionPlaybackHelper.playSavedTracks()
|
RemoteCollectionAction.Play -> collectionPlaybackHelper.playSavedTracks(shuffle)
|
||||||
RemoteCollectionAction.AddToQueue -> collectionPlaybackHelper.addSavedTracksToQueue()
|
RemoteCollectionAction.AddToQueue -> collectionPlaybackHelper.addSavedTracksToQueue()
|
||||||
RemoteCollectionAction.PlayNext -> {
|
RemoteCollectionAction.PlayNext -> {
|
||||||
// Saved tracks "play next" is not supported; add to queue instead
|
// Saved tracks "play next" is not supported; add to queue instead
|
||||||
@ -467,4 +471,4 @@ enum class ConnectionRequestResponse {
|
|||||||
Allow,
|
Allow,
|
||||||
AllowAlways,
|
AllowAlways,
|
||||||
Deny,
|
Deny,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,7 +25,7 @@ import kotlinx.serialization.Serializable
|
|||||||
sealed class RemoteControlCommand {
|
sealed class RemoteControlCommand {
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("play")
|
@SerialName("play")
|
||||||
data class Play(val source: String) : RemoteControlCommand()
|
data class Play(val source: String, val shuffle: Boolean = false) : RemoteControlCommand()
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("pause")
|
@SerialName("pause")
|
||||||
@ -147,4 +147,4 @@ data class RemoteQueueEntry(
|
|||||||
val album: String?,
|
val album: String?,
|
||||||
val coverUrl: String?,
|
val coverUrl: String?,
|
||||||
val durationMs: Long,
|
val durationMs: Long,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -64,6 +64,7 @@ sealed interface PlaybackDestinationRequest {
|
|||||||
val type: RemoteCollectionType,
|
val type: RemoteCollectionType,
|
||||||
val id: String,
|
val id: String,
|
||||||
val startTrack: MetadataTrack? = null,
|
val startTrack: MetadataTrack? = null,
|
||||||
|
val shuffle: Boolean = false,
|
||||||
) : PlaybackDestinationRequest
|
) : PlaybackDestinationRequest
|
||||||
|
|
||||||
data class Track(
|
data class Track(
|
||||||
@ -112,8 +113,9 @@ class RemotePlaybackController(
|
|||||||
id: String,
|
id: String,
|
||||||
title: String,
|
title: String,
|
||||||
startTrack: MetadataTrack? = null,
|
startTrack: MetadataTrack? = null,
|
||||||
|
shuffle: Boolean = false,
|
||||||
) {
|
) {
|
||||||
request(PlaybackDestinationRequest.Collection(title, PlaybackDestinationAction.Play, type, id, startTrack))
|
request(PlaybackDestinationRequest.Collection(title, PlaybackDestinationAction.Play, type, id, startTrack, shuffle))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun requestCollectionAddToQueue(type: RemoteCollectionType, id: String, title: String) {
|
fun requestCollectionAddToQueue(type: RemoteCollectionType, id: String, title: String) {
|
||||||
@ -239,7 +241,7 @@ class RemotePlaybackController(
|
|||||||
if (startTrack != null) {
|
if (startTrack != null) {
|
||||||
collectionPlaybackHelper.playPlaylistFromTrack(request.id, startTrack)
|
collectionPlaybackHelper.playPlaylistFromTrack(request.id, startTrack)
|
||||||
} else {
|
} else {
|
||||||
collectionPlaybackHelper.playPlaylist(request.id)
|
collectionPlaybackHelper.playPlaylist(request.id, request.shuffle)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -252,7 +254,7 @@ class RemotePlaybackController(
|
|||||||
if (startTrack != null) {
|
if (startTrack != null) {
|
||||||
collectionPlaybackHelper.playAlbumFromTrack(request.id, startTrack)
|
collectionPlaybackHelper.playAlbumFromTrack(request.id, startTrack)
|
||||||
} else {
|
} else {
|
||||||
collectionPlaybackHelper.playAlbum(request.id)
|
collectionPlaybackHelper.playAlbum(request.id, request.shuffle)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -271,7 +273,7 @@ class RemotePlaybackController(
|
|||||||
if (startTrack != null) {
|
if (startTrack != null) {
|
||||||
collectionPlaybackHelper.playSavedTracksFromTrack(startTrack)
|
collectionPlaybackHelper.playSavedTracksFromTrack(startTrack)
|
||||||
} else {
|
} else {
|
||||||
collectionPlaybackHelper.playSavedTracks()
|
collectionPlaybackHelper.playSavedTracks(request.shuffle)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -346,7 +348,7 @@ class RemotePlaybackController(
|
|||||||
RemoteCollectionType.SavedTracks -> "spotube://saved_tracks"
|
RemoteCollectionType.SavedTracks -> "spotube://saved_tracks"
|
||||||
}
|
}
|
||||||
when (request.action) {
|
when (request.action) {
|
||||||
PlaybackDestinationAction.Play -> RemoteControlCommand.Play(source)
|
PlaybackDestinationAction.Play -> RemoteControlCommand.Play(source, request.shuffle)
|
||||||
PlaybackDestinationAction.AddToQueue -> RemoteControlCommand.AddToQueue(source)
|
PlaybackDestinationAction.AddToQueue -> RemoteControlCommand.AddToQueue(source)
|
||||||
PlaybackDestinationAction.PlayNext -> RemoteControlCommand.PlayNext(source)
|
PlaybackDestinationAction.PlayNext -> RemoteControlCommand.PlayNext(source)
|
||||||
}
|
}
|
||||||
@ -385,4 +387,4 @@ class RemotePlaybackController(
|
|||||||
album?.id == other.album?.id &&
|
album?.id == other.album?.id &&
|
||||||
artists.map { it.id.ifBlank { it.name } } == other.artists.map { it.id.ifBlank { it.name } }
|
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.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||||
import dev.krtirtho.spotube.core.di.injectLogger
|
import dev.krtirtho.spotube.core.di.injectLogger
|
||||||
import dev.krtirtho.spotube.modules.plugin.PluginManager
|
import dev.krtirtho.spotube.modules.plugin.AudioPluginSource
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
|
|
||||||
class AlternativeTracksRepository(
|
class AlternativeTracksRepository(
|
||||||
private val pluginManager: PluginManager,
|
private val pluginManager: AudioPluginSource,
|
||||||
private val matchedTracksRepository: MatchedTracksRepository,
|
private val matchedTracksRepository: TrackSourceRepository,
|
||||||
private val streamingUrlRepository: StreamingUrlRepository,
|
private val streamingUrlRepository: StreamingUrlRepository,
|
||||||
private val audioPlayerQueue: AudioPlayerQueue,
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
private val cacheManager: CacheManager,
|
private val cacheManager: CacheManager,
|
||||||
@ -36,10 +36,6 @@ class AlternativeTracksRepository(
|
|||||||
|
|
||||||
suspend fun resolveAlternatives(track: MetadataTrack): List<AudioSource> {
|
suspend fun resolveAlternatives(track: MetadataTrack): List<AudioSource> {
|
||||||
val trackId = track.id
|
val trackId = track.id
|
||||||
streamingUrlRepository.getCachedAlternatives(trackId)?.let { cached ->
|
|
||||||
logger.v { "Using cached alternatives for track $trackId" }
|
|
||||||
return cached
|
|
||||||
}
|
|
||||||
|
|
||||||
val audioPlugin = pluginManager.selectedAudioPlugin.value
|
val audioPlugin = pluginManager.selectedAudioPlugin.value
|
||||||
if (audioPlugin == null) {
|
if (audioPlugin == null) {
|
||||||
@ -47,6 +43,11 @@ class AlternativeTracksRepository(
|
|||||||
return emptyList()
|
return emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
streamingUrlRepository.getCachedAlternatives(trackId, audioPlugin.pluginId)?.let { cached ->
|
||||||
|
logger.v { "Using cached alternatives for track $trackId" }
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
val sources = runCatching {
|
val sources = runCatching {
|
||||||
audioPlugin.use { audioAPI.getStreamsByTrack(track) }
|
audioPlugin.use { audioAPI.getStreamsByTrack(track) }
|
||||||
}.getOrElse { throwable ->
|
}.getOrElse { throwable ->
|
||||||
@ -59,24 +60,30 @@ class AlternativeTracksRepository(
|
|||||||
return emptyList()
|
return emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
streamingUrlRepository.cacheAlternatives(trackId, sources)
|
streamingUrlRepository.cacheAlternatives(trackId, sources, audioPlugin.pluginId)
|
||||||
logger.d { "Resolved ${sources.size} alternative sources for track $trackId" }
|
logger.d { "Resolved ${sources.size} alternative sources for track $trackId" }
|
||||||
return sources
|
return sources
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getActiveSourceId(track: MetadataTrack): String? {
|
suspend fun getActiveSourceId(track: MetadataTrack): String? {
|
||||||
return matchedTracksRepository.getTrackSource(track)?.id
|
val audioPlugin = pluginManager.selectedAudioPlugin.value ?: return null
|
||||||
|
return matchedTracksRepository.getTrackSource(track, audioPlugin.pluginId)?.id
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun selectAlternative(track: MetadataTrack, source: AudioSource) {
|
suspend fun selectAlternative(track: MetadataTrack, source: AudioSource) {
|
||||||
val trackId = track.id
|
val trackId = track.id
|
||||||
|
val audioPlugin = pluginManager.selectedAudioPlugin.value
|
||||||
|
if (audioPlugin == null) {
|
||||||
|
logger.w { "No audio plugin selected while selecting alternative for track $trackId" }
|
||||||
|
return
|
||||||
|
}
|
||||||
val basic = when (source) {
|
val basic = when (source) {
|
||||||
is AudioSource.Streamed -> source.toBasic()
|
is AudioSource.Streamed -> source.toBasic()
|
||||||
is AudioSource.Basic -> source
|
is AudioSource.Basic -> source
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.i { "Selecting alternative source for track $trackId: ${basic.id} (${basic.title})" }
|
logger.i { "Selecting alternative source for track $trackId: ${basic.id} (${basic.title})" }
|
||||||
matchedTracksRepository.saveTrackSource(track, basic)
|
matchedTracksRepository.saveTrackSource(track, basic, audioPlugin.pluginId)
|
||||||
streamingUrlRepository.invalidateCachedStreamUrl(trackId)
|
streamingUrlRepository.invalidateCachedStreamUrl(trackId)
|
||||||
streamingUrlRepository.invalidateCachedAlternatives(trackId)
|
streamingUrlRepository.invalidateCachedAlternatives(trackId)
|
||||||
cacheManager.invalidateCacheEntry(trackId)
|
cacheManager.invalidateCacheEntry(trackId)
|
||||||
|
|||||||
@ -24,23 +24,53 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
|||||||
import dev.krtirtho.spotube.core.db.Database
|
import dev.krtirtho.spotube.core.db.Database
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
class MatchedTracksRepository(private val database: Database) {
|
/**
|
||||||
suspend fun getTrackSource(track: MetadataTrack): AudioSource.Basic? {
|
* A matched [AudioSource.Basic] tagged with the id of the audio plugin that produced it.
|
||||||
|
* The [pluginId] tag lets [MatchedTracksRepository] detect and discard matches that were
|
||||||
|
* made by a plugin the user is no longer using, since a source id from one plugin (e.g. a
|
||||||
|
* YouTube video id) is meaningless - or worse, silently wrong - when handed to a different
|
||||||
|
* plugin's `getStreamsOfAudioSource`.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class PluginTaggedAudioSource(
|
||||||
|
val pluginId: String,
|
||||||
|
val source: AudioSource.Basic,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Persists (and looks up) the audio source matched to a [MetadataTrack] by a given plugin. */
|
||||||
|
interface TrackSourceRepository {
|
||||||
|
suspend fun getTrackSource(track: MetadataTrack, pluginId: String): AudioSource.Basic?
|
||||||
|
suspend fun saveTrackSource(track: MetadataTrack, source: AudioSource.Basic, pluginId: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
class MatchedTracksRepository(private val database: Database) : TrackSourceRepository {
|
||||||
|
/**
|
||||||
|
* Returns the previously matched source for [track], but only if it was produced by
|
||||||
|
* [pluginId]. A match saved by a different (or since-removed) plugin is treated as a
|
||||||
|
* cache miss rather than being handed to the wrong plugin.
|
||||||
|
*/
|
||||||
|
override suspend fun getTrackSource(track: MetadataTrack, pluginId: String): AudioSource.Basic? {
|
||||||
return database.matchedTracksDataStore.data.map { prefs ->
|
return database.matchedTracksDataStore.data.map { prefs ->
|
||||||
val json = prefs[stringPreferencesKey(track.id)]
|
val json = prefs[stringPreferencesKey(track.id)] ?: return@map null
|
||||||
if (json != null) {
|
val tagged = try {
|
||||||
Json.decodeFromString<AudioSource.Basic>(json as String)
|
Json.decodeFromString<PluginTaggedAudioSource>(json)
|
||||||
} else {
|
} catch (_: Exception) {
|
||||||
|
// Legacy entries stored before plugin-tagging was introduced; treat as
|
||||||
|
// unverifiable and force a fresh match instead of risking a wrong plugin.
|
||||||
return@map null
|
return@map null
|
||||||
}
|
}
|
||||||
|
if (tagged.pluginId != pluginId) return@map null
|
||||||
|
tagged.source
|
||||||
}.first()
|
}.first()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun saveTrackSource(track: MetadataTrack, source: AudioSource.Basic) {
|
override suspend fun saveTrackSource(track: MetadataTrack, source: AudioSource.Basic, pluginId: String) {
|
||||||
database.matchedTracksDataStore.edit { prefs ->
|
database.matchedTracksDataStore.edit { prefs ->
|
||||||
prefs[stringPreferencesKey(track.id)] = Json.encodeToString(source)
|
prefs[stringPreferencesKey(track.id)] =
|
||||||
|
Json.encodeToString(PluginTaggedAudioSource(pluginId, source))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -26,8 +26,8 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
|||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||||
import dev.krtirtho.spotube.core.di.injectLogger
|
import dev.krtirtho.spotube.core.di.injectLogger
|
||||||
import dev.krtirtho.spotube.modules.plugin.PluginManager
|
import dev.krtirtho.spotube.modules.plugin.AudioPluginSource
|
||||||
import dev.krtirtho.spotube.modules.settings.SettingsRepository
|
import dev.krtirtho.spotube.modules.settings.UserSettingsSource
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
@ -45,6 +45,15 @@ data class CachedStreamUrl(
|
|||||||
val selectedStream: AudioStream? = null,
|
val selectedStream: AudioStream? = null,
|
||||||
val preferredFormat: AudioFormat,
|
val preferredFormat: AudioFormat,
|
||||||
val preferredQuality: AudioQuality,
|
val preferredQuality: AudioQuality,
|
||||||
|
// The id of the audio plugin that produced this entry. Guards against a stale entry
|
||||||
|
// (e.g. a YouTube video URL) being served after the user switches to a different
|
||||||
|
// audio plugin, which would otherwise play back completely unrelated content.
|
||||||
|
val pluginId: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class CachedAlternatives(
|
||||||
|
val pluginId: String,
|
||||||
|
val sources: List<AudioSource>,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class StreamInfo(
|
data class StreamInfo(
|
||||||
@ -55,21 +64,18 @@ data class StreamInfo(
|
|||||||
)
|
)
|
||||||
|
|
||||||
class StreamingUrlRepository(
|
class StreamingUrlRepository(
|
||||||
private val pluginManager: PluginManager,
|
private val pluginManager: AudioPluginSource,
|
||||||
private val audioPlayerQueue: AudioPlayerQueue,
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
private val matchedTracksRepository: MatchedTracksRepository,
|
private val matchedTracksRepository: TrackSourceRepository,
|
||||||
private val settingsRepository: SettingsRepository,
|
private val settingsRepository: UserSettingsSource,
|
||||||
|
private val streamUrlCacheTtlMs: Long = 30.seconds.inWholeMilliseconds,
|
||||||
) : KoinComponent {
|
) : KoinComponent {
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val STREAM_URL_CACHE_TTL_MS = 30.seconds.inWholeMilliseconds
|
|
||||||
}
|
|
||||||
|
|
||||||
private val logger by injectLogger<StreamingUrlRepository>()
|
private val logger by injectLogger<StreamingUrlRepository>()
|
||||||
private val streamUrlCacheMutex = Mutex()
|
private val streamUrlCacheMutex = Mutex()
|
||||||
private val streamUrlCache = mutableMapOf<String, CachedStreamUrl>()
|
private val streamUrlCache = mutableMapOf<String, CachedStreamUrl>()
|
||||||
private val alternativesCacheMutex = Mutex()
|
private val alternativesCacheMutex = Mutex()
|
||||||
private val alternativesCache = mutableMapOf<String, List<AudioSource>>()
|
private val alternativesCache = mutableMapOf<String, CachedAlternatives>()
|
||||||
|
|
||||||
suspend fun resolveStreamInfo(
|
suspend fun resolveStreamInfo(
|
||||||
trackId: String,
|
trackId: String,
|
||||||
@ -95,19 +101,20 @@ class StreamingUrlRepository(
|
|||||||
val preferredFormat = settings.streamingMusicFormat
|
val preferredFormat = settings.streamingMusicFormat
|
||||||
val preferredQuality = settings.streamingMusicQuality
|
val preferredQuality = settings.streamingMusicQuality
|
||||||
|
|
||||||
if (!forceRefresh) {
|
|
||||||
getCachedStreamUrl(trackId, preferredFormat, preferredQuality)?.let { cached ->
|
|
||||||
logger.v { "Using cached stream URL for track $trackId" }
|
|
||||||
return cached
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val audioPlugin = pluginManager.selectedAudioPlugin.value
|
val audioPlugin = pluginManager.selectedAudioPlugin.value
|
||||||
if (audioPlugin == null) {
|
if (audioPlugin == null) {
|
||||||
logger.w { "No audio plugin selected while resolving stream for track $trackId" }
|
logger.w { "No audio plugin selected while resolving stream for track $trackId" }
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
val source = matchedTracksRepository.getTrackSource(track)
|
if (!forceRefresh) {
|
||||||
|
getCachedStreamUrl(trackId, preferredFormat, preferredQuality, audioPlugin.pluginId)?.let { cached ->
|
||||||
|
logger.v { "Using cached stream URL for track $trackId" }
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val source = matchedTracksRepository.getTrackSource(track, audioPlugin.pluginId)
|
||||||
|
|
||||||
if (source != null) {
|
if (source != null) {
|
||||||
logger.d { "Attempting stream resolution for track $trackId using cached source" }
|
logger.d { "Attempting stream resolution for track $trackId using cached source" }
|
||||||
@ -130,7 +137,7 @@ class StreamingUrlRepository(
|
|||||||
)
|
)
|
||||||
if (resolvedStream != null) {
|
if (resolvedStream != null) {
|
||||||
val info = resolvedStream.toStreamInfo()
|
val info = resolvedStream.toStreamInfo()
|
||||||
cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream)
|
cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId)
|
||||||
return info
|
return info
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -153,19 +160,19 @@ class StreamingUrlRepository(
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
cacheAlternatives(trackId, sources)
|
cacheAlternatives(trackId, sources, audioPlugin.pluginId)
|
||||||
|
|
||||||
val stream = runCatching {
|
val stream = runCatching {
|
||||||
audioPlugin.use {
|
audioPlugin.use {
|
||||||
sources.firstNotNullOfOrNull { src ->
|
sources.firstNotNullOfOrNull { src ->
|
||||||
when (src) {
|
when (src) {
|
||||||
is AudioSource.Streamed -> {
|
is AudioSource.Streamed -> {
|
||||||
matchedTracksRepository.saveTrackSource(track, src.toBasic())
|
matchedTracksRepository.saveTrackSource(track, src.toBasic(), audioPlugin.pluginId)
|
||||||
src
|
src
|
||||||
}
|
}
|
||||||
|
|
||||||
is AudioSource.Basic -> {
|
is AudioSource.Basic -> {
|
||||||
matchedTracksRepository.saveTrackSource(track, src)
|
matchedTracksRepository.saveTrackSource(track, src, audioPlugin.pluginId)
|
||||||
audioAPI.getStreamsOfAudioSource(src)
|
audioAPI.getStreamsOfAudioSource(src)
|
||||||
.firstOrNull()
|
.firstOrNull()
|
||||||
}
|
}
|
||||||
@ -190,7 +197,7 @@ class StreamingUrlRepository(
|
|||||||
)
|
)
|
||||||
if (resolvedStream != null) {
|
if (resolvedStream != null) {
|
||||||
val info = resolvedStream.toStreamInfo()
|
val info = resolvedStream.toStreamInfo()
|
||||||
cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream)
|
cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId)
|
||||||
return info
|
return info
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
@ -207,10 +214,15 @@ class StreamingUrlRepository(
|
|||||||
trackId: String,
|
trackId: String,
|
||||||
preferredFormat: AudioFormat,
|
preferredFormat: AudioFormat,
|
||||||
preferredQuality: AudioQuality,
|
preferredQuality: AudioQuality,
|
||||||
|
pluginId: String,
|
||||||
): StreamInfo? {
|
): StreamInfo? {
|
||||||
val now = Clock.System.now().toEpochMilliseconds()
|
val now = Clock.System.now().toEpochMilliseconds()
|
||||||
return streamUrlCacheMutex.withLock {
|
return streamUrlCacheMutex.withLock {
|
||||||
val cached = streamUrlCache[trackId] ?: return@withLock null
|
val cached = streamUrlCache[trackId] ?: return@withLock null
|
||||||
|
if (cached.pluginId != pluginId) {
|
||||||
|
logger.v { "Cached stream URL for track $trackId belongs to a different plugin; ignoring" }
|
||||||
|
return@withLock null
|
||||||
|
}
|
||||||
if (cached.preferredFormat != preferredFormat || cached.preferredQuality != preferredQuality) {
|
if (cached.preferredFormat != preferredFormat || cached.preferredQuality != preferredQuality) {
|
||||||
return@withLock null
|
return@withLock null
|
||||||
}
|
}
|
||||||
@ -229,8 +241,9 @@ class StreamingUrlRepository(
|
|||||||
preferredQuality: AudioQuality,
|
preferredQuality: AudioQuality,
|
||||||
source: AudioSource.Streamed,
|
source: AudioSource.Streamed,
|
||||||
selectedStream: AudioStream,
|
selectedStream: AudioStream,
|
||||||
|
pluginId: String,
|
||||||
) {
|
) {
|
||||||
val expiresAtMs = Clock.System.now().toEpochMilliseconds() + STREAM_URL_CACHE_TTL_MS
|
val expiresAtMs = Clock.System.now().toEpochMilliseconds() + streamUrlCacheTtlMs
|
||||||
streamUrlCacheMutex.withLock {
|
streamUrlCacheMutex.withLock {
|
||||||
streamUrlCache[trackId] = CachedStreamUrl(
|
streamUrlCache[trackId] = CachedStreamUrl(
|
||||||
url = info.url,
|
url = info.url,
|
||||||
@ -242,6 +255,7 @@ class StreamingUrlRepository(
|
|||||||
selectedStream = selectedStream,
|
selectedStream = selectedStream,
|
||||||
preferredFormat = preferredFormat,
|
preferredFormat = preferredFormat,
|
||||||
preferredQuality = preferredQuality,
|
preferredQuality = preferredQuality,
|
||||||
|
pluginId = pluginId,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -257,15 +271,21 @@ class StreamingUrlRepository(
|
|||||||
// Stream metadata is part of the same URL-cache entry and is removed with it.
|
// Stream metadata is part of the same URL-cache entry and is removed with it.
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getCachedAlternatives(trackId: String): List<AudioSource>? {
|
/** Returns cached alternatives only if they were produced by [pluginId]. */
|
||||||
|
suspend fun getCachedAlternatives(trackId: String, pluginId: String): List<AudioSource>? {
|
||||||
return alternativesCacheMutex.withLock {
|
return alternativesCacheMutex.withLock {
|
||||||
alternativesCache[trackId]
|
val cached = alternativesCache[trackId] ?: return@withLock null
|
||||||
|
if (cached.pluginId != pluginId) {
|
||||||
|
logger.v { "Cached alternatives for track $trackId belong to a different plugin; ignoring" }
|
||||||
|
return@withLock null
|
||||||
|
}
|
||||||
|
cached.sources
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun cacheAlternatives(trackId: String, sources: List<AudioSource>) {
|
suspend fun cacheAlternatives(trackId: String, sources: List<AudioSource>, pluginId: String) {
|
||||||
alternativesCacheMutex.withLock {
|
alternativesCacheMutex.withLock {
|
||||||
alternativesCache[trackId] = sources
|
alternativesCache[trackId] = CachedAlternatives(pluginId, sources)
|
||||||
logger.v { "Cached ${sources.size} alternative sources for track $trackId" }
|
logger.v { "Cached ${sources.size} alternative sources for track $trackId" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,7 +28,6 @@ import androidx.compose.foundation.hoverable
|
|||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||||
import androidx.compose.foundation.focusable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@ -70,7 +69,6 @@ import androidx.compose.ui.focus.onFocusChanged
|
|||||||
import androidx.compose.ui.input.key.Key
|
import androidx.compose.ui.input.key.Key
|
||||||
import androidx.compose.ui.input.key.KeyEvent
|
import androidx.compose.ui.input.key.KeyEvent
|
||||||
import androidx.compose.ui.input.key.key
|
import androidx.compose.ui.input.key.key
|
||||||
import androidx.compose.ui.input.key.onKeyEvent
|
|
||||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
@ -173,11 +171,9 @@ fun <T> AutocompleteTextField(
|
|||||||
val isHovered by interactionSource.collectIsHoveredAsState()
|
val isHovered by interactionSource.collectIsHoveredAsState()
|
||||||
|
|
||||||
var isMenuOpen by remember { mutableStateOf(false) }
|
var isMenuOpen by remember { mutableStateOf(false) }
|
||||||
var isMovingFocusToPopup by remember { mutableStateOf(false) }
|
|
||||||
var selectedIndex by remember { mutableIntStateOf(-1) }
|
var selectedIndex by remember { mutableIntStateOf(-1) }
|
||||||
var textFieldSize by remember { mutableStateOf(IntSize.Zero) }
|
var textFieldSize by remember { mutableStateOf(IntSize.Zero) }
|
||||||
val menuListState = rememberLazyListState()
|
val menuListState = rememberLazyListState()
|
||||||
val popupFocusRequester = remember { FocusRequester() }
|
|
||||||
val textFieldFocusRequester = remember { FocusRequester() }
|
val textFieldFocusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
val menuExpanded = isMenuOpen && items.isNotEmpty()
|
val menuExpanded = isMenuOpen && items.isNotEmpty()
|
||||||
@ -185,7 +181,6 @@ fun <T> AutocompleteTextField(
|
|||||||
LaunchedEffect(isFocused) {
|
LaunchedEffect(isFocused) {
|
||||||
if (isFocused) {
|
if (isFocused) {
|
||||||
isMenuOpen = true
|
isMenuOpen = true
|
||||||
isMovingFocusToPopup = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -291,7 +286,7 @@ fun <T> AutocompleteTextField(
|
|||||||
)
|
)
|
||||||
.focusRequester(textFieldFocusRequester)
|
.focusRequester(textFieldFocusRequester)
|
||||||
.onFocusChanged { state ->
|
.onFocusChanged { state ->
|
||||||
if (!state.isFocused && !isMovingFocusToPopup) {
|
if (!state.isFocused) {
|
||||||
isMenuOpen = false
|
isMenuOpen = false
|
||||||
selectedIndex = -1
|
selectedIndex = -1
|
||||||
}
|
}
|
||||||
@ -303,8 +298,6 @@ fun <T> AutocompleteTextField(
|
|||||||
if (!menuExpanded) return@onPreviewKeyEvent false
|
if (!menuExpanded) return@onPreviewKeyEvent false
|
||||||
when (event.key) {
|
when (event.key) {
|
||||||
Key.DirectionDown -> {
|
Key.DirectionDown -> {
|
||||||
isMovingFocusToPopup = true
|
|
||||||
popupFocusRequester.requestFocus()
|
|
||||||
navigate(1)
|
navigate(1)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@ -374,7 +367,7 @@ fun <T> AutocompleteTextField(
|
|||||||
Popup(
|
Popup(
|
||||||
alignment = Alignment.TopStart,
|
alignment = Alignment.TopStart,
|
||||||
offset = IntOffset(0, textFieldHeightPx + menuOffsetPx),
|
offset = IntOffset(0, textFieldHeightPx + menuOffsetPx),
|
||||||
properties = PopupProperties(focusable = true),
|
properties = PopupProperties(focusable = false),
|
||||||
onDismissRequest = {
|
onDismissRequest = {
|
||||||
selectedIndex = -1
|
selectedIndex = -1
|
||||||
isMenuOpen = false
|
isMenuOpen = false
|
||||||
@ -386,45 +379,7 @@ fun <T> AutocompleteTextField(
|
|||||||
.heightIn(max = menuMaxHeight)
|
.heightIn(max = menuMaxHeight)
|
||||||
.shadow(resolvedMenuTheme.shadowElevation, resolvedMenuTheme.shape)
|
.shadow(resolvedMenuTheme.shadowElevation, resolvedMenuTheme.shape)
|
||||||
.background(resolvedMenuTheme.background, resolvedMenuTheme.shape)
|
.background(resolvedMenuTheme.background, resolvedMenuTheme.shape)
|
||||||
.clip(resolvedMenuTheme.shape)
|
.clip(resolvedMenuTheme.shape),
|
||||||
.focusRequester(popupFocusRequester)
|
|
||||||
.focusable()
|
|
||||||
.onFocusChanged { state ->
|
|
||||||
if (state.isFocused) {
|
|
||||||
isMovingFocusToPopup = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onKeyEvent { event ->
|
|
||||||
when (event.key) {
|
|
||||||
Key.DirectionDown -> {
|
|
||||||
navigate(1); true
|
|
||||||
}
|
|
||||||
|
|
||||||
Key.DirectionUp -> {
|
|
||||||
navigate(-1); true
|
|
||||||
}
|
|
||||||
|
|
||||||
Key.Enter -> {
|
|
||||||
if (selectedIndex in items.indices) {
|
|
||||||
onItemSelected(items[selectedIndex])
|
|
||||||
selectedIndex = -1
|
|
||||||
isMenuOpen = false
|
|
||||||
textFieldFocusRequester.requestFocus()
|
|
||||||
return@onKeyEvent true
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
Key.Escape -> {
|
|
||||||
selectedIndex = -1
|
|
||||||
isMenuOpen = false
|
|
||||||
textFieldFocusRequester.requestFocus()
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = menuListState,
|
state = menuListState,
|
||||||
|
|||||||
@ -41,6 +41,7 @@ class BuiltInPluginService(
|
|||||||
private val pluginInfo: PluginEntry,
|
private val pluginInfo: PluginEntry,
|
||||||
) : PluginService, KoinComponent {
|
) : PluginService, KoinComponent {
|
||||||
|
|
||||||
|
override val pluginId: String = pluginInfo.id
|
||||||
private val loggedInStateFlow = MutableStateFlow(false)
|
private val loggedInStateFlow = MutableStateFlow(false)
|
||||||
override val loggedInFlow = loggedInStateFlow.asStateFlow()
|
override val loggedInFlow = loggedInStateFlow.asStateFlow()
|
||||||
val servicesRegistry = mutableMapOf<KClass<*>, ZiplineService>()
|
val servicesRegistry = mutableMapOf<KClass<*>, ZiplineService>()
|
||||||
|
|||||||
@ -52,6 +52,7 @@ class PluginServiceScope(private val registry: Map<KClass<*>, ZiplineService>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface PluginService {
|
interface PluginService {
|
||||||
|
val pluginId: String
|
||||||
val loggedInFlow: StateFlow<Boolean>
|
val loggedInFlow: StateFlow<Boolean>
|
||||||
|
|
||||||
suspend fun start()
|
suspend fun start()
|
||||||
|
|||||||
@ -105,6 +105,8 @@ open class ZiplinePluginService(
|
|||||||
private val pluginInfo: PluginEntry,
|
private val pluginInfo: PluginEntry,
|
||||||
) : PluginService, KoinComponent {
|
) : PluginService, KoinComponent {
|
||||||
|
|
||||||
|
override val pluginId: String = pluginInfo.id
|
||||||
|
|
||||||
// QuickJS compile() uses deep C-level recursion on the native thread stack.
|
// QuickJS compile() uses deep C-level recursion on the native thread stack.
|
||||||
// Zipline.create() sets maxStackSize to only 6 MiB, but compiling large JS modules
|
// Zipline.create() sets maxStackSize to only 6 MiB, but compiling large JS modules
|
||||||
// (e.g. kotlin-stdlib at ~491 KB) can exceed that during AST parsing.
|
// (e.g. kotlin-stdlib at ~491 KB) can exceed that during AST parsing.
|
||||||
|
|||||||
@ -78,7 +78,7 @@ fun AlbumScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onPlay = viewModel::playAlbum,
|
onPlay = viewModel::playAlbum,
|
||||||
onShufflePlay = {},
|
onShufflePlay = viewModel::shufflePlayAlbum,
|
||||||
onAddToQueue = viewModel::addAlbumToQueue,
|
onAddToQueue = viewModel::addAlbumToQueue,
|
||||||
isPlaying = isAlbumPlaying,
|
isPlaying = isAlbumPlaying,
|
||||||
isFollowing = savedAlbumIds.contains(albumId),
|
isFollowing = savedAlbumIds.contains(albumId),
|
||||||
|
|||||||
@ -224,6 +224,11 @@ class AlbumViewModel(
|
|||||||
remotePlaybackController.requestCollectionPlay(RemoteCollectionType.Album, albumId, title)
|
remotePlaybackController.requestCollectionPlay(RemoteCollectionType.Album, albumId, title)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun shufflePlayAlbum() {
|
||||||
|
val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album"
|
||||||
|
remotePlaybackController.requestCollectionPlay(RemoteCollectionType.Album, albumId, title, shuffle = true)
|
||||||
|
}
|
||||||
|
|
||||||
fun addAlbumToQueue() {
|
fun addAlbumToQueue() {
|
||||||
val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album"
|
val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album"
|
||||||
remotePlaybackController.requestCollectionAddToQueue(RemoteCollectionType.Album, albumId, title)
|
remotePlaybackController.requestCollectionAddToQueue(RemoteCollectionType.Album, albumId, title)
|
||||||
|
|||||||
@ -38,10 +38,16 @@ import androidx.compose.foundation.shape.CircleShape
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.PlainTooltip
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TooltipAnchorPosition
|
||||||
|
import androidx.compose.material3.TooltipBox
|
||||||
|
import androidx.compose.material3.TooltipDefaults
|
||||||
|
import androidx.compose.material3.rememberTooltipState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@ -81,6 +87,7 @@ import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
|
|||||||
import dev.krtirtho.spotube.core.ui.component.dragScrollable
|
import dev.krtirtho.spotube.core.ui.component.dragScrollable
|
||||||
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
|
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
|
||||||
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
|
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
|
||||||
|
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
|
||||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay
|
||||||
@ -134,7 +141,8 @@ fun ArtistScreen(
|
|||||||
state = listState,
|
state = listState,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(innerPadding),
|
.padding(innerPadding)
|
||||||
|
.padding(bottom = LocalAppShellBottomInset.current + 24.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
@ -495,6 +503,7 @@ private fun ArtistMeta(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun ArtistHeaderActions(
|
private fun ArtistHeaderActions(
|
||||||
isSaved: Boolean,
|
isSaved: Boolean,
|
||||||
@ -508,7 +517,12 @@ private fun ArtistHeaderActions(
|
|||||||
) {
|
) {
|
||||||
if (isSaved) {
|
if (isSaved) {
|
||||||
SecondaryButton(onClick = onFollowClick) {
|
SecondaryButton(onClick = onFollowClick) {
|
||||||
Text("Following", modifier = Modifier.width(65.dp), textAlign = TextAlign.Center)
|
Text(
|
||||||
|
"Following",
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
PrimaryButton(onClick = onFollowClick) {
|
PrimaryButton(onClick = onFollowClick) {
|
||||||
@ -516,20 +530,30 @@ private fun ArtistHeaderActions(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isBlacklisted) {
|
TooltipBox(
|
||||||
SecondaryIconButton(onClick = onBlacklistClick) {
|
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
|
||||||
Icon(
|
tooltip = {
|
||||||
imageVector = Iconsax.IconsaxUserRemove,
|
PlainTooltip {
|
||||||
contentDescription = "Remove from blacklist",
|
Text(if (isBlacklisted) "Remove from blacklist" else "Add to blacklist")
|
||||||
tint = MaterialTheme.colorScheme.error,
|
}
|
||||||
)
|
},
|
||||||
}
|
state = rememberTooltipState()
|
||||||
} else {
|
) {
|
||||||
SecondaryIconButton(onClick = onBlacklistClick) {
|
if (isBlacklisted) {
|
||||||
Icon(
|
SecondaryIconButton(onClick = onBlacklistClick) {
|
||||||
imageVector = Iconsax.IconsaxUserRemove,
|
Icon(
|
||||||
contentDescription = "Add to blacklist",
|
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
|
||||||
import compose.icons.feathericons.X
|
import compose.icons.feathericons.X
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsLine
|
import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsLine
|
||||||
|
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
|
||||||
|
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
|
||||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@ -70,6 +72,47 @@ fun LyricsScreen(
|
|||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
Scaffold(
|
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,
|
modifier = modifier,
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
Column(
|
Column(
|
||||||
@ -77,46 +120,9 @@ fun LyricsScreen(
|
|||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(innerPadding)
|
.padding(innerPadding)
|
||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = 16.dp)
|
||||||
|
.padding(bottom = LocalAppShellBottomInset.current + 24.dp)
|
||||||
) {
|
) {
|
||||||
Spacer(modifier = Modifier.height(16.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 {
|
when {
|
||||||
uiState.isLoading -> {
|
uiState.isLoading -> {
|
||||||
Box(
|
Box(
|
||||||
|
|||||||
@ -111,7 +111,7 @@ fun PlaylistScreen(
|
|||||||
ownerImageURL = ownerImageURL,
|
ownerImageURL = ownerImageURL,
|
||||||
onOwnerClick = {},
|
onOwnerClick = {},
|
||||||
onPlay = viewModel::playPlaylist,
|
onPlay = viewModel::playPlaylist,
|
||||||
onShufflePlay = {},
|
onShufflePlay = viewModel::shufflePlayPlaylist,
|
||||||
onAddToQueue = viewModel::addPlaylistToQueue,
|
onAddToQueue = viewModel::addPlaylistToQueue,
|
||||||
isPlaying = isPlaying,
|
isPlaying = isPlaying,
|
||||||
isFollowing = savedPlaylistIds.contains(playlistId),
|
isFollowing = savedPlaylistIds.contains(playlistId),
|
||||||
|
|||||||
@ -230,6 +230,11 @@ class PlaylistViewModel(
|
|||||||
remotePlaybackController.requestCollectionPlay(RemoteCollectionType.Playlist, playlistId, title)
|
remotePlaybackController.requestCollectionPlay(RemoteCollectionType.Playlist, playlistId, title)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun shufflePlayPlaylist() {
|
||||||
|
val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist"
|
||||||
|
remotePlaybackController.requestCollectionPlay(RemoteCollectionType.Playlist, playlistId, title, shuffle = true)
|
||||||
|
}
|
||||||
|
|
||||||
fun addPlaylistToQueue() {
|
fun addPlaylistToQueue() {
|
||||||
val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist"
|
val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist"
|
||||||
remotePlaybackController.requestCollectionAddToQueue(RemoteCollectionType.Playlist, playlistId, title)
|
remotePlaybackController.requestCollectionAddToQueue(RemoteCollectionType.Playlist, playlistId, title)
|
||||||
|
|||||||
@ -69,11 +69,18 @@ interface PluginProvider {
|
|||||||
val selectedMetadataPlugin: StateFlow<PluginService?>
|
val selectedMetadataPlugin: StateFlow<PluginService?>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Narrow read-only view of the active audio plugin, used by consumers that only need to
|
||||||
|
* key caches or make calls against the currently selected audio plugin without depending
|
||||||
|
* on the full [PluginManager] (which also owns plugin installation/lifecycle machinery). */
|
||||||
|
interface AudioPluginSource {
|
||||||
|
val selectedAudioPlugin: StateFlow<PluginService?>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class PluginManager(
|
class PluginManager(
|
||||||
val database: Database,
|
val database: Database,
|
||||||
val paths: Paths,
|
val paths: Paths,
|
||||||
) : KoinComponent, PluginProvider {
|
) : KoinComponent, PluginProvider, AudioPluginSource {
|
||||||
private val logger by injectLogger<PluginManager>()
|
private val logger by injectLogger<PluginManager>()
|
||||||
private val pluginExceptionHandler = CoroutineExceptionHandler { _, exception ->
|
private val pluginExceptionHandler = CoroutineExceptionHandler { _, exception ->
|
||||||
logger.e(exception) { "Plugin runtime threw an unhandled exception. Intercepted safely." }
|
logger.e(exception) { "Plugin runtime threw an unhandled exception. Intercepted safely." }
|
||||||
@ -245,7 +252,7 @@ class PluginManager(
|
|||||||
|
|
||||||
override val selectedMetadataPlugin =
|
override val selectedMetadataPlugin =
|
||||||
filterSelectedPluginByType(PluginAbility.METADATA)
|
filterSelectedPluginByType(PluginAbility.METADATA)
|
||||||
val selectedAudioPlugin =
|
override val selectedAudioPlugin =
|
||||||
filterSelectedPluginByType(PluginAbility.AUDIO)
|
filterSelectedPluginByType(PluginAbility.AUDIO)
|
||||||
val selectedLyricsPlugin =
|
val selectedLyricsPlugin =
|
||||||
filterSelectedPluginByType(PluginAbility.LYRICS)
|
filterSelectedPluginByType(PluginAbility.LYRICS)
|
||||||
|
|||||||
@ -70,7 +70,7 @@ fun SavedTracksScreen(
|
|||||||
ownerImageURL = null,
|
ownerImageURL = null,
|
||||||
onOwnerClick = {},
|
onOwnerClick = {},
|
||||||
onPlay = viewModel::playSavedTracks,
|
onPlay = viewModel::playSavedTracks,
|
||||||
onShufflePlay = {},
|
onShufflePlay = viewModel::shufflePlaySavedTracks,
|
||||||
onAddToQueue = viewModel::addSavedTracksToQueue,
|
onAddToQueue = viewModel::addSavedTracksToQueue,
|
||||||
isPlaying = isPlaying,
|
isPlaying = isPlaying,
|
||||||
isFollowing = false,
|
isFollowing = false,
|
||||||
|
|||||||
@ -206,6 +206,15 @@ class SavedTracksViewModel(
|
|||||||
remotePlaybackController.requestCollectionPlay(RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, "Saved Tracks")
|
remotePlaybackController.requestCollectionPlay(RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, "Saved Tracks")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun shufflePlaySavedTracks() {
|
||||||
|
remotePlaybackController.requestCollectionPlay(
|
||||||
|
RemoteCollectionType.SavedTracks,
|
||||||
|
SAVED_TRACKS_COLLECTION_ID,
|
||||||
|
"Saved Tracks",
|
||||||
|
shuffle = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fun addSavedTracksToQueue() {
|
fun addSavedTracksToQueue() {
|
||||||
remotePlaybackController.requestCollectionAddToQueue(RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, "Saved Tracks")
|
remotePlaybackController.requestCollectionAddToQueue(RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, "Saved Tracks")
|
||||||
}
|
}
|
||||||
@ -377,4 +386,4 @@ fun rememberIsSavedTracks(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
|
|||||||
@ -39,18 +39,19 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@ -84,11 +85,10 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
|||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUser
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUser
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
|
||||||
import dev.krtirtho.spotube.core.navigation.Routes
|
|
||||||
import dev.krtirtho.spotube.core.jam.JamRole
|
import dev.krtirtho.spotube.core.jam.JamRole
|
||||||
import dev.krtirtho.spotube.core.jam.JamRoomService
|
import dev.krtirtho.spotube.core.jam.JamRoomService
|
||||||
import org.koin.compose.koinInject
|
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||||
|
import dev.krtirtho.spotube.core.navigation.Routes
|
||||||
import dev.krtirtho.spotube.core.remote.RemotePlaybackController
|
import dev.krtirtho.spotube.core.remote.RemotePlaybackController
|
||||||
import dev.krtirtho.spotube.core.share.ShareService
|
import dev.krtirtho.spotube.core.share.ShareService
|
||||||
import dev.krtirtho.spotube.core.ui.base.AutocompleteTextField
|
import dev.krtirtho.spotube.core.ui.base.AutocompleteTextField
|
||||||
@ -103,6 +103,7 @@ import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
|
|||||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
|
import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
|
||||||
import dev.krtirtho.spotube.core.ui.component.UserCard
|
import dev.krtirtho.spotube.core.ui.component.UserCard
|
||||||
import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
|
import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
|
||||||
|
import dev.krtirtho.spotube.core.ui.component.dragScrollable
|
||||||
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
|
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
|
||||||
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
|
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
|
||||||
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
|
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
|
||||||
@ -624,8 +625,10 @@ private fun SearchTabs(
|
|||||||
selectedType: MetadataSupportedSearchType?,
|
selectedType: MetadataSupportedSearchType?,
|
||||||
onTabSelected: (MetadataSupportedSearchType) -> Unit,
|
onTabSelected: (MetadataSupportedSearchType) -> Unit,
|
||||||
) {
|
) {
|
||||||
|
val listState = rememberLazyListState()
|
||||||
LazyRow(
|
LazyRow(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
state = listState,
|
||||||
|
modifier = Modifier.fillMaxWidth().dragScrollable(listState),
|
||||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 4.dp),
|
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 4.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
) {
|
) {
|
||||||
|
|||||||
@ -87,6 +87,7 @@ data class UserSettings(
|
|||||||
|
|
||||||
// Updates
|
// Updates
|
||||||
val autoCheckForUpdates: Boolean = true,
|
val autoCheckForUpdates: Boolean = true,
|
||||||
|
val ignoredUpdateVersion: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -27,14 +27,21 @@ import kotlinx.coroutines.SupervisorJob
|
|||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
class SettingsRepository(private val database: Database) {
|
/** Narrow read-only view over the current [UserSettings], for consumers (like
|
||||||
|
* [dev.krtirtho.spotube.core.server.StreamingUrlRepository]) that only need to read the
|
||||||
|
* user's streaming preferences without depending on the full [SettingsRepository]. */
|
||||||
|
interface UserSettingsSource {
|
||||||
|
val userSettings: StateFlow<UserSettings>
|
||||||
|
}
|
||||||
|
|
||||||
|
class SettingsRepository(private val database: Database) : UserSettingsSource {
|
||||||
companion object {
|
companion object {
|
||||||
private val SETTINGS_KEY = stringPreferencesKey("user_settings")
|
private val SETTINGS_KEY = stringPreferencesKey("user_settings")
|
||||||
}
|
}
|
||||||
|
|
||||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
val userSettings: StateFlow<UserSettings> = database.settingsDataStore.data.map { prefs ->
|
override val userSettings: StateFlow<UserSettings> = database.settingsDataStore.data.map { prefs ->
|
||||||
val json = prefs[SETTINGS_KEY]
|
val json = prefs[SETTINGS_KEY]
|
||||||
if (json != null) {
|
if (json != null) {
|
||||||
Json.decodeFromString<UserSettings>(json as String)
|
Json.decodeFromString<UserSettings>(json as String)
|
||||||
@ -52,4 +59,10 @@ class SettingsRepository(private val database: Database) {
|
|||||||
prefs[SETTINGS_KEY] = Json.encodeToString(newSettings)
|
prefs[SETTINGS_KEY] = Json.encodeToString(newSettings)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
suspend fun getCurrentSettings(): UserSettings {
|
||||||
|
val preferences = database.settingsDataStore.data.first()
|
||||||
|
val settingsJson = preferences[SETTINGS_KEY] ?: return UserSettings()
|
||||||
|
return Json.decodeFromString<UserSettings>(settingsJson)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -42,11 +42,12 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import dev.krtirtho.spotube.NavigationItem
|
||||||
|
import dev.krtirtho.spotube.bottombarTabs
|
||||||
import dev.krtirtho.spotube.core.navigation.NavigationState
|
import dev.krtirtho.spotube.core.navigation.NavigationState
|
||||||
import dev.krtirtho.spotube.core.navigation.Navigator
|
import dev.krtirtho.spotube.core.navigation.Navigator
|
||||||
import dev.krtirtho.spotube.core.navigation.Routes
|
import dev.krtirtho.spotube.core.navigation.Routes
|
||||||
import dev.krtirtho.spotube.modules.downloads.DownloadBadgeIndicator
|
import dev.krtirtho.spotube.modules.downloads.DownloadBadgeIndicator
|
||||||
import dev.krtirtho.spotube.tabs
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AppBottombar(
|
fun AppBottombar(
|
||||||
@ -68,46 +69,47 @@ fun AppBottombar(
|
|||||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
tabs.forEach { (label, icon, activeIcon, screen) ->
|
bottombarTabs.filterIsInstance<NavigationItem.Tab<*>>()
|
||||||
val selected = navigationState.topLevelRoute == screen
|
.forEach { tab ->
|
||||||
val labelColor = if (selected) {
|
val selected = navigationState.topLevelRoute == tab.route
|
||||||
MaterialTheme.colorScheme.primary
|
val labelColor = if (selected) {
|
||||||
} else {
|
MaterialTheme.colorScheme.primary
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
} else {
|
||||||
}
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
}
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
Column(
|
||||||
.clip(CircleShape)
|
modifier = Modifier
|
||||||
.clickable { navigator.navigate(screen) }
|
.clip(CircleShape)
|
||||||
.padding(8.dp)
|
.clickable { navigator.navigate(tab.route) }
|
||||||
.size(56.dp),
|
.padding(8.dp)
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
.size(56.dp),
|
||||||
verticalArrangement = Arrangement.Center,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
verticalArrangement = Arrangement.Center,
|
||||||
Box {
|
) {
|
||||||
Icon(
|
Box {
|
||||||
imageVector = activeIcon,
|
Icon(
|
||||||
contentDescription = label,
|
imageVector = tab.icon,
|
||||||
tint = if (selected) MaterialTheme.colorScheme.primary
|
contentDescription = tab.title,
|
||||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
tint = if (selected) MaterialTheme.colorScheme.primary
|
||||||
modifier = Modifier.size(20.dp)
|
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
modifier = Modifier.size(20.dp)
|
||||||
if (screen == Routes.Library) {
|
)
|
||||||
DownloadBadgeIndicator(
|
if (tab.route == Routes.Library) {
|
||||||
modifier = Modifier.align(Alignment.TopEnd)
|
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.RowScope
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.LocalTextStyle
|
import androidx.compose.material3.LocalTextStyle
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@ -55,6 +59,7 @@ import dev.chrisbanes.haze.HazeState
|
|||||||
import dev.chrisbanes.haze.blur.HazeColorEffect
|
import dev.chrisbanes.haze.blur.HazeColorEffect
|
||||||
import dev.chrisbanes.haze.blur.blurEffect
|
import dev.chrisbanes.haze.blur.blurEffect
|
||||||
import dev.chrisbanes.haze.hazeEffect
|
import dev.chrisbanes.haze.hazeEffect
|
||||||
|
import dev.krtirtho.spotube.NavigationItem
|
||||||
import dev.krtirtho.spotube.core.navigation.NavigationState
|
import dev.krtirtho.spotube.core.navigation.NavigationState
|
||||||
import dev.krtirtho.spotube.core.navigation.Navigator
|
import dev.krtirtho.spotube.core.navigation.Navigator
|
||||||
import dev.krtirtho.spotube.core.navigation.Routes
|
import dev.krtirtho.spotube.core.navigation.Routes
|
||||||
@ -64,15 +69,15 @@ import dev.krtirtho.spotube.core.ui.base.OutlineButton
|
|||||||
import dev.krtirtho.spotube.core.ui.base.SecondaryButton
|
import dev.krtirtho.spotube.core.ui.base.SecondaryButton
|
||||||
import dev.krtirtho.spotube.core.ui.base.copyPadding
|
import dev.krtirtho.spotube.core.ui.base.copyPadding
|
||||||
import dev.krtirtho.spotube.core.ui.base.copyShape
|
import dev.krtirtho.spotube.core.ui.base.copyShape
|
||||||
|
import dev.krtirtho.spotube.core.ui.component.VerticalScrollbar
|
||||||
import dev.krtirtho.spotube.modules.downloads.DownloadBadgeIndicator
|
import dev.krtirtho.spotube.modules.downloads.DownloadBadgeIndicator
|
||||||
import dev.krtirtho.spotube.modules.library.LibraryState
|
import dev.krtirtho.spotube.modules.library.LibraryState
|
||||||
import dev.krtirtho.spotube.modules.library.LibraryTab
|
import dev.krtirtho.spotube.modules.library.LibraryTab
|
||||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxSetting2
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxSidebarLeftBroken
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxSidebarLeftBroken
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxSidebarRightBroken
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxSidebarRightBroken
|
||||||
import dev.krtirtho.spotube.resources.iconsax.User
|
import dev.krtirtho.spotube.sidebarTabs
|
||||||
import dev.krtirtho.spotube.tabs
|
|
||||||
import org.jetbrains.compose.resources.Font
|
import org.jetbrains.compose.resources.Font
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
import spotube.composeapp.generated.resources.Res
|
import spotube.composeapp.generated.resources.Res
|
||||||
@ -90,6 +95,7 @@ fun AppSidebar(
|
|||||||
val width by animateDpAsState(targetValue = if (expanded) 236.dp else 86.dp)
|
val width by animateDpAsState(targetValue = if (expanded) 236.dp else 86.dp)
|
||||||
val currentLibraryTab by libraryState.currentTab.collectAsState()
|
val currentLibraryTab by libraryState.currentTab.collectAsState()
|
||||||
val surfaceTint = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f)
|
val surfaceTint = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f)
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
@ -106,95 +112,107 @@ fun AppSidebar(
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(14.dp),
|
.padding(14.dp),
|
||||||
horizontalArrangement = if (expanded) Arrangement.SpaceBetween else Arrangement.Center,
|
horizontalArrangement = if (expanded) Arrangement.SpaceBetween else Arrangement.Center,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
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 }
|
|
||||||
) {
|
) {
|
||||||
Icon(
|
AnimatedVisibility(visible = expanded, enter = fadeIn(), exit = fadeOut()) {
|
||||||
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) {
|
|
||||||
Text(
|
Text(
|
||||||
text = "LIBRARY",
|
text = "Spotube",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.headlineLarge.copy(
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
fontFamily = FontFamily(
|
||||||
modifier = Modifier.fillMaxWidth()
|
listOf(
|
||||||
.padding(horizontal = 24.dp, vertical = 8.dp)
|
Font(Res.font.cookie_regular, weight = FontWeight.Normal)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
GhostIconButton(
|
||||||
LibraryTab.entries.forEach { tab ->
|
onClick = { expanded = !expanded }
|
||||||
val isSubSelected = selected && currentLibraryTab == tab
|
) {
|
||||||
|
Icon(
|
||||||
SidebarItem(
|
imageVector = if (expanded) Iconsax.IconsaxSidebarLeftBroken else Iconsax.IconsaxSidebarRightBroken,
|
||||||
label = tab.title,
|
contentDescription = if (expanded) "Collapse sidebar" else "Expand sidebar",
|
||||||
activeIcon = tab.icon,
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
onClick = {
|
|
||||||
libraryState.currentTab.value = tab
|
|
||||||
navigator.navigate(screen)
|
|
||||||
},
|
|
||||||
selected = isSubSelected,
|
|
||||||
expanded = expanded,
|
|
||||||
showDownloadBadge = tab == LibraryTab.Downloads,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
SidebarItem(
|
|
||||||
label = label,
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
activeIcon = activeIcon,
|
|
||||||
onClick = { navigator.navigate(screen) },
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
selected = selected,
|
LazyColumn(state = listState) {
|
||||||
expanded = expanded,
|
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(
|
SidebarItem(
|
||||||
label = "Devices",
|
label = "Settings",
|
||||||
activeIcon = Iconsax.IconsaxMirroringScreen,
|
activeIcon = Iconsax.IconsaxSetting2,
|
||||||
onClick = { navigator.navigate(Routes.Devices) },
|
onClick = {
|
||||||
selected = false,
|
navigator.navigate(Routes.Settings)
|
||||||
|
},
|
||||||
|
selected = navigationState.topLevelRoute == Routes.Settings,
|
||||||
expanded = expanded,
|
expanded = expanded,
|
||||||
)
|
)
|
||||||
SidebarItem(
|
|
||||||
label = "Group Jam",
|
|
||||||
activeIcon = Iconsax.User,
|
|
||||||
onClick = { navigator.navigate(Routes.Jam) },
|
|
||||||
selected = false,
|
|
||||||
expanded = expanded,
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(120.dp))
|
Spacer(modifier = Modifier.height(120.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -209,7 +227,7 @@ fun SidebarItem(
|
|||||||
showDownloadBadge: Boolean = false,
|
showDownloadBadge: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val itemContent: @Composable RowScope.() -> Unit = {
|
val itemContent: @Composable RowScope.() -> Unit = {
|
||||||
Box(modifier = Modifier.size(20.dp)) {
|
Box(modifier = Modifier.size(16.dp)) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = activeIcon,
|
imageVector = activeIcon,
|
||||||
contentDescription = label,
|
contentDescription = label,
|
||||||
@ -231,7 +249,7 @@ fun SidebarItem(
|
|||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
softWrap = false,
|
softWrap = false,
|
||||||
style = LocalTextStyle.current.copy(
|
style = LocalTextStyle.current.copy(
|
||||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
fontSize = MaterialTheme.typography.labelMedium.fontSize,
|
||||||
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
|
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
|
||||||
color = if (selected) {
|
color = if (selected) {
|
||||||
MaterialTheme.colorScheme.onSecondaryContainer
|
MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
@ -246,8 +264,8 @@ fun SidebarItem(
|
|||||||
|
|
||||||
val buttonModifier = Modifier
|
val buttonModifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 10.dp, vertical = 4.dp)
|
.padding(horizontal = 7.dp, vertical = 2.dp)
|
||||||
val contentPadding = PaddingValues(horizontal = 7.dp, vertical = 6.dp)
|
val contentPadding = PaddingValues(horizontal = 5.dp)
|
||||||
|
|
||||||
if (selected) {
|
if (selected) {
|
||||||
SecondaryButton(
|
SecondaryButton(
|
||||||
@ -255,7 +273,7 @@ fun SidebarItem(
|
|||||||
modifier = buttonModifier,
|
modifier = buttonModifier,
|
||||||
theme = LocalBaseUITheme.current.buttons.secondary
|
theme = LocalBaseUITheme.current.buttons.secondary
|
||||||
.copyPadding(contentPadding)
|
.copyPadding(contentPadding)
|
||||||
.copyShape(RoundedCornerShape(8.dp)),
|
.copyShape(RoundedCornerShape(5.dp)),
|
||||||
content = itemContent,
|
content = itemContent,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@ -264,7 +282,7 @@ fun SidebarItem(
|
|||||||
modifier = buttonModifier,
|
modifier = buttonModifier,
|
||||||
theme = LocalBaseUITheme.current.buttons.outline
|
theme = LocalBaseUITheme.current.buttons.outline
|
||||||
.copyPadding(contentPadding)
|
.copyPadding(contentPadding)
|
||||||
.copyShape(RoundedCornerShape(8.dp)),
|
.copyShape(RoundedCornerShape(5.dp)),
|
||||||
hoverOnly = true,
|
hoverOnly = true,
|
||||||
content = itemContent,
|
content = itemContent,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -27,10 +27,11 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
|||||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||||
import dev.krtirtho.spotube.core.server.CacheEntry
|
import dev.krtirtho.spotube.core.server.CacheEntry
|
||||||
import dev.krtirtho.spotube.core.server.CacheManager
|
import dev.krtirtho.spotube.core.server.CacheManager
|
||||||
import dev.krtirtho.spotube.core.server.MatchedTracksRepository
|
|
||||||
import dev.krtirtho.spotube.core.server.StreamingUrlRepository
|
import dev.krtirtho.spotube.core.server.StreamingUrlRepository
|
||||||
|
import dev.krtirtho.spotube.core.server.TrackSourceRepository
|
||||||
import dev.krtirtho.spotube.core.server.normalizeManifestUrl
|
import dev.krtirtho.spotube.core.server.normalizeManifestUrl
|
||||||
import dev.krtirtho.spotube.core.server.selectPreferredAudioStream
|
import dev.krtirtho.spotube.core.server.selectPreferredAudioStream
|
||||||
|
import dev.krtirtho.spotube.modules.plugin.AudioPluginSource
|
||||||
import dev.krtirtho.spotube.modules.settings.SettingsRepository
|
import dev.krtirtho.spotube.modules.settings.SettingsRepository
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@ -72,10 +73,11 @@ data class PlayerOptionsUiState(
|
|||||||
class PlayerOptionsViewModel(
|
class PlayerOptionsViewModel(
|
||||||
private val audioPlayer: AudioPlayerInterface,
|
private val audioPlayer: AudioPlayerInterface,
|
||||||
private val audioPlayerQueue: AudioPlayerQueue,
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
private val matchedTracksRepository: MatchedTracksRepository,
|
private val matchedTracksRepository: TrackSourceRepository,
|
||||||
private val streamingUrlRepository: StreamingUrlRepository,
|
private val streamingUrlRepository: StreamingUrlRepository,
|
||||||
private val cacheManager: CacheManager,
|
private val cacheManager: CacheManager,
|
||||||
private val settingsRepository: SettingsRepository,
|
private val settingsRepository: SettingsRepository,
|
||||||
|
private val pluginManager: AudioPluginSource,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val _uiState = MutableStateFlow(PlayerOptionsUiState())
|
private val _uiState = MutableStateFlow(PlayerOptionsUiState())
|
||||||
val uiState: StateFlow<PlayerOptionsUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<PlayerOptionsUiState> = _uiState.asStateFlow()
|
||||||
@ -155,7 +157,8 @@ class PlayerOptionsViewModel(
|
|||||||
runCatching {
|
runCatching {
|
||||||
val settings = settingsRepository.userSettings.value
|
val settings = settingsRepository.userSettings.value
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val matchedSource = matchedTracksRepository.getTrackSource(track)
|
val pluginId = pluginManager.selectedAudioPlugin.value?.pluginId
|
||||||
|
val matchedSource = pluginId?.let { matchedTracksRepository.getTrackSource(track, it) }
|
||||||
val cachedStream = streamingUrlRepository.getCachedStreamUrlEntry(track.id)
|
val cachedStream = streamingUrlRepository.getCachedStreamUrlEntry(track.id)
|
||||||
val localCacheEntry = if (settings.enableMusicCaching) {
|
val localCacheEntry = if (settings.enableMusicCaching) {
|
||||||
cacheManager.findCachedEntry(track.id)?.second
|
cacheManager.findCachedEntry(track.id)?.second
|
||||||
|
|||||||
@ -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
|
package dev.krtirtho.spotube.resources.iconsax
|
||||||
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
@ -56,11 +39,7 @@ val Iconsax.IconsaxSound: ImageVector
|
|||||||
curveTo(3.75f, 16.16f, 3.41f, 16.5f, 3f, 16.5f)
|
curveTo(3.75f, 16.16f, 3.41f, 16.5f, 3f, 16.5f)
|
||||||
close()
|
close()
|
||||||
}
|
}
|
||||||
path(
|
path(fill = SolidColor(Color.White)) {
|
||||||
fill = SolidColor(Color.White),
|
|
||||||
fillAlpha = 0.4f,
|
|
||||||
strokeAlpha = 0.4f
|
|
||||||
) {
|
|
||||||
moveTo(7.5f, 19f)
|
moveTo(7.5f, 19f)
|
||||||
curveTo(7.09f, 19f, 6.75f, 18.66f, 6.75f, 18.25f)
|
curveTo(7.09f, 19f, 6.75f, 18.66f, 6.75f, 18.25f)
|
||||||
verticalLineTo(5.75f)
|
verticalLineTo(5.75f)
|
||||||
@ -80,11 +59,7 @@ val Iconsax.IconsaxSound: ImageVector
|
|||||||
curveTo(12.75f, 21.16f, 12.41f, 21.5f, 12f, 21.5f)
|
curveTo(12.75f, 21.16f, 12.41f, 21.5f, 12f, 21.5f)
|
||||||
close()
|
close()
|
||||||
}
|
}
|
||||||
path(
|
path(fill = SolidColor(Color.White)) {
|
||||||
fill = SolidColor(Color.White),
|
|
||||||
fillAlpha = 0.4f,
|
|
||||||
strokeAlpha = 0.4f
|
|
||||||
) {
|
|
||||||
moveTo(16.5f, 19f)
|
moveTo(16.5f, 19f)
|
||||||
curveTo(16.09f, 19f, 15.75f, 18.66f, 15.75f, 18.25f)
|
curveTo(16.09f, 19f, 15.75f, 18.66f, 15.75f, 18.25f)
|
||||||
verticalLineTo(5.75f)
|
verticalLineTo(5.75f)
|
||||||
|
|||||||
@ -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(
|
class FakePluginService(
|
||||||
private val metadataTrackAPI: MetadataTrackAPI = FakeMetadataTrackAPI()
|
private val metadataTrackAPI: MetadataTrackAPI = FakeMetadataTrackAPI(),
|
||||||
|
override val pluginId: String = "fake-plugin",
|
||||||
) : PluginService {
|
) : PluginService {
|
||||||
override val loggedInFlow: StateFlow<Boolean> = MutableStateFlow(true)
|
override val loggedInFlow: StateFlow<Boolean> = MutableStateFlow(true)
|
||||||
|
|
||||||
|
|||||||
@ -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.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/e50b80b5a11d194a898bc3e6211b7c4b/redirect
|
||||||
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/476d3c08f4989328dee56d22e202d98d/redirect
|
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/476d3c08f4989328dee56d22e202d98d/redirect
|
||||||
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/5a88b04b5e582b332d2e6bc12b45f1b9/redirect
|
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/5a88b04b5e582b332d2e6bc12b45f1b9/redirect
|
||||||
toolchainVendor=ADOPTIUM
|
toolchainVendor=JETBRAINS
|
||||||
toolchainVersion=21
|
toolchainVersion=21
|
||||||
@ -46,8 +46,9 @@ kotlinx-serialization-json = "1.11.0"
|
|||||||
materialKolor = "4.1.1"
|
materialKolor = "4.1.1"
|
||||||
mqttClient = "2.1.1"
|
mqttClient = "2.1.1"
|
||||||
mqttBuffer = "6.30.8"
|
mqttBuffer = "6.30.8"
|
||||||
|
multiplatformMarkdownRenderer = "0.45.0"
|
||||||
murmurhash = "0.4.2"
|
murmurhash = "0.4.2"
|
||||||
newpipeextractor = "v0.26.2"
|
newpipeextractor = "v0.26.5"
|
||||||
newpipeExtractorKmp = "1.3.0"
|
newpipeExtractorKmp = "1.3.0"
|
||||||
nucleusNucleusApplication = "2.4.6"
|
nucleusNucleusApplication = "2.4.6"
|
||||||
semver = "2.1.0"
|
semver = "2.1.0"
|
||||||
@ -128,6 +129,8 @@ mqtt-x-models = { module = "com.ditchoom:mqtt-5-models", version.ref = "mqttClie
|
|||||||
mqtt-client = { module = "com.ditchoom:mqtt-client", version.ref = "mqttClient" }
|
mqtt-client = { module = "com.ditchoom:mqtt-client", version.ref = "mqttClient" }
|
||||||
mqtt-buffer = { module = "com.ditchoom:buffer", version.ref = "mqttBuffer" }
|
mqtt-buffer = { module = "com.ditchoom:buffer", version.ref = "mqttBuffer" }
|
||||||
mqtt-buffer-codec = { module = "com.ditchoom:buffer-codec", version.ref = "mqttBuffer" }
|
mqtt-buffer-codec = { module = "com.ditchoom:buffer-codec", version.ref = "mqttBuffer" }
|
||||||
|
multiplatform-markdown-renderer = { module = "com.mikepenz:multiplatform-markdown-renderer", version.ref = "multiplatformMarkdownRenderer" }
|
||||||
|
multiplatform-markdown-renderer-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "multiplatformMarkdownRenderer" }
|
||||||
murmurhash = { module = "com.goncalossilva:murmurhash", version.ref = "murmurhash" }
|
murmurhash = { module = "com.goncalossilva:murmurhash", version.ref = "murmurhash" }
|
||||||
newpipe-extractor-kmp = { module = "io.github.yushosei:newpipe-extractor-kmp", version.ref = "newpipeExtractorKmp" }
|
newpipe-extractor-kmp = { module = "io.github.yushosei:newpipe-extractor-kmp", version.ref = "newpipeExtractorKmp" }
|
||||||
newpipeextractor = { module = "com.github.teamnewpipe:NewPipeExtractor", version.ref = "newpipeextractor" }
|
newpipeextractor = { module = "com.github.teamnewpipe:NewPipeExtractor", version.ref = "newpipeextractor" }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user