media: add album art to bar widget

settings: keep the page still when a rebuilt row drops focus

fixes #3673
This commit is contained in:
bbedward
2026-10-03 12:23:57 -04:00
parent 49d7d6b2fe
commit 2a8368a8a4
10 changed files with 2863 additions and 875 deletions
File diff suppressed because it is too large Load Diff
@@ -128,6 +128,7 @@ var DEFAULTS = {
mediaSize: 1,
mediaAdaptiveWidthEnabled: true,
mediaShowLyrics: true,
mediaShowCoverArt: true,
audioScrollMode: "volume"
},
focusedWindow: {
@@ -44,7 +44,9 @@ FocusScope {
} else if (contains(content, focusedItem)) {
activePane = "content";
content.rememberFocus();
content._reveal(focusedItem);
// Focus that falls back to a container when its control is destroyed must not scroll the page
if (keyboardDriven() || focusedItem.cursorPosition !== undefined)
content._reveal(focusedItem);
}
}
+85 -34
View File
@@ -1,5 +1,6 @@
import QtQuick
import Quickshell.Services.Mpris
import Quickshell.Widgets
import qs.Common
import qs.Modules.DankBar
import qs.Modules.Plugins
@@ -27,6 +28,14 @@ BasePill {
property var widgetData: null
readonly property bool adaptiveWidthEnabled: SettingsData.widgetOption("music", widgetData, "mediaAdaptiveWidthEnabled")
readonly property bool lyricsEnabled: SettingsData.widgetOption("music", widgetData, "mediaShowLyrics") && LyricsService.allowed
readonly property bool coverArtEnabled: SettingsData.widgetOption("music", widgetData, "mediaShowCoverArt")
readonly property string coverArtUrl: coverArtEnabled && activePlayer && TrackArtService.artReadyFor(activePlayer) ? TrackArtService.resolvedArtUrl : ""
readonly property bool hasCoverArt: coverArtUrl !== ""
readonly property bool visualizerEnabled: CavaService.cavaAvailable && SettingsData.audioVisualizerEnabled
readonly property bool showIconSlot: visualizerEnabled || !hasCoverArt
readonly property real badgeSize: BarMetrics.mediaControlSize * contentScale
readonly property real badgeExtent: (hasCoverArt ? badgeSize + Theme.spacingXS : 0) + (showIconSlot ? badgeSize + Theme.spacingXS : 0)
readonly property string trackSummary: [MprisController.stableTitle, MprisController.stableArtist, MprisController.stableAlbum].filter(part => part).join(" • ")
readonly property bool lyricActive: {
if (!lyricsEnabled)
return false;
@@ -59,9 +68,8 @@ BasePill {
if (!isVerticalOrientation) {
return contentThickness;
}
const audioVizHeight = BarMetrics.mediaControlSize * root.contentScale;
const playButtonHeight = 24 * root.contentScale;
return audioVizHeight + Theme.spacingXS + playButtonHeight;
return badgeExtent + playButtonHeight;
}
property real scrollAccumulatorY: 0
@@ -71,6 +79,27 @@ BasePill {
active: root.lyricsEnabled && root.playerAvailable
}
Loader {
id: tooltipLoader
active: false
sourceComponent: DankTooltip {}
}
function showTrackTooltip() {
if (!root.lyricActive || !root.parentScreen)
return;
tooltipLoader.active = true;
const anchor = root.contextMenuAnchor();
tooltipLoader.item.show(root.trackSummary, anchor.x, anchor.y, anchor.screen, anchor.isVertical && anchor.edge === "left", anchor.isVertical && anchor.edge === "right");
}
function hideTrackTooltip() {
tooltipLoader.item?.hide();
tooltipLoader.active = false;
}
onClicked: hideTrackTooltip()
onWheel: function (wheelEvent) {
if (SettingsData.widgetOption("music", widgetData, "audioScrollMode") === "nothing")
return;
@@ -135,6 +164,42 @@ BasePill {
}
}
component CoverArt: ClippingRectangle {
width: root.badgeSize
height: root.badgeSize
radius: Theme.cornerRadiusXS
color: "transparent"
visible: root.hasCoverArt
Image {
anchors.fill: parent
source: root.coverArtUrl
sourceSize: Qt.size(Math.round(width * root.dpr), Math.round(height * root.dpr))
fillMode: Image.PreserveAspectCrop
asynchronous: true
}
}
component MediaIcon: Item {
width: root.badgeSize
height: root.badgeSize
visible: root.showIconSlot
AudioVisualization {
enabled: root.surfaceLive
anchors.fill: parent
visible: root.visualizerEnabled
}
DankIcon {
anchors.fill: parent
name: "music_note"
size: parent.width
color: Theme.primary
visible: !root.visualizerEnabled
}
}
component PlayButton: Item {
width: Theme.iconSize * root.contentScale
height: width
@@ -177,15 +242,18 @@ BasePill {
}
readonly property int horizontalContentWidth: {
const controlsWidth = 64 * root.contentScale + Theme.spacingXS * 2;
const audioVizWidth = BarMetrics.mediaControlSize * root.contentScale;
const baseWidth = audioVizWidth + Theme.spacingXS + controlsWidth;
return baseWidth + (measuredTextWidth > 0 ? measuredTextWidth + Theme.spacingXS : 0);
return root.badgeExtent + controlsWidth + (measuredTextWidth > 0 ? measuredTextWidth + Theme.spacingXS : 0);
}
implicitWidth: root.playerAvailable ? (root.isVerticalOrientation ? root.currentContentWidth : horizontalContentWidth) : 0
implicitHeight: root.playerAvailable ? root.currentContentHeight : 0
opacity: root.playerAvailable ? 1 : 0
HoverHandler {
enabled: root.lyricActive
onHoveredChanged: hovered ? root.showTrackTooltip() : root.hideTrackTooltip()
}
Behavior on opacity {
NumberAnimation {
duration: Theme.shortDuration
@@ -215,22 +283,17 @@ BasePill {
spacing: Theme.spacingXS
Item {
width: BarMetrics.mediaControlSize * root.contentScale
height: BarMetrics.mediaControlSize * root.contentScale
anchors.horizontalCenter: parent.horizontalCenter
width: root.badgeSize
height: badges.height
AudioVisualization {
enabled: root.surfaceLive
anchors.fill: parent
visible: CavaService.cavaAvailable && SettingsData.audioVisualizerEnabled
}
Column {
id: badges
spacing: Theme.spacingXS
DankIcon {
anchors.fill: parent
name: "music_note"
size: BarMetrics.mediaControlSize * root.contentScale
color: Theme.primary
visible: !CavaService.cavaAvailable || !SettingsData.audioVisualizerEnabled
CoverArt {}
MediaIcon {}
}
MouseArea {
@@ -272,24 +335,12 @@ BasePill {
spacing: Theme.spacingXS
anchors.verticalCenter: parent.verticalCenter
Item {
width: BarMetrics.mediaControlSize * root.contentScale
height: BarMetrics.mediaControlSize * root.contentScale
CoverArt {
anchors.verticalCenter: parent.verticalCenter
}
AudioVisualization {
enabled: root.surfaceLive
anchors.fill: parent
visible: CavaService.cavaAvailable && SettingsData.audioVisualizerEnabled
}
DankIcon {
anchors.fill: parent
name: "music_note"
size: BarMetrics.mediaControlSize * root.contentScale
color: Theme.primary
visible: !CavaService.cavaAvailable || !SettingsData.audioVisualizerEnabled
}
MediaIcon {
anchors.verticalCenter: parent.verticalCenter
}
Rectangle {
@@ -38,7 +38,7 @@ Item {
property real wordProgress: 1
implicitHeight: Math.ceil(line.implicitHeight * textScale) + Theme.spacingS
opacity: reach === 0 || !emphasize ? 1 : reach === 1 ? DashMetrics.lyricsNearOpacity : reach === 2 ? DashMetrics.lyricsFarOpacity : 0
opacity: reach === 0 || !emphasize ? 1 : reach === 1 ? DashMetrics.lyricsNearOpacity : DashMetrics.lyricsFarOpacity
onWordRevisionChanged: updateWordProgress()
onAnimateWordsChanged: updateWordProgress()
onSweepChanged: updateWordProgress()
@@ -46,6 +46,14 @@ Column {
onToggled: checked => root.page.set("mediaShowLyrics", checked)
}
SettingsToggleRow {
resetStore: root.page
resetKeys: ["mediaShowCoverArt"]
text: I18n.tr("Cover art", "media widget option showing the album cover in the pill")
checked: root.page.value("mediaShowCoverArt")
onToggled: checked => root.page.set("mediaShowCoverArt", checked)
}
SettingsDropdownRow {
resetStore: root.page
resetKeys: ["audioScrollMode"]
@@ -189,24 +189,25 @@ Item {
}
Repeater {
model: SettingsData.notificationRules
// Count model: a rule edit must not rebuild the row holding the focused toggle
model: SettingsData.notificationRules.length
delegate: SettingsRow {
id: ruleRow
required property var modelData
required property int index
readonly property var badges: root.outcomeBadges(modelData)
readonly property var rule: SettingsData.notificationRules[index] ?? ({})
readonly property var badges: root.outcomeBadges(rule)
title: modelData.pattern || I18n.tr("Rule %1", "notification rule heading, %1 is the rule number").arg(index + 1)
titleColor: modelData.enabled !== false ? Theme.surfaceText : Theme.surfaceVariantText
subtitle: root.matchSummary(modelData)
title: rule.pattern || I18n.tr("Rule %1", "notification rule heading, %1 is the rule number").arg(index + 1)
titleColor: rule.enabled !== false ? Theme.surfaceText : Theme.surfaceVariantText
subtitle: root.matchSummary(rule)
DankToggle {
anchors.verticalCenter: parent.verticalCenter
hideText: true
text: ruleRow.title
checked: ruleRow.modelData.enabled !== false
checked: ruleRow.rule.enabled !== false
onToggled: checked => SettingsData.updateNotificationRuleField(ruleRow.index, "enabled", checked)
}
@@ -214,7 +215,7 @@ Item {
anchors.verticalCenter: parent.verticalCenter
iconName: "edit"
Accessible.name: I18n.tr("Edit rule")
onClicked: root.openEditor(ruleRow.index, ruleRow.modelData)
onClicked: root.openEditor(ruleRow.index, ruleRow.rule)
}
DankActionButton {
@@ -32,6 +32,7 @@ Column {
SettingsData.barConfigs;
SettingsData.dockConfigs;
const bars = SettingsData.barConfigs.filter(config => !SettingsData.isDotBarConfig(config)).map(config => ({
key: "bar:" + config.id,
kind: "bar",
id: config.id,
name: config.name || config.id,
@@ -40,6 +41,7 @@ Column {
transparency: config.transparency ?? 1
}));
const docks = SettingsData.dockConfigs.map(config => ({
key: "dock:" + config.id,
kind: "dock",
id: config.id,
name: config.name,
@@ -372,37 +374,38 @@ Column {
SettingsToggleRow {
id: overrideRow
required property var modelData
required property string modelData
readonly property var target: root.opacityTargets.find(entry => entry.key === modelData) ?? null
tab: "theme"
tags: ["surface", "opacity", "transparency", "bar", "dock", "override"]
settingKey: "surfaceOpacity_" + modelData.kind + "_" + modelData.id
text: modelData.name
settingKey: "surfaceOpacity_" + modelData.replace(":", "_")
text: target?.name ?? ""
description: I18n.tr("Override")
checked: modelData.override
modified: modelData.override
checked: target?.override ?? false
modified: target?.override ?? false
resetByKeys: false
onResetRequested: root.setOpacityOverride(modelData, {
onResetRequested: root.setOpacityOverride(target, {
followInterfaceStyle: true,
transparency: 1
})
onToggled: checked => root.setOpacityOverride(modelData, {
onToggled: checked => root.setOpacityOverride(target, {
followInterfaceStyle: !checked
})
body: SettingsSliderRow {
width: parent.width
enabled: overrideRow.modelData.override
enabled: overrideRow.target?.override ?? false
text: I18n.tr("Opacity")
value: Math.round(overrideRow.modelData.transparency * 100)
value: Math.round((overrideRow.target?.transparency ?? 1) * 100)
minimum: 0
maximum: 100
modified: value !== 100
resetByKeys: false
onResetRequested: root.setOpacityOverride(overrideRow.modelData, {
onResetRequested: root.setOpacityOverride(overrideRow.target, {
transparency: 1
})
onSliderDragFinished: finalValue => root.setOpacityOverride(overrideRow.modelData, {
onSliderDragFinished: finalValue => root.setOpacityOverride(overrideRow.target, {
transparency: finalValue / 100
})
}
@@ -430,6 +433,9 @@ Column {
readonly property var targets: root.opacityTargets.filter(target => target.kind === modelData.kind)
readonly property var activeTargets: targets.filter(target => target.enabled)
readonly property var hiddenTargets: targets.filter(target => !target.enabled)
// String keys: a config edit must not rebuild the row holding the focused control
readonly property string activeKeys: activeTargets.map(target => target.key).join("\n")
readonly property string hiddenKeys: hiddenTargets.map(target => target.key).join("\n")
property bool showHidden: false
tab: "theme"
@@ -439,7 +445,7 @@ Column {
visible: targets.length > 0
Repeater {
model: targetCard.activeTargets
model: targetCard.activeKeys ? targetCard.activeKeys.split("\n") : []
delegate: opacityTargetRow
}
@@ -461,7 +467,7 @@ Column {
}
Repeater {
model: targetCard.showHidden ? targetCard.hiddenTargets : []
model: targetCard.showHidden && targetCard.hiddenKeys ? targetCard.hiddenKeys.split("\n") : []
delegate: opacityTargetRow
}
}
+1 -1
View File
@@ -149,7 +149,7 @@ Singleton {
readonly property string displayName: {
switch (compositor) {
case "niri":
return "Niri";
return "niri";
case "hyprland":
return "Hyprland";
case "mango":
@@ -9933,6 +9933,14 @@
"runtimeType": "barWidgetOption",
"optionFile": "IslandOptions.qml"
},
{
"section": "barWidgetOption:MusicOptions.qml:4",
"label": "Cover art",
"tabIndex": 22,
"keywords": [],
"runtimeType": "barWidgetOption",
"optionFile": "MusicOptions.qml"
},
{
"section": "barWidgetOption:RunningAppsOptions.qml:4",
"label": "Current display",
@@ -10294,7 +10302,7 @@
"optionFile": "IslandOptions.qml"
},
{
"section": "barWidgetOption:MusicOptions.qml:5",
"section": "barWidgetOption:MusicOptions.qml:6",
"label": "Media player",
"tabIndex": 22,
"keywords": [],
@@ -10438,7 +10446,7 @@
"optionFile": "PrivacyOptions.qml"
},
{
"section": "barWidgetOption:MusicOptions.qml:4",
"section": "barWidgetOption:MusicOptions.qml:5",
"label": "Scroll wheel",
"tabIndex": 22,
"keywords": [],