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.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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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() }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
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" }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user