From f2c0af2853a8bfae3f1d0055f6743590228db0ee Mon Sep 17 00:00:00 2001 From: dodo986 <264469474+dodo986@users.noreply.github.com> Date: Mon, 9 Nov 2026 16:03:30 +0300 Subject: [PATCH 1/5] [ryoku] gamebar: add the overlay and the Discord Voice widget A passive overlay per output, raised with SUPER + SHIFT + G over a fullscreen game: a strip of widget buttons, draggable and resizable cards, edge and grid snapping with guides, and a settle curve. The cards are capture, performance, resources, tasks, media, voice, audio and gallery, and each reads only while it is on screen. Discord Voice reads the official client over native RPC, or Vesktop through a Vencord companion that publishes the call on a local socket. A pinned voice card shrinks to a content-sized overlay in the corner the card was placed, stays passive when the bar is closed, and stops the bridge when Voice is not shown. The Hub gains a Game Bar page for the master switch, placement, motion and the voice overlay, every key stored under gamebar in shell.json. The keybind is registered in every window manager. Note: New: the Game Bar overlay (SUPER + SHIFT + G) with widgets and a pinned Discord Voice overlay Note: Fixed: Hub step rows now keep to the schema range --- CHANGELOG.md | 12 + docs/gamebar.md | 197 +++++ docs/structure.md | 4 +- ryoku/cli/vendor/ryoku-wm/binds.go | 1 + ryoku/hub/backend/vendor/ryoku-wm/binds.go | 1 + ryoku/hub/quickshell/Hub.qml | 21 +- ryoku/hub/quickshell/SettingsSheet.qml | 4 +- ryoku/hub/quickshell/pages/GameBarPage.qml | 38 + ryoku/hub/quickshell/schema/GameBarPage.js | 265 +++++++ ryoku/hyprland/modules/binds.lua | 1 + ryoku/rashin/backend/vendor/ryoku-wm/binds.go | 1 + ryoku/shell/ipc/daemon.go | 1 + ryoku/shell/ipc/settings.go | 73 ++ ryoku/shell/ipc/vendor/ryoku-wm/binds.go | 1 + .../quickshell/shell/components/GlyphIcon.qml | 5 +- .../shell/modules/gamebar/GameBar.qml | 740 ++++++++++++++++++ .../shell/modules/gamebar/GameBarAction.qml | 59 ++ .../shell/modules/gamebar/GameBarCard.qml | 197 +++++ .../shell/modules/gamebar/GameBarDataRow.qml | 39 + .../modules/gamebar/GameBarIconButton.qml | 50 ++ .../shell/modules/gamebar/GameBarMeter.qml | 54 ++ .../shell/modules/gamebar/GameBarSpark.qml | 54 ++ .../shell/modules/gamebar/GameBarStrip.qml | 202 +++++ .../shell/modules/gamebar/GameBarTabs.qml | 98 +++ .../shell/modules/gamebar/GameBarTaskRow.qml | 111 +++ .../modules/gamebar/GameBarTextButton.qml | 60 ++ .../shell/modules/gamebar/cards/AudioCard.qml | 92 +++ .../modules/gamebar/cards/CaptureCard.qml | 100 +++ .../modules/gamebar/cards/GalleryCard.qml | 142 ++++ .../shell/modules/gamebar/cards/MediaCard.qml | 140 ++++ .../modules/gamebar/cards/PerformanceCard.qml | 138 ++++ .../modules/gamebar/cards/ResourcesCard.qml | 124 +++ .../shell/modules/gamebar/cards/TasksCard.qml | 106 +++ .../shell/modules/gamebar/cards/VoiceCard.qml | 444 +++++++++++ .../discordVoice/ParticipantAvatar.qml | 141 ++++ .../modules/gamebar/discordVoice/bridge.py | 481 ++++++++++++ .../gamebar/discordVoice/companion/index.ts | 157 ++++ .../gamebar/discordVoice/companion/native.ts | 93 +++ .../gamebar/discordVoice/install_companion.sh | 144 ++++ .../modules/gamebar/discordVoice/layout.js | 48 ++ .../gamebar/discordVoice/layout.test.mjs | 51 ++ .../modules/gamebar/look/GameBarLook.qml | 48 ++ .../shell/modules/gamebar/look/qmldir | 1 + .../quickshell/shell/services/Config.qml | 50 ++ .../shell/services/DiscordVoice.qml | 297 +++++++ .../shell/services/GameBarStore.qml | 150 ++++ ryoku/shell/quickshell/shell/services/qmldir | 2 + ryoku/shell/quickshell/shell/shell.qml | 19 +- ryoku/shell/quickshell/shell/utils/tasks.js | 58 ++ .../quickshell/shell/utils/tasks.test.mjs | 44 ++ ryoku/shell/quickshell/shell/utils/units.js | 44 ++ .../quickshell/shell/utils/units.test.mjs | 33 + .../ryogami/daemon/vendor/ryoku-wm/binds.go | 1 + ryoku/wm/binds.go | 1 + ryoku/wm/niri/config_binds.go | 1 + ryoku/wm/wayfire/apply_test.go | 2 + ryoku/wm/wayfire/config_binds.go | 1 + 57 files changed, 5433 insertions(+), 9 deletions(-) create mode 100644 docs/gamebar.md create mode 100644 ryoku/hub/quickshell/pages/GameBarPage.qml create mode 100644 ryoku/hub/quickshell/schema/GameBarPage.js create mode 100644 ryoku/shell/quickshell/shell/modules/gamebar/GameBar.qml create mode 100644 ryoku/shell/quickshell/shell/modules/gamebar/GameBarAction.qml create mode 100644 ryoku/shell/quickshell/shell/modules/gamebar/GameBarCard.qml create mode 100644 ryoku/shell/quickshell/shell/modules/gamebar/GameBarDataRow.qml create mode 100644 ryoku/shell/quickshell/shell/modules/gamebar/GameBarIconButton.qml create mode 100644 ryoku/shell/quickshell/shell/modules/gamebar/GameBarMeter.qml create mode 100644 ryoku/shell/quickshell/shell/modules/gamebar/GameBarSpark.qml create mode 100644 ryoku/shell/quickshell/shell/modules/gamebar/GameBarStrip.qml create mode 100644 ryoku/shell/quickshell/shell/modules/gamebar/GameBarTabs.qml create mode 100644 ryoku/shell/quickshell/shell/modules/gamebar/GameBarTaskRow.qml create mode 100644 ryoku/shell/quickshell/shell/modules/gamebar/GameBarTextButton.qml create mode 100644 ryoku/shell/quickshell/shell/modules/gamebar/cards/AudioCard.qml create mode 100644 ryoku/shell/quickshell/shell/modules/gamebar/cards/CaptureCard.qml create mode 100644 ryoku/shell/quickshell/shell/modules/gamebar/cards/GalleryCard.qml create mode 100644 ryoku/shell/quickshell/shell/modules/gamebar/cards/MediaCard.qml create mode 100644 ryoku/shell/quickshell/shell/modules/gamebar/cards/PerformanceCard.qml create mode 100644 ryoku/shell/quickshell/shell/modules/gamebar/cards/ResourcesCard.qml create mode 100644 ryoku/shell/quickshell/shell/modules/gamebar/cards/TasksCard.qml create mode 100644 ryoku/shell/quickshell/shell/modules/gamebar/cards/VoiceCard.qml create mode 100644 ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/ParticipantAvatar.qml create mode 100755 ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/bridge.py create mode 100644 ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/companion/index.ts create mode 100644 ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/companion/native.ts create mode 100755 ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/install_companion.sh create mode 100644 ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/layout.js create mode 100644 ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/layout.test.mjs create mode 100644 ryoku/shell/quickshell/shell/modules/gamebar/look/GameBarLook.qml create mode 100644 ryoku/shell/quickshell/shell/modules/gamebar/look/qmldir create mode 100644 ryoku/shell/quickshell/shell/services/DiscordVoice.qml create mode 100644 ryoku/shell/quickshell/shell/services/GameBarStore.qml create mode 100644 ryoku/shell/quickshell/shell/utils/tasks.js create mode 100644 ryoku/shell/quickshell/shell/utils/tasks.test.mjs create mode 100644 ryoku/shell/quickshell/shell/utils/units.js create mode 100644 ryoku/shell/quickshell/shell/utils/units.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 099cd83527..124d8bdb0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ for finer detail. user is no longer in a mango session. ### Fixed +- Hub step rows now clamp to the schema's `lo` and `hi` values instead of + unrelated `from` and `to` keys, so a step control stays inside its stated + range. - A QML object that sets the same property or handler twice can no longer be committed or pass QML Lint. qmllint never flagged it, yet the file refuses to load, and one such slip in the audio service kept the desktop from starting. @@ -106,6 +109,15 @@ for finer detail. mutable channels current without changing frozen distro snapshots. ### Added +- **Game Bar.** `Super+Shift+G` raises a passive overlay over the focused + monitor: a strip of eight widgets and draggable cards for capture, + performance, resources, tasks, media, audio, gallery and Discord voice. Cards + snap to edges, centre lines and each other with a guide grid, the layout + survives a restart, and a pinned Voice card shrinks to a content-sized + overlay that shows the call without taking pointer or keyboard focus. Voice + reads the official Discord client over native RPC, or Vesktop and Equibop + through a bundled Vencord companion. Every setting lives in Ryoku Hub -> + Desktop -> Game Bar. - **Community docks.** RyoStore gains a Docks section: anyone can submit a dock as a folder to the ryostore catalogue, users install it there and pick it in Edit desktop -> Dock, and it becomes the dock on every monitor and every bar diff --git a/docs/gamebar.md b/docs/gamebar.md new file mode 100644 index 0000000000..a7a46008a7 --- /dev/null +++ b/docs/gamebar.md @@ -0,0 +1,197 @@ +# Game Bar + +The Game Bar is Ryoku's in-game overlay: one passive surface per output that +sits above a fullscreen game, with a strip of widget buttons and up to eight +cards for capture, performance, system load, tasks, media, Discord voice, audio +and recent captures. `Super+Shift+G` toggles it on the focused monitor. + +Everything is configured under `gamebar` in `~/.config/ryoku/shell.json` and +through **Ryoku Hub > Desktop > Game Bar**. Changes apply live; the shell never +restarts for them. The bar is meant for a game, but nothing requires one: it +opens over whatever is on that monitor. + +The overlay takes the pointer only where the strip or an open card is drawn. +It never takes keyboard focus, so the game keeps input while the bar is visible. +The bar has no close key of its own; press `Super+Shift+G` again, or use the +same shortcut from the keybind catalogue (`ryoku-shell gamebar` toggles it from +a terminal). + +## The strip + +The strip is centred at the top of the screen. Each button shows one widget; +pressing it again hides that widget. The set of open widgets is remembered, so +the next press of the shortcut brings back the same arrangement. + +To the right of the widget buttons, the grid button toggles snapping, then come +the clock and, on a machine with a battery, its charge. The strip is the only +part of the overlay that takes focus while the bar is open, and it hides itself +before a capture so it is never in its own screenshot or recording. + +## Widgets + +- **Capture** takes a region, window, monitor or all-monitors screenshot, and + starts or stops the screen recorder with its elapsed time. It uses the same + capture and recorder paths as the rest of the shell, not a second pipeline. +- **Performance** shows the machine's headroom while a game runs, with the + resolution and refresh of the display's compositor mode. Ryoku does not read + an in-game frame counter, so the card never claims one. +- **Resources** has a tab each for CPU, GPU and memory: the current reading in + large numerals, the total it is out of, and a short graph. +- **Tasks** lists the five busiest processes by average CPU with their memory, + and ends one through a two-step End. Desktop processes are protected, so the + list cannot offer to kill the compositor or the shell it floats above. +- **Media** follows the one now-playing pick the whole shell shares, with art, + title, artist and transport. +- **Voice** shows the Discord call (see below). +- **Audio** carries the same output and application faders as the audio menu. + A level set here is the level PipeWire holds everywhere. +- **Gallery** shows the newest screenshots and recordings as the files + themselves, plus a button to open either capture folder. + +## Placement + +Drag a card by its frame. A card released near a screen edge, a centre line or +another card pulls to that line; when nothing is near it falls back to the +snapping grid. The guide grid appears while a drag can snap. The grid button in +the strip turns snapping on and off, and the choice is saved. + +Only the Voice card resizes: drag its bottom-right grip. A card that does not +fit after a monitor change is pulled back onto the screen. Each card keeps its +own position and the Voice card keeps its size, both in the persisted state. + +Snap reach, grid spacing, guide spacing and visibility, and the settle spring +are all adjustable; see **Settings**. + +## Pinned overlay + +The pin button in a card's title bar keeps that card after the bar closes. +Closing the bar leaves the pinned cards on screen and removes everything else. +Pinned cards are pictures while the bar is closed: they never take the pointer +or keyboard focus, so the game keeps both. + +A pinned Voice card shrinks to a content-sized panel anchored to the screen +edge its full card sits against, so a card parked in a corner stays flush in +that corner instead of stopping at its full frame. It shows the channel, the +participants on an avatar grid, who is speaking, the mute and deafen state, and +the participant names if you keep them on. Because it is passive, change mute +or deafen from the Voice card in the open bar, not from the pinned panel. + +Pinning is saved with the rest of the Game Bar state, so a pinned call survives +a shell restart. Turning the Game Bar off in the Hub closes it and leaves +nothing behind. + +## Discord Voice + +Voice reads the Discord client you are already in; it does not join a call or +need a bot. Two clients are supported, through two different paths. + +### Official Discord + +The bridge speaks Discord's local RPC. On the first connection the card asks +you to **Authorize Discord**: confirm the request inside Discord, and Ryoku can +then read the channel, participants and speaking state and can set mute and +deafen. The grant uses the public StreamKit voice RPC client with +`rpc`, `rpc.voice.read` and `rpc.voice.write` only. The resulting token is kept +in `~/.cache/ryoku/discord-voice.json` with mode `0600`; delete that file to +force a fresh authorization. + +### Vesktop and Equibop + +Vesktop's local RPC proxy answers Rich Presence but not authenticated voice +RPC, so the shell cannot read a call from it directly. The game bar ships a +small Vencord companion that reads the call inside the client and publishes the +same state over a Unix socket in `$XDG_RUNTIME_DIR`. The Voice card detects the +missing companion and offers **Install voice companion**. + +The installer closes nothing for you: fully quit the client first, including +its tray icon. It then clones Vencord (or Equicord for Equibop) into +`~/.local/share/ryoku/discord-voice/`, copies the companion into +`src/userplugins/ryokuDiscordVoice`, installs dependencies and builds the +client. It writes the built `dist` path into the client's `state.json` +(`vencordDir` for Vesktop, `equicordDir` for Equibop) after saving a +`state.json.pre-ryoku-voice` backup. Start the client again and the plugin is +enabled by default. + +The installer needs `git`, `node` and `npm`; when `pnpm` is absent it enables +it through corepack or installs it into the user prefix. The first build takes a +few minutes. Running the installer again pulls the source and rebuilds. + +The bridge process runs only while the Voice widget is on screen, including +when it is pinned and the bar is closed. Hiding Voice stops the bridge; no +Discord socket is held when the widget is not in use. + +## Settings + +**Ryoku Hub > Desktop > Game Bar** has four tabs. Every control writes one key +under `gamebar` in `shell.json`, so the daemon persists it and the live shell +retunes without a restart. + +| Key | Default | Effect | +|---|---|---| +| `enabled` | `true` | Show the Game Bar and bind the shortcut. | +| `dim` | `0.62` | How far the game darkens behind the open bar. | +| `scale` | `1` | Scale the strip, cards and text together. | +| `radius` | `14` | Rounding of the strip and card frames. | +| `fade` | `200` | Opening and closing duration in milliseconds. | +| `snap` | `true` | Pull a released card to edges, centre lines and other cards. | +| `snapReach` | `9` | How close a card must come before it snaps. | +| `snapGrid` | `24` | Fallback spacing when no line is near. | +| `guideGrid` | `true` | Draw the guide grid while a drag can snap. | +| `gridStep` | `48` | Spacing of the guide grid. | +| `gridAlpha` | `0.28` | Visibility of the guide grid. | +| `spring` | `3.2` | Settle stiffness after a drop. | +| `damping` | `0.58` | Settle damping. | +| `mass` | `1` | Settle weight. | +| `voiceAvatars` | `8` | Participant avatars shown before the list stops. | +| `voiceDim` | `true` | Fade participants who are not speaking. | +| `voiceDimOpacity` | `0.5` | How far a quiet participant fades. | +| `voiceNames` | `true` | Names under avatars in the pinned panel. | +| `voiceAvatarSize` | `52` | Avatar size in the pinned panel, in pixels. | +| `voiceBgOpacity` | `0.72` | Background opacity of the pinned panel. | +| `voiceControls` | `true` | Show mute and deafen state in the pinned panel. | + +The compositor is untouched: the bar is a normal layer surface at overlay +level, with an exclusive zone of `-1` so it spans the bar and the dock. + +## Troubleshooting + +- **The shortcut does nothing.** Check `enabled` in the Hub, and that the Game + Bar binding is still in **Keybinds**. `ryoku-shell gamebar` always toggles it + when the shell is running. +- **The Voice card says Discord's voice socket is not reachable.** Start the + client, wait for it to finish loading, then press Retry. The official client + and Vesktop cannot both own the same RPC socket at once. +- **The card asks for a companion after Authorize.** The client is Vesktop or + Equibop. Quit it fully, run **Install voice companion**, start it again. +- **The companion build fails.** It needs `git`, `node` and `npm`, and the + client must be closed for the state file to be written. The last installer + line stays in the card; rerun after fixing the tool or closing the client. +- **Authorization never completes.** Confirm the request inside Discord. If + the prompt has gone stale, delete `~/.cache/ryoku/discord-voice.json` and + authorize again. +- **The bridge stopped.** Press Retry; the shell restarts it with a backoff. + If it keeps stopping, check that `python3` is on `PATH` and restart the shell. +- **A card is off screen.** Saved positions are clamped to the current output, + but a large layout change can leave a card under the strip or at an edge; + drag it back. Pinned cards reappear on the monitor saved in the state file. +- **A pinned card cannot be clicked.** That is deliberate. Pinned cards are + passive while the bar is closed; open the bar to interact with them. + +## Files + +- `ryoku/shell/quickshell/shell/modules/gamebar/` the surface, strip, cards, + look tokens, Discord bridge and companion, and their layout tests. +- `ryoku/shell/quickshell/shell/services/GameBarStore.qml` persisted state, + card order, pins, positions and sizes. +- `ryoku/shell/quickshell/shell/services/DiscordVoice.qml` the bridge process, + participant model, reconnect and installer control. +- `ryoku/shell/quickshell/shell/services/Config.qml` the `gamebar` defaults and + `setGameBar`, which patches one key through the settings daemon. +- `ryoku/hub/quickshell/pages/GameBarPage.qml` and + `ryoku/hub/quickshell/schema/GameBarPage.js` the Hub page and its rows. +- `ryoku/wm/binds.go` and each compositor's bind table the `shell.gamebar` + shortcut; `ryoku/shell/quickshell/shell/shell.qml` maps it to the surface. +- `~/.local/state/ryoku/gamebar.json` the open flag, pinned set, card set, + positions, sizes and remembered resource tab. +- `~/.local/share/ryoku/discord-voice/` the companion source build. +- `~/.cache/ryoku/discord-voice.json` the cached Discord token, mode `0600`. diff --git a/docs/structure.md b/docs/structure.md index cd34ff9343..ae0b58d943 100644 --- a/docs/structure.md +++ b/docs/structure.md @@ -20,7 +20,8 @@ Start with the guide that matches the job: [kernels and CachyOS gaming](kernels.md). - **Understand the desktop:** [what Ryoku is](ryoku.md), [UI and motion rules](ui-ux.md), [bar styles](barstyles.md), [sidebars](sidebars.md), - [launcher](launcher.md), [frame](frame.md), [Stage Editor](stage.md), + [Game Bar](gamebar.md), [launcher](launcher.md), [frame](frame.md), + [Stage Editor](stage.md), [Ryogami](ryogami.md), [RyoStore](store.md), [profiles](profile.md), [theming](theming.md), [performance](performance.md), [power](power.md) and [plugins](plugins.md). @@ -89,6 +90,7 @@ truth for the live desktop. `sidebarCard` plugin host (see `docs/sidebars.md`); `dock` the app dock on the edge opposite the bar (its own surface, shared by every bar style), `launcher`, `overview` (`Super+Tab`), + `gamebar` the in-game overlay with its widget cards (see `docs/gamebar.md`), `wallpaper`, `visualizer` (a click-through spectrum layer that renders through the shared `Ryoku.Ui` spectrum field, keeping only its per-frame band math in its own `Motion.qml`), `osd`, `notifications`, `capture`, `confirm`, and diff --git a/ryoku/cli/vendor/ryoku-wm/binds.go b/ryoku/cli/vendor/ryoku-wm/binds.go index 8577fe4a32..6b3e581ff7 100644 --- a/ryoku/cli/vendor/ryoku-wm/binds.go +++ b/ryoku/cli/vendor/ryoku-wm/binds.go @@ -210,6 +210,7 @@ var catalogue = []CatalogBind{ {ID: "shell.ask", Category: "Shell", Label: "Ask Rashin", Hint: "AI chat bar", Chord: "ALT + Space", Kind: BindShell, Needs: "ryoku-rashin"}, {ID: "shell.rashin", Category: "Shell", Label: "Rashin", Hint: "The AI companion app; a second press focuses it", Chord: "SUPER + ALT + Space", Kind: BindShell, Needs: "rashin-app"}, {ID: "shell.cheatsheet", Category: "Shell", Label: "Kit", Hint: "Press again to close", Chord: "SUPER + K", Kind: BindShell}, + {ID: "shell.gamebar", Category: "Shell", Label: "Game Bar", Hint: "Capture, performance and tasks over a fullscreen game", Chord: "SUPER + SHIFT + G", Kind: BindShell}, {ID: "shell.lock", Category: "Shell", Label: "Lock the screen", Chord: "SUPER + L", Kind: BindShell}, {ID: "shell.quicksettings", Category: "Shell", Label: "Quick settings", Hint: "Power, logout, restart, shutdown, wifi", Chord: "SUPER + Escape", Kind: BindShell}, {ID: "shell.wallpaper", Category: "Shell", Label: "Wallpaper picker", Chord: "SUPER + W", Kind: BindShell}, diff --git a/ryoku/hub/backend/vendor/ryoku-wm/binds.go b/ryoku/hub/backend/vendor/ryoku-wm/binds.go index 8577fe4a32..6b3e581ff7 100644 --- a/ryoku/hub/backend/vendor/ryoku-wm/binds.go +++ b/ryoku/hub/backend/vendor/ryoku-wm/binds.go @@ -210,6 +210,7 @@ var catalogue = []CatalogBind{ {ID: "shell.ask", Category: "Shell", Label: "Ask Rashin", Hint: "AI chat bar", Chord: "ALT + Space", Kind: BindShell, Needs: "ryoku-rashin"}, {ID: "shell.rashin", Category: "Shell", Label: "Rashin", Hint: "The AI companion app; a second press focuses it", Chord: "SUPER + ALT + Space", Kind: BindShell, Needs: "rashin-app"}, {ID: "shell.cheatsheet", Category: "Shell", Label: "Kit", Hint: "Press again to close", Chord: "SUPER + K", Kind: BindShell}, + {ID: "shell.gamebar", Category: "Shell", Label: "Game Bar", Hint: "Capture, performance and tasks over a fullscreen game", Chord: "SUPER + SHIFT + G", Kind: BindShell}, {ID: "shell.lock", Category: "Shell", Label: "Lock the screen", Chord: "SUPER + L", Kind: BindShell}, {ID: "shell.quicksettings", Category: "Shell", Label: "Quick settings", Hint: "Power, logout, restart, shutdown, wifi", Chord: "SUPER + Escape", Kind: BindShell}, {ID: "shell.wallpaper", Category: "Shell", Label: "Wallpaper picker", Chord: "SUPER + W", Kind: BindShell}, diff --git a/ryoku/hub/quickshell/Hub.qml b/ryoku/hub/quickshell/Hub.qml index 30a1b7aa56..a51076ff32 100644 --- a/ryoku/hub/quickshell/Hub.qml +++ b/ryoku/hub/quickshell/Hub.qml @@ -15,6 +15,7 @@ import "schema/DesktopPage.js" as DesktopSchema import "schema/GlobalPage.js" as GlobalSchema import "schema/BarStudioPage.js" as BarStudioSchema import "schema/ControlsPage.js" as ControlsSchema +import "schema/GameBarPage.js" as GameBarSchema import "schema/WindowSettings.js" as WindowSettingsSchema import "schema/PluginsPage.js" as PluginsSchema import "schema/InputPage.js" as InputSchema @@ -146,6 +147,7 @@ Rectangle { { name: "DESKTOP", items: [ { key: "subshells", name: "Subshells", wired: true }, { key: "bar-studio", name: "Bar Studio", wired: true }, { key: "desktop", name: "Desktop", wired: true }, { key: "controls", name: "Controls", wired: true }, { key: "overlays", name: "Overlays", wired: true }, + { key: "gamebar", name: "Game Bar" }, { key: "launcher", name: "App Launcher" } ] }, { name: "KEYS & APPS", items: [ { key: "keybinds", name: "Keybinds" }, { key: "appoverrides", name: "App Overrides", adv: true }, @@ -168,6 +170,7 @@ Rectangle { "connections": "接続", "gpu": "演算", "recording": "録画", "dictation": "音声", "plugins": "補", "subshells": "殻", "bar-studio": "帯", "desktop": "卓上", "controls": "操作", "overlays": "重ね", "launcher": "起動", "fastfetch": "情報", "lockscreen": "施錠", "animations": "動き", + "gamebar": "遊戯", "addons": "拡張", "windowrules": "規則", "appoverrides": "上書", "layerrules": "階層", "session": "起動", "performance": "性能", "rashin": "羅針", "updates": "更新", "credits": "謝辞", "global": "全般", "import": "取込", "windowmanager": "合成" @@ -194,6 +197,7 @@ Rectangle { "desktop": "desktop brand logo mark name wallpaper picker clipboard sidebar sidebars controls today panel corner", "controls": "controls quick settings super escape sidebar panel layout sections order graph vitals levels media plugins", "overlays": "osd volume popup brightness popup notifications popups toasts sidebar super escape quick settings design designs store", + "gamebar": "game bar gamebar overlay ingame in-game widgets dim snap snapping grid spring damping edges curve pinned pin drag capture performance fps", "launcher": "launcher spotlight command palette greeting weather home", "fastfetch": "fetch neofetch terminal system info logo ascii emblem readout", "lockscreen": "lock screensaver signin greeter skin theme login", @@ -228,7 +232,8 @@ Rectangle { "windowrules": WindowRulesSchema.rows, "appoverrides": AppOverridesSchema.rows, "layerrules": LayerRulesSchema.rows, "session": SessionSchema.rows, "performance": PerformanceSchema.rows, - "updates": UpdatesSchema.rows, "global": GlobalSchema.rows + "updates": UpdatesSchema.rows, "global": GlobalSchema.rows, + "gamebar": GameBarSchema.rows }) readonly property var searchIndex: { var srcs = hub.sectionRows; @@ -441,7 +446,7 @@ Rectangle { readonly property var framedSet: ({ "subshells": true, "bar-studio": true, "desktop": true, "controls": true, "plugins": true, "input": true, "animations": true, "global": true, "windowmanager": true, "windowrules": true, "appoverrides": true, "layerrules": true, - "session": true + "session": true, "gamebar": true }) // A section drives the compositor when any of its rows targets the neutral // window-manager store (src desktop.json), derived from the schema so it @@ -538,7 +543,7 @@ Rectangle { "recording": "RecordingPage", "performance": "PerformancePage", "launcher": "LauncherPage", "lockscreen": "LockscreenPage", "fastfetch": "FastfetchPage", "addons": "AddonsPage", - "credits": "CreditsPage" + "credits": "CreditsPage", "gamebar": "GameBarPage" }; map.global = "GlobalPage"; map["import"] = "ImportPage"; @@ -585,7 +590,15 @@ Rectangle { "deckFrostEnabled": true, "deckFrost": 0.45, "clipboard.widthPercent": 65, "clipboard.heightPercent": 42, "clipboard.bottomPercent": 0, "clipboard.panelRadius": 18, "clipboard.paneRadius": 12, "clipboard.cardRadius": 9, - "clipboard.pruneWeekly": false + "clipboard.pruneWeekly": false, + "gamebar.enabled": true, "gamebar.dim": 0.62, "gamebar.fade": 200, + "gamebar.scale": 1, "gamebar.radius": 14, + "gamebar.snap": true, "gamebar.snapReach": 9, "gamebar.snapGrid": 24, + "gamebar.guideGrid": true, "gamebar.gridStep": 48, "gamebar.gridAlpha": 0.28, + "gamebar.spring": 3.2, "gamebar.damping": 0.58, "gamebar.mass": 1, + "gamebar.voiceAvatars": 8, "gamebar.voiceDim": true, "gamebar.voiceDimOpacity": 0.5, + "gamebar.voiceAvatarSize": 52, "gamebar.voiceNames": true, + "gamebar.voiceControls": true, "gamebar.voiceBgOpacity": 0.72 }) // key -> source file, derived from the schema so it cannot drift. diff --git a/ryoku/hub/quickshell/SettingsSheet.qml b/ryoku/hub/quickshell/SettingsSheet.qml index 7d0f98a6c8..d5efe8b251 100644 --- a/ryoku/hub/quickshell/SettingsSheet.qml +++ b/ryoku/hub/quickshell/SettingsSheet.qml @@ -191,8 +191,8 @@ Item { if (isNaN(n)) return; if (r.ctl === "step") { - var lo = (r.from === undefined) ? 0 : Number(r.from); - var hi = (r.to === undefined) ? 100 : Number(r.to); + var lo = (r.lo === undefined) ? 0 : Number(r.lo); + var hi = (r.hi === undefined) ? 100 : Number(r.hi); sheet.edited(r.key, Math.max(lo, Math.min(hi, Math.round(n)))); return; } diff --git a/ryoku/hub/quickshell/pages/GameBarPage.qml b/ryoku/hub/quickshell/pages/GameBarPage.qml new file mode 100644 index 0000000000..1ab57f796c --- /dev/null +++ b/ryoku/hub/quickshell/pages/GameBarPage.qml @@ -0,0 +1,38 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Ryoku.Ui +import Ryoku.Ui.Singletons +import ".." +import "../schema/GameBarPage.js" as GameBarSchema + +// Game Bar: the overlay's master switch, look, placement and settle, plus the +// Discord Voice widget. The rows are the shared settings sheet over the +// shell.json draft, so Save, Revert and Reset ride the Hub's action bar. +Item { + id: pg + + property var hub + + readonly property string pTitle: I18n.tr("Game Bar") + readonly property string pEyebrow: I18n.tr("DESKTOP") + readonly property string pBlurb: I18n.tr("The overlay SUPER + SHIFT + G opens over a game: dimming, widget placement, snapping, motion and the Discord Voice overlay.") + readonly property var schema: GameBarSchema.rows + + function focusKey(k) { sp.focusKey(k) } + + SchemaPage { + id: sp + anchors.fill: parent + schema: pg.schema + draft: pg.hub ? pg.hub.draft : null + defaults: pg.hub ? pg.hub.committed : ({}) + advanced: pg.hub ? pg.hub.advanced : false + title: pg.pTitle + eyebrow: pg.pEyebrow + blurb: pg.pBlurb + query: pg.hub ? pg.hub.query : "" + onEdited: (k, v) => { if (pg.hub) pg.hub.edit(k, v); } + onPickRequested: (r) => { if (pg.hub) pg.hub.openPick(r); } + } +} diff --git a/ryoku/hub/quickshell/schema/GameBarPage.js b/ryoku/hub/quickshell/schema/GameBarPage.js new file mode 100644 index 0000000000..1ef90c2f6a --- /dev/null +++ b/ryoku/hub/quickshell/schema/GameBarPage.js @@ -0,0 +1,265 @@ +.pragma library + +// Game Bar: SUPER + SHIFT + G raises the overlay over a game. Every key is a +// plain shell.json value under `gamebar`, so the daemon persists it and the +// live shell retunes without a restart. + +var rows = [ + { + "tab": "Behaviour", + "group": "GAME BAR", + "key": "gamebar.enabled", + "label": "Show the Game Bar", + "desc": "SUPER + SHIFT + G opens the overlay over the active game. Turn off to disable it completely.", + "ctl": "sw", + "src": "shell", + "keywords": "gamebar game bar overlay ingame in-game shortcut disable enable" + }, { + "tab": "Behaviour", + "group": "BACKGROUND", + "key": "gamebar.dim", + "label": "Background dim", + "desc": "How far the game darkens behind the open bar.", + "ctl": "slid", + "src": "shell", + "lo": 0, + "hi": 0.9, + "unit": "%", + "pct": true, + "when": { "gamebar.enabled": [true] }, + "keywords": "dim darken scrim shade background game" + }, { + "tab": "Behaviour", + "group": "SIZE", + "key": "gamebar.scale", + "label": "Overlay size", + "desc": "Scales the strip, the widgets and their text together.", + "ctl": "slid", + "src": "shell", + "lo": 0.7, + "hi": 1.5, + "unit": "%", + "pct": true, + "when": { "gamebar.enabled": [true] }, + "keywords": "size scale zoom larger smaller widgets strip text" + }, { + "tab": "Behaviour", + "group": "SIZE", + "key": "gamebar.radius", + "label": "Corner rounding", + "desc": "Rounding of the strip and the widget frames.", + "ctl": "step", + "src": "shell", + "lo": 0, + "hi": 28, + "unit": " px", + "when": { "gamebar.enabled": [true] }, + "keywords": "round rounding corner corners radius frame strip shape" + }, { + "tab": "Behaviour", + "group": "ENTRANCE", + "key": "gamebar.fade", + "label": "Opening fade", + "desc": "How long the bar takes to appear and to leave.", + "ctl": "step", + "src": "shell", + "lo": 0, + "hi": 800, + "unit": " ms", + "when": { "gamebar.enabled": [true] }, + "keywords": "fade duration opening closing appear animate motion" + }, { + "tab": "Placement", + "group": "SNAPPING", + "key": "gamebar.snap", + "label": "Snap to edges", + "desc": "A released widget pulls to the screen edges, other widgets and the centre lines. Turn off to leave it exactly where it was let go.", + "ctl": "sw", + "src": "shell", + "when": { "gamebar.enabled": [true] }, + "keywords": "snap snapping magnet align edges grid position" + }, { + "tab": "Placement", + "group": "SNAPPING", + "key": "gamebar.snapReach", + "label": "Snap reach", + "desc": "How close a widget must come before it pulls to a line.", + "ctl": "step", + "src": "shell", + "lo": 0, + "hi": 40, + "unit": " px", + "when": { "gamebar.enabled": [true], "gamebar.snap": [true] }, + "keywords": "snap reach distance magnet pull threshold" + }, { + "tab": "Placement", + "group": "SNAPPING", + "key": "gamebar.snapGrid", + "label": "Fallback grid", + "desc": "Spacing a widget falls back to when no edge is near.", + "ctl": "step", + "src": "shell", + "lo": 4, + "hi": 64, + "unit": " px", + "when": { "gamebar.enabled": [true], "gamebar.snap": [true] }, + "keywords": "grid snap fallback spacing step" + }, { + "tab": "Placement", + "group": "GUIDES", + "key": "gamebar.guideGrid", + "label": "Show the guide grid", + "desc": "Draw the placement grid while a widget is dragged with snapping on.", + "ctl": "sw", + "src": "shell", + "when": { "gamebar.enabled": [true], "gamebar.snap": [true] }, + "keywords": "grid lines guides drag placement" + }, { + "tab": "Placement", + "group": "GUIDES", + "key": "gamebar.gridStep", + "label": "Guide spacing", + "desc": "Distance between the guide lines.", + "ctl": "step", + "src": "shell", + "lo": 8, + "hi": 96, + "unit": " px", + "when": { "gamebar.enabled": [true], "gamebar.snap": [true], "gamebar.guideGrid": [true] }, + "keywords": "grid spacing lines density guide" + }, { + "tab": "Placement", + "group": "GUIDES", + "key": "gamebar.gridAlpha", + "label": "Guide visibility", + "desc": "How strongly the guide lines show over the game.", + "ctl": "slid", + "src": "shell", + "lo": 0, + "hi": 0.6, + "unit": "%", + "pct": true, + "when": { "gamebar.enabled": [true], "gamebar.snap": [true], "gamebar.guideGrid": [true] }, + "keywords": "grid opacity alpha brightness visibility guide" + }, { + "tab": "Motion", + "group": "SETTLE", + "key": "gamebar.spring", + "label": "Settle stiffness", + "desc": "How hard a released widget is pulled into its spot. Higher settles faster.", + "ctl": "slid", + "src": "shell", + "lo": 1, + "hi": 8, + "when": { "gamebar.enabled": [true] }, + "keywords": "spring stiffness settle curve snappy fast motion" + }, { + "tab": "Motion", + "group": "SETTLE", + "key": "gamebar.damping", + "label": "Settle damping", + "desc": "How quickly the settle stops. Lower overshoots and bounces, higher is firm.", + "ctl": "slid", + "src": "shell", + "lo": 0.1, + "hi": 1, + "when": { "gamebar.enabled": [true] }, + "keywords": "damping bounce overshoot spring curve settle motion" + }, { + "tab": "Motion", + "group": "SETTLE", + "key": "gamebar.mass", + "label": "Settle weight", + "desc": "How heavy the settle feels. Heavier moves slower and softer.", + "ctl": "slid", + "src": "shell", + "lo": 0.3, + "hi": 4, + "when": { "gamebar.enabled": [true] }, + "keywords": "mass weight spring settle motion heavy" + }, { + "tab": "Voice", + "group": "DISCORD", + "key": "gamebar.voiceAvatars", + "label": "Participant avatars", + "desc": "How many people in the Discord call the Voice widget shows before it stops.", + "ctl": "step", + "src": "shell", + "lo": 1, + "hi": 8, + "unit": " avatars", + "when": { "gamebar.enabled": [true] }, + "keywords": "discord voice call participants avatars people limit count" + }, { + "tab": "Voice", + "group": "DISCORD", + "key": "gamebar.voiceDim", + "label": "Fade quiet participants", + "desc": "Dim everyone who is not speaking so the active speaker stands out.", + "ctl": "sw", + "src": "shell", + "when": { "gamebar.enabled": [true] }, + "keywords": "discord voice speaking dim fade quiet active" + }, { + "tab": "Voice", + "group": "DISCORD", + "key": "gamebar.voiceDimOpacity", + "label": "Quiet participant fade", + "desc": "How far a participant who is not speaking fades back.", + "ctl": "slid", + "src": "shell", + "lo": 0.2, + "hi": 1, + "unit": "%", + "pct": true, + "when": { "gamebar.enabled": [true], "gamebar.voiceDim": [true] }, + "keywords": "discord voice opacity dim fade speaking participants" + }, { + "tab": "Voice", + "group": "PINNED OVERLAY", + "key": "gamebar.voiceNames", + "label": "Participant names", + "desc": "Put each person's name under their avatar in the pinned voice overlay.", + "ctl": "sw", + "src": "shell", + "when": { "gamebar.enabled": [true] }, + "keywords": "discord voice pinned overlay names labels participants" + }, { + "tab": "Voice", + "group": "PINNED OVERLAY", + "key": "gamebar.voiceAvatarSize", + "label": "Avatar size", + "desc": "How large each participant avatar is when the voice overlay is pinned and the Game Bar is closed.", + "ctl": "step", + "src": "shell", + "lo": 32, + "hi": 72, + "unit": " px", + "when": { "gamebar.enabled": [true] }, + "keywords": "discord voice pinned overlay avatar size picture large small" + }, { + "tab": "Voice", + "group": "PINNED OVERLAY", + "key": "gamebar.voiceBgOpacity", + "label": "Panel background", + "desc": "How solid the pinned voice overlay panel is. Zero leaves only the content over the game.", + "ctl": "slid", + "src": "shell", + "lo": 0, + "hi": 1, + "unit": "%", + "pct": true, + "when": { "gamebar.enabled": [true] }, + "keywords": "discord voice pinned overlay background opacity transparency panel" + }, { + "tab": "Voice", + "group": "PINNED OVERLAY", + "key": "gamebar.voiceControls", + "label": "Mute and deafen", + "desc": "Show the microphone and headphone state in the pinned voice overlay. The pinned overlay is a picture and never takes the pointer.", + "ctl": "sw", + "src": "shell", + "when": { "gamebar.enabled": [true] }, + "keywords": "discord voice pinned overlay mute deafen microphone headphone controls" + } +]; diff --git a/ryoku/hyprland/modules/binds.lua b/ryoku/hyprland/modules/binds.lua index f58dafcf82..204d898d02 100644 --- a/ryoku/hyprland/modules/binds.lua +++ b/ryoku/hyprland/modules/binds.lua @@ -109,6 +109,7 @@ hl.bind(K(mod .. " + Space"), hl.dsp.global("ryoku:launcher")) hl.bind(K("ALT + Space"), hl.dsp.global("ryoku:ask")) -- ask Rashin hl.bind(K(mod .. " + ALT + Space"), hl.dsp.exec_cmd("ryoku-summon Rashin flock -n -o /tmp/rashin-app.lock rashin-app")) -- Rashin app: the AI companion window (a second press focuses it) hl.bind(K(mod .. " + K"), hl.dsp.exec_cmd("ryoku-shell cheatsheet")) -- Kit, or the bar style's own cheatsheet +hl.bind(K(mod .. " + SHIFT + G"), hl.dsp.global("ryoku:gamebar"), { dont_inhibit = true }) -- Game Bar: capture, performance and tasks over a fullscreen game hl.bind(K(mod .. " + L"), hl.dsp.exec_cmd("ryoku-shell lock")) -- lock the screen hl.bind(K(mod .. " + Escape"), hl.dsp.global("ryoku:quicksettings"), { dont_inhibit = true }) -- quick settings: power, logout, restart, shutdown, wifi hl.bind(K(mod .. " + W"), hl.dsp.exec_cmd("ryogami wallpaper ui")) -- ryogami wallpaper picker (full-screen browser: hero cards, colour filters, effects); the frame-blob menu stays on the bar logo diff --git a/ryoku/rashin/backend/vendor/ryoku-wm/binds.go b/ryoku/rashin/backend/vendor/ryoku-wm/binds.go index 8577fe4a32..6b3e581ff7 100644 --- a/ryoku/rashin/backend/vendor/ryoku-wm/binds.go +++ b/ryoku/rashin/backend/vendor/ryoku-wm/binds.go @@ -210,6 +210,7 @@ var catalogue = []CatalogBind{ {ID: "shell.ask", Category: "Shell", Label: "Ask Rashin", Hint: "AI chat bar", Chord: "ALT + Space", Kind: BindShell, Needs: "ryoku-rashin"}, {ID: "shell.rashin", Category: "Shell", Label: "Rashin", Hint: "The AI companion app; a second press focuses it", Chord: "SUPER + ALT + Space", Kind: BindShell, Needs: "rashin-app"}, {ID: "shell.cheatsheet", Category: "Shell", Label: "Kit", Hint: "Press again to close", Chord: "SUPER + K", Kind: BindShell}, + {ID: "shell.gamebar", Category: "Shell", Label: "Game Bar", Hint: "Capture, performance and tasks over a fullscreen game", Chord: "SUPER + SHIFT + G", Kind: BindShell}, {ID: "shell.lock", Category: "Shell", Label: "Lock the screen", Chord: "SUPER + L", Kind: BindShell}, {ID: "shell.quicksettings", Category: "Shell", Label: "Quick settings", Hint: "Power, logout, restart, shutdown, wifi", Chord: "SUPER + Escape", Kind: BindShell}, {ID: "shell.wallpaper", Category: "Shell", Label: "Wallpaper picker", Chord: "SUPER + W", Kind: BindShell}, diff --git a/ryoku/shell/ipc/daemon.go b/ryoku/shell/ipc/daemon.go index b25755bb7d..0505b90db4 100644 --- a/ryoku/shell/ipc/daemon.go +++ b/ryoku/shell/ipc/daemon.go @@ -1299,6 +1299,7 @@ var surfaceCommands = map[string]string{ "quicksettings": "sidebar-left", "wallpaper-menu": "wallpaper", "clipboard": "clipboard", + "gamebar": "gamebar", "settings": "settings", "cheatsheet": "cheatsheet", "tools": "tools", diff --git a/ryoku/shell/ipc/settings.go b/ryoku/shell/ipc/settings.go index 0b94c4cf63..206cbee245 100644 --- a/ryoku/shell/ipc/settings.go +++ b/ryoku/shell/ipc/settings.go @@ -56,6 +56,7 @@ var contractKeys = map[string]bool{ "controls": true, "batteryAlerts": true, "sounds": true, + "gamebar": true, } const ( @@ -138,6 +139,35 @@ type settings struct { Controls controlsSettings `json:"controls"` BatteryAlerts batteryAlertsSettings `json:"batteryAlerts"` Sounds soundsSettings `json:"sounds"` + GameBar gameBarSettings `json:"gamebar"` +} + +// gameBarSettings is the Game Bar overlay: the master switch, the placement +// snapping and guide, the settle curve, the look, and the Discord Voice widget. +type gameBarSettings struct { + Enabled bool `json:"enabled"` + Dim float64 `json:"dim"` + Fade int `json:"fade"` + Scale float64 `json:"scale"` + Radius int `json:"radius"` + Snap bool `json:"snap"` + SnapReach int `json:"snapReach"` + SnapGrid int `json:"snapGrid"` + GuideGrid bool `json:"guideGrid"` + GridStep int `json:"gridStep"` + GridAlpha float64 `json:"gridAlpha"` + Spring float64 `json:"spring"` + Damping float64 `json:"damping"` + Mass float64 `json:"mass"` + // Voice controls how the Discord card behaves: avatar limit, the fade for + // quiet people, and the compact pinned overlay's avatar, names and panel. + VoiceAvatars int `json:"voiceAvatars"` + VoiceDim bool `json:"voiceDim"` + VoiceDimOpacity float64 `json:"voiceDimOpacity"` + VoiceAvatarSize int `json:"voiceAvatarSize"` + VoiceNames bool `json:"voiceNames"` + VoiceControls bool `json:"voiceControls"` + VoiceBgOpacity float64 `json:"voiceBgOpacity"` } type generalSettings struct { @@ -479,6 +509,29 @@ func defaultSettings() *settings { }, BatteryAlerts: batteryAlertsSettings{Enabled: true, WarningEnabled: true, CriticalEnabled: true, WarningPercent: 25, CriticalPercent: 10}, Sounds: defaultSoundsSettings(), + GameBar: gameBarSettings{ + Enabled: true, + Dim: 0.62, + Fade: 200, + Scale: 1, + Radius: 14, + Snap: true, + SnapReach: 9, + SnapGrid: 24, + GuideGrid: true, + GridStep: 48, + GridAlpha: 0.28, + Spring: 3.2, + Damping: 0.58, + Mass: 1, + VoiceAvatars: 8, + VoiceDim: true, + VoiceDimOpacity: 0.5, + VoiceAvatarSize: 52, + VoiceNames: true, + VoiceControls: true, + VoiceBgOpacity: 0.72, + }, } } @@ -566,9 +619,28 @@ func (s *settings) normalize(strict bool) error { s.Controls.normalize(v) s.BatteryAlerts.normalize(v) s.Sounds.normalize(v) + s.GameBar.normalize(v) return v.err } +func (g *gameBarSettings) normalize(v *validator) { + v.clampF(&g.Dim, 0, 1) + v.rangeI("gamebar.fade", &g.Fade, 0, 2000) + v.clampF(&g.Scale, 0.5, 2) + v.rangeI("gamebar.radius", &g.Radius, 0, 64) + v.rangeI("gamebar.snapReach", &g.SnapReach, 0, 64) + v.rangeI("gamebar.snapGrid", &g.SnapGrid, 4, 128) + v.rangeI("gamebar.gridStep", &g.GridStep, 8, 256) + v.clampF(&g.GridAlpha, 0, 1) + v.clampF(&g.Spring, 0.5, 20) + v.clampF(&g.Damping, 0.05, 1) + v.clampF(&g.Mass, 0.1, 10) + v.rangeI("gamebar.voiceAvatars", &g.VoiceAvatars, 1, 8) + v.clampF(&g.VoiceDimOpacity, 0.2, 1) + v.rangeI("gamebar.voiceAvatarSize", &g.VoiceAvatarSize, 32, 72) + v.clampF(&g.VoiceBgOpacity, 0, 1) +} + func (g *generalSettings) normalize(v *validator) { v.enum("general.temperature_unit", g.TemperatureUnit, tempUnitValues) v.enum("general.weather_location_query.type", g.WeatherLocationQuery.Type, locQueryTypeValues) @@ -992,6 +1064,7 @@ func buildSettings(raw map[string]any, strict bool) (*settings, error) { "controls": &s.Controls, "batteryAlerts": &s.BatteryAlerts, "sounds": &s.Sounds, + "gamebar": &s.GameBar, } for k, ptr := range dst { raw, ok := raw[k] diff --git a/ryoku/shell/ipc/vendor/ryoku-wm/binds.go b/ryoku/shell/ipc/vendor/ryoku-wm/binds.go index 8577fe4a32..6b3e581ff7 100644 --- a/ryoku/shell/ipc/vendor/ryoku-wm/binds.go +++ b/ryoku/shell/ipc/vendor/ryoku-wm/binds.go @@ -210,6 +210,7 @@ var catalogue = []CatalogBind{ {ID: "shell.ask", Category: "Shell", Label: "Ask Rashin", Hint: "AI chat bar", Chord: "ALT + Space", Kind: BindShell, Needs: "ryoku-rashin"}, {ID: "shell.rashin", Category: "Shell", Label: "Rashin", Hint: "The AI companion app; a second press focuses it", Chord: "SUPER + ALT + Space", Kind: BindShell, Needs: "rashin-app"}, {ID: "shell.cheatsheet", Category: "Shell", Label: "Kit", Hint: "Press again to close", Chord: "SUPER + K", Kind: BindShell}, + {ID: "shell.gamebar", Category: "Shell", Label: "Game Bar", Hint: "Capture, performance and tasks over a fullscreen game", Chord: "SUPER + SHIFT + G", Kind: BindShell}, {ID: "shell.lock", Category: "Shell", Label: "Lock the screen", Chord: "SUPER + L", Kind: BindShell}, {ID: "shell.quicksettings", Category: "Shell", Label: "Quick settings", Hint: "Power, logout, restart, shutdown, wifi", Chord: "SUPER + Escape", Kind: BindShell}, {ID: "shell.wallpaper", Category: "Shell", Label: "Wallpaper picker", Chord: "SUPER + W", Kind: BindShell}, diff --git a/ryoku/shell/quickshell/shell/components/GlyphIcon.qml b/ryoku/shell/quickshell/shell/components/GlyphIcon.qml index f7abe464cc..1cb7f3f5dc 100644 --- a/ryoku/shell/quickshell/shell/components/GlyphIcon.qml +++ b/ryoku/shell/quickshell/shell/components/GlyphIcon.qml @@ -23,6 +23,7 @@ Item { "speaker": { d: "M4 9v6h4l5 4V5L8 9z M16 9.5a3 3 0 0 1 0 5 M18.5 7.5a6 6 0 0 1 0 9", fill: false }, "speaker-off": { d: "M4 9v6h4l5 4V5L8 9z M16.2 9.8l4.4 4.4 M20.6 9.8l-4.4 4.4", fill: false }, "headphones": { d: "M3 18v-6a9 9 0 0 1 18 0v6 M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3z M3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z", fill: false }, + "headphones-off": { d: "M3 18v-6a9 9 0 0 1 15.4-6.4 M21 12v6 M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3z M3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z M3 3l18 18", fill: false }, "mic": { d: "M9 9V6a3 3 0 0 1 6 0v6a3 3 0 0 1-6 0 M5 11a7 7 0 0 0 14 0 M12 18v3", fill: false }, "mic-off": { d: "M9 9V6a3 3 0 0 1 6 0v3 M15 12v0a3 3 0 0 1-5.6 1.5 M5 11a7 7 0 0 0 11 5.5 M12 19v3 M3 3l18 18", fill: false }, "lock": { d: "M6 10h12a1.5 1.5 0 0 1 1.5 1.5v6a1.5 1.5 0 0 1-1.5 1.5H6a1.5 1.5 0 0 1-1.5-1.5v-6A1.5 1.5 0 0 1 6 10z M8.5 10V7a3.5 3.5 0 0 1 7 0v3", fill: false }, @@ -55,6 +56,7 @@ Item { "phone": { d: "M8 3.5h8a1.3 1.3 0 0 1 1.3 1.3v14.4a1.3 1.3 0 0 1-1.3 1.3H8a1.3 1.3 0 0 1-1.3-1.3V4.8A1.3 1.3 0 0 1 8 3.5z M10.5 17.5h3", fill: false }, "watch": { d: "M12 8.5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7z M9.6 8.7l.5-4.2h3.8l.5 4.2 M9.6 15.3l.5 4.2h3.8l.5-4.2", fill: false }, "info": { d: "M12 3.3a8.7 8.7 0 1 0 0 17.4 8.7 8.7 0 0 0 0-17.4z M12 7.8v5.4 M12 16.4h.01", fill: false }, + "pin": { d: "M9 3.5h6l-.7 5.2 2.7 2.6v1.9H7v-1.9l2.7-2.6z M12 13.2v7.3", fill: false }, "plus": { d: "M12 5.5v13 M5.5 12h13", fill: false }, "battery": { d: "M3 8.5h13.4a1.4 1.4 0 0 1 1.4 1.4v4.2a1.4 1.4 0 0 1-1.4 1.4H3a1.4 1.4 0 0 1-1.4-1.4V9.9A1.4 1.4 0 0 1 3 8.5z M20.4 11v2", fill: false }, "bolt": { d: "M13 2L4 14h6l-1 8 9-12h-6z", fill: true }, @@ -100,7 +102,8 @@ Item { "remux": { d: "M16.5 3.5l4 4-4 4 M20.5 7.5H8.5a4 4 0 0 0-4 4 M7.5 20.5l-4-4 4-4 M3.5 16.5h12a4 4 0 0 0 4-4", fill: false }, "scan": { d: "M3 7V4.5A1.5 1.5 0 0 1 4.5 3H7 M17 3h2.5A1.5 1.5 0 0 1 21 4.5V7 M21 17v2.5a1.5 1.5 0 0 1-1.5 1.5H17 M7 21H4.5A1.5 1.5 0 0 1 3 19.5V17 M3 12h18", fill: false }, "flip": { d: "M12 3v18 M10 8l-5 4 5 4V8z M14 8l5 4-5 4V8z", fill: false }, - "discord": { d: "M5.5 4.5H19A1.8 1.8 0 0 1 20.8 6.3V13.7A1.8 1.8 0 0 1 19 15.5H9.5L5.5 19V15.5A1.8 1.8 0 0 1 3.7 13.7V6.3A1.8 1.8 0 0 1 5.5 4.5Z M10 8.2L14.5 11L10 13.8Z", fill: false }, + "grip": { d: "M13 20L20 13 M16 20L20 16 M19 20L20 19", fill: false }, + "discord": { d: "M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z", fill: true }, "wx-clear-day": { d: "M16 12a4 4 0 1 0-8 0 4 4 0 0 0 8 0 M12 2v2 M12 20v2 M4.2 4.2l1.4 1.4 M18.4 18.4l1.4 1.4 M2 12h2 M20 12h2 M4.2 19.8l1.4-1.4 M18.4 5.6l1.4-1.4", fill: false }, "wx-clear-night": { d: "M12 3a6.4 6.4 0 0 0 9 9 9 9 0 1 1-9-9z", fill: false }, "wx-partly-cloudy-day": { d: "M6 4v1.7 M2.7 5.5l1.2 1.2 M2 9h1.7 M9.5 5.5L8.3 6.7 M6 6.4a2.7 2.7 0 0 1 2.6 2 M8.5 18h9a2.9 2.9 0 0 0 .3-5.8 4.4 4.4 0 0 0-8.5-1.3A3.3 3.3 0 0 0 8.5 18z", fill: false }, diff --git a/ryoku/shell/quickshell/shell/modules/gamebar/GameBar.qml b/ryoku/shell/quickshell/shell/modules/gamebar/GameBar.qml new file mode 100644 index 0000000000..549c3f3c5b --- /dev/null +++ b/ryoku/shell/quickshell/shell/modules/gamebar/GameBar.qml @@ -0,0 +1,740 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Wayland +import shell.services +import Ryoku.Ui.Singletons +import "look" +import "cards" +import "discordVoice/layout.js" as VoiceLayout + +// One passive overlay per output, above a fullscreen game. Only the strip and +// the widgets take the pointer; the rest falls through. No keyboard focus. +PanelWindow { + id: win + + required property var modelData + + readonly property string mon: modelData ? modelData.name : "" + readonly property var cfg: Config.gamebar + readonly property real us: Tokens.uiScaleFor(win.mon) * win.cfg.scale + readonly property int fadeMs: Motion.dur(win.cfg.fade) + + readonly property var catalogue: [ + { id: "capture", label: I18n.tr("Capture"), symbol: "screenshot_region" }, + { id: "performance", label: I18n.tr("Performance"), symbol: "speed" }, + { id: "resources", label: I18n.tr("Resources"), symbol: "monitoring" }, + { id: "tasks", label: I18n.tr("Tasks"), symbol: "checklist" }, + { id: "media", label: I18n.tr("Media"), symbol: "music_note" }, + { id: "voice", label: I18n.tr("Voice"), symbol: "headset_mic", glyph: "discord", minimal: true }, + { id: "audio", label: I18n.tr("Audio"), symbol: "volume_up" }, + { id: "gallery", label: I18n.tr("Gallery"), symbol: "photo_library" } + ] + + readonly property var cards: GameBarStore.visibleCards + readonly property bool mine: GameBarStore.monitor.length > 0 + ? GameBarStore.monitor === win.mon : Wm.focusedOutput === win.mon + readonly property bool active: win.cfg.enabled && win.mine + && (GameBarStore.open || GameBarStore.pinned.length > 0) + + // Placement uses the widest and tallest so a grid never overlaps. + readonly property var cardSizes: ({ + capture: { w: 350, h: 240 }, + performance: { w: 340, h: 356 }, + resources: { w: 350, h: 200 }, + tasks: { w: 350, h: 270 }, + media: { w: 358, h: 235 }, + voice: { w: 358, h: 336 }, + audio: { w: 350, h: 282 }, + gallery: { w: 350, h: 290 } + }) + readonly property real maxCardW: 358 * win.us + readonly property real maxCardH: 356 * win.us + readonly property real gap: 12 * win.us + readonly property real gridStep: win.cfg.snapGrid * win.us + readonly property real gridLine: win.cfg.gridStep * win.us + readonly property real topPad: 56 * win.us + readonly property real sidePad: 16 * win.us + readonly property real stripH: 48 * win.us + + function symbolFor(id) { + for (let i = 0; i < win.catalogue.length; i++) { + if (win.catalogue[i].id === id) + return win.catalogue[i].symbol; + } + return ""; + } + + // Only cards that can resize carry limits; the rest keep their drawn size. + readonly property var cardLimits: ({ + voice: { minW: 300, minH: 280, maxW: 640, maxH: 640 } + }) + + function glyphFor(id) { + for (let i = 0; i < win.catalogue.length; i++) { + if (win.catalogue[i].id === id && win.catalogue[i].glyph) + return win.catalogue[i].glyph; + } + return ""; + } + + function limitsFor(id) { + return win.cardLimits[id] || { minW: 220, minH: 160, maxW: 800, maxH: 700 }; + } + + function minimalCard(id) { + for (let i = 0; i < win.catalogue.length; i++) { + if (win.catalogue[i].id === id) + return win.catalogue[i].minimal === true; + } + return false; + } + + // A pinned minimal card shrinks to what it draws, so it can meet a screen + // edge instead of stopping where the full card would. + function compactSizeFor(id) { + if (!win.minimalCard(id)) + return null; + const avatars = Math.max(1, Math.min(8, win.cfg.voiceAvatars || 8)); + const count = Math.min(avatars, DiscordVoice.participantCount); + const avatar = VoiceLayout.avatar(win.cfg.voiceAvatarSize); + const names = win.cfg.voiceNames !== false; + return VoiceLayout.size(win.fullSizeFor(id).w / win.us, count, avatar, names); + } + + function fullSizeFor(id) { + const e = win.cardSizes[id] || { w: 320, h: 300 }; + const saved = GameBarStore.sizeFor(id); + const lim = win.limitsFor(id); + const w = Math.max(lim.minW, Math.min(lim.maxW, + saved && typeof saved.w === "number" ? saved.w : e.w)); + const h = Math.max(lim.minH, Math.min(lim.maxH, + saved && typeof saved.h === "number" ? saved.h : e.h)); + return { w: w * win.us, h: h * win.us }; + } + + function sizeFor(id) { + if (id === win.resizeCard) + return { w: win.liveW, h: win.liveH }; + if (!GameBarStore.open && GameBarStore.isPinned(id)) { + const compact = win.compactSizeFor(id); + if (compact) + return { w: compact.w * win.us, h: compact.h * win.us }; + } + return win.fullSizeFor(id); + } + + property bool live: false + property var shownCards: [] + property int count: win.shownCards.length + + property bool suppressed: false + property bool sawSelecting: false + + // A pinned card outlives the strip: the strip leaves, the cards stay. During + // a full close the strip is held for the fade-out and then reset. + property bool stripUp: GameBarStore.open + + // Free placement. liveX/liveY hold the dragged card's spot so only that + // slot repaints; GameBarStore keeps the settled spot across reloads. + property real liveX: 0 + property real liveY: 0 + property string dragCard: "" + property string settlingCard: "" + property real dragFromX: 0 + property real dragFromY: 0 + + property real liveW: 0 + property real liveH: 0 + property string resizeCard: "" + property real resizeFromW: 0 + property real resizeFromH: 0 + + function shown(id) { + return win.shownCards.indexOf(id) !== -1; + } + + function clampTo(size, x, y) { + const maxX = Math.max(0, win.width - size.w); + const maxY = Math.max(0, win.height - size.h); + return { + x: Math.round(Math.max(0, Math.min(maxX, x))), + y: Math.round(Math.max(0, Math.min(maxY, y))) + }; + } + + function clampPos(id, x, y) { + return win.clampTo(win.sizeFor(id), x, y); + } + + // With snapping on, a released card settles onto the nearest edge or centre + // line; otherwise it stays where it was let go. + function snapPosition(id, x, y) { + const size = win.sizeFor(id); + if (!win.cfg.snap) + return win.clampPos(id, x, y); + + const reach = win.cfg.snapReach * win.us; + const xs = [ + 0, + Math.round((win.width - size.w) / 2), + win.width - size.w + ]; + const ys = [ + 0, + win.topPad + win.stripH + win.gap, + win.height - size.h + ]; + + for (let i = 0; i < win.shownCards.length; i++) { + const other = win.shownCards[i]; + if (other === id) + continue; + const o = win.positionFor(other); + const os = win.sizeFor(other); + xs.push(o.x, o.x + os.w, o.x - size.w, o.x + os.w - size.w); + ys.push(o.y, o.y + os.h, o.y - size.h, o.y + os.h - size.h); + } + + let sx = x; + let sy = y; + let dx = reach; + let dy = reach; + let foundX = false; + let foundY = false; + for (let i = 0; i < xs.length; i++) { + const d = Math.abs(x - xs[i]); + if (d <= dx) { + dx = d; + sx = xs[i]; + foundX = true; + } + } + for (let i = 0; i < ys.length; i++) { + const d = Math.abs(y - ys[i]); + if (d <= dy) { + dy = d; + sy = ys[i]; + foundY = true; + } + } + + if (!foundX) + sx = Math.round(x / win.gridStep) * win.gridStep; + if (!foundY) + sy = Math.round(y / win.gridStep) * win.gridStep; + + return win.clampPos(id, sx, sy); + } + + function defaultPosFor(id, size) { + const list = win.shownCards; + const i = Math.max(0, list.indexOf(id)); + const n = Math.max(1, list.length); + const cols = Math.min(4, Math.max(1, Math.floor( + (win.width - win.sidePad * 2 + win.gap) / (win.maxCardW + win.gap)))); + const used = Math.min(n, cols); + const gridW = used * win.maxCardW + Math.max(0, used - 1) * win.gap; + const col = i % cols; + const row = Math.floor(i / cols); + const x = (win.width - gridW) / 2 + col * (win.maxCardW + win.gap); + const y = win.topPad + win.stripH + win.gap + row * (win.maxCardH + win.gap); + return win.clampTo(size, x, y); + } + + function defaultPos(id) { + return win.defaultPosFor(id, win.sizeFor(id)); + } + + // The pinned card is placed against its full frame. When it shrinks, anchor + // the compact panel to the screen edge the full card sits against, or centre + // it on the full card when that is floating clear of every edge. + function compactPositionFor(id) { + const saved = GameBarStore.positionFor(id); + if (!saved || typeof saved.x !== "number" || typeof saved.y !== "number") + return win.defaultPosFor(id, win.sizeFor(id)); + const full = win.fullSizeFor(id); + const compact = win.sizeFor(id); + const rightEdge = saved.x + full.w; + const bottomEdge = saved.y + full.h; + const reach = 120 * win.us; + const right = win.width - rightEdge <= reach; + const left = saved.x <= reach; + const bottom = win.height - bottomEdge <= reach; + const top = saved.y <= reach; + const x = right ? rightEdge - compact.w + : (left ? saved.x : saved.x + (full.w - compact.w) / 2); + const y = bottom ? bottomEdge - compact.h + : (top ? saved.y : saved.y + (full.h - compact.h) / 2); + return win.clampPos(id, x, y); + } + + function positionFor(id) { + if (id === win.dragCard) + return { x: win.liveX, y: win.liveY }; + if (!GameBarStore.open && GameBarStore.isPinned(id) + && win.minimalCard(id)) + return win.compactPositionFor(id); + const saved = GameBarStore.positionFor(id); + if (saved && typeof saved.x === "number" && typeof saved.y === "number") + return win.clampPos(id, saved.x, saved.y); + return win.defaultPos(id); + } + + function beginDrag(id) { + const p = win.positionFor(id); + // A recent drop still has its spring armed; grabbing again must not + // drag through the settle animation. + win.settlingCard = ""; + settleTimer.stop(); + win.liveX = p.x; + win.liveY = p.y; + win.dragFromX = p.x; + win.dragFromY = p.y; + win.dragCard = id; + } + + function dragBy(id, dx, dy) { + if (win.dragCard !== id) + return; + const p = win.clampPos(id, win.dragFromX + dx, win.dragFromY + dy); + win.liveX = p.x; + win.liveY = p.y; + } + + function endDrag(id) { + if (win.dragCard !== id) + return; + const settled = win.snapPosition(id, win.liveX, win.liveY); + // Store the settled spot while liveX/liveY still pin the card, then + // clear the drag so the slot's spring animates into place. + GameBarStore.place(id, settled.x, settled.y); + win.settlingCard = id; + settleTimer.restart(); + win.dragCard = ""; + } + + function beginResize(id) { + const size = win.sizeFor(id); + win.settlingCard = ""; + settleTimer.stop(); + win.liveW = size.w; + win.liveH = size.h; + win.resizeFromW = size.w; + win.resizeFromH = size.h; + win.resizeCard = id; + } + + function resizeBy(id, dx, dy) { + if (win.resizeCard !== id) + return; + const lim = win.limitsFor(id); + win.liveW = Math.round(Math.max(lim.minW * win.us, + Math.min(lim.maxW * win.us, win.resizeFromW + dx))); + win.liveH = Math.round(Math.max(lim.minH * win.us, + Math.min(lim.maxH * win.us, win.resizeFromH + dy))); + } + + function endResize(id) { + if (win.resizeCard !== id) + return; + GameBarStore.resize(id, win.liveW / win.us, win.liveH / win.us); + win.resizeCard = ""; + const p = win.positionFor(id); + const clamped = win.clampPos(id, p.x, p.y); + if (clamped.x !== p.x || clamped.y !== p.y) + GameBarStore.place(id, clamped.x, clamped.y); + } + + // A pinned card is a picture once the bar closes: no mask rect, so the + // whole surface falls through and it takes no pointer or keyboard focus. + function maskRect(id) { + // While dragging, one full-surface region covers every card, so the per + // card rects stay at zero and the mask never changes between moves. + if (win.dragCard !== "" || !win.shown(id)) + return { x: 0, y: 0, w: 0, h: 0 }; + if (!win.stripUp) + return { x: 0, y: 0, w: 0, h: 0 }; + const p = win.positionFor(id); + const size = win.sizeFor(id); + return { x: Math.round(p.x), y: Math.round(p.y), w: Math.round(size.w), h: Math.round(size.h) }; + } + + Connections { + target: GameBarStore + function onOpenChanged() { + if (GameBarStore.open) + win.stripUp = true; + else if (GameBarStore.pinned.length > 0) + win.stripUp = false; + } + } + + // Turning it off in the Hub closes it at once, leaving nothing the shortcut + // and the strip can no longer reach. + Connections { + target: Config + function onGamebarChanged() { + if (!win.cfg.enabled) + GameBarStore.closeBar(); + } + } + + onActiveChanged: { + if (win.active) { + win.live = true; + win.shownCards = win.cards; + } else { + exitTimer.restart(); + } + } + // A transient deactivation during startup must not latch the surface off. + onWantedChanged: { + if (!win.wanted) + return; + win.live = true; + if (win.shownCards.length === 0) + win.shownCards = win.cards; + } + onCardsChanged: if (win.active) win.shownCards = win.cards + Component.onCompleted: { + win.live = win.active; + win.shownCards = win.cards; + if (!win.cfg.enabled) + GameBarStore.closeBar(); + } + + readonly property bool wanted: win.active && !win.suppressed + + screen: modelData + visible: win.live && !win.suppressed + color: "transparent" + // A negative exclusive zone makes the surface span the whole output, over + // the bar and the dock. + exclusiveZone: -1 + exclusionMode: ExclusionMode.Normal + anchors { top: true; left: true; right: true; bottom: true } + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.namespace: "ryoku-gamebar" + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + + mask: Region { + Region { + x: strip.x; y: strip.y + width: win.stripUp ? strip.width : 0 + height: win.stripUp ? strip.height : 0 + intersection: Intersection.Combine + } + Region { + readonly property var rect: win.maskRect("capture") + x: rect.x; y: rect.y; width: rect.w; height: rect.h + intersection: Intersection.Combine + } + Region { + readonly property var rect: win.maskRect("performance") + x: rect.x; y: rect.y; width: rect.w; height: rect.h + intersection: Intersection.Combine + } + Region { + readonly property var rect: win.maskRect("resources") + x: rect.x; y: rect.y; width: rect.w; height: rect.h + intersection: Intersection.Combine + } + Region { + readonly property var rect: win.maskRect("tasks") + x: rect.x; y: rect.y; width: rect.w; height: rect.h + intersection: Intersection.Combine + } + Region { + readonly property var rect: win.maskRect("media") + x: rect.x; y: rect.y; width: rect.w; height: rect.h + intersection: Intersection.Combine + } + Region { + readonly property var rect: win.maskRect("voice") + x: rect.x; y: rect.y; width: rect.w; height: rect.h + intersection: Intersection.Combine + } + Region { + readonly property var rect: win.maskRect("audio") + x: rect.x; y: rect.y; width: rect.w; height: rect.h + intersection: Intersection.Combine + } + Region { + readonly property var rect: win.maskRect("gallery") + x: rect.x; y: rect.y; width: rect.w; height: rect.h + intersection: Intersection.Combine + } + // A dragged or resized card must not let the pointer fall through to + // the bar, the dock or the game, so the surface takes the whole screen. + Region { + x: 0; y: 0 + width: (win.dragCard !== "" || win.resizeCard !== "") ? win.width : 0 + height: (win.dragCard !== "" || win.resizeCard !== "") ? win.height : 0 + intersection: Intersection.Combine + } + } + + // Opening fades in once the surface is mapped instead of popping on. + property bool faded: false + property real reveal: win.faded && win.wanted ? 1 : 0 + Behavior on reveal { + NumberAnimation { + duration: win.fadeMs + easing.type: Motion.rowRevealCurve + } + } + + onBackingWindowVisibleChanged: { + if (win.backingWindowVisible) { + win.faded = false; + fadeIn.restart(); + } else { + win.faded = false; + } + } + + Timer { + id: fadeIn + interval: 40 + onTriggered: win.faded = true + } + + Timer { + id: exitTimer + interval: 240 + onTriggered: { + if (win.wanted) { + win.live = true; + return; + } + win.live = false; + win.stripUp = GameBarStore.open; + if (!GameBarStore.open) + win.shownCards = []; + } + } + + Rectangle { + id: scrim + anchors.fill: parent + color: "#000000" + opacity: (win.stripUp ? win.cfg.dim : 0) * win.reveal + + Behavior on opacity { + NumberAnimation { + duration: win.fadeMs + easing.type: Motion.rowRevealCurve + } + } + } + + // The guide grid appears only while a dragged card can snap. + Item { + id: grid + anchors.fill: parent + visible: win.dragCard !== "" && win.cfg.snap && win.cfg.guideGrid + opacity: win.reveal + + readonly property color line: Qt.rgba(GameBarLook.accent.r, GameBarLook.accent.g, GameBarLook.accent.b, win.cfg.gridAlpha) + + Repeater { + model: grid.visible ? Math.ceil(win.width / win.gridLine) + 1 : 0 + + delegate: Rectangle { + required property int index + x: index * win.gridLine + width: 1 + height: grid.height + color: grid.line + } + } + + Repeater { + model: grid.visible ? Math.ceil(win.height / win.gridLine) + 1 : 0 + + delegate: Rectangle { + required property int index + y: index * win.gridLine + width: grid.width + height: 1 + color: grid.line + } + } + } + + // Cards paint under the strip, and each keeps one slot for the surface's + // life so switching widgets on never re-creates the ones already up. + Repeater { + model: win.catalogue + + delegate: Item { + id: slot + required property var modelData + + readonly property string cardId: slot.modelData.id + readonly property bool on: win.shown(slot.cardId) + readonly property var size: win.sizeFor(slot.cardId) + readonly property var pos: win.positionFor(slot.cardId) + + visible: slot.on + width: size.w + height: size.h + x: slot.pos.x + y: slot.pos.y + opacity: win.reveal + // Closed with pins up, a card is a picture: no hover, no drag, no + // cursor change, and the empty mask hands the pointer to the game. + enabled: win.stripUp + + // The spring belongs to a drop only; a reflow places cards at once. + Behavior on x { + enabled: !Motion.reduce && win.settlingCard === slot.cardId + SpringAnimation { + spring: win.cfg.spring + damping: win.cfg.damping + mass: win.cfg.mass + epsilon: 0.4 * win.us + } + } + Behavior on y { + enabled: !Motion.reduce && win.settlingCard === slot.cardId + SpringAnimation { + spring: win.cfg.spring + damping: win.cfg.damping + mass: win.cfg.mass + epsilon: 0.4 * win.us + } + } + + Loader { + id: cardLoader + anchors.fill: parent + active: slot.on + sourceComponent: win.componentFor(slot.cardId) + } + } + } + + Timer { + id: settleTimer + interval: 900 + onTriggered: win.settlingCard = "" + } + + GameBarStrip { + id: strip + s: win.us + visible: win.live + enabled: win.stripUp + x: Math.round((win.width - width) / 2) + y: win.topPad + opacity: win.stripUp ? win.reveal : 0 + cards: win.catalogue + activeIds: win.shownCards + snapping: win.cfg.snap + onPicked: id => GameBarStore.toggleCard(id) + onSnapToggled: Config.setGameBar("snap", !win.cfg.snap) + + Behavior on opacity { + NumberAnimation { + duration: win.fadeMs + easing.type: Motion.rowRevealCurve + } + } + } + + function componentFor(id) { + switch (id) { + case "capture": return captureComp; + case "performance": return performanceComp; + case "resources": return resourcesComp; + case "tasks": return tasksComp; + case "media": return mediaComp; + case "voice": return voiceComp; + case "audio": return audioComp; + case "gallery": return galleryComp; + } + return null; + } + + // The bar must not be in its own capture: unmap the surface, run the + // existing shot path, and restore after the selection overlay finishes. + function shoot(mode) { + if (Capture.selecting !== "") + return; + win.sawSelecting = false; + win.suppressed = true; + Capture.shoot(mode); + if (mode === "all") + win.restoreAfter(Capture.delay * 1000 + 800); + else + restorePoll.restart(); + } + + function startRecording() { + win.suppressed = true; + Recorder.startAfter(Recorder.recordArgs(), 1); + win.restoreAfter(1900); + } + + function restoreAfter(ms) { + restoreDelay.interval = Math.max(900, ms); + restoreDelay.restart(); + } + + Timer { + id: restorePoll + interval: 150 + repeat: true + onTriggered: { + if (Capture.selecting !== "") { + win.sawSelecting = true; + return; + } + if (!win.sawSelecting) + return; + restorePoll.stop(); + win.restoreAfter(Capture.delay * 1000 + 700); + } + } + + Timer { + id: restoreDelay + onTriggered: win.suppressed = false + } + + Component { + id: captureComp + CaptureCard { s: win.us; host: win; cardId: "capture" } + } + Component { + id: performanceComp + PerformanceCard { s: win.us; host: win; cardId: "performance"; monitorName: win.mon } + } + Component { + id: resourcesComp + ResourcesCard { s: win.us; host: win; cardId: "resources" } + } + Component { + id: tasksComp + TasksCard { s: win.us; host: win; cardId: "tasks" } + } + Component { + id: mediaComp + MediaCard { s: win.us; host: win; cardId: "media" } + } + Component { + id: voiceComp + VoiceCard { s: win.us; host: win; cardId: "voice"; minimalWhenPinned: win.minimalCard("voice") } + } + Component { + id: audioComp + AudioCard { s: win.us; host: win; cardId: "audio" } + } + Component { + id: galleryComp + GalleryCard { s: win.us; host: win; cardId: "gallery" } + } +} diff --git a/ryoku/shell/quickshell/shell/modules/gamebar/GameBarAction.qml b/ryoku/shell/quickshell/shell/modules/gamebar/GameBarAction.qml new file mode 100644 index 0000000000..1a4cb82a46 --- /dev/null +++ b/ryoku/shell/quickshell/shell/modules/gamebar/GameBarAction.qml @@ -0,0 +1,59 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import "../../components" +import "look" + +// A tile action in the capture grid: a 24px glyph over a 13px label on a raised +// 12px surface, the same fill the overlay's controls use. +Item { + id: root + + property real s: 1 + property string icon: "" + property string label: "" + property bool available: true + + signal clicked() + + implicitWidth: 68 * root.s + implicitHeight: 62 * root.s + + Rectangle { + anchors.fill: parent + radius: GameBarLook.radiusSmall * root.s + color: pointer.containsMouse && root.available ? GameBarLook.raisedHover : GameBarLook.raised + opacity: root.available ? 1 : 0.4 + + Column { + anchors.centerIn: parent + spacing: 6 * root.s + + GlyphIcon { + anchors.horizontalCenter: parent.horizontalCenter + width: 24 * root.s + height: 24 * root.s + name: root.icon + color: GameBarLook.fg + stroke: 1.6 + } + + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: root.label + color: GameBarLook.onLayer + font.family: GameBarLook.face + font.pixelSize: GameBarLook.smallie * root.s + } + } + } + + MouseArea { + id: pointer + anchors.fill: parent + hoverEnabled: true + enabled: root.available + cursorShape: Qt.PointingHandCursor + onClicked: root.clicked() + } +} diff --git a/ryoku/shell/quickshell/shell/modules/gamebar/GameBarCard.qml b/ryoku/shell/quickshell/shell/modules/gamebar/GameBarCard.qml new file mode 100644 index 0000000000..f8f3d9022c --- /dev/null +++ b/ryoku/shell/quickshell/shell/modules/gamebar/GameBarCard.qml @@ -0,0 +1,197 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import shell.services +import "../../components" +import "look" + +// end-4's StyledOverlayWidget: an 8px grab margin, an 18px panel with a 1px +// outline, a 30px title bar and a 12px content area. The widget drags from its +// surface; the body is the default property. +Item { + id: root + + property real s: 1 + property string title: "" + property string cardId: "" + property var host: null + property bool resizable: false + property bool minimalWhenPinned: false + + readonly property bool pinned: root.cardId.length > 0 && GameBarStore.isPinned(root.cardId) + readonly property bool minimal: root.minimalWhenPinned && root.pinned && !GameBarStore.open + readonly property string iconGlyph: root.host ? root.host.glyphFor(root.cardId) : "" + + default property alias body: bodyArea.data + + readonly property real grab: root.minimal ? 0 : 8 * root.s + readonly property real pad: 6 * root.s + readonly property real barHeight: 30 * root.s + readonly property real grip: 18 * root.s + + property string dragMode: "" + + DragHandler { + id: drag + target: null + acceptedButtons: Qt.LeftButton + // Qt's default start distance reads as a dead beat before the card + // answers; three pixels still keeps a button click a click. + dragThreshold: 3 + onActiveChanged: { + if (!root.host) + return; + if (active) { + const p = drag.centroid.pressPosition; + const edge = root.grab + root.grip; + root.dragMode = root.resizable && !root.minimal + && p.x >= root.width - edge && p.y >= root.height - edge + ? "resize" : "move"; + if (root.dragMode === "resize") { + root.host.beginResize(root.cardId); + root.host.resizeBy(root.cardId, drag.translation.x, drag.translation.y); + } else { + root.host.beginDrag(root.cardId); + // The threshold distance is already behind the pointer, so + // take it now instead of waiting for the next move. + root.host.dragBy(root.cardId, drag.translation.x, drag.translation.y); + } + } else { + if (root.dragMode === "resize") + root.host.endResize(root.cardId); + else if (root.dragMode === "move") + root.host.endDrag(root.cardId); + root.dragMode = ""; + } + } + onTranslationChanged: if (active && root.host) { + if (root.dragMode === "resize") + root.host.resizeBy(root.cardId, drag.translation.x, drag.translation.y); + else if (root.dragMode === "move") + root.host.dragBy(root.cardId, drag.translation.x, drag.translation.y); + } + } + + Rectangle { + id: frame + anchors.fill: parent + anchors.margins: root.grab + radius: root.minimal ? 0 : GameBarLook.radiusWindow * root.s + color: root.minimal ? "transparent" : GameBarLook.panel + border.width: root.minimal ? 0 : 1 + border.color: GameBarLook.border + + Item { + id: titleBar + visible: !root.minimal + x: root.pad + y: root.pad + width: parent.width - root.pad * 2 + height: root.barHeight + + Row { + id: titleRow + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + spacing: 0 + + Item { + anchors.verticalCenter: parent.verticalCenter + width: 20 * root.s + height: 20 * root.s + + MaterialIcon { + anchors.fill: parent + visible: root.iconGlyph.length === 0 + text: root.host ? root.host.symbolFor(root.cardId) : "" + color: GameBarLook.fg + font.pixelSize: 20 * root.s + } + + GlyphIcon { + anchors.fill: parent + visible: root.iconGlyph.length > 0 + name: root.iconGlyph + color: GameBarLook.fg + stroke: 1.6 + } + } + + Text { + anchors.verticalCenter: parent.verticalCenter + leftPadding: 4 * root.s + text: root.title + color: GameBarLook.fg + font.family: GameBarLook.face + font.pixelSize: GameBarLook.small * root.s + elide: Text.ElideRight + } + } + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 2 * root.s + + GameBarIconButton { + s: root.s + glyph: "pin" + toggled: root.pinned + onClicked: if (root.cardId.length > 0) GameBarStore.togglePinned(root.cardId) + } + + GameBarIconButton { + s: root.s + glyph: "close" + onClicked: if (root.cardId.length > 0) GameBarStore.closeCard(root.cardId) + } + } + } + + Item { + id: content + x: root.minimal ? 0 : root.pad + y: root.minimal ? 0 : titleBar.y + titleBar.height + 1 + width: parent.width - (root.minimal ? 0 : root.pad * 2) + height: parent.height - y - (root.minimal ? 0 : root.pad) + + Rectangle { + id: contentFill + anchors.fill: parent + radius: root.minimal ? 0 : GameBarLook.radiusSmall * root.s + color: root.minimal ? "transparent" : GameBarLook.content + } + + Item { + id: bodyArea + anchors.fill: parent + anchors.margins: root.minimal ? 0 : 8 * root.s + } + } + + Item { + id: gripArea + visible: root.resizable && !root.minimal + width: root.grip + height: root.grip + x: frame.width - width + y: frame.height - height + opacity: gripPointer.containsMouse || root.dragMode === "resize" ? 0.9 : 0.4 + + GlyphIcon { + anchors.fill: parent + name: "grip" + color: GameBarLook.onLayer + stroke: 1.6 + } + + MouseArea { + id: gripPointer + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + cursorShape: Qt.SizeFDiagCursor + } + } + } +} diff --git a/ryoku/shell/quickshell/shell/modules/gamebar/GameBarDataRow.qml b/ryoku/shell/quickshell/shell/modules/gamebar/GameBarDataRow.qml new file mode 100644 index 0000000000..cde39c2012 --- /dev/null +++ b/ryoku/shell/quickshell/shell/modules/gamebar/GameBarDataRow.qml @@ -0,0 +1,39 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import "look" + +// end-4's StyledText pair: a subtext label on the left, the value in the body +// size on the right. +Item { + id: root + + property real s: 1 + property string label: "" + property string value: "" + + implicitHeight: 24 * root.s + + Text { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + text: root.label + color: GameBarLook.subtext + font.family: GameBarLook.face + font.pixelSize: GameBarLook.smallie * root.s + elide: Text.ElideRight + width: Math.min(implicitWidth, root.width * 0.58) + } + + Text { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: root.value + color: GameBarLook.fg + font.family: GameBarLook.face + font.pixelSize: GameBarLook.small * root.s + elide: Text.ElideRight + width: Math.min(implicitWidth, root.width * 0.55) + horizontalAlignment: Text.AlignRight + } +} diff --git a/ryoku/shell/quickshell/shell/modules/gamebar/GameBarIconButton.qml b/ryoku/shell/quickshell/shell/modules/gamebar/GameBarIconButton.qml new file mode 100644 index 0000000000..5a55245260 --- /dev/null +++ b/ryoku/shell/quickshell/shell/modules/gamebar/GameBarIconButton.qml @@ -0,0 +1,50 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import "../../components" +import "look" + +// end-4's TitlebarButton: a 30px circle holding a 20px glyph, filled with the +// secondary container while its state is on, a layer wash under the pointer. +// No red hover: the overlay's close button is an ordinary button. +Item { + id: root + + property real s: 1 + property real size: 30 + property string glyph: "" + property bool toggled: false + + signal clicked() + + implicitWidth: root.size * root.s + implicitHeight: root.size * root.s + + Rectangle { + anchors.fill: parent + radius: height / 2 + color: root.toggled ? GameBarLook.toggled + : (pointer.containsMouse ? GameBarLook.hover : "transparent") + + Behavior on color { + ColorAnimation { duration: 120 } + } + } + + GlyphIcon { + anchors.centerIn: parent + width: 20 * root.s + height: 20 * root.s + name: root.glyph + color: root.toggled ? GameBarLook.toggledInk : GameBarLook.fg + stroke: 1.7 + } + + MouseArea { + id: pointer + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.clicked() + } +} diff --git a/ryoku/shell/quickshell/shell/modules/gamebar/GameBarMeter.qml b/ryoku/shell/quickshell/shell/modules/gamebar/GameBarMeter.qml new file mode 100644 index 0000000000..ef8f241eb9 --- /dev/null +++ b/ryoku/shell/quickshell/shell/modules/gamebar/GameBarMeter.qml @@ -0,0 +1,54 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import "look" + +// A label and a value over a 4px track, in the overlay's layer colours. +Item { + id: root + + property real s: 1 + property string label: "" + property string value: "" + property real fraction: 0 + property color tint: GameBarLook.accent + + implicitHeight: 30 * root.s + + readonly property real amount: Math.max(0, Math.min(1, root.fraction)) + + Text { + anchors.left: parent.left + anchors.top: parent.top + text: root.label + color: GameBarLook.subtext + font.family: GameBarLook.face + font.pixelSize: GameBarLook.smallie * root.s + } + + Text { + anchors.right: parent.right + anchors.top: parent.top + text: root.value + color: GameBarLook.fg + font.family: GameBarLook.face + font.pixelSize: GameBarLook.small * root.s + } + + Rectangle { + id: track + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: 4 * root.s + radius: height / 2 + color: GameBarLook.raised + + Rectangle { + width: track.width * root.amount + height: parent.height + radius: parent.radius + color: root.tint + } + } +} diff --git a/ryoku/shell/quickshell/shell/modules/gamebar/GameBarSpark.qml b/ryoku/shell/quickshell/shell/modules/gamebar/GameBarSpark.qml new file mode 100644 index 0000000000..c9a63f8db1 --- /dev/null +++ b/ryoku/shell/quickshell/shell/modules/gamebar/GameBarSpark.qml @@ -0,0 +1,54 @@ +import QtQuick +import QtQuick.Shapes +import "look" + +// end-4's Graph: a 2px line filled to half opacity underneath, newest sample +// at the right edge. Values are fractions, so a percentage history fills from +// the floor instead of stretching to its own peak. +Item { + id: root + + property var values: [] + property color color: GameBarLook.accent + + readonly property var samples: { + const source = root.values || []; + const result = []; + for (let i = 0; i < source.length; i++) + result.push(Math.max(0, Math.min(1, Number(source[i]) || 0))); + return result; + } + + readonly property var points: { + const list = root.samples; + if (list.length === 0) + return []; + if (list.length === 1) + return [Qt.point(0, (1 - list[0]) * height), Qt.point(width, (1 - list[0]) * height)]; + return list.map((v, i) => Qt.point(width * i / (list.length - 1), (1 - v) * height)); + } + + readonly property var areaPoints: points.length > 0 + ? [Qt.point(0, height)].concat(points, [Qt.point(width, height)]) : [] + + Shape { + anchors.fill: parent + visible: root.points.length > 0 + preferredRendererType: Shape.CurveRenderer + + ShapePath { + strokeColor: "transparent" + fillColor: Qt.rgba(root.color.r, root.color.g, root.color.b, 0.5) + PathPolyline { path: root.areaPoints } + } + + ShapePath { + strokeColor: root.color + strokeWidth: 2 + fillColor: "transparent" + capStyle: ShapePath.RoundCap + joinStyle: ShapePath.RoundJoin + PathPolyline { path: root.points } + } + } +} diff --git a/ryoku/shell/quickshell/shell/modules/gamebar/GameBarStrip.qml b/ryoku/shell/quickshell/shell/modules/gamebar/GameBarStrip.qml new file mode 100644 index 0000000000..19a21c865d --- /dev/null +++ b/ryoku/shell/quickshell/shell/modules/gamebar/GameBarStrip.qml @@ -0,0 +1,202 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import shell.services +import "../../components" +import "look" + +// end-4's OverlayTaskbar: widget buttons, a separator, the clock and the +// battery. A button shows its widget, a second press hides it. +Rectangle { + id: root + + property real s: 1 + property var cards: [] + property var activeIds: [] + property bool snapping: true + + signal picked(string id) + signal snapToggled() + + readonly property real padding: 8 * root.s + readonly property real button: 32 * root.s + + implicitWidth: row.implicitWidth + root.padding * 2 + implicitHeight: root.button + root.padding * 2 + radius: GameBarLook.radiusWindow * root.s + color: GameBarLook.bar + border.width: 1 + border.color: GameBarLook.border + + property string clock: "" + + function tick() { + const d = new Date(); + root.clock = Qt.formatTime(d, "HH:mm"); + } + + Component.onCompleted: root.tick() + + Timer { + interval: 10000 + running: root.visible && root.enabled + repeat: true + onTriggered: root.tick() + } + + onVisibleChanged: if (root.visible) root.tick() + onEnabledChanged: if (root.enabled) root.tick() + + Row { + id: row + anchors.fill: parent + anchors.margins: root.padding + spacing: 6 * root.s + + Row { + spacing: 4 * root.s + anchors.verticalCenter: parent.verticalCenter + + Repeater { + model: root.cards + + delegate: Item { + id: button + required property var modelData + + readonly property bool on: root.activeIds.indexOf(modelData.id) !== -1 + + width: root.button + height: root.button + + Rectangle { + anchors.fill: parent + radius: Math.max(0, (GameBarLook.radiusNormal - 8) * root.s) + color: button.on ? GameBarLook.toggled + : (pointer.containsMouse ? GameBarLook.hover : "transparent") + + Behavior on color { + ColorAnimation { duration: 120 } + } + } + + Item { + anchors.centerIn: parent + width: root.button + height: root.button + + MaterialIcon { + anchors.fill: parent + visible: !button.modelData.glyph + text: button.modelData.symbol + color: GameBarLook.onLayer + font.pixelSize: 21 * root.s + } + + GlyphIcon { + anchors.centerIn: parent + width: 20 * root.s + height: 20 * root.s + visible: !!button.modelData.glyph + name: button.modelData.glyph || "" + color: GameBarLook.onLayer + stroke: 1.6 + } + } + + MouseArea { + id: pointer + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.picked(button.modelData.id) + } + } + } + } + + Rectangle { + width: 1 + height: root.button - 20 * root.s + anchors.verticalCenter: parent.verticalCenter + color: GameBarLook.border + } + + Item { + id: snapButton + width: root.button + height: root.button + anchors.verticalCenter: parent.verticalCenter + + Rectangle { + anchors.fill: parent + radius: GameBarLook.radiusNormal * root.s + color: root.snapping ? GameBarLook.toggled + : (snapPointer.containsMouse ? GameBarLook.hover : "transparent") + + Behavior on color { + ColorAnimation { duration: 120 } + } + } + + MaterialIcon { + anchors.centerIn: parent + width: root.button + height: root.button + text: "grid_view" + color: GameBarLook.onLayer + font.pixelSize: 21 * root.s + } + + MouseArea { + id: snapPointer + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.snapToggled() + } + } + + Text { + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: 8 * root.s + anchors.rightMargin: 6 * root.s + text: root.clock + color: GameBarLook.fg + font.family: GameBarLook.face + font.pixelSize: GameBarLook.huge * root.s + } + + Rectangle { + visible: Battery.present + width: 1 + height: root.button - 20 * root.s + anchors.verticalCenter: parent.verticalCenter + color: GameBarLook.border + } + + Row { + visible: Battery.present + anchors.verticalCenter: parent.verticalCenter + spacing: 2 * root.s + + GlyphIcon { + anchors.verticalCenter: parent.verticalCenter + width: 24 * root.s + height: 24 * root.s + name: "battery" + color: Battery.low && !Battery.charging ? GameBarLook.danger : GameBarLook.fg + stroke: 1.7 + } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: Battery.pct + "%" + color: Battery.low && !Battery.charging ? GameBarLook.danger : GameBarLook.fg + font.family: GameBarLook.face + font.pixelSize: 18 * root.s + } + } + } +} diff --git a/ryoku/shell/quickshell/shell/modules/gamebar/GameBarTabs.qml b/ryoku/shell/quickshell/shell/modules/gamebar/GameBarTabs.qml new file mode 100644 index 0000000000..6598fce5c1 --- /dev/null +++ b/ryoku/shell/quickshell/shell/modules/gamebar/GameBarTabs.qml @@ -0,0 +1,98 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import "../../components" +import "look" + +// end-4's SecondaryTabBar: a 42px row of icon and label tabs over a 1px bottom +// border, with a 3px primary indicator tucked under the active one. +Item { + id: root + + property real s: 1 + property var model: [] + property int currentIndex: 0 + + signal picked(int index) + + implicitHeight: 42 * root.s + + Row { + id: tabs + anchors.fill: parent + anchors.bottomMargin: 1 * root.s + + Repeater { + model: root.model + + delegate: Item { + id: tab + required property var modelData + required property int index + + readonly property bool current: root.currentIndex === index + readonly property real cellWidth: root.width / Math.max(1, root.model.length) + + width: cellWidth + height: parent.height + + Rectangle { + anchors.fill: parent + anchors.margins: 3 * root.s + radius: GameBarLook.radiusNormal * root.s + color: pointer.containsMouse && !tab.current ? GameBarLook.hover : "transparent" + } + + Row { + anchors.centerIn: parent + spacing: 5 * root.s + + GlyphIcon { + anchors.verticalCenter: parent.verticalCenter + width: 22 * root.s + height: 22 * root.s + name: tab.modelData.icon + color: tab.current ? GameBarLook.accent : GameBarLook.onLayer + stroke: 1.7 + } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: tab.modelData.label + color: tab.current ? GameBarLook.accent : GameBarLook.onLayer + font.family: GameBarLook.face + font.pixelSize: GameBarLook.small * root.s + } + } + + MouseArea { + id: pointer + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.picked(tab.index) + } + } + } + } + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: 1 + color: GameBarLook.border + } + + Rectangle { + readonly property real baseWidth: root.width / Math.max(1, root.model.length) + readonly property real padding: 8 * root.s + x: root.currentIndex * baseWidth + padding + width: Math.max(0, baseWidth - padding * 2) + anchors.bottom: parent.bottom + height: 3 * root.s + topLeftRadius: height + topRightRadius: height + color: GameBarLook.accent + } +} diff --git a/ryoku/shell/quickshell/shell/modules/gamebar/GameBarTaskRow.qml b/ryoku/shell/quickshell/shell/modules/gamebar/GameBarTaskRow.qml new file mode 100644 index 0000000000..c1f29071eb --- /dev/null +++ b/ryoku/shell/quickshell/shell/modules/gamebar/GameBarTaskRow.qml @@ -0,0 +1,111 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell.Io +import "../../components" +import Ryoku.Ui.Singletons +import "look" +import "../../utils/units.js" as Units + +// One process line. End arms on the first press and only fires on the second, +// so a stray click cannot SIGTERM anything; processes the desktop needs never +// offer the control at all. +Item { + id: root + + property real s: 1 + property var task: null + property bool protectedName: false + + signal endRequested(int pid) + + property bool confirming: false + + implicitHeight: 32 * root.s + implicitWidth: 276 * root.s + + onTaskChanged: confirming = false + + Text { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: Math.max(0, actions.x - 12 * root.s) + text: root.task ? root.task.name : "" + color: GameBarLook.fg + font.family: GameBarLook.face + font.pixelSize: GameBarLook.small * root.s + elide: Text.ElideRight + } + + Text { + id: cpu + anchors.right: mem.left + anchors.rightMargin: 10 * root.s + anchors.verticalCenter: parent.verticalCenter + width: 40 * root.s + text: root.task ? Units.pct(root.task.cpu / 100) : "" + color: GameBarLook.subtext + font.family: GameBarLook.face + font.pixelSize: GameBarLook.smallie * root.s + horizontalAlignment: Text.AlignRight + } + + Text { + id: mem + anchors.right: actions.left + anchors.rightMargin: 10 * root.s + anchors.verticalCenter: parent.verticalCenter + width: 54 * root.s + text: root.task ? Math.round(root.task.memMiB) + " MB" : "" + color: GameBarLook.subtext + font.family: GameBarLook.face + font.pixelSize: GameBarLook.smallie * root.s + horizontalAlignment: Text.AlignRight + } + + Row { + id: actions + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 4 * root.s + + Text { + visible: root.protectedName && !root.confirming + anchors.verticalCenter: parent.verticalCenter + text: I18n.tr("core") + color: GameBarLook.subtext + font.family: GameBarLook.face + font.pixelSize: GameBarLook.smallie * root.s + } + + GameBarTextButton { + visible: !root.protectedName && !root.confirming + s: root.s + compact: true + label: I18n.tr("End") + onClicked: root.confirming = true + } + + GameBarTextButton { + visible: root.confirming + s: root.s + compact: true + label: I18n.tr("Stop") + danger: true + onClicked: { + if (root.task) + root.endRequested(root.task.pid); + root.confirming = false; + } + } + + GameBarTextButton { + visible: root.confirming + s: root.s + compact: true + quiet: true + label: I18n.tr("No") + onClicked: root.confirming = false + } + } +} diff --git a/ryoku/shell/quickshell/shell/modules/gamebar/GameBarTextButton.qml b/ryoku/shell/quickshell/shell/modules/gamebar/GameBarTextButton.qml new file mode 100644 index 0000000000..60cd6dbeb1 --- /dev/null +++ b/ryoku/shell/quickshell/shell/modules/gamebar/GameBarTextButton.qml @@ -0,0 +1,60 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import "look" + +// end-4's WButton at overlay size: a 12px radius, 40px tall, one raised fill, +// a layer wash under the pointer. The primary and danger forms take the accent +// and error roles whole. +Item { + id: root + + property real s: 1 + property string label: "" + property bool primary: false + property bool danger: false + property bool quiet: false + property bool compact: false + + signal clicked() + + implicitWidth: text.implicitWidth + (root.compact ? 20 : 30) * root.s + implicitHeight: (root.compact ? 32 : 40) * root.s + + Rectangle { + anchors.fill: parent + radius: GameBarLook.radiusSmall * root.s + color: { + if (root.primary) + return pointer.containsMouse ? Qt.lighter(GameBarLook.accent, 1.1) : GameBarLook.accent; + if (root.danger) + return pointer.containsMouse ? Qt.lighter(GameBarLook.danger, 1.1) : GameBarLook.danger; + if (root.quiet) + return pointer.containsMouse ? GameBarLook.hover : "transparent"; + return pointer.containsMouse ? GameBarLook.raisedHover : GameBarLook.raised; + } + + Behavior on color { + ColorAnimation { duration: 120 } + } + } + + Text { + id: text + anchors.centerIn: parent + text: root.label + color: root.primary ? GameBarLook.accentInk + : root.danger ? GameBarLook.onDanger + : GameBarLook.fg + font.family: GameBarLook.face + font.pixelSize: (root.compact ? GameBarLook.smallie : GameBarLook.small) * root.s + } + + MouseArea { + id: pointer + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.clicked() + } +} diff --git a/ryoku/shell/quickshell/shell/modules/gamebar/cards/AudioCard.qml b/ryoku/shell/quickshell/shell/modules/gamebar/cards/AudioCard.qml new file mode 100644 index 0000000000..1f99097b44 --- /dev/null +++ b/ryoku/shell/quickshell/shell/modules/gamebar/cards/AudioCard.qml @@ -0,0 +1,92 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import shell.services +import ".." +import "../../../components" +import Ryoku.Ui.Singletons +import "../look" + +// Audio: the same faders and app rows the audio menu uses, so a level set here +// is the level Pipewire holds everywhere. +GameBarCard { + id: card + + title: I18n.tr("Audio") + + readonly property var sinkAudio: Audio.sink && Audio.sink.audio ? Audio.sink.audio : null + readonly property var sourceAudio: Audio.source && Audio.source.audio ? Audio.source.audio : null + readonly property var streams: Audio.streams ? Audio.streams.filter(Audio.alive) : [] + + Column { + width: parent.width + spacing: 8 * card.s + + HFader { + width: parent.width + s: card.s + icon: Audio.sink ? Audio.nodeIcon(Audio.sink) : "speaker" + lit: true + value: card.sinkAudio ? card.sinkAudio.volume : 0 + muted: card.sinkAudio ? card.sinkAudio.muted : false + valueLabel: card.sinkAudio + ? (card.sinkAudio.muted ? I18n.tr("off") : Math.round(card.sinkAudio.volume * 100) + "%") + : "n/a" + peakNode: Audio.sink + peakEnabled: !!Audio.sink + onMoved: v => { if (card.sinkAudio) card.sinkAudio.volume = v; } + onIconTapped: { if (card.sinkAudio) card.sinkAudio.muted = !card.sinkAudio.muted; } + } + + HFader { + width: parent.width + s: card.s + icon: "mic" + lit: !!card.sourceAudio + value: card.sourceAudio ? card.sourceAudio.volume : 0 + muted: card.sourceAudio ? card.sourceAudio.muted : false + valueLabel: card.sourceAudio + ? (card.sourceAudio.muted ? I18n.tr("off") : Math.round(card.sourceAudio.volume * 100) + "%") + : "n/a" + peakNode: Audio.source + peakEnabled: !!Audio.source + onMoved: v => { if (card.sourceAudio) card.sourceAudio.volume = v; } + onIconTapped: { if (card.sourceAudio) card.sourceAudio.muted = !card.sourceAudio.muted; } + } + + Rectangle { + width: parent.width + height: 1 + color: GameBarLook.border + } + + Text { + text: I18n.tr("Apps") + color: GameBarLook.subtext + font.family: GameBarLook.face + font.pixelSize: GameBarLook.smallie * card.s + } + + Repeater { + model: card.streams.slice(0, 3) + + delegate: AudioAppRow { + required property var modelData + width: parent.width + s: card.s + stream: modelData + open: true + } + } + + Text { + visible: card.streams.length === 0 + width: parent.width + text: I18n.tr("Nothing playing") + color: GameBarLook.subtext + horizontalAlignment: Text.AlignHCenter + font.family: GameBarLook.face + font.pixelSize: GameBarLook.small * card.s + } + } +} diff --git a/ryoku/shell/quickshell/shell/modules/gamebar/cards/CaptureCard.qml b/ryoku/shell/quickshell/shell/modules/gamebar/cards/CaptureCard.qml new file mode 100644 index 0000000000..61760d9ee7 --- /dev/null +++ b/ryoku/shell/quickshell/shell/modules/gamebar/cards/CaptureCard.qml @@ -0,0 +1,100 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import shell.services +import ".." +import Ryoku.Ui.Singletons +import "../look" + +// Capture: the four shot targets and the screen recorder, each one hiding the +// Game Bar first so the overlay is never in its own shot. The recorder is the +// existing Recorder singleton, not a second capture path. +GameBarCard { + id: card + + title: I18n.tr("Capture") + + Column { + width: parent.width + spacing: 12 * card.s + + Row { + width: parent.width + spacing: 14 * card.s + + Grid { + id: targets + columns: 2 + spacing: 8 * card.s + + GameBarAction { + s: card.s + icon: "region" + label: I18n.tr("Region") + onClicked: if (card.host) card.host.shoot("region") + } + GameBarAction { + s: card.s + icon: "window" + label: I18n.tr("Window") + onClicked: if (card.host) card.host.shoot("window") + } + GameBarAction { + s: card.s + icon: "monitor" + label: I18n.tr("Screen") + onClicked: if (card.host) card.host.shoot("monitor") + } + GameBarAction { + s: card.s + icon: "screens" + label: I18n.tr("All") + onClicked: if (card.host) card.host.shoot("all") + } + } + + Column { + width: parent.width - targets.width - 14 * card.s + spacing: 4 * card.s + + Text { + text: I18n.tr("Recording") + color: GameBarLook.subtext + font.family: GameBarLook.face + font.pixelSize: GameBarLook.smallie * card.s + } + + Text { + text: Recorder.anyActive ? Recorder.elapsedText : I18n.tr("Idle") + color: Recorder.anyActive ? GameBarLook.accent : GameBarLook.fg + font.family: GameBarLook.face + font.pixelSize: GameBarLook.huge * card.s + } + + GameBarTextButton { + s: card.s + label: Recorder.anyActive ? I18n.tr("Stop") : I18n.tr("Record") + primary: !Recorder.anyActive + danger: Recorder.anyActive + onClicked: { + if (!card.host) + return; + if (Recorder.anyActive) + Recorder.stop(); + else + card.host.startRecording(); + } + } + } + } + + Text { + width: parent.width + text: I18n.tr("The bar hides while it captures.") + color: GameBarLook.subtext + font.family: GameBarLook.face + font.pixelSize: GameBarLook.smallie * card.s + wrapMode: Text.WordWrap + } + } +} diff --git a/ryoku/shell/quickshell/shell/modules/gamebar/cards/GalleryCard.qml b/ryoku/shell/quickshell/shell/modules/gamebar/cards/GalleryCard.qml new file mode 100644 index 0000000000..6e9ed69aed --- /dev/null +++ b/ryoku/shell/quickshell/shell/modules/gamebar/cards/GalleryCard.qml @@ -0,0 +1,142 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Qt.labs.folderlistmodel +import shell.services +import ".." +import Ryoku.Ui.Singletons +import "../look" + +// Gallery: the newest screenshots, live from the folder the capture path +// writes, plus one press to open either capture folder. Thumbnails are the +// files themselves, so there is no separate index to fall out of date. +GameBarCard { + id: card + + title: I18n.tr("Gallery") + + readonly property string shotsDir: Capture.shotsDir + readonly property string clipsDir: Paths.recordingsDir + + function newest(model, count) { + const all = []; + for (let i = 0; i < model.count; i++) { + const url = model.get(i, "fileUrl"); + if (!url) + continue; + const stamp = model.get(i, "fileModified"); + const when = stamp ? (typeof stamp.getTime === "function" + ? stamp.getTime() : Date.parse(stamp) || 0) : 0; + all.push({ + url: "" + url, + path: "" + model.get(i, "filePath"), + t: when + }); + } + all.sort((a, b) => b.t - a.t); + return all.slice(0, count); + } + + readonly property var shots: card.newest(shotFiles, 6) + + FolderListModel { + id: shotFiles + folder: "file://" + card.shotsDir + showDirs: false + nameFilters: ["*.png", "*.jpg", "*.jpeg", "*.webp"] + } + + Column { + width: parent.width + spacing: 8 * card.s + + Item { + width: parent.width + height: 24 * card.s + + Text { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + text: card.shots.length === 1 + ? I18n.tr("1 recent shot") : card.shots.length + " " + I18n.tr("recent shots") + color: GameBarLook.subtext + font.family: GameBarLook.face + font.pixelSize: GameBarLook.smallie * card.s + } + + GameBarTextButton { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + s: card.s + compact: true + label: I18n.tr("Folder") + onClicked: Spawn.run(["xdg-open", card.shotsDir]) + } + } + + Grid { + id: grid + width: parent.width + columns: 3 + spacing: 6 * card.s + + Repeater { + model: card.shots + + delegate: Item { + id: thumb + required property var modelData + + width: (grid.width - (grid.columns - 1) * grid.spacing) / grid.columns + height: 62 * card.s + + Rectangle { + anchors.fill: parent + radius: GameBarLook.radiusVerySmall * card.s + color: GameBarLook.raised + clip: true + + Image { + anchors.fill: parent + source: thumb.modelData.url + asynchronous: true + fillMode: Image.PreserveAspectCrop + } + MouseArea { + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: Spawn.run(["xdg-open", thumb.modelData.path]) + } + } + } + } + } + + Text { + visible: card.shots.length === 0 + width: parent.width + text: I18n.tr("No screenshots yet") + color: GameBarLook.subtext + horizontalAlignment: Text.AlignHCenter + font.family: GameBarLook.face + font.pixelSize: GameBarLook.small * card.s + } + + Row { + width: parent.width + spacing: 8 * card.s + + GameBarTextButton { + s: card.s + label: I18n.tr("Screenshots") + onClicked: Spawn.run(["xdg-open", card.shotsDir]) + } + GameBarTextButton { + s: card.s + label: I18n.tr("Recordings") + onClicked: Spawn.run(["xdg-open", card.clipsDir]) + } + } + } +} diff --git a/ryoku/shell/quickshell/shell/modules/gamebar/cards/MediaCard.qml b/ryoku/shell/quickshell/shell/modules/gamebar/cards/MediaCard.qml new file mode 100644 index 0000000000..6c3e4d4280 --- /dev/null +++ b/ryoku/shell/quickshell/shell/modules/gamebar/cards/MediaCard.qml @@ -0,0 +1,140 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import shell.services +import ".." +import "../../../components" +import Ryoku.Ui.Singletons +import "../look" + +// Media: the one now-playing pick the whole shell shares, with transport. The +// controls are 40px borderless buttons and the art sits on a 12px tile, as in +// the overlay's media pane. +GameBarCard { + id: card + + title: I18n.tr("Media") + + readonly property var player: Media.player + readonly property bool present: Media.present + readonly property string art: card.player && card.player.artUrl ? String(card.player.artUrl) : "" + readonly property string artist: card.player + ? Theme.joinArtists(card.player.trackArtists, card.player.trackArtist) : "" + + component Transport: Item { + id: button + property string glyph: "" + property bool available: true + + width: 40 * card.s + height: 40 * card.s + opacity: button.available ? 1 : 0.35 + + signal tapped() + + Rectangle { + anchors.fill: parent + radius: height / 2 + color: transportHover.containsMouse ? GameBarLook.hover : "transparent" + } + GlyphIcon { + anchors.centerIn: parent + width: 18 * card.s + height: 18 * card.s + name: button.glyph + color: GameBarLook.fg + stroke: 1.7 + } + MouseArea { + id: transportHover + anchors.fill: parent + hoverEnabled: true + enabled: button.available + cursorShape: Qt.PointingHandCursor + onClicked: button.tapped() + } + } + + Column { + width: parent.width + spacing: 12 * card.s + + Row { + width: parent.width + spacing: 14 * card.s + + Rectangle { + width: 104 * card.s + height: 104 * card.s + radius: GameBarLook.radiusSmall * card.s + color: GameBarLook.raised + clip: true + + Image { + anchors.fill: parent + source: card.art + asynchronous: true + fillMode: Image.PreserveAspectCrop + visible: card.art.length > 0 + } + GlyphIcon { + anchors.centerIn: parent + width: 32 * card.s + height: 32 * card.s + name: "music" + color: GameBarLook.onLayer + visible: card.art.length === 0 + stroke: 1.6 + } + } + + Column { + width: parent.width - 104 * card.s - 14 * card.s + spacing: 4 * card.s + anchors.verticalCenter: parent.verticalCenter + + Text { + width: parent.width + text: card.present ? card.player.trackTitle : I18n.tr("Nothing playing") + color: GameBarLook.fg + font.family: GameBarLook.face + font.pixelSize: GameBarLook.small * card.s + elide: Text.ElideRight + maximumLineCount: 2 + wrapMode: Text.WordWrap + } + Text { + width: parent.width + text: card.present + ? (card.artist.length > 0 ? card.artist : I18n.tr("Unknown artist")) + : I18n.tr("Start a track in any player") + color: GameBarLook.subtext + font.family: GameBarLook.face + font.pixelSize: GameBarLook.smallie * card.s + elide: Text.ElideRight + } + } + } + + Row { + anchors.horizontalCenter: parent.horizontalCenter + spacing: 8 * card.s + + Transport { + glyph: "prev" + available: card.present && card.player.canGoPrevious + onTapped: if (card.player) card.player.previous() + } + Transport { + glyph: Media.playing ? "pause" : "play" + available: card.present + onTapped: Media.toggle() + } + Transport { + glyph: "next" + available: card.present && card.player.canGoNext + onTapped: if (card.player) card.player.next() + } + } + } +} diff --git a/ryoku/shell/quickshell/shell/modules/gamebar/cards/PerformanceCard.qml b/ryoku/shell/quickshell/shell/modules/gamebar/cards/PerformanceCard.qml new file mode 100644 index 0000000000..975d145540 --- /dev/null +++ b/ryoku/shell/quickshell/shell/modules/gamebar/cards/PerformanceCard.qml @@ -0,0 +1,138 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell.Io +import shell.services +import ".." +import Ryoku.Ui.Singletons +import "../look" +import "../../../utils/units.js" as Units + +// Performance: the machine's headroom while a game runs, read from the one +// sysinfo sampler. The rate here is the display's compositor mode, never an +// in-game frame counter; Ryoku does not read one, so it does not claim one. +GameBarCard { + id: card + + property string monitorName: "" + property string displayMode: "" + + title: I18n.tr("Performance") + + readonly property var mon: Sysinfo.monitor + readonly property int hz: Units.hz(card.displayMode) + readonly property string resolution: { + const base = card.displayMode.split("@")[0]; + if (base.length > 0) + return base.replace("x", " x "); + const out = Wm.outputByName(card.monitorName); + return out ? out.width + " x " + out.height : "n/a"; + } + + Component.onCompleted: { + Sysinfo.setActive(card, true); + StatsFeed.setActive(card, true); + modeRead.running = true; + } + Component.onDestruction: { + Sysinfo.setActive(card, false); + StatsFeed.setActive(card, false); + } + + // The watch frames carry no mode, so the full state read is the one place + // the current mode is reported. One read per open, not a poll. + Process { + id: modeRead + command: ["ryoku", "wm", "state"] + stdout: StdioCollector { + onStreamFinished: { + try { + const doc = JSON.parse(text); + const list = doc.outputs || []; + for (let i = 0; i < list.length; i++) { + if (list[i].name === card.monitorName && list[i].mode) { + card.displayMode = String(list[i].mode); + return; + } + } + } catch (e) { + card.displayMode = ""; + } + } + } + } + + Column { + width: parent.width + spacing: 3 * card.s + + GameBarDataRow { + s: card.s + width: parent.width + label: I18n.tr("Display") + value: card.resolution + } + GameBarDataRow { + s: card.s + width: parent.width + label: I18n.tr("Refresh") + value: card.hz > 0 ? card.hz + " Hz" : "n/a" + } + GameBarDataRow { + s: card.s + width: parent.width + label: I18n.tr("Temperature") + value: card.mon.gpuTempAvailable ? Math.round(card.mon.gpuTemp) + " C" + : (card.mon.cpuTempAvailable ? Math.round(card.mon.cpuTemp) + " C" : "n/a") + } + + GameBarMeter { + s: card.s + width: parent.width + label: I18n.tr("CPU") + value: Units.pct(card.mon.cpuPercent / 100) + fraction: card.mon.cpuPercent / 100 + } + GameBarMeter { + s: card.s + width: parent.width + label: I18n.tr("GPU") + value: card.mon.gpuAvailable ? Units.pct(card.mon.gpuPercent / 100) : "n/a" + fraction: card.mon.gpuAvailable ? card.mon.gpuPercent / 100 : 0 + tint: Theme.tertiary + } + GameBarMeter { + s: card.s + width: parent.width + label: I18n.tr("Memory") + value: Units.gib(card.mon.memoryUsedGiB) + " / " + Units.gib(card.mon.memoryTotalGiB) + fraction: card.mon.memoryPercent / 100 + } + GameBarMeter { + s: card.s + width: parent.width + label: I18n.tr("VRAM") + value: card.mon.gpuMemoryAvailable + ? Units.gib(card.mon.gpuMemoryUsedGiB) + " / " + Units.gib(card.mon.gpuMemoryTotalGiB) + : "n/a" + fraction: card.mon.gpuMemoryAvailable && card.mon.gpuMemoryTotalGiB > 0 + ? card.mon.gpuMemoryUsedGiB / card.mon.gpuMemoryTotalGiB : 0 + tint: Theme.tertiary + } + + GameBarSpark { + width: parent.width + height: 34 * card.s + values: card.mon.cpuHistory + color: GameBarLook.accent + } + + Text { + width: parent.width + text: I18n.tr("Display refresh, not a game's frame rate.") + color: GameBarLook.subtext + font.family: GameBarLook.face + font.pixelSize: GameBarLook.smallie * card.s + } + } +} diff --git a/ryoku/shell/quickshell/shell/modules/gamebar/cards/ResourcesCard.qml b/ryoku/shell/quickshell/shell/modules/gamebar/cards/ResourcesCard.qml new file mode 100644 index 0000000000..26135571a4 --- /dev/null +++ b/ryoku/shell/quickshell/shell/modules/gamebar/cards/ResourcesCard.qml @@ -0,0 +1,124 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import shell.services +import ".." +import Ryoku.Ui.Singletons +import "../look" + +// Resources, cut like end-4's overlay widget: a tab per resource, the reading +// in huge numerals over "of %1", and the graph on the secondary container +// filling the rest. The sampler has no history for GPU and memory, so the card +// keeps its own short window for the two tabs it has to draw itself. +GameBarCard { + id: card + + title: I18n.tr("Resources") + + readonly property var mon: Sysinfo.monitor + + property var gpuHistory: [] + property var memHistory: [] + readonly property int tabIndex: GameBarStore.resourceTab + + readonly property var tabs: [ + { label: I18n.tr("CPU"), icon: "cpu" }, + { label: I18n.tr("GPU"), icon: "bolt" }, + { label: I18n.tr("RAM"), icon: "archive" } + ] + + readonly property real fraction: card.tabIndex === 0 ? card.mon.cpuPercent / 100 + : card.tabIndex === 1 ? (card.mon.gpuAvailable ? card.mon.gpuPercent / 100 : 0) + : card.mon.memoryPercent / 100 + + readonly property var history: card.tabIndex === 0 ? card.mon.cpuHistory + : card.tabIndex === 1 ? card.gpuHistory : card.memHistory + + readonly property string total: card.tabIndex === 0 ? "100%" + : card.tabIndex === 1 ? "100%" + : Math.round(card.mon.memoryTotalGiB) + " GiB" + + function push(list, value) { + const next = (list || []).slice(); + next.push(value); + while (next.length > 48) + next.shift(); + return next; + } + + Component.onCompleted: { + Sysinfo.setActive(card, true); + StatsFeed.setActive(card, true); + } + Component.onDestruction: { + Sysinfo.setActive(card, false); + StatsFeed.setActive(card, false); + } + + Timer { + interval: 1500 + running: true + repeat: true + onTriggered: { + card.gpuHistory = card.push(card.gpuHistory, card.mon.gpuPercent / 100); + card.memHistory = card.push(card.memHistory, card.mon.memoryPercent / 100); + } + } + + ColumnLayout { + anchors.fill: parent + spacing: 8 * card.s + + GameBarTabs { + Layout.fillWidth: true + Layout.preferredHeight: 42 * card.s + s: card.s + model: card.tabs + currentIndex: card.tabIndex + onPicked: index => GameBarStore.resourceTab = index + } + + RowLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 12 * card.s + + ColumnLayout { + spacing: 2 * card.s + + Text { + text: Math.round(card.fraction * 100) + "%" + color: GameBarLook.fg + font.family: GameBarLook.face + font.pixelSize: GameBarLook.huge * card.s + } + + Text { + text: I18n.tr("of %1").arg(card.total) + color: GameBarLook.subtext + font.family: GameBarLook.face + font.pixelSize: GameBarLook.smallie * card.s + } + + Item { + Layout.fillHeight: true + } + } + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + radius: GameBarLook.radiusSmall * card.s + color: GameBarLook.toggled + clip: true + + GameBarSpark { + anchors.fill: parent + values: card.history + color: GameBarLook.accent + } + } + } + } +} diff --git a/ryoku/shell/quickshell/shell/modules/gamebar/cards/TasksCard.qml b/ryoku/shell/quickshell/shell/modules/gamebar/cards/TasksCard.qml new file mode 100644 index 0000000000..58be90037e --- /dev/null +++ b/ryoku/shell/quickshell/shell/modules/gamebar/cards/TasksCard.qml @@ -0,0 +1,106 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell.Io +import ".." +import Ryoku.Ui.Singletons +import "../look" +import "../../../utils/tasks.js" as Tasks + +// Tasks: the five busiest processes by average CPU, with a two-step End. The +// protected set is the desktop itself, so the list cannot offer to kill the +// compositor or the shell it floats above. +GameBarCard { + id: card + + property var rows: [] + + title: I18n.tr("Tasks") + + function refresh() { + if (!ps.running) + ps.running = true; + } + + Component.onCompleted: card.refresh() + + Timer { + interval: 2000 + repeat: true + running: true + onTriggered: card.refresh() + } + + Process { + id: ps + command: ["ps", "-eo", "pid=,comm=,cputimes=,etimes=,rss="] + stdout: StdioCollector { + onStreamFinished: card.rows = Tasks.top(text, 5) + } + } + + Process { + id: killer + } + + Timer { + id: settle + interval: 700 + onTriggered: card.refresh() + } + + Column { + width: parent.width + spacing: 2 * card.s + + Item { + width: parent.width + height: 22 * card.s + + Text { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + text: I18n.tr("Process") + color: GameBarLook.subtext + font.family: GameBarLook.face + font.pixelSize: GameBarLook.smallie * card.s + } + Text { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: I18n.tr("CPU MEM") + color: GameBarLook.subtext + font.family: GameBarLook.face + font.pixelSize: GameBarLook.smallie * card.s + } + } + + Repeater { + model: card.rows + + delegate: GameBarTaskRow { + required property var modelData + s: card.s + width: parent.width + task: modelData + protectedName: Tasks.isProtected(modelData.name) + onEndRequested: pid => { + killer.command = ["kill", "-TERM", String(pid)]; + killer.running = true; + settle.restart(); + } + } + } + + Text { + visible: card.rows.length === 0 + width: parent.width + topPadding: 10 * card.s + text: I18n.tr("Reading processes") + color: GameBarLook.subtext + horizontalAlignment: Text.AlignHCenter + font.family: GameBarLook.face + font.pixelSize: GameBarLook.small * card.s + } + } +} diff --git a/ryoku/shell/quickshell/shell/modules/gamebar/cards/VoiceCard.qml b/ryoku/shell/quickshell/shell/modules/gamebar/cards/VoiceCard.qml new file mode 100644 index 0000000000..97d1dd1de9 --- /dev/null +++ b/ryoku/shell/quickshell/shell/modules/gamebar/cards/VoiceCard.qml @@ -0,0 +1,444 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import shell.services +import ".." +import "../../../components" +import Ryoku.Ui.Singletons +import "../look" +import "../discordVoice" +import "../discordVoice/layout.js" as VoiceLayout + +// Voice: the Discord channel the user is in, who is there, and who is talking. +// The bridge reads Discord's own RPC or the Ryoku companion inside Vesktop. +GameBarCard { + id: card + + title: I18n.tr("Voice") + resizable: true + + readonly property var voice: DiscordVoice + readonly property int avatarLimit: Math.max(1, Math.min(8, Config.gamebar.voiceAvatars || 8)) + readonly property bool dimOthers: Config.gamebar.voiceDim !== false + readonly property real dimOpacity: Math.max(0.2, Math.min(1, Config.gamebar.voiceDimOpacity || 0.5)) + readonly property real avatarSpacing: 4 * card.s + readonly property real avatarCell: 58 * card.s + readonly property int avatarColumns: Math.max(1, Math.min(8, + Math.floor((stage.width + card.avatarSpacing) / (card.avatarCell + card.avatarSpacing)))) + + readonly property real miniAvatarLogical: VoiceLayout.avatar(Config.gamebar.voiceAvatarSize) + readonly property real miniAvatarSize: card.miniAvatarLogical * card.s + readonly property bool miniNames: Config.gamebar.voiceNames !== false + readonly property bool miniControls: Config.gamebar.voiceControls !== false + readonly property real miniBgOpacity: { + const value = Config.gamebar.voiceBgOpacity; + return typeof value === "number" ? Math.max(0, Math.min(1, value)) : 0.72; + } + readonly property real miniPad: VoiceLayout.PAD * card.s + readonly property real miniGap: VoiceLayout.SPACE * card.s + readonly property real miniHeaderHeight: VoiceLayout.HEADER * card.s + readonly property real miniCell: VoiceLayout.cell(card.miniAvatarLogical, card.miniNames) * card.s + readonly property int miniColumns: VoiceLayout.columns(card.width / card.s, + Math.min(card.avatarLimit, card.voice.participantCount), + card.miniAvatarLogical, card.miniNames) + readonly property string miniSubtitle: { + if (card.voice.inVoice) { + const n = card.voice.participantCount; + return n === 1 ? I18n.tr("1 participant") + : I18n.tr("%1 participants").arg(n); + } + if (card.voice.status === "authenticated") + return I18n.tr("No voice channel"); + return card.subtitle; + } + + readonly property string headline: { + if (card.voice.inVoice) + return card.voice.channel.name || I18n.tr("Voice channel"); + switch (card.voice.status) { + case "auth_required": return I18n.tr("Discord voice"); + case "authorizing": return I18n.tr("Waiting for Discord"); + case "unavailable": return I18n.tr("Discord unavailable"); + case "disconnected": return I18n.tr("Reconnecting"); + case "stopped": return I18n.tr("Bridge stopped"); + case "authenticated": return I18n.tr("No voice channel"); + default: return I18n.tr("Connecting"); + } + } + + readonly property string subtitle: { + if (card.voice.inVoice) { + const n = card.voice.participantCount; + const line = n === 1 ? I18n.tr("1 person in voice") : I18n.tr("%1 people in voice").arg(n); + return card.voice.backendLabel.length > 0 + ? line + " " + card.voice.backendLabel : line; + } + if (card.voice.status === "authenticated") + return I18n.tr("Join a channel to see who is talking"); + return card.voice.backendLabel.length > 0 + ? I18n.tr("via %1").arg(card.voice.backendLabel) + : I18n.tr("Discord voice overlay"); + } + + readonly property string emptyMessage: { + switch (card.voice.status) { + case "auth_required": + return card.voice.companionNeeded + ? I18n.tr("Vesktop needs its voice companion before Ryoku can read the call.") + : I18n.tr("Allow Ryoku to read and control your Discord voice state."); + case "authorizing": return I18n.tr("Confirm the request in Discord."); + case "unavailable": return I18n.tr("Discord's voice socket is not reachable. Retry in a moment."); + case "disconnected": return I18n.tr("Discord went away. Reconnecting in a moment."); + case "stopped": return I18n.tr("The bridge stopped. Retry to start it again."); + case "authenticated": return I18n.tr("Join a voice channel to see who is talking."); + default: return I18n.tr("Connecting to Discord."); + } + } + + component VoiceToggle: Item { + id: toggle + property string glyph: "" + property bool on: false + property real diameter: 34 * card.s + signal clicked() + + width: toggle.diameter + height: toggle.diameter + + Rectangle { + anchors.fill: parent + radius: height / 2 + color: toggle.on ? GameBarLook.toggled + : (togglePointer.containsMouse ? GameBarLook.hover : GameBarLook.raised) + + Behavior on color { + ColorAnimation { duration: 120 } + } + } + + GlyphIcon { + anchors.centerIn: parent + width: toggle.diameter * 0.5 + height: toggle.diameter * 0.5 + name: toggle.glyph + color: toggle.on ? GameBarLook.toggledInk : GameBarLook.fg + stroke: 1.8 + } + + MouseArea { + id: togglePointer + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: toggle.clicked() + } + } + + Item { + anchors.fill: parent + visible: !card.minimal + + Item { + id: header + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + height: 40 * card.s + + Rectangle { + id: glyphTile + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: 36 * card.s + height: 36 * card.s + radius: GameBarLook.radiusSmall * card.s + color: card.voice.inVoice ? GameBarLook.toggled : GameBarLook.raised + + GlyphIcon { + anchors.centerIn: parent + width: 19 * card.s + height: 19 * card.s + name: "discord" + color: card.voice.inVoice ? GameBarLook.toggledInk : GameBarLook.onLayer + stroke: 1.7 + } + } + + Column { + anchors.left: glyphTile.right + anchors.leftMargin: 10 * card.s + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 1 * card.s + + Text { + width: parent.width + text: card.headline + color: GameBarLook.fg + font.family: GameBarLook.face + font.pixelSize: GameBarLook.small * card.s + font.weight: Font.DemiBold + elide: Text.ElideRight + } + + Text { + width: parent.width + text: card.subtitle + color: GameBarLook.subtext + font.family: GameBarLook.face + font.pixelSize: GameBarLook.smaller * card.s + elide: Text.ElideRight + } + } + } + + Item { + id: stage + anchors.top: header.bottom + anchors.topMargin: 6 * card.s + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: footer.top + anchors.bottomMargin: 4 * card.s + + Grid { + anchors.centerIn: parent + columns: card.avatarColumns + spacing: card.avatarSpacing + visible: card.voice.participantCount > 0 + + Repeater { + model: card.voice.participantModel + + delegate: ParticipantAvatar { + required property int index + s: card.s + visible: index < card.avatarLimit + dimOthers: card.dimOthers + dimOpacity: card.dimOpacity + } + } + } + + Column { + anchors.centerIn: parent + width: parent.width + spacing: 8 * card.s + visible: card.voice.participantCount === 0 + + GlyphIcon { + anchors.horizontalCenter: parent.horizontalCenter + width: 26 * card.s + height: 26 * card.s + name: "discord" + color: GameBarLook.subtext + stroke: 1.6 + } + + Text { + width: parent.width + text: card.emptyMessage + color: GameBarLook.onLayer + font.family: GameBarLook.face + font.pixelSize: GameBarLook.smaller * card.s + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + } + + GameBarTextButton { + anchors.horizontalCenter: parent.horizontalCenter + s: card.s + compact: true + primary: card.voice.status === "auth_required" || card.voice.status === "authorizing" + label: card.voice.status === "authorizing" + ? I18n.tr("Waiting for Discord") + : (card.voice.status === "auth_required" + ? I18n.tr("Authorize Discord") + : I18n.tr("Retry")) + enabled: card.voice.status !== "authorizing" + onClicked: { + if (card.voice.status === "auth_required") + card.voice.authorize(); + else + card.voice.connect(); + } + } + + GameBarTextButton { + anchors.horizontalCenter: parent.horizontalCenter + visible: card.voice.companionNeeded && !card.voice.installerBusy + s: card.s + compact: true + quiet: true + label: I18n.tr("Install voice companion") + onClicked: card.voice.installCompanion() + } + + Text { + width: parent.width + visible: card.voice.installerBusy || card.voice.installerState === "failed" + text: card.voice.installerBusy ? card.voice.installerLine + : I18n.tr("The companion install did not finish") + color: GameBarLook.subtext + font.family: GameBarLook.face + font.pixelSize: GameBarLook.smallest * card.s + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + } + } + } + + Item { + id: footer + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: visible ? 34 * card.s : 0 + visible: card.voice.authenticated && card.voice.status !== "authorizing" + + Row { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + spacing: 6 * card.s + + VoiceToggle { + glyph: card.voice.muted ? "mic-off" : "mic" + on: card.voice.muted + onClicked: card.voice.setMuted(!card.voice.muted) + } + + VoiceToggle { + glyph: card.voice.deafened ? "headphones-off" : "headphones" + on: card.voice.deafened + onClicked: card.voice.setDeafened(!card.voice.deafened) + } + } + + Text { + anchors.right: parent.right + anchors.rightMargin: card.resizable ? 16 * card.s : 0 + anchors.verticalCenter: parent.verticalCenter + text: card.voice.deafened ? I18n.tr("Deafened") + : (card.voice.muted ? I18n.tr("Muted") : I18n.tr("Mic on")) + color: GameBarLook.subtext + font.family: GameBarLook.face + font.pixelSize: GameBarLook.smaller * card.s + } + } + } + + // Pinned with the bar closed, the card becomes the overlay: a rounded + // translucent panel sized to its content, with the channel, mute and deafen + // state, and the people on an avatar grid. + Rectangle { + id: mini + anchors.fill: parent + visible: card.minimal + radius: GameBarLook.radiusVeryLarge * card.s + color: Qt.rgba(GameBarLook.panel.r, GameBarLook.panel.g, GameBarLook.panel.b, + card.miniBgOpacity) + + Item { + id: miniHeader + x: card.miniPad + y: card.miniPad + width: parent.width - card.miniPad * 2 + height: card.miniHeaderHeight + + Rectangle { + id: miniTile + anchors.verticalCenter: parent.verticalCenter + width: 36 * card.s + height: 36 * card.s + radius: GameBarLook.radiusSmall * card.s + color: card.voice.inVoice ? GameBarLook.toggled : GameBarLook.raised + + GlyphIcon { + anchors.centerIn: parent + width: 20 * card.s + height: 20 * card.s + name: "discord" + color: card.voice.inVoice ? GameBarLook.toggledInk : GameBarLook.onLayer + stroke: 1.7 + } + } + + Row { + id: miniButtons + readonly property bool active: card.miniControls && card.voice.authenticated + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 4 * card.s + visible: active + width: active ? implicitWidth : 0 + + VoiceToggle { + diameter: 40 * card.s + glyph: card.voice.muted ? "mic-off" : "mic" + on: card.voice.muted + onClicked: card.voice.setMuted(!card.voice.muted) + } + + VoiceToggle { + diameter: 40 * card.s + glyph: card.voice.deafened ? "headphones-off" : "headphones" + on: card.voice.deafened + onClicked: card.voice.setDeafened(!card.voice.deafened) + } + } + + Column { + anchors.left: miniTile.right + anchors.leftMargin: 9 * card.s + anchors.right: miniButtons.left + anchors.rightMargin: 8 * card.s + anchors.verticalCenter: parent.verticalCenter + spacing: 0 + + Text { + width: parent.width + text: card.headline + color: GameBarLook.fg + font.family: GameBarLook.face + font.pixelSize: GameBarLook.small * card.s + font.weight: Font.DemiBold + elide: Text.ElideRight + } + + Text { + width: parent.width + text: card.miniSubtitle + color: GameBarLook.subtext + font.family: GameBarLook.face + font.pixelSize: GameBarLook.smaller * card.s + elide: Text.ElideRight + } + } + } + + Grid { + id: miniGrid + anchors.horizontalCenter: parent.horizontalCenter + y: card.miniPad + card.miniHeaderHeight + VoiceLayout.GAP * card.s + columns: card.miniColumns + rowSpacing: card.miniGap + columnSpacing: card.miniGap + visible: card.voice.participantCount > 0 + + Repeater { + model: card.voice.participantModel + + delegate: ParticipantAvatar { + required property int index + width: card.miniCell + visible: index < card.avatarLimit + s: card.s + avatarSize: card.miniAvatarSize + showName: card.miniNames + dimOthers: card.dimOthers + dimOpacity: card.dimOpacity + } + } + } + } +} diff --git a/ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/ParticipantAvatar.qml b/ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/ParticipantAvatar.qml new file mode 100644 index 0000000000..ada7903f82 --- /dev/null +++ b/ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/ParticipantAvatar.qml @@ -0,0 +1,141 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Qt5Compat.GraphicalEffects +import shell.services +import "../../../components" +import "../look" + +// One voice participant: a round avatar with a speaking ring, a mute or deaf +// badge, and the display name under it. Everyone who is not speaking fades back +// so the active speaker reads at a glance. +Item { + id: root + + required property var participant + + property real s: 1 + property real avatarSize: 46 * root.s + property bool showName: true + property bool dimOthers: true + property real dimOpacity: 0.5 + + readonly property bool active: root.participant && root.participant.speaking === true + readonly property bool muted: root.participant && root.participant.mute === true + readonly property bool deafened: root.participant && root.participant.deaf === true + readonly property string displayName: { + if (!root.participant) + return ""; + return root.participant.nick || root.participant.username || "?"; + } + readonly property string initial: root.displayName.length > 0 + ? root.displayName.charAt(0).toUpperCase() : "?" + + implicitWidth: root.showName ? Math.max(root.avatarSize, 58 * root.s) : root.avatarSize + implicitHeight: root.avatarSize + (root.showName ? 15 * root.s : 0) + opacity: root.dimOthers && !root.active && DiscordVoice.participantCount > 1 + ? root.dimOpacity : 1 + + Behavior on opacity { + NumberAnimation { duration: 140 } + } + + Rectangle { + id: ring + anchors.horizontalCenter: parent.horizontalCenter + y: 0 + width: root.avatarSize + 4 * root.s + height: width + radius: width / 2 + color: "transparent" + border.width: root.active ? 2 * root.s : 0 + border.color: GameBarLook.accent + + Behavior on border.width { + NumberAnimation { duration: 120 } + } + } + + Item { + id: circle + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + width: root.avatarSize + height: root.avatarSize + + Rectangle { + anchors.fill: parent + radius: width / 2 + color: GameBarLook.raised + } + + Image { + id: photo + anchors.fill: parent + source: DiscordVoice.avatarUrl(root.participant, 128) + fillMode: Image.PreserveAspectCrop + asynchronous: true + cache: true + visible: false + layer.enabled: true + } + + Rectangle { + id: photoMask + anchors.fill: photo + radius: height / 2 + visible: false + layer.enabled: true + } + + OpacityMask { + anchors.fill: photo + source: photo + maskSource: photoMask + visible: photo.status === Image.Ready + } + + Text { + anchors.centerIn: parent + visible: photo.status !== Image.Ready + text: root.initial + color: GameBarLook.onLayer + font.family: GameBarLook.face + font.pixelSize: 17 * root.s + font.weight: Font.DemiBold + } + + Rectangle { + visible: root.muted || root.deafened + anchors.right: parent.right + anchors.bottom: parent.bottom + width: 16 * root.s + height: 16 * root.s + radius: width / 2 + color: GameBarLook.danger + + GlyphIcon { + anchors.centerIn: parent + width: 10 * root.s + height: 10 * root.s + name: root.deafened ? "headphones-off" : "mic-off" + color: GameBarLook.onDanger + stroke: 2 + } + } + } + + Text { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: circle.bottom + anchors.topMargin: 2 * root.s + width: root.width + visible: root.showName + text: root.displayName + color: GameBarLook.onLayer + font.family: GameBarLook.face + font.pixelSize: GameBarLook.smallest * root.s + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + } +} diff --git a/ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/bridge.py b/ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/bridge.py new file mode 100755 index 0000000000..74c214d2ee --- /dev/null +++ b/ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/bridge.py @@ -0,0 +1,481 @@ +#!/usr/bin/env python3 +"""Ryoku Discord voice bridge. + +The shell speaks newline-delimited JSON on stdin and stdout. Voice state comes +from one of two places: Discord's native RPC socket for the official client, or +a companion plugin inside Vesktop that pushes the same shape over a local Unix +socket. Only the Python standard library is used, and the OAuth token is never +printed. +""" + +from __future__ import annotations + +import asyncio +import json +import os +import stat +import struct +import sys +import urllib.request +from pathlib import Path +from typing import Any + +APP_ID = "207646673902501888" # Discord StreamKit, the public voice RPC client +SCOPES = ("rpc", "rpc.voice.read", "rpc.voice.write") +TOKEN_ENDPOINT = "https://streamkit.discord.com/overlay/token" +AUTHORIZE_TIMEOUT = 12.0 +COMPANION_WRITE_TIMEOUT = 2.0 +COMPANION_LIMIT = 512 * 1024 +COMPANION_NAME = "ryoku-discord-voice.sock" + +HANDSHAKE, FRAME, CLOSE, PING, PONG = range(5) + + +def emit(kind: str, **fields: Any) -> None: + print(json.dumps({"type": kind, **fields}, separators=(",", ":")), flush=True) + + +class VoiceBridge: + def __init__(self) -> None: + self.reader: asyncio.StreamReader | None = None + self.writer: asyncio.StreamWriter | None = None + self.rpc_path = "" + self.nonce = 0 + self.pending: dict[str, str] = {} + self.auth_tasks: dict[str, asyncio.Task[None]] = {} + self.rejected: set[str] = set() + self.users: dict[str, dict[str, Any]] = {} + self.channel: dict[str, Any] | None = None + self.muted = False + self.deafened = False + + cache = Path(os.environ.get("XDG_CACHE_HOME") or (Path.home() / ".cache")) + self.token_file = cache / "ryoku" / "discord-voice.json" + + runtime = os.environ.get("XDG_RUNTIME_DIR") + self.companion_path = Path(runtime) / COMPANION_NAME if runtime else None + self.companion_server: asyncio.Server | None = None + self.companion: asyncio.StreamWriter | None = None + self.companion_inode: int | None = None + self.companion_active = False + self.companion_digest = "" + + def token(self) -> str: + try: + os.chmod(self.token_file, 0o600) + return str(json.loads(self.token_file.read_text())["access_token"]) + except (OSError, KeyError, ValueError): + return "" + + def save_token(self, token: str) -> None: + self.token_file.parent.mkdir(parents=True, exist_ok=True) + fd = os.open(self.token_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump({"access_token": token}, handle) + + def clear_token(self) -> None: + try: + self.token_file.unlink() + except OSError: + pass + + def next_nonce(self) -> str: + self.nonce += 1 + return str(self.nonce) + + async def send_frame(self, payload: dict[str, Any], opcode: int = FRAME) -> None: + if not self.writer or self.writer.is_closing(): + return + body = json.dumps(payload).encode() + self.writer.write(struct.pack(" None: + nonce = self.next_nonce() + self.pending[nonce] = name + payload: dict[str, Any] = {"cmd": name, "nonce": nonce} + if args is not None: + payload["args"] = args + await self.send_frame(payload) + + async def subscribe(self, event: str, channel_id: str = "") -> None: + nonce = self.next_nonce() + self.pending[nonce] = "SUB_" + event + payload: dict[str, Any] = {"cmd": "SUBSCRIBE", "evt": event, "nonce": nonce} + if channel_id: + payload["args"] = {"channel_id": channel_id} + await self.send_frame(payload) + + @staticmethod + def candidates() -> list[str]: + runtime = os.environ.get("XDG_RUNTIME_DIR", "/tmp") + roots = (runtime, f"{runtime}/app/com.discordapp.Discord", "/tmp") + return [f"{root}/discord-ipc-{index}" for root in roots for index in range(10)] + + async def connect(self) -> bool: + if self.companion_active and self.companion and not self.companion.is_closing(): + return True + if self.writer and not self.writer.is_closing(): + return True + + for path in self.candidates(): + if path in self.rejected or not os.path.exists(path): + continue + try: + reader, writer = await asyncio.open_unix_connection(path) + self.reader, self.writer, self.rpc_path = reader, writer, path + await self.send_frame({"v": 1, "client_id": APP_ID}, HANDSHAKE) + opcode, ready = await self.receive(reader) + if opcode == CLOSE or ready.get("evt") != "READY": + raise ConnectionError("the client rejected the RPC handshake") + emit("backend", backend="discord") + emit("connected", socket=path) + asyncio.create_task(self.native_loop(reader, writer)) + token = self.token() + if token: + await self.authenticate(token) + else: + emit("auth_required") + return True + except (OSError, ConnectionError, asyncio.IncompleteReadError, ValueError): + self.close_native() + + emit("unavailable", message="Discord is not running") + return False + + async def start_companion_server(self) -> None: + if self.companion_path is None: + return + try: + current = self.companion_path.lstat() + if not stat.S_ISSOCK(current.st_mode) or current.st_uid != os.getuid(): + emit("companion_error", message="Refusing an unsafe Ryoku voice socket path") + return + self.companion_path.unlink() + except FileNotFoundError: + pass + + previous = os.umask(0o177) + try: + self.companion_server = await asyncio.start_unix_server( + self.companion_client, path=self.companion_path, limit=COMPANION_LIMIT) + finally: + os.umask(previous) + self.companion_inode = self.companion_path.stat().st_ino + + async def companion_client(self, reader: asyncio.StreamReader, + writer: asyncio.StreamWriter) -> None: + if self.companion and self.companion is not writer: + self.companion.close() + self.companion = writer + try: + while not writer.is_closing(): + try: + line = await reader.readline() + except ValueError: + continue + if not line: + break + try: + message = json.loads(line) + except (ValueError, TypeError): + continue + state = message.get("state") if message.get("type") == "state" else None + if isinstance(state, dict): + self.companion_state(state) + except OSError: + pass + finally: + if self.companion is writer: + self.companion = None + if self.companion_active: + self.companion_active = False + self.companion_digest = "" + self.channel = None + self.users.clear() + emit("disconnected", reason="The Vesktop companion stopped") + await self.connect() + writer.close() + try: + await asyncio.wait_for(writer.wait_closed(), COMPANION_WRITE_TIMEOUT) + except (asyncio.TimeoutError, OSError): + pass + + def companion_state(self, state: dict[str, Any], force: bool = False) -> None: + if state.get("version") != 1 or state.get("backend") != "vesktop": + return + stable = {key: value for key, value in state.items() if key != "timestamp"} + digest = json.dumps(stable, sort_keys=True, separators=(",", ":")) + if not force and digest == self.companion_digest: + return + + self.close_native() + for task in self.auth_tasks.values(): + task.cancel() + self.auth_tasks.clear() + self.pending.clear() + + self.companion_active = True + self.companion_digest = digest + self.channel = state.get("channel") or None + self.users = {str(user.get("id")): user for user in state.get("users") or [] + if isinstance(user, dict) and user.get("id")} + self.muted = bool(state.get("mute")) + self.deafened = bool(state.get("deaf")) + + emit("backend", backend="vesktop") + emit("authenticated", user=state.get("user") or {}) + emit("voice_channel", channel=self.channel, users=list(self.users.values())) + emit("voice_settings", mute=self.muted, deaf=self.deafened) + + async def companion_command(self, **fields: bool) -> None: + if not self.companion or self.companion.is_closing(): + return + writer = self.companion + writer.write((json.dumps({"type": "command", **fields}, separators=(",", ":")) + "\n").encode()) + try: + await asyncio.wait_for(writer.drain(), COMPANION_WRITE_TIMEOUT) + except (asyncio.TimeoutError, OSError): + emit("companion_error", message="The Vesktop companion stopped reading") + writer.close() + + def close_native(self) -> None: + if self.writer: + self.writer.close() + self.reader = None + self.writer = None + self.rpc_path = "" + + async def receive(self, reader: asyncio.StreamReader | None = None) -> tuple[int, dict[str, Any]]: + source = reader or self.reader + if source is None: + raise ConnectionError("no Discord socket") + header = await source.readexactly(8) + opcode, length = struct.unpack(" None: + nonce = self.next_nonce() + self.pending[nonce] = "AUTHENTICATE" + await self.send_frame({"cmd": "AUTHENTICATE", "args": {"access_token": token}, "nonce": nonce}) + + async def authorize(self) -> None: + if self.companion_active: + return + if not await self.connect(): + return + emit("authorizing") + nonce = self.next_nonce() + self.pending[nonce] = "AUTHORIZE" + await self.send_frame({"cmd": "AUTHORIZE", "args": { + "client_id": APP_ID, "scopes": list(SCOPES), "prompt": "none" + }, "nonce": nonce}) + self.auth_tasks[nonce] = asyncio.create_task(self.authorize_timeout(nonce, self.rpc_path)) + + def clear_auth_task(self, nonce: str) -> None: + task = self.auth_tasks.pop(nonce, None) + if task and task is not asyncio.current_task(): + task.cancel() + + async def authorize_timeout(self, nonce: str, path: str) -> None: + await asyncio.sleep(AUTHORIZE_TIMEOUT) + if self.pending.pop(nonce, None) != "AUTHORIZE": + return + self.auth_tasks.pop(nonce, None) + self.rejected.add(path) + self.close_native() + if await self.connect(): + await self.authorize() + return + emit("error", message=( + "This client does not support Discord voice authorization. " + "Vesktop needs the Ryoku voice companion; use Install voice " + "companion in the Voice widget.")) + + @staticmethod + def exchange(code: str) -> str: + request = urllib.request.Request( + TOKEN_ENDPOINT, + data=json.dumps({"code": code}).encode(), + headers={ + "Content-Type": "application/json", + "User-Agent": "Ryoku/DiscordVoice", + }, + method="POST", + ) + with urllib.request.urlopen(request, timeout=10) as response: + return str(json.loads(response.read().decode())["access_token"]) + + async def native_loop(self, reader: asyncio.StreamReader, + writer: asyncio.StreamWriter) -> None: + try: + while not writer.is_closing(): + opcode, payload = await self.receive(reader) + if opcode == PING: + await self.send_frame(payload, PONG) + elif opcode == CLOSE: + break + elif opcode == FRAME: + await self.frame(payload) + except (OSError, asyncio.IncompleteReadError, ValueError): + pass + if self.writer is writer: + self.close_native() + self.users.clear() + self.channel = None + if not self.companion_active: + emit("disconnected", reason="Discord RPC closed") + + async def frame(self, payload: dict[str, Any]) -> None: + nonce = payload.get("nonce", "") + event = payload.get("evt", "") + if event == "ERROR": + command = self.pending.pop(nonce, "") + self.clear_auth_task(nonce) + if command == "AUTHENTICATE": + self.clear_token() + emit("auth_required") + else: + emit("error", message=(payload.get("data") or {}).get("message", "Discord RPC error")) + return + if nonce in self.pending: + self.clear_auth_task(nonce) + await self.response(self.pending.pop(nonce), payload.get("data") or {}) + return + if payload.get("cmd") == "DISPATCH": + await self.dispatch(event, payload.get("data") or {}) + + async def response(self, command: str, data: dict[str, Any]) -> None: + if command == "AUTHORIZE": + code = data.get("code", "") + if not code: + emit("error", message="Discord authorization was not completed") + return + try: + token = await asyncio.to_thread(self.exchange, code) + self.save_token(token) + await self.authenticate(token) + except Exception: + emit("error", message="Discord token exchange failed") + elif command == "AUTHENTICATE": + emit("authenticated", user=data.get("user", {})) + await self.subscribe("VOICE_CHANNEL_SELECT") + await self.subscribe("VOICE_SETTINGS_UPDATE") + await self.command("GET_SELECTED_VOICE_CHANNEL") + elif command == "GET_SELECTED_VOICE_CHANNEL": + await self.select_channel(data if data and data.get("id") else None) + elif command in ("GET_VOICE_SETTINGS", "SET_VOICE_SETTINGS"): + self.muted = bool(data.get("mute")) + self.deafened = bool(data.get("deaf")) + emit("voice_settings", mute=self.muted, deaf=self.deafened) + + @staticmethod + def voice_user(state: dict[str, Any]) -> dict[str, Any]: + user = state.get("user", {}) + voice = state.get("voice_state", {}) + return { + "id": user.get("id", ""), + "username": user.get("username", ""), + "nick": state.get("nick") or user.get("global_name") or user.get("username", ""), + "avatar": user.get("avatar", ""), + "mute": bool(voice.get("mute") or voice.get("self_mute")), + "deaf": bool(voice.get("deaf") or voice.get("self_deaf")), + "speaking": False, + } + + async def select_channel(self, data: dict[str, Any] | None) -> None: + self.users.clear() + if not data: + self.channel = None + emit("voice_channel", channel=None, users=[]) + return + self.channel = { + "id": data.get("id", ""), + "name": data.get("name", "Voice channel"), + "guild_id": data.get("guild_id", ""), + } + for state in data.get("voice_states", []): + user = self.voice_user(state) + if user["id"]: + self.users[user["id"]] = user + emit("voice_channel", channel=self.channel, users=list(self.users.values())) + for event in ("VOICE_STATE_CREATE", "VOICE_STATE_UPDATE", "VOICE_STATE_DELETE", + "SPEAKING_START", "SPEAKING_STOP"): + await self.subscribe(event, self.channel["id"]) + await self.command("GET_VOICE_SETTINGS") + + async def dispatch(self, event: str, data: dict[str, Any]) -> None: + if event == "VOICE_CHANNEL_SELECT": + await self.command("GET_SELECTED_VOICE_CHANNEL") + elif event in ("VOICE_STATE_CREATE", "VOICE_STATE_UPDATE"): + user = self.voice_user(data) + old = self.users.get(user["id"], {}) + user["speaking"] = old.get("speaking", False) + if user["id"]: + self.users[user["id"]] = user + emit("voice_state", users=list(self.users.values())) + elif event == "VOICE_STATE_DELETE": + self.users.pop((data.get("user") or {}).get("id", ""), None) + emit("voice_state", users=list(self.users.values())) + elif event in ("SPEAKING_START", "SPEAKING_STOP"): + user_id = data.get("user_id", "") + if user_id in self.users: + self.users[user_id]["speaking"] = event == "SPEAKING_START" + emit("voice_state", users=list(self.users.values())) + elif event == "VOICE_SETTINGS_UPDATE": + self.muted = bool(data.get("mute")) + self.deafened = bool(data.get("deaf")) + emit("voice_settings", mute=self.muted, deaf=self.deafened) + + async def input_loop(self) -> None: + loop = asyncio.get_running_loop() + while True: + line = await loop.run_in_executor(None, sys.stdin.readline) + if not line: + return + try: + message = json.loads(line) + command = message.get("cmd") + if command == "connect": + await self.connect() + elif command == "authorize": + await self.authorize() + elif command == "set_voice_settings": + fields = {key: bool(message[key]) for key in ("mute", "deaf") if key in message} + if self.companion_active: + await self.companion_command(**fields) + elif self.writer: + await self.command("SET_VOICE_SETTINGS", fields) + elif command == "disconnect": + self.close_native() + except (ValueError, OSError): + emit("error", message="Invalid bridge command") + + async def shutdown(self) -> None: + self.close_native() + if self.companion: + self.companion.close() + if self.companion_server: + self.companion_server.close() + await self.companion_server.wait_closed() + if self.companion_path and self.companion_inode is not None: + try: + if self.companion_path.stat().st_ino == self.companion_inode: + self.companion_path.unlink() + except OSError: + pass + + +async def main() -> None: + bridge = VoiceBridge() + await bridge.start_companion_server() + emit("ready") + try: + await bridge.input_loop() + finally: + await bridge.shutdown() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/companion/index.ts b/ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/companion/index.ts new file mode 100644 index 0000000000..48cce277e1 --- /dev/null +++ b/ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/companion/index.ts @@ -0,0 +1,157 @@ +/* + * Ryoku Discord Voice companion for Vencord + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Vesktop's local RPC proxy answers Rich Presence but not authenticated voice + * RPC, so the Ryoku Game Bar needs this plugin to publish the call over a small + * local socket. Official Discord needs none of this. + */ + +import definePlugin, { PluginNative } from "@utils/types"; +import { findByPropsLazy, findStoreLazy } from "@webpack"; +import { + ChannelRTCStore, + ChannelStore, + GuildMemberStore, + SelectedChannelStore, + UserStore, + VoiceStateStore +} from "@webpack/common"; + +const Native = VencordNative.pluginHelpers.RyokuDiscordVoice as PluginNative; +const Audio = findByPropsLazy("toggleSelfMute", "toggleSelfDeaf"); +const SpeakingStore = findStoreLazy("SpeakingStore"); + +let heartbeat: ReturnType | undefined; +let running = false; +let busy = false; +let again = false; + +function participant(userId: string, state: any, guildId?: string) { + const user = UserStore.getUser(userId); + if (!user) + return null; + return { + id: user.id, + username: user.username, + nick: (guildId && GuildMemberStore.getNick(guildId, user.id)) || user.globalName || user.username, + avatar: user.avatar || "", + mute: Boolean(state.mute || state.selfMute), + deaf: Boolean(state.deaf || state.selfDeaf), + speaking: Boolean(state.speaking) + }; +} + +function activeSpeakers(channelId: string) { + const ids = new Set(); + if (channelId) { + for (const peer of ChannelRTCStore.getSpeakingParticipants(channelId) || []) { + if (peer?.user?.id) + ids.add(peer.user.id); + } + } + const me = UserStore.getCurrentUser(); + if (me && SpeakingStore?.isSpeaking(me.id)) + ids.add(me.id); + return ids; +} + +function snapshot() { + const channelId = SelectedChannelStore.getVoiceChannelId(); + const channel = channelId ? ChannelStore.getChannel(channelId) : null; + const states = channelId ? VoiceStateStore.getVoiceStatesForChannel(channelId) || {} : {}; + const speaking = activeSpeakers(channelId); + const me = UserStore.getCurrentUser(); + + const users = Object.entries(states) + .map(([userId, state]) => participant(userId, { ...(state as object), speaking: speaking.has(userId) }, channel?.guild_id)) + .filter(Boolean); + + const own = me ? VoiceStateStore.getVoiceStateForUser(me.id) : null; + return { + version: 1, + backend: "vesktop", + timestamp: Date.now(), + user: me ? { id: me.id, username: me.username, avatar: me.avatar || "" } : null, + channel: channel ? { + id: channel.id, + name: channel.name || "Voice channel", + guild_id: channel.guild_id || "" + } : null, + users, + mute: Boolean(own?.mute || own?.selfMute), + deaf: Boolean(own?.deaf || own?.selfDeaf) + }; +} + +function applyCommand(line: string) { + if (!line) + return; + try { + const command = JSON.parse(line); + if (command.type !== "command") + return; + const current = snapshot(); + if (typeof command.mute === "boolean" && command.mute !== current.mute) + Audio.toggleSelfMute(); + if (typeof command.deaf === "boolean" && command.deaf !== current.deaf) + Audio.toggleSelfDeaf(); + } catch { + // A malformed command is not worth tearing the socket down for. + } +} + +async function commandLoop() { + while (running) { + const line = await Native.nextCommand(); + if (running) + applyCommand(line); + } +} + +async function publish() { + if (busy) { + again = true; + return; + } + busy = true; + try { + do { + again = false; + await Native.publishState(JSON.stringify(snapshot())); + } while (again); + } finally { + busy = false; + again = false; + } +} + +export default definePlugin({ + name: "RyokuDiscordVoice", + description: "Shares Vesktop voice state with the Ryoku Game Bar", + authors: [{ name: "Ryoku", id: 0n }], + enabledByDefault: true, + + flux: { + VOICE_STATE_UPDATES() { void publish(); }, + AUDIO_TOGGLE_SELF_MUTE() { void publish(); }, + AUDIO_TOGGLE_SELF_DEAF() { void publish(); }, + SPEAKING() { void publish(); }, + STOP_SPEAKING() { void publish(); } + }, + + start() { + running = true; + void commandLoop(); + void publish(); + heartbeat = setInterval(() => void publish(), 4000); + }, + + stop() { + running = false; + if (heartbeat) + clearInterval(heartbeat); + heartbeat = undefined; + void Native.disconnect(); + } +}); diff --git a/ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/companion/native.ts b/ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/companion/native.ts new file mode 100644 index 0000000000..e4473690c4 --- /dev/null +++ b/ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/companion/native.ts @@ -0,0 +1,93 @@ +/* + * Ryoku Discord Voice companion for Vencord + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import type { IpcMainInvokeEvent } from "electron"; +import { createConnection, Socket } from "net"; +import { join } from "path"; + +const runtime = process.env.XDG_RUNTIME_DIR || ""; +const socketPath = runtime ? join(runtime, "ryoku-discord-voice.sock") : ""; + +let socket: Socket | undefined; +let connecting = false; +let latest = ""; +let incoming = ""; +const queue: string[] = []; +const waiters: Array<(line: string) => void> = []; + +function deliver(line: string) { + const waiter = waiters.shift(); + if (waiter) waiter(line); + else queue.push(line); +} + +function reset() { + incoming = ""; + connecting = false; +} + +function ensure() { + if (!socketPath || connecting) + return; + if (socket && !socket.destroyed) + return; + + connecting = true; + incoming = ""; + const candidate = createConnection(socketPath); + socket = candidate; + candidate.setEncoding("utf8"); + + candidate.on("connect", () => { + connecting = false; + if (latest) + candidate.write(latest); + }); + + candidate.on("data", (chunk: string) => { + incoming += chunk; + let newline = incoming.indexOf("\n"); + while (newline >= 0) { + const line = incoming.slice(0, newline); + incoming = incoming.slice(newline + 1); + if (line) + deliver(line); + newline = incoming.indexOf("\n"); + } + }); + + candidate.on("error", () => { + reset(); + candidate.destroy(); + }); + + candidate.on("close", () => { + reset(); + if (socket === candidate) + socket = undefined; + }); +} + +export function publishState(_: IpcMainInvokeEvent, json: string) { + latest = JSON.stringify({ type: "state", state: JSON.parse(json) }) + "\n"; + ensure(); + if (socket?.writable && !connecting) + socket.write(latest); +} + +export function nextCommand(_: IpcMainInvokeEvent): Promise { + const queued = queue.shift(); + if (queued !== undefined) + return Promise.resolve(queued); + return new Promise(resolve => waiters.push(resolve)); +} + +export function disconnect(_: IpcMainInvokeEvent) { + socket?.destroy(); + socket = undefined; + reset(); + for (const waiter of waiters.splice(0)) + waiter(""); +} diff --git a/ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/install_companion.sh b/ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/install_companion.sh new file mode 100755 index 0000000000..8bd3bbd861 --- /dev/null +++ b/ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/install_companion.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +# Build the Ryoku voice companion into Vesktop or Equibop. Both ship Vencord +# without a userplugin loader, so the plugin is compiled into a source build and +# the client's custom-mod setting points at dist. Close the client first. +# +# install_companion.sh [--client auto|vesktop|equibop] +set -euo pipefail + +DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}" +CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PLUGIN="$HERE/companion" +PLUGIN_NAME="ryokuDiscordVoice" +INSTALL_ROOT="$DATA_HOME/ryoku/discord-voice" + +CLIENT="auto" +if [[ "${1:-}" == "--client" ]]; then + CLIENT="${2:?--client needs auto, vesktop or equibop}" +fi + +say() { printf ' %s\n' "$*"; } +die() { printf 'error: %s\n' "$*" >&2; exit 1; } + +[[ -d "$PLUGIN" ]] || die "companion source is missing at $PLUGIN" +command -v git >/dev/null || die "git is required" +command -v node >/dev/null || die "node is required" +command -v npm >/dev/null || die "npm is required" + +export PATH="$HOME/.local/bin:$PATH" +if ! command -v pnpm >/dev/null; then + if command -v corepack >/dev/null; then + say "enabling pnpm through corepack" + corepack enable pnpm + else + say "installing pnpm into the user prefix" + npm install --global pnpm@10 + fi +fi +command -v pnpm >/dev/null || die "pnpm could not be installed" + +client_repo() { + case "$1" in + vesktop) printf 'https://github.com/Vendicated/Vencord.git' ;; + equibop) printf 'https://github.com/Equicord/Equicord.git' ;; + esac +} + +client_dir() { + case "$1" in + vesktop) printf '%s/Vencord' "$INSTALL_ROOT" ;; + equibop) printf '%s/Equicord' "$INSTALL_ROOT" ;; + esac +} + +client_state() { + case "$1" in + vesktop) printf '%s/vesktop/state.json' "$CONFIG_HOME" ;; + equibop) printf '%s/equibop/state.json' "$CONFIG_HOME" ;; + esac +} + +client_key() { + case "$1" in + vesktop) printf 'vencordDir' ;; + equibop) printf 'equicordDir' ;; + esac +} + +client_running() { + case "$1" in + vesktop) pgrep -x vesktop >/dev/null || pgrep -f 'vesktop --' >/dev/null ;; + equibop) pgrep -x equibop >/dev/null || pgrep -f 'equibop --' >/dev/null ;; + esac +} + +install_client() { + local client="$1" + local repo dir state key + + repo="$(client_repo "$client")" + dir="$(client_dir "$client")" + state="$(client_state "$client")" + key="$(client_key "$client")" + + if client_running "$client"; then + say "$client is running; close it fully (including the tray), then run this again." + return 1 + fi + + say "$client mod source: $dir" + if [[ -d "$dir/.git" ]]; then + git -C "$dir" pull --ff-only + else + mkdir -p "$(dirname "$dir")" + git clone --depth 1 "$repo" "$dir" + fi + + say "copying the $PLUGIN_NAME user plugin" + mkdir -p "$dir/src/userplugins" + rm -rf "$dir/src/userplugins/$PLUGIN_NAME" + cp -r "$PLUGIN" "$dir/src/userplugins/$PLUGIN_NAME" + + say "building $client (this takes a few minutes the first time)" + (cd "$dir" && pnpm install --frozen-lockfile && pnpm build) + cp "$dir/package.json" "$dir/dist/package.json" + + if [[ ! -f "$state" ]]; then + say "no $state found; set the custom mod location to $dir/dist by hand" + return 0 + fi + + say "pointing $client at the custom build" + [[ -f "$state.pre-ryoku-voice" ]] || cp "$state" "$state.pre-ryoku-voice" + python3 - "$state" "$key" "$dir/dist" <<'PY' +import json +import sys + +path, key, dist = sys.argv[1], sys.argv[2], sys.argv[3] +with open(path, "r", encoding="utf-8") as handle: + state = json.load(handle) +state[key] = dist +with open(path, "w", encoding="utf-8") as handle: + json.dump(state, handle, indent=4) + handle.write("\n") +PY + + say "done; start $client and RyokuDiscordVoice is enabled by default" + say "the Voice card connects on its own once the shell is running" +} + +clients=() +if [[ "$CLIENT" == "auto" ]]; then + [[ -d "$CONFIG_HOME/vesktop" ]] && clients+=("vesktop") + [[ -d "$CONFIG_HOME/equibop" ]] && clients+=("equibop") + [[ ${#clients[@]} -gt 0 ]] || die "no supported client config found; pass --client" +else + clients=("$CLIENT") +fi + +status=0 +for client in "${clients[@]}"; do + install_client "$client" || status=1 +done +exit "$status" diff --git a/ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/layout.js b/ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/layout.js new file mode 100644 index 0000000000..8eaf69003f --- /dev/null +++ b/ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/layout.js @@ -0,0 +1,48 @@ +// The pinned Voice overlay geometry. The compact panel and the card that draws +// it must agree on the same numbers, so both read them here. + +var PAD = 12; +var HEADER = 40; +var GAP = 10; +var SPACE = 9; +var NAME = 15; +var FLOOR = 344; +var CAP = 720; +var AVATAR_MIN = 32; +var AVATAR_MAX = 72; +var CELL_MIN = 76; + +function avatar(value) { + var size = typeof value === "number" ? value : 52; + return Math.max(AVATAR_MIN, Math.min(AVATAR_MAX, size)); +} + +function cell(avatarSize, names) { + return Math.max(avatarSize, names ? CELL_MIN : avatarSize + 8); +} + +function columns(width, count, avatarSize, names) { + if (count <= 0) + return 1; + var size = cell(avatarSize, names); + var design = Math.floor((CAP + SPACE) / (size + SPACE)); + var frame = Math.floor((width - PAD * 2 + SPACE) / (size + SPACE)); + return Math.max(1, Math.min(8, count, design, frame)); +} + +function size(fullWidth, count, avatarSize, names) { + var shown = Math.max(0, Math.min(8, count)); + var cols = columns(fullWidth, shown, avatarSize, names); + var rows = shown > 0 ? Math.ceil(shown / cols) : 0; + var grid = cols * cell(avatarSize, names) + (cols - 1) * SPACE; + var height = PAD * 2 + HEADER; + if (rows > 0) + height += GAP + rows * (avatarSize + (names ? NAME : 0)) + (rows - 1) * SPACE; + return { + w: Math.min(fullWidth, Math.max(FLOOR, grid + PAD * 2)), + h: height + }; +} + +if (typeof module !== "undefined" && module.exports) + module.exports = { PAD, HEADER, GAP, SPACE, NAME, avatar, cell, columns, size }; diff --git a/ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/layout.test.mjs b/ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/layout.test.mjs new file mode 100644 index 0000000000..67abfda995 --- /dev/null +++ b/ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/layout.test.mjs @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const { PAD, HEADER, SPACE, avatar, cell, columns, size } = require("./layout.js"); + +test("avatar size is clamped to the setting range", () => { + assert.equal(avatar(undefined), 52); + assert.equal(avatar(10), 32); + assert.equal(avatar(100), 72); + assert.equal(avatar(48), 48); +}); + +test("a cell fits an avatar and an optional name", () => { + assert.equal(cell(52, true), 76); + assert.equal(cell(52, false), 60); + assert.equal(cell(72, true), 76); +}); + +test("columns never exceed the participants, the design cap or the frame", () => { + assert.equal(columns(300, 0, 52, true), 1); + assert.equal(columns(300, 2, 52, true), 2); + assert.equal(columns(300, 8, 52, true), 3); + assert.equal(columns(2000, 8, 52, true), 8); +}); + +test("a quiet call is only the header", () => { + const box = size(300, 0, 52, true); + assert.equal(box.w, 300); + assert.equal(box.h, PAD * 2 + HEADER); +}); + +test("one row adds the gap, avatar and name", () => { + const box = size(300, 1, 52, true); + assert.equal(box.w, 300); + assert.equal(box.h, PAD * 2 + HEADER + 10 + 52 + 15); +}); + +test("the panel never grows past the frame the user placed", () => { + const box = size(300, 8, 52, true); + assert.equal(box.w, 300); + const wide = size(720, 8, 52, true); + assert.equal(wide.w, 8 * 76 + 7 * SPACE + PAD * 2); +}); + +test("names off leaves the avatar cell tighter", () => { + const named = size(400, 4, 52, true); + const plain = size(400, 4, 52, false); + assert.ok(plain.h < named.h); +}); diff --git a/ryoku/shell/quickshell/shell/modules/gamebar/look/GameBarLook.qml b/ryoku/shell/quickshell/shell/modules/gamebar/look/GameBarLook.qml new file mode 100644 index 0000000000..e6919ee939 --- /dev/null +++ b/ryoku/shell/quickshell/shell/modules/gamebar/look/GameBarLook.qml @@ -0,0 +1,48 @@ +pragma Singleton + +import QtQuick +import shell.services + +// The Game Bar follows end-4's overlay on Ryoku's rounding, type scale and +// matugen roles, so it tracks the wallpaper instead of a fixed palette. +QtObject { + id: root + + function alpha(c, a) { + return Qt.rgba(c.r, c.g, c.b, a); + } + + readonly property color bar: Theme.surfaceContainer + readonly property color panel: Theme.surfaceContainerLow + readonly property color content: Theme.surfaceContainer + readonly property color raised: Theme.surfaceContainerHigh + readonly property color raisedHover: Theme.surfaceContainerHighest + readonly property color border: Theme.outlineVariant + readonly property color fg: Theme.onSurface + readonly property color onLayer: Theme.onSurfaceVariant + readonly property color subtext: Theme.outline + readonly property color toggled: Theme.secondaryContainer + readonly property color toggledInk: Theme.onSecondaryContainer + readonly property color accent: Theme.primary + readonly property color accentInk: Theme.onPrimary + readonly property color danger: Theme.error + readonly property color onDanger: Theme.onError + + readonly property color hover: root.alpha(Theme.onSurface, 0.06) + + // Hub -> Game Bar -> Corner rounding owns the base; the smaller radii keep + // their distance from it so the strip, frame and chips stay in proportion. + readonly property int radiusWindow: Config.gamebar.radius + readonly property int radiusVeryLarge: Config.gamebar.radius + 8 + readonly property int radiusNormal: Config.gamebar.radius + 3 + readonly property int radiusSmall: Math.max(0, Config.gamebar.radius - 2) + readonly property int radiusVerySmall: Math.max(0, Config.gamebar.radius - 6) + + readonly property int smallest: 10 + readonly property int smaller: 12 + readonly property int smallie: 13 + readonly property int small: 15 + readonly property int huge: 22 + + readonly property string face: Theme.fontPrimary +} diff --git a/ryoku/shell/quickshell/shell/modules/gamebar/look/qmldir b/ryoku/shell/quickshell/shell/modules/gamebar/look/qmldir new file mode 100644 index 0000000000..a6a3c0cddb --- /dev/null +++ b/ryoku/shell/quickshell/shell/modules/gamebar/look/qmldir @@ -0,0 +1 @@ +singleton GameBarLook 1.0 GameBarLook.qml diff --git a/ryoku/shell/quickshell/shell/services/Config.qml b/ryoku/shell/quickshell/shell/services/Config.qml index fe76ac41df..76756c252a 100644 --- a/ryoku/shell/quickshell/shell/services/Config.qml +++ b/ryoku/shell/quickshell/shell/services/Config.qml @@ -135,6 +135,33 @@ Singleton { // Phone integration is optional and remains dormant until its Hub install // transaction has completed. property alias phone: adapter.phone + + // Game Bar knobs, merged under the file's object so an older or partial + // shell.json still yields every value. Writes go through the daemon. + readonly property var gamebar: root.mergeGameBar(adapter.gamebar) + function mergeGameBar(raw) { + var out = { + enabled: true, dim: 0.62, fade: 200, scale: 1, radius: 14, + snap: true, snapReach: 9, snapGrid: 24, + guideGrid: true, gridStep: 48, gridAlpha: 0.28, + spring: 3.2, damping: 0.58, mass: 1, + voiceAvatars: 8, voiceDim: true, voiceDimOpacity: 0.5, + voiceAvatarSize: 52, voiceNames: true, voiceControls: true, voiceBgOpacity: 0.72 + }; + if (raw && typeof raw === "object") + for (var k in out) + if (raw[k] !== undefined && raw[k] !== null) + out[k] = raw[k]; + return out; + } + function setGameBar(key, value) { + shaderCtl.queued += "call settings.patch " + + JSON.stringify({ path: "gamebar." + key, value: value }) + "\n"; + if (shaderCtl.connected) + shaderCtl.flushQueued(); + else + shaderCtl.connected = true; + } readonly property var normalizedNacre: NacreConfig.normalize(nacre) readonly property var defaultControls: ({ "position": "top-left", @@ -484,6 +511,29 @@ Singleton { "screen": "" } }) + property var gamebar: ({ + "enabled": true, + "dim": 0.62, + "fade": 200, + "scale": 1, + "radius": 14, + "snap": true, + "snapReach": 9, + "snapGrid": 24, + "guideGrid": true, + "gridStep": 48, + "gridAlpha": 0.28, + "spring": 3.2, + "damping": 0.58, + "mass": 1, + "voiceAvatars": 8, + "voiceDim": true, + "voiceDimOpacity": 0.5, + "voiceAvatarSize": 52, + "voiceNames": true, + "voiceControls": true, + "voiceBgOpacity": 0.72 + }) } } diff --git a/ryoku/shell/quickshell/shell/services/DiscordVoice.qml b/ryoku/shell/quickshell/shell/services/DiscordVoice.qml new file mode 100644 index 0000000000..f037188644 --- /dev/null +++ b/ryoku/shell/quickshell/shell/services/DiscordVoice.qml @@ -0,0 +1,297 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Io + +// Discord voice for the Game Bar. One JSON-line bridge process owns the Discord +// RPC socket or the Vesktop companion socket; this singleton folds its frames +// into a stable participant model so avatars are not rebuilt when someone speaks. +Singleton { + id: root + + property string status: "idle" + property string backend: "" + readonly property string backendLabel: root.backend === "vesktop" ? "Vesktop" + : (root.backend === "discord" ? "Discord" : "") + property string errorMessage: "" + property var channel: null + readonly property alias participantModel: participantsModel + readonly property int participantCount: participantsModel.count + property bool muted: false + property bool deafened: false + + property int restartAttempts: 0 + property int reconnectAttempts: 0 + property var pendingMessages: [] + + readonly property bool authenticated: root.status === "authenticated" || root.channel !== null + readonly property bool inVoice: root.channel !== null + + // The bridge only runs while the Voice widget is on screen. + readonly property bool wanted: Config.gamebar.enabled + && GameBarStore.visibleCards.indexOf("voice") !== -1 + onWantedChanged: root.wanted ? root.start(false) : root.stop() + Component.onCompleted: if (root.wanted) root.start(false) + + readonly property bool companionNeeded: { + const text = root.errorMessage.toLowerCase(); + return text.includes("companion") || text.includes("vesktop"); + } + readonly property int maxRestartAttempts: 5 + + property string installerState: "idle" + property string installerLine: "" + readonly property bool installerBusy: root.installerState === "running" + + ListModel { + id: participantsModel + dynamicRoles: true + } + + function updateParticipants(users) { + const incoming = users || []; + const ids = new Set(incoming.map(user => String(user.id || ""))); + + for (let index = participantsModel.count - 1; index >= 0; --index) { + const current = participantsModel.get(index).participant; + if (!ids.has(String(current && current.id ? current.id : ""))) + participantsModel.remove(index); + } + + for (let i = 0; i < incoming.length; i++) { + const user = incoming[i]; + const id = String(user.id || ""); + let found = -1; + for (let index = 0; index < participantsModel.count; index++) { + const current = participantsModel.get(index).participant; + if (String(current && current.id ? current.id : "") === id) { + found = index; + break; + } + } + if (found >= 0) + participantsModel.setProperty(found, "participant", user); + else + participantsModel.append({ participant: user }); + } + } + + function avatarUrl(user, size) { + if (!user || !user.id || !user.avatar) + return ""; + return "https://cdn.discordapp.com/avatars/" + user.id + "/" + user.avatar + + ".png?size=" + (size || 96); + } + + function send(message) { + if (!bridge.running) { + root.pendingMessages = root.pendingMessages.concat([message]); + root.start(true); + return; + } + bridge.write(JSON.stringify(message) + "\n"); + } + + function connect() { + root.disarmReconnect(); + root.issueConnect(); + } + + function issueConnect() { + root.errorMessage = ""; + root.send({ cmd: "connect" }); + } + + function backoffDelay(attempt) { + return Math.min(30000, 1000 * Math.pow(2, Math.max(0, attempt - 1))); + } + + function armReconnect() { + root.reconnectAttempts++; + reconnectTimer.interval = root.backoffDelay(root.reconnectAttempts); + reconnectTimer.restart(); + } + + function disarmReconnect() { + reconnectTimer.stop(); + root.reconnectAttempts = 0; + } + + function authorize() { + root.send({ cmd: "authorize" }); + } + + function setMuted(value) { + root.send({ cmd: "set_voice_settings", mute: value }); + } + + function setDeafened(value) { + root.send({ cmd: "set_voice_settings", deaf: value }); + } + + function start(manual) { + if (bridge.running) + return; + if (manual) { + root.restartAttempts = 0; + root.errorMessage = ""; + } + root.status = "starting"; + bridge.running = true; + } + + function stop() { + restartTimer.stop(); + root.disarmReconnect(); + if (bridge.running) + bridge.running = false; + root.status = "idle"; + root.backend = ""; + root.errorMessage = ""; + root.pendingMessages = []; + root.channel = null; + root.updateParticipants([]); + } + + function flushPendingMessages() { + const queued = root.pendingMessages; + root.pendingMessages = []; + for (let i = 0; i < queued.length; i++) + bridge.write(JSON.stringify(queued[i]) + "\n"); + } + + function handleLine(line) { + let message; + try { + message = JSON.parse(line); + } catch (error) { + return; + } + switch (message.type) { + case "ready": + root.issueConnect(); + break; + case "backend": + root.backend = message.backend || ""; + break; + case "connected": + root.status = "connected"; + root.restartAttempts = 0; + root.disarmReconnect(); + break; + case "auth_required": + root.status = "auth_required"; + break; + case "authorizing": + root.status = "authorizing"; + break; + case "authenticated": + root.status = "authenticated"; + root.restartAttempts = 0; + root.disarmReconnect(); + break; + case "voice_channel": + root.channel = message.channel || null; + root.updateParticipants(message.users); + break; + case "voice_state": + root.updateParticipants(message.users); + break; + case "voice_settings": + root.muted = message.mute === true; + root.deafened = message.deaf === true; + break; + case "unavailable": + root.status = "unavailable"; + root.errorMessage = message.message || ""; + root.armReconnect(); + break; + case "companion_error": + root.errorMessage = message.message || ""; + break; + case "disconnected": + root.status = "disconnected"; + root.backend = ""; + root.channel = null; + root.updateParticipants([]); + if (message.reason) + root.errorMessage = message.reason; + root.armReconnect(); + break; + case "error": + root.status = "auth_required"; + root.errorMessage = message.message || "Discord RPC error"; + break; + } + } + + function installCompanion() { + if (installer.running) + return; + root.installerState = "running"; + root.installerLine = ""; + installer.running = true; + } + + Timer { + id: restartTimer + onTriggered: root.start(false) + } + + Timer { + id: reconnectTimer + onTriggered: root.issueConnect() + } + + Process { + id: bridge + command: ["python3", Quickshell.shellPath("modules/gamebar/discordVoice/bridge.py")] + stdinEnabled: true + onStarted: root.flushPendingMessages() + stdout: SplitParser { onRead: data => root.handleLine(data) } + stderr: SplitParser { onRead: data => console.warn("[DiscordVoice]", data) } + onExited: (code, status) => { + root.disarmReconnect(); + root.channel = null; + root.updateParticipants([]); + if (!root.wanted) { + root.status = "idle"; + root.backend = ""; + return; + } + if (root.restartAttempts >= root.maxRestartAttempts) { + root.status = "stopped"; + root.errorMessage = "The Discord bridge stopped after repeated failures"; + return; + } + root.restartAttempts++; + root.status = "restarting"; + restartTimer.interval = root.backoffDelay(root.restartAttempts); + restartTimer.restart(); + } + } + + Process { + id: installer + command: ["bash", Quickshell.shellPath("modules/gamebar/discordVoice/install_companion.sh")] + stdout: SplitParser { + onRead: data => { + const line = data.trim(); + if (line) + root.installerLine = line; + } + } + stderr: SplitParser { + onRead: data => { + const line = data.trim(); + if (line) + root.installerLine = line; + } + } + onExited: (code, status) => { + root.installerState = code === 0 ? "done" : "failed"; + } + } +} diff --git a/ryoku/shell/quickshell/shell/services/GameBarStore.qml b/ryoku/shell/quickshell/shell/services/GameBarStore.qml new file mode 100644 index 0000000000..e3f9d13322 --- /dev/null +++ b/ryoku/shell/quickshell/shell/services/GameBarStore.qml @@ -0,0 +1,150 @@ +pragma Singleton + +import QtQuick +import Quickshell +import Quickshell.Io +import Ryoku.Ui.Singletons + +// Game Bar state: whether the strip is up, which monitor it belongs to, the +// cards on screen, and each card's saved spot and size. All of it persists. +Singleton { + id: root + + readonly property var order: ["capture", "performance", "resources", "tasks", "media", "voice", "audio", "gallery"] + readonly property string defaultCard: "performance" + + // The strip shows the remembered set; closing it leaves only pinned cards. + readonly property var visibleCards: { + const out = []; + for (let i = 0; i < root.order.length; i++) { + const id = root.order[i]; + if (root.pinned.indexOf(id) !== -1 || (root.open && root.opened.indexOf(id) !== -1)) + out.push(id); + } + return out; + } + + function isPinned(id) { + return root.pinned.indexOf(id) !== -1; + } + + function toggleCard(id) { + const open = root.opened.slice(); + const pinned = root.pinned.slice(); + const listed = open.indexOf(id) !== -1 || pinned.indexOf(id) !== -1; + if (listed) { + root.opened = open.filter(x => x !== id); + root.pinned = pinned.filter(x => x !== id); + return; + } + root.opened = open.concat([id]); + } + + function setPinned(id, on) { + if (on === root.isPinned(id)) + return; + root.pinned = on ? root.pinned.concat([id]) + : root.pinned.filter(x => x !== id); + } + + function togglePinned(id) { + root.setPinned(id, !root.isPinned(id)); + } + + function closeCard(id) { + root.opened = root.opened.filter(x => x !== id); + root.pinned = root.pinned.filter(x => x !== id); + } + + function positionFor(id) { + const list = root.layout || []; + for (let i = 0; i < list.length; i++) { + if (list[i] && list[i].id === id) + return list[i]; + } + return null; + } + + function place(id, x, y) { + const next = (root.layout || []).filter(e => e && e.id !== id); + next.push({ id: id, x: Math.round(x), y: Math.round(y) }); + root.layout = next; + } + + function sizeFor(id) { + const list = root.sizes || []; + for (let i = 0; i < list.length; i++) { + if (list[i] && list[i].id === id) + return list[i]; + } + return null; + } + + function resize(id, w, h) { + const next = (root.sizes || []).filter(e => e && e.id !== id); + next.push({ id: id, w: Math.round(w), h: Math.round(h) }); + root.sizes = next; + } + + function openBar(mon) { + if (root.open) + return; + root.monitor = mon && mon.length > 0 ? mon : (Wm.focusedOutput || root.monitor); + root.open = true; + } + + function closeBar() { + root.open = false; + } + + function toggleBar(mon) { + if (root.open) + root.closeBar(); + else + root.openBar(mon); + } + + FileView { + id: file + path: (Quickshell.env("XDG_STATE_HOME") || (Quickshell.env("HOME") + "/.local/state")) + "/ryoku/gamebar.json" + blockLoading: true + printErrors: false + atomicWrites: true + onAdapterUpdated: writeAdapter() + + JsonAdapter { + id: adapter + property bool open: false + property var pinned: [] + property var cards: [] + property var layout: [] + property var sizes: [] + property string monitor: "" + property int resourceTab: 0 + } + } + + property alias open: adapter.open + property alias pinned: adapter.pinned + property alias opened: adapter.cards + property alias layout: adapter.layout + property alias sizes: adapter.sizes + property alias monitor: adapter.monitor + property alias resourceTab: adapter.resourceTab + + // The shell is the only writer, so the file is read once and never watched: + // a watch would reload the state just after saving it and could put back a + // card the pointer had already turned off. + // + // A file from before the open set was saved has no cards key, so seed it + // once from the pins, or with the card the bar used to open on. + Component.onCompleted: { + const text = file.text(); + if (text && text.indexOf("\"cards\"") !== -1) + return; + if (root.opened.length > 0) + return; + root.opened = root.pinned.length > 0 ? root.pinned.slice() : [root.defaultCard]; + file.writeAdapter(); + } +} diff --git a/ryoku/shell/quickshell/shell/services/qmldir b/ryoku/shell/quickshell/shell/services/qmldir index 628189d0ce..da44538988 100644 --- a/ryoku/shell/quickshell/shell/services/qmldir +++ b/ryoku/shell/quickshell/shell/services/qmldir @@ -1,5 +1,6 @@ module shell.services singleton ShellState ShellState.qml +singleton GameBarStore GameBarStore.qml singleton SidebarState SidebarState.qml ServiceLoader ServiceLoader.qml singleton Theme Theme.qml @@ -35,6 +36,7 @@ singleton Camera Camera.qml singleton Keypresses Keypresses.qml singleton Toggles Toggles.qml singleton VoiceBars VoiceBars.qml +singleton DiscordVoice DiscordVoice.qml singleton Capture Capture.qml singleton Clipboard Clipboard.qml singleton Events Events.qml diff --git a/ryoku/shell/quickshell/shell/shell.qml b/ryoku/shell/quickshell/shell/shell.qml index 32a0f6ac78..e8475c8a31 100644 --- a/ryoku/shell/quickshell/shell/shell.qml +++ b/ryoku/shell/quickshell/shell/shell.qml @@ -33,6 +33,7 @@ import "modules/osd" import "modules/notifications" import "modules/capture" import "modules/ask" +import "modules/gamebar" import "modules/confirm" import Ryoku.Ui.Singletons import stage.modules.common as StageIsland @@ -268,6 +269,12 @@ ShellRoot { dockLaneCenter: dockLoader.item ? dockLoader.item.bandCenter : 0 } + // The Game Bar: a passive Overlay surface per output. The store + // decides whether this monitor is the one showing it. + GameBar { + modelData: perScreen.modelData + } + LazyLoader { id: controlsLoader property bool open: SidebarState.isOpen(perScreen.modelData) @@ -623,6 +630,10 @@ ShellRoot { if (st) st.clipboardOpen = !st.clipboardOpen; break; + case "gamebar": + if (Config.gamebar.enabled) + GameBarStore.toggleBar(Wm.focusedOutput); + break; case "settings": Spawn.run(["ryoku-shell", "hub", "open"]); break; @@ -654,6 +665,7 @@ ShellRoot { switch (id) { case "launcher": case "clipboard": + case "gamebar": case "settings": case "cheatsheet": if (!Config.styleOwns(id)) @@ -891,7 +903,7 @@ ShellRoot { // its focused-output cache is cold. function openSurface(mon: string, id: string): void { // these keys are Ryoku's under every style, so they skip the bus styles listen on, same as the Hyprland shortcuts - if (id === "launcher" || id === "clipboard" || id === "settings") { + if (id === "launcher" || id === "clipboard" || id === "settings" || id === "gamebar") { root.toggleSurface(id); return; } @@ -1109,6 +1121,11 @@ ShellRoot { description: I18n.tr("Open the clipboard history on the active monitor") onPressed: ShellState.requestSurfaceActive("clipboard", undefined) } + CustomShortcut { + name: "gamebar" + description: I18n.tr("Toggle the Game Bar on the active monitor") + onPressed: root.toggleSurface("gamebar") + } CustomShortcut { name: "ask" description: I18n.tr("Ask Rashin") diff --git a/ryoku/shell/quickshell/shell/utils/tasks.js b/ryoku/shell/quickshell/shell/utils/tasks.js new file mode 100644 index 0000000000..c0c5a751c1 --- /dev/null +++ b/ryoku/shell/quickshell/shell/utils/tasks.js @@ -0,0 +1,58 @@ +// ps rows for the Game Bar tasks card. The command is +// `ps -eo pid=,comm=,cputimes=,etimes=,rss=`: comm may contain spaces, so the +// numeric columns are peeled off both ends and whatever remains is the name. +// cputimes counts clock ticks of CPU time over the process lifetime, which is +// the average load, not an instantaneous sample: good enough for a short list. + +const CLK_TCK = 100; + +const PROTECTED = [ + "systemd", "systemd-logind", "logind", "dbus-daemon", "dbus-broker", + "niri", "hyprland", "xwayland", "xorg", "qs", "quickshell", + "ryoku-shell", "ryoku-wm", "ryoku-wm-niri", "ryoku-host", + "pipewire", "pipewire-pulse", "wireplumber", "gnome-keyring", + "polkitd", "swaybg", "mpvpaper", "prowl", "gpu-screen-rec" +]; + +function parse(text) { + const out = []; + const rows = String(text || "").split("\n"); + for (let i = 0; i < rows.length; i++) { + const line = rows[i].trim(); + if (line.length === 0) + continue; + const parts = line.split(/\s+/); + if (parts.length < 5) + continue; + const pid = parseInt(parts[0], 10); + const rssKb = parseInt(parts[parts.length - 1], 10); + const secs = parseInt(parts[parts.length - 2], 10); + const ticks = parseInt(parts[parts.length - 3], 10); + const name = parts.slice(1, parts.length - 3).join(" "); + if (!(pid > 0) || name.length === 0) + continue; + const cpu = Math.max(0, (ticks / CLK_TCK) / Math.max(1, secs) * 100); + out.push({ + pid: pid, + name: name, + cpu: cpu, + memMiB: (rssKb || 0) / 1024, + secs: secs || 0 + }); + } + return out; +} + +function isProtected(name) { + const key = String(name || "").toLowerCase(); + return PROTECTED.indexOf(key) !== -1; +} + +function top(text, count) { + const rows = parse(text); + rows.sort((a, b) => b.cpu - a.cpu || b.memMiB - a.memMiB); + return rows.slice(0, count || 5); +} + +if (typeof module !== "undefined" && module.exports) + module.exports = { parse, isProtected, top, PROTECTED }; diff --git a/ryoku/shell/quickshell/shell/utils/tasks.test.mjs b/ryoku/shell/quickshell/shell/utils/tasks.test.mjs new file mode 100644 index 0000000000..48599e61f2 --- /dev/null +++ b/ryoku/shell/quickshell/shell/utils/tasks.test.mjs @@ -0,0 +1,44 @@ +import { createRequire } from "node:module"; +const require = createRequire(import.meta.url); +const { parse, isProtected, top } = require("./tasks.js"); + +let failed = 0; +function eq(actual, expected, msg) { + const a = JSON.stringify(actual); + const e = JSON.stringify(expected); + if (a === e) console.log("PASS " + msg); + else { failed++; console.log("FAIL " + msg + "\n expected " + e + "\n got " + a); } +} +function ok(cond, msg) { eq(!!cond, true, msg); } +function near(actual, expected, msg) { + ok(Math.abs(actual - expected) < 0.001, msg + " (" + actual + ")"); +} + +const sample = [ + " 1 systemd 2 3442 16524", + " 4242 firefox 2400 120 812340", + " 5150 Web Content 1200 60 340012", + " 6001 kitty 50 300 51200", + "" +].join("\n"); + +const rows = parse(sample); +eq(rows.length, 4, "parses every row"); +eq(rows[0].pid, 1, "reads pid"); +eq(rows[0].name, "systemd", "reads a plain name"); +eq(rows[2].name, "Web Content", "keeps spaces in comm"); +eq(rows[2].memMiB, 332.04296875, "rss kB becomes MiB"); +near(rows[1].cpu, 20, "cpu averages ticks over lifetime"); +near(rows[3].cpu, 0.16666666666666669, "short lived process still gets a rate"); +eq(isProtected("niri"), true, "compositor is protected"); +eq(isProtected("ryoku-shell"), true, "shell is protected"); +eq(isProtected("firefox"), false, "an app is not protected"); +eq(top(sample, 2).map((r) => r.name), ["firefox", "Web Content"], "top sorts by cpu then memory"); +eq(parse("").length, 0, "empty input parses to nothing"); +eq(parse("garbage\n\n").length, 0, "junk lines are skipped"); + +if (failed > 0) { + console.log(failed + " failure(s)"); + process.exit(1); +} +console.log("all tasks tests passed"); diff --git a/ryoku/shell/quickshell/shell/utils/units.js b/ryoku/shell/quickshell/shell/utils/units.js new file mode 100644 index 0000000000..4e5a6584e9 --- /dev/null +++ b/ryoku/shell/quickshell/shell/utils/units.js @@ -0,0 +1,44 @@ +// Compact readouts for the Game Bar cards: bytes, rates, storage, uptime. +// Decimal for network rates (what a link is sold in) and binary for memory +// (what the kernel reports), so the numbers stay honest to their source. + +function bytes(v) { + const n = Number(v) || 0; + if (n < 1000) return Math.round(n) + " B"; + if (n < 1000 * 1000) return (n / 1000).toFixed(1) + " kB"; + if (n < 1000 * 1000 * 1000) return (n / 1000000).toFixed(1) + " MB"; + return (n / 1000000000).toFixed(1) + " GB"; +} + +function rate(bps) { + const n = Number(bps) || 0; + return n <= 0 ? "0" : bytes(n) + "/s"; +} + +function gib(v) { + return (Number(v) || 0).toFixed(1); +} + +function pct(v) { + return Math.round((Number(v) || 0) * 100) + "%"; +} + +function uptime(sec) { + let s = Math.max(0, Math.floor(Number(sec) || 0)); + const d = Math.floor(s / 86400); + s %= 86400; + const h = Math.floor(s / 3600); + s %= 3600; + const m = Math.floor(s / 60); + if (d > 0) return d + "d " + h + "h"; + if (h > 0) return h + "h " + m + "m"; + return m + "m"; +} + +function hz(mode) { + const m = /@([0-9]+(?:\.[0-9]+)?)/.exec(String(mode || "")); + return m ? Math.round(parseFloat(m[1])) : 0; +} + +if (typeof module !== "undefined" && module.exports) + module.exports = { bytes, rate, gib, pct, uptime, hz }; diff --git a/ryoku/shell/quickshell/shell/utils/units.test.mjs b/ryoku/shell/quickshell/shell/utils/units.test.mjs new file mode 100644 index 0000000000..507fd754e2 --- /dev/null +++ b/ryoku/shell/quickshell/shell/utils/units.test.mjs @@ -0,0 +1,33 @@ +import { createRequire } from "node:module"; +const require = createRequire(import.meta.url); +const { bytes, rate, gib, pct, uptime, hz } = require("./units.js"); + +let failed = 0; +function eq(actual, expected, msg) { + const a = JSON.stringify(actual); + const e = JSON.stringify(expected); + if (a === e) console.log("PASS " + msg); + else { failed++; console.log("FAIL " + msg + "\n expected " + e + "\n got " + a); } +} + +eq(bytes(0), "0 B", "zero bytes"); +eq(bytes(999), "999 B", "bytes under a kilo"); +eq(bytes(1500), "1.5 kB", "kilobytes"); +eq(bytes(2400000), "2.4 MB", "megabytes"); +eq(bytes(2400000000), "2.4 GB", "gigabytes"); +eq(rate(0), "0", "idle network reads as zero"); +eq(rate(1250000), "1.3 MB/s", "rate adds a per second suffix"); +eq(gib(15.6), "15.6", "one decimal of gibibytes"); +eq(pct(0.42), "42%", "fraction to percent"); +eq(uptime(90), "1m", "under an hour"); +eq(uptime(3 * 3600 + 12 * 60), "3h 12m", "hours and minutes"); +eq(uptime(2 * 86400 + 5 * 3600), "2d 5h", "days and hours"); +eq(hz("1920x1080@200.000"), 200, "refresh rate out of a mode string"); +eq(hz("2560x1440@59.951"), 60, "rounds a fractional refresh"); +eq(hz(""), 0, "no mode, no rate"); + +if (failed > 0) { + console.log(failed + " failure(s)"); + process.exit(1); +} +console.log("all units tests passed"); diff --git a/ryoku/shell/ryogami/daemon/vendor/ryoku-wm/binds.go b/ryoku/shell/ryogami/daemon/vendor/ryoku-wm/binds.go index 8577fe4a32..6b3e581ff7 100644 --- a/ryoku/shell/ryogami/daemon/vendor/ryoku-wm/binds.go +++ b/ryoku/shell/ryogami/daemon/vendor/ryoku-wm/binds.go @@ -210,6 +210,7 @@ var catalogue = []CatalogBind{ {ID: "shell.ask", Category: "Shell", Label: "Ask Rashin", Hint: "AI chat bar", Chord: "ALT + Space", Kind: BindShell, Needs: "ryoku-rashin"}, {ID: "shell.rashin", Category: "Shell", Label: "Rashin", Hint: "The AI companion app; a second press focuses it", Chord: "SUPER + ALT + Space", Kind: BindShell, Needs: "rashin-app"}, {ID: "shell.cheatsheet", Category: "Shell", Label: "Kit", Hint: "Press again to close", Chord: "SUPER + K", Kind: BindShell}, + {ID: "shell.gamebar", Category: "Shell", Label: "Game Bar", Hint: "Capture, performance and tasks over a fullscreen game", Chord: "SUPER + SHIFT + G", Kind: BindShell}, {ID: "shell.lock", Category: "Shell", Label: "Lock the screen", Chord: "SUPER + L", Kind: BindShell}, {ID: "shell.quicksettings", Category: "Shell", Label: "Quick settings", Hint: "Power, logout, restart, shutdown, wifi", Chord: "SUPER + Escape", Kind: BindShell}, {ID: "shell.wallpaper", Category: "Shell", Label: "Wallpaper picker", Chord: "SUPER + W", Kind: BindShell}, diff --git a/ryoku/wm/binds.go b/ryoku/wm/binds.go index 8577fe4a32..6b3e581ff7 100644 --- a/ryoku/wm/binds.go +++ b/ryoku/wm/binds.go @@ -210,6 +210,7 @@ var catalogue = []CatalogBind{ {ID: "shell.ask", Category: "Shell", Label: "Ask Rashin", Hint: "AI chat bar", Chord: "ALT + Space", Kind: BindShell, Needs: "ryoku-rashin"}, {ID: "shell.rashin", Category: "Shell", Label: "Rashin", Hint: "The AI companion app; a second press focuses it", Chord: "SUPER + ALT + Space", Kind: BindShell, Needs: "rashin-app"}, {ID: "shell.cheatsheet", Category: "Shell", Label: "Kit", Hint: "Press again to close", Chord: "SUPER + K", Kind: BindShell}, + {ID: "shell.gamebar", Category: "Shell", Label: "Game Bar", Hint: "Capture, performance and tasks over a fullscreen game", Chord: "SUPER + SHIFT + G", Kind: BindShell}, {ID: "shell.lock", Category: "Shell", Label: "Lock the screen", Chord: "SUPER + L", Kind: BindShell}, {ID: "shell.quicksettings", Category: "Shell", Label: "Quick settings", Hint: "Power, logout, restart, shutdown, wifi", Chord: "SUPER + Escape", Kind: BindShell}, {ID: "shell.wallpaper", Category: "Shell", Label: "Wallpaper picker", Chord: "SUPER + W", Kind: BindShell}, diff --git a/ryoku/wm/niri/config_binds.go b/ryoku/wm/niri/config_binds.go index 63fb66d3c1..08f5d64307 100644 --- a/ryoku/wm/niri/config_binds.go +++ b/ryoku/wm/niri/config_binds.go @@ -165,6 +165,7 @@ func shippedBinds() map[string]niriBind { "shell.ask": {action: spawnArgs("ryoku-shell", "ask")}, "shell.rashin": {action: spawnSh("ryoku-summon Rashin flock -n -o /tmp/rashin-app.lock rashin-app")}, "shell.cheatsheet": {action: spawnArgs("ryoku-shell", "cheatsheet")}, + "shell.gamebar": {action: spawnArgs("ryoku-shell", "gamebar"), ignoreInhibitor: true}, "shell.lock": {action: spawnArgs("ryoku-shell", "lock")}, "shell.quicksettings": {action: spawnArgs("ryoku-shell", "quicksettings"), ignoreInhibitor: true}, "shell.wallpaper": {action: spawnArgs("ryogami", "wallpaper", "ui")}, diff --git a/ryoku/wm/wayfire/apply_test.go b/ryoku/wm/wayfire/apply_test.go index efc68cef38..5454a97894 100644 --- a/ryoku/wm/wayfire/apply_test.go +++ b/ryoku/wm/wayfire/apply_test.go @@ -428,6 +428,8 @@ command_shell_rashin = ryoku-summon Rashin flock -n -o /tmp/rashin-app.lock rash binding_shell_rashin = KEY_SPACE command_shell_cheatsheet = ryoku-shell cheatsheet binding_shell_cheatsheet = KEY_K +command_shell_gamebar = ryoku-shell gamebar +binding_shell_gamebar = KEY_G command_shell_lock = ryoku-shell lock binding_shell_lock = KEY_L command_shell_quicksettings = ryoku-shell quicksettings diff --git a/ryoku/wm/wayfire/config_binds.go b/ryoku/wm/wayfire/config_binds.go index aa256a3893..a60b5dfa9b 100644 --- a/ryoku/wm/wayfire/config_binds.go +++ b/ryoku/wm/wayfire/config_binds.go @@ -301,6 +301,7 @@ func shippedBinds() map[string]wayfireBind { "shell.ask": {key: "shell_ask", cmd: "ryoku-shell ask"}, "shell.rashin": {key: "shell_rashin", cmd: "ryoku-summon Rashin flock -n -o /tmp/rashin-app.lock rashin-app"}, "shell.cheatsheet": {key: "shell_cheatsheet", cmd: "ryoku-shell cheatsheet"}, + "shell.gamebar": {key: "shell_gamebar", cmd: "ryoku-shell gamebar"}, "shell.lock": {key: "shell_lock", cmd: "ryoku-shell lock"}, "shell.quicksettings": {key: "shell_quicksettings", cmd: "ryoku-shell quicksettings"}, "shell.wallpaper": {key: "shell_wallpaper", cmd: "ryogami wallpaper ui"}, From e071293f86483054ca0503dc17a2c90c033abb79 Mon Sep 17 00:00:00 2001 From: dodo986 <264469474+dodo986@users.noreply.github.com> Date: Mon, 9 Nov 2026 16:39:47 +0300 Subject: [PATCH 2/5] [ryoku] gamebar: address review findings Only the active output fills its cards, card loaders wait for the surface to be live, the exit timer follows the fade, and capture suppression follows the selection overlay instead of polling. Gallery gains a screenshots/recordings switch with play tiles and file names. Voice delegates bind the participant explicitly. The bridge ignores non-object companion messages, the companion clears queued lines on disconnect, the installer cannot swallow a failed build, and the tasks card reads cputimes as seconds. Note: Fixed: the Game Bar hides for captures, lists recordings, shows Voice participants and reports real CPU use --- docs/gamebar.md | 3 +- .../shell/modules/gamebar/GameBar.qml | 26 ++++--- .../modules/gamebar/cards/GalleryCard.qml | 73 +++++++++++++++---- .../shell/modules/gamebar/cards/VoiceCard.qml | 4 + .../modules/gamebar/discordVoice/bridge.py | 2 + .../gamebar/discordVoice/companion/native.ts | 1 + .../gamebar/discordVoice/install_companion.sh | 6 +- ryoku/shell/quickshell/shell/utils/tasks.js | 10 +-- .../quickshell/shell/utils/tasks.test.mjs | 4 +- 9 files changed, 93 insertions(+), 36 deletions(-) diff --git a/docs/gamebar.md b/docs/gamebar.md index a7a46008a7..141de29530 100644 --- a/docs/gamebar.md +++ b/docs/gamebar.md @@ -46,7 +46,8 @@ before a capture so it is never in its own screenshot or recording. - **Audio** carries the same output and application faders as the audio menu. A level set here is the level PipeWire holds everywhere. - **Gallery** shows the newest screenshots and recordings as the files - themselves, plus a button to open either capture folder. + themselves, with a switch between the two lists and a button to open the + current folder. ## Placement diff --git a/ryoku/shell/quickshell/shell/modules/gamebar/GameBar.qml b/ryoku/shell/quickshell/shell/modules/gamebar/GameBar.qml index 549c3f3c5b..0acda1ee98 100644 --- a/ryoku/shell/quickshell/shell/modules/gamebar/GameBar.qml +++ b/ryoku/shell/quickshell/shell/modules/gamebar/GameBar.qml @@ -400,7 +400,8 @@ PanelWindow { onCardsChanged: if (win.active) win.shownCards = win.cards Component.onCompleted: { win.live = win.active; - win.shownCards = win.cards; + if (win.active) + win.shownCards = win.cards; if (!win.cfg.enabled) GameBarStore.closeBar(); } @@ -503,7 +504,7 @@ PanelWindow { Timer { id: exitTimer - interval: 240 + interval: Math.max(1, win.fadeMs) onTriggered: { if (win.wanted) { win.live = true; @@ -611,7 +612,7 @@ PanelWindow { Loader { id: cardLoader anchors.fill: parent - active: slot.on + active: slot.on && win.live sourceComponent: win.componentFor(slot.cardId) } } @@ -667,10 +668,8 @@ PanelWindow { win.sawSelecting = false; win.suppressed = true; Capture.shoot(mode); - if (mode === "all") + if (mode === "all" || Capture.selecting === "") win.restoreAfter(Capture.delay * 1000 + 800); - else - restorePoll.restart(); } function startRecording() { @@ -684,18 +683,21 @@ PanelWindow { restoreDelay.restart(); } - Timer { - id: restorePoll - interval: 150 - repeat: true - onTriggered: { + // Follow the selection overlay itself instead of polling for it: a fast + // click can open and close the overlay between two timer ticks, and a + // selection that never reaches the source must not leave the bar hidden. + Connections { + target: Capture + function onSelectingChanged() { + if (!win.suppressed) + return; if (Capture.selecting !== "") { win.sawSelecting = true; return; } if (!win.sawSelecting) return; - restorePoll.stop(); + win.sawSelecting = false; win.restoreAfter(Capture.delay * 1000 + 700); } } diff --git a/ryoku/shell/quickshell/shell/modules/gamebar/cards/GalleryCard.qml b/ryoku/shell/quickshell/shell/modules/gamebar/cards/GalleryCard.qml index 6e9ed69aed..3b4139c647 100644 --- a/ryoku/shell/quickshell/shell/modules/gamebar/cards/GalleryCard.qml +++ b/ryoku/shell/quickshell/shell/modules/gamebar/cards/GalleryCard.qml @@ -4,12 +4,13 @@ import QtQuick import Qt.labs.folderlistmodel import shell.services import ".." +import "../../../components" import Ryoku.Ui.Singletons import "../look" -// Gallery: the newest screenshots, live from the folder the capture path -// writes, plus one press to open either capture folder. Thumbnails are the -// files themselves, so there is no separate index to fall out of date. +// Gallery: the newest screenshots and recordings, live from the folders the +// capture path writes, plus one press to open the current folder. Thumbnails +// are the files themselves, so there is no separate index to fall out of date. GameBarCard { id: card @@ -18,7 +19,9 @@ GameBarCard { readonly property string shotsDir: Capture.shotsDir readonly property string clipsDir: Paths.recordingsDir - function newest(model, count) { + property string mode: "shots" + + function newest(model, count, kind) { const all = []; for (let i = 0; i < model.count; i++) { const url = model.get(i, "fileUrl"); @@ -30,6 +33,8 @@ GameBarCard { all.push({ url: "" + url, path: "" + model.get(i, "filePath"), + name: "" + model.get(i, "fileName"), + kind: kind, t: when }); } @@ -37,7 +42,10 @@ GameBarCard { return all.slice(0, count); } - readonly property var shots: card.newest(shotFiles, 6) + readonly property var shots: card.newest(shotFiles, 6, "shot") + readonly property var clips: card.newest(clipFiles, 6, "clip") + readonly property var items: card.mode === "clips" ? card.clips : card.shots + readonly property string folder: card.mode === "clips" ? card.clipsDir : card.shotsDir FolderListModel { id: shotFiles @@ -46,6 +54,13 @@ GameBarCard { nameFilters: ["*.png", "*.jpg", "*.jpeg", "*.webp"] } + FolderListModel { + id: clipFiles + folder: "file://" + card.clipsDir + showDirs: false + nameFilters: ["*.mp4", "*.mkv", "*.webm", "*.mov"] + } + Column { width: parent.width spacing: 8 * card.s @@ -57,8 +72,10 @@ GameBarCard { Text { anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter - text: card.shots.length === 1 - ? I18n.tr("1 recent shot") : card.shots.length + " " + I18n.tr("recent shots") + text: card.items.length === 1 + ? (card.mode === "clips" ? I18n.tr("1 recent clip") : I18n.tr("1 recent shot")) + : card.items.length + " " + (card.mode === "clips" + ? I18n.tr("recent clips") : I18n.tr("recent shots")) color: GameBarLook.subtext font.family: GameBarLook.face font.pixelSize: GameBarLook.smallie * card.s @@ -70,7 +87,7 @@ GameBarCard { s: card.s compact: true label: I18n.tr("Folder") - onClicked: Spawn.run(["xdg-open", card.shotsDir]) + onClicked: Spawn.run(["xdg-open", card.folder]) } } @@ -81,7 +98,7 @@ GameBarCard { spacing: 6 * card.s Repeater { - model: card.shots + model: card.items delegate: Item { id: thumb @@ -98,10 +115,35 @@ GameBarCard { Image { anchors.fill: parent - source: thumb.modelData.url + source: thumb.modelData.kind === "shot" ? thumb.modelData.url : "" + visible: thumb.modelData.kind === "shot" asynchronous: true fillMode: Image.PreserveAspectCrop } + + GlyphIcon { + anchors.centerIn: parent + anchors.verticalCenterOffset: -5 * card.s + width: 24 * card.s + height: 24 * card.s + visible: thumb.modelData.kind === "clip" + name: "play" + color: GameBarLook.fg + } + + Text { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: 4 * card.s + visible: thumb.modelData.kind === "clip" + text: thumb.modelData.name + color: GameBarLook.fg + font.family: GameBarLook.face + font.pixelSize: GameBarLook.smallest * card.s + elide: Text.ElideMiddle + } + MouseArea { anchors.fill: parent hoverEnabled: true @@ -114,9 +156,10 @@ GameBarCard { } Text { - visible: card.shots.length === 0 + visible: card.items.length === 0 width: parent.width - text: I18n.tr("No screenshots yet") + text: card.mode === "clips" + ? I18n.tr("No recordings yet") : I18n.tr("No screenshots yet") color: GameBarLook.subtext horizontalAlignment: Text.AlignHCenter font.family: GameBarLook.face @@ -129,13 +172,15 @@ GameBarCard { GameBarTextButton { s: card.s + primary: card.mode === "shots" label: I18n.tr("Screenshots") - onClicked: Spawn.run(["xdg-open", card.shotsDir]) + onClicked: card.mode = "shots" } GameBarTextButton { s: card.s + primary: card.mode === "clips" label: I18n.tr("Recordings") - onClicked: Spawn.run(["xdg-open", card.clipsDir]) + onClicked: card.mode = "clips" } } } diff --git a/ryoku/shell/quickshell/shell/modules/gamebar/cards/VoiceCard.qml b/ryoku/shell/quickshell/shell/modules/gamebar/cards/VoiceCard.qml index 97d1dd1de9..6a7467428b 100644 --- a/ryoku/shell/quickshell/shell/modules/gamebar/cards/VoiceCard.qml +++ b/ryoku/shell/quickshell/shell/modules/gamebar/cards/VoiceCard.qml @@ -212,6 +212,8 @@ GameBarCard { delegate: ParticipantAvatar { required property int index + required property var modelData + participant: modelData s: card.s visible: index < card.avatarLimit dimOthers: card.dimOthers @@ -430,6 +432,8 @@ GameBarCard { delegate: ParticipantAvatar { required property int index + required property var modelData + participant: modelData width: card.miniCell visible: index < card.avatarLimit s: card.s diff --git a/ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/bridge.py b/ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/bridge.py index 74c214d2ee..1a39853cce 100755 --- a/ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/bridge.py +++ b/ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/bridge.py @@ -180,6 +180,8 @@ async def companion_client(self, reader: asyncio.StreamReader, message = json.loads(line) except (ValueError, TypeError): continue + if not isinstance(message, dict): + continue state = message.get("state") if message.get("type") == "state" else None if isinstance(state, dict): self.companion_state(state) diff --git a/ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/companion/native.ts b/ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/companion/native.ts index e4473690c4..639ca292db 100644 --- a/ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/companion/native.ts +++ b/ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/companion/native.ts @@ -88,6 +88,7 @@ export function disconnect(_: IpcMainInvokeEvent) { socket?.destroy(); socket = undefined; reset(); + queue.length = 0; for (const waiter of waiters.splice(0)) waiter(""); } diff --git a/ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/install_companion.sh b/ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/install_companion.sh index 8bd3bbd861..2f1c37c089 100755 --- a/ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/install_companion.sh +++ b/ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/install_companion.sh @@ -139,6 +139,10 @@ fi status=0 for client in "${clients[@]}"; do - install_client "$client" || status=1 + if (set -e; install_client "$client"); then + : + else + status=1 + fi done exit "$status" diff --git a/ryoku/shell/quickshell/shell/utils/tasks.js b/ryoku/shell/quickshell/shell/utils/tasks.js index c0c5a751c1..06d632c59e 100644 --- a/ryoku/shell/quickshell/shell/utils/tasks.js +++ b/ryoku/shell/quickshell/shell/utils/tasks.js @@ -1,10 +1,8 @@ // ps rows for the Game Bar tasks card. The command is // `ps -eo pid=,comm=,cputimes=,etimes=,rss=`: comm may contain spaces, so the // numeric columns are peeled off both ends and whatever remains is the name. -// cputimes counts clock ticks of CPU time over the process lifetime, which is -// the average load, not an instantaneous sample: good enough for a short list. - -const CLK_TCK = 100; +// cputimes is CPU time in seconds over the process lifetime, which is the +// average load, not an instantaneous sample: good enough for a short list. const PROTECTED = [ "systemd", "systemd-logind", "logind", "dbus-daemon", "dbus-broker", @@ -27,11 +25,11 @@ function parse(text) { const pid = parseInt(parts[0], 10); const rssKb = parseInt(parts[parts.length - 1], 10); const secs = parseInt(parts[parts.length - 2], 10); - const ticks = parseInt(parts[parts.length - 3], 10); + const cpuSecs = parseInt(parts[parts.length - 3], 10); const name = parts.slice(1, parts.length - 3).join(" "); if (!(pid > 0) || name.length === 0) continue; - const cpu = Math.max(0, (ticks / CLK_TCK) / Math.max(1, secs) * 100); + const cpu = Math.max(0, cpuSecs / Math.max(1, secs) * 100); out.push({ pid: pid, name: name, diff --git a/ryoku/shell/quickshell/shell/utils/tasks.test.mjs b/ryoku/shell/quickshell/shell/utils/tasks.test.mjs index 48599e61f2..7cb434e8f9 100644 --- a/ryoku/shell/quickshell/shell/utils/tasks.test.mjs +++ b/ryoku/shell/quickshell/shell/utils/tasks.test.mjs @@ -28,8 +28,8 @@ eq(rows[0].pid, 1, "reads pid"); eq(rows[0].name, "systemd", "reads a plain name"); eq(rows[2].name, "Web Content", "keeps spaces in comm"); eq(rows[2].memMiB, 332.04296875, "rss kB becomes MiB"); -near(rows[1].cpu, 20, "cpu averages ticks over lifetime"); -near(rows[3].cpu, 0.16666666666666669, "short lived process still gets a rate"); +near(rows[1].cpu, 2000, "cpu averages cputime over lifetime"); +near(rows[3].cpu, 16.666666666666668, "short lived process still gets a rate"); eq(isProtected("niri"), true, "compositor is protected"); eq(isProtected("ryoku-shell"), true, "shell is protected"); eq(isProtected("firefox"), false, "an app is not protected"); From bde442cc5abac73d635d39bf137cd84536d40987 Mon Sep 17 00:00:00 2001 From: dodo986 <264469474+dodo986@users.noreply.github.com> Date: Mon, 9 Nov 2026 16:40:56 +0300 Subject: [PATCH 3/5] [ryoku] gamebar: type the voice channel id as optional SelectedChannelStore may return no channel, and the companion build accepts the undefined case at runtime. Say so in the signature so tsc --noEmit passes. --- .../shell/modules/gamebar/discordVoice/companion/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/companion/index.ts b/ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/companion/index.ts index 48cce277e1..7c450f981b 100644 --- a/ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/companion/index.ts +++ b/ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/companion/index.ts @@ -42,7 +42,7 @@ function participant(userId: string, state: any, guildId?: string) { }; } -function activeSpeakers(channelId: string) { +function activeSpeakers(channelId: string | null | undefined) { const ids = new Set(); if (channelId) { for (const peer of ChannelRTCStore.getSpeakingParticipants(channelId) || []) { From fc0d9441caa2c9f33161ed633b8e87fa1740d872 Mon Sep 17 00:00:00 2001 From: dodo986 <264469474+dodo986@users.noreply.github.com> Date: Mon, 9 Nov 2026 16:56:06 +0300 Subject: [PATCH 4/5] [ryoku] gamebar: fix the remaining review findings Stop a pending capture restore before a new one and keep the bar hidden for the whole recording, clearing that hold when Recorder stops. Stop the exit timer when the surface comes back. Keep a task's End confirmation while its PID survives a refresh, and end the name at the CPU column. Make the audio app list scroll past three rows. Re-read the display mode when the monitor changes. Refresh the gallery when a folder count changes. Follow only the current user's processes and filter them from ps. Guard the companion's stale socket handlers, time out the RPC handshake, emit escaped JSON, and use a valid Discord avatar size. Note: Fixed: recordings keep the bar hidden, the audio app list scrolls, task rows keep their columns, and the process list stays yours --- docs/gamebar.md | 10 ++-- .../shell/modules/gamebar/GameBar.qml | 48 ++++++++++++++++++- .../shell/modules/gamebar/GameBarTaskRow.qml | 21 ++++++-- .../shell/modules/gamebar/cards/AudioCard.qml | 13 +++-- .../modules/gamebar/cards/GalleryCard.qml | 10 +++- .../modules/gamebar/cards/PerformanceCard.qml | 18 ++++++- .../shell/modules/gamebar/cards/TasksCard.qml | 6 ++- .../modules/gamebar/discordVoice/bridge.py | 8 +++- .../gamebar/discordVoice/companion/native.ts | 8 ++-- .../shell/services/DiscordVoice.qml | 2 +- ryoku/shell/quickshell/shell/utils/tasks.js | 23 +++++---- .../quickshell/shell/utils/tasks.test.mjs | 13 +++-- 12 files changed, 142 insertions(+), 38 deletions(-) diff --git a/docs/gamebar.md b/docs/gamebar.md index 141de29530..cccf2c89ea 100644 --- a/docs/gamebar.md +++ b/docs/gamebar.md @@ -37,14 +37,16 @@ before a capture so it is never in its own screenshot or recording. an in-game frame counter, so the card never claims one. - **Resources** has a tab each for CPU, GPU and memory: the current reading in large numerals, the total it is out of, and a short graph. -- **Tasks** lists the five busiest processes by average CPU with their memory, - and ends one through a two-step End. Desktop processes are protected, so the - list cannot offer to kill the compositor or the shell it floats above. +- **Tasks** lists the five busiest of your processes by average CPU with their + memory, and ends one through a two-step End. Desktop processes are protected, + so the list cannot offer to kill the compositor or the shell it floats above. + Other users' processes are never listed. - **Media** follows the one now-playing pick the whole shell shares, with art, title, artist and transport. - **Voice** shows the Discord call (see below). - **Audio** carries the same output and application faders as the audio menu. - A level set here is the level PipeWire holds everywhere. + A level set here is the level PipeWire holds everywhere. The app list scrolls + when more than three apps are playing. - **Gallery** shows the newest screenshots and recordings as the files themselves, with a switch between the two lists and a button to open the current folder. diff --git a/ryoku/shell/quickshell/shell/modules/gamebar/GameBar.qml b/ryoku/shell/quickshell/shell/modules/gamebar/GameBar.qml index 0acda1ee98..87ea64eed3 100644 --- a/ryoku/shell/quickshell/shell/modules/gamebar/GameBar.qml +++ b/ryoku/shell/quickshell/shell/modules/gamebar/GameBar.qml @@ -131,6 +131,7 @@ PanelWindow { property bool suppressed: false property bool sawSelecting: false + property bool hidingForRecording: false // A pinned card outlives the strip: the strip leaves, the cards stay. During // a full close the strip is held for the fade-out and then reset. @@ -383,6 +384,7 @@ PanelWindow { onActiveChanged: { if (win.active) { + exitTimer.stop(); win.live = true; win.shownCards = win.cards; } else { @@ -393,6 +395,7 @@ PanelWindow { onWantedChanged: { if (!win.wanted) return; + exitTimer.stop(); win.live = true; if (win.shownCards.length === 0) win.shownCards = win.cards; @@ -665,6 +668,8 @@ PanelWindow { function shoot(mode) { if (Capture.selecting !== "") return; + restoreDelay.stop(); + recordStartFallback.stop(); win.sawSelecting = false; win.suppressed = true; Capture.shoot(mode); @@ -673,9 +678,19 @@ PanelWindow { } function startRecording() { + restoreDelay.stop(); + win.hidingForRecording = true; win.suppressed = true; Recorder.startAfter(Recorder.recordArgs(), 1); - win.restoreAfter(1900); + recordStartFallback.restart(); + } + + function endRecordingSuppression() { + if (!win.hidingForRecording) + return; + win.hidingForRecording = false; + recordStartFallback.stop(); + win.restoreAfter(900); } function restoreAfter(ms) { @@ -702,9 +717,38 @@ PanelWindow { } } + // A running capture would otherwise record the bar: hold suppression until + // Recorder reports the capture ended. The fallback covers a countdown that + // was cancelled or a recorder that never came up. + Connections { + target: Recorder + function onAnyActiveChanged() { + if (!win.hidingForRecording) + return; + if (Recorder.anyActive) { + recordStartFallback.stop(); + return; + } + win.endRecordingSuppression(); + } + } + + Timer { + id: recordStartFallback + interval: 4000 + onTriggered: { + if (win.hidingForRecording && !Recorder.anyActive && !Recorder.countingDown) + win.endRecordingSuppression(); + } + } + Timer { id: restoreDelay - onTriggered: win.suppressed = false + onTriggered: { + if (win.hidingForRecording && Recorder.anyActive) + return; + win.suppressed = false; + } } Component { diff --git a/ryoku/shell/quickshell/shell/modules/gamebar/GameBarTaskRow.qml b/ryoku/shell/quickshell/shell/modules/gamebar/GameBarTaskRow.qml index c1f29071eb..321f325067 100644 --- a/ryoku/shell/quickshell/shell/modules/gamebar/GameBarTaskRow.qml +++ b/ryoku/shell/quickshell/shell/modules/gamebar/GameBarTaskRow.qml @@ -20,16 +20,22 @@ Item { signal endRequested(int pid) property bool confirming: false + property int confirmingPid: 0 implicitHeight: 32 * root.s implicitWidth: 276 * root.s - onTaskChanged: confirming = false + onTaskChanged: { + if (!root.confirming) + return; + if (!root.task || root.task.pid !== root.confirmingPid) + root.confirming = false; + } Text { anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter - width: Math.max(0, actions.x - 12 * root.s) + width: Math.max(0, cpu.x - 12 * root.s) text: root.task ? root.task.name : "" color: GameBarLook.fg font.family: GameBarLook.face @@ -83,7 +89,10 @@ Item { s: root.s compact: true label: I18n.tr("End") - onClicked: root.confirming = true + onClicked: { + root.confirmingPid = root.task ? root.task.pid : 0; + root.confirming = true; + } } GameBarTextButton { @@ -96,6 +105,7 @@ Item { if (root.task) root.endRequested(root.task.pid); root.confirming = false; + root.confirmingPid = 0; } } @@ -105,7 +115,10 @@ Item { compact: true quiet: true label: I18n.tr("No") - onClicked: root.confirming = false + onClicked: { + root.confirming = false; + root.confirmingPid = 0; + } } } } diff --git a/ryoku/shell/quickshell/shell/modules/gamebar/cards/AudioCard.qml b/ryoku/shell/quickshell/shell/modules/gamebar/cards/AudioCard.qml index 1f99097b44..e638c1f63e 100644 --- a/ryoku/shell/quickshell/shell/modules/gamebar/cards/AudioCard.qml +++ b/ryoku/shell/quickshell/shell/modules/gamebar/cards/AudioCard.qml @@ -67,12 +67,19 @@ GameBarCard { font.pixelSize: GameBarLook.smallie * card.s } - Repeater { - model: card.streams.slice(0, 3) + ListView { + id: apps + width: parent.width + height: Math.min(contentHeight, 3 * 46 * card.s) + clip: true + spacing: 4 * card.s + boundsBehavior: Flickable.StopAtBounds + interactive: contentHeight > height + model: card.streams delegate: AudioAppRow { required property var modelData - width: parent.width + width: apps.width s: card.s stream: modelData open: true diff --git a/ryoku/shell/quickshell/shell/modules/gamebar/cards/GalleryCard.qml b/ryoku/shell/quickshell/shell/modules/gamebar/cards/GalleryCard.qml index 3b4139c647..ff1c589ad9 100644 --- a/ryoku/shell/quickshell/shell/modules/gamebar/cards/GalleryCard.qml +++ b/ryoku/shell/quickshell/shell/modules/gamebar/cards/GalleryCard.qml @@ -42,8 +42,14 @@ GameBarCard { return all.slice(0, count); } - readonly property var shots: card.newest(shotFiles, 6, "shot") - readonly property var clips: card.newest(clipFiles, 6, "clip") + readonly property var shots: { + shotFiles.count; + return card.newest(shotFiles, 6, "shot"); + } + readonly property var clips: { + clipFiles.count; + return card.newest(clipFiles, 6, "clip"); + } readonly property var items: card.mode === "clips" ? card.clips : card.shots readonly property string folder: card.mode === "clips" ? card.clipsDir : card.shotsDir diff --git a/ryoku/shell/quickshell/shell/modules/gamebar/cards/PerformanceCard.qml b/ryoku/shell/quickshell/shell/modules/gamebar/cards/PerformanceCard.qml index 975d145540..3ec62b21bb 100644 --- a/ryoku/shell/quickshell/shell/modules/gamebar/cards/PerformanceCard.qml +++ b/ryoku/shell/quickshell/shell/modules/gamebar/cards/PerformanceCard.qml @@ -29,16 +29,32 @@ GameBarCard { return out ? out.width + " x " + out.height : "n/a"; } + function readMode() { + card.displayMode = ""; + if (modeRead.running) + modeRead.running = false; + modeRestart.restart(); + } + Component.onCompleted: { Sysinfo.setActive(card, true); StatsFeed.setActive(card, true); - modeRead.running = true; + card.readMode(); } + onMonitorNameChanged: card.readMode() Component.onDestruction: { Sysinfo.setActive(card, false); StatsFeed.setActive(card, false); } + // restarting the process in the same tick may coalesce into no new run, so + // let the property settle first. + Timer { + id: modeRestart + interval: 1 + onTriggered: if (!modeRead.running) modeRead.running = true + } + // The watch frames carry no mode, so the full state read is the one place // the current mode is reported. One read per open, not a poll. Process { diff --git a/ryoku/shell/quickshell/shell/modules/gamebar/cards/TasksCard.qml b/ryoku/shell/quickshell/shell/modules/gamebar/cards/TasksCard.qml index 58be90037e..9bf2b728e9 100644 --- a/ryoku/shell/quickshell/shell/modules/gamebar/cards/TasksCard.qml +++ b/ryoku/shell/quickshell/shell/modules/gamebar/cards/TasksCard.qml @@ -1,6 +1,7 @@ pragma ComponentBehavior: Bound import QtQuick +import Quickshell import Quickshell.Io import ".." import Ryoku.Ui.Singletons @@ -14,6 +15,7 @@ GameBarCard { id: card property var rows: [] + readonly property string user: Quickshell.env("USER") || Quickshell.env("LOGNAME") || "" title: I18n.tr("Tasks") @@ -33,9 +35,9 @@ GameBarCard { Process { id: ps - command: ["ps", "-eo", "pid=,comm=,cputimes=,etimes=,rss="] + command: ["ps", "-eo", "user=,pid=,comm=,cputimes=,etimes=,rss="] stdout: StdioCollector { - onStreamFinished: card.rows = Tasks.top(text, 5) + onStreamFinished: card.rows = Tasks.top(text, 5, card.user) } } diff --git a/ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/bridge.py b/ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/bridge.py index 1a39853cce..d979318c05 100755 --- a/ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/bridge.py +++ b/ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/bridge.py @@ -32,7 +32,7 @@ def emit(kind: str, **fields: Any) -> None: - print(json.dumps({"type": kind, **fields}, separators=(",", ":")), flush=True) + print(json.dumps({"type": kind, **fields}, separators=(",", ":"), ensure_ascii=True), flush=True) class VoiceBridge: @@ -125,7 +125,11 @@ async def connect(self) -> bool: reader, writer = await asyncio.open_unix_connection(path) self.reader, self.writer, self.rpc_path = reader, writer, path await self.send_frame({"v": 1, "client_id": APP_ID}, HANDSHAKE) - opcode, ready = await self.receive(reader) + try: + opcode, ready = await asyncio.wait_for( + self.receive(reader), AUTHORIZE_TIMEOUT) + except asyncio.TimeoutError as error: + raise ConnectionError("the RPC handshake timed out") from error if opcode == CLOSE or ready.get("evt") != "READY": raise ConnectionError("the client rejected the RPC handshake") emit("backend", backend="discord") diff --git a/ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/companion/native.ts b/ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/companion/native.ts index 639ca292db..528662981a 100644 --- a/ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/companion/native.ts +++ b/ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/companion/native.ts @@ -59,14 +59,16 @@ function ensure() { }); candidate.on("error", () => { - reset(); + if (socket === candidate) + reset(); candidate.destroy(); }); candidate.on("close", () => { - reset(); - if (socket === candidate) + if (socket === candidate) { + reset(); socket = undefined; + } }); } diff --git a/ryoku/shell/quickshell/shell/services/DiscordVoice.qml b/ryoku/shell/quickshell/shell/services/DiscordVoice.qml index f037188644..a63d8b1efb 100644 --- a/ryoku/shell/quickshell/shell/services/DiscordVoice.qml +++ b/ryoku/shell/quickshell/shell/services/DiscordVoice.qml @@ -82,7 +82,7 @@ Singleton { if (!user || !user.id || !user.avatar) return ""; return "https://cdn.discordapp.com/avatars/" + user.id + "/" + user.avatar - + ".png?size=" + (size || 96); + + ".png?size=" + (size || 128); } function send(message) { diff --git a/ryoku/shell/quickshell/shell/utils/tasks.js b/ryoku/shell/quickshell/shell/utils/tasks.js index 06d632c59e..5516ee1ea2 100644 --- a/ryoku/shell/quickshell/shell/utils/tasks.js +++ b/ryoku/shell/quickshell/shell/utils/tasks.js @@ -1,8 +1,9 @@ // ps rows for the Game Bar tasks card. The command is -// `ps -eo pid=,comm=,cputimes=,etimes=,rss=`: comm may contain spaces, so the -// numeric columns are peeled off both ends and whatever remains is the name. -// cputimes is CPU time in seconds over the process lifetime, which is the -// average load, not an instantaneous sample: good enough for a short list. +// `ps -eo user=,pid=,comm=,cputimes=,etimes=,rss=`: user is the one leading +// token, comm may contain spaces, so the numeric columns are peeled off both +// ends and whatever remains is the name. cputimes is CPU time in seconds over +// the process lifetime, which is the average load, not an instantaneous +// sample: good enough for a short list. const PROTECTED = [ "systemd", "systemd-logind", "logind", "dbus-daemon", "dbus-broker", @@ -20,19 +21,21 @@ function parse(text) { if (line.length === 0) continue; const parts = line.split(/\s+/); - if (parts.length < 5) + if (parts.length < 6) continue; - const pid = parseInt(parts[0], 10); + const user = parts[0]; + const pid = parseInt(parts[1], 10); const rssKb = parseInt(parts[parts.length - 1], 10); const secs = parseInt(parts[parts.length - 2], 10); const cpuSecs = parseInt(parts[parts.length - 3], 10); - const name = parts.slice(1, parts.length - 3).join(" "); + const name = parts.slice(2, parts.length - 3).join(" "); if (!(pid > 0) || name.length === 0) continue; const cpu = Math.max(0, cpuSecs / Math.max(1, secs) * 100); out.push({ pid: pid, name: name, + user: user, cpu: cpu, memMiB: (rssKb || 0) / 1024, secs: secs || 0 @@ -46,8 +49,10 @@ function isProtected(name) { return PROTECTED.indexOf(key) !== -1; } -function top(text, count) { - const rows = parse(text); +function top(text, count, user) { + let rows = parse(text); + if (user) + rows = rows.filter(row => row.user === user); rows.sort((a, b) => b.cpu - a.cpu || b.memMiB - a.memMiB); return rows.slice(0, count || 5); } diff --git a/ryoku/shell/quickshell/shell/utils/tasks.test.mjs b/ryoku/shell/quickshell/shell/utils/tasks.test.mjs index 7cb434e8f9..ffda4a7a3b 100644 --- a/ryoku/shell/quickshell/shell/utils/tasks.test.mjs +++ b/ryoku/shell/quickshell/shell/utils/tasks.test.mjs @@ -15,16 +15,17 @@ function near(actual, expected, msg) { } const sample = [ - " 1 systemd 2 3442 16524", - " 4242 firefox 2400 120 812340", - " 5150 Web Content 1200 60 340012", - " 6001 kitty 50 300 51200", + "root 1 systemd 2 3442 16524", + "dodo 4242 firefox 2400 120 812340", + "dodo 5150 Web Content 1200 60 340012", + "dodo 6001 kitty 50 300 51200", "" ].join("\n"); const rows = parse(sample); eq(rows.length, 4, "parses every row"); eq(rows[0].pid, 1, "reads pid"); +eq(rows[0].user, "root", "reads the owner"); eq(rows[0].name, "systemd", "reads a plain name"); eq(rows[2].name, "Web Content", "keeps spaces in comm"); eq(rows[2].memMiB, 332.04296875, "rss kB becomes MiB"); @@ -33,7 +34,9 @@ near(rows[3].cpu, 16.666666666666668, "short lived process still gets a rate"); eq(isProtected("niri"), true, "compositor is protected"); eq(isProtected("ryoku-shell"), true, "shell is protected"); eq(isProtected("firefox"), false, "an app is not protected"); -eq(top(sample, 2).map((r) => r.name), ["firefox", "Web Content"], "top sorts by cpu then memory"); +eq(top(sample, 2, "dodo").map((r) => r.name), ["firefox", "Web Content"], "top sorts by cpu then memory"); +eq(top(sample, 5, "dodo").length, 3, "top keeps only the current user"); +eq(top(sample, 5, "root").map((r) => r.name), ["systemd"], "top can select the owner"); eq(parse("").length, 0, "empty input parses to nothing"); eq(parse("garbage\n\n").length, 0, "junk lines are skipped"); From 37c8c48deea1b7c11eccc11b64fb6d3a531a00e9 Mon Sep 17 00:00:00 2001 From: dodo986 <264469474+dodo986@users.noreply.github.com> Date: Mon, 9 Nov 2026 17:07:56 +0300 Subject: [PATCH 5/5] [ryoku] gamebar: keep recording reachable and refresh gallery Close the bar when a recording starts and reopen it when the capture ends. Opening the bar again during the recording releases the hold, so the Capture card's Stop button is reachable, and the fallback timer now keeps checking until the countdown ends instead of firing once. Bind the gallery to a revision counter driven by the folder models so a file replaced under the same count cannot leave stale paths on screen. Give the companion command loop a generation token, reject an unsupported --client in the installer, and let auto pick a single client. Keep a channel's name and guild when a refresh repeats only its id. Note: Fixed: the Game Bar keeps the recording reachable and the gallery refreshes in place --- docs/gamebar.md | 4 +- .../shell/modules/gamebar/GameBar.qml | 43 +++++++++++++++++-- .../modules/gamebar/cards/GalleryCard.qml | 25 ++++++++++- .../modules/gamebar/discordVoice/bridge.py | 13 ++++-- .../gamebar/discordVoice/companion/index.ts | 12 ++++-- .../gamebar/discordVoice/install_companion.sh | 19 ++++++-- 6 files changed, 99 insertions(+), 17 deletions(-) diff --git a/docs/gamebar.md b/docs/gamebar.md index cccf2c89ea..35c58d1abf 100644 --- a/docs/gamebar.md +++ b/docs/gamebar.md @@ -25,7 +25,9 @@ the next press of the shortcut brings back the same arrangement. To the right of the widget buttons, the grid button toggles snapping, then come the clock and, on a machine with a battery, its charge. The strip is the only part of the overlay that takes focus while the bar is open, and it hides itself -before a capture so it is never in its own screenshot or recording. +before a capture so it is never in its own screenshot or recording. A recording +holds the bar away until it stops; press the shortcut again to bring it back +and use the Capture card's Stop button. ## Widgets diff --git a/ryoku/shell/quickshell/shell/modules/gamebar/GameBar.qml b/ryoku/shell/quickshell/shell/modules/gamebar/GameBar.qml index 87ea64eed3..a916832319 100644 --- a/ryoku/shell/quickshell/shell/modules/gamebar/GameBar.qml +++ b/ryoku/shell/quickshell/shell/modules/gamebar/GameBar.qml @@ -132,6 +132,7 @@ PanelWindow { property bool suppressed: false property bool sawSelecting: false property bool hidingForRecording: false + property bool reopenAfterRecording: false // A pinned card outlives the strip: the strip leaves, the cards stay. During // a full close the strip is held for the fade-out and then reset. @@ -679,6 +680,10 @@ PanelWindow { function startRecording() { restoreDelay.stop(); + recordStartFallback.stop(); + win.reopenAfterRecording = GameBarStore.open; + if (GameBarStore.open) + GameBarStore.closeBar(); win.hidingForRecording = true; win.suppressed = true; Recorder.startAfter(Recorder.recordArgs(), 1); @@ -690,9 +695,24 @@ PanelWindow { return; win.hidingForRecording = false; recordStartFallback.stop(); + if (win.reopenAfterRecording && !GameBarStore.open) + GameBarStore.openBar(GameBarStore.monitor); + win.reopenAfterRecording = false; win.restoreAfter(900); } + // The shortcut must still reach the Capture card while a recording runs: + // opening the bar is an explicit choice to show it in the capture. + function releaseRecordingHold() { + if (!win.hidingForRecording) + return; + win.hidingForRecording = false; + win.reopenAfterRecording = false; + recordStartFallback.stop(); + restoreDelay.stop(); + win.suppressed = false; + } + function restoreAfter(ms) { restoreDelay.interval = Math.max(900, ms); restoreDelay.restart(); @@ -717,9 +737,19 @@ PanelWindow { } } + // Opening the bar during a recording is an explicit request for the Capture + // card, so let go of the hold instead of keeping the surface unreachable. + Connections { + target: GameBarStore + function onOpenChanged() { + if (GameBarStore.open) + win.releaseRecordingHold(); + } + } + // A running capture would otherwise record the bar: hold suppression until - // Recorder reports the capture ended. The fallback covers a countdown that - // was cancelled or a recorder that never came up. + // Recorder reports the capture ended. The fallback keeps checking so a + // cancelled countdown cannot leave the bar hidden. Connections { target: Recorder function onAnyActiveChanged() { @@ -735,9 +765,14 @@ PanelWindow { Timer { id: recordStartFallback - interval: 4000 + interval: 1500 + repeat: true onTriggered: { - if (win.hidingForRecording && !Recorder.anyActive && !Recorder.countingDown) + if (!win.hidingForRecording || Recorder.anyActive) { + stop(); + return; + } + if (!Recorder.countingDown) win.endRecordingSuppression(); } } diff --git a/ryoku/shell/quickshell/shell/modules/gamebar/cards/GalleryCard.qml b/ryoku/shell/quickshell/shell/modules/gamebar/cards/GalleryCard.qml index ff1c589ad9..360c8ef370 100644 --- a/ryoku/shell/quickshell/shell/modules/gamebar/cards/GalleryCard.qml +++ b/ryoku/shell/quickshell/shell/modules/gamebar/cards/GalleryCard.qml @@ -42,12 +42,33 @@ GameBarCard { return all.slice(0, count); } + // FolderListModel can replace a file without changing the row count, so a + // count binding alone can stay stale. Bump a revision on every model signal + // and let both lists read it as their dependency. + property int revision: 0 + + Connections { + target: shotFiles + function onDataChanged() { card.revision++ } + function onRowsInserted() { card.revision++ } + function onRowsRemoved() { card.revision++ } + function onModelReset() { card.revision++ } + } + + Connections { + target: clipFiles + function onDataChanged() { card.revision++ } + function onRowsInserted() { card.revision++ } + function onRowsRemoved() { card.revision++ } + function onModelReset() { card.revision++ } + } + readonly property var shots: { - shotFiles.count; + card.revision; return card.newest(shotFiles, 6, "shot"); } readonly property var clips: { - clipFiles.count; + card.revision; return card.newest(clipFiles, 6, "clip"); } readonly property var items: card.mode === "clips" ? card.clips : card.shots diff --git a/ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/bridge.py b/ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/bridge.py index d979318c05..188fed2c80 100755 --- a/ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/bridge.py +++ b/ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/bridge.py @@ -392,14 +392,19 @@ def voice_user(state: dict[str, Any]) -> dict[str, Any]: async def select_channel(self, data: dict[str, Any] | None) -> None: self.users.clear() - if not data: + if not data or not data.get("id"): self.channel = None emit("voice_channel", channel=None, users=[]) return + # A refresh can repeat only the channel id. Keep the name and guild that + # this same channel already reported instead of degrading the label. + previous = self.channel or {} + same = previous.get("id") == data["id"] self.channel = { - "id": data.get("id", ""), - "name": data.get("name", "Voice channel"), - "guild_id": data.get("guild_id", ""), + "id": data["id"], + "name": data.get("name") or (previous.get("name") if same else "") + or "Voice channel", + "guild_id": data.get("guild_id") or (previous.get("guild_id") if same else ""), } for state in data.get("voice_states", []): user = self.voice_user(state) diff --git a/ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/companion/index.ts b/ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/companion/index.ts index 7c450f981b..a9d4303af6 100644 --- a/ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/companion/index.ts +++ b/ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/companion/index.ts @@ -26,6 +26,7 @@ let heartbeat: ReturnType | undefined; let running = false; let busy = false; let again = false; +let commandGeneration = 0; function participant(userId: string, state: any, guildId?: string) { const user = UserStore.getUser(userId); @@ -102,10 +103,15 @@ function applyCommand(line: string) { } async function commandLoop() { - while (running) { + const generation = ++commandGeneration; + // Only the loop from the newest start keeps consuming. A loop that was + // still awaiting when the plugin restarted applies the one command it was + // handed, then yields so two consumers never run side by side. + while (running && generation === commandGeneration) { const line = await Native.nextCommand(); - if (running) - applyCommand(line); + if (!running) + return; + applyCommand(line); } } diff --git a/ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/install_companion.sh b/ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/install_companion.sh index 2f1c37c089..2297ae16ec 100755 --- a/ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/install_companion.sh +++ b/ryoku/shell/quickshell/shell/modules/gamebar/discordVoice/install_companion.sh @@ -21,6 +21,11 @@ fi say() { printf ' %s\n' "$*"; } die() { printf 'error: %s\n' "$*" >&2; exit 1; } +case "$CLIENT" in + auto|vesktop|equibop) ;; + *) die "unsupported client: $CLIENT (use auto, vesktop or equibop)" ;; +esac + [[ -d "$PLUGIN" ]] || die "companion source is missing at $PLUGIN" command -v git >/dev/null || die "git is required" command -v node >/dev/null || die "node is required" @@ -130,9 +135,17 @@ PY clients=() if [[ "$CLIENT" == "auto" ]]; then - [[ -d "$CONFIG_HOME/vesktop" ]] && clients+=("vesktop") - [[ -d "$CONFIG_HOME/equibop" ]] && clients+=("equibop") - [[ ${#clients[@]} -gt 0 ]] || die "no supported client config found; pass --client" + if client_running vesktop; then + clients=("vesktop") + elif client_running equibop; then + clients=("equibop") + elif [[ -d "$CONFIG_HOME/vesktop" ]]; then + clients=("vesktop") + elif [[ -d "$CONFIG_HOME/equibop" ]]; then + clients=("equibop") + else + die "no supported client config found; pass --client" + fi else clients=("$CLIENT") fi