512 lines
16 KiB
QML
512 lines
16 KiB
QML
pragma ComponentBehavior: Bound
|
|
|
|
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.services
|
|
import qs.utils
|
|
|
|
Item {
|
|
id: root
|
|
|
|
required property DrawerVisibilities visibilities
|
|
readonly property bool needsKeyboard: lyricMenuOpen
|
|
|
|
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 innerX: cover.implicitWidth / 2 + Tokens.spacing.small
|
|
readonly property real innerY: cover.implicitHeight / 2 + Tokens.spacing.small
|
|
property color colour: Colours.palette.m3primary
|
|
|
|
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 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 + (visualiser.innerX + strokeWidth / 2) * cos
|
|
startY: visualiser.centerY + (visualiser.innerY + strokeWidth / 2) * sin
|
|
|
|
PathLine {
|
|
x: visualiser.centerX + (visualiser.innerX + visualiserBar.strokeWidth / 2 + visualiserBar.magnitude) * visualiserBar.cos
|
|
y: visualiser.centerY + (visualiser.innerY + visualiserBar.strokeWidth / 2 + visualiserBar.magnitude) * visualiserBar.sin
|
|
}
|
|
|
|
Behavior on strokeColor {
|
|
CAnim {}
|
|
}
|
|
}
|
|
}
|
|
|
|
StyledClippingRect {
|
|
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
|
|
|
|
color: Colours.tPalette.m3surfaceContainerHigh
|
|
radius: Infinity
|
|
|
|
MaterialIcon {
|
|
anchors.centerIn: parent
|
|
|
|
grade: 200
|
|
text: "art_track"
|
|
color: Colours.palette.m3onSurfaceVariant
|
|
font: Tokens.font.icon.builders.medium.size((parent.width * 0.4) || 1).build()
|
|
}
|
|
|
|
Image {
|
|
id: image
|
|
|
|
anchors.fill: parent
|
|
|
|
source: Players.getArtUrl(Players.active)
|
|
asynchronous: true
|
|
fillMode: Image.PreserveAspectCrop
|
|
sourceSize.width: width
|
|
sourceSize.height: height
|
|
|
|
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.builders.large.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.size(Math.round(Tokens.font.icon.large.pointSize * 1.5)).build()
|
|
disabled: !Players.active?.canGoPrevious
|
|
onClicked: Players.active?.previous()
|
|
}
|
|
|
|
PlayerControl {
|
|
icon: Players.active?.isPlaying ? "pause" : "play_arrow"
|
|
label.animate: true
|
|
toggle: true
|
|
padding: Tokens.padding.extraSmall / 2
|
|
checked: Players.active?.isPlaying ?? false
|
|
font: Tokens.font.icon.builders.large.size(Math.round(Tokens.font.icon.large.pointSize * 1.5)).build()
|
|
disabled: !Players.active?.canTogglePlaying
|
|
onClicked: Players.active?.togglePlaying()
|
|
}
|
|
|
|
PlayerControl {
|
|
type: IconButton.Text
|
|
icon: "skip_next"
|
|
font: Tokens.font.icon.builders.large.size(Math.round(Tokens.font.icon.large.pointSize * 1.5)).build()
|
|
disabled: !Players.active?.canGoNext
|
|
onClicked: Players.active?.next()
|
|
}
|
|
|
|
PlayerControl {
|
|
type: IconButton.Text
|
|
icon: "lyrics"
|
|
font: Tokens.font.icon.builders.large.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 {
|
|
id: playerChanger
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
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 {}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|