diff --git a/lockscreens/nothing-interface/LICENSE b/lockscreens/nothing-interface/LICENSE new file mode 100644 index 00000000..a754f053 --- /dev/null +++ b/lockscreens/nothing-interface/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ivan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/lockscreens/nothing-interface/PROVENANCE.txt b/lockscreens/nothing-interface/PROVENANCE.txt new file mode 100644 index 00000000..c08edc59 --- /dev/null +++ b/lockscreens/nothing-interface/PROVENANCE.txt @@ -0,0 +1,18 @@ +Nothing Interface qylock theme + +Original QML and helper scripts by Ivan, 2026, under the MIT License in this +folder. The design is an independent fan interpretation of Nothing OS. + +No Nothing wallpaper, proprietary font, logo or AI-generated wallpaper is +installed with this lockscreen. During an in-session lock, the theme reads the +current Ryogami wallpaper, so it changes together with the desktop wallpaper. +It shows the local battery state, Ryoku's daily screen-time counter, and MPRIS +media metadata. The media control uses playerctl. In the system greeter, live +session-only data is unavailable and the background falls back to charcoal. + +The catalogue preview is a capture of the interface, not part of the installed +theme. The wallpaper and album art visible in that capture are not redistributed +as standalone assets. + +Upstream and maintainer: https://github.com/venchik111/ryoku-nothing-interface +Nothing trademarks and the referenced visual identity remain with their owners. diff --git a/lockscreens/nothing-interface/README.md b/lockscreens/nothing-interface/README.md new file mode 100644 index 00000000..8c6d9e57 --- /dev/null +++ b/lockscreens/nothing-interface/README.md @@ -0,0 +1,18 @@ +# Nothing Interface lockscreen + +A live Ryoku lockscreen with dot clock, date, battery, daily screen time and +MPRIS media controls. In a locked desktop session it displays the current +Ryogami wallpaper and follows wallpaper changes. It does not install its own +wallpaper. The system login greeter uses a charcoal fallback and cannot show +the session's live data. + +The QML and Python helpers are original MIT-licensed code by Ivan. Python 3 and +`playerctl` are used for live media data; the helpers read local power-supply +state and Ryoku's screen-time state. They write no files, make no outbound +network requests, and require no privileged commands. The image URL provided by +a local media player may be fetched by Qt when the lockscreen shows album art. + +The catalogue preview is not installed. No Nothing artwork or font is bundled. +See [PROVENANCE.txt](PROVENANCE.txt) and the +[upstream project](https://github.com/venchik111/ryoku-nothing-interface). +Maintainer: [venchik111](https://github.com/venchik111). diff --git a/lockscreens/nothing-interface/assets/preview.png b/lockscreens/nothing-interface/assets/preview.png new file mode 100644 index 00000000..40914215 Binary files /dev/null and b/lockscreens/nothing-interface/assets/preview.png differ diff --git a/lockscreens/nothing-interface/content/LiveData.qml b/lockscreens/nothing-interface/content/LiveData.qml new file mode 100644 index 00000000..5bc71aa6 --- /dev/null +++ b/lockscreens/nothing-interface/content/LiveData.qml @@ -0,0 +1,92 @@ +import QtQuick +import Quickshell +import Quickshell.Io + +Item { + id: root + readonly property string themeDir: Quickshell.env("QS_THEME_PATH") || "" + readonly property string socketPath: (Quickshell.env("XDG_RUNTIME_DIR") || "/tmp") + "/ryogami.sock" + property string wallpaperPath: "" + property string videoPath: "" + property int wallpaperRevision: 0 + property string fit: "Cover" + readonly property string wallpaperUrl: wallpaperPath.length ? "file://" + wallpaperPath + "?v=" + wallpaperRevision : "" + readonly property string videoUrl: videoPath.length ? "file://" + videoPath : "" + property var battery: ({level: -1, charging: false, plugged: false}) + property var activity: ({seconds: 0, available: false}) + property var media: ({playing: false, available: false, title: "", artist: "", position: 0, length: 0}) + + function applyWallpaper(line) { + try { + const frame = JSON.parse(line) + const output = Screen.name || "" + const entry = (frame.outputs && frame.outputs[output]) || frame.default + if (!entry) return + wallpaperPath = entry.path || "" + videoPath = entry.videoPath || "" + wallpaperRevision = Number(entry.revision || 0) + fit = entry.fit || "Cover" + } catch (error) { + console.warn("nothing-interface: invalid wallpaper frame", error) + } + } + + function readJson(raw, target) { + if (!raw || !raw.trim()) return + try { root[target] = JSON.parse(raw) } + catch (error) { console.warn("nothing-interface: invalid " + target + " data", error) } + } + function refresh() { + if (!batteryProbe.running) batteryProbe.running = true + if (!activityProbe.running) activityProbe.running = true + if (!mediaProbe.running) mediaProbe.running = true + } + + Socket { + id: wallpaperSocket + path: root.socketPath + parser: SplitParser { onRead: line => root.applyWallpaper(line) } + Component.onCompleted: connected = true + onConnectionStateChanged: { + if (connected) { + write("subscribe wallpaper\n") + flush() + } else retry.restart() + } + onError: { + connected = false + retry.restart() + } + } + Timer { + id: retry + interval: 2000 + onTriggered: if (!wallpaperSocket.connected) wallpaperSocket.connected = true + } + Process { + id: batteryProbe + command: ["python3", root.themeDir + "/bin/status.py"] + stdout: StdioCollector { onStreamFinished: root.readJson(this.text, "battery") } + } + Process { + id: activityProbe + command: ["python3", root.themeDir + "/bin/activity.py"] + stdout: StdioCollector { onStreamFinished: root.readJson(this.text, "activity") } + } + Process { + id: mediaProbe + command: ["python3", root.themeDir + "/bin/media.py"] + stdout: StdioCollector { onStreamFinished: root.readJson(this.text, "media") } + } + Process { + id: playback + command: ["playerctl", "play-pause"] + onExited: mediaProbe.running = true + } + function togglePlayback() { + if (media.available && !playback.running) playback.running = true + } + Timer { interval: 10000; repeat: true; running: true; onTriggered: root.refresh() } + Timer { interval: 2000; repeat: true; running: true; onTriggered: if (!mediaProbe.running) mediaProbe.running = true } + Component.onCompleted: Qt.callLater(refresh) +} diff --git a/lockscreens/nothing-interface/content/LiveVideo.qml b/lockscreens/nothing-interface/content/LiveVideo.qml new file mode 100644 index 00000000..dca11cfa --- /dev/null +++ b/lockscreens/nothing-interface/content/LiveVideo.qml @@ -0,0 +1,20 @@ +import QtQuick +import QtMultimedia + +Item { + id: root + property url sourceUrl: "" + VideoOutput { + id: output + anchors.fill: parent + fillMode: VideoOutput.PreserveAspectCrop + } + MediaPlayer { + id: player + source: root.sourceUrl + videoOutput: output + audioOutput: AudioOutput { muted: true } + loops: MediaPlayer.Infinite + onSourceChanged: if (source.toString().length) play() + } +} diff --git a/lockscreens/nothing-interface/content/Main.qml b/lockscreens/nothing-interface/content/Main.qml new file mode 100644 index 00000000..f785f510 --- /dev/null +++ b/lockscreens/nothing-interface/content/Main.qml @@ -0,0 +1,312 @@ +import QtQuick +import QtQuick.Window +import SddmComponents 2.0 + +Rectangle { + id: root + width: Screen.width + height: Screen.height + color: "#24262B" + focus: true + + // Ryoku's in-session SDDM shim exposes fingerprintState; the system greeter + // does not. Keep Quickshell-only imports in LiveData.qml. + readonly property bool inSession: typeof sddm !== "undefined" + && typeof sddm.fingerprintState !== "undefined" + readonly property var live: liveData.item + readonly property var battery: live ? live.battery : ({level: -1, charging: false}) + readonly property var activity: live ? live.activity : ({seconds: 0, available: false}) + readonly property var media: live ? live.media : ({available: false, playing: false}) + property date now: new Date() + property string errorMessage: "" + readonly property int sessionIndex: (typeof sessionModel !== "undefined" && sessionModel.lastIndex >= 0) + ? sessionModel.lastIndex : 0 + readonly property string loginUser: (typeof userModel !== "undefined" && userModel.lastUser) + ? userModel.lastUser : "" + readonly property real uiScale: Math.min(width / 1280, height / 720) + + function duration(seconds) { + const value = Math.max(0, Math.floor(Number(seconds || 0))) + return Math.floor(value / 3600) + " ч " + String(Math.floor(value % 3600 / 60)).padStart(2, "0") + " мин" + } + function stamp(seconds) { + const value = Math.max(0, Math.floor(Number(seconds || 0))) + return Math.floor(value / 60) + ":" + String(value % 60).padStart(2, "0") + } + function submit() { + if (!password.text.length || !loginUser.length) return + errorMessage = "" + sddm.login(loginUser, password.text, sessionIndex) + } + + Loader { id: liveData; active: root.inSession; source: "LiveData.qml" } + Timer { interval: 1000; running: true; repeat: true; onTriggered: root.now = new Date() } + Timer { id: errorTimer; interval: 3200; onTriggered: root.errorMessage = "" } + Connections { + target: typeof sddm !== "undefined" ? sddm : null + function onLoginFailed() { + password.text = "" + root.errorMessage = "Неверный пароль" + errorTimer.restart() + password.forceActiveFocus() + } + } + Component.onCompleted: password.forceActiveFocus() + + Image { + anchors.fill: parent + visible: root.live && root.live.wallpaperUrl.length > 0 + source: root.live ? root.live.wallpaperUrl : "" + fillMode: root.live && root.live.fit === "Contain" ? Image.PreserveAspectFit : Image.PreserveAspectCrop + asynchronous: true + cache: false + } + Loader { + id: videoLayer + anchors.fill: parent + active: root.inSession && root.live && root.live.videoUrl.length > 0 + source: "LiveVideo.qml" + onLoaded: item.sourceUrl = root.live.videoUrl + } + Connections { + target: root.live + function onVideoUrlChanged() { + if (videoLayer.item) videoLayer.item.sourceUrl = root.live.videoUrl + } + } + Rectangle { anchors.fill: parent; color: "#550F1013" } + + Item { + width: 1072 + height: 528 + anchors.centerIn: parent + scale: root.uiScale + + Text { + x: 0; y: -43 + text: Qt.formatDate(root.now, "dddd, d MMMM") + font.family: "Noto Sans"; font.pixelSize: 20 + color: "#F5F5F5" + } + + // A seven-row dot matrix drawn from vector primitives, no font asset. + Canvas { + id: clockDots + x: 0; y: 28; width: 460; height: 112 + property string digits: Qt.formatTime(root.now, "hh:mm") + onDigitsChanged: requestPaint() + onPaint: { + const glyphs = { + "0": ["11111","10001","10011","10101","11001","10001","11111"], + "1": ["00100","01100","00100","00100","00100","00100","01110"], + "2": ["11111","00001","00001","11111","10000","10000","11111"], + "3": ["11111","00001","00001","01111","00001","00001","11111"], + "4": ["10001","10001","10001","11111","00001","00001","00001"], + "5": ["11111","10000","10000","11111","00001","00001","11111"], + "6": ["11111","10000","10000","11111","10001","10001","11111"], + "7": ["11111","00001","00010","00100","01000","01000","01000"], + "8": ["11111","10001","10001","11111","10001","10001","11111"], + "9": ["11111","10001","10001","11111","00001","00001","11111"], + ":": ["0","0","1","0","1","0","0"] + } + const painter = getContext("2d") + painter.clearRect(0, 0, width, height) + const pitch = 14, radius = 4.4 + let column = 0 + for (const digit of digits) { + const bitmap = glyphs[digit] + if (!bitmap) continue + for (let row = 0; row < bitmap.length; row++) { + for (let col = 0; col < bitmap[row].length; col++) { + if (bitmap[row][col] !== "1") continue + painter.beginPath() + painter.arc((column + col) * pitch + radius, row * pitch + radius, radius, 0, Math.PI * 2) + painter.fillStyle = "#F5F5F5" + painter.fill() + } + } + column += bitmap[0].length + (digit === ":" ? 1 : 2) + } + } + } + Text { + x: 0; y: 160 + text: "NOTHING INTERFACE" + font.family: "Noto Sans"; font.pixelSize: 12; font.weight: Font.Medium + font.letterSpacing: 2.8 + color: "#D6D6DA" + } + + Rectangle { + x: 544; y: 0; width: 256; height: 120; radius: 30 + color: "#1C1D21" + Text { + x: 22; y: 18 + text: "ЗАРЯД" + font.family: "Noto Sans"; font.pixelSize: 11; font.letterSpacing: 1.5 + color: "#B1B3B3" + } + Text { + x: 22; y: 47 + text: root.battery.level >= 0 ? root.battery.level + "%" : "—" + font.family: "Noto Sans"; font.pixelSize: 38; font.weight: Font.Medium + color: "#F5F5F5" + } + Text { + x: 145; y: 78 + text: root.battery.charging ? "Заряжается" : (root.battery.plugged ? "Подключено" : "От батареи") + font.family: "Noto Sans"; font.pixelSize: 10 + color: "#B1B3B3" + } + Rectangle { + x: 22; y: 104; width: 212; height: 3; radius: 2; color: "#414247" + Rectangle { + width: parent.width * Math.max(0, Math.min(1, Number(root.battery.level) / 100)) + height: parent.height; radius: parent.radius; color: "#F5F5F5" + } + } + } + + Rectangle { + x: 816; y: 0; width: 256; height: 120; radius: 30 + color: "#1C1D21" + Text { + x: 22; y: 18 + text: "ВРЕМЯ ЗА ПК" + font.family: "Noto Sans"; font.pixelSize: 11; font.letterSpacing: 1.5 + color: "#B1B3B3" + } + Text { + x: 22; y: 49 + text: root.activity.available ? root.duration(root.activity.seconds) : "Нет данных" + font.family: "Noto Sans"; font.pixelSize: 25; font.weight: Font.Medium + color: "#F5F5F5" + } + Text { + x: 22; y: 96 + text: "Сегодня · Ryoku Rashin" + font.family: "Noto Sans"; font.pixelSize: 11 + color: "#B1B3B3" + } + } + + Rectangle { + x: 544; y: 136; width: 528; height: 256; radius: 30 + color: "#1C1D21" + Text { + x: 24; y: 19 + text: "СЕЙЧАС ИГРАЕТ" + font.family: "Noto Sans"; font.pixelSize: 11; font.letterSpacing: 1.5 + color: "#B1B3B3" + } + Text { + x: 148; y: 57; width: 356 + text: root.media.available ? (root.media.title || "Музыка") : "Ничего не играет" + font.family: "Noto Sans"; font.pixelSize: 26; font.weight: Font.Medium + color: "#F5F5F5"; elide: Text.ElideRight + } + Text { + x: 148; y: 104; width: 356 + text: root.media.available ? (root.media.artist || " ") : "Запустите музыку на рабочем столе" + font.family: "Noto Sans"; font.pixelSize: 15 + color: "#B1B3B3"; elide: Text.ElideRight + } + Rectangle { + x: 24; y: 56; width: 104; height: 104; radius: 52 + color: "#303136"; border.color: "#515258"; border.width: 1 + } + Image { + x: 24; y: 56; width: 104; height: 104 + source: root.media.available ? String(root.media.artUrl || "") : "" + fillMode: Image.PreserveAspectCrop + asynchronous: true + cache: true + visible: status === Image.Ready + } + Rectangle { + x: 24; y: 174; width: 480; height: 3; radius: 2; color: "#414247" + Rectangle { + width: parent.width * (Number(root.media.length) > 0 + ? Math.max(0, Math.min(1, Number(root.media.position) / Number(root.media.length))) : 0) + height: parent.height; radius: parent.radius; color: "#F5F5F5" + } + } + Text { + x: 24; y: 188 + text: root.stamp(root.media.position) + " / " + root.stamp(root.media.length) + font.family: "Noto Sans"; font.pixelSize: 11; color: "#B1B3B3" + } + Rectangle { + x: 451; y: 190; width: 54; height: 54; radius: 27 + color: "#303136"; opacity: root.media.available ? 1 : 0.5 + Text { + anchors.centerIn: parent + text: root.media.playing ? "Ⅱ" : "▶" + font.family: "Noto Sans"; font.pixelSize: 24; color: "#F5F5F5" + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: if (root.live) root.live.togglePlayback() + } + } + } + + Rectangle { + x: 0; y: 364; width: 440; height: 164; radius: 30 + color: "#1C1D21" + Text { + x: 24; y: 16 + text: root.loginUser.length ? root.loginUser : "Пользователь" + font.family: "Noto Sans"; font.pixelSize: 14; font.weight: Font.Medium + color: "#F5F5F5" + } + Rectangle { + x: 24; y: 50; width: 392; height: 52; radius: 26 + color: "#303136" + border.color: password.activeFocus ? "#77787D" : "#44454A" + TextInput { + id: password + anchors.fill: parent + anchors.leftMargin: 20; anchors.rightMargin: 20 + echoMode: TextInput.Password + passwordCharacter: "•" + font.family: "Noto Sans"; font.pixelSize: 18 + color: "#F5F5F5" + verticalAlignment: TextInput.AlignVCenter + onAccepted: root.submit() + } + Text { + anchors.fill: parent + anchors.leftMargin: 20 + verticalAlignment: Text.AlignVCenter + visible: password.text.length === 0 + text: "Пароль" + font.family: "Noto Sans"; font.pixelSize: 15 + color: "#A9AAAE" + MouseArea { anchors.fill: parent; onClicked: password.forceActiveFocus() } + } + } + Text { + x: 24; y: 112 + text: root.errorMessage.length ? root.errorMessage : "Нажмите Enter, чтобы разблокировать" + font.family: "Noto Sans"; font.pixelSize: 12 + color: root.errorMessage.length ? "#D84545" : "#B1B3B3" + } + Rectangle { + x: 369; y: 108; width: 47; height: 43; radius: 21 + color: "#F5F5F5" + Text { + anchors.centerIn: parent + text: "→"; font.family: "Noto Sans"; font.pixelSize: 22 + color: "#1C1D21" + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.submit() + } + } + } + } +} diff --git a/lockscreens/nothing-interface/content/bin/activity.py b/lockscreens/nothing-interface/content/bin/activity.py new file mode 100755 index 00000000..fafea3d0 --- /dev/null +++ b/lockscreens/nothing-interface/content/bin/activity.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +"""Read today's screen time from Ryoku's own local counter.""" + +import json +import os +from datetime import datetime +from pathlib import Path + + +def main(): + state_home = Path(os.environ.get('XDG_STATE_HOME') or (Path.home() / '.local/state')) + path = state_home / 'ryoku' / 'screentime.json' + try: + days = json.loads(path.read_text()).get('days', {}) + entry = days.get(datetime.now().strftime('%Y-%m-%d'), {}) + seconds = max(0, int(entry.get('total', 0))) + available = True + except (OSError, ValueError, TypeError, AttributeError): + seconds = 0 + available = False + print(json.dumps({'seconds': seconds, 'available': available})) + + +if __name__ == '__main__': + main() diff --git a/lockscreens/nothing-interface/content/bin/media.py b/lockscreens/nothing-interface/content/bin/media.py new file mode 100755 index 00000000..d9ca1685 --- /dev/null +++ b/lockscreens/nothing-interface/content/bin/media.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +"""Read local MPRIS metadata through playerctl.""" + +import json +import subprocess + + +def read(argv): + try: + return subprocess.run( + argv, capture_output=True, text=True, timeout=2, check=False, + ).stdout.strip() + except (OSError, subprocess.TimeoutExpired): + return '' + + +status = read(['playerctl', 'status']) +metadata = read(['playerctl', 'metadata', '--format', '{{title}}\n{{artist}}']) +art_url = read(['playerctl', 'metadata', '--format', '{{mpris:artUrl}}']) +lines = metadata.splitlines() +try: + position = max(0.0, float(read(['playerctl', 'position']))) +except ValueError: + position = 0.0 +try: + length = max(0.0, float(read(['playerctl', 'metadata', '--format', '{{mpris:length}}'])) / 1_000_000) +except ValueError: + length = 0.0 +print(json.dumps({ + 'playing': status == 'Playing', + 'available': status in ('Playing', 'Paused'), + 'title': lines[0][:80] if lines else '', + 'artist': lines[1][:60] if len(lines) > 1 else '', + 'artUrl': art_url if status in ('Playing', 'Paused') else '', + 'position': position, + 'length': length, +}, ensure_ascii=False)) diff --git a/lockscreens/nothing-interface/content/bin/status.py b/lockscreens/nothing-interface/content/bin/status.py new file mode 100755 index 00000000..a1ef9761 --- /dev/null +++ b/lockscreens/nothing-interface/content/bin/status.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +"""Read the local battery for the Nothing-style battery tile.""" + +import json +from pathlib import Path + + +def battery(): + plugged = False + for supply in Path('/sys/class/power_supply').iterdir(): + try: + if ((supply / 'type').read_text().strip().lower() != 'battery' + and (supply / 'online').read_text().strip() == '1'): + plugged = True + break + except (OSError, ValueError): + pass + for root in sorted(Path('/sys/class/power_supply').glob('BAT*')): + try: + level = int((root / 'capacity').read_text().strip()) + status = (root / 'status').read_text().strip().lower() + return {'level': max(0, min(level, 100)), + 'charging': status == 'charging', + 'plugged': plugged or status in ('charging', 'full')} + except (OSError, ValueError): + pass + return {'level': -1, 'charging': False, 'plugged': plugged} + + +print(json.dumps(battery())) diff --git a/lockscreens/nothing-interface/content/metadata.desktop b/lockscreens/nothing-interface/content/metadata.desktop new file mode 100644 index 00000000..b98a806c --- /dev/null +++ b/lockscreens/nothing-interface/content/metadata.desktop @@ -0,0 +1,10 @@ +[SddmGreeterTheme] +Name=Nothing Interface +Description=Nothing OS inspired live Ryoku lockscreen +Author=Ivan +License=MIT +Type=sddm-theme +Version=0.1.0 +MainScript=Main.qml +ConfigFile=theme.conf +QtVersion=6 diff --git a/lockscreens/nothing-interface/content/theme.conf b/lockscreens/nothing-interface/content/theme.conf new file mode 100644 index 00000000..e94cbbd3 --- /dev/null +++ b/lockscreens/nothing-interface/content/theme.conf @@ -0,0 +1 @@ +[General] diff --git a/lockscreens/nothing-interface/manifest.json b/lockscreens/nothing-interface/manifest.json new file mode 100644 index 00000000..1793150f --- /dev/null +++ b/lockscreens/nothing-interface/manifest.json @@ -0,0 +1,105 @@ +{ + "schema": 1, + "id": "nothing-interface", + "category": "lockscreens", + "version": "0.1.0", + "destination": "qylock/themes/nothing-interface", + "files": [ + { + "source": "LICENSE", + "destination": "LICENSE", + "mode": "0644", + "size": 1061, + "sha256": "ba2c1e000400342bf25ec91e60f59fba59d6d62079cfb698622a39f12468d9b9", + "install": true + }, + { + "source": "PROVENANCE.txt", + "destination": "PROVENANCE.txt", + "mode": "0644", + "size": 988, + "sha256": "9cf4b869099194b70802904404df29a3deb556bc654f10e58f8ec70e68178b5e", + "install": true + }, + { + "source": "README.md", + "destination": "README.md", + "mode": "0644", + "size": 1012, + "sha256": "136d38f513f00e1322883860b83d176078867ab5c208eee5478c76eacea2a84a", + "install": true + }, + { + "source": "assets/preview.png", + "destination": "preview.png", + "mode": "0644", + "size": 1676418, + "sha256": "28e3ab81506920d45dfe562a574c489754e9a2070abc7608d839beb565c0ca02", + "install": false + }, + { + "source": "content/LiveData.qml", + "destination": "LiveData.qml", + "mode": "0644", + "size": 3567, + "sha256": "418948536ec2a38892f7703e2488b5e0f2d0a4ae62b0834b14d3e24a03d28ba9", + "install": true + }, + { + "source": "content/LiveVideo.qml", + "destination": "LiveVideo.qml", + "mode": "0644", + "size": 459, + "sha256": "fa32e41180ba861516a0ac5943ccccf1b810587469865598d2a4f59d0af9812d", + "install": true + }, + { + "source": "content/Main.qml", + "destination": "Main.qml", + "mode": "0644", + "size": 13300, + "sha256": "98bfbd666ebbd996f8915e204613caafae1e4d2f9102c54f8383ad010adb58a5", + "install": true + }, + { + "source": "content/bin/activity.py", + "destination": "bin/activity.py", + "mode": "0755", + "size": 748, + "sha256": "fbda6814067e049ba155ebf3c2cd68a41357bff2bca5ba049e58c66f1364d09a", + "install": true + }, + { + "source": "content/bin/media.py", + "destination": "bin/media.py", + "mode": "0755", + "size": 1151, + "sha256": "cacb43232dbfeb5951315de0a8ab612fd3f3a043eeef12e2515c7b53b82d3a0c", + "install": true + }, + { + "source": "content/bin/status.py", + "destination": "bin/status.py", + "mode": "0755", + "size": 1040, + "sha256": "ac31d5e653ee3d17b57eef6b5ccdd7f60a9f728ee687b7c5d1b8f758242b6b10", + "install": true + }, + { + "source": "content/metadata.desktop", + "destination": "metadata.desktop", + "mode": "0644", + "size": 204, + "sha256": "c107b2489d2720f367365a1f869dc207bc354101e22b28f18c58f126a48f569d", + "install": true + }, + { + "source": "content/theme.conf", + "destination": "theme.conf", + "mode": "0644", + "size": 10, + "sha256": "a371ee2822ab833c1349cbe193bf4b9292d6ab6b49a22b525fc1dcd95aa933c1", + "install": true + } + ] +} diff --git a/lockscreens/registry.json b/lockscreens/registry.json index 0a7b5e48..b16a8daf 100644 --- a/lockscreens/registry.json +++ b/lockscreens/registry.json @@ -909,6 +909,33 @@ ], "manifest": "manifest.json", "manifestSha256": "cc3b45853c1f123626adf24d89beb60e3d332ec59e45b82f25ad6f93dbbb8cfe" + }, + { + "id": "nothing-interface", + "name": "Nothing Interface", + "version": "0.1.0", + "path": "lockscreens/nothing-interface", + "author": "Ivan <254410104+venchik111@users.noreply.github.com>", + "upstream": "https://github.com/venchik111/ryoku-nothing-interface", + "summary": "Dot clock and live Ryoku status over your current wallpaper.", + "description": "A Nothing OS-inspired lockscreen with a dot clock, battery, daily screen time and MPRIS media controls. Uses the current Ryogami wallpaper during a locked session; no wallpaper is bundled.", + "tags": [ + "nothing", + "monochrome", + "live", + "wallpaper" + ], + "accent": "#C2C4CB", + "surface": "#1C1D21", + "preview": "assets/preview.png", + "screenshots": [ + "assets/preview.png" + ], + "manifest": "manifest.json", + "manifestSha256": "158f1f5ad37b51c589f511366eb23724bf10261c9c0d96f9782ae8640ca772d4", + "official": false, + "license": "MIT", + "lastUpdated": "2026-10-01" } ] } diff --git a/plugins/nothing-analog-clock/LICENSE b/plugins/nothing-analog-clock/LICENSE new file mode 100644 index 00000000..a754f053 --- /dev/null +++ b/plugins/nothing-analog-clock/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ivan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/nothing-analog-clock/README.md b/plugins/nothing-analog-clock/README.md new file mode 100644 index 00000000..5dbd90f5 --- /dev/null +++ b/plugins/nothing-analog-clock/README.md @@ -0,0 +1,9 @@ +# Nothing Analog Clock + +An analog desktop clock inspired by the user's Nothing OS reference screenshot. +It uses a charcoal circular face, neutral tick marks and a single N-Red second +hand. The dial is drawn in QML; no proprietary clock artwork is bundled. + +The widget reads the local system clock. It runs no external commands, uses no +network access and writes no files. It is installed locally as a Ryoku desktop +widget. Its preview is a capture of the running widget. diff --git a/plugins/nothing-analog-clock/assets/preview-widget.png b/plugins/nothing-analog-clock/assets/preview-widget.png new file mode 100644 index 00000000..ad4d52f0 Binary files /dev/null and b/plugins/nothing-analog-clock/assets/preview-widget.png differ diff --git a/plugins/nothing-analog-clock/content/Widget.qml b/plugins/nothing-analog-clock/content/Widget.qml new file mode 100644 index 00000000..cb0faf0a --- /dev/null +++ b/plugins/nothing-analog-clock/content/Widget.qml @@ -0,0 +1,90 @@ +import QtQuick + +Item { + id: root + property var pluginApi + property var screen + property bool active: false + property string density: "compact" + property real s: 1 + property real widthBudget: 0 + + // Two cells on the Nothing Interface desktop grid (120px cells, 16px gap). + implicitWidth: 256 * s + implicitHeight: 256 * s + width: implicitWidth + height: implicitHeight + + Canvas { + id: dial + width: 236 * root.s + height: 236 * root.s + anchors.centerIn: parent + antialiasing: true + onPaint: { + const ctx = getContext("2d") + ctx.clearRect(0, 0, width, height) + ctx.save() + ctx.scale(root.s, root.s) + const cx = 118, cy = 118 + ctx.beginPath() + ctx.arc(cx, cy, 115, 0, Math.PI * 2) + ctx.fillStyle = "#1C1D21" + ctx.fill() + + for (let i = 0; i < 60; i++) { + const a = i * Math.PI / 30 - Math.PI / 2 + const major = i % 5 === 0 + const inner = major ? 91 : 99 + const outer = 108 + ctx.beginPath() + ctx.moveTo(cx + Math.cos(a) * inner, cy + Math.sin(a) * inner) + ctx.lineTo(cx + Math.cos(a) * outer, cy + Math.sin(a) * outer) + ctx.strokeStyle = major ? "#F5F5F5" : "#DCD7D2" + ctx.lineWidth = major ? 3 : 1.4 + ctx.lineCap = "round" + ctx.stroke() + } + + const now = new Date() + const second = now.getSeconds() + now.getMilliseconds() / 1000 + const minute = now.getMinutes() + second / 60 + const hour = (now.getHours() % 12) + minute / 60 + function hand(value, divisions, length, width, color, tail) { + const a = value * Math.PI * 2 / divisions - Math.PI / 2 + ctx.beginPath() + ctx.moveTo(cx - Math.cos(a) * tail, cy - Math.sin(a) * tail) + ctx.lineTo(cx + Math.cos(a) * length, cy + Math.sin(a) * length) + ctx.strokeStyle = color + ctx.lineWidth = width + ctx.lineCap = "round" + ctx.stroke() + return a + } + hand(hour, 12, 56, 5, "#F5F5F5", 8) + hand(minute, 60, 78, 4, "#F5F5F5", 10) + const secondAngle = hand(second, 60, 91, 2, "#C8102E", 16) + ctx.beginPath() + ctx.arc(cx + Math.cos(secondAngle) * 82, + cy + Math.sin(secondAngle) * 82, 6, 0, Math.PI * 2) + ctx.fillStyle = "#C8102E" + ctx.fill() + ctx.beginPath() + ctx.arc(cx, cy, 5.5, 0, Math.PI * 2) + ctx.fillStyle = "#F5F5F5" + ctx.fill() + ctx.beginPath() + ctx.arc(cx, cy, 2.6, 0, Math.PI * 2) + ctx.fillStyle = "#C8102E" + ctx.fill() + ctx.restore() + } + } + + Timer { + interval: 1000 + repeat: true + running: true + onTriggered: dial.requestPaint() + } +} diff --git a/plugins/nothing-analog-clock/manifest.json b/plugins/nothing-analog-clock/manifest.json new file mode 100644 index 00000000..5bd6154c --- /dev/null +++ b/plugins/nothing-analog-clock/manifest.json @@ -0,0 +1,34 @@ +{ + "id": "nothing-analog-clock", + "name": "Nothing Analog Clock", + "version": "0.2.0", + "author": "Ivan \u003c254410104+venchik111@users.noreply.github.com\u003e", + "description": "Charcoal analog clock with fine white markers and one N-Red second hand.", + "license": "MIT", + "official": false, + "hosts": [ + "desktopWidget" + ], + "entryPoints": { + "main": "service/Main.qml", + "content": "content/Widget.qml" + }, + "files": ["assets/preview-widget.png"], + "capabilities": { + "densities": [ + "compact", + "full" + ] + }, + "defaults": { + "host": "desktopWidget", + "label": "Nothing Analog Clock", + "desktopWidget": { "bg": "none" } + }, + "dependencies": { + "commands": [] + }, + "metadata": { + "settings": [] + } +} diff --git a/plugins/nothing-analog-clock/product-manifest.json b/plugins/nothing-analog-clock/product-manifest.json new file mode 100644 index 00000000..7f9863ca --- /dev/null +++ b/plugins/nothing-analog-clock/product-manifest.json @@ -0,0 +1,58 @@ +{ + "schema": 1, + "id": "nothing-analog-clock", + "category": "plugins", + "version": "0.2.0", + "destination": "ryoku/plugins/nothing-analog-clock", + "files": [ + { + "source": "LICENSE", + "destination": "LICENSE", + "sha256": "ba2c1e000400342bf25ec91e60f59fba59d6d62079cfb698622a39f12468d9b9", + "mode": "0644", + "size": 1061, + "install": false + }, + { + "source": "README.md", + "destination": "README.md", + "sha256": "0bc77bcd158930c0b10aa460545813849a112693799744df29e46438a330fb96", + "mode": "0644", + "size": 471, + "install": false + }, + { + "source": "assets/preview-widget.png", + "destination": "assets/preview-widget.png", + "sha256": "38bd9c56849c43070631a6449a73afdcbd9757eabb5d3c730fe48d56afd9ff7b", + "mode": "0644", + "size": 56874, + "install": false + }, + { + "source": "content/Widget.qml", + "destination": "content/Widget.qml", + "sha256": "671795ac80e72b1af9719525d24c0827532472a9f39622d3d36778a8b29ba18e", + "mode": "0644", + "size": 3079, + "install": true + }, + { + "source": "manifest.json", + "destination": "manifest.json", + "sha256": "d388d93f5791fb5bfd6a11d2bca46be96a5b7f946675f1704e55c31b6eeb9287", + "mode": "0644", + "size": 764, + "install": true + }, + { + "source": "service/Main.qml", + "destination": "service/Main.qml", + "sha256": "b8586fa685ccfa03adfce9db6a7135b987921b60454c300b0331b007fb03b76b", + "mode": "0644", + "size": 84, + "install": true + } + ], + "license": "MIT" +} diff --git a/plugins/nothing-analog-clock/service/Main.qml b/plugins/nothing-analog-clock/service/Main.qml new file mode 100644 index 00000000..b2b330ef --- /dev/null +++ b/plugins/nothing-analog-clock/service/Main.qml @@ -0,0 +1,6 @@ +pragma ComponentBehavior: Bound +import QtQuick + +Item { + property var pluginApi +} diff --git a/plugins/nothing-audio/LICENSE b/plugins/nothing-audio/LICENSE new file mode 100644 index 00000000..a754f053 --- /dev/null +++ b/plugins/nothing-audio/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ivan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/nothing-audio/README.md b/plugins/nothing-audio/README.md new file mode 100644 index 00000000..77c7d59d --- /dev/null +++ b/plugins/nothing-audio/README.md @@ -0,0 +1,9 @@ +# Nothing Audio + +A charcoal circular audio widget with a geometric white waveform, based on +the user's Nothing OS screenshot. Click it to mute or unmute the default +PipeWire output. The icon turns grey when muted. + +The service polls `bin/audio.py` every three seconds. The script reads the +default sink with `wpctl get-volume`; a click runs `wpctl set-mute`. It writes +no files, uses no network and needs no privileges. diff --git a/plugins/nothing-audio/assets/preview-widget.png b/plugins/nothing-audio/assets/preview-widget.png new file mode 100644 index 00000000..f3f83814 Binary files /dev/null and b/plugins/nothing-audio/assets/preview-widget.png differ diff --git a/plugins/nothing-audio/bin/audio.py b/plugins/nothing-audio/bin/audio.py new file mode 100755 index 00000000..2efeed72 --- /dev/null +++ b/plugins/nothing-audio/bin/audio.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python3 +"""Read default PipeWire sink volume and mute state.""" + +import json +import re +import subprocess + +try: + result = subprocess.run( + ['wpctl', 'get-volume', '@DEFAULT_AUDIO_SINK@'], + capture_output=True, text=True, timeout=2, check=False, + ) + raw = result.stdout +except (OSError, subprocess.TimeoutExpired): + raw = '' +match = re.search(r'Volume:\s*([0-9.]+)', raw) +level = round(float(match.group(1)) * 100) if match else 0 +print(json.dumps({'volume': level, 'muted': '[MUTED]' in raw})) diff --git a/plugins/nothing-audio/content/Widget.qml b/plugins/nothing-audio/content/Widget.qml new file mode 100644 index 00000000..e7fbcdc2 --- /dev/null +++ b/plugins/nothing-audio/content/Widget.qml @@ -0,0 +1,62 @@ +import QtQuick + +Item { + id: root + property var pluginApi + property var screen + property bool active: false + property string density: "compact" + property real s: 1 + property real widthBudget: 0 + readonly property var service: pluginApi ? pluginApi.mainInstance : null + readonly property bool muted: service ? service.data.muted === true : false + readonly property int volume: service ? Number(service.data.volume ?? 0) : 0 + + implicitWidth: 120 * s + implicitHeight: 120 * s + width: implicitWidth + height: implicitHeight + onMutedChanged: wave.requestPaint() + + Canvas { + id: wave + width: 108 * root.s + height: 108 * root.s + anchors.centerIn: parent + antialiasing: true + onPaint: { + const ctx = getContext("2d") + ctx.clearRect(0, 0, width, height) + ctx.save() + ctx.scale(root.s, root.s) + ctx.beginPath() + ctx.arc(54, 54, 54, 0, Math.PI * 2) + ctx.fillStyle = "#1C1D21" + ctx.fill() + ctx.fillStyle = root.muted ? "#B1B3B3" : "#F5F5F5" + const heights = [5, 13, 26, 40, 53, 40, 26, 13, 5] + for (let i = 0; i < heights.length; i++) { + const x = 26 + i * 7 + const h = heights[i] + ctx.beginPath() + ctx.rect(x, (108 - h) / 2, 3.6, h) + ctx.fill() + } + if (root.muted) { + ctx.beginPath() + ctx.moveTo(29, 77) + ctx.lineTo(79, 29) + ctx.strokeStyle = "#B1B3B3" + ctx.lineWidth = 3 + ctx.stroke() + } + ctx.restore() + } + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: if (root.service) root.service.toggleMute() + } +} diff --git a/plugins/nothing-audio/manifest.json b/plugins/nothing-audio/manifest.json new file mode 100644 index 00000000..ebe98199 --- /dev/null +++ b/plugins/nothing-audio/manifest.json @@ -0,0 +1,34 @@ +{ + "id": "nothing-audio", + "name": "Nothing Audio", + "version": "0.2.0", + "author": "Ivan \u003c254410104+venchik111@users.noreply.github.com\u003e", + "description": "Circular Nothing OS-style audio widget with mute control.", + "license": "MIT", + "official": false, + "hosts": [ + "desktopWidget" + ], + "entryPoints": { + "main": "service/Main.qml", + "content": "content/Widget.qml" + }, + "files": ["bin/audio.py", "assets/preview-widget.png"], + "capabilities": { + "densities": [ + "compact", + "full" + ] + }, + "defaults": { + "host": "desktopWidget", + "label": "Nothing Audio", + "desktopWidget": { "bg": "none" } + }, + "dependencies": { + "commands": ["python3", "wpctl"] + }, + "metadata": { + "settings": [] + } +} diff --git a/plugins/nothing-audio/product-manifest.json b/plugins/nothing-audio/product-manifest.json new file mode 100644 index 00000000..84a00238 --- /dev/null +++ b/plugins/nothing-audio/product-manifest.json @@ -0,0 +1,66 @@ +{ + "schema": 1, + "id": "nothing-audio", + "category": "plugins", + "version": "0.2.0", + "destination": "ryoku/plugins/nothing-audio", + "files": [ + { + "source": "LICENSE", + "destination": "LICENSE", + "sha256": "ba2c1e000400342bf25ec91e60f59fba59d6d62079cfb698622a39f12468d9b9", + "mode": "0644", + "size": 1061, + "install": false + }, + { + "source": "README.md", + "destination": "README.md", + "sha256": "3067bee3db10cad73f716ac4fd16da9ed303c4a3c5de814ecb3b5d6d4f9dae5b", + "mode": "0644", + "size": 420, + "install": false + }, + { + "source": "assets/preview-widget.png", + "destination": "assets/preview-widget.png", + "sha256": "8cd4460885065c01fadbf8e6a727ce0ebaf3301d7b3416ffa50060875c5e7af0", + "mode": "0644", + "size": 9866, + "install": false + }, + { + "source": "bin/audio.py", + "destination": "bin/audio.py", + "sha256": "16ae93f4d13735b3722628a96f6c6caa7fd21e3f10eabe72b2ee96fe66539083", + "mode": "0755", + "size": 534, + "install": true + }, + { + "source": "content/Widget.qml", + "destination": "content/Widget.qml", + "sha256": "44c2fe67ec6b0b16b9da47506b3a4cf31d72e366a32e0ad27be3590a3ab87e54", + "mode": "0644", + "size": 1887, + "install": true + }, + { + "source": "manifest.json", + "destination": "manifest.json", + "sha256": "e5b3b3b7a979f8a7b2c234fd8f75caf4edc40027e7f121eeed7fb3d8aa305be7", + "mode": "0644", + "size": 762, + "install": true + }, + { + "source": "service/Main.qml", + "destination": "service/Main.qml", + "sha256": "ad74de047fc07eb4786fa0e38e49defb6b191d0b6aa7db203ed7eded9bb00fd3", + "mode": "0644", + "size": 1002, + "install": true + } + ], + "license": "MIT" +} diff --git a/plugins/nothing-audio/service/Main.qml b/plugins/nothing-audio/service/Main.qml new file mode 100644 index 00000000..be7a767b --- /dev/null +++ b/plugins/nothing-audio/service/Main.qml @@ -0,0 +1,34 @@ +pragma ComponentBehavior: Bound +import QtQuick +import Quickshell.Io + +Item { + id: svc + property var pluginApi + property var data: ({volume: 0, muted: false}) + + function refresh() { + if (pluginApi && !probe.running) + probe.running = true + } + function ingest(raw) { + try { data = JSON.parse(raw) } + catch (e) { console.warn("nothing-audio: invalid volume output", e) } + } + function toggleMute() { + if (!toggle.running) + toggle.running = true + } + onPluginApiChanged: refresh() + Timer { interval: 3000; repeat: true; running: true; onTriggered: svc.refresh() } + Process { + id: probe + command: svc.pluginApi ? [svc.pluginApi.pluginDir + "/bin/audio.py"] : [] + stdout: StdioCollector { onStreamFinished: svc.ingest(this.text) } + } + Process { + id: toggle + command: ["wpctl", "set-mute", "@DEFAULT_AUDIO_SINK@", "toggle"] + onRunningChanged: if (!running) svc.refresh() + } +} diff --git a/plugins/nothing-dashboard/LICENSE b/plugins/nothing-dashboard/LICENSE new file mode 100644 index 00000000..a754f053 --- /dev/null +++ b/plugins/nothing-dashboard/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ivan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/nothing-dashboard/README.md b/plugins/nothing-dashboard/README.md new file mode 100644 index 00000000..495d61d8 --- /dev/null +++ b/plugins/nothing-dashboard/README.md @@ -0,0 +1,9 @@ +# Nothing Battery + +This desktop widget replaces the former CPU/MEM/BAT diagnostic dashboard. Its +charcoal square, circular device pictogram and dotted percentage follow the +user's Nothing OS screenshot. N-Red appears only when the battery is critical. + +`service/Main.qml` runs `bin/status.py` every ten seconds. The script reads +`/sys/class/power_supply/BAT*/capacity` and `status`, writes nothing, accesses +no network and uses no privileges. The widget is visual only. diff --git a/plugins/nothing-dashboard/assets/preview-widget.png b/plugins/nothing-dashboard/assets/preview-widget.png new file mode 100644 index 00000000..b828b845 Binary files /dev/null and b/plugins/nothing-dashboard/assets/preview-widget.png differ diff --git a/plugins/nothing-dashboard/bin/status.py b/plugins/nothing-dashboard/bin/status.py new file mode 100755 index 00000000..a1ef9761 --- /dev/null +++ b/plugins/nothing-dashboard/bin/status.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +"""Read the local battery for the Nothing-style battery tile.""" + +import json +from pathlib import Path + + +def battery(): + plugged = False + for supply in Path('/sys/class/power_supply').iterdir(): + try: + if ((supply / 'type').read_text().strip().lower() != 'battery' + and (supply / 'online').read_text().strip() == '1'): + plugged = True + break + except (OSError, ValueError): + pass + for root in sorted(Path('/sys/class/power_supply').glob('BAT*')): + try: + level = int((root / 'capacity').read_text().strip()) + status = (root / 'status').read_text().strip().lower() + return {'level': max(0, min(level, 100)), + 'charging': status == 'charging', + 'plugged': plugged or status in ('charging', 'full')} + except (OSError, ValueError): + pass + return {'level': -1, 'charging': False, 'plugged': plugged} + + +print(json.dumps(battery())) diff --git a/plugins/nothing-dashboard/content/Widget.qml b/plugins/nothing-dashboard/content/Widget.qml new file mode 100644 index 00000000..43a48c22 --- /dev/null +++ b/plugins/nothing-dashboard/content/Widget.qml @@ -0,0 +1,109 @@ +import QtQuick + +Rectangle { + id: root + property var pluginApi + property var screen + property bool active: false + property string density: "compact" + property real s: 1 + property real widthBudget: 0 + readonly property var service: pluginApi ? pluginApi.mainInstance : null + readonly property var state: service ? service.data : ({}) + readonly property int level: Number(state.level ?? -1) + readonly property bool charging: state.charging === true + readonly property bool plugged: state.plugged === true + + implicitWidth: 256 * s + implicitHeight: 256 * s + width: implicitWidth + height: implicitHeight + radius: 32 * s + color: "#1C1D21" + onLevelChanged: ring.requestPaint() + + Canvas { + id: ring + x: 17 * root.s + y: 17 * root.s + width: 112 * root.s + height: 112 * root.s + antialiasing: true + onPaint: { + const ctx = getContext("2d") + ctx.clearRect(0, 0, width, height) + ctx.save() + ctx.scale(root.s, root.s) + ctx.beginPath() + ctx.arc(56, 56, 55, 0, Math.PI * 2) + ctx.fillStyle = "#303136" + ctx.fill() + ctx.beginPath() + ctx.arc(56, 56, 48, 0, Math.PI * 2) + ctx.strokeStyle = "#44454A" + ctx.lineWidth = 5 + ctx.stroke() + if (root.level >= 0) { + ctx.beginPath() + ctx.arc(56, 56, 48, -Math.PI / 2, + -Math.PI / 2 + Math.PI * 2 * root.level / 100) + ctx.strokeStyle = root.level <= 15 ? "#C8102E" : "#DCD7D2" + ctx.lineWidth = 5 + ctx.lineCap = "round" + ctx.stroke() + } + ctx.strokeStyle = "#F5F5F5" + ctx.lineWidth = 3 + ctx.lineJoin = "round" + ctx.beginPath() + ctx.rect(37, 38, 38, 28) + ctx.stroke() + ctx.beginPath() + ctx.moveTo(33, 72) + ctx.lineTo(79, 72) + ctx.stroke() + ctx.restore() + } + } + + Canvas { + visible: root.plugged + x: 139 * root.s + y: 42 * root.s + width: 24 * root.s + height: 32 * root.s + onPaint: { + const ctx = getContext("2d") + ctx.clearRect(0, 0, width, height) + ctx.save() + ctx.scale(root.s, root.s) + ctx.fillStyle = "#F5F5F5" + ctx.beginPath() + ctx.moveTo(13, 1); ctx.lineTo(5, 17); ctx.lineTo(12, 15) + ctx.lineTo(10, 31); ctx.lineTo(21, 13); ctx.lineTo(14, 15) + ctx.closePath(); ctx.fill() + ctx.restore() + } + } + + Text { + visible: root.plugged + x: 19 * root.s + y: 137 * root.s + text: root.charging ? "Заряжается" : "Питание от сети" + color: "#B1B3B3" + font.family: "Noto Sans" + font.pixelSize: 12 * root.s + } + + Text { + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.rightMargin: 18 * root.s + anchors.bottomMargin: 15 * root.s + text: root.level >= 0 ? String(root.level) + "%" : "--" + color: root.level >= 0 && root.level <= 15 ? "#C8102E" : "#F5F5F5" + font.family: "Ndot77JPExtended" + font.pixelSize: 42 * root.s + } +} diff --git a/plugins/nothing-dashboard/manifest.json b/plugins/nothing-dashboard/manifest.json new file mode 100644 index 00000000..bedc4e7b --- /dev/null +++ b/plugins/nothing-dashboard/manifest.json @@ -0,0 +1,34 @@ +{ + "id": "nothing-dashboard", + "name": "Nothing Battery", + "version": "0.4.0", + "author": "Ivan \u003c254410104+venchik111@users.noreply.github.com\u003e", + "description": "A Nothing OS-inspired battery tile with a dotted charge readout.", + "license": "MIT", + "official": false, + "hosts": [ + "desktopWidget" + ], + "entryPoints": { + "main": "service/Main.qml", + "content": "content/Widget.qml" + }, + "files": ["bin/status.py", "assets/preview-widget.png"], + "capabilities": { + "densities": [ + "compact", + "full" + ] + }, + "defaults": { + "host": "desktopWidget", + "label": "Nothing Battery", + "desktopWidget": { "bg": "none" } + }, + "dependencies": { + "commands": ["python3"] + }, + "metadata": { + "settings": [] + } +} diff --git a/plugins/nothing-dashboard/product-manifest.json b/plugins/nothing-dashboard/product-manifest.json new file mode 100644 index 00000000..ec9d1c8a --- /dev/null +++ b/plugins/nothing-dashboard/product-manifest.json @@ -0,0 +1,66 @@ +{ + "schema": 1, + "id": "nothing-dashboard", + "category": "plugins", + "version": "0.4.0", + "destination": "ryoku/plugins/nothing-dashboard", + "files": [ + { + "source": "LICENSE", + "destination": "LICENSE", + "sha256": "ba2c1e000400342bf25ec91e60f59fba59d6d62079cfb698622a39f12468d9b9", + "mode": "0644", + "size": 1061, + "install": false + }, + { + "source": "README.md", + "destination": "README.md", + "sha256": "14facbb64ba818cb8c76cdcbb408921481337960efe5083f835abfde1babf2f5", + "mode": "0644", + "size": 470, + "install": false + }, + { + "source": "assets/preview-widget.png", + "destination": "assets/preview-widget.png", + "sha256": "39225ff4059e41d1138f70113b720eef47957053647d6760f4d3b00d79ef536c", + "mode": "0644", + "size": 18842, + "install": false + }, + { + "source": "bin/status.py", + "destination": "bin/status.py", + "sha256": "ac31d5e653ee3d17b57eef6b5ccdd7f60a9f728ee687b7c5d1b8f758242b6b10", + "mode": "0755", + "size": 1040, + "install": true + }, + { + "source": "content/Widget.qml", + "destination": "content/Widget.qml", + "sha256": "785596e10e89daf4f98e15d727683c2df57328a0e3409799ac5c3bcd0ffbb683", + "mode": "0644", + "size": 3362, + "install": true + }, + { + "source": "manifest.json", + "destination": "manifest.json", + "sha256": "f443511fd1ac713f2bee269aea33079d5790de99e8748a05e429bfc35b59220a", + "mode": "0644", + "size": 769, + "install": true + }, + { + "source": "service/Main.qml", + "destination": "service/Main.qml", + "sha256": "0cc0e785a5bce3ace9552701d4e24ccd16ea7f2496278bdade320f0fa2865704", + "mode": "0644", + "size": 787, + "install": true + } + ], + "license": "MIT" +} diff --git a/plugins/nothing-dashboard/service/Main.qml b/plugins/nothing-dashboard/service/Main.qml new file mode 100644 index 00000000..24026942 --- /dev/null +++ b/plugins/nothing-dashboard/service/Main.qml @@ -0,0 +1,35 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell.Io + +Item { + id: svc + property var pluginApi + property var data: ({level: -1, charging: false}) + + function refresh() { + if (pluginApi && !probe.running) + probe.running = true + } + + function ingest(raw) { + try { data = JSON.parse(raw) } + catch (e) { console.warn("nothing-dashboard: invalid battery output", e) } + } + + onPluginApiChanged: refresh() + + Timer { + interval: 10000 + repeat: true + running: true + onTriggered: svc.refresh() + } + + Process { + id: probe + command: svc.pluginApi ? [svc.pluginApi.pluginDir + "/bin/status.py"] : [] + stdout: StdioCollector { onStreamFinished: svc.ingest(this.text) } + } +} diff --git a/plugins/nothing-media/LICENSE b/plugins/nothing-media/LICENSE new file mode 100644 index 00000000..a754f053 --- /dev/null +++ b/plugins/nothing-media/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ivan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/nothing-media/README.md b/plugins/nothing-media/README.md new file mode 100644 index 00000000..288392cd --- /dev/null +++ b/plugins/nothing-media/README.md @@ -0,0 +1,18 @@ +# Nothing Media + +The preview image is a schematic illustration. It contains no wallpaper, +album art, track title or playback history. + +A 392 × 256 charcoal music widget, sized to three columns by two rows of the +Nothing Interface grid, with album art filling the cover area when the +player provides it. The geometric record remains when art is unavailable. It +displays the selected MPRIS player's title, artist, progress and song text. +Synced lines follow the playback position; plain lyrics remain still. When +the song has no text, the card says so. The button toggles play/pause. + +The service polls `bin/media.py` every two seconds. It reads local MPRIS +metadata through `playerctl`, then subscribes to `ryoku-shell.sock` for the +same lyrics frame as Ryoku's original desktop music widget. Ryoku's daemon +owns any lyric lookup. Qt may load the artwork URL supplied by the player; +for Spotify this is an HTTPS image. The plugin writes no files and needs no +privileges. diff --git a/plugins/nothing-media/assets/preview-widget.png b/plugins/nothing-media/assets/preview-widget.png new file mode 100644 index 00000000..be037ab2 Binary files /dev/null and b/plugins/nothing-media/assets/preview-widget.png differ diff --git a/plugins/nothing-media/assets/preview-widget.svg b/plugins/nothing-media/assets/preview-widget.svg new file mode 100644 index 00000000..3455845b --- /dev/null +++ b/plugins/nothing-media/assets/preview-widget.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/nothing-media/bin/media.py b/plugins/nothing-media/bin/media.py new file mode 100755 index 00000000..d9ca1685 --- /dev/null +++ b/plugins/nothing-media/bin/media.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +"""Read local MPRIS metadata through playerctl.""" + +import json +import subprocess + + +def read(argv): + try: + return subprocess.run( + argv, capture_output=True, text=True, timeout=2, check=False, + ).stdout.strip() + except (OSError, subprocess.TimeoutExpired): + return '' + + +status = read(['playerctl', 'status']) +metadata = read(['playerctl', 'metadata', '--format', '{{title}}\n{{artist}}']) +art_url = read(['playerctl', 'metadata', '--format', '{{mpris:artUrl}}']) +lines = metadata.splitlines() +try: + position = max(0.0, float(read(['playerctl', 'position']))) +except ValueError: + position = 0.0 +try: + length = max(0.0, float(read(['playerctl', 'metadata', '--format', '{{mpris:length}}'])) / 1_000_000) +except ValueError: + length = 0.0 +print(json.dumps({ + 'playing': status == 'Playing', + 'available': status in ('Playing', 'Paused'), + 'title': lines[0][:80] if lines else '', + 'artist': lines[1][:60] if len(lines) > 1 else '', + 'artUrl': art_url if status in ('Playing', 'Paused') else '', + 'position': position, + 'length': length, +}, ensure_ascii=False)) diff --git a/plugins/nothing-media/content/Widget.qml b/plugins/nothing-media/content/Widget.qml new file mode 100644 index 00000000..c681d5b1 --- /dev/null +++ b/plugins/nothing-media/content/Widget.qml @@ -0,0 +1,231 @@ +import QtQuick + +Rectangle { + id: root + property var pluginApi + property var screen + property bool active: false + property string density: "compact" + property real s: 1 + property real widthBudget: 0 + readonly property var service: pluginApi ? pluginApi.mainInstance : null + readonly property var state: service ? service.data : ({}) + readonly property var frame: service ? service.lyricFrame : ({}) + readonly property bool available: state.available === true + readonly property bool playing: state.playing === true + readonly property string titleText: available ? (state.title || "Музыка") : "Ничего не играет" + readonly property string artistText: available ? (state.artist || " ") : " " + readonly property string artUrl: available ? String(state.artUrl || "") : "" + readonly property real position: Number(state.position || 0) + readonly property real length: Number(state.length || 0) + readonly property bool lyricsMatch: available && String(frame.title || "") + .toLocaleLowerCase().startsWith(String(state.title || "").toLocaleLowerCase()) + readonly property string lyricsStatus: lyricsMatch ? String(frame.lyricsStatus || "idle") : "loading" + readonly property var syncedLines: lyricsStatus === "ok" && Array.isArray(frame.lyrics) + ? frame.lyrics : [] + readonly property var plainLines: lyricsStatus === "plain" && Array.isArray(frame.plain) + ? frame.plain : [] + readonly property int activeLine: { + let found = 0 + for (let i = 0; i < syncedLines.length; i++) { + if (Number(syncedLines[i].t) <= position) found = i + else break + } + return found + } + + function lineAt(index) { + if (index < 0 || index >= syncedLines.length) return "" + return String(syncedLines[index].text || "♪") + } + function stamp(seconds) { + const n = Math.max(0, Math.floor(seconds)) + return String(Math.floor(n / 60)) + ":" + String(n % 60).padStart(2, "0") + } + + // Three columns by two rows of the desktop grid. + implicitWidth: 392 * s + implicitHeight: 256 * s + width: implicitWidth + height: implicitHeight + radius: 28 * s + color: "#1C1D21" + + Canvas { + x: 18 * root.s + y: 18 * root.s + width: 108 * root.s + height: 108 * root.s + antialiasing: true + onPaint: { + const c = getContext("2d") + c.clearRect(0, 0, width, height) + c.save() + c.scale(root.s, root.s) + c.beginPath(); c.arc(54, 54, 52, 0, Math.PI * 2) + c.fillStyle = "#303136"; c.fill() + c.strokeStyle = "#515258"; c.lineWidth = 1.5 + for (const r of [40, 29, 18]) { + c.beginPath(); c.arc(54, 54, r, 0, Math.PI * 2); c.stroke() + } + c.beginPath(); c.arc(54, 54, 6, 0, Math.PI * 2) + c.fillStyle = "#DCD7D2"; c.fill() + c.restore() + } + } + + // Album artwork fills the cover slot; the record remains as a fallback. + Image { + id: albumArt + x: 18 * root.s + y: 18 * root.s + width: 108 * root.s + height: 108 * root.s + source: root.artUrl + fillMode: Image.PreserveAspectCrop + asynchronous: true + cache: true + visible: root.available && status === Image.Ready + } + + Text { + x: 142 * root.s; y: 19 * root.s + width: 230 * root.s; height: 52 * root.s + text: root.titleText + color: "#F5F5F5" + font.family: "Noto Sans" + font.pixelSize: 16 * root.s + font.weight: Font.Medium + wrapMode: Text.WordWrap + maximumLineCount: 2 + elide: Text.ElideRight + } + Text { + x: 142 * root.s; y: 74 * root.s + width: 183 * root.s + text: root.artistText + color: "#B1B3B3" + font.family: "Noto Sans" + font.pixelSize: 12 * root.s + elide: Text.ElideRight + } + Text { + x: 142 * root.s; y: 105 * root.s + text: root.available ? root.stamp(root.position) + " / " + root.stamp(root.length) : "" + color: "#B1B3B3" + font.family: "Noto Sans" + font.pixelSize: 11 * root.s + } + Rectangle { + id: playButton + x: 338 * root.s; y: 91 * root.s + width: 36 * root.s; height: 36 * root.s + radius: width / 2 + color: "#303136" + opacity: root.available ? 1 : 0.5 + Canvas { + id: playMark + anchors.fill: parent + onPaint: { + const c = getContext("2d") + c.clearRect(0, 0, width, height) + c.save(); c.scale(root.s, root.s) + c.fillStyle = "#F5F5F5" + if (root.playing) { + c.fillRect(12, 10, 4, 16) + c.fillRect(20, 10, 4, 16) + } else { + c.beginPath(); c.moveTo(13, 9) + c.lineTo(26, 18); c.lineTo(13, 27) + c.closePath(); c.fill() + } + c.restore() + } + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: if (root.service) root.service.togglePlayback() + } + } + onPlayingChanged: playMark.requestPaint() + + Rectangle { + id: lyricPlate + x: 18 * root.s; y: 143 * root.s + width: 356 * root.s; height: 76 * root.s + radius: 18 * root.s + color: "#303136" + clip: true + + Column { + visible: root.syncedLines.length > 0 + x: 14 * root.s; y: 7 * root.s + width: parent.width - 28 * root.s + spacing: 2 * root.s + Text { + width: parent.width + visible: text.length > 0 + text: root.lineAt(root.activeLine - 1) + color: "#B1B3B3"; opacity: 0.55 + font.family: "Noto Sans"; font.pixelSize: 11 * root.s + elide: Text.ElideRight + } + Text { + width: parent.width + text: root.lineAt(root.activeLine) + color: "#F5F5F5" + font.family: "Noto Sans"; font.pixelSize: 14 * root.s + font.weight: Font.Medium + wrapMode: Text.WordWrap + maximumLineCount: 2 + elide: Text.ElideRight + } + Text { + width: parent.width + visible: text.length > 0 + text: root.lineAt(root.activeLine + 1) + color: "#B1B3B3"; opacity: 0.7 + font.family: "Noto Sans"; font.pixelSize: 11 * root.s + elide: Text.ElideRight + } + } + Text { + visible: root.plainLines.length > 0 && root.syncedLines.length === 0 + x: 14 * root.s; y: 8 * root.s + width: parent.width - 28 * root.s + height: parent.height - 16 * root.s + text: root.plainLines.slice(0, 3).join("\n") + color: "#F5F5F5" + font.family: "Noto Sans"; font.pixelSize: 12 * root.s + wrapMode: Text.WordWrap + elide: Text.ElideRight + } + Text { + anchors.centerIn: parent + visible: root.plainLines.length === 0 && root.syncedLines.length === 0 + width: parent.width - 24 * root.s + horizontalAlignment: Text.AlignHCenter + text: !root.available ? "Включите музыку" + : root.lyricsStatus === "loading" ? "Ищем текст песни…" + : root.lyricsStatus === "error" ? "Текст недоступен" + : "Текст песни не найден" + color: "#B1B3B3" + font.family: "Noto Sans"; font.pixelSize: 13 * root.s + } + } + + Rectangle { + x: 18 * root.s; y: 238 * root.s + width: 356 * root.s; height: 3 * root.s + radius: height / 2 + color: "#4B4C52" + Rectangle { + width: parent.width * (root.length > 0 + ? Math.max(0, Math.min(1, root.position / root.length)) : 0) + height: parent.height + radius: parent.radius + color: "#DCD7D2" + } + } +} diff --git a/plugins/nothing-media/manifest.json b/plugins/nothing-media/manifest.json new file mode 100644 index 00000000..438e7aa8 --- /dev/null +++ b/plugins/nothing-media/manifest.json @@ -0,0 +1,43 @@ +{ + "id": "nothing-media", + "name": "Nothing Media", + "version": "0.3.2", + "author": "Ivan <254410104+venchik111@users.noreply.github.com>", + "description": "Nothing OS-style media tile with local MPRIS playback control.", + "license": "MIT", + "official": false, + "hosts": [ + "desktopWidget" + ], + "entryPoints": { + "main": "service/Main.qml", + "content": "content/Widget.qml" + }, + "files": [ + "bin/media.py", + "assets/preview-widget.png", + "assets/preview-widget.svg" + ], + "capabilities": { + "densities": [ + "compact", + "full" + ] + }, + "defaults": { + "host": "desktopWidget", + "label": "Nothing Media", + "desktopWidget": { + "bg": "none" + } + }, + "dependencies": { + "commands": [ + "python3", + "playerctl" + ] + }, + "metadata": { + "settings": [] + } +} diff --git a/plugins/nothing-media/product-manifest.json b/plugins/nothing-media/product-manifest.json new file mode 100644 index 00000000..3ed4cc18 --- /dev/null +++ b/plugins/nothing-media/product-manifest.json @@ -0,0 +1,74 @@ +{ + "schema": 1, + "id": "nothing-media", + "category": "plugins", + "version": "0.3.2", + "destination": "ryoku/plugins/nothing-media", + "files": [ + { + "source": "LICENSE", + "destination": "LICENSE", + "sha256": "ba2c1e000400342bf25ec91e60f59fba59d6d62079cfb698622a39f12468d9b9", + "mode": "0644", + "size": 1061, + "install": false + }, + { + "source": "README.md", + "destination": "README.md", + "sha256": "2b0e3e54243f04af848a0f1bef584585c6cf53830e9de004d12c789e28a33cf1", + "mode": "0644", + "size": 971, + "install": false + }, + { + "source": "assets/preview-widget.png", + "destination": "assets/preview-widget.png", + "sha256": "0df25428b38181f9b7b917b9dd5b96940b8e2f592323d5294a4c5636866882c4", + "mode": "0644", + "size": 27650, + "install": false + }, + { + "source": "assets/preview-widget.svg", + "destination": "assets/preview-widget.svg", + "sha256": "981f65d94e58b372cfa796ba074541cd9cb1574e98d3db9ef4854c213b041b36", + "mode": "0644", + "size": 1445, + "install": true + }, + { + "source": "bin/media.py", + "destination": "bin/media.py", + "sha256": "cacb43232dbfeb5951315de0a8ab612fd3f3a043eeef12e2515c7b53b82d3a0c", + "mode": "0755", + "size": 1151, + "install": true + }, + { + "source": "content/Widget.qml", + "destination": "content/Widget.qml", + "sha256": "df99e963ebada72b2c37d27c9f1f03fed3d92812f6f5ddab85f7649356e06113", + "mode": "0644", + "size": 8277, + "install": true + }, + { + "source": "manifest.json", + "destination": "manifest.json", + "sha256": "83a6bddb6cc317069432fac751f877cfcf5eb7d40bcc2343236004b8ed0e4b8c", + "mode": "0644", + "size": 834, + "install": true + }, + { + "source": "service/Main.qml", + "destination": "service/Main.qml", + "sha256": "628d0d6fe62f4f82fd693dcca7d31036cfd4f1d1519e0f6d04a35e94a712fe8e", + "mode": "0644", + "size": 1971, + "install": true + } + ], + "license": "MIT" +} diff --git a/plugins/nothing-media/service/Main.qml b/plugins/nothing-media/service/Main.qml new file mode 100644 index 00000000..1e847c26 --- /dev/null +++ b/plugins/nothing-media/service/Main.qml @@ -0,0 +1,62 @@ +pragma ComponentBehavior: Bound +import QtQuick +import Quickshell +import Quickshell.Io + +Item { + id: svc + property var pluginApi + property var data: ({playing: false, available: false, title: "", artist: "", + position: 0, length: 0}) + property var lyricFrame: ({}) + readonly property string sockPath: (Quickshell.env("XDG_RUNTIME_DIR") || "/tmp") + + "/ryoku-shell.sock" + + function refresh() { + if (pluginApi && !probe.running) + probe.running = true + } + function ingest(raw) { + if (!raw || !raw.trim()) return + try { data = JSON.parse(raw) } + catch (e) { console.warn("nothing-media: invalid MPRIS output", e) } + } + function ingestLyrics(raw) { + if (!raw || !raw.trim()) return + try { lyricFrame = JSON.parse(raw) } + catch (e) { console.warn("nothing-media: invalid lyric frame", e) } + } + function togglePlayback() { + if (data.available && !toggle.running) + toggle.running = true + } + onPluginApiChanged: refresh() + Timer { interval: 2000; repeat: true; running: true; onTriggered: svc.refresh() } + Process { + id: probe + command: svc.pluginApi ? [svc.pluginApi.pluginDir + "/bin/media.py"] : [] + stdout: StdioCollector { onStreamFinished: svc.ingest(this.text) } + } + Process { + id: toggle + command: ["playerctl", "play-pause"] + onRunningChanged: if (!running) svc.refresh() + } + Socket { + id: lyrics + path: svc.sockPath + parser: SplitParser { onRead: line => svc.ingestLyrics(line) } + Component.onCompleted: connected = true + onConnectionStateChanged: { + if (connected) { + write("subscribe music\n") + flush() + } else retry.restart() + } + } + Timer { + id: retry + interval: 2000 + onTriggered: if (!lyrics.connected) lyrics.connected = true + } +} diff --git a/plugins/nothing-screen-time/LICENSE b/plugins/nothing-screen-time/LICENSE new file mode 100644 index 00000000..a754f053 --- /dev/null +++ b/plugins/nothing-screen-time/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ivan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/nothing-screen-time/README.md b/plugins/nothing-screen-time/README.md new file mode 100644 index 00000000..d6097f67 --- /dev/null +++ b/plugins/nothing-screen-time/README.md @@ -0,0 +1,12 @@ +# Nothing Screen Time + +A charcoal capsule with a dotted readout of **today's** screen time. It uses the +same total as Ryoku's Stash and Screen Time service, which counts focused apps +and persists the last 30 days. The value resets at midnight, not when this +widget is reloaded. + +`service/Main.qml` runs `bin/activity.py` every ten seconds. The script only +reads `$XDG_STATE_HOME/ryoku/screentime.json` (or the corresponding +`~/.local/state` path) and prints today's total. It writes no files, uses no +network and needs no privileges. Ryoku persists the counter about every 30 +seconds, so this display may lag the Stash by that amount. diff --git a/plugins/nothing-screen-time/assets/preview-widget.png b/plugins/nothing-screen-time/assets/preview-widget.png new file mode 100644 index 00000000..9f4caacc Binary files /dev/null and b/plugins/nothing-screen-time/assets/preview-widget.png differ diff --git a/plugins/nothing-screen-time/bin/activity.py b/plugins/nothing-screen-time/bin/activity.py new file mode 100755 index 00000000..fafea3d0 --- /dev/null +++ b/plugins/nothing-screen-time/bin/activity.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +"""Read today's screen time from Ryoku's own local counter.""" + +import json +import os +from datetime import datetime +from pathlib import Path + + +def main(): + state_home = Path(os.environ.get('XDG_STATE_HOME') or (Path.home() / '.local/state')) + path = state_home / 'ryoku' / 'screentime.json' + try: + days = json.loads(path.read_text()).get('days', {}) + entry = days.get(datetime.now().strftime('%Y-%m-%d'), {}) + seconds = max(0, int(entry.get('total', 0))) + available = True + except (OSError, ValueError, TypeError, AttributeError): + seconds = 0 + available = False + print(json.dumps({'seconds': seconds, 'available': available})) + + +if __name__ == '__main__': + main() diff --git a/plugins/nothing-screen-time/content/Widget.qml b/plugins/nothing-screen-time/content/Widget.qml new file mode 100644 index 00000000..ce1260f8 --- /dev/null +++ b/plugins/nothing-screen-time/content/Widget.qml @@ -0,0 +1,34 @@ +import QtQuick + +Rectangle { + id: root + property var pluginApi + property var screen + property bool active: false + property string density: "compact" + property real s: 1 + property real widthBudget: 0 + readonly property var service: pluginApi ? pluginApi.mainInstance : null + readonly property bool available: service && service.data.available === true + readonly property int seconds: service ? Number(service.data.seconds ?? 0) : 0 + readonly property int hours: Math.floor(seconds / 3600) + readonly property int minutes: Math.floor(seconds / 60) % 60 + readonly property string readout: !available ? "—" : hours > 0 + ? String(hours) + "h" + String(minutes).padStart(2, "0") + "m" + : String(minutes).padStart(2, "0") + "m" + + implicitWidth: 256 * s + implicitHeight: 120 * s + width: implicitWidth + height: implicitHeight + radius: height / 2 + color: "#1C1D21" + + Text { + anchors.centerIn: parent + text: root.readout + color: "#F5F5F5" + font.family: "Ndot77JPExtended" + font.pixelSize: 38 * root.s + } +} diff --git a/plugins/nothing-screen-time/manifest.json b/plugins/nothing-screen-time/manifest.json new file mode 100644 index 00000000..a1632220 --- /dev/null +++ b/plugins/nothing-screen-time/manifest.json @@ -0,0 +1,34 @@ +{ + "id": "nothing-screen-time", + "name": "Nothing Screen Time", + "version": "0.3.0", + "author": "Ivan \u003c254410104+venchik111@users.noreply.github.com\u003e", + "description": "Dotted readout of Ryoku's existing screen-time total for today.", + "license": "MIT", + "official": false, + "hosts": [ + "desktopWidget" + ], + "entryPoints": { + "main": "service/Main.qml", + "content": "content/Widget.qml" + }, + "files": ["bin/activity.py", "assets/preview-widget.png"], + "capabilities": { + "densities": [ + "compact", + "full" + ] + }, + "defaults": { + "host": "desktopWidget", + "label": "Nothing Screen Time", + "desktopWidget": { "bg": "none" } + }, + "dependencies": { + "commands": ["python3"] + }, + "metadata": { + "settings": [] + } +} diff --git a/plugins/nothing-screen-time/product-manifest.json b/plugins/nothing-screen-time/product-manifest.json new file mode 100644 index 00000000..d6245d8d --- /dev/null +++ b/plugins/nothing-screen-time/product-manifest.json @@ -0,0 +1,66 @@ +{ + "schema": 1, + "id": "nothing-screen-time", + "category": "plugins", + "version": "0.3.0", + "destination": "ryoku/plugins/nothing-screen-time", + "files": [ + { + "source": "LICENSE", + "destination": "LICENSE", + "sha256": "ba2c1e000400342bf25ec91e60f59fba59d6d62079cfb698622a39f12468d9b9", + "mode": "0644", + "size": 1061, + "install": false + }, + { + "source": "README.md", + "destination": "README.md", + "sha256": "b3f660a67189f342d15ba7ad349d24f882678da94ca741bf91d621d9af9cda69", + "mode": "0644", + "size": 635, + "install": false + }, + { + "source": "assets/preview-widget.png", + "destination": "assets/preview-widget.png", + "sha256": "5090b4cfffd88f325277a7dfa7ad32d0d20b6d3f4caf833acca45e6437f6427b", + "mode": "0644", + "size": 15547, + "install": false + }, + { + "source": "bin/activity.py", + "destination": "bin/activity.py", + "sha256": "fbda6814067e049ba155ebf3c2cd68a41357bff2bca5ba049e58c66f1364d09a", + "mode": "0755", + "size": 748, + "install": true + }, + { + "source": "content/Widget.qml", + "destination": "content/Widget.qml", + "sha256": "d4f0adb4d326fe08be32387f5e0b46eb8b0b241e51177e0e58a7c56694a43178", + "mode": "0644", + "size": 1107, + "install": true + }, + { + "source": "manifest.json", + "destination": "manifest.json", + "sha256": "847a3eb523179437491e947bcf000d9032d9de1f87df73c5027eabe8619adc63", + "mode": "0644", + "size": 780, + "install": true + }, + { + "source": "service/Main.qml", + "destination": "service/Main.qml", + "sha256": "2c1dd40c099d49cf34bf667b2bf4bb8361f02c915d41a75739f5f778498ab4a0", + "mode": "0644", + "size": 820, + "install": true + } + ], + "license": "MIT" +} diff --git a/plugins/nothing-screen-time/service/Main.qml b/plugins/nothing-screen-time/service/Main.qml new file mode 100644 index 00000000..b21a5517 --- /dev/null +++ b/plugins/nothing-screen-time/service/Main.qml @@ -0,0 +1,28 @@ +pragma ComponentBehavior: Bound +import QtQuick +import Quickshell.Io + +Item { + id: svc + property var pluginApi + property var data: ({seconds: 0, available: false}) + + function refresh() { + if (pluginApi && !probe.running) + probe.running = true + } + function ingest(raw) { + if (!raw || !raw.trim()) return + try { data = JSON.parse(raw) } + catch (e) { console.warn("nothing-screen-time: invalid activity output", e) } + } + onPluginApiChanged: refresh() + Timer { interval: 10000; repeat: true; running: true; onTriggered: svc.refresh() } + Process { + id: probe + command: svc.pluginApi + ? [svc.pluginApi.pluginDir + "/bin/activity.py"] + : [] + stdout: StdioCollector { onStreamFinished: svc.ingest(this.text) } + } +} diff --git a/plugins/nothing-status/LICENSE b/plugins/nothing-status/LICENSE new file mode 100644 index 00000000..a754f053 --- /dev/null +++ b/plugins/nothing-status/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ivan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/nothing-status/README.md b/plugins/nothing-status/README.md new file mode 100644 index 00000000..14d3b7ef --- /dev/null +++ b/plugins/nothing-status/README.md @@ -0,0 +1,21 @@ +# Nothing Status + +A Ryoku QS Bar widget with local network, audio and battery indicators. +Its icons are drawn in QML as monochrome lines. The bar uses only neutral +ink; N-Red appears if the battery falls below 16%. The popup shows status +in a charcoal card. + +## Access + +The bar glyph opens its read-only panel. Show or hide each bar indicator from +QS Bar Settings > Community. The desktop owner can move the widget with +QS Bar Settings > Layout. + +## Capabilities + +- Runs its own `bin/status.py` every five seconds via Python 3. +- The script reads `/sys/class/power_supply/BAT*/{capacity,status}`. +- It calls local `nmcli` and `wpctl` to read status. +- It makes no network requests, writes no files and performs no privileged + actions. +- Text uses Noto Sans. Proprietary Nothing font files are not included. diff --git a/plugins/nothing-status/assets/preview-widget.png b/plugins/nothing-status/assets/preview-widget.png new file mode 100644 index 00000000..f6a9e90c Binary files /dev/null and b/plugins/nothing-status/assets/preview-widget.png differ diff --git a/plugins/nothing-status/bin/status.py b/plugins/nothing-status/bin/status.py new file mode 100755 index 00000000..0e318402 --- /dev/null +++ b/plugins/nothing-status/bin/status.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Read local network, audio and battery state for the Ryoku bar.""" + +import json +import re +import subprocess +from pathlib import Path + + +def run(argv): + try: + return subprocess.run( + argv, capture_output=True, text=True, timeout=2, check=False, + ).stdout.strip() + except (OSError, subprocess.TimeoutExpired): + return '' + + +def battery(): + plugged = False + for supply in Path('/sys/class/power_supply').iterdir(): + try: + if ((supply / 'type').read_text().strip().lower() != 'battery' + and (supply / 'online').read_text().strip() == '1'): + plugged = True + break + except (OSError, ValueError): + pass + for root in sorted(Path('/sys/class/power_supply').glob('BAT*')): + try: + level = int((root / 'capacity').read_text().strip()) + status = (root / 'status').read_text().strip().lower() + return level, status == 'charging', plugged or status in ('charging', 'full') + except (OSError, ValueError): + pass + return -1, False, plugged + + +network = run(['nmcli', '-t', '-f', 'CONNECTIVITY', 'general', 'status']) +audio = run(['wpctl', 'get-volume', '@DEFAULT_AUDIO_SINK@']) +volume_match = re.search(r'Volume:\s*([0-9.]+)', audio) +level, charging, plugged = battery() +print(json.dumps({ + 'network': network.splitlines()[0] if network else 'unknown', + 'volume': round(float(volume_match.group(1)) * 100) if volume_match else 0, + 'muted': '[MUTED]' in audio, + 'battery': level, + 'charging': charging, + 'plugged': plugged, +})) diff --git a/plugins/nothing-status/content/Icon.qml b/plugins/nothing-status/content/Icon.qml new file mode 100644 index 00000000..52021a24 --- /dev/null +++ b/plugins/nothing-status/content/Icon.qml @@ -0,0 +1,78 @@ +import QtQuick + +Canvas { + id: icon + property string kind: "network" + property color ink: "#FFFFFF" + property color accent: "#C8102E" + property int level: 100 + property bool charging: false + implicitWidth: 20 + implicitHeight: 20 + onKindChanged: requestPaint() + onInkChanged: requestPaint() + onAccentChanged: requestPaint() + onLevelChanged: requestPaint() + onChargingChanged: requestPaint() + onWidthChanged: requestPaint() + onHeightChanged: requestPaint() + + onPaint: { + const c = getContext("2d") + c.clearRect(0, 0, width, height) + c.save() + c.scale(width / 24, height / 24) + c.strokeStyle = icon.ink + c.fillStyle = icon.ink + c.lineWidth = 1.7 + c.lineCap = "square" + c.lineJoin = "miter" + + if (kind === "network") { + c.beginPath(); c.arc(12, 16, 10, Math.PI * 1.2, Math.PI * 1.8); c.stroke() + c.beginPath(); c.arc(12, 16, 6, Math.PI * 1.2, Math.PI * 1.8); c.stroke() + c.beginPath(); c.arc(12, 16, 2, 0, 2 * Math.PI); c.fill() + } else if (kind === "audio") { + c.beginPath(); c.moveTo(3, 9); c.lineTo(7, 9); c.lineTo(11, 5) + c.lineTo(11, 19); c.lineTo(7, 15); c.lineTo(3, 15); c.closePath(); c.stroke() + c.beginPath(); c.arc(11, 12, 5, -0.75, 0.75); c.stroke() + c.beginPath(); c.arc(11, 12, 9, -0.75, 0.75); c.stroke() + } else if (kind === "battery") { + c.strokeRect(2.5, 7, 17, 10) + c.fillRect(20.5, 10, 1.5, 4) + c.fillStyle = level <= 20 ? icon.accent : icon.ink + c.fillRect(5, 9.5, Math.max(0, 12 * Math.min(100, level) / 100), 5) + } else if (kind === "charge") { + c.beginPath() + c.moveTo(13, 2); c.lineTo(7, 13); c.lineTo(12, 12) + c.lineTo(10, 22); c.lineTo(18, 10); c.lineTo(13, 11) + c.closePath(); c.fill() + } else if (kind === "cpu") { + c.strokeRect(6, 6, 12, 12) + for (let i = 8; i <= 16; i += 4) { + c.beginPath(); c.moveTo(i, 2); c.lineTo(i, 6) + c.moveTo(i, 18); c.lineTo(i, 22); c.stroke() + c.beginPath(); c.moveTo(2, i); c.lineTo(6, i) + c.moveTo(18, i); c.lineTo(22, i); c.stroke() + } + c.fillRect(10, 10, 4, 4) + } else if (kind === "memory") { + c.strokeRect(3, 7, 18, 11) + c.fillRect(6, 10, 2, 5); c.fillRect(11, 10, 2, 5); c.fillRect(16, 10, 2, 5) + for (let x = 6; x <= 18; x += 4) { + c.beginPath(); c.moveTo(x, 18); c.lineTo(x, 21); c.stroke() + } + } else if (kind === "music") { + c.beginPath(); c.moveTo(10, 17); c.lineTo(10, 5); c.lineTo(19, 3) + c.lineTo(19, 15); c.stroke() + c.beginPath(); c.arc(7, 18, 2.5, 0, 2 * Math.PI); c.fill() + c.beginPath(); c.arc(16, 16, 2.5, 0, 2 * Math.PI); c.fill() + } else if (kind === "calendar") { + c.strokeRect(3, 5, 18, 16) + c.beginPath(); c.moveTo(3, 10); c.lineTo(21, 10) + c.moveTo(8, 2); c.lineTo(8, 7); c.moveTo(16, 2); c.lineTo(16, 7); c.stroke() + c.fillRect(7, 13, 2, 2); c.fillRect(11, 13, 2, 2); c.fillRect(15, 13, 2, 2) + } + c.restore() + } +} diff --git a/plugins/nothing-status/content/Panel.qml b/plugins/nothing-status/content/Panel.qml new file mode 100644 index 00000000..22fb0fb7 --- /dev/null +++ b/plugins/nothing-status/content/Panel.qml @@ -0,0 +1,59 @@ +import QtQuick + +Rectangle { + id: root + property var pluginApi + property string density: "full" + property real s: 1 + property real widthBudget: 320 + property bool active: false + readonly property var service: pluginApi ? pluginApi.mainInstance : null + readonly property var state: service ? service.data : ({}) + readonly property color white: "#F5F5F5" + readonly property color grey: "#B1B3B3" + implicitWidth: widthBudget + implicitHeight: 186 * s + color: "#2D2E33" + radius: 20 * s + + Column { + x: 20 * root.s + y: 17 * root.s + width: root.width - 40 * root.s + spacing: 13 * root.s + + Text { + text: "Система" + color: root.white + font.family: "Noto Sans" + font.pixelSize: 16 * root.s + } + Rectangle { width: parent.width; height: 1; color: "#55565B" } + Row { + spacing: 11 * root.s + Icon { kind: "network"; width: 19 * root.s; height: 19 * root.s; ink: root.white } + Text { text: "Сеть"; color: root.grey; width: 92 * root.s; font.family: "Noto Sans"; font.pixelSize: 12 * root.s } + Text { text: root.state.network === "full" ? "В сети" : "Нет сети"; color: root.white; font.family: "Noto Sans"; font.pixelSize: 12 * root.s } + } + Row { + spacing: 11 * root.s + Icon { kind: "audio"; width: 19 * root.s; height: 19 * root.s; ink: root.white } + Text { text: "Звук"; color: root.grey; width: 92 * root.s; font.family: "Noto Sans"; font.pixelSize: 12 * root.s } + Text { text: root.state.muted ? "Без звука" : String(root.state.volume ?? 0) + "%"; color: root.white; font.family: "Noto Sans"; font.pixelSize: 12 * root.s } + } + Row { + spacing: 11 * root.s + Icon { kind: "battery"; width: 19 * root.s; height: 19 * root.s; ink: root.white; level: root.state.battery ?? 100 } + Text { text: "Батарея"; color: root.grey; width: 92 * root.s; font.family: "Noto Sans"; font.pixelSize: 12 * root.s } + Text { + text: root.state.battery < 0 ? "--" + : root.state.charging ? String(root.state.battery) + "% · Зарядка" + : root.state.plugged ? String(root.state.battery) + "% · От сети" + : String(root.state.battery) + "%" + color: root.state.battery >= 0 && root.state.battery <= 15 ? "#C8102E" : root.white + font.family: "Noto Sans" + font.pixelSize: 12 * root.s + } + } + } +} diff --git a/plugins/nothing-status/content/Widget.qml b/plugins/nothing-status/content/Widget.qml new file mode 100644 index 00000000..6c0e9636 --- /dev/null +++ b/plugins/nothing-status/content/Widget.qml @@ -0,0 +1,70 @@ +import QtQuick + +Item { + id: root + property var pluginApi + property var screen + property bool active: false + property string density: "glyph" + property real s: 1 + property real widthBudget: 0 + readonly property var service: pluginApi ? pluginApi.mainInstance : null + readonly property var state: service ? service.data : ({}) + readonly property var settings: pluginApi ? pluginApi.pluginSettings : ({}) + readonly property bool online: state.network === "full" || state.network === "limited" + readonly property color ink: "#F5F5F5" + readonly property color dim: "#B1B3B3" + + implicitWidth: row.implicitWidth + implicitHeight: 28 * s + + Row { + id: row + anchors.centerIn: parent + spacing: 9 * root.s + Icon { + visible: root.settings.showNetwork !== false + kind: "network" + width: 17 * root.s; height: 17 * root.s + ink: root.online ? root.ink : root.dim + } + Icon { + visible: root.settings.showVolume !== false + kind: "audio" + width: 17 * root.s; height: 17 * root.s + ink: root.state.muted ? root.dim : root.ink + } + Row { + visible: root.settings.showBattery !== false && root.state.battery >= 0 + anchors.verticalCenter: parent.verticalCenter + spacing: 4 * root.s + Icon { + kind: "battery" + width: 17 * root.s; height: 17 * root.s + level: root.state.battery ?? 100 + ink: root.state.battery >= 0 && root.state.battery <= 15 + ? "#C8102E" : root.ink + } + Icon { + visible: root.state.charging === true || root.state.plugged === true + kind: "charge" + width: 10 * root.s; height: 17 * root.s + ink: root.ink + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: String(root.state.battery ?? 0) + color: root.state.battery >= 0 && root.state.battery <= 15 + ? "#C8102E" : root.ink + font.family: "Noto Sans" + font.pixelSize: 12 * root.s + } + } + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: if (root.pluginApi) root.pluginApi.togglePanel() + } +} diff --git a/plugins/nothing-status/manifest.json b/plugins/nothing-status/manifest.json new file mode 100644 index 00000000..9acee4a2 --- /dev/null +++ b/plugins/nothing-status/manifest.json @@ -0,0 +1,60 @@ +{ + "id": "nothing-status", + "name": "Nothing Status", + "version": "0.3.0", + "author": "Ivan \u003c254410104+venchik111@users.noreply.github.com\u003e", + "description": "Neutral Nothing OS-style status icons for the Ryoku bar.", + "license": "MIT", + "official": false, + "hosts": [ + "topbarGlyph" + ], + "entryPoints": { + "main": "service/Main.qml", + "content": "content/Widget.qml", + "panel": "content/Panel.qml" + }, + "panel": { + "width": 320 + }, + "files": ["content/Icon.qml", "bin/status.py", "assets/preview-widget.png"], + "capabilities": { + "densities": [ + "glyph", + "compact" + ] + }, + "defaults": { + "host": "topbarGlyph", + "label": "Nothing Status", + "bar": { "section": "right" } + }, + "dependencies": { + "commands": ["python3", "nmcli", "wpctl"] + }, + "metadata": { + "settings": [ + { + "key": "showNetwork", + "type": "toggle", + "label": "Show network", + "group": "Nothing Status", + "default": true + }, + { + "key": "showVolume", + "type": "toggle", + "label": "Show volume", + "group": "Nothing Status", + "default": true + }, + { + "key": "showBattery", + "type": "toggle", + "label": "Show battery", + "group": "Nothing Status", + "default": true + } + ] + } +} diff --git a/plugins/nothing-status/product-manifest.json b/plugins/nothing-status/product-manifest.json new file mode 100644 index 00000000..89a167cb --- /dev/null +++ b/plugins/nothing-status/product-manifest.json @@ -0,0 +1,82 @@ +{ + "schema": 1, + "id": "nothing-status", + "category": "plugins", + "version": "0.3.0", + "destination": "ryoku/plugins/nothing-status", + "files": [ + { + "source": "LICENSE", + "destination": "LICENSE", + "sha256": "ba2c1e000400342bf25ec91e60f59fba59d6d62079cfb698622a39f12468d9b9", + "mode": "0644", + "size": 1061, + "install": false + }, + { + "source": "README.md", + "destination": "README.md", + "sha256": "2737bc30d4b3ca2afcc96f67db004845dd079417298e0bddba8024d1de6055da", + "mode": "0644", + "size": 808, + "install": false + }, + { + "source": "assets/preview-widget.png", + "destination": "assets/preview-widget.png", + "sha256": "99e8c63c100f6c37fd3ce87ff14f67255e07c1a25b4ff65ef70cc287178bef75", + "mode": "0644", + "size": 4056, + "install": false + }, + { + "source": "bin/status.py", + "destination": "bin/status.py", + "sha256": "b8de6e1ffd780fd271ccbcfbaccc21725995ab23e0e8918fdcb01da717b40572", + "mode": "0755", + "size": 1642, + "install": true + }, + { + "source": "content/Icon.qml", + "destination": "content/Icon.qml", + "sha256": "cf1824c746298c5b2e18b101a8bc876188daab0d075bf354559248b1c8677066", + "mode": "0644", + "size": 3295, + "install": true + }, + { + "source": "content/Panel.qml", + "destination": "content/Panel.qml", + "sha256": "f6f2b8705df3c40cb62a2c2c7a23aa036ed5002145ca8d9cb5875aa868c2db26", + "mode": "0644", + "size": 2644, + "install": true + }, + { + "source": "content/Widget.qml", + "destination": "content/Widget.qml", + "sha256": "e93033f25e4258300ada27247b6cbc0099f507cc1614f7e5c2b00e26311f7eb7", + "mode": "0644", + "size": 2432, + "install": true + }, + { + "source": "manifest.json", + "destination": "manifest.json", + "sha256": "f797a26946c228dd028453a8116dc1d1a6e6a887e71df78e6b9ecbaf3857c926", + "mode": "0644", + "size": 1353, + "install": true + }, + { + "source": "service/Main.qml", + "destination": "service/Main.qml", + "sha256": "fa9165d96bbc73438e0a07bb933137891a9874c4b3546f170e1793cb85fe61e9", + "mode": "0644", + "size": 908, + "install": true + } + ], + "license": "MIT" +} diff --git a/plugins/nothing-status/service/Main.qml b/plugins/nothing-status/service/Main.qml new file mode 100644 index 00000000..faad7660 --- /dev/null +++ b/plugins/nothing-status/service/Main.qml @@ -0,0 +1,37 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell.Io + +Item { + id: svc + property var pluginApi + property var data: ({network: "unknown", volume: 0, muted: false, + battery: -1, charging: false, cpu: 0, memory: 0, + media: ""}) + + function refresh() { + if (pluginApi && !probe.running) + probe.running = true + } + + function ingest(raw) { + try { data = JSON.parse(raw) } + catch (e) { console.warn("nothing-status: invalid probe output", e) } + } + + onPluginApiChanged: refresh() + + Timer { + interval: 5000 + repeat: true + running: true + onTriggered: svc.refresh() + } + + Process { + id: probe + command: svc.pluginApi ? [svc.pluginApi.pluginDir + "/bin/status.py"] : [] + stdout: StdioCollector { onStreamFinished: svc.ingest(this.text) } + } +} diff --git a/plugins/registry.json b/plugins/registry.json index 4a0dc5d3..15382ab7 100644 --- a/plugins/registry.json +++ b/plugins/registry.json @@ -1441,6 +1441,186 @@ ], "lastUpdated": "2026-10-06", "upstream": "https://github.com/ryoku-dev/ryostore/tree/main/plugins/launchpad" + }, + { + "id": "nothing-analog-clock", + "name": "Nothing Analog Clock", + "version": "0.2.0", + "path": "plugins/nothing-analog-clock", + "author": "Ivan <254410104+venchik111@users.noreply.github.com>", + "upstream": "https://github.com/venchik111/ryoku-nothing-interface/tree/main/plugins/nothing-analog-clock", + "summary": "Charcoal analog clock with fine white markers and one N-Red second hand.", + "description": "Charcoal analog clock with fine white markers and one N-Red second hand.", + "tags": [ + "nothing", + "desktop-widget" + ], + "accent": "#C2C4CB", + "surface": "#1C1D21", + "preview": "assets/preview-widget.png", + "screenshots": [ + "assets/preview-widget.png" + ], + "manifest": "product-manifest.json", + "manifestSha256": "7fffcab88be6f73318026a1060d7f5383c7a0cc4d1d7d79f77a779948b1052a9", + "official": false, + "license": "MIT", + "tagline": "Nothing Interface for Ryoku", + "icon": "", + "hosts": [ + "desktopWidget" + ], + "lastUpdated": "2026-10-01" + }, + { + "id": "nothing-audio", + "name": "Nothing Audio", + "version": "0.2.0", + "path": "plugins/nothing-audio", + "author": "Ivan <254410104+venchik111@users.noreply.github.com>", + "upstream": "https://github.com/venchik111/ryoku-nothing-interface/tree/main/plugins/nothing-audio", + "summary": "Circular Nothing OS-style audio widget with mute control.", + "description": "Circular Nothing OS-style audio widget with mute control.", + "tags": [ + "nothing", + "desktop-widget" + ], + "accent": "#C2C4CB", + "surface": "#1C1D21", + "preview": "assets/preview-widget.png", + "screenshots": [ + "assets/preview-widget.png" + ], + "manifest": "product-manifest.json", + "manifestSha256": "7fd6bb56a2a47c04fe62647b010f2c32fa1a4c1af85dac22bdb84afbfe043018", + "official": false, + "license": "MIT", + "tagline": "Nothing Interface for Ryoku", + "icon": "", + "hosts": [ + "desktopWidget" + ], + "lastUpdated": "2026-10-01" + }, + { + "id": "nothing-dashboard", + "name": "Nothing Battery", + "version": "0.4.0", + "path": "plugins/nothing-dashboard", + "author": "Ivan <254410104+venchik111@users.noreply.github.com>", + "upstream": "https://github.com/venchik111/ryoku-nothing-interface/tree/main/plugins/nothing-dashboard", + "summary": "A Nothing OS-inspired battery tile with a dotted charge readout.", + "description": "A Nothing OS-inspired battery tile with a dotted charge readout.", + "tags": [ + "nothing", + "desktop-widget" + ], + "accent": "#C2C4CB", + "surface": "#1C1D21", + "preview": "assets/preview-widget.png", + "screenshots": [ + "assets/preview-widget.png" + ], + "manifest": "product-manifest.json", + "manifestSha256": "7769c40047b70e5a0b4443d4751061897a1b1833f5eb76594c2783a059a86671", + "official": false, + "license": "MIT", + "tagline": "Nothing Interface for Ryoku", + "icon": "", + "hosts": [ + "desktopWidget" + ], + "lastUpdated": "2026-10-01" + }, + { + "id": "nothing-media", + "name": "Nothing Media", + "version": "0.3.2", + "path": "plugins/nothing-media", + "author": "Ivan <254410104+venchik111@users.noreply.github.com>", + "upstream": "https://github.com/venchik111/ryoku-nothing-interface/tree/main/plugins/nothing-media", + "summary": "Nothing OS-style media tile with local MPRIS playback control.", + "description": "Nothing OS-style media tile with local MPRIS playback control.", + "tags": [ + "nothing", + "desktop-widget" + ], + "accent": "#C2C4CB", + "surface": "#1C1D21", + "preview": "assets/preview-widget.png", + "screenshots": [ + "assets/preview-widget.png" + ], + "manifest": "product-manifest.json", + "manifestSha256": "c72b805bc19bb7ed9d729fe019b25cc6aa21305267e57725f5f742a4f6b92d15", + "official": false, + "license": "MIT", + "tagline": "Nothing Interface for Ryoku", + "icon": "", + "hosts": [ + "desktopWidget" + ], + "lastUpdated": "2026-10-01" + }, + { + "id": "nothing-screen-time", + "name": "Nothing Screen Time", + "version": "0.3.0", + "path": "plugins/nothing-screen-time", + "author": "Ivan <254410104+venchik111@users.noreply.github.com>", + "upstream": "https://github.com/venchik111/ryoku-nothing-interface/tree/main/plugins/nothing-screen-time", + "summary": "Dotted readout of Ryoku's existing screen-time total for today.", + "description": "Dotted readout of Ryoku's existing screen-time total for today.", + "tags": [ + "nothing", + "desktop-widget" + ], + "accent": "#C2C4CB", + "surface": "#1C1D21", + "preview": "assets/preview-widget.png", + "screenshots": [ + "assets/preview-widget.png" + ], + "manifest": "product-manifest.json", + "manifestSha256": "344f7fcbe0e81f20d9bfa628e9083d866f60d59943531f85cfa46a056164d1bc", + "official": false, + "license": "MIT", + "tagline": "Nothing Interface for Ryoku", + "icon": "", + "hosts": [ + "desktopWidget" + ], + "lastUpdated": "2026-10-01" + }, + { + "id": "nothing-status", + "name": "Nothing Status", + "version": "0.3.0", + "path": "plugins/nothing-status", + "author": "Ivan <254410104+venchik111@users.noreply.github.com>", + "upstream": "https://github.com/venchik111/ryoku-nothing-interface/tree/main/plugins/nothing-status", + "summary": "Neutral Nothing OS-style status icons for the Ryoku bar.", + "description": "Neutral Nothing OS-style status icons for the Ryoku bar.", + "tags": [ + "nothing", + "bar-widget" + ], + "accent": "#C2C4CB", + "surface": "#1C1D21", + "preview": "assets/preview-widget.png", + "screenshots": [ + "assets/preview-widget.png" + ], + "manifest": "product-manifest.json", + "manifestSha256": "8e4f44ae8e69cbfc9a053bf14072d9d5ccc1b7e8b0229e32ac7f9b9401584e82", + "official": false, + "license": "MIT", + "tagline": "Nothing Interface for Ryoku", + "icon": "", + "hosts": [ + "topbarGlyph" + ], + "lastUpdated": "2026-10-01" } ] } diff --git a/rices/nothing-interface/README.md b/rices/nothing-interface/README.md new file mode 100644 index 00000000..8d0b6509 --- /dev/null +++ b/rices/nothing-interface/README.md @@ -0,0 +1,29 @@ +# Nothing Interface for Ryoku + +Community rice by Ivan. A fixed charcoal and grey palette, three island QS Bar +layout, and Noto Sans typography inspired by Nothing OS. The rice does not +install or change a wallpaper. It is an independent fan project and is not +affiliated with Nothing Technology Limited. + +This store item installs the Ryoku rice and palette. For the complete desktop, +install the related Ryostore plugins `nothing-analog-clock`, `nothing-dashboard`, +`nothing-media`, `nothing-screen-time`, and `nothing-status`; `nothing-audio` is +optional. The matching `nothing-interface` lockscreen is a separate store item. +The extra QML overrides for the large dotted desktop clock, bar clock, and +launcher mark, plus the 1920 × 1080 widget layout guide, are documented in the +[upstream project](https://github.com/venchik111/ryoku-nothing-interface). +Ryostore's rice format does not install those overlays or position the plugins. + +Built with Ryoku 0.75.3. Save your current rice before applying. On another +screen size, place the widgets in Ryoku Hub → Widgets. Adjust the bar in QS Bar +Settings → Bars / Layout / Community. The optional AI-generated wallpapers +linked from the upstream project are not bundled with this store item. + +The rice and palette are MIT licensed under the upstream project's +[LICENSE](https://github.com/venchik111/ryoku-nothing-interface/blob/main/LICENSE). +The catalogue preview is a capture of the running desktop and is not installed; +the wallpaper and album art visible in it are not part of the rice. Nothing +trademarks belong to their owners. + +Maintainer: [venchik111](https://github.com/venchik111). Please report +compatibility issues in the [upstream repository](https://github.com/venchik111/ryoku-nothing-interface/issues). diff --git a/rices/nothing-interface/assets/preview.png b/rices/nothing-interface/assets/preview.png new file mode 100644 index 00000000..482625b8 Binary files /dev/null and b/rices/nothing-interface/assets/preview.png differ diff --git a/rices/nothing-interface/palette.json b/rices/nothing-interface/palette.json new file mode 100644 index 00000000..17a8b73a --- /dev/null +++ b/rices/nothing-interface/palette.json @@ -0,0 +1,53 @@ +{ + "background": "#1C1D21", + "foreground": "#F5F5F5", + "cursor": "#F5F5F5", + "color0": "#1C1D21", + "color1": "#C8102E", + "color2": "#B1B3B3", + "color3": "#DCD7D2", + "color4": "#777980", + "color5": "#8A8C92", + "color6": "#C2C4CB", + "color7": "#F5F5F5", + "color8": "#55565B", + "color9": "#C8102E", + "color10": "#B1B3B3", + "color11": "#DCD7D2", + "color12": "#8A8C92", + "color13": "#C2C4CB", + "color14": "#E2E2E4", + "color15": "#FFFFFF", + "primary": "#DCD7D2", + "onPrimary": "#1C1D21", + "primaryContainer": "#3A3B40", + "onPrimaryContainer": "#F5F5F5", + "secondary": "#B1B3B3", + "onSecondary": "#1C1D21", + "secondaryContainer": "#303136", + "onSecondaryContainer": "#F5F5F5", + "tertiary": "#C2C4CB", + "onTertiary": "#1C1D21", + "tertiaryContainer": "#3C3E45", + "onTertiaryContainer": "#F5F5F5", + "error": "#C8102E", + "onError": "#FFFFFF", + "errorContainer": "#4A1D27", + "onErrorContainer": "#F5F5F5", + "surface": "#1C1D21", + "onSurface": "#F5F5F5", + "surfaceVariant": "#303136", + "onSurfaceVariant": "#C2C4CB", + "surfaceContainerLowest": "#17181B", + "surfaceContainerLow": "#222328", + "surfaceContainer": "#2D2E33", + "surfaceContainerHigh": "#383940", + "surfaceContainerHighest": "#44454A", + "surfaceTint": "#DCD7D2", + "outline": "#777980", + "outlineVariant": "#55565B", + "inverseSurface": "#DCD7D2", + "inverseOnSurface": "#1C1D21", + "shadow": "#000000", + "scrim": "#000000" +} diff --git a/rices/nothing-interface/rice.json b/rices/nothing-interface/rice.json new file mode 100644 index 00000000..f3b27723 --- /dev/null +++ b/rices/nothing-interface/rice.json @@ -0,0 +1,138 @@ +{ + "schema": 1, + "id": "nothing-interface", + "category": "rices", + "version": "1.0.0", + "destination": "ryoku/rices/nothing-interface", + "slug": "nothing-interface", + "name": "Nothing Interface", + "author": "Ivan", + "blurb": "Charcoal island bar and modular Nothing OS-inspired desktop widgets. No wallpaper is included.", + "tags": [ + "dark", + "monochrome", + "red-accent", + "no-wallpaper" + ], + "createdWith": "0.75.3", + "color": { + "mode": "fixed", + "palette": "palette.json", + "themeApps": false + }, + "assets": {}, + "look": { + "shell": { + "fontFamily": "Noto Sans", + "qsbar": { + "aiTools": "claude,codex,opencode", + "audioBoost": false, + "barAnim": 0, + "barAutoHide": false, + "barBorderEnabled": false, + "barColor": "foreground", + "barCornerRadius": 20, + "barFrostEnabled": false, + "barGapBottom": 0, + "barGapLeft": 8, + "barGapRight": 8, + "barGapTop": 8, + "barPosition": "top", + "barScale": 1, + "barSeps": [], + "barShadowEnabled": false, + "barShellStyle": "islands", + "barTemperatureSource": "cpu", + "iconOnlyGids": [], + "launcherLogoMode": "icon", + "layout": { + "center": [ + "clock", + "media" + ], + "left": [ + "launcher", + "workspaces", + "cpu", + "volume", + "memory", + "ai", + "cputemp" + ], + "right": [ + "quick", + "network", + "power", + "layout", + "battery", + "brightness", + "storage", + "gpu", + "bluetooth", + "status", + "nothing-status" + ], + "version": 1 + }, + "panelTooltipBorderEnabled": true, + "pickerStyle": "tanzaku", + "widgetColorStyles": "-", + "widgetGeom": {}, + "widgets": { + "battery": false, + "bluetooth": false, + "brightness": false, + "claude": false, + "cpu": true, + "cpuTemperature": true, + "gpu": false, + "layout": true, + "media": true, + "memory": true, + "mpris": true, + "network": true, + "power": false, + "quick": false, + "status": true, + "storage": false, + "volume": true, + "weather": true + }, + "workspaceMode": "active", + "workspaceStyle": "rings" + } + }, + "widgets": { + "clockAccent": "mono", + "clockAnchor": "free", + "clockBg": "none", + "clockColor": "#FFFFFF", + "clockDesign": "digital", + "clockEnabled": true, + "clockScale": 1.15, + "clockX": 104, + "clockY": 96, + "musicEnabled": false, + "statsEnabled": false, + "widgetFont": "Noto Sans" + } + }, + "files": [ + { + "source": "assets/preview.png", + "destination": "assets/preview.png", + "mode": "0644", + "size": 1655377, + "sha256": "03b405d50824b7378f82035a6cebcc601d61468fcacebe4a72338794dd244d36", + "install": false + }, + { + "source": "palette.json", + "destination": "palette.json", + "mode": "0644", + "size": 1421, + "sha256": "8b0499e094bf58bef147d9359fc6692d58f291421ccd0680bb8a3071cb3addc4", + "install": true + } + ] +} diff --git a/rices/registry.json b/rices/registry.json index b8aec1e7..abbf1678 100644 --- a/rices/registry.json +++ b/rices/registry.json @@ -218,6 +218,39 @@ "upstream": "https://github.com/eduardojvieira/ryostore/tree/feat/ryoku-paper-rices/rices/ryoku-paper-dark", "official": false, "lastUpdated": "2026-09-28" + }, + { + "id": "nothing-interface", + "name": "Nothing Interface", + "version": "1.0.0", + "path": "rices/nothing-interface", + "author": "Ivan <254410104+venchik111@users.noreply.github.com>", + "summary": "Nothing-inspired charcoal islands, dot clock and fixed grey palette.", + "description": "Charcoal Nothing OS-inspired Ryoku rice with a three-island QS Bar and fixed grey palette. Leaves the wallpaper unchanged. Matching widgets and lock screen are separate store entries.", + "tags": [ + "nothing", + "dark", + "monochrome", + "islands", + "no-wallpaper" + ], + "accent": "#DCD7D2", + "surface": "#1C1D21", + "preview": "assets/preview.png", + "screenshots": [ + "assets/preview.png" + ], + "manifest": "rice.json", + "manifestSha256": "c73a39c34a7c123970a1435c2eeedcf0f43a288894d8e7b3c7883c7670ad2842", + "blurb": "Charcoal island bar and modular Nothing OS-inspired desktop widgets. No wallpaper is included.", + "createdWith": "0.75.3", + "color": "fixed", + "palette": "rices/nothing-interface/palette.json", + "rounding": 0, + "upstream": "https://github.com/venchik111/ryoku-nothing-interface", + "official": false, + "license": "MIT", + "lastUpdated": "2026-10-01" } ] }