From 20f55786dfbde919003696286eca49210ca4f910 Mon Sep 17 00:00:00 2001 From: Alex Bates Date: Mon, 12 Oct 2026 11:31:44 +0100 Subject: [PATCH] support YouTube videos in mod galleries --- assets/components.css | 59 ++++++++++ assets/components/featured_carousel.js | 52 ++++++++- assets/components/media_carousel.js | 120 ++++++++++++++------ assets/components/media_input.css | 29 +++++ assets/components/media_input.js | 75 ++++++++++-- assets/components/youtube.js | 108 ++++++++++++++++++ src/atproto/lexicon/mod_listing.rs | 151 +++++++++++++++++++++++-- src/components/media_input.rs | 31 ++++- src/homepage.rs | 21 ++-- src/mods.rs | 69 ++++++++--- src/transfer.rs | 6 +- tests/test_media_gallery.py | 37 ++++++ 12 files changed, 670 insertions(+), 88 deletions(-) create mode 100644 assets/components/youtube.js diff --git a/assets/components.css b/assets/components.css index 82c6098..f59f70c 100644 --- a/assets/components.css +++ b/assets/components.css @@ -1845,10 +1845,15 @@ download-fields { } .featured-image { + position: relative; aspect-ratio: 4 / 3; overflow: hidden; border-inline-end: 3px solid var(--outline); + &[data-youtube] { + cursor: pointer; + } + & > img { inline-size: 100%; block-size: 100%; @@ -2264,3 +2269,57 @@ download-fields { .mod-card-byline { margin-block-start: var(--space-1); } + +/* A YouTube video playing over a gallery image, and the button that starts + it. */ +media-carousel > [data-youtube] { + position: relative; + + & > img { + inline-size: 100%; + block-size: 100%; + object-fit: contain; + } +} + +.video-player { + position: absolute; + inset: 0; + background: black; + + & iframe { + inline-size: 100%; + block-size: 100%; + border: 0; + } +} + +.video-play { + all: unset; + position: absolute; + inset: 0; + margin: auto; + display: grid; + place-items: center; + inline-size: 3.5rem; + block-size: 3.5rem; + border-radius: 50%; + background: oklch(0% 0 0 / 0.65); + color: white; + cursor: pointer; + + &:hover, + &:focus-visible { + background: oklch(0% 0 0 / 0.85); + } + + &:has(~ .video-player) { + display: none; + } + + & svg { + inline-size: 1.75rem; + block-size: 1.75rem; + fill: currentcolor; + } +} diff --git a/assets/components/featured_carousel.js b/assets/components/featured_carousel.js index 51a61a3..71842b7 100644 --- a/assets/components/featured_carousel.js +++ b/assets/components/featured_carousel.js @@ -1,8 +1,12 @@ // moves the homepage's featured mods along, sliding each // into place: clicking a mod either side of the current one, a dot, an arrow // key, or swiping on a touch screen moves to it. It also moves on by itself, -// except while the pointer or a keyboard's focus is on it. Without a script, the first -// shows, with the ones either side linking straight to their pages. +// except while the pointer or a keyboard's focus is on it, or a video is +// playing. A mod with a video shows its first image with a button that plays +// the video in its place. Without a script, the first shows, with the ones +// either side linking straight to their pages. + +import { addPlayButton, playVideo } from "./youtube.js"; // How far a swipe has to go, in pixels, to count. const SWIPE = 40; @@ -16,10 +20,27 @@ class FeaturedCarousel extends HTMLElement { #current = 0; #timer = null; #hovered = false; + // Whether the carousel is set to move on by itself, which isn't while + // it's being used. + #autoplaying = false; + // Stops the video playing on the current slide, if one is. + #stopVideo = null; connectedCallback() { this.#slides = [...this.querySelectorAll(".featured-slide")]; this.#dots = [...this.querySelectorAll(".featured-dots > button")]; + this.#slides.forEach((slide, n) => { + const image = slide.querySelector(".featured-image[data-youtube]"); + if (!image) return; + addPlayButton(image, () => this.#playVideo(n)); + // Anywhere on the current mod's image plays its video, rather + // than opening the mod's page. The others' move to them. + image.addEventListener("click", (event) => { + if (n !== this.#current) return; + event.preventDefault(); + this.#playVideo(n); + }); + }); if (this.#slides.length < 2) return; this.querySelector(".featured-dots").hidden = false; @@ -75,27 +96,52 @@ class FeaturedCarousel extends HTMLElement { disconnectedCallback() { this.#stop(); + this.#stopVideo?.(); } + // Moves on after a few seconds, but not from a video being watched. #play() { this.#stop(); if (matchMedia("(prefers-reduced-motion: reduce)").matches) return; if (this.#hovered || this.querySelector(":focus-visible")) return; + this.#autoplaying = true; + if (this.#stopVideo) return; this.#timer = setInterval(() => this.#show(this.#current + 1), AUTOPLAY); } #stop() { clearInterval(this.#timer); this.#timer = null; + this.#autoplaying = false; + } + + #endVideo() { + this.#stopVideo?.(); + this.#stopVideo = null; + } + + // Plays the video of slide `index`, in place of its image. + #playVideo(index) { + if (index !== this.#current) this.#show(index); + this.#endVideo(); + const image = this.#slides[index].querySelector(".featured-image[data-youtube]"); + this.#stopVideo = playVideo(image, image.dataset.youtube, () => { + this.#stopVideo = null; + this.#play(); + }); + clearInterval(this.#timer); + this.#timer = null; } #show(index) { const count = this.#slides.length; const hadFocus = this.#slides[this.#current].contains(document.activeElement); + const autoplaying = this.#autoplaying; this.#current = (index + count) % count; + this.#endVideo(); this.#update(); // Moving by hand starts the wait for the next one over. - if (this.#timer !== null) this.#play(); + if (autoplaying) this.#play(); // Focus would otherwise be left on a slide that's moved aside. if (hadFocus) this.#slides[this.#current].querySelector("a")?.focus(); } diff --git a/assets/components/media_carousel.js b/assets/components/media_carousel.js index e38fddc..cdb5fe9 100644 --- a/assets/components/media_carousel.js +++ b/assets/components/media_carousel.js @@ -1,4 +1,5 @@ import { html, css } from "./dom.js"; +import { addPlayButton, playVideo } from "./youtube.js"; const STYLE = css` :host { @@ -24,7 +25,8 @@ const STYLE = css` display: none; } - ::slotted(img) { + ::slotted(img), + ::slotted(div) { flex: 0 0 100%; scroll-snap-align: start; width: 100%; @@ -86,14 +88,89 @@ class MediaCarousel extends HTMLElement { htmx.process(shadow); - // Child elements may not be parsed yet, so defer the - // button/autoplay setup until the end of the current parse task. + // Child elements may not be parsed yet, so defer the button/autoplay + // setup until the end of the current parse task. requestAnimationFrame(() => this.#setup(shadow, track)); } #setup(shadow, track) { - const count = this.querySelectorAll("img").length; - if (count < 2) return; + const slides = [...this.children]; + const count = slides.length; + + function currentIndex() { + return Math.round(track.scrollLeft / track.clientWidth); + } + + function scrollToIndex(index) { + const wrapped = (index + count) % count; + track.scrollTo({ left: wrapped * track.clientWidth, behavior: "smooth" }); + } + + let timer = null; + let hovering = false; + let focused = false; + + // The video playing on the slide it's in, if one is, and the slides + // whose video has been played through. + let playing = null; + const watched = new Set(); + + const isVideo = (index) => slides[index]?.dataset.youtube !== undefined; + const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)").matches; + + const stopVideo = () => { + playing?.stop(); + playing = null; + }; + + // Moves on when a video is over, unless someone's using the carousel. + // One that couldn't play keeps its image for as long as any other. + const startVideo = (index, muted) => { + stopVideo(); + watched.delete(index); + clearInterval(timer); + const stop = playVideo( + slides[index], + slides[index].dataset.youtube, + (ended) => { + playing = null; + watched.add(index); + if (ended && count > 1 && !hovering && !focused) scrollToIndex(index + 1); + else restart(); + }, + muted, + ); + playing = { index, stop }; + }; + + // A slide that isn't a video, or a video that's played through, moves + // on after a few seconds. A video's own end moves on from it. + const restart = () => { + clearInterval(timer); + if (hovering || focused || count < 2) return; + if (isVideo(currentIndex()) && !watched.has(currentIndex())) return; + timer = setInterval(() => scrollToIndex(currentIndex() + 1), 5000); + }; + + // Plays the video that's now showing, and stops the one that's gone. + const settle = () => { + const index = currentIndex(); + if (playing && playing.index !== index) stopVideo(); + if (!playing && isVideo(index) && !reducedMotion) startVideo(index, true); + restart(); + }; + + // Without autoplay, a video starts when asked. + if (reducedMotion) { + slides.forEach((slide, index) => { + if (isVideo(index)) addPlayButton(slide, () => startVideo(index, false)); + }); + } + + if (count < 2) { + settle(); + return; + } shadow.appendChild(html` + `.firstElementChild; return item; } + // The id of the YouTube video a link is to, as a watch, short, embed, + // live, or youtu.be link. + function youtubeId(text) { + let url; + try { + url = new URL(text.trim()); + } catch { + return null; + } + if (!["http:", "https:"].includes(url.protocol)) return null; + const host = url.hostname.replace(/^(www|m)\./, ""); + const [, first, second] = url.pathname.split("/"); + let id = null; + if (host === "youtu.be") id = first; + else if (host === "youtube.com" || host === "youtube-nocookie.com") { + if (["embed", "shorts", "live", "v"].includes(first)) id = second; + else if (first === "watch") id = url.searchParams.get("v"); + } + return /^[A-Za-z0-9_-]{11}$/.test(id ?? "") ? id : null; + } + + const youtubeInput = this.querySelector(".youtube-url"); + function addYoutube() { + const id = youtubeId(youtubeInput.value); + if (!id) { + youtubeInput.setCustomValidity("Enter a link to a YouTube video"); + youtubeInput.reportValidity(); + return; + } + gallery.insertBefore( + itemTemplate("", "", 0, "", "", `https://www.youtube.com/watch?v=${id}`), + fileInput.closest("label"), + ); + youtubeInput.value = ""; + } + youtubeInput.addEventListener("input", () => youtubeInput.setCustomValidity("")); + // Enter here would otherwise save the whole form. + youtubeInput.addEventListener("keydown", (event) => { + if (event.key !== "Enter") return; + event.preventDefault(); + addYoutube(); + }); + this.querySelector(".youtube-add button").addEventListener("click", addYoutube); + async function upload(file) { const response = await fetch(endpoint, { method: "POST", @@ -113,12 +162,16 @@ class MediaInput extends HTMLElement { }); function sync() { - const items = Array.from(gallery.querySelectorAll(".item")).map((li) => ({ - cid: li.dataset.cid, - mimeType: li.dataset.mimeType, - size: Number(li.dataset.size), - alt: li.querySelector(".alt").value.trim(), - })); + const items = Array.from(gallery.querySelectorAll(".item")).map((li) => { + const alt = li.querySelector(".alt").value.trim(); + if (li.dataset.url) return { url: li.dataset.url, alt }; + return { + cid: li.dataset.cid, + mimeType: li.dataset.mimeType, + size: Number(li.dataset.size), + alt, + }; + }); field.value = JSON.stringify(items); } diff --git a/assets/components/youtube.js b/assets/components/youtube.js new file mode 100644 index 0000000..b6b2ce3 --- /dev/null +++ b/assets/components/youtube.js @@ -0,0 +1,108 @@ +// Plays a YouTube video over an element and says when it's over. + +// How long, in milliseconds, a video has to start playing before it's given +// up on, so a video that's blocked, deleted, or can't embed leaves its image +// showing rather than a player that never starts. +const START_TIMEOUT = 10000; + +const PLAYER_ENDED = 0; +const PLAYER_PLAYING = 1; + +let api = null; + +function loadApi() { + api ??= new Promise((resolve, reject) => { + if (window.YT?.Player) return resolve(window.YT); + const previous = window.onYouTubeIframeAPIReady; + window.onYouTubeIframeAPIReady = () => { + previous?.(); + resolve(window.YT); + }; + const script = document.createElement("script"); + script.src = "https://www.youtube.com/iframe_api"; + script.onerror = () => reject(new Error("YouTube's player could not be loaded")); + document.head.append(script); + }); + return api; +} + +// Starts video `id` filling `host`, which must be positioned, and calls `done` +// once when the video has ended, with true, or can't be played, with false. +// Returns a function that stops it and takes the player away, after which +// `done` isn't called. Browsers only let a video start by itself when it's +// `muted`, so one that isn't has to be started by a press. +export function playVideo(host, id, done, muted = false) { + const cover = document.createElement("div"); + cover.className = "video-player"; + const target = document.createElement("div"); + cover.append(target); + host.append(cover); + + let player = null; + let stopped = false; + let timeout = null; + + const finish = (ended, reason) => { + if (stopped) return; + if (!ended) console.warn(`YouTube video ${id} couldn't be played:`, reason); + stop(); + done(ended); + }; + const stop = () => { + stopped = true; + clearTimeout(timeout); + try { + player?.destroy(); + } catch { + // The player has already gone. + } + cover.remove(); + }; + + timeout = setTimeout(() => finish(false, "it didn't start"), START_TIMEOUT); + loadApi() + .then((YT) => { + if (stopped) return; + player = new YT.Player(target, { + videoId: id, + host: "https://www.youtube-nocookie.com", + playerVars: { + autoplay: 1, + mute: muted ? 1 : 0, + playsinline: 1, + rel: 0, + color: "white", + iv_load_policy: 3, + }, + events: { + onReady: (event) => event.target.playVideo(), + onStateChange: (event) => { + if (event.data === PLAYER_PLAYING) clearTimeout(timeout); + else if (event.data === PLAYER_ENDED) finish(true); + }, + onError: (event) => finish(false, `error ${event.data}`), + }, + }); + }) + .catch((error) => finish(false, error)); + + return stop; +} + +// A button over `host`'s image that plays its video when pressed. +export function addPlayButton(host, play) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "video-play"; + button.setAttribute("aria-label", "Play video"); + button.innerHTML = + ''; + button.addEventListener("click", (event) => { + // Not the link around a featured mod's image. + event.preventDefault(); + event.stopPropagation(); + play(); + }); + host.append(button); + return button; +} diff --git a/src/atproto/lexicon/mod_listing.rs b/src/atproto/lexicon/mod_listing.rs index 9b73aaa..f057684 100644 --- a/src/atproto/lexicon/mod_listing.rs +++ b/src/atproto/lexicon/mod_listing.rs @@ -215,16 +215,15 @@ pub struct ModListing { pub created_at: DateTime, } -/// An item in a mod's media gallery, stored as a PDS blob. +/// An item in a mod's media gallery: an image stored as a PDS blob, or a link +/// to a YouTube video. /// /// Mod deliverables are far too large for blob storage and live in the object /// store instead, referenced by URL from a `mod.version`. /// -/// `$type`-tagged, even though `Image` is the only variant today, because -/// this array is a lexicon field: a record already written with an untagged -/// image entry can never be migrated, so a future entry this doesn't -/// recognize (a YouTube link, a video blob) needs a tag to be told apart by, -/// and that tag has to have been there from the start. +/// `$type`-tagged because this array is a lexicon field: a record already +/// written with an untagged entry can never be migrated, so entries need a +/// tag to be told apart by. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "$type", rename_all = "camelCase")] pub enum MediaItem { @@ -234,21 +233,61 @@ pub enum MediaItem { #[serde(default, skip_serializing_if = "Option::is_none")] alt: Option, }, + /// A YouTube video, embedded where the gallery is shown. + #[serde(rename = "dev.starhaven.mod.listing#youtube")] + YouTube { + url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + alt: Option, + }, } impl MediaItem { - pub fn file(&self) -> &TypedBlob { + /// The image's blob. None for a video. + pub fn file(&self) -> Option<&TypedBlob> { + match self { + Self::Image { file, .. } => Some(file), + Self::YouTube { .. } => None, + } + } + + pub fn is_image(&self) -> bool { + matches!(self, Self::Image { .. }) + } + + /// The video's id on YouTube. None for an image, or a link that isn't to + /// a YouTube video. + pub fn youtube_id(&self) -> Option<&str> { match self { - Self::Image { file, .. } => file, + Self::YouTube { url, .. } => youtube_id(url), + Self::Image { .. } => None, } } pub fn alt(&self) -> Option<&str> { match self { - Self::Image { alt, .. } => alt.as_deref(), + Self::Image { alt, .. } | Self::YouTube { alt, .. } => alt.as_deref(), } } + /// The image through the blob proxy, for where an image is needed. None + /// for a video. `did` is who owns the listing. + pub fn image_url(&self, did: &Did) -> Option { + match self { + Self::Image { file, .. } => Some(format!("/blob/{did}/{}", file.inner.ref_.link)), + Self::YouTube { .. } => None, + } + } + + /// A gallery entry for the YouTube video `url` links to, if it does. + pub fn from_youtube_url(url: &str, alt: Option) -> Option { + let id = youtube_id(url)?; + Some(Self::YouTube { + url: format!("https://www.youtube.com/watch?v={id}"), + alt, + }) + } + /// Build a gallery entry from an uploaded blob's ref fields, as returned /// by `uploadBlob` and round-tripped through the gallery editor. pub fn from_blob_ref(cid: String, mime_type: String, size: u64, alt: Option) -> Self { @@ -263,6 +302,37 @@ impl MediaItem { } } +/// The id of the YouTube video that `url` links to: a watch, short, embed, +/// live, or youtu.be link. +pub fn youtube_id(url: &str) -> Option<&str> { + let rest = url + .strip_prefix("https://") + .or_else(|| url.strip_prefix("http://"))?; + let (host, path) = rest.split_once('/').unwrap_or((rest, "")); + let host = host + .strip_prefix("www.") + .or_else(|| host.strip_prefix("m.")) + .unwrap_or(host); + let (path, query) = path.split_once('?').unwrap_or((path, "")); + let id = match host { + "youtu.be" => path, + "youtube.com" | "youtube-nocookie.com" => match path.split_once('/') { + Some(("embed" | "shorts" | "live" | "v", id)) => id, + _ => query + .split('&') + .find_map(|pair| pair.strip_prefix("v=")) + .filter(|_| path == "watch")?, + }, + _ => return None, + }; + let id = id.split(['/', '#']).next()?; + let valid = id.len() == 11 + && id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'); + valid.then_some(id) +} + impl ModListing { /// The console this mod runs on, implied by `game`. pub fn platform(&self) -> Platform { @@ -403,6 +473,11 @@ impl Record for ModListing { if let Some(alt) = item.alt() { max_graphemes("alt", alt, 1000)?; } + if let MediaItem::YouTube { url, .. } = item { + if youtube_id(url).is_none() { + bail!("media has a link that isn't to a YouTube video"); + } + } } Ok(()) @@ -511,6 +586,64 @@ impl Record for ModListing { mod tests { use super::*; + #[test] + fn youtube_links_in_any_common_form_give_their_id() { + for url in [ + "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "https://youtube.com/watch?feature=share&v=dQw4w9WgXcQ", + "http://m.youtube.com/watch?v=dQw4w9WgXcQ&t=5", + "https://youtu.be/dQw4w9WgXcQ?si=abc", + "https://www.youtube.com/embed/dQw4w9WgXcQ", + "https://www.youtube.com/shorts/dQw4w9WgXcQ", + "https://www.youtube.com/live/dQw4w9WgXcQ?feature=share", + "https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ", + ] { + assert_eq!(youtube_id(url), Some("dQw4w9WgXcQ"), "{url}"); + } + } + + #[test] + fn a_youtube_entry_is_tagged_and_keeps_a_canonical_link() { + let item = MediaItem::from_youtube_url("https://youtu.be/dQw4w9WgXcQ?t=3", None).unwrap(); + let value = serde_json::to_value(&item).unwrap(); + assert_eq!( + value, + serde_json::json!({ + "$type": "dev.starhaven.mod.listing#youtube", + "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + }) + ); + assert_eq!(serde_json::from_value::(value).unwrap(), item); + assert!(MediaItem::from_youtube_url("https://example.com/", None).is_none()); + } + + #[test] + fn a_listing_with_a_link_to_something_else_is_invalid() { + let mut listing = listing(); + listing.media = vec![MediaItem::YouTube { + url: "https://example.com/video".to_string(), + alt: None, + }]; + assert!(listing.validate().is_err()); + } + + #[test] + fn other_links_have_no_youtube_id() { + for url in [ + "", + "dQw4w9WgXcQ", + "https://example.com/watch?v=dQw4w9WgXcQ", + "https://evil.com/youtube.com/watch?v=dQw4w9WgXcQ", + "https://www.youtube.com/watch", + "https://www.youtube.com/watch?v=short", + "https://www.youtube.com/@channel", + "https://youtu.be/", + "javascript:alert(1)", + ] { + assert_eq!(youtube_id(url), None, "{url}"); + } + } + fn listing() -> ModListing { ModListing { title: "Master Quest".to_string(), diff --git a/src/components/media_input.rs b/src/components/media_input.rs index 0786459..faa50d5 100644 --- a/src/components/media_input.rs +++ b/src/components/media_input.rs @@ -3,16 +3,21 @@ use maud::{html, Markup}; use crate::page::icon; pub struct Item { + /// The image's address. Empty for a video, which has no image. pub src: String, pub cid: String, pub mime_type: String, pub size: u64, + /// A YouTube video's link, in place of an image's `cid`, `mime_type`, + /// and `size`. + pub url: String, pub alt: String, } /// A reorderable drag-and-drop list of media items that also owns its file -/// input, progress bar, and hidden form field. Each image uploads immediately -/// on selection; the finished order serialises into `name` on submit. +/// input, YouTube link input, progress bar, and hidden form field. Each image +/// uploads immediately on selection; the finished order serialises into +/// `name` on submit. pub fn render(endpoint: &str, name: &str, items: &[Item]) -> Markup { html! { media-input endpoint=(endpoint) { @@ -21,14 +26,20 @@ pub fn render(endpoint: &str, name: &str, items: &[Item]) -> Markup { data-cid=(item.cid) data-mime-type=(item.mime_type) data-size=(item.size) + data-url=(item.url) { span.handle draggable="true" aria-hidden="true" { (icon(icondata::LuGripVertical)) } - img src=(item.src) alt=""; - input type="text" class="alt" placeholder="Describe this image" - aria-label="Describe this image" value=[(!item.alt.is_empty()).then_some(&item.alt)]; - button type="button" class="remove" aria-label="Remove image" { + @if item.src.is_empty() { + span.video-preview aria-hidden="true" { (icon(icondata::LuYoutube)) } + } @else { + img src=(item.src) alt=""; + } + @let what = if item.url.is_empty() { "image" } else { "video" }; + input type="text" class="alt" placeholder={ "Describe this " (what) } + aria-label={ "Describe this " (what) } value=[(!item.alt.is_empty()).then_some(&item.alt)]; + button type="button" class="remove" aria-label={ "Remove " (what) } { (icon(icondata::LuX)) } } @@ -38,6 +49,14 @@ pub fn render(endpoint: &str, name: &str, items: &[Item]) -> Markup { "Add images" input.visually-hidden type="file" accept="image/*" multiple; } + div.youtube-add { + input type="url" class="youtube-url" placeholder="Paste a YouTube link" + aria-label="YouTube link"; + button type="button" class="secondary" { + (icon(icondata::LuYoutube)) + "Add video" + } + } progress max="100" value="0" hidden {} input type="hidden" name=(name); } diff --git a/src/homepage.rs b/src/homepage.rs index 5da3380..df20b70 100644 --- a/src/homepage.rs +++ b/src/homepage.rs @@ -63,14 +63,18 @@ pub struct Card { } impl Card { - /// The `n`th image in the mod's gallery, through the blob proxy. + /// The `n`th image in the mod's gallery, through the blob proxy. Videos + /// aren't counted. fn image_url(&self, n: usize) -> Option { - let item = self.media.get(n)?; - Some(format!( - "/blob/{}/{}", - self.did, - item.file().inner.ref_.link - )) + self.media + .iter() + .filter_map(|item| item.image_url(&self.did)) + .nth(n) + } + + /// The id of the gallery's first video, if it has one. + fn video_id(&self) -> Option<&str> { + self.media.iter().find_map(MediaItem::youtube_id) } /// The mod's page. None when its author has no known handle, since the @@ -442,7 +446,8 @@ fn featured_card(card: &Card) -> Markup { .filter_map(|n| card.image_url(n)) .collect(); let inner = html! { - div.featured-image { + // The mod's video plays over its first image, or its placeholder. + div.featured-image data-youtube=[card.video_id()] { @match card.image_url(0) { Some(url) => img src=(url) alt="" decoding="async"; None => (image_placeholder(Some(card.game), &card.title)), diff --git a/src/mods.rs b/src/mods.rs index 9e91cdc..03ddf86 100644 --- a/src/mods.rs +++ b/src/mods.rs @@ -354,7 +354,8 @@ async fn handle_post_create( let Ok(media) = parse_media_field(&request.media) else { return Err(refuse("The images could not be read - try adding them again").await); }; - if media.is_empty() { + // A video can't stand in for the image that cards and search show. + if !media.iter().any(MediaItem::is_image) { return Err(refuse("Add an image").await); } @@ -530,11 +531,20 @@ fn mod_carousel(did: &Did, listing: &ModListing) -> Markup { } }; } + // A video shows the gallery's first image until it's played. + let poster = media.iter().find_map(|item| item.image_url(did)); html! { media-carousel { @for item in media { - img src=(format!("/blob/{}/{}", did, item.file().inner.ref_.link)) - alt=(item.alt().unwrap_or_default()); + @if let Some(id) = item.youtube_id() { + div data-youtube=(id) role="group" aria-label=(item.alt().unwrap_or("Video")) { + @if let Some(url) = &poster { + img src=(url) alt=""; + } + } + } @else if let Some(url) = item.image_url(did) { + img src=(url) alt=(item.alt().unwrap_or_default()); + } } } } @@ -859,12 +869,24 @@ fn gallery_items(did: &Did, listing: &ModListing) -> Vec ( + file.inner.ref_.link.to_string(), + file.inner.mime_type.clone(), + file.inner.size, + String::new(), + ), + MediaItem::YouTube { url, .. } => (String::new(), String::new(), 0, url.clone()), + }; + crate::components::media_input::Item { + src: item.image_url(did).unwrap_or_default(), + cid, + mime_type, + size, + url, + alt: item.alt().unwrap_or_default().to_string(), + } }) .collect() } @@ -878,11 +900,11 @@ fn media_gallery_field( ) -> Markup { html! { div.form-item id="media" { - label { "Images" } - div.hint { "4:3" } + label { "Media" } + div.hint { "4:3. A mod needs at least one image." } div.error id="media-error" hidden[error.is_none()] { (error.unwrap_or_default()) } noscript { - p.error { "Editing the images needs JavaScript, because each image is uploaded as soon as it's chosen." } + p.error { "Editing the media needs JavaScript, because each image is uploaded as soon as it's chosen." } } (crate::components::media_input::render(media_endpoint, "media", items)) } @@ -920,9 +942,15 @@ struct EditListingForm { #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct MediaField { + #[serde(default)] cid: String, + #[serde(default)] mime_type: String, + #[serde(default)] size: u64, + /// A YouTube link, in place of the image's `cid`, `mime_type`, and `size`. + #[serde(default)] + url: String, #[serde(default)] alt: String, } @@ -936,13 +964,22 @@ fn parse_media_field(json: &str) -> Result, ()> { return Ok(vec![]); } let items: Vec = serde_json::from_str(json).map_err(|_| ())?; - Ok(items + items .into_iter() .map(|item| { let alt = (!item.alt.trim().is_empty()).then_some(item.alt.trim().to_string()); - MediaItem::from_blob_ref(item.cid, item.mime_type, item.size, alt) + if item.url.is_empty() { + Ok(MediaItem::from_blob_ref( + item.cid, + item.mime_type, + item.size, + alt, + )) + } else { + MediaItem::from_youtube_url(&item.url, alt).ok_or(()) + } }) - .collect()) + .collect() } async fn handle_post_settings( @@ -995,7 +1032,7 @@ async fn handle_post_settings( errors.title = Some("Title must be 300 characters or less"); } // A mod is listed by its first image, so it keeps at least one. - if existing.record.media.is_empty() { + if !existing.record.media.iter().any(MediaItem::is_image) { errors.media = Some("Add an image"); } diff --git a/src/transfer.rs b/src/transfer.rs index 1c60de7..567932d 100644 --- a/src/transfer.rs +++ b/src/transfer.rs @@ -420,7 +420,11 @@ async fn accept(state: &AppState, session: &SessionCookie, id: i64) -> Result