diff --git a/go.mod b/go.mod index ea0175c78..99889a104 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require ( github.com/NYTimes/gziphandler v1.1.1 github.com/SherClockHolmes/webpush-go v1.4.0 github.com/ThalesGroup/crypto11 v0.0.0-00010101000000-000000000000 + github.com/abema/go-mp4 v1.4.1 github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d github.com/aws/aws-sdk-go-v2 v1.41.4 github.com/aws/aws-sdk-go-v2/credentials v1.19.12 @@ -159,7 +160,6 @@ require ( github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b // indirect github.com/StackExchange/wmi v1.2.1 // indirect github.com/VictoriaMetrics/fastcache v1.13.0 // indirect - github.com/abema/go-mp4 v1.4.1 // indirect github.com/agnivade/levenshtein v1.2.0 // indirect github.com/alecthomas/chroma/v2 v2.17.2 // indirect github.com/alecthomas/go-check-sumtype v0.3.1 // indirect diff --git a/js/app/components/mobile/desktop-ui/top-controls.tsx b/js/app/components/mobile/desktop-ui/top-controls.tsx index 5cc87c605..40dfbac42 100644 --- a/js/app/components/mobile/desktop-ui/top-controls.tsx +++ b/js/app/components/mobile/desktop-ui/top-controls.tsx @@ -19,6 +19,7 @@ import { Pressable, useWindowDimensions, } from "react-native"; +import { useStore } from "store"; import { convertNavigationParams } from "../../../src/navigation-helper"; import { LiveBubble } from "./live-bubble"; @@ -43,6 +44,7 @@ export function TopControlBar({ }: TopControlBarProps) { const navigation = useNavigation(); const profile = useAuthor(); + const openLoginModal = useStore((state) => state.openLoginModal); const { doSetIngestCamera } = useCameraToggle(); const avatar = useAvatar(); const { width } = useWindowDimensions(); @@ -159,6 +161,8 @@ export function TopControlBar({ <> + + {chatSection} + {/* The clip editor mounts here, as a sibling of the player, so it + sits outside MobileUi's fade overlay and gesture detector. It + opens itself from the clip store's draft state. */} + diff --git a/js/app/components/mobile/ui.tsx b/js/app/components/mobile/ui.tsx index 9243809e4..415a58a85 100644 --- a/js/app/components/mobile/ui.tsx +++ b/js/app/components/mobile/ui.tsx @@ -47,6 +47,7 @@ import Animated, { withTiming, } from "react-native-reanimated"; import { SafeAreaView } from "react-native-safe-area-context"; +import { useStore } from "store"; import { MobileChatPanel } from "./chat"; import { useResponsiveLayout } from "./useResponsiveLayout"; @@ -67,6 +68,7 @@ export function MobileUi({ }) { const { theme } = useTheme(); const navigation = useNavigation(); + const openLoginModal = useStore((state) => state.openLoginModal); const { ingest, title, @@ -232,6 +234,12 @@ export function MobileUi({ > + + {/* Only offer clipping while watching someone else's live + stream — not VOD, not your own ingest. */} + {mode !== "vod" && ls !== null && ingest === null && ( + + )} { + const call = jest.fn(); + class FakeStreamplaceAgent { + client = { call }; + } + return { + StreamplaceAgent: FakeStreamplaceAgent, + place: { + stream: { + clip: { + create: { $nsid: "place.stream.clip.create" }, + publish: { $nsid: "place.stream.clip.publish" }, + cancel: { $nsid: "place.stream.clip.cancel" }, + }, + }, + }, + __mockCall: call, + }; +}); + +const mockCall = (jest.requireMock("streamplace") as any) + .__mockCall as jest.Mock; + +const SESSION = { did: "did:plc:viewer" }; // stands in for a SessionManager +const STREAMER = "did:plc:streamer"; +const LIVESTREAM = "at://did:plc:streamer/place.stream.livestream/abc"; +const TTL_MS = 10 * 60 * 1000; + +const createResult = () => ({ + clipId: "clip-1", + previewUrl: "https://example.com/preview.mp4", + expiresAt: new Date(Date.now() + TTL_MS).toISOString(), + durationMs: 120000, +}); + +const publishResult = { + videoUri: "at://did:plc:viewer/place.stream.video/clip1", + clipUri: "at://did:plc:viewer/place.stream.clip.entry/clip1", +}; + +beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date("2026-01-01T00:00:00Z")); + useClipStore.getState().discard(); + mockCall.mockReset(); + mockCall.mockImplementation(async (proc: { $nsid: string }) => { + if (proc.$nsid === "place.stream.clip.create") return createResult(); + if (proc.$nsid === "place.stream.clip.publish") return { ...publishResult }; + if (proc.$nsid === "place.stream.clip.cancel") return { cancelled: true }; + throw new Error("unexpected xrpc call"); + }); +}); + +const requestClip = () => + useClipStore.getState().requestClip({ + streamerDID: STREAMER, + oauthSession: SESSION as any, + livestreamUri: LIVESTREAM, + }); + +describe("requestClip", () => { + it("errors with 'Still loading your session' while the session loads", async () => { + await useClipStore.getState().requestClip({ + streamerDID: STREAMER, + oauthSession: undefined, + livestreamUri: LIVESTREAM, + }); + const s = useClipStore.getState(); + expect(s.status).toBe("error"); + expect(s.error).toBe("Still loading your session"); + expect(mockCall).not.toHaveBeenCalled(); + }); + + it("errors with 'Please sign in to clip' when logged out, without a network call", async () => { + await useClipStore.getState().requestClip({ + streamerDID: STREAMER, + oauthSession: null, + livestreamUri: LIVESTREAM, + }); + const s = useClipStore.getState(); + expect(s.status).toBe("error"); + expect(s.error).toBe("Please sign in to clip"); + expect(mockCall).not.toHaveBeenCalled(); + }); + + it("requests a 120s grab and enters editing with a full-duration trim", async () => { + await requestClip(); + const s = useClipStore.getState(); + expect(s.status).toBe("editing"); + expect(s.clipId).toBe("clip-1"); + expect(s.previewUrl).toBe("https://example.com/preview.mp4"); + expect(s.durationMs).toBe(120000); + expect(s.trimStart).toBe(0); + expect(s.trimEnd).toBe(120000); + expect(s.livestreamUri).toBe(LIVESTREAM); + expect(s.timeRemaining).toBe(TTL_MS); + expect(mockCall).toHaveBeenCalledWith( + expect.objectContaining({ $nsid: "place.stream.clip.create" }), + { streamer: STREAMER, durationMs: 120000 }, + ); + }); + + it.each([ + ["ClippingDisabled", "The streamer has disabled clipping"], + ["RateLimited", "You're clipping too fast, try again in a moment"], + ["NotLive", "The stream isn't live right now"], + ["NoContent", "Nothing to clip yet, try again in a moment"], + ["Unauthorized", "Please sign in to clip"], + ])("maps the %s create error to user-facing copy", async (name, copy) => { + mockCall.mockRejectedValueOnce({ error: name, message: "server says" }); + await requestClip(); + const s = useClipStore.getState(); + expect(s.status).toBe("error"); + expect(s.error).toBe(copy); + expect(s.clipId).toBeNull(); + }); + + it("falls back to the server message for unknown create errors", async () => { + mockCall.mockRejectedValueOnce({ + error: "MysteryError", + message: "mystery", + }); + await requestClip(); + expect(useClipStore.getState().error).toBe("mystery"); + }); +}); + +describe("setTrim", () => { + beforeEach(requestClip); + + it("clamps to the track bounds", () => { + useClipStore.getState().setTrim(-1000, 130000); + const s = useClipStore.getState(); + expect(s.trimStart).toBe(0); + expect(s.trimEnd).toBe(120000); + }); + + it("enforces the 5s minimum by pushing the far boundary", () => { + useClipStore.getState().setTrim(18000, 20000); + const s = useClipStore.getState(); + expect(s.trimStart).toBe(18000); + expect(s.trimEnd).toBe(23000); + }); + + it("stores integer trims even when given gesture-derived floats", () => { + // px→ms conversion in the timeline produces non-integer ms; publish sends + // start/end as int64 so the store must round. + useClipStore.getState().setTrim(49830.50847457627, 90000.1234); + const s = useClipStore.getState(); + expect(Number.isInteger(s.trimStart)).toBe(true); + expect(Number.isInteger(s.trimEnd)).toBe(true); + expect(s.trimStart).toBe(49831); + expect(s.trimEnd).toBe(90000); + }); +}); + +describe("publish", () => { + it("does not publish without a title", async () => { + await requestClip(); + await useClipStore.getState().publish(); + const s = useClipStore.getState(); + expect(s.status).toBe("editing"); + expect(s.error).toBe("Title is required"); + expect(mockCall).not.toHaveBeenCalledWith( + expect.objectContaining({ $nsid: "place.stream.clip.publish" }), + expect.anything(), + ); + }); + + it("publishes the trim window and title", async () => { + await requestClip(); + useClipStore.getState().setTrim(30000, 90000); + useClipStore.getState().setTitle("Great moment"); + await useClipStore.getState().publish(); + const s = useClipStore.getState(); + expect(s.status).toBe("published"); + expect(s.videoUri).toBe(publishResult.videoUri); + expect(s.clipUri).toBe(publishResult.clipUri); + expect(mockCall).toHaveBeenCalledWith( + expect.objectContaining({ $nsid: "place.stream.clip.publish" }), + { + clipId: "clip-1", + livestream: LIVESTREAM, + start: 30000, + end: 90000, + title: "Great moment", + }, + ); + }); + + it.each([ + ["DraftNotFound", "This clip draft no longer exists, start over"], + ["DraftExpired", "This clip draft expired, start over"], + ["Unauthorized", "Please sign in to publish"], + ])( + "maps the %s publish error to copy and keeps the draft for retry", + async (name, copy) => { + await requestClip(); + useClipStore.getState().setTitle("x"); + mockCall.mockRejectedValueOnce({ error: name, message: "server says" }); + await useClipStore.getState().publish(); + const s = useClipStore.getState(); + expect(s.status).toBe("error"); + expect(s.error).toBe(copy); + // clipId retained → the editor stays open with a retryable Publish. + expect(s.clipId).toBe("clip-1"); + }, + ); + + it("refuses to publish an expired draft without sending a request", async () => { + await requestClip(); + useClipStore.getState().setTitle("x"); + // Advance past the TTL; the tick flips editing → expired. + useClipStore.getState().tick(Date.now() + TTL_MS + 1000); + expect(useClipStore.getState().status).toBe("expired"); + await useClipStore.getState().publish(); + expect(useClipStore.getState().status).toBe("expired"); + expect(mockCall).not.toHaveBeenCalledWith( + expect.objectContaining({ $nsid: "place.stream.clip.publish" }), + expect.anything(), + ); + }); + + it("does not resurrect a draft discarded while publishing", async () => { + await requestClip(); + useClipStore.getState().setTitle("x"); + let resolvePublish!: (v: unknown) => void; + mockCall.mockImplementationOnce( + (proc: { $nsid: string }) => + new Promise((resolve) => { + resolvePublish = resolve; + void proc; + }), + ); + const inFlight = useClipStore.getState().publish(); + // The dialog X/backdrop discards while the request is still in flight. + useClipStore.getState().discard(); + resolvePublish({ ...publishResult }); + await inFlight; + expect(useClipStore.getState().status).toBe("idle"); + expect(useClipStore.getState().videoUri).toBeNull(); + }); +}); + +describe("cancel", () => { + it("deletes the draft server-side and resets to idle", async () => { + await requestClip(); + await useClipStore.getState().cancel(); + const s = useClipStore.getState(); + expect(s.status).toBe("idle"); + expect(s.clipId).toBeNull(); + expect(mockCall).toHaveBeenCalledWith( + expect.objectContaining({ $nsid: "place.stream.clip.cancel" }), + { clipId: "clip-1" }, + ); + }); + + it("is best-effort: a server error still resets to idle", async () => { + await requestClip(); + mockCall.mockRejectedValueOnce({ error: "DraftNotFound" }); + await useClipStore.getState().cancel(); + expect(useClipStore.getState().status).toBe("idle"); + }); + + it("does not send a cancel request after publishing", async () => { + await requestClip(); + useClipStore.getState().setTitle("x"); + await useClipStore.getState().publish(); + mockCall.mockClear(); + await useClipStore.getState().cancel(); + expect(useClipStore.getState().status).toBe("idle"); + expect(mockCall).not.toHaveBeenCalledWith( + expect.objectContaining({ $nsid: "place.stream.clip.cancel" }), + expect.anything(), + ); + }); + + it("does nothing with no draft", async () => { + await useClipStore.getState().cancel(); + expect(mockCall).not.toHaveBeenCalled(); + }); +}); + +describe("expiry", () => { + it("ticks down timeRemaining while editing and flips to expired at zero", async () => { + await requestClip(); + useClipStore.getState().tick(Date.now() + 60_000); + const s = useClipStore.getState(); + expect(s.status).toBe("editing"); + expect(s.timeRemaining).toBe(TTL_MS - 60_000); + + useClipStore.getState().tick(Date.now() + TTL_MS + 1000); + const s2 = useClipStore.getState(); + expect(s2.status).toBe("expired"); + expect(s2.timeRemaining).toBe(0); + }); +}); + +describe("module singleton", () => { + it("keeps draft state across module re-imports (survives navigation)", async () => { + await requestClip(); + useClipStore.getState().setTitle("persisted title"); + // Re-importing resolves to the same cached module instance, so the draft + // survives whatever unmounts/remounts the editor. + const again = require("./clip-store"); + expect(again.useClipStore).toBe(useClipStore); + expect(again.useClipStore.getState().title).toBe("persisted title"); + expect(again.useClipStore.getState().status).toBe("editing"); + }); +}); + +describe("mapClipError", () => { + it("maps each create error name", () => { + expect(mapClipError("create", { error: "ClippingDisabled" })).toBe( + "The streamer has disabled clipping", + ); + expect(mapClipError("create", { error: "RateLimited" })).toBe( + "You're clipping too fast, try again in a moment", + ); + }); + + it("maps each publish error name", () => { + expect(mapClipError("publish", { error: "DraftExpired" })).toBe( + "This clip draft expired, start over", + ); + expect(mapClipError("publish", { error: "Unauthorized" })).toBe( + "Please sign in to publish", + ); + }); + + it("falls back to the server message and then a generic string", () => { + expect( + mapClipError("create", { error: "Unknown", message: "custom" }), + ).toBe("custom"); + expect(mapClipError("create", new Error("plain error"))).toBe( + "plain error", + ); + expect(mapClipError("create", {})).toBe("Failed to create clip"); + expect(mapClipError("publish", {})).toBe("Failed to publish clip"); + }); +}); diff --git a/js/components/src/clip-store/clip-store.tsx b/js/components/src/clip-store/clip-store.tsx new file mode 100644 index 000000000..d5382068a --- /dev/null +++ b/js/components/src/clip-store/clip-store.tsx @@ -0,0 +1,267 @@ +import { SessionManager } from "@atproto/api/dist/session-manager"; +import { place, StreamplaceAgent } from "streamplace"; +import { create } from "zustand"; +import { clampWindow, MIN_CLIP_MS } from "./trim-math"; + +export type ClipStatus = + | "idle" + | "requesting" + | "editing" + | "publishing" + | "published" + | "expired" + | "error"; + +export interface ClipState { + status: ClipStatus; + clipId: string | null; + previewUrl: string | null; + expiresAt: number | null; + durationMs: number; + trimStart: number; + trimEnd: number; + title: string; + videoUri: string | null; + clipUri: string | null; + error: string | null; + /** Milliseconds until the draft expires; ticked every second while editing. */ + timeRemaining: number; + /** + * Internal: snapshotted at draft creation. The oauth session and livestream + * URI live in per-provider context stores (not module singletons), so the + * draft captures its agent + livestream here — publish() stays argument-free + * and the draft survives navigation away from the player. + */ + agent: StreamplaceAgent | null; + livestreamUri: string | null; +} + +export interface ClipStore extends ClipState { + requestClip: (input: { + streamerDID: string; + oauthSession: SessionManager | null | undefined; + livestreamUri: string | null; + durationMs?: number; + }) => Promise; + setTrim: (start: number, end: number) => void; + setTitle: (title: string) => void; + publish: () => Promise; + /** Best-effort server cancel: deletes the ephemeral draft file, then resets. */ + cancel: () => Promise; + discard: () => void; + /** Called every second while editing; flips editing → expired at TTL. */ + tick: (now?: number) => void; +} + +const initialState: ClipState = { + status: "idle", + clipId: null, + previewUrl: null, + expiresAt: null, + durationMs: 0, + trimStart: 0, + trimEnd: 0, + title: "", + videoUri: null, + clipUri: null, + error: null, + timeRemaining: 0, + agent: null, + livestreamUri: null, +}; + +export type ClipErrorKind = "create" | "publish"; + +const CREATE_ERROR_COPY: Record = { + ClippingDisabled: "The streamer has disabled clipping", + RateLimited: "You're clipping too fast, try again in a moment", + NotLive: "The stream isn't live right now", + NoContent: "Nothing to clip yet, try again in a moment", + Unauthorized: "Please sign in to clip", +}; + +const PUBLISH_ERROR_COPY: Record = { + DraftNotFound: "This clip draft no longer exists, start over", + DraftExpired: "This clip draft expired, start over", + Unauthorized: "Please sign in to publish", +}; + +// Map a lexicon error name (the @atproto/lex client throws XrpcResponseError +// with `.error` = error name and `.message` = server description) to +// user-facing copy, falling back to the server message for unknown names. +export function mapClipError(kind: ClipErrorKind, error: unknown): string { + const name = (error as any)?.error; + if (typeof name === "string") { + const copy = (kind === "create" ? CREATE_ERROR_COPY : PUBLISH_ERROR_COPY)[ + name + ]; + if (copy) return copy; + } + const message = (error as any)?.message; + if (typeof message === "string" && message.trim()) return message; + return kind === "create" ? "Failed to create clip" : "Failed to publish clip"; +} + +let expiryTimer: ReturnType | null = null; + +function startExpiryTimer() { + if (expiryTimer !== null) return; + expiryTimer = setInterval(() => { + useClipStore.getState().tick(); + }, 1000); +} + +function clearExpiryTimer() { + if (expiryTimer !== null) { + clearInterval(expiryTimer); + expiryTimer = null; + } +} + +export const useClipStore = create()((set, get) => ({ + ...initialState, + + requestClip: async ({ + streamerDID, + oauthSession, + livestreamUri, + durationMs = 120000, + }) => { + // Branch on the three oauthSession states the same way usePDSAgent does: + // undefined = session still loading, null = logged out, SessionManager = + // logged in. The trigger only renders the login CTA when logged out, so + // these guards are defensive against boot-time races. + if (oauthSession === undefined) { + set({ status: "error", error: "Still loading your session" }); + return; + } + if (oauthSession === null) { + set({ status: "error", error: "Please sign in to clip" }); + return; + } + const agent = new StreamplaceAgent(oauthSession); + set({ status: "requesting", error: null }); + try { + const result = await agent.client.call(place.stream.clip.create, { + streamer: streamerDID as any, + durationMs, + }); + const expiresAt = new Date(result.expiresAt).getTime(); + set({ + status: "editing", + clipId: result.clipId, + previewUrl: result.previewUrl, + expiresAt, + durationMs: result.durationMs, + // Default trim: the full grabbed duration. result.durationMs is + // authoritative (may be less than requested if the buffer was short). + trimStart: 0, + trimEnd: result.durationMs, + title: "", + videoUri: null, + clipUri: null, + error: null, + timeRemaining: Math.max(0, expiresAt - Date.now()), + agent, + livestreamUri, + }); + startExpiryTimer(); + } catch (e) { + set({ status: "error", error: mapClipError("create", e) }); + } + }, + + setTrim: (start, end) => { + const s = get(); + if (s.status !== "editing") return; + const clamped = clampWindow(start, end, s.durationMs, MIN_CLIP_MS); + // The XRPC lexicon types start/end as int64 milliseconds. Timeline drags + // produce float ms via px→ms conversion, so round here — the store is the + // only writer of trim state and publish() sends these values verbatim. + set({ + trimStart: Math.round(clamped.start), + trimEnd: Math.round(clamped.end), + }); + }, + + setTitle: (title) => set({ title }), + + publish: async () => { + const s = get(); + // Guards run before any request is sent. An expired draft must never + // publish — the server's DraftExpired error is a backstop, not the primary + // guard. A publish failure leaves status "error" with the clipId intact so + // the editor can retry. + if (s.status === "publishing") return; + if (s.status === "expired") return; + if (!s.clipId || !s.agent || !s.livestreamUri) { + set({ + status: "error", + error: "This clip draft is no longer available, start over", + }); + return; + } + if (!s.title.trim()) { + set({ error: "Title is required" }); + return; + } + set({ status: "publishing", error: null }); + const clipId = s.clipId; + try { + const result = await s.agent.client.call(place.stream.clip.publish, { + clipId: s.clipId, + livestream: s.livestreamUri as any, + start: s.trimStart, + end: s.trimEnd, + title: s.title, + }); + // The draft may have been discarded (dialog X/backdrop) while the + // request was in flight — don't resurrect it. + if (get().clipId !== clipId) return; + clearExpiryTimer(); + set({ + status: "published", + videoUri: result.videoUri, + clipUri: result.clipUri ?? null, + }); + } catch (e) { + if (get().clipId !== clipId) return; + set({ status: "error", error: mapClipError("publish", e) }); + } + }, + + cancel: async () => { + const s = get(); + // Best-effort server cleanup: delete the ephemeral draft file now instead + // of waiting for the 10-minute TTL sweep. Failures are ignored — the + // sweep is the backstop. Published drafts have no ephemeral file left. + if (s.clipId && s.agent && s.status !== "published") { + try { + await s.agent.client.call(place.stream.clip.cancel, { + clipId: s.clipId, + }); + } catch { + // ignore — the TTL sweep cleans up eventually + } + } + clearExpiryTimer(); + set(initialState); + }, + + discard: () => { + clearExpiryTimer(); + set(initialState); + }, + + tick: (now = Date.now()) => { + const s = get(); + if (s.status !== "editing" || s.expiresAt === null) return; + const remaining = Math.max(0, s.expiresAt - now); + if (remaining <= 0) { + clearExpiryTimer(); + set({ status: "expired", timeRemaining: 0 }); + } else { + set({ timeRemaining: remaining }); + } + }, +})); diff --git a/js/components/src/clip-store/index.tsx b/js/components/src/clip-store/index.tsx new file mode 100644 index 000000000..1bdc26112 --- /dev/null +++ b/js/components/src/clip-store/index.tsx @@ -0,0 +1,8 @@ +export { mapClipError, useClipStore } from "./clip-store"; +export type { + ClipErrorKind, + ClipState, + ClipStatus, + ClipStore, +} from "./clip-store"; +export * from "./trim-math"; diff --git a/js/components/src/clip-store/no-debug-junk.test.ts b/js/components/src/clip-store/no-debug-junk.test.ts new file mode 100644 index 000000000..84f8eb4a5 --- /dev/null +++ b/js/components/src/clip-store/no-debug-junk.test.ts @@ -0,0 +1,23 @@ +import * as fs from "fs"; +import * as path from "path"; + +// Guards AC.13: no debug junk in the rewritten clip UI files. Reads the +// sources directly so the guard survives future edits (not just this rewrite). + +const targets = [ + "../components/mobile-player/ui/clip-button.tsx", + "../components/mobile-player/ui/clip-editor.tsx", +]; + +describe("no debug junk in the clip UI", () => { + for (const rel of targets) { + const abs = path.join(__dirname, rel); + const src = fs.readFileSync(abs, "utf8"); + + it(`keeps ${rel} free of debug junk`, () => { + expect(src).not.toContain("log in bro"); + expect(src).not.toContain("clip is rendering"); + expect(src).not.toMatch(/console\.(log|debug)\(/); + }); + } +}); diff --git a/js/components/src/clip-store/trim-math.test.ts b/js/components/src/clip-store/trim-math.test.ts new file mode 100644 index 000000000..fedb474a9 --- /dev/null +++ b/js/components/src/clip-store/trim-math.test.ts @@ -0,0 +1,166 @@ +import { + clampWindow, + dragZone, + MIN_CLIP_MS, + moveWindow, + msToPx, + pxToMs, + resizeWindow, +} from "./trim-math"; + +describe("msToPx / pxToMs", () => { + it("maps ms to px proportionally", () => { + expect(msToPx(30000, 120000, 600)).toBe(150); + expect(pxToMs(150, 120000, 600)).toBe(30000); + }); + + it("round-trips", () => { + expect(pxToMs(msToPx(12345, 120000, 600), 120000, 600)).toBeCloseTo(12345); + }); + + it("handles zero duration/width", () => { + expect(msToPx(1000, 0, 600)).toBe(0); + expect(pxToMs(100, 120000, 0)).toBe(0); + }); +}); + +describe("clampWindow", () => { + it("clamps to the track bounds", () => { + expect(clampWindow(-5000, 125000, 120000)).toEqual({ + start: 0, + end: 120000, + }); + }); + + it("normalizes inverted edges", () => { + expect(clampWindow(90000, 10000, 120000)).toEqual({ + start: 10000, + end: 90000, + }); + }); + + it("enforces the minimum window by pushing the far boundary", () => { + // [18000, 20000] is 2s; min 5s; room after the end → extend the end. + expect(clampWindow(18000, 20000, 120000, 5000)).toEqual({ + start: 18000, + end: 23000, + }); + }); + + it("pushes the near boundary when the far edge is at the track end", () => { + // [118000, 120000] is 2s; min 5s; no room at the end → extend the start. + expect(clampWindow(118000, 120000, 120000, 5000)).toEqual({ + start: 115000, + end: 120000, + }); + }); + + it("uses the whole track when the track is shorter than the minimum", () => { + expect(clampWindow(1000, 2000, 3000, 5000)).toEqual({ + start: 0, + end: 3000, + }); + }); + + it("treats a zero-duration track as empty", () => { + expect(clampWindow(0, 0, 0)).toEqual({ start: 0, end: 0 }); + }); + + it("keeps the full duration as the default window", () => { + expect(clampWindow(0, 120000, 120000)).toEqual({ start: 0, end: 120000 }); + }); +}); + +describe("moveWindow", () => { + it("moves the window by the delta", () => { + expect(moveWindow(30000, 60000, 10000, 120000)).toEqual({ + start: 40000, + end: 70000, + }); + }); + + it("clamps at the left edge, preserving size", () => { + expect(moveWindow(30000, 60000, -40000, 120000)).toEqual({ + start: 0, + end: 30000, + }); + }); + + it("clamps at the right edge, preserving size", () => { + expect(moveWindow(30000, 60000, 70000, 120000)).toEqual({ + start: 90000, + end: 120000, + }); + }); +}); + +describe("resizeWindow", () => { + it("resizes from the left edge", () => { + expect(resizeWindow("left", 30000, 60000, 40000, 120000)).toEqual({ + start: 40000, + end: 60000, + }); + }); + + it("resizes from the right edge", () => { + expect(resizeWindow("right", 30000, 60000, 50000, 120000)).toEqual({ + start: 30000, + end: 50000, + }); + }); + + it("never shrinks below the minimum window", () => { + // Left drag to 58s would make a 2s window → the handle stops at 55s. + expect(resizeWindow("left", 30000, 60000, 58000, 120000, 5000)).toEqual({ + start: 55000, + end: 60000, + }); + // Right drag to 32s would make a 2s window → the handle stops at 35s. + expect(resizeWindow("right", 30000, 60000, 32000, 120000, 5000)).toEqual({ + start: 30000, + end: 35000, + }); + }); + + it("clamps edges to the track", () => { + expect(resizeWindow("left", 30000, 60000, -5000, 120000)).toEqual({ + start: 0, + end: 60000, + }); + expect(resizeWindow("right", 30000, 60000, 200000, 120000)).toEqual({ + start: 30000, + end: 120000, + }); + }); +}); + +describe("dragZone", () => { + const left = 100; + const right = 300; + const hw = 12; + + it("returns left for the left handle zone", () => { + expect(dragZone(95, left, right, hw)).toBe("left"); + expect(dragZone(112, left, right, hw)).toBe("left"); + }); + + it("returns right for the right handle zone", () => { + expect(dragZone(295, left, right, hw)).toBe("right"); + expect(dragZone(312, left, right, hw)).toBe("right"); + }); + + it("returns body inside the window", () => { + expect(dragZone(150, left, right, hw)).toBe("body"); + }); + + it("returns none outside the window", () => { + expect(dragZone(50, left, right, hw)).toBe("none"); + expect(dragZone(400, left, right, hw)).toBe("none"); + }); +}); + +describe("MIN_CLIP_MS", () => { + it("is 5 seconds", () => { + expect(MIN_CLIP_MS).toBe(5000); + }); +}); diff --git a/js/components/src/clip-store/trim-math.ts b/js/components/src/clip-store/trim-math.ts new file mode 100644 index 000000000..ad1e151aa --- /dev/null +++ b/js/components/src/clip-store/trim-math.ts @@ -0,0 +1,122 @@ +// Pure trim-window math for the clip editor. No RN imports — this is the unit +// test surface (see trim-math.test.ts). All times are in milliseconds. +// +// The functions are marked as reanimated worklets so the TrimTimeline gestures +// can call them on the UI thread; on the JS thread the directive is inert. + +export const MIN_CLIP_MS = 5000; + +export type DragZone = "left" | "body" | "right" | "none"; + +export function msToPx( + ms: number, + durationMs: number, + trackWidth: number, +): number { + "worklet"; + if (durationMs <= 0 || trackWidth <= 0) return 0; + return (ms / durationMs) * trackWidth; +} + +export function pxToMs( + px: number, + durationMs: number, + trackWidth: number, +): number { + "worklet"; + if (durationMs <= 0 || trackWidth <= 0) return 0; + return (px / trackWidth) * durationMs; +} + +// Clamp a [start, end] window to the track and enforce the minimum window +// size. When the window would be smaller than minMs we push the far boundary +// out instead of shrinking the selection below the minimum. If the track +// itself is shorter than minMs, the whole track is the window. +export function clampWindow( + start: number, + end: number, + durationMs: number, + minMs = MIN_CLIP_MS, +): { start: number; end: number } { + "worklet"; + if (durationMs <= 0) return { start: 0, end: 0 }; + let s = Math.min(Math.max(0, start), durationMs); + let e = Math.min(Math.max(0, end), durationMs); + if (e < s) { + const tmp = s; + s = e; + e = tmp; + } + const min = Math.min(minMs, durationMs); + if (e - s < min) { + const need = min - (e - s); + const extendEnd = Math.min(need, durationMs - e); + e += extendEnd; + s -= need - extendEnd; + } + return { start: s, end: e }; +} + +// Slide the whole window by deltaMs, clamped to the track bounds and keeping +// its size (the window is already >= minMs from clampWindow). +export function moveWindow( + start: number, + end: number, + deltaMs: number, + durationMs: number, + _minMs = MIN_CLIP_MS, +): { start: number; end: number } { + "worklet"; + if (durationMs <= 0) return { start: 0, end: 0 }; + const w = Math.max(0, end - start); + let s = start + deltaMs; + let e = s + w; + if (s < 0) { + s = 0; + e = Math.min(w, durationMs); + } + if (e > durationMs) { + e = durationMs; + s = Math.max(0, durationMs - w); + } + return { start: s, end: e }; +} + +// Resize the window by dragging one edge. The dragged edge follows newEdgeMs, +// clamped so the window never shrinks below minMs (the dragged edge stops, +// the opposite edge stays fixed). +export function resizeWindow( + edge: "left" | "right", + start: number, + end: number, + newEdgeMs: number, + durationMs: number, + minMs = MIN_CLIP_MS, +): { start: number; end: number } { + "worklet"; + if (durationMs <= 0) return { start: 0, end: 0 }; + const min = Math.min(minMs, durationMs); + const clamped = Math.min(Math.max(0, newEdgeMs), durationMs); + if (edge === "left") { + const maxLeft = Math.max(0, end - min); + return { start: Math.min(clamped, maxLeft), end }; + } + const minRight = start + min; + return { start, end: Math.max(clamped, minRight) }; +} + +// Hit-test a pointer x (relative to the track) against the selection window: +// the handle zones at either edge, the window body, or empty track. +export function dragZone( + x: number, + windowLeft: number, + windowRight: number, + handleWidth = 12, +): DragZone { + "worklet"; + if (x < windowLeft - handleWidth || x > windowRight + handleWidth) + return "none"; + if (x <= windowLeft + handleWidth) return "left"; + if (x >= windowRight - handleWidth) return "right"; + return "body"; +} diff --git a/js/components/src/components/mobile-player/ui/clip-button.tsx b/js/components/src/components/mobile-player/ui/clip-button.tsx new file mode 100644 index 000000000..fea75d8a8 --- /dev/null +++ b/js/components/src/components/mobile-player/ui/clip-button.tsx @@ -0,0 +1,98 @@ +import { Clapperboard, Loader2, Lock } from "lucide-react-native"; +import { useEffect, useRef } from "react"; +import { Pressable } from "react-native"; +import { useClipStore } from "../../../clip-store"; +import { useAuthor } from "../../../hooks/useAuthor"; +import { useLivestreamStoreOptional } from "../../../livestream-store"; +import { useStreamplaceStore } from "../../../streamplace-store"; +import { Text, toast, useTheme } from "../../ui"; + +// The trigger for the clip editor: a clapperboard while idle, a spinner while +// a clip is being requested, and a login CTA for logged-out viewers. It never +// renders the editor — the ClipEditorModal (mounted at the player screen) is +// the only thing that does. +export function ClipButton({ + onLoginRequired, +}: { + /** Called when a logged-out viewer presses the login CTA. */ + onLoginRequired?: () => void; +}) { + const th = useTheme(); + const profile = useAuthor(); + const oauthSession = useStreamplaceStore((x) => x.oauthSession); + const livestreamUri = useLivestreamStoreOptional( + (x) => x.livestream?.uri ?? null, + ); + + const status = useClipStore((s) => s.status); + const clipId = useClipStore((s) => s.clipId); + const error = useClipStore((s) => s.error); + const requestClip = useClipStore((s) => s.requestClip); + const discard = useClipStore((s) => s.discard); + + // When a *create* request fails (no clipId yet) the button surfaces the + // mapped error as a toast and returns to idle. Publish failures (clipId set) + // are handled inline by the editor, which stays open. + const prevStatus = useRef(status); + useEffect(() => { + if (prevStatus.current !== "error" && status === "error" && !clipId) { + if (error) { + toast.show("Couldn't create clip", error, { + variant: "error", + duration: 4, + }); + } + discard(); + } + prevStatus.current = status; + }, [status, error, clipId, discard]); + + // Logged out: render a login CTA instead of the clapperboard. The session + // can also be undefined while it's still loading — that's not "logged out", + // so keep the clapperboard (disabled) in that case. + if (oauthSession === null) { + return ( + + + Log in to clip + + ); + } + + const isLoading = status === "requesting"; + const canClip = profile?.did != null && oauthSession !== undefined; + + const handlePress = () => { + if (!profile?.did || oauthSession === undefined) return; + requestClip({ + streamerDID: profile.did, + oauthSession, + livestreamUri, + }); + }; + + return ( + + {isLoading ? ( + + ) : ( + + )} + + ); +} diff --git a/js/components/src/components/mobile-player/ui/clip-editor-window.tsx b/js/components/src/components/mobile-player/ui/clip-editor-window.tsx new file mode 100644 index 000000000..e9abbf919 --- /dev/null +++ b/js/components/src/components/mobile-player/ui/clip-editor-window.tsx @@ -0,0 +1,108 @@ +import { X } from "lucide-react-native"; +import { useMemo } from "react"; +import { Pressable, ScrollView, useWindowDimensions, View } from "react-native"; +import { Gesture, GestureDetector } from "react-native-gesture-handler"; +import Animated, { + useAnimatedStyle, + useSharedValue, +} from "react-native-reanimated"; +import { Text, useTheme } from "../../ui"; + +const PANEL_WIDTH = 380; +const PANEL_MARGIN = 24; +const HEADER_HEIGHT = 44; + +// Web-only: a draggable, non-modal overlay window for the clip editor. The +// player stays visible and interactive underneath — only the header drags the +// window. Native uses the fullscreen bottom sheet instead (see +// ClipEditorModal), so this component is never rendered there. +export function ClipEditorWindow({ + title, + onClose, + children, +}: { + title: string; + onClose: () => void; + children: React.ReactNode; +}) { + const th = useTheme(); + const { width: screenWidth, height: screenHeight } = useWindowDimensions(); + const width = Math.min(PANEL_WIDTH, screenWidth - PANEL_MARGIN); + + // Right-anchored (matching the player chrome): dragging right moves the + // window right, decreasing the right offset. + const top = useSharedValue(80); + const right = useSharedValue(PANEL_MARGIN); + const startTop = useSharedValue(0); + const startRight = useSharedValue(0); + + const drag = useMemo( + () => + Gesture.Pan() + .onStart(() => { + startTop.value = top.value; + startRight.value = right.value; + }) + .onUpdate((e) => { + top.value = Math.max(0, startTop.value + e.translationY); + right.value = Math.max(0, startRight.value - e.translationX); + }), + [top, right, startTop, startRight], + ); + + const panelStyle = useAnimatedStyle(() => ({ + top: top.value, + right: right.value, + })); + + return ( + + + + + {title} + + + + + + + + {children} + + + ); +} diff --git a/js/components/src/components/mobile-player/ui/clip-editor.tsx b/js/components/src/components/mobile-player/ui/clip-editor.tsx new file mode 100644 index 000000000..e3a8956a8 --- /dev/null +++ b/js/components/src/components/mobile-player/ui/clip-editor.tsx @@ -0,0 +1,267 @@ +import { AlertCircle, CheckCircle, Clock } from "lucide-react-native"; +import { useEffect, useState } from "react"; +import { Platform, TextInput } from "react-native"; +import { useClipStore } from "../../../clip-store"; +import { Button, ResponsiveDialog, Text, View, useTheme } from "../../ui"; +import { ClipEditorWindow } from "./clip-editor-window"; +import { ClipPreview } from "./clip-preview"; +import { TrimTimeline } from "./trim-timeline"; + +function formatCountdown(ms: number): string { + const totalSeconds = Math.ceil(Math.max(0, ms) / 1000); + const m = Math.floor(totalSeconds / 60); + const s = totalSeconds % 60; + return `${m}:${String(s).padStart(2, "0")}`; +} + +// The clip editor, mounted at the player screen as a sibling of the player — +// outside MobileUi's fade overlay and gesture detector. Presentation is +// platform-split: a draggable overlay window on web (see ClipEditorWindow) and +// a full-screen bottom-sheet takeover on native (ResponsiveDialog). Both share +// the same ClipEditorBody, which renders every draft state: editing, +// publishing, published, expired, and publish errors. +export function ClipEditorModal() { + const status = useClipStore((s) => s.status); + const clipId = useClipStore((s) => s.clipId); + const cancel = useClipStore((s) => s.cancel); + + const isPublished = status === "published"; + const isExpired = status === "expired"; + const isPublishing = status === "publishing"; + // status "error" with a clipId means a publish failure — keep the editor + // open with an inline banner. Create failures (no clipId) are toasted and + // reset to idle by the trigger, so they never reach the editor. + const isPublishError = status === "error" && clipId !== null; + const isOpen = + status === "editing" || + isPublishing || + isPublished || + isExpired || + isPublishError; + + const title = isPublished + ? "Clip published" + : isExpired + ? "Clip expired" + : "Create clip"; + + if (Platform.OS === "web") { + return isOpen ? ( + + + + ) : null; + } + + return ( + + + + ); +} + +function ClipEditorBody() { + const th = useTheme(); + const status = useClipStore((s) => s.status); + const clipId = useClipStore((s) => s.clipId); + const previewUrl = useClipStore((s) => s.previewUrl); + const durationMs = useClipStore((s) => s.durationMs); + const trimStart = useClipStore((s) => s.trimStart); + const trimEnd = useClipStore((s) => s.trimEnd); + const title = useClipStore((s) => s.title); + const timeRemaining = useClipStore((s) => s.timeRemaining); + const error = useClipStore((s) => s.error); + const videoUri = useClipStore((s) => s.videoUri); + const setTitle = useClipStore((s) => s.setTitle); + const publish = useClipStore((s) => s.publish); + const cancel = useClipStore((s) => s.cancel); + const discard = useClipStore((s) => s.discard); + + const [playheadMs, setPlayheadMs] = useState(0); + const [seekTo, setSeekTo] = useState(null); + const [copied, setCopied] = useState(false); + + const isPublishing = status === "publishing"; + const isPublished = status === "published"; + const isExpired = status === "expired"; + const isPublishError = status === "error" && clipId !== null; + + // After a trim commit, point the preview at the new range start instead of + // the last manual seek. + useEffect(() => { + setSeekTo(null); + }, [trimStart, trimEnd]); + + const copyLink = async () => { + if (Platform.OS !== "web" || !videoUri) return; + try { + await navigator.clipboard.writeText(videoUri); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // Clipboard unavailable (permissions/HTTPS) — leave the copy button as-is. + } + }; + + const lowTime = timeRemaining < 60000; + const countdownColor = lowTime ? "#f59e0b" : th.theme.colors.primary; + + if (isPublished) { + return ( + + + + + Your clip is live! + + + {Platform.OS === "web" ? ( + + ) : ( + + + Clip URL + + + {videoUri} + + + )} + + + ); + } + + if (isExpired) { + return ( + + + + + Your clip has expired + + + + The draft is gone — start a new clip to try again. + + + + ); + } + + return ( + + {/* Preview */} + + {previewUrl ? ( + + ) : null} + + + {/* Trim timeline */} + {durationMs > 0 && ( + + )} + + {/* Countdown */} + + + + expires in {formatCountdown(timeRemaining)} + + + + {/* Title */} + + + Title + + + + + {/* Publish error banner */} + {isPublishError && error && ( + + + + {error} + + + )} + + {/* Actions */} + + + + + + + + + + ); +} diff --git a/js/components/src/components/mobile-player/ui/clip-preview.native.tsx b/js/components/src/components/mobile-player/ui/clip-preview.native.tsx new file mode 100644 index 000000000..740dbb7b9 --- /dev/null +++ b/js/components/src/components/mobile-player/ui/clip-preview.native.tsx @@ -0,0 +1,95 @@ +import { useVideoPlayer, VideoView } from "expo-video"; +import { Play } from "lucide-react-native"; +import { useEffect, useState } from "react"; +import { Pressable } from "react-native"; +import { View } from "../../ui"; + +// Native clip preview: expo-video VideoView (the same library the main native +// player uses), with native controls hidden. The range-looping clamp lives +// here because the player store doesn't know about clip trims. +export function ClipPreview({ + uri, + trimStart, + trimEnd, + seekTo, + onTimeUpdate, +}: { + uri: string; + /** Trim window in ms — expo-video reports currentTime in seconds. */ + trimStart: number; + trimEnd: number; + /** External seek request (e.g. a timeline tap), in ms. */ + seekTo?: number; + onTimeUpdate?: (ms: number) => void; +}) { + const [playing, setPlaying] = useState(false); + const player = useVideoPlayer({ uri }, (player) => { + // Loop the whole file; the timeUpdate clamp below keeps playback inside + // [trimStart, trimEnd] for trimmed selections. + player.loop = true; + player.muted = false; + player.play(); + }); + + // Loop playback inside the selected range. The store's trims are in ms while + // expo-video's currentTime is in seconds — convert at the boundary. + useEffect(() => { + const sub = player.addListener("timeUpdate", ({ currentTime }) => { + if (currentTime * 1000 > trimEnd || currentTime * 1000 < trimStart) { + player.currentTime = trimStart / 1000; + } + onTimeUpdate?.(currentTime * 1000); + }); + return () => sub.remove(); + }, [player, trimStart, trimEnd, onTimeUpdate]); + + // Jump to the requested position (trim change or timeline tap). + useEffect(() => { + player.currentTime = (seekTo ?? trimStart) / 1000; + }, [player, seekTo, trimStart, uri]); + + useEffect(() => { + const sub = player.addListener("playingChange", ({ isPlaying }) => + setPlaying(isPlaying), + ); + return () => sub.remove(); + }, [player]); + + const togglePlay = () => { + if (player.playing) player.pause(); + else player.play(); + }; + + return ( + + + {!playing && ( + + + + )} + + ); +} diff --git a/js/components/src/components/mobile-player/ui/clip-preview.tsx b/js/components/src/components/mobile-player/ui/clip-preview.tsx new file mode 100644 index 000000000..f733911ff --- /dev/null +++ b/js/components/src/components/mobile-player/ui/clip-preview.tsx @@ -0,0 +1,106 @@ +import { Play } from "lucide-react-native"; +import { useEffect, useRef, useState } from "react"; +import { Pressable } from "react-native"; +import { View } from "../../ui"; + +// Web clip preview: a raw