diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index fdcec177..fc8d9c02 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -724,6 +724,20 @@ "open_dream": "Open dream", "open_playlist": "Open playlist", "open_keyframe": "Open keyframe" + }, + "dream_progress": { + "queued": "Queued", + "rendering": "Rendering", + "ingesting": "Ingesting", + "eta": "ETA {{time}}", + "playlist": "Playlist progress", + "completed_count": "{{completed}} of {{total}} completed", + "remaining_one": "{{count}} remaining", + "remaining_other": "{{count}} remaining", + "count_in_progress": "{{count}} in progress", + "count_queued": "{{count}} queued", + "count_failed": "{{count}} failed", + "count_idle": "{{count}} not started" } }, "units": { diff --git a/src/api/dream/query/useDream.ts b/src/api/dream/query/useDream.ts index 306b8789..feaa5a30 100644 --- a/src/api/dream/query/useDream.ts +++ b/src/api/dream/query/useDream.ts @@ -19,33 +19,24 @@ type DreamResponse = { dream?: Dream; }; -export const getDream = async ( - uuid: string, - signal?: AbortSignal, -): Promise => { +export const getDreamResponse = async (uuid: string, signal?: AbortSignal) => { const res = await axiosClient.get>( `/v1/dream/${uuid}`, { headers: getRequestHeaders({ contentType: ContentType.json }), signal }, ); - return res.data?.data?.dream; + return res.data; }; +export const getDream = async (uuid: string, signal?: AbortSignal) => + (await getDreamResponse(uuid, signal)).data?.dream; + export const fetchDream = async (uuid?: string) => { - const data = await queryClient.fetchQuery>({ + if (!uuid) return; + const response = await queryClient.fetchQuery({ queryKey: [DREAM_QUERY_KEY, uuid], - queryFn: () => - axiosClient - .get(`/v1/dream/${uuid ?? ""}`, { - headers: getRequestHeaders({ - contentType: ContentType.json, - }), - }) - .then((res) => { - return res.data; - }), + queryFn: ({ signal }) => getDreamResponse(uuid, signal), }); - - return data?.data?.dream; + return response.data?.dream; }; export const useDream = (uuid?: string, options?: HookOptions) => { diff --git a/src/api/playlist/query/usePlaylist.ts b/src/api/playlist/query/usePlaylist.ts index fe8a4ec0..43463004 100644 --- a/src/api/playlist/query/usePlaylist.ts +++ b/src/api/playlist/query/usePlaylist.ts @@ -1,3 +1,4 @@ +import useAuth from "@/hooks/useAuth"; import { ContentType, getRequestHeaders } from "@/constants/auth.constants"; import { Playlist } from "@/types/playlist.types"; import useApiQuery from "@/api/shared/useApiQuery"; @@ -37,6 +38,7 @@ export const fetchPlaylist = async (uuid?: string) => { * fetch — without it a missing uuid requests `/v1/playlist/`. */ export const usePlaylist = (uuid?: string, enabled = true) => { + const { user } = useAuth(); return useApiQuery( [PLAYLIST_QUERY_KEY, uuid], `/v1/playlist/${uuid ?? ""}`, @@ -46,6 +48,11 @@ export const usePlaylist = (uuid?: string, enabled = true) => { }), }, {}, - enabled ? {} : { enabled: false }, + { + enabled: enabled && Boolean(uuid) && Boolean(user), + refetchInterval: (data) => + data?.data?.playlist?.progress?.remaining ? 5000 : false, + refetchOnWindowFocus: true, + }, ); }; diff --git a/src/components/pages/studio/components/add-from-playlist-modal.tsx b/src/components/pages/studio/components/add-from-playlist-modal.tsx index baaa9b2a..a5a9a705 100644 --- a/src/components/pages/studio/components/add-from-playlist-modal.tsx +++ b/src/components/pages/studio/components/add-from-playlist-modal.tsx @@ -1,3 +1,4 @@ +import { DreamProgressOverlay } from "@/components/shared/dream-progress/dream-progress"; import React, { useState, useMemo, useCallback } from "react"; import { useStudioStore } from "@/stores/studio.store"; import type { StudioImage } from "@/types/studio.types"; @@ -145,6 +146,7 @@ export const AddFromPlaylistModal: React.FC = ({ onClose }) => { dream.processedMediaHeight, )} /> + ); })} diff --git a/src/components/pages/studio/components/add-reference-frames-from-playlist-modal.tsx b/src/components/pages/studio/components/add-reference-frames-from-playlist-modal.tsx index e7b91f55..1c9a2e31 100644 --- a/src/components/pages/studio/components/add-reference-frames-from-playlist-modal.tsx +++ b/src/components/pages/studio/components/add-reference-frames-from-playlist-modal.tsx @@ -1,3 +1,4 @@ +import { DreamProgressOverlay } from "@/components/shared/dream-progress/dream-progress"; import React, { useCallback, useState } from "react"; import { v4 as uuidv4 } from "uuid"; import { useFlowStore } from "@/stores/flow.store"; @@ -123,6 +124,7 @@ export const AddReferenceFramesFromPlaylistModal: React.FC = ({ dream.processedMediaHeight, )} /> + ); })} diff --git a/src/components/pages/studio/components/images-tab.tsx b/src/components/pages/studio/components/images-tab.tsx index d81eea24..752e974b 100644 --- a/src/components/pages/studio/components/images-tab.tsx +++ b/src/components/pages/studio/components/images-tab.tsx @@ -1,3 +1,4 @@ +import { DreamProgressOverlay } from "@/components/shared/dream-progress/dream-progress"; import React, { useCallback, useMemo, useRef, useState } from "react"; import { v4 as uuidv4 } from "uuid"; import { useStudioStore } from "@/stores/studio.store"; @@ -170,13 +171,10 @@ export const ImagesTab: React.FC = () => { ) : img.status === "processing" && img.url ? ( - ) : ( - - {img.status === "queue" && "Queued..."} - {img.status === "processing" && `${img.progress ?? 0}%`} - {img.status === "failed" && "Failed"} - - )} + ) : img.status === "failed" ? ( + Failed + ) : null} + {img.seed != null && #{img.seed}} = ({ }) => { const isLoop = frame.isLoopFrame ?? false; const isUploading = frame.uploadStatus === "uploading"; + const isGenerating = isUploading && !!frame.dreamUuid && !frame.imageUrl; const isFailed = frame.uploadStatus === "failed"; const isBusy = isUploading || isFailed; @@ -97,7 +100,13 @@ export const ReferenceFrameCard: React.FC = ({ $ratio={aspectRatioOf(frame)} {...(isLoop || isBusy ? {} : { ...attributes, ...listeners })} > - {imgSrc ? ( + {isGenerating && frame.dreamUuid ? ( + + + + ) : imgSrc ? ( = ({ {frame.name} )} - {isUploading && ( + {isUploading && !isGenerating && ( { } > {job.status === "processed" && "done"} - {job.status === "processing" && - `${job.progress ?? 0}%`} - {job.status === "queue" && "queued"} + {job.status === "failed" && "failed"} diff --git a/src/components/pages/studio/components/select-image-dream-card.tsx b/src/components/pages/studio/components/select-image-dream-card.tsx index bae37742..bd51c6f9 100644 --- a/src/components/pages/studio/components/select-image-dream-card.tsx +++ b/src/components/pages/studio/components/select-image-dream-card.tsx @@ -1,3 +1,4 @@ +import { DreamProgressOverlay } from "@/components/shared/dream-progress/dream-progress"; import React, { memo } from "react"; import { Dream } from "@/types/dream.types"; import { mediaAspectRatio } from "../utils/media-aspect-ratio"; @@ -50,6 +51,7 @@ const SelectImageDreamCardComponent: React.FC = ({ )} {isSelected && } + ); }; diff --git a/src/components/pages/studio/components/transition-gap.styled.tsx b/src/components/pages/studio/components/transition-gap.styled.tsx index 2361f07c..799e47b6 100644 --- a/src/components/pages/studio/components/transition-gap.styled.tsx +++ b/src/components/pages/studio/components/transition-gap.styled.tsx @@ -1,5 +1,10 @@ import styled, { css, keyframes } from "styled-components"; import { FLOW } from "@/constants/flow-theme.constants"; +import { + ProgressContent, + ProgressLabel, + ProgressEta, +} from "@/components/shared/dream-progress/dream-progress.styled"; const pulseDot = keyframes` 0%, 100% { opacity: 1; transform: scale(1); } @@ -39,6 +44,23 @@ export const GapContainer = styled.div<{ $expanded: boolean }>` gap: 14px; cursor: pointer; transition: width 0.3s ease; + + ${ProgressContent} { + width: 56px; + gap: 6px; + font-size: 10px; + text-align: center; + } + + ${ProgressLabel} { + flex-direction: column; + align-items: center; + gap: 2px; + } + + ${ProgressEta} { + font-size: 9px; + } `; export type GapLineVariant = "idle" | "configured" | "failed" | "mismatched"; @@ -125,21 +147,6 @@ export const StatusNode = styled.div<{ $variant: string }>` `} `; -export const ProgressRing = styled.div<{ $percent: number }>` - position: absolute; - inset: -4px; - border-radius: 50%; - background: conic-gradient( - ${FLOW.processing} ${(p) => p.$percent}%, - transparent ${(p) => p.$percent}% - ); - mask: radial-gradient(circle, transparent 60%, #000 61%) center / 100% 100% - no-repeat; - -webkit-mask: radial-gradient(circle, transparent 60%, #000 61%) center / 100% - 100% no-repeat; - pointer-events: none; -`; - export const GapStatusLabel = styled.span<{ $status: string }>` font-size: 9px; font-family: ${FLOW.fontFamily}; diff --git a/src/components/pages/studio/components/transition-gap.tsx b/src/components/pages/studio/components/transition-gap.tsx index d40987f5..19c8d40a 100644 --- a/src/components/pages/studio/components/transition-gap.tsx +++ b/src/components/pages/studio/components/transition-gap.tsx @@ -1,3 +1,4 @@ +import { DreamCardProgress } from "@/components/shared/dream-progress/dream-progress"; import type { KeyboardEvent } from "react"; import { Check, Loader2, AlertTriangle, RotateCcw } from "lucide-react"; import type { FlowTransition } from "@/types/flow.types"; @@ -5,7 +6,6 @@ import { GapContainer, GapLine, StatusNode, - ProgressRing, GapStatusLabel, DurationLabel, } from "./transition-gap.styled"; @@ -36,7 +36,7 @@ export function TransitionGapEnhanced({ mismatch, onClick, }: TransitionGapProps) { - const { status, progress } = transition; + const { status } = transition; const configured = hasOverrides(transition); const activate = { @@ -91,34 +91,24 @@ export function TransitionGapEnhanced({ ); } - // Queued — soft pulsing dot. - if (status === "queue") { + if (status === "queue" || status === "processing") { + const fallbackLabel = status === "queue" ? "queued" : "rendering"; return ( - - - queued - - ); - } - - // Processing — spinning loader inside a node, with progress ring. - if (status === "processing") { - const pct = Math.max(0, Math.min(100, progress ?? 0)); - return ( - 0 ? `, ${Math.round(pct)}%` : "" - }`} - > - - {pct > 0 && } - - - - {pct > 0 ? `${Math.round(pct)}%` : "rendering"} - + + {transition.dreamUuid ? ( + + ) : ( + <> + + {status === "processing" && ( + + )} + + + {fallbackLabel} + + + )} ); } @@ -135,6 +125,14 @@ export function TransitionGapEnhanced({ {effectiveDuration}s + {transition.uprezDreamUuid && transition.uprezStatus && ( + + )} ); } diff --git a/src/components/pages/studio/hooks/useDreamSegments.ts b/src/components/pages/studio/hooks/useDreamSegments.ts index af40424c..e02197e4 100644 --- a/src/components/pages/studio/hooks/useDreamSegments.ts +++ b/src/components/pages/studio/hooks/useDreamSegments.ts @@ -1,10 +1,11 @@ +import type { ApiResponse } from "@/types/api.types"; import { useMemo } from "react"; import { useQueries, type QueryFunctionContext, type UseQueryOptions, } from "@tanstack/react-query"; -import { DREAM_QUERY_KEY, getDream } from "@/api/dream/query/useDream"; +import { DREAM_QUERY_KEY, getDreamResponse } from "@/api/dream/query/useDream"; import type { Dream } from "@/types/dream.types"; import type { CrossfadeSegment } from "../components/crossfade-video"; import { dreamsToSegments } from "../utils/dream-segments"; @@ -12,7 +13,7 @@ import { dreamsToSegments } from "../utils/dream-segments"; const POLL_INTERVAL_MS = 3000; type DreamQueryOptions = UseQueryOptions< - Dream | undefined, + ApiResponse<{ dream: Dream }>, unknown, Dream | undefined, [string, string] @@ -32,9 +33,12 @@ export function useDreamSegments(uuids: readonly string[]): CrossfadeSegment[] { queries: uuids.map( (uuid): DreamQueryOptions => ({ queryKey: [DREAM_QUERY_KEY, uuid], - queryFn: ({ signal }: QueryFunctionContext) => getDream(uuid, signal), + queryFn: ({ signal }: QueryFunctionContext) => + getDreamResponse(uuid, signal), + select: (response) => response.data?.dream, staleTime: Infinity, - refetchInterval: (data) => (data?.video ? false : POLL_INTERVAL_MS), + refetchInterval: (_data, query) => + query.state.data?.data?.dream?.video ? false : POLL_INTERVAL_MS, refetchIntervalInBackground: false, }), ), diff --git a/src/components/pages/studio/hooks/useFlowJobProgress.ts b/src/components/pages/studio/hooks/useFlowJobProgress.ts index bdab28c7..3339421f 100644 --- a/src/components/pages/studio/hooks/useFlowJobProgress.ts +++ b/src/components/pages/studio/hooks/useFlowJobProgress.ts @@ -1,3 +1,5 @@ +import type { ApiResponse } from "@/types/api.types"; +import { useDreamRooms } from "@/hooks/useDreamRooms"; import { useEffect, useCallback, useMemo, useRef } from "react"; import { useQueries, type QueryFunctionContext } from "@tanstack/react-query"; import { toast } from "react-toastify"; @@ -6,14 +8,10 @@ import { useSocket } from "@/hooks/useSocket"; import { DREAM_QUERY_KEY, fetchDream, - getDream, + getDreamResponse, } from "@/api/dream/query/useDream"; import type { Dream } from "@/types/dream.types"; -import { - JOB_PROGRESS_EVENT, - JOIN_DREAM_ROOM_EVENT, - LEAVE_DREAM_ROOM_EVENT, -} from "@/constants/remote-control.constants"; +import { JOB_PROGRESS_EVENT } from "@/constants/remote-control.constants"; import { mapSocketStatus, shouldApplyStatus, @@ -92,7 +90,7 @@ export function useFlowJobProgress() { dreamUuid?: string; dream_uuid?: string; status?: string; - progress?: number; + progress?: number | null; }) => { const uuid = data.dreamUuid || data.dream_uuid; if (!uuid) return; @@ -104,7 +102,7 @@ export function useFlowJobProgress() { uuid, entry.isUprez, data.status, - data.progress, + data.progress ?? undefined, ); if (nextStatus === "failed") { @@ -124,53 +122,28 @@ export function useFlowJobProgress() { }; }, [socket, handleProgress]); - const joinedUuidsRef = useRef>(new Set()); - - useEffect(() => { - if (!socket) return; - - const currentSet = new Set(pendingUuids); - const prevSet = joinedUuidsRef.current; - - for (const uuid of currentSet) { - if (!prevSet.has(uuid)) socket.emit(JOIN_DREAM_ROOM_EVENT, uuid); - } - for (const uuid of prevSet) { - if (!currentSet.has(uuid)) socket.emit(LEAVE_DREAM_ROOM_EVENT, uuid); - } - joinedUuidsRef.current = currentSet; - }, [socket, pendingUuids]); - - useEffect(() => { - if (!socket) return; - const rejoinAll = () => { - joinedUuidsRef.current.forEach((uuid) => - socket.emit(JOIN_DREAM_ROOM_EVENT, uuid), - ); - }; - socket.on("connect", rejoinAll); - return () => { - socket.off("connect", rejoinAll); - joinedUuidsRef.current.forEach((uuid) => - socket.emit(LEAVE_DREAM_ROOM_EVENT, uuid), - ); - joinedUuidsRef.current = new Set(); - }; - }, [socket]); + useDreamRooms(pendingUuids); useQueries({ queries: pendingEntries.map((entry) => ({ queryKey: [DREAM_QUERY_KEY, entry.uuid], queryFn: ({ signal }: QueryFunctionContext) => - getDream(entry.uuid, signal), + getDreamResponse(entry.uuid, signal), + select: (response: ApiResponse<{ dream: Dream }>) => response.data?.dream, refetchInterval: RECONCILE_POLL_MS, refetchIntervalInBackground: false, onSuccess: (dream: Dream | undefined) => { if (!dream) return; - if (mapSocketStatus(dream.status) === "failed") { + const status = dream.jobProgress?.status ?? dream.status; + if (mapSocketStatus(status) === "failed") { toastFailure(entry.uuid, dream.error); } - applyStatus(entry.uuid, entry.isUprez, dream.status); + applyStatus( + entry.uuid, + entry.isUprez, + status, + dream.jobProgress?.progress ?? undefined, + ); }, })), }); diff --git a/src/components/pages/studio/hooks/useStudioJobProgress.ts b/src/components/pages/studio/hooks/useStudioJobProgress.ts index 0fa94f00..ccd305db 100644 --- a/src/components/pages/studio/hooks/useStudioJobProgress.ts +++ b/src/components/pages/studio/hooks/useStudioJobProgress.ts @@ -1,24 +1,26 @@ -import { useEffect } from "react"; +import { useQueries, type QueryFunctionContext } from "@tanstack/react-query"; +import type { ApiResponse } from "@/types/api.types"; +import type { Dream } from "@/types/dream.types"; +import { useDreamRooms } from "@/hooks/useDreamRooms"; +import { useCallback, useEffect } from "react"; import { useShallow } from "zustand/react/shallow"; import { toast } from "react-toastify"; import useSocket from "@/hooks/useSocket"; import { useStudioStore } from "@/stores/studio.store"; -import { useSessionStore } from "@/stores/session.store"; import queryClient from "@/api/query-client"; -import { DREAM_QUERY_KEY, fetchDream } from "@/api/dream/query/useDream"; -import { USER_QUERY_KEY } from "@/api/user/query/useUser"; import { - JOB_PROGRESS_EVENT, - JOIN_DREAM_ROOM_EVENT, - LEAVE_DREAM_ROOM_EVENT, -} from "@/constants/remote-control.constants"; + DREAM_QUERY_KEY, + fetchDream, + getDreamResponse, +} from "@/api/dream/query/useDream"; +import { USER_QUERY_KEY } from "@/api/user/query/useUser"; +import { JOB_PROGRESS_EVENT } from "@/constants/remote-control.constants"; import { dreamMediaUrl } from "../utils/resolve-dream-media"; import { useDreamMediaResolver } from "./useDreamMediaResolver"; import { mapSocketStatus, shouldApplyStatus, isPendingStatus, - type DreamJobStatus, } from "./mapSocketStatus"; const RECONCILE_POLL_MS = 5000; @@ -35,17 +37,15 @@ export const useStudioJobProgress = () => { const jobUuids = s.jobs .filter((j) => isPendingStatus(j.status)) .map((j) => j.dreamUuid); - return [...imageUuids, ...jobUuids]; + return [...new Set([...imageUuids, ...jobUuids])]; }), ); - useEffect(() => { - if (!socket) return; - - const handleProgress = (data: { + const handleProgress = useCallback( + (data: { dream_uuid: string; status?: string; - progress?: number; + progress?: number | null; preview_frame?: string; }) => { const { dream_uuid, progress, preview_frame } = data; @@ -56,7 +56,7 @@ export const useStudioJobProgress = () => { if (image) { const applyStatus = shouldApplyStatus(image.status, mappedStatus); state.updateImage(dream_uuid, { - progress, + progress: progress ?? undefined, previewFrame: preview_frame, ...(applyStatus && mappedStatus ? { status: mappedStatus } : {}), }); @@ -64,6 +64,7 @@ export const useStudioJobProgress = () => { if ( applyStatus && mappedStatus === "processed" && + isPendingStatus(image.status) && !image.url?.startsWith("http") ) { queryClient.invalidateQueries([DREAM_QUERY_KEY, dream_uuid]); @@ -87,12 +88,12 @@ export const useStudioJobProgress = () => { const isNowFailed = applyStatus && mappedStatus === "failed"; state.updateJob(dream_uuid, { - progress, + progress: progress ?? undefined, previewFrame: preview_frame, ...(applyStatus && mappedStatus ? { status: mappedStatus } : {}), }); - if (isNowCompleted) { + if (isNowCompleted && wasPending) { queryClient.invalidateQueries([DREAM_QUERY_KEY, dream_uuid]); queryClient.invalidateQueries([USER_QUERY_KEY]); fetchDream(dream_uuid) @@ -120,95 +121,42 @@ export const useStudioJobProgress = () => { .catch(() => {}); } } - }; + }, + [resolveMedia], + ); + useEffect(() => { + if (!socket) return; socket.on(JOB_PROGRESS_EVENT, handleProgress); return () => { socket.off(JOB_PROGRESS_EVENT, handleProgress); }; - }, [socket, resolveMedia]); + }, [socket, handleProgress]); - useEffect(() => { - if (!socket || pendingUuids.length === 0) return; - - queryClient.invalidateQueries([USER_QUERY_KEY]); - - const joinRooms = () => { - pendingUuids.forEach((uuid) => socket.emit(JOIN_DREAM_ROOM_EVENT, uuid)); - }; - - if (socket.connected) joinRooms(); - socket.on("connect", joinRooms); - - return () => { - socket.off("connect", joinRooms); - pendingUuids.forEach((uuid) => socket.emit(LEAVE_DREAM_ROOM_EVENT, uuid)); - }; - }, [socket, pendingUuids]); + useDreamRooms(pendingUuids); const hasPending = pendingUuids.length > 0; - const activeSessionId = useSessionStore((s) => s.activeSessionId); - useEffect(() => { - if (!hasPending) return; - - const reconcile = () => { - const state = useStudioStore.getState(); - - for (const img of state.images.filter((i) => isPendingStatus(i.status))) { - fetchDream(img.uuid) - .then((dream) => { - if (!dream) return; - if (dream.status === img.status) return; - if (!shouldApplyStatus(img.status, dream.status)) return; - useStudioStore.getState().updateImage(img.uuid, { - status: dream.status as DreamJobStatus, - }); - }) - .catch(() => {}); - } - - for (const job of state.jobs.filter((j) => isPendingStatus(j.status))) { - fetchDream(job.dreamUuid) - .then((dream) => { - if (!dream) return; - if (dream.status === job.status) return; - if (!shouldApplyStatus(job.status, dream.status)) return; - - const wasNotCompleted = job.status !== "processed"; - const isNowCompleted = dream.status === "processed"; - - useStudioStore.getState().updateJob(job.dreamUuid, { - status: dream.status as DreamJobStatus, - ...(isNowCompleted && dream.thumbnail - ? { thumbnailUrl: dream.thumbnail } - : {}), - }); - - if (wasNotCompleted && isNowCompleted) { - queryClient.invalidateQueries([DREAM_QUERY_KEY, job.dreamUuid]); - const s = useStudioStore.getState(); - if (s.activeTab !== "results") s.incrementNewCompleted(); - } - }) - .catch(() => {}); - } - }; - - reconcile(); - - const interval = isConnected - ? null - : setInterval(reconcile, RECONCILE_POLL_MS); - - const onVisibility = () => { - if (document.visibilityState === "visible") reconcile(); - }; - document.addEventListener("visibilitychange", onVisibility); - - return () => { - if (interval) clearInterval(interval); - document.removeEventListener("visibilitychange", onVisibility); - }; - }, [hasPending, activeSessionId, isConnected]); + if (hasPending) void queryClient.invalidateQueries([USER_QUERY_KEY]); + }, [hasPending]); + + useQueries({ + queries: pendingUuids.map((uuid) => ({ + queryKey: [DREAM_QUERY_KEY, uuid], + queryFn: ({ signal }: QueryFunctionContext) => + getDreamResponse(uuid, signal), + staleTime: 1000, + refetchInterval: isConnected ? 30_000 : RECONCILE_POLL_MS, + refetchOnWindowFocus: true, + onSuccess: (response: ApiResponse<{ dream: Dream }>) => { + const dream = response.data?.dream; + if (!dream) return; + handleProgress({ + dream_uuid: uuid, + status: dream.jobProgress?.status ?? dream.status, + progress: dream.jobProgress?.progress, + }); + }, + })), + }); }; diff --git a/src/components/pages/view-dream/view-dream.page.tsx b/src/components/pages/view-dream/view-dream.page.tsx index 27007cdc..41ffdb6c 100644 --- a/src/components/pages/view-dream/view-dream.page.tsx +++ b/src/components/pages/view-dream/view-dream.page.tsx @@ -1,3 +1,9 @@ +import { + useDreamProgress, + DREAM_PROGRESS_QUERY_KEY, +} from "@/hooks/useDreamProgress"; +import { DreamProgress } from "@/components/shared/dream-progress/dream-progress"; +import { isActiveProgress } from "@/utils/job-progress.util"; import { yupResolver } from "@hookform/resolvers/yup"; import { useDeleteDream } from "@/api/dream/mutation/useDeleteDream"; import { useUpdateDream } from "@/api/dream/mutation/useUpdateDream"; @@ -67,12 +73,6 @@ import { import { isAdmin } from "@/utils/user.util"; import { useUploadDreamVideo } from "@/api/dream/hooks/useUploadDreamVideo"; import useSocket from "@/hooks/useSocket"; -import useSocketEventListener from "@/hooks/useSocketEventListener"; -import { - JOB_PROGRESS_EVENT, - JOIN_DREAM_ROOM_EVENT, - LEAVE_DREAM_ROOM_EVENT, -} from "@/constants/remote-control.constants"; import { emitPlayDream } from "@/utils/socket.util"; import { truncateString } from "@/utils/string.util"; import { AnchorLink } from "@/components/shared"; @@ -98,7 +98,6 @@ import { import { ReportDreamModal } from "@/components/modals/report-dream.modal"; import { useUpdateReport } from "@/api/report/mutation/useUpdateReport"; import { Tooltip } from "react-tooltip"; -import { JobProgressData } from "./view-dream-inputs"; import { TOAST_DEFAULT_CONFIG, TOOLTIP_DELAY_MS, @@ -108,8 +107,6 @@ import { PLAYLIST_PERMISSIONS } from "@/constants/permissions.constants"; import PermissionContext from "@/context/permission.context"; import { Dream } from "@/types/dream.types"; import { ApiResponse } from "@/types/api.types"; -import ProgressBar from "@/components/shared/progress-bar/progress-bar"; -import { formatEta } from "@/utils/video.utils"; import Text from "@/components/shared/text/text"; import { useModels } from "@/api/model/query/useModels"; import { estimateUnitCostUsd } from "@/utils/model-cost.util"; @@ -131,8 +128,6 @@ const SectionID = "dream"; const FALLBACK_ERROR_MESSAGE = "An error occurred while processing this dream."; -const FINISHED_JOB_STATUSES = new Set(["COMPLETED", "FAILED", "CANCELLED"]); - const PREVIEW_FRAME_ALGORITHMS = new Set(["deforum", "uprez", "nvidia-uprez"]); const formatDreamError = (error?: string | null): string => { @@ -228,9 +223,6 @@ const ViewDreamPage: React.FC = () => { const [removingPlaylistItemId, setRemovingPlaylistItemId] = useState< number | null >(null); - const [progress, setProgress] = useState(undefined); - const [jobStatus, setJobStatus] = useState(undefined); - const [countdownMs, setCountdownMs] = useState(undefined); const validatePromptRef = useRef<(() => boolean) | null>(null); const resetPromptRef = useRef<(() => void) | null>(null); @@ -254,56 +246,12 @@ const ViewDreamPage: React.FC = () => { const { socket } = useSocket(); - const handleJobProgress = useCallback( - async (data?: JobProgressData) => { - if (data && data.dream_uuid === uuid) { - if (data.progress !== undefined) { - setProgress(Number(data.progress)); - } - if (typeof data.status === "string") { - setJobStatus(data.status); - } - if (typeof data.countdown_ms === "number") { - setCountdownMs(data.countdown_ms); - } - } - }, - [uuid], - ); - - useSocketEventListener( - JOB_PROGRESS_EVENT, - handleJobProgress, + const dream = data?.data?.dream; + const jobProgress = useDreamProgress( + dream?.uuid === uuid ? dream : undefined, + { poll: true }, ); - - useEffect(() => { - setProgress(undefined); - setJobStatus(undefined); - setCountdownMs(undefined); - }, [uuid]); - - useEffect(() => { - if (!socket || !uuid) return; - - const joinRoom = () => { - socket.emit(JOIN_DREAM_ROOM_EVENT, uuid); - }; - - if (socket.connected) { - joinRoom(); - } - - socket.on("connect", joinRoom); - - return () => { - socket.off("connect", joinRoom); - if (socket && uuid) { - socket.emit(LEAVE_DREAM_ROOM_EVENT, uuid); - } - }; - }, [uuid, socket]); - - const dream = useMemo(() => data?.data?.dream, [data]); + const jobStatus = jobProgress?.status; const displayDream = useMemo(() => { if (!dream || dream.uuid === uuid) return dream; @@ -399,16 +347,11 @@ const ViewDreamPage: React.FC = () => { [dream], ); - const isDreamProcessing: boolean = useMemo( - () => - isDreamProcessingRaw && - !FINISHED_JOB_STATUSES.has((jobStatus ?? "").toUpperCase()), - [isDreamProcessingRaw, jobStatus], - ); - - const dreamProcessingPhase = useMemo( - () => getDreamProcessingPhase(isDreamProcessingRaw, jobStatus), - [isDreamProcessingRaw, jobStatus], + const isDreamProcessing = isActiveProgress(jobProgress); + const dreamProcessingPhase = getDreamProcessingPhase( + isDreamProcessing, + jobStatus, + jobProgress?.stage, ); const isDreamFailed: boolean = useMemo( @@ -792,9 +735,7 @@ const ViewDreamPage: React.FC = () => { if (response?.success) { toast.success(`${t("page.view_dream.dream_processing_successfully")}`); setTumbnail(undefined); - setProgress(0); - setJobStatus(undefined); - setCountdownMs(undefined); + void queryClient.invalidateQueries([DREAM_PROGRESS_QUERY_KEY, uuid]); refetch(); queryClient.invalidateQueries([USER_QUERY_KEY, user?.uuid]); closeModal(); @@ -818,9 +759,7 @@ const ViewDreamPage: React.FC = () => { } setTumbnail(undefined); - setProgress(undefined); - setJobStatus(undefined); - setCountdownMs(undefined); + void queryClient.invalidateQueries([DREAM_PROGRESS_QUERY_KEY, uuid]); closeModal(); if (!response.data?.jobFound) { @@ -1248,27 +1187,7 @@ const ViewDreamPage: React.FC = () => { {isDreamProcessing && ( - {jobStatus?.toUpperCase() === "IN_PROGRESS" && - typeof progress === "number" && ( - <> - - - Rendering {progress.toFixed(1)}% done - {countdownMs && - `, ETA ${formatEta( - Math.floor(countdownMs / 1000), - )}`} - - - )} + )} diff --git a/src/components/pages/view-playlist/view-playlist.page.tsx b/src/components/pages/view-playlist/view-playlist.page.tsx index efcac728..2ea423f2 100644 --- a/src/components/pages/view-playlist/view-playlist.page.tsx +++ b/src/components/pages/view-playlist/view-playlist.page.tsx @@ -1,3 +1,4 @@ +import { PlaylistProgress } from "@/components/shared/dream-progress/playlist-progress"; import { yupResolver } from "@hookform/resolvers/yup"; import { Button, ItemCardList, Row } from "@/components/shared"; import { UprezPlaylistControls } from "./components/uprez-playlist-controls"; @@ -828,8 +829,14 @@ export const ViewPlaylistPage = () => { style={{ minWidth: "320px" }} onSubmit={formMethods.handleSubmit(onSubmit)} > - - + + + +
{editMode ? ( <> diff --git a/src/components/shared/dream-progress/dream-progress.styled.tsx b/src/components/shared/dream-progress/dream-progress.styled.tsx new file mode 100644 index 00000000..044010d7 --- /dev/null +++ b/src/components/shared/dream-progress/dream-progress.styled.tsx @@ -0,0 +1,107 @@ +import ProgressBar from "@/components/shared/progress-bar/progress-bar"; +import styled, { keyframes } from "styled-components"; + +const slide = keyframes` + from { transform: translateX(-100%); } + to { transform: translateX(300%); } +`; + +export const ProgressContent = styled.div` + display: grid; + gap: 8px; + width: 100%; + min-width: 0; + color: ${(p) => p.theme.textPrimaryColor}; + font-size: 0.8125rem; + line-height: 1.5; + + @media (prefers-reduced-motion: reduce) { + * { + transition: none !important; + } + } +`; + +export const ProgressLabel = styled.div` + display: flex; + align-items: baseline; + flex-wrap: wrap; + justify-content: space-between; + gap: 2px 8px; + font-variant-numeric: tabular-nums; +`; + +export const ProgressStage = styled.span` + font-weight: 600; +`; + +export const ProgressPercent = styled.span` + color: ${(p) => p.theme.textAccentColor}; + font-weight: 600; +`; + +export const ProgressEta = styled.span` + color: ${(p) => p.theme.textBodyColor}; + font-size: 0.75rem; +`; + +export const ProgressMeter = styled(ProgressBar).attrs(({ theme }) => ({ + bgColor: theme.textAccentColor, + baseBgColor: theme.inputBackgroundColor, + transitionDuration: "0.3s", +}))``; + +export const IndeterminateTrack = styled.div` + height: 6px; + overflow: hidden; + border-radius: 4px; + background: ${(p) => p.theme.inputBackgroundColor}; + + span { + display: block; + width: 35%; + height: 100%; + background: ${(p) => p.theme.textAccentColor}; + border-radius: inherit; + animation: ${slide} 1.8s ease-in-out infinite; + } + + @media (prefers-reduced-motion: reduce) { + span { + animation: none; + margin: auto; + } + } +`; + +export const ProgressOverlay = styled.div` + position: absolute; + inset: auto 0 0; + z-index: 1; + padding: 28px 12px 12px; + background: linear-gradient( + to bottom, + transparent, + rgba(0, 0, 0, 0.75) 40%, + rgba(0, 0, 0, 0.92) + ); + pointer-events: none; +`; + +export const PlaylistSummary = styled.section` + display: grid; + gap: 8px; + margin: 16px 0; + padding: 8px 0; + color: ${(p) => p.theme.textPrimaryColor}; + font-size: 0.875rem; + font-variant-numeric: tabular-nums; +`; + +export const PlaylistProgressOverlayContainer = styled(ProgressOverlay)` + ${PlaylistSummary} { + margin: 0; + padding: 0; + font-size: 0.8125rem; + } +`; diff --git a/src/components/shared/dream-progress/dream-progress.tsx b/src/components/shared/dream-progress/dream-progress.tsx new file mode 100644 index 00000000..eb0a3d05 --- /dev/null +++ b/src/components/shared/dream-progress/dream-progress.tsx @@ -0,0 +1,85 @@ +import { useTranslation } from "react-i18next"; +import { useDreamProgress } from "@/hooks/useDreamProgress"; +import type { DreamJobProgress } from "@/types/job-progress.types"; +import { + isActiveProgress, + type DreamProgressSource, +} from "@/utils/job-progress.util"; +import { formatEta } from "@/utils/video.utils"; +import { + IndeterminateTrack, + ProgressContent, + ProgressLabel, + ProgressOverlay, + ProgressStage, + ProgressPercent, + ProgressEta, + ProgressMeter, +} from "./dream-progress.styled"; + +export function DreamProgress({ progress }: { progress?: DreamJobProgress }) { + const { t } = useTranslation(); + if (!progress || !isActiveProgress(progress)) return null; + + const label = t(`components.dream_progress.${progress.stage}`); + const percent = + progress.progress == null ? null : Math.round(progress.progress); + const eta = progress.countdown_ms; + + return ( + + + {label} + {percent !== null && {percent}%} + + {percent === null ? ( + + + + ) : ( +
+ +
+ )} + {eta != null && eta > 0 && ( + + {t("components.dream_progress.eta", { + time: formatEta(Math.ceil(eta / 1000)), + })} + + )} +
+ ); +} + +export function DreamCardProgress({ dream }: { dream: DreamProgressSource }) { + const progress = useDreamProgress(dream); + return ; +} + +export function DreamProgressOverlay({ + dream, +}: { + dream: DreamProgressSource; +}) { + const progress = useDreamProgress(dream); + if (!isActiveProgress(progress)) return null; + return ( + + + + ); +} diff --git a/src/components/shared/dream-progress/playlist-progress.tsx b/src/components/shared/dream-progress/playlist-progress.tsx new file mode 100644 index 00000000..5c4c3d57 --- /dev/null +++ b/src/components/shared/dream-progress/playlist-progress.tsx @@ -0,0 +1,79 @@ +import { useTranslation } from "react-i18next"; +import type { PlaylistProgress as PlaylistProgressData } from "@/types/job-progress.types"; +import { + PlaylistSummary, + ProgressLabel, + ProgressMeter, + PlaylistProgressOverlayContainer, +} from "./dream-progress.styled"; + +export function PlaylistProgressOverlay({ + progress, +}: { + progress?: PlaylistProgressData; +}) { + if (!progress?.remaining) return null; + + return ( + + + + ); +} + +export function PlaylistProgress({ + progress, +}: { + progress?: PlaylistProgressData; +}) { + const { t } = useTranslation(); + if (!progress?.total) return null; + const percent = Math.round((progress.completed / progress.total) * 100); + const label = t("components.dream_progress.completed_count", { + completed: progress.completed, + total: progress.total, + }); + const counts = [ + ["in_progress", progress.inProgress], + ["queued", progress.queued], + ["failed", progress.failed], + ["idle", progress.idle], + ] as const; + + return ( + + + {label} + + {t("components.dream_progress.remaining", { + count: progress.remaining, + })} + + +
+ +
+ + {counts + .filter(([, count]) => count > 0) + .map(([status, count]) => + t(`components.dream_progress.count_${status}`, { count }), + ) + .join(" · ")} + +
+ ); +} diff --git a/src/components/shared/item-card/item-card.tsx b/src/components/shared/item-card/item-card.tsx index 35b84e7a..d8c6ac0e 100644 --- a/src/components/shared/item-card/item-card.tsx +++ b/src/components/shared/item-card/item-card.tsx @@ -1,3 +1,6 @@ +import { DreamProgressOverlay } from "@/components/shared/dream-progress/dream-progress"; +import { PlaylistProgressOverlay } from "@/components/shared/dream-progress/playlist-progress"; +import type { PlaylistProgress as PlaylistProgressData } from "@/types/job-progress.types"; import { DND_ACTIONS, DND_METADATA } from "@/constants/dnd.constants"; import { ROUTES } from "@/constants/routes.constants"; import { @@ -454,78 +457,75 @@ const ItemCardComponent: React.FC = ({ [showStatusBadge, type, item], ); - const Thumbnail = useMemo( - () => () => { - if (type === "virtual-playlist") { - return ( - - {thumbnailDreams.map((dream, index) => { - const dreamFailed = dream?.status === DreamStatusType.FAILED; - return ( - - {dreamFailed ? ( - - - - ) : ( - - )} - - ); - })} - - ); - } - - if (isDreamFailed) { - return ( - - - - ); - } + const thumbnailContent = useMemo(() => { + if (type === "virtual-playlist") { + return ( + + {thumbnailDreams.map((dream, index) => { + const dreamFailed = dream?.status === DreamStatusType.FAILED; + return ( + + {dreamFailed ? ( + + + + ) : ( + + )} + + ); + })} + + ); + } - if (thumbnail) { - return ; - } + if (isDreamFailed) { + return ( + + + + ); + } - if (statusBadge) { - return ( - - - - - - ); - } + if (thumbnail) { + return ; + } + if (statusBadge) { return ( - + + + ); - }, - [ - type, - thumbnail, - thumbnailDreams, - size, - thumbnailUrl, - isDreamFailed, - statusBadge, - ], - ); + } - const ThumbnailAndPlayButton = useMemo( - () => () => ( + return ( + + + + ); + }, [ + type, + thumbnail, + thumbnailDreams, + size, + thumbnailUrl, + isDreamFailed, + statusBadge, + ]); + + const thumbnailAndPlayButton = useMemo( + () => ( = ({ mr={inline ? [0, 4, 4, 4] : 0} flex={["auto", 0, 0, 0]} > - + {thumbnailContent} + {type === "dream" && item && ( + + )} + {type === "playlist" && item && ( + + )} - {statusBadge && ( + {statusBadge && statusBadge.tone !== "processing" && ( {t(statusBadge.labelKey)} )} - {showPlayButton && !statusBadge && ( - - - - - ) : ( - - - - ) - } - onClick={handlePlay} - /> - - )} + {showPlayButton && + !statusBadge && + !( + type === "dream" && + ((item as Dream)?.status === DreamStatusType.QUEUE || + (item as Dream)?.status === DreamStatusType.PROCESSING) + ) && ( + + + + + ) : ( + + + + ) + } + onClick={handlePlay} + /> + + )} {type == "virtual-playlist" && shouldVirtualPlaylistDisplayDots( @@ -589,7 +601,16 @@ const ItemCardComponent: React.FC = ({ )} ), - [Thumbnail, handlePlay, item, type, inline, showPlayButton, statusBadge, t], + [ + thumbnailContent, + handlePlay, + item, + type, + inline, + showPlayButton, + statusBadge, + t, + ], ); const onHideClientNotConnectedModal = () => @@ -698,14 +719,14 @@ const ItemCardComponent: React.FC = ({ )} )} - {inline && } + {inline && thumbnailAndPlayButton} - {!inline && } + {!inline && thumbnailAndPlayButton} @@ -798,52 +819,96 @@ export const ItemCardSkeleton: React.FC = ({ children, }) => {children}; -const isVirtualPlaylist = (item: Item): item is VirtualPlaylist => { - return item && "dreams" in item; +const isVirtualPlaylist = (item: Item): item is VirtualPlaylist => + Boolean(item) && "dreams" in item; + +const sameThumbnailDreams = ( + prevItem: VirtualPlaylist, + nextItem: VirtualPlaylist, +): boolean => { + const prev = getVirtualPlaylistThumbnailDreams(prevItem?.dreams); + const next = getVirtualPlaylistThumbnailDreams(nextItem?.dreams); + return ( + prev.length === next.length && + prev.every((dream, index) => dream.id === next[index]?.id) + ); +}; + +const ownerAvatar = (item: Item) => + item.displayedOwner ? item.displayedOwner.avatar : item.user?.avatar; + +const PROGRESS_FIELDS = [ + "total", + "completed", + "queued", + "inProgress", + "failed", + "idle", + "remaining", +] as const; + +const samePlaylistProgress = ( + prev?: PlaylistProgressData, + next?: PlaylistProgressData, +): boolean => { + if (prev === next) return true; + if (!prev || !next) return false; + return PROGRESS_FIELDS.every((field) => prev[field] === next[field]); }; -// Verifies if changes on item should rerender the component const areItemsEqual = ( + type: ItemType | undefined, prevItem: Item | undefined, nextItem: Item | undefined, ): boolean => { - // If both items are undefined, considered it equal - if (!prevItem && !nextItem) return true; - - // If one is undefined and the other isn't, they're not equal + if (prevItem === nextItem) return true; if (!prevItem || !nextItem) return false; + if (prevItem.uuid !== nextItem.uuid) return false; - // Check if both (prev and next item) are VirtualPlaylists and thumbnail dreams changes if (isVirtualPlaylist(prevItem) && isVirtualPlaylist(nextItem)) { - const prevThumbnailDreams = getVirtualPlaylistThumbnailDreams( - prevItem?.dreams, - ); - const nextThumbnailDreams = getVirtualPlaylistThumbnailDreams( - nextItem?.dreams, - ); - - // If every thumbnail dream is the same, then do not rerender - return prevThumbnailDreams.every( - (prevDream, index) => prevDream.id === nextThumbnailDreams[index]?.id, - ); + return sameThumbnailDreams(prevItem, nextItem); } - // If items has same uuid consider it equal, unless a dream's status changed - // (keeps the "My Dreams" status badge in sync on refetch without a remount) - if (prevItem.uuid === nextItem.uuid) { - return (prevItem as Dream).status === (nextItem as Dream).status; + const prevDream = prevItem as Dream; + const nextDream = nextItem as Dream; + const prevPlaylist = prevItem as Playlist; + const nextPlaylist = nextItem as Playlist; + + return ( + prevItem.name === nextItem.name && + getThumbnail(type ?? "", prevItem) === getThumbnail(type ?? "", nextItem) && + ownerAvatar(prevItem) === ownerAvatar(nextItem) && + getUserName(prevItem.displayedOwner ?? prevItem.user) === + getUserName(nextItem.displayedOwner ?? nextItem.user) && + prevDream.status === nextDream.status && + prevDream.mediaType === nextDream.mediaType && + prevDream.jobProgress?.stage === nextDream.jobProgress?.stage && + prevDream.jobProgress?.progress === nextDream.jobProgress?.progress && + samePlaylistProgress(prevPlaylist.progress, nextPlaylist.progress) + ); +}; + +const arePropsEqual = ( + prevProps: ItemCardProps, + nextProps: ItemCardProps, +): boolean => { + const keys = new Set([ + ...Object.keys(prevProps), + ...Object.keys(nextProps), + ]) as Set; + + for (const key of keys) { + if (key === "item") { + if (!areItemsEqual(nextProps.type, prevProps.item, nextProps.item)) + return false; + } else if (prevProps[key] !== nextProps[key]) { + return false; + } } - // If types don't match or aren't VirtualPlaylist, consider them not equal - return false; + return true; }; -// Try rerender component only when order or some item properties changes -export const ItemCard = memo( - ItemCardComponent, - (prevProps, nextProps) => - prevProps.order === nextProps.order && - areItemsEqual(prevProps.item, nextProps.item), -); +export const ItemCard = memo(ItemCardComponent, arePropsEqual); export default ItemCard; diff --git a/src/context/socket.context.tsx b/src/context/socket.context.tsx index 8beb0444..60dae1db 100644 --- a/src/context/socket.context.tsx +++ b/src/context/socket.context.tsx @@ -5,6 +5,7 @@ import React, { useMemo, useRef, useState, + useSyncExternalStore, } from "react"; import socketIO, { Socket } from "socket.io-client"; import useAuth from "@/hooks/useAuth"; @@ -42,9 +43,27 @@ export const SocketProvider: React.FC<{ children?: React.ReactNode; }> = ({ children }) => { const { user, authenticateUser } = useAuth(); - - // boolean flag on state to know if socket is connected - const [isConnected, setIsConnected] = useState(false); + const userUuid = user?.uuid; + const [socket, setSocket] = useState(null); + + const isConnected = useSyncExternalStore( + useCallback( + (onStoreChange: () => void) => { + socket?.on("connect", onStoreChange); + socket?.on("disconnect", onStoreChange); + window.addEventListener("online", onStoreChange); + window.addEventListener("offline", onStoreChange); + return () => { + socket?.off("connect", onStoreChange); + socket?.off("disconnect", onStoreChange); + window.removeEventListener("online", onStoreChange); + window.removeEventListener("offline", onStoreChange); + }; + }, + [socket], + ), + () => (socket?.connected ?? false) && navigator.onLine, + ); const [connectedDevicesCount, setConnectedDevicesCount] = useState(0); const [hasWebPlayer, setHasWebPlayer] = useState(false); @@ -76,19 +95,7 @@ export const SocketProvider: React.FC<{ }, }); - setIsConnected(newSocket.connected); - - // "connect" fires on initial connection and every reconnection - newSocket.on("connect", () => { - setIsConnected(true); - }); - - newSocket.on("disconnect", () => { - setIsConnected(false); - }); - newSocket.on("connect_error", (error) => { - setIsConnected(false); // only auth failures need us — socket.io can't refresh an expired cookie; // everything else is connectivity it retries on its own if (error.message === SOCKET_AUTH_ERROR_MESSAGES.UNAUTHORIZED) { @@ -175,7 +182,8 @@ export const SocketProvider: React.FC<{ useEffect(() => { // if there's user generate instance - socketRef.current = user ? generateSocketInstance() : null; + socketRef.current = userUuid ? generateSocketInstance() : null; + setSocket(socketRef.current); const handleVisibilityChange = () => { if (!document.hidden) { @@ -187,30 +195,26 @@ export const SocketProvider: React.FC<{ nudgeReconnect(); }; - const handleOffline = () => { - setIsConnected(false); - }; - // Add event listener for when the tab becomes visible or focus document.addEventListener("visibilitychange", handleVisibilityChange); + window.addEventListener("focus", nudgeReconnect); // Add event listener for when window online status is active window.addEventListener("online", handleOnline); - window.addEventListener("offline", handleOffline); return () => { // Remove socket listeners, disconnect socket and set socketRef to null teardownSocket(); // Clean ups functions to prevent execute them when are no longer needed document.removeEventListener("visibilitychange", handleVisibilityChange); + window.removeEventListener("focus", nudgeReconnect); window.removeEventListener("online", handleOnline); - window.removeEventListener("offline", handleOffline); }; - }, [user, generateSocketInstance, nudgeReconnect, teardownSocket]); + }, [userUuid, generateSocketInstance, nudgeReconnect, teardownSocket]); // useMemo to memoize context value const contextValue = useMemo( () => ({ - socket: socketRef.current, + socket, isConnected, connectedDevicesCount, hasWebPlayer, @@ -219,6 +223,7 @@ export const SocketProvider: React.FC<{ removeEmitListener, }), [ + socket, isConnected, connectedDevicesCount, hasWebPlayer, diff --git a/src/hooks/useDreamProgress.ts b/src/hooks/useDreamProgress.ts new file mode 100644 index 00000000..98193e8f --- /dev/null +++ b/src/hooks/useDreamProgress.ts @@ -0,0 +1,86 @@ +import { + useQuery, + useQueryClient, + type QueryClient, +} from "@tanstack/react-query"; +import { DREAM_QUERY_KEY, getDreamResponse } from "@/api/dream/query/useDream"; +import useSocket from "@/hooks/useSocket"; +import { useDreamRooms } from "@/hooks/useDreamRooms"; +import { + isActiveProgress, + latestProgress, + progressFromDream, + type DreamProgressSource, +} from "@/utils/job-progress.util"; +import type { DreamJobProgress } from "@/types/job-progress.types"; + +export const DREAM_PROGRESS_QUERY_KEY = "dreamProgress"; + +const DREAM_STALE_TIME_MS = 1000; +const PROGRESS_STALE_TIME_MS = 5000; +const CONNECTED_POLL_INTERVAL_MS = 30_000; +const DISCONNECTED_POLL_INTERVAL_MS = 5000; + +export const getDreamProgressQueryKey = (uuid?: string) => + [DREAM_PROGRESS_QUERY_KEY, uuid] as const; + +export function applyProgress( + queryClient: QueryClient, + incoming: DreamJobProgress, +): DreamJobProgress | undefined { + if (!incoming?.dream_uuid || !incoming.stage) return; + + const queryKey = getDreamProgressQueryKey(incoming.dream_uuid); + const current = queryClient.getQueryData(queryKey); + const merged = latestProgress(current, incoming); + if (merged !== current) queryClient.setQueryData(queryKey, merged); + return merged; +} + +async function fetchDreamProgress( + queryClient: QueryClient, + uuid?: string, +): Promise { + if (!uuid) throw new Error("Dream UUID is required"); + + const response = await queryClient.fetchQuery({ + queryKey: [DREAM_QUERY_KEY, uuid], + queryFn: ({ signal }) => getDreamResponse(uuid, signal), + staleTime: DREAM_STALE_TIME_MS, + }); + const dream = response.data?.dream; + if (!dream) throw new Error("Dream not found"); + + return progressFromDream(dream); +} + +export function useDreamProgress( + dream?: DreamProgressSource, + { poll = false }: { poll?: boolean } = {}, +) { + const queryClient = useQueryClient(); + const { isConnected } = useSocket(); + const uuid = dream?.uuid; + const derived = dream ? progressFromDream(dream) : undefined; + const pending = isActiveProgress(derived); + + useDreamRooms(pending && uuid ? [uuid] : []); + + const { data: live } = useQuery({ + queryKey: getDreamProgressQueryKey(uuid), + queryFn: () => fetchDreamProgress(queryClient, uuid), + enabled: Boolean(uuid) && pending && poll, + staleTime: PROGRESS_STALE_TIME_MS, + refetchOnWindowFocus: poll, + refetchOnReconnect: poll, + refetchInterval: + poll && pending + ? isConnected + ? CONNECTED_POLL_INTERVAL_MS + : DISCONNECTED_POLL_INTERVAL_MS + : false, + }); + + if (!derived) return undefined; + return live ? latestProgress(live, derived) : derived; +} diff --git a/src/hooks/useDreamRooms.ts b/src/hooks/useDreamRooms.ts new file mode 100644 index 00000000..4c3a2fc9 --- /dev/null +++ b/src/hooks/useDreamRooms.ts @@ -0,0 +1,101 @@ +import { useEffect, useMemo, useRef } from "react"; +import type { Socket } from "socket.io-client"; +import useSocket from "@/hooks/useSocket"; +import { + JOIN_DREAM_ROOM_EVENT, + LEAVE_DREAM_ROOM_EVENT, +} from "@/constants/remote-control.constants"; + +function createRoomSubscriptions(socket: Socket) { + const counts = new Map(); + const join = (uuid: string) => socket.emit(JOIN_DREAM_ROOM_EVENT, uuid); + const leave = (uuid: string) => socket.emit(LEAVE_DREAM_ROOM_EVENT, uuid); + const rejoin = () => counts.forEach((_, uuid) => join(uuid)); + + socket.on("connect", rejoin); + + return { + add(uuid: string) { + const count = counts.get(uuid) ?? 0; + counts.set(uuid, count + 1); + if (count === 0 && socket.connected) join(uuid); + }, + remove(uuid: string) { + const count = (counts.get(uuid) ?? 1) - 1; + if (count > 0) { + counts.set(uuid, count); + return; + } + + counts.delete(uuid); + if (socket.connected) leave(uuid); + }, + get isEmpty() { + return counts.size === 0; + }, + dispose() { + socket.off("connect", rejoin); + }, + }; +} + +type RoomSubscriptions = ReturnType; + +const subscriptions = new WeakMap(); + +function getRoomSubscriptions(socket: Socket) { + const existing = subscriptions.get(socket); + if (existing) return existing; + + const created = createRoomSubscriptions(socket); + subscriptions.set(socket, created); + return created; +} + +function releaseRooms(socket: Socket, rooms: RoomSubscriptions) { + if (!rooms.isEmpty) return; + rooms.dispose(); + subscriptions.delete(socket); +} + +export function useDreamRooms(uuids: readonly string[]) { + const { socket } = useSocket(); + const roomsKey = [...new Set(uuids)].sort().join(","); + const roomIds = useMemo( + () => (roomsKey ? roomsKey.split(",") : []), + [roomsKey], + ); + const joinedRef = useRef([]); + + useEffect(() => { + if (!socket) return; + + const rooms = getRoomSubscriptions(socket); + const previous = joinedRef.current; + const nextSet = new Set(roomIds); + const previousSet = new Set(previous); + + for (const uuid of roomIds) { + if (!previousSet.has(uuid)) rooms.add(uuid); + } + for (const uuid of previous) { + if (!nextSet.has(uuid)) rooms.remove(uuid); + } + + joinedRef.current = roomIds; + releaseRooms(socket, rooms); + }, [socket, roomIds]); + + useEffect(() => { + if (!socket) return; + + return () => { + const rooms = subscriptions.get(socket); + if (!rooms) return; + + joinedRef.current.forEach((uuid) => rooms.remove(uuid)); + joinedRef.current = []; + releaseRooms(socket, rooms); + }; + }, [socket]); +} diff --git a/src/providers/job-progress.provider.tsx b/src/providers/job-progress.provider.tsx new file mode 100644 index 00000000..cd8b44da --- /dev/null +++ b/src/providers/job-progress.provider.tsx @@ -0,0 +1,152 @@ +import { useEffect, type ReactNode } from "react"; +import { + focusManager, + onlineManager, + useQueryClient, + type QueryClient, +} from "@tanstack/react-query"; +import type { Socket } from "socket.io-client"; +import useSocket from "@/hooks/useSocket"; +import { + DREAM_PROGRESS_QUERY_KEY, + applyProgress, +} from "@/hooks/useDreamProgress"; +import { JOB_PROGRESS_EVENT } from "@/constants/remote-control.constants"; +import { DREAM_QUERY_KEY } from "@/api/dream/query/useDream"; +import { MY_DREAMS_QUERY_KEY } from "@/api/dream/query/useMyDreams"; +import { DREAMS_QUERY_KEY } from "@/api/dream/query/useDreams"; +import { MY_IMAGE_DREAMS_QUERY_KEY } from "@/api/dream/query/useMyImageDreams"; +import { PLAYLIST_QUERY_KEY } from "@/api/playlist/query/usePlaylist"; +import { PLAYLIST_ITEMS_QUERY_KEY } from "@/api/playlist/query/usePlaylistItems"; +import { PLAYLISTS_QUERY_KEY } from "@/api/playlist/query/usePlaylists"; +import { MY_PLAYLISTS_QUERY_KEY } from "@/api/playlist/query/useMyPlaylists"; +import { FEED_QUERY_KEY } from "@/api/feed/query/useFeed"; +import { FEED_MY_DREAMS_QUERY_KEY } from "@/api/feed/query/useFeedMyDreams"; +import { RANKED_FEED_QUERY_KEY } from "@/api/feed/query/useRankedFeed"; +import { GROUPED_FEED_QUERY_KEY } from "@/api/feed/query/useGroupedFeed"; +import type { DreamJobProgress } from "@/types/job-progress.types"; +import { isActiveProgress } from "@/utils/job-progress.util"; + +const REFRESH_DELAY_MS = 300; +const LIST_QUERY_KEYS = [ + DREAMS_QUERY_KEY, + MY_DREAMS_QUERY_KEY, + MY_IMAGE_DREAMS_QUERY_KEY, + PLAYLIST_QUERY_KEY, + PLAYLIST_ITEMS_QUERY_KEY, + PLAYLISTS_QUERY_KEY, + MY_PLAYLISTS_QUERY_KEY, + FEED_QUERY_KEY, + FEED_MY_DREAMS_QUERY_KEY, + RANKED_FEED_QUERY_KEY, + GROUPED_FEED_QUERY_KEY, +]; +const RECONCILE_QUERY_KEYS = [ + DREAM_QUERY_KEY, + DREAM_PROGRESS_QUERY_KEY, + ...LIST_QUERY_KEYS, +]; +const COMPLETION_QUERY_KEYS = LIST_QUERY_KEYS; + +function invalidateQueries(queryClient: QueryClient, keys: readonly string[]) { + keys.forEach((key) => { + void queryClient.invalidateQueries( + { queryKey: [key] }, + { cancelRefetch: false }, + ); + }); +} + +function getProgressVersion(progress: DreamJobProgress) { + return `${progress.run_id}:${progress.stage}:${progress.status}`; +} + +function useJobProgressSync(socket: Socket | null | undefined) { + const queryClient = useQueryClient(); + + useEffect(() => { + const active = new Set(); + const completed = new Set(); + const versions = new Map(); + let refreshTimer: ReturnType | undefined; + + const reconcile = (force = false) => { + if (!focusManager.isFocused() || !onlineManager.isOnline()) return; + if (!force && active.size === 0) return; + invalidateQueries(queryClient, RECONCILE_QUERY_KEYS); + }; + + const refresh = () => { + refreshTimer = undefined; + if (completed.size === 0) return; + + completed.forEach((uuid) => { + void queryClient.invalidateQueries([DREAM_QUERY_KEY, uuid]); + }); + completed.clear(); + invalidateQueries(queryClient, COMPLETION_QUERY_KEYS); + }; + + const track = (progress: DreamJobProgress) => { + const version = getProgressVersion(progress); + if (versions.get(progress.dream_uuid) === version) return; + versions.set(progress.dream_uuid, version); + + if (isActiveProgress(progress)) { + active.add(progress.dream_uuid); + return; + } + + active.delete(progress.dream_uuid); + completed.add(progress.dream_uuid); + refreshTimer ??= setTimeout(refresh, REFRESH_DELAY_MS); + }; + + const onProgress = (progress: DreamJobProgress) => { + const merged = applyProgress(queryClient, progress); + if (merged) track(merged); + }; + + const unsubscribeCache = queryClient.getQueryCache().subscribe((event) => { + if (event.type !== "updated" || event.action.type !== "success") return; + if (event.query.queryKey[0] !== DREAM_PROGRESS_QUERY_KEY) return; + + const progress = event.query.state.data as DreamJobProgress | undefined; + if (progress) track(progress); + }); + + let wasDisconnected = false; + const onDisconnect = () => { + wasDisconnected = true; + }; + const onConnect = () => { + if (!wasDisconnected) return; + wasDisconnected = false; + reconcile(true); + }; + const onFocusOrOnline = () => reconcile(); + + socket?.on(JOB_PROGRESS_EVENT, onProgress); + socket?.on("connect", onConnect); + socket?.on("disconnect", onDisconnect); + const unsubscribeFocus = focusManager.subscribe(onFocusOrOnline); + const unsubscribeOnline = onlineManager.subscribe(onFocusOrOnline); + + return () => { + socket?.off(JOB_PROGRESS_EVENT, onProgress); + socket?.off("connect", onConnect); + socket?.off("disconnect", onDisconnect); + unsubscribeFocus(); + unsubscribeOnline(); + unsubscribeCache(); + clearTimeout(refreshTimer); + queryClient.removeQueries([DREAM_PROGRESS_QUERY_KEY]); + }; + }, [socket, queryClient]); +} + +export function JobProgressProvider({ children }: { children?: ReactNode }) { + const { socket } = useSocket(); + useJobProgressSync(socket); + return <>{children}; +} diff --git a/src/providers/providers.tsx b/src/providers/providers.tsx index c28da952..1b3d3b0e 100644 --- a/src/providers/providers.tsx +++ b/src/providers/providers.tsx @@ -1,3 +1,4 @@ +import { JobProgressProvider } from "./job-progress.provider"; import React from "react"; import AuthProvider from "@/providers/auth.provider"; import ModalProvider from "@/providers/modal.provider"; @@ -30,6 +31,7 @@ export const Providers = [ ModalProvider, PermissionProvider, SocketProvider, + JobProgressProvider, PlaybackSyncProvider, DesktopClientProvider, VideoJSProvider, diff --git a/src/types/dream.types.ts b/src/types/dream.types.ts index a72deb0f..da9cba07 100644 --- a/src/types/dream.types.ts +++ b/src/types/dream.types.ts @@ -1,3 +1,4 @@ +import type { DreamJobProgress } from "./job-progress.types"; import { User } from "./auth.types"; import { Keyframe } from "./keyframe.types"; import { PlaylistItem } from "./playlist.types"; @@ -42,6 +43,7 @@ export type Dream = { processedMediaHeight?: number; render_duration?: number | null; status: DreamStatusType; + jobProgress?: DreamJobProgress; mediaType?: DreamMediaType; nsfw?: boolean; hidden?: boolean; diff --git a/src/types/job-progress.types.ts b/src/types/job-progress.types.ts new file mode 100644 index 00000000..0afec3fb --- /dev/null +++ b/src/types/job-progress.types.ts @@ -0,0 +1,38 @@ +export type JobStage = + | "queued" + | "rendering" + | "ingesting" + | "completed" + | "failed" + | "cancelled" + | "idle"; + +export type JobStatus = + | "IN_QUEUE" + | "IN_PROGRESS" + | "COMPLETED" + | "FAILED" + | "CANCELLED"; + +export interface DreamJobProgress { + dream_uuid: string; + status: JobStatus; + stage: JobStage; + progress: number | null; + countdown_ms: number | null; + updated_at: number; + jobId?: string; + queue?: string; + run_id?: string; + run_started_at?: number; +} + +export interface PlaylistProgress { + total: number; + completed: number; + queued: number; + inProgress: number; + failed: number; + idle: number; + remaining: number; +} diff --git a/src/types/playlist.types.ts b/src/types/playlist.types.ts index 72636402..121de2bd 100644 --- a/src/types/playlist.types.ts +++ b/src/types/playlist.types.ts @@ -1,3 +1,4 @@ +import type { PlaylistProgress } from "./job-progress.types"; import { User } from "./auth.types"; import { Dream } from "./dream.types"; import { Keyframe } from "./keyframe.types"; @@ -54,6 +55,7 @@ export type Playlist = { totalDurationSeconds?: number; totalDurationFormatted?: string; totalDreamCount?: number; + progress?: PlaylistProgress; prompt?: string | null; }; diff --git a/src/utils/dream.util.ts b/src/utils/dream.util.ts index 77024f98..750a8721 100644 --- a/src/utils/dream.util.ts +++ b/src/utils/dream.util.ts @@ -1,3 +1,4 @@ +import type { JobStage } from "@/types/job-progress.types"; import { ApiResponse } from "@/types/api.types"; import { Dream, @@ -28,8 +29,12 @@ const FINISHED_JOB_STATUSES = new Set(["CANCELLED", "FAILED"]); export const getDreamProcessingPhase = ( isProcessing: boolean, jobStatus?: string, + stage?: JobStage, ): DreamProcessingPhase | undefined => { if (!isProcessing) return undefined; + if (stage === "ingesting") return "INGESTING"; + if (stage === "rendering") return "RENDERING"; + if (stage === "queued") return "QUEUED"; const status = (jobStatus ?? "").toUpperCase(); if (FINISHED_JOB_STATUSES.has(status)) return undefined; diff --git a/src/utils/job-progress.util.ts b/src/utils/job-progress.util.ts new file mode 100644 index 00000000..569e8001 --- /dev/null +++ b/src/utils/job-progress.util.ts @@ -0,0 +1,54 @@ +import type { + DreamJobProgress, + JobStage, + JobStatus, +} from "@/types/job-progress.types"; + +export interface DreamProgressSource { + uuid: string; + status: string; + jobProgress?: DreamJobProgress; + updated_at?: string; +} + +const DREAM_STATES: Record = { + queue: ["IN_QUEUE", "queued"], + processing: ["IN_PROGRESS", "ingesting"], + processed: ["COMPLETED", "completed"], + failed: ["FAILED", "failed"], + none: ["CANCELLED", "idle"], +}; + +export const isActiveProgress = (progress?: DreamJobProgress) => + progress?.stage === "queued" || + progress?.stage === "rendering" || + progress?.stage === "ingesting"; + +export function progressFromDream( + dream: DreamProgressSource, +): DreamJobProgress { + if (dream.jobProgress) return dream.jobProgress; + + const [status, stage] = DREAM_STATES[dream.status] ?? ["CANCELLED", "idle"]; + return { + dream_uuid: dream.uuid, + status, + stage, + progress: stage === "completed" ? 100 : null, + countdown_ms: null, + updated_at: dream.updated_at ? Date.parse(dream.updated_at) : 0, + }; +} + +export function latestProgress( + current: DreamJobProgress | undefined, + next: DreamJobProgress, +): DreamJobProgress { + if (!current) return next; + if (current.run_id && next.run_id && current.run_id !== next.run_id) { + return (next.run_started_at ?? 0) > (current.run_started_at ?? 0) + ? next + : current; + } + return next.updated_at >= current.updated_at ? next : current; +}