pragma ComponentBehavior: Bound import qs.components import qs.components.effects import qs.components.controls import qs.services import qs.utils import qs.config import Caelestia.Services import Quickshell import Quickshell.Widgets import Quickshell.Services.Mpris import QtQuick import QtQuick.Layouts import QtQuick.Shapes Item { id: root required property PersistentProperties visibilities property real playerProgress: { const active = Players.active; return active?.length ? active.position / active.length : 0; } function lengthStr(length: int): string { if (length < 0) return "-1:-1"; const hours = Math.floor(length / 3600); const mins = Math.floor((length % 3600) / 60); const secs = Math.floor(length % 60).toString().padStart(2, "0"); if (hours > 0) return `${hours}:${mins.toString().padStart(2, "0")}:${secs}`; return `${mins}:${secs}`; } implicitWidth: cover.implicitWidth + Config.dashboard.sizes.mediaVisualiserSize * 2 + details.implicitWidth + details.anchors.leftMargin + bongocat.implicitWidth + bongocat.anchors.leftMargin * 2 + Appearance.padding.large * 2 implicitHeight: Math.max(cover.implicitHeight + Config.dashboard.sizes.mediaVisualiserSize * 2, details.implicitHeight, bongocat.implicitHeight) + Appearance.padding.large * 2 Behavior on playerProgress { Anim { duration: Appearance.anim.durations.large } } Timer { running: Players.active?.isPlaying ?? false interval: Config.dashboard.mediaUpdateInterval triggeredOnStart: true repeat: true onTriggered: Players.active?.positionChanged() } ServiceRef { service: Audio.cava } ServiceRef { service: Audio.beatTracker } Shape { id: visualiser readonly property real centerX: width / 2 readonly property real centerY: height / 2 readonly property real innerX: cover.implicitWidth / 2 + Appearance.spacing.small readonly property real innerY: cover.implicitHeight / 2 + Appearance.spacing.small property color colour: Colours.palette.m3primary anchors.fill: cover anchors.margins: -Config.dashboard.sizes.mediaVisualiserSize asynchronous: true preferredRendererType: Shape.CurveRenderer data: visualiserBars.instances } Variants { id: visualiserBars model: Array.from({ length: Config.services.visualiserBars }, (_, i) => i) ShapePath { id: visualiserBar required property int modelData readonly property real value: Math.max(1e-3, Math.min(1, Audio.cava.values[modelData])) readonly property real angle: modelData * 2 * Math.PI / Config.services.visualiserBars readonly property real magnitude: value * Config.dashboard.sizes.mediaVisualiserSize readonly property real cos: Math.cos(angle) readonly property real sin: Math.sin(angle) capStyle: Appearance.rounding.scale === 0 ? ShapePath.SquareCap : ShapePath.RoundCap strokeWidth: 360 / Config.services.visualiserBars - Appearance.spacing.small / 4 strokeColor: Colours.palette.m3primary startX: visualiser.centerX + (visualiser.innerX + strokeWidth / 2) * cos startY: visualiser.centerY + (visualiser.innerY + strokeWidth / 2) * sin PathLine { x: visualiser.centerX + (visualiser.innerX + visualiserBar.strokeWidth / 2 + visualiserBar.magnitude) * visualiserBar.cos y: visualiser.centerY + (visualiser.innerY + visualiserBar.strokeWidth / 2 + visualiserBar.magnitude) * visualiserBar.sin } Behavior on strokeColor { CAnim {} } } } StyledClippingRect { id: cover anchors.verticalCenter: parent.verticalCenter anchors.left: parent.left anchors.leftMargin: Appearance.padding.large + Config.dashboard.sizes.mediaVisualiserSize implicitWidth: Config.dashboard.sizes.mediaCoverArtSize implicitHeight: Config.dashboard.sizes.mediaCoverArtSize color: Colours.tPalette.m3surfaceContainerHigh radius: Infinity MaterialIcon { anchors.centerIn: parent grade: 200 text: "art_track" color: Colours.palette.m3onSurfaceVariant font.pointSize: (parent.width * 0.4) || 1 } Image { id: image anchors.fill: parent source: Players.active?.trackArtUrl ?? "" asynchronous: true fillMode: Image.PreserveAspectCrop sourceSize.width: width sourceSize.height: height } } ColumnLayout { id: details anchors.verticalCenter: parent.verticalCenter anchors.left: visualiser.right anchors.leftMargin: Appearance.spacing.normal spacing: Appearance.spacing.small StyledText { id: title Layout.fillWidth: true Layout.maximumWidth: parent.implicitWidth animate: true horizontalAlignment: Text.AlignHCenter text: (Players.active?.trackTitle ?? qsTr("No media")) || qsTr("Unknown title") color: Players.active ? Colours.palette.m3primary : Colours.palette.m3onSurface font.pointSize: Appearance.font.size.normal elide: Text.ElideRight } StyledText { id: album Layout.fillWidth: true Layout.maximumWidth: parent.implicitWidth animate: true horizontalAlignment: Text.AlignHCenter visible: !!Players.active text: Players.active?.trackAlbum || qsTr("Unknown album") color: Colours.palette.m3outline font.pointSize: Appearance.font.size.small elide: Text.ElideRight } StyledText { id: artist Layout.fillWidth: true Layout.maximumWidth: parent.implicitWidth animate: true horizontalAlignment: Text.AlignHCenter text: (Players.active?.trackArtist ?? qsTr("Play some music for stuff to show up here!")) || qsTr("Unknown artist") color: Players.active ? Colours.palette.m3secondary : Colours.palette.m3outline elide: Text.ElideRight wrapMode: Players.active ? Text.NoWrap : Text.WordWrap } RowLayout { id: controls Layout.alignment: Qt.AlignHCenter Layout.topMargin: Appearance.spacing.small Layout.bottomMargin: Appearance.spacing.smaller spacing: Appearance.spacing.small PlayerControl { type: IconButton.Text icon: "skip_previous" font.pointSize: Math.round(Appearance.font.size.large * 1.5) disabled: !Players.active?.canGoPrevious onClicked: Players.active?.previous() } PlayerControl { icon: Players.active?.isPlaying ? "pause" : "play_arrow" label.animate: true toggle: true padding: Appearance.padding.small / 2 checked: Players.active?.isPlaying font.pointSize: Math.round(Appearance.font.size.large * 1.5) disabled: !Players.active?.canTogglePlaying onClicked: Players.active?.togglePlaying() } PlayerControl { type: IconButton.Text icon: "skip_next" font.pointSize: Math.round(Appearance.font.size.large * 1.5) disabled: !Players.active?.canGoNext onClicked: Players.active?.next() } } StyledSlider { id: slider enabled: !!Players.active implicitWidth: 280 implicitHeight: Appearance.padding.normal * 3 onMoved: { const active = Players.active; if (active?.canSeek && active?.positionSupported) active.position = value * active.length; } Binding { target: slider property: "value" value: root.playerProgress when: !slider.pressed } CustomMouseArea { anchors.fill: parent acceptedButtons: Qt.NoButton function onWheel(event: WheelEvent) { const active = Players.active; if (!active?.canSeek || !active?.positionSupported) return; event.accepted = true; const delta = event.angleDelta.y > 0 ? 10 : -10; // Time 10 seconds Qt.callLater(() => { active.position = Math.max(0, Math.min(active.length, active.position + delta)); }); } } } Item { Layout.fillWidth: true implicitHeight: Math.max(position.implicitHeight, length.implicitHeight) StyledText { id: position anchors.left: parent.left text: root.lengthStr(Players.active?.position ?? -1) color: Colours.palette.m3onSurfaceVariant font.pointSize: Appearance.font.size.small } StyledText { id: length anchors.right: parent.right text: root.lengthStr(Players.active?.length ?? -1) color: Colours.palette.m3onSurfaceVariant font.pointSize: Appearance.font.size.small } } RowLayout { Layout.alignment: Qt.AlignHCenter spacing: Appearance.spacing.small PlayerControl { type: IconButton.Text icon: "move_up" inactiveOnColour: Colours.palette.m3secondary padding: Appearance.padding.small font.pointSize: Appearance.font.size.large disabled: !Players.active?.canRaise onClicked: { Players.active?.raise(); root.visibilities.dashboard = false; } } SplitButton { id: playerSelector disabled: !Players.list.length active: menuItems.find(m => m.modelData === Players.active) ?? menuItems[0] menu.onItemSelected: item => Players.manualActive = item.modelData menuItems: playerList.instances fallbackIcon: "music_off" fallbackText: qsTr("No players") label.Layout.maximumWidth: slider.implicitWidth * 0.28 label.elide: Text.ElideRight stateLayer.disabled: true menuOnTop: true Variants { id: playerList model: Players.list MenuItem { required property MprisPlayer modelData icon: modelData === Players.active ? "check" : "" text: Players.getIdentity(modelData) activeIcon: "animated_images" } } } PlayerControl { type: IconButton.Text icon: "delete" inactiveOnColour: Colours.palette.m3error padding: Appearance.padding.small font.pointSize: Appearance.font.size.large disabled: !Players.active?.canQuit onClicked: Players.active?.quit() } } } Item { id: bongocat anchors.verticalCenter: parent.verticalCenter anchors.left: details.right anchors.leftMargin: Appearance.spacing.normal implicitWidth: visualiser.width implicitHeight: visualiser.height AnimatedImage { anchors.centerIn: parent width: visualiser.width * 0.75 height: visualiser.height * 0.75 playing: Players.active?.isPlaying ?? false speed: Audio.beatTracker.bpm / 300 source: Paths.absolutePath(Config.paths.mediaGif) asynchronous: true fillMode: AnimatedImage.PreserveAspectFit } } component PlayerControl: IconButton { Layout.preferredWidth: implicitWidth + (stateLayer.pressed ? Appearance.padding.large : internalChecked ? Appearance.padding.smaller : 0) radius: stateLayer.pressed ? Appearance.rounding.small / 2 : internalChecked ? Appearance.rounding.small : implicitHeight / 2 radiusAnim.duration: Appearance.anim.durations.expressiveFastSpatial radiusAnim.easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial Behavior on Layout.preferredWidth { Anim { duration: Appearance.anim.durations.expressiveFastSpatial easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial } } } }