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