diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 6935717e..34b20dd2 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -226,6 +226,7 @@ kotlin { implementation(libs.nucleus.nucleus.application) implementation(libs.nucleus.decorated.window.tao) implementation(libs.compose.native.tray) + implementation(libs.nucleus.media.control) } } } diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/di/Modules.jvm.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/di/Modules.jvm.kt index 73d65bc6..8c8c1936 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/di/Modules.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/di/Modules.jvm.kt @@ -19,13 +19,16 @@ package dev.krtirtho.spotube.core.di import dev.krtirtho.spotube.core.audioplayer.AudioPlayer import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface +import dev.krtirtho.spotube.core.mediacontrol.SystemMediaControlService import dev.krtirtho.spotube.core.paths.Paths import dev.krtirtho.spotube.core.share.JvmShareService import dev.krtirtho.spotube.core.share.ShareService import dev.krtirtho.spotube.core.systemtray.SystemTrayService import dev.krtirtho.spotube.modules.library.local_tracks.media.JvmLocalMediaDiscoveryService import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaDiscoveryService +import org.koin.core.module.dsl.createdAtStart import org.koin.core.module.dsl.singleOf +import org.koin.core.module.dsl.withOptions import org.koin.dsl.module actual val platformModules = module { @@ -34,4 +37,5 @@ actual val platformModules = module { single { JvmLocalMediaDiscoveryService() } single { JvmShareService() } single { SystemTrayService(get(), get(), get()) } + single { SystemMediaControlService(get()) } withOptions { createdAtStart() } } diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/mediacontrol/SystemMediaControlService.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/mediacontrol/SystemMediaControlService.kt new file mode 100644 index 00000000..05281c43 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/mediacontrol/SystemMediaControlService.kt @@ -0,0 +1,148 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.core.mediacontrol + +import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface +import dev.krtirtho.spotube.core.audioplayer.MediaItem +import dev.krtirtho.spotube.core.audioplayer.PlayerState +import dev.krtirtho.spotube.core.di.injectLogger +import dev.nucleusframework.media.control.MediaControlEvent +import dev.nucleusframework.media.control.MediaControlService +import dev.nucleusframework.media.control.MediaMetadata +import dev.nucleusframework.media.control.MediaPlaybackState +import dev.nucleusframework.media.control.MediaPlaybackStatus +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import kotlin.time.Duration.Companion.milliseconds + +/** + * Publishes now-playing metadata and playback state to the OS media center + * (MPRIS on Linux, Now Playing on macOS, SMTC on Windows) and forwards media + * key commands back into the [AudioPlayerInterface]. + * + * Events from the OS are delivered on the Swing EDT; playback commands are + * dispatched onto [scope] so they reach the player's suspend API. + */ +class SystemMediaControlService( + private val audioPlayer: AudioPlayerInterface, +) : KoinComponent, AutoCloseable { + + private val logger by injectLogger() + + private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + + private val enabled: Boolean = MediaControlService.isAvailable() + + init { + if (enabled) { + MediaControlService.configure() + MediaControlService.attach(::onMediaControlEvent) + observePlayer() + } else { + logger.i { "System media control backend unavailable; skipping integration" } + } + } + + private fun onMediaControlEvent(event: MediaControlEvent) { + when (event) { + MediaControlEvent.Play -> scope.launch { audioPlayer.play() } + MediaControlEvent.Pause -> scope.launch { audioPlayer.pause() } + MediaControlEvent.Toggle -> scope.launch { + if (audioPlayer.playerStateFlow.value == PlayerState.PLAYING) { + audioPlayer.pause() + } else { + audioPlayer.play() + } + } + MediaControlEvent.Next -> scope.launch { audioPlayer.skipToNext() } + MediaControlEvent.Previous -> scope.launch { audioPlayer.skipToPrevious() } + MediaControlEvent.Stop -> scope.launch { audioPlayer.stop() } + is MediaControlEvent.SeekBy -> scope.launch { + audioPlayer.seekTo(audioPlayer.positionFlow.value + event.offsetMs.milliseconds) + } + is MediaControlEvent.SetPosition -> scope.launch { + audioPlayer.seekTo(event.positionMs.milliseconds) + } + is MediaControlEvent.SetVolume -> scope.launch { + audioPlayer.setVolume(event.volume.toFloat()) + } + is MediaControlEvent.OpenUri, + MediaControlEvent.Raise, + MediaControlEvent.Quit, + -> Unit + } + } + + private fun observePlayer() { + scope.launch { + audioPlayer.currentMediaItemFlow.collect { item -> + if (item != null) { + MediaControlService.setMetadata(item.toMediaMetadata()) + } + } + } + + scope.launch { + combine( + audioPlayer.playerStateFlow, + audioPlayer.positionFlow, + ) { state, position -> + MediaPlaybackState( + status = state.toMediaPlaybackStatus(), + positionMs = position.inWholeMilliseconds, + ) + }.collect { MediaControlService.setPlaybackState(it) } + } + + scope.launch { + audioPlayer.volumeFlow.collect { volume -> + MediaControlService.setVolume(volume.toDouble()) + } + } + } + + private fun MediaItem.toMediaMetadata(): MediaMetadata { + return MediaMetadata( + title = title, + artist = artist, + album = album, + coverUrl = coverURL.ifBlank { null }, + duration = duration.inWholeMilliseconds, + ) + } + + private fun PlayerState.toMediaPlaybackStatus(): MediaPlaybackStatus { + return when (this) { + PlayerState.PLAYING -> MediaPlaybackStatus.PLAYING + PlayerState.PAUSED -> MediaPlaybackStatus.PAUSED + else -> MediaPlaybackStatus.STOPPED + } + } + + override fun close() { + if (enabled) { + MediaControlService.detach() + } + scope.cancel() + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a750a3be..04084c5a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -47,7 +47,7 @@ materialKolor = "4.1.1" murmurhash = "0.4.2" newpipeextractor = "v0.26.2" newpipeExtractorKmp = "1.3.0" -nucleusNucleusApplication = "2.4.4" +nucleusNucleusApplication = "2.4.6" semver = "2.1.0" vlcj = "4.12.1" vlcjNative = "4.12.0" @@ -123,6 +123,7 @@ newpipe-extractor-kmp = { module = "io.github.yushosei:newpipe-extractor-kmp", v newpipeextractor = { module = "com.github.teamnewpipe:NewPipeExtractor", version.ref = "newpipeextractor" } nucleus-core-runtime = { module = "dev.nucleusframework:nucleus.core-runtime", version.ref = "nucleusNucleusApplication" } nucleus-decorated-window-tao = { module = "dev.nucleusframework:nucleus.decorated-window-tao", version.ref = "nucleusNucleusApplication" } +nucleus-media-control = { module = "dev.nucleusframework:nucleus.media-control", version.ref = "nucleusNucleusApplication" } nucleus-nucleus-application = { module = "dev.nucleusframework:nucleus.nucleus-application", version.ref = "nucleusNucleusApplication" } semver = { module = "net.swiftzer.semver:semver", version.ref = "semver" } vlcj = { module = "uk.co.caprica:vlcj", version.ref = "vlcj" }