Files
nixos/other/quickshell/bar/mpris/PlayersPopup.qml
T

530 lines
20 KiB
QML

pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Qt5Compat.GraphicalEffects
import Quickshell
import Quickshell.Widgets
import Quickshell.Wayland
import Quickshell.Hyprland
import Quickshell.Services.Mpris
import "../.."
import "../../components"
import ".."
PopupWindow {
id: root
property bool isOpen: false
implicitWidth: 700
//implicitHeight: popupContent.implicitHeight + popupContent.anchors.margins * 2
implicitHeight: 800
mask: Region {
item: wrapper
}
color: "transparent"
property Scope positionInfo: Scope {
id: positionInfo
property int position: Math.floor(MprisController.activePlayer.position)
property int length: Math.floor(MprisController.activePlayer.length)
FrameAnimation {
id: posTracker
running: MprisController.activePlayer.isPlaying
onTriggered: MprisController.activePlayer.positionChanged()
}
function timeStr(time: int): string {
const seconds = time % 60;
const minutes = Math.floor(time / 60);
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
}
Rope {
segmentCount: 4
segmentLen: 22
start: Qt.vector2d(250, -5)
end: Qt.vector2d(wrapper.x + 49, wrapper.y)
}
Rope {
segmentCount: 4
segmentLen: 22
start: Qt.vector2d(wrapper.width + 200 - 50, -5)
end: Qt.vector2d(wrapper.width + wrapper.x - 49, wrapper.y)
}
Rectangle {
id: wrapper
implicitWidth: 500
implicitHeight: popupContent.implicitHeight + popupContent.anchors.margins * 2
color: "#824524"
x: 200
y: -wrapper.height
readonly property var targetX: 200
readonly property var targetY: root.isOpen ? 70 : -wrapper.height - 50
property var velocityX: 0
property var velocityY: 0
FrameAnimation {
running: true
function dampingVelocity(currentVelocity, delta) {
const spring = 8.0;
const damping = 0.3;
const springForce = spring * delta;
const dampingForce = -damping * currentVelocity;
return currentVelocity + (springForce + dampingForce);
}
onTriggered: {
const deltaX = wrapper.targetX - wrapper.x;
const deltaY = wrapper.targetY - wrapper.y;
if (Math.abs(deltaX) > 0.1 || Math.abs(deltaY) > 0.1) {
wrapper.velocityX = dampingVelocity(wrapper.velocityX, deltaX);
wrapper.velocityY = dampingVelocity(wrapper.velocityY, deltaY);
wrapper.x += wrapper.velocityX * frameTime;
wrapper.y += wrapper.velocityY * frameTime;
}
}
}
MouseArea {
property var prevMouseX: 0
property var prevMouseY: 0
anchors.fill: parent
onPressed: e => {
prevMouseX = e.x;
prevMouseY = e.y;
}
onPositionChanged: e => {
wrapper.x += (e.x - prevMouseX) * 0.3;
wrapper.y += (e.y - prevMouseY) * 0.3;
prevMouseX = e.x;
prevMouseY = e.y;
}
}
Item {
anchors.fill: parent
anchors.margins: 8
clip: true
Image {
scale: 10
anchors.fill: parent
source: "../../assets/wood.png"
smooth: false
fillMode: Image.Tile
opacity: 0.4
}
}
ColumnLayout {
id: popupContent
anchors.fill: parent
anchors.margins: 8
Connections {
target: MprisController
function onTrackChanged(reverse: bool) {
trackStack.updateTrack(reverse, false);
}
}
Item {
id: playerSelectorContainment
Layout.fillWidth: true
implicitHeight: playerSelector.implicitHeight
implicitWidth: playerSelector.implicitWidth
Rectangle {
anchors.centerIn: parent
implicitWidth: 50
implicitHeight: 50
color: "#824524"
}
RowLayout {
id: playerSelector
property Item selectedPlayerDisplay: Mpris.players[0]
x: parent.width / 2 - (selectedPlayerDisplay ? selectedPlayerDisplay.x + selectedPlayerDisplay.width / 2 : 0)
anchors.verticalCenter: parent.verticalCenter
Behavior on x {
NumberAnimation {
duration: 400
easing.type: Easing.OutExpo
}
}
Repeater {
model: Mpris.players
MouseArea {
required property MprisPlayer modelData
readonly property bool selected: modelData == MprisController.activePlayer
onSelectedChanged: () => {
if (selected) {
playerSelector.selectedPlayerDisplay = this;
}
}
implicitWidth: childrenRect.width
implicitHeight: childrenRect.height
onClicked: MprisController.setActivePlayer(modelData)
Item {
width: 50
height: 50
Image {
anchors.fill: parent
anchors.margins: 0
source: {
// So janky xD
const identity = modelData.identity.toLowerCase();
if (identity.includes("chrome") || identity.includes("chromium")) {
return Quickshell.iconPath("google-chrome") || Quickshell.iconPath("chromium");
}
if (identity.includes("firefox")) {
return Quickshell.iconPath("firefox");
}
if (identity.includes("spotify")) {
return Quickshell.iconPath("spotify");
}
if (modelData.identity == "Brave") {
return "/opt/brave-bin/product_logo_64.png";
}
const entry = DesktopEntries.byId(modelData.desktopEntry);
if (entry && entry.icon) {
return Quickshell.iconPath(entry.icon);
}
return "../../assets/mpd.png";
}
smooth: false
sourceSize.width: 50
sourceSize.height: 50
cache: false
}
}
}
}
}
}
Item {
Layout.fillWidth: true
Layout.topMargin: 8
Layout.bottomMargin: 16
Label {
anchors.centerIn: parent
text: MprisController.activePlayer.identity
font.family: "BigBlueTermPlusNerdFont"
color: "white"
}
}
SlideView {
id: trackStack
Layout.fillWidth: true
implicitHeight: 100
clip: animating || (lastFlicked?.contentX ?? 0) != 0
property Flickable lastFlicked
property bool reverse: false
Component.onCompleted: updateTrack(false, true)
function updateTrack(reverse: bool, immediate: bool) {
this.reverse = reverse;
this.replace(trackComponent, {
track: MprisController.activeTrack
}, immediate);
}
property var trackComponent: Component {
Flickable {
id: flickable
required property var track
readonly property bool svReady: img.status === Image.Ready
contentWidth: width + 1
onDragStarted: trackStack.lastFlicked = this
onDragEnded: {
if (Math.abs(contentX) > 75) {
if (contentX < 0)
MprisController.previous();
else if (contentX > 0)
MprisController.next();
}
}
RowLayout {
anchors.fill: parent
spacing: 8
Rectangle {
Layout.alignment: Qt.AlignVCenter | Qt.AlignHCenter
implicitHeight: 128
implicitWidth: 128
color: "transparent"
Image {
id: img
anchors.fill: parent
fillMode: Image.PreserveAspectCrop
visible: flickable.track.artUrl != ""
source: flickable.track.artUrl
cache: false
asynchronous: true
}
}
ColumnLayout {
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
Label {
text: flickable.track.title
font.pointSize: albumLabel.font.pointSize + 1
font.family: "BigBlueTermPlusNerdFont"
color: "white"
elide: Text.ElideRight
Layout.maximumWidth: Math.min(300, implicitWidth)
}
Label {
id: albumLabel
text: flickable.track.album
opacity: 0.8
font.family: "BigBlueTermPlusNerdFont"
color: "white"
Layout.maximumWidth: Math.min(300, implicitWidth)
}
Label {
text: flickable.track.artist
opacity: 0.8
font.family: "BigBlueTermPlusNerdFont"
color: "white"
Layout.maximumWidth: Math.min(300, implicitWidth)
}
}
}
}
}
readonly property real fromPos: trackStack.width * (trackStack.reverse ? -1 : 1)
enterTransition: PropertyAnimation {
property: "x"
from: trackStack.fromPos
to: 0
duration: 350
easing.type: Easing.OutExpo
}
exitTransition: PropertyAnimation {
property: "x"
to: target.x - trackStack.fromPos
duration: 350
easing.type: Easing.OutExpo
}
}
Item {
Layout.fillWidth: true
implicitHeight: controlsRow.implicitHeight
RowLayout {
id: controlsRow
anchors.centerIn: parent
ClickableIcon {
Layout.leftMargin: 2
Layout.rightMargin: 2
implicitWidth: 24
implicitHeight: 24
enabled: MprisController.loopSupported
image: {
switch (MprisController.loopState) {
case MprisLoopState.None:
return "../../assets/repeat-off.png";
case MprisLoopState.Playlist:
return "../../assets/repeat.png";
case MprisLoopState.Track:
return "../../assets/repeat-once.png";
}
}
onClicked: {
let target = MprisLoopState.None;
switch (MprisController.loopState) {
case MprisLoopState.None:
target = MprisLoopState.Playlist;
break;
case MprisLoopState.Playlist:
target = MprisLoopState.Track;
break;
case MprisLoopState.Track:
target = MprisLoopState.None;
break;
}
MprisController.setLoopState(target);
}
}
ClickableIcon {
Layout.leftMargin: 2
Layout.rightMargin: 2
implicitWidth: 32
implicitHeight: 32
enabled: MprisController.canGoPrevious
image: "../../assets/fastforward.png"
mirror: true
onClicked: MprisController.previous()
}
ClickableIcon {
implicitWidth: 42
implicitHeight: 42
enabled: MprisController.canTogglePlaying
image: `../../assets/${MprisController.isPlaying ? "pause" : "play"}.png`
onClicked: MprisController.togglePlaying()
}
ClickableIcon {
Layout.leftMargin: 2
Layout.rightMargin: 2
implicitWidth: 32
implicitHeight: 32
enabled: MprisController.canGoNext
image: "../../assets/fastforward.png"
onClicked: MprisController.next()
}
ClickableIcon {
Layout.leftMargin: 2
Layout.rightMargin: 2
implicitWidth: 24
implicitHeight: 24
enabled: MprisController.shuffleSupported
image: `../../assets/${MprisController.hasShuffle ? "shuffle" : "noshuffle"}.png`
onClicked: MprisController.setShuffle(!MprisController.hasShuffle)
}
}
}
RowLayout {
Layout.margins: 5
Label {
Layout.preferredWidth: lengthLabel.implicitWidth
text: positionInfo.timeStr(positionInfo.position)
font.family: "BigBlueTermPlusNerdFont"
color: "white"
}
Slider {
id: slider
property bool bindSlider: true
property real boundAnimStart: 0
property real boundAnimFactor: 1
property real lastPosition: 0
property real lastLength: 0
property real boundPosition: {
const ppos = MprisController.activePlayer.position / MprisController.activePlayer.length;
const bpos = boundAnimStart;
return (ppos * boundAnimFactor) + (bpos * (1.0 - boundAnimFactor));
}
Layout.fillWidth: true
enabled: MprisController.activePlayer.canSeek
from: 0
to: 1
background: Rectangle {
x: slider.leftPadding
y: slider.topPadding + slider.availableHeight / 2 - height / 2
implicitHeight: 24
width: slider.availableWidth
height: implicitHeight
color: "#824524"
Rectangle {
anchors.margins: 8
x: anchors.leftMargin
y: anchors.topMargin
width: slider.visualPosition * (parent.width - anchors.leftMargin - anchors.rightMargin)
height: parent.height - anchors.topMargin - anchors.bottomMargin
color: "#8D804B"
}
}
handle: Rectangle {
x: slider.leftPadding + slider.visualPosition * (slider.availableWidth - width)
y: slider.topPadding + slider.availableHeight / 2 - height / 2
implicitWidth: 16
implicitHeight: 16
rotation: 45
color: "#8D804B"
}
Connections {
target: MprisController.activePlayer
function onPositionChanged() {
slider.lastPosition = MprisController.activePlayer.position;
slider.lastLength = MprisController.activePlayer.length;
}
}
onPressedChanged: () => {
if (!pressed)
MprisController.activePlayer.position = value * MprisController.activePlayer.length;
bindSlider = !pressed;
}
Binding {
when: slider.bindSlider
slider.value: slider.boundPosition
}
}
Label {
id: lengthLabel
text: positionInfo.timeStr(positionInfo.length)
font.family: "BigBlueTermPlusNerdFont"
color: "white"
}
}
}
}
Image {
source: "../../assets/rope-tie.png"
smooth: false
width: 32
height: 32
x: wrapper.x + 49 - width / 2
y: wrapper.y - height / 2
}
Image {
source: "../../assets/rope-tie.png"
smooth: false
width: 32
height: 32
x: wrapper.width + wrapper.x - 49 - width / 2
y: wrapper.y - height / 2
}
}