From 2c451b9779e502a67322cac10945553b71731d58 Mon Sep 17 00:00:00 2001 From: purian23 Date: Sat, 13 Sep 2025 18:12:00 -0400 Subject: [PATCH] Media Hub redesign --- Modules/DankDash/MediaPlayerTab.qml | 1666 +++++++++++++++++++-------- 1 file changed, 1200 insertions(+), 466 deletions(-) diff --git a/Modules/DankDash/MediaPlayerTab.qml b/Modules/DankDash/MediaPlayerTab.qml index f0929705..6f77b313 100644 --- a/Modules/DankDash/MediaPlayerTab.qml +++ b/Modules/DankDash/MediaPlayerTab.qml @@ -2,8 +2,10 @@ import QtQuick import QtQuick.Controls import QtQuick.Effects import QtQuick.Shapes +import QtQuick.Layouts import Quickshell.Services.Mpris import Quickshell.Services.Pipewire +import Quickshell import qs.Common import qs.Services import qs.Widgets @@ -12,40 +14,46 @@ Item { id: root property MprisPlayer activePlayer: MprisController.activePlayer + property var allPlayers: MprisController.availablePlayers + + onActivePlayerChanged: { + if (activePlayer) { + lastValidTitle = "" + lastValidArtist = "" + lastValidAlbum = "" + lastValidArtUrl = "" + } + } + + onAllPlayersChanged: { + if (allPlayers) { + for (let i = 0; i < allPlayers.length; i++) { + } + } + } + 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 + property color extractedDominantColor: Theme.surface + property color extractedAccentColor: Theme.primary + property bool colorsExtracted: false + readonly property real ratio: { if (!activePlayer || activePlayer.length <= 0) { return 0 } - const calculatedRatio = displayPosition / activePlayer.length + const calculatedRatio = (activePlayer.position || 0) / 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 @@ -58,11 +66,195 @@ Item { lastValidArtist = "" lastValidAlbum = "" lastValidArtUrl = "" - currentPosition = 0 + extractedDominantColor = Theme.surface + extractedAccentColor = Theme.primary + colorsExtracted = false stop() } } + + ColorQuantizer { + id: colorQuantizer + source: { + const artUrl = (root.activePlayer && root.activePlayer.trackArtUrl) || root.lastValidArtUrl || "" + if (!artUrl) return "" + + const urlString = String(artUrl) + if (!urlString || typeof urlString !== 'string') return "" + + if (urlString.includes("scdn.co")) { + return urlString.replace(/640x640|300x300|64x64/, "640x640").replace("http://", "https://") + } else if (urlString.startsWith("file://")) { + return urlString + } else if (urlString.includes("googleusercontent.com") || urlString.includes("ytimg.com") || urlString.includes("youtube.com")) { + if (urlString.includes("=")) { + return urlString.replace(/=w\d+-h\d+/, "=w640-h640").replace(/=s\d+/, "=s640") + } else {e + return urlString + "=w640-h640" + } + } else if (urlString.includes("ggpht.com")) { + return urlString.includes("=") ? urlString.replace(/=s\d+/, "=s640") : urlString + "=s640" + } else if (urlString.includes("discordapp.com") || urlString.includes("discord.com")) { + return urlString + } else if (urlString.includes("soundcloud.com")) { + return urlString.replace("large.jpg", "t500x500.jpg") + } else if (urlString.includes("bandcamp.com")) { + return urlString.replace("_10.jpg", "_2.jpg").replace("_16.jpg", "_2.jpg") + } else if (urlString.includes("last.fm") || urlString.includes("lastfm.")) { + return urlString.replace("/174s/", "/300x300/").replace("/64s/", "/300x300/") + } else if (urlString.includes("tidal.com")) { + return urlString.replace(/\/\d+x\d+\//, "/640x640/") + } + + return urlString + } + depth: 4 + rescaleSize: 128 + + onSourceChanged: { + if (source) { + root.colorsExtracted = false + colorFallbackTimer.restart() + const playerName = root.activePlayer ? root.activePlayer.identity : "Unknown" + const sourceString = String(source) + let sourceDomain = "local" + if (sourceString.startsWith("file://")) { + if (sourceString.includes(".com.google.Chrome")) { + sourceDomain = "chrome-temp" + } else if (sourceString.includes("/tmp/")) { + sourceDomain = "temp-file" + } else { + sourceDomain = "local-file" + } + } else if (sourceString.includes("//")) { + const parts = sourceString.split("/") + sourceDomain = parts.length > 2 ? parts[2] : "unknown-url" + } + + } + } + + onColorsChanged: { + if (colors.length > 0) { + colorFallbackTimer.stop() + root.extractedDominantColor = colors[0] + root.extractedAccentColor = colors.length > 2 ? colors[2] : (colors.length > 1 ? colors[1] : colors[0]) + root.colorsExtracted = true + } + } + + } + + Timer { + id: colorFallbackTimer + interval: { + const source = String(colorQuantizer.source) + if (source.includes(".com.google.Chrome") && source.includes("/tmp/")) { + return 500 + } else if (source.includes("scdn.co") || source.includes("spotify.com")) { + return 2500 + } + return 3000 + } + onTriggered: { + if (!root.colorsExtracted) { + const playerName = root.activePlayer ? root.activePlayer.identity : "Unknown" + const source = String(colorQuantizer.source) + + if (source.includes("scdn.co") || source.includes("spotify.com")) { + console.info(`Spotify CORS block expected for ${playerName} - using theme colors`) + } else if (source.includes(".com.google.Chrome") && source.includes("/tmp/")) { + console.info(`Chrome temporary file inaccessible for ${playerName} - using theme colors`) + } else { + console.warn(`ColorQuantizer timeout for ${playerName} image:`, source) + console.warn("Using fallback colors - network or CORS issue likely") + } + + root.extractedDominantColor = Theme.primary + root.extractedAccentColor = Theme.secondary + root.colorsExtracted = true + } + } + } + + Rectangle { + id: dynamicBackground + anchors.fill: parent + radius: Theme.cornerRadius + visible: true + opacity: colorsExtracted ? 1.0 : 0.3 + + gradient: Gradient { + GradientStop { + position: 0.0 + color: colorsExtracted ? + Qt.rgba(extractedDominantColor.r, extractedDominantColor.g, extractedDominantColor.b, 0.4) : + Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.2) + } + GradientStop { + position: 0.3 + color: colorsExtracted ? + Qt.rgba(extractedAccentColor.r, extractedAccentColor.g, extractedAccentColor.b, 0.3) : + Qt.rgba(Theme.secondary.r, Theme.secondary.g, Theme.secondary.b, 0.15) + } + GradientStop { + position: 0.7 + color: colorsExtracted ? + Qt.rgba(extractedDominantColor.r, extractedDominantColor.g, extractedDominantColor.b, 0.2) : + Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.1) + } + GradientStop { + position: 1.0 + color: Qt.rgba(Theme.surface.r, Theme.surface.g, Theme.surface.b, 0.85) + } + } + + Behavior on visible { + NumberAnimation { duration: Theme.mediumDuration } + } + } + + Rectangle { + id: dynamicOverlay + anchors.fill: parent + radius: Theme.cornerRadius + visible: colorsExtracted && ((activePlayer && activePlayer.trackTitle !== "") || lastValidTitle !== "") + color: "transparent" + + Rectangle { + width: parent.width * 0.8 + height: parent.height * 0.4 + x: parent.width * 0.1 + y: parent.height * 0.1 + radius: Theme.cornerRadius * 2 + opacity: 0.15 + + gradient: Gradient { + GradientStop { + position: 0.0 + color: Qt.rgba(extractedAccentColor.r, extractedAccentColor.g, extractedAccentColor.b, 0.6) + } + GradientStop { + position: 1.0 + color: "transparent" + } + } + + layer.enabled: true + layer.effect: MultiEffect { + blurEnabled: true + blur: 1.0 + blurMax: 64 + blurMultiplier: 1.0 + } + } + + Behavior on visible { + NumberAnimation { duration: Theme.mediumDuration } + } + } + Column { anchors.centerIn: parent spacing: Theme.spacingM @@ -85,21 +277,323 @@ Item { Item { anchors.fill: parent + clip: false 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 + Rectangle { + id: audioDevicesDropdown + width: 280 + height: audioDevicesButton.devicesExpanded ? Math.max(200, Math.min(280, audioDevicesDropdown.availableDevices.length * 50 + 100)) : 0 + x: parent.width + Theme.spacingS + y: 180 + visible: audioDevicesButton.devicesExpanded + clip: true + z: 150 + + property var availableDevices: Pipewire.nodes.values.filter(node => { + return node.audio && node.isSink && !node.isStream + }) + + + + color: Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, 0.98) + border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.6) + border.width: 2 + radius: Theme.cornerRadius * 2 + + opacity: audioDevicesButton.devicesExpanded ? 1 : 0 + + // Drop shadow effect + layer.enabled: true + layer.effect: MultiEffect { + shadowEnabled: true + shadowHorizontalOffset: 0 + shadowVerticalOffset: 8 + shadowBlur: 1.0 + shadowColor: Qt.rgba(0, 0, 0, 0.4) + shadowOpacity: 0.7 + } + + Behavior on height { + NumberAnimation { duration: Theme.mediumDuration } + } + + Behavior on opacity { + NumberAnimation { duration: Theme.mediumDuration } + } + + Column { + anchors.fill: parent + anchors.margins: Theme.spacingM + + StyledText { + text: "Audio Output Devices (" + audioDevicesDropdown.availableDevices.length + ")" + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: Theme.surfaceText + width: parent.width + horizontalAlignment: Text.AlignHCenter + bottomPadding: Theme.spacingM + } + + DankFlickable { + width: parent.width + height: parent.height - 40 + contentHeight: deviceColumn.height + clip: true + + Column { + id: deviceColumn + width: parent.width + spacing: Theme.spacingS + + Repeater { + model: audioDevicesDropdown.availableDevices + delegate: Rectangle { + required property var modelData + required property int index + + width: parent.width + height: 48 + radius: Theme.cornerRadius + color: deviceMouseAreaLeft.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : 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.2) + border.width: modelData === AudioService.sink ? 2 : 1 + + Row { + anchors.left: parent.left + anchors.leftMargin: Theme.spacingM + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingM + width: parent.width - Theme.spacingM * 2 + + DankIcon { + name: { + if (modelData.name.includes("bluez") || modelData.name.includes("bluetooth")) + return "headset" + else if (modelData.name.includes("hdmi")) + return "tv" + else if (modelData.name.includes("usb")) + return "headset" + else + return "speaker" + } + size: 20 + color: modelData === AudioService.sink ? Theme.primary : Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } + + Column { + anchors.verticalCenter: parent.verticalCenter + width: parent.width - 20 - Theme.spacingM * 2 + + StyledText { + text: AudioService.displayName(modelData) + font.pixelSize: Theme.fontSizeMedium + 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: deviceMouseAreaLeft + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + if (modelData) { + Pipewire.preferredDefaultAudioSink = modelData + console.log("Current default sink after change:", AudioService.sink ? AudioService.sink.name : "null") + } + audioDevicesButton.devicesExpanded = false + } + } + + Behavior on color { + ColorAnimation { duration: Theme.shortDuration } + } + + Behavior on border.color { + ColorAnimation { duration: Theme.shortDuration } + } + } + } + } + } + } + } + + Rectangle { + id: playerSelectorDropdown + width: 240 + height: playerSelectorButton.playersExpanded ? Math.max(180, Math.min(240, (root.allPlayers?.length || 0) * 50 + 80)) : 0 + x: parent.width + Theme.spacingS + y: 130 + visible: playerSelectorButton.playersExpanded + clip: true + z: 150 + + color: Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, 0.98) + border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.6) + border.width: 2 + radius: Theme.cornerRadius * 2 + + opacity: playerSelectorButton.playersExpanded ? 1 : 0 + + layer.enabled: true + layer.effect: MultiEffect { + shadowEnabled: true + shadowHorizontalOffset: 0 + shadowVerticalOffset: 8 + shadowBlur: 1.0 + shadowColor: Qt.rgba(0, 0, 0, 0.4) + shadowOpacity: 0.7 + } + + Behavior on height { + NumberAnimation { duration: Theme.mediumDuration } + } + + Behavior on opacity { + NumberAnimation { duration: Theme.mediumDuration } + } + + Column { + anchors.fill: parent + anchors.margins: Theme.spacingM + + StyledText { + text: "Media Players (" + (allPlayers?.length || 0) + ")" + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: Theme.surfaceText + width: parent.width + horizontalAlignment: Text.AlignHCenter + bottomPadding: Theme.spacingM + } + + DankFlickable { + width: parent.width + height: parent.height - 40 + contentHeight: playerColumn.height + clip: true + + Column { + id: playerColumn + width: parent.width + spacing: Theme.spacingS + + Repeater { + model: allPlayers || [] + delegate: Rectangle { + required property var modelData + required property int index + + width: parent.width + height: 48 + radius: Theme.cornerRadius + color: playerMouseArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, index % 2 === 0 ? 0.3 : 0.2) + border.color: modelData === activePlayer ? Theme.primary : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2) + border.width: modelData === activePlayer ? 2 : 1 + + Row { + anchors.left: parent.left + anchors.leftMargin: Theme.spacingM + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingM + width: parent.width - Theme.spacingM * 2 + + DankIcon { + name: "music_note" + size: 20 + color: modelData === activePlayer ? Theme.primary : Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } + + Column { + anchors.verticalCenter: parent.verticalCenter + width: parent.width - 20 - Theme.spacingM * 2 + + StyledText { + text: modelData && modelData.identity ? modelData.identity : "Unknown Player" + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceText + font.weight: modelData === activePlayer ? Font.Medium : Font.Normal + elide: Text.ElideRight + width: parent.width + wrapMode: Text.NoWrap + } + + StyledText { + text: modelData === activePlayer ? "Active" : "Available" + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + elide: Text.ElideRight + width: parent.width + wrapMode: Text.NoWrap + } + } + } + + MouseArea { + id: playerMouseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + if (modelData && modelData.identity) { + console.log("Switching to player:", modelData.identity) + + // Pause the currently active player before switching + if (activePlayer && activePlayer !== modelData && activePlayer.canPause) { + console.log("Pausing current player:", activePlayer.identity) + activePlayer.pause() + } + + MprisController.activePlayer = modelData + } + playerSelectorButton.playersExpanded = false + } + } + + Behavior on color { + ColorAnimation { duration: Theme.shortDuration } + } + + Behavior on border.color { + ColorAnimation { duration: Theme.shortDuration } + } + } + } + } + } + } + } + + // Center Column: Main Media Content + ColumnLayout { + x: 72 // 48 + 24 spacing + y: 20 // Adjusted top position for better centering + width: 484 // 700 - 72 (left) - 144 (right for floating buttons) = 484 + height: 370 // Fixed height to fit within container (410 - 40 margin) + spacing: Theme.spacingXS // More compact spacing - // Album Art Section Item { width: parent.width - height: parent.height * 0.55 - anchors.horizontalCenter: parent.horizontalCenter + height: 200 Item { width: Math.min(parent.width * 0.8, parent.height * 0.9) @@ -107,7 +601,7 @@ Item { anchors.centerIn: parent Loader { - active: activePlayer?.playbackState === MprisPlaybackState.Playing + active: activePlayer && activePlayer.playbackState === MprisPlaybackState.Playing sourceComponent: Component { Ref { service: CavaService @@ -120,7 +614,7 @@ Item { width: parent.width * 1.1 height: parent.height * 1.1 anchors.centerIn: parent - visible: activePlayer?.playbackState === MprisPlaybackState.Playing + visible: activePlayer && activePlayer.playbackState === MprisPlaybackState.Playing asynchronous: false antialiasing: true preferredRendererType: Shape.CurveRenderer @@ -132,7 +626,7 @@ Item { readonly property real centerX: width / 2 readonly property real centerY: height / 2 - readonly property real baseRadius: Math.min(width, height) * 0.35 + readonly property real baseRadius: Math.min(width, height) * 0.41 readonly property int segments: 24 property var audioLevels: { @@ -233,7 +727,7 @@ Item { } Rectangle { - width: parent.width * 0.75 + width: parent.width * 0.88 height: width radius: width / 2 color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.3) @@ -308,24 +802,25 @@ Item { } // Song Info and Controls Section - Column { + Item { width: parent.width - height: parent.height * 0.45 - spacing: Theme.spacingS - anchors.horizontalCenter: parent.horizontalCenter + Layout.fillHeight: true // Song Info Column { + id: songInfo width: parent.width - spacing: Theme.spacingS + spacing: Theme.spacingXS + anchors.top: parent.top anchors.horizontalCenter: parent.horizontalCenter StyledText { - text: (activePlayer && activePlayer.trackTitle) || lastValidTitle || "Unknown Track" - onTextChanged: { + text: { if (activePlayer && activePlayer.trackTitle) { lastValidTitle = activePlayer.trackTitle + return activePlayer.trackTitle } + return lastValidTitle || "Unknown Track" } font.pixelSize: Theme.fontSizeLarge font.weight: Font.Bold @@ -333,473 +828,289 @@ Item { width: parent.width horizontalAlignment: Text.AlignHCenter elide: Text.ElideRight - wrapMode: Text.NoWrap - maximumLineCount: 1 + wrapMode: Text.WordWrap + maximumLineCount: 2 } StyledText { - text: (activePlayer && activePlayer.trackArtist) || lastValidArtist || "Unknown Artist" - onTextChanged: { + text: { if (activePlayer && activePlayer.trackArtist) { lastValidArtist = activePlayer.trackArtist + return activePlayer.trackArtist } + return lastValidArtist || "Unknown Artist" } 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 + wrapMode: Text.WordWrap maximumLineCount: 1 } StyledText { - text: (activePlayer && activePlayer.trackAlbum) || lastValidAlbum || "" - onTextChanged: { + text: { if (activePlayer && activePlayer.trackAlbum) { lastValidAlbum = activePlayer.trackAlbum + return activePlayer.trackAlbum } + return lastValidAlbum || "" } 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 + wrapMode: Text.WordWrap 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 - + // Controls Group Column { - id: deviceColumn + id: controlsGroup width: parent.width spacing: Theme.spacingXS + anchors.bottom: parent.bottom + anchors.bottomMargin: 0 - Repeater { - model: Pipewire.nodes.values.filter(node => { - return node.audio && node.isSink && !node.isStream - }) + Item { + width: parent.width * 0.8 + height: 20 + anchors.horizontalCenter: parent.horizontalCenter - delegate: Rectangle { - required property var modelData - required property int index + Loader { + anchors.fill: parent + visible: activePlayer && activePlayer.length > 0 + sourceComponent: SettingsData.waveProgressEnabled ? seekBarWaveComponent : seekBarFlatComponent - 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 + Component { + id: seekBarWaveComponent - Row { - anchors.left: parent.left - anchors.verticalCenter: parent.verticalCenter - anchors.leftMargin: Theme.spacingS - spacing: Theme.spacingS + M3WaveProgress { + value: ratio + isPlaying: activePlayer && activePlayer.playbackState === MprisPlaybackState.Playing - 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 - } + MouseArea { + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + enabled: activePlayer ? (activePlayer.canSeek && activePlayer.length > 0) : false - Column { - anchors.verticalCenter: parent.verticalCenter - width: parent.parent.width - parent.parent.anchors.leftMargin - parent.spacing - Theme.iconSize - Theme.spacingS * 2 + property real pendingSeekPosition: -1 - 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 - } + Timer { + id: mainSeekDebounceTimer + interval: 150 + onTriggered: { + if (parent.pendingSeekPosition >= 0 && activePlayer && activePlayer.canSeek && activePlayer && activePlayer.length > 0) { + const clamped = Math.min(parent.pendingSeekPosition, activePlayer.length * 0.99) + activePlayer.position = clamped + parent.pendingSeekPosition = -1 + } + } + } - StyledText { - text: modelData === AudioService.sink ? "Active" : "Available" - font.pixelSize: Theme.fontSizeSmall - color: Theme.surfaceVariantText - elide: Text.ElideRight - width: parent.width - wrapMode: Text.NoWrap + onPressed: (mouse) => { + root.isSeeking = true + if (activePlayer && activePlayer.length > 0 && activePlayer && activePlayer.canSeek) { + const r = Math.max(0, Math.min(1, mouse.x / parent.width)) + pendingSeekPosition = r * activePlayer.length + mainSeekDebounceTimer.restart() + } + } + onReleased: { + root.isSeeking = false + mainSeekDebounceTimer.stop() + if (pendingSeekPosition >= 0 && activePlayer && activePlayer.canSeek && activePlayer && activePlayer.length > 0) { + const clamped = Math.min(pendingSeekPosition, activePlayer.length * 0.99) + activePlayer.position = clamped + pendingSeekPosition = -1 + } + } + onPositionChanged: (mouse) => { + if (pressed && root.isSeeking && activePlayer && activePlayer.length > 0 && activePlayer && activePlayer.canSeek) { + const r = Math.max(0, Math.min(1, mouse.x / parent.width)) + pendingSeekPosition = r * activePlayer.length + mainSeekDebounceTimer.restart() + } + } + onClicked: (mouse) => { + if (activePlayer && activePlayer.length > 0 && activePlayer && activePlayer.canSeek) { + const r = Math.max(0, Math.min(1, mouse.x / parent.width)) + activePlayer.position = r * activePlayer.length + } + } } } } + Component { + id: seekBarFlatComponent + + Item { + property real value: ratio + property real lineWidth: 3 + 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: 3 + height: Math.max(parent.lineWidth + 8, 14) + 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 { + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + enabled: activePlayer ? (activePlayer.canSeek && activePlayer.length > 0) : false + + property real pendingSeekPosition: -1 + + Timer { + id: mainFlatSeekDebounceTimer + interval: 150 + onTriggered: { + if (parent.pendingSeekPosition >= 0 && activePlayer && activePlayer.canSeek && activePlayer && activePlayer.length > 0) { + const clamped = Math.min(parent.pendingSeekPosition, activePlayer.length * 0.99) + activePlayer.position = clamped + parent.pendingSeekPosition = -1 + } + } + } + + onPressed: (mouse) => { + root.isSeeking = true + if (activePlayer && activePlayer.length > 0 && activePlayer && activePlayer.canSeek) { + const r = Math.max(0, Math.min(1, mouse.x / parent.width)) + pendingSeekPosition = r * activePlayer.length + mainFlatSeekDebounceTimer.restart() + } + } + onReleased: { + root.isSeeking = false + mainFlatSeekDebounceTimer.stop() + if (pendingSeekPosition >= 0 && activePlayer && activePlayer.canSeek && activePlayer && activePlayer.length > 0) { + const clamped = Math.min(pendingSeekPosition, activePlayer.length * 0.99) + activePlayer.position = clamped + pendingSeekPosition = -1 + } + } + onPositionChanged: (mouse) => { + if (pressed && root.isSeeking && activePlayer && activePlayer.length > 0 && activePlayer && activePlayer.canSeek) { + const r = Math.max(0, Math.min(1, mouse.x / parent.width)) + pendingSeekPosition = r * activePlayer.length + mainFlatSeekDebounceTimer.restart() + } + } + onClicked: (mouse) => { + if (activePlayer && activePlayer.length > 0 && activePlayer && activePlayer.canSeek) { + const r = Math.max(0, Math.min(1, mouse.x / parent.width)) + activePlayer.position = r * activePlayer.length + } + } + } + } + } + } + } + + Item { + width: parent.width * 0.8 + height: 20 + anchors.horizontalCenter: parent.horizontalCenter + + StyledText { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + text: { + if (!activePlayer) return "0:00" + const pos = Math.max(0, activePlayer.position || 0) + const minutes = Math.floor(pos / 60) + const seconds = Math.floor(pos % 60) + const timeStr = minutes + ":" + (seconds < 10 ? "0" : "") + seconds + return timeStr + } + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + } + + StyledText { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: { + if (!activePlayer || !activePlayer.length) return "0:00" + const dur = Math.max(0, activePlayer.length || 0) // Length is already in seconds + const minutes = Math.floor(dur / 60) + const seconds = Math.floor(dur % 60) + return minutes + ":" + (seconds < 10 ? "0" : "") + seconds + } + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + } + } + + Item { + width: parent.width + height: 50 + + Row { + anchors.centerIn: parent + spacing: Theme.spacingM + height: parent.height + + Rectangle { + width: 40 + height: 40 + radius: 20 + color: shuffleArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent" + anchors.verticalCenter: parent.verticalCenter + visible: activePlayer && activePlayer.shuffleSupported + + DankIcon { + anchors.centerIn: parent + name: "shuffle" + size: 20 + color: activePlayer && activePlayer.shuffle ? Theme.primary : Theme.surfaceText + } + MouseArea { - id: deviceMouseArea + id: shuffleArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: { - if (modelData) { - Pipewire.preferredDefaultAudioSink = modelData + if (activePlayer && activePlayer.canControl && activePlayer.shuffleSupported) { + activePlayer.shuffle = !activePlayer.shuffle } } } @@ -807,22 +1118,445 @@ Item { Behavior on color { ColorAnimation { duration: Theme.shortDuration } } + } - Behavior on border.color { + Rectangle { + width: 40 + height: 40 + radius: 20 + 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: 24 + 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: 50 + height: 50 + radius: 25 + color: Theme.primary + anchors.verticalCenter: parent.verticalCenter + + DankIcon { + anchors.centerIn: parent + name: activePlayer && activePlayer.playbackState === MprisPlaybackState.Playing ? "pause" : "play_arrow" + size: 28 + 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: 40 + height: 40 + radius: 20 + 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: 24 + color: Theme.surfaceText + } + + MouseArea { + id: nextBtnArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: activePlayer && activePlayer.next() + } + } + + Rectangle { + width: 40 + height: 40 + radius: 20 + color: repeatArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent" + anchors.verticalCenter: parent.verticalCenter + visible: activePlayer && activePlayer.loopSupported + + DankIcon { + anchors.centerIn: parent + name: { + if (!activePlayer) return "repeat" + switch(activePlayer.loopState) { + case MprisLoopState.Track: return "repeat_one" + case MprisLoopState.Playlist: return "repeat" + default: return "repeat" + } + } + size: 20 + color: activePlayer && activePlayer.loopState !== MprisLoopState.None ? Theme.primary : Theme.surfaceText + } + + MouseArea { + id: repeatArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + if (activePlayer && activePlayer.canControl && activePlayer.loopSupported) { + switch(activePlayer.loopState) { + case MprisLoopState.None: + activePlayer.loopState = MprisLoopState.Playlist + break + case MprisLoopState.Playlist: + activePlayer.loopState = MprisLoopState.Track + break + case MprisLoopState.Track: + activePlayer.loopState = MprisLoopState.None + break + } + } + } + } + + Behavior on color { ColorAnimation { duration: Theme.shortDuration } } } + } + } + } + } + } + + Rectangle { + id: playerSelectorButton + width: 40 + height: 40 + radius: 20 + x: parent.width - 40 - Theme.spacingM + y: 180 // Top button position + color: playerSelectorArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.2) : Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.8) + border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.3) + border.width: 1 + z: 100 + visible: (allPlayers?.length || 0) >= 1 + + property bool playersExpanded: false + + DankIcon { + anchors.centerIn: parent + name: "assistant_device" + size: 18 + color: Theme.surfaceText + } + + MouseArea { + id: playerSelectorArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + parent.playersExpanded = !parent.playersExpanded + } + } + + Behavior on color { + ColorAnimation { duration: Theme.shortDuration } + } + } + + Rectangle { + id: volumeButton + width: 40 + height: 40 + radius: 20 + x: parent.width - 40 - Theme.spacingM + y: 235 + color: volumeButtonArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.2) : Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.8) + border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.3) + border.width: 1 + z: 100 + + property bool volumeExpanded: false + + 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: 18 + color: defaultSink && !defaultSink.audio.muted && defaultSink.audio.volume > 0 ? Theme.primary : Theme.surfaceText + } + + MouseArea { + id: volumeButtonArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + volumeButton.volumeExpanded = !volumeButton.volumeExpanded + } + } + + Behavior on color { + ColorAnimation { duration: Theme.shortDuration } + } + } + + Rectangle { + id: volumeSliderPanel + width: 60 + height: volumeButton.volumeExpanded ? 180 : 0 + radius: Theme.cornerRadius * 2 + x: volumeButton.x - 10 + y: volumeButton.y - height - Theme.spacingS + color: Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, 0.95) + border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.3) + border.width: 1 + visible: volumeButton.volumeExpanded + clip: true + z: 110 + + opacity: volumeButton.volumeExpanded ? 1 : 0 + + Behavior on height { + NumberAnimation { duration: Theme.mediumDuration; easing.type: Easing.OutCubic } + } + + Behavior on opacity { + NumberAnimation { duration: Theme.mediumDuration } + } + + Item { + anchors.fill: parent + anchors.margins: Theme.spacingS + + Item { + width: parent.width * 0.6 + height: parent.height - Theme.spacingXL * 2 + anchors.top: parent.top + anchors.topMargin: Theme.spacingS + anchors.horizontalCenter: parent.horizontalCenter + + property bool dragging: false + property bool containsMouse: volumeSliderArea.containsMouse + + Rectangle { + width: parent.width + height: parent.height + anchors.centerIn: parent + color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.5) + radius: width / 2 + } + + Rectangle { + width: parent.width + height: defaultSink ? (defaultSink.audio.volume * parent.height) : 0 + anchors.bottom: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter + color: Theme.primary + radius: width / 2 + + Behavior on height { + enabled: !parent.dragging + NumberAnimation { duration: 150 } + } + } + + MouseArea { + id: volumeSliderArea + anchors.fill: parent + anchors.margins: -12 + enabled: defaultSink !== null + hoverEnabled: true + preventStealing: true + + onPressed: function(mouse) { + parent.dragging = true + updateVolume(mouse) + } + + onReleased: { + parent.dragging = false + } + + onPositionChanged: function(mouse) { + if (pressed) { + updateVolume(mouse) + } + } + + onClicked: function(mouse) { + updateVolume(mouse) + } + + onWheel: function(wheel) { + if (defaultSink) { + const delta = wheel.angleDelta.y / 120 // Standard wheel step + const increment = delta * 0.05 // 5% per scroll step + const newVolume = Math.max(0, Math.min(1, defaultSink.audio.volume + increment)) + defaultSink.audio.volume = newVolume + if (newVolume > 0 && defaultSink.audio.muted) { + defaultSink.audio.muted = false + } + } + } + + function updateVolume(mouse) { + if (defaultSink) { + const ratio = 1.0 - (mouse.y / height) + const volume = Math.max(0, Math.min(1, ratio)) + defaultSink.audio.volume = volume + if (volume > 0 && defaultSink.audio.muted) { + defaultSink.audio.muted = false + } + } + } + } + } + + StyledText { + anchors.bottom: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottomMargin: Theme.spacingL + text: defaultSink ? Math.round(defaultSink.audio.volume * 100) + "%" : "0%" + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceText + font.weight: Font.Medium + } + } + } + + Rectangle { + id: audioDevicesButton + width: 40 + height: 40 + radius: 20 + x: parent.width - 40 - Theme.spacingM + y: 290 + color: audioDevicesArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.2) : Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.8) + border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.3) + border.width: 1 + z: 100 + + property bool devicesExpanded: false + + DankIcon { + anchors.centerIn: parent + name: parent.devicesExpanded ? "expand_less" : "speaker" + size: 18 + color: Theme.surfaceText + } + + MouseArea { + id: audioDevicesArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + parent.devicesExpanded = !parent.devicesExpanded + } + } + + Behavior on color { + ColorAnimation { duration: Theme.shortDuration } + } + } + + MouseArea { + anchors.fill: parent + enabled: audioDevicesButton.devicesExpanded || volumeButton.volumeExpanded || playerSelectorButton.playersExpanded + z: 50 + onClicked: function(mouse) { + + if (playerSelectorButton.playersExpanded) { + const playerDropdownX = playerSelectorDropdown.x + const playerDropdownY = playerSelectorDropdown.y + const playerDropdownWidth = playerSelectorDropdown.width + const playerDropdownHeight = playerSelectorDropdown.height + + if (mouse.x < playerDropdownX || mouse.x > playerDropdownX + playerDropdownWidth || + mouse.y < playerDropdownY || mouse.y > playerDropdownY + playerDropdownHeight) { + playerSelectorButton.playersExpanded = false + } + } + + if (audioDevicesButton.devicesExpanded) { + const dropdownX = audioDevicesDropdown.x + const dropdownY = audioDevicesDropdown.y + const dropdownWidth = audioDevicesDropdown.width + const dropdownHeight = audioDevicesDropdown.height + + if (mouse.x < dropdownX || mouse.x > dropdownX + dropdownWidth || + mouse.y < dropdownY || mouse.y > dropdownY + dropdownHeight) { + audioDevicesButton.devicesExpanded = false + } + } + + if (volumeButton.volumeExpanded) { + const volumeX = volumeSliderPanel.x + const volumeY = volumeSliderPanel.y + const volumeWidth = volumeSliderPanel.width + const volumeHeight = volumeSliderPanel.height + + const buttonX = volumeButton.x + const buttonY = volumeButton.y + const buttonWidth = volumeButton.width + const buttonHeight = volumeButton.height + + const clickInPanel = mouse.x >= volumeX && mouse.x <= volumeX + volumeWidth && + mouse.y >= volumeY && mouse.y <= volumeY + volumeHeight + const clickInButton = mouse.x >= buttonX && mouse.x <= buttonX + buttonWidth && + mouse.y >= buttonY && mouse.y <= buttonY + buttonHeight + + if (!clickInPanel && !clickInButton) { + volumeButton.volumeExpanded = false } } } } } - - MouseArea { - id: progressMouseArea - anchors.fill: parent - enabled: false - visible: false - property bool isSeeking: false - } } \ No newline at end of file