Improve gallery video UX and add upload-to-publish media workflow.

Stage raw files in upload/, publish with make sync-media/publish, and polish the lightbox: autoplay, remembered volume, Escape to close, and image/video icons without poster or caption clutter.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-04 23:55:43 +12:00
parent 6c215d40e6
commit 3f5235daaf
22 changed files with 644 additions and 119 deletions

View File

@@ -22,7 +22,7 @@ func main() {
log.Fatal(err)
}
imagesDir := filepath.Join(root, "images")
imagesDir := envOr("IMAGES_DIR", filepath.Join(root, "images"))
staticDir := filepath.Join(root, "static")
if *thumbsOnly {
@@ -54,6 +54,7 @@ func main() {
srv.RegisterRoutes(mux)
addr := envOr("ADDR", ":8080")
log.Printf("gallery images: %s", imagesDir)
log.Printf("Technical Kiwi website listening on %s", addr)
if err := http.ListenAndServe(addr, middleware.Gzip(mux)); err != nil {
log.Fatal(err)

View File

@@ -2,7 +2,7 @@
set -e
# Browser: http://localhost:7331 (proxy injects auto-reload)
# App: http://127.0.0.1:8080 (internal, via proxy)
# App: http://127.0.0.1:8080 (direct, same app)
exec templ generate --watch \
--proxy="http://127.0.0.1:8080" \
--proxybind="0.0.0.0" \

View File

@@ -1 +0,0 @@

View File

@@ -16,7 +16,7 @@ type Image struct {
Collection string // event subfolder label, e.g. Anniversary
CollectionKey string // slug for filters
Filename string // basename
URL string
URL string // served file URL (/images/…)
ThumbURL string
HeroURL string
IsVideo bool

View File

@@ -15,6 +15,9 @@ func isVideo(name string) bool {
}
func isGalleryMedia(name string) bool {
if isWebDerivative(name) {
return false
}
return isJPEG(name) || isVideo(name)
}
@@ -34,3 +37,7 @@ func videoPosterRel(rel string) string {
}
return strings.TrimSuffix(rel, ext) + ".jpg"
}
func isWebDerivative(name string) bool {
return strings.HasSuffix(strings.ToLower(name), ".web.mp4")
}

View File

@@ -12,6 +12,15 @@ func TestVideoPosterRel(t *testing.T) {
}
}
func TestIsWebDerivative(t *testing.T) {
if !isWebDerivative("clip.web.mp4") {
t.Fatal("expected web derivative")
}
if isGalleryMedia("clip.web.mp4") {
t.Fatal("web derivative must not be listed")
}
}
func TestList_includesVideo(t *testing.T) {
dir := t.TempDir()
album := filepath.Join(dir, "portal")

View File

@@ -73,8 +73,6 @@ func New(imagesDir, staticDir string, mailCfg *mail.Config) (*Server, error) {
} else {
log.Print("gallery thumbnails: ready")
}
// Thumbnail URLs are resolved during gallery.List. Re-list after derivative
// generation so newly-created thumbs appear immediately on first load.
if refreshed, err := gallery.List(imagesDir); err != nil {
log.Printf("gallery refresh after thumbnails: %v", err)
} else {

3
app/scripts/convert-videos.sh Executable file
View File

@@ -0,0 +1,3 @@
#!/usr/bin/env bash
# Deprecated alias — use sync-media.sh (upload/ → app/images/).
exec "$(dirname "$0")/sync-media.sh" "$@"

193
app/scripts/sync-media.sh Executable file
View File

@@ -0,0 +1,193 @@
#!/usr/bin/env bash
# Copy photos and convert videos from upload/ into app/images/ (gallery source).
# Videos are always re-encoded for browser playback (H.264 + AAC, yuv420p, faststart).
#
# Usage (from app/):
# ./scripts/sync-media.sh
# UPLOAD_DIR=/upload IMAGES_DIR=/app/images ./scripts/sync-media.sh
#
# Requires: ffmpeg (for videos)
set -euo pipefail
upload_dir="${UPLOAD_DIR:-../upload}"
images_dir="${IMAGES_DIR:-images}"
dry_run=0
force=0
max_width=1920
crf=23
preset=medium
usage() {
sed -n '2,8p' "$0"
echo ""
echo "Options:"
echo " --upload-dir DIR Raw media source (default: ../upload)"
echo " --images-dir DIR Gallery output (default: images)"
echo " --dry-run Print actions only"
echo " --force Re-copy / re-encode even if output looks up to date"
echo " --max-width N Scale down wide video (default: 1920)"
echo " --crf N H.264 quality (default: 23)"
echo " -h, --help Show this help"
}
while [[ $# -gt 0 ]]; do
case "$1" in
--upload-dir) upload_dir=$2; shift 2 ;;
--images-dir) images_dir=$2; shift 2 ;;
--dry-run) dry_run=1; shift ;;
--force) force=1; shift ;;
--max-width) max_width=$2; shift 2 ;;
--crf) crf=$2; shift 2 ;;
-h | --help) usage; exit 0 ;;
*) echo "Unknown option: $1" >&2; usage; exit 1 ;;
esac
done
if ! command -v ffmpeg >/dev/null 2>&1; then
echo "ffmpeg is required." >&2
exit 1
fi
if [[ ! -d "$upload_dir" ]]; then
echo "Upload directory not found: $upload_dir" >&2
exit 1
fi
mkdir -p "$images_dir"
is_video_ext() {
case "${1,,}" in
.mp4 | .mov | .webm) return 0 ;;
*) return 1 ;;
esac
}
is_jpeg_ext() {
case "${1,,}" in
.jpg | .jpeg) return 0 ;;
*) return 1 ;;
esac
}
skip_name() {
local base=${1##*/}
[[ "$base" == .* ]] && return 0
[[ "$base" == *.web.mp4 ]] && return 0
[[ "$base" == *.converting.mp4 ]] && return 0
[[ "$base" == *.part ]] && return 0
return 1
}
skip_path() {
case "$1" in
*/thumbs/* | */hero/*) return 0 ;;
esac
return 1
}
needs_convert() {
local src=$1 dst=$2
if [[ -f "$dst" ]] && [[ "$force" -eq 0 ]] && [[ "$dst" -nt "$src" ]]; then
return 1
fi
return 0
}
copy_one() {
local src=$1 dst=$2 rel
rel=${src#"$upload_dir"/}
rel=${rel#/}
echo "→ copy $rel"
if [[ "$dry_run" -eq 1 ]]; then
echo " would write: ${dst#"$images_dir"/}"
return 0
fi
mkdir -p "$(dirname "$dst")"
cp -p "$src" "$dst"
echo " wrote: ${dst#"$images_dir"/}"
}
convert_one() {
local src=$1 dst=$2
local dir name tmp rel
dir=$(dirname "$dst")
name=$(basename "$dst" .mp4)
tmp="$dir/.${name}.converting.mp4"
rel=${src#"$upload_dir"/}
rel=${rel#/}
echo "→ convert $rel"
if [[ "$dry_run" -eq 1 ]]; then
echo " would write: ${dst#"$images_dir"/}"
return 0
fi
mkdir -p "$dir"
rm -f "$tmp"
trap 'rm -f "$tmp"' RETURN
ffmpeg -nostdin -hide_banner -loglevel error -y \
-i "$src" \
-vf "scale=min(${max_width}\\,iw):-2" \
-c:v libx264 -profile:v high -level:v 4.0 -pix_fmt yuv420p \
-crf "$crf" -preset "$preset" \
-c:a aac -b:a 128k -ac 2 \
-movflags +faststart \
-f mp4 \
"$tmp"
mv -f "$tmp" "$dst"
trap - RETURN
echo " wrote: ${dst#"$images_dir"/}"
}
copied=0
converted=0
skipped=0
while IFS= read -r -d '' src; do
base=$(basename "$src")
ext=".${base##*.}"
if skip_name "$base" || skip_path "$src"; then
continue
fi
rel=${src#"$upload_dir"/}
rel=${rel#/}
dst="$images_dir/$rel"
if is_jpeg_ext "$ext"; then
if [[ -f "$dst" ]] && [[ "$force" -eq 0 ]] && [[ "$dst" -nt "$src" ]]; then
((skipped++)) || true
continue
fi
copy_one "$src" "$dst"
((copied++)) || true
continue
fi
if ! is_video_ext "$ext"; then
continue
fi
dst="$images_dir/${rel%.*}.mp4"
if ! needs_convert "$src" "$dst"; then
((skipped++)) || true
continue
fi
convert_one "$src" "$dst"
((converted++)) || true
done < <(find "$upload_dir" -type f \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.mov' -o -iname '*.mp4' -o -iname '*.webm' \) -print0)
echo ""
echo "Done. Copied: $copied, converted: $converted, skipped: $skipped."
if [[ "$dry_run" -eq 1 ]]; then
echo "(dry run — no files changed)"
fi

View File

@@ -311,21 +311,26 @@ main {
background: var(--surface);
}
.gallery-video-badge {
.gallery-media-icon {
position: absolute;
left: 0.35rem;
bottom: 0.35rem;
padding: 0.15rem 0.4rem;
border-radius: 3px;
background: rgba(0, 0, 0, 0.65);
top: 0.5rem;
right: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 4px;
background: rgba(20, 16, 12, 0.8);
color: var(--text);
font-family: var(--mono);
font-size: 0.65rem;
letter-spacing: 0.04em;
text-transform: uppercase;
pointer-events: none;
}
.gallery-media-icon svg {
width: 1rem;
height: 1rem;
}
.gallery-item:hover img {
transform: scale(1.04);
}
@@ -524,54 +529,30 @@ main {
background: #000;
}
.modal-video {
position: relative;
width: 100%;
}
.modal-figure img,
.modal-figure video {
.modal-figure > img {
display: block;
width: 100%;
max-height: 75vh;
min-height: 12rem;
object-fit: contain;
border-radius: 8px;
background: #000;
}
.modal-figure video.video-broken {
display: none;
}
.video-unavailable {
margin: 0;
padding: 1.25rem;
text-align: center;
color: var(--text-muted);
background: #000;
border-radius: 8px;
min-height: 12rem;
.modal-video-stack {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
width: 100%;
}
.video-unavailable[hidden] {
display: none !important;
}
.video-unavailable a {
color: var(--accent);
}
.modal-figure figcaption {
margin-top: 0.75rem;
color: var(--text-muted);
font-family: var(--mono);
font-size: 0.85rem;
.modal-video-stack video {
display: block;
width: 100%;
max-height: 75vh;
min-height: 3.5rem;
object-fit: contain;
border-radius: 8px;
background: #000;
}
.modal-nav {
@@ -742,14 +723,25 @@ main {
min-height: 0;
}
.modal-figure img,
.modal-figure video {
.modal-figure > img {
flex: 1;
min-height: 0;
max-height: none;
object-fit: contain;
}
.modal-video-stack {
flex: 1;
min-height: 0;
justify-content: center;
}
.modal-video-stack video {
flex: 1;
min-height: 0;
max-height: none;
}
.modal-nav {
flex-shrink: 0;
gap: 0.5rem;

275
app/static/video-test.html Normal file
View File

@@ -0,0 +1,275 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Video playback test — Technical Kiwi</title>
<style>
:root {
--bg: #14100c;
--surface: #2a2219;
--text: #f5efe6;
--muted: #b5a090;
--accent: #c47b3a;
--border: rgba(245, 239, 230, 0.12);
--mono: ui-monospace, "Cascadia Code", monospace;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
}
main {
max-width: 960px;
margin: 0 auto;
padding: 1.5rem;
}
h1 { font-size: 1.35rem; margin: 0 0 0.5rem; }
p.lead { color: var(--muted); margin: 0 0 1.5rem; }
label { display: block; font-size: 0.85rem; color: var(--muted); margin-bottom: 0.35rem; }
.row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1rem;
}
input[type="text"] {
flex: 1 1 16rem;
min-width: 0;
padding: 0.55rem 0.75rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
color: var(--text);
font: inherit;
}
button, .btn {
padding: 0.55rem 1rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
color: var(--text);
font: inherit;
cursor: pointer;
}
button:hover, .btn:hover { border-color: var(--accent); }
.panel {
border: 1px solid var(--border);
border-radius: 12px;
padding: 1rem;
margin-bottom: 1rem;
background: rgba(42, 34, 25, 0.5);
}
.panel h2 { font-size: 1rem; margin: 0 0 0.75rem; }
video {
display: block;
width: 100%;
max-height: 60vh;
background: #000;
border-radius: 8px;
}
.meta {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));
gap: 0.5rem 1rem;
font-family: var(--mono);
font-size: 0.8rem;
margin-top: 0.75rem;
}
.meta dt { color: var(--muted); }
.meta dd { margin: 0; word-break: break-all; }
#log {
font-family: var(--mono);
font-size: 0.75rem;
max-height: 14rem;
overflow: auto;
margin: 0;
padding: 0.75rem;
background: #0a0806;
border-radius: 8px;
list-style: none;
}
#log li { margin: 0.15rem 0; }
#log .err { color: #f87171; }
#log .ok { color: #86efac; }
table.codec {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
}
table.codec th, table.codec td {
text-align: left;
padding: 0.35rem 0.5rem;
border-bottom: 1px solid var(--border);
}
table.codec th { color: var(--muted); font-weight: 500; }
a { color: var(--accent); }
</style>
</head>
<body>
<main>
<h1>Video playback test</h1>
<p class="lead">
Served from <code>/static/video-test.html</code>.
Use with <code>make dev</code> at
<a href="http://localhost:7331/static/video-test.html">localhost:7331</a>
or <a href="http://localhost:8080/static/video-test.html">8080</a>.
Path is under <code>/images/…</code> (copy from gallery Network tab).
</p>
<div class="panel">
<label for="path">Video path (site-relative)</label>
<div class="row">
<input id="path" type="text" placeholder="/images/album/clip.mp4" spellcheck="false">
<button type="button" id="load">Load</button>
<a class="btn" id="open-tab" href="#" target="_blank" rel="noopener">Open URL</a>
</div>
<p style="margin:0;font-size:0.85rem;color:var(--muted)">
Query link: <code>?path=/images/…</code> (encode slashes as <code>%2F</code> if needed)
</p>
</div>
<div class="panel">
<h2>Player</h2>
<video id="player" controls playsinline preload="auto"></video>
<dl class="meta" id="stats">
<div><dt>src</dt><dd id="stat-src"></dd></div>
<div><dt>readyState</dt><dd id="stat-ready"></dd></div>
<div><dt>dimensions</dt><dd id="stat-dim"></dd></div>
<div><dt>duration</dt><dd id="stat-dur"></dd></div>
<div><dt>networkState</dt><dd id="stat-net"></dd></div>
<div><dt>error</dt><dd id="stat-err"></dd></div>
</dl>
</div>
<div class="panel">
<h2>Browser codec hints (canPlayType)</h2>
<table class="codec" id="codec-table">
<thead><tr><th>MIME</th><th>Result</th></tr></thead>
<tbody></tbody>
</table>
</div>
<div class="panel">
<h2>Events</h2>
<ul id="log"></ul>
</div>
</main>
<script>
(function () {
var pathInput = document.getElementById("path");
var player = document.getElementById("player");
var logEl = document.getElementById("log");
var openTab = document.getElementById("open-tab");
var codecs = [
'video/mp4; codecs="avc1.42E01E, mp4a.40.2"',
'video/mp4; codecs="hvc1.1.6.L93.B0, mp4a.40.2"',
'video/mp4; codecs="hev1.1.6.L93.B0, mp4a.40.2"',
"video/mp4",
"video/quicktime",
"video/webm; codecs=\"vp9, opus\"",
"video/webm",
];
function log(msg, cls) {
var li = document.createElement("li");
if (cls) li.className = cls;
li.textContent = new Date().toISOString().slice(11, 23) + " " + msg;
logEl.prepend(li);
}
function updateStats() {
document.getElementById("stat-src").textContent = player.currentSrc || player.src || "—";
document.getElementById("stat-ready").textContent = String(player.readyState);
document.getElementById("stat-dim").textContent =
player.videoWidth + "×" + player.videoHeight;
document.getElementById("stat-dur").textContent = isFinite(player.duration)
? player.duration.toFixed(2) + "s"
: "—";
document.getElementById("stat-net").textContent = String(player.networkState);
var err = player.error;
document.getElementById("stat-err").textContent = err
? err.code + " " + (err.message || mediaErrorLabel(err.code))
: "—";
}
function mediaErrorLabel(code) {
return (
{ 1: "MEDIA_ERR_ABORTED", 2: "MEDIA_ERR_NETWORK", 3: "MEDIA_ERR_DECODE", 4: "MEDIA_ERR_SRC_NOT_SUPPORTED" }[code] || "?"
);
}
function normalizePath(raw) {
raw = (raw || "").trim();
if (!raw) return "";
if (raw.startsWith("http://") || raw.startsWith("https://")) {
try {
var u = new URL(raw);
return u.pathname;
} catch (e) {
return raw;
}
}
return raw.startsWith("/") ? raw : "/" + raw;
}
function loadFromInput() {
var path = normalizePath(pathInput.value);
if (!path) {
log("No path entered", "err");
return;
}
pathInput.value = path;
logEl.innerHTML = "";
log("Loading " + path);
player.removeAttribute("poster");
player.src = path;
openTab.href = path;
player.load();
history.replaceState(null, "", "?path=" + encodeURIComponent(path));
updateStats();
}
[
"loadstart", "loadedmetadata", "loadeddata", "canplay", "canplaythrough",
"playing", "pause", "ended", "error", "stalled", "waiting", "suspend",
].forEach(function (ev) {
player.addEventListener(ev, function () {
var cls = ev === "error" ? "err" : ev === "canplay" || ev === "playing" ? "ok" : "";
log(ev + (ev === "error" && player.error ? " " + mediaErrorLabel(player.error.code) : ""));
if (cls && ev !== "error") logEl.querySelector("li").className = cls;
updateStats();
});
});
document.getElementById("load").addEventListener("click", loadFromInput);
pathInput.addEventListener("keydown", function (e) {
if (e.key === "Enter") loadFromInput();
});
var tbody = document.querySelector("#codec-table tbody");
var probe = document.createElement("video");
codecs.forEach(function (mime) {
var tr = document.createElement("tr");
var tdM = document.createElement("td");
var tdR = document.createElement("td");
tdM.textContent = mime;
tdR.textContent = probe.canPlayType(mime) || '""';
tr.appendChild(tdM);
tr.appendChild(tdR);
tbody.appendChild(tr);
});
var q = new URLSearchParams(location.search).get("path");
if (q) {
pathInput.value = q;
loadFromInput();
}
})();
</script>
</body>
</html>

View File

@@ -33,9 +33,7 @@ templ GalleryGrid(images []gallery.Image) {
} else {
<span class="gallery-placeholder" aria-hidden="true"></span>
}
if img.IsVideo {
<span class="gallery-video-badge" aria-hidden="true">Video</span>
}
@GalleryMediaIcon(img.IsVideo)
if img.Collection != "" {
<span class="gallery-album">{ img.Collection }</span>
} else if img.Album != "" {
@@ -49,6 +47,20 @@ templ GalleryGrid(images []gallery.Image) {
}
}
templ GalleryMediaIcon(isVideo bool) {
<span class="gallery-media-icon" aria-hidden="true">
if isVideo {
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"></path>
</svg>
} else {
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"></path>
</svg>
}
</span>
}
templ ImageModal(img gallery.Image, prevPath, nextPath string) {
<div
class="modal-backdrop"
@@ -65,21 +77,12 @@ templ ImageModal(img gallery.Image, prevPath, nextPath string) {
</button>
<figure class="modal-figure">
if img.IsVideo {
<div class="modal-video">
<video controls playsinline preload="auto" poster={ img.ThumbURL }>
<source src={ img.URL } type={ videoMIME(img.Filename) }/>
</video>
<p class="video-unavailable" hidden>
Playback is not supported in this browser.
<a href={ img.URL } download={ img.Filename }>Download the video</a>
</p>
<div class="modal-video-stack">
<video src={ img.URL } controls autoplay playsinline preload="auto"></video>
</div>
} else {
<img src={ img.URL } alt={ img.Filename }/>
}
if !img.Date.IsZero() {
<figcaption>{ modalCaption(img) }</figcaption>
}
</figure>
<nav class="modal-nav" aria-label="Gallery navigation">
if prevPath != "" {
@@ -124,13 +127,3 @@ func modalAriaLabel(img gallery.Image) string {
}
return "Image viewer"
}
func modalCaption(img gallery.Image) string {
if img.Collection != "" {
return img.Collection + " — " + formatDate(img.Date)
}
if img.Album != "" {
return gallery.AlbumLabel(img.Album) + " — " + formatDate(img.Date)
}
return formatDate(img.Date)
}

View File

@@ -1,22 +1,7 @@
package templates
import (
"path/filepath"
"strings"
"time"
)
import "time"
func currentYear() string {
return time.Now().Format("2006")
}
func videoMIME(filename string) string {
switch strings.ToLower(filepath.Ext(filename)) {
case ".webm":
return "video/webm"
case ".mov":
return "video/quicktime"
default:
return "video/mp4"
}
}

View File

@@ -68,27 +68,63 @@ templ Layout(title string, preloadImage string, content templ.Component) {
document.addEventListener("DOMContentLoaded", function () {
hydrateLazyThumbs(document);
var modalRoot = document.getElementById("modal-root");
if (!modalRoot) return;
modalRoot.addEventListener("volumechange", function (event) {
if (event.target.tagName !== "VIDEO") return;
saveVideoVolume(event.target.volume);
});
});
function initModalVideos(root) {
root.querySelectorAll(".modal-video video").forEach(function (video) {
var wrap = video.closest(".modal-video");
var notice = wrap && wrap.querySelector(".video-unavailable");
video.addEventListener(
"error",
function () {
video.classList.add("video-broken");
if (notice) notice.hidden = false;
},
{ once: true },
);
video.load();
});
function closeModal() {
var modal = document.getElementById("modal-root");
if (modal) modal.innerHTML = "";
}
var galleryVideoVolumeKey = "gallery-video-volume";
function getSavedVideoVolume() {
try {
var stored = localStorage.getItem(galleryVideoVolumeKey);
if (stored === null) return null;
var v = parseFloat(stored);
if (!isFinite(v) || v < 0 || v > 1) return null;
return v;
} catch (e) {
return null;
}
}
function saveVideoVolume(volume) {
try {
localStorage.setItem(galleryVideoVolumeKey, String(volume));
} catch (e) {}
}
function playModalVideo() {
var video = document.querySelector("#modal-root video");
if (!video) return;
var saved = getSavedVideoVolume();
if (saved !== null) video.volume = saved;
var p = video.play();
if (p && typeof p.catch === "function") {
p.catch(function () {});
}
}
document.addEventListener("keydown", function (event) {
if (event.key !== "Escape") return;
var modal = document.getElementById("modal-root");
if (!modal || !modal.firstElementChild) return;
event.preventDefault();
closeModal();
});
document.body.addEventListener("htmx:afterSwap", function (event) {
hydrateLazyThumbs(event.target);
initModalVideos(event.target);
if (event.detail && event.detail.target && event.detail.target.id === "modal-root") {
playModalVideo();
}
});
})();
</script>