diff --git a/quickshell/Common/LyricLayout.js b/quickshell/Common/LyricLayout.js new file mode 100644 index 000000000..764e44eac --- /dev/null +++ b/quickshell/Common/LyricLayout.js @@ -0,0 +1,23 @@ +.pragma library + +const FILLER = /\s|[♩♪♫♬.…·*~()\[\]\-–—]|🎵|🎶|🎼|\uD834[\uDD00-\uDDFF]/g; + +function hasWords(text) { + return String(text ?? "").replace(FILLER, "") !== ""; +} + +// Halving a too-wide median line gives two even pages instead of a full page and a stub; +// slack absorbs the word the greedy pager pushes over the half. +function pageWidth(widths, cap, minimum, slack) { + const sorted = widths.filter(width => width > 0).sort((a, b) => a - b); + if (sorted.length === 0) + return 0; + const mid = sorted.length >> 1; + const median = sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; + let width = cap; + if (median <= cap) + width = median; + else if (median / 2 + slack <= cap) + width = median / 2 + slack; + return Math.ceil(Math.max(minimum, width)); +} diff --git a/quickshell/Common/settings/BarWidgetDefaults.js b/quickshell/Common/settings/BarWidgetDefaults.js index 277422f9b..b7e420576 100644 --- a/quickshell/Common/settings/BarWidgetDefaults.js +++ b/quickshell/Common/settings/BarWidgetDefaults.js @@ -37,6 +37,7 @@ var ISLAND_DEFAULTS = { islandPalette: "default", islandHighContrast: false, islandMediaClockVisible: true, + islandMediaLyrics: false, islandMediaAutoSwitch: false, islandNotificationBadgeClearOnOpen: false, islandNotificationExpand: false, diff --git a/quickshell/Modules/DBar/Widgets/Media.qml b/quickshell/Modules/DBar/Widgets/Media.qml index dd3e7f795..81316f686 100644 --- a/quickshell/Modules/DBar/Widgets/Media.qml +++ b/quickshell/Modules/DBar/Widgets/Media.qml @@ -7,6 +7,7 @@ import qs.Modules.Plugins import qs.Services import qs.DCommon.Widgets import qs.Widgets +import "../../../Common/LyricLayout.js" as LyricLayout BasePill { id: root @@ -47,7 +48,7 @@ BasePill { if (!controller.enabled || !controller.synced) return false; const parts = controller.lines[controller.activeIndex]?.parts ?? []; - return parts.some(part => part.x.trim() !== ""); + return parts.some(part => LyricLayout.hasWords(part.x)); } readonly property int maxTextWidth: { const size = SettingsData.widgetOption("music", widgetData, "mediaSize"); diff --git a/quickshell/Modules/DIsland/Activities/MediaCompact.qml b/quickshell/Modules/DIsland/Activities/MediaCompact.qml index eb1f133e6..080aebad4 100644 --- a/quickshell/Modules/DIsland/Activities/MediaCompact.qml +++ b/quickshell/Modules/DIsland/Activities/MediaCompact.qml @@ -10,6 +10,8 @@ MediaActivityFace { isVertical: controller.isVertical artworkSize: controller.compactIconSize clockVisible: controller.mediaClockVisible + lyricsEnabled: controller.mediaLyrics + maxLength: controller.mediaCompactMaxLength function updateLength() { controller.setMediaContentLength(isVertical ? implicitHeight : implicitWidth); } diff --git a/quickshell/Modules/DIsland/IslandBarHost.qml b/quickshell/Modules/DIsland/IslandBarHost.qml index 600ad86c2..3caf88cbc 100644 --- a/quickshell/Modules/DIsland/IslandBarHost.qml +++ b/quickshell/Modules/DIsland/IslandBarHost.qml @@ -435,6 +435,7 @@ Item { homeBrightnessDisplay: SettingsData.islandLevelDisplay(root.barConfig, "islandHomeBrightnessDisplay") batteryStyle: root.setting("islandBatteryStyle") mediaClockVisible: root.setting("islandMediaClockVisible") + mediaLyrics: root.setting("islandMediaLyrics") // Home and media share the dot face; see mediaFaceToggleAvailable. mediaAutoSwitch: !root.dotMode && root.setting("islandMediaAutoSwitch") launcherCycleEnabled: SettingsData.launcherStyle === "island" diff --git a/quickshell/Modules/DIsland/IslandController.qml b/quickshell/Modules/DIsland/IslandController.qml index 24e361282..8b279fce2 100644 --- a/quickshell/Modules/DIsland/IslandController.qml +++ b/quickshell/Modules/DIsland/IslandController.qml @@ -67,6 +67,7 @@ QtObject { property real compactThickness: 38 property string batteryStyle: "duo" property bool mediaClockVisible: true + property bool mediaLyrics: false property int transientTimeout: 2200 property int notificationTimeout: 5000 property string transientReturnActivity: "" diff --git a/quickshell/Modules/Settings/Widgets/IslandBehaviorCard.qml b/quickshell/Modules/Settings/Widgets/IslandBehaviorCard.qml index f481e6a23..1b60165ac 100644 --- a/quickshell/Modules/Settings/Widgets/IslandBehaviorCard.qml +++ b/quickshell/Modules/Settings/Widgets/IslandBehaviorCard.qml @@ -183,6 +183,16 @@ SettingsCard { onToggled: checked => root.store.apply("islandMediaClockVisible", checked) } + SettingsToggleRow { + settingKey: root.keyPrefix + "MediaLyrics" + tags: ["island", "media", "lyrics", "compact"] + visible: !root.isDot + text: I18n.tr("Lyrics", "Media player lyrics button") + description: I18n.tr("Shows the line being sung instead of the title and artist while synced lyrics are available", "media widget lyrics toggle description") + checked: root.store.setting("islandMediaLyrics") + onToggled: checked => root.store.apply("islandMediaLyrics", checked) + } + SettingsToggleRow { settingKey: root.keyPrefix + "MediaAutoSwitch" tags: ["island", "media", "playback", "auto", "switch", "compact"] diff --git a/quickshell/Modules/SurfaceWidgets/MediaActivityFace.qml b/quickshell/Modules/SurfaceWidgets/MediaActivityFace.qml index 0aafa18b4..4abeaf94b 100644 --- a/quickshell/Modules/SurfaceWidgets/MediaActivityFace.qml +++ b/quickshell/Modules/SurfaceWidgets/MediaActivityFace.qml @@ -7,6 +7,7 @@ import qs.Modules.DBar.Widgets import qs.Services import qs.DCommon.Widgets import qs.Widgets +import "../../Common/LyricLayout.js" as LyricLayout Item { id: root @@ -35,13 +36,52 @@ Item { } readonly property string minuteText: String(systemClock.date.getMinutes()).padStart(2, "0") property bool clockVisible: false - readonly property real naturalTextWidth: Math.max(root.minTextWidth, titleMetrics.width, root.dense ? 0 : artistMetrics.width) + root.textGutter * 2 - readonly property real measuredWidth: { - let width = Theme.spacingS + root.artworkSize + Theme.spacingXS + root.cavaWidth + Theme.spacingXS + root.naturalTextWidth + Theme.spacingXS; + property bool lyricsEnabled: false + readonly property bool lyricsWanted: lyricsEnabled && LyricsService.allowed && !isVertical + readonly property bool lyricsLive: lyricsWanted && enabled + readonly property bool lyricActive: { + if (!lyricsLive) + return false; + const controller = LyricsService.controller; + if (!controller.enabled || !controller.synced) + return false; + const parts = controller.lines[controller.activeIndex]?.parts ?? []; + return parts.some(part => LyricLayout.hasWords(part.x)); + } + property real maxLength: Infinity + property var lyricWidths: [] + property real lyricWordWidth: 0 + readonly property real titleFontSize: Theme.fontSizeSmall * root.contentScale + readonly property real chromeWidth: { + let width = Theme.spacingS + root.artworkSize + Theme.spacingXS + root.cavaWidth + Theme.spacingXS + root.textGutter * 2 + Theme.spacingXS; if (root.clockVisible) width += clockText.width + Theme.spacingXS * 2 + Theme.spacingS; return width; } + readonly property real lyricWidth: LyricsService.controller.synced ? LyricLayout.pageWidth(root.lyricWidths, root.maxLength - root.chromeWidth, root.minTextWidth, root.lyricWordWidth / 2) : 0 + readonly property real naturalTextWidth: root.lyricWidth > 0 ? root.lyricWidth : Math.max(root.minTextWidth, titleMetrics.width, root.dense ? 0 : artistMetrics.width) + readonly property real measuredWidth: root.chromeWidth + root.naturalTextWidth + + onLyricsWantedChanged: measureLyrics() + + function measureLyrics() { + if (!root.lyricsWanted && root.lyricWidths.length === 0) + return; + const widths = []; + let words = 0; + for (const line of root.lyricsWanted ? LyricsService.controller.lines : []) { + for (const part of line.parts) { + if (part.background || !LyricLayout.hasWords(part.x)) + continue; + lyricMetrics.text = part.x.replace(/\s+/g, " ").trim(); + widths.push(lyricMetrics.width); + words += lyricMetrics.text.split(" ").length; + } + } + lyricMetrics.text = ""; + root.lyricWordWidth = words > 0 ? widths.reduce((sum, width) => sum + width, 0) / words : 0; + root.lyricWidths = widths; + } implicitWidth: isVertical ? artworkSize + verticalPad * 2 : measuredWidth implicitHeight: isVertical ? verticalFace.implicitHeight + verticalPad * 2 : artworkSize @@ -51,11 +91,27 @@ Item { StyledTextMetrics { id: titleMetrics - font.pixelSize: Theme.fontSizeSmall * root.contentScale + font.pixelSize: root.titleFontSize font.weight: Theme.fontWeightMedium text: root.mediaModel.title } + StyledTextMetrics { + id: lyricMetrics + + font.pixelSize: root.titleFontSize + font.weight: Theme.fontWeightMedium + onFontChanged: root.measureLyrics() + } + + Connections { + target: LyricsService.controller + + function onLinesChanged() { + root.measureLyrics(); + } + } + StyledTextMetrics { id: artistMetrics @@ -63,6 +119,10 @@ Item { text: root.dense ? "" : root.mediaModel.artist } + LyricsSubscription { + active: root.lyricsLive && root.mediaModel.available + } + SystemClock { id: systemClock @@ -110,8 +170,11 @@ Item { spacing: 0 ScrollingText { + id: titleText + x: root.textGutter width: parent.width - root.textGutter * 2 + visible: !root.lyricActive text: root.mediaModel.title color: Theme.surfaceText font.pixelSize: Theme.fontSizeSmall * root.contentScale @@ -123,6 +186,18 @@ Item { overscroll: root.textGutter } + MediaLyricLine { + x: root.textGutter + width: parent.width - root.textGutter * 2 + height: titleText.height + visible: root.lyricActive + controller: LyricsService.controller + color: Theme.surfaceText + font.pixelSize: titleText.font.pixelSize + font.weight: titleText.font.weight + live: root.enabled + } + StyledText { x: root.textGutter width: parent.width - root.textGutter * 2 diff --git a/quickshell/tests/lyric-layout.test.mjs b/quickshell/tests/lyric-layout.test.mjs new file mode 100644 index 000000000..4829058ca --- /dev/null +++ b/quickshell/tests/lyric-layout.test.mjs @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { loadScript } from "./qml-script.mjs"; + +const layout = loadScript(new URL("../Common/LyricLayout.js", import.meta.url)); + +test("hasWords rejects instrumental filler and keeps sung text", () => { + for (const text of ["", " ", "♪", " ♫ ♫ ", "🎵🎶", "(...)", "♪ ~ ♪", "[—]", "\u{1D160}", " \u{1D158}\u{1D165}\u{1D16E} "]) + assert.equal(layout.hasWords(text), false, JSON.stringify(text)); + for (const text of ["I", "la la ♪", "(oh)", "夜に駆ける", "привет", "🎤"]) + assert.equal(layout.hasWords(text), true, JSON.stringify(text)); +}); + +test("pageWidth sizes from the median line", () => { + const cases = [ + [[100, 150, 400], 200, 48, 10, 150], + [[100, 140, 160, 900], 200, 48, 10, 150], + [[300, 320, 340], 200, 48, 10, 170], + [[300, 380, 1000], 210, 48, 10, 200], + [[300, 500, 600], 200, 48, 10, 200], + [[20, 30], 200, 48, 10, 48], + [[0, 0], 200, 48, 10, 0], + [[], 200, 48, 10, 0] + ]; + for (const [widths, cap, minimum, slack, expected] of cases) + assert.equal(layout.pageWidth(widths, cap, minimum, slack), expected, JSON.stringify(widths)); +}); diff --git a/quickshell/translations/settings_search_index.json b/quickshell/translations/settings_search_index.json index e44ff88b1..4f90cbc6f 100644 --- a/quickshell/translations/settings_search_index.json +++ b/quickshell/translations/settings_search_index.json @@ -3776,6 +3776,36 @@ "parentLabel": "Bar", "conditionKey": "frameEnabled" }, + { + "section": "islandMediaLyrics", + "label": "Lyrics", + "tabIndex": 3, + "category": "General", + "keywords": [ + "artist", + "available", + "bar", + "being", + "compact", + "general", + "island", + "line", + "lyrics", + "media", + "panel", + "shows", + "statusbar", + "sung", + "synced", + "taskbar", + "title", + "topbar", + "while" + ], + "parentLabel": "Bar", + "description": "Shows the line being sung instead of the title and artist while synced lyrics are available", + "conditionKey": "islandEnabled" + }, { "section": "barManualVisibility", "label": "Manual show/hide", @@ -9879,7 +9909,7 @@ "optionFile": "PrivacyOptions.qml" }, { - "section": "barWidgetOption:IslandOptions.qml:27", + "section": "barWidgetOption:IslandOptions.qml:28", "label": "Charging", "tabIndex": 22, "keywords": [], @@ -9887,7 +9917,7 @@ "optionFile": "IslandOptions.qml" }, { - "section": "barWidgetOption:IslandOptions.qml:29", + "section": "barWidgetOption:IslandOptions.qml:30", "label": "Clear badge on open", "tabIndex": 22, "keywords": [], @@ -10103,7 +10133,7 @@ "optionFile": "WorkspaceSwitcherOptions.qml" }, { - "section": "barWidgetOption:IslandOptions.qml:28", + "section": "barWidgetOption:IslandOptions.qml:29", "label": "Expand by default", "tabIndex": 22, "keywords": [], @@ -10326,6 +10356,14 @@ "runtimeType": "barWidgetOption", "optionFile": "AppsDockOptions.qml" }, + { + "section": "barWidgetOption:IslandOptions.qml:23", + "label": "Lyrics", + "tabIndex": 22, + "keywords": [], + "runtimeType": "barWidgetOption", + "optionFile": "IslandOptions.qml" + }, { "section": "barWidgetOption:MusicOptions.qml:4", "label": "Lyrics", @@ -10407,7 +10445,7 @@ "optionFile": "WorkspaceSwitcherOptions.qml" }, { - "section": "barWidgetOption:IslandOptions.qml:30", + "section": "barWidgetOption:IslandOptions.qml:31", "label": "Motion", "tabIndex": 22, "keywords": [], @@ -10455,7 +10493,7 @@ "optionFile": "ClockOptions.qml" }, { - "section": "barWidgetOption:IslandOptions.qml:24", + "section": "barWidgetOption:IslandOptions.qml:25", "label": "Notifications", "tabIndex": 22, "keywords": [], @@ -10735,7 +10773,7 @@ "optionFile": "AppsDockOptions.qml" }, { - "section": "barWidgetOption:IslandOptions.qml:32", + "section": "barWidgetOption:IslandOptions.qml:33", "label": "Spring damping", "tabIndex": 22, "keywords": [], @@ -10743,7 +10781,7 @@ "optionFile": "IslandOptions.qml" }, { - "section": "barWidgetOption:IslandOptions.qml:33", + "section": "barWidgetOption:IslandOptions.qml:34", "label": "Spring mass", "tabIndex": 22, "keywords": [], @@ -10751,7 +10789,7 @@ "optionFile": "IslandOptions.qml" }, { - "section": "barWidgetOption:IslandOptions.qml:31", + "section": "barWidgetOption:IslandOptions.qml:32", "label": "Spring stiffness", "tabIndex": 22, "keywords": [], @@ -10783,7 +10821,7 @@ "optionFile": "WorkspaceSwitcherOptions.qml" }, { - "section": "barWidgetOption:IslandOptions.qml:23", + "section": "barWidgetOption:IslandOptions.qml:24", "label": "Switch to media when playback starts", "tabIndex": 22, "keywords": [], @@ -10839,7 +10877,7 @@ "optionFile": "SystemTrayOptions.qml" }, { - "section": "barWidgetOption:IslandOptions.qml:26", + "section": "barWidgetOption:IslandOptions.qml:27", "label": "Use standard OSDs", "tabIndex": 22, "keywords": [], @@ -10847,7 +10885,7 @@ "optionFile": "IslandOptions.qml" }, { - "section": "barWidgetOption:IslandOptions.qml:25", + "section": "barWidgetOption:IslandOptions.qml:26", "label": "Use standard popups", "tabIndex": 22, "keywords": [],