import QtQuick import QtQuick.Controls import QtQuick.Effects import QtQuick.Shapes import Quickshell.Services.Mpris import Quickshell.Services.Pipewire import qs.Common import qs.Services import qs.Widgets Item { id: root property MprisPlayer activePlayer: MprisController.activePlayer property string lastValidTitle: "" property string lastValidArtist: "" property string lastValidAlbum: "" property string lastValidArtUrl: "" property real currentPosition: activePlayer && activePlayer.positionSupported ? activePlayer.position : 0 property real displayPosition: currentPosition property var defaultSink: AudioService.sink readonly property real ratio: { if (!activePlayer || activePlayer.length <= 0) { return 0 } const calculatedRatio = displayPosition / activePlayer.length return Math.max(0, Math.min(1, calculatedRatio)) } implicitWidth: 700 implicitHeight: 410 onActivePlayerChanged: { if (activePlayer && activePlayer.positionSupported) { currentPosition = Qt.binding(() => activePlayer?.position || 0) } else { currentPosition = 0 } } Timer { id: positionTimer interval: 300 running: activePlayer && activePlayer.playbackState === MprisPlaybackState.Playing && !isSeeking repeat: true onTriggered: activePlayer && activePlayer.positionSupported && activePlayer.positionChanged() } property bool isSeeking: false Timer { id: cleanupTimer interval: 2000 running: !activePlayer onTriggered: { lastValidTitle = "" lastValidArtist = "" lastValidAlbum = "" lastValidArtUrl = "" currentPosition = 0 stop() } } Column { anchors.centerIn: parent spacing: Theme.spacingM visible: (!activePlayer && !lastValidTitle) || (activePlayer && activePlayer.trackTitle === "" && lastValidTitle === "") DankIcon { name: "music_note" size: Theme.iconSize * 3 color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.5) anchors.horizontalCenter: parent.horizontalCenter } StyledText { text: "No Active Players" font.pixelSize: Theme.fontSizeLarge color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7) anchors.horizontalCenter: parent.horizontalCenter } } Item { anchors.fill: parent visible: (activePlayer && activePlayer.trackTitle !== "") || lastValidTitle !== "" // Left Column: Album Art and Controls (60%) Column { x: 0 y: 0 width: parent.width * 0.6 - Theme.spacingM height: parent.height spacing: Theme.spacingL // Album Art Section Item { width: parent.width height: parent.height * 0.55 anchors.horizontalCenter: parent.horizontalCenter Item { width: Math.min(parent.width * 0.8, parent.height * 0.9) height: width anchors.centerIn: parent Loader { active: activePlayer?.playbackState === MprisPlaybackState.Playing sourceComponent: Component { Ref { service: CavaService } } } Shape { id: morphingBlob width: parent.width * 1.1 height: parent.height * 1.1 anchors.centerIn: parent visible: activePlayer?.playbackState === MprisPlaybackState.Playing asynchronous: false antialiasing: true preferredRendererType: Shape.CurveRenderer z: 0 layer.enabled: true layer.smooth: true layer.samples: 4 readonly property real centerX: width / 2 readonly property real centerY: height / 2 readonly property real baseRadius: Math.min(width, height) * 0.35 readonly property int segments: 24 property var audioLevels: { if (!CavaService.cavaAvailable || CavaService.values.length === 0) { return [0.5, 0.3, 0.7, 0.4, 0.6, 0.5] } return CavaService.values } property var smoothedLevels: [0.5, 0.3, 0.7, 0.4, 0.6, 0.5] property var cubics: [] onAudioLevelsChanged: updatePath() Timer { running: morphingBlob.visible interval: 16 repeat: true onTriggered: morphingBlob.updatePath() } Component { id: cubicSegment PathCubic {} } Component.onCompleted: { shapePath.pathElements.push(Qt.createQmlObject( 'import QtQuick; import QtQuick.Shapes; PathMove {}', shapePath )) for (let i = 0; i < segments; i++) { const seg = cubicSegment.createObject(shapePath) shapePath.pathElements.push(seg) cubics.push(seg) } updatePath() } function expSmooth(prev, next, alpha) { return prev + alpha * (next - prev) } function updatePath() { if (cubics.length === 0) return for (let i = 0; i < Math.min(smoothedLevels.length, audioLevels.length); i++) { smoothedLevels[i] = expSmooth(smoothedLevels[i], audioLevels[i], 0.2) } const points = [] for (let i = 0; i < segments; i++) { const angle = (i / segments) * 2 * Math.PI const audioIndex = i % Math.min(smoothedLevels.length, 6) const audioLevel = Math.max(0.1, Math.min(1.5, (smoothedLevels[audioIndex] || 0) / 50)) const radius = baseRadius * (1.0 + audioLevel * 0.3) const x = centerX + Math.cos(angle) * radius const y = centerY + Math.sin(angle) * radius points.push({x: x, y: y}) } const startMove = shapePath.pathElements[0] startMove.x = points[0].x startMove.y = points[0].y const tension = 0.5 for (let i = 0; i < segments; i++) { const p0 = points[(i - 1 + segments) % segments] const p1 = points[i] const p2 = points[(i + 1) % segments] const p3 = points[(i + 2) % segments] const c1x = p1.x + (p2.x - p0.x) * tension / 3 const c1y = p1.y + (p2.y - p0.y) * tension / 3 const c2x = p2.x - (p3.x - p1.x) * tension / 3 const c2y = p2.y - (p3.y - p1.y) * tension / 3 const seg = cubics[i] seg.control1X = c1x seg.control1Y = c1y seg.control2X = c2x seg.control2Y = c2y seg.x = p2.x seg.y = p2.y } } ShapePath { id: shapePath fillColor: Theme.primary strokeColor: "transparent" strokeWidth: 0 joinStyle: ShapePath.RoundJoin fillRule: ShapePath.WindingFill } } Rectangle { width: parent.width * 0.75 height: width radius: width / 2 color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.3) border.color: Theme.surfaceContainer border.width: 1 anchors.centerIn: parent z: 1 Image { id: albumArt source: (activePlayer && activePlayer.trackArtUrl) || lastValidArtUrl || "" onSourceChanged: { if (activePlayer && activePlayer.trackArtUrl && albumArt.status !== Image.Error) { lastValidArtUrl = activePlayer.trackArtUrl } } anchors.fill: parent anchors.margins: 2 fillMode: Image.PreserveAspectCrop smooth: true mipmap: true cache: true asynchronous: true visible: false onStatusChanged: { if (status === Image.Error) { console.warn("Failed to load album art:", source) source = "" if (activePlayer && activePlayer.trackArtUrl === source) { lastValidArtUrl = "" } } } } MultiEffect { anchors.fill: parent anchors.margins: 2 source: albumArt maskEnabled: true maskSource: circularMask visible: albumArt.status === Image.Ready maskThresholdMin: 0.5 maskSpreadAtMin: 1 } Item { id: circularMask width: parent.width - 4 height: parent.height - 4 layer.enabled: true layer.smooth: true visible: false Rectangle { anchors.fill: parent radius: width / 2 color: "black" antialiasing: true } } DankIcon { anchors.centerIn: parent name: "album" size: parent.width * 0.3 color: Theme.surfaceVariantText visible: albumArt.status !== Image.Ready } } } } // Song Info and Controls Section Column { width: parent.width height: parent.height * 0.45 spacing: Theme.spacingS anchors.horizontalCenter: parent.horizontalCenter // Song Info Column { width: parent.width spacing: Theme.spacingS anchors.horizontalCenter: parent.horizontalCenter StyledText { text: (activePlayer && activePlayer.trackTitle) || lastValidTitle || "Unknown Track" onTextChanged: { if (activePlayer && activePlayer.trackTitle) { lastValidTitle = activePlayer.trackTitle } } font.pixelSize: Theme.fontSizeLarge font.weight: Font.Bold color: Theme.surfaceText width: parent.width horizontalAlignment: Text.AlignHCenter elide: Text.ElideRight wrapMode: Text.NoWrap maximumLineCount: 1 } StyledText { text: (activePlayer && activePlayer.trackArtist) || lastValidArtist || "Unknown Artist" onTextChanged: { if (activePlayer && activePlayer.trackArtist) { lastValidArtist = activePlayer.trackArtist } } font.pixelSize: Theme.fontSizeMedium color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.8) width: parent.width horizontalAlignment: Text.AlignHCenter elide: Text.ElideRight wrapMode: Text.NoWrap maximumLineCount: 1 } StyledText { text: (activePlayer && activePlayer.trackAlbum) || lastValidAlbum || "" onTextChanged: { if (activePlayer && activePlayer.trackAlbum) { lastValidAlbum = activePlayer.trackAlbum } } font.pixelSize: Theme.fontSizeSmall color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.6) width: parent.width horizontalAlignment: Text.AlignHCenter elide: Text.ElideRight wrapMode: Text.NoWrap maximumLineCount: 1 visible: text.length > 0 } } Loader { width: parent.width + 4 height: 20 x: -2 visible: activePlayer?.length > 0 sourceComponent: SettingsData.waveProgressEnabled ? waveProgressComponent : flatProgressComponent Component { id: waveProgressComponent M3WaveProgress { value: ratio isPlaying: activePlayer?.playbackState === MprisPlaybackState.Playing MouseArea { id: progressSliderArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor enabled: activePlayer ? (activePlayer.canSeek && activePlayer.length > 0) : false property real pendingSeekPosition: -1 Timer { id: seekDebounceTimer interval: 150 onTriggered: { if (progressSliderArea.pendingSeekPosition >= 0 && activePlayer?.canSeek && activePlayer?.length > 0) { const clamped = Math.min(progressSliderArea.pendingSeekPosition, activePlayer.length * 0.99) activePlayer.position = clamped progressSliderArea.pendingSeekPosition = -1 } } } onPressed: (mouse) => { root.isSeeking = true if (activePlayer?.length > 0 && activePlayer?.canSeek) { const r = Math.max(0, Math.min(1, mouse.x / parent.width)) pendingSeekPosition = r * activePlayer.length displayPosition = pendingSeekPosition seekDebounceTimer.restart() } } onReleased: { root.isSeeking = false seekDebounceTimer.stop() if (pendingSeekPosition >= 0 && activePlayer?.canSeek && activePlayer?.length > 0) { const clamped = Math.min(pendingSeekPosition, activePlayer.length * 0.99) activePlayer.position = clamped pendingSeekPosition = -1 } displayPosition = Qt.binding(() => currentPosition) } onPositionChanged: (mouse) => { if (pressed && root.isSeeking && activePlayer?.length > 0 && activePlayer?.canSeek) { const r = Math.max(0, Math.min(1, mouse.x / parent.width)) pendingSeekPosition = r * activePlayer.length displayPosition = pendingSeekPosition seekDebounceTimer.restart() } } onClicked: (mouse) => { if (activePlayer?.length > 0 && activePlayer?.canSeek) { const r = Math.max(0, Math.min(1, mouse.x / parent.width)) activePlayer.position = r * activePlayer.length } } } } } Component { id: flatProgressComponent Item { property real value: ratio property real lineWidth: 2.5 property color trackColor: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.40) property color fillColor: Theme.primary property color playheadColor: Theme.primary readonly property real midY: height / 2 Rectangle { width: parent.width height: parent.lineWidth anchors.verticalCenter: parent.verticalCenter color: parent.trackColor radius: height / 2 } Rectangle { width: Math.max(0, Math.min(parent.width, parent.width * parent.value)) height: parent.lineWidth anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter color: parent.fillColor radius: height / 2 Behavior on width { NumberAnimation { duration: 80 } } } Rectangle { id: playhead width: 2.5 height: Math.max(parent.lineWidth + 8, 12) radius: width / 2 color: parent.playheadColor x: Math.max(0, Math.min(parent.width, parent.width * parent.value)) - width / 2 y: parent.midY - height / 2 z: 3 Behavior on x { NumberAnimation { duration: 80 } } } MouseArea { id: progressSliderArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor enabled: activePlayer ? (activePlayer.canSeek && activePlayer.length > 0) : false property real pendingSeekPosition: -1 Timer { id: seekDebounceTimer interval: 150 onTriggered: { if (progressSliderArea.pendingSeekPosition >= 0 && activePlayer?.canSeek && activePlayer?.length > 0) { const clamped = Math.min(progressSliderArea.pendingSeekPosition, activePlayer.length * 0.99) activePlayer.position = clamped progressSliderArea.pendingSeekPosition = -1 } } } onPressed: (mouse) => { root.isSeeking = true if (activePlayer?.length > 0 && activePlayer?.canSeek) { const r = Math.max(0, Math.min(1, mouse.x / parent.width)) pendingSeekPosition = r * activePlayer.length displayPosition = pendingSeekPosition seekDebounceTimer.restart() } } onReleased: { root.isSeeking = false seekDebounceTimer.stop() if (pendingSeekPosition >= 0 && activePlayer?.canSeek && activePlayer?.length > 0) { const clamped = Math.min(pendingSeekPosition, activePlayer.length * 0.99) activePlayer.position = clamped pendingSeekPosition = -1 } displayPosition = Qt.binding(() => currentPosition) } onPositionChanged: (mouse) => { if (pressed && root.isSeeking && activePlayer?.length > 0 && activePlayer?.canSeek) { const r = Math.max(0, Math.min(1, mouse.x / parent.width)) pendingSeekPosition = r * activePlayer.length displayPosition = pendingSeekPosition seekDebounceTimer.restart() } } onClicked: (mouse) => { if (activePlayer?.length > 0 && activePlayer?.canSeek) { const r = Math.max(0, Math.min(1, mouse.x / parent.width)) activePlayer.position = r * activePlayer.length } } } } } } // Media Controls Row { anchors.horizontalCenter: parent.horizontalCenter spacing: Theme.spacingXL height: 64 Rectangle { width: 32 height: 32 radius: 16 color: prevBtnArea.containsMouse ? Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.12) : "transparent" anchors.verticalCenter: parent.verticalCenter DankIcon { anchors.centerIn: parent name: "skip_previous" size: 18 color: Theme.surfaceText } MouseArea { id: prevBtnArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: { if (!activePlayer) { return } if (activePlayer.position > 8 && activePlayer.canSeek) { activePlayer.position = 0 } else { activePlayer.previous() } } } } Rectangle { width: 44 height: 44 radius: 22 color: Theme.primary anchors.verticalCenter: parent.verticalCenter DankIcon { anchors.centerIn: parent name: activePlayer && activePlayer.playbackState === MprisPlaybackState.Playing ? "pause" : "play_arrow" size: 24 color: Theme.background weight: 500 } MouseArea { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: activePlayer && activePlayer.togglePlaying() } layer.enabled: true layer.effect: MultiEffect { shadowEnabled: true shadowHorizontalOffset: 0 shadowVerticalOffset: 6 shadowBlur: 1.0 shadowColor: Qt.rgba(0, 0, 0, 0.3) shadowOpacity: 0.3 } } Rectangle { width: 32 height: 32 radius: 16 color: nextBtnArea.containsMouse ? Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.12) : "transparent" anchors.verticalCenter: parent.verticalCenter DankIcon { anchors.centerIn: parent name: "skip_next" size: 18 color: Theme.surfaceText } MouseArea { id: nextBtnArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: activePlayer && activePlayer.next() } } } } } // Right Column: Audio Controls (40%) Column { x: parent.width * 0.6 + Theme.spacingM y: 0 width: parent.width * 0.4 - Theme.spacingM height: parent.height spacing: Theme.spacingS anchors.verticalCenter: parent.verticalCenter // Volume Control Row { x: -Theme.spacingS width: parent.width + Theme.spacingS height: 40 spacing: Theme.spacingXS Rectangle { width: Theme.iconSize + Theme.spacingS * 2 height: Theme.iconSize + Theme.spacingS * 2 anchors.verticalCenter: parent.verticalCenter radius: (Theme.iconSize + Theme.spacingS * 2) / 2 color: iconArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent" Behavior on color { ColorAnimation { duration: Theme.shortDuration } } MouseArea { id: iconArea anchors.fill: parent visible: defaultSink !== null hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: { if (defaultSink) { defaultSink.audio.muted = !defaultSink.audio.muted } } } DankIcon { anchors.centerIn: parent name: { if (!defaultSink) return "volume_off" let volume = defaultSink.audio.volume let muted = defaultSink.audio.muted if (muted || volume === 0.0) return "volume_off" if (volume <= 0.33) return "volume_down" if (volume <= 0.66) return "volume_up" return "volume_up" } size: Theme.iconSize color: defaultSink && !defaultSink.audio.muted && defaultSink.audio.volume > 0 ? Theme.primary : Theme.surfaceText } } DankSlider { anchors.verticalCenter: parent.verticalCenter width: parent.width - (Theme.iconSize + Theme.spacingS * 2) - Theme.spacingXS enabled: defaultSink !== null minimum: 0 maximum: 100 value: defaultSink ? Math.round(defaultSink.audio.volume * 100) : 0 onSliderValueChanged: function(newValue) { if (defaultSink) { defaultSink.audio.volume = newValue / 100.0 if (newValue > 0 && defaultSink.audio.muted) { defaultSink.audio.muted = false } } } } } // Audio Devices DankFlickable { width: parent.width height: parent.height - y contentHeight: deviceColumn.height clip: true Column { id: deviceColumn width: parent.width spacing: Theme.spacingXS Repeater { model: Pipewire.nodes.values.filter(node => { return node.audio && node.isSink && !node.isStream }) delegate: Rectangle { required property var modelData required property int index width: parent.width height: 42 radius: Theme.cornerRadius color: deviceMouseArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) : Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, index % 2 === 0 ? 0.3 : 0.2) border.color: modelData === AudioService.sink ? Theme.primary : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12) border.width: modelData === AudioService.sink ? 2 : 1 Row { anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter anchors.leftMargin: Theme.spacingS spacing: Theme.spacingS DankIcon { name: { if (modelData.name.includes("bluez")) return "headset" else if (modelData.name.includes("hdmi")) return "tv" else if (modelData.name.includes("usb")) return "headset" else return "speaker" } size: Theme.iconSize - 4 color: modelData === AudioService.sink ? Theme.primary : Theme.surfaceText anchors.verticalCenter: parent.verticalCenter } Column { anchors.verticalCenter: parent.verticalCenter width: parent.parent.width - parent.parent.anchors.leftMargin - parent.spacing - Theme.iconSize - Theme.spacingS * 2 StyledText { text: AudioService.displayName(modelData) font.pixelSize: Theme.fontSizeSmall color: Theme.surfaceText font.weight: modelData === AudioService.sink ? Font.Medium : Font.Normal elide: Text.ElideRight width: parent.width wrapMode: Text.NoWrap } StyledText { text: modelData === AudioService.sink ? "Active" : "Available" font.pixelSize: Theme.fontSizeSmall color: Theme.surfaceVariantText elide: Text.ElideRight width: parent.width wrapMode: Text.NoWrap } } } MouseArea { id: deviceMouseArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: { if (modelData) { Pipewire.preferredDefaultAudioSink = modelData } } } Behavior on color { ColorAnimation { duration: Theme.shortDuration } } Behavior on border.color { ColorAnimation { duration: Theme.shortDuration } } } } } } } } MouseArea { id: progressMouseArea anchors.fill: parent enabled: false visible: false property bool isSeeking: false } }