/*
[RFC-0202] Living-photos runtime. Wires the `in-viewport` (IntersectionObserver) and `tap`
(button) playback triggers for [data-live-photo] elements emitted by . No GSAP, no
framework — a few KB of vanilla DOM. The `autoplay` trigger is native and is never touched
here (those elements carry no data-trigger). Respects prefers-reduced-motion: non-interactive
playback is suppressed (static poster wins); a user tap always wins.
- Do not handle the autoplay (native) path — those elements have no data-trigger.
- Do not resolve clips or own presentation — that is @warpgogol/werkstatt-site/ui.
- RFC-0202: introduced the living-photos runtime.
*/
export interface LivePhotosOptions {
prefersReducedMotion?: boolean;
}
const PLAYING = "live-photo--playing";
const USER_PLAYING = "live-photo--user-playing";
const READY = "live-photo--ready";
const UNSUPPORTED = "live-photo--unsupported";
/** Cached WebM support check — true if browser claims VP8/VP9 support. */
let webmSupportCache: boolean | undefined;
function isIOSLike(): boolean {
return (
/iPad|iPhone|iPod/.test(navigator.userAgent) ||
(navigator.platform === "MacIntel" && "ontouchend" in document)
);
}
/** Detect WebM support via canPlayType. Now more optimistic since we have MP4 fallbacks. */
function detectWebMSupport(): boolean {
if (webmSupportCache !== undefined) return webmSupportCache;
const video = document.createElement("video");
const canPlay =
video.canPlayType('video/webm; codecs="vp8, vorbis"') !== "" ||
video.canPlayType('video/webm; codecs="vp9"') !== "" ||
video.canPlayType("video/webm") !== "";
// Check if MP4 is supported as fallback (almost universal)
const canPlayMP4 =
video.canPlayType('video/mp4; codecs="avc1.42E01E"') !== "" ||
video.canPlayType("video/mp4") !== "";
// If we have MP4 support, we can be less strict about WebM
// iOS will get MP4 fallback, so we don't need to mark as unsupported
webmSupportCache = canPlay || canPlayMP4;
return webmSupportCache;
}
/** Mark a living photo as unsupported — hide video, keep poster visible. */
function markUnsupported(root: HTMLElement, video: HTMLVideoElement): void {
root.classList.add(UNSUPPORTED);
root.classList.remove(PLAYING, USER_PLAYING, READY);
video.style.display = "none";
// Hide the toggle button on unsupported devices
const toggle = root.querySelector(".live-photo__toggle");
if (toggle) toggle.style.display = "none";
}
function setReady(root: HTMLElement, ready: boolean): void {
if (ready) {
root.classList.add(READY);
} else {
root.classList.remove(READY);
}
}
function ensureVideoSources(video: HTMLVideoElement): void {
let changed = false;
for (const source of Array.from(video.querySelectorAll("source[data-src]"))) {
const src = source.dataset.src;
if (!src || source.getAttribute("src") === src) continue;
source.setAttribute("src", src);
changed = true;
}
if (changed) video.load();
}
function attemptPlay(root: HTMLElement, video: HTMLVideoElement, _byUser: boolean): void {
ensureVideoSources(video);
const canPlay = video.readyState >= 3; // HAVE_FUTURE_DATA
if (!canPlay) {
// Wait for video to be ready before playing (prevents iOS black flash)
video.addEventListener(
"canplaythrough",
() => {
setReady(root, true);
void video.play().catch(() => handlePlayFailure(root));
},
{ once: true },
);
// Also listen for error — if video can't load, revert to poster
video.addEventListener(
"error",
() => {
handlePlayFailure(root);
},
{ once: true },
);
return;
}
setReady(root, true);
void video.play().catch(() => handlePlayFailure(root));
}
function handlePlayFailure(root: HTMLElement): void {
// Play policy rejected (e.g., iOS without user gesture) or video error — restore poster
root.classList.remove(PLAYING, USER_PLAYING, READY);
const toggle = root.querySelector(".live-photo__toggle");
if (toggle) toggle.setAttribute("aria-pressed", "false");
}
function setPlaying(
root: HTMLElement,
video: HTMLVideoElement,
on: boolean,
byUser: boolean,
): void {
if (on) {
root.classList.add(PLAYING);
if (byUser) root.classList.add(USER_PLAYING);
attemptPlay(root, video, byUser);
} else {
root.classList.remove(PLAYING, USER_PLAYING, READY);
video.pause();
}
const toggle = root.querySelector(".live-photo__toggle");
if (toggle) toggle.setAttribute("aria-pressed", String(root.classList.contains(PLAYING)));
}
export async function initLivePhotos(options: LivePhotosOptions = {}): Promise {
const prefersReducedMotion =
options.prefersReducedMotion ?? window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const roots = document.querySelectorAll("[data-live-photo][data-trigger]");
if (roots.length === 0) return;
// One shared observer for all in-viewport clips.
const observed = new Map();
const observer =
"IntersectionObserver" in window
? new IntersectionObserver(
(entries) => {
for (const entry of entries) {
const video = observed.get(entry.target);
if (!video) continue;
const isIntersecting = entry.isIntersecting;
// iOS Safari respects autoplay attribute more than programmatic play()
if (isIntersecting) {
video.setAttribute("autoplay", "");
} else {
video.removeAttribute("autoplay");
}
setPlaying(entry.target as HTMLElement, video, isIntersecting, false);
}
},
{ threshold: 0.25 },
)
: null;
for (const root of Array.from(roots)) {
if (root.dataset.livePhotoReady === "true") continue;
root.dataset.livePhotoReady = "true";
const video = root.querySelector(".live-photo__video");
if (!video) continue;
// Check WebM support early — if unsupported, hide video and keep poster
if (!detectWebMSupport()) {
markUnsupported(root, video);
continue;
}
const mp4Src = root.dataset.mp4Src;
// iOS Safari decodes VP8/VP9 WebM but ignores its alpha channel: a transparent
// living photo plays with a dirty opaque background instead of the cut-out subject.
// (Other browsers honour VP9 alpha and render it correctly.) These decorative
// portraits ship WebM-only — there is no iOS-playable alpha source — so the only
// correct iOS behaviour is to keep the transparent static poster. When an explicit
// MP4 fallback is authored (opaque clips), we still let iOS play it below.
if (isIOSLike() && !mp4Src) {
markUnsupported(root, video);
continue;
}
if (mp4Src && isIOSLike()) {
const firstSource = video.querySelector("source");
if (firstSource) {
firstSource.dataset.src = mp4Src;
firstSource.setAttribute("type", "video/mp4");
}
}
const trigger = root.dataset.trigger;
const tapBehavior = root.dataset.tapBehavior ?? "toggle";
const toggle = root.querySelector(".live-photo__toggle");
// Control: user-initiated, always permitted (even under reduced motion).
if (toggle) {
toggle.addEventListener("click", () => {
const playing = root.classList.contains(PLAYING);
if (playing && tapBehavior === "toggle") {
setPlaying(root, video, false, false);
} else {
// For iOS: ensure autoplay attribute is set before user-triggered play
video.setAttribute("autoplay", "");
setPlaying(root, video, true, true);
}
});
}
// Video load error handling — iOS may fail to decode WebM
video.addEventListener("error", () => {
// Video failed to load/decode — ensure poster remains visible
root.classList.remove(PLAYING, USER_PLAYING, READY);
if (!mp4Src) video.style.display = "none";
});
// Metadata loaded — we know dimensions but may not be ready to play yet
video.addEventListener("loadedmetadata", () => {
// Video metadata available, but we wait for canplaythrough before marking ready
});
// in-viewport auto playback — suppressed under reduced motion (poster stays).
if (trigger === "in-viewport" && !prefersReducedMotion && observer) {
observed.set(root, video);
observer.observe(root);
}
// Pause when tab is hidden (mobile UX + power saving)
document.addEventListener("visibilitychange", () => {
if (document.hidden && root.classList.contains(PLAYING)) {
setPlaying(root, video, false, false);
}
});
}
document.documentElement.setAttribute("data-live-photos-initialized", "true");
}