1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-01-25 05:52:50 -05:00
Files
DankMaterialShell/Widgets/CenterCommandCenter/MediaPlayerWidget.qml
2025-07-11 15:45:18 -04:00

366 lines
13 KiB
QML

import QtQuick
import QtQuick.Controls
import Qt5Compat.GraphicalEffects
import Quickshell
import Quickshell.Services.Mpris
import "../../Common"
import "../../Services"
Rectangle {
id: mediaPlayerWidget
property MprisPlayer activePlayer: MprisController.activePlayer
property var theme: Theme
width: parent.width
height: 160 // Reduced height to prevent overflow
radius: theme.cornerRadius
color: Qt.rgba(theme.primary.r, theme.primary.g, theme.primary.b, 0.08)
border.color: Qt.rgba(theme.primary.r, theme.primary.g, theme.primary.b, 0.2)
border.width: 1
// Constants and helpers - all microseconds
readonly property real oneSecondUs: 1000000.0
function asSec(us) { return us / oneSecondUs }
function ratio() { return trackLenUs > 0 ? uiPosUs / trackLenUs : 0 }
function normalizeLength(lenRaw) {
// If length < 86 400 it's almost certainly seconds (24 h upper bound).
// Convert to µs; otherwise return as-is.
return (lenRaw > 0 && lenRaw < 86400) ? lenRaw * oneSecondUs : lenRaw;
}
// Call seek() in safe 5-second chunks so every player obeys.
function chunkedSeek(offsetUs) {
if (Math.abs(offsetUs) < 5 * oneSecondUs) { // ≤5 s? single shot.
activePlayer.seek(offsetUs);
return;
}
const step = 5 * oneSecondUs; // 5 s
let remaining = offsetUs;
let safety = 0; // avoid infinite loops
while (Math.abs(remaining) > step && safety < 40) { // max 200 s
activePlayer.seek(Math.sign(remaining) * step);
remaining -= Math.sign(remaining) * step;
safety++;
}
if (remaining !== 0) activePlayer.seek(remaining);
}
// Returns a guaranteed-valid object-path for the current track.
function trackPath() {
const md = activePlayer.metadata || {};
// Spec: "/org/mpris/MediaPlayer2/Track/NNN"
if (typeof md["mpris:trackid"] === "string" &&
md["mpris:trackid"].length > 1 && md["mpris:trackid"].startsWith("/"))
return md["mpris:trackid"];
// Nothing reliable? Fall back to the *current* playlist entry object if exposed
if (activePlayer.currentTrackPath) return activePlayer.currentTrackPath;
// Absolute last resort—return null so caller knows SetPosition will fail
return null;
}
// Position tracking - all microseconds
property real uiPosUs: 0
property real backendPosUs: 0
property real trackLenUs: 0
property double backendStamp: Date.now() // wall-clock of last update in ms
// Optimistic timer
Timer {
id: tickTimer
interval: 50 // 20 fps feels smooth, cheap
repeat: true
running: activePlayer?.playbackState === MprisPlaybackState.Playing
onTriggered: {
if (trackLenUs <= 0) return;
const projected = backendPosUs + (Date.now() - backendStamp) * 1000.0;
uiPosUs = Math.min(projected, trackLenUs); // never exceed track end
}
}
// --- 500-ms poll to keep external moves in sync -------------------
// Timer {
// id: pollTimer
// interval: 500 // ms
// repeat: true
// running: true // always on; cost is negligible
// onTriggered: {
// if (!activePlayer || trackLenUs <= 0) return;
// const polledUs = activePlayer.position; // property read
// // Compare in percent to avoid false positives
// if (Math.abs((polledUs - backendPosUs) / trackLenUs) > 0.01) { // >1 % jump
// backendPosUs = polledUs;
// backendStamp = Date.now();
// uiPosUs = polledUs; // snap instantly
// }
// }
// }
// Initialize when player changes
onActivePlayerChanged: {
if (activePlayer) {
backendPosUs = activePlayer.position || 0
trackLenUs = normalizeLength(activePlayer.length || 0)
backendStamp = Date.now()
uiPosUs = backendPosUs
console.log(`player change len ${asSec(trackLenUs)} s, pos ${asSec(uiPosUs)} s`)
} else {
backendPosUs = 0
trackLenUs = 0
backendStamp = Date.now()
uiPosUs = 0
}
}
// Backend events
Connections {
target: activePlayer
function onPositionChanged() {
const posUs = activePlayer.position
backendPosUs = posUs
backendStamp = Date.now()
uiPosUs = posUs // snap immediately on tick
}
function onSeeked(pos) {
backendPosUs = pos
backendStamp = Date.now()
uiPosUs = backendPosUs
}
function onPostTrackChanged() {
backendPosUs = activePlayer?.position || 0
trackLenUs = normalizeLength(activePlayer?.length || 0)
backendStamp = Date.now()
uiPosUs = backendPosUs
}
function onTrackTitleChanged() {
backendPosUs = activePlayer?.position || 0
trackLenUs = normalizeLength(activePlayer?.length || 0)
backendStamp = Date.now()
uiPosUs = backendPosUs
}
}
Column {
anchors.fill: parent
anchors.margins: theme.spacingM
spacing: theme.spacingM
// Album art and track info
Row {
width: parent.width
height: 70 // Reduced height
spacing: theme.spacingM
// Album Art
Rectangle {
width: 70
height: 70
radius: theme.cornerRadius
color: Qt.rgba(theme.surfaceVariant.r, theme.surfaceVariant.g, theme.surfaceVariant.b, 0.3)
Item {
anchors.fill: parent
clip: true
Image {
id: albumArt
anchors.fill: parent
source: activePlayer?.trackArtUrl || ""
fillMode: Image.PreserveAspectCrop
smooth: true
}
Rectangle {
anchors.fill: parent
visible: albumArt.status !== Image.Ready
color: "transparent"
Text {
anchors.centerIn: parent
text: "album"
font.family: theme.iconFont
font.pixelSize: 28
color: theme.surfaceVariantText
}
}
}
}
// Track Info
Column {
width: parent.width - 70 - theme.spacingM
spacing: theme.spacingXS
anchors.verticalCenter: parent.verticalCenter
Text {
text: activePlayer?.trackTitle || "Unknown Track"
font.pixelSize: theme.fontSizeMedium
font.weight: Font.Bold
color: theme.surfaceText
width: parent.width
elide: Text.ElideRight
}
Text {
text: activePlayer?.trackArtist || "Unknown Artist"
font.pixelSize: theme.fontSizeSmall
color: Qt.rgba(theme.surfaceText.r, theme.surfaceText.g, theme.surfaceText.b, 0.8)
width: parent.width
elide: Text.ElideRight
}
Text {
text: activePlayer?.trackAlbum || ""
font.pixelSize: theme.fontSizeSmall
color: Qt.rgba(theme.surfaceText.r, theme.surfaceText.g, theme.surfaceText.b, 0.6)
width: parent.width
elide: Text.ElideRight
visible: text.length > 0
}
}
}
// Progress bar
Rectangle {
width: parent.width
height: 6
radius: 3
color: Qt.rgba(theme.surfaceVariant.r, theme.surfaceVariant.g, theme.surfaceVariant.b, 0.3)
Rectangle {
id: progressFill
height: parent.height
radius: parent.radius
color: theme.primary
width: parent.width * ratio()
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: (mouse) => {
if (!activePlayer || !activePlayer.canSeek || trackLenUs <= 0) return
const targetUs = (mouse.x / width) * trackLenUs
const offset = targetUs - backendPosUs
if (typeof activePlayer.setPosition === "function") {
activePlayer.setPosition(trackPath() || "/", Math.round(targetUs))
console.log(`SetPosition ${asSec(targetUs)} s`)
} else {
chunkedSeek(offset) // <-- use helper
console.log(`chunkedSeek ${asSec(offset/oneSecondUs)} s`)
}
uiPosUs = backendPosUs = targetUs
}
}
}
// Control buttons - compact to fit
Row {
anchors.horizontalCenter: parent.horizontalCenter
spacing: theme.spacingL
// Previous button
Rectangle {
width: 28
height: 28
radius: 14
color: prevBtnArea.containsMouse ? Qt.rgba(theme.surfaceVariant.r, theme.surfaceVariant.g, theme.surfaceVariant.b, 0.12) : "transparent"
Text {
anchors.centerIn: parent
text: "skip_previous"
font.family: theme.iconFont
font.pixelSize: 16
color: theme.surfaceText
}
MouseArea {
id: prevBtnArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (!activePlayer) return
// >8 s → jump to start, otherwise previous track
if (uiPosUs > 8 * oneSecondUs && activePlayer.canSeek) {
if (typeof activePlayer.setPosition === "function") {
activePlayer.setPosition(trackPath() || "/", 0)
console.log("Back → SetPosition 0 µs")
} else {
chunkedSeek(-backendPosUs) // <-- use helper
console.log("Back → chunkedSeek to 0")
}
uiPosUs = backendPosUs = 0
} else {
activePlayer.previous()
}
}
}
}
// Play/Pause button
Rectangle {
width: 36
height: 36
radius: 18
color: theme.primary
Text {
anchors.centerIn: parent
text: activePlayer?.playbackState === MprisPlaybackState.Playing ? "pause" : "play_arrow"
font.family: theme.iconFont
font.pixelSize: 20
color: theme.background
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: activePlayer?.togglePlaying()
}
}
// Next button
Rectangle {
width: 28
height: 28
radius: 14
color: nextBtnArea.containsMouse ? Qt.rgba(theme.surfaceVariant.r, theme.surfaceVariant.g, theme.surfaceVariant.b, 0.12) : "transparent"
Text {
anchors.centerIn: parent
text: "skip_next"
font.family: theme.iconFont
font.pixelSize: 16
color: theme.surfaceText
}
MouseArea {
id: nextBtnArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: activePlayer?.next()
}
}
}
}
}