mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-09-20 06:34:00 +00:00
feat(mediacontrol): implement SystemMediaControlService for OS media integration
This commit is contained in:
parent
7b506b49e6
commit
94ec831a96
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<LocalMediaDiscoveryService> { JvmLocalMediaDiscoveryService() }
|
||||
single<ShareService> { JvmShareService() }
|
||||
single { SystemTrayService(get(), get(), get()) }
|
||||
single { SystemMediaControlService(get()) } withOptions { createdAtStart() }
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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" }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user