import QtQuick import QtQuick.Controls import QtQuick.Effects import QtQuick.Layouts import Quickshell import qs.Commons import qs.Modules.MainScreen import qs.Services.Media import qs.Services.UI import qs.Widgets import qs.Widgets.AudioSpectrum SmartPanel { id: root preferredWidth: Math.round((root.isSideBySide ? 480 : 360) * Style.uiScaleRatio) // Fallback only; SmartPanel uses panelContent.contentPreferredHeight when set. preferredHeight: Math.round((root.compactMode ? 240 : 400) * Style.uiScaleRatio) property var mediaMiniSettings: { const widget = BarService.lookupWidget("MediaMini", screen?.name); return widget ? widget.widgetSettings : null; } function refreshMediaMiniSettings() { const widget = BarService.lookupWidget("MediaMini", screen?.name); root.mediaMiniSettings = widget ? widget.widgetSettings : null; } Connections { target: BarService function onActiveWidgetsChanged() { root.refreshMediaMiniSettings(); } } Connections { target: Settings function onSettingsSaved() { root.refreshMediaMiniSettings(); } } readonly property string visualizerType: (mediaMiniSettings && mediaMiniSettings.visualizerType !== undefined) ? mediaMiniSettings.visualizerType : "linear" readonly property bool showArtistFirst: !!(mediaMiniSettings && mediaMiniSettings.showArtistFirst !== undefined ? mediaMiniSettings.showArtistFirst : true) readonly property bool showAlbumArt: !!(mediaMiniSettings && mediaMiniSettings.panelShowAlbumArt !== undefined ? mediaMiniSettings.panelShowAlbumArt : true) readonly property bool showVisualizer: !!(mediaMiniSettings && mediaMiniSettings.showVisualizer !== undefined ? mediaMiniSettings.showVisualizer : true) readonly property bool compactMode: !!(mediaMiniSettings && mediaMiniSettings.compactMode !== undefined ? mediaMiniSettings.compactMode : false) readonly property string scrollingMode: (mediaMiniSettings && mediaMiniSettings.scrollingMode !== undefined) ? mediaMiniSettings.scrollingMode : "hover" readonly property bool isSideBySide: root.compactMode && root.showAlbumArt readonly property bool needsSpectrum: root.showVisualizer && root.visualizerType !== "" && root.visualizerType !== "none" && root.isPanelOpen && MediaService.isPlaying onNeedsSpectrumChanged: { if (root.needsSpectrum) { SpectrumService.registerComponent("mediaplayerpanel"); } else { SpectrumService.unregisterComponent("mediaplayerpanel"); } } Component.onCompleted: { if (root.needsSpectrum) { SpectrumService.registerComponent("mediaplayerpanel"); } } Component.onDestruction: { SpectrumService.unregisterComponent("mediaplayerpanel"); } panelContent: Item { id: playerContent anchors.fill: parent property real contentPreferredHeight: mainLayout.implicitHeight + Style.margin2L property Component visualizerSource: { switch (root.visualizerType) { case "linear": return linearComponent; case "mirrored": return mirroredComponent; case "wave": return waveComponent; default: return null; } } ColumnLayout { id: mainLayout anchors.fill: parent anchors.margins: Style.marginL spacing: Style.marginM NBox { Layout.fillWidth: true Layout.preferredHeight: headerRow.implicitHeight + Style.margin2M RowLayout { id: headerRow anchors.fill: parent anchors.margins: Style.marginM spacing: Style.marginM NIcon { icon: "music" pointSize: Style.fontSizeL color: Color.mPrimary } NText { text: I18n.tr("common.media-player") font.weight: Style.fontWeightBold pointSize: Style.fontSizeL color: Color.mOnSurface Layout.fillWidth: true } Rectangle { radius: Style.radiusS color: playerSelectorMouse.containsMouse ? Color.mPrimary : "transparent" implicitWidth: playerRow.implicitWidth + Style.marginM implicitHeight: Style.baseWidgetSize * 0.8 visible: MediaService.getAvailablePlayers().length > 1 RowLayout { id: playerRow anchors.centerIn: parent spacing: Style.marginXS NText { text: MediaService.currentPlayer ? MediaService.currentPlayer.identity : "Select Player" pointSize: Style.fontSizeXS color: playerSelectorMouse.containsMouse ? Color.mOnPrimary : Color.mOnSurfaceVariant } NIcon { icon: "chevron-down" pointSize: Style.fontSizeXS color: playerSelectorMouse.containsMouse ? Color.mOnPrimary : Color.mOnSurfaceVariant } } MouseArea { id: playerSelectorMouse anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: playerContextMenu.open() } Popup { id: playerContextMenu x: 0 y: parent.height width: 160 padding: Style.marginS background: Rectangle { color: Color.mSurfaceVariant border.color: Color.mOutline border.width: Style.borderS radius: Style.iRadiusM } contentItem: ColumnLayout { spacing: 0 Repeater { model: MediaService.getAvailablePlayers() delegate: Rectangle { Layout.fillWidth: true Layout.preferredHeight: 30 color: "transparent" Rectangle { anchors.fill: parent color: itemMouse.containsMouse ? Color.mPrimary : "transparent" radius: Style.iRadiusS } RowLayout { anchors.fill: parent anchors.margins: Style.marginS spacing: Style.marginS NIcon { visible: MediaService.currentPlayer && MediaService.currentPlayer.identity === modelData.identity icon: "check" color: itemMouse.containsMouse ? Color.mOnPrimary : Color.mPrimary pointSize: Style.fontSizeS } NText { text: modelData.identity pointSize: Style.fontSizeS color: itemMouse.containsMouse ? Color.mOnPrimary : Color.mOnSurface Layout.fillWidth: true elide: Text.ElideRight } } MouseArea { id: itemMouse anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: { MediaService.currentPlayer = modelData; playerContextMenu.close(); } } } } } } } NIconButton { icon: "close" tooltipText: I18n.tr("common.close") baseSize: Style.baseWidgetSize * 0.8 onClicked: root.close() } } } NBox { Layout.fillWidth: true Layout.preferredHeight: mediaContentGrid.implicitHeight + Style.margin2M // Visualizer background for content area Loader { anchors.fill: parent z: 0 active: !!(root.needsSpectrum && !root.showAlbumArt) sourceComponent: visualizerSource } GridLayout { id: mediaContentGrid anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top anchors.leftMargin: root.compactMode ? Style.marginL : Style.marginM anchors.rightMargin: root.compactMode ? Style.marginL : Style.marginM anchors.topMargin: Style.marginM anchors.bottomMargin: Style.marginM columns: root.isSideBySide ? 2 : 1 columnSpacing: Style.marginL rowSpacing: root.compactMode ? Style.marginL : Style.marginM // Album Art (Vertical in normal, Horizontal in compact) Item { id: albumArtItem readonly property real compactArtSize: Math.round(110 * Style.uiScaleRatio) readonly property bool artSizeKnown: artSizeProbe.status === Image.Ready && artSizeProbe.sourceSize.width > 0 && artSizeProbe.sourceSize.height > 0 readonly property real artAspectRatio: artSizeKnown ? artSizeProbe.sourceSize.width / artSizeProbe.sourceSize.height : 1 // Non-compact: height from width÷aspect so grid implicit height does not depend on panel height (no layout loop). readonly property real artBoxW: root.compactMode ? compactArtSize : Math.max(parent.width, 1) readonly property real artBoxH: root.compactMode ? compactArtSize : (artBoxW / Math.max(artAspectRatio, 0.001)) readonly property real fitArtW: artBoxW / artBoxH > artAspectRatio ? artBoxH * artAspectRatio : artBoxW readonly property real fitArtH: artBoxW / artBoxH > artAspectRatio ? artBoxH : artBoxW / artAspectRatio Layout.preferredWidth: fitArtW Layout.preferredHeight: fitArtH Layout.minimumWidth: fitArtW Layout.maximumWidth: fitArtW Layout.minimumHeight: fitArtH Layout.maximumHeight: fitArtH Layout.fillWidth: false Layout.fillHeight: false Layout.alignment: Qt.AlignHCenter visible: root.showAlbumArt Image { id: artSizeProbe visible: false asynchronous: true source: MediaService.trackArtUrl } NImageRounded { anchors.fill: parent radius: root.compactMode ? Style.radiusM : Style.radiusL imagePath: MediaService.trackArtUrl imageFillMode: Image.PreserveAspectCrop fallbackIcon: "disc" fallbackIconSize: root.compactMode ? Style.fontSizeXXXL * 3 : Style.fontSizeXXXL * 6 borderWidth: 0 } Loader { anchors.fill: parent anchors.margins: Style.marginS z: 2 active: !!(root.needsSpectrum && root.showAlbumArt) sourceComponent: visualizerSource } } ColumnLayout { id: controlsLayout Layout.preferredWidth: root.compactMode ? -1 : albumArtItem.width Layout.fillWidth: true Layout.alignment: Qt.AlignHCenter Layout.fillHeight: root.compactMode spacing: root.compactMode ? Style.marginXS : Style.marginS ColumnLayout { Layout.fillWidth: true spacing: 0 NScrollText { Layout.fillWidth: true maxWidth: parent.width text: { if (root.showArtistFirst) { return MediaService.trackArtist || (MediaService.trackAlbum || "Unknown Artist"); } else { return MediaService.trackTitle || "No Media"; } } scrollMode: { if (root.scrollingMode === "always") return NScrollText.ScrollMode.Always; if (root.scrollingMode === "hover") return NScrollText.ScrollMode.Hover; return NScrollText.ScrollMode.Never; } fadeExtent: 0.01 fadeCornerRadius: Style.radiusM delegate: NText { pointSize: root.compactMode ? Style.fontSizeL : Style.fontSizeXL font.weight: Style.fontWeightBold color: Color.mOnSurface horizontalAlignment: root.isSideBySide ? Text.AlignLeft : Text.AlignHCenter elide: Text.ElideNone wrapMode: Text.NoWrap } } NScrollText { Layout.fillWidth: true maxWidth: parent.width text: { if (root.showArtistFirst) { return MediaService.trackTitle || "No Media"; } else { return MediaService.trackArtist || (MediaService.trackAlbum || "Unknown Artist"); } } scrollMode: { if (root.scrollingMode === "always") return NScrollText.ScrollMode.Always; if (root.scrollingMode === "hover") return NScrollText.ScrollMode.Hover; return NScrollText.ScrollMode.Never; } fadeExtent: 0.01 fadeCornerRadius: Style.radiusM delegate: NText { pointSize: root.compactMode ? Style.fontSizeS : Style.fontSizeM color: Color.mOnSurfaceVariant horizontalAlignment: root.isSideBySide ? Text.AlignLeft : Text.AlignHCenter elide: Text.ElideNone wrapMode: Text.NoWrap } } } Item { id: progressWrapper visible: (MediaService.currentPlayer && MediaService.trackLength > 0) Layout.fillWidth: true Layout.preferredHeight: progressColumn.implicitHeight property real localSeekRatio: -1 property real lastSentSeekRatio: -1 property real seekEpsilon: 0.01 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)); } Timer { id: seekDebounce interval: 75 repeat: false onTriggered: { if (MediaService.isSeeking && progressWrapper.localSeekRatio >= 0) { const next = Math.max(0, Math.min(1, progressWrapper.localSeekRatio)); if (progressWrapper.lastSentSeekRatio < 0 || Math.abs(next - progressWrapper.lastSentSeekRatio) >= progressWrapper.seekEpsilon) { MediaService.seekByRatio(next); progressWrapper.lastSentSeekRatio = next; } } } } ColumnLayout { id: progressColumn anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top spacing: 2 Item { Layout.fillWidth: true Layout.preferredHeight: root.compactMode ? (Style.baseWidgetSize * 0.4) : (Style.baseWidgetSize * 0.5) NSlider { id: progressSlider anchors.fill: parent from: 0 to: 1 stepSize: 0 snapAlways: false enabled: MediaService.trackLength > 0 && MediaService.canSeek heightRatio: 0.4 value: (!MediaService.isSeeking) ? progressWrapper.progressRatio : (progressWrapper.localSeekRatio >= 0 ? progressWrapper.localSeekRatio : 0) onMoved: { progressWrapper.localSeekRatio = value; seekDebounce.restart(); } onPressedChanged: { if (pressed) { MediaService.isSeeking = true; progressWrapper.localSeekRatio = value; MediaService.seekByRatio(value); progressWrapper.lastSentSeekRatio = value; } else { seekDebounce.stop(); MediaService.seekByRatio(value); MediaService.isSeeking = false; progressWrapper.localSeekRatio = -1; progressWrapper.lastSentSeekRatio = -1; } } } } RowLayout { Layout.fillWidth: true spacing: 0 NText { text: MediaService.positionString || "0:00" pointSize: Style.fontSizeXS color: Color.mOnSurfaceVariant visible: progressWrapper.visible } Item { Layout.fillWidth: true Layout.minimumWidth: 0 } NText { text: MediaService.lengthString || "0:00" pointSize: Style.fontSizeXS color: Color.mOnSurfaceVariant horizontalAlignment: Text.AlignRight visible: progressWrapper.visible } } } } Item { Layout.preferredHeight: root.isSideBySide ? Style.marginM : Style.marginS } RowLayout { Layout.alignment: Qt.AlignHCenter spacing: root.isSideBySide ? Style.marginL : Style.marginXL NIconButton { icon: "media-prev" baseSize: root.compactMode ? (Style.baseWidgetSize * 0.9) : (Style.baseWidgetSize * 1.2) onClicked: MediaService.previous() } Rectangle { implicitWidth: root.compactMode ? (Style.baseWidgetSize * 1.3) : (Style.baseWidgetSize * 1.8) implicitHeight: root.compactMode ? (Style.baseWidgetSize * 1.3) : (Style.baseWidgetSize * 1.8) radius: root.compactMode ? Style.iRadiusM : Style.iRadiusL color: Color.mPrimary NIcon { anchors.centerIn: parent icon: MediaService.isPlaying ? "media-pause" : "media-play" pointSize: root.compactMode ? Style.fontSizeL : Style.fontSizeXXL color: Color.mOnPrimary } MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor hoverEnabled: true onEntered: parent.color = Color.mPrimary onClicked: MediaService.playPause() } } NIconButton { icon: "media-next" baseSize: root.compactMode ? (Style.baseWidgetSize * 0.9) : (Style.baseWidgetSize * 1.2) onClicked: MediaService.next() } } } } } } } // Visualizer Components Component { id: linearComponent NLinearSpectrum { width: parent.width - Style.marginS height: 20 values: SpectrumService.values fillColor: Color.mPrimary opacity: 0.4 barPosition: Settings.getBarPositionForScreen(root.screen?.name) mirrored: Settings.data.audio.spectrumMirrored } } Component { id: mirroredComponent 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: waveComponent 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 } } }