import QtQuick import QtQuick.Controls import QtQuick.Layouts import Quickshell import qs.Commons import qs.Modules.Bar.Extras import qs.Services.Media import qs.Services.UI import qs.Widgets import qs.Widgets.AudioSpectrum Item { id: root property ShellScreen screen property string widgetId: "" property string section: "" property int sectionWidgetIndex: -1 property int sectionWidgetsCount: 0 // Settings property var widgetMetadata: BarWidgetRegistry.widgetMetadata[widgetId] ?? {} // Explicit screenName property ensures reactive binding when screen changes readonly property string screenName: screen ? screen.name : "" property var widgetSettings: { if (section && sectionWidgetIndex >= 0 && screenName) { var widgets = Settings.getBarWidgetsForScreen(screenName)[section]; if (widgets && sectionWidgetIndex < widgets.length) { return widgets[sectionWidgetIndex]; } } return {}; } // Bar orientation (per-screen) readonly property string barPosition: Settings.getBarPositionForScreen(screenName) readonly property bool isVertical: barPosition === "left" || barPosition === "right" readonly property real capsuleHeight: Style.getCapsuleHeightForScreen(screenName) readonly property real barFontSize: Style.getBarFontSizeForScreen(screenName) // Widget settings readonly property string hideMode: widgetSettings.hideMode !== undefined ? widgetSettings.hideMode : widgetMetadata.hideMode readonly property bool hideWhenIdle: widgetSettings.hideWhenIdle !== undefined ? widgetSettings.hideWhenIdle : widgetMetadata.hideWhenIdle readonly property bool showAlbumArt: widgetSettings.showAlbumArt !== undefined ? widgetSettings.showAlbumArt : widgetMetadata.showAlbumArt readonly property bool showArtistFirst: widgetSettings.showArtistFirst !== undefined ? widgetSettings.showArtistFirst : widgetMetadata.showArtistFirst readonly property bool showVisualizer: widgetSettings.showVisualizer !== undefined ? widgetSettings.showVisualizer : widgetMetadata.showVisualizer readonly property string visualizerType: widgetSettings.visualizerType !== undefined ? widgetSettings.visualizerType : widgetMetadata.visualizerType readonly property string scrollingMode: widgetSettings.scrollingMode !== undefined ? widgetSettings.scrollingMode : widgetMetadata.scrollingMode readonly property bool showProgressRing: widgetSettings.showProgressRing !== undefined ? widgetSettings.showProgressRing : widgetMetadata.showProgressRing readonly property bool useFixedWidth: widgetSettings.useFixedWidth !== undefined ? widgetSettings.useFixedWidth : widgetMetadata.useFixedWidth readonly property real maxWidth: widgetSettings.maxWidth !== undefined ? widgetSettings.maxWidth : Math.max(widgetMetadata.maxWidth, screen ? screen.width * 0.06 : 0) readonly property string textColorKey: widgetSettings.textColor !== undefined ? widgetSettings.textColor : widgetMetadata.textColor readonly property color textColor: Color.resolveColorKey(textColorKey) // Dimensions readonly property int artSize: Style.toOdd(capsuleHeight * 0.75) readonly property int iconSize: Style.toOdd(capsuleHeight * 0.75) readonly property int verticalSize: Style.toOdd(capsuleHeight * 0.85) readonly property int progressWidth: 2 // State readonly property bool hasPlayer: MediaService.currentPlayer !== null readonly property bool shouldHideIdle: (hideMode === "idle" || hideWhenIdle) && !MediaService.isPlaying readonly property bool shouldHideEmpty: !hasPlayer && hideMode === "hidden" readonly property bool isHidden: shouldHideIdle || shouldHideEmpty // Title readonly property string title: { if (!hasPlayer) return I18n.tr("bar.media-mini.no-active-player"); var artist = MediaService.trackArtist; var track = MediaService.trackTitle; return showArtistFirst ? (artist ? `${artist} - ${track}` : track) : (artist ? `${track} - ${artist}` : track); } // SpectrumService registration for visualizer readonly property string spectrumComponentId: "bar:mediamini:" + root.screen?.name + ":" + root.section + ":" + root.sectionWidgetIndex readonly property bool needsSpectrum: root.showVisualizer && root.visualizerType !== "" && root.visualizerType !== "none" && !root.isHidden Layout.preferredHeight: isVertical ? -1 : Style.getBarHeightForScreen(screenName) Layout.preferredWidth: isVertical ? Style.getBarHeightForScreen(screenName) : -1 Layout.fillHeight: false Layout.fillWidth: false onNeedsSpectrumChanged: { if (root.needsSpectrum) { SpectrumService.registerComponent(root.spectrumComponentId); } else { SpectrumService.unregisterComponent(root.spectrumComponentId); } } Component.onCompleted: { if (root.needsSpectrum) { SpectrumService.registerComponent(root.spectrumComponentId); } } Component.onDestruction: { SpectrumService.unregisterComponent(root.spectrumComponentId); } // Layout // For horizontal bars, height is always capsuleHeight (no animation needed to prevent jitter) // For vertical bars, collapse to 0 when hidden implicitWidth: isVertical ? (isHidden ? 0 : verticalSize) : (isHidden ? 0 : contentWidth) implicitHeight: isVertical ? (isHidden ? 0 : verticalSize) : capsuleHeight visible: !shouldHideIdle && (hideMode !== "hidden" || opacity > 0) opacity: isHidden ? 0.0 : ((hideMode === "transparent" && !hasPlayer) ? 0.0 : 1.0) property real mainContentWidth: 0 readonly property real contentWidth: { if (useFixedWidth) return maxWidth; // Calculate icon/art width (must match RowLayout visibility) var iconWidth = 0; if (!hasPlayer) { iconWidth = iconSize; } else if (showAlbumArt || showProgressRing) { iconWidth = artSize; } var margins = isVertical ? 0 : Style.margin2S; // Add spacing and text width var textWidth = 0; if (titleContainer.measuredWidth > 0) { if (iconWidth > 0) margins += Style.marginS; textWidth = titleContainer.measuredWidth + Style.margin2XXS; } var total = iconWidth + textWidth + margins; // calculate the width of all elements except the scrolling text mainContentWidth = total - textWidth; return hasPlayer ? Math.min(total, maxWidth) : total; } Behavior on opacity { NumberAnimation { duration: Style.animationNormal easing.type: Easing.InOutCubic } } Behavior on implicitWidth { NumberAnimation { duration: Style.animationNormal easing.type: Easing.InOutCubic } } Behavior on implicitHeight { NumberAnimation { duration: Style.animationNormal easing.type: Easing.InOutCubic } } // Context menu NPopupContextMenu { id: contextMenu model: { var items = []; if (hasPlayer && MediaService.canPlay) { items.push({ "label": MediaService.isPlaying ? I18n.tr("common.pause") : I18n.tr("common.play"), "action": "play-pause", "icon": MediaService.isPlaying ? "media-pause" : "media-play" }); } if (hasPlayer && MediaService.canGoPrevious) { items.push({ "label": I18n.tr("common.previous"), "action": "previous", "icon": "media-prev" }); } if (hasPlayer && MediaService.canGoNext) { items.push({ "label": I18n.tr("common.next"), "action": "next", "icon": "media-next" }); } // Append available players (like in Control Center) so user can switch from the bar var players = MediaService.getAvailablePlayers ? MediaService.getAvailablePlayers() : []; if (players && players.length > 1) { for (var i = 0; i < players.length; i++) { var isCurrent = (i === MediaService.selectedPlayerIndex); items.push({ "label": players[i].identity, "action": "player-" + i, "icon": isCurrent ? "check" : "disc", "enabled": true, "visible": true }); } } items.push({ "label": I18n.tr("actions.widget-settings"), "action": "widget-settings", "icon": "settings" }); return items; } onTriggered: action => { contextMenu.close(); PanelService.closeContextMenu(screen); if (action === "play-pause") MediaService.playPause(); else if (action === "previous") MediaService.previous(); else if (action === "next") MediaService.next(); else if (action && action.indexOf("player-") === 0) { var idx = parseInt(action.split("-")[1]); if (!isNaN(idx)) { MediaService.switchToPlayer(idx); } } else if (action === "widget-settings") { BarService.openWidgetSettings(screen, section, sectionWidgetIndex, widgetId, widgetSettings); } } } // Main container - stays at content size, pixel-perfect centered in parent Rectangle { id: container x: Style.pixelAlignCenter(parent.width, width) y: Style.pixelAlignCenter(parent.height, height) width: Style.toOdd(isVertical ? (isHidden ? 0 : verticalSize) : (isHidden ? 0 : contentWidth)) height: Style.toOdd(isVertical ? (isHidden ? 0 : verticalSize) : capsuleHeight) radius: Style.radiusM color: Style.capsuleColor border.color: Style.capsuleBorderColor border.width: Style.capsuleBorderWidth Behavior on width { NumberAnimation { duration: Style.animationNormal easing.type: Easing.InOutCubic } } Behavior on height { NumberAnimation { duration: Style.animationNormal easing.type: Easing.InOutCubic } } Item { anchors.fill: parent anchors.leftMargin: isVertical ? 0 : Style.marginS anchors.rightMargin: isVertical ? 0 : Style.marginS // Visualizer Loader { x: Style.pixelAlignCenter(parent.width, width) y: Style.pixelAlignCenter(parent.height, height) width: Style.toOdd(parent.width) height: Style.toOdd(parent.height) active: showVisualizer z: 0 sourceComponent: { if (!showVisualizer) return null; if (visualizerType === "linear") return linearSpectrum; if (visualizerType === "mirrored") return mirroredSpectrum; if (visualizerType === "wave") return waveSpectrum; return null; } } // Horizontal layout RowLayout { anchors.fill: parent anchors.verticalCenter: parent.verticalCenter spacing: Style.marginS visible: !isVertical z: 1 // Album art / Progress ring Item { visible: hasPlayer && (showAlbumArt || showProgressRing) Layout.preferredWidth: visible ? artSize : 0 Layout.preferredHeight: visible ? artSize : 0 Layout.alignment: Qt.AlignVCenter ProgressRing { id: progressRing anchors.fill: parent visible: showProgressRing progress: MediaService.trackLength > 0 ? MediaService.currentPosition / MediaService.trackLength : 0 lineWidth: root.progressWidth } NImageRounded { visible: showAlbumArt && hasPlayer anchors.fill: parent anchors.margins: showProgressRing ? root.progressWidth * 2 : 0 radius: width / 2 imagePath: MediaService.trackArtUrl borderWidth: 0 imageFillMode: Image.PreserveAspectCrop } } // Scrolling title NScrollText { id: titleContainer Layout.fillWidth: true Layout.alignment: Qt.AlignVCenter Layout.preferredHeight: capsuleHeight fadeRoundLeftCorners: !(showAlbumArt || showProgressRing) text: title scrollMode: { if (scrollingMode === "always") return NScrollText.ScrollMode.Always; if (scrollingMode === "hover") return NScrollText.ScrollMode.Hover; return NScrollText.ScrollMode.Never; } cursorShape: hasPlayer ? Qt.PointingHandCursor : Qt.ArrowCursor maxWidth: root.maxWidth - root.mainContentWidth forcedHover: mainMouseArea.containsMouse fadeExtent: 0.1 fadeCornerRadius: Style.radiusM NText { color: hasPlayer ? root.textColor : Color.mOnSurfaceVariant pointSize: barFontSize elide: Text.ElideNone } } } // Vertical layout Item { id: verticalLayout visible: isVertical width: Style.toOdd(verticalSize) height: Style.toOdd(width) x: Style.pixelAlignCenter(parent.width, width) y: Style.pixelAlignCenter(parent.height, height) z: 1 ProgressRing { anchors.fill: parent visible: showProgressRing progress: MediaService.trackLength > 0 ? MediaService.currentPosition / MediaService.trackLength : 0 lineWidth: root.progressWidth } NImageRounded { visible: showAlbumArt && hasPlayer anchors.fill: parent anchors.margins: showProgressRing ? root.progressWidth * 2 : 0 radius: width / 2 imagePath: MediaService.trackArtUrl borderWidth: 0 imageFillMode: Image.PreserveAspectCrop } } // Mouse interaction moved to root } } // Mouse interaction MouseArea { id: mainMouseArea anchors.fill: parent // Extend click area to screen edge if widget is at the start/end anchors.leftMargin: (!isVertical && section === "left" && sectionWidgetIndex === 0) ? -Style.marginS : 0 anchors.rightMargin: (!isVertical && section === "right" && sectionWidgetIndex === sectionWidgetsCount - 1) ? -Style.marginS : 0 anchors.topMargin: (isVertical && section === "left" && sectionWidgetIndex === 0) ? -Style.marginM : 0 anchors.bottomMargin: (isVertical && section === "right" && sectionWidgetIndex === sectionWidgetsCount - 1) ? -Style.marginM : 0 hoverEnabled: true cursorShape: Qt.PointingHandCursor acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton | Qt.ForwardButton | Qt.BackButton onClicked: mouse => { TooltipService.hide(); if (mouse.button === Qt.LeftButton) { PanelService.getPanel("mediaPlayerPanel", screen)?.toggle(container); } else if (mouse.button === Qt.RightButton) { PanelService.showContextMenu(contextMenu, container, screen); } else if (mouse.button === Qt.MiddleButton && hasPlayer) { MediaService.playPause(); } else if (mouse.button === Qt.ForwardButton && hasPlayer) { MediaService.next(); } else if (mouse.button === Qt.BackButton && hasPlayer) { MediaService.previous(); } } onEntered: { if (!root || !screen) { return; } var scrollMode = scrollingMode; if ((isVertical || scrollMode === "never")) { var panel = PanelService.getPanel("mediaPlayerPanel", screen); if (panel && !panel.isPanelOpen) { TooltipService.show(root, title, BarService.getTooltipDirection(root.screen?.name)); } } } onExited: TooltipService.hide() } // Components Component { id: linearSpectrum NLinearSpectrum { width: parent.width - Style.marginS height: 20 values: SpectrumService.values fillColor: Color.mPrimary opacity: 0.4 barPosition: root.barPosition mirrored: Settings.data.audio.spectrumMirrored } } Component { id: mirroredSpectrum NMirroredSpectrum { width: parent.width - Style.marginS height: parent.height - Style.marginS values: SpectrumService.values fillColor: Color.mPrimary opacity: 0.4 mirrored: Settings.data.audio.spectrumMirrored } } Component { id: waveSpectrum NWaveSpectrum { width: parent.width - Style.marginS height: parent.height - Style.marginS values: SpectrumService.values fillColor: Color.mPrimary opacity: 0.4 mirrored: Settings.data.audio.spectrumMirrored } } // Progress Ring Component component ProgressRing: Canvas { property real progress: 0 property real lineWidth: 2 function repaint() { if (this.visible && this.opacity > 0) requestPaint(); } onProgressChanged: repaint() Component.onCompleted: repaint() Connections { target: Color function onMPrimaryChanged() { repaint(); } } onPaint: { if (width <= 0 || height <= 0) return; var ctx = getContext("2d"); var centerX = width / 2; var centerY = height / 2; var radius = Math.min(width, height) / 2 - lineWidth; ctx.reset(); // Background ctx.beginPath(); ctx.arc(centerX, centerY, radius, 0, 2 * Math.PI); ctx.lineWidth = lineWidth; ctx.strokeStyle = Qt.alpha(Color.mOnSurface, 0.4); ctx.stroke(); // Progress ctx.beginPath(); ctx.arc(centerX, centerY, radius, -Math.PI / 2, -Math.PI / 2 + progress * 2 * Math.PI); ctx.lineWidth = lineWidth; ctx.strokeStyle = Color.mPrimary; ctx.lineCap = "round"; ctx.stroke(); } } }