feat: improve dashboard media tab
This commit is contained in:
parent
8457438f14
commit
a28316ad5a
10 changed files with 662 additions and 491 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
84
modules/dashboard/media/CoverVisualiser.qml
Normal file
84
modules/dashboard/media/CoverVisualiser.qml
Normal 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
|
||||
}
|
||||
}
|
||||
177
modules/dashboard/media/Details.qml
Normal file
177
modules/dashboard/media/Details.qml
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
293
modules/dashboard/media/LyricList.qml
Normal file
293
modules/dashboard/media/LyricList.qml
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
83
modules/dashboard/media/LyricsAndSelector.qml
Normal file
83
modules/dashboard/media/LyricsAndSelector.qml
Normal 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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue