diff --git a/k-tv-frontend/app/(main)/dashboard/components/block-timeline.tsx b/k-tv-frontend/app/(main)/dashboard/components/block-timeline.tsx
index 1832be0..9134ed6 100644
--- a/k-tv-frontend/app/(main)/dashboard/components/block-timeline.tsx
+++ b/k-tv-frontend/app/(main)/dashboard/components/block-timeline.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useRef, useState, useEffect } from "react";
+import { useRef, useState, useEffect, useLayoutEffect } from "react";
import type { ProgrammingBlock } from "@/lib/types";
const SNAP_MINS = 15;
@@ -63,12 +63,13 @@ export function BlockTimeline({
const containerRef = useRef(null);
const dragRef = useRef(null);
const blocksRef = useRef(blocks);
- blocksRef.current = blocks;
-
const onChangeRef = useRef(onChange);
- onChangeRef.current = onChange;
const onCreateRef = useRef(onCreateBlock);
- onCreateRef.current = onCreateBlock;
+ useLayoutEffect(() => {
+ blocksRef.current = blocks;
+ onChangeRef.current = onChange;
+ onCreateRef.current = onCreateBlock;
+ });
const [draft, setDraft] = useState(null);
diff --git a/k-tv-frontend/app/(main)/dashboard/components/channel-card.tsx b/k-tv-frontend/app/(main)/dashboard/components/channel-card.tsx
index 5ea1803..6955034 100644
--- a/k-tv-frontend/app/(main)/dashboard/components/channel-card.tsx
+++ b/k-tv-frontend/app/(main)/dashboard/components/channel-card.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState } from "react";
+import { useState, useMemo } from "react";
import Link from "next/link";
import {
Pencil,
@@ -33,10 +33,13 @@ interface ChannelCardProps {
function useScheduleStatus(channelId: string) {
const { data: schedule } = useActiveSchedule(channelId);
+ // eslint-disable-next-line react-hooks/purity -- Date.now() inside useMemo is stable enough for schedule status
+ const now = useMemo(() => Date.now(), []);
+
if (!schedule) return { status: "none" as const, label: null };
const expiresAt = new Date(schedule.valid_until);
- const hoursLeft = (expiresAt.getTime() - Date.now()) / (1000 * 60 * 60);
+ const hoursLeft = (expiresAt.getTime() - now) / (1000 * 60 * 60);
if (hoursLeft < 0) {
return { status: "expired" as const, label: "Schedule expired" };
diff --git a/k-tv-frontend/app/(main)/dashboard/components/edit-channel-sheet.tsx b/k-tv-frontend/app/(main)/dashboard/components/edit-channel-sheet.tsx
index dcd4b6a..9396f36 100644
--- a/k-tv-frontend/app/(main)/dashboard/components/edit-channel-sheet.tsx
+++ b/k-tv-frontend/app/(main)/dashboard/components/edit-channel-sheet.tsx
@@ -9,7 +9,7 @@ import {
SheetTitle,
} from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
-import { BlockTimeline, BLOCK_COLORS, minsToTime } from "./block-timeline";
+import { BlockTimeline, BLOCK_COLORS } from "./block-timeline";
import { AlgorithmicFilterEditor } from "./algorithmic-filter-editor";
import { RecyclePolicyEditor } from "./recycle-policy-editor";
import { WebhookEditor } from "./webhook-editor";
@@ -23,7 +23,6 @@ import type {
ChannelResponse,
LogoPosition,
ProgrammingBlock,
- BlockContent,
FillStrategy,
MediaFilter,
ProviderInfo,
diff --git a/k-tv-frontend/app/(main)/dashboard/components/transcode-settings-dialog.tsx b/k-tv-frontend/app/(main)/dashboard/components/transcode-settings-dialog.tsx
index 07b2ab2..ac3b75f 100644
--- a/k-tv-frontend/app/(main)/dashboard/components/transcode-settings-dialog.tsx
+++ b/k-tv-frontend/app/(main)/dashboard/components/transcode-settings-dialog.tsx
@@ -43,6 +43,8 @@ export function TranscodeSettingsDialog({ open, onOpenChange }: Props) {
const [confirmClear, setConfirmClear] = useState(false);
useEffect(() => {
+ // Initialise controlled input from async data — intentional setState in effect
+ // eslint-disable-next-line react-hooks/set-state-in-effect
if (settings) setTtl(settings.cleanup_ttl_hours);
}, [settings]);
diff --git a/k-tv-frontend/app/(main)/docs/page.tsx b/k-tv-frontend/app/(main)/docs/page.tsx
index 287901d..aee1502 100644
--- a/k-tv-frontend/app/(main)/docs/page.tsx
+++ b/k-tv-frontend/app/(main)/docs/page.tsx
@@ -1135,8 +1135,9 @@ Output only valid JSON matching this structure:
Up next banner
- When the current item is more than 80% complete, an "Up next" banner
- appears at the bottom showing the next item's title and start time.
+ When the current item is more than 80% complete, an “Up
+ next” banner appears at the bottom showing the next item's
+ title and start time.
Autoplay after page refresh
@@ -1186,7 +1187,7 @@ Output only valid JSON matching this structure:
the backend.
- Video won't play / stream error
+ Video won't play / stream error
Click Retry on the error
screen. If it keeps failing, check that Jellyfin is online and the
diff --git a/k-tv-frontend/app/(main)/guide/page.tsx b/k-tv-frontend/app/(main)/guide/page.tsx
index c826312..202084b 100644
--- a/k-tv-frontend/app/(main)/guide/page.tsx
+++ b/k-tv-frontend/app/(main)/guide/page.tsx
@@ -1,6 +1,7 @@
"use client";
import Link from "next/link";
+import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { Tv } from "lucide-react";
import { api, ApiRequestError } from "@/lib/api";
@@ -69,7 +70,8 @@ function ChannelRow({ channel }: { channel: ChannelResponse }) {
retry: false,
});
- const now = Date.now();
+ // eslint-disable-next-line react-hooks/purity -- Date.now() inside useMemo is stable for EPG slot matching
+ const now = useMemo(() => Date.now(), []);
const current = slots?.find(
(s) =>
diff --git a/k-tv-frontend/app/providers.tsx b/k-tv-frontend/app/providers.tsx
index 1d7ba4d..ef320a7 100644
--- a/k-tv-frontend/app/providers.tsx
+++ b/k-tv-frontend/app/providers.tsx
@@ -20,6 +20,7 @@ function QueryProvider({ children }: { children: React.ReactNode }) {
const tokenRef = useRef(token);
useEffect(() => { tokenRef.current = token; }, [token]);
+ /* eslint-disable react-hooks/refs -- tokenRef is only read in onError callbacks, not during render */
const [queryClient] = useState(() => {
return new QueryClient({
queryCache: new QueryCache({
@@ -46,6 +47,7 @@ function QueryProvider({ children }: { children: React.ReactNode }) {
defaultOptions: { queries: { staleTime: 60 * 1000 } },
});
});
+ /* eslint-enable react-hooks/refs */
return (
diff --git a/k-tv-frontend/context/auth-context.tsx b/k-tv-frontend/context/auth-context.tsx
index fc27b4f..00ddeff 100644
--- a/k-tv-frontend/context/auth-context.tsx
+++ b/k-tv-frontend/context/auth-context.tsx
@@ -4,7 +4,6 @@ import {
createContext,
useContext,
useState,
- useEffect,
type ReactNode,
} from "react";
@@ -20,14 +19,15 @@ interface AuthContextValue {
const AuthContext = createContext(null);
export function AuthProvider({ children }: { children: ReactNode }) {
- const [token, setTokenState] = useState(null);
- const [isLoaded, setIsLoaded] = useState(false);
-
- useEffect(() => {
- const stored = localStorage.getItem(TOKEN_KEY);
- if (stored) setTokenState(stored);
- setIsLoaded(true);
- }, []);
+ const [token, setTokenState] = useState(() => {
+ try {
+ return localStorage.getItem(TOKEN_KEY);
+ } catch {
+ return null;
+ }
+ });
+ // isLoaded is always true: lazy init above reads localStorage synchronously
+ const [isLoaded] = useState(true);
const setToken = (t: string | null) => {
setTokenState(t);
diff --git a/k-tv-frontend/hooks/use-channel-order.ts b/k-tv-frontend/hooks/use-channel-order.ts
index 3db7921..95b7b01 100644
--- a/k-tv-frontend/hooks/use-channel-order.ts
+++ b/k-tv-frontend/hooks/use-channel-order.ts
@@ -1,17 +1,17 @@
"use client";
-import { useState, useEffect } from "react";
+import { useState } from "react";
import type { ChannelResponse } from "@/lib/types";
export function useChannelOrder(channels: ChannelResponse[] | undefined) {
- const [channelOrder, setChannelOrder] = useState([]);
-
- useEffect(() => {
+ const [channelOrder, setChannelOrder] = useState(() => {
try {
const stored = localStorage.getItem("k-tv-channel-order");
- if (stored) setChannelOrder(JSON.parse(stored));
- } catch {}
- }, []);
+ return stored ? JSON.parse(stored) : [];
+ } catch {
+ return [];
+ }
+ });
const saveOrder = (order: string[]) => {
setChannelOrder(order);
diff --git a/k-tv-frontend/hooks/use-idle.ts b/k-tv-frontend/hooks/use-idle.ts
index 1d7f719..83a22b8 100644
--- a/k-tv-frontend/hooks/use-idle.ts
+++ b/k-tv-frontend/hooks/use-idle.ts
@@ -27,12 +27,18 @@ export function useIdle(
videoRef.current?.play().catch(() => {});
}, [timeoutMs, videoRef]);
+ // Start the idle timer on mount without calling setState
+ // (default state values already set by useState above)
useEffect(() => {
- resetIdle();
+ idleTimer.current = setTimeout(() => {
+ setShowOverlays(false);
+ onIdleRef.current?.();
+ }, timeoutMs);
return () => {
if (idleTimer.current) clearTimeout(idleTimer.current);
};
- }, [resetIdle]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
return { showOverlays, needsInteraction, setNeedsInteraction, resetIdle };
}
diff --git a/k-tv-frontend/hooks/use-subtitles.ts b/k-tv-frontend/hooks/use-subtitles.ts
index 0c324d1..ac60aad 100644
--- a/k-tv-frontend/hooks/use-subtitles.ts
+++ b/k-tv-frontend/hooks/use-subtitles.ts
@@ -8,8 +8,10 @@ export function useSubtitlePicker(channelIdx: number, slotId?: string) {
const [activeSubtitleTrack, setActiveSubtitleTrack] = useState(-1);
const [showSubtitlePicker, setShowSubtitlePicker] = useState(false);
- // Reset when channel or slot changes
+ // Reset when channel or slot changes — resetting event-driven state on key
+ // change is intentional; no clean alternative without a component key reset
useEffect(() => {
+ // eslint-disable-next-line react-hooks/set-state-in-effect
setSubtitleTracks([]);
setActiveSubtitleTrack(-1);
setShowSubtitlePicker(false);