openvibers/openvibe.live Commits

Channel player on a phone: a tap on the speaker pops the volume slider up above it (#374)

OP OpenVibers committed 18 days ago · 1aa1d2e96b · 3 files, +73 −0

Under 480px the bar has no room for the slider (2026-10-25), so a phone could only mute (the owner, 2026-10-26: "can't
easily adjust volume" on mobile). Now a tap on the speaker also shows the slider above it, in a small pill, for 3.5 s
(longer while it is dragged). Not where a page cannot set the volume (an iPhone's Safari, _volumeSettable): there the
speaker mutes and the phone's own buttons set the level.

Checked: test/phone-volume.test.js, the 9 tests the change reaches, and the channel CSS and markup in Chrome at 390 and
360 px (the pill sits above the speaker, 150x40, clear of the bar).

Changes

ddiff --git a/public/css/features/channel.css b/public/css/features/channel.css
iindex 615179d4..ea1cf7e8 100644
---- a/public/css/features/channel.css
++++ b/public/css/features/channel.css
@@@ -1523,7 +1523,17 @@
@media (max-width: 480px) {
#page-channel .vc-volume-group .volume-slider { display: none !important; }
#page-channel #uptime-badge { display: none; }
++ /* …except above the speaker for a few seconds after a tap on it (stream-player.js popVolume, 2026-10-26): the
++ volume stays adjustable on a phone without taking room in the bar. */
++ #page-channel .vc-volume-group { position: relative; }
++ #page-channel .vc-volume-group.is-popped .volume-slider {
++ display: block !important; position: absolute; left: -4px; bottom: calc(100% + 10px); z-index: 6;
++ width: 150px !important; height: 40px; padding: 0 14px; opacity: 1 !important; box-sizing: border-box;
++ border-radius: 999px; background: rgba(12, 15, 24, 0.92); box-shadow: 0 6px 18px rgba(0, 0, 0, 0.45);
++ animation: vc-volume-pop 0.18s ease-out;
++ }
}
++@keyframes vc-volume-pop { from { opacity: 0; transform: translateY(4px); } }
/* ── The player takes the stream's shape (2026-10-26) ──────────────────────────────
player-shape.js sets --video-ar (the picture's width / height) on #video-container. The box stays the column's width,
ddiff --git a/public/js/stream-player.js b/public/js/stream-player.js
iindex c56f8200..1530a905 100644
---- a/public/js/stream-player.js
++++ b/public/js/stream-player.js
@@@ -2766,7 +2766,20 @@ function setupVideoControls() {
// Start showing pause icon since autoplay
btnPlay.innerHTML = '<i class="fa-solid fa-pause"></i>';
++ // A phone under 480px has no room for the slider in the bar (2026-10-25): a tap on the speaker also shows it, above
++ // the button, for a few seconds (the owner, 2026-10-26: "can't easily adjust volume" on a phone). Not on an iPhone,
++ // whose Safari ignores a page setting the volume; there the speaker mutes, and the phone's buttons set the level.
++ const volGroup = btnVol.closest('.vc-volume-group');
++ const popVolume = () => {
++ if (!volGroup || !window.matchMedia || !matchMedia('(max-width: 480px)').matches || !_volumeSettable()) return;
++ volGroup.classList.add('is-popped');
++ clearTimeout(popVolume.timer);
++ popVolume.timer = setTimeout(() => volGroup.classList.remove('is-popped'), 3500);
++ };
++ volSlider.onpointerdown = popVolume;
++
btnVol.onclick = () => {
++ popVolume();
muted = !muted;
btnVol.innerHTML = muted
? '<i class="fa-solid fa-volume-xmark"></i>'
@@@ -2781,6 +2794,7 @@ function setupVideoControls() {
};
volSlider.oninput = () => {
++ popVolume();
const v = volSlider.value / 100;
setVolume(v, { muted: v === 0 });
muted = v === 0;
@@@ -3391,6 +3405,14 @@ function _buildClipTrimModal(clipId, dur, defaultTitle) {
overlay.onclick = (e) => { if (e.target === overlay) close(); };
}
++/** Whether this browser lets a page set a media element's volume (an iPhone's Safari does not: only mute). */
++function _volumeSettable() {
++ if (_volumeSettable.value == null) {
++ try { const a = document.createElement('audio'); a.volume = 0.5; _volumeSettable.value = Math.abs(a.volume - 0.5) < 0.01; } catch { _volumeSettable.value = false; }
++ }
++ return _volumeSettable.value;
++}
++
function setVolume(v) {
const options = arguments[1] || {};
const volume = Math.max(0, Math.min(1, Number(v) || 0));
ddiff --git a/test/phone-volume.test.js b/test/phone-volume.test.js
nnew file mode 100644
iindex 00000000..4af2ae7b
---- /dev/null
++++ b/test/phone-volume.test.js
@@@ -0,0 +1,41 @@
++/**
++ * The volume stays adjustable on a phone (owner, 2026-10-26: "can't easily adjust volume" on mobile). Under 480px the
++ * channel player's bar has no room for the slider (2026-10-25), so a tap on the speaker also pops the slider up above
++ * it for a few seconds (stream-player.js popVolume, channel.css .is-popped). Not where a page cannot set the volume
++ * (an iPhone's Safari): there the speaker mutes and the phone's own buttons set the level.
++ *
++ * node test/phone-volume.test.js
++ */
++'use strict';
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const vm = require('vm');
++
++const root = path.join(__dirname, '..');
++const player = fs.readFileSync(path.join(root, 'public/js/stream-player.js'), 'utf8');
++const css = fs.readFileSync(path.join(root, 'public/css/features/channel.css'), 'utf8');
++
++// _volumeSettable: true where an audio element takes a volume, false where it stays 1 (iOS Safari), asked once.
++const fn = /\/\*\* Whether this browser lets a page set[\s\S]*?\nfunction _volumeSettable\(\) \{[\s\S]*?\n\}\n/.exec(player);
++assert.ok(fn, '_volumeSettable() is in stream-player.js');
++for (const [fixed, want] of [[false, true], [true, false]]) {
++ let made = 0;
++ const ctx = { Math, document: { createElement: () => { made++; let v = 1; return { get volume() { return v; }, set volume(x) { if (!fixed) v = x; } }; } } };
++ vm.createContext(ctx);
++ vm.runInContext(`${fn[0]}; this.f = _volumeSettable;`, ctx);
++ assert.strictEqual(ctx.f(), want, fixed ? 'an iPhone: no slider' : 'Android, desktop: the slider');
++ ctx.f();
++ assert.strictEqual(made, 1, 'asked once');
++}
++
++// The speaker and the slider pop it up; it only pops on a narrow screen that can set the volume.
++assert.match(player, /btnVol\.onclick = \(\) => \{\n\s+popVolume\(\);/);
++assert.match(player, /volSlider\.oninput = \(\) => \{\n\s+popVolume\(\);/);
++assert.match(player, /matchMedia\('\(max-width: 480px\)'\)\.matches \|\| !_volumeSettable\(\)\) return;/);
++assert.match(player, /setTimeout\(\(\) => volGroup\.classList\.remove\('is-popped'\), 3500\)/);
++// The bar still hides the slider under 480px; the popped one shows above the speaker.
++const narrow = /@media \(max-width: 480px\) \{([\s\S]*?)\n\}\n/.exec(css.slice(css.indexOf("The player's controls on a narrow phone")));
++assert.ok(narrow && /\.vc-volume-group \.volume-slider \{ display: none !important; \}/.test(narrow[1]));
++assert.match(narrow[1], /\.vc-volume-group\.is-popped \.volume-slider \{\s*display: block !important; position: absolute;[^}]*bottom: calc\(100% \+ 10px\)/);
++console.log('✅ phone volume: the slider pops above the speaker on a narrow phone that can set the volume');