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)
Changes
@@@ -4,6 +4,18 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semvebreaking 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 …@@@ -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 {@@@ -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) {@@@ -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)');
@@ -4,6 +4,18 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -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.
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).
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
## 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 …
- **Switching accounts works on every site** (`navbar.js`; the owner, 2026-10-26: "the thing that lets you switch …
@@ -174,8 +174,18 @@
@@ -174,8 +174,18 @@
.ov-player.is-waiting:not(.is-paused) .ov-player-spinner { display: block; }
.ov-player.is-waiting:not(.is-paused) .ov-player-spinner { display: block; }
@keyframes ov-player-spin { to { transform: rotate(360deg); } }
@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; } }
@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:empty { display: none; }
.ov-player-message {
.ov-player-message {
@@ -15,6 +15,9 @@
@@ -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
* 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
* 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.
* 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,
* 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.
* data-ov-thumb (a tiny inline image) shows blurred, the poster sharpens over it, and the first frame fades in.
@@ -71,6 +74,16 @@
@@ -71,6 +74,16 @@
b.innerHTML = icon;
b.innerHTML = icon;
return b;
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 captionTrack = (video) => Array.from(video.textTracks || []).find((t) => t.kind === 'captions' || t.kind === 'subtitles') || null;
const fullscreenElement = () => document.fullscreenElement || document.webkitFullscreenElement || null;
const fullscreenElement = () => document.fullscreenElement || document.webkitFullscreenElement || null;
@@ -235,6 +248,13 @@
@@ -235,6 +248,13 @@
if (saved && !video.hasAttribute('muted')) { video.volume = Math.min(1, Math.max(0, saved.volume)); video.muted = !!saved.muted; }
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.
// 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);
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 ───────────────────────────────────────
// ── State → UI ───────────────────────────────────────
const duration = () => (Number.isFinite(video.duration) ? video.duration : 0);
const duration = () => (Number.isFinite(video.duration) ? video.duration : 0);
@@ -266,10 +286,11 @@
@@ -266,10 +286,11 @@
}
}
function paintVolume() {
function paintVolume() {
const v = video.muted ? 0 : video.volume;
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);
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;
muteBtn.setAttribute('aria-label', label); muteBtn.title = label;
volSlider.value = String(v);
volSlider.value = String(v);
volSlider.style.setProperty('--ovp-fill', `${v * 100}%`);
volSlider.style.setProperty('--ovp-fill', `${v * 100}%`);
@@ -331,16 +352,29 @@
@@ -331,16 +352,29 @@
// A short sign in the middle: a seek, a volume level (the double tap and the keys).
// A short sign in the middle: a seek, a volume level (the double tap and the keys).
let flashTimer = 0;
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.className = `ov-player-flash is-on${side ? ` is-${side}` : ''}`;
flash.innerHTML = `${icon}<span>${text}</span>`;
flash.innerHTML = `${icon}<span>${text}</span>`;
clearTimeout(flashTimer);
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(); });
unmute.addEventListener('click', (e) => { e.stopPropagation(); api.unmute(); });
big.addEventListener('click', () => api.toggle());
big.addEventListener('click', () => api.toggle());
playBtn.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)));
volSlider.addEventListener('input', () => api.setVolume(Number(volSlider.value)));
ccBtn.addEventListener('click', () => api.toggleCaptions());
ccBtn.addEventListener('click', () => api.toggleCaptions());
speedBtn.addEventListener('click', () => api.nextSpeed());
speedBtn.addEventListener('click', () => api.nextSpeed());
@@ -424,14 +458,16 @@
@@ -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('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('pause', () => { paintPlay(); paintVolume(); if (ambient) ambient.stop(); });
video.addEventListener('ended', paintPlay);
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('durationchange', paintTime);
video.addEventListener('loadedmetadata', () => { paintTime(); paintCaptions(); });
video.addEventListener('loadedmetadata', () => { paintTime(); paintCaptions(); });
video.addEventListener('progress', paintBuffered);
video.addEventListener('progress', paintBuffered);
video.addEventListener('volumechange', paintVolume);
video.addEventListener('volumechange', paintVolume);
video.addEventListener('ratechange', paintSpeed);
video.addEventListener('ratechange', paintSpeed);
video.addEventListener('waiting', () => root.classList.add('is-waiting'));
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('canplay', () => root.classList.remove('is-waiting'));
video.addEventListener('error', () => { root.classList.remove('is-waiting'); message.textContent = 'This video could not be played.'; });
video.addEventListener('error', () => { root.classList.remove('is-waiting'); message.textContent = 'This video could not be played.'; });
if (video.textTracks && video.textTracks.addEventListener) {
if (video.textTracks && video.textTracks.addEventListener) {
@@ -166,9 +166,73 @@ console.log('player: all checks passed');
@@ -166,9 +166,73 @@ console.log('player: all checks passed');
v.__ovPlayer.skip(10);
v.__ovPlayer.skip(10);
assert.ok(q('.ov-player-flash').className.includes('is-right') && q('.ov-player-flash').textContent.includes('+10 s'));
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.
// The styles, and a mute icon from the one rounded set.
assert.match(css, /\.ov-player-unmute \{/);
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(css, /\.ov-player\.has-frame \.ov-player-video/);
assert.match(src, /Material Icons Round/);
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)');