From a28316ad5a1d15fa160324ef7d2b060c6d45a79c Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Mon, 4 May 2026 03:47:03 +1000 Subject: [PATCH] feat: improve dashboard media tab --- components/controls/SplitButton.qml | 2 + modules/dashboard/Content.qml | 4 +- modules/dashboard/Media.qml | 489 +----------------- modules/dashboard/MediaWrapper.qml | 13 - modules/dashboard/media/CoverVisualiser.qml | 84 +++ modules/dashboard/media/Details.qml | 177 +++++++ modules/dashboard/media/LyricList.qml | 293 +++++++++++ modules/dashboard/media/LyricsAndSelector.qml | 83 +++ plugin/src/Caelestia/Config/tokens.hpp | 6 +- services/Players.qml | 2 + 10 files changed, 662 insertions(+), 491 deletions(-) delete mode 100644 modules/dashboard/MediaWrapper.qml create mode 100644 modules/dashboard/media/CoverVisualiser.qml create mode 100644 modules/dashboard/media/Details.qml create mode 100644 modules/dashboard/media/LyricList.qml create mode 100644 modules/dashboard/media/LyricsAndSelector.qml diff --git a/components/controls/SplitButton.qml b/components/controls/SplitButton.qml index 9724f6b6..2feaf615 100644 --- a/components/controls/SplitButton.qml +++ b/components/controls/SplitButton.qml @@ -28,6 +28,8 @@ Row { readonly property alias iconLabel: iconLabel readonly property alias label: label readonly property alias stateLayer: stateLayer + readonly property alias textRow: textRow + readonly property alias expandBtn: expandBtn property color colour: type == SplitButton.Filled ? Colours.palette.m3primary : Colours.palette.m3secondaryContainer property color textColour: type == SplitButton.Filled ? Colours.palette.m3onPrimary : Colours.palette.m3onSecondaryContainer diff --git a/modules/dashboard/Content.qml b/modules/dashboard/Content.qml index 56360edc..807664c9 100644 --- a/modules/dashboard/Content.qml +++ b/modules/dashboard/Content.qml @@ -16,7 +16,7 @@ Item { const count = repeater.count; for (let i = 0; i < count; i++) { const item = repeater.itemAt(i) as Loader; - if (item?.sourceComponent === mediaComponent && (item?.item as MediaWrapper)?.needsKeyboard) + if (item?.sourceComponent === mediaComponent && (item?.item as Media)?.needsKeyboard) return true; } return false; @@ -174,7 +174,7 @@ Item { Component { id: mediaComponent - MediaWrapper { + Media { visibilities: root.visibilities } } diff --git a/modules/dashboard/Media.qml b/modules/dashboard/Media.qml index 697a4b24..82111843 100644 --- a/modules/dashboard/Media.qml +++ b/modules/dashboard/Media.qml @@ -1,495 +1,36 @@ -pragma ComponentBehavior: Bound - +import "media" import QtQuick import QtQuick.Layouts -import QtQuick.Shapes -import Quickshell -import Quickshell.Services.Mpris import Caelestia.Config -import Caelestia.Services import qs.components -import qs.components.controls -import qs.components.widgets import qs.services -import qs.utils Item { id: root required property DrawerVisibilities visibilities - readonly property bool needsKeyboard: lyricMenuOpen + readonly property bool needsKeyboard: false // TODO - readonly property real nonAnimHeight: Math.max(cover.implicitHeight + Tokens.sizes.dashboard.mediaVisualiserSize * 2, lyricMenuOpen ? lyricMenu.implicitHeight : details.implicitHeight, bongocat.implicitHeight) + Tokens.padding.extraLargeIncreased - readonly property real detailsHeightWithoutLyrics: details.implicitHeight - lyricsViewInDetails.implicitHeight - - property bool lyricMenuOpen: false - property bool lyricsShowing: LyricsService.lyricsVisible && LyricsService.model.count != 0 - property bool lyricsShowingDebounced: false - - property real playerProgress: { - const active = Players.active; - return active?.length ? (active.position % active.length) / 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}`; - } - - onLyricsShowingChanged: { - if (lyricsShowing) { - lyricsHideDelay.stop(); - lyricsShowingDebounced = true; - } else { - lyricsHideDelay.restart(); - } - } - - implicitWidth: cover.implicitWidth + Tokens.sizes.dashboard.mediaVisualiserSize * 2 + details.implicitWidth + details.anchors.leftMargin + bongocat.implicitWidth + bongocat.anchors.leftMargin * 2 + Tokens.padding.extraLargeIncreased - implicitHeight: nonAnimHeight - - Behavior on implicitHeight { - Anim {} - } - - Behavior on playerProgress { - Anim { - type: Anim.StandardLarge - } - } - - Timer { - running: Players.active?.isPlaying ?? false - interval: GlobalConfig.dashboard.mediaUpdateInterval - triggeredOnStart: true - repeat: true - onTriggered: { - if (!Players.active) - return; - LyricsService.updatePosition(); - Players.active?.positionChanged(); - } - } - - Timer { - id: lyricsHideDelay - - interval: 300 - repeat: false - } - - Connections { - function onTriggered() { - root.lyricsShowingDebounced = false; - } - - target: lyricsHideDelay - } - - 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 coverMaxRadius: { - const bounds = cover.shape.pathBounds(); - return Math.max(bounds.width, bounds.height) / 2; - } - readonly property real spacing: Tokens.spacing.medium - - anchors.fill: cover - anchors.margins: -Tokens.sizes.dashboard.mediaVisualiserSize - - asynchronous: true - preferredRendererType: Shape.CurveRenderer - data: visualiserBars.instances - } - - Variants { - id: visualiserBars - - model: Array.from({ - length: GlobalConfig.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 / GlobalConfig.services.visualiserBars - readonly property real magnitude: value * root.Tokens.sizes.dashboard.mediaVisualiserSize - readonly property real shapeEdgeDist: { - cover.shape.rotation; // Update when shape rotation changes - return cover.shape.distanceAtAngle(modelData * 360 / GlobalConfig.services.visualiserBars); - } - readonly property real cos: Math.cos(angle) - readonly property real sin: Math.sin(angle) - - capStyle: root.Tokens.rounding.scale === 0 ? ShapePath.SquareCap : ShapePath.RoundCap - strokeWidth: 360 / GlobalConfig.services.visualiserBars - root.Tokens.spacing.small / 4 - strokeColor: Colours.palette.m3primary - - startX: visualiser.centerX + (shapeEdgeDist + visualiser.spacing + strokeWidth / 2) * cos - startY: visualiser.centerY + (shapeEdgeDist + visualiser.spacing + strokeWidth / 2) * sin - - PathLine { - x: visualiser.centerX + (visualiser.coverMaxRadius + visualiser.spacing + visualiserBar.strokeWidth / 2 + visualiserBar.magnitude) * visualiserBar.cos - y: visualiser.centerY + (visualiser.coverMaxRadius + visualiser.spacing + visualiserBar.strokeWidth / 2 + visualiserBar.magnitude) * visualiserBar.sin - } - - Behavior on strokeColor { - CAnim {} - } - } - } - - CoverArt { - id: cover - - anchors.verticalCenter: parent.verticalCenter - anchors.left: parent.left - anchors.leftMargin: Tokens.padding.large + Tokens.sizes.dashboard.mediaVisualiserSize - - implicitWidth: Tokens.sizes.dashboard.mediaCoverArtSize - implicitHeight: Tokens.sizes.dashboard.mediaCoverArtSize - - MouseArea { - anchors.fill: parent - onClicked: { - LyricsService.toggleVisibility(); - } - } - } - - ColumnLayout { - id: details - - anchors.verticalCenter: parent.verticalCenter - anchors.left: visualiser.right - anchors.leftMargin: Tokens.spacing.medium - - spacing: Tokens.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: Tokens.font.title.small - 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: Tokens.font.body.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 - } - - LyricsView { - id: lyricsViewInDetails - - Layout.fillWidth: true - Layout.preferredHeight: 200 - } - - RowLayout { - id: controls - - Layout.alignment: Qt.AlignHCenter - Layout.topMargin: Tokens.spacing.small - Layout.bottomMargin: Tokens.spacing.medium - - spacing: Tokens.spacing.small - - PlayerControl { - type: IconButton.Text - icon: Players.active?.shuffle ? "shuffle_on" : "shuffle" - font: Tokens.font.icon.size(Math.round(Tokens.font.icon.large.pointSize)).build() - disabled: !Players.active?.shuffleSupported - onClicked: Players.active.shuffle = !Players.active?.shuffle - } - - PlayerControl { - type: IconButton.Text - icon: "skip_previous" - font: Tokens.font.icon.builders.large.scale(1.5).build() - disabled: !Players.active?.canGoPrevious - onClicked: Players.active?.previous() - } - - PlayerControl { - icon: Players.active?.isPlaying ? "pause" : "play_arrow" - label.animate: true - isToggle: true - padding: Tokens.padding.extraSmall / 2 - checked: Players.active?.isPlaying ?? false - font: Tokens.font.icon.builders.large.scale(1.5).build() - disabled: !Players.active?.canTogglePlaying - onClicked: Players.active?.togglePlaying() - } - - PlayerControl { - type: IconButton.Text - icon: "skip_next" - font: Tokens.font.icon.builders.large.scale(1.5).build() - disabled: !Players.active?.canGoNext - onClicked: Players.active?.next() - } - - PlayerControl { - type: IconButton.Text - icon: "lyrics" - font: Tokens.font.icon.size(Math.round(Tokens.font.icon.large.pointSize)).build() - onClicked: root.lyricMenuOpen = !root.lyricMenuOpen - } - } - - StyledSlider { - id: slider - - enabled: !!Players.active - implicitWidth: 280 - implicitHeight: Tokens.padding.medium * 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 { - 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)); - }); - } - - anchors.fill: parent - acceptedButtons: Qt.NoButton - } - } - - Item { - Layout.fillWidth: true - implicitHeight: Math.max(position.implicitHeight, length.implicitHeight) - - StyledText { - id: position - - anchors.left: parent.left - - text: root.lengthStr(Players.active ? Players.active.position % Players.active.length : -1) - color: Colours.palette.m3onSurfaceVariant - font: Tokens.font.body.small - } - - StyledText { - id: length - - anchors.right: parent.right - - text: root.lengthStr(Players.active?.length ?? -1) - color: Colours.palette.m3onSurfaceVariant - font: Tokens.font.body.small - } - } - } - - ColumnLayout { - id: leftSection - - anchors.verticalCenter: parent.verticalCenter - anchors.verticalCenterOffset: playerChanger.parent == leftSection ? -playerChanger.height : 0 - anchors.left: details.right - anchors.leftMargin: Tokens.spacing.medium - - visible: lyricMenu.height === 0 || opacity > 0 - opacity: lyricMenu.height === 0 ? 1 : 0 - - Behavior on opacity { - NumberAnimation { - duration: Tokens.anim.durations.normal - easing.type: Easing.OutCubic - } - } - - Item { - id: bongocat - - 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 / Config.general.mediaGifSpeedAdjustment // qmllint disable unresolved-type - source: Paths.absolutePath(Config.paths.mediaGif) - asynchronous: true - fillMode: AnimatedImage.PreserveAspectFit - } - } - } - - LyricMenu { - id: lyricMenu - - anchors.top: parent.top - anchors.left: details.right - anchors.right: parent.right - anchors.leftMargin: Tokens.spacing.medium - - contentHeight: !root.lyricsShowingDebounced ? root.detailsHeightWithoutLyrics + Tokens.padding.large * 5 : root.detailsHeightWithoutLyrics + lyricsViewInDetails.implicitHeight - - visible: root.lyricMenuOpen || height > 0 - height: root.lyricMenuOpen ? implicitHeight : 0 - clip: true - - Behavior on height { - NumberAnimation { - duration: Tokens.anim.durations.normal - easing.type: Easing.OutCubic - } - } - } + implicitWidth: Tokens.sizes.dashboard.mediaTabWidth + implicitHeight: Tokens.sizes.dashboard.mediaTabHeight RowLayout { - id: playerChanger + anchors.fill: parent + anchors.margins: Tokens.padding.large + spacing: Tokens.spacing.extraLarge - parent: !root.lyricsShowingDebounced ? details : leftSection - Layout.alignment: Qt.AlignHCenter - spacing: Tokens.spacing.small - - PlayerControl { - type: IconButton.Text - icon: "move_up" - inactiveOnColour: Colours.palette.m3secondary - padding: Tokens.padding.small - font: Tokens.font.icon.large - disabled: !Players.active?.canRaise - onClicked: { - Players.active?.raise(); - root.visibilities.dashboard = false; - } + CoverVisualiser { + Layout.fillHeight: true + implicitWidth: Tokens.sizes.dashboard.mediaSectionWidth } - SplitButton { - id: playerSelector - - disabled: !Players.list.length - active: menuItems.find(m => m.modelData === Players.active) ?? menuItems[0] ?? null - menu.onItemSelected: item => Players.manualActive = (item as PlayerItem).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 - - PlayerItem {} - } + Details { + Layout.fillWidth: true } - PlayerControl { - type: IconButton.Text - icon: "delete" - inactiveOnColour: Colours.palette.m3error - padding: Tokens.padding.small - font: Tokens.font.icon.large - disabled: !Players.active?.canQuit - onClicked: Players.active?.quit() - } - } - - component PlayerItem: MenuItem { - required property MprisPlayer modelData - - icon: modelData === Players.active ? "check" : "" - text: Players.getIdentity(modelData) - activeIcon: "animated_images" - } - - component PlayerControl: IconButton { - Layout.preferredWidth: implicitWidth + (stateLayer.pressed ? Tokens.padding.large : internalChecked ? Tokens.padding.small : 0) - radius: stateLayer.pressed ? Tokens.rounding.medium / 2 : internalChecked ? Tokens.rounding.medium : implicitHeight / 2 - radiusAnim.duration: Tokens.anim.durations.expressiveFastSpatial - radiusAnim.easing: Tokens.anim.expressiveFastSpatial - - Behavior on Layout.preferredWidth { - Anim { - type: Anim.FastSpatial - } + LyricsAndSelector { + Layout.fillHeight: true + implicitWidth: Tokens.sizes.dashboard.mediaSectionWidth } } } diff --git a/modules/dashboard/MediaWrapper.qml b/modules/dashboard/MediaWrapper.qml deleted file mode 100644 index b03a11ee..00000000 --- a/modules/dashboard/MediaWrapper.qml +++ /dev/null @@ -1,13 +0,0 @@ -import QtQuick - -Item { - property alias visibilities: media.visibilities - readonly property alias needsKeyboard: media.needsKeyboard - - implicitWidth: media.implicitWidth - implicitHeight: media.nonAnimHeight - - Media { - id: media - } -} diff --git a/modules/dashboard/media/CoverVisualiser.qml b/modules/dashboard/media/CoverVisualiser.qml new file mode 100644 index 00000000..96ad8105 --- /dev/null +++ b/modules/dashboard/media/CoverVisualiser.qml @@ -0,0 +1,84 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Shapes +import Quickshell +import Caelestia.Config +import Caelestia.Services +import qs.components +import qs.components.widgets +import qs.services + +Item { + id: root + + readonly property real centerX: width / 2 + readonly property real centerY: height / 2 + readonly property real coverMaxRadius: { + const bounds = cover.shape.pathBounds(); + return Math.max(bounds.width, bounds.height) / 2; + } + readonly property real spacing: Tokens.spacing.medium + readonly property real maxMagnitude: (implicitWidth - cover.implicitWidth) / 2 - spacing + + ServiceRef { + service: Audio.cava + } + + Shape { + anchors.fill: parent + asynchronous: true + preferredRendererType: Shape.CurveRenderer + data: bars.instances + } + + Variants { + id: bars + + model: Array.from({ + length: GlobalConfig.services.visualiserBars + }, (_, i) => i) + + ShapePath { + id: bar + + 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 / GlobalConfig.services.visualiserBars + readonly property real dist: root.coverMaxRadius + root.spacing + strokeWidth / 2 + value * root.maxMagnitude + readonly property real shapeEdgeDist: { + cover.shape.rotation; // Update when shape rotation changes + const sDist = cover.shape.distanceAtAngle(modelData * 360 / GlobalConfig.services.visualiserBars); + return sDist + root.spacing + strokeWidth / 2; + } + readonly property real cos: Math.cos(angle) + readonly property real sin: Math.sin(angle) + + asynchronous: true + capStyle: root.Tokens.rounding.scale === 0 ? ShapePath.SquareCap : ShapePath.RoundCap + strokeWidth: 360 / GlobalConfig.services.visualiserBars - root.Tokens.spacing.small / 4 + strokeColor: Colours.palette.m3primary + + startX: root.centerX + shapeEdgeDist * cos + startY: root.centerY + shapeEdgeDist * sin + + PathLine { + x: root.centerX + bar.dist * bar.cos + y: root.centerY + bar.dist * bar.sin + } + + Behavior on strokeColor { + CAnim {} + } + } + } + + CoverArt { + id: cover + + anchors.centerIn: parent + implicitWidth: Tokens.sizes.dashboard.mediaCoverArtSize + implicitHeight: Tokens.sizes.dashboard.mediaCoverArtSize + } +} diff --git a/modules/dashboard/media/Details.qml b/modules/dashboard/media/Details.qml new file mode 100644 index 00000000..2a08274a --- /dev/null +++ b/modules/dashboard/media/Details.qml @@ -0,0 +1,177 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell.Services.Mpris +import Caelestia.Components +import Caelestia.Config +import qs.components +import qs.components.controls +import qs.services + +ColumnLayout { + id: root + + 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}`; + } + + spacing: Tokens.spacing.extraSmall + + Timer { + running: Players.active?.isPlaying ?? false + interval: GlobalConfig.dashboard.mediaUpdateInterval + triggeredOnStart: true + repeat: true + onTriggered: { + if (!Players.active) + return; + + LyricsService.updatePosition(); + Players.active.positionChanged(); + } + } + + StyledText { + Layout.fillWidth: true + text: Players.active?.trackTitle ?? "" + font: Tokens.font.title.large + elide: Text.ElideRight + animate: true + } + + StyledText { + Layout.fillWidth: true + text: Players.active?.trackArtist ?? qsTr("Unknown artist") + color: Colours.palette.m3onSurfaceVariant + font: Tokens.font.title.medium + elide: Text.ElideRight + animate: true + } + + StyledText { + Layout.fillWidth: true + text: Players.active?.trackAlbum ?? qsTr("Unknown album") + color: Colours.palette.m3secondary + font: Tokens.font.title.medium + elide: Text.ElideRight + animate: true + } + + RowLayout { + Layout.topMargin: Tokens.spacing.extraLargeIncreased + Layout.fillWidth: true + spacing: Tokens.spacing.small + + TextMetrics { + id: timeMetrics + + text: Players.active ? root.lengthStr(Math.max(Players.active.position, Players.active.length)).replace(/[1-9]/g, "0") : "00:00" + font: Tokens.font.label.medium + } + + StyledText { + Layout.preferredWidth: timeMetrics.width + text: root.lengthStr(Players.active?.position ?? -1) + color: Colours.palette.m3onSurfaceVariant + font: timeMetrics.font + horizontalAlignment: Text.AlignHCenter + } + + StyledSlider { + Layout.fillWidth: true + value: Players.active ? Players.active.position / (Players.active.length || 1) : 0 + enabled: Players.active?.canSeek ?? false + } + + StyledText { + Layout.preferredWidth: timeMetrics.width + text: root.lengthStr(Players.active?.length ?? -1) + color: Colours.palette.m3onSurfaceVariant + font: timeMetrics.font + horizontalAlignment: Text.AlignHCenter + } + } + + ButtonRow { + Layout.topMargin: Tokens.spacing.largeIncreased + Layout.fillWidth: true + spacing: Tokens.spacing.extraSmall + + IconButton { + type: IconButton.Tonal + icon: "shuffle" + isRound: true + shapeMorph: true + checked: Players.active?.shuffle ?? false + font: Tokens.font.icon.builders.medium.weight(Font.Medium).build() + disabled: !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 + font: Tokens.font.icon.large + disabled: !Players.active?.canGoPrevious + onClicked: Players.active?.previous() + } + + IconButton { + id: playPauseBtn + + icon: Players.active?.isPlaying ? "pause" : "play_arrow" + isRound: true + shapeMorph: true + fillWidth: true + checked: Players.active?.isPlaying ?? false + font: Tokens.font.icon.large + disabled: !Players.active?.canTogglePlaying + onClicked: Players.active?.togglePlaying() + } + + IconButton { + id: nextBtn + + type: IconButton.Tonal + icon: "skip_next" + isRound: true + shapeMorph: true + font: Tokens.font.icon.large + disabled: !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 + font: Tokens.font.icon.builders.medium.weight(Font.Medium).build() + disabled: !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) + } + } +} diff --git a/modules/dashboard/media/LyricList.qml b/modules/dashboard/media/LyricList.qml new file mode 100644 index 00000000..bd222d2b --- /dev/null +++ b/modules/dashboard/media/LyricList.qml @@ -0,0 +1,293 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Effects +import QtQuick.Layouts +import Quickshell +import Quickshell.Services.Mpris +import Caelestia.Config +import Caelestia.Services +import qs.components +import qs.components.containers +import qs.components.controls +import qs.components.effects +import qs.services + +Item { + id: root + + // Funny binding hack to make lyrics update + readonly property var _: { + const p = Players.active; + if (p) + Lyrics.setTrack(p.trackArtist, p.trackTitle, p.trackAlbum, p.length); + else + Lyrics.clearTrack(); + } + + readonly property real fadeAmount: 0.1 + property bool flag + + layer.enabled: true + layer.effect: Mask { + maskSource: mask + + Rectangle { + id: mask + + layer.enabled: true + visible: false + implicitWidth: root.width + implicitHeight: root.height + + gradient: Gradient { + orientation: Gradient.Vertical + + GradientStop { + color: Qt.alpha("black", 0) + position: 0 + } + GradientStop { + color: Qt.alpha("black", 1) + position: root.fadeAmount + } + GradientStop { + color: Qt.alpha("black", 1) + position: 1 - root.fadeAmount + } + GradientStop { + color: Qt.alpha("black", 0) + position: 1 + } + } + } + } + + state: { + flag; // For some reason it doesn't update sometimes, so use this to force an update + if (Lyrics.hasLyrics) + return "hasLyrics"; + if (Lyrics.loading) + return "loading"; + return "noLyrics"; + } + + states: [ + State { + name: "loading" + + PropertyChanges { + loadingIndicator.opacity: 1 + lyrics.opacity: 0 + noLyrics.opacity: 0 + } + }, + State { + name: "hasLyrics" + + PropertyChanges { + loadingIndicator.opacity: 0 + lyrics.opacity: 1 + noLyrics.opacity: 0 + } + }, + State { + name: "noLyrics" + + PropertyChanges { + loadingIndicator.opacity: 0 + lyrics.opacity: 0 + noLyrics.opacity: 1 + } + } + ] + + transitions: [ + Transition { + from: "loading" + + SequentialAnimation { + Anim { + target: loadingIndicator + property: "opacity" + type: Anim.DefaultEffects + } + Anim { + targets: [lyrics, noLyrics] + property: "opacity" + type: Anim.SlowEffects + } + } + }, + Transition { + from: "hasLyrics" + + SequentialAnimation { + Anim { + target: lyrics + property: "opacity" + type: Anim.DefaultEffects + } + Anim { + targets: [loadingIndicator, noLyrics] + property: "opacity" + type: Anim.SlowEffects + } + } + }, + Transition { + from: "noLyrics" + + SequentialAnimation { + Anim { + target: noLyrics + property: "opacity" + type: Anim.DefaultEffects + } + Anim { + targets: [loadingIndicator, lyrics] + property: "opacity" + type: Anim.SlowEffects + } + } + } + ] + + Connections { + function onHasLyricsChanged() { + root.flag = !root.flag; + } + + target: Lyrics + } + + Loader { + id: loadingIndicator + + anchors.centerIn: parent + asynchronous: true + active: opacity > 0 + opacity: 0 + + sourceComponent: ColumnLayout { + spacing: Tokens.spacing.large + + StyledRect { + Layout.alignment: Qt.AlignHCenter + implicitWidth: shape.implicitSize + Tokens.padding.medium * 2 + implicitHeight: shape.implicitSize + Tokens.padding.medium * 2 + color: Colours.palette.m3primaryContainer + radius: Tokens.rounding.full + + LoadingIndicator { + id: shape + + anchors.centerIn: parent + implicitSize: Math.round(Tokens.sizes.dashboard.mediaSectionWidth / 5) + containsIcon: true // This removes the pentagon, which is not centered + } + } + + StyledText { + text: qsTr("Loading lyrics...") + color: Colours.palette.m3onSurfaceVariant + font: Tokens.font.title.medium + } + } + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + } + + Loader { + id: noLyrics + + anchors.centerIn: parent + asynchronous: true + active: opacity > 0 + opacity: 0 + + sourceComponent: ColumnLayout { + spacing: Tokens.spacing.small + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + text: "sentiment_sad" + fontStyle: Tokens.font.icon.builders.large.scale(2).build() + color: Colours.palette.m3outline + } + + StyledText { + text: qsTr("No lyrics found") + color: Colours.palette.m3outline + font: Tokens.font.title.medium + } + } + } + + StyledListView { + id: lyrics + + anchors.fill: parent + anchors.topMargin: parent.height * root.fadeAmount / 2 + anchors.bottomMargin: parent.height * root.fadeAmount / 2 + + displayMarginBeginning: anchors.topMargin + displayMarginEnd: anchors.bottomMargin + + model: Lyrics.lyrics + Component.onCompleted: { + currentIndex = Qt.binding(() => { + Lyrics.lyrics; // Force update when lyrics change + return Lyrics.indexForTime(Players.active?.position ?? 0); + }); + positionViewAtIndex(currentIndex, ListView.Center); + } + onModelChanged: Qt.callLater(() => positionViewAtIndex(currentIndex, ListView.Center)) + + highlightRangeMode: ListView.ApplyRange + highlightMoveDuration: Tokens.anim.durations.large + highlightMoveVelocity: -1 + preferredHighlightBegin: (height - (currentItem?.implicitHeight ?? 0)) / 2 + preferredHighlightEnd: (height + (currentItem?.implicitHeight ?? 0)) / 2 + + spacing: Tokens.spacing.small + opacity: 0 + + delegate: StyledText { + id: lyric + + required property string modelData + property real effectScale: ListView.isCurrentItem ? 1 : 0 + + text: modelData || ". . ." + color: ListView.isCurrentItem ? Colours.palette.m3primary : Colours.palette.m3outline + font: Tokens.font.body.medium + + layer.enabled: effectScale > 0 + layer.effect: MultiEffect { + shadowEnabled: true + shadowColor: Colours.palette.m3primary + shadowOpacity: 0.5 * lyric.effectScale + shadowBlur: 0.6 * lyric.effectScale + blur: 0.4 * lyric.effectScale + autoPaddingEnabled: false + } + + Behavior on effectScale { + Anim { + type: Anim.SlowEffects + } + } + } + + Behavior on opacity { + Anim { + type: Anim.SlowEffects + } + } + } +} diff --git a/modules/dashboard/media/LyricsAndSelector.qml b/modules/dashboard/media/LyricsAndSelector.qml new file mode 100644 index 00000000..1f226e6e --- /dev/null +++ b/modules/dashboard/media/LyricsAndSelector.qml @@ -0,0 +1,83 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Services.Mpris +import Caelestia.Config +import qs.components +import qs.components.controls +import qs.services + +Item { + ColumnLayout { + id: layout + + anchors.fill: parent + anchors.leftMargin: Tokens.padding.medium + spacing: Tokens.spacing.medium + + RowLayout { + Layout.bottomMargin: -Tokens.spacing.medium + spacing: Tokens.spacing.medium + + MaterialIcon { + Layout.topMargin: Math.round(fontInfo.pointSize * 0.12) + text: "lyrics" + fontStyle: Tokens.font.icon.medium + } + + StyledText { + Layout.fillWidth: true + text: qsTr("Lyrics") + font: Tokens.font.title.medium + } + + IconButton { + icon: "settings" + isToggle: true + isRound: true + type: IconButton.Tonal + } + } + + LyricList { + Layout.fillWidth: true + Layout.fillHeight: true + } + + SplitButton { + Layout.alignment: Qt.AlignHCenter + + type: SplitButton.Tonal + disabled: !Players.list.length + active: menuItems.find(m => m.modelData === Players.active) ?? menuItems[0] ?? null + menu.onItemSelected: item => Players.manualActive = (item as PlayerItem).modelData + + menuItems: playerList.instances + fallbackIcon: "music_off" + fallbackText: qsTr("No players") + + minLeftWidth: layout.width - expandBtn.implicitWidth - spacing + label.Layout.maximumWidth: minLeftWidth - iconLabel.implicitWidth - textRow.spacing - textRow.anchors.horizontalCenterOffset / 2 - horizontalPadding * 2 + label.elide: Text.ElideRight + + stateLayer.disabled: true + menuOnTop: true + + Variants { + id: playerList + + model: Players.list + + PlayerItem {} + } + } + } + + component PlayerItem: MenuItem { + required property MprisPlayer modelData + + icon: modelData === Players.active ? "check" : "" + text: Players.getIdentity(modelData) + activeIcon: "animated_images" + } +} diff --git a/plugin/src/Caelestia/Config/tokens.hpp b/plugin/src/Caelestia/Config/tokens.hpp index 381897f0..b94df232 100644 --- a/plugin/src/Caelestia/Config/tokens.hpp +++ b/plugin/src/Caelestia/Config/tokens.hpp @@ -187,8 +187,10 @@ class DashboardTokens : public ConfigObject { CONFIG_PROPERTY(int, mediaProgressThickness, 6) CONFIG_PROPERTY(int, resourceProgressThickness, 6) CONFIG_PROPERTY(int, weatherWidth, 275) - CONFIG_PROPERTY(int, mediaCoverArtSize, 150) - CONFIG_PROPERTY(int, mediaVisualiserSize, 80) + CONFIG_PROPERTY(int, mediaCoverArtSize, 200) + CONFIG_PROPERTY(int, mediaTabWidth, 1000) + CONFIG_PROPERTY(int, mediaTabHeight, 320) + CONFIG_PROPERTY(int, mediaSectionWidth, 300) CONFIG_PROPERTY(int, perfHeroCardWidth, 400) CONFIG_PROPERTY(int, perfUsageShapeSize, 100) CONFIG_PROPERTY(int, perfStorageTextWidth, 160) diff --git a/services/Players.qml b/services/Players.qml index 41507839..59f96214 100644 --- a/services/Players.qml +++ b/services/Players.qml @@ -16,6 +16,8 @@ Singleton { property alias manualActive: props.manualActive function getIdentity(player: MprisPlayer): string { + if (!player) + return ""; const alias = GlobalConfig.services.playerAliases.find(a => a.from === player.identity); return alias?.to ?? player.identity; }