convert flacs to aac for streaming

This commit is contained in:
2026-06-16 12:49:30 +02:00
parent f5e8b87846
commit 7aa1746f97
12 changed files with 401 additions and 8 deletions

View File

@@ -0,0 +1,100 @@
'use client'
import { useState } from "react";
import { AudioLines, Loader2, RefreshCw } from "lucide-react";
import { Button } from "~/components/ui/button";
import { trpc } from "~/app/_trpc/Client";
import { useUploadThing } from "~/lib/uploadthing";
import { transcodeToAac } from "~/lib/ffmpeg/transcode";
import { toast } from "sonner";
import type { RouterOutputs } from "~/server/routers/_app";
import type { IterableElement } from "type-fest";
export default function ConvertToStreamButton(props: {
track: IterableElement<RouterOutputs['music']['list']>;
}) {
const { track } = props;
const utils = trpc.useUtils();
const [busy, setBusy] = useState(false);
const [stage, setStage] = useState("");
const [progress, setProgress] = useState(0);
const setStream = trpc.music.setStream.useMutation({
onSuccess: () => utils.music.list.invalidate(),
});
const { startUpload } = useUploadThing("musicUploader");
async function handleConvert() {
setBusy(true);
setProgress(0);
let currentStage = "Loading ffmpeg";
const goto = (s: string) => {
currentStage = s;
setStage(s);
};
try {
goto("Transcoding");
const file = await transcodeToAac({
sourceUrl: track.fileUrl,
outputName: `${track.title || "track"}.m4a`,
onProgress: setProgress,
});
goto("Uploading");
const uploaded = await startUpload([file]);
const res = uploaded?.[0];
if (!res) throw new Error("Upload returned no file");
goto("Saving");
await setStream.mutateAsync({
id: track.id,
streamUrl: res.serverData.fileUrl,
streamKey: res.serverData.fileKey,
streamName: res.serverData.fileName,
});
toast("Streaming version saved");
} catch (e) {
console.error("[ConvertToStream] failed during", currentStage, e);
const detail =
e instanceof Error
? e.message
: typeof e === "string"
? e
: (() => {
try {
return JSON.stringify(e);
} catch {
return String(e);
}
})();
toast(`Conversion failed (${currentStage}): ${detail || "see console for details"}`);
} finally {
setBusy(false);
setStage("");
setProgress(0);
}
}
return (
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" disabled={busy} onClick={handleConvert}>
{busy ? (
<Loader2 className="animate-spin" />
) : track.streamUrl ? (
<RefreshCw />
) : (
<AudioLines />
)}
{busy
? `${stage}${stage === "Transcoding" && progress ? ` ${Math.round(progress * 100)}%` : "…"}`
: track.streamUrl
? "Re-generate stream"
: "Generate stream (AAC)"}
</Button>
{track.streamUrl && !busy && (
<span className="text-xs text-muted-foreground">Streaming version ready</span>
)}
</div>
);
}

View File

@@ -3,6 +3,7 @@
import { trpc } from "~/app/_trpc/Client";
import * as Card from "~/components/ui/card";
import UploadMusicForm from "./_components/UploadMusicForm";
import ConvertToStreamButton from "./_components/ConvertToStreamButton";
import { CollapsibleForm } from "~/app/_components/Form/Components";
import { useEffect } from "react";
@@ -14,10 +15,11 @@ export default function AdminMusicPage() {
{tracks && <>
{tracks.map((t) => (
<Card.Card key={t.id}>
<Card.CardContent>
<Card.CardContent className="flex flex-col gap-4">
<UploadMusicForm entity={t} className="w-full"/>
<ConvertToStreamButton track={t} />
</Card.CardContent>
</Card.Card>
</Card.Card>
))}
</>}
<CollapsibleForm entityName="Track" form={UploadMusicForm}/>

View File

@@ -0,0 +1,125 @@
'use client'
import { useRef, useState } from "react";
import { Download, Loader2, Pause, Play } from "lucide-react";
import { Slider } from "~/components/ui/slider";
import { Button } from "~/components/ui/button";
import { toast } from "sonner";
function formatTime(seconds: number) {
if (!Number.isFinite(seconds)) return "0:00";
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s.toString().padStart(2, "0")}`;
}
export default function AudioPlayer(props: {
/** Streaming-friendly source the player actually plays. */
src: string;
/** Original high-quality file offered via the download button. */
downloadUrl: string;
downloadName: string;
}) {
const audioRef = useRef<HTMLAudioElement>(null);
const [playing, setPlaying] = useState(false);
const [current, setCurrent] = useState(0);
const [duration, setDuration] = useState(0);
const [seeking, setSeeking] = useState(false);
const [downloading, setDownloading] = useState(false);
function togglePlay() {
const audio = audioRef.current;
if (!audio) return;
if (audio.paused) {
audio.play();
} else {
audio.pause();
}
}
async function handleDownload() {
setDownloading(true);
try {
// The download file is cross-origin, so the <a download> attribute is
// ignored — fetch it as a blob to force a real download.
const res = await fetch(props.downloadUrl);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = props.downloadName;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (e) {
toast(`Download failed: ${e instanceof Error ? e.message : "unknown error"}`);
} finally {
setDownloading(false);
}
}
return (
<div className="flex items-center gap-3 rounded-lg border bg-transparent px-3 py-2">
<audio
ref={audioRef}
src={props.src}
preload="metadata"
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onLoadedMetadata={(e) => setDuration(e.currentTarget.duration)}
onTimeUpdate={(e) => {
if (!seeking) setCurrent(e.currentTarget.currentTime);
}}
onEnded={() => setPlaying(false)}
/>
<Button
type="button"
size="icon"
variant="ghost"
aria-label={playing ? "Pause" : "Play"}
onClick={togglePlay}
>
{playing ? <Pause /> : <Play />}
</Button>
<span className="w-10 shrink-0 text-right font-mono text-xs text-muted-foreground tabular-nums">
{formatTime(current)}
</span>
<Slider
className="flex-1"
min={0}
max={duration || 1}
step={0.1}
value={[current]}
onValueChange={([v]) => {
setSeeking(true);
setCurrent(v ?? 0);
}}
onValueCommit={([v]) => {
if (audioRef.current) audioRef.current.currentTime = v ?? 0;
setSeeking(false);
}}
/>
<span className="w-10 shrink-0 font-mono text-xs text-muted-foreground tabular-nums">
{formatTime(duration)}
</span>
<Button
type="button"
size="icon"
variant="ghost"
aria-label="Download lossless file"
title="Download lossless file"
disabled={downloading}
onClick={handleDownload}
>
{downloading ? <Loader2 className="animate-spin" /> : <Download />}
</Button>
</div>
);
}

View File

@@ -7,6 +7,7 @@ import { Spinner } from "~/components/ui/spinner";
import AnimateTextIn from "../_components/Animated/AnimateIn";
import { ScrollArea } from "~/components/ui/scroll-area";
import AnimatePopUp from "../_components/Animated/AnimatePopUp";
import AudioPlayer from "./_components/AudioPlayer";
export default function MusicPage() {
const { data: tracks, isLoading } = trpc.music.list.useQuery();
useTimeLine(tracks)
@@ -41,9 +42,11 @@ export default function MusicPage() {
<p className="text-sm text-muted-foreground gsapant">{track.description}</p>
)}
<AnimatePopUp position={i + 1.3}>
<audio controls className="w-full player" src={track.fileUrl}>
Your browser does not support the audio element.
</audio>
<AudioPlayer
src={track.streamUrl ?? track.fileUrl}
downloadUrl={track.fileUrl}
downloadName={track.fileName}
/>
</AnimatePopUp>
</Card.CardContent>
</Card.AnimatedCard>