diff --git a/mpv/BarWidget.qml b/mpv/BarWidget.qml new file mode 100644 index 000000000..58f105d55 --- /dev/null +++ b/mpv/BarWidget.qml @@ -0,0 +1,231 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import qs.Commons +import qs.Modules.Bar.Extras +import qs.Widgets +import "state.js" as State + +Item { + id: root + + property var pluginApi: null + + property ShellScreen screen + property string widgetId: "" + property string section: "" + property int sectionWidgetIndex: -1 + property int sectionWidgetsCount: 0 + + readonly property var cfg: pluginApi?.pluginSettings || ({}) + readonly property var defaults: pluginApi?.manifest?.metadata?.defaultSettings || ({}) + + readonly property string screenName: screen ? screen.name : "" + readonly property string barPosition: Settings.getBarPositionForScreen(screenName) + readonly property bool isBarVertical: barPosition === "left" || barPosition === "right" + readonly property real capsuleHeight: Style.getCapsuleHeightForScreen(screenName) + readonly property real barFontSize: Style.getBarFontSizeForScreen(screenName) + + property string mpvStatus: "stopped" + property string rawTitle: "" + + readonly property string leftAction: cfg.leftButton ?? defaults.leftButton ?? "toggle" + readonly property string rightAction: cfg.rightButton ?? defaults.rightButton ?? "next" + readonly property string middleAction: cfg.middleButton ?? defaults.middleButton ?? "prev" + + readonly property string shortTitle: { + if (!rawTitle) return "" + if (rawTitle.startsWith("https://") || rawTitle.startsWith("http://")) return "" + return rawTitle + } + + // icons: playing = pause (can pause), paused = play (can play) + readonly property string statusIcon: { + switch (mpvStatus) { + case "playing": return "player-pause" + case "paused": return "player-play" + default: return "music-note" + } + } + + readonly property real contentWidth: content.implicitWidth + Style.marginM * 0.5 + readonly property real contentHeight: capsuleHeight + + implicitWidth: contentWidth + implicitHeight: contentHeight + + function commandForAction(action) { + switch (action) { + case "next": return ["sh", "-c", "echo '{\"command\":[\"playlist-next\"]}' | socat - UNIX-CONNECT:/tmp/mpvsocket"] + case "prev": return ["sh", "-c", "echo '{\"command\":[\"playlist-prev\"]}' | socat - UNIX-CONNECT:/tmp/mpvsocket"] + case "toggle": return ["sh", "-c", "echo '{\"command\":[\"cycle\",\"pause\"]}' | socat - UNIX-CONNECT:/tmp/mpvsocket"] + case "stop": return ["sh", "-c", "echo '{\"command\":[\"stop\"]}' | socat - UNIX-CONNECT:/tmp/mpvsocket"] + default: return null + } + } + + Process { + id: mpvTitleProc + command: ["sh", "-c", "echo '{\"command\":[\"get_property\",\"media-title\"]}' | socat - UNIX-CONNECT:/tmp/mpvsocket 2>/dev/null"] + running: true + + stdout: StdioCollector { + onStreamFinished: { + const text = this.text.trim() + if (!text) { + root.mpvStatus = "stopped" + root.rawTitle = "" + return + } + + const lines = text.split("\n").filter(l => l.trim() !== "") + let title = "" + for (const line of lines) { + try { + const json = JSON.parse(line) + if (json.error === "success" && json.data) { + const data = json.data + if (!data.startsWith("https://") && + !data.startsWith("http://") && + !data.includes("watch?v=")) { + title = data + } + } + } catch (e) {} + } + + if (title) { + root.rawTitle = title + } else { + root.rawTitle = "" + } + } + } + } + + Process { + id: mpvPauseProc + command: ["sh", "-c", "echo '{\"command\":[\"get_property\",\"pause\"]}' | socat - UNIX-CONNECT:/tmp/mpvsocket 2>/dev/null"] + running: true + + stdout: StdioCollector { + onStreamFinished: { + const text = this.text.trim() + if (!text) { + root.mpvStatus = "stopped" + return + } + try { + const json = JSON.parse(text) + if (json.error === "success") { + if (root.rawTitle) { + root.mpvStatus = json.data ? "paused" : "playing" + } else { + root.mpvStatus = "stopped" + } + } + } catch (e) {} + } + } + } + + Timer { + interval: 1000 + running: true + repeat: true + onTriggered: { + mpvTitleProc.running = true + mpvPauseProc.running = true + } + } + + Timer { + id: hoverOpenTimer + interval: 700 + repeat: false + onTriggered: { + if (mouseArea.containsMouse && root.mpvStatus !== "stopped" + && pluginApi && !pluginApi.panelOpenScreen) + pluginApi.openPanel(root.screen, root) + } + } + + Timer { + id: widgetExitTimer + interval: 400 + repeat: false + onTriggered: { + if (pluginApi && !State.cursorOnPanel) + pluginApi.closePanel(root.screen) + } + } + + Rectangle { + id: visualCapsule + x: Style.pixelAlignCenter(parent.width, width) + y: Style.pixelAlignCenter(parent.height, height) + width: root.contentWidth + height: root.contentHeight + color: mouseArea.containsMouse ? Color.mHover : Style.capsuleColor + radius: Style.radiusL + border.color: Style.capsuleBorderColor + border.width: Style.capsuleBorderWidth + + RowLayout { + id: content + anchors.centerIn: parent + spacing: Style.marginS + + NIcon { + icon: root.statusIcon + color: Color.mOnSurface + applyUiScale: true + } + + NText { + visible: root.shortTitle !== "" + text: root.shortTitle + color: Color.mOnSurface + pointSize: barFontSize + font.weight: Font.Medium + elide: Text.ElideRight + Layout.maximumWidth: 200 + } + } + } + + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + + onEntered: { + State.cursorOnWidget = true + widgetExitTimer.stop() + hoverOpenTimer.start() + } + + onExited: { + State.cursorOnWidget = false + hoverOpenTimer.stop() + if (pluginApi?.panelOpenScreen) widgetExitTimer.start() + } + + onClicked: (mouse) => { + var action = null + if (mouse.button === Qt.LeftButton) action = root.leftAction + else if (mouse.button === Qt.RightButton) action = root.rightAction + else if (mouse.button === Qt.MiddleButton) action = root.middleAction + + const cmd = commandForAction(action) + if (cmd) Quickshell.execDetached(cmd) + + mpvTitleProc.running = true + mpvPauseProc.running = true + } + } +} diff --git a/mpv/LICENSE b/mpv/LICENSE new file mode 100644 index 000000000..5bc093487 --- /dev/null +++ b/mpv/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ido Perlmuter + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/mpv/Panel.qml b/mpv/Panel.qml new file mode 100644 index 000000000..8d83f785f --- /dev/null +++ b/mpv/Panel.qml @@ -0,0 +1,182 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell.Io +import qs.Commons +import qs.Widgets +import "state.js" as State + +Item { + id: root + + property var pluginApi: null + + readonly property var geometryPlaceholder: panelContainer + property real contentPreferredWidth: 340 * Style.uiScaleRatio + property real contentPreferredHeight: 280 * Style.uiScaleRatio + readonly property bool allowAttach: true + + anchors.fill: parent + + property string songTitle: "" + property string videoId: "" + property string songArtist: "" + + MouseArea { + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + property bool wasEntered: false + + onEntered: { + wasEntered = true + State.cursorOnPanel = true + panelCloseTimer.stop() + } + + onExited: { + if (!wasEntered) return + State.cursorOnPanel = false + panelCloseTimer.start() + } + } + + Timer { + id: panelCloseTimer + interval: 500 + repeat: false + onTriggered: { + if (pluginApi && !State.cursorOnWidget) + pluginApi.closePanel(pluginApi.panelOpenScreen) + } + } + + // fetches title via socat + Process { + id: mpvDetailProc + command: ["sh", "-c", "echo '{\"command\":[\"get_property\",\"media-title\"]}' | socat - UNIX-CONNECT:/tmp/mpvsocket 2>/dev/null"] + + stdout: StdioCollector { + onStreamFinished: { + const text = this.text.trim() + if (!text) return + try { + const json = JSON.parse(text) + if (json.error === "success" && json.data) { + const raw = json.data + if (raw.includes(" - ")) { + root.songTitle = raw.split(" - ")[0].trim() + root.songArtist = raw.split(" - ")[1].replace(/\s*\(.*$/, "").trim() + } else { + root.songTitle = raw + root.songArtist = "" + } + } + } catch (e) {} + } + } + } + + // fetches video ID for thumbnail + Process { + id: mpvUrlProc + command: ["sh", "-c", "echo '{\"command\":[\"get_property\",\"path\"]}' | socat - UNIX-CONNECT:/tmp/mpvsocket 2>/dev/null"] + + stdout: StdioCollector { + onStreamFinished: { + const text = this.text.trim() + if (!text) return + try { + const json = JSON.parse(text) + if (json.error === "success" && json.data) { + const match = json.data.match(/[?&]v=([^&]+)/) + if (match) root.videoId = match[1] + } + } catch (e) {} + } + } + } + + Component.onCompleted: { + mpvDetailProc.running = true + mpvUrlProc.running = true + } + + Rectangle { + id: panelContainer + anchors.fill: parent + color: "transparent" + + Rectangle { + anchors { + fill: parent + margins: Style.marginS + } + color: Style.capsuleColor + radius: Style.radiusL + border.color: Style.capsuleBorderColor + border.width: Style.capsuleBorderWidth + clip: true + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + // thumbnail + Rectangle { + Layout.fillWidth: true + height: 180 * Style.uiScaleRatio + color: Color.mSurfaceVariant + + Image { + id: thumbImage + anchors.fill: parent + fillMode: Image.PreserveAspectCrop + asynchronous: true + cache: false + source: root.videoId !== "" + ? "https://img.youtube.com/vi/" + root.videoId + "/maxresdefault.jpg" + : "" + } + + NIcon { + anchors.centerIn: parent + visible: thumbImage.status !== Image.Ready + icon: "music-note" + color: Color.mOnSurface + applyUiScale: true + } + } + + // track info below + ColumnLayout { + Layout.fillWidth: true + Layout.topMargin: Style.marginM + Layout.bottomMargin: Style.marginM + Layout.leftMargin: Style.marginM + Layout.rightMargin: Style.marginM + spacing: Style.marginXS + + NText { + text: root.songTitle !== "" ? root.songTitle : "Loading..." + color: Color.mOnSurface + pointSize: Style.fontSizeM + font.weight: Font.Bold + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + + NText { + visible: root.songArtist !== "" + text: root.songArtist + color: Color.mOnSurface + pointSize: Style.fontSizeS + font.weight: Font.Medium + opacity: 0.7 + elide: Text.ElideRight + Layout.fillWidth: true + } + } + } + } + } +} diff --git a/mpv/README.md b/mpv/README.md new file mode 100644 index 000000000..a113338c0 --- /dev/null +++ b/mpv/README.md @@ -0,0 +1,48 @@ +# MPV Plugin for Noctalia + +A plugin for the [Noctalia](https://noctalia.dev/) shell for controlling [mpv](https://mpv.io/). +It provides a bar widget displaying the current playback state and song name, +with a hover panel showing full track details and album art. + +![Preview](preview.png) + +## Features + +- Shows playback status icon (playing / paused / stopped) and current song name in the bar +- Hover panel with title, artist, and YouTube thumbnail +- Configurable left, right, and middle click actions +- Works with anything mpv plays: local files, streams, YouTube via [yt-x](https://github.com/Benexl/yt-x) + +## Requirements + +- Noctalia shell +- mpv with IPC socket enabled +- [`socat`](http://www.dest-unreach.org/socat/) available in `$PATH` + +## Installation + +Add the following to your `~/.config/mpv/mpv.conf`: + +```ini +input-ipc-server=/tmp/mpvsocket +``` + +Then copy this directory into your Noctalia plugins folder, enable the plugin from Noctalia's settings, and restart Noctalia. + +## Configuration + +| Setting | Default | Description | +|---------|---------|-------------| +| Left click | Play / Pause | Action triggered by left-clicking the widget | +| Right click | Next track | Action triggered by right-clicking the widget | +| Middle click | Previous track | Action triggered by middle-clicking the widget | + +Available actions: Next track, Previous track, Play / Pause, Stop. + +## Credits + +Based on [noctalia-mpd](https://github.com/ido50/noctalia-mpd) by [Ido Perlmuter](https://github.com/ido50). + +## License + +MIT - see [LICENSE](LICENSE) for details. diff --git a/mpv/Settings.qml b/mpv/Settings.qml new file mode 100644 index 000000000..4242e21da --- /dev/null +++ b/mpv/Settings.qml @@ -0,0 +1,52 @@ +import QtQuick +import QtQuick.Layouts +import qs.Commons +import qs.Widgets +ColumnLayout { + id: root + property var pluginApi: null + readonly property var cfg: pluginApi?.pluginSettings || ({}) + readonly property var defaults: pluginApi?.manifest?.metadata?.defaultSettings || ({}) + property string editLeftButton: cfg.leftButton ?? defaults.leftButton ?? "toggle" + property string editRightButton: cfg.rightButton ?? defaults.rightButton ?? "next" + property string editMiddleButton: cfg.middleButton ?? defaults.middleButton ?? "prev" + spacing: Style.marginL + readonly property var actionModel: [ + { key: "next", name: "Next track" }, + { key: "prev", name: "Previous track" }, + { key: "toggle", name: "Play / Pause" }, + { key: "stop", name: "Stop" }, + ] + function saveSettings() { + if (!pluginApi) return + pluginApi.pluginSettings.leftButton = root.editLeftButton + pluginApi.pluginSettings.rightButton = root.editRightButton + pluginApi.pluginSettings.middleButton = root.editMiddleButton + pluginApi.saveSettings() + } + ColumnLayout { + spacing: Style.marginM + Layout.fillWidth: true + NComboBox { + label: pluginApi?.tr("settings.left-click") + description: pluginApi?.tr("settings.left-click-desc") + model: root.actionModel + currentKey: root.editLeftButton + onSelected: key => root.editLeftButton = key + } + NComboBox { + label: pluginApi?.tr("settings.right-click") + description: pluginApi?.tr("settings.right-click-desc") + model: root.actionModel + currentKey: root.editRightButton + onSelected: key => root.editRightButton = key + } + NComboBox { + label: pluginApi?.tr("settings.middle-click") + description: pluginApi?.tr("settings.middle-click-desc") + model: root.actionModel + currentKey: root.editMiddleButton + onSelected: key => root.editMiddleButton = key + } + } +} \ No newline at end of file diff --git a/mpv/manifest.json b/mpv/manifest.json new file mode 100644 index 000000000..eafc82ae8 --- /dev/null +++ b/mpv/manifest.json @@ -0,0 +1,30 @@ +{ + "id": "mpv", + "name": "MPV", + "version": "1.0.0", + "minNoctaliaVersion": "3.6.0", + "author": "aglairdev", + "license": "MIT", + "repository": "https://github.com/noctalia-dev/noctalia-plugins", + "description": "MPV plugin for Noctalia. Based on noctalia-mpd by Ido Perlmuter.", + "tags": [ + "Bar", + "Audio", + "Music" + ], + "entryPoints": { + "barWidget": "BarWidget.qml", + "panel": "Panel.qml", + "settings": "Settings.qml" + }, + "dependencies": { + "plugins": [] + }, + "metadata": { + "defaultSettings": { + "leftButton": "toggle", + "rightButton": "next", + "middleButton": "prev" + } + } +} diff --git a/mpv/preview.png b/mpv/preview.png new file mode 100644 index 000000000..de349ad1b Binary files /dev/null and b/mpv/preview.png differ diff --git a/mpv/state.js b/mpv/state.js new file mode 100644 index 000000000..e760c9db9 --- /dev/null +++ b/mpv/state.js @@ -0,0 +1,7 @@ +.pragma library + +// Shared hover state between MPD.qml (widget) and Panel.qml. +// .pragma library makes this a singleton across the entire QML engine — +// writes in one component are immediately visible in the other. +var cursorOnWidget = false +var cursorOnPanel = false