Build & Push Docker Image / Build & push image (push) Successful in 8m57s
- OpenHome renderer (Product, Playlist, Time, Info, Volume): the app hands the whole queue to the bridge, which plays it on its own - Soft fades on pause, stop and user track changes (FADE_MS) - Gapless: cover per track, ffmpeg conversion of mixed formats, resume at the paused position after BluOS reconnects - Monitoring: /api/health, /api/prtg, /metrics - English web UI (UI_LANG=en|de) - Multi-arch image build via Gitea Actions, image includes ffmpeg Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
474 lines
22 KiB
Python
474 lines
22 KiB
Python
"""OpenHome renderer services for bluos-upnp-bridge.
|
|
|
|
With OpenHome the control point (e.g. Symfonium in OpenHome mode) hands the *whole* play queue to
|
|
the bridge (Playlist service). The bridge then plays it on its own - the phone may sleep or leave -
|
|
and keeps the BluOS player fed with the current and the following track (gapless mode included).
|
|
|
|
Services: Product, Playlist, Time, Info, Volume (av-openhome-org, version 1).
|
|
The playback itself is done by the Renderer; this module only manages the playlist and maps it.
|
|
"""
|
|
import base64
|
|
import html
|
|
import logging
|
|
import xml.etree.ElementTree as ET
|
|
|
|
log = logging.getLogger("openhome")
|
|
|
|
SERVICES = {
|
|
"Product": "urn:av-openhome-org:service:Product:1",
|
|
"Playlist": "urn:av-openhome-org:service:Playlist:1",
|
|
"Time": "urn:av-openhome-org:service:Time:1",
|
|
"Info": "urn:av-openhome-org:service:Info:1",
|
|
"Volume": "urn:av-openhome-org:service:Volume:1",
|
|
}
|
|
SERVICE_ID_PREFIX = "urn:av-openhome-org:serviceId:"
|
|
TRACKS_MAX = 1000
|
|
|
|
|
|
def _a(name, direction, var):
|
|
return name, direction, var
|
|
|
|
|
|
def _vars(evented, args):
|
|
"""State variable table: evented vars + A_ARG helper vars (not evented)."""
|
|
table = {name: (dtype, "yes", allowed) for name, dtype, allowed in evented}
|
|
table.update({name: (dtype, "no", None) for name, dtype in args})
|
|
return table
|
|
|
|
|
|
ARG_VARS = [("A_ARG_String", "string"), ("A_ARG_Bool", "boolean"), ("A_ARG_Ui4", "ui4"), ("A_ARG_I4", "i4"),
|
|
("A_ARG_Base64", "bin.base64")]
|
|
S, B, U, I = "A_ARG_String", "A_ARG_Bool", "A_ARG_Ui4", "A_ARG_I4"
|
|
|
|
SPECS = {
|
|
"Product": ({
|
|
"Manufacturer": [_a("Name", "out", S), _a("Info", "out", S), _a("Url", "out", S), _a("ImageUri", "out", S)],
|
|
"Model": [_a("Name", "out", S), _a("Info", "out", S), _a("Url", "out", S), _a("ImageUri", "out", S)],
|
|
"Product": [_a("Room", "out", S), _a("Name", "out", S), _a("Info", "out", S), _a("Url", "out", S),
|
|
_a("ImageUri", "out", S)],
|
|
"Standby": [_a("Value", "out", B)],
|
|
"SetStandby": [_a("Value", "in", B)],
|
|
"SourceCount": [_a("Value", "out", U)],
|
|
"SourceXml": [_a("Value", "out", S)],
|
|
"SourceIndex": [_a("Value", "out", U)],
|
|
"SetSourceIndex": [_a("Value", "in", U)],
|
|
"SetSourceIndexByName": [_a("Value", "in", S)],
|
|
"Source": [_a("Index", "in", U), _a("SystemName", "out", S), _a("Type", "out", S), _a("Name", "out", S),
|
|
_a("Visible", "out", B)],
|
|
"Attributes": [_a("Value", "out", S)],
|
|
"SourceXmlChangeCount": [_a("Value", "out", U)],
|
|
}, _vars([(n, "string", None) for n in (
|
|
"ManufacturerName", "ManufacturerInfo", "ManufacturerUrl", "ManufacturerImageUri", "ModelName", "ModelInfo",
|
|
"ModelUrl", "ModelImageUri", "ProductRoom", "ProductName", "ProductInfo", "ProductUrl", "ProductImageUri",
|
|
"SourceXml", "Attributes")] + [("Standby", "boolean", None), ("SourceCount", "ui4", None),
|
|
("SourceIndex", "ui4", None)], ARG_VARS)),
|
|
"Playlist": ({
|
|
"Play": [], "Pause": [], "Stop": [], "Next": [], "Previous": [],
|
|
"SetRepeat": [_a("Value", "in", B)], "Repeat": [_a("Value", "out", B)],
|
|
"SetShuffle": [_a("Value", "in", B)], "Shuffle": [_a("Value", "out", B)],
|
|
"SeekSecondAbsolute": [_a("Value", "in", U)], "SeekSecondRelative": [_a("Value", "in", I)],
|
|
"SeekId": [_a("Value", "in", U)], "SeekIndex": [_a("Value", "in", U)],
|
|
"TransportState": [_a("Value", "out", S)], "Id": [_a("Value", "out", U)],
|
|
"Read": [_a("Id", "in", U), _a("Uri", "out", S), _a("Metadata", "out", S)],
|
|
"ReadList": [_a("IdList", "in", S), _a("TrackList", "out", S)],
|
|
"Insert": [_a("AfterId", "in", U), _a("Uri", "in", S), _a("Metadata", "in", S), _a("NewId", "out", U)],
|
|
"DeleteId": [_a("Value", "in", U)], "DeleteAll": [],
|
|
"TracksMax": [_a("Value", "out", U)],
|
|
"IdArray": [_a("Token", "out", U), _a("Array", "out", "A_ARG_Base64")],
|
|
"IdArrayChanged": [_a("Token", "in", U), _a("Value", "out", B)],
|
|
"ProtocolInfo": [_a("Value", "out", S)],
|
|
}, _vars([("TransportState", "string", ["Playing", "Paused", "Stopped", "Buffering"]),
|
|
("Repeat", "boolean", None), ("Shuffle", "boolean", None), ("Id", "ui4", None),
|
|
("IdArray", "bin.base64", None), ("TracksMax", "ui4", None), ("ProtocolInfo", "string", None)],
|
|
ARG_VARS)),
|
|
"Time": ({
|
|
"Time": [_a("TrackCount", "out", U), _a("Duration", "out", U), _a("Seconds", "out", U)],
|
|
}, _vars([("TrackCount", "ui4", None), ("Duration", "ui4", None), ("Seconds", "ui4", None)], ARG_VARS)),
|
|
"Info": ({
|
|
"Counters": [_a("TrackCount", "out", U), _a("DetailsCount", "out", U), _a("MetatextCount", "out", U)],
|
|
"Track": [_a("Uri", "out", S), _a("Metadata", "out", S)],
|
|
"Details": [_a("Duration", "out", U), _a("BitRate", "out", U), _a("BitDepth", "out", U),
|
|
_a("SampleRate", "out", U), _a("Lossless", "out", B), _a("CodecName", "out", S)],
|
|
"Metatext": [_a("Value", "out", S)],
|
|
}, _vars([("TrackCount", "ui4", None), ("DetailsCount", "ui4", None), ("MetatextCount", "ui4", None),
|
|
("Uri", "string", None), ("Metadata", "string", None), ("Duration", "ui4", None),
|
|
("BitRate", "ui4", None), ("BitDepth", "ui4", None), ("SampleRate", "ui4", None),
|
|
("Lossless", "boolean", None), ("CodecName", "string", None), ("Metatext", "string", None)],
|
|
ARG_VARS)),
|
|
"Volume": ({
|
|
"Characteristics": [_a("VolumeMax", "out", U), _a("VolumeUnity", "out", U), _a("VolumeSteps", "out", U),
|
|
_a("VolumeMilliDbPerStep", "out", U), _a("BalanceMax", "out", U),
|
|
_a("FadeMax", "out", U)],
|
|
"SetVolume": [_a("Value", "in", U)], "VolumeInc": [], "VolumeDec": [], "Volume": [_a("Value", "out", U)],
|
|
"SetBalance": [_a("Value", "in", I)], "BalanceInc": [], "BalanceDec": [], "Balance": [_a("Value", "out", I)],
|
|
"SetFade": [_a("Value", "in", I)], "FadeInc": [], "FadeDec": [], "Fade": [_a("Value", "out", I)],
|
|
"SetMute": [_a("Value", "in", B)], "Mute": [_a("Value", "out", B)],
|
|
"VolumeLimit": [_a("Value", "out", U)],
|
|
}, _vars([("Volume", "ui4", None), ("Mute", "boolean", None), ("Balance", "i4", None), ("Fade", "i4", None),
|
|
("VolumeLimit", "ui4", None), ("VolumeMax", "ui4", None), ("VolumeUnity", "ui4", None),
|
|
("VolumeSteps", "ui4", None), ("VolumeMilliDbPerStep", "ui4", None), ("BalanceMax", "ui4", None),
|
|
("FadeMax", "ui4", None)], ARG_VARS)),
|
|
}
|
|
|
|
TRANSPORT = {"PLAYING": "Playing", "PAUSED_PLAYBACK": "Paused", "TRANSITIONING": "Buffering"}
|
|
|
|
|
|
def _bool(value):
|
|
return "1" if value else "0"
|
|
|
|
|
|
def _parse_bool(value):
|
|
return str(value).strip().lower() in ("1", "true", "yes")
|
|
|
|
|
|
class OHError(Exception):
|
|
def __init__(self, code, desc):
|
|
super().__init__(desc)
|
|
self.code, self.desc = code, desc
|
|
|
|
|
|
class OpenHome:
|
|
"""Playlist state + action handlers. Uses the renderer's lock for everything."""
|
|
|
|
def __init__(self, renderer, product_name, room, sink_protocols, version):
|
|
self.r = renderer
|
|
self.lock = renderer.lock
|
|
self.product_name = product_name
|
|
self.room = room
|
|
self.sink_protocols = sink_protocols
|
|
self.version = version
|
|
self.tracks = [] # [(id, uri, metadata)]
|
|
self.next_id = 1
|
|
self.current_id = 0
|
|
self.repeat = False
|
|
self.shuffle = False
|
|
self.token = 0
|
|
self.track_count = 0
|
|
self.active = False # OpenHome is driving the renderer
|
|
self.notify = None # callback(service) set by the bridge
|
|
self._last = {}
|
|
self._busy = False
|
|
|
|
# ---- helpers
|
|
def _index(self, track_id):
|
|
for i, (tid, _, _) in enumerate(self.tracks):
|
|
if tid == track_id:
|
|
return i
|
|
return -1
|
|
|
|
def _following(self, track_id):
|
|
i = self._index(track_id)
|
|
if i < 0:
|
|
return None
|
|
if i + 1 < len(self.tracks):
|
|
return self.tracks[i + 1]
|
|
return self.tracks[0] if self.repeat and self.tracks else None
|
|
|
|
def _preceding(self, track_id):
|
|
i = self._index(track_id)
|
|
if i > 0:
|
|
return self.tracks[i - 1]
|
|
return self.tracks[-1] if self.repeat and self.tracks and i == 0 else None
|
|
|
|
def id_array(self):
|
|
return base64.b64encode(b"".join(t[0].to_bytes(4, "big") for t in self.tracks)).decode()
|
|
|
|
def _changed_array(self):
|
|
self.token += 1
|
|
self._sync_next()
|
|
self._emit("Playlist")
|
|
|
|
def _emit(self, service):
|
|
if self.notify:
|
|
self.notify(service)
|
|
|
|
def _sync_next(self):
|
|
"""Make the renderer's "next" track match the playlist."""
|
|
if not self.active or not self.current_id:
|
|
return
|
|
nxt = self._following(self.current_id)
|
|
uri, meta = (nxt[1], nxt[2]) if nxt else ("", "")
|
|
if (uri, meta) != (self.r.next_uri, self.r.next_meta):
|
|
self.r.set_next_uri(uri, meta)
|
|
|
|
def _play_id(self, track_id, start=True):
|
|
i = self._index(track_id)
|
|
if i < 0:
|
|
raise OHError(800, "Id not found")
|
|
tid, uri, meta = self.tracks[i]
|
|
self.active = True
|
|
self.current_id = tid
|
|
self.track_count += 1
|
|
log.info("OpenHome: play track %d of %d", i + 1, len(self.tracks))
|
|
# set the follow-up first, so a gapless session starts with the right queue
|
|
nxt = self._following(tid)
|
|
self.r.next_uri, self.r.next_meta = (nxt[1], nxt[2]) if nxt else ("", "")
|
|
self.r.set_uri(uri, meta)
|
|
self._sync_next()
|
|
if start and self.r.transport_state != "PLAYING":
|
|
self.r.play()
|
|
self._emit("Playlist")
|
|
self._emit("Info")
|
|
|
|
# ---- called by the renderer whenever its state changes
|
|
def on_renderer_change(self):
|
|
with self.lock:
|
|
if not self.active or self._busy:
|
|
return
|
|
self._busy = True
|
|
try:
|
|
self._on_change()
|
|
finally:
|
|
self._busy = False
|
|
|
|
def _on_change(self):
|
|
if True:
|
|
if self.r.current_uri and self.current_id:
|
|
cur = self.tracks[self._index(self.current_id)] if self._index(self.current_id) >= 0 else None
|
|
if not cur or cur[1] != self.r.current_uri:
|
|
# the renderer moved on (track ended, gapless crossing): find the new current track
|
|
nxt = self._following(self.current_id)
|
|
match = nxt if nxt and nxt[1] == self.r.current_uri else next(
|
|
(t for t in self.tracks if t[1] == self.r.current_uri), None)
|
|
if match:
|
|
self.current_id = match[0]
|
|
self.track_count += 1
|
|
self._sync_next()
|
|
self._emit("Info")
|
|
state = (self.current_id, self.transport_state(), self.r.position, self.r.volume, self.r.mute)
|
|
if state != self._last.get("state"):
|
|
prev = self._last.get("state") or (None,) * 5
|
|
self._last["state"] = state
|
|
if state[:2] != prev[:2]:
|
|
self._emit("Playlist")
|
|
if state[2] != prev[2] or state[0] != prev[0]:
|
|
self._emit("Time")
|
|
if state[3:] != prev[3:]:
|
|
self._emit("Volume")
|
|
|
|
def transport_state(self):
|
|
if not self.active or not self.r.owned:
|
|
return "Stopped"
|
|
return TRANSPORT.get(self.r.transport_state, "Stopped")
|
|
|
|
# ---- evented state per service
|
|
def state(self, service):
|
|
r = self.r
|
|
cur = self.tracks[self._index(self.current_id)] if self._index(self.current_id) >= 0 else (0, "", "")
|
|
if service == "Product":
|
|
return {
|
|
"ManufacturerName": "bluos-upnp-bridge", "ManufacturerInfo": "",
|
|
"ManufacturerUrl": "https://git.techniverse.net/scriptos/bluos-upnp-bridge",
|
|
"ManufacturerImageUri": "", "ModelName": "BluOS UPnP Bridge", "ModelInfo": self.version,
|
|
"ModelUrl": "", "ModelImageUri": "", "ProductRoom": self.room, "ProductName": self.product_name,
|
|
"ProductInfo": "", "ProductUrl": "", "ProductImageUri": "", "Standby": "0", "SourceCount": "1",
|
|
"SourceXml": self.source_xml(), "SourceIndex": "0", "Attributes": "Info Time Volume",
|
|
}
|
|
if service == "Playlist":
|
|
return {"TransportState": self.transport_state(), "Repeat": _bool(self.repeat),
|
|
"Shuffle": _bool(self.shuffle), "Id": str(self.current_id), "IdArray": self.id_array(),
|
|
"TracksMax": str(TRACKS_MAX), "ProtocolInfo": self.sink_protocols}
|
|
if service == "Time":
|
|
return {"TrackCount": str(self.track_count), "Duration": str(int(r.duration() or 0)),
|
|
"Seconds": str(int(r.position or 0))}
|
|
if service == "Info":
|
|
ctype = (r.current_info or {}).get("content_type", "")
|
|
return {"TrackCount": str(self.track_count), "DetailsCount": str(self.track_count),
|
|
"MetatextCount": "0", "Uri": cur[1], "Metadata": cur[2],
|
|
"Duration": str(int(r.duration() or 0)), "BitRate": "0", "BitDepth": "0", "SampleRate": "0",
|
|
"Lossless": _bool("flac" in ctype), "CodecName": ctype.split("/")[-1] if ctype else "",
|
|
"Metatext": ""}
|
|
if service == "Volume":
|
|
return {"Volume": str(r.volume), "Mute": _bool(r.mute), "Balance": "0", "Fade": "0",
|
|
"VolumeLimit": "100", "VolumeMax": "100", "VolumeUnity": "100", "VolumeSteps": "100",
|
|
"VolumeMilliDbPerStep": "1000", "BalanceMax": "0", "FadeMax": "0"}
|
|
return {}
|
|
|
|
def source_xml(self):
|
|
return ("<SourceList><Source><Name>Playlist</Name><Type>Playlist</Type><Visible>true</Visible>"
|
|
"</Source></SourceList>")
|
|
|
|
# ---- actions
|
|
def handle(self, service, action, args):
|
|
with self.lock:
|
|
return getattr(self, f"_{service.lower()}")(action, args)
|
|
|
|
def _product(self, action, args):
|
|
st = self.state("Product")
|
|
simple = {
|
|
"Manufacturer": {"Name": st["ManufacturerName"], "Info": "", "Url": st["ManufacturerUrl"], "ImageUri": ""},
|
|
"Model": {"Name": st["ModelName"], "Info": st["ModelInfo"], "Url": "", "ImageUri": ""},
|
|
"Product": {"Room": self.room, "Name": self.product_name, "Info": "", "Url": "", "ImageUri": ""},
|
|
"Standby": {"Value": "0"}, "SetStandby": {}, "SourceCount": {"Value": "1"},
|
|
"SourceXml": {"Value": st["SourceXml"]}, "SourceIndex": {"Value": "0"}, "SetSourceIndex": {},
|
|
"SetSourceIndexByName": {}, "Attributes": {"Value": st["Attributes"]},
|
|
"SourceXmlChangeCount": {"Value": "0"},
|
|
}
|
|
if action == "Source":
|
|
if args.get("Index", "0") != "0":
|
|
raise OHError(800, "Index out of range")
|
|
return {"SystemName": "Playlist", "Type": "Playlist", "Name": "Playlist", "Visible": "1"}
|
|
if action in simple:
|
|
return simple[action]
|
|
raise OHError(401, "Invalid Action")
|
|
|
|
def _playlist(self, action, args):
|
|
r = self.r
|
|
if action == "Play":
|
|
if not self.tracks:
|
|
return {}
|
|
if self.active and self.current_id and r.owned and r.transport_state == "PAUSED_PLAYBACK":
|
|
r.play()
|
|
elif not (self.active and r.transport_state == "PLAYING" and r.owned):
|
|
self._play_id(self.current_id if self._index(self.current_id) >= 0 else self.tracks[0][0])
|
|
return {}
|
|
if action == "Pause":
|
|
r.pause()
|
|
return {}
|
|
if action == "Stop":
|
|
r.stop()
|
|
return {}
|
|
if action in ("Next", "Previous"):
|
|
target = (self._following if action == "Next" else self._preceding)(self.current_id)
|
|
if target:
|
|
self._play_id(target[0])
|
|
elif action == "Previous" and self.current_id:
|
|
self._play_id(self.current_id)
|
|
return {}
|
|
if action == "SetRepeat":
|
|
self.repeat = _parse_bool(args.get("Value"))
|
|
self._sync_next()
|
|
self._emit("Playlist")
|
|
return {}
|
|
if action == "SetShuffle":
|
|
# the control point shuffles the queue itself; the flag is only reported back
|
|
self.shuffle = _parse_bool(args.get("Value"))
|
|
self._emit("Playlist")
|
|
return {}
|
|
if action == "Repeat":
|
|
return {"Value": _bool(self.repeat)}
|
|
if action == "Shuffle":
|
|
return {"Value": _bool(self.shuffle)}
|
|
if action == "SeekSecondAbsolute":
|
|
r.seek("REL_TIME", _hms(int(args.get("Value", "0"))))
|
|
return {}
|
|
if action == "SeekSecondRelative":
|
|
r.seek("REL_TIME", _hms(max(0, int(r.position) + int(args.get("Value", "0")))))
|
|
return {}
|
|
if action == "SeekId":
|
|
self._play_id(int(args.get("Value", "0")))
|
|
return {}
|
|
if action == "SeekIndex":
|
|
idx = int(args.get("Value", "0"))
|
|
if not 0 <= idx < len(self.tracks):
|
|
raise OHError(800, "Index out of range")
|
|
self._play_id(self.tracks[idx][0])
|
|
return {}
|
|
if action == "TransportState":
|
|
return {"Value": self.transport_state()}
|
|
if action == "Id":
|
|
return {"Value": str(self.current_id)}
|
|
if action == "Read":
|
|
i = self._index(int(args.get("Id", "0")))
|
|
if i < 0:
|
|
raise OHError(800, "Id not found")
|
|
return {"Uri": self.tracks[i][1], "Metadata": self.tracks[i][2]}
|
|
if action == "ReadList":
|
|
entries = []
|
|
for part in (args.get("IdList") or "").split():
|
|
i = self._index(int(part)) if part.isdigit() else -1
|
|
if i >= 0:
|
|
tid, uri, meta = self.tracks[i]
|
|
entries.append(f"<Entry><Id>{tid}</Id><Uri>{html.escape(uri)}</Uri>"
|
|
f"<Metadata>{html.escape(meta)}</Metadata></Entry>")
|
|
return {"TrackList": "<TrackList>" + "".join(entries) + "</TrackList>"}
|
|
if action == "Insert":
|
|
if len(self.tracks) >= TRACKS_MAX:
|
|
raise OHError(801, "Playlist full")
|
|
after = int(args.get("AfterId", "0"))
|
|
pos = 0 if after == 0 else self._index(after) + 1
|
|
if after and pos == 0:
|
|
raise OHError(800, "Id not found")
|
|
new_id = self.next_id
|
|
self.next_id += 1
|
|
self.tracks.insert(pos, (new_id, args.get("Uri", ""), args.get("Metadata", "")))
|
|
self._changed_array()
|
|
return {"NewId": str(new_id)}
|
|
if action == "DeleteId":
|
|
tid = int(args.get("Value", "0"))
|
|
i = self._index(tid)
|
|
if i < 0:
|
|
return {}
|
|
playing_this = tid == self.current_id
|
|
nxt = self._following(tid) if playing_this else None
|
|
del self.tracks[i]
|
|
if playing_this:
|
|
if nxt and nxt[0] != tid and r.transport_state == "PLAYING":
|
|
self._play_id(nxt[0])
|
|
else:
|
|
self.current_id = nxt[0] if nxt and nxt[0] != tid else 0
|
|
if not self.current_id:
|
|
r.stop()
|
|
self._changed_array()
|
|
return {}
|
|
if action == "DeleteAll":
|
|
self.tracks.clear()
|
|
self.current_id = 0
|
|
if self.active:
|
|
r.stop()
|
|
self.active = False
|
|
self._changed_array()
|
|
return {}
|
|
if action == "TracksMax":
|
|
return {"Value": str(TRACKS_MAX)}
|
|
if action == "IdArray":
|
|
return {"Token": str(self.token), "Array": self.id_array()}
|
|
if action == "IdArrayChanged":
|
|
return {"Value": _bool(str(self.token) != args.get("Token", ""))}
|
|
if action == "ProtocolInfo":
|
|
return {"Value": self.sink_protocols}
|
|
raise OHError(401, "Invalid Action")
|
|
|
|
def _time(self, action, args):
|
|
if action == "Time":
|
|
return self.state("Time")
|
|
raise OHError(401, "Invalid Action")
|
|
|
|
def _info(self, action, args):
|
|
st = self.state("Info")
|
|
if action == "Counters":
|
|
return {k: st[k] for k in ("TrackCount", "DetailsCount", "MetatextCount")}
|
|
if action == "Track":
|
|
return {"Uri": st["Uri"], "Metadata": st["Metadata"]}
|
|
if action == "Details":
|
|
return {k: st[k] for k in ("Duration", "BitRate", "BitDepth", "SampleRate", "Lossless", "CodecName")}
|
|
if action == "Metatext":
|
|
return {"Value": ""}
|
|
raise OHError(401, "Invalid Action")
|
|
|
|
def _volume(self, action, args):
|
|
r = self.r
|
|
st = self.state("Volume")
|
|
if action == "Characteristics":
|
|
return {k: st[k] for k in ("VolumeMax", "VolumeUnity", "VolumeSteps", "VolumeMilliDbPerStep",
|
|
"BalanceMax", "FadeMax")}
|
|
if action == "SetVolume":
|
|
r.set_volume(max(0, min(100, int(args.get("Value", "0")))))
|
|
elif action in ("VolumeInc", "VolumeDec"):
|
|
r.set_volume(max(0, min(100, r.volume + (2 if action == "VolumeInc" else -2))))
|
|
elif action == "SetMute":
|
|
r.set_mute(_parse_bool(args.get("Value")))
|
|
elif action in ("Volume", "Mute", "Balance", "Fade", "VolumeLimit"):
|
|
return {"Value": st[action]}
|
|
elif action in ("SetBalance", "BalanceInc", "BalanceDec", "SetFade", "FadeInc", "FadeDec"):
|
|
pass # BluOS has no balance/fade per player
|
|
else:
|
|
raise OHError(401, "Invalid Action")
|
|
self._emit("Volume")
|
|
return {}
|
|
|
|
|
|
def _hms(secs):
|
|
secs = max(0, int(secs))
|
|
return f"{secs // 3600}:{secs % 3600 // 60:02d}:{secs % 60:02d}"
|