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 // Widget properties passed from Bar.qml for per-instance settings property string widgetId: "" property string section: "" property int sectionWidgetIndex: -1 property int sectionWidgetsCount: 0 property real scaling: 1.0 property var widgetMetadata: BarWidgetRegistry.widgetMetadata[widgetId] property var widgetSettings: { if (section && sectionWidgetIndex >= 0) { var widgets = Settings.data.bar.widgets[section]; if (widgets && sectionWidgetIndex < widgets.length) { return widgets[sectionWidgetIndex]; } } return {}; } readonly property bool isVerticalBar: (Settings.data.bar.position === "left" || Settings.data.bar.position === "right") readonly property string hideMode: (widgetSettings.hideMode !== undefined) ? widgetSettings.hideMode : "hidden" // "visible", "hidden", "transparent", "idle" // Backward compatibility: honor legacy hideWhenIdle setting if present readonly property bool hideWhenIdle: (widgetSettings.hideWhenIdle !== undefined) ? widgetSettings.hideWhenIdle : (widgetMetadata.hideWhenIdle !== undefined ? widgetMetadata.hideWhenIdle : false) 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 !== "") ? 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 // Private constants for element sizes readonly property int _iconOnlySize: Math.round(18 * scaling) readonly property int _artAndProgressSize: Math.round(21 * scaling) // Maximum widget width with user settings support readonly property real maxWidth: (widgetSettings.maxWidth !== undefined) ? widgetSettings.maxWidth : Math.max(widgetMetadata.maxWidth, screen ? screen.width * 0.06 : 0) readonly property bool useFixedWidth: (widgetSettings.useFixedWidth !== undefined) ? widgetSettings.useFixedWidth : widgetMetadata.useFixedWidth readonly property bool hasActivePlayer: MediaService.currentPlayer !== null readonly property string placeholderText: I18n.tr("bar.widget-settings.media-mini.no-active-player") readonly property string tooltipText: { var title = getTitle(); var controls = ""; if (MediaService.canGoNext) { controls += "Right click for next.\n"; } if (MediaService.canGoPrevious) { controls += "Middle click for previous."; } if (controls !== "") { return title + "\n\n" + controls; } return title; } // Hide conditions readonly property bool shouldHideIdle: ((hideMode === "idle") || hideWhenIdle) && !MediaService.isPlaying readonly property bool isEmptyForHideMode: (!hasActivePlayer) && (hideMode === "hidden") implicitHeight: visible ? (isVerticalBar ? ((shouldHideIdle || isEmptyForHideMode) ? 0 : calculatedVerticalDimension()) : Style.capsuleHeight) : 0 implicitWidth: visible ? (isVerticalBar ? ((shouldHideIdle || isEmptyForHideMode) ? 0 : calculatedVerticalDimension()) : ((shouldHideIdle || isEmptyForHideMode) ? 0 : dynamicWidth)) : 0 // "visible": Always Visible, "hidden": Hide When Empty, "transparent": Transparent When Empty, "idle": Hide When Idle (not playing) visible: shouldHideIdle ? false : (hideMode !== "hidden" || opacity > 0) opacity: shouldHideIdle ? 0.0 : (((hideMode !== "hidden" || hasActivePlayer) && (hideMode !== "transparent" || hasActivePlayer)) ? 1.0 : 0.0) 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 } } function getTitle() { if (showArtistFirst) { return (MediaService.trackArtist !== "" ? `${MediaService.trackArtist} - ` : "") + MediaService.trackTitle; } else { return MediaService.trackTitle + (MediaService.trackArtist !== "" ? ` - ${MediaService.trackArtist}` : ""); } } function calculatedVerticalDimension() { return Math.round((Style.baseWidgetSize - 5) * scaling); } function calculateContentWidth() { // Calculate the actual content width based on visible elements var contentWidth = 0; // Icon, progress ring, or album art width if (!hasActivePlayer || (!showAlbumArt && !showProgressRing)) { // Icon width only contentWidth += _iconOnlySize; } else if (showProgressRing && hasActivePlayer) { // Progress ring width (same as album art width to maintain consistent sizing) contentWidth += _artAndProgressSize; } else if (showAlbumArt && hasActivePlayer) { // Album art width contentWidth += _artAndProgressSize; } // Spacing between icon/art and text; only if there is text if (fullTitleMetrics.contentWidth > 0) { contentWidth += Style.marginS * scaling; // Text width (use the measured width) contentWidth += fullTitleMetrics.contentWidth; // Additional small margin for text contentWidth += Style.marginXXS * 2; } return Math.ceil(contentWidth); } // Dynamic width: adapt to content but respect maximum width setting readonly property real dynamicWidth: { var contentWidth = calculateContentWidth(); // For vertical bars, there are no horizontal margins to add var margins = isVerticalBar ? 0 : (Style.marginS * scaling * 2); var totalWidth = contentWidth + margins; // If using fixed width mode, always use maxWidth if (useFixedWidth) { return maxWidth; } // If there's no active player, the widget should be compact if (!hasActivePlayer) { return totalWidth; } // Adapt to content but don't exceed user-set maximum width return Math.min(totalWidth, maxWidth); } // A hidden text element to safely measure the full title width NText { id: fullTitleMetrics visible: false text: titleText.text font: titleText.font applyUiScale: false pointSize: Style.fontSizeS * scaling } NPopupContextMenu { id: contextMenu model: { var items = []; if (hasActivePlayer && MediaService.canPlay) { items.push({ "label": MediaService.isPlaying ? I18n.tr("context-menu.pause") : I18n.tr("context-menu.play"), "action": "play-pause", "icon": MediaService.isPlaying ? "media-pause" : "media-play" }); } if (hasActivePlayer && MediaService.canGoPrevious) { items.push({ "label": I18n.tr("context-menu.previous"), "action": "previous", "icon": "media-prev" }); } if (hasActivePlayer && MediaService.canGoNext) { items.push({ "label": I18n.tr("context-menu.next"), "action": "next", "icon": "media-next" }); } items.push({ "label": I18n.tr("context-menu.widget-settings"), "action": "widget-settings", "icon": "settings" }); return items; } onTriggered: action => { var popupMenuWindow = PanelService.getPopupMenuWindow(screen); if (popupMenuWindow) { popupMenuWindow.close(); } if (action === "play-pause") { MediaService.playPause(); } else if (action === "previous") { MediaService.previous(); } else if (action === "next") { MediaService.next(); } else if (action === "widget-settings") { BarService.openWidgetSettings(screen, section, sectionWidgetIndex, widgetId, widgetSettings); } } } Rectangle { id: mediaMini visible: root.visible anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter width: isVerticalBar ? ((shouldHideIdle || isEmptyForHideMode) ? 0 : calculatedVerticalDimension()) : ((shouldHideIdle || isEmptyForHideMode) ? 0 : dynamicWidth) height: isVerticalBar ? ((shouldHideIdle || isEmptyForHideMode) ? 0 : calculatedVerticalDimension()) : Style.capsuleHeight radius: isVerticalBar ? width / 2 : Style.radiusM color: Style.capsuleColor // Smooth width transition Behavior on width { NumberAnimation { duration: Style.animationNormal easing.type: Easing.InOutCubic } } // Smooth height transition for vertical bar Behavior on height { NumberAnimation { duration: Style.animationNormal easing.type: Easing.InOutCubic } } Item { id: mainContainer anchors.fill: parent anchors.leftMargin: isVerticalBar ? 0 : Style.marginS * scaling anchors.rightMargin: isVerticalBar ? 0 : Style.marginS * scaling clip: true Loader { anchors.verticalCenter: parent.verticalCenter anchors.horizontalCenter: parent.horizontalCenter active: showVisualizer && visualizerType == "linear" z: 0 sourceComponent: NLinearSpectrum { width: mainContainer.width - Style.marginS height: 20 values: CavaService.values fillColor: Color.mPrimary opacity: 0.4 } } Loader { anchors.verticalCenter: parent.verticalCenter anchors.horizontalCenter: parent.horizontalCenter active: showVisualizer && visualizerType == "mirrored" z: 0 sourceComponent: NMirroredSpectrum { width: mainContainer.width - Style.marginS height: mainContainer.height - Style.marginS values: CavaService.values fillColor: Color.mPrimary opacity: 0.4 } } Loader { anchors.verticalCenter: parent.verticalCenter anchors.horizontalCenter: parent.horizontalCenter active: showVisualizer && visualizerType == "wave" z: 0 sourceComponent: NWaveSpectrum { width: mainContainer.width - Style.marginS height: mainContainer.height - Style.marginS values: CavaService.values fillColor: Color.mPrimary opacity: 0.4 } } // Horizontal layout for top/bottom bars RowLayout { id: rowLayout anchors.verticalCenter: parent.verticalCenter spacing: Style.marginS * scaling visible: !isVerticalBar z: 1 // Above the visualizer NIcon { id: windowIcon icon: hasActivePlayer ? (MediaService.isPlaying ? "media-pause" : "media-play") : "disc" color: hasActivePlayer ? Color.mOnSurface : Color.mOnSurfaceVariant pointSize: Style.fontSizeL * scaling verticalAlignment: Text.AlignVCenter horizontalAlignment: Text.AlignHCenter Layout.alignment: Qt.AlignVCenter Layout.preferredWidth: _iconOnlySize Layout.preferredHeight: _iconOnlySize visible: !hasActivePlayer || (!showAlbumArt && !showProgressRing) } ColumnLayout { Layout.alignment: Qt.AlignVCenter spacing: 0 // Progress circle (independent of album art) Item { Layout.preferredWidth: (hasActivePlayer && (showProgressRing || showAlbumArt)) ? _artAndProgressSize : 0 Layout.preferredHeight: (hasActivePlayer && (showProgressRing || showAlbumArt)) ? _artAndProgressSize : 0 Layout.minimumWidth: (hasActivePlayer && showProgressRing) ? _artAndProgressSize : 0 Layout.minimumHeight: (hasActivePlayer && showProgressRing) ? _artAndProgressSize : 0 Layout.maximumWidth: (hasActivePlayer && (showProgressRing || showAlbumArt)) ? _artAndProgressSize : 0 Layout.maximumHeight: (hasActivePlayer && (showProgressRing || showAlbumArt)) ? _artAndProgressSize : 0 Layout.fillWidth: false Layout.fillHeight: false visible: hasActivePlayer && (showProgressRing || showAlbumArt) // Show container when there's active player and either feature is enabled // Progress circle - always available when showProgressRing is true Canvas { id: progressCanvas anchors.fill: parent anchors.margins: 0 // Align exactly with parent to avoid clipping visible: hasActivePlayer && showProgressRing // Only show when progress ring is enabled z: 0 // Behind the album art or icon // Calculate progress ratio: 0 to 1 property real progressRatio: { if (!MediaService.currentPlayer || MediaService.trackLength <= 0) return 0; const r = MediaService.currentPosition / MediaService.trackLength; if (isNaN(r) || !isFinite(r)) return 0; return Math.max(0, Math.min(1, r)); } onProgressRatioChanged: requestPaint() onPaint: { var ctx = getContext("2d"); // Check if width/height are valid before calculating radius if (width <= 0 || height <= 0) { return; // Skip drawing if dimensions are invalid } var centerX = width / 2; var centerY = height / 2; var radius = Math.max(0, Math.min(width, height) / 2 - (1.25 * scaling)); // Larger radius, accounting for line width to approach edge ctx.reset(); // Background circle (full track, not played yet) ctx.beginPath(); ctx.arc(centerX, centerY, radius, 0, 2 * Math.PI); ctx.lineWidth = 2.5 * scaling; // Thicker line width based on scaling property ctx.strokeStyle = Qt.alpha(Color.mOnSurface, 0.4); // More opaque for better visibility ctx.stroke(); // Progress arc (played portion) ctx.beginPath(); ctx.arc(centerX, centerY, radius, -Math.PI / 2, -Math.PI / 2 + progressRatio * 2 * Math.PI); ctx.lineWidth = 2.5 * scaling; // Thicker line width based on scaling property ctx.strokeStyle = Color.mPrimary; // Use primary color for progress ctx.lineCap = "round"; ctx.stroke(); } } // Connection to update progress when media position changes Connections { target: MediaService function onCurrentPositionChanged() { progressCanvas.requestPaint(); } function onTrackLengthChanged() { progressCanvas.requestPaint(); } } // Property to track mPrimary color changes and trigger repaint Item { id: colorTrackerHorizontal property color currentColor: Color.mPrimary onCurrentColorChanged: progressCanvas.requestPaint() } // Album art or icon - only show album art when enabled and player is active Item { anchors.fill: parent anchors.margins: showProgressRing ? (3 * scaling) : 0.5 // Adjusted to align with progress circle better NImageRounded { id: trackArt anchors.fill: parent anchors.margins: showProgressRing ? 0 : -1 * scaling // Add negative margin to make album art larger when no progress ring radius: width * 0.5 visible: showAlbumArt && hasActivePlayer imagePath: MediaService.trackArtUrl fallbackIcon: MediaService.isPlaying ? "media-pause" : "media-play" fallbackIconSize: showProgressRing ? 10 : 12 // Larger fallback icon when no progress ring borderWidth: 0 borderColor: Color.transparent z: 1 // In front of the progress circle } // Fallback icon when no album art or album art not shown NIcon { anchors.centerIn: parent width: parent.width height: parent.height icon: hasActivePlayer ? (MediaService.isPlaying ? "media-pause" : "media-play") : "disc" color: hasActivePlayer ? Color.mOnSurface : Color.mOnSurfaceVariant pointSize: (showAlbumArt || showProgressRing) ? 8 * scaling : 12 * scaling // Smaller when inside album art circle or progress ring, larger when alone verticalAlignment: Text.AlignVCenter horizontalAlignment: Text.AlignHCenter visible: (!showAlbumArt && hasActivePlayer) && showProgressRing z: 1 // In front of the progress circle } } } } Item { id: titleContainer Layout.preferredWidth: { // Calculate available width based on other elements in the row var iconWidth = (windowIcon.visible ? (_iconOnlySize + Style.marginS * scaling) : 0); var artWidth = (hasActivePlayer && (showAlbumArt || showProgressRing) ? (_artAndProgressSize + Style.marginS * scaling) : 0); var totalMargins = Style.marginXXS * 2; var availableWidth = mainContainer.width - iconWidth - artWidth - totalMargins; return Math.max(20, availableWidth); } Layout.maximumWidth: Layout.preferredWidth Layout.alignment: Qt.AlignVCenter Layout.preferredHeight: titleText.height clip: true property bool isScrolling: false property bool isResetting: false property real textWidth: fullTitleMetrics.contentWidth property real containerWidth: 0 property bool needsScrolling: textWidth > containerWidth // Timer for "always" mode with delay Timer { id: scrollStartTimer interval: 1000 repeat: false onTriggered: { if (scrollingMode === "always" && titleContainer.needsScrolling) { titleContainer.isScrolling = true; titleContainer.isResetting = false; } } } // Update scrolling state based on mode property var updateScrollingState: function () { if (scrollingMode === "never") { isScrolling = false; isResetting = false; } else if (scrollingMode === "always") { if (needsScrolling) { if (mouseArea.containsMouse) { isScrolling = false; isResetting = true; } else { scrollStartTimer.restart(); } } else { scrollStartTimer.stop(); isScrolling = false; isResetting = false; } } else if (scrollingMode === "hover") { if (mouseArea.containsMouse && needsScrolling) { isScrolling = true; isResetting = false; } else { isScrolling = false; if (needsScrolling) { isResetting = true; } } } } onWidthChanged: { containerWidth = width; updateScrollingState(); } Component.onCompleted: { containerWidth = width; updateScrollingState(); } Connections { target: mouseArea function onContainsMouseChanged() { titleContainer.updateScrollingState(); } } // Scrolling content Item { id: scrollContainer height: parent.height width: parent.width property real scrollX: 0 x: scrollX RowLayout { spacing: 50 // Gap between text copies NText { id: titleText text: hasActivePlayer ? getTitle() : placeholderText pointSize: Style.fontSizeS * scaling applyUiScale: false font.weight: Style.fontWeightMedium verticalAlignment: Text.AlignVCenter horizontalAlignment: hasActivePlayer ? Text.AlignLeft : Text.AlignHCenter color: hasActivePlayer ? Color.mOnSurface : Color.mOnSurfaceVariant onTextChanged: { titleContainer.isScrolling = false; titleContainer.isResetting = false; scrollContainer.scrollX = 0; if (titleContainer.needsScrolling) { scrollStartTimer.restart(); } } } NText { text: hasActivePlayer ? getTitle() : placeholderText font: titleText.font applyUiScale: false pointSize: Style.fontSizeS * scaling verticalAlignment: Text.AlignVCenter horizontalAlignment: hasActivePlayer ? Text.AlignLeft : Text.AlignHCenter color: hasActivePlayer ? Color.mOnSurface : Color.mOnSurfaceVariant visible: hasActivePlayer && titleContainer.needsScrolling && titleContainer.isScrolling } } // Reset animation NumberAnimation on scrollX { running: titleContainer.isResetting to: 0 duration: 300 easing.type: Easing.OutQuad onFinished: { titleContainer.isResetting = false; } } // Seamless infinite scroll NumberAnimation on scrollX { id: infiniteScroll running: titleContainer.isScrolling && !titleContainer.isResetting from: 0 to: -(titleContainer.textWidth + 50) // Scroll one complete text width + gap duration: Math.max(4000, getTitle().length * 120) loops: Animation.Infinite easing.type: Easing.Linear } } } } // Progress circle for vertical layout - follows background radius Canvas { id: progressCanvasVertical anchors.fill: parent anchors.margins: 0 // Align with parent container (mainContainer which matches mediaMini) visible: isVerticalBar && showProgressRing // Control visibility with setting z: 0 // Behind other content // Calculate progress ratio: 0 to 1 property real progressRatio: { if (!MediaService.currentPlayer || MediaService.trackLength <= 0) return 0; const r = MediaService.currentPosition / MediaService.trackLength; if (isNaN(r) || !isFinite(r)) return 0; return Math.max(0, Math.min(1, r)); } onProgressRatioChanged: requestPaint() onPaint: { var ctx = getContext("2d"); // Check if width/height are valid before calculating radius if (width <= 0 || height <= 0) { return; // Skip drawing if dimensions are invalid } var centerX = width / 2; var centerY = height / 2; // Align with mediaMini radius which is circular in vertical mode var radius = Math.max(0, Math.min(width, height) / 2 - 4); // Position ring near the outer edge of background ctx.reset(); // Background circle (full track, not played yet) ctx.beginPath(); ctx.arc(centerX, centerY, radius, 0, 2 * Math.PI); ctx.lineWidth = 2.5 * scaling; // Line width based on scaling property, thinner for vertical layout ctx.strokeStyle = Qt.alpha(Color.mOnSurface, 0.4); // More opaque for better visibility ctx.stroke(); // Progress arc (played portion) ctx.beginPath(); ctx.arc(centerX, centerY, radius, -Math.PI / 2, -Math.PI / 2 + progressRatio * 2 * Math.PI); ctx.lineWidth = 2.5 * scaling; // Line width based on scaling property, thinner for vertical layout ctx.strokeStyle = Color.mPrimary; // Use primary color for progress ctx.lineCap = "round"; ctx.stroke(); } } // Vertical layout for left/right bars - icon or album art Item { id: verticalLayout anchors.centerIn: parent width: showProgressRing ? (Style.baseWidgetSize * 0.5 * scaling) : (calculatedVerticalDimension() - 4 * scaling) height: width visible: isVerticalBar z: 1 // Above the visualizer and progress ring // Album Art NImageRounded { anchors.fill: parent visible: showAlbumArt && hasActivePlayer radius: width * 0.5 imagePath: MediaService.trackArtUrl fallbackIcon: MediaService.isPlaying ? "media-pause" : "media-play" fallbackIconSize: 12 borderWidth: 0 } // Media icon (fallback) NIcon { id: mediaIconVertical anchors.centerIn: parent width: parent.width height: parent.height visible: !showAlbumArt || !hasActivePlayer icon: hasActivePlayer ? (MediaService.isPlaying ? "media-pause" : "media-play") : "disc" color: hasActivePlayer ? Color.mOnSurface : Color.mOnSurfaceVariant pointSize: Style.fontSizeM * scaling verticalAlignment: Text.AlignVCenter horizontalAlignment: Text.AlignHCenter } } // Connection to update vertical progress when media position changes Connections { target: MediaService function onCurrentPositionChanged() { progressCanvasVertical.requestPaint(); } function onTrackLengthChanged() { progressCanvasVertical.requestPaint(); } } // Property to track mPrimary color changes and trigger repaint for vertical canvas Item { id: colorTrackerVertical property color currentColor: Color.mPrimary onCurrentColorChanged: progressCanvasVertical.requestPaint() } // Mouse area for hover detection MouseArea { id: mouseArea anchors.fill: parent hoverEnabled: true cursorShape: hasActivePlayer ? Qt.PointingHandCursor : Qt.ArrowCursor acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton onClicked: mouse => { if (mouse.button === Qt.LeftButton) { if (!hasActivePlayer || !MediaService.currentPlayer || !MediaService.canPlay) { return; } MediaService.playPause(); } else if (mouse.button === Qt.RightButton) { TooltipService.hide(); var popupMenuWindow = PanelService.getPopupMenuWindow(screen); if (popupMenuWindow) { popupMenuWindow.showContextMenu(contextMenu); const pos = BarService.getContextMenuPosition(mediaMini, contextMenu.implicitWidth, contextMenu.implicitHeight); contextMenu.openAtItem(mediaMini, pos.x, pos.y); } } else if (mouse.button === Qt.MiddleButton) { if (hasActivePlayer && MediaService.canGoPrevious) { MediaService.previous(); TooltipService.hide(); } } } onEntered: { var textToShow = hasActivePlayer ? tooltipText : placeholderText; if ((textToShow !== "") && isVerticalBar || (scrollingMode === "never")) { TooltipService.show(root, textToShow, BarService.getTooltipDirection()); } } onExited: { TooltipService.hide(); } } } } }