diff --git a/Bar.qml b/Bar.qml index f628a98..ccc0b63 100644 --- a/Bar.qml +++ b/Bar.qml @@ -91,6 +91,7 @@ Variants { id: visibilities property bool sidebar + property bool dashboard Component.onCompleted: Visibilities.load(scope.modelData, this) } diff --git a/Components/ColoredIcon.qml b/Components/ColoredIcon.qml new file mode 100644 index 0000000..fb38a08 --- /dev/null +++ b/Components/ColoredIcon.qml @@ -0,0 +1,35 @@ +pragma ComponentBehavior: Bound + +import ZShell +import Quickshell.Widgets +import QtQuick + +IconImage { + id: root + + required property color color + + asynchronous: true + + layer.enabled: true + layer.effect: Coloriser { + sourceColor: analyser.dominantColour + colorizationColor: root.color + } + + layer.onEnabledChanged: { + if (layer.enabled && status === Image.Ready) + analyser.requestUpdate(); + } + + onStatusChanged: { + if (layer.enabled && status === Image.Ready) + analyser.requestUpdate(); + } + + ImageAnalyser { + id: analyser + + sourceItem: root + } +} diff --git a/Components/Coloriser.qml b/Components/Coloriser.qml new file mode 100644 index 0000000..f0212f1 --- /dev/null +++ b/Components/Coloriser.qml @@ -0,0 +1,14 @@ +import QtQuick +import QtQuick.Effects +import qs.Modules + +MultiEffect { + property color sourceColor: "black" + + colorization: 1 + brightness: 1 - sourceColor.hslLightness + + Behavior on colorizationColor { + CAnim {} + } +} diff --git a/Components/Ref.qml b/Components/Ref.qml new file mode 100644 index 0000000..0a694a4 --- /dev/null +++ b/Components/Ref.qml @@ -0,0 +1,8 @@ +import QtQuick + +QtObject { + required property var service + + Component.onCompleted: service.refCount++ + Component.onDestruction: service.refCount-- +} diff --git a/Config/Appearance.qml b/Config/Appearance.qml new file mode 100644 index 0000000..00efc96 --- /dev/null +++ b/Config/Appearance.qml @@ -0,0 +1,14 @@ +pragma Singleton + +import Quickshell + +Singleton { + // Literally just here to shorten accessing stuff :woe: + // Also kinda so I can keep accessing it with `Appearance.xxx` instead of `Conf.appearance.xxx` + readonly property AppearanceConf.Rounding rounding: Config.appearance.rounding + readonly property AppearanceConf.Spacing spacing: Config.appearance.spacing + readonly property AppearanceConf.Padding padding: Config.appearance.padding + readonly property AppearanceConf.FontStuff font: Config.appearance.font + readonly property AppearanceConf.Anim anim: Config.appearance.anim + readonly property AppearanceConf.Transparency transparency: Config.appearance.transparency +} diff --git a/Config/AppearanceConf.qml b/Config/AppearanceConf.qml new file mode 100644 index 0000000..3d590dc --- /dev/null +++ b/Config/AppearanceConf.qml @@ -0,0 +1,94 @@ +import Quickshell.Io + +JsonObject { + property Rounding rounding: Rounding {} + property Spacing spacing: Spacing {} + property Padding padding: Padding {} + property FontStuff font: FontStuff {} + property Anim anim: Anim {} + property Transparency transparency: Transparency {} + + component Rounding: JsonObject { + property real scale: 1 + property int small: 12 * scale + property int normal: 17 * scale + property int large: 25 * scale + property int full: 1000 * scale + } + + component Spacing: JsonObject { + property real scale: 1 + property int small: 7 * scale + property int smaller: 10 * scale + property int normal: 12 * scale + property int larger: 15 * scale + property int large: 20 * scale + } + + component Padding: JsonObject { + property real scale: 1 + property int small: 5 * scale + property int smaller: 7 * scale + property int normal: 10 * scale + property int larger: 12 * scale + property int large: 15 * scale + } + + component FontFamily: JsonObject { + property string sans: "Rubik" + property string mono: "CaskaydiaCove NF" + property string material: "Material Symbols Rounded" + property string clock: "Rubik" + } + + component FontSize: JsonObject { + property real scale: 1 + property int small: 11 * scale + property int smaller: 12 * scale + property int normal: 13 * scale + property int larger: 15 * scale + property int large: 18 * scale + property int extraLarge: 28 * scale + } + + component FontStuff: JsonObject { + property FontFamily family: FontFamily {} + property FontSize size: FontSize {} + } + + component AnimCurves: JsonObject { + property list emphasized: [0.05, 0, 2 / 15, 0.06, 1 / 6, 0.4, 5 / 24, 0.82, 0.25, 1, 1, 1] + property list emphasizedAccel: [0.3, 0, 0.8, 0.15, 1, 1] + property list emphasizedDecel: [0.05, 0.7, 0.1, 1, 1, 1] + property list standard: [0.2, 0, 0, 1, 1, 1] + property list standardAccel: [0.3, 0, 1, 1, 1, 1] + property list standardDecel: [0, 0, 0, 1, 1, 1] + property list expressiveFastSpatial: [0.42, 1.67, 0.21, 0.9, 1, 1] + property list expressiveDefaultSpatial: [0.38, 1.21, 0.22, 1, 1, 1] + property list expressiveEffects: [0.34, 0.8, 0.34, 1, 1, 1] + } + + component AnimDurations: JsonObject { + property real scale: 1 + property int small: 200 * scale + property int normal: 400 * scale + property int large: 600 * scale + property int extraLarge: 1000 * scale + property int expressiveFastSpatial: 350 * scale + property int expressiveDefaultSpatial: 500 * scale + property int expressiveEffects: 200 * scale + } + + component Anim: JsonObject { + property real mediaGifSpeedAdjustment: 300 + property real sessionGifSpeed: 0.7 + property AnimCurves curves: AnimCurves {} + property AnimDurations durations: AnimDurations {} + } + + component Transparency: JsonObject { + property bool enabled: false + property real base: 0.85 + property real layers: 0.4 + } +} diff --git a/Config/BarConfig.qml b/Config/BarConfig.qml index 707ff76..4a17694 100644 --- a/Config/BarConfig.qml +++ b/Config/BarConfig.qml @@ -20,6 +20,10 @@ JsonObject { id: "updates", enabled: true }, + { + id: "dash", + enabled: true + }, { id: "spacer", enabled: true diff --git a/Config/Config.qml b/Config/Config.qml index 3dd36f2..2800040 100644 --- a/Config/Config.qml +++ b/Config/Config.qml @@ -29,6 +29,9 @@ Singleton { property alias notifs: adapter.notifs property alias sidebar: adapter.sidebar property alias utilities: adapter.utilities + property alias general: adapter.general + property alias dashboard: adapter.dashboard + property alias appearance: adapter.appearance FileView { id: root @@ -66,6 +69,9 @@ Singleton { property NotifConfig notifs: NotifConfig {} property SidebarConfig sidebar: SidebarConfig {} property UtilConfig utilities: UtilConfig {} + property General general: General {} + property DashboardConfig dashboard: DashboardConfig {} + property AppearanceConf appearance: AppearanceConf {} } } } diff --git a/Config/DashboardConfig.qml b/Config/DashboardConfig.qml new file mode 100644 index 0000000..030292b --- /dev/null +++ b/Config/DashboardConfig.qml @@ -0,0 +1,25 @@ +import Quickshell.Io + +JsonObject { + property bool enabled: true + property bool showOnHover: true + property int mediaUpdateInterval: 500 + property int dragThreshold: 50 + property Sizes sizes: Sizes {} + + component Sizes: JsonObject { + readonly property int tabIndicatorHeight: 3 + readonly property int tabIndicatorSpacing: 5 + readonly property int infoWidth: 200 + readonly property int infoIconSize: 25 + readonly property int dateTimeWidth: 110 + readonly property int mediaWidth: 200 + readonly property int mediaProgressSweep: 180 + 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 + readonly property int resourceSize: 200 + } +} diff --git a/Config/General.qml b/Config/General.qml new file mode 100644 index 0000000..882df88 --- /dev/null +++ b/Config/General.qml @@ -0,0 +1,5 @@ +import Quickshell.Io + +JsonObject { + property string logo: "" +} diff --git a/Config/Services.qml b/Config/Services.qml index 651f9f2..422539c 100644 --- a/Config/Services.qml +++ b/Config/Services.qml @@ -4,4 +4,11 @@ import QtQuick JsonObject { property string weatherLocation: "" property real brightnessIncrement: 0.1 + property string defaultPlayer: "Spotify" + property list playerAliases: [ + { + "from": "com.github.th_ch.youtube_music", + "to": "YT Music" + } + ] } diff --git a/Daemons/NotifServer.qml b/Daemons/NotifServer.qml index 9b9365e..f7a22f1 100644 --- a/Daemons/NotifServer.qml +++ b/Daemons/NotifServer.qml @@ -223,10 +223,10 @@ Singleton { const hash = (h2 >>> 0).toString(16).padStart(8, 0) + (h1 >>> 0).toString(16).padStart(8, 0); const cache = `${Paths.notifimagecache}/${hash}.png`; - ZShellIo.saveItem(this, Qt.resolvedUrl(cache), () => { - notif.image = cache; - notif.dummyImageLoader.active = false; - }); + ZShellIo.saveItem(this, Qt.resolvedUrl(cache), () => { + notif.image = cache; + notif.dummyImageLoader.active = false; + }); } anchors.fill: parent diff --git a/Drawers/Backgrounds.qml b/Drawers/Backgrounds.qml index 0696d38..ef73a88 100644 --- a/Drawers/Backgrounds.qml +++ b/Drawers/Backgrounds.qml @@ -4,6 +4,7 @@ import qs.Modules as Modules import qs.Modules.Notifications as Notifications import qs.Modules.Notifications.Sidebar as Sidebar import qs.Modules.Notifications.Sidebar.Utils as Utils +import qs.Modules.Dashboard as Dashboard Shape { id: root @@ -32,6 +33,13 @@ Shape { startY: 0 } + Dashboard.Background { + wrapper: root.panels.dashboard + + startX: ( root.width - wrapper.width ) / 2 - rounding + startY: 0 + } + Utils.Background { wrapper: root.panels.utilities sidebar: sidebar diff --git a/Drawers/Panels.qml b/Drawers/Panels.qml index 296c6e8..343b58f 100644 --- a/Drawers/Panels.qml +++ b/Drawers/Panels.qml @@ -5,6 +5,7 @@ import qs.Modules as Modules import qs.Modules.Notifications as Notifications import qs.Modules.Notifications.Sidebar as Sidebar import qs.Modules.Notifications.Sidebar.Utils as Utils +import qs.Modules.Dashboard as Dashboard import qs.Config Item { @@ -18,6 +19,7 @@ Item { readonly property alias sidebar: sidebar readonly property alias notifications: notifications readonly property alias utilities: utilities + readonly property alias dashboard: dashboard anchors.fill: parent // anchors.margins: 8 @@ -60,6 +62,15 @@ Item { anchors.right: parent.right } + Dashboard.Wrapper { + id: dashboard + + visibilities: root.visibilities + + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + } + Sidebar.Wrapper { id: sidebar diff --git a/Helpers/Hypr.qml b/Helpers/Hypr.qml index 2d2c0e3..126173c 100644 --- a/Helpers/Hypr.qml +++ b/Helpers/Hypr.qml @@ -57,18 +57,9 @@ Singleton { Component.onCompleted: reloadDynamicConfs() - function updateActiveWindow(): void { - root.desktopName = root.applicationDir + root.activeToplevel?.lastIpcObject.class + ".desktop"; - } - - Connections { - target: Hyprland - - function onRawEvent( event: HyprlandEvent ): void { - if ( event.name === "activewindow" ) { - } - } - } + // function updateActiveWindow(): void { + // root.desktopName = root.applicationDir + root.activeToplevel?.lastIpcObject.class + ".desktop"; + // } Connections { target: Hyprland @@ -84,20 +75,20 @@ Singleton { } else if (["workspace", "moveworkspace", "activespecial", "focusedmon"].includes(n)) { Hyprland.refreshWorkspaces(); Hyprland.refreshMonitors(); - Qt.callLater( root.updateActiveWindow ); + // Qt.callLater( root.updateActiveWindow ); } else if (["openwindow", "closewindow", "movewindow"].includes(n)) { Hyprland.refreshToplevels(); Hyprland.refreshWorkspaces(); - Qt.callLater( root.updateActiveWindow ); + // Qt.callLater( root.updateActiveWindow ); } else if (n.includes("mon")) { Hyprland.refreshMonitors(); - Qt.callLater( root.updateActiveWindow ); + // Qt.callLater( root.updateActiveWindow ); } else if (n.includes("workspace")) { Hyprland.refreshWorkspaces(); - Qt.callLater( root.updateActiveWindow ); + // Qt.callLater( root.updateActiveWindow ); } else if (n.includes("window") || n.includes("group") || ["pin", "fullscreen", "changefloatingmode", "minimize"].includes(n)) { Hyprland.refreshToplevels(); - Qt.callLater( root.updateActiveWindow ); + // Qt.callLater( root.updateActiveWindow ); } } } diff --git a/Helpers/Players.qml b/Helpers/Players.qml index 68bc112..a412849 100644 --- a/Helpers/Players.qml +++ b/Helpers/Players.qml @@ -1,10 +1,123 @@ pragma Singleton import Quickshell +import Quickshell.Io import Quickshell.Services.Mpris +import QtQml +import ZShell +import qs.Config +import qs.Components Singleton { - id: root + id: root readonly property list list: Mpris.players.values + readonly property MprisPlayer active: props.manualActive ?? list.find(p => getIdentity(p) === Config.services.defaultPlayer) ?? list[0] ?? null + property alias manualActive: props.manualActive + + function getIdentity(player: MprisPlayer): string { + const alias = Config.services.playerAliases.find(a => a.from === player.identity); + return alias?.to ?? player.identity; + } + + Connections { + target: active + + function onPostTrackChanged() { + if (!Config.utilities.toasts.nowPlaying) { + return; + } + } + } + + PersistentProperties { + id: props + + property MprisPlayer manualActive + + reloadableId: "players" + } + + CustomShortcut { + name: "mediaToggle" + description: "Toggle media playback" + onPressed: { + const active = root.active; + if (active && active.canTogglePlaying) + active.togglePlaying(); + } + } + + CustomShortcut { + name: "mediaPrev" + description: "Previous track" + onPressed: { + const active = root.active; + if (active && active.canGoPrevious) + active.previous(); + } + } + + CustomShortcut { + name: "mediaNext" + description: "Next track" + onPressed: { + const active = root.active; + if (active && active.canGoNext) + active.next(); + } + } + + CustomShortcut { + name: "mediaStop" + description: "Stop media playback" + onPressed: root.active?.stop() + } + + IpcHandler { + target: "mpris" + + function getActive(prop: string): string { + const active = root.active; + return active ? active[prop] ?? "Invalid property" : "No active player"; + } + + function list(): string { + return root.list.map(p => root.getIdentity(p)).join("\n"); + } + + function play(): void { + const active = root.active; + if (active?.canPlay) + active.play(); + } + + function pause(): void { + const active = root.active; + if (active?.canPause) + active.pause(); + } + + function playPause(): void { + const active = root.active; + if (active?.canTogglePlaying) + active.togglePlaying(); + } + + function previous(): void { + const active = root.active; + if (active?.canGoPrevious) + active.previous(); + } + + function next(): void { + const active = root.active; + if (active?.canGoNext) + active.next(); + } + + function stop(): void { + root.active?.stop(); + } + } } diff --git a/Helpers/SystemInfo.qml b/Helpers/SystemInfo.qml new file mode 100644 index 0000000..550a544 --- /dev/null +++ b/Helpers/SystemInfo.qml @@ -0,0 +1,84 @@ +pragma Singleton + +import qs.Config +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + property string osName + property string osPrettyName + property string osId + property list osIdLike + property string osLogo + property bool isDefaultLogo: true + + property string uptime + readonly property string user: Quickshell.env("USER") + readonly property string wm: Quickshell.env("XDG_CURRENT_DESKTOP") || Quickshell.env("XDG_SESSION_DESKTOP") + readonly property string shell: Quickshell.env("SHELL").split("/").pop() + + FileView { + id: osRelease + + path: "/etc/os-release" + onLoaded: { + const lines = text().split("\n"); + + const fd = key => lines.find(l => l.startsWith(`${key}=`))?.split("=")[1].replace(/"/g, "") ?? ""; + + root.osName = fd("NAME"); + root.osPrettyName = fd("PRETTY_NAME"); + root.osId = fd("ID"); + root.osIdLike = fd("ID_LIKE").split(" "); + + const logo = Quickshell.iconPath(fd("LOGO"), true); + if (Config.general.logo) { + root.osLogo = Quickshell.iconPath(Config.general.logo, true) || "file://" + Paths.absolutePath(Config.general.logo); + root.isDefaultLogo = false; + } else if (logo) { + root.osLogo = logo; + root.isDefaultLogo = false; + } + } + } + + Connections { + target: Config.general + + function onLogoChanged(): void { + osRelease.reload(); + } + } + + Timer { + running: true + repeat: true + interval: 15000 + onTriggered: fileUptime.reload() + } + + FileView { + id: fileUptime + + path: "/proc/uptime" + onLoaded: { + const up = parseInt(text().split(" ")[0] ?? 0); + + const days = Math.floor(up / 86400); + const hours = Math.floor((up % 86400) / 3600); + const minutes = Math.floor((up % 3600) / 60); + + let str = ""; + if (days > 0) + str += `${days} day${days === 1 ? "" : "s"}`; + if (hours > 0) + str += `${str ? ", " : ""}${hours} hour${hours === 1 ? "" : "s"}`; + if (minutes > 0 || !str) + str += `${str ? ", " : ""}${minutes} minute${minutes === 1 ? "" : "s"}`; + root.uptime = str; + } + } +} diff --git a/Helpers/SystemUsage.qml b/Helpers/SystemUsage.qml new file mode 100644 index 0000000..5b09ee4 --- /dev/null +++ b/Helpers/SystemUsage.qml @@ -0,0 +1,222 @@ +pragma Singleton + +import Quickshell +import Quickshell.Io +import QtQuick +import qs.Config + +Singleton { + id: root + + property real cpuPerc + property real cpuTemp + readonly property string gpuType: Config.gpuType.toUpperCase() || autoGpuType + property string autoGpuType: "NONE" + property real gpuPerc + property real gpuTemp + property real memUsed + property real memTotal + readonly property real memPerc: memTotal > 0 ? memUsed / memTotal : 0 + property real storageUsed + property real storageTotal + property real storagePerc: storageTotal > 0 ? storageUsed / storageTotal : 0 + + property real lastCpuIdle + property real lastCpuTotal + + property int refCount + + function formatKib(kib: real): var { + const mib = 1024; + const gib = 1024 ** 2; + const tib = 1024 ** 3; + + if (kib >= tib) + return { + value: kib / tib, + unit: "TiB" + }; + if (kib >= gib) + return { + value: kib / gib, + unit: "GiB" + }; + if (kib >= mib) + return { + value: kib / mib, + unit: "MiB" + }; + return { + value: kib, + unit: "KiB" + }; + } + + Timer { + running: root.refCount > 0 + interval: 3000 + repeat: true + triggeredOnStart: true + onTriggered: { + stat.reload(); + meminfo.reload(); + storage.running = true; + gpuUsage.running = true; + sensors.running = true; + } + } + + FileView { + id: stat + + path: "/proc/stat" + onLoaded: { + const data = text().match(/^cpu\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/); + if (data) { + const stats = data.slice(1).map(n => parseInt(n, 10)); + const total = stats.reduce((a, b) => a + b, 0); + const idle = stats[3] + (stats[4] ?? 0); + + const totalDiff = total - root.lastCpuTotal; + const idleDiff = idle - root.lastCpuIdle; + root.cpuPerc = totalDiff > 0 ? (1 - idleDiff / totalDiff) : 0; + + root.lastCpuTotal = total; + root.lastCpuIdle = idle; + } + } + } + + FileView { + id: meminfo + + path: "/proc/meminfo" + onLoaded: { + const data = text(); + root.memTotal = parseInt(data.match(/MemTotal: *(\d+)/)[1], 10) || 1; + root.memUsed = (root.memTotal - parseInt(data.match(/MemAvailable: *(\d+)/)[1], 10)) || 0; + } + } + + Process { + id: storage + + command: ["sh", "-c", "df | grep '^/dev/' | awk '{print $1, $3, $4}'"] + stdout: StdioCollector { + onStreamFinished: { + const deviceMap = new Map(); + + for (const line of text.trim().split("\n")) { + if (line.trim() === "") + continue; + + const parts = line.trim().split(/\s+/); + if (parts.length >= 3) { + const device = parts[0]; + const used = parseInt(parts[1], 10) || 0; + const avail = parseInt(parts[2], 10) || 0; + + // Only keep the entry with the largest total space for each device + if (!deviceMap.has(device) || (used + avail) > (deviceMap.get(device).used + deviceMap.get(device).avail)) { + deviceMap.set(device, { + used: used, + avail: avail + }); + } + } + } + + let totalUsed = 0; + let totalAvail = 0; + + for (const [device, stats] of deviceMap) { + totalUsed += stats.used; + totalAvail += stats.avail; + } + + root.storageUsed = totalUsed; + root.storageTotal = totalUsed + totalAvail; + } + } + } + + Process { + id: gpuTypeCheck + + running: !Config.gpuType + command: ["sh", "-c", "if command -v nvidia-smi &>/dev/null && nvidia-smi -L &>/dev/null; then echo NVIDIA; elif ls /sys/class/drm/card*/device/gpu_busy_percent 2>/dev/null | grep -q .; then echo GENERIC; else echo NONE; fi"] + stdout: StdioCollector { + onStreamFinished: root.autoGpuType = text.trim() + } + } + + Process { + id: gpuUsage + + command: root.gpuType === "GENERIC" ? ["sh", "-c", "cat /sys/class/drm/card*/device/gpu_busy_percent"] : root.gpuType === "NVIDIA" ? ["nvidia-smi", "--query-gpu=utilization.gpu,temperature.gpu", "--format=csv,noheader,nounits"] : ["echo"] + stdout: StdioCollector { + onStreamFinished: { + if (root.gpuType === "GENERIC") { + const percs = text.trim().split("\n"); + const sum = percs.reduce((acc, d) => acc + parseInt(d, 10), 0); + root.gpuPerc = sum / percs.length / 100; + } else if (root.gpuType === "NVIDIA") { + const [usage, temp] = text.trim().split(","); + root.gpuPerc = parseInt(usage, 10) / 100; + root.gpuTemp = parseInt(temp, 10); + } else { + root.gpuPerc = 0; + root.gpuTemp = 0; + } + } + } + } + + Process { + id: sensors + + command: ["sensors"] + environment: ({ + LANG: "C.UTF-8", + LC_ALL: "C.UTF-8" + }) + stdout: StdioCollector { + onStreamFinished: { + let cpuTemp = text.match(/(?:Package id [0-9]+|Tdie):\s+((\+|-)[0-9.]+)(°| )C/); + if (!cpuTemp) + // If AMD Tdie pattern failed, try fallback on Tctl + cpuTemp = text.match(/Tctl:\s+((\+|-)[0-9.]+)(°| )C/); + + if (cpuTemp) + root.cpuTemp = parseFloat(cpuTemp[1]); + + if (root.gpuType !== "GENERIC") + return; + + let eligible = false; + let sum = 0; + let count = 0; + + for (const line of text.trim().split("\n")) { + if (line === "Adapter: PCI adapter") + eligible = true; + else if (line === "") + eligible = false; + else if (eligible) { + let match = line.match(/^(temp[0-9]+|GPU core|edge)+:\s+\+([0-9]+\.[0-9]+)(°| )C/); + if (!match) + // Fall back to junction/mem if GPU doesn't have edge temp (for AMD GPUs) + match = line.match(/^(junction|mem)+:\s+\+([0-9]+\.[0-9]+)(°| )C/); + + if (match) { + sum += parseFloat(match[2]); + count++; + } + } + } + + root.gpuTemp = count > 0 ? sum / count : 0; + } + } + } +} diff --git a/Helpers/Weather.qml b/Helpers/Weather.qml index c0aefb9..df28df5 100644 --- a/Helpers/Weather.qml +++ b/Helpers/Weather.qml @@ -1,8 +1,9 @@ pragma Singleton -import qs.Config import Quickshell import QtQuick +import ZShell +import qs.Config Singleton { id: root diff --git a/Modules/Bar/BarLoader.qml b/Modules/Bar/BarLoader.qml index 086b05f..d017b25 100644 --- a/Modules/Bar/BarLoader.qml +++ b/Modules/Bar/BarLoader.qml @@ -63,10 +63,6 @@ RowLayout { // popouts.currentName = "calendar"; // popouts.currentCenter = Qt.binding( () => item.mapToItem( root, itemWidth / 2, 0 ).x ); // popouts.hasCurrent = true; - } else if ( id === "activeWindow" && Config.barConfig.popouts.activeWindow ) { - popouts.currentName = "dash"; - popouts.currentCenter = root.width / 2; - popouts.hasCurrent = true; } else if ( id === "network" && Config.barConfig.popouts.network ) { popouts.currentName = "network"; popouts.currentCenter = Qt.binding( () => item.mapToItem( root, itemWidth / 2, 0 ).x ); @@ -182,6 +178,14 @@ RowLayout { sourceComponent: NetworkWidget {} } } + DelegateChoice { + roleValue: "dash" + delegate: WrappedLoader { + sourceComponent: DashWidget { + visibilities: root.visibilities + } + } + } } } diff --git a/Modules/Content.qml b/Modules/Content.qml index 6f8c06f..3f0b30e 100644 --- a/Modules/Content.qml +++ b/Modules/Content.qml @@ -92,14 +92,6 @@ Item { } } - Popout { - name: "dash" - - sourceComponent: Dashboard { - wrapper: root.wrapper - } - } - Popout { name: "upower" diff --git a/Modules/DashWidget.qml b/Modules/DashWidget.qml new file mode 100644 index 0000000..72b1c93 --- /dev/null +++ b/Modules/DashWidget.qml @@ -0,0 +1,31 @@ +import Quickshell +import QtQuick +import qs.Config +import qs.Helpers +import qs.Components + +CustomRect { + id: root + + required property PersistentProperties visibilities + + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.topMargin: 6 + anchors.bottomMargin: 6 + implicitWidth: 40 + color: DynamicColors.tPalette.m3surfaceContainer + radius: 1000 + + StateLayer { + onClicked: { + root.visibilities.dashboard = !root.visibilities.dashboard; + } + } + + MaterialIcon { + anchors.centerIn: parent + text: "widgets" + color: DynamicColors.palette.m3onSurface + } +} diff --git a/Modules/Dashboard/Background.qml b/Modules/Dashboard/Background.qml new file mode 100644 index 0000000..9dd284b --- /dev/null +++ b/Modules/Dashboard/Background.qml @@ -0,0 +1,67 @@ +import qs.Components +import qs.Helpers +import qs.Config +import qs.Modules as Modules +import QtQuick +import QtQuick.Shapes + +ShapePath { + id: root + + required property Wrapper wrapper + readonly property real rounding: 8 + readonly property bool flatten: wrapper.height < rounding * 2 + readonly property real roundingY: flatten ? wrapper.height / 2 : rounding + + strokeWidth: -1 + fillColor: DynamicColors.palette.m3surface + + PathArc { + relativeX: root.rounding + relativeY: root.roundingY + radiusX: root.rounding + radiusY: Math.min(root.rounding, root.wrapper.height) + } + + PathLine { + relativeX: 0 + relativeY: root.wrapper.height - root.roundingY * 2 + } + + PathArc { + relativeX: root.rounding + relativeY: root.roundingY + radiusX: root.rounding + radiusY: Math.min(root.rounding, root.wrapper.height) + direction: PathArc.Counterclockwise + } + + PathLine { + relativeX: root.wrapper.width - root.rounding * 2 + relativeY: 0 + } + + PathArc { + relativeX: root.rounding + relativeY: -root.roundingY + radiusX: root.rounding + radiusY: Math.min(root.rounding, root.wrapper.height) + direction: PathArc.Counterclockwise + } + + PathLine { + relativeX: 0 + relativeY: -(root.wrapper.height - root.roundingY * 2) + } + + PathArc { + relativeX: root.rounding + relativeY: -root.roundingY + radiusX: root.rounding + radiusY: Math.min(root.rounding, root.wrapper.height) + } + + Behavior on fillColor { + Modules.CAnim {} + } +} diff --git a/Modules/Dashboard/Dashboard.qml b/Modules/Dashboard/Content.qml similarity index 93% rename from Modules/Dashboard/Dashboard.qml rename to Modules/Dashboard/Content.qml index 573a145..7000583 100644 --- a/Modules/Dashboard/Dashboard.qml +++ b/Modules/Dashboard/Content.qml @@ -10,13 +10,8 @@ import qs.Modules Item { id: root - required property var wrapper - readonly property PersistentProperties state: PersistentProperties { - property int currentTab: 0 - property date currentDate: new Date() - - reloadableId: "dashboardState" - } + required property PersistentProperties visibilities + required property PersistentProperties state readonly property real nonAnimWidth: view.implicitWidth + viewWrapper.anchors.margins * 2 readonly property real nonAnimHeight: view.implicitHeight + viewWrapper.anchors.margins * 2 @@ -30,6 +25,7 @@ Item { anchors.left: parent.left anchors.right: parent.right anchors.bottom: parent.bottom + anchors.margins: Appearance.padding.large radius: 8 color: "transparent" diff --git a/Modules/Dashboard/Dash.qml b/Modules/Dashboard/Dash.qml index aa09613..3d9b684 100644 --- a/Modules/Dashboard/Dash.qml +++ b/Modules/Dashboard/Dash.qml @@ -2,6 +2,7 @@ import Quickshell import QtQuick.Layouts import qs.Helpers import qs.Components +import qs.Paths import qs.Modules import qs.Config import qs.Modules.Dashboard.Dash @@ -11,29 +12,31 @@ GridLayout { required property PersistentProperties state - rowSpacing: 8 - columnSpacing: 8 + rowSpacing: Appearance.spacing.normal + columnSpacing: Appearance.spacing.normal Rect { Layout.column: 2 Layout.columnSpan: 3 - Layout.preferredWidth: 48 - Layout.preferredHeight: 48 + Layout.preferredWidth: user.implicitWidth + Layout.preferredHeight: user.implicitHeight - radius: 8 + radius: 6 - CachingImage { - path: Quickshell.env("HOME") + "/.face" - } + User { + id: user + + state: root.state + } } Rect { Layout.row: 0 Layout.columnSpan: 2 - Layout.preferredWidth: 250 + Layout.preferredWidth: Config.dashboard.sizes.weatherWidth Layout.fillHeight: true - radius: 8 + radius: 6 Weather {} } @@ -43,13 +46,56 @@ GridLayout { Layout.preferredWidth: dateTime.implicitWidth Layout.fillHeight: true - radius: 8 + radius: 6 DateTime { id: dateTime } } + Rect { + Layout.row: 1 + Layout.column: 1 + Layout.columnSpan: 3 + Layout.fillWidth: true + Layout.preferredHeight: calendar.implicitHeight + + radius: 6 + + Calendar { + id: calendar + + state: root.state + } + } + + Rect { + Layout.row: 1 + Layout.column: 4 + Layout.preferredWidth: resources.implicitWidth + Layout.fillHeight: true + + radius: 6 + + Resources { + id: resources + } + } + + Rect { + Layout.row: 0 + Layout.column: 5 + Layout.rowSpan: 2 + Layout.preferredWidth: media.implicitWidth + Layout.fillHeight: true + + radius: 6 + + Media { + id: media + } + } + component Rect: CustomRect { color: DynamicColors.tPalette.m3surfaceContainer } diff --git a/Modules/Dashboard/Dash/Audio.qml b/Modules/Dashboard/Dash/Audio.qml new file mode 100644 index 0000000..31f07b5 --- /dev/null +++ b/Modules/Dashboard/Dash/Audio.qml @@ -0,0 +1,144 @@ +pragma Singleton + +import qs.Config +import ZShell.Services +import ZShell +import Quickshell +import Quickshell.Services.Pipewire +import QtQuick + +Singleton { + id: root + + property string previousSinkName: "" + property string previousSourceName: "" + + readonly property var nodes: Pipewire.nodes.values.reduce((acc, node) => { + if (!node.isStream) { + if (node.isSink) + acc.sinks.push(node); + else if (node.audio) + acc.sources.push(node); + } else if (node.isStream && node.audio) { + // Application streams (output streams) + acc.streams.push(node); + } + return acc; + }, { + sources: [], + sinks: [], + streams: [] + }) + + readonly property list sinks: nodes.sinks + readonly property list sources: nodes.sources + readonly property list streams: nodes.streams + + readonly property PwNode sink: Pipewire.defaultAudioSink + readonly property PwNode source: Pipewire.defaultAudioSource + + readonly property bool muted: !!sink?.audio?.muted + readonly property real volume: sink?.audio?.volume ?? 0 + + readonly property bool sourceMuted: !!source?.audio?.muted + readonly property real sourceVolume: source?.audio?.volume ?? 0 + + readonly property alias beatTracker: beatTracker + + function setVolume(newVolume: real): void { + if (sink?.ready && sink?.audio) { + sink.audio.muted = false; + sink.audio.volume = Math.max(0, Math.min(Config.services.maxVolume, newVolume)); + } + } + + function incrementVolume(amount: real): void { + setVolume(volume + (amount || Config.services.audioIncrement)); + } + + function decrementVolume(amount: real): void { + setVolume(volume - (amount || Config.services.audioIncrement)); + } + + function setSourceVolume(newVolume: real): void { + if (source?.ready && source?.audio) { + source.audio.muted = false; + source.audio.volume = Math.max(0, Math.min(Config.services.maxVolume, newVolume)); + } + } + + function incrementSourceVolume(amount: real): void { + setSourceVolume(sourceVolume + (amount || Config.services.audioIncrement)); + } + + function decrementSourceVolume(amount: real): void { + setSourceVolume(sourceVolume - (amount || Config.services.audioIncrement)); + } + + function setAudioSink(newSink: PwNode): void { + Pipewire.preferredDefaultAudioSink = newSink; + } + + function setAudioSource(newSource: PwNode): void { + Pipewire.preferredDefaultAudioSource = newSource; + } + + function setStreamVolume(stream: PwNode, newVolume: real): void { + if (stream?.ready && stream?.audio) { + stream.audio.muted = false; + stream.audio.volume = Math.max(0, Math.min(Config.services.maxVolume, newVolume)); + } + } + + function setStreamMuted(stream: PwNode, muted: bool): void { + if (stream?.ready && stream?.audio) { + stream.audio.muted = muted; + } + } + + function getStreamVolume(stream: PwNode): real { + return stream?.audio?.volume ?? 0; + } + + function getStreamMuted(stream: PwNode): bool { + return !!stream?.audio?.muted; + } + + function getStreamName(stream: PwNode): string { + if (!stream) + return qsTr("Unknown"); + // Try application name first, then description, then name + return stream.applicationName || stream.description || stream.name || qsTr("Unknown Application"); + } + + onSinkChanged: { + if (!sink?.ready) + return; + + const newSinkName = sink.description || sink.name || qsTr("Unknown Device"); + + previousSinkName = newSinkName; + } + + onSourceChanged: { + if (!source?.ready) + return; + + const newSourceName = source.description || source.name || qsTr("Unknown Device"); + + previousSourceName = newSourceName; + } + + Component.onCompleted: { + previousSinkName = sink?.description || sink?.name || qsTr("Unknown Device"); + previousSourceName = source?.description || source?.name || qsTr("Unknown Device"); + } + + PwObjectTracker { + objects: [...root.sinks, ...root.sources, ...root.streams] + } + + BeatTracker { + id: beatTracker + } +} diff --git a/Modules/Dashboard/Dash/Calendar.qml b/Modules/Dashboard/Dash/Calendar.qml new file mode 100644 index 0000000..fac5815 --- /dev/null +++ b/Modules/Dashboard/Dash/Calendar.qml @@ -0,0 +1,252 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import qs.Components +import qs.Helpers +import qs.Config +import qs.Modules + +CustomMouseArea { + id: root + + required property var state + + readonly property int currMonth: state.currentDate.getMonth() + readonly property int currYear: state.currentDate.getFullYear() + + anchors.left: parent.left + anchors.right: parent.right + implicitHeight: inner.implicitHeight + inner.anchors.margins * 2 + + acceptedButtons: Qt.MiddleButton + onClicked: root.state.currentDate = new Date() + + function onWheel(event: WheelEvent): void { + if (event.angleDelta.y > 0) + root.state.currentDate = new Date(root.currYear, root.currMonth - 1, 1); + else if (event.angleDelta.y < 0) + root.state.currentDate = new Date(root.currYear, root.currMonth + 1, 1); + } + + ColumnLayout { + id: inner + + anchors.fill: parent + anchors.margins: Appearance.padding.large + spacing: Appearance.spacing.small + + RowLayout { + id: monthNavigationRow + + Layout.fillWidth: true + spacing: Appearance.spacing.small + + Item { + implicitWidth: implicitHeight + implicitHeight: prevMonthText.implicitHeight + Appearance.padding.small * 2 + + StateLayer { + id: prevMonthStateLayer + + radius: Appearance.rounding.full + + function onClicked(): void { + root.state.currentDate = new Date(root.currYear, root.currMonth - 1, 1); + } + } + + MaterialIcon { + id: prevMonthText + + anchors.centerIn: parent + text: "chevron_left" + color: DynamicColors.palette.m3tertiary + font.pointSize: Appearance.font.size.normal + font.weight: 700 + } + } + + Item { + Layout.fillWidth: true + + implicitWidth: monthYearDisplay.implicitWidth + Appearance.padding.small * 2 + implicitHeight: monthYearDisplay.implicitHeight + Appearance.padding.small * 2 + + StateLayer { + anchors.fill: monthYearDisplay + anchors.margins: -Appearance.padding.small + anchors.leftMargin: -Appearance.padding.normal + anchors.rightMargin: -Appearance.padding.normal + + radius: Appearance.rounding.full + disabled: { + const now = new Date(); + return root.currMonth === now.getMonth() && root.currYear === now.getFullYear(); + } + + function onClicked(): void { + root.state.currentDate = new Date(); + } + } + + CustomText { + id: monthYearDisplay + + anchors.centerIn: parent + text: grid.title + color: DynamicColors.palette.m3primary + font.pointSize: Appearance.font.size.normal + font.weight: 500 + font.capitalization: Font.Capitalize + } + } + + Item { + implicitWidth: implicitHeight + implicitHeight: nextMonthText.implicitHeight + Appearance.padding.small * 2 + + StateLayer { + id: nextMonthStateLayer + + radius: Appearance.rounding.full + + function onClicked(): void { + root.state.currentDate = new Date(root.currYear, root.currMonth + 1, 1); + } + } + + MaterialIcon { + id: nextMonthText + + anchors.centerIn: parent + text: "chevron_right" + color: DynamicColors.palette.m3tertiary + font.pointSize: Appearance.font.size.normal + font.weight: 700 + } + } + } + + DayOfWeekRow { + id: daysRow + + Layout.fillWidth: true + locale: grid.locale + + delegate: CustomText { + required property var model + + horizontalAlignment: Text.AlignHCenter + text: model.shortName + font.weight: 500 + color: (model.day === 0 || model.day === 6) ? DynamicColors.palette.m3secondary : DynamicColors.palette.m3onSurfaceVariant + } + } + + Item { + Layout.fillWidth: true + implicitHeight: grid.implicitHeight + + MonthGrid { + id: grid + + month: root.currMonth + year: root.currYear + + anchors.fill: parent + + spacing: 3 + locale: Qt.locale() + + delegate: Item { + id: dayItem + + required property var model + + implicitWidth: implicitHeight + implicitHeight: text.implicitHeight + Appearance.padding.small * 2 + + CustomText { + id: text + + anchors.centerIn: parent + + horizontalAlignment: Text.AlignHCenter + text: grid.locale.toString(dayItem.model.day) + color: { + const dayOfWeek = dayItem.model.date.getUTCDay(); + if (dayOfWeek === 0 || dayOfWeek === 6) + return DynamicColors.palette.m3secondary; + + return DynamicColors.palette.m3onSurfaceVariant; + } + opacity: dayItem.model.today || dayItem.model.month === grid.month ? 1 : 0.4 + font.pointSize: Appearance.font.size.normal + font.weight: 500 + } + } + } + + CustomRect { + id: todayIndicator + + readonly property Item todayItem: grid.contentItem.children.find(c => c.model.today) ?? null + property Item today + + onTodayItemChanged: { + if (todayItem) + today = todayItem; + } + + x: today ? today.x + (today.width - implicitWidth) / 2 : 0 + y: today?.y ?? 0 + + implicitWidth: today?.implicitWidth ?? 0 + implicitHeight: today?.implicitHeight ?? 0 + + clip: true + radius: Appearance.rounding.full + color: DynamicColors.palette.m3primary + + opacity: todayItem ? 1 : 0 + scale: todayItem ? 1 : 0.7 + + Coloriser { + x: -todayIndicator.x + y: -todayIndicator.y + + implicitWidth: grid.width + implicitHeight: grid.height + + source: grid + sourceColor: DynamicColors.palette.m3onSurface + colorizationColor: DynamicColors.palette.m3onPrimary + } + + Behavior on opacity { + Anim {} + } + + Behavior on scale { + Anim {} + } + + Behavior on x { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } + + Behavior on y { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } + } + } + } +} diff --git a/Modules/Dashboard/Dash/DateTime.qml b/Modules/Dashboard/Dash/DateTime.qml index e31c3a6..5efa85b 100644 --- a/Modules/Dashboard/Dash/DateTime.qml +++ b/Modules/Dashboard/Dash/DateTime.qml @@ -4,6 +4,7 @@ import QtQuick import QtQuick.Layouts import qs.Components import qs.Config +import qs.Helpers Item { id: root diff --git a/Modules/Dashboard/Dash/Media.qml b/Modules/Dashboard/Dash/Media.qml new file mode 100644 index 0000000..b7058e6 --- /dev/null +++ b/Modules/Dashboard/Dash/Media.qml @@ -0,0 +1,255 @@ +import ZShell.Services +import QtQuick +import QtQuick.Shapes +import qs.Components +import qs.Config +import qs.Helpers +import qs.Modules +import qs.Paths + +Item { + id: root + + property real playerProgress: { + const active = Players.active; + return active?.length ? active.position / active.length : 0; + } + + anchors.top: parent.top + anchors.bottom: parent.bottom + implicitWidth: Config.dashboard.sizes.mediaWidth + + Behavior on playerProgress { + Anim { + duration: Appearance.anim.durations.large + } + } + + Timer { + running: Players.active?.isPlaying ?? false + interval: Config.dashboard.mediaUpdateInterval + triggeredOnStart: true + repeat: true + onTriggered: Players.active?.positionChanged() + } + + ServiceRef { + service: Audio.beatTracker + } + + Shape { + preferredRendererType: Shape.CurveRenderer + + ShapePath { + fillColor: "transparent" + strokeColor: DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHigh, 2) + strokeWidth: Config.dashboard.sizes.mediaProgressThickness + capStyle: Appearance.rounding.scale === 0 ? ShapePath.SquareCap : ShapePath.RoundCap + + PathAngleArc { + centerX: cover.x + cover.width / 2 + centerY: cover.y + cover.height / 2 + radiusX: (cover.width + Config.dashboard.sizes.mediaProgressThickness) / 2 + Appearance.spacing.small + radiusY: (cover.height + Config.dashboard.sizes.mediaProgressThickness) / 2 + Appearance.spacing.small + startAngle: -90 - Config.dashboard.sizes.mediaProgressSweep / 2 + sweepAngle: Config.dashboard.sizes.mediaProgressSweep + } + + Behavior on strokeColor { + CAnim {} + } + } + + ShapePath { + fillColor: "transparent" + strokeColor: DynamicColors.palette.m3primary + strokeWidth: Config.dashboard.sizes.mediaProgressThickness + capStyle: Appearance.rounding.scale === 0 ? ShapePath.SquareCap : ShapePath.RoundCap + + PathAngleArc { + centerX: cover.x + cover.width / 2 + centerY: cover.y + cover.height / 2 + radiusX: (cover.width + Config.dashboard.sizes.mediaProgressThickness) / 2 + Appearance.spacing.small + radiusY: (cover.height + Config.dashboard.sizes.mediaProgressThickness) / 2 + Appearance.spacing.small + startAngle: -90 - Config.dashboard.sizes.mediaProgressSweep / 2 + sweepAngle: Config.dashboard.sizes.mediaProgressSweep * root.playerProgress + } + + Behavior on strokeColor { + CAnim {} + } + } + } + + CustomClippingRect { + id: cover + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: Appearance.padding.large + Config.dashboard.sizes.mediaProgressThickness + Appearance.spacing.small + + implicitHeight: width + color: DynamicColors.tPalette.m3surfaceContainerHigh + radius: Infinity + + MaterialIcon { + anchors.centerIn: parent + + grade: 200 + text: "art_track" + color: DynamicColors.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 + } + } + + CustomText { + id: title + + anchors.top: cover.bottom + anchors.horizontalCenter: parent.horizontalCenter + anchors.topMargin: Appearance.spacing.normal + + animate: true + horizontalAlignment: Text.AlignHCenter + text: (Players.active?.trackTitle ?? qsTr("No media")) || qsTr("Unknown title") + color: DynamicColors.palette.m3primary + font.pointSize: Appearance.font.size.normal + + width: parent.implicitWidth - Appearance.padding.large * 2 + elide: Text.ElideRight + } + + CustomText { + id: album + + anchors.top: title.bottom + anchors.horizontalCenter: parent.horizontalCenter + anchors.topMargin: Appearance.spacing.small + + animate: true + horizontalAlignment: Text.AlignHCenter + text: (Players.active?.trackAlbum ?? qsTr("No media")) || qsTr("Unknown album") + color: DynamicColors.palette.m3outline + font.pointSize: Appearance.font.size.small + + width: parent.implicitWidth - Appearance.padding.large * 2 + elide: Text.ElideRight + } + + CustomText { + id: artist + + anchors.top: album.bottom + anchors.horizontalCenter: parent.horizontalCenter + anchors.topMargin: Appearance.spacing.small + + animate: true + horizontalAlignment: Text.AlignHCenter + text: (Players.active?.trackArtist ?? qsTr("No media")) || qsTr("Unknown artist") + color: DynamicColors.palette.m3secondary + + width: parent.implicitWidth - Appearance.padding.large * 2 + elide: Text.ElideRight + } + + Row { + id: controls + + anchors.top: artist.bottom + anchors.horizontalCenter: parent.horizontalCenter + anchors.topMargin: Appearance.spacing.smaller + + 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(); + } + } + } + + AnimatedImage { + id: bongocat + + anchors.top: controls.bottom + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: Appearance.spacing.small + anchors.bottomMargin: Appearance.padding.large + anchors.margins: Appearance.padding.large * 2 + + playing: Players.active?.isPlaying ?? false + speed: Audio.beatTracker.bpm / Appearance.anim.mediaGifSpeedAdjustment + source: Paths.absolutePath(Config.paths.mediaGif) + asynchronous: true + fillMode: AnimatedImage.PreserveAspectFit + } + + component Control: CustomRect { + 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 ? DynamicColors.palette.m3onSurface : DynamicColors.palette.m3outline + font.pointSize: Appearance.font.size.large + } + } +} diff --git a/Modules/Dashboard/Dash/Resources.qml b/Modules/Dashboard/Dash/Resources.qml new file mode 100644 index 0000000..7795621 --- /dev/null +++ b/Modules/Dashboard/Dash/Resources.qml @@ -0,0 +1,87 @@ +import QtQuick +import qs.Components +import qs.Helpers +import qs.Config +import qs.Modules as Modules + +Row { + id: root + + anchors.top: parent.top + anchors.bottom: parent.bottom + + padding: Appearance.padding.large + spacing: Appearance.spacing.normal + + Ref { + service: SystemUsage + } + + Resource { + icon: "memory" + value: SystemUsage.cpuPerc + color: DynamicColors.palette.m3primary + } + + Resource { + icon: "memory_alt" + value: SystemUsage.memPerc + color: DynamicColors.palette.m3secondary + } + + Resource { + icon: "hard_disk" + value: SystemUsage.storagePerc + color: DynamicColors.palette.m3tertiary + } + + component Resource: Item { + id: res + + required property string icon + required property real value + required property color color + + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.margins: Appearance.padding.large + implicitWidth: icon.implicitWidth + + CustomRect { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + anchors.bottom: icon.top + anchors.bottomMargin: Appearance.spacing.small + + implicitWidth: Config.dashboard.sizes.resourceProgessThickness + + color: DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHigh, 2) + radius: Appearance.rounding.full + + CustomRect { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + implicitHeight: res.value * parent.height + + color: res.color + radius: Appearance.rounding.full + } + } + + MaterialIcon { + id: icon + + anchors.bottom: parent.bottom + + text: res.icon + color: res.color + } + + Behavior on value { + Modules.Anim { + duration: Appearance.anim.durations.large + } + } + } +} diff --git a/Modules/Dashboard/Dash/User.qml b/Modules/Dashboard/Dash/User.qml new file mode 100644 index 0000000..4295da0 --- /dev/null +++ b/Modules/Dashboard/Dash/User.qml @@ -0,0 +1,128 @@ +import qs.Components +import qs.Config +import qs.Paths +import qs.Helpers +import qs.Modules +import Quickshell +import QtQuick + +Row { + id: root + + required property PersistentProperties state + + padding: 20 + spacing: 12 + + CustomClippingRect { + implicitWidth: info.implicitHeight + implicitHeight: info.implicitHeight + + radius: 8 + color: DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHigh, 2) + + MaterialIcon { + anchors.centerIn: parent + + text: "person" + fill: 1 + grade: 200 + font.pointSize: Math.floor(info.implicitHeight / 2) || 1 + } + + CachingImage { + id: pfp + + anchors.fill: parent + path: `${Paths.home}/.face` + } + } + + Column { + id: info + + anchors.verticalCenter: parent.verticalCenter + spacing: 12 + + Item { + id: line + + implicitWidth: icon.implicitWidth + text.width + text.anchors.leftMargin + implicitHeight: Math.max(icon.implicitHeight, text.implicitHeight) + + ColoredIcon { + id: icon + + anchors.left: parent.left + anchors.leftMargin: (Config.dashboard.sizes.infoIconSize - implicitWidth) / 2 + + source: SystemInfo.osLogo + implicitSize: Math.floor(13 * 1.34) + color: DynamicColors.palette.m3primary + } + + CustomText { + id: text + + anchors.verticalCenter: icon.verticalCenter + anchors.left: icon.right + anchors.leftMargin: icon.anchors.leftMargin + text: `: ${SystemInfo.osPrettyName || SystemInfo.osName}` + font.pointSize: 13 + + width: Config.dashboard.sizes.infoWidth + elide: Text.ElideRight + } + } + + InfoLine { + icon: "select_window_2" + text: SystemInfo.wm + colour: DynamicColors.palette.m3secondary + } + + InfoLine { + id: uptime + + icon: "timer" + text: qsTr("up %1").arg(SystemInfo.uptime) + colour: DynamicColors.palette.m3tertiary + } + } + + component InfoLine: Item { + id: line + + required property string icon + required property string text + required property color colour + + implicitWidth: icon.implicitWidth + text.width + text.anchors.leftMargin + implicitHeight: Math.max(icon.implicitHeight, text.implicitHeight) + + MaterialIcon { + id: icon + + anchors.left: parent.left + anchors.leftMargin: (Config.dashboard.sizes.infoIconSize - implicitWidth) / 2 + + fill: 1 + text: line.icon + color: line.colour + font.pointSize: 13 + } + + CustomText { + id: text + + anchors.verticalCenter: icon.verticalCenter + anchors.left: icon.right + anchors.leftMargin: icon.anchors.leftMargin + text: `: ${line.text}` + font.pointSize: 13 + + width: Config.dashboard.sizes.infoWidth + elide: Text.ElideRight + } + } +} diff --git a/Modules/Dashboard/Wrapper.qml b/Modules/Dashboard/Wrapper.qml new file mode 100644 index 0000000..5e9b0cb --- /dev/null +++ b/Modules/Dashboard/Wrapper.qml @@ -0,0 +1,93 @@ +pragma ComponentBehavior: Bound + +import ZShell +import Quickshell +import QtQuick +import qs.Components +import qs.Helpers +import qs.Config +import qs.Modules as Modules + +Item { + id: root + + required property PersistentProperties visibilities + readonly property PersistentProperties dashState: PersistentProperties { + property int currentTab + property date currentDate: new Date() + + reloadableId: "dashboardState" + } + + readonly property real nonAnimHeight: state === "visible" ? (content.item?.nonAnimHeight ?? 0) : 0 + + visible: height > 0 + implicitHeight: 0 + implicitWidth: content.implicitWidth + + onStateChanged: { + if (state === "visible" && timer.running) { + timer.triggered(); + timer.stop(); + } + } + + states: State { + name: "visible" + when: root.visibilities.dashboard && Config.dashboard.enabled + + PropertyChanges { + root.implicitHeight: content.implicitHeight + } + } + + transitions: [ + Transition { + from: "" + to: "visible" + + Modules.Anim { + target: root + property: "implicitHeight" + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + }, + Transition { + from: "visible" + to: "" + + Modules.Anim { + target: root + property: "implicitHeight" + easing.bezierCurve: Appearance.anim.curves.emphasized + } + } + ] + + Timer { + id: timer + + running: true + interval: Appearance.anim.durations.extraLarge + onTriggered: { + content.active = Qt.binding(() => (root.visibilities.dashboard && Config.dashboard.enabled) || root.visible); + content.visible = true; + } + } + + Loader { + id: content + + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + + visible: false + active: true + + sourceComponent: Content { + visibilities: root.visibilities + state: root.dashState + } + } +} diff --git a/Plugins/ZShell/CMakeLists.txt b/Plugins/ZShell/CMakeLists.txt index 1c4bf42..f112b74 100644 --- a/Plugins/ZShell/CMakeLists.txt +++ b/Plugins/ZShell/CMakeLists.txt @@ -1,5 +1,12 @@ -find_package(Qt6 REQUIRED COMPONENTS Core Qml Gui Quick Concurrent Sql) +find_package(Qt6 REQUIRED COMPONENTS Core Qml Gui Quick Concurrent Sql Network DBus) find_package(PkgConfig REQUIRED) +pkg_check_modules(Qalculate IMPORTED_TARGET libqalculate REQUIRED) +pkg_check_modules(Pipewire IMPORTED_TARGET libpipewire-0.3 REQUIRED) +pkg_check_modules(Aubio IMPORTED_TARGET aubio REQUIRED) +pkg_check_modules(Cava IMPORTED_TARGET libcava QUIET) +if(NOT Cava_FOUND) + pkg_check_modules(Cava IMPORTED_TARGET cava REQUIRED) +endif() set(QT_QML_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/qml") qt_standard_project_setup(REQUIRES 6.9) @@ -34,6 +41,7 @@ qml_module(ZShell writefile.hpp writefile.cpp appdb.hpp appdb.cpp imageanalyser.hpp imageanalyser.cpp + requests.hpp requests.cpp LIBRARIES Qt::Gui Qt::Quick @@ -43,3 +51,4 @@ qml_module(ZShell add_subdirectory(Models) add_subdirectory(Internal) +add_subdirectory(Services) diff --git a/Plugins/ZShell/Services/CMakeLists.txt b/Plugins/ZShell/Services/CMakeLists.txt new file mode 100644 index 0000000..39ecda2 --- /dev/null +++ b/Plugins/ZShell/Services/CMakeLists.txt @@ -0,0 +1,14 @@ +qml_module(ZShell-services + URI ZShell.Services + SOURCES + service.hpp service.cpp + serviceref.hpp serviceref.cpp + beattracker.hpp beattracker.cpp + audiocollector.hpp audiocollector.cpp + audioprovider.hpp audioprovider.cpp + cavaprovider.hpp cavaprovider.cpp + LIBRARIES + PkgConfig::Pipewire + PkgConfig::Aubio + PkgConfig::Cava +) diff --git a/Plugins/ZShell/Services/audiocollector.cpp b/Plugins/ZShell/Services/audiocollector.cpp new file mode 100644 index 0000000..770c499 --- /dev/null +++ b/Plugins/ZShell/Services/audiocollector.cpp @@ -0,0 +1,247 @@ +#include "audiocollector.hpp" + +#include "service.hpp" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ZShell::services { + +PipeWireWorker::PipeWireWorker(std::stop_token token, AudioCollector* collector) + : m_loop(nullptr) + , m_stream(nullptr) + , m_timer(nullptr) + , m_idle(true) + , m_token(token) + , m_collector(collector) { + pw_init(nullptr, nullptr); + + m_loop = pw_main_loop_new(nullptr); + if (!m_loop) { + qWarning() << "PipeWireWorker::init: failed to create PipeWire main loop"; + pw_deinit(); + return; + } + + timespec timeout = { 0, 10 * SPA_NSEC_PER_MSEC }; + m_timer = pw_loop_add_timer(pw_main_loop_get_loop(m_loop), handleTimeout, this); + pw_loop_update_timer(pw_main_loop_get_loop(m_loop), m_timer, &timeout, &timeout, false); + + auto props = pw_properties_new( + PW_KEY_MEDIA_TYPE, "Audio", PW_KEY_MEDIA_CATEGORY, "Capture", PW_KEY_MEDIA_ROLE, "Music", nullptr); + pw_properties_set(props, PW_KEY_STREAM_CAPTURE_SINK, "true"); + pw_properties_setf( + props, PW_KEY_NODE_LATENCY, "%u/%u", nextPowerOf2(512 * ac::SAMPLE_RATE / 48000), ac::SAMPLE_RATE); + pw_properties_set(props, PW_KEY_NODE_PASSIVE, "true"); + pw_properties_set(props, PW_KEY_NODE_VIRTUAL, "true"); + pw_properties_set(props, PW_KEY_STREAM_DONT_REMIX, "false"); + pw_properties_set(props, "channelmix.upmix", "true"); + + std::vector buffer(ac::CHUNK_SIZE); + spa_pod_builder b; + spa_pod_builder_init(&b, buffer.data(), static_cast(buffer.size())); + + spa_audio_info_raw info{}; + info.format = SPA_AUDIO_FORMAT_S16; + info.rate = ac::SAMPLE_RATE; + info.channels = 1; + + const spa_pod* params[1]; + params[0] = spa_format_audio_raw_build(&b, SPA_PARAM_EnumFormat, &info); + + pw_stream_events events{}; + events.state_changed = [](void* data, pw_stream_state, pw_stream_state state, const char*) { + auto* self = static_cast(data); + self->streamStateChanged(state); + }; + events.process = [](void* data) { + auto* self = static_cast(data); + self->processStream(); + }; + + m_stream = pw_stream_new_simple(pw_main_loop_get_loop(m_loop), "ZShell-shell", props, &events, this); + + const int success = pw_stream_connect(m_stream, PW_DIRECTION_INPUT, PW_ID_ANY, + static_cast( + PW_STREAM_FLAG_AUTOCONNECT | PW_STREAM_FLAG_MAP_BUFFERS | PW_STREAM_FLAG_RT_PROCESS), + params, 1); + if (success < 0) { + qWarning() << "PipeWireWorker::init: failed to connect stream"; + pw_stream_destroy(m_stream); + pw_main_loop_destroy(m_loop); + pw_deinit(); + return; + } + + pw_main_loop_run(m_loop); + + pw_stream_destroy(m_stream); + pw_main_loop_destroy(m_loop); + pw_deinit(); +} + +void PipeWireWorker::handleTimeout(void* data, uint64_t expirations) { + auto* self = static_cast(data); + + if (self->m_token.stop_requested()) { + pw_main_loop_quit(self->m_loop); + return; + } + + if (!self->m_idle) { + if (expirations < 10) { + self->m_collector->clearBuffer(); + } else { + self->m_idle = true; + timespec timeout = { 0, 500 * SPA_NSEC_PER_MSEC }; + pw_loop_update_timer(pw_main_loop_get_loop(self->m_loop), self->m_timer, &timeout, &timeout, false); + } + } +} + +void PipeWireWorker::streamStateChanged(pw_stream_state state) { + m_idle = false; + switch (state) { + case PW_STREAM_STATE_PAUSED: { + timespec timeout = { 0, 10 * SPA_NSEC_PER_MSEC }; + pw_loop_update_timer(pw_main_loop_get_loop(m_loop), m_timer, &timeout, &timeout, false); + break; + } + case PW_STREAM_STATE_STREAMING: + pw_loop_update_timer(pw_main_loop_get_loop(m_loop), m_timer, nullptr, nullptr, false); + break; + case PW_STREAM_STATE_ERROR: + pw_main_loop_quit(m_loop); + break; + default: + break; + } +} + +void PipeWireWorker::processStream() { + if (m_token.stop_requested()) { + pw_main_loop_quit(m_loop); + return; + } + + pw_buffer* buffer = pw_stream_dequeue_buffer(m_stream); + if (buffer == nullptr) { + return; + } + + const spa_buffer* buf = buffer->buffer; + const qint16* samples = reinterpret_cast(buf->datas[0].data); + if (samples == nullptr) { + return; + } + + const quint32 count = buf->datas[0].chunk->size / 2; + m_collector->loadChunk(samples, count); + + pw_stream_queue_buffer(m_stream, buffer); +} + +unsigned int PipeWireWorker::nextPowerOf2(unsigned int n) { + if (n == 0) { + return 1; + } + + n--; + n |= n >> 1; + n |= n >> 2; + n |= n >> 4; + n |= n >> 8; + n |= n >> 16; + n++; + + return n; +} + +AudioCollector& AudioCollector::instance() { + static AudioCollector instance; + return instance; +} + +void AudioCollector::clearBuffer() { + auto* writeBuffer = m_writeBuffer.load(std::memory_order_relaxed); + std::fill(writeBuffer->begin(), writeBuffer->end(), 0.0f); + + auto* oldRead = m_readBuffer.exchange(writeBuffer, std::memory_order_acq_rel); + m_writeBuffer.store(oldRead, std::memory_order_release); +} + +void AudioCollector::loadChunk(const qint16* samples, quint32 count) { + if (count > ac::CHUNK_SIZE) { + count = ac::CHUNK_SIZE; + } + + auto* writeBuffer = m_writeBuffer.load(std::memory_order_relaxed); + std::transform(samples, samples + count, writeBuffer->begin(), [](qint16 sample) { + return sample / 32768.0f; + }); + + auto* oldRead = m_readBuffer.exchange(writeBuffer, std::memory_order_acq_rel); + m_writeBuffer.store(oldRead, std::memory_order_release); +} + +quint32 AudioCollector::readChunk(float* out, quint32 count) { + if (count == 0 || count > ac::CHUNK_SIZE) { + count = ac::CHUNK_SIZE; + } + + auto* readBuffer = m_readBuffer.load(std::memory_order_acquire); + std::memcpy(out, readBuffer->data(), count * sizeof(float)); + + return count; +} + +quint32 AudioCollector::readChunk(double* out, quint32 count) { + if (count == 0 || count > ac::CHUNK_SIZE) { + count = ac::CHUNK_SIZE; + } + + auto* readBuffer = m_readBuffer.load(std::memory_order_acquire); + std::transform(readBuffer->begin(), readBuffer->begin() + count, out, [](float sample) { + return static_cast(sample); + }); + + return count; +} + +AudioCollector::AudioCollector(QObject* parent) + : Service(parent) + , m_buffer1(ac::CHUNK_SIZE) + , m_buffer2(ac::CHUNK_SIZE) + , m_readBuffer(&m_buffer1) + , m_writeBuffer(&m_buffer2) { +} + +AudioCollector::~AudioCollector() { + stop(); +} + +void AudioCollector::start() { + if (m_thread.joinable()) { + return; + } + + clearBuffer(); + + m_thread = std::jthread([this](std::stop_token token) { + PipeWireWorker worker(token, this); + }); +} + +void AudioCollector::stop() { + if (m_thread.joinable()) { + m_thread.request_stop(); + m_thread.join(); + } +} + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/audiocollector.hpp b/Plugins/ZShell/Services/audiocollector.hpp new file mode 100644 index 0000000..d3f3ce1 --- /dev/null +++ b/Plugins/ZShell/Services/audiocollector.hpp @@ -0,0 +1,76 @@ +#pragma once + +#include "service.hpp" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ZShell::services { + +namespace ac { + +constexpr quint32 SAMPLE_RATE = 44100; +constexpr quint32 CHUNK_SIZE = 512; + +} // namespace ac + +class AudioCollector; + +class PipeWireWorker { +public: +explicit PipeWireWorker(std::stop_token token, AudioCollector* collector); + +void run(); + +private: +pw_main_loop* m_loop; +pw_stream* m_stream; +spa_source* m_timer; +bool m_idle; + +std::stop_token m_token; +AudioCollector* m_collector; + +static void handleTimeout(void* data, uint64_t expirations); +void streamStateChanged(pw_stream_state state); +void processStream(); + +[[nodiscard]] unsigned int nextPowerOf2(unsigned int n); +}; + +class AudioCollector : public Service { +Q_OBJECT + +public: +AudioCollector(const AudioCollector&) = delete; +AudioCollector& operator=(const AudioCollector&) = delete; + +static AudioCollector& instance(); + +void clearBuffer(); +void loadChunk(const qint16* samples, quint32 count); +quint32 readChunk(float* out, quint32 count = 0); +quint32 readChunk(double* out, quint32 count = 0); + +private: +explicit AudioCollector(QObject* parent = nullptr); +~AudioCollector(); + +std::jthread m_thread; +std::vector m_buffer1; +std::vector m_buffer2; +std::atomic*> m_readBuffer; +std::atomic*> m_writeBuffer; +quint32 m_sampleCount; + +void reload(); +void start() override; +void stop() override; +}; + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/audioprovider.cpp b/Plugins/ZShell/Services/audioprovider.cpp new file mode 100644 index 0000000..dd99bf1 --- /dev/null +++ b/Plugins/ZShell/Services/audioprovider.cpp @@ -0,0 +1,80 @@ +#include "audioprovider.hpp" + +#include "audiocollector.hpp" +#include "service.hpp" +#include +#include + +namespace ZShell::services { + +AudioProcessor::AudioProcessor(QObject* parent) + : QObject(parent) { +} + +AudioProcessor::~AudioProcessor() { + stop(); +} + +void AudioProcessor::init() { + m_timer = new QTimer(this); + m_timer->setInterval(static_cast(ac::CHUNK_SIZE * 1000.0 / ac::SAMPLE_RATE)); + connect(m_timer, &QTimer::timeout, this, &AudioProcessor::process); +} + +void AudioProcessor::start() { + QMetaObject::invokeMethod(&AudioCollector::instance(), &AudioCollector::ref, Qt::QueuedConnection, this); + if (m_timer) { + m_timer->start(); + } +} + +void AudioProcessor::stop() { + if (m_timer) { + m_timer->stop(); + } + QMetaObject::invokeMethod(&AudioCollector::instance(), &AudioCollector::unref, Qt::QueuedConnection, this); +} + +AudioProvider::AudioProvider(QObject* parent) + : Service(parent) + , m_processor(nullptr) + , m_thread(nullptr) { +} + +AudioProvider::~AudioProvider() { + if (m_thread) { + m_thread->quit(); + m_thread->wait(); + } +} + +void AudioProvider::init() { + if (!m_processor) { + qWarning() << "AudioProvider::init: attempted to init with no processor set"; + return; + } + + m_thread = new QThread(this); + m_processor->moveToThread(m_thread); + + connect(m_thread, &QThread::started, m_processor, &AudioProcessor::init); + connect(m_thread, &QThread::finished, m_processor, &AudioProcessor::deleteLater); + connect(m_thread, &QThread::finished, m_thread, &QThread::deleteLater); + + m_thread->start(); +} + +void AudioProvider::start() { + if (m_processor) { + AudioCollector::instance(); // Create instance on main thread + QMetaObject::invokeMethod(m_processor, &AudioProcessor::start); + } +} + +void AudioProvider::stop() { + if (m_processor) { + QMetaObject::invokeMethod(m_processor, &AudioProcessor::stop); + } +} + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/audioprovider.hpp b/Plugins/ZShell/Services/audioprovider.hpp new file mode 100644 index 0000000..32f95cb --- /dev/null +++ b/Plugins/ZShell/Services/audioprovider.hpp @@ -0,0 +1,48 @@ +#pragma once + +#include "service.hpp" +#include +#include + +namespace ZShell::services { + +class AudioProcessor : public QObject { +Q_OBJECT + +public: +explicit AudioProcessor(QObject* parent = nullptr); +~AudioProcessor(); + +void init(); + +public slots: +void start(); +void stop(); + +protected: +virtual void process() = 0; + +private: +QTimer* m_timer; +}; + +class AudioProvider : public Service { +Q_OBJECT + +public: +explicit AudioProvider(QObject* parent = nullptr); +~AudioProvider(); + +protected: +AudioProcessor* m_processor; + +void init(); + +private: +QThread* m_thread; + +void start() override; +void stop() override; +}; + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/beattracker.cpp b/Plugins/ZShell/Services/beattracker.cpp new file mode 100644 index 0000000..5e83770 --- /dev/null +++ b/Plugins/ZShell/Services/beattracker.cpp @@ -0,0 +1,59 @@ +#include "beattracker.hpp" + +#include "audiocollector.hpp" +#include "audioprovider.hpp" +#include + +namespace ZShell::services { + +BeatProcessor::BeatProcessor(QObject* parent) + : AudioProcessor(parent) + , m_tempo(new_aubio_tempo("default", 1024, ac::CHUNK_SIZE, ac::SAMPLE_RATE)) + , m_in(new_fvec(ac::CHUNK_SIZE)) + , m_out(new_fvec(2)) { +}; + +BeatProcessor::~BeatProcessor() { + if (m_tempo) { + del_aubio_tempo(m_tempo); + } + if (m_in) { + del_fvec(m_in); + } + del_fvec(m_out); +} + +void BeatProcessor::process() { + if (!m_tempo || !m_in) { + return; + } + + AudioCollector::instance().readChunk(m_in->data); + + aubio_tempo_do(m_tempo, m_in, m_out); + if (!qFuzzyIsNull(m_out->data[0])) { + emit beat(aubio_tempo_get_bpm(m_tempo)); + } +} + +BeatTracker::BeatTracker(QObject* parent) + : AudioProvider(parent) + , m_bpm(120) { + m_processor = new BeatProcessor(); + init(); + + connect(static_cast(m_processor), &BeatProcessor::beat, this, &BeatTracker::updateBpm); +} + +smpl_t BeatTracker::bpm() const { + return m_bpm; +} + +void BeatTracker::updateBpm(smpl_t bpm) { + if (!qFuzzyCompare(bpm + 1.0f, m_bpm + 1.0f)) { + m_bpm = bpm; + emit bpmChanged(); + } +} + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/beattracker.hpp b/Plugins/ZShell/Services/beattracker.hpp new file mode 100644 index 0000000..301a962 --- /dev/null +++ b/Plugins/ZShell/Services/beattracker.hpp @@ -0,0 +1,49 @@ +#pragma once + +#include "audioprovider.hpp" +#include +#include + +namespace ZShell::services { + +class BeatProcessor : public AudioProcessor { +Q_OBJECT + +public: +explicit BeatProcessor(QObject* parent = nullptr); +~BeatProcessor(); + +signals: +void beat(smpl_t bpm); + +protected: +void process() override; + +private: +aubio_tempo_t* m_tempo; +fvec_t* m_in; +fvec_t* m_out; +}; + +class BeatTracker : public AudioProvider { +Q_OBJECT +QML_ELEMENT + +Q_PROPERTY(smpl_t bpm READ bpm NOTIFY bpmChanged) + +public: +explicit BeatTracker(QObject* parent = nullptr); + +[[nodiscard]] smpl_t bpm() const; + +signals: +void bpmChanged(); +void beat(smpl_t bpm); + +private: +smpl_t m_bpm; + +void updateBpm(smpl_t bpm); +}; + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/cavaprovider.cpp b/Plugins/ZShell/Services/cavaprovider.cpp new file mode 100644 index 0000000..fdff3b3 --- /dev/null +++ b/Plugins/ZShell/Services/cavaprovider.cpp @@ -0,0 +1,141 @@ +#include "cavaprovider.hpp" + +#include "audiocollector.hpp" +#include "audioprovider.hpp" +#include +#include +#include + +namespace ZShell::services { + +CavaProcessor::CavaProcessor(QObject* parent) + : AudioProcessor(parent) + , m_plan(nullptr) + , m_in(new double[ac::CHUNK_SIZE]) + , m_out(nullptr) + , m_bars(0) { +}; + +CavaProcessor::~CavaProcessor() { + cleanup(); + delete[] m_in; +} + +void CavaProcessor::process() { + if (!m_plan || m_bars == 0 || !m_out) { + return; + } + + const int count = static_cast(AudioCollector::instance().readChunk(m_in)); + + // Process in data via cava + cava_execute(m_in, count, m_out, m_plan); + + // Apply monstercat filter + QVector values(m_bars); + + // Left to right pass + const double inv = 1.0 / 1.5; + double carry = 0.0; + for (int i = 0; i < m_bars; ++i) { + carry = std::max(m_out[i], carry * inv); + values[i] = carry; + } + + // Right to left pass and combine + carry = 0.0; + for (int i = m_bars - 1; i >= 0; --i) { + carry = std::max(m_out[i], carry * inv); + values[i] = std::max(values[i], carry); + } + + // Update values + if (values != m_values) { + m_values = std::move(values); + emit valuesChanged(m_values); + } +} + +void CavaProcessor::setBars(int bars) { + if (bars < 0) { + qWarning() << "CavaProcessor::setBars: bars must be greater than 0. Setting to 0."; + bars = 0; + } + + if (m_bars != bars) { + m_bars = bars; + reload(); + } +} + +void CavaProcessor::reload() { + cleanup(); + initCava(); +} + +void CavaProcessor::cleanup() { + if (m_plan) { + cava_destroy(m_plan); + m_plan = nullptr; + } + + if (m_out) { + delete[] m_out; + m_out = nullptr; + } +} + +void CavaProcessor::initCava() { + if (m_plan || m_bars == 0) { + return; + } + + m_plan = cava_init(m_bars, ac::SAMPLE_RATE, 1, 1, 0.85, 50, 10000); + m_out = new double[static_cast(m_bars)]; +} + +CavaProvider::CavaProvider(QObject* parent) + : AudioProvider(parent) + , m_bars(0) + , m_values(m_bars, 0.0) { + m_processor = new CavaProcessor(); + init(); + + connect(static_cast(m_processor), &CavaProcessor::valuesChanged, this, &CavaProvider::updateValues); +} + +int CavaProvider::bars() const { + return m_bars; +} + +void CavaProvider::setBars(int bars) { + if (bars < 0) { + qWarning() << "CavaProvider::setBars: bars must be greater than 0. Setting to 0."; + bars = 0; + } + + if (m_bars == bars) { + return; + } + + m_values.resize(bars, 0.0); + m_bars = bars; + emit barsChanged(); + emit valuesChanged(); + + QMetaObject::invokeMethod( + static_cast(m_processor), &CavaProcessor::setBars, Qt::QueuedConnection, bars); +} + +QVector CavaProvider::values() const { + return m_values; +} + +void CavaProvider::updateValues(QVector values) { + if (values != m_values) { + m_values = values; + emit valuesChanged(); + } +} + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/cavaprovider.hpp b/Plugins/ZShell/Services/cavaprovider.hpp new file mode 100644 index 0000000..88dc18e --- /dev/null +++ b/Plugins/ZShell/Services/cavaprovider.hpp @@ -0,0 +1,64 @@ +#pragma once + +#include "audioprovider.hpp" +#include +#include + +namespace ZShell::services { + +class CavaProcessor : public AudioProcessor { +Q_OBJECT + +public: +explicit CavaProcessor(QObject* parent = nullptr); +~CavaProcessor(); + +void setBars(int bars); + +signals: +void valuesChanged(QVector values); + +protected: +void process() override; + +private: +struct cava_plan* m_plan; +double* m_in; +double* m_out; + +int m_bars; +QVector m_values; + +void reload(); +void initCava(); +void cleanup(); +}; + +class CavaProvider : public AudioProvider { +Q_OBJECT +QML_ELEMENT + +Q_PROPERTY(int bars READ bars WRITE setBars NOTIFY barsChanged) + +Q_PROPERTY(QVector values READ values NOTIFY valuesChanged) + +public: +explicit CavaProvider(QObject* parent = nullptr); + +[[nodiscard]] int bars() const; +void setBars(int bars); + +[[nodiscard]] QVector values() const; + +signals: +void barsChanged(); +void valuesChanged(); + +private: +int m_bars; +QVector m_values; + +void updateValues(QVector values); +}; + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/service.cpp b/Plugins/ZShell/Services/service.cpp new file mode 100644 index 0000000..ef30d0a --- /dev/null +++ b/Plugins/ZShell/Services/service.cpp @@ -0,0 +1,27 @@ +#include "service.hpp" + +#include +#include + +namespace ZShell::services { + +Service::Service(QObject* parent) + : QObject(parent) { +} + +void Service::ref(QObject* sender) { + if (m_refs.isEmpty()) { + start(); + } + + QObject::connect(sender, &QObject::destroyed, this, &Service::unref); + m_refs << sender; +} + +void Service::unref(QObject* sender) { + if (m_refs.remove(sender) && m_refs.isEmpty()) { + stop(); + } +} + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/service.hpp b/Plugins/ZShell/Services/service.hpp new file mode 100644 index 0000000..3bb47b9 --- /dev/null +++ b/Plugins/ZShell/Services/service.hpp @@ -0,0 +1,24 @@ +#pragma once + +#include +#include + +namespace ZShell::services { + +class Service : public QObject { +Q_OBJECT + +public: +explicit Service(QObject* parent = nullptr); + +void ref(QObject* sender); +void unref(QObject* sender); + +private: +QSet m_refs; + +virtual void start() = 0; +virtual void stop() = 0; +}; + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/serviceref.cpp b/Plugins/ZShell/Services/serviceref.cpp new file mode 100644 index 0000000..69ae40c --- /dev/null +++ b/Plugins/ZShell/Services/serviceref.cpp @@ -0,0 +1,36 @@ +#include "serviceref.hpp" + +#include "service.hpp" + +namespace ZShell::services { + +ServiceRef::ServiceRef(Service* service, QObject* parent) + : QObject(parent) + , m_service(service) { + if (m_service) { + m_service->ref(this); + } +} + +Service* ServiceRef::service() const { + return m_service; +} + +void ServiceRef::setService(Service* service) { + if (m_service == service) { + return; + } + + if (m_service) { + m_service->unref(this); + } + + m_service = service; + emit serviceChanged(); + + if (m_service) { + m_service->ref(this); + } +} + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/serviceref.hpp b/Plugins/ZShell/Services/serviceref.hpp new file mode 100644 index 0000000..9fb9623 --- /dev/null +++ b/Plugins/ZShell/Services/serviceref.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include "service.hpp" +#include +#include + +namespace ZShell::services { + +class ServiceRef : public QObject { +Q_OBJECT +QML_ELEMENT + +Q_PROPERTY(ZShell::services::Service* service READ service WRITE setService NOTIFY serviceChanged) + +public: +explicit ServiceRef(Service* service = nullptr, QObject* parent = nullptr); + +[[nodiscard]] Service* service() const; +void setService(Service* service); + +signals: +void serviceChanged(); + +private: +QPointer m_service; +}; + +} // namespace ZShell::services diff --git a/Plugins/ZShell/requests.cpp b/Plugins/ZShell/requests.cpp new file mode 100644 index 0000000..3b8130c --- /dev/null +++ b/Plugins/ZShell/requests.cpp @@ -0,0 +1,36 @@ +#include "requests.hpp" + +#include +#include +#include + +namespace ZShell { + +Requests::Requests(QObject* parent) + : QObject(parent) + , m_manager(new QNetworkAccessManager(this)) { +} + +void Requests::get(const QUrl& url, QJSValue onSuccess, QJSValue onError) const { + if (!onSuccess.isCallable()) { + qWarning() << "Requests::get: onSuccess is not callable"; + return; + } + + QNetworkRequest request(url); + auto reply = m_manager->get(request); + + QObject::connect(reply, &QNetworkReply::finished, [reply, onSuccess, onError]() { + if (reply->error() == QNetworkReply::NoError) { + onSuccess.call({ QString(reply->readAll()) }); + } else if (onError.isCallable()) { + onError.call({ reply->errorString() }); + } else { + qWarning() << "Requests::get: request failed with error" << reply->errorString(); + } + + reply->deleteLater(); + }); +} + +} // namespace ZShell diff --git a/Plugins/ZShell/requests.hpp b/Plugins/ZShell/requests.hpp new file mode 100644 index 0000000..40e751b --- /dev/null +++ b/Plugins/ZShell/requests.hpp @@ -0,0 +1,23 @@ +#pragma once + +#include +#include +#include + +namespace ZShell { + +class Requests : public QObject { +Q_OBJECT +QML_ELEMENT +QML_SINGLETON + +public: +explicit Requests(QObject* parent = nullptr); + +Q_INVOKABLE void get(const QUrl& url, QJSValue callback, QJSValue onError = QJSValue()) const; + +private: +QNetworkAccessManager* m_manager; +}; + +} // namespace ZShell