--!nonstrict -- Synchronized lyrics bar widget with karaoke highlighting and long-line scrolling. noctalia.setUpdateInterval(33) local glyph = noctalia.getConfig("glyph") local showArtist = noctalia.getConfig("show_artist") local hideWhenPaused = noctalia.getConfig("hide_when_paused") local showCover = noctalia.getConfig("show_cover") if showCover == nil then showCover = true end local coverShape = noctalia.getConfig("cover_shape") or "circle" local coverRadius = tonumber(noctalia.getConfig("cover_radius")) or 5 local coverSize = tonumber(noctalia.getConfig("cover_size")) or 18 local activeColor = noctalia.getConfig("active_color") or "on_surface" local inactiveColor = noctalia.getConfig("inactive_color") or "on_surface_variant" local secondaryColor = noctalia.getConfig("secondary_color") or "on_surface_variant" -- These are plugin settings, not per-widget settings. local scrollMode = noctalia.getConfig("scroll_mode") or "auto" local marqueeSpeed = tonumber(noctalia.getConfig("marquee_speed")) or 30 local maxLines = tonumber(noctalia.getConfig("max_lines")) or 1 local maxChars = tonumber(noctalia.getConfig("max_chars")) or 15 local charWidth = tonumber(noctalia.getConfig("char_width")) or 9 local gradientOn = noctalia.getConfig("gradient") if gradientOn == nil then gradientOn = true end local animation = noctalia.getConfig("animation") or "karaoke" local cueText = noctalia.getConfig("cue_text") or "•••••" if cueText == "" then cueText = "•••••" end local cueFontMode = noctalia.getConfig("cue_font_mode") or "follow" local cueFontFamily = noctalia.getConfig("cue_font_family") or "sans-serif" if cueFontFamily == "" then cueFontFamily = "sans-serif" end local displayMode = noctalia.getConfig("display_mode") or "toggle" local doubleLine = noctalia.getConfig("double_line") if doubleLine == nil then doubleLine = true end local doubleLineAutoFit = noctalia.getConfig("double_line_auto_fit") if doubleLineAutoFit == nil then doubleLineAutoFit = true end local doubleLineHeightBudget = tonumber(noctalia.getConfig("double_line_height_budget")) or 26 local showTranslation = noctalia.getConfig("show_translation") if showTranslation == nil then showTranslation = true end local showRomanization = noctalia.getConfig("show_romanization") == true local secondaryLineMode = noctalia.getConfig("secondary_line_mode") or "translation_first" local karaokeEnabled = noctalia.getConfig("karaoke_enabled") if karaokeEnabled == nil then karaokeEnabled = true end local fontFamily = noctalia.getConfig("font_family") or "" local primaryFontSize = tonumber(noctalia.getConfig("primary_font_size")) or 0 local secondaryFontSize = tonumber(noctalia.getConfig("secondary_font_size")) or 10 local fontWeight = noctalia.getConfig("font_weight") or "normal" local fontStyle = noctalia.getConfig("font_style") or "normal" local lineGap = tonumber(noctalia.getConfig("line_gap")) or 2 local paddingLeft = tonumber(noctalia.getConfig("padding_left")) or 0 local paddingRight = tonumber(noctalia.getConfig("padding_right")) or 0 local track = nil local lyrics = nil local cover = nil local playing = false local showMode = displayMode == "track" and "track" or "auto" local rendered = false local playerInstance = "" local sourceUsed = "" local baselinePosition = 0 local baselineClock = os.clock() local lastClock = 0 local displayKey = "" local displayedLine = nil local outgoingLine = nil local transitionElapsed = 0 local marqueeElapsed = 0 local smoothProgress = 0 local FADE_OUT_MS = 130 local FADE_IN_MS = 260 local MARQUEE_HOLD = 1.1 local BLEND_MS = 350 local INTERLUDE_GAP_MS = 7000 local INTRO_MIN_MS = 6000 local INTERLUDE_MIN_MS = 8000 local function shellQuote(value) return "'" .. tostring(value):gsub("'", "'\\''") .. "'" end local function clamp(value, low, high) return math.min(high, math.max(low, value)) end local function easeOutCubic(t) local u = 1 - clamp(t, 0, 1) return 1 - u * u * u end local function toChars(text) local chars = {} local i = 1 while i <= #text do local byte = text:byte(i) local length = 1 if byte >= 0xF0 then length = 4 elseif byte >= 0xE0 then length = 3 elseif byte >= 0xC0 then length = 2 end chars[#chars + 1] = text:sub(i, i + length - 1) i = i + length end return chars end local function charUnits(char) if char == " " or char == "\t" then return 0.35 end local byte = char:byte(1) or 0 if byte < 0x80 then if char:match("[%.,:;!'|ilI%-%(%)]") then return 0.38 end if char:match("[MW@#%%&]") then return 0.85 end return 0.58 end return 1 end local function textUnits(text) local units = 0 for _, char in ipairs(toChars(text or "")) do units = units + charUnits(char) end return units end local function getProgressMs() local elapsed = playing and (os.clock() - baselineClock) * 1000 or 0 return baselinePosition / 1000 + elapsed end local function getLineInfo() if not lyrics or #lyrics == 0 or not track then return nil end local position = getProgressMs() local synced = lyrics[1].time >= 0 if not synced then return { key = "plain", index = 1, line = lyrics[1], progress = 0, synced = false } end local firstTime = lyrics[1].time if firstTime >= INTRO_MIN_MS and position < firstTime then return { key = "intro", index = 0, line = { text = cueText, cue = true }, progress = clamp(position / firstTime, 0, 1), synced = true, position = position, cue = true, } end local index = 1 for i = #lyrics, 1, -1 do if lyrics[i].time <= position then index = i break end end local line = lyrics[index] local startTime = line.time local durationMs = track.duration and track.duration > 0 and track.duration / 1000 or nil local nextLine = lyrics[index + 1] local nextTime = nextLine and nextLine.time or durationMs or (startTime + 4000) local lineEnd = nextTime if line.duration and line.duration > 0 then lineEnd = math.min(nextTime, startTime + line.duration) elseif nextTime - startTime >= INTERLUDE_GAP_MS then -- LRC only marks line starts. Do not stretch a lyric across a long instrumental gap. local estimatedDuration = clamp(#toChars(line.text) * 320, 3200, 6000) lineEnd = math.min(nextTime, startTime + estimatedDuration) end -- Only show an interlude between two real lyric lines. The final lyric stays -- visible through the outro instead of turning the whole song ending into dots. if nextLine and nextTime - lineEnd >= INTERLUDE_MIN_MS and position >= lineEnd then return { key = "interlude-" .. tostring(index), index = index, line = { text = cueText, cue = true }, progress = clamp((position - lineEnd) / (nextTime - lineEnd), 0, 1), synced = true, position = position, cue = true, } end local progress = lineEnd > startTime and clamp((position - startTime) / (lineEnd - startTime), 0, 1) or 1 return { key = "line-" .. tostring(index), index = index, line = line, progress = progress, synced = true, position = position, } end local function getTrackLabel() if not track then return "--" end if not showArtist then return track.title end if track.artist and track.artist ~= "" then return track.artist .. " - " .. track.title end return track.title end local function getFallbackLabel() if not track then return "--" end if track.artist and track.artist ~= "" then return track.title .. " + " .. track.artist end return track.title end local function shouldMarquee(text) return scrollMode ~= "static" and textUnits(text) > maxChars end local function getMarqueeOffset(chars) local trailingChars = 0 local units = 0 for index = #chars, 1, -1 do local char = chars[index] local nextUnits = units + charUnits(char) if nextUnits > maxChars then break end units = nextUnits trailingChars = trailingChars + 1 end local distance = math.max(0, #chars - math.max(1, trailingChars)) if distance == 0 then return 0 end -- The setting is pixels per second; convert it to character cells per second. local charsPerSecond = math.max(0.5, marqueeSpeed / math.max(1, charWidth)) local travelTime = distance / charsPerSecond local cycle = MARQUEE_HOLD * 2 + travelTime * 2 local phase = marqueeElapsed % cycle if phase < MARQUEE_HOLD then return 0 end phase = phase - MARQUEE_HOLD if phase < travelTime then return distance * (phase / travelTime) end phase = phase - travelTime if phase < MARQUEE_HOLD then return distance end phase = phase - MARQUEE_HOLD return distance * (1 - phase / travelTime) end local function karaokeColor(charTime, charProgress, position, useGradient) if not playing then return inactiveColor end local active = activeColor local inactive = inactiveColor if charTime and position then if not useGradient then return position >= charTime and active or inactive end if position >= charTime then return active end local distance = charTime - position if distance >= BLEND_MS then return inactive end local alpha = 1 - distance / BLEND_MS if alpha <= 0.5 then return inactive end return active .. "/" .. string.format("%.2f", math.max(0.72, alpha)) end if not useGradient then return smoothProgress >= charProgress and active or inactive end local distance = charProgress - smoothProgress if distance <= 0 then return active end if distance >= 0.16 then return inactive end local alpha = 1 - distance / 0.16 if alpha <= 0.5 then return inactive end return active .. "/" .. string.format("%.2f", math.max(0.72, alpha)) end local function labelBaseline() if fontStyle == "fixed" then return "textFixedHeight" end if fontStyle == "ink_centered" then return "inkCentered" end return "text" end local function secondaryText(line) if not doubleLine or type(line) ~= "table" then return "" end local translation = showTranslation and (line.translation or "") or "" local romanization = showRomanization and (line.romanization or "") or "" if secondaryLineMode == "translation" then return translation end if secondaryLineMode == "romanization" then return romanization end if secondaryLineMode == "romanization_first" then return romanization ~= "" and romanization or translation end return translation ~= "" and translation or romanization end local function secondaryRowEnabled() return doubleLine and (showTranslation or showRomanization) end local function compactMetrics(hasSecondary, vertical) if not hasSecondary or vertical or not doubleLineAutoFit then return 1, lineGap end local primaryBase = primaryFontSize > 0 and primaryFontSize or 13 local secondaryBase = secondaryFontSize > 0 and secondaryFontSize or 10 local naturalHeight = primaryBase * 1.25 + secondaryBase * 1.25 + lineGap local scale = math.min(1, doubleLineHeightBudget / math.max(1, naturalHeight)) return scale, math.floor(lineGap * scale + 0.5) end local function buildLineRow(line, opts) opts = opts or {} local text = type(line) == "table" and line.text or line local times = type(line) == "table" and line.chars or nil local isCue = opts.cue or (type(line) == "table" and line.cue == true) local chars = toChars(text or "") if #chars == 0 then chars = { " " } end local exactOffset = opts.marquee and getMarqueeOffset(chars) or 0 local offset = math.floor(exactOffset) local fraction = exactOffset - offset local first = offset + 1 local last = #chars if opts.marquee then local units = 0 last = first - 1 for sourceIndex = first, #chars do units = units + charUnits(chars[sourceIndex]) last = sourceIndex if units > maxChars then break end end end local labels = {} for sourceIndex = first, last do local active = playing and activeColor or inactiveColor local color = opts.secondary and secondaryColor or (opts.dim and inactiveColor or active) if not opts.solid and not opts.dim then local progress = #chars > 1 and (sourceIndex - 1) / (#chars - 1) or 0 color = karaokeColor(times and times[sourceIndex], progress, opts.position, opts.useGradient) end -- Soft viewport edges make cell-by-cell marquee movement less abrupt. local alpha = 1 local edgeAlpha = noctalia.isDarkMode() and 0.5 or 0.72 if opts.marquee and offset > 0 and sourceIndex == first then alpha = edgeAlpha end if opts.marquee and last < #chars and sourceIndex == last then alpha = edgeAlpha end if opts.cascade then local charProgress = (sourceIndex - first) / math.max(1, last - first) local cascadeProgress = clamp((transitionElapsed - FADE_OUT_MS) / 420, 0, 1) alpha = alpha * clamp((cascadeProgress - charProgress * 0.45) * 2.4, 0.08, 1) end if opts.wave then local waveProgress = clamp((transitionElapsed - FADE_OUT_MS) / 650, 0, 1) local wave = 0.55 + 0.45 * math.sin(waveProgress * math.pi * 2 - sourceIndex * 0.78) alpha = alpha * (wave * (1 - waveProgress) + waveProgress) end if opts.typewriter then local reveal = clamp((transitionElapsed - FADE_OUT_MS) / 520, 0, 1) local threshold = (sourceIndex - first) / math.max(1, last - first + 1) alpha = alpha * (reveal >= threshold and 1 or 0.08) end if opts.blink and transitionElapsed < FADE_OUT_MS + 540 then local blink = math.floor(math.max(0, transitionElapsed - FADE_OUT_MS) / 90) % 2 alpha = alpha * (blink == 0 and 0.35 or 1) end -- Bar labels elide glyphs when constrained below their natural width, so -- animate the viewport edges with opacity without clipping CJK characters. if opts.marquee and fraction > 0 then if sourceIndex == first then alpha = alpha * (1 - fraction) elseif sourceIndex == last and sourceIndex > first then alpha = alpha * fraction end end labels[#labels + 1] = ui.label({ key = "char-" .. tostring(sourceIndex), text = chars[sourceIndex], color = color, opacity = alpha, maxLines = 1, fontFamily = isCue and cueFontMode == "custom" and cueFontFamily or (fontFamily ~= "" and fontFamily or nil), fontSize = opts.secondary and math.max(6, secondaryFontSize * (opts.fontScale or 1)) or ((primaryFontSize > 0 or (opts.fontScale or 1) < 1) and math.max(6, (primaryFontSize > 0 and primaryFontSize or 13) * (opts.fontScale or 1)) or nil), fontWeight = opts.secondary and "normal" or fontWeight, baseline = labelBaseline(), }) end if opts.marquee then while #labels < math.ceil(maxChars) + 1 do labels[#labels + 1] = ui.label({ text = " ", opacity = 0, maxLines = 1 }) end end return ui.row({ key = opts.key or "line", gap = 0, align = "center", minWidth = maxChars * charWidth, opacity = opts.opacity or 1, }, labels) end local function currentTransitionLine(info) if animation == "none" or transitionElapsed >= FADE_OUT_MS then local opacity = animation == "none" and 1 or easeOutCubic((transitionElapsed - FADE_OUT_MS) / FADE_IN_MS) if animation == "pulse" and transitionElapsed < FADE_OUT_MS + 600 then opacity = opacity * (0.76 + 0.24 * math.sin(math.max(0, transitionElapsed - FADE_OUT_MS) / 75)) end return info and info.line or nil, opacity end return outgoingLine, 1 - easeOutCubic(transitionElapsed / FADE_OUT_MS) end local function render() if hideWhenPaused and not playing then barWidget.setVisible(false) return end barWidget.setVisible(true) local vertical = barWidget.isVertical() local useGradient = (animation == "karaoke" or animation == "cascade" or animation == "wave") and gradientOn local position = getProgressMs() local prefix = {} local coverPath = showCover and cover and cover ~= "" and noctalia.fileExists(cover) and cover or nil if coverPath then local radius = coverShape == "circle" and coverSize / 2 or coverShape == "rounded" and math.min(5, coverSize / 2) or coverShape == "square" and 0 or math.min(coverRadius, coverSize / 2) prefix[#prefix + 1] = ui.image({ path = coverPath, width = coverSize, height = coverSize, radius = radius, fit = "cover" }) else prefix[#prefix + 1] = ui.glyph({ name = glyph, size = 14, color = playing and activeColor or inactiveColor, }) end local lineNodes = {} local renderLineGap = lineGap local renderTextHeight = 0 if displayMode == "track" or showMode == "track" then local label = getTrackLabel() lineNodes[1] = buildLineRow(label, { key = "track", marquee = shouldMarquee(label), solid = true, }) else local info = getLineInfo() if info then local shownLine, opacity = currentTransitionLine(info) if shownLine then local showSecondaryRow = secondaryRowEnabled() and not shownLine.cue and not info.cue local secondary = showSecondaryRow and secondaryText(shownLine) or "" local fontScale, effectiveLineGap = compactMetrics(showSecondaryRow, vertical) lineNodes[#lineNodes + 1] = buildLineRow(shownLine, { key = "current-" .. tostring(info.index), marquee = shownLine == info.line and shouldMarquee(shownLine.text), position = position, useGradient = karaokeEnabled and shownLine == info.line and info.synced and useGradient, solid = not karaokeEnabled or not info.synced or (animation ~= "karaoke" and animation ~= "cascade" and animation ~= "wave"), cascade = shownLine == info.line and animation == "cascade", wave = shownLine == info.line and animation == "wave", typewriter = shownLine == info.line and animation == "typewriter", blink = shownLine == info.line and animation == "blink", opacity = opacity, cue = shownLine == info.line and info.cue == true, fontScale = fontScale, compact = showSecondaryRow and not vertical and doubleLineAutoFit, }) if showSecondaryRow then lineNodes[#lineNodes + 1] = buildLineRow({ text = secondary }, { key = "secondary-" .. tostring(info.index), marquee = secondary ~= "" and shouldMarquee(secondary), solid = true, dim = true, secondary = true, opacity = secondary ~= "" and math.min(0.82, opacity) or 0, fontScale = fontScale, compact = not vertical and doubleLineAutoFit, }) renderLineGap = effectiveLineGap if not vertical and doubleLineAutoFit then renderTextHeight = doubleLineHeightBudget end end end if vertical and maxLines > 1 and shownLine == info.line then for i = 1, maxLines - 1 do local nextLine = lyrics[info.index + i] if not nextLine then break end lineNodes[#lineNodes + 1] = buildLineRow(nextLine, { key = "next-" .. tostring(info.index + i), dim = true, opacity = math.max(0.25, 0.58 - (i - 1) * 0.16), }) end end else local label = getFallbackLabel() lineNodes[1] = buildLineRow(label, { key = "fallback", marquee = shouldMarquee(label), solid = true, }) end end if vertical then barWidget.render(ui.column({ gap = 3, align = "start", opacity = playing and 1 or 0.58 }, { ui.row({ gap = 6, align = "center" }, prefix), ui.column({ gap = renderLineGap, align = "start", justify = renderTextHeight > 0 and "center" or nil, height = renderTextHeight > 0 and renderTextHeight or nil, }, lineNodes), })) else local children = {} if paddingLeft > 0 then children[#children + 1] = ui.spacer({ width = paddingLeft }) end for _, node in ipairs(prefix) do children[#children + 1] = node end children[#children + 1] = ui.column({ gap = renderLineGap, align = "start", justify = renderTextHeight > 0 and "center" or nil, height = renderTextHeight > 0 and renderTextHeight or nil, }, lineNodes) if paddingRight > 0 then children[#children + 1] = ui.spacer({ width = paddingRight }) end barWidget.render(ui.row({ gap = 6, align = "center", opacity = playing and 1 or 0.58 }, children)) end local sourceNames = { lrclib = "LRCLIB", netease = "NetEase", qqmusic = "QQ Music", kugou = "Kugou", qishui = "Qishui", apple_music = "Apple Music", spotify = "Spotify", musixmatch = "Musixmatch", mpris = "MPRIS", custom = "Custom", cache = "Cache", } local sourceLabel = noctalia.tr("source_label") local tooltip = getTrackLabel() if sourceUsed ~= "" then tooltip = tooltip .. "\n" .. sourceLabel .. ": " .. (sourceNames[sourceUsed] or sourceUsed) end barWidget.setTooltip(tooltip) rendered = true end function onClick() if displayMode ~= "toggle" then return end showMode = showMode == "auto" and "track" or "auto" marqueeElapsed = 0 if rendered then render() end end function onRightClick() if playerInstance == "" then return end noctalia.runAsync("playerctl --player " .. shellQuote(playerInstance) .. " play-pause", function(result) if result.exitCode == 0 then local wasPlaying = playing if wasPlaying then baselinePosition = getProgressMs() * 1000 end playing = not wasPlaying noctalia.state.set("playing", playing) baselineClock = os.clock() render() end end) end function update() local now = os.clock() local delta = lastClock > 0 and now - lastClock or 0 lastClock = now track = noctalia.state.get("track") lyrics = noctalia.state.get("lyrics") cover = noctalia.state.get("cover") playing = noctalia.state.get("playing") == true playerInstance = noctalia.state.get("player_instance") or "" sourceUsed = noctalia.state.get("lyrics_source_used") or "" local info = getLineInfo() local nextKey = info and info.key or "fallback" if nextKey ~= displayKey then outgoingLine = displayedLine displayKey = nextKey transitionElapsed = animation == "none" and FADE_OUT_MS + FADE_IN_MS or 0 marqueeElapsed = 0 smoothProgress = info and info.progress or 0 else local target = info and info.progress or 0 smoothProgress = smoothProgress + (target - smoothProgress) * math.min(1, delta * 8) end displayedLine = info and info.line or nil if playing then transitionElapsed = transitionElapsed + delta * 1000 local text = showMode == "track" and getTrackLabel() or (info and info.line.text or "") local secondary = showMode ~= "track" and info and secondaryText(info.line) or "" if shouldMarquee(text) or (secondary ~= "" and shouldMarquee(secondary)) then marqueeElapsed = marqueeElapsed + delta end end render() end noctalia.state.watch("position", function(value) baselinePosition = tonumber(value) or 0 baselineClock = os.clock() end) noctalia.state.watch("lyrics", function(value) lyrics = value displayKey = "" displayedLine = nil outgoingLine = nil marqueeElapsed = 0 end) noctalia.state.watch("playing", function(value) -- Preserve the interpolated position when pausing between service polls. if playing and value ~= true then baselinePosition = getProgressMs() * 1000 end playing = value == true baselineClock = os.clock() end)