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 iconLabel: iconLabel
readonly property alias label: label readonly property alias label: label
readonly property alias stateLayer: stateLayer 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 colour: type == SplitButton.Filled ? Colours.palette.m3primary : Colours.palette.m3secondaryContainer
property color textColour: type == SplitButton.Filled ? Colours.palette.m3onPrimary : Colours.palette.m3onSecondaryContainer property color textColour: type == SplitButton.Filled ? Colours.palette.m3onPrimary : Colours.palette.m3onSecondaryContainer

View file

@ -16,7 +16,7 @@ Item {
const count = repeater.count; const count = repeater.count;
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const item = repeater.itemAt(i) as Loader; 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 true;
} }
return false; return false;
@ -174,7 +174,7 @@ Item {
Component { Component {
id: mediaComponent id: mediaComponent
MediaWrapper { Media {
visibilities: root.visibilities visibilities: root.visibilities
} }
} }

View file

@ -1,495 +1,36 @@
pragma ComponentBehavior: Bound import "media"
import QtQuick import QtQuick
import QtQuick.Layouts import QtQuick.Layouts
import QtQuick.Shapes
import Quickshell
import Quickshell.Services.Mpris
import Caelestia.Config import Caelestia.Config
import Caelestia.Services
import qs.components import qs.components
import qs.components.controls
import qs.components.widgets
import qs.services import qs.services
import qs.utils
Item { Item {
id: root id: root
required property DrawerVisibilities visibilities 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 implicitWidth: Tokens.sizes.dashboard.mediaTabWidth
readonly property real detailsHeightWithoutLyrics: details.implicitHeight - lyricsViewInDetails.implicitHeight implicitHeight: Tokens.sizes.dashboard.mediaTabHeight
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
}
}
}
RowLayout { RowLayout {
id: playerChanger anchors.fill: parent
anchors.margins: Tokens.padding.large
spacing: Tokens.spacing.extraLarge
parent: !root.lyricsShowingDebounced ? details : leftSection CoverVisualiser {
Layout.alignment: Qt.AlignHCenter Layout.fillHeight: true
spacing: Tokens.spacing.small implicitWidth: Tokens.sizes.dashboard.mediaSectionWidth
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;
}
} }
SplitButton { Details {
id: playerSelector Layout.fillWidth: true
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 {}
}
} }
PlayerControl { LyricsAndSelector {
type: IconButton.Text Layout.fillHeight: true
icon: "delete" implicitWidth: Tokens.sizes.dashboard.mediaSectionWidth
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
}
} }
} }
} }

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, mediaProgressThickness, 6)
CONFIG_PROPERTY(int, resourceProgressThickness, 6) CONFIG_PROPERTY(int, resourceProgressThickness, 6)
CONFIG_PROPERTY(int, weatherWidth, 275) CONFIG_PROPERTY(int, weatherWidth, 275)
CONFIG_PROPERTY(int, mediaCoverArtSize, 150) CONFIG_PROPERTY(int, mediaCoverArtSize, 200)
CONFIG_PROPERTY(int, mediaVisualiserSize, 80) CONFIG_PROPERTY(int, mediaTabWidth, 1000)
CONFIG_PROPERTY(int, mediaTabHeight, 320)
CONFIG_PROPERTY(int, mediaSectionWidth, 300)
CONFIG_PROPERTY(int, perfHeroCardWidth, 400) CONFIG_PROPERTY(int, perfHeroCardWidth, 400)
CONFIG_PROPERTY(int, perfUsageShapeSize, 100) CONFIG_PROPERTY(int, perfUsageShapeSize, 100)
CONFIG_PROPERTY(int, perfStorageTextWidth, 160) CONFIG_PROPERTY(int, perfStorageTextWidth, 160)

View file

@ -16,6 +16,8 @@ Singleton {
property alias manualActive: props.manualActive property alias manualActive: props.manualActive
function getIdentity(player: MprisPlayer): string { function getIdentity(player: MprisPlayer): string {
if (!player)
return "";
const alias = GlobalConfig.services.playerAliases.find(a => a.from === player.identity); const alias = GlobalConfig.services.playerAliases.find(a => a.from === player.identity);
return alias?.to ?? player.identity; return alias?.to ?? player.identity;
} }