feat: dashboard media panel
This commit is contained in:
parent
e8adf58097
commit
90a65cd153
5 changed files with 423 additions and 11 deletions
|
|
@ -5,6 +5,7 @@ import QtQuick
|
|||
|
||||
Singleton {
|
||||
readonly property int mediaUpdateInterval: 500
|
||||
readonly property int visualiserBars: 45
|
||||
readonly property Sizes sizes: Sizes {}
|
||||
|
||||
component Sizes: QtObject {
|
||||
|
|
@ -18,5 +19,7 @@ Singleton {
|
|||
readonly property int mediaProgressThickness: 8
|
||||
readonly property int resourceProgessThickness: 10
|
||||
readonly property int weatherWidth: 250
|
||||
readonly property int mediaCoverArtSize: 150
|
||||
readonly property int mediaVisualiserSize: 80
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,7 +48,39 @@ Item {
|
|||
|
||||
currentIndex: tabs.currentIndex
|
||||
|
||||
Dash {}
|
||||
ClippingWrapperRectangle {
|
||||
radius: Appearance.rounding.normal
|
||||
color: "transparent"
|
||||
|
||||
Dash {
|
||||
clip: true
|
||||
}
|
||||
}
|
||||
|
||||
ClippingWrapperRectangle {
|
||||
radius: Appearance.rounding.normal
|
||||
color: "transparent"
|
||||
|
||||
Media {
|
||||
clip: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on implicitWidth {
|
||||
NumberAnimation {
|
||||
duration: Appearance.anim.durations.large
|
||||
easing.type: Easing.BezierSpline
|
||||
easing.bezierCurve: Appearance.anim.curves.emphasized
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on implicitHeight {
|
||||
NumberAnimation {
|
||||
duration: Appearance.anim.durations.large
|
||||
easing.type: Easing.BezierSpline
|
||||
easing.bezierCurve: Appearance.anim.curves.emphasized
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
366
modules/dashboard/Media.qml
Normal file
366
modules/dashboard/Media.qml
Normal file
|
|
@ -0,0 +1,366 @@
|
|||
pragma ComponentBehavior: Bound
|
||||
|
||||
import "root:/widgets"
|
||||
import "root:/services"
|
||||
import "root:/config"
|
||||
import Quickshell.Io
|
||||
import Quickshell.Widgets
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property real playerProgress: {
|
||||
const active = Players.active;
|
||||
return active?.length ? active.position / active.length : 0;
|
||||
}
|
||||
|
||||
property list<int> cava: []
|
||||
|
||||
function lengthStr(length: int): string {
|
||||
if (length < 0)
|
||||
return "-1:-1";
|
||||
return `${Math.floor(length / 60)}:${Math.floor(length % 60).toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
implicitWidth: cover.implicitWidth + DashboardConfig.sizes.mediaVisualiserSize * 2 + details.implicitWidth + details.anchors.leftMargin + bongocat.implicitWidth + bongocat.anchors.leftMargin * 2 + Appearance.padding.large * 2
|
||||
implicitHeight: Math.max(cover.implicitHeight + DashboardConfig.sizes.mediaVisualiserSize * 2, details.implicitHeight, bongocat.implicitHeight) + Appearance.padding.large * 2
|
||||
|
||||
Behavior on playerProgress {
|
||||
NumberAnimation {
|
||||
duration: Appearance.anim.durations.large
|
||||
easing.type: Easing.BezierSpline
|
||||
easing.bezierCurve: Appearance.anim.curves.standard
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
running: root.visible && (Players.active?.isPlaying ?? false)
|
||||
interval: DashboardConfig.mediaUpdateInterval
|
||||
triggeredOnStart: true
|
||||
repeat: true
|
||||
onTriggered: Players.active?.positionChanged()
|
||||
}
|
||||
|
||||
Process {
|
||||
running: true
|
||||
command: ["sh", "-c", `printf '[general]\nframerate=60\nbars=${DashboardConfig.visualiserBars}\n[output]\nchannels=mono\nmethod=raw\nraw_target=/dev/stdout\ndata_format=ascii\nascii_max_range=100' | cava -p /dev/stdin`]
|
||||
stdout: SplitParser {
|
||||
onRead: data => {
|
||||
root.cava = data.slice(0, -1).split(";").map(v => parseInt(v, 10));
|
||||
if (visualiser.visible)
|
||||
visualiser.requestPaint();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Canvas {
|
||||
id: visualiser
|
||||
|
||||
readonly property real centerX: width / 2
|
||||
readonly property real centerY: height / 2
|
||||
readonly property real innerX: cover.implicitWidth / 2 + Appearance.spacing.small
|
||||
readonly property real innerY: cover.implicitHeight / 2 + Appearance.spacing.small
|
||||
|
||||
anchors.fill: cover
|
||||
anchors.margins: -DashboardConfig.sizes.mediaVisualiserSize
|
||||
|
||||
onPaint: {
|
||||
const ctx = getContext("2d");
|
||||
ctx.reset();
|
||||
|
||||
const values = root.cava;
|
||||
const len = values.length;
|
||||
const size = DashboardConfig.sizes.mediaVisualiserSize;
|
||||
const cx = centerX;
|
||||
const cy = centerY;
|
||||
const rx = innerX;
|
||||
const ry = innerY;
|
||||
|
||||
for (let i = 0; i < len; i++) {
|
||||
const v = Math.max(1, Math.min(100, values[i]));
|
||||
|
||||
const angle = i * 2 * Math.PI / len;
|
||||
const magnitude = v / 100 * size;
|
||||
const cos = Math.cos(angle);
|
||||
const sin = Math.sin(angle);
|
||||
|
||||
ctx.moveTo(cx + rx * cos, cy + ry * sin);
|
||||
ctx.lineTo(cx + (rx + magnitude) * cos, cy + (ry + magnitude) * sin);
|
||||
}
|
||||
|
||||
ctx.strokeStyle = Colours.palette.m3primary;
|
||||
ctx.lineWidth = 360 / len - Appearance.spacing.small / 4;
|
||||
ctx.lineCap = "round";
|
||||
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
ClippingRectangle {
|
||||
id: cover
|
||||
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Appearance.padding.large + DashboardConfig.sizes.mediaVisualiserSize
|
||||
|
||||
implicitWidth: DashboardConfig.sizes.mediaCoverArtSize
|
||||
implicitHeight: DashboardConfig.sizes.mediaCoverArtSize
|
||||
|
||||
color: Colours.palette.m3surfaceContainerHigh
|
||||
radius: Appearance.rounding.full
|
||||
|
||||
MaterialIcon {
|
||||
anchors.centerIn: parent
|
||||
|
||||
text: "art_track"
|
||||
color: Colours.palette.m3onSurfaceVariant
|
||||
font.pointSize: (parent.width * 0.4) || 1
|
||||
}
|
||||
|
||||
Image {
|
||||
id: image
|
||||
|
||||
anchors.fill: parent
|
||||
|
||||
source: Players.active?.trackArtUrl ?? ""
|
||||
asynchronous: true
|
||||
fillMode: Image.PreserveAspectCrop
|
||||
sourceSize.width: width
|
||||
sourceSize.height: height
|
||||
}
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Appearance.anim.durations.normal
|
||||
easing.type: Easing.BezierSpline
|
||||
easing.bezierCurve: Appearance.anim.curves.standard
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
id: details
|
||||
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.left: visualiser.right
|
||||
anchors.leftMargin: Appearance.spacing.normal
|
||||
|
||||
spacing: Appearance.spacing.small
|
||||
|
||||
StyledText {
|
||||
id: title
|
||||
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
text: (Players.active?.trackTitle ?? qsTr("No media")) || qsTr("Unknown title")
|
||||
color: Colours.palette.m3primary
|
||||
font.pointSize: Appearance.font.size.normal
|
||||
|
||||
width: parent.implicitWidth
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
StyledText {
|
||||
id: album
|
||||
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
text: (Players.active?.trackAlbum ?? qsTr("No media")) || qsTr("Unknown album")
|
||||
color: Colours.palette.m3outline
|
||||
font.pointSize: Appearance.font.size.small
|
||||
|
||||
width: parent.implicitWidth
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
StyledText {
|
||||
id: artist
|
||||
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
text: (Players.active?.trackArtist ?? qsTr("No media")) || qsTr("Unknown artist")
|
||||
color: Colours.palette.m3secondary
|
||||
|
||||
width: parent.implicitWidth
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Row {
|
||||
id: controls
|
||||
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
|
||||
spacing: Appearance.spacing.small
|
||||
|
||||
Control {
|
||||
icon: "skip_previous"
|
||||
canUse: Players.active?.canGoPrevious ?? false
|
||||
|
||||
function onClicked(): void {
|
||||
Players.active?.previous();
|
||||
}
|
||||
}
|
||||
|
||||
Control {
|
||||
icon: Players.active?.isPlaying ? "pause" : "play_arrow"
|
||||
canUse: Players.active?.canTogglePlaying ?? false
|
||||
|
||||
function onClicked(): void {
|
||||
Players.active?.togglePlaying();
|
||||
}
|
||||
}
|
||||
|
||||
Control {
|
||||
icon: "skip_next"
|
||||
canUse: Players.active?.canGoNext ?? false
|
||||
|
||||
function onClicked(): void {
|
||||
Players.active?.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Slider {
|
||||
id: slider
|
||||
|
||||
implicitWidth: controls.implicitWidth * 2
|
||||
implicitHeight: Appearance.padding.normal * 3
|
||||
|
||||
value: root.playerProgress
|
||||
onMoved: {
|
||||
const active = Players.active;
|
||||
if (active?.canSeek && active?.positionSupported)
|
||||
active.position = value * active.length;
|
||||
}
|
||||
|
||||
background: Item {
|
||||
StyledRect {
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.left: parent.left
|
||||
anchors.topMargin: slider.implicitHeight / 3
|
||||
anchors.bottomMargin: slider.implicitHeight / 3
|
||||
|
||||
implicitWidth: slider.handle.x - slider.implicitHeight / 6
|
||||
|
||||
color: Colours.palette.m3primary
|
||||
radius: Appearance.rounding.full
|
||||
topRightRadius: slider.implicitHeight / 15
|
||||
bottomRightRadius: slider.implicitHeight / 15
|
||||
}
|
||||
|
||||
StyledRect {
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.right: parent.right
|
||||
anchors.topMargin: slider.implicitHeight / 3
|
||||
anchors.bottomMargin: slider.implicitHeight / 3
|
||||
|
||||
implicitWidth: parent.width - slider.handle.x - slider.handle.implicitWidth - slider.implicitHeight / 6
|
||||
|
||||
color: Colours.palette.m3surfaceContainer
|
||||
radius: Appearance.rounding.full
|
||||
topLeftRadius: slider.implicitHeight / 15
|
||||
bottomLeftRadius: slider.implicitHeight / 15
|
||||
}
|
||||
}
|
||||
|
||||
handle: StyledRect {
|
||||
id: rect
|
||||
|
||||
x: slider.visualPosition * slider.availableWidth
|
||||
|
||||
implicitWidth: slider.implicitHeight / 4.5
|
||||
implicitHeight: slider.implicitHeight
|
||||
|
||||
color: Colours.palette.m3primary
|
||||
radius: Appearance.rounding.full
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onPressed: event => event.accepted = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
|
||||
implicitHeight: Math.max(position.implicitHeight, length.implicitHeight)
|
||||
|
||||
StyledText {
|
||||
id: position
|
||||
|
||||
anchors.left: parent.left
|
||||
|
||||
text: root.lengthStr(Players.active?.position ?? -1)
|
||||
color: Colours.palette.m3onSurfaceVariant
|
||||
font.pointSize: Appearance.font.size.small
|
||||
}
|
||||
|
||||
StyledText {
|
||||
id: length
|
||||
|
||||
anchors.right: parent.right
|
||||
|
||||
text: root.lengthStr(Players.active?.length ?? -1)
|
||||
color: Colours.palette.m3onSurfaceVariant
|
||||
font.pointSize: Appearance.font.size.small
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedImage {
|
||||
id: bongocat
|
||||
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.left: details.right
|
||||
anchors.leftMargin: Appearance.spacing.large
|
||||
|
||||
playing: visible && (Players.active?.isPlaying ?? false)
|
||||
speed: BeatDetector.bpm / 300
|
||||
source: "root:/assets/bongocat.gif"
|
||||
asynchronous: true
|
||||
fillMode: AnimatedImage.PreserveAspectFit
|
||||
}
|
||||
|
||||
component Control: StyledRect {
|
||||
id: control
|
||||
|
||||
required property string icon
|
||||
required property bool canUse
|
||||
function onClicked(): void {
|
||||
}
|
||||
|
||||
implicitWidth: Math.max(icon.implicitHeight, icon.implicitHeight) + Appearance.padding.small
|
||||
implicitHeight: implicitWidth
|
||||
|
||||
StateLayer {
|
||||
disabled: !control.canUse
|
||||
radius: Appearance.rounding.full
|
||||
|
||||
function onClicked(): void {
|
||||
control.onClicked();
|
||||
}
|
||||
}
|
||||
|
||||
MaterialIcon {
|
||||
id: icon
|
||||
|
||||
anchors.centerIn: parent
|
||||
anchors.verticalCenterOffset: font.pointSize * 0.05
|
||||
|
||||
animate: true
|
||||
text: control.icon
|
||||
color: control.canUse ? Colours.palette.m3onSurface : Colours.palette.m3outline
|
||||
font.pointSize: Appearance.font.size.large
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -12,7 +12,7 @@ Item {
|
|||
|
||||
property real playerProgress: {
|
||||
const active = Players.active;
|
||||
return active ? active.position / active.length : 0;
|
||||
return active?.length ? active.position / active.length : 0;
|
||||
}
|
||||
|
||||
anchors.top: parent.top
|
||||
|
|
@ -223,18 +223,11 @@ Item {
|
|||
anchors.bottomMargin: Appearance.padding.large
|
||||
anchors.margins: Appearance.padding.large * 2
|
||||
|
||||
playing: Players.active?.isPlaying ?? false
|
||||
playing: visible && (Players.active?.isPlaying ?? false)
|
||||
speed: BeatDetector.bpm / 300
|
||||
source: "root:/assets/bongocat.gif"
|
||||
asynchronous: true
|
||||
fillMode: AnimatedImage.PreserveAspectFit
|
||||
|
||||
Process {
|
||||
running: true
|
||||
command: [`${Quickshell.shellRoot}/assets/realtime-beat-detector.py`]
|
||||
stdout: SplitParser {
|
||||
onRead: data => bongocat.speed = parseFloat(data) / 300
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component Control: StyledRect {
|
||||
|
|
|
|||
18
services/BeatDetector.qml
Normal file
18
services/BeatDetector.qml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
pragma Singleton
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property real bpm
|
||||
|
||||
Process {
|
||||
running: true
|
||||
command: [`${Quickshell.shellRoot}/assets/realtime-beat-detector.py`]
|
||||
stdout: SplitParser {
|
||||
onRead: data => root.bpm = parseFloat(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue