diff --git a/quickshell/Modules/DankDash/MediaPlayerDashChrome.qml b/quickshell/Modules/DankDash/MediaPlayerDashChrome.qml new file mode 100644 index 000000000..60f72be9a --- /dev/null +++ b/quickshell/Modules/DankDash/MediaPlayerDashChrome.qml @@ -0,0 +1,505 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell.Services.Mpris +import qs.Common +import qs.Services +import qs.Widgets +import "../../Common/Format.js" as Format + +Item { + id: root + + required property var player + + property alias volumeButton: volumeButton + property alias playerSelectorButton: playerSelectorButton + property alias audioDevicesButton: audioDevicesButton + + implicitHeight: playerContent.height + playerContent.anchors.topMargin * 2 + + MediaArtBackdrop { + anchors.fill: parent + activePlayer: root.player.activePlayer + onArtReady: root.player.maybeFinishSwitch() + } + + MediaPlayerEmptyState { + anchors.centerIn: parent + visible: player.showNoPlayerNow + } + + Item { + anchors.fill: parent + clip: false + visible: !player.noneAvailable && (!player.showNoPlayerNow) + ColumnLayout { + id: playerContent + width: 484 + height: 370 + spacing: Theme.spacingXS + anchors.top: parent.top + anchors.topMargin: 20 + anchors.horizontalCenter: parent.horizontalCenter + + Item { + width: parent.width + height: 200 + clip: false + + DankAlbumArt { + width: Math.min(parent.width * 0.8, parent.height * 0.9) + height: width + anchors.centerIn: parent + activePlayer: player.activePlayer + artUrl: TrackArtService.resolvedArtUrl + accentColor: MediaAccentService.accent + showAnimation: SettingsData.audioVisualizerEnabled + } + } + + // Song Info and Controls Section + Item { + width: parent.width + Layout.fillHeight: true + + Column { + id: songInfo + width: parent.width + spacing: Theme.spacingXS + anchors.top: parent.top + anchors.horizontalCenter: parent.horizontalCenter + + StyledText { + text: MprisController.stableTitle || I18n.tr("Unknown Track") + font.pixelSize: Theme.fontSizeLarge + font.weight: Font.Bold + color: Theme.surfaceText + width: parent.width + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + wrapMode: Text.WordWrap + maximumLineCount: 2 + } + + StyledText { + text: MprisController.stableArtist || I18n.tr("Unknown Artist") + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceTextMedium + width: parent.width + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + wrapMode: Text.WordWrap + maximumLineCount: 1 + } + + StyledText { + text: MprisController.stableAlbum + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceTextSecondary + width: parent.width + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + wrapMode: Text.WordWrap + maximumLineCount: 1 + // Reserve the line so late album metadata doesn't shift the seekbar. + height: Math.max(implicitHeight, Theme.fontSizeSmall * 1.4) + } + } + + Item { + id: seekbarContainer + width: parent.width + anchors.top: songInfo.bottom + anchors.bottom: playbackControls.top + anchors.horizontalCenter: parent.horizontalCenter + + Column { + width: parent.width + spacing: Theme.spacingXXS + anchors.horizontalCenter: parent.horizontalCenter + anchors.verticalCenter: parent.verticalCenter + anchors.verticalCenterOffset: parent.height * 0.2 + + DankSeekbar { + width: parent.width * 0.8 + height: 20 + anchors.horizontalCenter: parent.horizontalCenter + activePlayer: player.activePlayer + stableLength: MprisController.activePlayerStableLength + accentColor: MediaAccentService.accent + accentTrackColor: MediaAccentService.accentTrack + accentSubtleColor: MediaAccentService.accentSubtle + isSeeking: player.isSeeking + onIsSeekingChanged: player.isSeeking = isSeeking + } + + Item { + width: parent.width * 0.8 + height: 16 + anchors.horizontalCenter: parent.horizontalCenter + + StyledText { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + text: { + if (!player.activePlayer) + return "0:00"; + const rawPos = Math.max(0, player.activePlayer.position || 0); + return Format.formatDuration(player.stableLength ? rawPos % Math.max(1, player.stableLength) : rawPos); + } + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + } + + StyledText { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: { + if (!player.activePlayer || player.stableLength <= 0) + return "--:--"; + return Format.formatDuration(player.stableLength); + } + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + } + } + } + } + + Item { + id: playbackControls + width: parent.width + height: 50 + anchors.bottom: parent.bottom + + Row { + anchors.centerIn: parent + spacing: Theme.spacingM + height: parent.height + + Item { + width: 50 + height: 50 + anchors.verticalCenter: parent.verticalCenter + visible: player.activePlayer && player.activePlayer.shuffleSupported + + Rectangle { + width: 40 + height: 40 + radius: 20 + anchors.centerIn: parent + color: shuffleArea.containsMouse ? player.accentHover : Theme.withAlpha(player.accent, 0) + + DankIcon { + anchors.centerIn: parent + name: "shuffle" + size: 20 + color: player.activePlayer && player.activePlayer.shuffle ? player.accent : Theme.surfaceText + } + + MouseArea { + id: shuffleArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + if (player.activePlayer && player.activePlayer.canControl && player.activePlayer.shuffleSupported) { + player.activePlayer.shuffle = !player.activePlayer.shuffle; + } + } + } + } + } + + Item { + width: 50 + height: 50 + anchors.verticalCenter: parent.verticalCenter + + Rectangle { + width: 40 + height: 40 + radius: 20 + anchors.centerIn: parent + color: prevBtnArea.containsMouse ? Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency) : Theme.withAlpha(Theme.surfaceContainerHigh, 0) + + DankIcon { + anchors.centerIn: parent + name: "skip_previous" + size: 24 + color: Theme.surfaceText + } + + MouseArea { + id: prevBtnArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: MprisController.previousOrRewind() + } + } + } + + Item { + width: 50 + height: 50 + anchors.verticalCenter: parent.verticalCenter + + Rectangle { + width: 50 + height: 50 + radius: 25 + anchors.centerIn: parent + color: player.accent + + DankIcon { + anchors.centerIn: parent + name: player.activePlayer && player.activePlayer.playbackState === MprisPlaybackState.Playing ? "pause" : "play_arrow" + size: 28 + color: player.onAccent + weight: 500 + } + + StateLayer { + id: playPauseArea + disabled: !player.activePlayer || !player.activePlayer.canTogglePlaying + stateColor: player.onAccent + cornerRadius: parent.radius + onClicked: player.activePlayer.togglePlaying() + } + + ElevationShadow { + anchors.fill: parent + z: -1 + level: Theme.elevationLevel1 + fallbackOffset: 1 + targetRadius: parent.radius + targetColor: parent.color + shadowOpacity: Theme.elevationLevel1 && Theme.elevationLevel1.alpha !== undefined ? Theme.elevationLevel1.alpha : 0.2 + shadowEnabled: Theme.elevationEnabled + } + } + } + + Item { + width: 50 + height: 50 + anchors.verticalCenter: parent.verticalCenter + + Rectangle { + width: 40 + height: 40 + radius: 20 + anchors.centerIn: parent + color: nextBtnArea.containsMouse ? Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency) : Theme.withAlpha(Theme.surfaceContainerHigh, 0) + + DankIcon { + anchors.centerIn: parent + name: "skip_next" + size: 24 + color: Theme.surfaceText + } + + MouseArea { + id: nextBtnArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: MprisController.next() + } + } + } + + Item { + width: 50 + height: 50 + anchors.verticalCenter: parent.verticalCenter + visible: player.activePlayer && player.activePlayer.loopSupported + + Rectangle { + width: 40 + height: 40 + radius: 20 + anchors.centerIn: parent + color: repeatArea.containsMouse ? player.accentHover : Theme.withAlpha(player.accent, 0) + + DankIcon { + anchors.centerIn: parent + name: { + if (!player.activePlayer) + return "repeat"; + switch (player.activePlayer.loopState) { + case MprisLoopState.Track: + return "repeat_one"; + case MprisLoopState.Playlist: + return "repeat"; + default: + return "repeat"; + } + } + size: 20 + color: player.activePlayer && player.activePlayer.loopState !== MprisLoopState.None ? player.accent : Theme.surfaceText + } + + MouseArea { + id: repeatArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: player.cycleLoopState() + } + } + } + } + } + } + } + } + + Rectangle { + id: playerSelectorButton + width: 40 + height: 40 + radius: 20 + x: player.isRightEdge ? Theme.spacingM : parent.width - 40 - Theme.spacingM + y: 185 + color: playerSelectorArea.containsMouse || player.playersExpanded ? player.accentPressed : Theme.withAlpha(player.accentPressed, 0) + border.color: Theme.outlineStrong + border.width: 1 + z: 100 + visible: (player.allPlayers?.length || 0) >= 1 + + DankIcon { + anchors.centerIn: parent + name: "assistant_device" + size: 18 + color: Theme.surfaceText + } + + MouseArea { + id: playerSelectorArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + if (player.playersExpanded) { + player.cycleNextPlayer(); + return; + } + player.triggerPlayersDropdown(); + } + onEntered: { + player.dropdownButtonEntered(); + player.triggerPlayersDropdown(); + } + onExited: { + if (player.playersExpanded) + player.dropdownButtonExited(); + } + } + } + + Rectangle { + id: volumeButton + width: 40 + height: 40 + radius: 20 + x: player.isRightEdge ? Theme.spacingM : parent.width - 40 - Theme.spacingM + y: 130 + color: volumeButtonArea.containsMouse && player.volumeAvailable || player.volumeExpanded ? player.accentPressed : Theme.withAlpha(player.accentPressed, 0) + border.color: player.volumeAvailable ? Theme.outlineStrong : Theme.outlineMedium + border.width: 1 + z: 101 + enabled: player.volumeAvailable + + DankIcon { + anchors.centerIn: parent + name: player.getVolumeIcon() + size: 18 + color: player.volumeAvailable && player.currentVolume > 0 ? player.accent : Theme.withAlpha(Theme.surfaceText, player.volumeAvailable ? 1.0 : 0.5) + } + + MouseArea { + id: volumeButtonArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onEntered: { + player.dropdownButtonEntered(); + player.triggerVolumeDropdown(); + } + onExited: { + if (player.volumeExpanded) + player.dropdownButtonExited(); + } + onClicked: player.toggleMute() + property real wheelAccum: 0 + onWheel: wheelEvent => { + wheelEvent.accepted = true; + wheelAccum += wheelEvent.angleDelta.y; + const notches = wheelAccum > 0 ? Math.floor(wheelAccum / 120) : Math.ceil(wheelAccum / 120); + if (notches === 0) + return; + wheelAccum -= notches * 120; + player.adjustVolume(notches * AudioService.wheelVolumeStep); + } + } + } + + Rectangle { + id: audioDevicesButton + width: 40 + height: 40 + radius: 20 + x: player.isRightEdge ? Theme.spacingM : parent.width - 40 - Theme.spacingM + y: 240 + color: audioDevicesArea.containsMouse || player.devicesExpanded ? player.accentPressed : Theme.withAlpha(player.accentPressed, 0) + border.color: Theme.outlineStrong + border.width: 1 + z: 100 + + DankIcon { + anchors.centerIn: parent + name: "speaker" + size: 18 + color: Theme.surfaceText + } + + MouseArea { + id: audioDevicesArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + acceptedButtons: Qt.LeftButton | Qt.RightButton + onWheel: wheelEvent => { + const delta = wheelEvent.angleDelta.y; + if (delta === 0) + return; + AudioService.cycleAudioOutputDirection(delta < 0); + wheelEvent.accepted = true; + } + onClicked: mouse => { + if (mouse.button === Qt.RightButton) { + if (!AudioService.sink?.audio) + return; + SessionData.suppressOSDTemporarily(); + AudioService.sink.audio.muted = !AudioService.sink.audio.muted; + return; + } + if (player.devicesExpanded) { + player.cycleNextSink(); + return; + } + player.triggerDevicesDropdown(); + } + onEntered: { + player.dropdownButtonEntered(); + player.triggerDevicesDropdown(); + } + onExited: { + if (player.devicesExpanded) + player.dropdownButtonExited(); + } + } + } +} diff --git a/quickshell/Modules/DankDash/MediaPlayerEmptyState.qml b/quickshell/Modules/DankDash/MediaPlayerEmptyState.qml new file mode 100644 index 000000000..18256d9e0 --- /dev/null +++ b/quickshell/Modules/DankDash/MediaPlayerEmptyState.qml @@ -0,0 +1,21 @@ +import QtQuick +import qs.Common +import qs.Widgets + +Column { + spacing: Theme.spacingM + + DankIcon { + name: "music_note" + size: Theme.iconSize * 3 + color: Theme.surfaceTextSecondary + anchors.horizontalCenter: parent.horizontalCenter + } + + StyledText { + text: I18n.tr("No Active Players") + font.pixelSize: Theme.fontSizeLarge + color: Theme.surfaceTextMedium + anchors.horizontalCenter: parent.horizontalCenter + } +} diff --git a/quickshell/Modules/DankDash/MediaPlayerIslandChrome.qml b/quickshell/Modules/DankDash/MediaPlayerIslandChrome.qml new file mode 100644 index 000000000..850b80a07 --- /dev/null +++ b/quickshell/Modules/DankDash/MediaPlayerIslandChrome.qml @@ -0,0 +1,386 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell.Services.Mpris +import qs.Common +import qs.Services +import qs.Widgets +import "../../Common/Format.js" as Format + +Item { + id: root + + required property var player + + property alias volumeButton: volumeButton + property alias playerSelectorButton: sourceButton + property alias audioDevicesButton: outputButton + + readonly property var activePlayer: root.player.activePlayer + readonly property bool playing: root.activePlayer?.playbackState === MprisPlaybackState.Playing + readonly property color accent: root.player.accent + readonly property color onAccent: root.player.onAccent + readonly property string title: MprisController.stableTitle || I18n.tr("Unknown Track") + readonly property string artist: MprisController.stableArtist || I18n.tr("Unknown Artist") + readonly property string album: MprisController.stableAlbum || "" + readonly property string artUrl: TrackArtService.resolvedArtUrl || root.activePlayer?.trackArtUrl || "" + readonly property real railWidth: 44 + readonly property real railInset: root.railWidth + Theme.spacingM + + implicitHeight: 352 + + MediaArtBackdrop { + anchors.fill: parent + radius: 30 + activePlayer: root.activePlayer + onArtReady: root.player.maybeFinishSwitch() + } + + MediaPlayerEmptyState { + anchors.centerIn: parent + visible: root.player.showNoPlayerNow + } + + Item { + id: cardBody + + anchors { + fill: parent + margins: 18 + } + visible: !root.player.noneAvailable && !root.player.showNoPlayerNow + + Item { + id: headerRow + + anchors { + left: parent.left + right: parent.right + top: parent.top + } + height: 132 + + Item { + id: artCluster + + anchors { + left: parent.left + top: parent.top + } + width: 120 + height: 120 + + MediaBlobHalo { + anchors.centerIn: parent + width: 136 + height: 136 + accentColor: root.accent + playing: root.player.live && root.playing + } + + Rectangle { + anchors.fill: parent + radius: 30 + color: "transparent" + border.width: 1 + border.color: Theme.withAlpha(root.accent, 0.3) + + MediaArtwork { + anchors { + fill: parent + margins: 2 + } + cornerRadius: 28 + placeholderIconSize: 46 + artUrl: root.artUrl + } + } + } + + Column { + anchors { + left: artCluster.right + leftMargin: Theme.spacingL + right: parent.right + top: parent.top + topMargin: 2 + } + spacing: Theme.spacingXS + + StyledText { + width: parent.width + text: root.title + color: Theme.surfaceText + font.pixelSize: Theme.fontSizeXLarge + font.weight: Font.Bold + elide: Text.ElideRight + wrapMode: Text.WordWrap + maximumLineCount: 2 + lineHeight: 1.08 + } + + StyledText { + width: parent.width + text: root.artist + color: Theme.surfaceTextMedium + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + elide: Text.ElideRight + maximumLineCount: 1 + } + + StyledText { + width: parent.width + text: root.album + color: Theme.surfaceTextSecondary + font.pixelSize: Theme.fontSizeSmall + elide: Text.ElideRight + maximumLineCount: 1 + visible: text.length > 0 + } + } + } + + Column { + id: iconRail + + anchors { + right: parent.right + top: headerRow.bottom + topMargin: Theme.spacingL + bottom: parent.bottom + } + width: root.railWidth + spacing: Theme.spacingS + + RailButton { + id: volumeButton + + iconName: root.player.getVolumeIcon() + iconColor: root.player.volumeAvailable && root.player.currentVolume > 0 ? root.accent : Theme.surfaceTextMedium + enabled: root.player.volumeAvailable + onClicked: root.player.toggleMute() + onEntered: root.player.triggerVolumeDropdown() + onExited: { + if (root.player.volumeExpanded) + root.player.dropdownButtonExited(); + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.NoButton + onWheel: wheelEvent => { + wheelEvent.accepted = true; + root.player.adjustVolume((wheelEvent.angleDelta.y > 0 ? 1 : -1) * AudioService.wheelVolumeStep); + } + } + } + + RailButton { + id: outputButton + + iconName: root.player.getAudioDeviceIcon(AudioService.sink) + onClicked: { + if (root.player.devicesExpanded) { + root.player.cycleNextSink(); + return; + } + root.player.triggerDevicesDropdown(); + } + onEntered: root.player.triggerDevicesDropdown() + onExited: { + if (root.player.devicesExpanded) + root.player.dropdownButtonExited(); + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.NoButton + onWheel: wheelEvent => { + wheelEvent.accepted = true; + AudioService.cycleAudioOutputDirection(wheelEvent.angleDelta.y < 0); + } + } + } + + RailButton { + id: sourceButton + + visible: (root.player.allPlayers?.length || 0) > 0 + iconName: "assistant_device" + iconColor: root.player.playersExpanded ? root.accent : Theme.surfaceText + backgroundColor: Theme.withAlpha(root.accent, root.player.playersExpanded ? 0.16 : 0.08) + onClicked: { + if (root.player.playersExpanded) { + root.player.cycleNextPlayer(); + return; + } + root.player.triggerPlayersDropdown(); + } + onEntered: root.player.triggerPlayersDropdown() + onExited: { + if (root.player.playersExpanded) + root.player.dropdownButtonExited(); + } + } + } + + Item { + id: playColumn + + anchors { + left: parent.left + right: iconRail.left + rightMargin: Theme.spacingM + top: headerRow.bottom + topMargin: Theme.spacingL + bottom: parent.bottom + } + + Item { + id: seekBlock + + anchors { + left: parent.left + leftMargin: root.railInset + right: parent.right + bottom: parent.bottom + bottomMargin: 76 + } + height: 40 + + DankSeekbar { + anchors.top: parent.top + width: parent.width + height: 22 + activePlayer: root.activePlayer + stableLength: root.player.stableLength + accentColor: root.accent + accentTrackColor: MediaAccentService.accentTrack + accentSubtleColor: MediaAccentService.accentSubtle + isSeeking: root.player.isSeeking + onIsSeekingChanged: root.player.isSeeking = isSeeking + } + + StyledText { + anchors { + left: parent.left + bottom: parent.bottom + } + text: { + const rawPos = Math.max(0, root.activePlayer?.position || 0); + const length = root.player.stableLength; + return Format.formatDuration(length ? rawPos % Math.max(1, length) : rawPos); + } + color: root.accent + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.DemiBold + } + + StyledText { + anchors { + right: parent.right + bottom: parent.bottom + } + text: root.player.stableLength > 0 ? Format.formatDuration(root.player.stableLength) : "--:--" + color: Theme.surfaceTextSecondary + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + } + } + + Row { + anchors { + horizontalCenter: parent.horizontalCenter + horizontalCenterOffset: root.railInset / 2 + bottom: parent.bottom + bottomMargin: 2 + } + spacing: Theme.spacingS + + DankActionButton { + anchors.verticalCenter: parent.verticalCenter + visible: !!root.activePlayer?.shuffleSupported + buttonSize: 40 + radius: 20 + iconName: "shuffle" + iconSize: 18 + iconColor: root.activePlayer?.shuffle ? root.accent : Theme.surfaceText + backgroundColor: Theme.withAlpha(root.accent, root.activePlayer?.shuffle ? 0.16 : 0) + onClicked: { + if (root.activePlayer?.canControl && root.activePlayer?.shuffleSupported) + root.activePlayer.shuffle = !root.activePlayer.shuffle; + } + } + + DankActionButton { + anchors.verticalCenter: parent.verticalCenter + buttonSize: 52 + radius: 18 + iconName: "skip_previous" + iconSize: 26 + backgroundColor: Theme.withAlpha(root.accent, 0.14) + enabled: !!root.activePlayer?.canGoPrevious || !!root.activePlayer?.canSeek + opacity: enabled ? 1 : 0.38 + onClicked: MprisController.previousOrRewind() + } + + Rectangle { + anchors.verticalCenter: parent.verticalCenter + width: 68 + height: 68 + radius: root.playing ? 22 : 34 + color: root.accent + opacity: root.activePlayer?.canTogglePlaying ? 1 : 0.38 + + DankIcon { + anchors.centerIn: parent + name: root.playing ? "pause" : "play_arrow" + size: 34 + color: root.onAccent + weight: 500 + } + + StateLayer { + stateColor: root.onAccent + disabled: !root.activePlayer?.canTogglePlaying + onClicked: root.activePlayer.togglePlaying() + } + } + + DankActionButton { + anchors.verticalCenter: parent.verticalCenter + buttonSize: 52 + radius: 18 + iconName: "skip_next" + iconSize: 26 + backgroundColor: Theme.withAlpha(root.accent, 0.14) + enabled: !!root.activePlayer?.canGoNext + opacity: enabled ? 1 : 0.38 + onClicked: MprisController.next() + } + + DankActionButton { + anchors.verticalCenter: parent.verticalCenter + visible: !!root.activePlayer?.loopSupported + buttonSize: 40 + radius: 20 + iconName: root.activePlayer?.loopState === MprisLoopState.Track ? "repeat_one" : "repeat" + iconSize: 18 + iconColor: root.activePlayer && root.activePlayer.loopState !== MprisLoopState.None ? root.accent : Theme.surfaceText + backgroundColor: Theme.withAlpha(root.accent, root.activePlayer && root.activePlayer.loopState !== MprisLoopState.None ? 0.16 : 0) + onClicked: root.player.cycleLoopState() + } + } + } + } + + component RailButton: DankActionButton { + buttonSize: root.railWidth + radius: root.railWidth / 2 + iconSize: 20 + iconColor: Theme.surfaceText + backgroundColor: Theme.withAlpha(Theme.surfaceText, 0.08) + opacity: enabled ? 1 : 0.38 + } +} diff --git a/quickshell/Modules/DankDash/MediaPlayerTab.qml b/quickshell/Modules/DankDash/MediaPlayerTab.qml index 9fef8f4a7..491b36459 100644 --- a/quickshell/Modules/DankDash/MediaPlayerTab.qml +++ b/quickshell/Modules/DankDash/MediaPlayerTab.qml @@ -1,12 +1,7 @@ import QtQuick -import QtQuick.Effects -import QtQuick.Layouts import Quickshell.Services.Mpris -import Quickshell.Widgets import qs.Common import qs.Services -import qs.Widgets -import "../../Common/Format.js" as Format Item { id: root @@ -27,13 +22,8 @@ Item { property int barPosition: SettingsData.Position.Top property bool live: Window.window?.visible ?? false property bool menusEnabled: true - property bool compact: false - - readonly property real contentTopMargin: compact ? Theme.spacingM : 20 - readonly property real contentHeight: compact ? 320 : 370 - readonly property real artAreaHeight: compact ? 150 : 200 - readonly property real sideButtonTop: contentTopMargin + artAreaHeight / 2 + 10 - readonly property real sideButtonStep: 55 + property string chrome: "dash" + readonly property bool islandChrome: chrome === "island" readonly property color accent: MediaAccentService.accent readonly property color onAccent: MediaAccentService.onAccent @@ -126,7 +116,7 @@ Item { } implicitWidth: SettingsData.showWeekNumber ? 736 : 700 - implicitHeight: playerContent.height + playerContent.anchors.topMargin * 2 + implicitHeight: chromeLoader.item?.implicitHeight ?? 410 Connections { target: activePlayer @@ -203,7 +193,7 @@ Item { function dropdownAnchor(button) { const buttonsOnRight = !root.isRightEdge; - const btnY = button.y + button.height / 2; + const btnY = button.mapToItem(root, 0, button.height / 2).y; return { "pos": Qt.point(buttonsOnRight ? (root.popoutX + root.popoutWidth) : root.popoutX, root.popoutY + root.contentOffsetY + btnY), "rightEdge": buttonsOnRight @@ -213,7 +203,7 @@ Item { function triggerVolumeDropdown() { if (!root.menusEnabled || !volumeAvailable || volumeExpanded) return; - const anchor = dropdownAnchor(volumeButton); + const anchor = dropdownAnchor(chromeLoader.item.volumeButton); hideDropdowns(); volumeExpanded = true; showVolumeDropdown(anchor.pos, targetScreen, anchor.rightEdge, activePlayer, allPlayers); @@ -222,7 +212,7 @@ Item { function triggerPlayersDropdown() { if (!root.menusEnabled || playersExpanded) return; - const anchor = dropdownAnchor(playerSelectorButton); + const anchor = dropdownAnchor(chromeLoader.item.playerSelectorButton); hideDropdowns(); playersExpanded = true; showPlayersDropdown(anchor.pos, targetScreen, anchor.rightEdge, activePlayer, allPlayers); @@ -231,7 +221,7 @@ Item { function triggerDevicesDropdown() { if (!root.menusEnabled || devicesExpanded) return; - const anchor = dropdownAnchor(audioDevicesButton); + const anchor = dropdownAnchor(chromeLoader.item.audioDevicesButton); hideDropdowns(); devicesExpanded = true; showAudioDevicesDropdown(anchor.pos, targetScreen, anchor.rightEdge); @@ -372,619 +362,26 @@ Item { onTriggered: activePlayer?.positionChanged() } - Item { - id: bgContainer - anchors.fill: parent - - // Fall back to the live mpris url so the background is never blank. - readonly property string curArt: { - const resolved = TrackArtService.resolvedArtUrl; - if (resolved !== "") - return resolved; - const p = root.activePlayer; - if (!p) - return ""; - if (p.trackArtUrl) - return p.trackArtUrl; - const m = p.metadata; - return m && m["mpris:artUrl"] ? m["mpris:artUrl"].toString() : ""; - } - // Two layers crossfade: new art loads into the hidden one and fades in once decoded. - property bool _showA: true - visible: layerA.ready || layerB.ready - - onCurArtChanged: syncArt() - Component.onCompleted: syncArt() - - function syncArt() { - if (curArt === "") - return; - const front = _showA ? layerA : layerB; - const back = _showA ? layerB : layerA; - if (front.art == curArt) - return; - if (back.art == curArt) { - // Already decoded in the hidden layer; flip once ready (else promote() does). - if (back.ready) - _showA = !_showA; - return; - } - back.art = curArt; - } - - // Flip to the hidden layer only when it holds the current art, ignoring stale - // Ready re-emits (e.g. popout re-expose) that would otherwise ping-pong _showA. - function promote(layer) { - const back = _showA ? layerB : layerA; - if (layer !== back) - return; - if (layer.art != curArt) - return; - _showA = (layer === layerA); - root.maybeFinishSwitch(); - } - - BgBlurLayer { - id: layerA - front: bgContainer._showA - onLoaded: bgContainer.promote(layerA) - } - - BgBlurLayer { - id: layerB - front: !bgContainer._showA - onLoaded: bgContainer.promote(layerB) - } - - Rectangle { - anchors.fill: parent - radius: Theme.cornerRadius - color: Theme.surface - opacity: 0.3 - } - } - - component BgBlurLayer: ClippingRectangle { - id: layer - property alias art: layerImg.source - readonly property bool ready: layerImg.status === Image.Ready && layerImg.source != "" - property bool front: false - signal loaded + Loader { + id: chromeLoader anchors.fill: parent - radius: Theme.cornerRadius - color: "transparent" - antialiasing: true - opacity: front ? 0.7 : 0 + sourceComponent: root.islandChrome ? islandChromeComponent : dashChromeComponent + } - Behavior on opacity { - NumberAnimation { - duration: 350 - easing.type: Easing.InOutQuad - } - } + Component { + id: dashChromeComponent - Image { - id: layerImg - anchors.centerIn: parent - width: Math.max(parent.width, parent.height) * 1.1 - height: width - fillMode: Image.PreserveAspectCrop - asynchronous: true - cache: true - visible: false - onStatusChanged: { - if (status === Image.Ready && source != "") - layer.loaded(); - } - } - - MultiEffect { - anchors.centerIn: parent - width: layerImg.width - height: layerImg.height - source: layerImg - blurEnabled: true - blurMax: 64 - blur: 0.8 - saturation: -0.2 - brightness: -0.25 + MediaPlayerDashChrome { + player: root } } - Column { - anchors.centerIn: parent - spacing: Theme.spacingM - visible: showNoPlayerNow + Component { + id: islandChromeComponent - DankIcon { - name: "music_note" - size: Theme.iconSize * 3 - color: Theme.surfaceTextSecondary - anchors.horizontalCenter: parent.horizontalCenter - } - - StyledText { - text: I18n.tr("No Active Players") - font.pixelSize: Theme.fontSizeLarge - color: Theme.surfaceTextMedium - anchors.horizontalCenter: parent.horizontalCenter - } - } - - Item { - anchors.fill: parent - clip: false - visible: !noneAvailable && (!showNoPlayerNow) - ColumnLayout { - id: playerContent - width: 484 - height: root.contentHeight - spacing: Theme.spacingXS - anchors.top: parent.top - anchors.topMargin: root.contentTopMargin - anchors.horizontalCenter: parent.horizontalCenter - - Item { - width: parent.width - height: root.artAreaHeight - clip: false - - DankAlbumArt { - width: Math.min(parent.width * 0.8, parent.height * 0.9) - height: width - anchors.centerIn: parent - activePlayer: root.activePlayer - artUrl: TrackArtService.resolvedArtUrl - accentColor: MediaAccentService.accent - cavaService: CavaService - showAnimation: SettingsData.audioVisualizerEnabled - } - } - - // Song Info and Controls Section - Item { - width: parent.width - Layout.fillHeight: true - - Column { - id: songInfo - width: parent.width - spacing: Theme.spacingXS - anchors.top: parent.top - anchors.horizontalCenter: parent.horizontalCenter - - StyledText { - text: MprisController.stableTitle || I18n.tr("Unknown Track") - font.pixelSize: Theme.fontSizeLarge - font.weight: Font.Bold - color: Theme.surfaceText - width: parent.width - horizontalAlignment: Text.AlignHCenter - elide: Text.ElideRight - wrapMode: Text.WordWrap - maximumLineCount: 2 - } - - StyledText { - text: MprisController.stableArtist || I18n.tr("Unknown Artist") - font.pixelSize: Theme.fontSizeMedium - color: Theme.surfaceTextMedium - width: parent.width - horizontalAlignment: Text.AlignHCenter - elide: Text.ElideRight - wrapMode: Text.WordWrap - maximumLineCount: 1 - } - - StyledText { - text: MprisController.stableAlbum - font.pixelSize: Theme.fontSizeSmall - color: Theme.surfaceTextSecondary - width: parent.width - horizontalAlignment: Text.AlignHCenter - elide: Text.ElideRight - wrapMode: Text.WordWrap - maximumLineCount: 1 - // Reserve the line so late album metadata doesn't shift the seekbar. - height: Math.max(implicitHeight, Theme.fontSizeSmall * 1.4) - } - } - - Item { - id: seekbarContainer - width: parent.width - anchors.top: songInfo.bottom - anchors.bottom: playbackControls.top - anchors.horizontalCenter: parent.horizontalCenter - - Column { - width: parent.width - spacing: Theme.spacingXXS - anchors.horizontalCenter: parent.horizontalCenter - anchors.verticalCenter: parent.verticalCenter - anchors.verticalCenterOffset: parent.height * 0.2 - - DankSeekbar { - width: parent.width * 0.8 - height: 20 - anchors.horizontalCenter: parent.horizontalCenter - activePlayer: root.activePlayer - stableLength: MprisController.activePlayerStableLength - accentColor: MediaAccentService.accent - accentTrackColor: MediaAccentService.accentTrack - accentSubtleColor: MediaAccentService.accentSubtle - isSeeking: root.isSeeking - onIsSeekingChanged: root.isSeeking = isSeeking - } - - Item { - width: parent.width * 0.8 - height: 16 - anchors.horizontalCenter: parent.horizontalCenter - - StyledText { - anchors.left: parent.left - anchors.verticalCenter: parent.verticalCenter - text: { - if (!activePlayer) - return "0:00"; - const rawPos = Math.max(0, activePlayer.position || 0); - return Format.formatDuration(stableLength ? rawPos % Math.max(1, stableLength) : rawPos); - } - font.pixelSize: Theme.fontSizeSmall - color: Theme.surfaceVariantText - } - - StyledText { - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - text: { - if (!activePlayer || stableLength <= 0) - return "--:--"; - return Format.formatDuration(stableLength); - } - font.pixelSize: Theme.fontSizeSmall - color: Theme.surfaceVariantText - } - } - } - } - - Item { - id: playbackControls - width: parent.width - height: 50 - anchors.bottom: parent.bottom - - Row { - anchors.centerIn: parent - spacing: Theme.spacingM - height: parent.height - - Item { - width: 50 - height: 50 - anchors.verticalCenter: parent.verticalCenter - visible: activePlayer && activePlayer.shuffleSupported - - Rectangle { - width: 40 - height: 40 - radius: 20 - anchors.centerIn: parent - color: shuffleArea.containsMouse ? root.accentHover : Theme.withAlpha(root.accent, 0) - - DankIcon { - anchors.centerIn: parent - name: "shuffle" - size: 20 - color: activePlayer && activePlayer.shuffle ? root.accent : Theme.surfaceText - } - - MouseArea { - id: shuffleArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - if (activePlayer && activePlayer.canControl && activePlayer.shuffleSupported) { - activePlayer.shuffle = !activePlayer.shuffle; - } - } - } - } - } - - Item { - width: 50 - height: 50 - anchors.verticalCenter: parent.verticalCenter - - Rectangle { - width: 40 - height: 40 - radius: 20 - anchors.centerIn: parent - color: prevBtnArea.containsMouse ? Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency) : Theme.withAlpha(Theme.surfaceContainerHigh, 0) - - DankIcon { - anchors.centerIn: parent - name: "skip_previous" - size: 24 - color: Theme.surfaceText - } - - MouseArea { - id: prevBtnArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: MprisController.previousOrRewind() - } - } - } - - Item { - width: 50 - height: 50 - anchors.verticalCenter: parent.verticalCenter - - Rectangle { - width: 50 - height: 50 - radius: 25 - anchors.centerIn: parent - color: root.accent - - DankIcon { - anchors.centerIn: parent - name: activePlayer && activePlayer.playbackState === MprisPlaybackState.Playing ? "pause" : "play_arrow" - size: 28 - color: root.onAccent - weight: 500 - } - - StateLayer { - id: playPauseArea - disabled: !root.activePlayer || !root.activePlayer.canTogglePlaying - stateColor: root.onAccent - cornerRadius: parent.radius - onClicked: root.activePlayer.togglePlaying() - } - - ElevationShadow { - anchors.fill: parent - z: -1 - level: Theme.elevationLevel1 - fallbackOffset: 1 - targetRadius: parent.radius - targetColor: parent.color - shadowOpacity: Theme.elevationLevel1 && Theme.elevationLevel1.alpha !== undefined ? Theme.elevationLevel1.alpha : 0.2 - shadowEnabled: Theme.elevationEnabled - } - } - } - - Item { - width: 50 - height: 50 - anchors.verticalCenter: parent.verticalCenter - - Rectangle { - width: 40 - height: 40 - radius: 20 - anchors.centerIn: parent - color: nextBtnArea.containsMouse ? Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency) : Theme.withAlpha(Theme.surfaceContainerHigh, 0) - - DankIcon { - anchors.centerIn: parent - name: "skip_next" - size: 24 - color: Theme.surfaceText - } - - MouseArea { - id: nextBtnArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: MprisController.next() - } - } - } - - Item { - width: 50 - height: 50 - anchors.verticalCenter: parent.verticalCenter - visible: activePlayer && activePlayer.loopSupported - - Rectangle { - width: 40 - height: 40 - radius: 20 - anchors.centerIn: parent - color: repeatArea.containsMouse ? root.accentHover : Theme.withAlpha(root.accent, 0) - - 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 ? root.accent : Theme.surfaceText - } - - MouseArea { - id: repeatArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: root.cycleLoopState() - } - } - } - } - } - } - } - } - - Rectangle { - id: playerSelectorButton - width: 40 - height: 40 - radius: 20 - x: isRightEdge ? Theme.spacingM : parent.width - 40 - Theme.spacingM - y: root.sideButtonTop + root.sideButtonStep - color: playerSelectorArea.containsMouse || playersExpanded ? root.accentPressed : Theme.withAlpha(root.accentPressed, 0) - border.color: Theme.outlineStrong - border.width: 1 - z: 100 - visible: (allPlayers?.length || 0) >= 1 - - DankIcon { - anchors.centerIn: parent - name: "assistant_device" - size: 18 - color: Theme.surfaceText - } - - MouseArea { - id: playerSelectorArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - if (playersExpanded) { - root.cycleNextPlayer(); - return; - } - root.triggerPlayersDropdown(); - } - onEntered: { - dropdownButtonEntered(); - root.triggerPlayersDropdown(); - } - onExited: { - if (playersExpanded) - dropdownButtonExited(); - } - } - } - - Rectangle { - id: volumeButton - width: 40 - height: 40 - radius: 20 - x: isRightEdge ? Theme.spacingM : parent.width - 40 - Theme.spacingM - y: root.sideButtonTop - color: volumeButtonArea.containsMouse && volumeAvailable || volumeExpanded ? root.accentPressed : Theme.withAlpha(root.accentPressed, 0) - border.color: volumeAvailable ? Theme.outlineStrong : Theme.outlineMedium - border.width: 1 - z: 101 - enabled: volumeAvailable - - DankIcon { - anchors.centerIn: parent - name: getVolumeIcon() - size: 18 - color: volumeAvailable && currentVolume > 0 ? root.accent : Theme.withAlpha(Theme.surfaceText, volumeAvailable ? 1.0 : 0.5) - } - - MouseArea { - id: volumeButtonArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onEntered: { - dropdownButtonEntered(); - root.triggerVolumeDropdown(); - } - onExited: { - if (volumeExpanded) - dropdownButtonExited(); - } - onClicked: root.toggleMute() - property real wheelAccum: 0 - onWheel: wheelEvent => { - wheelEvent.accepted = true; - wheelAccum += wheelEvent.angleDelta.y; - const notches = wheelAccum > 0 ? Math.floor(wheelAccum / 120) : Math.ceil(wheelAccum / 120); - if (notches === 0) - return; - wheelAccum -= notches * 120; - root.adjustVolume(notches * AudioService.wheelVolumeStep); - } - } - } - - Rectangle { - id: audioDevicesButton - width: 40 - height: 40 - radius: 20 - x: isRightEdge ? Theme.spacingM : parent.width - 40 - Theme.spacingM - y: root.sideButtonTop + root.sideButtonStep * 2 - color: audioDevicesArea.containsMouse || devicesExpanded ? root.accentPressed : Theme.withAlpha(root.accentPressed, 0) - border.color: Theme.outlineStrong - border.width: 1 - z: 100 - - DankIcon { - anchors.centerIn: parent - name: "speaker" - size: 18 - color: Theme.surfaceText - } - - MouseArea { - id: audioDevicesArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - acceptedButtons: Qt.LeftButton | Qt.RightButton - onWheel: wheelEvent => { - const delta = wheelEvent.angleDelta.y; - if (delta === 0) - return; - AudioService.cycleAudioOutputDirection(delta < 0); - wheelEvent.accepted = true; - } - onClicked: mouse => { - if (mouse.button === Qt.RightButton) { - if (!AudioService.sink?.audio) - return; - SessionData.suppressOSDTemporarily(); - AudioService.sink.audio.muted = !AudioService.sink.audio.muted; - return; - } - if (devicesExpanded) { - root.cycleNextSink(); - return; - } - root.triggerDevicesDropdown(); - } - onEntered: { - dropdownButtonEntered(); - root.triggerDevicesDropdown(); - } - onExited: { - if (devicesExpanded) - dropdownButtonExited(); - } + MediaPlayerIslandChrome { + player: root } } } diff --git a/quickshell/Modules/DankDash/Overview/MediaOverviewCard.qml b/quickshell/Modules/DankDash/Overview/MediaOverviewCard.qml index 9edb56fff..104c3dac9 100644 --- a/quickshell/Modules/DankDash/Overview/MediaOverviewCard.qml +++ b/quickshell/Modules/DankDash/Overview/MediaOverviewCard.qml @@ -61,7 +61,6 @@ Card { activePlayer: root.activePlayer artUrl: TrackArtService.resolvedArtUrl accentColor: MediaAccentService.accent - cavaService: CavaService showAnimation: root.live && SettingsData.audioVisualizerEnabled albumSize: 76 animationScale: 1.05 diff --git a/quickshell/Modules/DankIsland/Activities/MediaExpanded.qml b/quickshell/Modules/DankIsland/Activities/MediaExpanded.qml index f1c0a66e6..8cc35666f 100644 --- a/quickshell/Modules/DankIsland/Activities/MediaExpanded.qml +++ b/quickshell/Modules/DankIsland/Activities/MediaExpanded.qml @@ -19,9 +19,10 @@ DashTabFace { signal dropdownHoverEnded activityId: "media" + inset: 1 tabComponent: Component { MediaPlayerTab { - compact: true + chrome: "island" live: root.live menusEnabled: root.menusEnabled targetScreen: root.effectiveScreen diff --git a/quickshell/Modules/DankIsland/IslandController.qml b/quickshell/Modules/DankIsland/IslandController.qml index 5e7d08cf0..6064da6f9 100644 --- a/quickshell/Modules/DankIsland/IslandController.qml +++ b/quickshell/Modules/DankIsland/IslandController.qml @@ -330,7 +330,7 @@ QtObject { readonly property var homeCompactTarget: pillTarget(homeCompactWidth, homeCompactFaceHeight) readonly property var mediaCompactTarget: pillTarget(mediaCompactWidth, compactFaceHeight) readonly property var dashSheetTarget: sheetTarget(SettingsData.showWeekNumber ? 736 : 700, 452) - readonly property var mediaExpandedTarget: sheetTarget(600, 384) + readonly property var mediaExpandedTarget: sheetTarget(600, 352) readonly property var launcherExpandedTarget: sheetTarget(680, 560) readonly property var controlCenterExpandedTarget: sheetTarget(580, controlCenterHeight) readonly property var systemCompactTarget: pillTarget(SettingsData.osdAlwaysShowValue ? 330 : 282, compactFaceHeight) diff --git a/quickshell/Modules/DankIsland/IslandMediaSource.qml b/quickshell/Modules/DankIsland/IslandMediaSource.qml index c692845cb..7ef4466c4 100644 --- a/quickshell/Modules/DankIsland/IslandMediaSource.qml +++ b/quickshell/Modules/DankIsland/IslandMediaSource.qml @@ -14,7 +14,7 @@ QtObject { readonly property bool available: !!player && player.playbackState !== MprisPlaybackState.Stopped readonly property string title: MprisController.stableTitle || player?.trackTitle || I18n.tr("Unknown Track", "island media face: fallback title") readonly property string artist: MprisController.stableArtist || player?.trackArtist || player?.identity || I18n.tr("Unknown Artist", "island media face: fallback artist") - readonly property string artUrl: TrackArtService.resolvedArtUrl + readonly property string artUrl: TrackArtService.resolvedArtUrl || player?.trackArtUrl || "" readonly property bool playing: !!player?.isPlaying Component.onCompleted: controller.updateMediaAvailability(available) diff --git a/quickshell/Widgets/DankAlbumArt.qml b/quickshell/Widgets/DankAlbumArt.qml index 782a9a91c..9d7a8a85a 100644 --- a/quickshell/Widgets/DankAlbumArt.qml +++ b/quickshell/Widgets/DankAlbumArt.qml @@ -9,7 +9,6 @@ Item { property MprisPlayer activePlayer property string artUrl: "" property color accentColor: Theme.primary - property var cavaService: null property string lastValidArtUrl: "" // Live mpris url — always valid for the current track; fallback so art is never blank. readonly property string rawArtUrl: { @@ -31,36 +30,8 @@ Item { property bool showAnimation: true property real animationScale: 1.0 - readonly property real blobBaseRadiusFactor: 0.43 - readonly property real blobAmplitudeFactor: 0.115 - readonly property real blobOvershoot: 1.15 - readonly property real blobEnergySensitivity: 1.15 - readonly property real cavaFullScale: 45 - readonly property real blobAttack: 0.75 - readonly property real blobRelease: 0.2 - readonly property real blobBeatBoost: 2.5 - readonly property real blobBeatKick: 4 - readonly property real blobOnsetThreshold: 1.4 - readonly property real blobSpringStiffness: 220 - readonly property real blobSpringDamping: 19 - readonly property real blobMorphSpeed: 0.05 - readonly property real blobMorphBoost: 1.7 - readonly property real blobSpinSpeed: 0.03 - readonly property bool onScreen: visible && (Window.window?.visible ?? false) - readonly property bool blobActive: (cavaService?.cavaAvailable ?? false) && onScreen && activePlayer?.playbackState === MprisPlaybackState.Playing && showAnimation && albumArtStatus === Image.Ready - property var smoothedBands: [0, 0, 0, 0, 0, 0] - property var slowBands: [0, 0, 0, 0, 0, 0] - property var bandTargets: [0, 0, 0, 0, 0, 0] - property var bandDisplay: [0, 0, 0, 0, 0, 0] - property var prevLevels: [0, 0, 0, 0, 0, 0] - readonly property var fluxWeights: [1.0, 1.0, 0.6, 0.6, 0.35, 0.35] - property real fluxAvg: 0.02 - property real loudCtx: 0.1 - property int beatCooldown: 0 - property real energyTarget: 0 - property real energyPos: 0 - property real energyVel: 0 + readonly property bool playing: activePlayer?.playbackState === MprisPlaybackState.Playing onActivePlayerChanged: { lastValidArtUrl = ""; @@ -111,117 +82,12 @@ Item { easing.type: Easing.InOutQuad } - function updateBands() { - const vals = cavaService?.values; - if (!vals || vals.length < 6) - return; - - const s = smoothedBands; - const slow = slowBands; - const out = bandTargets; - const prev = prevLevels; - const w = fluxWeights; - let flux = 0; - for (let i = 0; i < 6; i++) { - const level = Math.min(Math.max(vals[i], 0), cavaFullScale) / cavaFullScale; - flux += Math.max(0, level - prev[i]) * w[i]; - prev[i] = level; - const alpha = level > s[i] ? blobAttack : blobRelease; - s[i] += alpha * (level - s[i]); - slow[i] += 0.05 * (level - slow[i]); - const punch = Math.max(0, s[i] - slow[i]) * blobBeatBoost; - out[i] = Math.min(1, (0.55 * s[i] + punch) * blobEnergySensitivity); - } - - const ratio = flux / Math.max(fluxAvg, 0.004); - fluxAvg += 0.06 * (flux - fluxAvg); - if (beatCooldown > 0) { - beatCooldown--; - } else if (ratio > blobOnsetThreshold && flux > 0.008) { - energyVel += blobBeatKick * Math.min(2.5, ratio - 1); - beatCooldown = 3; - } - - const loud = 0.7 * Math.max(prev[0], prev[1]) + 0.3 * Math.max(prev[2], prev[3]); - loudCtx += 0.03 * (loud - loudCtx); - const surge = Math.max(0, loud / Math.max(loudCtx, 0.05) - 1); - energyTarget = Math.min(1, 0.5 * loud + 0.6 * Math.min(1, surge)); - } - - function stepBlob(dt) { - energyVel += (blobSpringStiffness * (energyTarget - energyPos) - blobSpringDamping * energyVel) * dt; - energyPos = Math.max(0, Math.min(blobOvershoot, energyPos + energyVel * dt)); - blobEffect.energy = energyPos; - - const d = bandDisplay; - const t = bandTargets; - const f = Math.min(1, dt * 14); - for (let i = 0; i < 6; i++) { - d[i] += f * (t[i] - d[i]); - } - blobEffect.bandsA = Qt.vector4d(d[0], d[1], d[2], d[3]); - blobEffect.bandsB = Qt.vector2d(d[4], d[5]); - - const speed = 1 + energyPos * blobMorphBoost; - blobEffect.phase = (blobEffect.phase + dt * blobMorphSpeed * speed) % 1; - blobEffect.spin = (blobEffect.spin + dt * blobSpinSpeed) % 6.28318530718; - } - - Loader { - active: root.cavaService && root.onScreen && activePlayer?.playbackState === MprisPlaybackState.Playing && showAnimation - sourceComponent: Component { - Ref { - service: root.cavaService - } - } - } - - Connections { - target: root.cavaService - enabled: blobEffect.visible - function onValuesChanged() { - root.updateBands(); - } - } - - // Timer, not FrameAnimation — a running animation commits frames every vsync (#2863) - Timer { - running: blobEffect.visible && root.onScreen - interval: 33 - repeat: true - onTriggered: root.stepBlob(0.033) - } - - ShaderEffect { - id: blobEffect - - readonly property real span: Math.min(root.width, root.height) - - width: span * (root.blobBaseRadiusFactor + root.blobAmplitudeFactor * root.blobOvershoot) * 2 * root.animationScale + 4 - height: width - anchors.centerIn: parent + MediaBlobHalo { + anchors.fill: parent z: 0 - visible: root.blobActive || activation > 0.004 - - property real phase: 0 - property real spin: 0 - property real sizePx: width - property real baseRadiusPx: span * root.blobBaseRadiusFactor * root.animationScale - property real amplitudePx: span * root.blobAmplitudeFactor * root.animationScale - property real activation: root.blobActive ? 1 : 0 - property real energy: 0 - property vector4d bandsA: Qt.vector4d(0, 0, 0, 0) - property vector2d bandsB: Qt.vector2d(0, 0) - property vector4d fillColor: Qt.vector4d(root.accentColor.r, root.accentColor.g, root.accentColor.b, root.accentColor.a) - - Behavior on activation { - NumberAnimation { - duration: 550 - easing.type: Easing.InOutQuad - } - } - - fragmentShader: Qt.resolvedUrl("../Shaders/qsb/blob.frag.qsb") + accentColor: root.accentColor + animationScale: root.animationScale + playing: root.playing && root.showAnimation && root.albumArtStatus === Image.Ready } DankCircularImage { @@ -246,7 +112,7 @@ Item { // Apple Music animated cover, layered over the static art which stays as fallback. Loader { - readonly property bool wantsAnimatedArt: root.onScreen && activePlayer?.playbackState === MprisPlaybackState.Playing && AppleMusicArtService.animatedArtUrl !== "" + readonly property bool wantsAnimatedArt: root.onScreen && root.playing && AppleMusicArtService.animatedArtUrl !== "" width: albumSize height: albumSize diff --git a/quickshell/Widgets/MediaArtBackdrop.qml b/quickshell/Widgets/MediaArtBackdrop.qml new file mode 100644 index 000000000..c14622f87 --- /dev/null +++ b/quickshell/Widgets/MediaArtBackdrop.qml @@ -0,0 +1,129 @@ +import QtQuick +import QtQuick.Effects +import Quickshell.Services.Mpris +import Quickshell.Widgets +import qs.Common +import qs.Services + +Item { + id: root + + property MprisPlayer activePlayer + property real radius: Theme.cornerRadius + property real artOpacity: 0.7 + property real surfaceTint: 0.3 + + signal artReady + + // Fall back to the live mpris url so the backdrop is never blank. + readonly property string curArt: { + const resolved = TrackArtService.resolvedArtUrl; + if (resolved !== "") + return resolved; + const p = root.activePlayer; + if (!p) + return ""; + if (p.trackArtUrl) + return p.trackArtUrl; + const m = p.metadata; + return m && m["mpris:artUrl"] ? m["mpris:artUrl"].toString() : ""; + } + // Two layers crossfade: new art loads into the hidden one and fades in once decoded. + property bool _showA: true + + visible: layerA.ready || layerB.ready + + onCurArtChanged: syncArt() + Component.onCompleted: syncArt() + + function syncArt() { + if (curArt === "") + return; + const front = _showA ? layerA : layerB; + const back = _showA ? layerB : layerA; + if (front.art == curArt) + return; + if (back.art == curArt) { + if (back.ready) + _showA = !_showA; + return; + } + back.art = curArt; + } + + // Flip only when the hidden layer holds the current art, ignoring stale Ready re-emits. + function promote(layer) { + const back = _showA ? layerB : layerA; + if (layer !== back || layer.art != curArt) + return; + _showA = (layer === layerA); + root.artReady(); + } + + BgBlurLayer { + id: layerA + front: root._showA + onLoaded: root.promote(layerA) + } + + BgBlurLayer { + id: layerB + front: !root._showA + onLoaded: root.promote(layerB) + } + + Rectangle { + anchors.fill: parent + radius: root.radius + color: Theme.surface + opacity: root.surfaceTint + } + + component BgBlurLayer: ClippingRectangle { + id: layer + property alias art: layerImg.source + readonly property bool ready: layerImg.status === Image.Ready && layerImg.source != "" + property bool front: false + signal loaded + + anchors.fill: parent + radius: root.radius + color: "transparent" + antialiasing: true + opacity: front ? root.artOpacity : 0 + + Behavior on opacity { + NumberAnimation { + duration: 350 + easing.type: Easing.InOutQuad + } + } + + Image { + id: layerImg + anchors.centerIn: parent + width: Math.max(parent.width, parent.height) * 1.1 + height: width + fillMode: Image.PreserveAspectCrop + asynchronous: true + cache: true + visible: false + onStatusChanged: { + if (status === Image.Ready && source != "") + layer.loaded(); + } + } + + MultiEffect { + anchors.centerIn: parent + width: layerImg.width + height: layerImg.height + source: layerImg + blurEnabled: true + blurMax: 64 + blur: 0.8 + saturation: -0.2 + brightness: -0.25 + } + } +} diff --git a/quickshell/Widgets/MediaBlobHalo.qml b/quickshell/Widgets/MediaBlobHalo.qml new file mode 100644 index 000000000..654705dc0 --- /dev/null +++ b/quickshell/Widgets/MediaBlobHalo.qml @@ -0,0 +1,157 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.Common +import qs.Services + +Item { + id: root + + property color accentColor: Theme.primary + property bool playing: false + property real animationScale: 1.0 + + readonly property bool onScreen: visible && enabled && (Window.window?.visible ?? false) + readonly property bool available: CavaService.cavaAvailable && SettingsData.audioVisualizerEnabled + readonly property bool blobActive: root.onScreen && root.playing && root.available + + readonly property real blobBaseRadiusFactor: 0.43 + readonly property real blobAmplitudeFactor: 0.115 + readonly property real blobOvershoot: 1.15 + readonly property real blobEnergySensitivity: 1.15 + readonly property real cavaFullScale: 45 + readonly property real blobAttack: 0.75 + readonly property real blobRelease: 0.2 + readonly property real blobBeatBoost: 2.5 + readonly property real blobBeatKick: 4 + readonly property real blobOnsetThreshold: 1.4 + readonly property real blobSpringStiffness: 220 + readonly property real blobSpringDamping: 19 + readonly property real blobMorphSpeed: 0.05 + readonly property real blobMorphBoost: 1.7 + readonly property real blobSpinSpeed: 0.03 + readonly property var fluxWeights: [1.0, 1.0, 0.6, 0.6, 0.35, 0.35] + + property var smoothedBands: [0, 0, 0, 0, 0, 0] + property var slowBands: [0, 0, 0, 0, 0, 0] + property var bandTargets: [0, 0, 0, 0, 0, 0] + property var bandDisplay: [0, 0, 0, 0, 0, 0] + property var prevLevels: [0, 0, 0, 0, 0, 0] + property real fluxAvg: 0.02 + property real loudCtx: 0.1 + property int beatCooldown: 0 + property real energyTarget: 0 + property real energyPos: 0 + property real energyVel: 0 + + function updateBands() { + const vals = CavaService.values; + if (!vals || vals.length < 6) + return; + + const s = smoothedBands; + const slow = slowBands; + const out = bandTargets; + const prev = prevLevels; + const w = fluxWeights; + let flux = 0; + for (let i = 0; i < 6; i++) { + const level = Math.min(Math.max(vals[i], 0), cavaFullScale) / cavaFullScale; + flux += Math.max(0, level - prev[i]) * w[i]; + prev[i] = level; + const alpha = level > s[i] ? blobAttack : blobRelease; + s[i] += alpha * (level - s[i]); + slow[i] += 0.05 * (level - slow[i]); + const punch = Math.max(0, s[i] - slow[i]) * blobBeatBoost; + out[i] = Math.min(1, (0.55 * s[i] + punch) * blobEnergySensitivity); + } + + const ratio = flux / Math.max(fluxAvg, 0.004); + fluxAvg += 0.06 * (flux - fluxAvg); + if (beatCooldown > 0) { + beatCooldown--; + } else if (ratio > blobOnsetThreshold && flux > 0.008) { + energyVel += blobBeatKick * Math.min(2.5, ratio - 1); + beatCooldown = 3; + } + + const loud = 0.7 * Math.max(prev[0], prev[1]) + 0.3 * Math.max(prev[2], prev[3]); + loudCtx += 0.03 * (loud - loudCtx); + const surge = Math.max(0, loud / Math.max(loudCtx, 0.05) - 1); + energyTarget = Math.min(1, 0.5 * loud + 0.6 * Math.min(1, surge)); + } + + function stepBlob(dt) { + energyVel += (blobSpringStiffness * (energyTarget - energyPos) - blobSpringDamping * energyVel) * dt; + energyPos = Math.max(0, Math.min(blobOvershoot, energyPos + energyVel * dt)); + blobEffect.energy = energyPos; + + const d = bandDisplay; + const t = bandTargets; + const f = Math.min(1, dt * 14); + for (let i = 0; i < 6; i++) + d[i] += f * (t[i] - d[i]); + blobEffect.bandsA = Qt.vector4d(d[0], d[1], d[2], d[3]); + blobEffect.bandsB = Qt.vector2d(d[4], d[5]); + + const speed = 1 + energyPos * blobMorphBoost; + blobEffect.phase = (blobEffect.phase + dt * blobMorphSpeed * speed) % 1; + blobEffect.spin = (blobEffect.spin + dt * blobSpinSpeed) % 6.28318530718; + } + + Loader { + active: root.blobActive + sourceComponent: Component { + Ref { + service: CavaService + } + } + } + + Connections { + target: CavaService + enabled: root.blobActive + + function onValuesChanged() { + root.updateBands(); + } + } + + Timer { + running: blobEffect.visible && root.onScreen + interval: 33 + repeat: true + onTriggered: root.stepBlob(0.033) + } + + ShaderEffect { + id: blobEffect + + readonly property real span: Math.min(root.width, root.height) + + width: span * (root.blobBaseRadiusFactor + root.blobAmplitudeFactor * root.blobOvershoot) * 2 * root.animationScale + 4 + height: width + anchors.centerIn: parent + visible: root.blobActive || activation > 0.004 + + property real phase: 0 + property real spin: 0 + property real sizePx: width + property real baseRadiusPx: span * root.blobBaseRadiusFactor * root.animationScale + property real amplitudePx: span * root.blobAmplitudeFactor * root.animationScale + property real activation: root.blobActive ? 1 : 0 + property real energy: 0 + property vector4d bandsA: Qt.vector4d(0, 0, 0, 0) + property vector2d bandsB: Qt.vector2d(0, 0) + property vector4d fillColor: Qt.vector4d(root.accentColor.r, root.accentColor.g, root.accentColor.b, root.accentColor.a) + + Behavior on activation { + NumberAnimation { + duration: 550 + easing.type: Easing.InOutQuad + } + } + + fragmentShader: Qt.resolvedUrl("../Shaders/qsb/blob.frag.qsb") + } +}