diff --git a/Components/CustomSlider.qml b/Components/CustomSlider.qml index 755d21f..3c906d8 100644 --- a/Components/CustomSlider.qml +++ b/Components/CustomSlider.qml @@ -13,11 +13,13 @@ Slider { property bool animateWave property color bgColor: enabled ? Colors.palette.m3secondaryContainer : Qt.alpha(Colors.palette.m3onSurface, 0.1) + readonly property bool dragging: mouse.pressed property color fgColor: enabled ? Colors.palette.m3primary : Qt.alpha(Colors.palette.m3onSurface, 0.38) property real filledWidth property alias inset: inset property color insetColor: enabled ? (inset.attached ? Colors.palette.m3onSecondaryContainer : Colors.palette.m3onPrimary) : Colors.palette.m3onSurface property string insetIcon: "" + property bool interactionOnMove: true property real pos: visualPosition property int radius: Tokens.rounding.small property int waveDuration: 1000 @@ -139,7 +141,7 @@ Slider { CAnim { } } - Anim on waveProgress { + NumberAnimation on waveProgress { duration: root.waveDuration easing.type: Easing.Linear from: 0 @@ -186,7 +188,8 @@ Slider { onPositionChanged: e => { dragMovement = (e.x - pressStartX) / width; - root.interaction(posBinding.value); + if (root.interactionOnMove) + root.interaction(posBinding.value); } onPressed: e => { widthBehavior.enabled = false; diff --git a/Modules/Dashboard/Dash/Media.qml b/Modules/Dashboard/Dash/Media.qml index 67a2384..775db58 100644 --- a/Modules/Dashboard/Dash/Media.qml +++ b/Modules/Dashboard/Dash/Media.qml @@ -1,10 +1,12 @@ pragma ComponentBehavior: Bound import Quickshell +import Quickshell.Services.Mpris import QtQuick import QtQuick.Layouts import QtQuick.Shapes import QtQuick.Effects +import ZShell.Components import ZShell.Services import ZShell.Config import qs.Daemons @@ -14,7 +16,7 @@ import qs.Services Item { id: root - + readonly property bool hasUnknownLength: (Players.active?.length ?? 0) > 2147483647 property real playerProgress: { const active = Players.active; return active?.length ? (active.position % active.length) / active.length : 0; @@ -23,7 +25,19 @@ Item { anchors.left: parent.left anchors.right: parent.right - implicitHeight: cover.height + rowHeight * 2 + implicitHeight: layout.implicitHeight + 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}`; + } Behavior on playerProgress { Anim { @@ -32,11 +46,10 @@ Item { } Timer { - interval: Config.dashboard.mediaUpdateInterval - repeat: true running: Players.active?.isPlaying ?? false + interval: Config.dashboard.mediaUpdateInterval triggeredOnStart: true - + repeat: true onTriggered: Players.active?.positionChanged() } @@ -72,7 +85,7 @@ Item { const n = values.length; if (n < 2) - return ""; + return ""; const h = layout.height + shape.strokeWidth / 2; const w = layout.width + shape.strokeWidth; @@ -126,58 +139,11 @@ Item { } } - Shape { - preferredRendererType: Shape.CurveRenderer - - ShapePath { - capStyle: Tokens.rounding.scale === 0 ? ShapePath.SquareCap : ShapePath.RoundCap - fillColor: "transparent" - strokeColor: Colors.layer(Colors.palette.m3surfaceContainerHigh, 2) - strokeWidth: Config.dashboard.sizes.mediaProgressThickness - - Behavior on strokeColor { - CAnim { - } - } - - PathAngleArc { - centerX: cover.x + cover.width / 2 - centerY: cover.y + cover.height / 2 - radiusX: (cover.width + Config.dashboard.sizes.mediaProgressThickness) / 2 + Tokens.spacing.small - radiusY: (cover.height + Config.dashboard.sizes.mediaProgressThickness) / 2 + Tokens.spacing.small - startAngle: -90 - Config.dashboard.sizes.mediaProgressSweep / 2 - sweepAngle: Config.dashboard.sizes.mediaProgressSweep - } - } - - ShapePath { - capStyle: Tokens.rounding.scale === 0 ? ShapePath.SquareCap : ShapePath.RoundCap - fillColor: "transparent" - strokeColor: Colors.palette.m3primary - strokeWidth: Config.dashboard.sizes.mediaProgressThickness - - Behavior on strokeColor { - CAnim { - } - } - - PathAngleArc { - centerX: cover.x + cover.width / 2 - centerY: cover.y + cover.height / 2 - radiusX: (cover.width + Config.dashboard.sizes.mediaProgressThickness) / 2 + Tokens.spacing.small - radiusY: (cover.height + Config.dashboard.sizes.mediaProgressThickness) / 2 + Tokens.spacing.small - startAngle: -90 - Config.dashboard.sizes.mediaProgressSweep / 2 - sweepAngle: Config.dashboard.sizes.mediaProgressSweep * root.playerProgress - } - } - } - RowLayout { id: layout anchors.left: parent.left anchors.right: parent.right - implicitHeight: root.implicitHeight CustomClippingRect { id: cover @@ -211,147 +177,174 @@ Item { } } - CustomRect { + ColumnLayout { Layout.fillWidth: true + Layout.leftMargin: Tokens.spacing.large + Layout.rightMargin: Tokens.spacing.large Layout.preferredHeight: childrenRect.height + spacing: Tokens.spacing.normal + MarqueeText { id: title - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: parent.top + Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter color: Colors.palette.m3primary font.pointSize: Tokens.font.size.normal horizontalAlignment: Text.AlignHCenter pauseMs: 4000 text: (Players.active?.trackTitle ?? qsTr("No media")) || qsTr("Unknown title") - width: parent.width - Tokens.padding.large * 4 + implicitWidth: parent.width - Tokens.padding.large * 4 } MarqueeText { id: album - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: title.bottom - anchors.topMargin: Tokens.spacing.small + Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter color: Colors.palette.m3outline font.pointSize: Tokens.font.size.small horizontalAlignment: Text.AlignHCenter pauseMs: 4000 text: (Players.active?.trackAlbum ?? qsTr("No media")) || qsTr("Unknown album") - width: parent.width - Tokens.padding.large * 4 + implicitWidth: parent.width - Tokens.padding.large * 4 } MarqueeText { id: artist - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: album.bottom - anchors.topMargin: Tokens.spacing.small + Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter color: Colors.palette.m3secondary horizontalAlignment: Text.AlignHCenter pauseMs: 4000 text: (Players.active?.trackArtist ?? qsTr("No media")) || qsTr("Unknown artist") - width: parent.width - Tokens.padding.large * 4 + implicitWidth: parent.width - Tokens.padding.large * 4 } RowLayout { - id: controls - - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: artist.bottom - anchors.topMargin: Tokens.spacing.smaller + id: positionSliderLayout + Layout.topMargin: Tokens.spacing.extraLargeIncreased + Layout.fillWidth: true spacing: Tokens.spacing.small - Control { - function onClicked(): void { - Players.active?.previous(); - } + TextMetrics { + id: timeMetrics - canUse: Players.active?.canGoPrevious ?? false + text: Players.active ? root.lengthStr(Math.max(Players.active.position, root.hasUnknownLength ? 0 : Players.active.length)).replace(/[1-9]/g, "0") : "00:00" + } + + CustomText { + id: positionLabel + + Layout.preferredWidth: timeMetrics.width + text: root.lengthStr(Players.active?.position ?? -1) + color: Colors.palette.m3onSurfaceVariant + font: timeMetrics.font + horizontalAlignment: Text.AlignHCenter + } + + CustomSlider { + id: positionSlider + + Layout.fillWidth: true + value: Players.active ? Players.active.position / (Players.active.length || 1) : 0 + enabled: (Players.active?.canSeek ?? false) && !root.hasUnknownLength + wavy: true + animateWave: Players.active?.isPlaying ?? false + waveFrequency: 5 + waveDuration: 2000 + interactionOnMove: false + onInteraction: value => { + const active = Players.active; + if (active?.canSeek && active?.positionSupported) + active.position = value * active.length; + } + Binding { + target: positionLabel + property: "text" + value: root.lengthStr(positionSlider.pos * (Players.active?.length ?? 0)) + when: positionSlider.dragging + } + } + + CustomText { + Layout.preferredWidth: timeMetrics.width + text: root.hasUnknownLength ? "--:--" : root.lengthStr(Players.active?.length ?? -1) + color: Colors.palette.m3onSurfaceVariant + font: timeMetrics.font + horizontalAlignment: Text.AlignHCenter + } + } + ButtonRow { + Layout.topMargin: Tokens.spacing.largeIncreased + Layout.fillWidth: true + spacing: Tokens.spacing.extraSmall + Layout.leftMargin: positionSliderLayout.implicitWidth / 4 + Layout.rightMargin: positionSliderLayout.implicitWidth / 4 + + IconButton { + type: IconButton.Tonal + icon: "shuffle" + isRound: true + shapeMorph: true + checked: Players.active?.shuffle ?? false + enabled: Players.active?.shuffleSupported + onClicked: Players.active.shuffle = !Players.active?.shuffle + implicitWidth: Math.round(implicitHeight * 0.9) + } + + IconButton { + id: previousBtn + + type: IconButton.Tonal icon: "skip_previous" + isRound: true + shapeMorph: true + enabled: Players.active?.canGoPrevious + onClicked: Players.active?.previous() } - Control { - function onClicked(): void { - Players.active?.togglePlaying(); - } + IconButton { + id: playPauseBtn - canUse: Players.active?.canTogglePlaying ?? false icon: Players.active?.isPlaying ? "pause" : "play_arrow" + isRound: true + shapeMorph: true + fillWidth: true + checked: Players.active?.isPlaying ?? false + enabled: Players.active?.canTogglePlaying + onClicked: Players.active?.togglePlaying() } - Control { - function onClicked(): void { - Players.active?.next(); - } + IconButton { + id: nextBtn - canUse: Players.active?.canGoNext ?? false + type: IconButton.Tonal icon: "skip_next" + isRound: true + shapeMorph: true + enabled: Players.active?.canGoNext + onClicked: Players.active?.next() + } + + IconButton { + type: IconButton.Tonal + icon: Players.active?.loopState === MprisLoopState.Track ? "repeat_one" : "repeat" + isRound: true + shapeMorph: true + checked: Players.active?.loopState === MprisLoopState.Track || Players.active?.loopState === MprisLoopState.Playlist + enabled: Players.active?.loopSupported + onClicked: { + const state = Players.active.loopState; + if (state === MprisLoopState.None) + Players.active.loopState = MprisLoopState.Track; + else if (state === MprisLoopState.Track) + Players.active.loopState = MprisLoopState.Playlist; + else + Players.active.loopState = MprisLoopState.None; + } + implicitWidth: Math.round(implicitHeight * 0.9) } } } } - - component Control: CustomRect { - id: control - - required property bool canUse - required property string icon - property int level: 1 - property string set_color: "Secondary" - - function onClicked(): void { - } - - Layout.preferredWidth: implicitWidth + (controlState.pressed ? Tokens.padding.normal * 2 : 0) - color: canUse ? Colors.palette[`m3${set_color.toLowerCase()}`] : Colors.palette[`m3${set_color.toLowerCase()}Container`] - implicitHeight: implicitWidth - implicitWidth: Math.max(icon.implicitHeight, icon.implicitHeight) + Tokens.padding.small - radius: Tokens.rounding.full - - Behavior on Layout.preferredWidth { - Anim { - duration: Tokens.anim.durations.expressiveFastSpatial - easing.bezierCurve: Tokens.anim.curves.expressiveFastSpatial - } - } - Behavior on radius { - Anim { - duration: Tokens.anim.durations.expressiveFastSpatial - easing.bezierCurve: Tokens.anim.curves.expressiveFastSpatial - } - } - - Elevation { - anchors.fill: parent - level: controlState.containsMouse && !controlState.pressed ? control.level + 1 : control.level - radius: parent.radius - z: -1 - } - - StateLayer { - id: controlState - - color: control.canUse ? Colors.palette[`m3on${control.set_color}`] : Colors.palette[`m3on${control.set_color}Container`] - enabled: control.canUse - - onClicked: { - control.onClicked(); - } - // radius: Tokens.rounding.full - } - - MaterialIcon { - id: icon - - anchors.centerIn: parent - anchors.verticalCenterOffset: font.pointSize * 0.05 - animate: true - color: control.canUse ? Colors.palette[`m3on${control.set_color}`] : Colors.palette[`m3on${control.set_color}Container`] - fill: control.canUse ? 1 : 0 - font.pointSize: Tokens.font.size.large - text: control.icon - } - } }