From 4c818e327b6860a55bda837eae92a9e3a64d6421 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Mon, 27 Apr 2026 21:10:13 +0200 Subject: [PATCH] Codex. Cover support, updates for Music Box --- .codex | 0 .gitignore | 4 + AGENTS.md | 20 + basic/scenes/Default_Stream_HUD.json | 800 ++++++++++++++++++++++++-- bridges/mpd-state.py | 253 ++++++++- desktop/index.html | 184 +++--- music-box/cover.html | 295 ++++++++++ music-box/nc-music-box.html | 804 +++++++++++++++++++++++++++ plugin_manager/modules.json | 13 + user.ini | 4 +- 10 files changed, 2248 insertions(+), 129 deletions(-) create mode 100644 .codex create mode 100644 AGENTS.md create mode 100644 music-box/cover.html create mode 100644 music-box/nc-music-box.html diff --git a/.codex b/.codex new file mode 100644 index 0000000..e69de29 diff --git a/.gitignore b/.gitignore index f528046..b35db50 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,10 @@ loading/cmd.js # setup.sh from your existing plugin_config/obs-websocket/config.json. vendor/obs-config.js +# Album-art cache written by bridges/mpd-state.py on every track change — +# overlays load it as ``. Pure runtime state. +bridges/cover.jpg + # Local backups created during edits — keep out of git noise. landing/*.manual loading/*.manual diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..97ddaaf --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,20 @@ +# Repository Guidelines + +## Project Structure & Module Organization +This repository is the live OBS Studio config for the `ophi118` rig. Core OBS state lives in `basic/scenes/` and `basic/profiles/`; treat those as OBS-owned files. Browser-source overlays are organized by scene in `landing/`, `loading/`, `game/`, `desktop/`, `music-box/`, `music/`, and `goodbye/`. Support automation lives in `scripts/`, Python services in `bridges/`, `twitch-bot/`, and `station/server/`, shared browser helpers in `vendor/`, and plugin settings in `plugin_config/`. + +## Build, Test, and Development Commands +Run OBS with `flatpak run com.obsproject.Studio`. Before first use on a rig, run `bash scripts/deploy-rig.sh` or `bash scripts/deploy-rig.sh --check` for a dry run. Refresh browser-source connectivity with `bash scripts/setup.sh`. Update overlay data with `bash scripts/loading.sh` and `bash scripts/telemetry.sh --collect`. + +For Python smoke checks, use: +```bash +python -m py_compile bridges/mpd-state.py twitch-bot/*.py station/server/status_service.py +``` +## Coding Style & Naming Conventions +Shell scripts should keep `bash` shebangs, `set -euo pipefail`, and concise helper functions. Python follows 4-space indentation, `snake_case`, and small single-purpose modules. Keep overlay pages self-contained and scene-specific; prefer descriptive filenames such as `loading/playlist.json` or `music-box/widget.html`. Preserve existing JSON key names because OBS and browser overlays depend on them exactly. + +## Testing Guidelines +There is no single automated test suite for the repo. Validate Python with `py_compile`, validate generated JSON with the existing scripts, and smoke-test overlay edits in OBS by refreshing the browser source. Close OBS before editing `basic/scenes/*.json`, `user.ini`, or `global.ini`, or your changes may be overwritten on exit. + +## Commit & Pull Request Guidelines +Recent history uses scoped, imperative commit subjects such as `Landing: rig name decode/scramble in top HUD` and `Twitch bot: per-user !skip cooldown + !queue + !info`. Follow that format: `: `. PRs should state which scene, script, or service changed, list manual verification steps, and include screenshots for overlay/UI updates. Call out any secret-bearing or rig-specific files that were intentionally excluded. diff --git a/basic/scenes/Default_Stream_HUD.json b/basic/scenes/Default_Stream_HUD.json index ceeeca6..5d93143 100644 --- a/basic/scenes/Default_Stream_HUD.json +++ b/basic/scenes/Default_Stream_HUD.json @@ -179,7 +179,36 @@ "deinterlace_mode": 0, "deinterlace_field_order": 0, "monitoring_type": 0, - "private_settings": {} + "private_settings": {}, + "filters": [ + { + "prev_ver": 536936450, + "name": "Crop/Pad", + "uuid": "8ea7885e-70c6-4dfd-b5f9-a7581f61b7c2", + "id": "crop_filter", + "versioned_id": "crop_filter", + "settings": { + "top": 360, + "right": 700 + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": {}, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {} + } + ] }, { "prev_ver": 536936450, @@ -507,7 +536,7 @@ "mixers": 255, "sync": 0, "flags": 0, - "volume": 0.0, + "volume": 0.38266569375991821, "balance": 0.5, "enabled": true, "muted": false, @@ -643,6 +672,7 @@ "settings": { "is_local_file": true, "local_file": "/home/jin/.config/obs-studio/music-box/widget.html", + "undo_uuid": "f662d513-6fe6-49fd-99f6-ea7f44e8de18", "width": 549, "height": 880, "fps": 15, @@ -672,6 +702,138 @@ "monitoring_type": 0, "private_settings": {} }, + { + "prev_ver": 536936450, + "name": "Cover", + "uuid": "54d4ac81-e354-431a-a2dc-a00e5555e8d9", + "id": "browser_source", + "versioned_id": "browser_source", + "settings": { + "url": "file:///home/jin/.config/obs-studio/music-box/cover.html" + }, + "mixers": 255, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": { + "libobs.mute": [], + "libobs.unmute": [], + "libobs.push-to-mute": [], + "libobs.push-to-talk": [], + "ObsBrowser.Refresh": [] + }, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {} + }, + { + "prev_ver": 536936450, + "name": "Cover Simple", + "uuid": "7062784f-7b1b-4728-9067-27128c0d5748", + "id": "browser_source", + "versioned_id": "browser_source", + "settings": { + "url": "file:///home/jin/.config/obs-studio/music-box/cover.html?bars=0" + }, + "mixers": 255, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": { + "libobs.mute": [], + "libobs.unmute": [], + "libobs.push-to-mute": [], + "libobs.push-to-talk": [], + "ObsBrowser.Refresh": [] + }, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {} + }, + { + "prev_ver": 536936450, + "name": "NC Music Box HUD", + "uuid": "edbd893d-ce53-4c91-bf6c-4d52fbf394d6", + "id": "browser_source", + "versioned_id": "browser_source", + "settings": { + "url": "file:///home/jin/.config/obs-studio/music-box/nc-music-box.html", + "width": 2560, + "height": 1331, + "fps": 9, + "fps_custom": true + }, + "mixers": 255, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": { + "libobs.mute": [], + "libobs.unmute": [], + "libobs.push-to-mute": [], + "libobs.push-to-talk": [], + "ObsBrowser.Refresh": [] + }, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {} + }, + { + "prev_ver": 536936450, + "name": "Waveform Visualizer", + "uuid": "f28cc995-6966-4afa-b831-ebb50d89ebe8", + "id": "phandasm_waveform_source", + "versioned_id": "phandasm_waveform_source", + "settings": { + "audio_source": "MPD", + "display_mode": "stepped_bars", + "color_base": 4278216704, + "render_mode": "solid", + "pulse_mode": "peak_magnitude", + "channel_mode": "mono", + "step_gap": 3 + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": {}, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {} + }, { "prev_ver": 536936450, "name": "Desktop", @@ -703,12 +865,12 @@ "id": 13, "group_item_backup": false, "pos": { - "x": 10.0, - "y": 10.0 + "x": 566.0, + "y": 7.0 }, "pos_rel": { - "x": -1.9011975526809692, - "y": -0.98502993583679199 + "x": -1.0688623189926147, + "y": -0.98952096700668335 }, "scale": { "x": 1.0, @@ -868,20 +1030,20 @@ "id": 16, "group_item_backup": false, "pos": { - "x": 2019.0, - "y": 20.0 + "x": 22.0, + "y": 902.0 }, "pos_rel": { - "x": 1.1062874794006348, - "y": -0.97005987167358398 + "x": -1.8832335472106934, + "y": 0.35029935836791992 }, "scale": { - "x": 0.27239581942558289, - "y": 0.27222222089767456 + "x": 0.4270491898059845, + "y": 0.42727276682853699 }, "scale_rel": { - "x": 0.22020021080970764, - "y": 0.22005987167358398 + "x": 0.34521940350532532, + "y": 0.3454001247882843 }, "bounds": { "x": 0.0, @@ -923,12 +1085,12 @@ "id": 17, "group_item_backup": false, "pos": { - "x": 2007.0, - "y": 353.0 + "x": 12.0, + "y": 6.0 }, "pos_rel": { - "x": 1.0883233547210693, - "y": -0.4715569019317627 + "x": -1.8982036113739014, + "y": -0.99101793766021729 }, "scale": { "x": 0.99089258909225464, @@ -1416,20 +1578,20 @@ "id": 16, "group_item_backup": false, "pos": { - "x": 1937.0, - "y": 98.0 + "x": 83.0, + "y": 88.0 }, "pos_rel": { - "x": 0.98353290557861328, - "y": -0.85329341888427734 + "x": -1.7919161319732666, + "y": -0.86826348304748535 }, "scale": { - "x": 0.28593748807907104, - "y": 0.28611111640930176 + "x": 0.24262295663356781, + "y": 0.24318182468414307 }, "scale_rel": { - "x": 0.23114706575870514, - "y": 0.23128743469715118 + "x": 0.19613233208656311, + "y": 0.19658410549163818 }, "bounds": { "x": 0.0, @@ -1443,10 +1605,22 @@ "blend_method": "default", "blend_type": "normal", "show_transition": { - "duration": 0 + "id": "wipe_transition", + "versioned_id": "wipe_transition", + "name": "Camera Show Transition", + "transition": { + "luma_image": "checkerboard-small.png" + }, + "duration": 1000 }, "hide_transition": { - "duration": 0 + "id": "wipe_transition", + "versioned_id": "wipe_transition", + "name": "Camera Hide Transition", + "transition": { + "luma_image": "checkerboard-small.png" + }, + "duration": 1000 }, "private_settings": {} }, @@ -2227,7 +2401,7 @@ "id": "scene", "versioned_id": "scene", "settings": { - "id_counter": 2, + "id_counter": 6, "custom_size": false, "items": [ { @@ -2288,7 +2462,7 @@ { "name": "Music Box HUD", "source_uuid": "1b9f5436-864d-4f24-9f92-f45702a23953", - "visible": true, + "visible": false, "locked": false, "rot": 0.0, "scale_ref": { @@ -2339,6 +2513,183 @@ "duration": 300 }, "private_settings": {} + }, + { + "name": "Cover Simple", + "source_uuid": "7062784f-7b1b-4728-9067-27128c0d5748", + "visible": false, + "locked": false, + "rot": 0.0, + "scale_ref": { + "x": 2560.0, + "y": 1336.0 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds_crop": false, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 4, + "group_item_backup": false, + "pos": { + "x": 1711.0, + "y": 176.0 + }, + "pos_rel": { + "x": 0.64520961046218872, + "y": -0.7365269660949707 + }, + "scale": { + "x": 0.36875000596046448, + "y": 0.36833333969116211 + }, + "scale_rel": { + "x": 0.36875000596046448, + "y": 0.36833333969116211 + }, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "bounds_rel": { + "x": 0.0, + "y": 0.0 + }, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 300 + }, + "hide_transition": { + "duration": 300 + }, + "private_settings": {} + }, + { + "name": "NC Music Box HUD", + "source_uuid": "edbd893d-ce53-4c91-bf6c-4d52fbf394d6", + "visible": true, + "locked": false, + "rot": 0.0, + "scale_ref": { + "x": 2560.0, + "y": 1336.0 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds_crop": false, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 5, + "group_item_backup": false, + "pos": { + "x": 0.0, + "y": 0.0 + }, + "pos_rel": { + "x": -1.9161676168441772, + "y": -1.0 + }, + "scale": { + "x": 1.0, + "y": 1.0 + }, + "scale_rel": { + "x": 1.0, + "y": 1.0 + }, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "bounds_rel": { + "x": 0.0, + "y": 0.0 + }, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "id": "wipe_transition", + "versioned_id": "wipe_transition", + "name": "NC Music Box HUD Show Transition", + "transition": { + "luma_image": "checkerboard-small.png" + }, + "duration": 300 + }, + "hide_transition": { + "id": "wipe_transition", + "versioned_id": "wipe_transition", + "name": "NC Music Box HUD Hide Transition", + "transition": { + "luma_image": "checkerboard-small.png" + }, + "duration": 300 + }, + "private_settings": {} + }, + { + "name": "Waveform Visualizer", + "source_uuid": "f28cc995-6966-4afa-b831-ebb50d89ebe8", + "visible": true, + "locked": false, + "rot": 0.0, + "scale_ref": { + "x": 2560.0, + "y": 1336.0 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds_crop": false, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 6, + "group_item_backup": false, + "pos": { + "x": 2166.0, + "y": 1078.0 + }, + "pos_rel": { + "x": 1.3263473510742188, + "y": 0.61377251148223877 + }, + "scale": { + "x": 0.38249999284744263, + "y": 0.38222223520278931 + }, + "scale_rel": { + "x": 0.38249999284744263, + "y": 0.38222223520278931 + }, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "bounds_rel": { + "x": 0.0, + "y": 0.0 + }, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 300 + }, + "hide_transition": { + "duration": 300 + }, + "private_settings": {} } ] }, @@ -2363,7 +2714,387 @@ "libobs.show_scene_item.1": [], "libobs.hide_scene_item.1": [], "libobs.show_scene_item.2": [], - "libobs.hide_scene_item.2": [] + "libobs.hide_scene_item.2": [], + "libobs.show_scene_item.4": [], + "libobs.hide_scene_item.4": [], + "libobs.show_scene_item.5": [], + "libobs.hide_scene_item.5": [], + "libobs.show_scene_item.6": [], + "libobs.hide_scene_item.6": [] + }, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "canvas_uuid": "6c69626f-6273-4c00-9d88-c5136d61696e", + "private_settings": {} + }, + { + "prev_ver": 536936450, + "name": "Desktop (No Cam)", + "uuid": "e68f67a3-f90b-4e81-a89b-308a0ea83e57", + "id": "scene", + "versioned_id": "scene", + "settings": { + "id_counter": 6, + "custom_size": false, + "items": [ + { + "name": "Screen Capture (PipeWire)", + "source_uuid": "8f6ef725-1eec-4fdf-930a-2aab93d84c06", + "visible": true, + "locked": false, + "rot": 0.0, + "scale_ref": { + "x": 2560.0, + "y": 1336.0 + }, + "align": 5, + "bounds_type": 1, + "bounds_align": 0, + "bounds_crop": false, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 1, + "group_item_backup": false, + "pos": { + "x": 10.0, + "y": 10.0 + }, + "pos_rel": { + "x": -1.9011975526809692, + "y": -0.98502993583679199 + }, + "scale": { + "x": 1.0, + "y": 1.0 + }, + "scale_rel": { + "x": 1.0, + "y": 1.0 + }, + "bounds": { + "x": 2010.0, + "y": 1213.0 + }, + "bounds_rel": { + "x": 3.0089819431304932, + "y": 1.8158682584762573 + }, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "MPD", + "source_uuid": "25440930-0606-46d9-ba2c-be786ff4a3ae", + "visible": true, + "locked": false, + "rot": 0.0, + "scale_ref": { + "x": 2560.0, + "y": 1336.0 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds_crop": false, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 2, + "group_item_backup": false, + "pos": { + "x": 0.0, + "y": 0.0 + }, + "pos_rel": { + "x": -1.9161676168441772, + "y": -1.0 + }, + "scale": { + "x": 1.0, + "y": 1.0 + }, + "scale_rel": { + "x": 1.0, + "y": 1.0 + }, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "bounds_rel": { + "x": 0.0, + "y": 0.0 + }, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 300 + }, + "hide_transition": { + "duration": 300 + }, + "private_settings": {} + }, + { + "name": "Desktop HUD", + "source_uuid": "8acfb832-cfb4-4310-8ebb-b58fad2b3509", + "visible": true, + "locked": false, + "rot": 0.0, + "scale_ref": { + "x": 2560.0, + "y": 1336.0 + }, + "align": 5, + "bounds_type": 1, + "bounds_align": 0, + "bounds_crop": false, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 3, + "group_item_backup": false, + "pos": { + "x": -1.0, + "y": 0.0 + }, + "pos_rel": { + "x": -1.917664647102356, + "y": -1.0 + }, + "scale": { + "x": 1.0, + "y": 1.0 + }, + "scale_rel": { + "x": 1.0, + "y": 1.0 + }, + "bounds": { + "x": 2560.0, + "y": 1336.0 + }, + "bounds_rel": { + "x": 3.8323352336883545, + "y": 2.0 + }, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 300 + }, + "hide_transition": { + "duration": 300 + }, + "private_settings": {} + }, + { + "name": "Camera", + "source_uuid": "a8b362a1-91a6-491b-8751-26e2dad55ccd", + "visible": false, + "locked": false, + "rot": 0.0, + "scale_ref": { + "x": 2560.0, + "y": 1336.0 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds_crop": false, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 4, + "group_item_backup": false, + "pos": { + "x": 2019.0, + "y": 22.0 + }, + "pos_rel": { + "x": 1.1062874794006348, + "y": -0.96706587076187134 + }, + "scale": { + "x": 0.34521940350532532, + "y": 0.3454001247882843 + }, + "scale_rel": { + "x": 0.34521940350532532, + "y": 0.3454001247882843 + }, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "bounds_rel": { + "x": 0.0, + "y": 0.0 + }, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "Music Box Widget", + "source_uuid": "f662d513-6fe6-49fd-99f6-ea7f44e8de18", + "visible": true, + "locked": false, + "rot": 0.0, + "scale_ref": { + "x": 2560.0, + "y": 1336.0 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds_crop": false, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 5, + "group_item_backup": false, + "pos": { + "x": 2043.0, + "y": 402.0 + }, + "pos_rel": { + "x": 1.1422156095504761, + "y": -0.39820361137390137 + }, + "scale": { + "x": 0.90892529487609863, + "y": 0.90909093618392944 + }, + "scale_rel": { + "x": 0.90892529487609863, + "y": 0.90909093618392944 + }, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "bounds_rel": { + "x": 0.0, + "y": 0.0 + }, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 300 + }, + "hide_transition": { + "duration": 300 + }, + "private_settings": {} + }, + { + "name": "Cover", + "source_uuid": "54d4ac81-e354-431a-a2dc-a00e5555e8d9", + "visible": true, + "locked": false, + "rot": 0.0, + "scale_ref": { + "x": 2560.0, + "y": 1336.0 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds_crop": false, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 6, + "group_item_backup": false, + "pos": { + "x": 2044.0, + "y": 13.0 + }, + "pos_rel": { + "x": 1.1437125205993652, + "y": -0.98053890466690063 + }, + "scale": { + "x": 0.62374997138977051, + "y": 0.62333333492279053 + }, + "scale_rel": { + "x": 0.62374997138977051, + "y": 0.62333333492279053 + }, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "bounds_rel": { + "x": 0.0, + "y": 0.0 + }, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 300 + }, + "hide_transition": { + "duration": 300 + }, + "private_settings": {} + } + ] + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": { + "OBSBasic.SelectScene": [], + "libobs.show_scene_item.1": [], + "libobs.hide_scene_item.1": [], + "libobs.show_scene_item.2": [], + "libobs.hide_scene_item.2": [], + "libobs.show_scene_item.3": [], + "libobs.hide_scene_item.3": [], + "libobs.show_scene_item.4": [], + "libobs.hide_scene_item.4": [], + "libobs.show_scene_item.5": [], + "libobs.hide_scene_item.5": [], + "libobs.show_scene_item.6": [], + "libobs.hide_scene_item.6": [] }, "deinterlace_mode": 0, "deinterlace_field_order": 0, @@ -2595,6 +3326,9 @@ { "name": "Desktop" }, + { + "name": "Desktop (No Cam)" + }, { "name": "Music Box" }, @@ -2602,8 +3336,8 @@ "name": "Good Bye" } ], - "current_scene": "Landing", - "current_program_scene": "Landing", + "current_scene": "Music Box", + "current_program_scene": "Music Box", "canvases": [], "current_transition": "Luma Wipe", "transition_duration": 1000, diff --git a/bridges/mpd-state.py b/bridges/mpd-state.py index b1f87db..d470e63 100755 --- a/bridges/mpd-state.py +++ b/bridges/mpd-state.py @@ -9,14 +9,35 @@ the custom music/index.html browser daemon as the source of truth. Payload shape matches the original daemon's broadcastState() so the overlay needs zero changes: - { _type, index, total, title, file, currentTime, duration, paused, - nextTitle, nextArtist, nextTrackTitle, nextFile, nextTitles } + { _type, index, total, title, artist, trackTitle, album, year, genre, + file, currentTime, duration, paused, + nextTitle, nextArtist, nextTrackTitle, nextFile, nextTitles, + coverPath, coverHash } Next-track fields are populated when MPD reports `nextsong` in its status. `nextTitles` is up to 3 display strings peeked from the queue starting at the current song's successor — used by the Music Box overlay to show "coming up" lines. The single `nextTitle`/`nextArtist`/etc. fields mirror nextTitles[0] for overlays that only need one. +Library stats (artists/albums/songs/db_playtime) come from MPD `stats`. +That command is cheap but not free, and the results barely change between +ticks (only when files are added/removed) — so we cache and refresh every +STATS_REFRESH_SEC. Exposed under the `stats` key: + stats: { artists, albums, songs, dbPlaytime } # dbPlaytime is human-formatted + +Cover art resolution (run once per track change, cached): + 1. Sibling file in song's directory matching folder/cover/front × + jpg/jpeg/png/webp (case-insensitive). Covers the Kolekcja layout + where ~/HDD/Music/Kolekcja symlinks to /media/nvme/Music/Kolekcja + and each album dir ships a folder.jpg. + 2. MPD `readpicture` then `albumart` commands for embedded art (yt-dlp + --embed-thumbnail tracks under the rest of ~/HDD/Music). +Bytes are written to ./cover.jpg (next to this script). Overlays load +`` so a hash bump cache-busts +cleanly. coverHash null = no art available; overlays should show a +placeholder. Reads MPD library root from MPD_MUSIC_DIR env var, default +~/HDD/Music (matches mpd.conf). + Reads OBS WS credentials from ../vendor/obs-config.js (auto-generated by scripts/setup.sh from plugin_config/obs-websocket/config.json — single source of truth). @@ -41,7 +62,12 @@ HERE = Path(__file__).resolve().parent OBS_CONFIG_JS = HERE.parent / "vendor" / "obs-config.js" MPD_HOST = os.environ.get("MPD_HOST", "localhost") MPD_PORT = int(os.environ.get("MPD_PORT", "6600")) +MPD_MUSIC_DIR = Path(os.environ.get("MPD_MUSIC_DIR", "~/HDD/Music")).expanduser() TICK_SECONDS = 1.0 # broadcast cadence; matches the old daemon's setInterval(broadcastState, 1000) +STATS_REFRESH_SEC = 30.0 # how often to re-poll MPD `stats` for library counts +COVER_OUT = HERE / "cover.jpg" # canonical cover bytes; overlays cache-bust via ?v= +COVER_STEMS = ("folder", "cover", "front") +COVER_EXTS = (".jpg", ".jpeg", ".png", ".webp") # ─── config + auth ────────────────────────────────────────────────────────── @@ -101,7 +127,66 @@ def _song_display(song: dict) -> tuple: return display, artist, track_name -def mpd_to_event(status: dict, song: dict, next_songs: list | None = None) -> dict: +def _format_dbplaytime(seconds: float) -> str: + """MPD's db_playtime is the total runtime of every file in the library + in seconds. Render the most-significant two units (47d 12h, 5h 22m, …) + so the stats line stays compact even at multi-day libraries.""" + s = int(seconds or 0) + days, s = divmod(s, 86400) + hours, s = divmod(s, 3600) + mins, _ = divmod(s, 60) + if days: return f"{days}d {hours}h" + if hours: return f"{hours}h {mins}m" + if mins: return f"{mins}m" + return "0m" + + +def _status_bool(status: dict, key: str) -> bool: + """MPD status flags arrive as "0"/"1" strings; normalize to bool.""" + return str(status.get(key, "0")).strip() == "1" + + +def _status_volume(status: dict) -> int | None: + """Volume is usually 0..100, but MPD can report -1 when unavailable.""" + raw = status.get("volume") + if raw is None: + return None + try: + volume = int(raw) + except (TypeError, ValueError): + return None + return volume if volume >= 0 else None + + +# Module-level cache: MPD stats barely move between ticks (only on file +# add/remove), so we refresh every STATS_REFRESH_SEC and reuse otherwise. +_stats_cache = {"data": None, "next_refresh": 0.0} + + +async def refresh_stats(client) -> dict | None: + now = time.monotonic() + if _stats_cache["data"] and now < _stats_cache["next_refresh"]: + return _stats_cache["data"] + try: + s = await client.stats() + _stats_cache["data"] = { + "artists": int(s.get("artists") or 0), + "albums": int(s.get("albums") or 0), + "songs": int(s.get("songs") or 0), + "dbPlaytime": _format_dbplaytime(float(s.get("db_playtime") or 0)), + } + _stats_cache["next_refresh"] = now + STATS_REFRESH_SEC + except Exception as e: + # Keep last-known on transient failure — overlays prefer stale to + # blank for slow-moving counters like this. + print(f"[stats] refresh failed: {e}", flush=True) + return _stats_cache["data"] + + +def mpd_to_event(status: dict, song: dict, next_songs: list | None = None, + cover_path: str | None = None, + cover_hash: str | None = None, + stats: dict | None = None) -> dict: """Translate `status` + `currentsong` (+ optional `next_songs`, the next up to 3 queued tracks) into the overlay payload. The single `nextTitle` family mirrors `nextTitles[0]` for overlays that only need one.""" @@ -110,6 +195,23 @@ def mpd_to_event(status: dict, song: dict, next_songs: list | None = None) -> di duration = float(status.get("duration") or song.get("duration") or 0) display, artist, track_name = _song_display(song) + album = _flatten_tag(song.get("album")) + # MPD's date tag is whatever the file carries — ID3v2 TYER/TDRC, Vorbis + # DATE, etc. Often a 4-digit year, sometimes a full ISO date. Take the + # leading 4 digits when present so consumers always get a clean year. + raw_date = _flatten_tag(song.get("date") or song.get("originaldate")) + year = None + if raw_date: + m = re.match(r"\s*(\d{4})", raw_date) + year = m.group(1) if m else raw_date + genre = _flatten_tag(song.get("genre")) + playback = { + "volume": _status_volume(status), + "repeat": _status_bool(status, "repeat"), + "random": _status_bool(status, "random"), + "single": _status_bool(status, "single"), + "consume": _status_bool(status, "consume"), + } next_titles = [] next_display, next_artist, next_track, next_file = None, None, None, None @@ -128,6 +230,9 @@ def mpd_to_event(status: dict, song: dict, next_songs: list | None = None) -> di "title": display, "artist": artist, "trackTitle": track_name, + "album": album, + "year": year, + "genre": genre, "file": song.get("file"), "currentTime": elapsed, "duration": duration, @@ -137,6 +242,10 @@ def mpd_to_event(status: dict, song: dict, next_songs: list | None = None) -> di "nextTrackTitle": next_track, "nextFile": next_file, "nextTitles": next_titles, + "coverPath": cover_path, + "coverHash": cover_hash, + "playback": playback, + "stats": stats, } @@ -144,14 +253,145 @@ class SharedState: def __init__(self): self.data = { "index": -1, "total": 0, - "title": None, "artist": None, "trackTitle": None, "file": None, + "title": None, "artist": None, "trackTitle": None, + "album": None, "year": None, "genre": None, "file": None, "currentTime": 0, "duration": 0, "paused": True, "nextTitle": None, "nextArtist": None, "nextTrackTitle": None, "nextFile": None, "nextTitles": [], + "coverPath": None, "coverHash": None, + "playback": {"volume": None, "repeat": False, "random": False, "single": False, "consume": False}, + "stats": None, } self.changed = asyncio.Event() +# ─── cover art resolution ─────────────────────────────────────────────────── + +class CoverState: + """Caches the last resolved (uri → hash) so we only re-run cover lookup + on actual track change, not every status tick. The hash also lets the + overlay cache-bust without us re-reading the file each second.""" + def __init__(self): + self.last_uri: str | None = None + self.last_hash: str | None = None + + +_cover_state = CoverState() + + +def _find_folder_cover(song_uri: str) -> Path | None: + """Look in the song's own directory for folder/cover/front.{jpg,jpeg, + png,webp}. Case-insensitive. song_uri is MPD-relative (e.g. + "Kolekcja/VBS - Nikoś/01 - Track.mp3"); resolve() follows the symlink + so /media/nvme/Music/Kolekcja files are found. + + Lookup is prioritized: stems in COVER_STEMS order × exts in COVER_EXTS + order. So when an album ships both `folder.jpg` and `folder.webp` (the + Kolekcja convention), the jpg wins — keeps the on-disk cache file's + extension aligned with its bytes most of the time.""" + if not song_uri: + return None + try: + full = (MPD_MUSIC_DIR / song_uri).resolve() + except (OSError, RuntimeError): + return None + parent = full.parent + if not parent.is_dir(): + return None + # Snapshot dir as lower-name → Path so prioritized lookup is O(1) and + # case-insensitive without re-scanning per candidate. + try: + by_lower = {p.name.lower(): p for p in parent.iterdir() if p.is_file()} + except OSError: + return None + for stem in COVER_STEMS: + for ext in COVER_EXTS: + hit = by_lower.get(f"{stem}{ext}") + if hit: + return hit + return None + + +async def _fetch_embedded_art(client, uri: str) -> bytes | None: + """Try MPD `readpicture` first (preferred — works for any tagged file + with embedded art) then fall back to `albumart` (older, MPD-specific + sidecar lookup). Both stream chunks; we loop with offset until size + is reached.""" + for cmd_name in ("readpicture", "albumart"): + cmd = getattr(client, cmd_name, None) + if cmd is None: + continue + try: + buf = bytearray() + offset = 0 + while True: + resp = await cmd(uri, offset) + if not isinstance(resp, dict): + break + chunk = resp.get("binary") + if not chunk: + break + buf.extend(chunk) + size = int(resp.get("size") or 0) + offset = len(buf) + if size and offset >= size: + break + if buf: + return bytes(buf) + except Exception: + # MPD raises when the file lacks embedded art for the given + # command — that's the signal to try the next one. + continue + return None + + +async def resolve_cover(client, song_uri: str | None) -> tuple[str | None, str | None]: + """Resolve cover art for the current song; return (coverPath, coverHash). + Writes bytes to COVER_OUT only when the hash changes. Returns (None, None) + when no art is available — overlays should render a placeholder. + + Caches by `song_uri`: re-running disk reads + MPD readpicture each tick + would be wasteful. State is reset on track change (or song_uri=None). + """ + if not song_uri: + _cover_state.last_uri = None + _cover_state.last_hash = None + return None, None + if song_uri == _cover_state.last_uri: + if _cover_state.last_hash: + return f"../bridges/{COVER_OUT.name}", _cover_state.last_hash + return None, None + _cover_state.last_uri = song_uri + + data: bytes | None = None + folder = _find_folder_cover(song_uri) + if folder: + try: + data = folder.read_bytes() + except OSError: + data = None + if not data: + try: + data = await _fetch_embedded_art(client, song_uri) + except Exception: + data = None + + if not data: + _cover_state.last_hash = None + return None, None + + digest = hashlib.sha1(data).hexdigest()[:12] + if digest != _cover_state.last_hash: + try: + COVER_OUT.write_bytes(data) + except OSError as e: + print(f"[cover] write failed: {e}", flush=True) + _cover_state.last_hash = None + return None, None + _cover_state.last_hash = digest + return f"../bridges/{COVER_OUT.name}", digest + + # ─── MPD side ─────────────────────────────────────────────────────────────── async def mpd_loop(state: SharedState): @@ -179,7 +419,10 @@ async def mpd_loop(state: SharedState): next_songs.append(rows[0]) except Exception: break # transient — let it retry next tick - state.data = mpd_to_event(status, song, next_songs) + cover_path, cover_hash = await resolve_cover(client, song.get("file")) + stats = await refresh_stats(client) + state.data = mpd_to_event(status, song, next_songs, + cover_path, cover_hash, stats) state.changed.set() await asyncio.sleep(TICK_SECONDS) except Exception as e: diff --git a/desktop/index.html b/desktop/index.html index 9446689..174ce16 100644 --- a/desktop/index.html +++ b/desktop/index.html @@ -45,41 +45,6 @@ user-select: none; } - /* ───────── Top HUD ───────── */ - .hud { - position: absolute; - top: var(--pad-y); - left: var(--pad-x); - right: var(--pad-x); - display: grid; - grid-template-columns: 1fr 1fr 1fr; - align-items: center; - font-family: var(--mono); - font-size: 20px; - font-weight: 700; - color: var(--hud); - letter-spacing: 0.08em; - text-shadow: 0 0 8px rgba(7, 8, 13, 0.9), 0 0 4px rgba(7, 8, 13, 0.9); - } - .hud .group { display: flex; gap: 24px; align-items: center; } - .hud > .group:nth-child(1) { justify-self: start; } - .hud > .group:nth-child(2) { justify-self: center; } - .hud > .group:nth-child(3) { justify-self: end; } - .hud .dim { color: var(--hud-dim); } - .hud .group > span { display: inline-flex; align-items: center; gap: 10px; } - .hud .led { - display: inline-block; - width: 10px; height: 10px; - border-radius: 50%; - background: var(--accent); - box-shadow: 0 0 10px var(--accent); - margin-top: -0.08em; - animation: rec-blink 1.6s ease-in-out infinite; - } - #signal { - display: inline-block; - transform: translateY(-0.10em); - } @keyframes rec-blink { 0%, 55% { opacity: 1; } 65%, 100% { opacity: 0.2; } @@ -186,6 +151,7 @@ .status-line { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; } .status-line + .status-line { margin-top: 8px; } .status-line .sep { color: var(--hud-dim); } + .status-line .spacer { flex: 1 1 auto; } .live-block { display: flex; align-items: center; gap: 10px; @@ -201,13 +167,27 @@ box-shadow: 0 0 10px var(--accent); animation: rec-blink 1.6s ease-in-out infinite; } - /* The "DESKTOP" label fills the slot the Game HUD uses for game name. */ + /* OPHI-118 // DESKTOP — the rig identifier moved here from the old top HUD. */ .scene-name { color: var(--ink); font-weight: 700; font-size: 22px; letter-spacing: 0.10em; } + /* HUD tag — TRANSMISSION/SIGNAL/UTC labels formerly in the top HUD, + restyled to sit cleanly in the bottom bar (cyan, mono, smaller). */ + .hud-tag { + display: inline-flex; + align-items: center; + gap: 10px; + color: var(--hud); + font-weight: 700; + font-size: 16px; + letter-spacing: 0.08em; + text-shadow: 0 0 6px rgba(7, 8, 13, 0.9); + } + .hud-tag .dim { color: var(--hud-dim); } + #signal { display: inline-block; transform: translateY(-0.10em); } .meta-line { color: var(--ink-dim); font-size: 16px; @@ -217,21 +197,6 @@ -
-
- TRANSMISSION - OPHI-118 // DESKTOP -
-
- SIGNAL - ▮▮▮▯▯ -
-
- UTC - --:--:-- -
-
-
@@ -264,7 +229,11 @@ LIVE
· - DESKTOP + OPHI-118 // DESKTOP + + SIGNAL▮▮▮▯▯ + · + UTC--:--:--
@@ -311,12 +280,18 @@ tickElapsed(); setInterval(tickElapsed, 1000); // ── OBS WS: live frame sync for camera + screen capture ── - // URL params let you rename the sources / scene without editing JS: - // ?camera=Webcam&screen=Display&scene=Desktop + // URL params: + // ?camera=Webcam override the source name to track for camera + // ?screen=Display override the source name to track for screen + // ?scene=Desktop LOCK the HUD to a specific scene (optional) + // When ?scene= is omitted (the default) the HUD follows whichever scene is + // currently program — so the same browser source can be referenced in both + // Desktop and Desktop (No Cam): switching scenes rebinds to the items in + // the now-active scene, and the camera/screen frames toggle/move with it. const params = new URLSearchParams(location.search); const CAM_SOURCE = params.get('camera') || 'Camera'; const SCR_SOURCE = params.get('screen') || 'Screen Capture (PipeWire)'; - const SCENE = params.get('scene') || 'Desktop'; + const SCENE_LOCK = params.get('scene') || null; const camEl = document.getElementById('cam'); const scrEl = document.getElementById('scr'); @@ -336,10 +311,12 @@ }; } - // Bind a frame element to a scene item: live-sync size/position into the - // --{prefix}-x/y/w/h CSS vars and toggle .off on enable changes. + // Frame binding for one tracked source. Holds mutable {sceneName, itemId} + // so the event filter reads through to the *current* binding — that lets + // rebind() swap scenes without leaking listeners or stale itemIds. // Assumes alignment 5 (top-left) — OBS's default for newly added items. - async function syncSourceFrame(obs, { sceneName, sourceName, frameEl, varPrefix }) { + function makeFrame(obs, sourceName, frameEl, varPrefix) { + const state = { sceneName: null, itemId: null }; const setVisible = (v) => frameEl.classList.toggle('off', !v); const setTransform = (t) => { if (!t) return; @@ -350,30 +327,39 @@ rootStyle.setProperty(`--${varPrefix}-w`, `${w}px`); rootStyle.setProperty(`--${varPrefix}-h`, `${h}px`); }; - let itemId = null; - try { - const r = await obs.call('GetSceneItemId', { sceneName, sourceName }); - itemId = r.sceneItemId; - const e = await obs.call('GetSceneItemEnabled', - { sceneName, sceneItemId: itemId }); - setVisible(e.sceneItemEnabled); - const t = await obs.call('GetSceneItemTransform', - { sceneName, sceneItemId: itemId }); - setTransform(t.sceneItemTransform); - } catch (err) { - console.warn(`[desktop] frame sync failed (${sceneName}/${sourceName}):`, err.message); - return; - } - obs.addEventListener('event', (ev) => { - const d = ev.detail || {}; - if (d.eventData?.sceneName !== sceneName - || d.eventData?.sceneItemId !== itemId) return; - if (d.eventType === 'SceneItemEnableStateChanged') { - setVisible(d.eventData.sceneItemEnabled); - } else if (d.eventType === 'SceneItemTransformChanged') { - setTransform(d.eventData.sceneItemTransform); - } - }); + return { + async rebind(sceneName) { + state.sceneName = sceneName; + state.itemId = null; + if (!sceneName) { setVisible(false); return; } + try { + const r = await obs.call('GetSceneItemId', + { sceneName, sourceName }); + state.itemId = r.sceneItemId; + const e = await obs.call('GetSceneItemEnabled', + { sceneName, sceneItemId: state.itemId }); + setVisible(e.sceneItemEnabled); + const t = await obs.call('GetSceneItemTransform', + { sceneName, sceneItemId: state.itemId }); + setTransform(t.sceneItemTransform); + } catch { + // Source isn't in this scene (or scene doesn't exist) — hide + // the frame outright. This is what makes "Desktop (No Cam)" + // work cleanly even if Camera were fully removed (rather than + // just disabled). + setVisible(false); + } + }, + handleEvent(d) { + if (d.eventData?.sceneName !== state.sceneName + || d.eventData?.sceneItemId !== state.itemId) return; + if (d.eventType === 'SceneItemEnableStateChanged') { + setVisible(d.eventData.sceneItemEnabled); + } else if (d.eventType === 'SceneItemTransformChanged') { + setTransform(d.eventData.sceneItemTransform); + } + }, + }; } async function connectOBS() { @@ -381,14 +367,34 @@ try { const obs = new OBSWSMini(window.__OBSWS.url, window.__OBSWS.password); await obs.connect(); - obs.addEventListener('close', () => { - setTimeout(connectOBS, 2500); + obs.addEventListener('close', () => setTimeout(connectOBS, 2500)); + + const camFrame = makeFrame(obs, CAM_SOURCE, camEl, 'cam'); + const scrFrame = makeFrame(obs, SCR_SOURCE, scrEl, 'scr'); + + // Resolve the scene to track. With ?scene= explicit, lock to it; + // otherwise follow whatever's currently program. Modern obs-websocket + // returns currentProgramSceneName; older builds returned sceneName — + // accept both. + let scene = SCENE_LOCK; + if (!scene) { + try { + const r = await obs.call('GetCurrentProgramScene'); + scene = r.currentProgramSceneName || r.sceneName || null; + } catch { /* leave null; rebind on first scene change */ } + } + await Promise.all([camFrame.rebind(scene), scrFrame.rebind(scene)]); + + obs.addEventListener('event', async (ev) => { + const d = ev.detail || {}; + if (!SCENE_LOCK && d.eventType === 'CurrentProgramSceneChanged') { + const next = d.eventData?.sceneName; + await Promise.all([camFrame.rebind(next), scrFrame.rebind(next)]); + return; + } + camFrame.handleEvent(d); + scrFrame.handleEvent(d); }); - // Run both syncs in parallel — independent items, independent failures. - await Promise.all([ - syncSourceFrame(obs, { sceneName: SCENE, sourceName: CAM_SOURCE, frameEl: camEl, varPrefix: 'cam' }), - syncSourceFrame(obs, { sceneName: SCENE, sourceName: SCR_SOURCE, frameEl: scrEl, varPrefix: 'scr' }), - ]); } catch (e) { // OBS WS optional — silently retry; defaults stand meanwhile. setTimeout(connectOBS, 2500); diff --git a/music-box/cover.html b/music-box/cover.html new file mode 100644 index 0000000..621f6c2 --- /dev/null +++ b/music-box/cover.html @@ -0,0 +1,295 @@ + + + + +Music Box cover + + + + + + + + + +
+ ALBUM ART + CH 118.0 // MUSIC +
+ +
+ +
+ + + + + + — NO ART — + awaiting cover… +
+
+
+ +
+ connecting… +
+ + + + + + diff --git a/music-box/nc-music-box.html b/music-box/nc-music-box.html new file mode 100644 index 0000000..8b2b2ae --- /dev/null +++ b/music-box/nc-music-box.html @@ -0,0 +1,804 @@ + + + + +OPHI-118 / NC-MUSIC-BOX + + + + + +
+
+ ON AIR + OPHI-118 // MUSIC +
+
+ SIGNAL + ▮▮▮▮▯ +
+
+ UTC + --:--:-- +
+
+ +
+ +
+ ┤ TERMINAL ├ +
+
+
+
+ next:— end of queue — +
+
+ then: +
+
+ then: +
+
+
+ connecting to daemon…[--:-- / --:--] +
+
+
+
+ + + +
+ +
+ CHAT + + !skipnext track + !queuepeek next 3 + !infofull metadata + +
+ + +
+
+ + + + + + + + diff --git a/plugin_manager/modules.json b/plugin_manager/modules.json index 00d1058..c7999f0 100644 --- a/plugin_manager/modules.json +++ b/plugin_manager/modules.json @@ -9,5 +9,18 @@ "services": [], "sources": [], "version": "" + }, + { + "display_name": "phandasm_waveform", + "enabled": true, + "encoders": [], + "id": "", + "module_name": "waveform", + "outputs": [], + "services": [], + "sources": [ + "phandasm_waveform_source" + ], + "version": "" } ] diff --git a/user.ini b/user.ini index 094beea..8ae5861 100644 --- a/user.ini +++ b/user.ini @@ -25,8 +25,8 @@ cx=720 cy=580 [BasicWindow] -geometry=AdnQywADAAAAAAAAAAAAAAAACf8AAAVDAAAKAQAAABkAAA3AAAAEHAAAAAECAAAACgAAAAAAAAAAAAAACf8AAAVD -DockState=AAAA/wAAAAD9AAAAAQAAAAMAAAoAAAABKPwBAAAABvsAAAAUAHMAYwBlAG4AZQBzAEQAbwBjAGsBAAAAAAAAAdgAAACYAP////sAAAAWAHMAbwB1AHIAYwBlAHMARABvAGMAawEAAAHcAAAB1QAAAJgA////+wAAABIAbQBpAHgAZQByAEQAbwBjAGsBAAADtQAAAroAAAECAP////sAAAAeAHQAcgBhAG4AcwBpAHQAaQBvAG4AcwBEAG8AYwBrAQAABnMAAAHSAAAArQD////7AAAAGABjAG8AbgB0AHIAbwBsAHMARABvAGMAawEAAAhJAAABtwAAAKIA////+wAAABIAcwB0AGEAdABzAEQAbwBjAGsCAAAExAAAAXAAAAK8AAAAyAAACgAAAAPcAAAABAAAAAQAAAAIAAAACPwAAAAA +geometry=AdnQywADAAAAAAAAAAAAAAAACf8AAAVDAAAGPwAAABkAAAn+AAAEFgAAAAECAAAACgAAAAAAAAAAAAAACf8AAAVD +DockState=AAAA/wAAAAD9AAAAAQAAAAMAAAoAAAABA/wBAAAABvsAAAAUAHMAYwBlAG4AZQBzAEQAbwBjAGsBAAAAAAAAAdgAAACYAP////sAAAAWAHMAbwB1AHIAYwBlAHMARABvAGMAawEAAAHcAAAB1QAAAJgA////+wAAABIAbQBpAHgAZQByAEQAbwBjAGsBAAADtQAAAroAAAECAP////sAAAAeAHQAcgBhAG4AcwBpAHQAaQBvAG4AcwBEAG8AYwBrAQAABnMAAAHSAAAArQD////7AAAAGABjAG8AbgB0AHIAbwBsAHMARABvAGMAawEAAAhJAAABtwAAAKIA////+wAAABIAcwB0AGEAdABzAEQAbwBjAGsCAAAExAAAAXAAAAK8AAAAyAAACgAAAAQBAAAABAAAAAQAAAAIAAAACPwAAAAA PreviewEnabled=true AlwaysOnTop=false SceneDuplicationMode=true