feat: initial conversational flow (#37)

* chore: only set visibility for container particles

* create reusable controls indicator for reply or new

* compress the size of top bar

* refactor: restructure state, routing, and more

* introduce stream compose flow

* feat: compose new stream full flow

* implement stream player

* fix: prevent redirect for signed object urls

* fix: implement stream playback cleaner structure

* refactor: layout file name

* feat: show stream name in breadcrumbs

* chore: tweak padding

* chore: adjust position of audio bars

* feat: show latest particle preview in stream list

* fix: remove console log

* refactor: reorder classes

* fix: avoid passing in updated_at to firestore particle

* refactor: extract properties for container particles to flat fields in firestore

* make the stream previews look alive

* feat: show audio bars during audio clip playback

* feat: order streams by last child creation

* feat: playback where I left off

* chore: remove unused store

* fix: recording mode not using shared state

* chore: clean unused variable

* remove unused imports

* fix: improve controls indicator immersion

* feat: show playback progress in bar & auto-play text

* feat: auto-exit stream on playback completion

* fix: jittery media playback progress

* fix: navigate during state change is invalid with react router

* fix: buggy exit progress when changing clips

* feat: add app icon

* update package.json info

* feat: only show streams visible to me

* feat: show seen indicator on particles

* fix: prevent unnecessary effects

* fix: play new particle after playback is ended

* use contols indicator for exit timer
This commit was merged in pull request #37.
This commit is contained in:
Arjun Patel
2026-03-19 16:29:40 -07:00
committed by GitHub
parent 990137b829
commit 4b66d8e185
48 changed files with 2112 additions and 1482 deletions
-15
View File
@@ -1,15 +0,0 @@
import { create } from "zustand";
/**
* Minimal app-level store. Navigation state is now URL-driven via PathResolver.
* Stream/particle state will move to Firestore hooks.
*/
interface AppState {
selectedNetworkId: string | null;
setSelectedNetwork: (id: string | null) => void;
}
export const useAppStore = create<AppState>((set) => ({
selectedNetworkId: null,
setSelectedNetwork: (id) => set({ selectedNetworkId: id }),
}));
+21
View File
@@ -0,0 +1,21 @@
import { create } from "zustand";
export type RecordingMode = "video" | "audio";
const KEY = "llink:recording-mode";
interface MediaSettingsState {
recordingMode: RecordingMode;
setRecordingMode: (mode: RecordingMode) => void;
}
export const useMediaSettingsStore = create<MediaSettingsState>((set) => ({
recordingMode: (() => {
const stored = localStorage.getItem(KEY);
return stored === "audio" ? "audio" : "video";
})(),
setRecordingMode: (mode) => {
localStorage.setItem(KEY, mode);
set({ recordingMode: mode });
},
}));
-88
View File
@@ -1,88 +0,0 @@
import { create } from "zustand";
import type { StreamParticle } from "@/api/types";
type PlaybackStatus = "idle" | "playing" | "ended";
interface PlaybackState {
streamId: string | null;
particles: StreamParticle[];
currentIndex: number;
status: PlaybackStatus;
paused: boolean;
downloadUrlCache: Record<string, string>;
initStream: (
streamId: string,
particles: StreamParticle[],
startIndex: number,
) => void;
next: () => void;
prev: () => void;
goTo: (index: number) => void;
pause: () => void;
resume: () => void;
cacheDownloadUrl: (particleId: string, url: string) => void;
reset: () => void;
}
export const usePlaybackStore = create<PlaybackState>((set, get) => ({
streamId: null,
particles: [],
currentIndex: 0,
status: "idle",
paused: false,
downloadUrlCache: {},
initStream: (streamId, particles, startIndex) => {
set({
streamId,
particles,
currentIndex: startIndex,
status: particles.length > 0 ? "playing" : "ended",
downloadUrlCache: {},
});
},
next: () => {
const { currentIndex, particles } = get();
if (currentIndex < particles.length - 1) {
set({ currentIndex: currentIndex + 1, paused: false });
} else {
set({ status: "ended", paused: false });
}
},
prev: () => {
const { currentIndex } = get();
if (currentIndex > 0) {
set({ currentIndex: currentIndex - 1, status: "playing", paused: false });
}
},
goTo: (index) => {
const { particles } = get();
if (index >= 0 && index < particles.length) {
set({ currentIndex: index, status: "playing", paused: false });
}
},
pause: () => set({ paused: true }),
resume: () => set({ paused: false }),
cacheDownloadUrl: (particleId, url) => {
set({
downloadUrlCache: { ...get().downloadUrlCache, [particleId]: url },
});
},
reset: () => {
set({
streamId: null,
particles: [],
currentIndex: 0,
status: "idle",
paused: false,
downloadUrlCache: {},
});
},
}));
-54
View File
@@ -1,54 +0,0 @@
import { create } from "zustand";
type RecordingStatus = "idle" | "recording" | "reviewing" | "uploading" | "error";
type RecordingMode = "video" | "audio";
const RECORDING_MODE_KEY = "llink:recording-mode";
function loadRecordingMode(): RecordingMode {
const stored = localStorage.getItem(RECORDING_MODE_KEY);
return stored === "audio" ? "audio" : "video";
}
interface RecordingState {
status: RecordingStatus;
error: string | null;
mediaStream: MediaStream | null;
recordingMode: RecordingMode;
reviewBlob: Blob | null;
reviewDurationMs: number;
setStatus: (status: RecordingStatus) => void;
setError: (error: string) => void;
setMediaStream: (stream: MediaStream | null) => void;
setRecordingMode: (mode: RecordingMode) => void;
setReviewBlob: (blob: Blob, durationMs: number) => void;
reset: () => void;
}
export const useRecordingStore = create<RecordingState>((set) => ({
status: "idle",
error: null,
mediaStream: null,
recordingMode: loadRecordingMode(),
reviewBlob: null,
reviewDurationMs: 0,
setStatus: (status) => set({ status, error: null }),
setError: (error) => set({ status: "error", error }),
setMediaStream: (mediaStream) => set({ mediaStream }),
setRecordingMode: (recordingMode) => {
localStorage.setItem(RECORDING_MODE_KEY, recordingMode);
set({ recordingMode });
},
setReviewBlob: (reviewBlob, reviewDurationMs) =>
set({ status: "reviewing", reviewBlob, reviewDurationMs }),
reset: () =>
set({
status: "idle",
error: null,
mediaStream: null,
reviewBlob: null,
reviewDurationMs: 0,
}),
}));