island: show synced lyrics in the media slot (#3979)

port 2.0

(cherry picked from commit 9d279e7f88)
This commit is contained in:
hecate cantus authored and dux-ci[bot] committed 2026-10-28 14:33:01 +00:00
1 parent 4a1fadc349
commit 9fd55f6f5e
10 files changed
+195 -16

No files matched your search

+23
View File
@@ -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));
}
@@ -37,6 +37,7 @@ var ISLAND_DEFAULTS = {
islandPalette: "default",
islandHighContrast: false,
islandMediaClockVisible: true,
islandMediaLyrics: false,
islandMediaAutoSwitch: false,
islandNotificationBadgeClearOnOpen: false,
islandNotificationExpand: false,
+2 -1
View File
@@ -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");
@@ -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);
}
@@ -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"
@@ -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: ""
@@ -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"]
@@ -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
+27
View File
@@ -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));
});
@@ -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": [],