ported some quickshell stuff from rivendell-hyprdots

This commit is contained in:
Tomas Rivera
2026-03-10 16:22:35 +01:00
parent 4b442dd121
commit b9e3f35abe
80 changed files with 4462 additions and 9 deletions
@@ -0,0 +1,165 @@
pragma Singleton
pragma ComponentBehavior: Bound
import QtQml.Models
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Services.Mpris
Singleton {
id: root
property MprisPlayer trackedPlayer: null
property MprisPlayer activePlayer: trackedPlayer ?? Mpris.players.values[0] ?? null
signal trackChanged(reverse: bool)
property bool __reverse: false
property var activeTrack
Instantiator {
model: Mpris.players
Connections {
required property MprisPlayer modelData
target: modelData
Component.onCompleted: {
if (root.trackedPlayer == null || modelData.isPlaying) {
root.trackedPlayer = modelData;
}
}
Component.onDestruction: {
if (root.trackedPlayer == null || !root.trackedPlayer.isPlaying) {
for (const player of Mpris.players.values) {
if (player.playbackState.isPlaying) {
root.trackedPlayer = player;
break;
}
}
if (trackedPlayer == null && Mpris.players.values.length != 0) {
trackedPlayer = Mpris.players.values[0];
}
}
}
function onPlaybackStateChanged() {
if (root.trackedPlayer !== modelData)
root.trackedPlayer = modelData;
}
}
}
Connections {
target: activePlayer
function onPostTrackChanged() {
root.updateTrack();
}
function onTrackArtUrlChanged() {
//root.updateTrack();
if (root.activePlayer.uniqueId == root.activeTrack.uniqueId && root.activePlayer.trackArtUrl != root.activeTrack.artUrl) {
// cantata likes to send cover updates *BEFORE* updating the track info.
// as such, art url changes shouldn't be able to break the reverse animation
const r = root.__reverse;
root.updateTrack();
root.__reverse = r;
}
}
}
onActivePlayerChanged: this.updateTrack()
function updateTrack() {
//console.log(`update: ${this.activePlayer?.trackTitle ?? ""} : ${this.activePlayer?.trackArtists}`)
this.activeTrack = {
uniqueId: this.activePlayer?.uniqueId ?? 0,
artUrl: this.activePlayer?.trackArtUrl ?? "",
title: this.activePlayer?.trackTitle || "Unknown Title",
artist: this.activePlayer?.trackArtist || "Unknown Artist",
album: this.activePlayer?.trackAlbum || "Unknown Album"
};
this.trackChanged(__reverse);
this.__reverse = false;
}
property bool isPlaying: this.activePlayer && this.activePlayer.isPlaying
property bool canTogglePlaying: this.activePlayer?.canTogglePlaying ?? false
function togglePlaying() {
if (this.canTogglePlaying)
this.activePlayer.togglePlaying();
}
property bool canGoPrevious: this.activePlayer?.canGoPrevious ?? false
function previous() {
if (this.canGoPrevious) {
this.__reverse = true;
this.activePlayer.previous();
}
}
property bool canGoNext: this.activePlayer?.canGoNext ?? false
function next() {
if (this.canGoNext) {
this.__reverse = false;
this.activePlayer.next();
}
}
property bool canChangeVolume: this.activePlayer && this.activePlayer.volumeSupported && this.activePlayer.canControl
property bool loopSupported: this.activePlayer && this.activePlayer.loopSupported && this.activePlayer.canControl
property var loopState: this.activePlayer?.loopState ?? MprisLoopState.None
function setLoopState(loopState: var) {
if (this.loopSupported) {
this.activePlayer.loopState = loopState;
}
}
property bool shuffleSupported: this.activePlayer && this.activePlayer.shuffleSupported && this.activePlayer.canControl
property bool hasShuffle: this.activePlayer?.shuffle ?? false
function setShuffle(shuffle: bool) {
if (this.shuffleSupported) {
this.activePlayer.shuffle = shuffle;
}
}
function setActivePlayer(player: MprisPlayer) {
const targetPlayer = player ?? Mpris.players[0];
console.log(`setactive: ${targetPlayer} from ${activePlayer}`);
if (targetPlayer && this.activePlayer) {
this.__reverse = Mpris.players.indexOf(targetPlayer) < Mpris.players.indexOf(this.activePlayer);
} else {
// always animate forward if going to null
this.__reverse = false;
}
this.trackedPlayer = targetPlayer;
}
IpcHandler {
target: "mpris"
function pauseAll(): void {
for (const player of Mpris.players.values) {
if (player.canPause)
player.pause();
}
}
function playPause(): void {
root.togglePlaying();
}
function previous(): void {
root.previous();
}
function next(): void {
root.next();
}
}
}
+151
View File
@@ -0,0 +1,151 @@
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 ".."
MouseArea {
id: root
required property var window
required property var bar
readonly property var activePlayer: MprisController.activePlayer
property var isPopupOpen: false
implicitWidth: column.implicitWidth + 10
Layout.fillHeight: true
acceptedButtons: Qt.RightButton
onClicked: isPopupOpen = !isPopupOpen
Item {
id: widget
anchors.fill: parent
scale: root.isPopupOpen ? 0.92 : 1
Behavior on scale {
NumberAnimation {
duration: 70
}
}
implicitHeight: column.implicitHeight + 10
RowLayout {
id: column
anchors {
fill: parent
}
ClickableIcon {
acceptedButtons: Qt.LeftButton
implicitWidth: 20
implicitHeight: 20
image: "../../assets/fastforward.png"
mirror: true
hoverEnabled: true
enabled: MprisController.canGoPrevious
onClicked: MprisController.previous()
scale: containsMouse ? 0.9 : 1
Behavior on scale {
NumberAnimation {
duration: 70
}
}
}
ClickableIcon {
acceptedButtons: Qt.LeftButton
implicitWidth: 26
implicitHeight: 26
Layout.leftMargin: -4
Layout.rightMargin: -4
image: `../../assets/${MprisController.isPlaying ? "pause" : "play"}.png`
hoverEnabled: true
enabled: MprisController.canTogglePlaying
onClicked: MprisController.togglePlaying()
}
ClickableIcon {
acceptedButtons: Qt.LeftButton
implicitWidth: 20
implicitHeight: 20
image: "../../assets/fastforward.png"
hoverEnabled: true
enabled: MprisController.canGoNext
onClicked: MprisController.next()
scale: containsMouse ? 0.9 : 1
Behavior on scale {
NumberAnimation {
duration: 70
}
}
}
}
Connections {
target: root
function onIsPopupOpenChanged() {
root.window.focusable = root.isPopupOpen;
}
}
LazyLoader {
id: popupLoader
active: root.isPopupOpen
// Leave time for exit anim
Behavior on active {
enabled: popupLoader.active
NumberAnimation {
duration: 1000
}
}
PlayersPopup {
id: popup
visible: true
isOpen: root.isPopupOpen
anchor.window: root.window
anchor.rect.x: root.window.width
anchor.rect.y: 0
HyprlandFocusGrab {
active: root.isPopupOpen
windows: [popup]
}
contentItem {
focus: true
Keys.onEscapePressed: root.isPopupOpen = false
}
}
}
}
component CenteredText: Item {
Layout.fillWidth: true
property alias text: label.text
property alias font: label.font
Label {
id: label
visible: text != ""
anchors.centerIn: parent
elide: Text.ElideRight
width: Math.min(parent.width - 20, implicitWidth)
font.family: "BigBlueTermPlusNerdFont"
color: "white"
}
}
}
+529
View File
@@ -0,0 +1,529 @@
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
}
}