diff --git a/services/frontend/src/app/(dashboard)/recordings/view.tsx b/services/frontend/src/app/(dashboard)/recordings/view.tsx index ed1f5d19..b2013603 100644 --- a/services/frontend/src/app/(dashboard)/recordings/view.tsx +++ b/services/frontend/src/app/(dashboard)/recordings/view.tsx @@ -9,6 +9,8 @@ import { Loader2, MessagesSquare, Mic, + Pause, + Play, Search, Trash2, Users, @@ -99,6 +101,9 @@ export function RecordingsView({ const [playingId, setPlayingId] = useState(null); const [exportingIds, setExportingIds] = useState>(new Set()); const [exportingAll, setExportingAll] = useState(false); + const [exportingSessions, setExportingSessions] = useState>( + new Set(), + ); // Maximum older pages to prevent infinite runaway memory usage const MAX_OLDER_PAGES = 10; @@ -141,6 +146,8 @@ export function RecordingsView({ ]; }, [items]); + const sessions = useMemo(() => groupSessions(items ?? []), [items]); + const loadOlder = useCallback(async () => { if ( !hasMore || @@ -249,6 +256,46 @@ export function RecordingsView({ } }; + const onExportSession = async (session: Session) => { + if (exportingSessions.has(session.key)) return; + const target = session.clips.filter( + (r): r is VoiceRecording & { download_url: string } => + Boolean(r.download_url), + ); + if (target.length === 0) return; + setExportingSessions((prev) => new Set(prev).add(session.key)); + try { + const buffers = []; + for (const r of target) { + buffers.push(await decodeAudio(r.download_url)); + } + const merged = concatAudioBuffers(buffers); + if (!merged) throw new Error("No audio decoded"); + const wav = audioBufferToWav(merged); + const safeChannel = (session.channelName ?? "session").replace( + /[^\w.-]+/g, + "_", + ); + downloadBlob(wav, `gmw-session-${safeChannel}-${target.length}clips.wav`); + toast({ + title: `Session exported: ${target.length} clips as one WAV`, + tone: "signal", + }); + } catch (e) { + toast({ + title: "Session export failed", + description: String(e), + tone: "vermilion", + }); + } finally { + setExportingSessions((prev) => { + const next = new Set(prev); + next.delete(session.key); + return next; + }); + } + }; + if (error && !items) return void mutate()} />; if (!items && isLoading) @@ -439,128 +486,144 @@ export function RecordingsView({ ref={deckRef} className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3" > - {(items ?? []).map((r) => { - const up = uploadStatus(r); - const isPlaying = playingId === r.id; - const isExporting = exportingIds.has(r.id); - return ( -
-
- {/* Header info */} -
- -
-
- {r.username} -
-
- - - {r.channel_name ?? "voice"} - - · - - {formatRelativeTime(r.created_at)} - -
-
- {isPlaying && } - {up && !isPlaying && ( - - {up.label} - - )} -
- - {/* Transcription snippet */} -
- -
- - {/* Audio Player Scrub */} -
- {r.download_url ? ( - - setPlayingId((prev) => { - if (active) return r.id; - return prev === r.id ? null : prev; - }) - } - /> - ) : ( -
- - {r.upload_status === "pending" - ? "UPLOAD_PENDING..." - : r.upload_error - ? r.upload_error - : "SYNTHESIZING_PCM..."} -
- )} -
-
- - {/* Actions & File Stats */} -
- - SIZE: {formatBytes(r.size_bytes)} - -
- {r.download_url && ( - <> - - - {" "} - RAW - - - )} - -
-
-
+ {sessions.flatMap((session) => { + const sessionPlayable = session.clips.some((c) => + Boolean(c.download_url), ); + const sessionExporting = exportingSessions.has(session.key); + const header = ( + void onExportSession(session)} + /> + ); + const cards = session.clips.map((r) => { + const up = uploadStatus(r); + const isPlaying = playingId === r.id; + const isExporting = exportingIds.has(r.id); + return ( +
+
+ {/* Header info */} +
+ +
+
+ {r.username} +
+
+ + + {r.channel_name ?? "voice"} + + · + + {formatRelativeTime(r.created_at)} + +
+
+ {isPlaying && } + {up && !isPlaying && ( + + {up.label} + + )} +
+ + {/* Transcription snippet */} +
+ +
+ + {/* Audio Player Scrub */} +
+ {r.download_url ? ( + + setPlayingId((prev) => { + if (active) return r.id; + return prev === r.id ? null : prev; + }) + } + /> + ) : ( +
+ + {r.upload_status === "pending" + ? "UPLOAD_PENDING..." + : r.upload_error + ? r.upload_error + : "SYNTHESIZING_PCM..."} +
+ )} +
+
+ + {/* Actions & File Stats */} +
+ + SIZE: {formatBytes(r.size_bytes)} + +
+ {r.download_url && ( + <> + + + {" "} + RAW + + + )} + +
+
+
+ ); + }); + return [header, ...cards]; })} @@ -749,3 +812,171 @@ function uploadStatus( if (r.upload_status === "pending") return { tone: "amber", label: "pending" }; return { tone: "amber", label: "processing" }; } + +// ── Session grouping ────────────────────────────────────────────────────── +// Clips become one "meeting session" when they share a channel and the gap +// between consecutive clips is within SESSION_GAP_MS. items arrive newest-first, +// so sessions are produced most-recent-first and each keeps its clips in that +// order (newest → oldest). +const SESSION_GAP_MS = 120_000; + +interface Session { + key: string; + channelId: string; + channelName: string; + startTs: number; + endTs: number; + clips: VoiceRecording[]; +} + +function groupSessions(items: VoiceRecording[]): Session[] { + const sessions: Session[] = []; + for (const r of items) { + const last = sessions[sessions.length - 1]; + if ( + last && + last.channelId === (r.channel_id ?? "") && + last.startTs - r.created_at <= SESSION_GAP_MS + ) { + last.clips.push(r); + last.startTs = r.created_at; + } else { + sessions.push({ + key: r.id, + channelId: r.channel_id ?? "", + channelName: r.channel_name ?? "voice", + startTs: r.created_at, + endTs: r.created_at, + clips: [r], + }); + } + } + return sessions; +} + +function sessionEstDurationSec(session: Session): number { + const bytes = session.clips.reduce((acc, c) => acc + c.size_bytes, 0); + return Math.round((bytes * 8) / 128000); +} + +function sessionTimestamp(session: Session): string { + const d = new Date(session.endTs); + const s = new Date(session.startTs); + const sameDay = d.toDateString() === s.toDateString(); + const fmt = (x: Date) => + `${x.getMonth() + 1}/${x.getDate()} ${String(x.getHours()).padStart(2, "0")}:${String( + x.getMinutes(), + ).padStart(2, "0")}`; + return sameDay ? fmt(d) : `${fmt(d)} – ${fmt(s)}`; +} + +/** Sequential playback of a session's clips via one