diff --git a/BarWidget.qml b/BarWidget.qml index d8ac690..727e078 100644 --- a/BarWidget.qml +++ b/BarWidget.qml @@ -13,9 +13,14 @@ BarWidget { readonly property bool playing: waveformService ? waveformService.playing : false readonly property bool showControls: setting("showControls", true) === true readonly property bool showTitle: setting("showTitle", true) === true + readonly property bool showArtist: String(setting("showArtist", false)).toLowerCase() === "true" + readonly property bool showFullTitle: String(setting("showFullTitle", false)).toLowerCase() === "true" + readonly property bool groupControls: String(setting("groupControls", false)).toLowerCase() === "true" + readonly property bool showCover: String(setting("showCover", false)).toLowerCase() === "true" readonly property bool hideWhenPaused: setting("hideWhenPaused", false) === true readonly property real waveformWidth: Math.min(240, Math.max(40, Number(setting("waveformWidth", 72)) || 72)) + readonly property string artUrl: waveformService ? waveformService.trackArtUrl : "" readonly property real maxTitleWidth: Math.min(320, Math.max(60, Number(setting("maxTitleWidth", 150)) || 150)) @@ -75,24 +80,41 @@ BarWidget { anchors.centerIn: parent spacing: Style.space(3) - Button { - visible: root.showControls - enabled: root.actionEnabled("previous") - opacity: enabled ? 1 : 0.35 - iconText: "󰒮" - foreground: root.bar ? root.bar.barForeground : Color.foreground - fontFamily: root.bar ? root.bar.fontFamily : Style.font.family - iconSize: Style.font.body - horizontalPadding: Style.space(3) - verticalPadding: Style.space(2) - tooltipText: "Previous" - onClicked: if (root.waveformService) root.waveformService.runAction("previous") - } + Item { + id: prevLeft + visible: root.showControls && !root.groupControls + implicitWidth: prevLeftBtn.implicitWidth + implicitHeight: prevLeftBtn.implicitHeight + readonly property bool tooltipHovered: prevLeftHover.containsMouse + + Button { + id: prevLeftBtn + anchors.fill: parent + enabled: root.actionEnabled("previous") + opacity: enabled ? 1 : 0.35 + iconText: "󰒮" + foreground: root.bar ? root.bar.barForeground : Color.foreground + fontFamily: root.bar ? root.bar.fontFamily : Style.font.family + iconSize: Style.font.body + horizontalPadding: Style.space(3) + verticalPadding: Style.space(2) + onClicked: if (root.waveformService) root.waveformService.runAction("previous") + } + MouseArea { + id: prevLeftHover + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + onEntered: if (root.bar) root.bar.showTooltip(prevLeft, "Previous") + onExited: if (root.bar) root.bar.hideTooltip(prevLeft) + } + } Item { id: mediaButton implicitWidth: mediaRow.implicitWidth implicitHeight: Math.max(Style.space(24), mediaRow.implicitHeight) + readonly property bool tooltipHovered: mediaHover.containsMouse Row { id: mediaRow @@ -110,9 +132,26 @@ BarWidget { anchors.verticalCenter: parent.verticalCenter } + Item { + visible: root.showCover && root.artUrl !== "" && !root.vertical + width: Style.space(18) + height: Style.space(18) + clip: true + anchors.verticalCenter: parent.verticalCenter + + Image { + anchors.fill: parent + fillMode: Image.PreserveAspectCrop + asynchronous: true + source: root.artUrl + } + } + Item { visible: root.showTitle && !root.vertical - width: visible ? Math.min(root.maxTitleWidth, titleText.implicitWidth) : 0 + width: visible ? (root.showFullTitle + ? titleText.implicitWidth + : Math.min(root.maxTitleWidth, titleText.implicitWidth)) : 0 height: titleText.implicitHeight clip: true anchors.verticalCenter: parent.verticalCenter @@ -121,16 +160,24 @@ BarWidget { id: titleText width: parent.width height: implicitHeight - text: root.waveformService ? root.waveformService.title : "" + text: { + if (!root.waveformService) return "" + var title = root.waveformService.title || "" + if (root.showArtist && root.waveformService.artist) { + return title + " \u2014 " + root.waveformService.artist + } + return title + } foreground: root.bar ? root.bar.barForeground : Color.foreground fontFamily: root.bar ? root.bar.fontFamily : Style.font.family fontPixelSize: Style.font.bodySmall - active: !root.opened + active: !root.opened && !root.showFullTitle } } } MouseArea { + id: mediaHover anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor @@ -141,39 +188,111 @@ BarWidget { root.waveformService.runAction(wheel.angleDelta.y > 0 ? "previous" : "next") } onEntered: if (root.bar && root.waveformService) - root.bar.showTooltip(root, root.waveformService.title + root.bar.showTooltip(mediaButton, root.waveformService.title + (root.waveformService.artist ? " — " + root.waveformService.artist : "")) - onExited: if (root.bar) root.bar.hideTooltip(root) + onExited: if (root.bar) root.bar.hideTooltip(mediaButton) } } - Button { + Row { + id: controlsGroup visible: root.showControls - enabled: root.actionEnabled("playPause") - opacity: enabled ? 1 : 0.35 - iconText: root.playing ? "󰏤" : "󰐊" - foreground: root.bar ? root.bar.barForeground : Color.foreground - fontFamily: root.bar ? root.bar.fontFamily : Style.font.family - iconSize: Style.font.body - horizontalPadding: Style.space(4) - verticalPadding: Style.space(2) - tooltipText: root.playing ? "Pause" : "Play" - onClicked: if (root.waveformService) root.waveformService.runAction("playPause") - } + spacing: Style.space(3) - Button { - visible: root.showControls - enabled: root.actionEnabled("next") - opacity: enabled ? 1 : 0.35 - iconText: "󰒭" - foreground: root.bar ? root.bar.barForeground : Color.foreground - fontFamily: root.bar ? root.bar.fontFamily : Style.font.family - iconSize: Style.font.body - horizontalPadding: Style.space(3) - verticalPadding: Style.space(2) - tooltipText: "Next" - onClicked: if (root.waveformService) root.waveformService.runAction("next") + Item { + id: prevButton + visible: root.groupControls + implicitWidth: prevBtn.implicitWidth + implicitHeight: prevBtn.implicitHeight + readonly property bool tooltipHovered: prevHover.containsMouse + + Button { + id: prevBtn + anchors.fill: parent + enabled: root.actionEnabled("previous") + opacity: enabled ? 1 : 0.35 + iconText: "󰒮" + foreground: root.bar ? root.bar.barForeground : Color.foreground + fontFamily: root.bar ? root.bar.fontFamily : Style.font.family + iconSize: Style.font.body + horizontalPadding: Style.space(3) + verticalPadding: Style.space(2) + onClicked: if (root.waveformService) root.waveformService.runAction("previous") + } + + MouseArea { + id: prevHover + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + onEntered: if (root.bar) root.bar.showTooltip(prevButton, "Previous") + onExited: if (root.bar) root.bar.hideTooltip(prevButton) + } + } + + Item { + id: playButton + visible: true + implicitWidth: playBtn.implicitWidth + implicitHeight: playBtn.implicitHeight + readonly property bool tooltipHovered: playHover.containsMouse + + Button { + id: playBtn + anchors.fill: parent + enabled: root.actionEnabled("playPause") + opacity: enabled ? 1 : 0.35 + iconText: root.playing ? "󰏤" : "󰐊" + foreground: root.bar ? root.bar.barForeground : Color.foreground + fontFamily: root.bar ? root.bar.fontFamily : Style.font.family + iconSize: Style.font.body + horizontalPadding: Style.space(3) + verticalPadding: Style.space(2) + onClicked: if (root.waveformService) root.waveformService.runAction("playPause") + } + + MouseArea { + id: playHover + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + onEntered: if (root.bar) root.bar.showTooltip(playButton, root.playing ? "Pause" : "Play") + onExited: if (root.bar) root.bar.hideTooltip(playButton) + } + } + + Item { + id: nextButton + visible: true + implicitWidth: nextBtn.implicitWidth + implicitHeight: nextBtn.implicitHeight + readonly property bool tooltipHovered: nextHover.containsMouse + + Button { + id: nextBtn + anchors.fill: parent + enabled: root.actionEnabled("next") + opacity: enabled ? 1 : 0.35 + iconText: "󰒭" + foreground: root.bar ? root.bar.barForeground : Color.foreground + fontFamily: root.bar ? root.bar.fontFamily : Style.font.family + iconSize: Style.font.body + horizontalPadding: Style.space(3) + verticalPadding: Style.space(2) + onClicked: if (root.waveformService) root.waveformService.runAction("next") + } + + MouseArea { + id: nextHover + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + onEntered: if (root.bar) root.bar.showTooltip(nextButton, "Next") + onExited: if (root.bar) root.bar.hideTooltip(nextButton) + } + } } + } Column { diff --git a/CHANGELOG.md b/CHANGELOG.md index 666130e..35b0202 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## Unreleased + +### Media and interface + +- Added a `showArtist` setting to display the artist name after the track title + in the widget (matching the panel tooltip). +- Added a `showFullTitle` setting to show the whole track title without + truncating or horizontal scrolling. +- Added a `showCover` setting to show the album art thumbnail between the + waveform and the track title, and as the panel header art. +- Added a `groupControls` setting to keep the previous/play/pause/next buttons + grouped beside the waveform instead of splitting previous to the far side. +- Added an in-panel **Settings** section (behind a cog button) that toggles + title, artist, full-title, album-cover, controls, grouped-controls, and + hide-when-paused options. +- Used squared theming for the waveform bars and progress/volume controls via a + plugin-local `WavebarSlider`, keeping other Omarchy panels' sliders unchanged. +- Corrected panel tooltips to use the bar's native tooltip targets and made the + previous button tooltip switch targets when controls are grouped. + +### Security + +- Reintroduced opt-in album art with a hardened allowlist: only local `file://` + paths and trusted cover CDNs (Apple Music, Spotify, YouTube, Tidal/Deezer) are + ever loaded; all other MPRIS-provided URLs are rejected and treated as no + cover. The allowlist lives on the service so bar and panel share one check. + ## 1.0.2 — 2026-09-02 ### Security and lifecycle diff --git a/Panel.qml b/Panel.qml index 66c6a9b..d72cb1a 100644 --- a/Panel.qml +++ b/Panel.qml @@ -20,6 +20,9 @@ Panel { readonly property bool playing: service ? service.playing : false readonly property bool hasLength: player && player.positionSupported && player.lengthSupported && Number(player.length) > 0 + property bool showSettings: false + + readonly property string artUrl: service ? service.trackArtUrl : "" function open() { controller.show() @@ -50,6 +53,26 @@ Panel { return minutes + ":" + String(remainder).padStart(2, "0") } + function setBooleanSetting(key, value) { + var shell = root.bar && root.bar.shell + if (!shell || typeof shell.mutateShellConfig !== "function") return + shell.mutateShellConfig(function(config) { + if (!Util.isPlainObject(config.bar)) config.bar = {} + if (!Util.isPlainObject(config.bar.layout)) config.bar.layout = {} + for (var region in config.bar.layout) { + var entries = config.bar.layout[region] + if (!Array.isArray(entries)) continue + for (var i = 0; i < entries.length; i++) { + var entry = entries[i] + if (entry && entry.id === root.moduleName) { + entry[key] = !!value + return + } + } + } + }) + } + function captureMessage() { if (!service) return "Media service is loading" if (service.inputRejected) return "Media inputs exceeded safety limits" @@ -65,7 +88,6 @@ Panel { } onPlayerChanged: updatePosition() - Timer { interval: 500 repeat: true @@ -81,7 +103,8 @@ Panel { open: root.opened focusTarget: keyCatcher contentWidth: panel.fittedContentWidth(Style.space(390)) - contentHeight: panel.fittedContentHeight(content.implicitHeight, Style.space(620)) + contentHeight: panel.fittedContentHeight(content.implicitHeight, + Style.space(root.showSettings ? 1180 : 620)) PanelKeyCatcher { id: keyCatcher @@ -99,272 +122,409 @@ Panel { else if (key === "p" || key === "P") root.service.runAction("previous") } - Column { - id: content - width: parent.width - spacing: Style.space(10) + Flickable { + id: panelFlick + anchors.fill: parent + contentWidth: width + contentHeight: content.implicitHeight + clip: true + boundsBehavior: Flickable.StopAtBounds + flickableDirection: Flickable.VerticalFlick + interactive: contentHeight > height + QQC.ScrollBar.vertical: QQC.ScrollBar { policy: QQC.ScrollBar.AsNeeded } - Row { - width: parent.width + Column { + id: content + width: panelFlick.width spacing: Style.space(10) + Row { + width: parent.width + spacing: Style.space(10) + + BorderSurface { + width: Style.space(72) + height: Style.space(72) + radius: Style.cornerRadius + color: Style.normalFillFor(root.barForeground, Color.accent) + borderSpec: Border.controlSpec("normal", root.barForeground, Color.accent) + clip: true + + Image { + anchors.fill: parent + anchors.margins: Style.space(2) + fillMode: Image.PreserveAspectCrop + asynchronous: true + source: root.artUrl + visible: source !== "" + } + + Text { + anchors.centerIn: parent + visible: root.artUrl === "" + text: "󰝚" + color: root.barForeground + font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.pixelSize: Style.font.displayLarge + } + } + + Column { + width: parent.width - Style.space(82) + spacing: Style.space(3) + anchors.verticalCenter: parent.verticalCenter + + Text { + width: parent.width + textFormat: Text.PlainText + text: root.service && root.service.title ? root.service.title : "Nothing playing" + color: root.barForeground + font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.pixelSize: Style.font.subtitle + font.bold: true + elide: Text.ElideRight + } + + Text { + width: parent.width + visible: text !== "" + textFormat: Text.PlainText + text: root.service ? root.service.artist : "" + color: Qt.darker(root.barForeground, 1.35) + font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.pixelSize: Style.font.bodySmall + elide: Text.ElideRight + } + + Text { + width: parent.width + textFormat: Text.PlainText + text: root.service ? root.service.identity : "" + color: Qt.darker(root.barForeground, 1.55) + font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.pixelSize: Style.font.caption + elide: Text.ElideRight + } + } + } + BorderSurface { - width: Style.space(72) - height: Style.space(72) + width: parent.width + height: Style.space(88) radius: Style.cornerRadius color: Style.normalFillFor(root.barForeground, Color.accent) borderSpec: Border.controlSpec("normal", root.barForeground, Color.accent) - clip: true - Text { - anchors.centerIn: parent - text: "󰝚" - color: root.barForeground - font.family: root.bar ? root.bar.fontFamily : Style.font.family - font.pixelSize: Style.font.displayLarge + Waveform { + anchors.fill: parent + anchors.margins: Style.space(12) + barCount: 24 + samples: root.service ? root.service.samples : [] + active: root.playing + live: root.service ? root.service.receivingFrames : false + foreground: root.barForeground + gap: Style.space(2) + minimumBarHeight: Style.space(2) } } - Column { - width: parent.width - Style.space(82) - spacing: Style.space(3) - anchors.verticalCenter: parent.verticalCenter + Text { + width: parent.width + textFormat: Text.PlainText + text: root.captureMessage() + color: Qt.darker(root.barForeground, 1.4) + font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.pixelSize: Style.font.caption + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + } + + Row { + width: parent.width + visible: root.hasLength + spacing: Style.space(6) Text { - width: parent.width - textFormat: Text.PlainText - text: root.service && root.service.title ? root.service.title : "Nothing playing" + width: Style.space(42) + text: root.formatDuration(positionSlider.dragging ? positionSlider.liveValue : root.displayedPosition) color: root.barForeground font.family: root.bar ? root.bar.fontFamily : Style.font.family - font.pixelSize: Style.font.subtitle - font.bold: true - elide: Text.ElideRight + font.pixelSize: Style.font.caption + anchors.verticalCenter: parent.verticalCenter } - Text { - width: parent.width - visible: text !== "" - textFormat: Text.PlainText - text: root.service ? root.service.artist : "" - color: Qt.darker(root.barForeground, 1.35) - font.family: root.bar ? root.bar.fontFamily : Style.font.family - font.pixelSize: Style.font.bodySmall - elide: Text.ElideRight + WavebarSlider { + id: positionSlider + width: parent.width - Style.space(90) + bar: root.bar + minimum: 0 + maximum: root.player ? Math.max(1, Number(root.player.length) || 1) : 1 + value: root.displayedPosition + step: 5 + onMoved: function(value) { root.displayedPosition = value } + onReleased: function(value) { + if (root.service) root.service.seekTo(value) + root.displayedPosition = value + } } Text { - width: parent.width - textFormat: Text.PlainText - text: root.service ? root.service.identity : "" - color: Qt.darker(root.barForeground, 1.55) + width: Style.space(42) + text: root.formatDuration(root.player ? root.player.length : 0) + color: root.barForeground font.family: root.bar ? root.bar.fontFamily : Style.font.family font.pixelSize: Style.font.caption - elide: Text.ElideRight + horizontalAlignment: Text.AlignRight + anchors.verticalCenter: parent.verticalCenter } } - } - - BorderSurface { - width: parent.width - height: Style.space(88) - radius: Style.cornerRadius - color: Style.normalFillFor(root.barForeground, Color.accent) - borderSpec: Border.controlSpec("normal", root.barForeground, Color.accent) - - Waveform { - anchors.fill: parent - anchors.margins: Style.space(12) - barCount: 24 - samples: root.service ? root.service.samples : [] - active: root.playing - live: root.service ? root.service.receivingFrames : false - foreground: root.barForeground - gap: Style.space(2) - minimumBarHeight: Style.space(2) - } - } - Text { - width: parent.width - textFormat: Text.PlainText - text: root.captureMessage() - color: Qt.darker(root.barForeground, 1.4) - font.family: root.bar ? root.bar.fontFamily : Style.font.family - font.pixelSize: Style.font.caption - horizontalAlignment: Text.AlignHCenter - wrapMode: Text.WordWrap - } + Row { + anchors.horizontalCenter: parent.horizontalCenter + spacing: Style.space(8) - Row { - width: parent.width - visible: root.hasLength - spacing: Style.space(6) + Button { + iconText: "󰒮" + foreground: root.barForeground + iconSize: Style.font.icon + enabled: root.player && root.player.canGoPrevious + opacity: enabled ? 1 : 0.35 + tooltipText: "Previous (P)" + onClicked: if (root.service) root.service.runAction("previous") + } - Text { - width: Style.space(42) - text: root.formatDuration(positionSlider.dragging ? positionSlider.liveValue : root.displayedPosition) - color: root.barForeground - font.family: root.bar ? root.bar.fontFamily : Style.font.family - font.pixelSize: Style.font.caption - anchors.verticalCenter: parent.verticalCenter - } + Button { + iconText: root.playing ? "󰏤" : "󰐊" + foreground: root.barForeground + iconSize: Style.font.icon + enabled: root.player && (root.player.canTogglePlaying || root.player.canPlay || root.player.canPause) + opacity: enabled ? 1 : 0.35 + tooltipText: root.playing ? "Pause (Space)" : "Play (Space)" + onClicked: if (root.service) root.service.runAction("playPause") + } - PanelSlider { - id: positionSlider - width: parent.width - Style.space(90) - bar: root.bar - minimum: 0 - maximum: root.player ? Math.max(1, Number(root.player.length) || 1) : 1 - value: root.displayedPosition - step: 5 - onMoved: function(value) { root.displayedPosition = value } - onReleased: function(value) { - if (root.service) root.service.seekTo(value) - root.displayedPosition = value + Button { + iconText: "󰒭" + foreground: root.barForeground + iconSize: Style.font.icon + enabled: root.player && root.player.canGoNext + opacity: enabled ? 1 : 0.35 + tooltipText: "Next (N)" + onClicked: if (root.service) root.service.runAction("next") } } - Text { - width: Style.space(42) - text: root.formatDuration(root.player ? root.player.length : 0) - color: root.barForeground - font.family: root.bar ? root.bar.fontFamily : Style.font.family - font.pixelSize: Style.font.caption - horizontalAlignment: Text.AlignRight - anchors.verticalCenter: parent.verticalCenter - } - } + Row { + width: parent.width + spacing: Style.space(8) - Row { - anchors.horizontalCenter: parent.horizontalCenter - spacing: Style.space(8) + Text { + text: "󰕾" + visible: root.service && root.service.volumeSupported + color: root.barForeground + font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.pixelSize: Style.font.icon + anchors.verticalCenter: parent.verticalCenter + } - Button { - iconText: "󰒮" - foreground: root.barForeground - enabled: root.player && root.player.canGoPrevious - opacity: enabled ? 1 : 0.35 - tooltipText: "Previous (P)" - onClicked: if (root.service) root.service.runAction("previous") - } + WavebarSlider { + width: parent.width - Style.space(28) - settingsButton.implicitWidth + visible: root.service && root.service.volumeSupported + anchors.verticalCenter: parent.verticalCenter + bar: root.bar + minimum: 0 + maximum: 1 + step: 0.05 + value: root.service ? root.service.volume : 0 + onMoved: function(value) { if (root.service) root.service.setVolume(value) } + onReleased: function(value) { if (root.service) root.service.setVolume(value) } + } - Button { - iconText: root.playing ? "󰏤" : "󰐊" - foreground: root.barForeground - iconSize: Style.font.iconLarge - horizontalPadding: Style.spacing.panelGap - enabled: root.player && (root.player.canTogglePlaying || root.player.canPlay || root.player.canPause) - opacity: enabled ? 1 : 0.35 - tooltipText: root.playing ? "Pause (Space)" : "Play (Space)" - onClicked: if (root.service) root.service.runAction("playPause") + Button { + id: settingsButton + iconText: "" + foreground: root.barForeground + iconSize: Style.font.icon + anchors.verticalCenter: parent.verticalCenter + tooltipText: root.showSettings ? "Hide settings" : "Settings" + onClicked: root.showSettings = !root.showSettings + } } - Button { - iconText: "󰒭" + PanelSeparator { + visible: root.service && root.service.focusedPlayers.length > 1 foreground: root.barForeground - enabled: root.player && root.player.canGoNext - opacity: enabled ? 1 : 0.35 - tooltipText: "Next (N)" - onClicked: if (root.service) root.service.runAction("next") } - } - Row { - width: parent.width - visible: root.service && root.service.volumeSupported - spacing: Style.space(8) + Column { + id: sourceList + width: parent.width + visible: root.service && root.service.focusedPlayers.length > 1 + spacing: Style.space(4) - Text { - text: "󰕾" - color: root.barForeground - font.family: root.bar ? root.bar.fontFamily : Style.font.family - font.pixelSize: Style.font.body - anchors.verticalCenter: parent.verticalCenter - } + PanelSectionHeader { + text: "Media sources" + foreground: root.barForeground + } - PanelSlider { - width: parent.width - Style.space(28) - bar: root.bar - minimum: 0 - maximum: 1 - step: 0.05 - value: root.service ? root.service.volume : 0 - onMoved: function(value) { if (root.service) root.service.setVolume(value) } - onReleased: function(value) { if (root.service) root.service.setVolume(value) } + ListView { + id: sourceView + width: parent.width + height: Math.min(contentHeight, Style.space(210)) + spacing: Style.space(4) + clip: true + boundsBehavior: Flickable.StopAtBounds + interactive: contentHeight > height + + QQC.ScrollBar.vertical: QQC.ScrollBar { policy: QQC.ScrollBar.AsNeeded } + + model: root.service + ? root.service.focusedPlayers.slice(0, root.service.maxPlayers) : [] + + delegate: Button { + id: sourceButton + required property var modelData + required property int index + readonly property var sourcePlayer: modelData + readonly property bool isCurrent: root.player && root.service + && root.service.playerKey(root.player) === root.service.playerKey(sourcePlayer) + readonly property string sourceTitle: root.service + ? (root.service.playerTitle(sourcePlayer) + || root.service.playerIdentity(sourcePlayer) || "Media") : "Media" + readonly property string sourceArtist: root.service + ? root.service.playerArtist(sourcePlayer) : "" + + width: ListView.view.width + height: sourceButton.implicitHeight + clip: true + leftAlign: true + foreground: root.barForeground + selected: isCurrent + iconText: sourcePlayer && sourcePlayer.isPlaying ? "󰏤" : "󰐊" + text: "" + tooltipText: sourceTitle + (sourceArtist ? " — " + sourceArtist : "") + onClicked: if (root.service) root.service.selectAndPlay(root.service.playerKey(sourcePlayer)) + + MarqueeText { + z: 1 + anchors.left: parent.left + anchors.leftMargin: sourceButton.horizontalPadding + Style.space(22) + anchors.right: parent.right + anchors.rightMargin: sourceButton.horizontalPadding + anchors.verticalCenter: parent.verticalCenter + height: implicitHeight + text: sourceButton.sourceTitle + foreground: sourceButton.selected + ? Style.selectedStateColor(root.barForeground, Color.accent) + : root.barForeground + fontFamily: root.bar ? root.bar.fontFamily : Style.font.family + fontPixelSize: Style.font.bodySmall + fontBold: sourceButton.selected + active: sourceButton.hot || sourceButton.isCurrent + } + } + } } - } - PanelSeparator { - visible: root.service && root.service.focusedPlayers.length > 1 - foreground: root.barForeground - } + Column { + width: parent.width + visible: root.showSettings + spacing: Style.space(4) - Column { - id: sourceList - width: parent.width - visible: root.service && root.service.focusedPlayers.length > 1 - spacing: Style.space(4) + PanelSeparator { + foreground: root.barForeground + } - PanelSectionHeader { - text: "Media sources" - foreground: root.barForeground - } + PanelSectionHeader { + text: "Settings" + foreground: root.barForeground + } - ListView { - id: sourceView - width: parent.width - height: Math.min(contentHeight, Style.space(210)) - spacing: Style.space(4) - clip: true - boundsBehavior: Flickable.StopAtBounds - interactive: contentHeight > height - - QQC.ScrollBar.vertical: QQC.ScrollBar { policy: QQC.ScrollBar.AsNeeded } - - model: root.service - ? root.service.focusedPlayers.slice(0, root.service.maxPlayers) : [] - - delegate: Button { - id: sourceButton - required property var modelData - required property int index - readonly property var sourcePlayer: modelData - readonly property bool isCurrent: root.player && root.service - && root.service.playerKey(root.player) === root.service.playerKey(sourcePlayer) - readonly property string sourceTitle: root.service - ? (root.service.playerTitle(sourcePlayer) - || root.service.playerIdentity(sourcePlayer) || "Media") : "Media" - readonly property string sourceArtist: root.service - ? root.service.playerArtist(sourcePlayer) : "" - - width: ListView.view.width - height: sourceButton.implicitHeight - clip: true - leftAlign: true + Toggle { + width: parent.width + label: "Show track title" + description: "Show the scrolling track title beside the waveform." + checked: String(root.setting("showTitle", true)).toLowerCase() === "true" foreground: root.barForeground - selected: isCurrent - iconText: sourcePlayer && sourcePlayer.isPlaying ? "󰏤" : "󰐊" - text: "" - tooltipText: sourceTitle + (sourceArtist ? " — " + sourceArtist : "") - onClicked: if (root.service) root.service.selectAndPlay(root.service.playerKey(sourcePlayer)) - - MarqueeText { - z: 1 - anchors.left: parent.left - anchors.leftMargin: sourceButton.horizontalPadding + Style.space(22) - anchors.right: parent.right - anchors.rightMargin: sourceButton.horizontalPadding - anchors.verticalCenter: parent.verticalCenter - height: implicitHeight - text: sourceButton.sourceTitle - foreground: sourceButton.selected - ? Style.selectedStateColor(root.barForeground, Color.accent) - : root.barForeground - fontFamily: root.bar ? root.bar.fontFamily : Style.font.family - fontPixelSize: Style.font.bodySmall - fontBold: sourceButton.selected - active: sourceButton.hot || sourceButton.isCurrent - } + accent: Color.accent + fontFamily: root.bar ? root.bar.fontFamily : Style.font.family + onClicked: root.setBooleanSetting("showTitle", + String(root.setting("showTitle", true)).toLowerCase() !== "true") + } + + Toggle { + width: parent.width + visible: String(root.setting("showTitle", true)).toLowerCase() === "true" + label: "Show artist in title" + description: "Show the artist name after the track title." + checked: String(root.setting("showArtist", false)).toLowerCase() === "true" + foreground: root.barForeground + accent: Color.accent + fontFamily: root.bar ? root.bar.fontFamily : Style.font.family + onClicked: root.setBooleanSetting("showArtist", + String(root.setting("showArtist", false)).toLowerCase() !== "true") + } + + Toggle { + width: parent.width + visible: String(root.setting("showTitle", true)).toLowerCase() === "true" + label: "Show full track info" + description: "Show the whole track title (and artist) without scrolling or clipping, instead of truncating and scrolling long titles." + checked: String(root.setting("showFullTitle", false)).toLowerCase() === "true" + foreground: root.barForeground + accent: Color.accent + fontFamily: root.bar ? root.bar.fontFamily : Style.font.family + onClicked: root.setBooleanSetting("showFullTitle", + String(root.setting("showFullTitle", false)).toLowerCase() !== "true") + } + + Toggle { + width: parent.width + label: "Show album cover" + description: "Show the album art thumbnail between the waveform and the track title when a cover is available." + checked: String(root.setting("showCover", false)).toLowerCase() === "true" + foreground: root.barForeground + accent: Color.accent + fontFamily: root.bar ? root.bar.fontFamily : Style.font.family + onClicked: root.setBooleanSetting("showCover", + String(root.setting("showCover", false)).toLowerCase() !== "true") + } + + Toggle { + width: parent.width + label: "Show playback controls" + description: "Show previous, play/pause, and next buttons in the bar." + checked: String(root.setting("showControls", true)).toLowerCase() === "true" + foreground: root.barForeground + accent: Color.accent + fontFamily: root.bar ? root.bar.fontFamily : Style.font.family + onClicked: root.setBooleanSetting("showControls", + String(root.setting("showControls", true)).toLowerCase() !== "true") + } + + Toggle { + width: parent.width + visible: String(root.setting("showControls", true)).toLowerCase() === "true" + label: "Group playback controls" + description: "Keep previous next to play/pause and next, beside the waveform." + checked: String(root.setting("groupControls", false)).toLowerCase() === "true" + foreground: root.barForeground + accent: Color.accent + fontFamily: root.bar ? root.bar.fontFamily : Style.font.family + onClicked: root.setBooleanSetting("groupControls", + String(root.setting("groupControls", false)).toLowerCase() !== "true") + } + + Toggle { + width: parent.width + label: "Hide when paused" + description: "Remove WaveBar from the bar while playback is paused." + checked: String(root.setting("hideWhenPaused", false)).toLowerCase() === "true" + foreground: root.barForeground + accent: Color.accent + fontFamily: root.bar ? root.bar.fontFamily : Style.font.family + onClicked: root.setBooleanSetting("hideWhenPaused", + String(root.setting("hideWhenPaused", false)).toLowerCase() !== "true") } } } diff --git a/README.md b/README.md index 14e687d..c7b6795 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,17 @@ desktop media players. - Controls the matched local PipeWire stream volume for browsers whose MPRIS endpoint ignores volume writes. - Selecting a media source immediately starts it and pauses the prior source. -- Keeps long titles inside the widget and source list with horizontal scrolling. +- Keeps long titles inside the widget and source list with horizontal scrolling, + or shows the full title without scrolling or clipping when enabled. +- Can show the artist name after the track title in the widget. +- Can show the album art thumbnail beside the waveform and as the panel header + art, using only trusted local files or known cover CDNs. +- Squared waveform and progress/volume controls with an optional grouped + previous/play/pause/next button cluster. - Supports horizontal and vertical Omarchy bars. - Can live in the left, center, or right section of the Omarchy bar. -- Exposes display options through Omarchy's native widget settings UI. +- Exposes display options through Omarchy's native widget settings UI and an + in-panel settings section. ## How media filtering works @@ -83,8 +90,10 @@ omarchy bar move io.github.erikburdett.wavebar --before omarchy.tray The manifest uses `left` only as the initial default. Omarchy preserves the user's chosen placement in `~/.config/omarchy/shell.json`. -Use WaveBar's native widget settings in Omarchy to show or hide the playback -controls and title, hide the widget while paused, or adjust the waveform and +Use WaveBar's native widget settings in Omarchy, or the **Settings** section in +the media panel (click the cog), to show or hide the playback controls and title, +show the artist and album cover, group the playback buttons, show the full title +without scrolling, hide the widget while paused, or adjust the waveform and title widths. The equivalent inline configuration is: ```json @@ -92,7 +101,11 @@ title widths. The equivalent inline configuration is: "id": "io.github.erikburdett.wavebar", "showControls": true, "showTitle": true, + "showArtist": false, "hideWhenPaused": false, + "groupControls": false, + "showFullTitle": false, + "showCover": false, "waveformWidth": 72, "maxTitleWidth": 150 } @@ -108,10 +121,16 @@ title widths. The equivalent inline configuration is: - `/usr/bin/pw-record` from the `pipewire-audio` package - `/usr/bin/python3` and the Python 3 standard library from the `python` package -WaveBar opens no network connections and rejects MPRIS-provided artwork rather -than loading an untrusted URL or file. It uses only local MPRIS and PipeWire -services. Media collections, metadata fields, capture targets, waveform frames, -and user-configurable widths all have explicit limits. +WaveBar opens no network connections for waveform or media handling. Album art +is the one optional exception: when the user enables cover display, only local +`file://` paths and a known allowlist of trusted cover CDNs (Apple Music +`mzstatic.com`, Spotify `scdn.co`, YouTube `yimg.com`/`ggpht.com`/ +`googleusercontent.com`, and Tidal/Deezer `tidal.com`/`dzcdn.net`) are loaded. +All other MPRIS-provided URLs — remote hosts, `data:`, special files, and +oversized sources — are rejected and treated as no cover. WaveBar otherwise uses +only local MPRIS and PipeWire services. Media collections, metadata fields, +capture targets, waveform frames, and user-configurable widths all have explicit +limits. The service invokes fixed `/usr/bin/python3` and `/usr/bin/pw-record` paths with a cleared environment and no shell. Its helper validates system-executable @@ -156,7 +175,7 @@ omarchy plugin validate "$PLUGIN_DIR" -i /usr/share/omarchy/shell/Ui/qmldir \ "$PLUGIN_DIR/Service.qml" "$PLUGIN_DIR/BarWidget.qml" \ "$PLUGIN_DIR/Panel.qml" "$PLUGIN_DIR/Waveform.qml" \ - "$PLUGIN_DIR/MarqueeText.qml" + "$PLUGIN_DIR/WavebarSlider.qml" "$PLUGIN_DIR/MarqueeText.qml" node "$PLUGIN_DIR/tests/test_media_model.js" /usr/bin/python3 "$PLUGIN_DIR/tests/test_manifest.py" /usr/bin/python3 "$PLUGIN_DIR/tests/test_waveform.py" diff --git a/Service.qml b/Service.qml index 54f04a7..3781fc6 100644 --- a/Service.qml +++ b/Service.qml @@ -36,6 +36,8 @@ Item { readonly property string artist: MediaModel.playerArtist(activePlayer) readonly property string album: MediaModel.playerAlbum(activePlayer) readonly property string identity: MediaModel.playerIdentity(activePlayer) + readonly property string trackArtUrl: safeTrackArt(activePlayer && activePlayer.trackArtUrl + ? activePlayer.trackArtUrl : "") // Matching player metadata to PipeWire nodes is the most expensive model // pass. Reuse one bounded result for capture and volume instead of scoring @@ -82,6 +84,30 @@ Item { function playerArtist(player) { return MediaModel.playerArtist(player) } function playerIdentity(player) { return MediaModel.playerIdentity(player) } + // Album art hardening: only allow local files or trusted cover CDNs (Spotify, + // YouTube, Apple Music, Tidal/Deezer). Anything else is rejected so we never + // load an untrusted remote URL supplied by a media player. + function isSafeTrackArt(url) { + if (typeof url !== "string" || url === "") return false + if (url.startsWith("file://")) return true + if (url.startsWith("/")) return true + var m = /^https?:\/\/([^\/?#]+)/.exec(url) + if (!m) return false + var host = m[1] + if (host === "localhost" || host === "127.0.0.1") return true + if (host.endsWith(".mzstatic.com")) return true // Apple Music + if (host.endsWith(".scdn.co")) return true // Spotify + if (host === "i.ytimg.com" || host.endsWith(".ggpht.com") + || host.endsWith(".googleusercontent.com")) return true // YouTube + if (host.endsWith(".tidal.com") || host === "e-cdns-images.dzcdn.net" + || host.endsWith(".dzcdn.net")) return true // Tidal / Deezer CDN + return false + } + + function safeTrackArt(url) { + return isSafeTrackArt(url) ? url : "" + } + function playerKey(player) { return MediaModel.playerKey(player) } diff --git a/WavebarSlider.qml b/WavebarSlider.qml new file mode 100644 index 0000000..714e335 --- /dev/null +++ b/WavebarSlider.qml @@ -0,0 +1,150 @@ +import QtQuick +import qs.Commons +import qs.Ui + +Item { + id: root + + property QtObject bar: null + property real value: 0 + property real minimum: 0 + property real maximum: 1 + property real step: 0.05 + property bool integer: false + property color trackColor: bar ? Style.selectedFillFor(bar.foreground, Color.accent) : "#333" + property color fillColor: bar ? bar.foreground : Color.foreground + property color knobColor: bar ? bar.foreground : Color.foreground + property bool dragging: false + property real trackHeight: Math.max(4, Math.round(Style.spacing.controlHeight * 0.11)) + property real knobSize: Math.max(14, Math.round(Style.spacing.controlHeight * 0.38)) + property real liveValue: value + + // macOS-style notches. When > 1, that many evenly-spaced tick marks are cut + // into the track (drawn in the panel background color, so only the part + // crossing the track shows). Purely visual — snapping is the caller's job via + // `integer`/`step` or an index-based value. Default 0 leaves the track plain. + property int tickCount: 0 + property color tickColor: bar ? bar.background : Color.background + + onValueChanged: if (!dragging) liveValue = value + + signal moved(real value) + signal released(real value) + + // Right-click is a secondary action on the whole track — audio uses it to + // mute the channel the slider belongs to. Dragging stays left-button only. + signal rightClicked() + + implicitWidth: Style.space(200) + implicitHeight: Math.max(Style.space(22), knobSize + Style.spacing.md) + + readonly property real range: Math.max(0.0001, maximum - minimum) + readonly property real progress: Math.max(0, Math.min(1, (liveValue - minimum) / range)) + readonly property bool _hot: mouseArea.containsMouse || root.dragging + + Rectangle { + id: track + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.right: parent.right + height: root.trackHeight + radius: 0 + color: root.trackColor + } + + Rectangle { + id: fill + anchors.verticalCenter: track.verticalCenter + anchors.left: track.left + height: track.height + radius: track.radius + color: root.fillColor + width: track.width * root.progress + + Behavior on width { + enabled: !root.dragging + NumberAnimation { duration: 140; easing.type: Easing.OutCubic } + } + } + + Repeater { + model: root.tickCount > 1 ? root.tickCount : 0 + Rectangle { + required property int index + width: Math.max(1, Style.space(2)) + height: root.trackHeight + Style.space(4) + radius: 0 + color: root.tickColor + anchors.verticalCenter: track.verticalCenter + x: Math.max(0, Math.min(track.width - width, + track.width * (index / (root.tickCount - 1)) - width / 2)) + } + } + + BorderSurface { + id: knob + width: root.knobSize + height: root.knobSize + radius: 0 + color: root.knobColor + borderSpec: Border.flat(root.bar ? root.bar.background : "#101315", Math.max(1, Style.space(2))) + anchors.verticalCenter: track.verticalCenter + x: Math.max(0, Math.min(track.width - width, track.width * root.progress - width / 2)) + scale: root._hot ? 1.15 : 1.0 + + Behavior on x { + enabled: !root.dragging + NumberAnimation { duration: 140; easing.type: Easing.OutCubic } + } + + Behavior on scale { + NumberAnimation { duration: 110; easing.type: Easing.OutCubic } + } + } + + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + acceptedButtons: Qt.LeftButton | Qt.RightButton + + function valueFromX(x) { + var clamped = Math.max(0, Math.min(track.width, x)) + var raw = root.minimum + (clamped / track.width) * root.range + if (root.integer) raw = Math.round(raw) + return Math.max(root.minimum, Math.min(root.maximum, raw)) + } + + onPressed: function(mouse) { + if (mouse.button !== Qt.LeftButton) return + root.dragging = true + var next = valueFromX(mouse.x) + root.liveValue = next + root.moved(next) + } + onClicked: function(mouse) { + if (mouse.button === Qt.RightButton) root.rightClicked() + } + onPositionChanged: function(mouse) { + if (!root.dragging) return + var next = valueFromX(mouse.x) + root.liveValue = next + root.moved(next) + } + onReleased: function(mouse) { + if (mouse.button !== Qt.LeftButton) return + root.dragging = false + root.released(root.liveValue) + root.liveValue = root.value + } + onWheel: function(wheel) { + var delta = wheel.angleDelta.y > 0 ? root.step : -root.step + var next = Math.max(root.minimum, Math.min(root.maximum, root.liveValue + delta)) + if (root.integer) next = Math.round(next) + root.liveValue = next + root.moved(next) + root.released(next) + } + } +} diff --git a/Waveform.qml b/Waveform.qml index c020589..c32afa1 100644 --- a/Waveform.qml +++ b/Waveform.qml @@ -35,7 +35,7 @@ Item { width: Math.max(1, slotWidth - root.gap) height: Math.max(root.minimumBarHeight, Math.round(root.height * (0.08 + level * 0.92))) y: Math.round((root.height - height) / 2) - radius: width / 2 + radius: 0 color: root.foreground opacity: root.live ? 0.95 : (root.active ? 0.48 : 0.28) diff --git a/manifest.json b/manifest.json index a6f4e5a..fffc13b 100644 --- a/manifest.json +++ b/manifest.json @@ -26,7 +26,11 @@ "showTitle": true, "hideWhenPaused": false, "waveformWidth": 72, - "maxTitleWidth": 150 + "maxTitleWidth": 150, + "showCover": false, + "showArtist": false, + "showFullTitle": false, + "groupControls": false }, "schema": [ { @@ -36,6 +40,13 @@ "description": "Show previous, play/pause, and next controls in the bar.", "defaultValue": true }, + { + "key": "groupControls", + "type": "boolean", + "label": "Group playback controls", + "description": "Place the previous/play/next buttons in one tight group next to the media info instead of separating previous from the rest.", + "defaultValue": false + }, { "key": "showTitle", "type": "boolean", @@ -43,6 +54,13 @@ "description": "Show the scrolling track title beside the waveform on horizontal bars.", "defaultValue": true }, + { + "key": "showArtist", + "type": "boolean", + "label": "Show artist in title", + "description": "Include the artist name after the track title in the scrolling text.", + "defaultValue": false + }, { "key": "hideWhenPaused", "type": "boolean", @@ -50,6 +68,20 @@ "description": "Remove WaveBar from the bar while the selected media source is paused.", "defaultValue": false }, + { + "key": "showFullTitle", + "type": "boolean", + "label": "Show full track info", + "description": "Show the whole track title (and artist) without scrolling or clipping, instead of truncating and scrolling long titles.", + "defaultValue": false + }, + { + "key": "showCover", + "type": "boolean", + "label": "Show album cover", + "description": "Show the album art thumbnail between the waveform and the track title when a cover is available.", + "defaultValue": false + }, { "key": "waveformWidth", "type": "integer",