Compare commits

...

5 Commits

Author SHA1 Message Date
dependabot[bot]
911d6babc5
Merge d48b0a2f65 into fab60107e3 2026-10-03 04:25:46 -07:00
Kingkor Roy Tirtho
fab60107e3 feat(scrobble): implement ScrobbleManager to handle track scrobbling and integrate with audio player 2026-10-02 20:57:50 +06:00
Kingkor Roy Tirtho
eeaff3c1db refactor(main): simplify main function and remove unused WebKit GPU compositing logic 2026-10-02 20:05:23 +06:00
Kingkor Roy Tirtho
4e2afeaea4 feat(update): refactor update checking mechanism and implement new dialog for app and plugin updates 2026-09-30 09:58:04 +06:00
dependabot[bot]
d48b0a2f65
chore(deps): bump softprops/action-gh-release from 2 to 3
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2 to 3.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/v2...v3)

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

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

View File

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

View File

@ -31,6 +31,7 @@
- `plugin_interfaces` also has a JS target (`browser()`), used by the plugin system.
## 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.
- **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.

View File

@ -198,4 +198,12 @@
<string name="player_go_to_album">Go to album</string>
<string name="player_sleep_timer">Sleep timer</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>

View File

@ -22,30 +22,20 @@ import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.ui.NavDisplay
import com.mikepenz.markdown.m3.Markdown
import dev.krtirtho.spotube.core.navigation.Navigator
import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES
import dev.krtirtho.spotube.core.navigation.rememberNavigationState
import dev.krtirtho.spotube.core.navigation.toEntries
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
import dev.krtirtho.spotube.core.ui.base.OutlineButton
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
import dev.krtirtho.spotube.core.ui.base.ThemedDialog
import dev.krtirtho.spotube.core.ui.base.rememberBaseUITheme
import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope
import dev.krtirtho.spotube.core.ui.theming.SpotubeTheme
@ -53,7 +43,10 @@ import dev.krtirtho.spotube.modules.library.LibraryTab
import dev.krtirtho.spotube.modules.settings.SettingsRepository
import dev.krtirtho.spotube.modules.settings.UserSettings
import dev.krtirtho.spotube.modules.shell.AppShell
import dev.krtirtho.spotube.modules.update.UpdateCheckerViewModel
import dev.krtirtho.spotube.modules.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.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxCd
@ -69,7 +62,6 @@ import dev.krtirtho.spotube.resources.iconsax.User
import kotlinx.coroutines.ExperimentalCoroutinesApi
import org.koin.compose.koinInject
import org.koin.compose.navigation3.koinEntryProvider
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.annotation.KoinExperimentalAPI
interface NavigationItem {
@ -143,8 +135,10 @@ fun App(
) {
val settingsRepository: SettingsRepository = koinInject<SettingsRepository>()
val userSettings by settingsRepository.userSettings.collectAsStateWithLifecycle(initialValue = UserSettings())
val updateCheckerViewModel: UpdateCheckerViewModel = koinViewModel()
val availableUpdate by updateCheckerViewModel.availableUpdate.collectAsStateWithLifecycle()
val appUpdateService: AppUpdateService = koinInject()
val pluginUpdateService: PluginUpdateService = koinInject()
val availableAppUpdate by appUpdateService.availableUpdate.collectAsStateWithLifecycle()
val availablePluginUpdate by pluginUpdateService.availableUpdate.collectAsStateWithLifecycle()
val navigationState = rememberNavigationState(
startRoute = Routes.Home, topLevelRoutes = TOP_LEVEL_ROUTES
@ -182,42 +176,27 @@ fun App(
}
}
content()
if (availableUpdate != null) {
val update = availableUpdate!!
ThemedDialog(
onDismissRequest = updateCheckerViewModel::dismissUpdate,
title = {
Text(
text = "Spotube ${update.tag} is available",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
)
availableAppUpdate?.let { update ->
AppUpdateDialog(
update = update,
onDismiss = appUpdateService::dismissUpdate,
onIgnore = appUpdateService::ignoreUpdate,
onUpdate = {
runCatching { openUrlInBrowser("https://spotube.cc/downloads/") }
appUpdateService.dismissUpdate()
},
actions = {
OutlineButton(onClick = updateCheckerViewModel::ignoreUpdate) {
Text("Ignore")
}
PrimaryButton(
onClick = {
runCatching { openUrlInBrowser("https://spotube.cc/downloads/") }
updateCheckerViewModel.dismissUpdate()
},
) {
Text("Update")
}
)
}
availablePluginUpdate?.let { update ->
PluginUpdateDialog(
update = update,
onDismiss = pluginUpdateService::dismissUpdate,
onIgnore = pluginUpdateService::ignoreUpdate,
onUpdate = {
runCatching { openUrlInBrowser(update.info.directDownloadUrl) }
pluginUpdateService.dismissUpdate()
},
) {
if (update.releaseNotesMarkdown.isNotBlank()) {
SelectionContainer {
Markdown(
content = update.releaseNotesMarkdown,
modifier = Modifier.padding(bottom = 8.dp),
)
}
} else {
Text("See what's new in this release on the Spotube downloads page.")
}
}
)
}
}
}

View File

@ -32,6 +32,7 @@ import dev.krtirtho.spotube.core.remote.RemoteControlClient
import dev.krtirtho.spotube.core.remote.RemoteControlHandler
import dev.krtirtho.spotube.core.remote.RemoteControlService
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.server.AlternativeTracksRepository
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.PluginProvider
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.SavedTracksViewModel
import dev.krtirtho.spotube.modules.search.SearchRepository
import dev.krtirtho.spotube.modules.search.SearchScreenViewModel
import dev.krtirtho.spotube.modules.update.UpdateCheckerViewModel
import dev.krtirtho.spotube.modules.update.AppUpdateService
import dev.krtirtho.spotube.modules.update.PluginUpdateService
import dev.krtirtho.spotube.modules.settings.SettingsProvider
import dev.krtirtho.spotube.modules.settings.SettingsRepository
import dev.krtirtho.spotube.modules.settings.UserSettingsSource
@ -129,6 +132,7 @@ val sharedModules = module {
singleOf(::PluginManager) {
bind<PluginProvider>()
bind<AudioPluginSource>()
bind<ScrobblePluginSource>()
}
viewModelOf(::PluginViewModel)
@ -136,7 +140,10 @@ val sharedModules = module {
singleOf(::SettingsRepository) { bind<UserSettingsSource>() }
viewModelOf(::SettingsViewModel) { bind<SettingsProvider>() }
viewModelOf(::JamSettingsViewModel)
viewModelOf(::UpdateCheckerViewModel)
// Updates
single { AppUpdateService(get()) } withOptions { createdAtStart() }
single { PluginUpdateService(get(), get()) } withOptions { createdAtStart() }
// Downloads
singleOf(::DownloadManager)
@ -264,6 +271,9 @@ val sharedModules = module {
single { DiscordRpcService(get(), get()) } withOptions { createdAtStart() }
// Scrobbling
single { ScrobbleManager(get(), get(), get()) } withOptions { createdAtStart() }
factory { (tag: String?) ->
if (tag != null) {
Logger.withTag(tag)

View File

@ -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)
}
}
}

View File

@ -215,7 +215,6 @@ fun PlatformWebViewScreen(webViewController: WebViewController) {
webViewJsBridge = webViewBridge,
onCreated = { webView ->
navigator.evaluateJavaScript(bridgeBootstrapScript)
},
)
}

View File

@ -76,11 +76,18 @@ interface AudioPluginSource {
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(
val database: Database,
val paths: Paths,
) : KoinComponent, PluginProvider, AudioPluginSource {
) : KoinComponent, PluginProvider, AudioPluginSource, ScrobblePluginSource {
private val logger by injectLogger<PluginManager>()
private val pluginExceptionHandler = CoroutineExceptionHandler { _, exception ->
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 defaultSelectedPlugins = mapOf(
PluginAbility.AUDIO to NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN,
PluginAbility.SCROBBLE to LRCLIB_BUILT_IN_PLUGIN,
)
if (json == null) {
PluginManagerStates.Data(
@ -136,11 +142,21 @@ class PluginManager(
} else it
}.toSet()
res.copy(
plugins = (plugins union BUILT_IN_PLUGINS).toList(),
selectedPlugins = res.selectedPlugins.ifEmpty {
defaultSelectedPlugins
val resolvedPlugins = (plugins union BUILT_IN_PLUGINS).toList()
val resolvedPluginsById = resolvedPlugins.associateBy { it.id }
// Drop selections for plugins that don't actually declare the ability
// (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) {
PluginManagerStates.Data(
@ -256,7 +272,7 @@ class PluginManager(
filterSelectedPluginByType(PluginAbility.AUDIO)
val selectedLyricsPlugin =
filterSelectedPluginByType(PluginAbility.LYRICS)
val selectedScrobblePlugin =
override val selectedScrobblePlugin =
filterSelectedPluginByType(PluginAbility.SCROBBLE)

View File

@ -87,7 +87,8 @@ data class UserSettings(
// Updates
val autoCheckForUpdates: Boolean = true,
val ignoredUpdateVersion: String? = null,
val ignoredAppUpdateVersion: String? = null,
val ignoredPluginUpdateVersions: Map<String, String> = emptyMap(),
)
/**

View File

@ -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))
}
}
}

View File

@ -17,8 +17,6 @@
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
@ -30,6 +28,9 @@ 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.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@ -38,27 +39,35 @@ import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import net.swiftzer.semver.SemVer
import org.koin.core.component.KoinComponent
data class AvailableUpdate(
data class AvailableAppUpdate(
val tag: 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,
) : ViewModel() {
private val logger = Logger.withTag("UpdateChecker")
) : KoinComponent, AutoCloseable {
private val logger = Logger.withTag("AppUpdateService")
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val httpClient = HttpClient(CIO) {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
}
private val _availableUpdate = MutableStateFlow<AvailableUpdate?>(null)
val availableUpdate: StateFlow<AvailableUpdate?> = _availableUpdate.asStateFlow()
private val _availableUpdate = MutableStateFlow<AvailableAppUpdate?>(null)
val availableUpdate: StateFlow<AvailableAppUpdate?> = _availableUpdate.asStateFlow()
init {
viewModelScope.launch {
scope.launch {
checkForUpdate()
}
}
@ -80,8 +89,8 @@ class UpdateCheckerViewModel(
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(
if (latestVersion > currentVersion && release.tagName != settings.ignoredAppUpdateVersion) {
_availableUpdate.value = AvailableAppUpdate(
tag = release.tagName,
releaseNotesMarkdown = release.body.orEmpty(),
)
@ -94,10 +103,10 @@ class UpdateCheckerViewModel(
fun ignoreUpdate() {
val update = _availableUpdate.value ?: return
_availableUpdate.value = null
viewModelScope.launch {
scope.launch {
runCatching {
val settings = settingsRepository.getCurrentSettings()
settingsRepository.updateSettings(settings.copy(ignoredUpdateVersion = update.tag))
settingsRepository.updateSettings(settings.copy(ignoredAppUpdateVersion = update.tag))
}.onFailure { e ->
logger.e(e) { "Failed to persist ignored app version" }
}
@ -112,7 +121,7 @@ class UpdateCheckerViewModel(
SemVer.parse(version)
}.getOrNull()
override fun onCleared() {
override fun close() {
httpClient.close()
}
}

View File

@ -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))
}
}
}

View File

@ -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()
}

View File

@ -9,10 +9,13 @@ import kotlin.time.Duration
class FakeAudioPlayer : AudioPlayerInterface {
private val _playlistFlow = MutableStateFlow<List<MediaItem>>(emptyList())
override val playlistFlow: StateFlow<List<MediaItem>> = _playlistFlow.asStateFlow()
override val durationFlow: StateFlow<Duration>
get() = TODO("Not yet implemented")
override val positionFlow: StateFlow<Duration>
get() = TODO("Not yet implemented")
private val _durationFlow = MutableStateFlow(Duration.ZERO)
override val durationFlow: StateFlow<Duration> = _durationFlow.asStateFlow()
private val _positionFlow = MutableStateFlow(Duration.ZERO)
override val positionFlow: StateFlow<Duration> = _positionFlow.asStateFlow()
override val bufferingPositionFlow: StateFlow<Duration>
get() = TODO("Not yet implemented")
@ -152,8 +155,8 @@ class FakeAudioPlayer : AudioPlayerInterface {
_currentMediaItemFlow.value = _playlistFlow.value.getOrNull(index)
}
override val playerStateFlow: StateFlow<PlayerState>
get() = TODO("Not yet implemented")
private val _playerStateFlow = MutableStateFlow(PlayerState.IDLE)
override val playerStateFlow: StateFlow<PlayerState> = _playerStateFlow.asStateFlow()
fun setPlaylist(items: List<MediaItem>) {
_playlistFlow.value = items
@ -167,6 +170,18 @@ class FakeAudioPlayer : AudioPlayerInterface {
_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() {
loadCallCount = 0
playCallCount = 0

View File

@ -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()
}
}
}

View File

@ -58,52 +58,9 @@ private object KoinServicesProvider : KoinComponent {
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)
fun main(args: Array<String>) {
disableWebKitGpuCompositing()
handleStartupDeepLinks(args)
fun main() {
FileKit.init(appId = "dev.krtirtho.spotube")
initKoin()
NewPipeDownloader.init(KoinPathsProvider.paths)

View File

@ -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.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/476d3c08f4989328dee56d22e202d98d/redirect
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/5a88b04b5e582b332d2e6bc12b45f1b9/redirect
toolchainVendor=JETBRAINS
toolchainVersion=21

View File

@ -50,7 +50,7 @@ multiplatformMarkdownRenderer = "0.45.0"
murmurhash = "0.4.2"
newpipeextractor = "v0.26.5"
newpipeExtractorKmp = "1.3.0"
nucleusNucleusApplication = "2.4.6"
nucleusNucleusApplication = "2.5.18"
semver = "2.1.0"
vlcj = "4.12.1"
vlcjNative = "4.12.0"
@ -61,8 +61,8 @@ kotlinStdlib = "2.4.10"
runner = "1.7.0"
core = "1.7.0"
filekit = "0.14.2"
compose-webview = "1.0.1"
composeNativeTray = "2.0.3"
compose-webview = "0.1.0-SNAPSHOT"
composeNativeTray = "2.1.6"
koin = "4.2.2"
multiplatform-nav3-ui = "1.1.1"
compose-multiplatform-adaptive = "1.3.0-alpha05"