diff --git a/frontend/js/feed.js b/frontend/js/feed.js index 863ee12..2def2db 100644 --- a/frontend/js/feed.js +++ b/frontend/js/feed.js @@ -135,6 +135,59 @@ App.feed = App.feed || {}; if (scroller) scroller.scrollTo({ top: next * slideHeight(), behavior: 'smooth' }); }; + // ------------------------------------------------------------------ + // Sound in a split + // + // iOS gives a page one audible video at a time. Start a second one with + // sound and the system pauses the first -- which, in a split of two or + // four panels, reads as "only one panel is playing". Muted video carries + // no such limit, so where the rule applies exactly one panel keeps its + // sound and the rest are silenced: four pictures moving is what the split + // is for, and four audio tracks at once was never the point. + // + // Nothing in the platform announces this, and by the time the symptom + // shows -- a video we started, stopped by something that isn't us -- the + // reader has already watched a panel die. So it is read off the device + // rather than discovered. + const audioIsExclusive = (function() { + const ua = navigator.userAgent || ''; + if (/iPhone|iPad|iPod/.test(ua)) return true; + // iPadOS 13+ claims to be a Mac; the touch points give it away. + return /Macintosh/.test(ua) && (navigator.maxTouchPoints || 0) > 1; + })(); + + // Leaves at most one audible panel in the step being watched. `prefer` is + // the panel whose sound was just asked for, if any; otherwise the first + // one already audible keeps it. Silenced panels get their own button + // updated too, so what is shown never disagrees with what is heard. + const limitAudibleToOne = function(prefer) { + if (!audioIsExclusive) return; + const slide = slidesByIndex.get(state.feedActiveIndex); + if (!slide) return; + let kept = (prefer && prefer._muted === false) ? prefer : null; + panesOf(slide).forEach((pane) => { + if (pane._muted) return; + if (!kept) { kept = pane; return; } + if (pane === kept) return; + pane._muted = true; + if (pane._syncMute) pane._syncMute(); + }); + }; + + // Starts whatever in the current step is loaded but stopped -- which is + // what iOS leaves behind when a panel's sound is taken away from it. + const resumeActivePanes = function() { + const slide = slidesByIndex.get(state.feedActiveIndex); + if (!slide) return; + panesOf(slide).forEach((pane) => { + if (!pane.classList.contains('is-loaded')) return; + const video = pane.querySelector('.feed-video'); + if (!video || !video.paused) return; + const playPromise = video.play(); + if (playPromise && typeof playPromise.catch === 'function') playPromise.catch(() => {}); + }); + }; + // Remembers playback position per video id so scrolling away and back // resumes where the user left off. Slides kept in the window are merely // paused (instant resume); slides whose