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.
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.
landing/*.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_field_order": 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": {}
}
]
},
{
"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,

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
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
`<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
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=<coverHash>
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:

View File

@@ -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 @@
</head>
<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">
<span class="tick tl"></span>
<span class="tick tr"></span>
@@ -264,7 +229,11 @@
<span>LIVE</span>
</div>
<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 class="status-line meta-line">
<span class="icon"></span>
@@ -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;
return {
async rebind(sceneName) {
state.sceneName = sceneName;
state.itemId = null;
if (!sceneName) { setVisible(false); return; }
try {
const r = await obs.call('GetSceneItemId', { sceneName, sourceName });
itemId = r.sceneItemId;
const r = await obs.call('GetSceneItemId',
{ sceneName, sourceName });
state.itemId = r.sceneItemId;
const e = await obs.call('GetSceneItemEnabled',
{ sceneName, sceneItemId: itemId });
{ sceneName, sceneItemId: state.itemId });
setVisible(e.sceneItemEnabled);
const t = await obs.call('GetSceneItemTransform',
{ sceneName, sceneItemId: itemId });
{ sceneName, sceneItemId: state.itemId });
setTransform(t.sceneItemTransform);
} catch (err) {
console.warn(`[desktop] frame sync failed (${sceneName}/${sourceName}):`, err.message);
return;
} 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);
}
obs.addEventListener('event', (ev) => {
const d = ev.detail || {};
if (d.eventData?.sceneName !== sceneName
|| d.eventData?.sceneItemId !== itemId) return;
},
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);

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": [],
"sources": [],
"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
[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