feat(mediacontrol): implement SystemMediaControlService for OS media integration

This commit is contained in:
Kingkor Roy Tirtho 2026-08-16 15:07:28 +06:00
parent 7b506b49e6
commit 94ec831a96
4 changed files with 155 additions and 1 deletions

View File

@ -226,6 +226,7 @@ kotlin {
implementation(libs.nucleus.nucleus.application) implementation(libs.nucleus.nucleus.application)
implementation(libs.nucleus.decorated.window.tao) implementation(libs.nucleus.decorated.window.tao)
implementation(libs.compose.native.tray) implementation(libs.compose.native.tray)
implementation(libs.nucleus.media.control)
} }
} }
} }

View File

@ -19,13 +19,16 @@ package dev.krtirtho.spotube.core.di
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface 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.paths.Paths
import dev.krtirtho.spotube.core.share.JvmShareService import dev.krtirtho.spotube.core.share.JvmShareService
import dev.krtirtho.spotube.core.share.ShareService import dev.krtirtho.spotube.core.share.ShareService
import dev.krtirtho.spotube.core.systemtray.SystemTrayService 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.JvmLocalMediaDiscoveryService
import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaDiscoveryService 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.singleOf
import org.koin.core.module.dsl.withOptions
import org.koin.dsl.module import org.koin.dsl.module
actual val platformModules = module { actual val platformModules = module {
@ -34,4 +37,5 @@ actual val platformModules = module {
single<LocalMediaDiscoveryService> { JvmLocalMediaDiscoveryService() } single<LocalMediaDiscoveryService> { JvmLocalMediaDiscoveryService() }
single<ShareService> { JvmShareService() } single<ShareService> { JvmShareService() }
single { SystemTrayService(get(), get(), get()) } single { SystemTrayService(get(), get(), get()) }
single { SystemMediaControlService(get()) } withOptions { createdAtStart() }
} }

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<SystemMediaControlService>()
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()
}
}

View File

@ -47,7 +47,7 @@ materialKolor = "4.1.1"
murmurhash = "0.4.2" murmurhash = "0.4.2"
newpipeextractor = "v0.26.2" newpipeextractor = "v0.26.2"
newpipeExtractorKmp = "1.3.0" newpipeExtractorKmp = "1.3.0"
nucleusNucleusApplication = "2.4.4" nucleusNucleusApplication = "2.4.6"
semver = "2.1.0" semver = "2.1.0"
vlcj = "4.12.1" vlcj = "4.12.1"
vlcjNative = "4.12.0" 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" } newpipeextractor = { module = "com.github.teamnewpipe:NewPipeExtractor", version.ref = "newpipeextractor" }
nucleus-core-runtime = { module = "dev.nucleusframework:nucleus.core-runtime", version.ref = "nucleusNucleusApplication" } 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-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" } nucleus-nucleus-application = { module = "dev.nucleusframework:nucleus.nucleus-application", version.ref = "nucleusNucleusApplication" }
semver = { module = "net.swiftzer.semver:semver", version.ref = "semver" } semver = { module = "net.swiftzer.semver:semver", version.ref = "semver" }
vlcj = { module = "uk.co.caprica:vlcj", version.ref = "vlcj" } vlcj = { module = "uk.co.caprica:vlcj", version.ref = "vlcj" }