Codex. Cover support, updates for Music Box

This commit is contained in:
Jakub Zych
2026-04-27 21:10:13 +02:00
parent e961d264c0
commit 4c818e327b
10 changed files with 2248 additions and 129 deletions

0
.codex Normal file
View File

4
.gitignore vendored
View File

@@ -47,6 +47,10 @@ loading/cmd.js
# setup.sh from your existing plugin_config/obs-websocket/config.json. # setup.sh from your existing plugin_config/obs-websocket/config.json.
vendor/obs-config.js vendor/obs-config.js
# Album-art cache written by bridges/mpd-state.py on every track change —
# overlays load it as `<img src="../bridges/cover.jpg?v=<hash>">`. Pure runtime state.
bridges/cover.jpg
# Local backups created during edits — keep out of git noise. # Local backups created during edits — keep out of git noise.
landing/*.manual landing/*.manual
loading/*.manual loading/*.manual

20
AGENTS.md Normal file
View File

@@ -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: `<Area>: <change>`. 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.

View File

@@ -179,7 +179,36 @@
"deinterlace_mode": 0, "deinterlace_mode": 0,
"deinterlace_field_order": 0, "deinterlace_field_order": 0,
"monitoring_type": 0, "monitoring_type": 0,
"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": {} "private_settings": {}
}
]
}, },
{ {
"prev_ver": 536936450, "prev_ver": 536936450,
@@ -507,7 +536,7 @@
"mixers": 255, "mixers": 255,
"sync": 0, "sync": 0,
"flags": 0, "flags": 0,
"volume": 0.0, "volume": 0.38266569375991821,
"balance": 0.5, "balance": 0.5,
"enabled": true, "enabled": true,
"muted": false, "muted": false,
@@ -643,6 +672,7 @@
"settings": { "settings": {
"is_local_file": true, "is_local_file": true,
"local_file": "/home/jin/.config/obs-studio/music-box/widget.html", "local_file": "/home/jin/.config/obs-studio/music-box/widget.html",
"undo_uuid": "f662d513-6fe6-49fd-99f6-ea7f44e8de18",
"width": 549, "width": 549,
"height": 880, "height": 880,
"fps": 15, "fps": 15,
@@ -672,6 +702,138 @@
"monitoring_type": 0, "monitoring_type": 0,
"private_settings": {} "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, "prev_ver": 536936450,
"name": "Desktop", "name": "Desktop",
@@ -703,12 +865,12 @@
"id": 13, "id": 13,
"group_item_backup": false, "group_item_backup": false,
"pos": { "pos": {
"x": 10.0, "x": 566.0,
"y": 10.0 "y": 7.0
}, },
"pos_rel": { "pos_rel": {
"x": -1.9011975526809692, "x": -1.0688623189926147,
"y": -0.98502993583679199 "y": -0.98952096700668335
}, },
"scale": { "scale": {
"x": 1.0, "x": 1.0,
@@ -868,20 +1030,20 @@
"id": 16, "id": 16,
"group_item_backup": false, "group_item_backup": false,
"pos": { "pos": {
"x": 2019.0, "x": 22.0,
"y": 20.0 "y": 902.0
}, },
"pos_rel": { "pos_rel": {
"x": 1.1062874794006348, "x": -1.8832335472106934,
"y": -0.97005987167358398 "y": 0.35029935836791992
}, },
"scale": { "scale": {
"x": 0.27239581942558289, "x": 0.4270491898059845,
"y": 0.27222222089767456 "y": 0.42727276682853699
}, },
"scale_rel": { "scale_rel": {
"x": 0.22020021080970764, "x": 0.34521940350532532,
"y": 0.22005987167358398 "y": 0.3454001247882843
}, },
"bounds": { "bounds": {
"x": 0.0, "x": 0.0,
@@ -923,12 +1085,12 @@
"id": 17, "id": 17,
"group_item_backup": false, "group_item_backup": false,
"pos": { "pos": {
"x": 2007.0, "x": 12.0,
"y": 353.0 "y": 6.0
}, },
"pos_rel": { "pos_rel": {
"x": 1.0883233547210693, "x": -1.8982036113739014,
"y": -0.4715569019317627 "y": -0.99101793766021729
}, },
"scale": { "scale": {
"x": 0.99089258909225464, "x": 0.99089258909225464,
@@ -1416,20 +1578,20 @@
"id": 16, "id": 16,
"group_item_backup": false, "group_item_backup": false,
"pos": { "pos": {
"x": 1937.0, "x": 83.0,
"y": 98.0 "y": 88.0
}, },
"pos_rel": { "pos_rel": {
"x": 0.98353290557861328, "x": -1.7919161319732666,
"y": -0.85329341888427734 "y": -0.86826348304748535
}, },
"scale": { "scale": {
"x": 0.28593748807907104, "x": 0.24262295663356781,
"y": 0.28611111640930176 "y": 0.24318182468414307
}, },
"scale_rel": { "scale_rel": {
"x": 0.23114706575870514, "x": 0.19613233208656311,
"y": 0.23128743469715118 "y": 0.19658410549163818
}, },
"bounds": { "bounds": {
"x": 0.0, "x": 0.0,
@@ -1443,10 +1605,22 @@
"blend_method": "default", "blend_method": "default",
"blend_type": "normal", "blend_type": "normal",
"show_transition": { "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": { "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": {} "private_settings": {}
}, },
@@ -2227,7 +2401,7 @@
"id": "scene", "id": "scene",
"versioned_id": "scene", "versioned_id": "scene",
"settings": { "settings": {
"id_counter": 2, "id_counter": 6,
"custom_size": false, "custom_size": false,
"items": [ "items": [
{ {
@@ -2288,7 +2462,7 @@
{ {
"name": "Music Box HUD", "name": "Music Box HUD",
"source_uuid": "1b9f5436-864d-4f24-9f92-f45702a23953", "source_uuid": "1b9f5436-864d-4f24-9f92-f45702a23953",
"visible": true, "visible": false,
"locked": false, "locked": false,
"rot": 0.0, "rot": 0.0,
"scale_ref": { "scale_ref": {
@@ -2339,6 +2513,183 @@
"duration": 300 "duration": 300
}, },
"private_settings": {} "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.show_scene_item.1": [],
"libobs.hide_scene_item.1": [], "libobs.hide_scene_item.1": [],
"libobs.show_scene_item.2": [], "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_mode": 0,
"deinterlace_field_order": 0, "deinterlace_field_order": 0,
@@ -2595,6 +3326,9 @@
{ {
"name": "Desktop" "name": "Desktop"
}, },
{
"name": "Desktop (No Cam)"
},
{ {
"name": "Music Box" "name": "Music Box"
}, },
@@ -2602,8 +3336,8 @@
"name": "Good Bye" "name": "Good Bye"
} }
], ],
"current_scene": "Landing", "current_scene": "Music Box",
"current_program_scene": "Landing", "current_program_scene": "Music Box",
"canvases": [], "canvases": [],
"current_transition": "Luma Wipe", "current_transition": "Luma Wipe",
"transition_duration": 1000, "transition_duration": 1000,

View File

@@ -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 Payload shape matches the original daemon's broadcastState() so the overlay
needs zero changes: needs zero changes:
{ _type, index, total, title, file, currentTime, duration, paused, { _type, index, total, title, artist, trackTitle, album, year, genre,
nextTitle, nextArtist, nextTrackTitle, nextFile, nextTitles } file, currentTime, duration, paused,
nextTitle, nextArtist, nextTrackTitle, nextFile, nextTitles,
coverPath, coverHash }
Next-track fields are populated when MPD reports `nextsong` in its status. 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 `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 the current song's successor — used by the Music Box overlay to show
"coming up" lines. The single `nextTitle`/`nextArtist`/etc. fields mirror "coming up" lines. The single `nextTitle`/`nextArtist`/etc. fields mirror
nextTitles[0] for overlays that only need one. 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
`<img src="../bridges/cover.jpg?v=<coverHash>">` 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 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). 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" OBS_CONFIG_JS = HERE.parent / "vendor" / "obs-config.js"
MPD_HOST = os.environ.get("MPD_HOST", "localhost") MPD_HOST = os.environ.get("MPD_HOST", "localhost")
MPD_PORT = int(os.environ.get("MPD_PORT", "6600")) 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) 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=<coverHash>
COVER_STEMS = ("folder", "cover", "front")
COVER_EXTS = (".jpg", ".jpeg", ".png", ".webp")
# ─── config + auth ────────────────────────────────────────────────────────── # ─── config + auth ──────────────────────────────────────────────────────────
@@ -101,7 +127,66 @@ def _song_display(song: dict) -> tuple:
return display, artist, track_name 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 """Translate `status` + `currentsong` (+ optional `next_songs`, the next
up to 3 queued tracks) into the overlay payload. The single `nextTitle` up to 3 queued tracks) into the overlay payload. The single `nextTitle`
family mirrors `nextTitles[0]` for overlays that only need one.""" 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) duration = float(status.get("duration") or song.get("duration") or 0)
display, artist, track_name = _song_display(song) 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_titles = []
next_display, next_artist, next_track, next_file = None, None, None, None 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, "title": display,
"artist": artist, "artist": artist,
"trackTitle": track_name, "trackTitle": track_name,
"album": album,
"year": year,
"genre": genre,
"file": song.get("file"), "file": song.get("file"),
"currentTime": elapsed, "currentTime": elapsed,
"duration": duration, "duration": duration,
@@ -137,6 +242,10 @@ def mpd_to_event(status: dict, song: dict, next_songs: list | None = None) -> di
"nextTrackTitle": next_track, "nextTrackTitle": next_track,
"nextFile": next_file, "nextFile": next_file,
"nextTitles": next_titles, "nextTitles": next_titles,
"coverPath": cover_path,
"coverHash": cover_hash,
"playback": playback,
"stats": stats,
} }
@@ -144,14 +253,145 @@ class SharedState:
def __init__(self): def __init__(self):
self.data = { self.data = {
"index": -1, "total": 0, "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, "currentTime": 0, "duration": 0, "paused": True,
"nextTitle": None, "nextArtist": None, "nextTrackTitle": None, "nextFile": None, "nextTitle": None, "nextArtist": None, "nextTrackTitle": None, "nextFile": None,
"nextTitles": [], "nextTitles": [],
"coverPath": None, "coverHash": None,
"playback": {"volume": None, "repeat": False, "random": False, "single": False, "consume": False},
"stats": None,
} }
self.changed = asyncio.Event() 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 ─────────────────────────────────────────────────────────────── # ─── MPD side ───────────────────────────────────────────────────────────────
async def mpd_loop(state: SharedState): async def mpd_loop(state: SharedState):
@@ -179,7 +419,10 @@ async def mpd_loop(state: SharedState):
next_songs.append(rows[0]) next_songs.append(rows[0])
except Exception: except Exception:
break # transient — let it retry next tick 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() state.changed.set()
await asyncio.sleep(TICK_SECONDS) await asyncio.sleep(TICK_SECONDS)
except Exception as e: except Exception as e:

View File

@@ -45,41 +45,6 @@
user-select: none; 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 { @keyframes rec-blink {
0%, 55% { opacity: 1; } 0%, 55% { opacity: 1; }
65%, 100% { opacity: 0.2; } 65%, 100% { opacity: 0.2; }
@@ -186,6 +151,7 @@
.status-line { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; } .status-line { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; }
.status-line + .status-line { margin-top: 8px; } .status-line + .status-line { margin-top: 8px; }
.status-line .sep { color: var(--hud-dim); } .status-line .sep { color: var(--hud-dim); }
.status-line .spacer { flex: 1 1 auto; }
.live-block { .live-block {
display: flex; align-items: center; gap: 10px; display: flex; align-items: center; gap: 10px;
@@ -201,13 +167,27 @@
box-shadow: 0 0 10px var(--accent); box-shadow: 0 0 10px var(--accent);
animation: rec-blink 1.6s ease-in-out infinite; 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 { .scene-name {
color: var(--ink); color: var(--ink);
font-weight: 700; font-weight: 700;
font-size: 22px; font-size: 22px;
letter-spacing: 0.10em; 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 { .meta-line {
color: var(--ink-dim); color: var(--ink-dim);
font-size: 16px; font-size: 16px;
@@ -217,21 +197,6 @@
</head> </head>
<body> <body>
<header class="hud">
<div class="group">
<span><span class="led"></span>TRANSMISSION</span>
<span>OPHI-118 // DESKTOP</span>
</div>
<div class="group">
<span class="dim">SIGNAL</span>
<span id="signal">▮▮▮▯▯</span>
</div>
<div class="group">
<span class="dim">UTC</span>
<span id="clock">--:--:--</span>
</div>
</header>
<div class="screen-frame" id="scr"> <div class="screen-frame" id="scr">
<span class="tick tl"></span> <span class="tick tl"></span>
<span class="tick tr"></span> <span class="tick tr"></span>
@@ -264,7 +229,11 @@
<span>LIVE</span> <span>LIVE</span>
</div> </div>
<span class="sep">·</span> <span class="sep">·</span>
<span class="scene-name">DESKTOP</span> <span class="scene-name">OPHI-118 // DESKTOP</span>
<span class="spacer"></span>
<span class="hud-tag"><span class="dim">SIGNAL</span><span id="signal">▮▮▮▯▯</span></span>
<span class="sep">·</span>
<span class="hud-tag"><span class="dim">UTC</span><span id="clock">--:--:--</span></span>
</div> </div>
<div class="status-line meta-line"> <div class="status-line meta-line">
<span class="icon"></span> <span class="icon"></span>
@@ -311,12 +280,18 @@
tickElapsed(); setInterval(tickElapsed, 1000); tickElapsed(); setInterval(tickElapsed, 1000);
// ── OBS WS: live frame sync for camera + screen capture ── // ── OBS WS: live frame sync for camera + screen capture ──
// URL params let you rename the sources / scene without editing JS: // URL params:
// ?camera=Webcam&screen=Display&scene=Desktop // ?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 params = new URLSearchParams(location.search);
const CAM_SOURCE = params.get('camera') || 'Camera'; const CAM_SOURCE = params.get('camera') || 'Camera';
const SCR_SOURCE = params.get('screen') || 'Screen Capture (PipeWire)'; 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 camEl = document.getElementById('cam');
const scrEl = document.getElementById('scr'); const scrEl = document.getElementById('scr');
@@ -336,10 +311,12 @@
}; };
} }
// Bind a frame element to a scene item: live-sync size/position into the // Frame binding for one tracked source. Holds mutable {sceneName, itemId}
// --{prefix}-x/y/w/h CSS vars and toggle .off on enable changes. // 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. // 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 setVisible = (v) => frameEl.classList.toggle('off', !v);
const setTransform = (t) => { const setTransform = (t) => {
if (!t) return; if (!t) return;
@@ -350,30 +327,39 @@
rootStyle.setProperty(`--${varPrefix}-w`, `${w}px`); rootStyle.setProperty(`--${varPrefix}-w`, `${w}px`);
rootStyle.setProperty(`--${varPrefix}-h`, `${h}px`); rootStyle.setProperty(`--${varPrefix}-h`, `${h}px`);
}; };
let itemId = null; return {
async rebind(sceneName) {
state.sceneName = sceneName;
state.itemId = null;
if (!sceneName) { setVisible(false); return; }
try { try {
const r = await obs.call('GetSceneItemId', { sceneName, sourceName }); const r = await obs.call('GetSceneItemId',
itemId = r.sceneItemId; { sceneName, sourceName });
state.itemId = r.sceneItemId;
const e = await obs.call('GetSceneItemEnabled', const e = await obs.call('GetSceneItemEnabled',
{ sceneName, sceneItemId: itemId }); { sceneName, sceneItemId: state.itemId });
setVisible(e.sceneItemEnabled); setVisible(e.sceneItemEnabled);
const t = await obs.call('GetSceneItemTransform', const t = await obs.call('GetSceneItemTransform',
{ sceneName, sceneItemId: itemId }); { sceneName, sceneItemId: state.itemId });
setTransform(t.sceneItemTransform); setTransform(t.sceneItemTransform);
} catch (err) { } catch {
console.warn(`[desktop] frame sync failed (${sceneName}/${sourceName}):`, err.message); // Source isn't in this scene (or scene doesn't exist) — hide
return; // the frame outright. This is what makes "Desktop (No Cam)"
// work cleanly even if Camera were fully removed (rather than
// just disabled).
setVisible(false);
} }
obs.addEventListener('event', (ev) => { },
const d = ev.detail || {}; handleEvent(d) {
if (d.eventData?.sceneName !== sceneName if (d.eventData?.sceneName !== state.sceneName
|| d.eventData?.sceneItemId !== itemId) return; || d.eventData?.sceneItemId !== state.itemId) return;
if (d.eventType === 'SceneItemEnableStateChanged') { if (d.eventType === 'SceneItemEnableStateChanged') {
setVisible(d.eventData.sceneItemEnabled); setVisible(d.eventData.sceneItemEnabled);
} else if (d.eventType === 'SceneItemTransformChanged') { } else if (d.eventType === 'SceneItemTransformChanged') {
setTransform(d.eventData.sceneItemTransform); setTransform(d.eventData.sceneItemTransform);
} }
}); },
};
} }
async function connectOBS() { async function connectOBS() {
@@ -381,14 +367,34 @@
try { try {
const obs = new OBSWSMini(window.__OBSWS.url, window.__OBSWS.password); const obs = new OBSWSMini(window.__OBSWS.url, window.__OBSWS.password);
await obs.connect(); await obs.connect();
obs.addEventListener('close', () => { obs.addEventListener('close', () => setTimeout(connectOBS, 2500));
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) { } catch (e) {
// OBS WS optional — silently retry; defaults stand meanwhile. // OBS WS optional — silently retry; defaults stand meanwhile.
setTimeout(connectOBS, 2500); setTimeout(connectOBS, 2500);

295
music-box/cover.html Normal file
View File

@@ -0,0 +1,295 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Music Box cover</title>
<!--
Album-art widget. Subscribes to mpd:state on the OBS WS bus and renders
the cover bytes the bridge writes to ../bridges/cover.jpg. Cache-busting
uses ?v=<coverHash> so a new track swaps the image atomically.
Sized to fit any source — uses object-fit: contain so a square cover
letterboxes inside a non-square frame instead of stretching. Author the
OBS browser_source's width/height to match the scene-item dimensions
you place it at (bounds_type 0, no stretch — same convention as
music-box/widget.html).
URL params:
?bars=0 hide the top header + bottom caption strip; just the cyan
frame around the artwork (used when the widget stands in
for the camera in compact scenes).
-->
<style>
:root {
--bg: #04120a;
--hud: #4fd2ff;
--hud-dim: #2a7fa6;
--term-fg: #5fdc62;
--term-fg-bright: #97f99a;
--term-fg-dim: #2c8d2f;
--term-glow: rgba(80, 220, 100, 0.45);
--frame: rgba(79, 210, 255, 0.85);
--frame-glow: rgba(79, 210, 255, 0.30);
--mono: 'DejaVu Sans Mono', 'Liberation Mono', 'Consolas', monospace;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%; height: 100%;
background: var(--bg);
color: var(--term-fg);
font-family: var(--mono);
overflow: hidden;
user-select: none;
}
body {
position: relative;
display: flex;
flex-direction: column;
border: 1.5px solid var(--frame);
box-shadow:
0 0 18px var(--frame-glow),
inset 0 0 50px rgba(0, 30, 0, 0.55);
text-shadow: 0 0 4px var(--term-glow);
}
.corner {
position: absolute;
width: 14px; height: 14px;
border-color: var(--frame);
border-style: solid;
border-width: 0;
z-index: 4;
}
.corner.tl { top: 4px; left: 4px; border-top-width: 2px; border-left-width: 2px; }
.corner.tr { top: 4px; right: 4px; border-top-width: 2px; border-right-width: 2px; }
.corner.bl { bottom: 4px; left: 4px; border-bottom-width: 2px; border-left-width: 2px; }
.corner.br { bottom: 4px; right: 4px; border-bottom-width: 2px; border-right-width: 2px; }
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 22px 10px;
font-size: 13px;
letter-spacing: 0.22em;
color: var(--hud);
border-bottom: 1px solid rgba(79, 210, 255, 0.22);
flex: 0 0 auto;
}
.header .left { display: inline-flex; gap: 10px; align-items: center; }
.header .right { color: var(--hud-dim); font-size: 13px; letter-spacing: 0.12em; }
/* Stage holds the artwork. Both <img> and placeholder are absolutely
positioned full-bleed; we cross-fade between them on hash change. */
.art-stage {
position: relative;
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
background: #02080a;
}
#cover {
position: absolute; inset: 0;
width: 100%; height: 100%;
object-fit: contain;
opacity: 0;
transition: opacity 320ms ease;
}
#cover.on { opacity: 1; }
.placeholder {
position: absolute; inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
text-align: center;
padding: 0 22px;
color: var(--term-fg-dim);
transition: opacity 320ms ease;
}
.placeholder.off { opacity: 0; }
.placeholder svg { opacity: 0.55; }
.placeholder .ph-text {
font-size: 13px;
letter-spacing: 0.42em;
color: rgba(95, 220, 98, 0.65);
}
.placeholder .ph-sub {
font-size: 11px;
letter-spacing: 0.18em;
color: var(--term-fg-dim);
}
/* Same scanline treatment as music-box/widget.html so the widget reads
as part of the same family. Sits above the artwork; below corners. */
.scanlines {
position: absolute; inset: 0;
pointer-events: none;
background: repeating-linear-gradient(
to bottom,
rgba(0,0,0,0) 0px,
rgba(0,0,0,0) 2px,
rgba(0,0,0,0.18) 3px,
rgba(0,0,0,0.18) 4px
);
opacity: 0.32;
mix-blend-mode: multiply;
z-index: 3;
}
.caption {
flex: 0 0 auto;
padding: 10px 22px 12px;
font-size: 14px;
border-top: 1px solid rgba(95, 220, 98, 0.18);
color: var(--term-fg);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
gap: 10px;
align-items: baseline;
}
.caption .arrow {
color: var(--term-fg-bright);
flex: 0 0 auto;
animation: np-pulse 1.05s ease-in-out infinite alternate;
}
.caption .text {
color: var(--term-fg-bright);
flex: 1 1 auto;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.caption.empty .arrow,
.caption.empty .text { color: var(--term-fg-dim); }
@keyframes np-pulse {
0% { opacity: 0.45; }
100% { opacity: 1.00; }
}
/* ?bars=0 — strip header + caption so the artwork + cyan frame is all
that remains. The art-stage already flex-grows to fill, so dropping
the siblings just gives it the entire body. */
body.no-bars .header,
body.no-bars .caption { display: none; }
</style>
</head>
<body>
<span class="corner tl"></span>
<span class="corner tr"></span>
<span class="corner bl"></span>
<span class="corner br"></span>
<div class="header">
<span class="left">ALBUM ART</span>
<span class="right">CH 118.0 // MUSIC</span>
</div>
<div class="art-stage">
<img id="cover" alt="">
<div class="placeholder" id="placeholder">
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="6" y="6" width="52" height="52" rx="2" stroke="#5fdc62" stroke-width="2"/>
<circle cx="32" cy="32" r="14" stroke="#5fdc62" stroke-width="2"/>
<circle cx="32" cy="32" r="3" fill="#5fdc62"/>
</svg>
<span class="ph-text">— NO ART —</span>
<span class="ph-sub">awaiting cover…</span>
</div>
<div class="scanlines"></div>
</div>
<div class="caption empty" id="caption">
<span class="arrow"></span><span class="text" id="captionText">connecting…</span>
</div>
<script src="../vendor/obs-config.js"></script>
<script src="../vendor/obs-ws-mini.js"></script>
<script>
'use strict';
// ?bars=0 → minimal mode: just frame + art, no header/caption strip.
const params = new URLSearchParams(location.search);
if (params.get('bars') === '0') document.body.classList.add('no-bars');
const coverImg = document.getElementById('cover');
const placeholder = document.getElementById('placeholder');
const caption = document.getElementById('caption');
const captionText = document.getElementById('captionText');
let lastHash = undefined; // undefined = never seen; null = no art
function showArt(path, hash) {
if (hash === lastHash) return;
lastHash = hash;
if (!path || !hash) {
coverImg.classList.remove('on');
placeholder.classList.remove('off');
coverImg.removeAttribute('src');
return;
}
// ?v=<hash> cache-busts the canonical cover.jpg path so CEF doesn't
// serve a stale image when the bridge swaps bytes.
coverImg.onload = () => {
coverImg.classList.add('on');
placeholder.classList.add('off');
};
coverImg.onerror = () => {
coverImg.classList.remove('on');
placeholder.classList.remove('off');
};
coverImg.src = `${path}?v=${hash}`;
}
function applyState(s) {
showArt(s.coverPath, s.coverHash);
if (s.title) {
captionText.textContent = s.title;
caption.classList.remove('empty');
} else {
captionText.textContent = '—';
caption.classList.add('empty');
}
}
function setOffline(msg) {
captionText.textContent = msg;
caption.classList.add('empty');
coverImg.classList.remove('on');
placeholder.classList.remove('off');
coverImg.removeAttribute('src');
lastHash = undefined;
}
let obs = null;
async function connect() {
if (!window.__OBSWS) {
setOffline('config missing');
return;
}
try {
obs = new OBSWSMini(window.__OBSWS.url, window.__OBSWS.password);
await obs.connect();
obs.addEventListener('close', () => {
setOffline('disconnected');
setTimeout(connect, 2000);
});
obs.onCustom('mpd:state', applyState);
} catch (e) {
setOffline('daemon unreachable');
setTimeout(connect, 2500);
}
}
connect();
</script>
</body>
</html>

804
music-box/nc-music-box.html Normal file
View File

@@ -0,0 +1,804 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>OPHI-118 / NC-MUSIC-BOX</title>
<!--
ncurses-styled Music Box. Forked from music-box/index.html but laid out
as a 2-pane TUI: terminal on the left (boot + scrolling history + pinned
next-up + now-playing), cover + metadata pane on the right.
Subscribes to mpd:state and uses the same coverPath/coverHash mechanism
as music-box/cover.html — so this works alongside the existing widget,
it doesn't replace anything.
Designed for the same canvas as music-box/index.html (2560×1336 stretch).
Keep the OBS browser_source bounds_type at 1 (stretch) and width/height
at 2560×1336 — same as the Music Box scene's existing HUD source.
-->
<style>
:root {
--bg: #07080d;
--ink: #e8e8e0;
--hud: #4fd2ff;
--hud-dim: #2a7fa6;
--accent: #e63a2e;
--warn: #ffd000;
--onair: #5fdc62;
--term-bg: #04120a;
--term-fg: #5fdc62;
--term-fg-bright: #97f99a;
--term-fg-dim: #2c8d2f;
--term-glow: rgba(80, 220, 100, 0.55);
--term-edge: rgba(80, 220, 100, 0.45);
--term-edge-soft: rgba(80, 220, 100, 0.22);
--mono: 'DejaVu Sans Mono', 'Liberation Mono', 'Consolas', monospace;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%; height: 100%;
background: var(--bg);
color: var(--ink);
font-family: var(--mono);
overflow: hidden;
user-select: none;
}
body {
position: relative;
display: grid;
grid-template-rows: auto 1fr auto;
padding: 40px 72px;
gap: 22px;
}
/* ───────── HUD top strip ───────── */
.hud {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
align-items: center;
font-size: 22px;
font-weight: 700;
color: var(--hud);
letter-spacing: 0.08em;
}
.hud .group { display: flex; gap: 28px; 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: 12px; }
.hud .led {
display: inline-block;
width: 11px; height: 11px;
border-radius: 50%;
background: var(--onair);
box-shadow: 0 0 10px var(--onair);
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.20; }
}
/* ───────── Main: 2-pane TUI ─────────
Terminal left (1fr), Now-Playing right (~30 % at canvas size). gap is
the visual seam between panes — both panes have their own border so
the gap reads as a column gutter, not as padding inside one pane. */
.tui {
display: grid;
grid-template-columns: 1fr 36ch;
gap: 24px;
min-height: 0;
}
/* ───────── Pane shell (shared) ─────────
The "ncurses" feel: solid 2 px border, a tiny tab on the top edge that
reads as a title bar. Tabs are drawn with overlapping CSS rather than
box-drawing chars to keep alignment crisp at this size. */
.pane {
position: relative;
background: var(--term-bg);
border: 2px solid var(--term-edge);
border-radius: 4px;
display: flex;
flex-direction: column;
min-height: 0;
box-shadow:
0 0 60px rgba(80, 220, 100, 0.10),
inset 0 0 90px rgba(0, 30, 0, 0.65);
}
.pane::after {
/* Pane scanline overlay — same texture as music-box/index.html. */
content: '';
position: absolute; inset: 0;
pointer-events: none;
background: repeating-linear-gradient(
to bottom,
rgba(0,0,0,0) 0px,
rgba(0,0,0,0) 2px,
rgba(0,0,0,0.28) 3px,
rgba(0,0,0,0.28) 4px
);
opacity: 0.55;
mix-blend-mode: multiply;
}
.pane > .pane-tab {
position: absolute;
top: -14px;
left: 28px;
padding: 2px 14px;
background: var(--bg);
color: var(--term-fg-bright);
font-size: 16px;
letter-spacing: 0.32em;
text-shadow: 0 0 6px var(--term-glow);
z-index: 1;
}
/* ───────── Terminal pane ───────── */
.terminal {
padding: 28px 36px;
font-size: 24px;
line-height: 1.55;
color: var(--term-fg);
text-shadow: 0 0 6px var(--term-glow);
}
#termOutput {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
justify-content: flex-end;
overflow: hidden;
}
.term-line { word-break: break-word; }
.term-prompt { color: var(--term-fg-bright); }
.term-out { color: var(--term-fg); }
.term-dim { color: var(--term-fg-dim); }
.pin-spacer { height: 14px; }
.np-line .np-arrow {
color: var(--term-fg-bright);
margin-right: 12px;
display: inline-block;
animation: np-pulse 1.05s ease-in-out infinite alternate;
}
.np-line .np-title { color: var(--term-fg-bright); }
.np-line .np-time { color: var(--term-fg-dim); margin-left: 14px; }
@keyframes np-pulse {
0% { opacity: 0.45; }
100% { opacity: 1.00; }
}
.next-line .next-arrow { color: var(--term-fg-dim); margin-right: 10px; }
.next-line .next-label { color: var(--term-fg-dim); margin-right: 6px; }
.next-line .next-title { color: var(--term-fg); }
.next-line .next-title.empty { color: var(--term-fg-dim); }
/* ───────── Now Playing pane (right) ───────── */
.np-pane {
padding: 24px 22px 20px;
font-size: 18px;
color: var(--term-fg);
text-shadow: 0 0 6px var(--term-glow);
display: grid;
/* art | meta | LIBRARY | <slack 1fr> | spectrum | progress
Slack sits between the upper info block and the bottom strip so the
spectrum analyzer reserve docks just above the progress bar — pairs
them visually as a single "playback" zone at the foot of the pane. */
grid-template-rows: auto auto auto 1fr auto auto;
gap: 18px;
}
/* Cover stage: 1:1 square inside the pane width. */
.np-art {
position: relative;
aspect-ratio: 1 / 1;
background: rgba(0, 30, 0, 0.45);
border: 1px solid var(--term-edge-soft);
overflow: hidden;
}
.np-art img {
position: absolute; inset: 0;
width: 100%; height: 100%;
object-fit: cover;
opacity: 0;
transition: opacity 320ms ease;
}
.np-art img.on { opacity: 1; }
.np-art .placeholder {
position: absolute; inset: 0;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
gap: 10px;
color: var(--term-fg-dim);
text-align: center;
transition: opacity 320ms ease;
}
.np-art .placeholder.off { opacity: 0; }
.np-art .placeholder svg { opacity: 0.5; }
.np-art .placeholder .ph-text {
font-size: 14px;
letter-spacing: 0.42em;
color: rgba(95, 220, 98, 0.65);
}
/* Metadata key/value table — fixed-width keys read like a config block.
Empty values render as `—` in dim color so missing tags don't visually
collapse the row. */
.np-meta {
display: grid;
grid-template-columns: 6.5em 1fr;
row-gap: 6px;
column-gap: 12px;
font-size: 17px;
align-content: start;
}
.np-meta .key { color: var(--term-fg-dim); letter-spacing: 0.18em; }
.np-meta .val {
color: var(--term-fg-bright);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.np-meta .val.empty { color: var(--term-fg-dim); }
/* LIBRARY block — sub-section inside the Now-Playing pane. Header reads
like an inset divider rather than a full pane tab so it doesn't compete
with the pane's own ┤ NOW PLAYING ├ at the top edge. */
.np-stats {
display: flex;
flex-direction: column;
gap: 8px;
padding-top: 6px;
border-top: 1px dashed var(--term-edge-soft);
}
.np-stats .stats-label {
font-size: 14px;
letter-spacing: 0.32em;
color: var(--term-fg-dim);
text-shadow: 0 0 4px var(--term-glow);
}
.np-stats .stats-grid {
display: grid;
grid-template-columns: 6.5em 1fr;
row-gap: 4px;
column-gap: 12px;
font-size: 16px;
}
.np-stats .stats-grid .key { color: var(--term-fg-dim); letter-spacing: 0.16em; }
.np-stats .stats-grid .val {
color: var(--term-fg);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.np-stats .stats-grid .val.empty { color: var(--term-fg-dim); }
/* Spectrum slot — reserves the row that the waveform plugin source sits
on top of. Empty + transparent: the plugin draws into this region, the
widget just holds the layout space so progress stays in its expected
position regardless of whether the analyzer source is enabled. */
.np-spectrum {
min-height: 64px;
}
/* Progress block: thin bar + elapsed/total, all term-green. The bar fill
transitions on width change so the second-tick advance reads smoothly
instead of stepping. */
.np-progress {
display: grid;
grid-template-rows: auto auto;
gap: 6px;
}
.np-progress .bar {
position: relative;
height: 6px;
background: rgba(95, 220, 98, 0.14);
border: 1px solid var(--term-edge-soft);
}
.np-progress .bar .fill {
position: absolute; inset: 0 auto 0 0;
width: 0%;
background: var(--term-fg);
box-shadow: 0 0 8px var(--term-glow);
transition: width 350ms linear;
}
.np-progress .time {
font-size: 15px;
color: var(--term-fg-dim);
letter-spacing: 0.10em;
display: flex;
justify-content: space-between;
}
.np-progress .time .now { color: var(--term-fg-bright); }
/* ───────── Status strip (chat help — always visible) ───────── */
.status-strip {
display: flex;
align-items: center;
justify-content: center;
gap: 28px;
font-size: 18px;
letter-spacing: 0.18em;
color: var(--hud-dim);
padding: 10px 24px;
border-top: 1px solid rgba(79, 210, 255, 0.22);
border-bottom: 1px solid rgba(79, 210, 255, 0.22);
background: rgba(7, 8, 13, 0.55);
}
.status-strip .label { color: var(--hud); letter-spacing: 0.32em; }
.status-strip .cmd-group { display: inline-flex; gap: 18px; }
.status-strip .cmd {
color: var(--term-fg-bright);
text-shadow: 0 0 6px var(--term-glow);
}
.status-strip .desc { color: var(--hud-dim); margin-left: 6px; font-size: 16px; letter-spacing: 0.10em; }
/* ───────── Body overlays (CRT atmosphere) ───────── */
#static {
position: absolute; inset: 0;
width: 100%; height: 100%;
pointer-events: none;
opacity: 0.045;
mix-blend-mode: screen;
image-rendering: pixelated;
}
.scanlines {
position: absolute; inset: 0;
pointer-events: none;
background: repeating-linear-gradient(
to bottom,
rgba(0,0,0,0) 0px,
rgba(0,0,0,0) 2px,
rgba(0,0,0,0.20) 3px,
rgba(0,0,0,0.20) 4px
);
opacity: 0.5;
mix-blend-mode: multiply;
}
.vignette {
position: absolute; inset: 0;
pointer-events: none;
background: radial-gradient(
ellipse at center,
rgba(0,0,0,0) 45%,
rgba(0,0,0,0.60) 100%
);
}
</style>
</head>
<body>
<header class="hud">
<div class="group">
<span><span class="led"></span>ON AIR</span>
<span>OPHI-118 // MUSIC</span>
</div>
<div class="group">
<span class="dim">SIGNAL</span>
<span id="signal">▮▮▮▮▯</span>
</div>
<div class="group">
<span class="dim">UTC</span>
<span id="clock">--:--:--</span>
</div>
</header>
<main class="tui">
<!-- ── Terminal pane ── -->
<section class="pane terminal">
<span class="pane-tab">┤ TERMINAL ├</span>
<div id="termOutput">
<div id="pinnedBlock">
<div class="pin-spacer"></div>
<div class="term-line next-line">
<span class="next-arrow"></span><span class="next-label">next:</span><span class="next-title empty" data-slot="0">— end of queue —</span>
</div>
<div class="term-line next-line">
<span class="next-arrow"></span><span class="next-label">then:</span><span class="next-title empty" data-slot="1"></span>
</div>
<div class="term-line next-line">
<span class="next-arrow"></span><span class="next-label">then:</span><span class="next-title empty" data-slot="2"></span>
</div>
<div class="pin-spacer"></div>
<div class="term-line np-line">
<span class="np-arrow"></span><span class="np-title">connecting to daemon…</span><span class="np-time">[--:-- / --:--]</span>
</div>
</div>
</div>
</section>
<!-- ── Now-playing pane ── -->
<aside class="pane np-pane">
<span class="pane-tab">┤ NOW PLAYING ├</span>
<div class="np-art">
<img id="cover" alt="">
<div class="placeholder" id="placeholder">
<svg width="56" height="56" viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="5" y="5" width="46" height="46" rx="2" stroke="#5fdc62" stroke-width="2"/>
<circle cx="28" cy="28" r="12" stroke="#5fdc62" stroke-width="2"/>
<circle cx="28" cy="28" r="3" fill="#5fdc62"/>
</svg>
<span class="ph-text">— NO ART —</span>
</div>
</div>
<div class="np-meta">
<span class="key">ARTIST</span><span class="val empty" id="metaArtist"></span>
<span class="key">TITLE</span> <span class="val empty" id="metaTitle"></span>
<span class="key">ALBUM</span> <span class="val empty" id="metaAlbum"></span>
<span class="key">YEAR</span> <span class="val empty" id="metaYear"></span>
<span class="key">GENRE</span> <span class="val empty" id="metaGenre"></span>
</div>
<div class="np-stats">
<div class="stats-label">─ LIBRARY ─</div>
<div class="stats-grid">
<span class="key">ARTISTS</span> <span class="val empty" id="statArtists"></span>
<span class="key">ALBUMS</span> <span class="val empty" id="statAlbums"></span>
<span class="key">SONGS</span> <span class="val empty" id="statSongs"></span>
<span class="key">PLAYTIME</span><span class="val empty" id="statPlaytime"></span>
<span class="key">QUEUE</span> <span class="val empty" id="statQueue"></span>
</div>
</div>
<div aria-hidden="true"></div>
<!-- Empty slot directly above the progress bar — the OBS waveform
plugin source is overlaid here. We just hold the row open. -->
<div class="np-spectrum" id="spectrumSlot"></div>
<div class="np-progress">
<div class="bar"><div class="fill" id="progFill"></div></div>
<div class="time">
<span class="now" id="progElapsed">00:00</span>
<span id="progTotal">00:00</span>
</div>
</div>
</aside>
</main>
<section class="status-strip">
<span class="label">CHAT</span>
<span class="cmd-group">
<span><span class="cmd">!skip</span><span class="desc">next track</span></span>
<span><span class="cmd">!queue</span><span class="desc">peek next 3</span></span>
<span><span class="cmd">!info</span><span class="desc">full metadata</span></span>
</span>
</section>
<canvas id="static"></canvas>
<div class="scanlines"></div>
<div class="vignette"></div>
<script src="../loading/playlist.js"></script>
<script src="../vendor/obs-config.js"></script>
<script src="../vendor/obs-ws-mini.js"></script>
<script>
'use strict';
const pad = n => String(n).padStart(2, '0');
const sleep = ms => new Promise(r => setTimeout(r, ms));
const fmtClock = s => (!isFinite(s) || s < 0)
? '--:--' : `${pad(Math.floor(s / 60))}:${pad(Math.floor(s % 60))}`;
// ── UTC clock ──
const clockEl = document.getElementById('clock');
const tickClock = () => {
const d = new Date();
clockEl.textContent =
`${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
};
tickClock(); setInterval(tickClock, 1000);
// ── Signal ──
const sigEl = document.getElementById('signal');
setInterval(() => {
const r = Math.random();
const n = r < 0.15 ? 3 : r < 0.65 ? 4 : 5;
sigEl.textContent = '▮'.repeat(n) + '▯'.repeat(5 - n);
}, 1500);
// ── Static (CRT noise) ──
const cv = document.getElementById('static'), ctx = cv.getContext('2d');
const W = 320, H = 180;
cv.width = W; cv.height = H;
const img = ctx.createImageData(W, H);
const drawNoise = () => {
const d = img.data;
for (let i = 0; i < d.length; i += 4) {
const v = (Math.random() * 255) | 0;
d[i] = d[i+1] = d[i+2] = v;
d[i+3] = 255;
}
ctx.putImageData(img, 0, 0);
};
drawNoise();
setInterval(drawNoise, 1000 / 8);
// ── Track count from playlist manifest (used in boot sequence) ──
const playlistData = window.__PLAYLIST || { tracks: [] };
const trackCount = (playlistData.tracks || []).length;
// ── Terminal ──
const term = document.getElementById('termOutput');
const pinnedBlock = document.getElementById('pinnedBlock');
const npTitle = pinnedBlock.querySelector('.np-title');
const npTime = pinnedBlock.querySelector('.np-time');
const nextSlots = [0, 1, 2].map(i => pinnedBlock.querySelector(`[data-slot="${i}"]`));
const PROMPT = 'OPHI-118://> ';
const MAX_TERM_LINES = 200;
const MODE_KEYS = ['repeat', 'random', 'single'];
let bootStarted = false;
let bootFinished = false;
let lastPlaybackModes = null;
let pendingState = null;
function pruneTerminal() {
while (term.children.length > MAX_TERM_LINES) {
const first = term.firstElementChild;
if (!first || first === pinnedBlock) break;
term.removeChild(first);
}
while (term.children.length > 1) {
let total = 0;
for (const c of term.children) total += c.getBoundingClientRect().height;
if (total <= term.clientHeight) break;
const first = term.firstElementChild;
if (!first || first === pinnedBlock) break;
term.removeChild(first);
}
}
function logBeforePins(text, cls = 'term-out', prompt = '> ') {
const div = document.createElement('div');
div.className = 'term-line';
if (prompt) {
const p = document.createElement('span');
p.className = 'term-prompt';
p.textContent = prompt;
div.appendChild(p);
}
const t = document.createElement('span');
if (cls) t.className = cls;
t.textContent = text;
div.appendChild(t);
term.insertBefore(div, pinnedBlock);
pruneTerminal();
}
async function typeCommand(text) {
const div = document.createElement('div');
div.className = 'term-line';
const p = document.createElement('span');
p.className = 'term-prompt';
p.textContent = PROMPT;
div.appendChild(p);
const body = document.createElement('span');
body.className = 'term-out';
div.appendChild(body);
term.insertBefore(div, pinnedBlock);
for (const c of text) {
body.textContent += c;
await sleep(38);
}
await sleep(280);
return body;
}
function normalizePlayback(playback) {
const src = playback && typeof playback === 'object' ? playback : {};
const normalized = {};
for (const key of MODE_KEYS) normalized[key] = !!src[key];
normalized.consume = !!src.consume;
normalized.volume = Number.isFinite(src.volume) ? src.volume : null;
return normalized;
}
function formatBootCommand(playback) {
const p = normalizePlayback(playback);
const parts = ['music', '--boot'];
if (p.volume !== null) parts.push(`--volume=${p.volume}%`);
MODE_KEYS.forEach(key => {
if (p[key]) parts.push(`--${key}`);
});
if (p.consume) parts.push('--consume');
return parts.join(' ');
}
async function logModeChange(mode, enabled) {
await typeCommand(`music --${mode}=${enabled ? 'true' : 'false'}`);
logBeforePins(`[audio] ${mode} mode ${enabled ? 'enabled' : 'disabled'}`, 'term-out', null);
}
let modeLogChain = Promise.resolve();
function trackModeChanges(playback) {
const current = normalizePlayback(playback);
if (!lastPlaybackModes) {
lastPlaybackModes = current;
return;
}
MODE_KEYS.forEach(key => {
if (current[key] !== lastPlaybackModes[key]) {
modeLogChain = modeLogChain.then(() => logModeChange(key, current[key]));
}
});
lastPlaybackModes = current;
}
function renderState(s) {
if (lastTrackIndex !== null && s.index !== lastTrackIndex && s.title) {
logBeforePins(`[audio] now: ${s.title}`);
}
lastTrackIndex = s.index;
// Terminal pinned block
npTitle.textContent = s.title || '—';
npTime.textContent = `[${fmtClock(s.currentTime)} / ${fmtClock(s.duration)}]`;
const titles = Array.isArray(s.nextTitles) ? s.nextTitles : [];
nextSlots.forEach((slot, i) => {
const v = titles[i];
if (v) {
slot.textContent = v;
slot.classList.remove('empty');
} else {
slot.textContent = i === 0 ? '— end of queue —' : '—';
slot.classList.add('empty');
}
});
// Now-playing pane
showArt(s.coverPath, s.coverHash);
setMeta(metaArtist, s.artist);
setMeta(metaTitle, s.trackTitle);
setMeta(metaAlbum, s.album);
setMeta(metaYear, s.year);
setMeta(metaGenre, s.genre);
// Progress bar — clamp 0..100 so a stale duration doesn't blow it past
// the bar end during the brief gap between track change and the next
// status tick.
const dur = Number(s.duration) || 0;
const cur = Number(s.currentTime) || 0;
const pct = dur > 0 ? Math.max(0, Math.min(100, (cur / dur) * 100)) : 0;
progFill.style.width = `${pct}%`;
progElapsed.textContent = fmtClock(cur);
progTotal.textContent = fmtClock(dur);
// Library stats — bridge updates `stats` ~every 30s. Queue is built
// from the per-tick index/total fields; render 1-indexed (mpc style).
const stats = s.stats;
if (stats) {
setMeta(statArtists, fmtCount(stats.artists));
setMeta(statAlbums, fmtCount(stats.albums));
setMeta(statSongs, fmtCount(stats.songs));
setMeta(statPlaytime, stats.dbPlaytime);
}
const total = Number(s.total) || 0;
const idx = Number.isInteger(s.index) ? s.index : -1;
setMeta(statQueue,
total > 0 && idx >= 0 ? `${idx + 1} / ${total}` : null);
pruneTerminal();
}
async function bootFromState(s) {
if (bootStarted) return;
bootStarted = true;
lastPlaybackModes = normalizePlayback(s.playback);
await typeCommand(formatBootCommand(s.playback));
logBeforePins('[audio] subscribing to mpd:state events', 'term-out');
await sleep(220);
if (trackCount > 0) {
logBeforePins(`[audio] library: ${trackCount} tracks`, 'term-out');
} else {
logBeforePins('[audio] library manifest empty', 'term-dim');
}
await sleep(180);
logBeforePins('[audio] standby for transmission ✓', 'term-out');
bootFinished = true;
if (pendingState) renderState(pendingState);
}
// ── Now-playing pane handles ──
const coverImg = document.getElementById('cover');
const placeholder = document.getElementById('placeholder');
const metaArtist = document.getElementById('metaArtist');
const metaTitle = document.getElementById('metaTitle');
const metaAlbum = document.getElementById('metaAlbum');
const metaYear = document.getElementById('metaYear');
const metaGenre = document.getElementById('metaGenre');
const progFill = document.getElementById('progFill');
const progElapsed = document.getElementById('progElapsed');
const progTotal = document.getElementById('progTotal');
const statArtists = document.getElementById('statArtists');
const statAlbums = document.getElementById('statAlbums');
const statSongs = document.getElementById('statSongs');
const statPlaytime = document.getElementById('statPlaytime');
const statQueue = document.getElementById('statQueue');
const fmtCount = n => (typeof n === 'number' && isFinite(n))
? n.toLocaleString('en-US') : null;
// Set a metadata cell. Empty/missing → dim em-dash so the row stays
// present (otherwise the grid collapses and the layout dances).
function setMeta(el, value) {
if (value && String(value).trim()) {
el.textContent = value;
el.classList.remove('empty');
} else {
el.textContent = '—';
el.classList.add('empty');
}
}
let lastCoverHash = undefined;
function showArt(path, hash) {
if (hash === lastCoverHash) return;
lastCoverHash = hash;
if (!path || !hash) {
coverImg.classList.remove('on');
placeholder.classList.remove('off');
coverImg.removeAttribute('src');
return;
}
coverImg.onload = () => { coverImg.classList.add('on'); placeholder.classList.add('off'); };
coverImg.onerror = () => { coverImg.classList.remove('on'); placeholder.classList.remove('off'); };
coverImg.src = `${path}?v=${hash}`;
}
// ── State application ──
let lastTrackIndex = null;
function applyState(s) {
pendingState = s;
if (!bootStarted) {
bootFromState(s);
return;
}
if (!bootFinished) return;
renderState(s);
trackModeChanges(s.playback);
}
function setOffline(msg) {
npTitle.textContent = msg;
npTime.textContent = '[--:-- / --:--]';
}
let obs = null;
async function connectMusic() {
if (!window.__OBSWS) {
logBeforePins('[audio] vendor/obs-config.js missing — run scripts/setup.sh', 'term-dim');
return;
}
try {
obs = new OBSWSMini(window.__OBSWS.url, window.__OBSWS.password);
await obs.connect();
obs.addEventListener('close', () => {
logBeforePins('[audio] daemon connection lost — retrying', 'term-dim');
setOffline('daemon disconnected');
setTimeout(connectMusic, 2000);
});
obs.onCustom('mpd:state', applyState);
} catch (e) {
logBeforePins(`[audio] daemon unreachable (${e.message}) — retrying`, 'term-dim');
setTimeout(connectMusic, 2500);
}
}
// Boot sequence — same shape as music-box/index.html so the two scenes
// feel like the same machine in different windows.
(async () => {
await sleep(450);
logBeforePins('[audio] waiting for mpd state', 'term-dim', null);
await sleep(280);
connectMusic();
})();
</script>
</body>
</html>

View File

@@ -9,5 +9,18 @@
"services": [], "services": [],
"sources": [], "sources": [],
"version": "" "version": ""
},
{
"display_name": "phandasm_waveform",
"enabled": true,
"encoders": [],
"id": "",
"module_name": "waveform",
"outputs": [],
"services": [],
"sources": [
"phandasm_waveform_source"
],
"version": ""
} }
] ]

View File

@@ -25,8 +25,8 @@ cx=720
cy=580 cy=580
[BasicWindow] [BasicWindow]
geometry=AdnQywADAAAAAAAAAAAAAAAACf8AAAVDAAAKAQAAABkAAA3AAAAEHAAAAAECAAAACgAAAAAAAAAAAAAACf8AAAVD geometry=AdnQywADAAAAAAAAAAAAAAAACf8AAAVDAAAGPwAAABkAAAn+AAAEFgAAAAECAAAACgAAAAAAAAAAAAAACf8AAAVD
DockState=AAAA/wAAAAD9AAAAAQAAAAMAAAoAAAABKPwBAAAABvsAAAAUAHMAYwBlAG4AZQBzAEQAbwBjAGsBAAAAAAAAAdgAAACYAP////sAAAAWAHMAbwB1AHIAYwBlAHMARABvAGMAawEAAAHcAAAB1QAAAJgA////+wAAABIAbQBpAHgAZQByAEQAbwBjAGsBAAADtQAAAroAAAECAP////sAAAAeAHQAcgBhAG4AcwBpAHQAaQBvAG4AcwBEAG8AYwBrAQAABnMAAAHSAAAArQD////7AAAAGABjAG8AbgB0AHIAbwBsAHMARABvAGMAawEAAAhJAAABtwAAAKIA////+wAAABIAcwB0AGEAdABzAEQAbwBjAGsCAAAExAAAAXAAAAK8AAAAyAAACgAAAAPcAAAABAAAAAQAAAAIAAAACPwAAAAA DockState=AAAA/wAAAAD9AAAAAQAAAAMAAAoAAAABA/wBAAAABvsAAAAUAHMAYwBlAG4AZQBzAEQAbwBjAGsBAAAAAAAAAdgAAACYAP////sAAAAWAHMAbwB1AHIAYwBlAHMARABvAGMAawEAAAHcAAAB1QAAAJgA////+wAAABIAbQBpAHgAZQByAEQAbwBjAGsBAAADtQAAAroAAAECAP////sAAAAeAHQAcgBhAG4AcwBpAHQAaQBvAG4AcwBEAG8AYwBrAQAABnMAAAHSAAAArQD////7AAAAGABjAG8AbgB0AHIAbwBsAHMARABvAGMAawEAAAhJAAABtwAAAKIA////+wAAABIAcwB0AGEAdABzAEQAbwBjAGsCAAAExAAAAXAAAAK8AAAAyAAACgAAAAQBAAAABAAAAAQAAAAIAAAACPwAAAAA
PreviewEnabled=true PreviewEnabled=true
AlwaysOnTop=false AlwaysOnTop=false
SceneDuplicationMode=true SceneDuplicationMode=true