mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
Compare commits
5 Commits
501f1259d4
...
911d6babc5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
911d6babc5 | ||
|
|
fab60107e3 | ||
|
|
eeaff3c1db | ||
|
|
4e2afeaea4 | ||
|
|
d48b0a2f65 |
2
.github/workflows/spotube-release-binary.yml
vendored
2
.github/workflows/spotube-release-binary.yml
vendored
@ -425,7 +425,7 @@ jobs:
|
|||||||
done
|
done
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v3
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ steps.vars.outputs.tag }}
|
tag_name: ${{ steps.vars.outputs.tag }}
|
||||||
name: ${{ steps.vars.outputs.release_name }}
|
name: ${{ steps.vars.outputs.release_name }}
|
||||||
|
|||||||
@ -31,6 +31,7 @@
|
|||||||
- `plugin_interfaces` also has a JS target (`browser()`), used by the plugin system.
|
- `plugin_interfaces` also has a JS target (`browser()`), used by the plugin system.
|
||||||
|
|
||||||
## UI component patterns
|
## UI component patterns
|
||||||
|
- **ALL user-facing strings MUST come from `composeApp/src/commonMain/composeResources/values/strings.xml` via `stringResource(Res.string.xxx)`.** Never hardcode a literal string in a `Text(...)`, dialog title, button label, content description, placeholder, or error message inside `@Composable` code. If the string you need doesn't exist yet, add a new `<string name="...">` entry to `strings.xml` (grouped near related keys, following the existing `feature_context_purpose` naming convention) and reference it — do not inline the text "just this once". This applies to every module, including ones you're about to add (dialogs, screens, snackbars, etc.), not just `modules/settings`. `strings.xml` is the single source of truth for copy, not a showcase file for one screen.
|
||||||
- **ViewModels own ALL state and logic.** Composables are dumb renderers: they collect a single `StateFlow<UiState>` from the ViewModel and forward user events (clicks, text input, drag callbacks) back to ViewModel functions. No business logic, filtering, derivation, reordering buffers, or `LaunchedEffect`-based state syncing belongs in a composable — it goes in the ViewModel. The `combine`/`stateIn` flow chain in the ViewModel must produce fully-computed, ready-to-render UI state so the composable never needs intermediate `remember` derivations or `mutableStateListOf` mirrors.
|
- **ViewModels own ALL state and logic.** Composables are dumb renderers: they collect a single `StateFlow<UiState>` from the ViewModel and forward user events (clicks, text input, drag callbacks) back to ViewModel functions. No business logic, filtering, derivation, reordering buffers, or `LaunchedEffect`-based state syncing belongs in a composable — it goes in the ViewModel. The `combine`/`stateIn` flow chain in the ViewModel must produce fully-computed, ready-to-render UI state so the composable never needs intermediate `remember` derivations or `mutableStateListOf` mirrors.
|
||||||
- **AdaptiveDropdownBottomSheet** (`commonMain/.../core/ui/component/AdaptiveDropdownBottomSheet.kt`): switches between `DropdownMenu` (large screen) and `ModalBottomSheet` (small screen) via `currentWindowAdaptiveInfo()`. Do NOT use expect/actual — all adaptive components that rely ONLY on Compose/Material3 APIs belong in commonMain.
|
- **AdaptiveDropdownBottomSheet** (`commonMain/.../core/ui/component/AdaptiveDropdownBottomSheet.kt`): switches between `DropdownMenu` (large screen) and `ModalBottomSheet` (small screen) via `currentWindowAdaptiveInfo()`. Do NOT use expect/actual — all adaptive components that rely ONLY on Compose/Material3 APIs belong in commonMain.
|
||||||
- **AdaptiveDialogBottomSheet** (`commonMain/.../core/ui/component/AdaptiveDialogBottomSheet.kt`): switches between `ThemedDialog` (large screen) and `ModalBottomSheet` (small screen) via `currentWindowAdaptiveInfo()`. Same rule — keep in commonMain unless platform-specific APIs are required.
|
- **AdaptiveDialogBottomSheet** (`commonMain/.../core/ui/component/AdaptiveDialogBottomSheet.kt`): switches between `ThemedDialog` (large screen) and `ModalBottomSheet` (small screen) via `currentWindowAdaptiveInfo()`. Same rule — keep in commonMain unless platform-specific APIs are required.
|
||||||
|
|||||||
@ -198,4 +198,12 @@
|
|||||||
<string name="player_go_to_album">Go to album</string>
|
<string name="player_go_to_album">Go to album</string>
|
||||||
<string name="player_sleep_timer">Sleep timer</string>
|
<string name="player_sleep_timer">Sleep timer</string>
|
||||||
<string name="player_track_details">Track details</string>
|
<string name="player_track_details">Track details</string>
|
||||||
|
|
||||||
|
<string name="update_action_ignore">Ignore</string>
|
||||||
|
<string name="update_action_update">Update</string>
|
||||||
|
<string name="app_update_dialog_title">Spotube %1$s is available</string>
|
||||||
|
<string name="app_update_dialog_no_notes">See what\'s new in this release on the Spotube downloads page.</string>
|
||||||
|
<string name="plugin_update_dialog_title">%1$s %2$s is available</string>
|
||||||
|
<string name="plugin_update_dialog_current_version">Currently installed: %1$s</string>
|
||||||
|
<string name="plugin_update_dialog_no_notes">See what\'s new in this update from the plugin\'s page.</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@ -22,30 +22,20 @@ 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.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
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.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.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
|
||||||
@ -53,7 +43,10 @@ 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.update.AppUpdateDialog
|
||||||
|
import dev.krtirtho.spotube.modules.update.AppUpdateService
|
||||||
|
import dev.krtirtho.spotube.modules.update.PluginUpdateDialog
|
||||||
|
import dev.krtirtho.spotube.modules.update.PluginUpdateService
|
||||||
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.IconsaxCd
|
||||||
@ -69,7 +62,6 @@ 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
|
||||||
|
|
||||||
interface NavigationItem {
|
interface NavigationItem {
|
||||||
@ -143,8 +135,10 @@ 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 appUpdateService: AppUpdateService = koinInject()
|
||||||
val availableUpdate by updateCheckerViewModel.availableUpdate.collectAsStateWithLifecycle()
|
val pluginUpdateService: PluginUpdateService = koinInject()
|
||||||
|
val availableAppUpdate by appUpdateService.availableUpdate.collectAsStateWithLifecycle()
|
||||||
|
val availablePluginUpdate by pluginUpdateService.availableUpdate.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val navigationState = rememberNavigationState(
|
val navigationState = rememberNavigationState(
|
||||||
startRoute = Routes.Home, topLevelRoutes = TOP_LEVEL_ROUTES
|
startRoute = Routes.Home, topLevelRoutes = TOP_LEVEL_ROUTES
|
||||||
@ -182,42 +176,27 @@ fun App(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
content()
|
content()
|
||||||
if (availableUpdate != null) {
|
availableAppUpdate?.let { update ->
|
||||||
val update = availableUpdate!!
|
AppUpdateDialog(
|
||||||
ThemedDialog(
|
update = update,
|
||||||
onDismissRequest = updateCheckerViewModel::dismissUpdate,
|
onDismiss = appUpdateService::dismissUpdate,
|
||||||
title = {
|
onIgnore = appUpdateService::ignoreUpdate,
|
||||||
Text(
|
onUpdate = {
|
||||||
text = "Spotube ${update.tag} is available",
|
runCatching { openUrlInBrowser("https://spotube.cc/downloads/") }
|
||||||
style = MaterialTheme.typography.titleLarge,
|
appUpdateService.dismissUpdate()
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
actions = {
|
)
|
||||||
OutlineButton(onClick = updateCheckerViewModel::ignoreUpdate) {
|
}
|
||||||
Text("Ignore")
|
availablePluginUpdate?.let { update ->
|
||||||
}
|
PluginUpdateDialog(
|
||||||
PrimaryButton(
|
update = update,
|
||||||
onClick = {
|
onDismiss = pluginUpdateService::dismissUpdate,
|
||||||
runCatching { openUrlInBrowser("https://spotube.cc/downloads/") }
|
onIgnore = pluginUpdateService::ignoreUpdate,
|
||||||
updateCheckerViewModel.dismissUpdate()
|
onUpdate = {
|
||||||
},
|
runCatching { openUrlInBrowser(update.info.directDownloadUrl) }
|
||||||
) {
|
pluginUpdateService.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.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -32,6 +32,7 @@ import dev.krtirtho.spotube.core.remote.RemoteControlClient
|
|||||||
import dev.krtirtho.spotube.core.remote.RemoteControlHandler
|
import dev.krtirtho.spotube.core.remote.RemoteControlHandler
|
||||||
import dev.krtirtho.spotube.core.remote.RemoteControlService
|
import dev.krtirtho.spotube.core.remote.RemoteControlService
|
||||||
import dev.krtirtho.spotube.core.remote.RemotePlaybackController
|
import dev.krtirtho.spotube.core.remote.RemotePlaybackController
|
||||||
|
import dev.krtirtho.spotube.core.scrobble.ScrobbleManager
|
||||||
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
|
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
|
||||||
import dev.krtirtho.spotube.core.server.AlternativeTracksRepository
|
import dev.krtirtho.spotube.core.server.AlternativeTracksRepository
|
||||||
import dev.krtirtho.spotube.core.server.CacheManager
|
import dev.krtirtho.spotube.core.server.CacheManager
|
||||||
@ -69,11 +70,13 @@ 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
|
||||||
|
import dev.krtirtho.spotube.modules.plugin.ScrobblePluginSource
|
||||||
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
|
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.update.AppUpdateService
|
||||||
|
import dev.krtirtho.spotube.modules.update.PluginUpdateService
|
||||||
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.UserSettingsSource
|
||||||
@ -129,6 +132,7 @@ val sharedModules = module {
|
|||||||
singleOf(::PluginManager) {
|
singleOf(::PluginManager) {
|
||||||
bind<PluginProvider>()
|
bind<PluginProvider>()
|
||||||
bind<AudioPluginSource>()
|
bind<AudioPluginSource>()
|
||||||
|
bind<ScrobblePluginSource>()
|
||||||
}
|
}
|
||||||
viewModelOf(::PluginViewModel)
|
viewModelOf(::PluginViewModel)
|
||||||
|
|
||||||
@ -136,7 +140,10 @@ val sharedModules = module {
|
|||||||
singleOf(::SettingsRepository) { bind<UserSettingsSource>() }
|
singleOf(::SettingsRepository) { bind<UserSettingsSource>() }
|
||||||
viewModelOf(::SettingsViewModel) { bind<SettingsProvider>() }
|
viewModelOf(::SettingsViewModel) { bind<SettingsProvider>() }
|
||||||
viewModelOf(::JamSettingsViewModel)
|
viewModelOf(::JamSettingsViewModel)
|
||||||
viewModelOf(::UpdateCheckerViewModel)
|
|
||||||
|
// Updates
|
||||||
|
single { AppUpdateService(get()) } withOptions { createdAtStart() }
|
||||||
|
single { PluginUpdateService(get(), get()) } withOptions { createdAtStart() }
|
||||||
|
|
||||||
// Downloads
|
// Downloads
|
||||||
singleOf(::DownloadManager)
|
singleOf(::DownloadManager)
|
||||||
@ -264,6 +271,9 @@ val sharedModules = module {
|
|||||||
|
|
||||||
single { DiscordRpcService(get(), get()) } withOptions { createdAtStart() }
|
single { DiscordRpcService(get(), get()) } withOptions { createdAtStart() }
|
||||||
|
|
||||||
|
// Scrobbling
|
||||||
|
single { ScrobbleManager(get(), get(), get()) } withOptions { createdAtStart() }
|
||||||
|
|
||||||
factory { (tag: String?) ->
|
factory { (tag: String?) ->
|
||||||
if (tag != null) {
|
if (tag != null) {
|
||||||
Logger.withTag(tag)
|
Logger.withTag(tag)
|
||||||
|
|||||||
@ -0,0 +1,272 @@
|
|||||||
|
/*
|
||||||
|
* 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.core.scrobble
|
||||||
|
|
||||||
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||||
|
import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleTrack
|
||||||
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
||||||
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||||
|
import dev.krtirtho.spotube.core.audioplayer.PlayerState
|
||||||
|
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||||
|
import dev.krtirtho.spotube.core.di.injectLogger
|
||||||
|
import dev.krtirtho.spotube.modules.plugin.ScrobblePluginSource
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.IO
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.koin.core.component.KoinComponent
|
||||||
|
import kotlin.time.Clock
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watches local playback and submits scrobbles through the currently selected scrobble plugin.
|
||||||
|
*
|
||||||
|
* Scrobbling follows the standard Last.fm rules, which plugins are expected to honour as well:
|
||||||
|
* - Tracks shorter than [MIN_TRACK_DURATION_MS] are never scrobbled.
|
||||||
|
* - The track must be listened to for at least half of its duration, or [MAX_SCROBBLE_THRESHOLD_MS],
|
||||||
|
* whichever comes first.
|
||||||
|
*
|
||||||
|
* Only time during which the player is actively [PlayerState.PLAYING] is counted, so pauses are
|
||||||
|
* ignored, and forward seeks don't credit unheard audio. The timestamp sent with the scrobble is
|
||||||
|
* the moment playback of the track started. Each play is scrobbled at most once; replaying or
|
||||||
|
* repeating the track starts a new scrobble session.
|
||||||
|
*/
|
||||||
|
class ScrobbleManager(
|
||||||
|
private val audioPlayer: AudioPlayerInterface,
|
||||||
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
|
private val scrobblePluginSource: ScrobblePluginSource,
|
||||||
|
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
||||||
|
) : KoinComponent {
|
||||||
|
|
||||||
|
private val logger by injectLogger<ScrobbleManager>()
|
||||||
|
|
||||||
|
private var activeTrackKey: String? = null
|
||||||
|
private var playbackStartedAtEpochSeconds: Long? = null
|
||||||
|
private var listenedMs: Long = 0L
|
||||||
|
private var lastPositionMs: Long? = null
|
||||||
|
private var scrobbled = false
|
||||||
|
|
||||||
|
init {
|
||||||
|
scope.launch {
|
||||||
|
combine(
|
||||||
|
audioPlayerQueue.currentQueueEntryFlow,
|
||||||
|
audioPlayer.playerStateFlow,
|
||||||
|
audioPlayer.positionFlow,
|
||||||
|
audioPlayer.durationFlow,
|
||||||
|
) { entry, playerState, position, duration ->
|
||||||
|
PlaybackSnapshot(
|
||||||
|
entry = entry,
|
||||||
|
playerState = playerState,
|
||||||
|
positionMs = position.inWholeMilliseconds,
|
||||||
|
playerDurationMs = duration.inWholeMilliseconds,
|
||||||
|
)
|
||||||
|
}.collect(::onPlaybackSnapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onPlaybackSnapshot(snapshot: PlaybackSnapshot) {
|
||||||
|
val entry = snapshot.entry
|
||||||
|
if (entry == null) {
|
||||||
|
clearSession()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val trackKey = entry.trackKey()
|
||||||
|
if (trackKey != activeTrackKey) {
|
||||||
|
beginSession(trackKey, snapshot.positionMs)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val previousPositionMs = lastPositionMs
|
||||||
|
lastPositionMs = snapshot.positionMs
|
||||||
|
|
||||||
|
if (snapshot.playerState != PlayerState.PLAYING) return
|
||||||
|
if (previousPositionMs == null) {
|
||||||
|
playbackStartedAtEpochSeconds = playbackStartedAtEpochSeconds ?: nowEpochSeconds()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playbackStartedAtEpochSeconds == null) {
|
||||||
|
playbackStartedAtEpochSeconds = nowEpochSeconds()
|
||||||
|
}
|
||||||
|
|
||||||
|
val deltaMs = snapshot.positionMs - previousPositionMs
|
||||||
|
if (deltaMs < 0) {
|
||||||
|
// A backward seek to the very beginning means the play started over
|
||||||
|
// (e.g. repeat one), so allow it to be scrobbled again.
|
||||||
|
if (snapshot.positionMs <= RESTART_POSITION_THRESHOLD_MS) {
|
||||||
|
restartSession(snapshot.positionMs)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// A jump larger than a couple of position ticks is a forward seek or a
|
||||||
|
// buffering/app-suspension stall. Never credit unplayed audio for it.
|
||||||
|
if (deltaMs > MAX_COUNTED_POSITION_DELTA_MS) return
|
||||||
|
|
||||||
|
listenedMs += deltaMs
|
||||||
|
if (scrobbled) return
|
||||||
|
|
||||||
|
val durationMs = entry.durationMs().takeIf { it > 0 } ?: snapshot.playerDurationMs
|
||||||
|
val thresholdMs = scrobbleThresholdMs(durationMs) ?: return
|
||||||
|
if (listenedMs < thresholdMs) return
|
||||||
|
|
||||||
|
if (scrobblePluginSource.selectedScrobblePlugin.value == null) {
|
||||||
|
// No scrobble plugin selected yet: keep the session hot so it can be
|
||||||
|
// submitted if the user selects one while the track is still playing.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
scrobbled = true
|
||||||
|
val track = entry.toScrobbleTrack(
|
||||||
|
timestamp = playbackStartedAtEpochSeconds ?: nowEpochSeconds(),
|
||||||
|
durationMs = durationMs,
|
||||||
|
)
|
||||||
|
scope.launch { submitScrobble(track) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun submitScrobble(track: ScrobbleTrack) {
|
||||||
|
val plugin = scrobblePluginSource.selectedScrobblePlugin.value ?: return
|
||||||
|
try {
|
||||||
|
plugin.use { scrobbleAPI.scrobble(track) }
|
||||||
|
logger.d { "Scrobbled \"${track.trackName}\" by ${track.artistName} via ${plugin.pluginId}" }
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.e(e) { "Failed to scrobble \"${track.trackName}\" by ${track.artistName}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun beginSession(trackKey: String, positionMs: Long) {
|
||||||
|
activeTrackKey = trackKey
|
||||||
|
restartSession(positionMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun restartSession(positionMs: Long) {
|
||||||
|
listenedMs = 0L
|
||||||
|
playbackStartedAtEpochSeconds = null
|
||||||
|
lastPositionMs = positionMs
|
||||||
|
scrobbled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearSession() {
|
||||||
|
activeTrackKey = null
|
||||||
|
listenedMs = 0L
|
||||||
|
playbackStartedAtEpochSeconds = null
|
||||||
|
lastPositionMs = null
|
||||||
|
scrobbled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun nowEpochSeconds(): Long = Clock.System.now().epochSeconds
|
||||||
|
|
||||||
|
private fun QueueEntry.trackKey(): String = when (this) {
|
||||||
|
is QueueEntry.StreamingTrack -> track.id
|
||||||
|
is QueueEntry.LocalTrack -> url
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun QueueEntry.durationMs(): Long = when (this) {
|
||||||
|
is QueueEntry.StreamingTrack -> track.durationMs
|
||||||
|
is QueueEntry.LocalTrack -> duration
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun QueueEntry.toScrobbleTrack(timestamp: Long, durationMs: Long): ScrobbleTrack {
|
||||||
|
return when (this) {
|
||||||
|
is QueueEntry.StreamingTrack -> ScrobbleTrack(
|
||||||
|
timestamp = timestamp,
|
||||||
|
trackId = track.id,
|
||||||
|
artistId = track.artists.firstOrNull()?.id.orEmpty(),
|
||||||
|
albumId = track.album?.id,
|
||||||
|
trackName = track.title,
|
||||||
|
artistName = track.artists.joinToString(", ") { it.name },
|
||||||
|
albumName = track.album?.title,
|
||||||
|
streamingProvider = resolveStreamingProvider(track),
|
||||||
|
durationMs = durationMs,
|
||||||
|
)
|
||||||
|
|
||||||
|
is QueueEntry.LocalTrack -> ScrobbleTrack(
|
||||||
|
timestamp = timestamp,
|
||||||
|
trackId = url,
|
||||||
|
artistId = "",
|
||||||
|
albumId = null,
|
||||||
|
trackName = name,
|
||||||
|
artistName = artists.joinToString(", "),
|
||||||
|
albumName = album,
|
||||||
|
streamingProvider = LOCAL_PROVIDER,
|
||||||
|
durationMs = durationMs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maps a track's external URI to the streaming provider key scrobble plugins expect. */
|
||||||
|
private fun resolveStreamingProvider(track: MetadataTrack): String {
|
||||||
|
val host = track.externalUri
|
||||||
|
?.substringAfter("://", missingDelimiterValue = "")
|
||||||
|
?.substringBefore('/')
|
||||||
|
?.substringAfter('@')
|
||||||
|
?.substringBefore(':')
|
||||||
|
?.lowercase()
|
||||||
|
?.removePrefix("www.")
|
||||||
|
.orEmpty()
|
||||||
|
|
||||||
|
if (host.isBlank()) return UNKNOWN_PROVIDER
|
||||||
|
|
||||||
|
return when {
|
||||||
|
"spotify" in host -> "spotify"
|
||||||
|
"apple" in host -> "apple_music"
|
||||||
|
"music.youtube" in host -> "youtube_music"
|
||||||
|
"youtube" in host || "youtu.be" in host -> "youtube"
|
||||||
|
"deezer" in host -> "deezer"
|
||||||
|
"tidal" in host -> "tidal"
|
||||||
|
"soundcloud" in host -> "soundcloud"
|
||||||
|
"amazon" in host -> "amazon_music"
|
||||||
|
"pandora" in host -> "pandora"
|
||||||
|
else -> host.substringBefore('.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class PlaybackSnapshot(
|
||||||
|
val entry: QueueEntry?,
|
||||||
|
val playerState: PlayerState,
|
||||||
|
val positionMs: Long,
|
||||||
|
val playerDurationMs: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** Tracks shorter than this are never scrobbled (Last.fm rule). */
|
||||||
|
private const val MIN_TRACK_DURATION_MS = 30_000L
|
||||||
|
|
||||||
|
/** Listening threshold is capped at four minutes (Last.fm rule). */
|
||||||
|
private const val MAX_SCROBBLE_THRESHOLD_MS = 4 * 60 * 1000L
|
||||||
|
|
||||||
|
/** Largest position delta credited as listening, covers position polling jitter. */
|
||||||
|
private const val MAX_COUNTED_POSITION_DELTA_MS = 2_000L
|
||||||
|
|
||||||
|
/** Positions at or below this are treated as the start of a fresh play. */
|
||||||
|
private const val RESTART_POSITION_THRESHOLD_MS = 5_000L
|
||||||
|
|
||||||
|
private const val UNKNOWN_PROVIDER = "unknown"
|
||||||
|
private const val LOCAL_PROVIDER = "local"
|
||||||
|
|
||||||
|
private fun scrobbleThresholdMs(durationMs: Long): Long? {
|
||||||
|
if (durationMs < MIN_TRACK_DURATION_MS) return null
|
||||||
|
return minOf(durationMs / 2, MAX_SCROBBLE_THRESHOLD_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -215,7 +215,6 @@ fun PlatformWebViewScreen(webViewController: WebViewController) {
|
|||||||
webViewJsBridge = webViewBridge,
|
webViewJsBridge = webViewBridge,
|
||||||
onCreated = { webView ->
|
onCreated = { webView ->
|
||||||
navigator.evaluateJavaScript(bridgeBootstrapScript)
|
navigator.evaluateJavaScript(bridgeBootstrapScript)
|
||||||
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -76,11 +76,18 @@ interface AudioPluginSource {
|
|||||||
val selectedAudioPlugin: StateFlow<PluginService?>
|
val selectedAudioPlugin: StateFlow<PluginService?>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Narrow read-only view of the active scrobble plugin, used by consumers that only need
|
||||||
|
* to submit scrobbles against the currently selected plugin without depending on the full
|
||||||
|
* [PluginManager] (which also owns plugin installation/lifecycle machinery). */
|
||||||
|
interface ScrobblePluginSource {
|
||||||
|
val selectedScrobblePlugin: StateFlow<PluginService?>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class PluginManager(
|
class PluginManager(
|
||||||
val database: Database,
|
val database: Database,
|
||||||
val paths: Paths,
|
val paths: Paths,
|
||||||
) : KoinComponent, PluginProvider, AudioPluginSource {
|
) : KoinComponent, PluginProvider, AudioPluginSource, ScrobblePluginSource {
|
||||||
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." }
|
||||||
@ -116,7 +123,6 @@ class PluginManager(
|
|||||||
val json = p[DatabaseKeys.PLUGINS_STATE_KEY]
|
val json = p[DatabaseKeys.PLUGINS_STATE_KEY]
|
||||||
val defaultSelectedPlugins = mapOf(
|
val defaultSelectedPlugins = mapOf(
|
||||||
PluginAbility.AUDIO to NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN,
|
PluginAbility.AUDIO to NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN,
|
||||||
PluginAbility.SCROBBLE to LRCLIB_BUILT_IN_PLUGIN,
|
|
||||||
)
|
)
|
||||||
if (json == null) {
|
if (json == null) {
|
||||||
PluginManagerStates.Data(
|
PluginManagerStates.Data(
|
||||||
@ -136,11 +142,21 @@ class PluginManager(
|
|||||||
} else it
|
} else it
|
||||||
}.toSet()
|
}.toSet()
|
||||||
|
|
||||||
res.copy(
|
val resolvedPlugins = (plugins union BUILT_IN_PLUGINS).toList()
|
||||||
plugins = (plugins union BUILT_IN_PLUGINS).toList(),
|
val resolvedPluginsById = resolvedPlugins.associateBy { it.id }
|
||||||
selectedPlugins = res.selectedPlugins.ifEmpty {
|
// Drop selections for plugins that don't actually declare the ability
|
||||||
defaultSelectedPlugins
|
// (e.g. a scrobble selection left over from a removed built-in plugin)
|
||||||
|
// and always point selections at the resolved plugin entry.
|
||||||
|
val selectedPlugins = res.selectedPlugins.ifEmpty { defaultSelectedPlugins }
|
||||||
|
.mapNotNull { (ability, selected) ->
|
||||||
|
val plugin = resolvedPluginsById[selected.id] ?: return@mapNotNull null
|
||||||
|
if (ability in plugin.abilities) ability to plugin else null
|
||||||
}
|
}
|
||||||
|
.toMap()
|
||||||
|
|
||||||
|
res.copy(
|
||||||
|
plugins = resolvedPlugins,
|
||||||
|
selectedPlugins = selectedPlugins,
|
||||||
)
|
)
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
PluginManagerStates.Data(
|
PluginManagerStates.Data(
|
||||||
@ -256,7 +272,7 @@ class PluginManager(
|
|||||||
filterSelectedPluginByType(PluginAbility.AUDIO)
|
filterSelectedPluginByType(PluginAbility.AUDIO)
|
||||||
val selectedLyricsPlugin =
|
val selectedLyricsPlugin =
|
||||||
filterSelectedPluginByType(PluginAbility.LYRICS)
|
filterSelectedPluginByType(PluginAbility.LYRICS)
|
||||||
val selectedScrobblePlugin =
|
override val selectedScrobblePlugin =
|
||||||
filterSelectedPluginByType(PluginAbility.SCROBBLE)
|
filterSelectedPluginByType(PluginAbility.SCROBBLE)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -87,7 +87,8 @@ data class UserSettings(
|
|||||||
|
|
||||||
// Updates
|
// Updates
|
||||||
val autoCheckForUpdates: Boolean = true,
|
val autoCheckForUpdates: Boolean = true,
|
||||||
val ignoredUpdateVersion: String? = null,
|
val ignoredAppUpdateVersion: String? = null,
|
||||||
|
val ignoredPluginUpdateVersions: Map<String, String> = emptyMap(),
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -0,0 +1,80 @@
|
|||||||
|
/*
|
||||||
|
* 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.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.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.mikepenz.markdown.m3.Markdown
|
||||||
|
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 org.jetbrains.compose.resources.stringResource
|
||||||
|
import spotube.composeapp.generated.resources.Res
|
||||||
|
import spotube.composeapp.generated.resources.app_update_dialog_no_notes
|
||||||
|
import spotube.composeapp.generated.resources.app_update_dialog_title
|
||||||
|
import spotube.composeapp.generated.resources.update_action_ignore
|
||||||
|
import spotube.composeapp.generated.resources.update_action_update
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prompts the user that a newer Spotube release is available on GitHub. [onUpdate]
|
||||||
|
* should take the user to the downloads page; [onIgnore] persistently silences this
|
||||||
|
* specific release tag so the check moves on to the next newer one.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun AppUpdateDialog(
|
||||||
|
update: AvailableAppUpdate,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onIgnore: () -> Unit,
|
||||||
|
onUpdate: () -> Unit,
|
||||||
|
) {
|
||||||
|
ThemedDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(Res.string.app_update_dialog_title, update.tag),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
OutlineButton(onClick = onIgnore) {
|
||||||
|
Text(stringResource(Res.string.update_action_ignore))
|
||||||
|
}
|
||||||
|
PrimaryButton(onClick = onUpdate) {
|
||||||
|
Text(stringResource(Res.string.update_action_update))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
if (update.releaseNotesMarkdown.isNotBlank()) {
|
||||||
|
SelectionContainer {
|
||||||
|
Markdown(
|
||||||
|
content = update.releaseNotesMarkdown,
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(stringResource(Res.string.app_update_dialog_no_notes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -17,8 +17,6 @@
|
|||||||
|
|
||||||
package dev.krtirtho.spotube.modules.update
|
package dev.krtirtho.spotube.modules.update
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import co.touchlab.kermit.Logger
|
import co.touchlab.kermit.Logger
|
||||||
import dev.krtirtho.spotube.AppBuildInfo
|
import dev.krtirtho.spotube.AppBuildInfo
|
||||||
import dev.krtirtho.spotube.modules.settings.SettingsRepository
|
import dev.krtirtho.spotube.modules.settings.SettingsRepository
|
||||||
@ -30,6 +28,9 @@ import io.ktor.client.request.get
|
|||||||
import io.ktor.client.request.header
|
import io.ktor.client.request.header
|
||||||
import io.ktor.http.HttpHeaders
|
import io.ktor.http.HttpHeaders
|
||||||
import io.ktor.serialization.kotlinx.json.json
|
import io.ktor.serialization.kotlinx.json.json
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@ -38,27 +39,35 @@ import kotlinx.serialization.SerialName
|
|||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import net.swiftzer.semver.SemVer
|
import net.swiftzer.semver.SemVer
|
||||||
|
import org.koin.core.component.KoinComponent
|
||||||
|
|
||||||
data class AvailableUpdate(
|
data class AvailableAppUpdate(
|
||||||
val tag: String,
|
val tag: String,
|
||||||
val releaseNotesMarkdown: String,
|
val releaseNotesMarkdown: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
class UpdateCheckerViewModel(
|
/**
|
||||||
|
* Checks GitHub for a newer app release once on startup and exposes it for the UI to
|
||||||
|
* surface via [dev.krtirtho.spotube.modules.update.AppUpdateDialog]. This is an
|
||||||
|
* app-scoped singleton (not a ViewModel) since the check runs once for the whole
|
||||||
|
* process, independent of any screen's lifecycle.
|
||||||
|
*/
|
||||||
|
class AppUpdateService(
|
||||||
private val settingsRepository: SettingsRepository,
|
private val settingsRepository: SettingsRepository,
|
||||||
) : ViewModel() {
|
) : KoinComponent, AutoCloseable {
|
||||||
private val logger = Logger.withTag("UpdateChecker")
|
private val logger = Logger.withTag("AppUpdateService")
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
private val httpClient = HttpClient(CIO) {
|
private val httpClient = HttpClient(CIO) {
|
||||||
install(ContentNegotiation) {
|
install(ContentNegotiation) {
|
||||||
json(Json { ignoreUnknownKeys = true })
|
json(Json { ignoreUnknownKeys = true })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val _availableUpdate = MutableStateFlow<AvailableUpdate?>(null)
|
private val _availableUpdate = MutableStateFlow<AvailableAppUpdate?>(null)
|
||||||
val availableUpdate: StateFlow<AvailableUpdate?> = _availableUpdate.asStateFlow()
|
val availableUpdate: StateFlow<AvailableAppUpdate?> = _availableUpdate.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
scope.launch {
|
||||||
checkForUpdate()
|
checkForUpdate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -80,8 +89,8 @@ class UpdateCheckerViewModel(
|
|||||||
val currentVersion = parseVersion(AppBuildInfo.VERSION)
|
val currentVersion = parseVersion(AppBuildInfo.VERSION)
|
||||||
?: return logger.w { "Ignoring update check for invalid app version: ${AppBuildInfo.VERSION}" }
|
?: return logger.w { "Ignoring update check for invalid app version: ${AppBuildInfo.VERSION}" }
|
||||||
|
|
||||||
if (latestVersion > currentVersion && release.tagName != settings.ignoredUpdateVersion) {
|
if (latestVersion > currentVersion && release.tagName != settings.ignoredAppUpdateVersion) {
|
||||||
_availableUpdate.value = AvailableUpdate(
|
_availableUpdate.value = AvailableAppUpdate(
|
||||||
tag = release.tagName,
|
tag = release.tagName,
|
||||||
releaseNotesMarkdown = release.body.orEmpty(),
|
releaseNotesMarkdown = release.body.orEmpty(),
|
||||||
)
|
)
|
||||||
@ -94,10 +103,10 @@ class UpdateCheckerViewModel(
|
|||||||
fun ignoreUpdate() {
|
fun ignoreUpdate() {
|
||||||
val update = _availableUpdate.value ?: return
|
val update = _availableUpdate.value ?: return
|
||||||
_availableUpdate.value = null
|
_availableUpdate.value = null
|
||||||
viewModelScope.launch {
|
scope.launch {
|
||||||
runCatching {
|
runCatching {
|
||||||
val settings = settingsRepository.getCurrentSettings()
|
val settings = settingsRepository.getCurrentSettings()
|
||||||
settingsRepository.updateSettings(settings.copy(ignoredUpdateVersion = update.tag))
|
settingsRepository.updateSettings(settings.copy(ignoredAppUpdateVersion = update.tag))
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
logger.e(e) { "Failed to persist ignored app version" }
|
logger.e(e) { "Failed to persist ignored app version" }
|
||||||
}
|
}
|
||||||
@ -112,7 +121,7 @@ class UpdateCheckerViewModel(
|
|||||||
SemVer.parse(version)
|
SemVer.parse(version)
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun close() {
|
||||||
httpClient.close()
|
httpClient.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -0,0 +1,89 @@
|
|||||||
|
/*
|
||||||
|
* 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.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.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.mikepenz.markdown.m3.Markdown
|
||||||
|
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 org.jetbrains.compose.resources.stringResource
|
||||||
|
import spotube.composeapp.generated.resources.Res
|
||||||
|
import spotube.composeapp.generated.resources.plugin_update_dialog_current_version
|
||||||
|
import spotube.composeapp.generated.resources.plugin_update_dialog_no_notes
|
||||||
|
import spotube.composeapp.generated.resources.plugin_update_dialog_title
|
||||||
|
import spotube.composeapp.generated.resources.update_action_ignore
|
||||||
|
import spotube.composeapp.generated.resources.update_action_update
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prompts the user that a newer version of one of their installed plugins is
|
||||||
|
* available. [onUpdate] should open the plugin's direct download URL; [onIgnore]
|
||||||
|
* persistently silences this specific plugin version so the check moves on to the
|
||||||
|
* next newer one (or another plugin).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun PluginUpdateDialog(
|
||||||
|
update: AvailablePluginUpdate,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onIgnore: () -> Unit,
|
||||||
|
onUpdate: () -> Unit,
|
||||||
|
) {
|
||||||
|
ThemedDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
Res.string.plugin_update_dialog_title,
|
||||||
|
update.pluginName,
|
||||||
|
update.info.latestVersion,
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
OutlineButton(onClick = onIgnore) {
|
||||||
|
Text(stringResource(Res.string.update_action_ignore))
|
||||||
|
}
|
||||||
|
PrimaryButton(onClick = onUpdate) {
|
||||||
|
Text(stringResource(Res.string.update_action_update))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(Res.string.plugin_update_dialog_current_version, update.currentVersion),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp),
|
||||||
|
)
|
||||||
|
if (update.info.releaseNotes.isNotBlank()) {
|
||||||
|
SelectionContainer {
|
||||||
|
Markdown(content = update.info.releaseNotes)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(stringResource(Res.string.plugin_update_dialog_no_notes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,137 @@
|
|||||||
|
/*
|
||||||
|
* 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 co.touchlab.kermit.Logger
|
||||||
|
import dev.krtirtho.plugin_interfaces.plugin_apis.core.PluginUpdateInfo
|
||||||
|
import dev.krtirtho.spotube.core.zipline.PluginService
|
||||||
|
import dev.krtirtho.spotube.modules.plugin.PluginEntry
|
||||||
|
import dev.krtirtho.spotube.modules.plugin.PluginManager
|
||||||
|
import dev.krtirtho.spotube.modules.plugin.PluginManagerStates
|
||||||
|
import dev.krtirtho.spotube.modules.settings.SettingsRepository
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import net.swiftzer.semver.SemVer
|
||||||
|
import org.koin.core.component.KoinComponent
|
||||||
|
|
||||||
|
data class AvailablePluginUpdate(
|
||||||
|
val pluginId: String,
|
||||||
|
val pluginName: String,
|
||||||
|
val currentVersion: String,
|
||||||
|
val info: PluginUpdateInfo,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks the currently active plugins for updates through their own [PluginService.use]
|
||||||
|
* -> `coreAPI.checkPluginUpdates` API whenever the selected plugin set changes, and
|
||||||
|
* exposes at most one pending update at a time for the UI to surface via
|
||||||
|
* [dev.krtirtho.spotube.modules.update.PluginUpdateDialog]. This is an app-scoped
|
||||||
|
* singleton (not a ViewModel) since plugin runtimes are owned by [PluginManager]
|
||||||
|
* independent of any screen's lifecycle.
|
||||||
|
*/
|
||||||
|
class PluginUpdateService(
|
||||||
|
private val pluginManager: PluginManager,
|
||||||
|
private val settingsRepository: SettingsRepository,
|
||||||
|
) : KoinComponent {
|
||||||
|
private val logger = Logger.withTag("PluginUpdateService")
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
|
||||||
|
private val _availableUpdate = MutableStateFlow<AvailablePluginUpdate?>(null)
|
||||||
|
val availableUpdate: StateFlow<AvailablePluginUpdate?> = _availableUpdate.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
scope.launch {
|
||||||
|
pluginManager.ziplineServices.filterNotNull().collect { services ->
|
||||||
|
checkForUpdates(services.values.filterNotNull())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun checkForUpdates(services: List<PluginService>) {
|
||||||
|
// Only surface one plugin update prompt at a time; re-checks happen the
|
||||||
|
// next time the selected plugin set changes.
|
||||||
|
if (_availableUpdate.value != null) return
|
||||||
|
|
||||||
|
val settings = settingsRepository.getCurrentSettings()
|
||||||
|
if (!settings.autoCheckForUpdates) return
|
||||||
|
|
||||||
|
for (service in services.distinctBy { it.pluginId }) {
|
||||||
|
val pluginEntry = findPluginEntry(service.pluginId) ?: continue
|
||||||
|
val currentVersion = parseVersion(pluginEntry.version) ?: continue
|
||||||
|
|
||||||
|
try {
|
||||||
|
val updateInfo = service.use { coreAPI.checkPluginUpdates(currentVersion) } ?: continue
|
||||||
|
val latestVersion = parseVersion(updateInfo.latestVersion)
|
||||||
|
if (latestVersion == null) {
|
||||||
|
logger.w { "Ignoring plugin update with invalid SemVer: ${updateInfo.latestVersion}" }
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
val ignoredVersion = settings.ignoredPluginUpdateVersions[pluginEntry.id]
|
||||||
|
if (latestVersion > currentVersion && updateInfo.latestVersion != ignoredVersion) {
|
||||||
|
_availableUpdate.value = AvailablePluginUpdate(
|
||||||
|
pluginId = pluginEntry.id,
|
||||||
|
pluginName = pluginEntry.name,
|
||||||
|
currentVersion = pluginEntry.version,
|
||||||
|
info = updateInfo,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.w(e) { "Failed to check updates for plugin ${pluginEntry.name}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findPluginEntry(pluginId: String): PluginEntry? {
|
||||||
|
val state = pluginManager.state.value as? PluginManagerStates.Data ?: return null
|
||||||
|
return state.plugins.find { it.id == pluginId }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ignoreUpdate() {
|
||||||
|
val update = _availableUpdate.value ?: return
|
||||||
|
_availableUpdate.value = null
|
||||||
|
scope.launch {
|
||||||
|
runCatching {
|
||||||
|
val settings = settingsRepository.getCurrentSettings()
|
||||||
|
settingsRepository.updateSettings(
|
||||||
|
settings.copy(
|
||||||
|
ignoredPluginUpdateVersions = settings.ignoredPluginUpdateVersions +
|
||||||
|
(update.pluginId to update.info.latestVersion)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}.onFailure { e ->
|
||||||
|
logger.e(e) { "Failed to persist ignored plugin update version" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissUpdate() {
|
||||||
|
_availableUpdate.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseVersion(version: String): SemVer? = runCatching {
|
||||||
|
SemVer.parse(version)
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
@ -9,10 +9,13 @@ import kotlin.time.Duration
|
|||||||
class FakeAudioPlayer : AudioPlayerInterface {
|
class FakeAudioPlayer : AudioPlayerInterface {
|
||||||
private val _playlistFlow = MutableStateFlow<List<MediaItem>>(emptyList())
|
private val _playlistFlow = MutableStateFlow<List<MediaItem>>(emptyList())
|
||||||
override val playlistFlow: StateFlow<List<MediaItem>> = _playlistFlow.asStateFlow()
|
override val playlistFlow: StateFlow<List<MediaItem>> = _playlistFlow.asStateFlow()
|
||||||
override val durationFlow: StateFlow<Duration>
|
|
||||||
get() = TODO("Not yet implemented")
|
private val _durationFlow = MutableStateFlow(Duration.ZERO)
|
||||||
override val positionFlow: StateFlow<Duration>
|
override val durationFlow: StateFlow<Duration> = _durationFlow.asStateFlow()
|
||||||
get() = TODO("Not yet implemented")
|
|
||||||
|
private val _positionFlow = MutableStateFlow(Duration.ZERO)
|
||||||
|
override val positionFlow: StateFlow<Duration> = _positionFlow.asStateFlow()
|
||||||
|
|
||||||
override val bufferingPositionFlow: StateFlow<Duration>
|
override val bufferingPositionFlow: StateFlow<Duration>
|
||||||
get() = TODO("Not yet implemented")
|
get() = TODO("Not yet implemented")
|
||||||
|
|
||||||
@ -152,8 +155,8 @@ class FakeAudioPlayer : AudioPlayerInterface {
|
|||||||
_currentMediaItemFlow.value = _playlistFlow.value.getOrNull(index)
|
_currentMediaItemFlow.value = _playlistFlow.value.getOrNull(index)
|
||||||
}
|
}
|
||||||
|
|
||||||
override val playerStateFlow: StateFlow<PlayerState>
|
private val _playerStateFlow = MutableStateFlow(PlayerState.IDLE)
|
||||||
get() = TODO("Not yet implemented")
|
override val playerStateFlow: StateFlow<PlayerState> = _playerStateFlow.asStateFlow()
|
||||||
|
|
||||||
fun setPlaylist(items: List<MediaItem>) {
|
fun setPlaylist(items: List<MediaItem>) {
|
||||||
_playlistFlow.value = items
|
_playlistFlow.value = items
|
||||||
@ -167,6 +170,18 @@ class FakeAudioPlayer : AudioPlayerInterface {
|
|||||||
_loopStateFlow.value = state
|
_loopStateFlow.value = state
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setPlayerState(state: PlayerState) {
|
||||||
|
_playerStateFlow.value = state
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPosition(position: Duration) {
|
||||||
|
_positionFlow.value = position
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setDuration(duration: Duration) {
|
||||||
|
_durationFlow.value = duration
|
||||||
|
}
|
||||||
|
|
||||||
fun resetCallCounts() {
|
fun resetCallCounts() {
|
||||||
loadCallCount = 0
|
loadCallCount = 0
|
||||||
playCallCount = 0
|
playCallCount = 0
|
||||||
|
|||||||
@ -0,0 +1,408 @@
|
|||||||
|
package dev.krtirtho.spotube.core.scrobble
|
||||||
|
|
||||||
|
import co.touchlab.kermit.Logger
|
||||||
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum
|
||||||
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumType
|
||||||
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
|
||||||
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||||
|
import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI
|
||||||
|
import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleTrack
|
||||||
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||||
|
import dev.krtirtho.spotube.core.audioplayer.FakeAudioPlayer
|
||||||
|
import dev.krtirtho.spotube.core.audioplayer.PlayerState
|
||||||
|
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.ScrobblePluginSource
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||||
|
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.assertTrue
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class ScrobbleManagerTest {
|
||||||
|
|
||||||
|
private lateinit var player: FakeAudioPlayer
|
||||||
|
private lateinit var queue: FakeQueue
|
||||||
|
private lateinit var plugin: FakeScrobblePluginService
|
||||||
|
private lateinit var pluginSource: FakeScrobblePluginSource
|
||||||
|
|
||||||
|
/** Virtual playback position advanced by [playSeconds]; mirrors a real player clock. */
|
||||||
|
private var currentPositionMs = 0L
|
||||||
|
|
||||||
|
@BeforeTest
|
||||||
|
fun setup() {
|
||||||
|
startKoin {
|
||||||
|
modules(module {
|
||||||
|
factory { (tag: String?) -> Logger.withTag(tag ?: "test") }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
player = FakeAudioPlayer()
|
||||||
|
queue = FakeQueue()
|
||||||
|
plugin = FakeScrobblePluginService()
|
||||||
|
pluginSource = FakeScrobblePluginSource(plugin)
|
||||||
|
currentPositionMs = 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterTest
|
||||||
|
fun teardown() {
|
||||||
|
stopKoin()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun TestScope.createManager(): ScrobbleManager {
|
||||||
|
return ScrobbleManager(
|
||||||
|
audioPlayer = player,
|
||||||
|
audioPlayerQueue = queue,
|
||||||
|
scrobblePluginSource = pluginSource,
|
||||||
|
scope = CoroutineScope(backgroundScope.coroutineContext + UnconfinedTestDispatcher(testScheduler)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun playSeconds(seconds: Int) {
|
||||||
|
repeat(seconds) {
|
||||||
|
currentPositionMs += 1_000
|
||||||
|
player.setPosition(currentPositionMs.milliseconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `scrobbles once half of the track has been played`() = runTest {
|
||||||
|
createManager()
|
||||||
|
queue.setCurrentEntry(
|
||||||
|
streamingEntry(
|
||||||
|
id = "t1",
|
||||||
|
durationMs = 200_000,
|
||||||
|
externalUri = "https://open.spotify.com/track/t1",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
player.setPlayerState(PlayerState.PLAYING)
|
||||||
|
player.setDuration(200.seconds)
|
||||||
|
|
||||||
|
playSeconds(99)
|
||||||
|
assertEquals(0, plugin.scrobbled.size)
|
||||||
|
|
||||||
|
playSeconds(1)
|
||||||
|
assertEquals(1, plugin.scrobbled.size)
|
||||||
|
|
||||||
|
val scrobble = plugin.scrobbled.single()
|
||||||
|
assertEquals("t1", scrobble.trackId)
|
||||||
|
assertEquals("artist-t1", scrobble.artistId)
|
||||||
|
assertEquals("album-t1", scrobble.albumId)
|
||||||
|
assertEquals("Track t1", scrobble.trackName)
|
||||||
|
assertEquals("Artist t1", scrobble.artistName)
|
||||||
|
assertEquals("Album t1", scrobble.albumName)
|
||||||
|
assertEquals("spotify", scrobble.streamingProvider)
|
||||||
|
assertEquals(200_000L, scrobble.durationMs)
|
||||||
|
assertTrue(scrobble.timestamp > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `does not scrobble tracks shorter than 30 seconds`() = runTest {
|
||||||
|
createManager()
|
||||||
|
queue.setCurrentEntry(streamingEntry(id = "short", durationMs = 20_000))
|
||||||
|
player.setPlayerState(PlayerState.PLAYING)
|
||||||
|
|
||||||
|
playSeconds(20)
|
||||||
|
|
||||||
|
assertEquals(0, plugin.scrobbled.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `caps the listening threshold at four minutes`() = runTest {
|
||||||
|
createManager()
|
||||||
|
queue.setCurrentEntry(streamingEntry(id = "long", durationMs = 1_800_000))
|
||||||
|
player.setPlayerState(PlayerState.PLAYING)
|
||||||
|
|
||||||
|
playSeconds(239)
|
||||||
|
assertEquals(0, plugin.scrobbled.size)
|
||||||
|
|
||||||
|
playSeconds(1)
|
||||||
|
assertEquals(1, plugin.scrobbled.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `only scrobbles a track once per play`() = runTest {
|
||||||
|
createManager()
|
||||||
|
queue.setCurrentEntry(streamingEntry(id = "t1", durationMs = 40_000))
|
||||||
|
player.setPlayerState(PlayerState.PLAYING)
|
||||||
|
|
||||||
|
playSeconds(20)
|
||||||
|
playSeconds(15)
|
||||||
|
|
||||||
|
assertEquals(1, plugin.scrobbled.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `does not credit forward seeks`() = runTest {
|
||||||
|
createManager()
|
||||||
|
queue.setCurrentEntry(streamingEntry(id = "t1", durationMs = 100_000))
|
||||||
|
player.setPlayerState(PlayerState.PLAYING)
|
||||||
|
|
||||||
|
playSeconds(10)
|
||||||
|
currentPositionMs = 60_000
|
||||||
|
player.setPosition(currentPositionMs.milliseconds)
|
||||||
|
|
||||||
|
playSeconds(39)
|
||||||
|
assertEquals(0, plugin.scrobbled.size)
|
||||||
|
|
||||||
|
playSeconds(1)
|
||||||
|
assertEquals(1, plugin.scrobbled.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `does not credit time while paused`() = runTest {
|
||||||
|
createManager()
|
||||||
|
queue.setCurrentEntry(streamingEntry(id = "t1", durationMs = 100_000))
|
||||||
|
player.setPlayerState(PlayerState.PLAYING)
|
||||||
|
|
||||||
|
playSeconds(30)
|
||||||
|
|
||||||
|
player.setPlayerState(PlayerState.PAUSED)
|
||||||
|
currentPositionMs = 80_000
|
||||||
|
player.setPosition(currentPositionMs.milliseconds)
|
||||||
|
|
||||||
|
player.setPlayerState(PlayerState.PLAYING)
|
||||||
|
playSeconds(19)
|
||||||
|
assertEquals(0, plugin.scrobbled.size)
|
||||||
|
|
||||||
|
playSeconds(1)
|
||||||
|
assertEquals(1, plugin.scrobbled.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `restarting the track allows scrobbling it again`() = runTest {
|
||||||
|
createManager()
|
||||||
|
queue.setCurrentEntry(streamingEntry(id = "t1", durationMs = 40_000))
|
||||||
|
player.setPlayerState(PlayerState.PLAYING)
|
||||||
|
|
||||||
|
playSeconds(20)
|
||||||
|
assertEquals(1, plugin.scrobbled.size)
|
||||||
|
|
||||||
|
// Repeat-one restarts playback from the beginning without a queue entry change.
|
||||||
|
currentPositionMs = 0
|
||||||
|
player.setPosition(0.milliseconds)
|
||||||
|
playSeconds(20)
|
||||||
|
|
||||||
|
assertEquals(2, plugin.scrobbled.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `switching tracks starts a fresh scrobble session`() = runTest {
|
||||||
|
createManager()
|
||||||
|
queue.setCurrentEntry(streamingEntry(id = "t1", durationMs = 40_000))
|
||||||
|
player.setPlayerState(PlayerState.PLAYING)
|
||||||
|
|
||||||
|
playSeconds(20)
|
||||||
|
assertEquals(1, plugin.scrobbled.size)
|
||||||
|
|
||||||
|
queue.setCurrentEntry(streamingEntry(id = "t2", durationMs = 40_000))
|
||||||
|
currentPositionMs = 0
|
||||||
|
player.setPosition(0.milliseconds)
|
||||||
|
playSeconds(20)
|
||||||
|
|
||||||
|
assertEquals(2, plugin.scrobbled.size)
|
||||||
|
assertEquals("t2", plugin.scrobbled.last().trackId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `waits for a scrobble plugin to be selected`() = runTest {
|
||||||
|
pluginSource.setPlugin(null)
|
||||||
|
createManager()
|
||||||
|
queue.setCurrentEntry(streamingEntry(id = "t1", durationMs = 40_000))
|
||||||
|
player.setPlayerState(PlayerState.PLAYING)
|
||||||
|
|
||||||
|
playSeconds(25)
|
||||||
|
assertEquals(0, plugin.scrobbled.size)
|
||||||
|
|
||||||
|
pluginSource.setPlugin(plugin)
|
||||||
|
playSeconds(1)
|
||||||
|
|
||||||
|
assertEquals(1, plugin.scrobbled.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `scrobbles local tracks`() = runTest {
|
||||||
|
createManager()
|
||||||
|
queue.setCurrentEntry(
|
||||||
|
localEntry(
|
||||||
|
name = "Local Song",
|
||||||
|
url = "file:///music/local.mp3",
|
||||||
|
duration = 100_000,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
player.setPlayerState(PlayerState.PLAYING)
|
||||||
|
|
||||||
|
playSeconds(50)
|
||||||
|
|
||||||
|
val scrobble = plugin.scrobbled.single()
|
||||||
|
assertEquals("file:///music/local.mp3", scrobble.trackId)
|
||||||
|
assertEquals("Local Song", scrobble.trackName)
|
||||||
|
assertEquals("Local Artist", scrobble.artistName)
|
||||||
|
assertEquals("Local Album", scrobble.albumName)
|
||||||
|
assertEquals("local", scrobble.streamingProvider)
|
||||||
|
assertEquals(100_000L, scrobble.durationMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `uses unknown provider when the track has no external uri`() = runTest {
|
||||||
|
createManager()
|
||||||
|
queue.setCurrentEntry(streamingEntry(id = "t1", durationMs = 40_000, externalUri = null))
|
||||||
|
player.setPlayerState(PlayerState.PLAYING)
|
||||||
|
|
||||||
|
playSeconds(20)
|
||||||
|
|
||||||
|
assertEquals("unknown", plugin.scrobbled.single().streamingProvider)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- fixtures ---
|
||||||
|
|
||||||
|
private fun streamingTrack(
|
||||||
|
id: String,
|
||||||
|
durationMs: Long = 200_000,
|
||||||
|
externalUri: String? = null,
|
||||||
|
): MetadataTrack {
|
||||||
|
val artist = MetadataArtist.Basic(
|
||||||
|
id = "artist-$id",
|
||||||
|
name = "Artist $id",
|
||||||
|
thumbnails = emptyList(),
|
||||||
|
externalUri = null,
|
||||||
|
)
|
||||||
|
val album = MetadataAlbum.Detailed(
|
||||||
|
releaseDate = "2024",
|
||||||
|
genres = emptyList(),
|
||||||
|
trackCount = 1,
|
||||||
|
id = "album-$id",
|
||||||
|
title = "Album $id",
|
||||||
|
description = null,
|
||||||
|
thumbnails = emptyList(),
|
||||||
|
albumType = MetadataAlbumType.Album,
|
||||||
|
artists = listOf(artist),
|
||||||
|
externalUri = null,
|
||||||
|
)
|
||||||
|
return MetadataTrack(
|
||||||
|
id = id,
|
||||||
|
title = "Track $id",
|
||||||
|
durationMs = durationMs,
|
||||||
|
trackNumber = 1,
|
||||||
|
discNumber = 1,
|
||||||
|
artists = listOf(artist),
|
||||||
|
album = album,
|
||||||
|
thumbnails = null,
|
||||||
|
explicit = false,
|
||||||
|
popularity = null,
|
||||||
|
isrcCode = null,
|
||||||
|
externalUri = externalUri,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun streamingEntry(
|
||||||
|
id: String,
|
||||||
|
durationMs: Long = 200_000,
|
||||||
|
externalUri: String? = null,
|
||||||
|
): QueueEntry.StreamingTrack {
|
||||||
|
return QueueEntry.StreamingTrack(
|
||||||
|
track = streamingTrack(id, durationMs, externalUri),
|
||||||
|
url = "http://127.0.0.1:14769/stream/$id",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun localEntry(
|
||||||
|
name: String = "Local Track",
|
||||||
|
url: String = "file:///music/local.mp3",
|
||||||
|
duration: Long = 180_000,
|
||||||
|
): QueueEntry.LocalTrack {
|
||||||
|
return QueueEntry.LocalTrack(
|
||||||
|
name = name,
|
||||||
|
artists = listOf("Local Artist"),
|
||||||
|
duration = duration,
|
||||||
|
album = "Local Album",
|
||||||
|
coverBytes = null,
|
||||||
|
url = url,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeQueue : AudioPlayerQueue {
|
||||||
|
private val queueState = MutableStateFlow<List<QueueEntry>>(emptyList())
|
||||||
|
private val currentEntryState = MutableStateFlow<QueueEntry?>(null)
|
||||||
|
|
||||||
|
override val queueFlow: StateFlow<List<QueueEntry>> = queueState
|
||||||
|
override val currentQueueEntryFlow: StateFlow<QueueEntry?> = currentEntryState
|
||||||
|
override val currentCollectionEntryFlow: StateFlow<QueueCollectionEntry?> =
|
||||||
|
MutableStateFlow(null)
|
||||||
|
override val collectionHistoryFlow: StateFlow<List<QueueCollectionEntry>> =
|
||||||
|
MutableStateFlow(emptyList())
|
||||||
|
|
||||||
|
fun setCurrentEntry(entry: QueueEntry?) {
|
||||||
|
currentEntryState.value = entry
|
||||||
|
}
|
||||||
|
|
||||||
|
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> = queueState.value
|
||||||
|
override suspend fun getCurrentQueueEntry(): QueueEntry? = currentEntryState.value
|
||||||
|
override suspend fun getCurrentCollectionEntry(): QueueCollectionEntry? = null
|
||||||
|
override suspend fun getCollectionHistory(): List<QueueCollectionEntry> = emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeScrobblePluginSource(
|
||||||
|
initialPlugin: PluginService? = null,
|
||||||
|
) : ScrobblePluginSource {
|
||||||
|
private val selected = MutableStateFlow(initialPlugin)
|
||||||
|
override val selectedScrobblePlugin: StateFlow<PluginService?> = selected
|
||||||
|
|
||||||
|
fun setPlugin(service: PluginService?) {
|
||||||
|
selected.value = service
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeScrobblePluginService : PluginService {
|
||||||
|
val scrobbled = mutableListOf<ScrobbleTrack>()
|
||||||
|
override val pluginId: String = "fake-scrobble-plugin"
|
||||||
|
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 api = object : ScrobbleAPI {
|
||||||
|
override suspend fun scrobble(track: ScrobbleTrack) {
|
||||||
|
scrobbled.add(track)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val scope = PluginServiceScope(mapOf(ScrobbleAPI::class to api))
|
||||||
|
return scope.block()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -58,52 +58,9 @@ private object KoinServicesProvider : KoinComponent {
|
|||||||
val settingsProvider: SettingsProvider get() = get()
|
val settingsProvider: SettingsProvider get() = get()
|
||||||
}
|
}
|
||||||
|
|
||||||
private interface LibC : Library {
|
|
||||||
fun setenv(name: String, value: String, overwrite: Int): Int
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
val INSTANCE: LibC = Native.load("c", LibC::class.java)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* WebKitGTK is only used on Linux (Windows = WebView2, macOS = WKWebView).
|
|
||||||
* The webview is created *after* the window is already mapped and the Tao
|
|
||||||
* render/swap loop is running. WebKitGTK's accelerated-compositing path then
|
|
||||||
* initialises its own GL context in-process, racing Tao's swap thread on the
|
|
||||||
* same Mesa display, which deterministically segfaults `libgallium` on the
|
|
||||||
* next Compose flush. Disabling WebKit's hardware-accelerated compositing
|
|
||||||
* (and its DMABUF renderer) removes that GL context entirely — login pages
|
|
||||||
* render fine in software. Must run before libwebkit2gtk is loaded.
|
|
||||||
*/
|
|
||||||
private fun disableWebKitGpuCompositing() {
|
|
||||||
if (!System.getProperty("os.name").lowercase().contains("linux")) return
|
|
||||||
LibC.INSTANCE.setenv("WEBKIT_DISABLE_COMPOSITING_MODE", "1", 1)
|
|
||||||
LibC.INSTANCE.setenv("WEBKIT_DISABLE_DMABUF_RENDERER", "1", 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Routes `spotube://` deep links into [ExternalUriHandler].
|
|
||||||
* macOS delivers them through the open-URI handler; on Linux/Windows they arrive
|
|
||||||
* as command line arguments (scheme registration is handled by the distribution
|
|
||||||
* packaging, e.g. the `.desktop` file's `Exec %u`).
|
|
||||||
*/
|
|
||||||
private fun handleStartupDeepLinks(args: Array<String>) {
|
|
||||||
runCatching {
|
|
||||||
if (java.awt.Desktop.isDesktopSupported()) {
|
|
||||||
java.awt.Desktop.getDesktop().setOpenURIHandler { event ->
|
|
||||||
ExternalUriHandler.onNewUri(event.uri.toString())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
args.firstOrNull { it.startsWith("spotube:", ignoreCase = true) }
|
|
||||||
?.let(ExternalUriHandler::onNewUri)
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalComposeUiApi::class)
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
fun main(args: Array<String>) {
|
fun main() {
|
||||||
disableWebKitGpuCompositing()
|
|
||||||
handleStartupDeepLinks(args)
|
|
||||||
FileKit.init(appId = "dev.krtirtho.spotube")
|
FileKit.init(appId = "dev.krtirtho.spotube")
|
||||||
initKoin()
|
initKoin()
|
||||||
NewPipeDownloader.init(KoinPathsProvider.paths)
|
NewPipeDownloader.init(KoinPathsProvider.paths)
|
||||||
|
|||||||
@ -26,5 +26,4 @@ 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=JETBRAINS
|
|
||||||
toolchainVersion=21
|
toolchainVersion=21
|
||||||
@ -50,7 +50,7 @@ multiplatformMarkdownRenderer = "0.45.0"
|
|||||||
murmurhash = "0.4.2"
|
murmurhash = "0.4.2"
|
||||||
newpipeextractor = "v0.26.5"
|
newpipeextractor = "v0.26.5"
|
||||||
newpipeExtractorKmp = "1.3.0"
|
newpipeExtractorKmp = "1.3.0"
|
||||||
nucleusNucleusApplication = "2.4.6"
|
nucleusNucleusApplication = "2.5.18"
|
||||||
semver = "2.1.0"
|
semver = "2.1.0"
|
||||||
vlcj = "4.12.1"
|
vlcj = "4.12.1"
|
||||||
vlcjNative = "4.12.0"
|
vlcjNative = "4.12.0"
|
||||||
@ -61,8 +61,8 @@ kotlinStdlib = "2.4.10"
|
|||||||
runner = "1.7.0"
|
runner = "1.7.0"
|
||||||
core = "1.7.0"
|
core = "1.7.0"
|
||||||
filekit = "0.14.2"
|
filekit = "0.14.2"
|
||||||
compose-webview = "1.0.1"
|
compose-webview = "0.1.0-SNAPSHOT"
|
||||||
composeNativeTray = "2.0.3"
|
composeNativeTray = "2.1.6"
|
||||||
koin = "4.2.2"
|
koin = "4.2.2"
|
||||||
multiplatform-nav3-ui = "1.1.1"
|
multiplatform-nav3-ui = "1.1.1"
|
||||||
compose-multiplatform-adaptive = "1.3.0-alpha05"
|
compose-multiplatform-adaptive = "1.3.0-alpha05"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user