Jelajahi Sumber

add music player widget

Milan Jurkulak 4 hari lalu
induk
melakukan
631b032254

+ 9 - 0
PLUGINS.md

@@ -43,3 +43,12 @@ are read without loading any class, and nothing is loaded until the user switche
 Only enable plugins you trust.
 
 State (which plugins are enabled) is stored in `~/.mjdev/desktop/plugins.json`.
+
+## Built-in widgets
+
+- **Memory** (`builtin.memory`) — donut chart, drawn from a `RemoteDocument`.
+- **Music player** (`builtin.music`) — plays the files of the user's Music folder (mp3, flac, ogg,
+  wav, m4a, aac, opus; sub-folders up to 3 levels) with seek bar and previous / play-pause / next.
+  Off by default; switch it on in the control center Plugins tab. A document cannot hold buttons, so
+  it uses the optional `IDesktopPlugin.content` composable (the document then only gives size and
+  anchor). Audio comes from the klibs.io library ComposeMediaPlayer (`composemediaplayer-audio`).

+ 10 - 5
shared/src/commonMain/kotlin/org/mjdev/desktop/components/desktop/widgets/PluginWidget.kt

@@ -55,11 +55,16 @@ fun PluginWidget(
                     .size(document.width.dp, document.height.dp)
                     .align(RemoteAlign.parse(document.anchor, RemoteAlign.BOTTOM_END).alignment),
         ) {
-            RemoteDocumentPlayer(
-                document = document,
-                variables = variables,
-                modifier = Modifier.fillMaxSize(),
-            )
+            val custom = plugin.content
+            if (custom != null) {
+                custom(context)
+            } else {
+                RemoteDocumentPlayer(
+                    document = document,
+                    variables = variables,
+                    modifier = Modifier.fillMaxSize(),
+                )
+            }
         }
     }
 }

+ 30 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/components/desktop/widgets/music/MusicControlButton.kt

@@ -0,0 +1,30 @@
+package org.mjdev.desktop.components.desktop.widgets.music
+
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.unit.dp
+import org.mjdev.desktop.plugins.music.MusicDefaults
+
+/** One round transport button of the music widget. */
+@Suppress("FunctionName")
+@Composable
+fun MusicControlButton(
+    imageVector: ImageVector,
+    description: String,
+    tint: Color,
+    onClick: () -> Unit,
+) = IconButton(
+    modifier = Modifier.size(MusicDefaults.BUTTON_DP.dp),
+    onClick = onClick,
+) {
+    Icon(
+        imageVector = imageVector,
+        contentDescription = description,
+        tint = tint,
+    )
+}

+ 94 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/components/desktop/widgets/music/MusicPlayerController.kt

@@ -0,0 +1,94 @@
+package org.mjdev.desktop.components.desktop.widgets.music
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import io.github.kdroidfilter.composemediaplayer.audio.AudioPlayer
+import io.github.kdroidfilter.composemediaplayer.audio.AudioPlayerState
+import io.github.kdroidfilter.composemediaplayer.audio.ErrorListener
+import org.mjdev.desktop.log.Log
+import org.mjdev.desktop.plugins.music.MusicQueue
+import org.mjdev.desktop.plugins.music.MusicTrack
+
+/**
+ * Drives one [AudioPlayer] over a [MusicQueue]. The UI reads [queue] and the live player state and
+ * calls the transport functions; a track that ends (or fails) never makes the player skip in a
+ * loop: only [wantsPlayback] together with a finished track moves to the next one.
+ */
+class MusicPlayerController(
+    private val player: AudioPlayer,
+) {
+    /** Tracks and the cursor; replaced when the music folder was scanned. */
+    var queue by mutableStateOf(MusicQueue())
+        private set
+
+    /** True while the user wants music, set by play and cleared by pause or a playback error. */
+    var wantsPlayback by mutableStateOf(false)
+        private set
+
+    init {
+        player.setOnErrorListener(
+            object : ErrorListener {
+                override fun onError(message: String?) {
+                    Log.d("Music player error: $message")
+                    wantsPlayback = false
+                }
+            },
+        )
+    }
+
+    /** Replaces the queue with [tracks], starting at the first one. Playback is left untouched. */
+    fun setTracks(tracks: List<MusicTrack>) {
+        queue = MusicQueue(tracks)
+    }
+
+    /** Pauses a playing track, resumes a paused one, or starts the current track when idle. */
+    fun toggle(state: AudioPlayerState) {
+        when (state) {
+            AudioPlayerState.PLAYING -> pause()
+            AudioPlayerState.PAUSED -> resume()
+            else -> startCurrent()
+        }
+    }
+
+    /** Moves to the next track and plays it when music was playing. */
+    fun next() = moveTo(queue.next())
+
+    /** Moves to the previous track and plays it when music was playing. */
+    fun previous() = moveTo(queue.previous())
+
+    /** Called when the current track reached its end: continues with the next track. */
+    fun onTrackEnded() {
+        if (wantsPlayback) next()
+    }
+
+    /** Seeks the current track to [positionMs]. */
+    fun seekTo(positionMs: Long) = player.seekTo(positionMs)
+
+    /** Stops playback and frees the native player. */
+    fun release() {
+        wantsPlayback = false
+        player.release()
+    }
+
+    private fun pause() {
+        wantsPlayback = false
+        player.pause()
+    }
+
+    private fun resume() {
+        wantsPlayback = true
+        player.play()
+    }
+
+    private fun startCurrent() {
+        val track = queue.current ?: return
+        wantsPlayback = true
+        player.play(track.path)
+    }
+
+    private fun moveTo(target: MusicQueue) {
+        queue = target
+        if (wantsPlayback) startCurrent()
+    }
+}

+ 129 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/components/desktop/widgets/music/MusicPlayerWidget.kt

@@ -0,0 +1,129 @@
+package org.mjdev.desktop.components.desktop.widgets.music
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Pause
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material.icons.filled.SkipNext
+import androidx.compose.material.icons.filled.SkipPrevious
+import androidx.compose.material3.Slider
+import androidx.compose.material3.SliderDefaults
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.produceState
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import io.github.kdroidfilter.composemediaplayer.audio.AudioPlayerState
+import io.github.kdroidfilter.composemediaplayer.audio.rememberAudioPlayerLiveState
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.mjdev.desktop.context.DesktopContextScope.Companion.withDesktopContext
+import org.mjdev.desktop.plugins.music.MusicDefaults
+import org.mjdev.desktop.plugins.music.MusicLibrary
+
+/**
+ * Music player card for the desktop: shows the current track of the user's music folder with a
+ * seek bar and previous / play-pause / next buttons. Colors follow the wallpaper palette.
+ */
+@Suppress("FunctionName")
+@Composable
+fun MusicPlayerWidget(modifier: Modifier = Modifier) = withDesktopContext {
+    val live = rememberAudioPlayerLiveState()
+    val controller = remember(live.player) { MusicPlayerController(live.player) }
+    val musicDir = currentUser.userDirs.musicDirectory
+    val tracks by produceState(emptyList(), musicDir) {
+        value = withContext(Dispatchers.Default) { MusicLibrary.scan(musicDir) }
+    }
+    LaunchedEffect(tracks) { controller.setTracks(tracks) }
+    DisposableEffect(controller) { onDispose { controller.release() } }
+
+    // The last PLAYING sample tells whether a following IDLE is a finished track, not a manual stop.
+    var nearEnd by remember { mutableStateOf(false) }
+    LaunchedEffect(live.position, live.duration, live.state) {
+        if (live.state == AudioPlayerState.PLAYING) {
+            nearEnd = live.duration > 0L && live.position + MusicDefaults.END_TOLERANCE_MS >= live.duration
+        }
+    }
+    LaunchedEffect(live.state) {
+        if (live.state == AudioPlayerState.IDLE && nearEnd) {
+            nearEnd = false
+            controller.onTrackEnded()
+        }
+    }
+
+    var dragFraction by remember { mutableStateOf<Float?>(null) }
+    val progress = if (live.duration > 0L) (live.position.toFloat() / live.duration).coerceIn(0f, 1f) else 0f
+    val isPlaying = live.state == AudioPlayerState.PLAYING
+
+    Column(
+        modifier =
+            modifier
+                .fillMaxSize()
+                .clip(RoundedCornerShape(MusicDefaults.CORNER_DP.dp))
+                .background(backgroundColor.copy(alpha = MusicDefaults.CARD_ALPHA))
+                .padding(MusicDefaults.PADDING_DP.dp),
+        verticalArrangement = Arrangement.spacedBy(MusicDefaults.SPACING_DP.dp),
+    ) {
+        Text(
+            text = controller.queue.current?.title ?: MusicDefaults.EMPTY_TITLE,
+            color = textColor,
+            fontSize = MusicDefaults.TITLE_SP.sp,
+            maxLines = 1,
+            overflow = TextOverflow.Ellipsis,
+        )
+        Text(
+            text = "${formatMusicTime(live.position)} / ${formatMusicTime(live.duration)}",
+            color = textColor.copy(alpha = MusicDefaults.CARD_ALPHA),
+            fontSize = MusicDefaults.SUBTITLE_SP.sp,
+        )
+        Slider(
+            modifier = Modifier.fillMaxWidth(),
+            value = dragFraction ?: progress,
+            enabled = live.duration > 0L,
+            onValueChange = { fraction -> dragFraction = fraction },
+            onValueChangeFinished = {
+                dragFraction?.let { fraction -> controller.seekTo((fraction * live.duration).toLong()) }
+                dragFraction = null
+            },
+            colors =
+                SliderDefaults.colors(
+                    thumbColor = iconsTintColor,
+                    activeTrackColor = iconsTintColor,
+                ),
+        )
+        Row(
+            modifier = Modifier.fillMaxWidth(),
+            horizontalArrangement = Arrangement.Center,
+            verticalAlignment = Alignment.CenterVertically,
+        ) {
+            MusicControlButton(Icons.Filled.SkipPrevious, MusicDefaults.PREVIOUS_LABEL, iconsTintColor) {
+                controller.previous()
+            }
+            MusicControlButton(
+                imageVector = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
+                description = if (isPlaying) MusicDefaults.PAUSE_LABEL else MusicDefaults.PLAY_LABEL,
+                tint = iconsTintColor,
+            ) { controller.toggle(live.state) }
+            MusicControlButton(Icons.Filled.SkipNext, MusicDefaults.NEXT_LABEL, iconsTintColor) {
+                controller.next()
+            }
+        }
+    }
+}

+ 11 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/components/desktop/widgets/music/MusicTimeFormat.kt

@@ -0,0 +1,11 @@
+package org.mjdev.desktop.components.desktop.widgets.music
+
+import org.mjdev.desktop.plugins.music.MusicDefaults
+
+/** Formats a position in milliseconds as `m:ss`; negative values count as zero. */
+fun formatMusicTime(millis: Long): String {
+    val totalSeconds = millis.coerceAtLeast(0L) / MusicDefaults.MILLIS_IN_SECOND
+    val minutes = totalSeconds / MusicDefaults.SECONDS_IN_MINUTE
+    val seconds = totalSeconds % MusicDefaults.SECONDS_IN_MINUTE
+    return "$minutes:${seconds.toString().padStart(2, '0')}"
+}

+ 8 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/plugins/IDesktopPlugin.kt

@@ -1,5 +1,6 @@
 package org.mjdev.desktop.plugins
 
+import androidx.compose.runtime.Composable
 import org.mjdev.desktop.context.IDesktopContext
 import org.mjdev.desktop.plugins.remote.RemoteDocument
 import org.mjdev.desktop.plugins.remote.RemoteVariables
@@ -20,6 +21,13 @@ interface IDesktopPlugin {
     val refreshMs: Long
         get() = PluginDefaults.DEFAULT_REFRESH_MS
 
+    /**
+     * Optional interactive UI. When not null the desktop draws it instead of the [document] (the
+     * document then only supplies size and anchor); used by built-in widgets that need buttons.
+     */
+    val content: (@Composable (IDesktopContext) -> Unit)?
+        get() = null
+
     /** Produces the current values the [document] binds to. Called off the UI thread. */
     fun variables(context: IDesktopContext): RemoteVariables
 

+ 64 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/plugins/music/MusicDefaults.kt

@@ -0,0 +1,64 @@
+package org.mjdev.desktop.plugins.music
+
+/** Constants of the music player widget: what is playable, how deep to scan and how it is laid out. */
+object MusicDefaults {
+    /** File extensions (lower case, no dot) the widget treats as playable music. */
+    val EXTENSIONS: Set<String> = setOf("mp3", "flac", "ogg", "wav", "m4a", "aac", "opus")
+
+    /** How many sub-folder levels of the music folder are scanned. */
+    const val MAX_SCAN_DEPTH = 3
+
+    /** Upper bound of tracks read from the music folder, protects against huge libraries. */
+    const val MAX_TRACKS = 5_000
+
+    /** Widget width in dp. */
+    const val WIDTH_DP = 320f
+
+    /** Widget height in dp. */
+    const val HEIGHT_DP = 170f
+
+    /** Corner radius of the widget card in dp. */
+    const val CORNER_DP = 20
+
+    /** Inner padding of the widget card in dp. */
+    const val PADDING_DP = 14
+
+    /** Gap between rows of the widget in dp. */
+    const val SPACING_DP = 6
+
+    /** Size of the transport buttons in dp. */
+    const val BUTTON_DP = 44
+
+    /** Alpha of the card background so the wallpaper shows through. */
+    const val CARD_ALPHA = 0.55f
+
+    /** Title text size in sp. */
+    const val TITLE_SP = 16
+
+    /** Secondary text size in sp. */
+    const val SUBTITLE_SP = 12
+
+    /** The player counts a track as finished when it stops this close (ms) to its end. */
+    const val END_TOLERANCE_MS = 1_500L
+
+    /** Milliseconds in one second, used to format the time labels. */
+    const val MILLIS_IN_SECOND = 1_000L
+
+    /** Seconds in one minute, used to format the time labels. */
+    const val SECONDS_IN_MINUTE = 60L
+
+    /** Label shown when the music folder holds no playable file. */
+    const val EMPTY_TITLE = "No music found"
+
+    /** Label of the previous-track button (accessibility). */
+    const val PREVIOUS_LABEL = "Previous"
+
+    /** Label of the play button (accessibility). */
+    const val PLAY_LABEL = "Play"
+
+    /** Label of the pause button (accessibility). */
+    const val PAUSE_LABEL = "Pause"
+
+    /** Label of the next-track button (accessibility). */
+    const val NEXT_LABEL = "Next"
+}

+ 40 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/plugins/music/MusicLibrary.kt

@@ -0,0 +1,40 @@
+package org.mjdev.desktop.plugins.music
+
+import okio.Path
+import org.mjdev.desktop.extensions.PathExt.absolutePath
+import org.mjdev.desktop.extensions.PathExt.dirsOnly
+import org.mjdev.desktop.extensions.PathExt.extension
+import org.mjdev.desktop.extensions.PathExt.filesOnly
+import org.mjdev.desktop.extensions.PathExt.nameWithoutExtension
+import org.mjdev.desktop.extensions.PathExt.sortedByName
+
+/** Finds the playable files of a music folder. Reads only, nothing is ever changed or deleted. */
+object MusicLibrary {
+    /**
+     * Scans [root] and its sub-folders (up to [MusicDefaults.MAX_SCAN_DEPTH] levels) and returns
+     * the playable tracks sorted by path, at most [MusicDefaults.MAX_TRACKS] of them.
+     */
+    fun scan(root: Path): List<MusicTrack> = runCatching { collect(root, 0) }
+        .getOrDefault(emptyList())
+        .take(MusicDefaults.MAX_TRACKS)
+
+    private fun collect(
+        dir: Path,
+        depth: Int,
+    ): List<MusicTrack> {
+        val files =
+            dir.filesOnly
+                .filter { file -> file.extension.lowercase() in MusicDefaults.EXTENSIONS }
+                .sortedByName()
+                .map { file -> file.toTrack() }
+        if (depth >= MusicDefaults.MAX_SCAN_DEPTH) return files
+        val nested = dir.dirsOnly.sortedByName().flatMap { sub -> collect(sub, depth + 1) }
+        return files + nested
+    }
+
+    private fun Path.toTrack() = MusicTrack(
+        id = absolutePath,
+        title = nameWithoutExtension,
+        path = absolutePath,
+    )
+}

+ 25 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/plugins/music/MusicQueue.kt

@@ -0,0 +1,25 @@
+package org.mjdev.desktop.plugins.music
+
+/**
+ * Immutable play queue over [tracks] with a cursor at [index]. Moving past either end wraps
+ * around, so next/previous always land on a track while the queue is not empty.
+ */
+data class MusicQueue(
+    /** Tracks in play order. */
+    val tracks: List<MusicTrack> = emptyList(),
+    /** Position of the current track in [tracks]. */
+    val index: Int = 0,
+) {
+    /** The track the cursor points at, or null for an empty queue. */
+    val current: MusicTrack?
+        get() = tracks.getOrNull(index)
+
+    /** Queue moved to the following track, wrapping to the first one after the last. */
+    fun next(): MusicQueue = moveBy(1)
+
+    /** Queue moved to the preceding track, wrapping to the last one before the first. */
+    fun previous(): MusicQueue = moveBy(-1)
+
+    private fun moveBy(step: Int): MusicQueue =
+        if (tracks.isEmpty()) this else copy(index = (index + step).mod(tracks.size))
+}

+ 11 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/plugins/music/MusicTrack.kt

@@ -0,0 +1,11 @@
+package org.mjdev.desktop.plugins.music
+
+/** One playable file found in the music folder. */
+data class MusicTrack(
+    /** Stable id of the track, its absolute path. */
+    val id: String,
+    /** Label shown in the widget, the file name without extension. */
+    val title: String,
+    /** Absolute path handed to the audio player. */
+    val path: String,
+)

+ 55 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/plugins/music/MusicWidgetPlugin.kt

@@ -0,0 +1,55 @@
+package org.mjdev.desktop.plugins.music
+
+import androidx.compose.runtime.Composable
+import org.mjdev.desktop.components.desktop.widgets.music.MusicPlayerWidget
+import org.mjdev.desktop.context.IDesktopContext
+import org.mjdev.desktop.plugins.IDesktopPlugin
+import org.mjdev.desktop.plugins.PluginMetadata
+import org.mjdev.desktop.plugins.remote.RemoteAlign
+import org.mjdev.desktop.plugins.remote.RemoteDocument
+import org.mjdev.desktop.plugins.remote.RemoteVariables
+
+/**
+ * Built-in music player widget: plays the files of the user's music folder. A document cannot
+ * hold buttons, so the plugin draws its own [content]; the empty [document] only carries the size
+ * and the place on the desktop.
+ */
+class MusicWidgetPlugin : IDesktopPlugin {
+    override val document: RemoteDocument =
+        RemoteDocument(
+            width = MusicDefaults.WIDTH_DP,
+            height = MusicDefaults.HEIGHT_DP,
+            anchor = RemoteAlign.BOTTOM_START.name,
+        )
+
+    override val content: (@Composable (IDesktopContext) -> Unit) = { MusicPlayerWidget() }
+
+    override fun variables(context: IDesktopContext): RemoteVariables = RemoteVariables()
+
+    companion object {
+        /** Stable id used to persist whether the widget is enabled. */
+        const val ID = "builtin.music"
+
+        /** Label of the plugin in the control center. */
+        const val TITLE = "Music player"
+
+        /** Description shown when the plugin row is expanded. */
+        const val DESCRIPTION = "Plays the music in your Music folder directly on the desktop."
+
+        /** Author shown in the plugin details. */
+        const val AUTHOR = "mjdev"
+
+        /** Version shown in the plugin details. */
+        const val VERSION = "1.0"
+
+        /** Metadata of the built-in plugin. */
+        val METADATA =
+            PluginMetadata(
+                id = ID,
+                name = TITLE,
+                description = DESCRIPTION,
+                version = VERSION,
+                author = AUTHOR,
+            )
+    }
+}

+ 17 - 0
shared/src/commonTest/kotlin/org/mjdev/desktop/components/desktop/widgets/music/MusicTimeFormatTest.kt

@@ -0,0 +1,17 @@
+package org.mjdev.desktop.components.desktop.widgets.music
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+/** Regression tests of the `m:ss` label, including the unknown (zero / negative) duration. */
+class MusicTimeFormatTest {
+    @Test
+    fun padsSecondsToTwoDigits() {
+        assertEquals("3:05", formatMusicTime(185_000L))
+    }
+
+    @Test
+    fun negativeCountsAsZero() {
+        assertEquals("0:00", formatMusicTime(-1L))
+    }
+}

+ 28 - 0
shared/src/commonTest/kotlin/org/mjdev/desktop/plugins/music/MusicQueueTest.kt

@@ -0,0 +1,28 @@
+package org.mjdev.desktop.plugins.music
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+/** Regression tests of the queue cursor: wrap-around and the empty queue must never crash. */
+class MusicQueueTest {
+    private val tracks = listOf("a", "b", "c").map { name -> MusicTrack(id = name, title = name, path = "/m/$name") }
+
+    @Test
+    fun nextWrapsFromLastToFirst() {
+        assertEquals("a", MusicQueue(tracks, index = 2).next().current?.id)
+    }
+
+    @Test
+    fun previousWrapsFromFirstToLast() {
+        assertEquals("c", MusicQueue(tracks, index = 0).previous().current?.id)
+    }
+
+    @Test
+    fun emptyQueueHasNoCurrentAndStaysPut() {
+        val empty = MusicQueue()
+        assertNull(empty.current)
+        assertEquals(empty, empty.next())
+        assertEquals(empty, empty.previous())
+    }
+}

+ 17 - 2
shared/src/desktopMain/kotlin/org/mjdev/desktop/managers/plugins/PluginManager.kt

@@ -15,6 +15,7 @@ import org.mjdev.desktop.plugins.IDesktopPlugin
 import org.mjdev.desktop.plugins.MemoryWidgetPlugin
 import org.mjdev.desktop.plugins.PluginDefaults
 import org.mjdev.desktop.plugins.PluginInfo
+import org.mjdev.desktop.plugins.music.MusicWidgetPlugin
 import java.io.File
 import java.net.URLClassLoader
 import java.util.ServiceLoader
@@ -43,6 +44,13 @@ class PluginManager(
 
     private val builtIn = MemoryWidgetPlugin()
 
+    // every plugin shipped inside the desktop, by id; the memory chart above is kept as before
+    private val builtInPlugins: Map<String, IDesktopPlugin> =
+        mapOf(
+            MemoryWidgetPlugin.ID to builtIn,
+            MusicWidgetPlugin.ID to MusicWidgetPlugin(),
+        )
+
     private val jars = mutableMapOf<String, File>()
     private val loaders = mutableMapOf<String, URLClassLoader>()
     private val instances = mutableMapOf<String, IDesktopPlugin>()
@@ -58,7 +66,7 @@ class PluginManager(
 
     override fun refresh() {
         jars.clear()
-        val found = mutableListOf(builtInInfo())
+        val found = (listOf(builtInInfo(), musicInfo())).toMutableList()
         scanJars().forEach { jar ->
             val metadata = PluginJarReader.readMetadata(jar)
             when {
@@ -104,7 +112,7 @@ class PluginManager(
         return when {
             info == null || !info.enabled -> null
             instances.containsKey(id) -> instances[id]
-            info.builtIn -> builtIn.also { instances[id] = it; it.onEnabled() }
+            info.builtIn -> builtInPlugins[id]?.also { instances[id] = it; it.onEnabled() }
             else -> load(id)
         }
     }
@@ -148,6 +156,13 @@ class PluginManager(
         source = BUILT_IN_SOURCE,
     )
 
+    private fun musicInfo() = PluginInfo(
+        metadata = MusicWidgetPlugin.METADATA,
+        enabled = MusicWidgetPlugin.ID in state.enabled,
+        builtIn = true,
+        source = BUILT_IN_SOURCE,
+    )
+
     private fun scanJars(): List<File> {
         val dir = File(pluginsDir)
         if (!dir.exists()) dir.mkdirs()