Milan Jurkulak 4 дней назад
Родитель
Сommit
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.
 Only enable plugins you trust.
 
 
 State (which plugins are enabled) is stored in `~/.mjdev/desktop/plugins.json`.
 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)
                     .size(document.width.dp, document.height.dp)
                     .align(RemoteAlign.parse(document.anchor, RemoteAlign.BOTTOM_END).alignment),
                     .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
 package org.mjdev.desktop.plugins
 
 
+import androidx.compose.runtime.Composable
 import org.mjdev.desktop.context.IDesktopContext
 import org.mjdev.desktop.context.IDesktopContext
 import org.mjdev.desktop.plugins.remote.RemoteDocument
 import org.mjdev.desktop.plugins.remote.RemoteDocument
 import org.mjdev.desktop.plugins.remote.RemoteVariables
 import org.mjdev.desktop.plugins.remote.RemoteVariables
@@ -20,6 +21,13 @@ interface IDesktopPlugin {
     val refreshMs: Long
     val refreshMs: Long
         get() = PluginDefaults.DEFAULT_REFRESH_MS
         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. */
     /** Produces the current values the [document] binds to. Called off the UI thread. */
     fun variables(context: IDesktopContext): RemoteVariables
     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.MemoryWidgetPlugin
 import org.mjdev.desktop.plugins.PluginDefaults
 import org.mjdev.desktop.plugins.PluginDefaults
 import org.mjdev.desktop.plugins.PluginInfo
 import org.mjdev.desktop.plugins.PluginInfo
+import org.mjdev.desktop.plugins.music.MusicWidgetPlugin
 import java.io.File
 import java.io.File
 import java.net.URLClassLoader
 import java.net.URLClassLoader
 import java.util.ServiceLoader
 import java.util.ServiceLoader
@@ -43,6 +44,13 @@ class PluginManager(
 
 
     private val builtIn = MemoryWidgetPlugin()
     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 jars = mutableMapOf<String, File>()
     private val loaders = mutableMapOf<String, URLClassLoader>()
     private val loaders = mutableMapOf<String, URLClassLoader>()
     private val instances = mutableMapOf<String, IDesktopPlugin>()
     private val instances = mutableMapOf<String, IDesktopPlugin>()
@@ -58,7 +66,7 @@ class PluginManager(
 
 
     override fun refresh() {
     override fun refresh() {
         jars.clear()
         jars.clear()
-        val found = mutableListOf(builtInInfo())
+        val found = (listOf(builtInInfo(), musicInfo())).toMutableList()
         scanJars().forEach { jar ->
         scanJars().forEach { jar ->
             val metadata = PluginJarReader.readMetadata(jar)
             val metadata = PluginJarReader.readMetadata(jar)
             when {
             when {
@@ -104,7 +112,7 @@ class PluginManager(
         return when {
         return when {
             info == null || !info.enabled -> null
             info == null || !info.enabled -> null
             instances.containsKey(id) -> instances[id]
             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)
             else -> load(id)
         }
         }
     }
     }
@@ -148,6 +156,13 @@ class PluginManager(
         source = BUILT_IN_SOURCE,
         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> {
     private fun scanJars(): List<File> {
         val dir = File(pluginsDir)
         val dir = File(pluginsDir)
         if (!dir.exists()) dir.mkdirs()
         if (!dir.exists()) dir.mkdirs()