fix(audio): off/on Audio + Video toggles; fix Safari autoplay + GPU video-off
Operator-driven live-UI revision:
- Video + Audio are now Dev-Mode-style on/off toggles (Audio on = soundtrack);
white-noise deferred (removed from the live control; pure machinery kept).
- FIX video-off not blanking: the <video>/<canvas> are GPU-composited and could
paint above the auto-z #black on a real GPU — give #black z-index + its own
layer AND hide the video layers (opacity 0) on video-off.
- FIX no soundtrack on Safari/iOS: Safari unlocks <audio> only when play() runs
INSIDE the user gesture; the start gesture now primes both A/B elements
synchronously, so the async crossfades are allowed to play.
- Toggles wired on 'change' (matches the Dev Mode switch).
- player/audio.py AUDIO_SOURCES -> {off, soundtrack}; tests + E2E updated.
- Verified in headless Chromium AND WebKit: audio plays, video-off blanks both
layers, zero JS errors. 288 tests + 3 Playwright E2E pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+22
-21
@@ -63,36 +63,37 @@ def page(app_url):
|
||||
browser.close()
|
||||
|
||||
|
||||
def test_white_noise_loads_the_noise_asset(page):
|
||||
page.select_option("#audio", "white_noise")
|
||||
page.wait_for_function(
|
||||
"['audA','audB'].some(id => "
|
||||
"document.getElementById(id).src.includes('/media/audio/noise/pink.mp3'))"
|
||||
)
|
||||
|
||||
|
||||
def test_soundtrack_loads_the_current_scale_asset(page):
|
||||
# initial altitude is cosmos (index 0) → soundtrack resolves its scale asset,
|
||||
# not the noise bed (the altitude-coupling itself is covered at the API tier)
|
||||
page.select_option("#audio", "soundtrack")
|
||||
def test_audio_toggle_plays_the_current_scale_soundtrack(page):
|
||||
# Audio on (toggle) → the current altitude's soundtrack (cosmos at index 0) plays
|
||||
page.click("label[for='audio'] .dev-switch-track")
|
||||
page.wait_for_function(
|
||||
"['audA','audB'].some(id => {"
|
||||
" const s = document.getElementById(id).src;"
|
||||
" return /\\/media\\/audio\\/.+\\.mp3/.test(s) && !s.includes('noise');"
|
||||
" const a = document.getElementById(id);"
|
||||
" return /\\/media\\/audio\\/.+\\.mp3/.test(a.src) && !a.paused;"
|
||||
"})"
|
||||
)
|
||||
|
||||
|
||||
def test_visual_off_keeps_audio_and_fades_video(page):
|
||||
page.select_option("#audio", "white_noise")
|
||||
def test_video_toggle_off_blanks_the_screen_and_hides_layers(page):
|
||||
page.click("label[for='visual'] .dev-switch-track")
|
||||
page.wait_for_selector("#black:not(.hidden)") # black cover shown
|
||||
# the video layers themselves are hidden too (GPU-composite-proof blanking);
|
||||
# wait for the opacity transition to settle to 0
|
||||
page.wait_for_function(
|
||||
"['audA','audB'].some(id => "
|
||||
"document.getElementById(id).src.includes('/media/audio/noise/pink.mp3'))"
|
||||
"['vid','paint'].every(id => getComputedStyle(document.getElementById(id)).opacity === '0')"
|
||||
)
|
||||
page.uncheck("#visual")
|
||||
page.wait_for_selector("#black:not(.hidden)") # video faded to black
|
||||
|
||||
|
||||
def test_audio_survives_video_off(page):
|
||||
page.click("label[for='audio'] .dev-switch-track")
|
||||
page.wait_for_function(
|
||||
"['audA','audB'].some(id => !document.getElementById(id).paused "
|
||||
"&& document.getElementById(id).src)"
|
||||
)
|
||||
page.click("label[for='visual'] .dev-switch-track")
|
||||
page.wait_for_selector("#black:not(.hidden)")
|
||||
playing = page.evaluate(
|
||||
"['audA','audB'].some(id => "
|
||||
"{const a = document.getElementById(id); return !a.paused && !!a.src;})"
|
||||
)
|
||||
assert playing is True # audio survives the video fade
|
||||
assert playing is True # audio keeps playing with video off
|
||||
|
||||
+12
-16
@@ -10,9 +10,6 @@ from player.audio import (
|
||||
resolve_visual,
|
||||
)
|
||||
|
||||
NOISE = "/media/audio/noise/pink.mp3"
|
||||
|
||||
|
||||
def test_visual_on_off():
|
||||
assert resolve_visual("on") is True
|
||||
assert resolve_visual("off") is False
|
||||
@@ -27,37 +24,36 @@ def test_visual_positions_are_on_off():
|
||||
assert VISUAL_POSITIONS == frozenset({"on", "off"})
|
||||
|
||||
|
||||
def test_audio_sources_are_off_and_soundtrack():
|
||||
assert AUDIO_SOURCES == frozenset({"off", "soundtrack"})
|
||||
|
||||
|
||||
def test_soundtrack_resolves_to_the_current_scales_asset():
|
||||
url = resolve_audio_url("soundtrack", scale_audio="coast/waves.loop.mp3", noise_url=NOISE)
|
||||
url = resolve_audio_url("soundtrack", scale_audio="coast/waves.loop.mp3")
|
||||
assert url == "/media/audio/coast/waves.loop.mp3"
|
||||
|
||||
|
||||
def test_white_noise_resolves_to_the_global_bed_independent_of_altitude():
|
||||
url = resolve_audio_url("white_noise", scale_audio="coast/waves.loop.mp3", noise_url=NOISE)
|
||||
assert url == NOISE
|
||||
|
||||
|
||||
def test_off_resolves_to_silence():
|
||||
assert resolve_audio_url("off", scale_audio="coast/waves.loop.mp3", noise_url=NOISE) is None
|
||||
assert resolve_audio_url("off", scale_audio="coast/waves.loop.mp3") is None
|
||||
|
||||
|
||||
def test_soundtrack_with_no_scale_asset_is_silent():
|
||||
assert resolve_audio_url("soundtrack", scale_audio="", noise_url=NOISE) is None
|
||||
assert resolve_audio_url("soundtrack", scale_audio="") is None
|
||||
|
||||
|
||||
def test_resolve_audio_marks_only_soundtrack_altitude_coupled():
|
||||
r = resolve_audio("soundtrack", scale_audio="reef/soundscape.loop.mp3", noise_url=NOISE)
|
||||
r = resolve_audio("soundtrack", scale_audio="reef/soundscape.loop.mp3")
|
||||
assert isinstance(r, AudioResolution)
|
||||
assert r.source == "soundtrack" and r.altitude_coupled is True
|
||||
assert r.url == "/media/audio/reef/soundscape.loop.mp3"
|
||||
assert resolve_audio("white_noise", scale_audio="x/y.mp3", noise_url=NOISE).altitude_coupled is False
|
||||
assert resolve_audio("off", scale_audio="x/y.mp3", noise_url=NOISE).altitude_coupled is False
|
||||
assert resolve_audio("off", scale_audio="x/y.mp3").altitude_coupled is False
|
||||
|
||||
|
||||
def test_resolve_audio_url_rejects_unknown_source():
|
||||
with pytest.raises(ValueError):
|
||||
resolve_audio_url("podcast", scale_audio="", noise_url=NOISE)
|
||||
resolve_audio_url("white_noise", scale_audio="") # deferred, not a v1 source
|
||||
|
||||
|
||||
def test_music_is_not_a_v1_source():
|
||||
def test_music_and_white_noise_are_not_v1_sources():
|
||||
assert "music" not in AUDIO_SOURCES
|
||||
assert "white_noise" not in AUDIO_SOURCES
|
||||
|
||||
@@ -11,7 +11,7 @@ from player.controls import (
|
||||
|
||||
def test_visual_and_audio_enums_are_the_v1_positions():
|
||||
assert VISUAL_POSITIONS == frozenset({"on", "off"})
|
||||
assert AUDIO_SOURCES == frozenset({"off", "soundtrack", "white_noise"})
|
||||
assert AUDIO_SOURCES == frozenset({"off", "soundtrack"})
|
||||
|
||||
|
||||
def test_valid_controls_pass_validation():
|
||||
@@ -47,10 +47,10 @@ def test_out_of_range_or_non_int_knob_rejected(field, bad):
|
||||
|
||||
def test_parse_controls_from_mapping():
|
||||
c = parse_controls(
|
||||
{"visual": "on", "audio": "white_noise", "left": 1, "right": 2, "dark": 3,
|
||||
{"visual": "on", "audio": "soundtrack", "left": 1, "right": 2, "dark": 3,
|
||||
"light": 0, "volume": 2, "brightness": 1}
|
||||
)
|
||||
assert c == Controls("on", "white_noise", 1, 2, 3, 0, 2, 1)
|
||||
assert c == Controls("on", "soundtrack", 1, 2, 3, 0, 2, 1)
|
||||
|
||||
|
||||
def test_parse_controls_rejects_unknown_keys():
|
||||
|
||||
@@ -38,11 +38,11 @@ def test_visual_off_from_video_fades_to_black():
|
||||
|
||||
|
||||
def test_audio_survives_visual_off():
|
||||
# white noise + black is now reachable (the gap the bundled dial omitted)
|
||||
# soundtrack + black is reachable (a gap the bundled dial omitted)
|
||||
p = Player(LIB)
|
||||
t = p.update(_controls(visual="off", audio="white_noise"))
|
||||
t = p.update(_controls(visual="off", audio="soundtrack"))
|
||||
assert t.playback.video is False
|
||||
assert t.playback.audio_source == "white_noise"
|
||||
assert t.playback.audio_source == "soundtrack"
|
||||
|
||||
|
||||
def test_no_change_yields_none_transition():
|
||||
@@ -90,7 +90,7 @@ def test_volume_only_change_is_a_live_update():
|
||||
|
||||
def test_audio_source_change_while_black_is_a_live_update():
|
||||
p = Player(LIB)
|
||||
p.update(_controls(visual="off", audio="white_noise"))
|
||||
p.update(_controls(visual="off", audio="off"))
|
||||
t = p.update(_controls(visual="off", audio="soundtrack"))
|
||||
assert t.kind == TransitionKind.LIVE_UPDATE
|
||||
assert t.playback.audio_source == "soundtrack"
|
||||
|
||||
@@ -56,13 +56,11 @@ def test_alteration_visual_off_hides_video(client):
|
||||
assert body["render"]["video"]["shown"] is False
|
||||
|
||||
|
||||
def test_alteration_white_noise_is_altitude_independent(ring_client):
|
||||
for idx in (0, 1, 2):
|
||||
body = ring_client.post("/api/alteration", json={
|
||||
"controls": _controls(audio="white_noise"), "altitude_index": idx,
|
||||
}).json()
|
||||
assert body["render"]["audio"]["url"] == "/media/audio/noise/pink.mp3"
|
||||
assert body["render"]["audio"]["altitude_coupled"] is False
|
||||
def test_alteration_off_is_silent(ring_client):
|
||||
body = ring_client.post("/api/alteration", json={
|
||||
"controls": _controls(audio="off"), "altitude_index": 0,
|
||||
}).json()
|
||||
assert body["render"]["audio"] == {"source": "off", "url": None, "altitude_coupled": False}
|
||||
|
||||
|
||||
def test_alteration_soundtrack_couples_to_the_given_altitude(ring_client):
|
||||
|
||||
Reference in New Issue
Block a user