mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-10-30 15:07:49 -04:00
island: re-add different player style
This commit is contained in:
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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user