Webapp/desktop: video preview and lightbox sound settings (#1798)

This commit is contained in:
BFlat
2026-07-22 08:17:19 +07:00
committed by GitHub
parent 9c83c20c46
commit 5faa94a9dd
20 changed files with 233 additions and 11 deletions
@@ -7,7 +7,7 @@ import { TabSelector } from "@storyteller/ui-tab-selector";
import { TruchetPattern } from "@storyteller/ui-vfx";
import Seo from "../../components/seo";
import { useIsMobile } from "../ui/use-mobile";
import { GalleryViewToggle } from "./GalleryViewToggle";
import { GalleryAutoplayToggle, GalleryViewToggle } from "./GalleryViewToggle";
import {
MobileCreateTabsProvider,
type MobileCreateTab,
@@ -23,6 +23,9 @@ interface CreateMediaPageShellProps {
authChecked: boolean;
// Content
hasContent: boolean;
// Video page only: playing/still toggle for video preview thumbnails,
// rendered next to the grid/list toggle on the mobile History tab.
showAutoplayToggle?: boolean;
emptyStateTitle: string;
emptyStateSubtitle: string;
// Optional CTA rendered under the empty-state subtitle (e.g. a signup button
@@ -54,6 +57,7 @@ export function CreateMediaPageShell({
description,
authChecked,
hasContent,
showAutoplayToggle = false,
emptyStateTitle,
emptyStateSubtitle,
emptyStateCta,
@@ -100,7 +104,8 @@ export function CreateMediaPageShell({
className="w-auto"
/>
{mobileTab === "history" && hasContent && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<div className="absolute right-3 top-1/2 flex -translate-y-1/2 items-center gap-1.5">
{showAutoplayToggle && <GalleryAutoplayToggle />}
<GalleryViewToggle />
</div>
)}
@@ -1,2 +1,5 @@
// Moved to the shared generation-list lib (used by both webapp and desktop).
export { GalleryViewToggle } from "@storyteller/ui-generation-list";
export {
GalleryViewToggle,
GalleryAutoplayToggle,
} from "@storyteller/ui-generation-list";
@@ -1,7 +1,7 @@
export { GenerationGallery } from "./GenerationGallery";
export { GenerationGalleryGrid } from "./GenerationGalleryGrid";
export { GenerationGalleryList } from "./GenerationGalleryList";
export { GalleryViewToggle } from "./GalleryViewToggle";
export { GalleryViewToggle, GalleryAutoplayToggle } from "./GalleryViewToggle";
export { useGalleryData, type GalleryItem } from "./useGalleryData";
export { useSessionMediaByClass } from "@storyteller/ui-generation-list";
export {
@@ -32,6 +32,7 @@ import {
applyMakeVideoFromImage,
applyRecreateFromMediaToken,
} from "../../lib/recreate";
import { useLightboxSoundStore } from "../../lib/lightbox-sound-store";
// ── Types ──────────────────────────────────────────────────────────────────
@@ -80,6 +81,7 @@ export function Lightbox({
showBatchCarousel = true,
}: LightboxProps) {
const navigate = useNavigate();
const soundEnabled = useLightboxSoundStore((s) => s.soundEnabled);
const [mediaLoaded, setMediaLoaded] = useState(false);
const [promptData, setPromptData] = useState<PromptData>(EMPTY_PROMPT);
const [creator, setCreator] = useState<UserInfo | null>(null);
@@ -379,7 +381,7 @@ export function Lightbox({
controls
loop
autoPlay
muted
muted={!soundEnabled}
playsInline
disablePictureInPicture
controlsList="nodownload noplaybackrate nofullscreen"
@@ -389,6 +391,22 @@ export function Lightbox({
const el = e.currentTarget;
setMediaWidth(el.videoWidth);
setMediaHeight(el.videoHeight);
// Restore the user's last volume (getState: subscribing
// would re-render on every tick of a volume drag).
el.volume = useLightboxSoundStore.getState().volume;
// If the browser blocked unmuted autoplay, fall back to
// muted playback rather than leaving the video frozen.
if (soundEnabled && el.paused) {
el.play().catch(() => {
el.muted = true;
el.play().catch(() => {});
});
}
}}
onVolumeChange={(e) => {
useLightboxSoundStore
.getState()
.setVolume(e.currentTarget.volume);
}}
ref={(el) => {
if (el) {
@@ -6,6 +6,7 @@ import { Switch } from "@storyteller/ui-switch";
import { USER_FEATURE_FLAGS } from "@storyteller/api";
import { twMerge } from "tailwind-merge";
import { useEnterToGenerateStore } from "../../lib/enter-to-generate-store";
import { useLightboxSoundStore } from "../../lib/lightbox-sound-store";
import { useSession } from "../../lib/session";
import { AccountSection } from "./AccountSection";
import { ApiKeySection } from "./ApiKeySection";
@@ -105,6 +106,8 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
function GeneralPanel() {
const enterToGenerate = useEnterToGenerateStore((s) => s.enabled);
const setEnterToGenerate = useEnterToGenerateStore((s) => s.setEnabled);
const lightboxSound = useLightboxSoundStore((s) => s.soundEnabled);
const setLightboxSound = useLightboxSoundStore((s) => s.setSoundEnabled);
return (
<div className="space-y-4 text-base-fg">
@@ -123,6 +126,20 @@ function GeneralPanel() {
offClassName="bg-white/20"
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-0.5">
<p className="text-sm font-medium">Play videos with sound</p>
<p className="text-xs opacity-70">
When on, videos in the media viewer start unmuted so you don't have
to click unmute every time. When off (default), they start muted.
</p>
</div>
<Switch
enabled={lightboxSound}
setEnabled={setLightboxSound}
offClassName="bg-white/20"
/>
</div>
</div>
);
}
@@ -1,6 +1,9 @@
import { Link, useLocation } from "react-router-dom";
import { SidebarTrigger, useSidebar } from "../ui/sidebar";
import { GalleryViewToggle } from "../generation-gallery/GalleryViewToggle";
import {
GalleryAutoplayToggle,
GalleryViewToggle,
} from "../generation-gallery/GalleryViewToggle";
import { Breadcrumbs } from "./breadcrumbs";
import { TopBarActions } from "./TopBarActions";
@@ -14,6 +17,8 @@ export function TopBar() {
// On mobile the toggle lives next to the Generate/History tabs (see
// CreateMediaPageShell), so keep it out of the top bar there.
const showViewToggle = !isMobile && GALLERY_VIEW_ROUTES.has(pathname);
// Playing/still video previews only matter where the feed has videos.
const showAutoplayToggle = showViewToggle && pathname === "/create-video";
return (
<header className="sticky top-0 z-20 flex items-center gap-3 border-b border-white/[0.06] bg-[#121212]/80 backdrop-blur-md px-3 pb-4 pt-3 sm:pt-6">
@@ -41,6 +46,7 @@ export function TopBar() {
{/* Right: gallery layout toggle (create pages) + credits / upgrade / task queue / avatar */}
<div className="ml-auto flex items-center gap-1.5 sm:gap-2">
{showAutoplayToggle && <GalleryAutoplayToggle />}
{showViewToggle && <GalleryViewToggle />}
<TopBarActions />
</div>
@@ -0,0 +1,26 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
// Audio behavior for lightbox videos: whether they open with sound (off by
// default, toggled in the settings modal's General tab) and the last volume
// the user picked, so each video doesn't reset to 100%.
interface LightboxSoundStore {
soundEnabled: boolean;
setSoundEnabled: (enabled: boolean) => void;
// 0..1, mirrors HTMLMediaElement.volume.
volume: number;
setVolume: (volume: number) => void;
}
export const useLightboxSoundStore = create<LightboxSoundStore>()(
persist(
(set) => ({
soundEnabled: false,
setSoundEnabled: (soundEnabled) => set({ soundEnabled }),
volume: 1,
setVolume: (volume) => set({ volume }),
}),
{ name: "artcraft-lightbox-sound" },
),
);
@@ -1454,6 +1454,7 @@ export default function CreateVideo() {
description="Generate stunning AI videos with ArtCraft"
authChecked={authChecked}
hasContent={hasContent}
showAutoplayToggle
emptyStateTitle="Create Video"
emptyStateSubtitle="Describe a scene. See it in motion."
emptyStateCta={
@@ -8,6 +8,7 @@ import {
} from "@storyteller/tauri-events";
import { MediaFilesApi } from "@storyteller/api";
import {
getMediaStillThumbnail,
getMediaThumbnail,
getThumbnailUrl,
THUMBNAIL_SIZES,
@@ -410,6 +411,12 @@ async function taskToGalleryItems(
id: batchFile.token,
label,
thumbnail: thumbnail || cdnUrl,
stillThumbnail:
mediaType === "video"
? getMediaStillThumbnail(batchFile.media_links, {
size: THUMBNAIL_SIZES.LARGE,
})
: null,
fullImage: cdnUrl,
createdAt,
mediaClass: mediaType,
@@ -8,7 +8,6 @@ import {
faCoins,
faGear,
faGem,
faGrid2,
faHouse,
faImages,
faCalculator,
@@ -51,7 +50,10 @@ import {
useCostBreakdownModalStore,
CreditsModal,
} from "@storyteller/ui-pricing-modal";
import { GalleryViewToggle } from "@storyteller/ui-generation-list";
import {
GalleryAutoplayToggle,
GalleryViewToggle,
} from "@storyteller/ui-generation-list";
import { SettingsModal } from "@storyteller/ui-settings-modal";
import { Tooltip } from "@storyteller/ui-tooltip";
import { useEffect, useRef, useState } from "react";
@@ -500,6 +502,7 @@ export const TopBar = ({ pageName }: Props) => {
<div className="flex justify-end gap-2" data-tauri-drag-region>
<div className="no-drag flex items-center gap-1.5">
{tabStore.activeTabId === "VIDEO" && <GalleryAutoplayToggle />}
{(tabStore.activeTabId === "IMAGE" ||
tabStore.activeTabId === "VIDEO" ||
tabStore.activeTabId === "AUDIO") && <GalleryViewToggle />}
@@ -84,6 +84,41 @@ export function getMediaThumbnail(
return thumbnailUrl;
}
// Still-frame thumbnail for a video (first frame), for hosts that let the
// user turn off animated previews. Falls back to the regular thumbnail
// template when the backend hasn't attached still previews.
export function getMediaStillThumbnail(
mediaLinks:
| {
maybe_thumbnail_template?: string | null;
maybe_video_previews?: {
still?: string | null;
still_thumbnail_template?: string | null;
} | null;
}
| null
| undefined,
options: MediaThumbnailOptions = {},
): string | null {
if (!mediaLinks) return null;
const { size = THUMBNAIL_SIZES.MEDIUM } = options;
const previews = mediaLinks.maybe_video_previews;
if (previews?.still_thumbnail_template) {
return getThumbnailUrl(previews.still_thumbnail_template, { width: size });
}
if (previews?.still) {
return previews.still;
}
if (mediaLinks.maybe_thumbnail_template) {
return getThumbnailUrl(mediaLinks.maybe_thumbnail_template, {
width: size,
});
}
return null;
}
export function getContextImageThumbnail(
contextImage: {
media_links: {
@@ -5,6 +5,7 @@ export * from "./lib/prompts-cache";
export * from "./lib/media-prompt-token-cache";
export * from "./lib/gallery-view-store";
export * from "./lib/GalleryViewToggle";
export * from "./lib/GalleryAutoplayToggle";
export * from "./lib/useGalleryData";
export * from "./lib/useSessionMediaByClass";
export * from "./lib/useGalleryEntries";
@@ -0,0 +1,51 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faPause, faPlay } from "@fortawesome/pro-solid-svg-icons";
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { Tooltip } from "@storyteller/ui-tooltip";
import { useGalleryViewStore } from "./gallery-view-store";
const OPTIONS: {
autoplay: boolean;
icon: IconDefinition;
label: string;
}[] = [
{ autoplay: true, icon: faPlay, label: "Play video previews" },
{ autoplay: false, icon: faPause, label: "Still thumbnails" },
];
// Segmented playing/still switch for video preview thumbnails. Sits next to
// GalleryViewToggle on the video page; the selection is shared with the
// gallery via useGalleryViewStore.
export function GalleryAutoplayToggle() {
const autoplayVideos = useGalleryViewStore((s) => s.autoplayVideos);
const setAutoplayVideos = useGalleryViewStore((s) => s.setAutoplayVideos);
return (
<div
role="group"
aria-label="Video preview playback"
className="flex items-center gap-0.5 rounded-lg border border-white/[0.08] bg-white/[0.04] p-0.5"
>
{OPTIONS.map(({ autoplay, icon, label }) => {
const active = autoplayVideos === autoplay;
return (
<Tooltip key={label} content={label} position="bottom" delay={300}>
<button
type="button"
aria-label={label}
aria-pressed={active}
onClick={() => setAutoplayVideos(autoplay)}
className={`flex h-6 w-6 items-center justify-center rounded-md text-xs transition-colors ${
active
? "bg-white/10 text-white"
: "text-white/45 hover:text-white/80"
}`}
>
<FontAwesomeIcon icon={icon} />
</button>
</Tooltip>
);
})}
</div>
);
}
@@ -185,6 +185,7 @@ export const GalleryCard = memo(function GalleryCard({
) : (
<GalleryThumbnail
thumbnail={item.thumbnail}
stillThumbnail={item.stillThumbnail}
alt={item.label}
isVideo={isVideo}
fallbackIcon={mediaIcon}
@@ -85,6 +85,7 @@ export const GalleryRow = memo(function GalleryRow({
<div className="relative size-[100px] shrink-0 overflow-hidden rounded-md bg-ui-controls/40 leading-none">
<GalleryThumbnail
thumbnail={item.thumbnail}
stillThumbnail={item.stillThumbnail}
alt={item.label}
isVideo={isVideo}
fallbackIcon={mediaIcon}
@@ -3,6 +3,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSpinnerThird } from "@fortawesome/pro-solid-svg-icons";
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { PLACEHOLDER_IMAGES } from "@storyteller/common";
import { useGalleryViewStore } from "./gallery-view-store";
// Media thumbnail shared by the masonry GalleryCard and the list GalleryRow.
// Encapsulates the two awkward bits of thumbnail rendering: freshly generated
@@ -40,6 +41,9 @@ const RETRY_INTERVAL = 5000;
interface GalleryThumbnailProps {
thumbnail: string | null;
// Still first-frame variant for videos. Shown instead of the (animated)
// thumbnail when the user turns off autoplay in useGalleryViewStore.
stillThumbnail?: string | null;
alt: string;
isVideo: boolean;
// Icon shown when there is no thumbnail at all (e.g. 3D meshes).
@@ -54,7 +58,8 @@ interface GalleryThumbnailProps {
}
export function GalleryThumbnail({
thumbnail,
thumbnail: animatedThumbnail,
stillThumbnail,
alt,
isVideo,
fallbackIcon,
@@ -63,6 +68,13 @@ export function GalleryThumbnail({
showRetryLabel = true,
onLoad,
}: GalleryThumbnailProps) {
const autoplayVideos = useGalleryViewStore((s) => s.autoplayVideos);
// With autoplay off, videos freeze on their still first frame. The still is
// also what the retry machinery below polls in that mode.
const thumbnail =
isVideo && !autoplayVideos && stillThumbnail
? stillThumbnail
: animatedThumbnail;
// "retrying" flips to true only after the first error, so videos with ready
// thumbnails render the normal <img> path with zero overhead. While retrying,
// a hidden <img> loads via ref (no re-renders) and the spinner stays stable.
@@ -10,6 +10,10 @@ export type GalleryViewMode = "grid" | "list";
interface GalleryViewState {
viewMode: GalleryViewMode;
setViewMode: (mode: GalleryViewMode) => void;
// Whether video cards play their animated previews. When off, they show the
// still first-frame thumbnail instead (toggled from the video page TopBar).
autoplayVideos: boolean;
setAutoplayVideos: (autoplay: boolean) => void;
}
export const useGalleryViewStore = create<GalleryViewState>()(
@@ -17,6 +21,8 @@ export const useGalleryViewStore = create<GalleryViewState>()(
(set) => ({
viewMode: "grid",
setViewMode: (viewMode) => set({ viewMode }),
autoplayVideos: true,
setAutoplayVideos: (autoplayVideos) => set({ autoplayVideos }),
}),
{ name: "artcraft-gallery-view" },
),
@@ -42,6 +42,9 @@ export interface GalleryItem {
id: string;
label: string;
thumbnail: string | null;
// Still first-frame thumbnail for videos, shown when the user turns off
// animated previews (thumbnail holds the animated one).
stillThumbnail?: string | null;
fullImage: string | null;
createdAt: string;
mediaClass: string;
@@ -4,7 +4,11 @@ import {
FilterMediaClasses,
FilterMediaType,
} from "@storyteller/api";
import { getMediaThumbnail, THUMBNAIL_SIZES } from "@storyteller/common";
import {
getMediaStillThumbnail,
getMediaThumbnail,
THUMBNAIL_SIZES,
} from "@storyteller/common";
import type { GalleryItem } from "./types";
import { is3DMediaClass } from "./types";
@@ -83,6 +87,12 @@ export function useGalleryData(options: {
id: item.token,
label: getLabel(item),
thumbnail,
stillThumbnail:
item.media_class === "video"
? getMediaStillThumbnail(item.media_links, {
size: THUMBNAIL_SIZES.LARGE,
})
: null,
fullImage: item.media_links?.cdn_url || null,
createdAt: item.created_at,
mediaClass: item.media_class || "image",
@@ -1,7 +1,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { JobsApi, JobStatus, MediaFilesApi } from "@storyteller/api";
import type { Job, Prompts } from "@storyteller/api";
import { getMediaThumbnail, THUMBNAIL_SIZES } from "@storyteller/common";
import {
getMediaStillThumbnail,
getMediaThumbnail,
THUMBNAIL_SIZES,
} from "@storyteller/common";
import {
getModelDisplayName,
getProviderDisplayName,
@@ -252,6 +256,12 @@ function jobToGalleryItem(
id: result.entity_token,
label: getPrompt(job, promptsMap) || "Generation",
thumbnail,
stillThumbnail:
mediaClass === "video"
? getMediaStillThumbnail(result.media_links, {
size: THUMBNAIL_SIZES.LARGE,
})
: null,
fullImage: result.media_links?.cdn_url || null,
// Sort by job creation time (not completion time) so the completed card
// occupies the same Masonry slot the pending card held — no layout shift.
@@ -294,6 +304,12 @@ async function expandBatchItems(
id: file.token,
label: item.label,
thumbnail,
stillThumbnail:
item.mediaClass === "video"
? getMediaStillThumbnail(file.media_links, {
size: THUMBNAIL_SIZES.LARGE,
})
: null,
fullImage: cdnUrl,
createdAt: item.createdAt,
mediaClass: item.mediaClass,