diff --git a/docs/IPC.md b/docs/IPC.md index b46f3a014..c9bc62d61 100644 --- a/docs/IPC.md +++ b/docs/IPC.md @@ -188,6 +188,94 @@ dms ipc call mpris playPause dms ipc call mpris next ``` +## Target: `media` + +Custom media source control for non-MPRIS media players. This allows external tools (like rmpc, cmus, mpv) to push media metadata to DMS and control playback through shell commands. + +The custom source integrates seamlessly with MPRIS players - DMS automatically selects the active source based on which one has content loaded. Custom source takes precedence when it has a track title set. + +### Functions + +**`update `** +- Update the current media state with JSON data +- Parameters: `jsonData` - JSON string with media information +- Supported JSON fields: + - `title` - Track title + - `artist` - Artist name + - `album` - Album name + - `artUrl` - URL or path to album art + - `state` - Playback state (0=stopped, 1=playing, 2=paused) + - `position` / `elapsed` - Current position in seconds + - `length` / `duration` - Total length in seconds + - `volume` - Volume level (0.0-1.0) + - `available` - Whether the source is available (true/false) + - `identity` - Player identity/name + - `sourceId` - Unique source identifier +- Returns: "MEDIA_UPDATE_SUCCESS" or error message + +**`setCommands `** +- Set shell commands to execute for playback control +- Parameters: `jsonData` - JSON string with command mappings +- Supported commands: + - `play` - Command to start playback + - `pause` - Command to pause playback + - `toggle` - Command to toggle play/pause + - `next` - Command to skip to next track + - `previous` / `prev` - Command to go to previous track +- Returns: "MEDIA_COMMANDS_SET_SUCCESS" or error message + +**`clear`** +- Clear the current media state +- Returns: "MEDIA_CLEAR_SUCCESS" + +**`status`** +- Get current custom media source status +- Returns: JSON object with current media state + +**`play`** +- Execute the configured play command + +**`pause`** +- Execute the configured pause command + +**`playPause`** +- Execute the configured toggle command + +**`next`** +- Execute the configured next track command + +**`previous`** +- Execute the configured previous track command + +### Examples + +**Setting up rmpc integration with hooks (recommended):** + +1. Create a hook script (`~/.config/rmpc/dms_hook`): +```bash +#!/bin/bash +# Set playback commands (idempotent - safe to call every time) +dms ipc call media setCommands '{"play":"rmpc play","pause":"rmpc pause","toggle":"rmpc togglepause","next":"rmpc next","prev":"rmpc prev"}' + +# Push current track info to DMS +dms ipc call media update "{\"title\":\"$TITLE\",\"artist\":\"$ARTIST\",\"album\":\"$ALBUM\",\"duration\":$DURATION,\"state\":1,\"available\":true,\"sourceId\":\"rmpc\",\"identity\":\"RMPC\"}" +``` + +2. Make it executable and add to rmpc's `on_song_change` hook in `~/.config/rmpc/config.ron`: +```ron +on_song_change: [ + "~/.config/rmpc/dms_hook" +] +``` + +The hook sets playback commands on every call (idempotent) and pushes track updates. This ensures commands are always available even after DMS restarts. + +**Query current status:** +```bash +dms ipc call media status +# Returns: {"available":true,"sourceId":"rmpc","title":"Song","artist":"Artist",...} +``` + ## Target: `lock` Screen lock control and status. diff --git a/quickshell/DMSShellIPC.qml b/quickshell/DMSShellIPC.qml index e86e11b4c..c082d058f 100644 --- a/quickshell/DMSShellIPC.qml +++ b/quickshell/DMSShellIPC.qml @@ -372,6 +372,68 @@ Item { target: "mpris" } + IpcHandler { + function update(jsonData: string): string { + try { + const data = JSON.parse(jsonData) + CustomMediaSource.update(data) + return "MEDIA_UPDATE_SUCCESS" + } catch (e) { + return "MEDIA_UPDATE_ERROR: " + e.message + } + } + + function setCommands(jsonData: string): string { + try { + const cmds = JSON.parse(jsonData) + CustomMediaSource.setCommands(cmds) + return "MEDIA_COMMANDS_SET_SUCCESS" + } catch (e) { + return "MEDIA_COMMANDS_ERROR: " + e.message + } + } + + function clear(): string { + CustomMediaSource.clear() + return "MEDIA_CLEAR_SUCCESS" + } + + function status(): string { + return JSON.stringify({ + available: CustomMediaSource.available, + sourceId: CustomMediaSource.sourceId, + title: CustomMediaSource.trackTitle, + artist: CustomMediaSource.trackArtist, + album: CustomMediaSource.trackAlbum, + state: CustomMediaSource.playbackState, + position: CustomMediaSource.position, + length: CustomMediaSource.length + }) + } + + function play(): void { + CustomMediaSource.play() + } + + function pause(): void { + CustomMediaSource.pause() + } + + function playPause(): void { + CustomMediaSource.togglePlaying() + } + + function next(): void { + CustomMediaSource.next() + } + + function previous(): void { + CustomMediaSource.previous() + } + + target: "media" + } + IpcHandler { function toggle(provider: string): string { if (!provider) diff --git a/quickshell/Modules/DankBar/WidgetHost.qml b/quickshell/Modules/DankBar/WidgetHost.qml index bef4ec115..0cede8d08 100644 --- a/quickshell/Modules/DankBar/WidgetHost.qml +++ b/quickshell/Modules/DankBar/WidgetHost.qml @@ -29,7 +29,7 @@ Loader { readonly property bool orientationMatches: (axis?.isVertical ?? false) === isInColumn - active: orientationMatches && getWidgetVisible(widgetId, DgopService.dgopAvailable) && (widgetId !== "music" || MprisController.activePlayer !== null) + active: orientationMatches && getWidgetVisible(widgetId, DgopService.dgopAvailable) && (widgetId !== "music" || MprisController.currentPlayer !== null) sourceComponent: getWidgetComponent(widgetId, components) opacity: getWidgetEnabled(widgetData?.enabled) ? 1 : 0 diff --git a/quickshell/Modules/DankBar/Widgets/AudioVisualization.qml b/quickshell/Modules/DankBar/Widgets/AudioVisualization.qml index 52ace330f..a7dfe14ac 100644 --- a/quickshell/Modules/DankBar/Widgets/AudioVisualization.qml +++ b/quickshell/Modules/DankBar/Widgets/AudioVisualization.qml @@ -6,9 +6,9 @@ import qs.Services Item { id: root - readonly property MprisPlayer activePlayer: MprisController.activePlayer + readonly property var activePlayer: MprisController.currentPlayer readonly property bool hasActiveMedia: activePlayer !== null - readonly property bool isPlaying: hasActiveMedia && activePlayer && activePlayer.playbackState === MprisPlaybackState.Playing + readonly property bool isPlaying: MprisController.currentIsPlaying width: 20 height: Theme.iconSize diff --git a/quickshell/Modules/DankBar/Widgets/Media.qml b/quickshell/Modules/DankBar/Widgets/Media.qml index fc1634a01..0a96d52a9 100644 --- a/quickshell/Modules/DankBar/Widgets/Media.qml +++ b/quickshell/Modules/DankBar/Widgets/Media.qml @@ -8,7 +8,7 @@ import qs.Widgets BasePill { id: root - readonly property MprisPlayer activePlayer: MprisController.activePlayer + readonly property var activePlayer: MprisController.currentPlayer readonly property bool playerAvailable: activePlayer !== null readonly property bool __isChromeBrowser: { if (!activePlayer?.identity) @@ -191,15 +191,15 @@ BasePill { height: 24 radius: 12 anchors.horizontalCenter: parent.horizontalCenter - color: activePlayer && activePlayer.playbackState === 1 ? Theme.primary : Theme.primaryHover + color: MprisController.currentIsPlaying ? Theme.primary : Theme.primaryHover visible: root.playerAvailable opacity: activePlayer ? 1 : 0.3 DankIcon { anchors.centerIn: parent - name: activePlayer && activePlayer.playbackState === 1 ? "pause" : "play_arrow" + name: MprisController.currentIsPlaying ? "pause" : "play_arrow" size: 14 - color: activePlayer && activePlayer.playbackState === 1 ? Theme.background : Theme.primary + color: MprisController.currentIsPlaying ? Theme.background : Theme.primary } MouseArea { @@ -381,15 +381,15 @@ BasePill { height: 24 radius: 12 anchors.verticalCenter: parent.verticalCenter - color: activePlayer && activePlayer.playbackState === 1 ? Theme.primary : Theme.primaryHover + color: MprisController.currentIsPlaying ? Theme.primary : Theme.primaryHover visible: root.playerAvailable opacity: activePlayer ? 1 : 0.3 DankIcon { anchors.centerIn: parent - name: activePlayer && activePlayer.playbackState === 1 ? "pause" : "play_arrow" + name: MprisController.currentIsPlaying ? "pause" : "play_arrow" size: 14 - color: activePlayer && activePlayer.playbackState === 1 ? Theme.background : Theme.primary + color: MprisController.currentIsPlaying ? Theme.background : Theme.primary } MouseArea { diff --git a/quickshell/Modules/DankDash/DankDashPopout.qml b/quickshell/Modules/DankDash/DankDashPopout.qml index 03600971b..0feddfce4 100644 --- a/quickshell/Modules/DankDash/DankDashPopout.qml +++ b/quickshell/Modules/DankDash/DankDashPopout.qml @@ -28,6 +28,7 @@ DankPopout { property bool __dropdownRightEdge: false property var __dropdownPlayer: null property var __dropdownPlayers: [] + property bool __dropdownPlayerIsCustom: false function __showVolumeDropdown(pos, rightEdge, player, players) { __dropdownAnchor = pos; @@ -43,11 +44,12 @@ DankPopout { __dropdownType = 2; } - function __showPlayersDropdown(pos, rightEdge, player, players) { + function __showPlayersDropdown(pos, rightEdge, player, players, isCustom) { __dropdownAnchor = pos; __dropdownRightEdge = rightEdge; __dropdownPlayer = player; __dropdownPlayers = players; + __dropdownPlayerIsCustom = isCustom || false; __dropdownType = 3; } @@ -82,6 +84,7 @@ DankPopout { isRightEdge: root.__dropdownRightEdge activePlayer: root.__dropdownPlayer allPlayers: root.__dropdownPlayers + activeIsCustom: root.__dropdownPlayerIsCustom onCloseRequested: root.__hideDropdowns() onPanelEntered: root.__stopCloseTimer() onPanelExited: root.__startCloseTimer() @@ -95,12 +98,12 @@ DankPopout { AudioService.sink.audio.volume = volume; } } - onPlayerSelected: player => { - const currentPlayer = MprisController.activePlayer; - if (currentPlayer && currentPlayer !== player && currentPlayer.canPause) { + onPlayerSelected: source => { + const currentPlayer = MprisController.currentPlayer; + if (currentPlayer && !source?.isCustom && currentPlayer !== source?.player && currentPlayer.canPause) { currentPlayer.pause(); } - MprisController.activePlayer = player; + MprisController.selectSource(source); root.__hideDropdowns(); } onDeviceSelected: device => { @@ -382,8 +385,8 @@ DankPopout { onShowAudioDevicesDropdown: (pos, screen, rightEdge) => { root.__showAudioDevicesDropdown(pos, rightEdge); } - onShowPlayersDropdown: (pos, screen, rightEdge, player, players) => { - root.__showPlayersDropdown(pos, rightEdge, player, players); + onShowPlayersDropdown: (pos, screen, rightEdge, player, players, isCustom) => { + root.__showPlayersDropdown(pos, rightEdge, player, players, isCustom); } onHideDropdowns: root.__hideDropdowns() onVolumeButtonExited: root.__startCloseTimer() diff --git a/quickshell/Modules/DankDash/MediaDropdownOverlay.qml b/quickshell/Modules/DankDash/MediaDropdownOverlay.qml index a38ac5518..ef83ec05c 100644 --- a/quickshell/Modules/DankDash/MediaDropdownOverlay.qml +++ b/quickshell/Modules/DankDash/MediaDropdownOverlay.qml @@ -14,6 +14,7 @@ Item { property int dropdownType: 0 property var activePlayer: null property var allPlayers: [] + property bool activeIsCustom: false // True if activePlayer is CustomMediaSource property point anchorPos: Qt.point(0, 0) property bool isRightEdge: false @@ -407,12 +408,19 @@ Item { required property var modelData required property int index + readonly property bool isActive: { + if (modelData?.isCustom) { + return activeIsCustom + } + return modelData?.player === activePlayer + } + width: parent.width height: 48 radius: Theme.cornerRadius color: playerMouseArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency) - border.color: modelData === activePlayer ? Theme.primary : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2) - border.width: modelData === activePlayer ? 2 : 1 + border.color: isActive ? Theme.primary : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2) + border.width: isActive ? 2 : 1 Row { anchors.left: parent.left @@ -422,9 +430,9 @@ Item { width: parent.width - Theme.spacingM * 2 DankIcon { - name: "music_note" + name: modelData?.isCustom ? "queue_music" : "music_note" size: 20 - color: modelData === activePlayer ? Theme.primary : Theme.surfaceText + color: isActive ? Theme.primary : Theme.surfaceText anchors.verticalCenter: parent.verticalCenter } @@ -436,28 +444,18 @@ Item { text: { if (!modelData) return "Unknown Player"; - const identity = modelData.identity || "Unknown Player"; - const trackTitle = modelData.trackTitle || ""; - return trackTitle.length > 0 ? identity + " - " + trackTitle : identity; + return modelData.identity || "Unknown Player"; } font.pixelSize: Theme.fontSizeMedium color: Theme.surfaceText - font.weight: modelData === activePlayer ? Font.Medium : Font.Normal + font.weight: isActive ? Font.Medium : Font.Normal elide: Text.ElideRight wrapMode: Text.NoWrap width: parent.width } StyledText { - text: { - if (!modelData) - return ""; - const artist = modelData.trackArtist || ""; - const isActive = modelData === activePlayer; - if (artist.length > 0) - return artist + (isActive ? " (Active)" : ""); - return isActive ? "Active" : "Available"; - } + text: isActive ? "Active" : "Available" font.pixelSize: Theme.fontSizeSmall color: Theme.surfaceVariantText elide: Text.ElideRight diff --git a/quickshell/Modules/DankDash/MediaPlayerTab.qml b/quickshell/Modules/DankDash/MediaPlayerTab.qml index 6a72bb9fe..d2eecb198 100644 --- a/quickshell/Modules/DankDash/MediaPlayerTab.qml +++ b/quickshell/Modules/DankDash/MediaPlayerTab.qml @@ -13,8 +13,9 @@ Item { LayoutMirroring.enabled: I18n.isRtl LayoutMirroring.childrenInherit: true - property MprisPlayer activePlayer: MprisController.activePlayer + property var activePlayer: MprisController.currentPlayer property var allPlayers: MprisController.availablePlayers + property var allSources: MprisController.allSources property var targetScreen: null property real popoutX: 0 property real popoutY: 0 @@ -26,7 +27,7 @@ Item { signal showVolumeDropdown(point pos, var screen, bool rightEdge, var player, var players) signal showAudioDevicesDropdown(point pos, var screen, bool rightEdge) - signal showPlayersDropdown(point pos, var screen, bool rightEdge, var player, var players) + signal showPlayersDropdown(point pos, var screen, bool rightEdge, var player, var players, bool isCustom) signal hideDropdowns signal volumeButtonExited @@ -66,7 +67,7 @@ Item { // Derived "no players" state: always correct, no timers. readonly property int _playerCount: allPlayers ? allPlayers.length : 0 readonly property bool _noneAvailable: _playerCount === 0 - readonly property bool _trulyIdle: activePlayer && activePlayer.playbackState === MprisPlaybackState.Stopped && !activePlayer.trackTitle && !activePlayer.trackArtist + readonly property bool _trulyIdle: activePlayer && activePlayer.playbackState === CustomMediaSource.stateStopped && !activePlayer.trackTitle && !activePlayer.trackArtist readonly property bool showNoPlayerNow: (!_switchHold) && (_noneAvailable || _trulyIdle) property bool _switchHold: false @@ -191,7 +192,7 @@ Item { Timer { interval: 1000 - running: activePlayer?.playbackState === MprisPlaybackState.Playing && !isSeeking + running: activePlayer?.playbackState === CustomMediaSource.statePlaying && !isSeeking repeat: true onTriggered: activePlayer?.positionChanged() } @@ -516,7 +517,7 @@ Item { DankIcon { anchors.centerIn: parent - name: activePlayer && activePlayer.playbackState === MprisPlaybackState.Playing ? "pause" : "play_arrow" + name: MprisController.currentIsPlaying ? "pause" : "play_arrow" size: 28 color: Theme.background weight: 500 @@ -641,7 +642,7 @@ Item { border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.3) border.width: 1 z: 100 - visible: (allPlayers?.length || 0) >= 1 + visible: (allSources?.length || 0) >= 1 DankIcon { anchors.centerIn: parent @@ -666,7 +667,7 @@ Item { const btnY = playerSelectorButton.y + playerSelectorButton.height / 2; const screenX = buttonsOnRight ? (popoutX + popoutWidth) : popoutX; const screenY = popoutY + contentOffsetY + btnY; - showPlayersDropdown(Qt.point(screenX, screenY), targetScreen, buttonsOnRight, activePlayer, allPlayers); + showPlayersDropdown(Qt.point(screenX, screenY), targetScreen, buttonsOnRight, activePlayer, allSources, MprisController.useCustomSource); } onEntered: sharedTooltip.show("Media Players", playerSelectorButton, 0, 0, isRightEdge ? "right" : "left") onExited: sharedTooltip.hide() diff --git a/quickshell/Modules/DankDash/Overview/MediaOverviewCard.qml b/quickshell/Modules/DankDash/Overview/MediaOverviewCard.qml index d1f8e96c8..b5b032923 100644 --- a/quickshell/Modules/DankDash/Overview/MediaOverviewCard.qml +++ b/quickshell/Modules/DankDash/Overview/MediaOverviewCard.qml @@ -12,7 +12,7 @@ Card { signal clicked() - property MprisPlayer activePlayer: MprisController.activePlayer + property var activePlayer: MprisController.currentPlayer property real currentPosition: activePlayer?.positionSupported ? activePlayer.position : 0 property real displayPosition: currentPosition @@ -33,7 +33,7 @@ Card { Timer { interval: 300 - running: activePlayer?.playbackState === MprisPlaybackState.Playing && !isSeeking + running: activePlayer?.playbackState === CustomMediaSource.statePlaying && !isSeeking repeat: true onTriggered: activePlayer?.positionSupported && activePlayer.positionChanged() } @@ -165,7 +165,7 @@ Card { DankIcon { anchors.centerIn: parent - name: activePlayer?.playbackState === MprisPlaybackState.Playing ? "pause" : "play_arrow" + name: MprisController.currentIsPlaying ? "pause" : "play_arrow" size: 16 color: Theme.background } diff --git a/quickshell/Modules/Lock/LockScreenContent.qml b/quickshell/Modules/Lock/LockScreenContent.qml index e8ba84f52..ad56762e3 100644 --- a/quickshell/Modules/Lock/LockScreenContent.qml +++ b/quickshell/Modules/Lock/LockScreenContent.qml @@ -1176,12 +1176,12 @@ Item { height: 24 color: Qt.rgba(255, 255, 255, 0.2) anchors.verticalCenter: parent.verticalCenter - visible: MprisController.activePlayer && SettingsData.lockScreenShowMediaPlayer + visible: MprisController.currentPlayer && SettingsData.lockScreenShowMediaPlayer } Row { spacing: Theme.spacingS - visible: MprisController.activePlayer && SettingsData.lockScreenShowMediaPlayer + visible: MprisController.currentPlayer && SettingsData.lockScreenShowMediaPlayer anchors.verticalCenter: parent.verticalCenter Item { @@ -1190,7 +1190,7 @@ Item { anchors.verticalCenter: parent.verticalCenter Loader { - active: MprisController.activePlayer?.playbackState === MprisPlaybackState.Playing + active: MprisController.currentPlayer?.playbackState === CustomMediaSource.statePlaying sourceComponent: Component { Ref { @@ -1200,7 +1200,7 @@ Item { } Timer { - running: !CavaService.cavaAvailable && MprisController.activePlayer?.playbackState === MprisPlaybackState.Playing + running: !CavaService.cavaAvailable && MprisController.currentPlayer?.playbackState === CustomMediaSource.statePlaying interval: 256 repeat: true onTriggered: { @@ -1219,7 +1219,7 @@ Item { width: 2 height: { - if (MprisController.activePlayer?.playbackState === MprisPlaybackState.Playing && CavaService.values.length > index) { + if (MprisController.currentPlayer?.playbackState === CustomMediaSource.statePlaying && CavaService.values.length > index) { const rawLevel = CavaService.values[index] || 0; const scaledLevel = Math.sqrt(Math.min(Math.max(rawLevel, 0), 100) / 100) * 100; const maxHeight = Theme.iconSize - 2; @@ -1246,7 +1246,7 @@ Item { StyledText { text: { - const player = MprisController.activePlayer; + const player = MprisController.currentPlayer; if (!player?.trackTitle) return ""; const title = player.trackTitle; @@ -1273,8 +1273,8 @@ Item { radius: 10 anchors.verticalCenter: parent.verticalCenter color: prevArea.containsMouse ? Qt.rgba(255, 255, 255, 0.2) : "transparent" - visible: MprisController.activePlayer - opacity: (MprisController.activePlayer?.canGoPrevious ?? false) ? 1 : 0.3 + visible: MprisController.currentPlayer + opacity: (MprisController.currentPlayer?.canGoPrevious ?? false) ? 1 : 0.3 DankIcon { anchors.centerIn: parent @@ -1286,10 +1286,10 @@ Item { MouseArea { id: prevArea anchors.fill: parent - enabled: MprisController.activePlayer?.canGoPrevious ?? false + enabled: MprisController.currentPlayer?.canGoPrevious ?? false hoverEnabled: enabled cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor - onClicked: MprisController.activePlayer?.previous() + onClicked: MprisController.currentPlayer?.previous() } } @@ -1298,22 +1298,22 @@ Item { height: 24 radius: 12 anchors.verticalCenter: parent.verticalCenter - color: MprisController.activePlayer?.playbackState === MprisPlaybackState.Playing ? Qt.rgba(255, 255, 255, 0.9) : Qt.rgba(255, 255, 255, 0.2) - visible: MprisController.activePlayer + color: MprisController.currentPlayer?.playbackState === CustomMediaSource.statePlaying ? Qt.rgba(255, 255, 255, 0.9) : Qt.rgba(255, 255, 255, 0.2) + visible: MprisController.currentPlayer DankIcon { anchors.centerIn: parent - name: MprisController.activePlayer?.playbackState === MprisPlaybackState.Playing ? "pause" : "play_arrow" + name: MprisController.currentPlayer?.playbackState === CustomMediaSource.statePlaying ? "pause" : "play_arrow" size: 14 - color: MprisController.activePlayer?.playbackState === MprisPlaybackState.Playing ? "black" : "white" + color: MprisController.currentPlayer?.playbackState === CustomMediaSource.statePlaying ? "black" : "white" } MouseArea { anchors.fill: parent - enabled: MprisController.activePlayer + enabled: MprisController.currentPlayer hoverEnabled: enabled cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor - onClicked: MprisController.activePlayer?.togglePlaying() + onClicked: MprisController.currentPlayer?.togglePlaying() } } @@ -1323,8 +1323,8 @@ Item { radius: 10 anchors.verticalCenter: parent.verticalCenter color: nextArea.containsMouse ? Qt.rgba(255, 255, 255, 0.2) : "transparent" - visible: MprisController.activePlayer - opacity: (MprisController.activePlayer?.canGoNext ?? false) ? 1 : 0.3 + visible: MprisController.currentPlayer + opacity: (MprisController.currentPlayer?.canGoNext ?? false) ? 1 : 0.3 DankIcon { anchors.centerIn: parent @@ -1336,10 +1336,10 @@ Item { MouseArea { id: nextArea anchors.fill: parent - enabled: MprisController.activePlayer?.canGoNext ?? false + enabled: MprisController.currentPlayer?.canGoNext ?? false hoverEnabled: enabled cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor - onClicked: MprisController.activePlayer?.next() + onClicked: MprisController.currentPlayer?.next() } } } @@ -1350,7 +1350,7 @@ Item { height: 24 color: Qt.rgba(255, 255, 255, 0.2) anchors.verticalCenter: parent.verticalCenter - visible: MprisController.activePlayer && SettingsData.lockScreenShowMediaPlayer && WeatherService.weather.available + visible: MprisController.currentPlayer && SettingsData.lockScreenShowMediaPlayer && WeatherService.weather.available } Row { diff --git a/quickshell/Modules/OSD/MediaPlaybackOSD.qml b/quickshell/Modules/OSD/MediaPlaybackOSD.qml index 904c68fc8..5d4174921 100644 --- a/quickshell/Modules/OSD/MediaPlaybackOSD.qml +++ b/quickshell/Modules/OSD/MediaPlaybackOSD.qml @@ -9,7 +9,8 @@ DankOSD { id: root readonly property bool useVertical: isVerticalLayout - readonly property var player: MprisController.activePlayer + readonly property var player: MprisController.currentPlayer + readonly property bool isCustomSource: MprisController.useCustomSource osdWidth: useVertical ? (40 + Theme.spacingS * 2) : Math.min(280, Screen.width - Theme.spacingM * 2) osdHeight: useVertical ? (Theme.iconSize * 2) : (40 + Theme.spacingS * 2) @@ -26,11 +27,11 @@ DankOSD { } let icon = "music_note"; switch (player.playbackState) { - case MprisPlaybackState.Playing: + case CustomMediaSource.statePlaying: icon = "play_arrow"; break; - case MprisPlaybackState.Paused: - case MprisPlaybackState.Stopped: + case CustomMediaSource.statePaused: + case CustomMediaSource.stateStopped: icon = "pause"; break; } @@ -97,42 +98,60 @@ DankOSD { } } - Connections { - target: player - - function handleUpdate() { - if (!root.player?.trackTitle) - return; - if (!SettingsData.osdMediaPlaybackEnabled) - return; + // Unified update handler for both MPRIS and CustomSource + function handlePlaybackUpdate() { + if (!root.player?.trackTitle) + return; + if (!SettingsData.osdMediaPlaybackEnabled) + return; - root.updatePlaybackIcon(); - TrackArtService.loadArtwork(player.trackArtUrl); + root.updatePlaybackIcon(); + TrackArtService.loadArtwork(player.trackArtUrl); - if (!player.trackArtUrl || player.trackArtUrl === "") { - root.show(); - return; - } - if (TrackArtService.loading) { - root._pendingShow = true; - return; - } - if (!TrackArtService._bgArtSource || artPreloader.status === Image.Ready) { - root.show(); - return; - } + if (!player.trackArtUrl || player.trackArtUrl === "") { + root.show(); + return; + } + if (TrackArtService.loading) { root._pendingShow = true; + return; + } + if (!TrackArtService._bgArtSource || artPreloader.status === Image.Ready) { + root.show(); + return; } + root._pendingShow = true; + } + + // Connections for MPRIS player + Connections { + target: isCustomSource ? null : player function onTrackArtUrlChanged() { TrackArtService.loadArtwork(player.trackArtUrl); } function onIsPlayingChanged() { - handleUpdate(); + handlePlaybackUpdate(); } function onTrackChanged() { if (!useVertical) - handleUpdate(); + handlePlaybackUpdate(); + } + } + + // Connections for CustomMediaSource + Connections { + target: isCustomSource ? CustomMediaSource : null + + function onPlayingStateChanged() { + handlePlaybackUpdate(); + } + function onTrackInfoChanged() { + if (!useVertical) + handlePlaybackUpdate(); + } + function onArtworkChanged() { + TrackArtService.loadArtwork(player.trackArtUrl); } } diff --git a/quickshell/Modules/OSD/MediaVolumeOSD.qml b/quickshell/Modules/OSD/MediaVolumeOSD.qml index 147d81e81..4da03a9cf 100644 --- a/quickshell/Modules/OSD/MediaVolumeOSD.qml +++ b/quickshell/Modules/OSD/MediaVolumeOSD.qml @@ -7,7 +7,7 @@ DankOSD { id: root readonly property bool useVertical: isVerticalLayout - readonly property var player: MprisController.activePlayer + readonly property var player: MprisController.currentPlayer readonly property bool volumeSupported: player?.volumeSupported ?? false property bool _suppressNewPlayer: false property int _displayVolume: 0 diff --git a/quickshell/Services/CustomMediaSource.qml b/quickshell/Services/CustomMediaSource.qml new file mode 100644 index 000000000..0d8ef72f7 --- /dev/null +++ b/quickshell/Services/CustomMediaSource.qml @@ -0,0 +1,151 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import qs.Common + +Singleton { + id: root + + // Signals for OSD and other listeners + signal playingStateChanged() + signal trackInfoChanged() + signal artworkChanged() + + // Media state (populated via IPC hooks) + property string trackTitle: "" + property string trackArtist: "" + property string trackAlbum: "" + property string trackArtUrl: "" + property int playbackState: 0 // 0=stopped, 1=playing, 2=paused + property real position: 0 + property real length: 0 + property real volume: 0.5 + property bool volumeSupported: true + property bool canControl: true + property bool canPlay: true + property bool canPause: true + property bool canGoNext: true + property bool canGoPrevious: true + property bool canSeek: false + property bool shuffle: false + property bool shuffleSupported: false + property int loopState: 0 + property bool loopSupported: false + property bool available: false + property string identity: "" + property string sourceId: "" + + // Internal tracking for change detection + property string _lastTitle: "" + property bool _lastIsPlaying: false + + // Playback state constants + readonly property int stateStopped: 0 + readonly property int statePlaying: 1 + readonly property int statePaused: 2 + + // Commands to execute for playback control + property string _playCommand: "" + property string _pauseCommand: "" + property string _toggleCommand: "" + property string _nextCommand: "" + property string _previousCommand: "" + property string _volumeCommand: "" + + readonly property bool isPlaying: playbackState === 1 + + // Monitor isPlaying changes + onIsPlayingChanged: { + if (isPlaying !== _lastIsPlaying) { + _lastIsPlaying = isPlaying + root.playingStateChanged() + } + } + + // Monitor trackTitle changes + onTrackTitleChanged: { + if (trackTitle !== _lastTitle) { + _lastTitle = trackTitle + root.trackInfoChanged() + } + } + + // Monitor trackArtUrl changes + onTrackArtUrlChanged: { + root.artworkChanged() + } + + // Called via IPC: media.update '{"title": "...", "artist": "...", ...}' + function update(data: var) { + if (data.title !== undefined) trackTitle = data.title + if (data.artist !== undefined) trackArtist = data.artist + if (data.album !== undefined) trackAlbum = data.album + if (data.artUrl !== undefined) trackArtUrl = data.artUrl + if (data.state !== undefined) playbackState = data.state + if (data.position !== undefined) position = data.position + if (data.length !== undefined) length = data.length + if (data.elapsed !== undefined) position = data.elapsed + if (data.duration !== undefined) length = data.duration + if (data.volume !== undefined) volume = data.volume + if (data.available !== undefined) available = data.available + if (data.identity !== undefined) identity = data.identity + if (data.sourceId !== undefined) sourceId = data.sourceId + } + + function setCommands(cmds: var) { + if (cmds.play !== undefined) _playCommand = cmds.play + if (cmds.pause !== undefined) _pauseCommand = cmds.pause + if (cmds.toggle !== undefined) _toggleCommand = cmds.toggle + if (cmds.next !== undefined) _nextCommand = cmds.next + if (cmds.previous !== undefined) _previousCommand = cmds.previous + if (cmds.prev !== undefined) _previousCommand = cmds.prev + if (cmds.volume !== undefined) _volumeCommand = cmds.volume + } + + function _exec(cmd) { + if (cmd) Quickshell.execDetached(["sh", "-c", cmd]) + } + + function play() { + playbackState = statePlaying + _exec(_playCommand) + } + + function pause() { + playbackState = statePaused + _exec(_pauseCommand) + } + + function togglePlaying() { + playbackState = isPlaying ? statePaused : statePlaying + _exec(_toggleCommand) + } + + function next() { + _exec(_nextCommand) + } + + function previous() { + _exec(_previousCommand) + } + + function stop() { + playbackState = stateStopped + _exec(_pauseCommand) + } + + function clear() { + trackTitle = "" + trackArtist = "" + trackAlbum = "" + trackArtUrl = "" + playbackState = 0 + position = 0 + length = 0 + available = false + identity = "" + sourceId = "" + } +} diff --git a/quickshell/Services/IdleService.qml b/quickshell/Services/IdleService.qml index f452617d7..68536b2ba 100644 --- a/quickshell/Services/IdleService.qml +++ b/quickshell/Services/IdleService.qml @@ -37,7 +37,7 @@ Singleton { readonly property int suspendTimeout: isOnBattery ? SettingsData.batterySuspendTimeout : SettingsData.acSuspendTimeout readonly property int suspendBehavior: isOnBattery ? SettingsData.batterySuspendBehavior : SettingsData.acSuspendBehavior - readonly property bool mediaPlaying: MprisController.activePlayer !== null && MprisController.activePlayer.isPlaying + readonly property bool mediaPlaying: MprisController.currentPlayer !== null && MprisController.currentPlayer.isPlaying onMonitorTimeoutChanged: _rearmIdleMonitors() onLockTimeoutChanged: _rearmIdleMonitors() diff --git a/quickshell/Services/MprisController.qml b/quickshell/Services/MprisController.qml index d0962b54d..b07def5d2 100644 --- a/quickshell/Services/MprisController.qml +++ b/quickshell/Services/MprisController.qml @@ -10,4 +10,62 @@ Singleton { readonly property list availablePlayers: Mpris.players.values property MprisPlayer activePlayer: availablePlayers.find(p => p.isPlaying) ?? availablePlayers.find(p => p.canControl && p.canPlay) ?? null + + // Manual source selection (null = auto, "custom" = force custom source) + property string forcedSource: "auto" + + // Check if custom source has actual content + readonly property bool customHasContent: CustomMediaSource.available && CustomMediaSource.trackTitle !== "" + + // Use custom source when forced or when auto-detect determines it + readonly property bool useCustomSource: { + if (forcedSource === "custom") return CustomMediaSource.available + if (forcedSource === "mpris") return false + // Auto: use custom when it has content (track loaded), regardless of playing state + return customHasContent + } + + readonly property var currentPlayer: useCustomSource ? CustomMediaSource : activePlayer + + // Direct property bindings for reliable UI updates (QML doesn't track nested var properties well) + readonly property int currentPlaybackState: useCustomSource ? CustomMediaSource.playbackState : (activePlayer ? activePlayer.playbackState : 0) + readonly property bool currentIsPlaying: currentPlaybackState === CustomMediaSource.statePlaying + readonly property string currentTrackTitle: useCustomSource ? CustomMediaSource.trackTitle : (activePlayer ? activePlayer.trackTitle : "") + readonly property string currentTrackArtist: useCustomSource ? CustomMediaSource.trackArtist : (activePlayer ? activePlayer.trackArtist : "") + readonly property string currentTrackAlbum: useCustomSource ? CustomMediaSource.trackAlbum : (activePlayer ? activePlayer.trackAlbum : "") + readonly property string currentTrackArtUrl: useCustomSource ? CustomMediaSource.trackArtUrl : (activePlayer ? activePlayer.trackArtUrl : "") + readonly property string currentIdentity: useCustomSource ? CustomMediaSource.identity : (activePlayer ? activePlayer.identity : "") + + // Combined list of all sources (for player selector dropdown) + readonly property var allSources: { + const sources = [] + // Add custom source first if available + if (CustomMediaSource.available) { + sources.push({ + isCustom: true, + identity: CustomMediaSource.identity || "Custom Media", + sourceId: CustomMediaSource.sourceId + }) + } + // Add MPRIS players + for (let i = 0; i < availablePlayers.length; i++) { + sources.push({ + isCustom: false, + identity: availablePlayers[i].identity, + player: availablePlayers[i] + }) + } + return sources + } + + function selectSource(source) { + if (source && source.isCustom) { + forcedSource = "custom" + } else if (source && source.player) { + forcedSource = "mpris" + activePlayer = source.player + } else { + forcedSource = "auto" + } + } } diff --git a/quickshell/Services/TrackArtService.qml b/quickshell/Services/TrackArtService.qml index e2c6ea552..ada9eef39 100644 --- a/quickshell/Services/TrackArtService.qml +++ b/quickshell/Services/TrackArtService.qml @@ -32,7 +32,8 @@ Singleton { loading = true; const localUrl = url; - const filePath = url.startsWith("file://") ? url.substring(7) : url; + // Remove query parameters for file existence check + const filePath = url.startsWith("file://") ? url.substring(7).split("?")[0] : url.split("?")[0]; Proc.runCommand("trackart", ["test", "-f", filePath], (output, exitCode) => { if (_lastArtUrl !== localUrl) return; @@ -41,9 +42,17 @@ Singleton { }, 200); } - property MprisPlayer activePlayer: MprisController.activePlayer + property var activePlayer: MprisController.currentPlayer onActivePlayerChanged: { loadArtwork(activePlayer?.trackArtUrl ?? ""); } + + // Watch for artwork changes on CustomMediaSource + Connections { + target: MprisController.useCustomSource ? CustomMediaSource : null + function onArtworkChanged() { + loadArtwork(CustomMediaSource.trackArtUrl); + } + } } diff --git a/quickshell/Widgets/DankAlbumArt.qml b/quickshell/Widgets/DankAlbumArt.qml index d5f8d109d..0e39d0ddb 100644 --- a/quickshell/Widgets/DankAlbumArt.qml +++ b/quickshell/Widgets/DankAlbumArt.qml @@ -7,7 +7,7 @@ import qs.Services Item { id: root - property MprisPlayer activePlayer + property var activePlayer property string artUrl: (activePlayer?.trackArtUrl) || "" property string lastValidArtUrl: "" property alias albumArtStatus: albumArt.imageStatus @@ -22,7 +22,7 @@ Item { } Loader { - active: activePlayer?.playbackState === MprisPlaybackState.Playing && showAnimation + active: activePlayer?.playbackState === CustomMediaSource.statePlaying && showAnimation sourceComponent: Component { Ref { service: CavaService @@ -35,7 +35,7 @@ Item { width: parent.width * 1.1 height: parent.height * 1.1 anchors.centerIn: parent - visible: CavaService.cavaAvailable && activePlayer?.playbackState === MprisPlaybackState.Playing && showAnimation + visible: CavaService.cavaAvailable && activePlayer?.playbackState === CustomMediaSource.statePlaying && showAnimation asynchronous: false antialiasing: true preferredRendererType: Shape.CurveRenderer diff --git a/quickshell/Widgets/DankSeekbar.qml b/quickshell/Widgets/DankSeekbar.qml index d30249a0b..9fc8aa1e6 100644 --- a/quickshell/Widgets/DankSeekbar.qml +++ b/quickshell/Widgets/DankSeekbar.qml @@ -7,7 +7,7 @@ import qs.Widgets Item { id: root - property MprisPlayer activePlayer + property var activePlayer property real value: { if (!activePlayer || activePlayer.length <= 0) return 0 const pos = (activePlayer.position || 0) % Math.max(1, activePlayer.length) @@ -29,7 +29,7 @@ Item { M3WaveProgress { value: root.value - isPlaying: activePlayer && activePlayer.playbackState === MprisPlaybackState.Playing + isPlaying: MprisController.currentIsPlaying MouseArea { anchors.fill: parent