island: re-add different player style

This commit is contained in:
bbedward committed 2026-09-02 14:47:01 -04:00
1 parent 65ee46e330
commit 863152a441
11 files changed
+1228 -767

No files matched your search

@@ -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();
}
}
}
}
@@ -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
}
}
@@ -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
}
}
+19 -622
View File
@@ -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
}
}
}
@@ -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
@@ -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
@@ -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)
@@ -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)
+7 -141
View File
@@ -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
+129
View File
@@ -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
}
}
}
+157
View File
@@ -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")
}
}