Codex. Cover support, updates for Music Box
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -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
20
AGENTS.md
Normal 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.
|
||||
@@ -179,7 +179,36 @@
|
||||
"deinterlace_mode": 0,
|
||||
"deinterlace_field_order": 0,
|
||||
"monitoring_type": 0,
|
||||
"private_settings": {}
|
||||
"private_settings": {},
|
||||
"filters": [
|
||||
{
|
||||
"prev_ver": 536936450,
|
||||
"name": "Crop/Pad",
|
||||
"uuid": "8ea7885e-70c6-4dfd-b5f9-a7581f61b7c2",
|
||||
"id": "crop_filter",
|
||||
"versioned_id": "crop_filter",
|
||||
"settings": {
|
||||
"top": 360,
|
||||
"right": 700
|
||||
},
|
||||
"mixers": 0,
|
||||
"sync": 0,
|
||||
"flags": 0,
|
||||
"volume": 1.0,
|
||||
"balance": 0.5,
|
||||
"enabled": true,
|
||||
"muted": false,
|
||||
"push-to-mute": false,
|
||||
"push-to-mute-delay": 0,
|
||||
"push-to-talk": false,
|
||||
"push-to-talk-delay": 0,
|
||||
"hotkeys": {},
|
||||
"deinterlace_mode": 0,
|
||||
"deinterlace_field_order": 0,
|
||||
"monitoring_type": 0,
|
||||
"private_settings": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"prev_ver": 536936450,
|
||||
@@ -507,7 +536,7 @@
|
||||
"mixers": 255,
|
||||
"sync": 0,
|
||||
"flags": 0,
|
||||
"volume": 0.0,
|
||||
"volume": 0.38266569375991821,
|
||||
"balance": 0.5,
|
||||
"enabled": true,
|
||||
"muted": false,
|
||||
@@ -643,6 +672,7 @@
|
||||
"settings": {
|
||||
"is_local_file": true,
|
||||
"local_file": "/home/jin/.config/obs-studio/music-box/widget.html",
|
||||
"undo_uuid": "f662d513-6fe6-49fd-99f6-ea7f44e8de18",
|
||||
"width": 549,
|
||||
"height": 880,
|
||||
"fps": 15,
|
||||
@@ -672,6 +702,138 @@
|
||||
"monitoring_type": 0,
|
||||
"private_settings": {}
|
||||
},
|
||||
{
|
||||
"prev_ver": 536936450,
|
||||
"name": "Cover",
|
||||
"uuid": "54d4ac81-e354-431a-a2dc-a00e5555e8d9",
|
||||
"id": "browser_source",
|
||||
"versioned_id": "browser_source",
|
||||
"settings": {
|
||||
"url": "file:///home/jin/.config/obs-studio/music-box/cover.html"
|
||||
},
|
||||
"mixers": 255,
|
||||
"sync": 0,
|
||||
"flags": 0,
|
||||
"volume": 1.0,
|
||||
"balance": 0.5,
|
||||
"enabled": true,
|
||||
"muted": false,
|
||||
"push-to-mute": false,
|
||||
"push-to-mute-delay": 0,
|
||||
"push-to-talk": false,
|
||||
"push-to-talk-delay": 0,
|
||||
"hotkeys": {
|
||||
"libobs.mute": [],
|
||||
"libobs.unmute": [],
|
||||
"libobs.push-to-mute": [],
|
||||
"libobs.push-to-talk": [],
|
||||
"ObsBrowser.Refresh": []
|
||||
},
|
||||
"deinterlace_mode": 0,
|
||||
"deinterlace_field_order": 0,
|
||||
"monitoring_type": 0,
|
||||
"private_settings": {}
|
||||
},
|
||||
{
|
||||
"prev_ver": 536936450,
|
||||
"name": "Cover Simple",
|
||||
"uuid": "7062784f-7b1b-4728-9067-27128c0d5748",
|
||||
"id": "browser_source",
|
||||
"versioned_id": "browser_source",
|
||||
"settings": {
|
||||
"url": "file:///home/jin/.config/obs-studio/music-box/cover.html?bars=0"
|
||||
},
|
||||
"mixers": 255,
|
||||
"sync": 0,
|
||||
"flags": 0,
|
||||
"volume": 1.0,
|
||||
"balance": 0.5,
|
||||
"enabled": true,
|
||||
"muted": false,
|
||||
"push-to-mute": false,
|
||||
"push-to-mute-delay": 0,
|
||||
"push-to-talk": false,
|
||||
"push-to-talk-delay": 0,
|
||||
"hotkeys": {
|
||||
"libobs.mute": [],
|
||||
"libobs.unmute": [],
|
||||
"libobs.push-to-mute": [],
|
||||
"libobs.push-to-talk": [],
|
||||
"ObsBrowser.Refresh": []
|
||||
},
|
||||
"deinterlace_mode": 0,
|
||||
"deinterlace_field_order": 0,
|
||||
"monitoring_type": 0,
|
||||
"private_settings": {}
|
||||
},
|
||||
{
|
||||
"prev_ver": 536936450,
|
||||
"name": "NC Music Box HUD",
|
||||
"uuid": "edbd893d-ce53-4c91-bf6c-4d52fbf394d6",
|
||||
"id": "browser_source",
|
||||
"versioned_id": "browser_source",
|
||||
"settings": {
|
||||
"url": "file:///home/jin/.config/obs-studio/music-box/nc-music-box.html",
|
||||
"width": 2560,
|
||||
"height": 1331,
|
||||
"fps": 9,
|
||||
"fps_custom": true
|
||||
},
|
||||
"mixers": 255,
|
||||
"sync": 0,
|
||||
"flags": 0,
|
||||
"volume": 1.0,
|
||||
"balance": 0.5,
|
||||
"enabled": true,
|
||||
"muted": false,
|
||||
"push-to-mute": false,
|
||||
"push-to-mute-delay": 0,
|
||||
"push-to-talk": false,
|
||||
"push-to-talk-delay": 0,
|
||||
"hotkeys": {
|
||||
"libobs.mute": [],
|
||||
"libobs.unmute": [],
|
||||
"libobs.push-to-mute": [],
|
||||
"libobs.push-to-talk": [],
|
||||
"ObsBrowser.Refresh": []
|
||||
},
|
||||
"deinterlace_mode": 0,
|
||||
"deinterlace_field_order": 0,
|
||||
"monitoring_type": 0,
|
||||
"private_settings": {}
|
||||
},
|
||||
{
|
||||
"prev_ver": 536936450,
|
||||
"name": "Waveform Visualizer",
|
||||
"uuid": "f28cc995-6966-4afa-b831-ebb50d89ebe8",
|
||||
"id": "phandasm_waveform_source",
|
||||
"versioned_id": "phandasm_waveform_source",
|
||||
"settings": {
|
||||
"audio_source": "MPD",
|
||||
"display_mode": "stepped_bars",
|
||||
"color_base": 4278216704,
|
||||
"render_mode": "solid",
|
||||
"pulse_mode": "peak_magnitude",
|
||||
"channel_mode": "mono",
|
||||
"step_gap": 3
|
||||
},
|
||||
"mixers": 0,
|
||||
"sync": 0,
|
||||
"flags": 0,
|
||||
"volume": 1.0,
|
||||
"balance": 0.5,
|
||||
"enabled": true,
|
||||
"muted": false,
|
||||
"push-to-mute": false,
|
||||
"push-to-mute-delay": 0,
|
||||
"push-to-talk": false,
|
||||
"push-to-talk-delay": 0,
|
||||
"hotkeys": {},
|
||||
"deinterlace_mode": 0,
|
||||
"deinterlace_field_order": 0,
|
||||
"monitoring_type": 0,
|
||||
"private_settings": {}
|
||||
},
|
||||
{
|
||||
"prev_ver": 536936450,
|
||||
"name": "Desktop",
|
||||
@@ -703,12 +865,12 @@
|
||||
"id": 13,
|
||||
"group_item_backup": false,
|
||||
"pos": {
|
||||
"x": 10.0,
|
||||
"y": 10.0
|
||||
"x": 566.0,
|
||||
"y": 7.0
|
||||
},
|
||||
"pos_rel": {
|
||||
"x": -1.9011975526809692,
|
||||
"y": -0.98502993583679199
|
||||
"x": -1.0688623189926147,
|
||||
"y": -0.98952096700668335
|
||||
},
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
@@ -868,20 +1030,20 @@
|
||||
"id": 16,
|
||||
"group_item_backup": false,
|
||||
"pos": {
|
||||
"x": 2019.0,
|
||||
"y": 20.0
|
||||
"x": 22.0,
|
||||
"y": 902.0
|
||||
},
|
||||
"pos_rel": {
|
||||
"x": 1.1062874794006348,
|
||||
"y": -0.97005987167358398
|
||||
"x": -1.8832335472106934,
|
||||
"y": 0.35029935836791992
|
||||
},
|
||||
"scale": {
|
||||
"x": 0.27239581942558289,
|
||||
"y": 0.27222222089767456
|
||||
"x": 0.4270491898059845,
|
||||
"y": 0.42727276682853699
|
||||
},
|
||||
"scale_rel": {
|
||||
"x": 0.22020021080970764,
|
||||
"y": 0.22005987167358398
|
||||
"x": 0.34521940350532532,
|
||||
"y": 0.3454001247882843
|
||||
},
|
||||
"bounds": {
|
||||
"x": 0.0,
|
||||
@@ -923,12 +1085,12 @@
|
||||
"id": 17,
|
||||
"group_item_backup": false,
|
||||
"pos": {
|
||||
"x": 2007.0,
|
||||
"y": 353.0
|
||||
"x": 12.0,
|
||||
"y": 6.0
|
||||
},
|
||||
"pos_rel": {
|
||||
"x": 1.0883233547210693,
|
||||
"y": -0.4715569019317627
|
||||
"x": -1.8982036113739014,
|
||||
"y": -0.99101793766021729
|
||||
},
|
||||
"scale": {
|
||||
"x": 0.99089258909225464,
|
||||
@@ -1416,20 +1578,20 @@
|
||||
"id": 16,
|
||||
"group_item_backup": false,
|
||||
"pos": {
|
||||
"x": 1937.0,
|
||||
"y": 98.0
|
||||
"x": 83.0,
|
||||
"y": 88.0
|
||||
},
|
||||
"pos_rel": {
|
||||
"x": 0.98353290557861328,
|
||||
"y": -0.85329341888427734
|
||||
"x": -1.7919161319732666,
|
||||
"y": -0.86826348304748535
|
||||
},
|
||||
"scale": {
|
||||
"x": 0.28593748807907104,
|
||||
"y": 0.28611111640930176
|
||||
"x": 0.24262295663356781,
|
||||
"y": 0.24318182468414307
|
||||
},
|
||||
"scale_rel": {
|
||||
"x": 0.23114706575870514,
|
||||
"y": 0.23128743469715118
|
||||
"x": 0.19613233208656311,
|
||||
"y": 0.19658410549163818
|
||||
},
|
||||
"bounds": {
|
||||
"x": 0.0,
|
||||
@@ -1443,10 +1605,22 @@
|
||||
"blend_method": "default",
|
||||
"blend_type": "normal",
|
||||
"show_transition": {
|
||||
"duration": 0
|
||||
"id": "wipe_transition",
|
||||
"versioned_id": "wipe_transition",
|
||||
"name": "Camera Show Transition",
|
||||
"transition": {
|
||||
"luma_image": "checkerboard-small.png"
|
||||
},
|
||||
"duration": 1000
|
||||
},
|
||||
"hide_transition": {
|
||||
"duration": 0
|
||||
"id": "wipe_transition",
|
||||
"versioned_id": "wipe_transition",
|
||||
"name": "Camera Hide Transition",
|
||||
"transition": {
|
||||
"luma_image": "checkerboard-small.png"
|
||||
},
|
||||
"duration": 1000
|
||||
},
|
||||
"private_settings": {}
|
||||
},
|
||||
@@ -2227,7 +2401,7 @@
|
||||
"id": "scene",
|
||||
"versioned_id": "scene",
|
||||
"settings": {
|
||||
"id_counter": 2,
|
||||
"id_counter": 6,
|
||||
"custom_size": false,
|
||||
"items": [
|
||||
{
|
||||
@@ -2288,7 +2462,7 @@
|
||||
{
|
||||
"name": "Music Box HUD",
|
||||
"source_uuid": "1b9f5436-864d-4f24-9f92-f45702a23953",
|
||||
"visible": true,
|
||||
"visible": false,
|
||||
"locked": false,
|
||||
"rot": 0.0,
|
||||
"scale_ref": {
|
||||
@@ -2339,6 +2513,183 @@
|
||||
"duration": 300
|
||||
},
|
||||
"private_settings": {}
|
||||
},
|
||||
{
|
||||
"name": "Cover Simple",
|
||||
"source_uuid": "7062784f-7b1b-4728-9067-27128c0d5748",
|
||||
"visible": false,
|
||||
"locked": false,
|
||||
"rot": 0.0,
|
||||
"scale_ref": {
|
||||
"x": 2560.0,
|
||||
"y": 1336.0
|
||||
},
|
||||
"align": 5,
|
||||
"bounds_type": 0,
|
||||
"bounds_align": 0,
|
||||
"bounds_crop": false,
|
||||
"crop_left": 0,
|
||||
"crop_top": 0,
|
||||
"crop_right": 0,
|
||||
"crop_bottom": 0,
|
||||
"id": 4,
|
||||
"group_item_backup": false,
|
||||
"pos": {
|
||||
"x": 1711.0,
|
||||
"y": 176.0
|
||||
},
|
||||
"pos_rel": {
|
||||
"x": 0.64520961046218872,
|
||||
"y": -0.7365269660949707
|
||||
},
|
||||
"scale": {
|
||||
"x": 0.36875000596046448,
|
||||
"y": 0.36833333969116211
|
||||
},
|
||||
"scale_rel": {
|
||||
"x": 0.36875000596046448,
|
||||
"y": 0.36833333969116211
|
||||
},
|
||||
"bounds": {
|
||||
"x": 0.0,
|
||||
"y": 0.0
|
||||
},
|
||||
"bounds_rel": {
|
||||
"x": 0.0,
|
||||
"y": 0.0
|
||||
},
|
||||
"scale_filter": "disable",
|
||||
"blend_method": "default",
|
||||
"blend_type": "normal",
|
||||
"show_transition": {
|
||||
"duration": 300
|
||||
},
|
||||
"hide_transition": {
|
||||
"duration": 300
|
||||
},
|
||||
"private_settings": {}
|
||||
},
|
||||
{
|
||||
"name": "NC Music Box HUD",
|
||||
"source_uuid": "edbd893d-ce53-4c91-bf6c-4d52fbf394d6",
|
||||
"visible": true,
|
||||
"locked": false,
|
||||
"rot": 0.0,
|
||||
"scale_ref": {
|
||||
"x": 2560.0,
|
||||
"y": 1336.0
|
||||
},
|
||||
"align": 5,
|
||||
"bounds_type": 0,
|
||||
"bounds_align": 0,
|
||||
"bounds_crop": false,
|
||||
"crop_left": 0,
|
||||
"crop_top": 0,
|
||||
"crop_right": 0,
|
||||
"crop_bottom": 0,
|
||||
"id": 5,
|
||||
"group_item_backup": false,
|
||||
"pos": {
|
||||
"x": 0.0,
|
||||
"y": 0.0
|
||||
},
|
||||
"pos_rel": {
|
||||
"x": -1.9161676168441772,
|
||||
"y": -1.0
|
||||
},
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"scale_rel": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"bounds": {
|
||||
"x": 0.0,
|
||||
"y": 0.0
|
||||
},
|
||||
"bounds_rel": {
|
||||
"x": 0.0,
|
||||
"y": 0.0
|
||||
},
|
||||
"scale_filter": "disable",
|
||||
"blend_method": "default",
|
||||
"blend_type": "normal",
|
||||
"show_transition": {
|
||||
"id": "wipe_transition",
|
||||
"versioned_id": "wipe_transition",
|
||||
"name": "NC Music Box HUD Show Transition",
|
||||
"transition": {
|
||||
"luma_image": "checkerboard-small.png"
|
||||
},
|
||||
"duration": 300
|
||||
},
|
||||
"hide_transition": {
|
||||
"id": "wipe_transition",
|
||||
"versioned_id": "wipe_transition",
|
||||
"name": "NC Music Box HUD Hide Transition",
|
||||
"transition": {
|
||||
"luma_image": "checkerboard-small.png"
|
||||
},
|
||||
"duration": 300
|
||||
},
|
||||
"private_settings": {}
|
||||
},
|
||||
{
|
||||
"name": "Waveform Visualizer",
|
||||
"source_uuid": "f28cc995-6966-4afa-b831-ebb50d89ebe8",
|
||||
"visible": true,
|
||||
"locked": false,
|
||||
"rot": 0.0,
|
||||
"scale_ref": {
|
||||
"x": 2560.0,
|
||||
"y": 1336.0
|
||||
},
|
||||
"align": 5,
|
||||
"bounds_type": 0,
|
||||
"bounds_align": 0,
|
||||
"bounds_crop": false,
|
||||
"crop_left": 0,
|
||||
"crop_top": 0,
|
||||
"crop_right": 0,
|
||||
"crop_bottom": 0,
|
||||
"id": 6,
|
||||
"group_item_backup": false,
|
||||
"pos": {
|
||||
"x": 2166.0,
|
||||
"y": 1078.0
|
||||
},
|
||||
"pos_rel": {
|
||||
"x": 1.3263473510742188,
|
||||
"y": 0.61377251148223877
|
||||
},
|
||||
"scale": {
|
||||
"x": 0.38249999284744263,
|
||||
"y": 0.38222223520278931
|
||||
},
|
||||
"scale_rel": {
|
||||
"x": 0.38249999284744263,
|
||||
"y": 0.38222223520278931
|
||||
},
|
||||
"bounds": {
|
||||
"x": 0.0,
|
||||
"y": 0.0
|
||||
},
|
||||
"bounds_rel": {
|
||||
"x": 0.0,
|
||||
"y": 0.0
|
||||
},
|
||||
"scale_filter": "disable",
|
||||
"blend_method": "default",
|
||||
"blend_type": "normal",
|
||||
"show_transition": {
|
||||
"duration": 300
|
||||
},
|
||||
"hide_transition": {
|
||||
"duration": 300
|
||||
},
|
||||
"private_settings": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -2363,7 +2714,387 @@
|
||||
"libobs.show_scene_item.1": [],
|
||||
"libobs.hide_scene_item.1": [],
|
||||
"libobs.show_scene_item.2": [],
|
||||
"libobs.hide_scene_item.2": []
|
||||
"libobs.hide_scene_item.2": [],
|
||||
"libobs.show_scene_item.4": [],
|
||||
"libobs.hide_scene_item.4": [],
|
||||
"libobs.show_scene_item.5": [],
|
||||
"libobs.hide_scene_item.5": [],
|
||||
"libobs.show_scene_item.6": [],
|
||||
"libobs.hide_scene_item.6": []
|
||||
},
|
||||
"deinterlace_mode": 0,
|
||||
"deinterlace_field_order": 0,
|
||||
"monitoring_type": 0,
|
||||
"canvas_uuid": "6c69626f-6273-4c00-9d88-c5136d61696e",
|
||||
"private_settings": {}
|
||||
},
|
||||
{
|
||||
"prev_ver": 536936450,
|
||||
"name": "Desktop (No Cam)",
|
||||
"uuid": "e68f67a3-f90b-4e81-a89b-308a0ea83e57",
|
||||
"id": "scene",
|
||||
"versioned_id": "scene",
|
||||
"settings": {
|
||||
"id_counter": 6,
|
||||
"custom_size": false,
|
||||
"items": [
|
||||
{
|
||||
"name": "Screen Capture (PipeWire)",
|
||||
"source_uuid": "8f6ef725-1eec-4fdf-930a-2aab93d84c06",
|
||||
"visible": true,
|
||||
"locked": false,
|
||||
"rot": 0.0,
|
||||
"scale_ref": {
|
||||
"x": 2560.0,
|
||||
"y": 1336.0
|
||||
},
|
||||
"align": 5,
|
||||
"bounds_type": 1,
|
||||
"bounds_align": 0,
|
||||
"bounds_crop": false,
|
||||
"crop_left": 0,
|
||||
"crop_top": 0,
|
||||
"crop_right": 0,
|
||||
"crop_bottom": 0,
|
||||
"id": 1,
|
||||
"group_item_backup": false,
|
||||
"pos": {
|
||||
"x": 10.0,
|
||||
"y": 10.0
|
||||
},
|
||||
"pos_rel": {
|
||||
"x": -1.9011975526809692,
|
||||
"y": -0.98502993583679199
|
||||
},
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"scale_rel": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"bounds": {
|
||||
"x": 2010.0,
|
||||
"y": 1213.0
|
||||
},
|
||||
"bounds_rel": {
|
||||
"x": 3.0089819431304932,
|
||||
"y": 1.8158682584762573
|
||||
},
|
||||
"scale_filter": "disable",
|
||||
"blend_method": "default",
|
||||
"blend_type": "normal",
|
||||
"show_transition": {
|
||||
"duration": 0
|
||||
},
|
||||
"hide_transition": {
|
||||
"duration": 0
|
||||
},
|
||||
"private_settings": {}
|
||||
},
|
||||
{
|
||||
"name": "MPD",
|
||||
"source_uuid": "25440930-0606-46d9-ba2c-be786ff4a3ae",
|
||||
"visible": true,
|
||||
"locked": false,
|
||||
"rot": 0.0,
|
||||
"scale_ref": {
|
||||
"x": 2560.0,
|
||||
"y": 1336.0
|
||||
},
|
||||
"align": 5,
|
||||
"bounds_type": 0,
|
||||
"bounds_align": 0,
|
||||
"bounds_crop": false,
|
||||
"crop_left": 0,
|
||||
"crop_top": 0,
|
||||
"crop_right": 0,
|
||||
"crop_bottom": 0,
|
||||
"id": 2,
|
||||
"group_item_backup": false,
|
||||
"pos": {
|
||||
"x": 0.0,
|
||||
"y": 0.0
|
||||
},
|
||||
"pos_rel": {
|
||||
"x": -1.9161676168441772,
|
||||
"y": -1.0
|
||||
},
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"scale_rel": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"bounds": {
|
||||
"x": 0.0,
|
||||
"y": 0.0
|
||||
},
|
||||
"bounds_rel": {
|
||||
"x": 0.0,
|
||||
"y": 0.0
|
||||
},
|
||||
"scale_filter": "disable",
|
||||
"blend_method": "default",
|
||||
"blend_type": "normal",
|
||||
"show_transition": {
|
||||
"duration": 300
|
||||
},
|
||||
"hide_transition": {
|
||||
"duration": 300
|
||||
},
|
||||
"private_settings": {}
|
||||
},
|
||||
{
|
||||
"name": "Desktop HUD",
|
||||
"source_uuid": "8acfb832-cfb4-4310-8ebb-b58fad2b3509",
|
||||
"visible": true,
|
||||
"locked": false,
|
||||
"rot": 0.0,
|
||||
"scale_ref": {
|
||||
"x": 2560.0,
|
||||
"y": 1336.0
|
||||
},
|
||||
"align": 5,
|
||||
"bounds_type": 1,
|
||||
"bounds_align": 0,
|
||||
"bounds_crop": false,
|
||||
"crop_left": 0,
|
||||
"crop_top": 0,
|
||||
"crop_right": 0,
|
||||
"crop_bottom": 0,
|
||||
"id": 3,
|
||||
"group_item_backup": false,
|
||||
"pos": {
|
||||
"x": -1.0,
|
||||
"y": 0.0
|
||||
},
|
||||
"pos_rel": {
|
||||
"x": -1.917664647102356,
|
||||
"y": -1.0
|
||||
},
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"scale_rel": {
|
||||
"x": 1.0,
|
||||
"y": 1.0
|
||||
},
|
||||
"bounds": {
|
||||
"x": 2560.0,
|
||||
"y": 1336.0
|
||||
},
|
||||
"bounds_rel": {
|
||||
"x": 3.8323352336883545,
|
||||
"y": 2.0
|
||||
},
|
||||
"scale_filter": "disable",
|
||||
"blend_method": "default",
|
||||
"blend_type": "normal",
|
||||
"show_transition": {
|
||||
"duration": 300
|
||||
},
|
||||
"hide_transition": {
|
||||
"duration": 300
|
||||
},
|
||||
"private_settings": {}
|
||||
},
|
||||
{
|
||||
"name": "Camera",
|
||||
"source_uuid": "a8b362a1-91a6-491b-8751-26e2dad55ccd",
|
||||
"visible": false,
|
||||
"locked": false,
|
||||
"rot": 0.0,
|
||||
"scale_ref": {
|
||||
"x": 2560.0,
|
||||
"y": 1336.0
|
||||
},
|
||||
"align": 5,
|
||||
"bounds_type": 0,
|
||||
"bounds_align": 0,
|
||||
"bounds_crop": false,
|
||||
"crop_left": 0,
|
||||
"crop_top": 0,
|
||||
"crop_right": 0,
|
||||
"crop_bottom": 0,
|
||||
"id": 4,
|
||||
"group_item_backup": false,
|
||||
"pos": {
|
||||
"x": 2019.0,
|
||||
"y": 22.0
|
||||
},
|
||||
"pos_rel": {
|
||||
"x": 1.1062874794006348,
|
||||
"y": -0.96706587076187134
|
||||
},
|
||||
"scale": {
|
||||
"x": 0.34521940350532532,
|
||||
"y": 0.3454001247882843
|
||||
},
|
||||
"scale_rel": {
|
||||
"x": 0.34521940350532532,
|
||||
"y": 0.3454001247882843
|
||||
},
|
||||
"bounds": {
|
||||
"x": 0.0,
|
||||
"y": 0.0
|
||||
},
|
||||
"bounds_rel": {
|
||||
"x": 0.0,
|
||||
"y": 0.0
|
||||
},
|
||||
"scale_filter": "disable",
|
||||
"blend_method": "default",
|
||||
"blend_type": "normal",
|
||||
"show_transition": {
|
||||
"duration": 0
|
||||
},
|
||||
"hide_transition": {
|
||||
"duration": 0
|
||||
},
|
||||
"private_settings": {}
|
||||
},
|
||||
{
|
||||
"name": "Music Box Widget",
|
||||
"source_uuid": "f662d513-6fe6-49fd-99f6-ea7f44e8de18",
|
||||
"visible": true,
|
||||
"locked": false,
|
||||
"rot": 0.0,
|
||||
"scale_ref": {
|
||||
"x": 2560.0,
|
||||
"y": 1336.0
|
||||
},
|
||||
"align": 5,
|
||||
"bounds_type": 0,
|
||||
"bounds_align": 0,
|
||||
"bounds_crop": false,
|
||||
"crop_left": 0,
|
||||
"crop_top": 0,
|
||||
"crop_right": 0,
|
||||
"crop_bottom": 0,
|
||||
"id": 5,
|
||||
"group_item_backup": false,
|
||||
"pos": {
|
||||
"x": 2043.0,
|
||||
"y": 402.0
|
||||
},
|
||||
"pos_rel": {
|
||||
"x": 1.1422156095504761,
|
||||
"y": -0.39820361137390137
|
||||
},
|
||||
"scale": {
|
||||
"x": 0.90892529487609863,
|
||||
"y": 0.90909093618392944
|
||||
},
|
||||
"scale_rel": {
|
||||
"x": 0.90892529487609863,
|
||||
"y": 0.90909093618392944
|
||||
},
|
||||
"bounds": {
|
||||
"x": 0.0,
|
||||
"y": 0.0
|
||||
},
|
||||
"bounds_rel": {
|
||||
"x": 0.0,
|
||||
"y": 0.0
|
||||
},
|
||||
"scale_filter": "disable",
|
||||
"blend_method": "default",
|
||||
"blend_type": "normal",
|
||||
"show_transition": {
|
||||
"duration": 300
|
||||
},
|
||||
"hide_transition": {
|
||||
"duration": 300
|
||||
},
|
||||
"private_settings": {}
|
||||
},
|
||||
{
|
||||
"name": "Cover",
|
||||
"source_uuid": "54d4ac81-e354-431a-a2dc-a00e5555e8d9",
|
||||
"visible": true,
|
||||
"locked": false,
|
||||
"rot": 0.0,
|
||||
"scale_ref": {
|
||||
"x": 2560.0,
|
||||
"y": 1336.0
|
||||
},
|
||||
"align": 5,
|
||||
"bounds_type": 0,
|
||||
"bounds_align": 0,
|
||||
"bounds_crop": false,
|
||||
"crop_left": 0,
|
||||
"crop_top": 0,
|
||||
"crop_right": 0,
|
||||
"crop_bottom": 0,
|
||||
"id": 6,
|
||||
"group_item_backup": false,
|
||||
"pos": {
|
||||
"x": 2044.0,
|
||||
"y": 13.0
|
||||
},
|
||||
"pos_rel": {
|
||||
"x": 1.1437125205993652,
|
||||
"y": -0.98053890466690063
|
||||
},
|
||||
"scale": {
|
||||
"x": 0.62374997138977051,
|
||||
"y": 0.62333333492279053
|
||||
},
|
||||
"scale_rel": {
|
||||
"x": 0.62374997138977051,
|
||||
"y": 0.62333333492279053
|
||||
},
|
||||
"bounds": {
|
||||
"x": 0.0,
|
||||
"y": 0.0
|
||||
},
|
||||
"bounds_rel": {
|
||||
"x": 0.0,
|
||||
"y": 0.0
|
||||
},
|
||||
"scale_filter": "disable",
|
||||
"blend_method": "default",
|
||||
"blend_type": "normal",
|
||||
"show_transition": {
|
||||
"duration": 300
|
||||
},
|
||||
"hide_transition": {
|
||||
"duration": 300
|
||||
},
|
||||
"private_settings": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"mixers": 0,
|
||||
"sync": 0,
|
||||
"flags": 0,
|
||||
"volume": 1.0,
|
||||
"balance": 0.5,
|
||||
"enabled": true,
|
||||
"muted": false,
|
||||
"push-to-mute": false,
|
||||
"push-to-mute-delay": 0,
|
||||
"push-to-talk": false,
|
||||
"push-to-talk-delay": 0,
|
||||
"hotkeys": {
|
||||
"OBSBasic.SelectScene": [],
|
||||
"libobs.show_scene_item.1": [],
|
||||
"libobs.hide_scene_item.1": [],
|
||||
"libobs.show_scene_item.2": [],
|
||||
"libobs.hide_scene_item.2": [],
|
||||
"libobs.show_scene_item.3": [],
|
||||
"libobs.hide_scene_item.3": [],
|
||||
"libobs.show_scene_item.4": [],
|
||||
"libobs.hide_scene_item.4": [],
|
||||
"libobs.show_scene_item.5": [],
|
||||
"libobs.hide_scene_item.5": [],
|
||||
"libobs.show_scene_item.6": [],
|
||||
"libobs.hide_scene_item.6": []
|
||||
},
|
||||
"deinterlace_mode": 0,
|
||||
"deinterlace_field_order": 0,
|
||||
@@ -2595,6 +3326,9 @@
|
||||
{
|
||||
"name": "Desktop"
|
||||
},
|
||||
{
|
||||
"name": "Desktop (No Cam)"
|
||||
},
|
||||
{
|
||||
"name": "Music Box"
|
||||
},
|
||||
@@ -2602,8 +3336,8 @@
|
||||
"name": "Good Bye"
|
||||
}
|
||||
],
|
||||
"current_scene": "Landing",
|
||||
"current_program_scene": "Landing",
|
||||
"current_scene": "Music Box",
|
||||
"current_program_scene": "Music Box",
|
||||
"canvases": [],
|
||||
"current_transition": "Luma Wipe",
|
||||
"transition_duration": 1000,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
try {
|
||||
const r = await obs.call('GetSceneItemId', { sceneName, sourceName });
|
||||
itemId = r.sceneItemId;
|
||||
const e = await obs.call('GetSceneItemEnabled',
|
||||
{ sceneName, sceneItemId: itemId });
|
||||
setVisible(e.sceneItemEnabled);
|
||||
const t = await obs.call('GetSceneItemTransform',
|
||||
{ sceneName, sceneItemId: itemId });
|
||||
setTransform(t.sceneItemTransform);
|
||||
} catch (err) {
|
||||
console.warn(`[desktop] frame sync failed (${sceneName}/${sourceName}):`, err.message);
|
||||
return;
|
||||
}
|
||||
obs.addEventListener('event', (ev) => {
|
||||
const d = ev.detail || {};
|
||||
if (d.eventData?.sceneName !== sceneName
|
||||
|| d.eventData?.sceneItemId !== itemId) return;
|
||||
if (d.eventType === 'SceneItemEnableStateChanged') {
|
||||
setVisible(d.eventData.sceneItemEnabled);
|
||||
} else if (d.eventType === 'SceneItemTransformChanged') {
|
||||
setTransform(d.eventData.sceneItemTransform);
|
||||
}
|
||||
});
|
||||
return {
|
||||
async rebind(sceneName) {
|
||||
state.sceneName = sceneName;
|
||||
state.itemId = null;
|
||||
if (!sceneName) { setVisible(false); return; }
|
||||
try {
|
||||
const r = await obs.call('GetSceneItemId',
|
||||
{ sceneName, sourceName });
|
||||
state.itemId = r.sceneItemId;
|
||||
const e = await obs.call('GetSceneItemEnabled',
|
||||
{ sceneName, sceneItemId: state.itemId });
|
||||
setVisible(e.sceneItemEnabled);
|
||||
const t = await obs.call('GetSceneItemTransform',
|
||||
{ sceneName, sceneItemId: state.itemId });
|
||||
setTransform(t.sceneItemTransform);
|
||||
} catch {
|
||||
// Source isn't in this scene (or scene doesn't exist) — hide
|
||||
// the frame outright. This is what makes "Desktop (No Cam)"
|
||||
// work cleanly even if Camera were fully removed (rather than
|
||||
// just disabled).
|
||||
setVisible(false);
|
||||
}
|
||||
},
|
||||
handleEvent(d) {
|
||||
if (d.eventData?.sceneName !== state.sceneName
|
||||
|| d.eventData?.sceneItemId !== state.itemId) return;
|
||||
if (d.eventType === 'SceneItemEnableStateChanged') {
|
||||
setVisible(d.eventData.sceneItemEnabled);
|
||||
} else if (d.eventType === 'SceneItemTransformChanged') {
|
||||
setTransform(d.eventData.sceneItemTransform);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function connectOBS() {
|
||||
@@ -381,14 +367,34 @@
|
||||
try {
|
||||
const obs = new OBSWSMini(window.__OBSWS.url, window.__OBSWS.password);
|
||||
await obs.connect();
|
||||
obs.addEventListener('close', () => {
|
||||
setTimeout(connectOBS, 2500);
|
||||
obs.addEventListener('close', () => setTimeout(connectOBS, 2500));
|
||||
|
||||
const camFrame = makeFrame(obs, CAM_SOURCE, camEl, 'cam');
|
||||
const scrFrame = makeFrame(obs, SCR_SOURCE, scrEl, 'scr');
|
||||
|
||||
// Resolve the scene to track. With ?scene= explicit, lock to it;
|
||||
// otherwise follow whatever's currently program. Modern obs-websocket
|
||||
// returns currentProgramSceneName; older builds returned sceneName —
|
||||
// accept both.
|
||||
let scene = SCENE_LOCK;
|
||||
if (!scene) {
|
||||
try {
|
||||
const r = await obs.call('GetCurrentProgramScene');
|
||||
scene = r.currentProgramSceneName || r.sceneName || null;
|
||||
} catch { /* leave null; rebind on first scene change */ }
|
||||
}
|
||||
await Promise.all([camFrame.rebind(scene), scrFrame.rebind(scene)]);
|
||||
|
||||
obs.addEventListener('event', async (ev) => {
|
||||
const d = ev.detail || {};
|
||||
if (!SCENE_LOCK && d.eventType === 'CurrentProgramSceneChanged') {
|
||||
const next = d.eventData?.sceneName;
|
||||
await Promise.all([camFrame.rebind(next), scrFrame.rebind(next)]);
|
||||
return;
|
||||
}
|
||||
camFrame.handleEvent(d);
|
||||
scrFrame.handleEvent(d);
|
||||
});
|
||||
// Run both syncs in parallel — independent items, independent failures.
|
||||
await Promise.all([
|
||||
syncSourceFrame(obs, { sceneName: SCENE, sourceName: CAM_SOURCE, frameEl: camEl, varPrefix: 'cam' }),
|
||||
syncSourceFrame(obs, { sceneName: SCENE, sourceName: SCR_SOURCE, frameEl: scrEl, varPrefix: 'scr' }),
|
||||
]);
|
||||
} catch (e) {
|
||||
// OBS WS optional — silently retry; defaults stand meanwhile.
|
||||
setTimeout(connectOBS, 2500);
|
||||
|
||||
295
music-box/cover.html
Normal file
295
music-box/cover.html
Normal 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
804
music-box/nc-music-box.html
Normal 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>
|
||||
@@ -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": ""
|
||||
}
|
||||
]
|
||||
|
||||
4
user.ini
4
user.ini
@@ -25,8 +25,8 @@ cx=720
|
||||
cy=580
|
||||
|
||||
[BasicWindow]
|
||||
geometry=AdnQywADAAAAAAAAAAAAAAAACf8AAAVDAAAKAQAAABkAAA3AAAAEHAAAAAECAAAACgAAAAAAAAAAAAAACf8AAAVD
|
||||
DockState=AAAA/wAAAAD9AAAAAQAAAAMAAAoAAAABKPwBAAAABvsAAAAUAHMAYwBlAG4AZQBzAEQAbwBjAGsBAAAAAAAAAdgAAACYAP////sAAAAWAHMAbwB1AHIAYwBlAHMARABvAGMAawEAAAHcAAAB1QAAAJgA////+wAAABIAbQBpAHgAZQByAEQAbwBjAGsBAAADtQAAAroAAAECAP////sAAAAeAHQAcgBhAG4AcwBpAHQAaQBvAG4AcwBEAG8AYwBrAQAABnMAAAHSAAAArQD////7AAAAGABjAG8AbgB0AHIAbwBsAHMARABvAGMAawEAAAhJAAABtwAAAKIA////+wAAABIAcwB0AGEAdABzAEQAbwBjAGsCAAAExAAAAXAAAAK8AAAAyAAACgAAAAPcAAAABAAAAAQAAAAIAAAACPwAAAAA
|
||||
geometry=AdnQywADAAAAAAAAAAAAAAAACf8AAAVDAAAGPwAAABkAAAn+AAAEFgAAAAECAAAACgAAAAAAAAAAAAAACf8AAAVD
|
||||
DockState=AAAA/wAAAAD9AAAAAQAAAAMAAAoAAAABA/wBAAAABvsAAAAUAHMAYwBlAG4AZQBzAEQAbwBjAGsBAAAAAAAAAdgAAACYAP////sAAAAWAHMAbwB1AHIAYwBlAHMARABvAGMAawEAAAHcAAAB1QAAAJgA////+wAAABIAbQBpAHgAZQByAEQAbwBjAGsBAAADtQAAAroAAAECAP////sAAAAeAHQAcgBhAG4AcwBpAHQAaQBvAG4AcwBEAG8AYwBrAQAABnMAAAHSAAAArQD////7AAAAGABjAG8AbgB0AHIAbwBsAHMARABvAGMAawEAAAhJAAABtwAAAKIA////+wAAABIAcwB0AGEAdABzAEQAbwBjAGsCAAAExAAAAXAAAAK8AAAAyAAACgAAAAQBAAAABAAAAAQAAAAIAAAACPwAAAAA
|
||||
PreviewEnabled=true
|
||||
AlwaysOnTop=false
|
||||
SceneDuplicationMode=true
|
||||
|
||||
Reference in New Issue
Block a user