Webapp: mobile audio ref fixes (#1816)
- **Fixed: picking an audio reference on mobile opened the photo/video picker.** iPhones don't have an audio library, so the browser fell back to Photos - the only thing you could "attach as audio" was a video, and it silently uploaded as an audio ref. Audio pickers now open the Files app filtered to real audio files (MP3, WAV, M4A, FLAC…). - **Non-audio files are now rejected with a clear error toast** instead of being silently attached (also covers drag & drop and Android's "any file" picker). - **Fixed: limit warnings never appeared in the web app.** Errors like "Total audio duration cannot exceed 15s" from shared components were firing into a toast system the web app doesn't render. They're now routed to the app's own toasts, so length/limit errors actually show up. - Applies to the create video, create audio, and desktop reference deck flows
This commit is contained in:
1 parent
e1b16cf2de
commit
4ff5bdf475
10 files changed
+217
-28
No files matched your search
@@ -10,6 +10,11 @@ import {
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { UploaderStates } from "@storyteller/common";
|
||||
import {
|
||||
AUDIO_FILE_ACCEPT,
|
||||
AUDIO_FILE_TYPE_ERROR,
|
||||
isAudioFile,
|
||||
} from "@storyteller/ui-promptbox";
|
||||
import { toast } from "../toast/toast";
|
||||
import { AddButton } from "./ImagePromptRow";
|
||||
import type { RefVideo, RefAudio } from "./types";
|
||||
@@ -137,6 +142,10 @@ export const MediaReferenceRow = ({
|
||||
const baseAudios = [...referenceAudios];
|
||||
|
||||
const file = files[0];
|
||||
if (!isAudioFile(file)) {
|
||||
toast.error(AUDIO_FILE_TYPE_ERROR);
|
||||
return;
|
||||
}
|
||||
const duration = await getAudioDuration(file);
|
||||
|
||||
if (duration <= 0) {
|
||||
@@ -208,7 +217,7 @@ export const MediaReferenceRow = ({
|
||||
type="file"
|
||||
ref={audioInputRef}
|
||||
className="hidden"
|
||||
accept="audio/*"
|
||||
accept={AUDIO_FILE_ACCEPT}
|
||||
onChange={handleAudioUpload}
|
||||
/>
|
||||
<div
|
||||
|
||||
@@ -55,15 +55,15 @@
|
||||
{
|
||||
"path": "../../libs/components/slider-v2/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/toaster/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/omni-gen/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/pricing-modal/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/promptbox/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/model-selector/tsconfig.lib.json"
|
||||
},
|
||||
@@ -73,15 +73,18 @@
|
||||
{
|
||||
"path": "../../libs/components/pagescene/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/gallery-modal/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/lightbox-modal/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/gallery-modal/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/switch/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/promptbox/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/audio-player/tsconfig.lib.json"
|
||||
},
|
||||
|
||||
@@ -20,15 +20,15 @@
|
||||
{
|
||||
"path": "../../libs/components/slider-v2"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/toaster"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/omni-gen"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/pricing-modal"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/promptbox"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/model-selector"
|
||||
},
|
||||
@@ -38,15 +38,21 @@
|
||||
{
|
||||
"path": "../../libs/components/pagescene"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/gallery-modal"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/lightbox-modal"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/gallery-modal"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/switch"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/promptbox"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/audio-player"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/viewer-3d"
|
||||
},
|
||||
@@ -68,9 +74,6 @@
|
||||
{
|
||||
"path": "../../libs/components/generation-list"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/audio-player"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/tab-selector"
|
||||
},
|
||||
|
||||
@@ -20,6 +20,48 @@ import {
|
||||
getAudioDuration,
|
||||
} from "./upload-media";
|
||||
|
||||
// iOS Safari has no system audio library, so a bare `accept="audio/*"` falls
|
||||
// back to the Photos picker (photos and videos only). Concrete MIME types and
|
||||
// extensions make iOS open the Files picker filtered to audio instead.
|
||||
const AUDIO_FILE_ACCEPT = [
|
||||
"audio/mpeg",
|
||||
"audio/wav",
|
||||
"audio/x-wav",
|
||||
"audio/mp4",
|
||||
"audio/x-m4a",
|
||||
"audio/aac",
|
||||
"audio/ogg",
|
||||
"audio/opus",
|
||||
"audio/flac",
|
||||
"audio/webm",
|
||||
".mp3",
|
||||
".wav",
|
||||
".m4a",
|
||||
".aac",
|
||||
".ogg",
|
||||
".oga",
|
||||
".opus",
|
||||
".flac",
|
||||
].join(",");
|
||||
|
||||
const AUDIO_FILE_EXTENSIONS = new Set([
|
||||
"mp3",
|
||||
"wav",
|
||||
"m4a",
|
||||
"aac",
|
||||
"ogg",
|
||||
"oga",
|
||||
"opus",
|
||||
"flac",
|
||||
]);
|
||||
|
||||
// Some platforms report .m4a as `video/mp4`, so check the extension too.
|
||||
const isAudioFile = (file: File): boolean => {
|
||||
if (file.type.startsWith("audio/")) return true;
|
||||
const extension = file.name.split(".").pop()?.toLowerCase() ?? "";
|
||||
return AUDIO_FILE_EXTENSIONS.has(extension);
|
||||
};
|
||||
|
||||
interface MediaReferenceRowProps {
|
||||
referenceVideos: RefVideo[];
|
||||
onReferenceVideosChange: (videos: RefVideo[]) => void;
|
||||
@@ -133,6 +175,10 @@ export const MediaReferenceRow = ({
|
||||
const baseAudios = [...referenceAudios];
|
||||
|
||||
const file = files[0];
|
||||
if (!isAudioFile(file)) {
|
||||
toast.error("Please choose an audio file (MP3, WAV, M4A, FLAC…)");
|
||||
return;
|
||||
}
|
||||
const duration = await getAudioDuration(file);
|
||||
|
||||
if (duration <= 0) {
|
||||
@@ -204,7 +250,7 @@ export const MediaReferenceRow = ({
|
||||
type="file"
|
||||
ref={audioInputRef}
|
||||
className="hidden"
|
||||
accept="audio/*"
|
||||
accept={AUDIO_FILE_ACCEPT}
|
||||
onChange={handleAudioUpload}
|
||||
/>
|
||||
<div
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./lib/common/AspectRatioIcon";
|
||||
export * from "./lib/common/audioFiles";
|
||||
export * from "./lib/PromptBox2D";
|
||||
export * from "./lib/PromptBox3D";
|
||||
export * from "./lib/PromptBoxEdit";
|
||||
|
||||
@@ -25,6 +25,11 @@ import { RefImage, RefVideo, RefAudio } from "./promptStore";
|
||||
import { toast } from "@storyteller/ui-toaster";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { UploaderStates } from "@storyteller/common";
|
||||
import {
|
||||
AUDIO_FILE_ACCEPT,
|
||||
AUDIO_FILE_TYPE_ERROR,
|
||||
isAudioFile,
|
||||
} from "./common/audioFiles";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
@@ -500,13 +505,18 @@ export const ImagePromptRow = ({
|
||||
const files = Array.from(event.target.files || []);
|
||||
if (files.length === 0) return;
|
||||
|
||||
const audioFiles = files.filter(isAudioFile);
|
||||
if (audioFiles.length < files.length) {
|
||||
toast.error(AUDIO_FILE_TYPE_ERROR);
|
||||
}
|
||||
|
||||
const availableSlots = Math.max(0, maxAudioCount - referenceAudios.length);
|
||||
if (availableSlots <= 0) {
|
||||
if (audioFiles.length === 0 || availableSlots <= 0) {
|
||||
if (audioFileInputRef.current) audioFileInputRef.current.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const filesToProcess = files.slice(0, availableSlots);
|
||||
const filesToProcess = audioFiles.slice(0, availableSlots);
|
||||
|
||||
for (const file of filesToProcess) {
|
||||
const duration = await getAudioDuration(file);
|
||||
@@ -819,7 +829,7 @@ export const ImagePromptRow = ({
|
||||
type="file"
|
||||
ref={audioFileInputRef}
|
||||
className="hidden"
|
||||
accept="audio/*"
|
||||
accept={AUDIO_FILE_ACCEPT}
|
||||
onChange={handleAudioFileUpload}
|
||||
multiple={maxAudioCount > 1}
|
||||
/>
|
||||
|
||||
@@ -13,6 +13,11 @@ import { toast } from "@storyteller/ui-toaster";
|
||||
import { UploaderStates } from "@storyteller/common";
|
||||
import type { UploadMediaFn } from "@storyteller/api";
|
||||
import type { RefAudio, RefImage } from "../promptStore";
|
||||
import {
|
||||
AUDIO_FILE_ACCEPT,
|
||||
AUDIO_FILE_TYPE_ERROR,
|
||||
isAudioFile,
|
||||
} from "./audioFiles";
|
||||
|
||||
export interface AudioReferenceRowProps {
|
||||
referenceAudios: RefAudio[];
|
||||
@@ -67,8 +72,13 @@ export function AudioReferenceRow({
|
||||
const files = Array.from(event.target.files || []);
|
||||
if (files.length === 0) return;
|
||||
|
||||
const audioFiles = files.filter(isAudioFile);
|
||||
if (audioFiles.length < files.length) {
|
||||
toast.error(AUDIO_FILE_TYPE_ERROR);
|
||||
}
|
||||
|
||||
const availableSlots = Math.max(0, maxAudioCount - referenceAudios.length);
|
||||
const filesToProcess = files.slice(0, availableSlots);
|
||||
const filesToProcess = audioFiles.slice(0, availableSlots);
|
||||
|
||||
for (const file of filesToProcess) {
|
||||
const duration = await getAudioFileDuration(file);
|
||||
@@ -160,7 +170,7 @@ export function AudioReferenceRow({
|
||||
<input
|
||||
ref={audioInputRef}
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
accept={AUDIO_FILE_ACCEPT}
|
||||
className="hidden"
|
||||
multiple={maxAudioCount > 1}
|
||||
onChange={handleAudioFileUpload}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// iOS Safari has no system audio library, so a bare `accept="audio/*"` makes
|
||||
// the file input fall back to the Photos picker (photos and videos only) —
|
||||
// on a phone the only thing users could "attach as audio" was a video.
|
||||
// Listing concrete audio MIME types and extensions instead makes iOS open the
|
||||
// Files picker filtered to audio; desktop browsers filter identically.
|
||||
export const AUDIO_FILE_ACCEPT = [
|
||||
"audio/mpeg",
|
||||
"audio/wav",
|
||||
"audio/x-wav",
|
||||
"audio/mp4",
|
||||
"audio/x-m4a",
|
||||
"audio/aac",
|
||||
"audio/ogg",
|
||||
"audio/opus",
|
||||
"audio/flac",
|
||||
"audio/webm",
|
||||
"audio/aiff",
|
||||
"audio/x-aiff",
|
||||
".mp3",
|
||||
".wav",
|
||||
".m4a",
|
||||
".aac",
|
||||
".ogg",
|
||||
".oga",
|
||||
".opus",
|
||||
".flac",
|
||||
".weba",
|
||||
".aiff",
|
||||
".aif",
|
||||
].join(",");
|
||||
|
||||
export const AUDIO_FILE_TYPE_ERROR =
|
||||
"Please choose an audio file (MP3, WAV, M4A, FLAC…)";
|
||||
|
||||
const AUDIO_FILE_EXTENSIONS = new Set([
|
||||
"mp3",
|
||||
"wav",
|
||||
"m4a",
|
||||
"aac",
|
||||
"ogg",
|
||||
"oga",
|
||||
"opus",
|
||||
"flac",
|
||||
"weba",
|
||||
"aiff",
|
||||
"aif",
|
||||
]);
|
||||
|
||||
// Guards the audio-ref paths against non-audio picks (Android "any file"
|
||||
// pickers, drag-and-drop, older iOS). Checks the extension as well as the
|
||||
// MIME type because some platforms report .m4a as `video/mp4`.
|
||||
export function isAudioFile(file: File): boolean {
|
||||
if (file.type.startsWith("audio/")) return true;
|
||||
const extension = file.name.split(".").pop()?.toLowerCase() ?? "";
|
||||
return AUDIO_FILE_EXTENSIONS.has(extension);
|
||||
}
|
||||
@@ -3,6 +3,11 @@ import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
|
||||
import { downloadFileFromUrl, type UploadMediaFn } from "@storyteller/api";
|
||||
import { toast } from "@storyteller/ui-toaster";
|
||||
import { UploaderStates } from "@storyteller/common";
|
||||
import {
|
||||
AUDIO_FILE_ACCEPT,
|
||||
AUDIO_FILE_TYPE_ERROR,
|
||||
isAudioFile,
|
||||
} from "../common/audioFiles";
|
||||
|
||||
/** Minimal structural shapes so both apps' ref types fit. */
|
||||
export interface DeckRefLike {
|
||||
@@ -368,12 +373,18 @@ export function useDeckMedia<
|
||||
};
|
||||
|
||||
const processAudioFiles = async (files: File[]) => {
|
||||
const audioFiles = files.filter(isAudioFile);
|
||||
if (audioFiles.length < files.length) {
|
||||
toast.error(AUDIO_FILE_TYPE_ERROR, { id: "audio-ref-type" });
|
||||
}
|
||||
if (audioFiles.length === 0) return;
|
||||
|
||||
const availableSlots = Math.max(0, maxAudios - referenceAudios.length);
|
||||
if (availableSlots <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filesToProcess = files.slice(0, availableSlots);
|
||||
const filesToProcess = audioFiles.slice(0, availableSlots);
|
||||
|
||||
for (const file of filesToProcess) {
|
||||
const duration = await getAudioDuration(file);
|
||||
@@ -441,8 +452,12 @@ export function useDeckMedia<
|
||||
if (files.length === 0) return;
|
||||
|
||||
const images = files.filter((f) => f.type.startsWith("image/"));
|
||||
const videos = files.filter((f) => f.type.startsWith("video/"));
|
||||
const audios = files.filter((f) => f.type.startsWith("audio/"));
|
||||
// isAudioFile also claims .m4a files that platforms report as video/mp4,
|
||||
// so exclude them from the video bucket.
|
||||
const audios = files.filter(isAudioFile);
|
||||
const videos = files.filter(
|
||||
(f) => f.type.startsWith("video/") && !isAudioFile(f),
|
||||
);
|
||||
|
||||
if (images.length > 0) processImageFiles(images, "start");
|
||||
if (videos.length > 0 && setReferenceVideos) processVideoFiles(videos);
|
||||
@@ -453,7 +468,7 @@ export function useDeckMedia<
|
||||
const anyUploadAccept = [
|
||||
...(maxImages > 0 ? ["image/*"] : []),
|
||||
...(setReferenceVideos ? ["video/mp4", ".mp4"] : []),
|
||||
...(setReferenceAudios ? ["audio/*"] : []),
|
||||
...(setReferenceAudios ? [AUDIO_FILE_ACCEPT] : []),
|
||||
].join(",");
|
||||
|
||||
const handleGalleryClose = () => {
|
||||
@@ -681,7 +696,7 @@ export function useDeckMedia<
|
||||
type="file"
|
||||
ref={audioFileInputRef}
|
||||
className="hidden"
|
||||
accept="audio/*"
|
||||
accept={AUDIO_FILE_ACCEPT}
|
||||
onChange={handleAudioFileUpload}
|
||||
multiple={maxAudios > 1}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,40 @@
|
||||
import toast, { Toaster as RHToaster } from "react-hot-toast";
|
||||
import rhToast, { Toaster as RHToaster } from "react-hot-toast";
|
||||
|
||||
// Host apps that render their own toast UI instead of mounting <Toaster />
|
||||
// (e.g. the webapp) register a delegate here. Shared-lib `toast.success` /
|
||||
// `toast.error` calls are forwarded to it — without this they dispatch into
|
||||
// react-hot-toast, which renders nothing when its container isn't mounted.
|
||||
export interface ToastDelegate {
|
||||
success: (message: string) => void;
|
||||
error: (message: string) => void;
|
||||
}
|
||||
|
||||
let toastDelegate: ToastDelegate | null = null;
|
||||
|
||||
export function setToastDelegate(delegate: ToastDelegate | null) {
|
||||
toastDelegate = delegate;
|
||||
}
|
||||
|
||||
const toast: typeof rhToast = Object.assign(
|
||||
((...args: Parameters<typeof rhToast>) => rhToast(...args)) as typeof rhToast,
|
||||
rhToast,
|
||||
{
|
||||
success: ((message, opts) => {
|
||||
if (toastDelegate && typeof message === "string") {
|
||||
toastDelegate.success(message);
|
||||
return "";
|
||||
}
|
||||
return rhToast.success(message, opts);
|
||||
}) as typeof rhToast.success,
|
||||
error: ((message, opts) => {
|
||||
if (toastDelegate && typeof message === "string") {
|
||||
toastDelegate.error(message);
|
||||
return "";
|
||||
}
|
||||
return rhToast.error(message, opts);
|
||||
}) as typeof rhToast.error,
|
||||
},
|
||||
);
|
||||
|
||||
interface ToasterProps {
|
||||
position?: "top-right" | "top-left" | "bottom-right" | "bottom-left";
|
||||
|
||||
Reference in new issue
Block a user