From 93593443c876750cf6a4f39dfcf34798391604a7 Mon Sep 17 00:00:00 2001 From: Brad Thornbury Date: Sat, 3 Oct 2026 18:21:54 -0500 Subject: [PATCH] feat(media): add an Icon presentation for Now Playing On narrow outputs a playing track widens G9 by the skip controls and the 88pt title marquee (~160pt on a 1270pt-wide bar). That pushes the bar into a responsive stage that hides G9 and G10, and because a hidden group cannot be re-measured, the bar stays in that stage after playback stops until the shell reloads. The existing Compact (FULL/muse) presentation is about as wide, so it does not avoid this. Add a third G9 presentation, `mediaStyle: "icon"`: the default row with only play/pause and the playing pulse. The skip controls and marquee are hidden (and the marquee animation stays stopped), while the lazy panel keeps the full controls. - media BarWidget: normalize default/full/icon; iconStyle hides the skip controls and marquee; expose marqueeVisible like the existing hooks. - state Service: normalizedMediaStyle() keeps "icon" in both the reader and the appearance setter. - Control Center: offer Default / Compact / Icon for G9. - tests: media smoke checks the Icon presentation and that Default still shows the marquee; regression and control-center smoke cover the third option and its persistence. - docs: describe Icon in the V1 presentation contract. Co-Authored-By: Claude Opus 5.5 --- docs/v1-presentation-contract.md | 6 +++- .../WidgetAppearanceWorkbench.qml | 8 +++-- hancore.shibumi.media/BarWidget.qml | 22 ++++++++++---- hancore.shibumi.state/Service.qml | 10 +++++-- tests/audio-media-plugins-regression.sh | 13 ++++++-- tests/control-center-smoke.qml | 17 ++++++++--- tests/media-plugin-smoke.qml | 30 +++++++++++++++---- 7 files changed, 83 insertions(+), 23 deletions(-) diff --git a/docs/v1-presentation-contract.md b/docs/v1-presentation-contract.md index a4337ec2..f02d2e24 100644 --- a/docs/v1-presentation-contract.md +++ b/docs/v1-presentation-contract.md @@ -140,7 +140,11 @@ remain open acceptance gates. G9 must preserve V1's idle note, previous/play/next controls, fixed-width marquee, playing pulse, tooltip, cover/progress panel, and selectable FULL/muse outcome: 24-band real Cava, vinyl mark, transport state, click -play/pause, and wheel previous/next. State, player selection, source selection, +play/pause, and wheel previous/next. An optional Icon presentation keeps the +default row's play/pause control and playing pulse but drops the skip controls +and title marquee, so a playing track adds little width on narrow outputs and +does not push the bar into a responsive stage that hides G9; the lazy panel +keeps the full controls. State, player selection, source selection, and every transport action come from the single official `omarchy.media` service. One lazy process-wide Shibumi spectrum service may own the bounded Cava process and degraded state; no per-output, per-panel, or per-view process diff --git a/hancore.shibumi.control-center/WidgetAppearanceWorkbench.qml b/hancore.shibumi.control-center/WidgetAppearanceWorkbench.qml index b2569b4f..56fa7740 100644 --- a/hancore.shibumi.control-center/WidgetAppearanceWorkbench.qml +++ b/hancore.shibumi.control-center/WidgetAppearanceWorkbench.qml @@ -31,7 +31,8 @@ Column { ] readonly property var mediaStyleOptions: [ { value: "default", label: "Default" }, - { value: "full", label: "Compact" } + { value: "full", label: "Compact" }, + { value: "icon", label: "Icon" } ] readonly property var v1CompactGroupIds: [ "G4", "G5", "G6", "G11", "G12", "G13", "G14", "G15", "G18" @@ -484,8 +485,9 @@ Column { const catalogGroup = String(catalogGroupValue || catalogGroupForSettingsGroup(group) || "") if (catalogGroup === "G9") - return String(widgetSetting(group, "mediaStyle", "default")) === "full" - ? "full" : "default" + return ["full", "icon"].indexOf( + String(widgetSetting(group, "mediaStyle", "default"))) >= 0 + ? String(widgetSetting(group, "mediaStyle", "default")) : "default" const stored = String(widgetSetting(group, "displayMode", "")) const mode = stored !== "" ? stored : widgetSetting(group, "compact", false) === true ? "icon" : "full" diff --git a/hancore.shibumi.media/BarWidget.qml b/hancore.shibumi.media/BarWidget.qml index be4f439b..08fd4bb3 100644 --- a/hancore.shibumi.media/BarWidget.qml +++ b/hancore.shibumi.media/BarWidget.qml @@ -34,13 +34,18 @@ Ui.Panel { ? tokens.widgetContentColor(settings, bar ? bar.urgent : Commons.Color.accent) : (bar ? bar.urgent : Commons.Color.accent) - // Now Playing follows the two-presentation contract shared by the original + // Now Playing follows the presentation contract shared by the original // V1 and V2 shells. It is deliberately not part of the generic // icon/text/full content-mode family used by the other V2 widgets. - readonly property string mediaStyle: - String(setting("mediaStyle", "default")) === "full" - ? "full" : "default" - readonly property bool defaultMode: mediaStyle === "default" + // "icon" is Default without the skip controls and the title marquee, so + // a playing track adds little width and narrow outputs do not drop into a + // responsive stage that hides this group; the panel keeps the full controls. + readonly property string mediaStyle: { + const style = String(setting("mediaStyle", "default")) + return style === "full" || style === "icon" ? style : "default" + } + readonly property bool iconStyle: mediaStyle === "icon" + readonly property bool defaultMode: mediaStyle === "default" || iconStyle readonly property bool fullMode: mediaStyle === "full" readonly property bool iconMode: false readonly property bool textMode: false @@ -64,6 +69,7 @@ Ui.Panel { readonly property bool textVisible: activeText.visible readonly property bool iconVisible: activeIcon.visible readonly property bool v1FullVisible: activeRow.visible + readonly property bool marqueeVisible: marqueeClip.visible visible: mediaService !== null implicitWidth: visible ? (bar && bar.vertical @@ -194,6 +200,7 @@ Ui.Panel { spacing: root.tokens ? root.tokens.compactGap : Commons.Style.space(4) MediaControl { + visible: !root.iconStyle icon: "skip_previous" enabled: root.activePlayer && root.activePlayer.canGoPrevious === true action: "previous" @@ -208,6 +215,7 @@ Ui.Panel { } MediaControl { + visible: !root.iconStyle icon: "skip_next" enabled: root.activePlayer && root.activePlayer.canGoNext === true action: "next" @@ -234,6 +242,7 @@ Ui.Panel { Item { id: marqueeClip width: Commons.Style.space(88) + visible: !root.iconStyle height: mediaSurface.height anchors.verticalCenter: parent.verticalCenter @@ -241,7 +250,7 @@ Ui.Panel { marqueeAnimation.stop() marqueeText.x = 0 Qt.callLater(function() { - if (root.visible && root.playing && !root.opened + if (root.visible && !root.iconStyle && root.playing && !root.opened && marqueeText.implicitWidth > marqueeClip.width) marqueeAnimation.start() }) @@ -288,6 +297,7 @@ Ui.Panel { function onPlayingChanged() { marqueeClip.reset() } function onOpenedChanged() { marqueeClip.reset() } function onVisibleChanged() { marqueeClip.reset() } + function onIconStyleChanged() { marqueeClip.reset() } } SequentialAnimation { diff --git a/hancore.shibumi.state/Service.qml b/hancore.shibumi.state/Service.qml index 6e5a765c..6c105e88 100644 --- a/hancore.shibumi.state/Service.qml +++ b/hancore.shibumi.state/Service.qml @@ -176,6 +176,12 @@ Item { ? appearance[variant] : ({}) } + // G9 Now Playing presentations: Default, Full (muse) and Icon. + function normalizedMediaStyle(value) { + const style = String(value || "default") + return style === "full" || style === "icon" ? style : "default" + } + function normalizedDisplayMode(groupId, variantValue, value) { const group = String(groupId || "") const variant = normalizedVariant(variantValue) @@ -215,7 +221,7 @@ Item { && !Object.prototype.hasOwnProperty.call(settings, name) && settings.compact === true) value = "full" - value = String(value || "default") === "full" ? "full" : "default" + value = normalizedMediaStyle(value) } return value } @@ -334,7 +340,7 @@ Item { else if (name === "compact") normalizedValue = value === true else if (name === "mediaStyle") - normalizedValue = String(value || "") === "full" ? "full" : "default" + normalizedValue = normalizedMediaStyle(value) else if (name === "batteryOrientation") normalizedValue = value === "vertical" ? "vertical" : "horizontal" diff --git a/tests/audio-media-plugins-regression.sh b/tests/audio-media-plugins-regression.sh index 0a79a535..6a5eec27 100755 --- a/tests/audio-media-plugins-regression.sh +++ b/tests/audio-media-plugins-regression.sh @@ -190,9 +190,18 @@ rg -q 'target: "shibumi-media-spectrum"' \ rg -Fq 'readonly property string mediaStyle:' \ "$repo_root/hancore.shibumi.media/BarWidget.qml" \ || fail "G9 does not normalize its two-presentation style" -rg -Fq 'String(setting("mediaStyle", "default")) === "full"' \ +rg -Fq 'return style === "full" || style === "icon" ? style : "default"' \ "$repo_root/hancore.shibumi.media/BarWidget.qml" \ - || fail "G9 does not expose Default and Full presentations" + || fail "G9 does not expose Default, Full and Icon presentations" +rg -Fq 'visible: !root.iconStyle' \ + "$repo_root/hancore.shibumi.media/BarWidget.qml" \ + || fail "G9 Icon does not drop the skip controls and marquee" +rg -Fq '{ value: "icon", label: "Icon" }' \ + "$repo_root/hancore.shibumi.control-center/WidgetAppearanceWorkbench.qml" \ + || fail "Control Center does not offer the G9 Icon presentation" +rg -Fq 'return style === "full" || style === "icon" ? style : "default"' \ + "$repo_root/hancore.shibumi.state/Service.qml" \ + || fail "State does not preserve the G9 Icon presentation" rg -q 'readonly property bool museMode: fullMode' \ "$repo_root/hancore.shibumi.media/BarWidget.qml" \ || fail "G9 does not share FULL/muse across V1 and V2" diff --git a/tests/control-center-smoke.qml b/tests/control-center-smoke.qml index 9fed684c..40672b41 100644 --- a/tests/control-center-smoke.qml +++ b/tests/control-center-smoke.qml @@ -837,16 +837,23 @@ ShellRoot { appearance.showWidgetOverview() if (!appearance.openWidgetDetails("G9", "") || appearance.selectedWidgetMode !== "default" - || appearance.selectedWidgetModeOptions.length !== 2 + || appearance.selectedWidgetModeOptions.length !== 3 || appearance.selectedWidgetModeOptions[0].value !== "default" || appearance.selectedWidgetModeOptions[0].label !== "Default" || appearance.selectedWidgetModeOptions[1].value !== "full" || appearance.selectedWidgetModeOptions[1].label !== "Compact" + || appearance.selectedWidgetModeOptions[2].value !== "icon" + || appearance.selectedWidgetModeOptions[2].label !== "Icon" || !appearance.cycleSelectedWidgetMode() || appearance.selectedWidgetMode !== "full" || stateService.groupAppearanceSettingForVariant( "G9", "v2", "mediaStyle", "") !== "default") - return root.fail("V1 Now Playing Default/Compact contract drifted") + return root.fail("V1 Now Playing Default/Compact/Icon contract drifted") + if (!appearance.cycleSelectedWidgetMode() + || appearance.selectedWidgetMode !== "icon" + || stateService.groupAppearanceSettingForVariant( + "G9", "v1", "mediaStyle", "") !== "icon") + return root.fail("V1 Now Playing Icon style did not persist") if (!appearance.cycleSelectedWidgetMode() || appearance.selectedWidgetMode !== "default") return root.fail("V1 Now Playing style did not restore") @@ -876,16 +883,18 @@ ShellRoot { panel.v2LayoutActive = true if (!appearance.openWidgetDetails("G9", "") || appearance.selectedWidgetMode !== "default" - || appearance.selectedWidgetModeOptions.length !== 2 + || appearance.selectedWidgetModeOptions.length !== 3 || appearance.selectedWidgetModeOptions[0].value !== "default" || appearance.selectedWidgetModeOptions[0].label !== "Default" || appearance.selectedWidgetModeOptions[1].value !== "full" || appearance.selectedWidgetModeOptions[1].label !== "Compact" + || appearance.selectedWidgetModeOptions[2].value !== "icon" + || appearance.selectedWidgetModeOptions[2].label !== "Icon" || !appearance.cycleSelectedWidgetMode() || appearance.selectedWidgetMode !== "full" || stateService.groupAppearanceSettingForVariant( "G9", "v1", "mediaStyle", "") !== "default") - return root.fail("V2 Now Playing Default/Compact contract drifted") + return root.fail("V2 Now Playing Default/Compact/Icon contract drifted") if (!appearance.openWidgetDetails("G4", "") || appearance.selectedWidgetMode !== "full") return root.fail("V2 appearance did not remain independent") diff --git a/tests/media-plugin-smoke.qml b/tests/media-plugin-smoke.qml index 822f660f..97f5ebab 100644 --- a/tests/media-plugin-smoke.qml +++ b/tests/media-plugin-smoke.qml @@ -282,14 +282,34 @@ ShellRoot { if (!media.fullMode || !media.museMode || !media.museVisible || media.v1Presentation || fakeSpectrum.clientCount !== 1) return root.fail("V2 FULL muse presentation/lease") - root.mediaSettings = ({ spectrum: false, mediaStyle: "default" }) + root.mediaSettings = ({ spectrum: false, mediaStyle: "icon" }) } else if (root.phase === 3) { + // Icon keeps the default row's play/pause and pulse but drops the + // skip controls and title, so a playing track stays narrow. + if (!media.iconStyle || !media.defaultMode || media.fullMode + || !media.v1FullVisible || media.museVisible + || fakeSpectrum.clientCount !== 0) + return root.fail("icon presentation") + // Width follows the row after a polish, which this headless window + // does not run; the real-bar width drop is covered by the hidden + // marquee and the static check on the skip controls. + if (media.marqueeVisible) + return root.fail("icon presentation still shows the marquee") + media.interactionTarget.triggerPress(Qt.LeftButton) + if (mediaState.lastAction !== "playPause" || playerA.isPlaying) + return root.fail("icon play/pause action forwarding") + media.interactionTarget.triggerPress(Qt.LeftButton) + if (!playerA.isPlaying) + return root.fail("icon play/pause did not resume") + root.mediaSettings = ({ spectrum: false, mediaStyle: "default" }) + } else if (root.phase === 4) { if (media.fullMode || !media.defaultMode || !media.v1FullVisible + || media.iconStyle || !media.marqueeVisible || media.museVisible || fakeSpectrum.clientCount !== 0) return root.fail("shared default presentation") fakeShell.spectrumBackend = null media.interactionTarget.triggerPress(Qt.RightButton) - } else if (root.phase === 4) { + } else if (root.phase === 5) { if (!media.opened || !media.panelLoaded || !media.panelItem) { if (++root.waits > 45) return root.fail("real media panel did not load") return @@ -362,15 +382,15 @@ ShellRoot { || mediaState.activePlayer !== playerB) return root.fail("source selection forwarding") media.close() - } else if (root.phase === 5) { + } else if (root.phase === 6) { if (media.opened || media.panelLoaded || media.activePlayer !== playerB) return root.fail("lazy panel close") mediaState.activePlayer = null - } else if (root.phase === 6) { + } else if (root.phase === 7) { if (media.active || media.implicitWidth >= root.activeWidth) return root.fail("idle V1 presentation") media.interactionTarget.triggerPress(Qt.LeftButton) - } else if (root.phase === 7) { + } else if (root.phase === 8) { if (!media.opened || !media.panelLoaded) return media.close() mediaLoader.active = false