feat: improve dashboard media tab

This commit is contained in:
2 * r + 2 * t 2026-05-04 03:47:03 +10:00
parent 8457438f14
commit a28316ad5a
10 changed files with 662 additions and 491 deletions

View file

@ -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

View file

@ -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
}
}

View file

@ -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
}
}
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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)
}
}
}

View file

@ -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
}
}
}
}

View file

@ -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"
}
}

View file

@ -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)

View file

@ -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;
}