openvibers/openvibe.shared Commits

Player: a volume slider on phones (where the page can set it), and a video with no sound says so (4.14 batch) (#122)

OP OpenVibers committed 18 days ago · 942e350f0f · 4 files, +134 −12

Touch screens only had mute; the slider now shows finger-sized wherever the volume can be set (Android; an iPhone keeps its own buttons). A video with no sound track (VOD 5986) shows 'No sound in this video' once, with a dimmed speaker, no slider and no 'Tap for sound': Firefox mozHasAudio, Chrome/Safari no audio decoded after 2 s of decoded video. Checked: test/player.test.js, and the real player in Chrome with phone emulation (the slider at 84x36 sets 25% on a tap; a video without audio gets is-silent).

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 22e1281..4f74b40 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,18 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
breaking change to any exported module, browser global or served file name is a new major.
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
++## 4.14.0 — unreleased
++
++- **Volume on phones, and a video with no sound says so** (`player.js`, `player.css`; the owner, 2026-10-26, on
++ openvibe.video: "Volume seems broken … on mobile can't hear this and also can't easily adjust volume"). Touch screens
++ only had the mute button. Now the volume slider shows there too, finger-sized (84 px, an 18 px thumb), wherever the
++ page can set the volume (`.can-volume`: Android). An iPhone leaves volume to its own buttons, so it keeps the mute
++ button alone. A video with no sound track (that VOD, 5986, was recorded without its microphone) showed a working
++ speaker that did nothing. Now, once the browser can tell, it shows "No sound in this video" once, with a dimmed speaker
++ that says it again when tapped, no slider and no "Tap for sound". Firefox reports this directly (`mozHasAudio`).
++ Chrome and Safari count it: no audio decoded after 2 s of decoded video, which they count muted or not. A new source
++ asks again. `test/player.test.js`.
++
## 4.13.0 — 2026-10-26
- **Switching accounts works on every site** (`navbar.js`; the owner, 2026-10-26: "the thing that lets you switch …
ddiff --git a/player.css b/player.css
iindex 67ed414..c173a40 100644
---- a/player.css
++++ b/player.css
@@@ -174,8 +174,18 @@
.ov-player.is-waiting:not(.is-paused) .ov-player-spinner { display: block; }
@keyframes ov-player-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .ov-player-spinner { animation-duration: 2.4s; } .ov-player-bar, .ov-player-video, .ov-player-poster, .ov-player-flash { transition: none; } .ov-player-unmute { animation: none; } }
--/* Touch screens set the volume with the device: the mute button stays, the slider does not. */
--@media (hover: none) { .ov-player-volume-slider { display: none; } }
++/* Touch screens: the slider shows, finger-sized, wherever the page can set the volume (.can-volume: Android). An iPhone
++ leaves volume to its own buttons, so there the mute button stays alone. */
++@media (hover: none) {
++ .ov-player-volume-slider { display: none; }
++ .ov-player.can-volume .ov-player-volume-slider { display: block; width: 84px; height: 36px; opacity: 1; margin: 0 8px 0 0; }
++ .ov-player.can-volume .ov-player-volume-slider::-webkit-slider-thumb { width: 18px; height: 18px; margin-top: -7px; }
++ .ov-player.can-volume .ov-player-volume-slider::-moz-range-thumb { width: 18px; height: 18px; }
++}
++@media (hover: none) and (max-width: 360px) { .ov-player.can-volume .ov-player-volume-slider { width: 64px; } }
++/* No sound track: no slider, a dimmed speaker that says so when tapped. */
++.ov-player.is-silent .ov-player-volume-slider { display: none; }
++.ov-player.is-silent .ov-player-mute { opacity: 0.55; }
.ov-player-message:empty { display: none; }
.ov-player-message {
ddiff --git a/player.js b/player.js
iindex 286397c..afd4d26 100644
---- a/player.js
++++ b/player.js
@@@ -15,6 +15,9 @@
* Sound: a video playing muted (autoplay) shows "Tap for sound"; the first tap on it unmutes, and so do the media
* volume keys where a browser passes them to the page (desktop keyboards, TVs; phones keep their hardware buttons to
* themselves, so no page can see those). Lock screens and headsets get play/pause/seek through the Media Session API.
++ * On a touch screen the volume slider shows, finger-sized, wherever the page can set the volume (Android; an iPhone
++ * leaves volume to its own buttons, so it keeps the mute button alone). A video with no sound track says so ("No sound
++ * in this video", a dimmed speaker) instead of offering a mute button that seems broken.
*
* Loading looks like it is already there (owner, 2026-10-25): data-ov-color paints the average colour at once,
* data-ov-thumb (a tiny inline image) shows blurred, the poster sharpens over it, and the first frame fades in.
@@@ -71,6 +74,16 @@
b.innerHTML = icon;
return b;
}
++ /** Whether this browser lets a page set a media element's volume (an iPhone's Safari does not: only muted). */
++ function volumeSettable(video) {
++ const was = video.volume;
++ try {
++ video.volume = was > 0.5 ? was - 0.1 : was + 0.1;
++ const ok = Math.abs(video.volume - was) > 0.01;
++ video.volume = was;
++ return ok;
++ } catch { return false; }
++ }
const captionTrack = (video) => Array.from(video.textTracks || []).find((t) => t.kind === 'captions' || t.kind === 'subtitles') || null;
const fullscreenElement = () => document.fullscreenElement || document.webkitFullscreenElement || null;
@@@ -235,6 +248,13 @@
if (saved && !video.hasAttribute('muted')) { video.volume = Math.min(1, Math.max(0, saved.volume)); video.muted = !!saved.muted; }
// Muted by the page (autoplay) rather than by the viewer: the first tap or a volume key brings the sound in.
let pageMuted = video.muted && (video.hasAttribute('muted') || video.autoplay || !saved || !saved.muted);
++ if (volumeSettable(video)) root.classList.add('can-volume');
++ // No sound track (a recording whose microphone never reached the recorder: VOD 5986, 2026-10-25). Only a browser
++ // that can tell says so: Firefox (mozHasAudio), Chrome and Safari (no audio decoded after 2 s of decoded video,
++ // which they count muted or not). A new source asks again.
++ let silent = false;
++ let soundKnown = false;
++ let playedFrom = null;
// ── State → UI ───────────────────────────────────────
const duration = () => (Number.isFinite(video.duration) ? video.duration : 0);
@@@ -266,10 +286,11 @@
}
function paintVolume() {
const v = video.muted ? 0 : video.volume;
-- unmute.hidden = !(video.muted && pageMuted && !video.paused);
++ unmute.hidden = silent || !(video.muted && pageMuted && !video.paused);
root.classList.toggle('is-muted', video.muted || v === 0);
-- muteBtn.innerHTML = v === 0 ? ICON.muted : v < 0.5 ? ICON.low : ICON.volume;
-- const label = video.muted || v === 0 ? 'Unmute (m)' : 'Mute (m)';
++ root.classList.toggle('is-silent', silent);
++ muteBtn.innerHTML = silent || v === 0 ? ICON.muted : v < 0.5 ? ICON.low : ICON.volume;
++ const label = silent ? 'No sound in this video' : video.muted || v === 0 ? 'Unmute (m)' : 'Mute (m)';
muteBtn.setAttribute('aria-label', label); muteBtn.title = label;
volSlider.value = String(v);
volSlider.style.setProperty('--ovp-fill', `${v * 100}%`);
@@@ -331,16 +352,29 @@
// A short sign in the middle: a seek, a volume level (the double tap and the keys).
let flashTimer = 0;
-- function show(icon, text, side) {
++ function show(icon, text, side, ms = 650) {
flash.className = `ov-player-flash is-on${side ? ` is-${side}` : ''}`;
flash.innerHTML = `${icon}<span>${text}</span>`;
clearTimeout(flashTimer);
-- flashTimer = setTimeout(() => { flash.className = 'ov-player-flash'; }, 650);
++ flashTimer = setTimeout(() => { flash.className = 'ov-player-flash'; }, ms);
++ }
++ function checkSound() {
++ if (soundKnown || video.paused) return;
++ let none = null;
++ if (typeof video.mozHasAudio === 'boolean' && video.readyState >= 2) none = !video.mozHasAudio;
++ else if (typeof video.webkitAudioDecodedByteCount === 'number' && video.webkitVideoDecodedByteCount > 0
++ && playedFrom != null && (video.currentTime || 0) - playedFrom >= 2) none = video.webkitAudioDecodedByteCount === 0;
++ if (none == null) return;
++ soundKnown = true;
++ if (!none) return;
++ silent = true;
++ paintVolume();
++ show(ICON.muted, 'No sound in this video', null, 2600);
}
unmute.addEventListener('click', (e) => { e.stopPropagation(); api.unmute(); });
big.addEventListener('click', () => api.toggle());
playBtn.addEventListener('click', () => api.toggle());
-- muteBtn.addEventListener('click', () => api.toggleMute());
++ muteBtn.addEventListener('click', () => { if (silent) show(ICON.muted, 'No sound in this video', null, 2600); else api.toggleMute(); });
volSlider.addEventListener('input', () => api.setVolume(Number(volSlider.value)));
ccBtn.addEventListener('click', () => api.toggleCaptions());
speedBtn.addEventListener('click', () => api.nextSpeed());
@@@ -424,14 +458,16 @@
video.addEventListener('play', () => { root.classList.remove('is-start'); paintPlay(); paintVolume(); current = api; session(api, options); if (ambient) ambient.start(); });
video.addEventListener('pause', () => { paintPlay(); paintVolume(); if (ambient) ambient.stop(); });
video.addEventListener('ended', paintPlay);
-- video.addEventListener('timeupdate', paintTime);
++ video.addEventListener('timeupdate', () => { paintTime(); checkSound(); });
++ video.addEventListener('emptied', () => { silent = false; soundKnown = false; playedFrom = null; paintVolume(); });
video.addEventListener('durationchange', paintTime);
video.addEventListener('loadedmetadata', () => { paintTime(); paintCaptions(); });
video.addEventListener('progress', paintBuffered);
video.addEventListener('volumechange', paintVolume);
video.addEventListener('ratechange', paintSpeed);
video.addEventListener('waiting', () => root.classList.add('is-waiting'));
-- video.addEventListener('playing', () => { root.classList.remove('is-waiting'); message.textContent = ''; paintPlay(); });
++ video.addEventListener('playing', () => { root.classList.remove('is-waiting'); message.textContent = ''; if (playedFrom == null) playedFrom = video.currentTime || 0; paintPlay(); });
++ video.addEventListener('seeked', () => { if (!soundKnown) playedFrom = video.currentTime || 0; });
video.addEventListener('canplay', () => root.classList.remove('is-waiting'));
video.addEventListener('error', () => { root.classList.remove('is-waiting'); message.textContent = 'This video could not be played.'; });
if (video.textTracks && video.textTracks.addEventListener) {
ddiff --git a/test/player.test.js b/test/player.test.js
iindex eedae1d..ed20e05 100644
---- a/test/player.test.js
++++ b/test/player.test.js
@@@ -166,9 +166,73 @@ console.log('player: all checks passed');
v.__ovPlayer.skip(10);
assert.ok(q('.ov-player-flash').className.includes('is-right') && q('.ov-player-flash').textContent.includes('+10 s'));
}
++// ── 4.14: volume on phones, and a video with no sound (owner, 2026-10-26: "Volume seems broken … on mobile can't hear
++// this and also can't easily adjust volume"; VOD 5986 had no sound track) ──
++function player(html, { volumeFixed = false, decoded = null } = {}) {
++ const { window: w, document: d } = parseHTML(`<!doctype html><html><body><div class="host">${html}</div></body></html>`);
++ const v = d.querySelector('video');
++ let p = true;
++ const m = { currentTime: 0, duration: 300, volume: 1, muted: false, playbackRate: 1, ended: false, readyState: 4 };
++ const f = (type) => v.dispatchEvent(new w.Event(type));
++ Object.defineProperties(v, {
++ paused: { get: () => p }, ended: { get: () => m.ended }, readyState: { get: () => m.readyState },
++ currentTime: { get: () => m.currentTime, set: (x) => { m.currentTime = x; f('timeupdate'); } },
++ duration: { get: () => m.duration },
++ // An iPhone's Safari ignores a page setting the volume: it stays 1.
++ volume: { get: () => m.volume, set: (x) => { if (!volumeFixed) m.volume = x; f('volumechange'); } },
++ muted: { get: () => m.muted, set: (x) => { m.muted = x; f('volumechange'); } },
++ playbackRate: { get: () => m.playbackRate, set: (x) => { m.playbackRate = x; f('ratechange'); } },
++ buffered: { get: () => ({ length: 0 }) },
++ textTracks: { value: Object.assign([], { addEventListener() {} }) },
++ ...(decoded ? { webkitAudioDecodedByteCount: { get: () => decoded.audio }, webkitVideoDecodedByteCount: { get: () => decoded.video } } : {}),
++ });
++ v.play = () => { p = false; f('play'); f('playing'); return Promise.resolve(); };
++ v.pause = () => { p = true; f('pause'); };
++ const c = { window: w, document: d, console, Promise, setTimeout, clearTimeout, setInterval, clearInterval, Date, Math, Number, String, JSON, Object, Array,
++ localStorage: { getItem: () => null, setItem() {} } };
++ w.OpenVibePlayer = undefined;
++ vm.createContext(c);
++ vm.runInContext(src, c);
++ return { v, m, f, w, root: v.parentNode, q: (sel) => v.parentNode.querySelector(sel) };
++}
++{
++ // The page can set the volume (desktop, Android): the slider is offered on touch screens too. An iPhone: mute only.
++ assert.ok(root.classList.contains('can-volume'), 'a settable volume marks .can-volume');
++ const ios = player('<video data-ov-player src="/a.mp4"></video>', { volumeFixed: true });
++ assert.ok(!ios.root.classList.contains('can-volume'), 'an iPhone keeps its own volume buttons');
++ assert.strictEqual(ios.m.volume, 1, 'the check leaves the volume as it was');
++
++ // No sound track: after 2 s of decoded video and no audio decoded, the player says so.
++ const decoded = { audio: 0, video: 0 };
++ const quiet = player('<video data-ov-player src="/a.mp4"></video>', { decoded });
++ quiet.v.play();
++ decoded.video = 50000; quiet.m.currentTime = 1; quiet.f('timeupdate');
++ assert.ok(!quiet.root.classList.contains('is-silent'), 'not judged before 2 s of playback');
++ quiet.m.currentTime = 2.5; quiet.f('timeupdate');
++ assert.ok(quiet.root.classList.contains('is-silent'), 'no audio decoded: is-silent');
++ assert.strictEqual(quiet.q('.ov-player-mute').getAttribute('aria-label'), 'No sound in this video');
++ assert.ok(quiet.q('.ov-player-flash').textContent.includes('No sound in this video'), 'and says so once');
++ assert.strictEqual(quiet.q('.ov-player-unmute').hidden, true, 'no "Tap for sound" on a silent video');
++ quiet.q('.ov-player-mute').dispatchEvent(new quiet.w.Event('click'));
++ assert.strictEqual(quiet.m.muted, false, 'its speaker explains instead of muting');
++ // A new source asks again.
++ quiet.f('emptied');
++ assert.ok(!quiet.root.classList.contains('is-silent'));
++
++ // Sound decoded (muted or not): never called silent; a seek restarts the 2 s.
++ const loud = { audio: 0, video: 0 };
++ const withSound = player('<video data-ov-player muted src="/a.mp4"></video>', { decoded: loud });
++ withSound.v.play();
++ withSound.m.currentTime = 120; withSound.f('seeked');
++ loud.video = 40000; withSound.m.currentTime = 121; withSound.f('timeupdate');
++ assert.ok(!withSound.root.classList.contains('is-silent'), 'a seek restarts the count');
++ loud.audio = 9000; withSound.m.currentTime = 123; withSound.f('timeupdate');
++ assert.ok(!withSound.root.classList.contains('is-silent'), 'audio decoded while muted: it has sound');
++}
// The styles, and a mute icon from the one rounded set.
assert.match(css, /\.ov-player-unmute \{/);
--assert.match(css, /@media \(hover: none\) \{ \.ov-player-volume-slider \{ display: none; \} \}/, 'no hidden slider on touch screens');
++assert.match(css, /@media \(hover: none\) \{\s*\.ov-player-volume-slider \{ display: none; \}\s*\.ov-player\.can-volume \.ov-player-volume-slider \{ display: block; width: 84px; height: 36px;/, 'on touch screens the slider shows, finger-sized, where the volume can be set');
++assert.match(css, /\.ov-player\.is-silent \.ov-player-volume-slider \{ display: none; \}/);
assert.match(css, /\.ov-player\.has-frame \.ov-player-video/);
assert.match(src, /Material Icons Round/);
--console.log('player: all checks passed (4.13 sound, layers, feedback)');
++console.log('player: all checks passed (4.13 sound, layers, feedback; 4.14 volume on phones, no-sound videos)');