refactor(frontend): extract logic to hooks, split inline components
Area 1 (tv/page.tsx 964→423 lines): - hooks: use-fullscreen, use-idle, use-volume, use-quality, use-subtitles, use-channel-input, use-channel-passwords, use-tv-keyboard - components: SubtitlePicker, VolumeControl, QualityPicker, TopControlBar, LogoWatermark, AutoplayPrompt, ChannelNumberOverlay, TvBaseLayer Area 2 (edit-channel-sheet.tsx 1244→678 lines): - hooks: use-channel-form (all form state + reset logic) - lib/schemas.ts: extracted Zod schemas + extractErrors - components: AlgorithmicFilterEditor, RecyclePolicyEditor, WebhookEditor, AccessSettingsEditor, LogoEditor Area 3 (dashboard/page.tsx 406→261 lines): - hooks: use-channel-order, use-import-channel, use-regenerate-all - lib/channel-export.ts: pure export utility - components: DashboardHeader
This commit is contained in:
158
k-tv-frontend/hooks/use-channel-form.ts
Normal file
158
k-tv-frontend/hooks/use-channel-form.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { minsToTime } from "@/app/(main)/dashboard/components/block-timeline";
|
||||
import type {
|
||||
AccessMode,
|
||||
ChannelResponse,
|
||||
LogoPosition,
|
||||
ProgrammingBlock,
|
||||
MediaFilter,
|
||||
RecyclePolicy,
|
||||
} from "@/lib/types";
|
||||
|
||||
export const WEBHOOK_PRESETS = {
|
||||
discord: `{
|
||||
"embeds": [{
|
||||
"title": "📺 {{event}}",
|
||||
"description": "{{#if data.item.title}}Now playing: **{{data.item.title}}**{{else}}No signal{{/if}}",
|
||||
"color": 3447003,
|
||||
"timestamp": "{{timestamp}}"
|
||||
}]
|
||||
}`,
|
||||
slack: `{
|
||||
"text": "📺 *{{event}}*{{#if data.item.title}} — {{data.item.title}}{{/if}}"
|
||||
}`,
|
||||
} as const;
|
||||
|
||||
export type WebhookFormat = "default" | "discord" | "slack" | "custom";
|
||||
|
||||
export function defaultFilter(): MediaFilter {
|
||||
return {
|
||||
content_type: null,
|
||||
genres: [],
|
||||
decade: null,
|
||||
tags: [],
|
||||
min_duration_secs: null,
|
||||
max_duration_secs: null,
|
||||
collections: [],
|
||||
series_names: [],
|
||||
search_term: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultBlock(startMins = 20 * 60, durationMins = 60): ProgrammingBlock {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
name: "",
|
||||
start_time: minsToTime(startMins),
|
||||
duration_mins: durationMins,
|
||||
content: { type: "algorithmic", filter: defaultFilter(), strategy: "random" },
|
||||
loop_on_finish: true,
|
||||
ignore_recycle_policy: false,
|
||||
access_mode: "public",
|
||||
};
|
||||
}
|
||||
|
||||
export function useChannelForm(channel: ChannelResponse | null) {
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [timezone, setTimezone] = useState("UTC");
|
||||
const [blocks, setBlocks] = useState<ProgrammingBlock[]>([]);
|
||||
const [recyclePolicy, setRecyclePolicy] = useState<RecyclePolicy>({
|
||||
cooldown_days: null,
|
||||
cooldown_generations: null,
|
||||
min_available_ratio: 0.1,
|
||||
});
|
||||
const [autoSchedule, setAutoSchedule] = useState(false);
|
||||
const [accessMode, setAccessMode] = useState<AccessMode>("public");
|
||||
const [accessPassword, setAccessPassword] = useState("");
|
||||
const [logo, setLogo] = useState<string | null>(null);
|
||||
const [logoPosition, setLogoPosition] = useState<LogoPosition>("top_right");
|
||||
const [logoOpacity, setLogoOpacity] = useState(100);
|
||||
const [webhookUrl, setWebhookUrl] = useState("");
|
||||
const [webhookPollInterval, setWebhookPollInterval] = useState<number | "">(5);
|
||||
const [webhookFormat, setWebhookFormat] = useState<WebhookFormat>("default");
|
||||
const [webhookBodyTemplate, setWebhookBodyTemplate] = useState("");
|
||||
const [webhookHeaders, setWebhookHeaders] = useState("");
|
||||
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Reset form when channel changes
|
||||
useEffect(() => {
|
||||
if (channel) {
|
||||
setName(channel.name);
|
||||
setDescription(channel.description ?? "");
|
||||
setTimezone(channel.timezone);
|
||||
setBlocks(channel.schedule_config.blocks);
|
||||
setRecyclePolicy(channel.recycle_policy);
|
||||
setAutoSchedule(channel.auto_schedule);
|
||||
setAccessMode(channel.access_mode ?? "public");
|
||||
setAccessPassword("");
|
||||
setLogo(channel.logo ?? null);
|
||||
setLogoPosition(channel.logo_position ?? "top_right");
|
||||
setLogoOpacity(Math.round((channel.logo_opacity ?? 1) * 100));
|
||||
setWebhookUrl(channel.webhook_url ?? "");
|
||||
setWebhookPollInterval(channel.webhook_poll_interval_secs ?? 5);
|
||||
const tmpl = channel.webhook_body_template ?? "";
|
||||
setWebhookBodyTemplate(tmpl);
|
||||
setWebhookHeaders(channel.webhook_headers ?? "");
|
||||
if (!tmpl) {
|
||||
setWebhookFormat("default");
|
||||
} else if (tmpl === WEBHOOK_PRESETS.discord) {
|
||||
setWebhookFormat("discord");
|
||||
} else if (tmpl === WEBHOOK_PRESETS.slack) {
|
||||
setWebhookFormat("slack");
|
||||
} else {
|
||||
setWebhookFormat("custom");
|
||||
}
|
||||
setSelectedBlockId(null);
|
||||
}
|
||||
}, [channel]);
|
||||
|
||||
const addBlock = (startMins = 20 * 60, durationMins = 60) => {
|
||||
const block = defaultBlock(startMins, durationMins);
|
||||
setBlocks((prev) => [...prev, block]);
|
||||
setSelectedBlockId(block.id);
|
||||
};
|
||||
|
||||
const updateBlock = (idx: number, block: ProgrammingBlock) =>
|
||||
setBlocks((prev) => prev.map((b, i) => (i === idx ? block : b)));
|
||||
|
||||
const removeBlock = (idx: number) => {
|
||||
setBlocks((prev) => {
|
||||
const next = prev.filter((_, i) => i !== idx);
|
||||
if (selectedBlockId === prev[idx].id) setSelectedBlockId(null);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
// Basic info
|
||||
name, setName,
|
||||
description, setDescription,
|
||||
timezone, setTimezone,
|
||||
autoSchedule, setAutoSchedule,
|
||||
// Access
|
||||
accessMode, setAccessMode,
|
||||
accessPassword, setAccessPassword,
|
||||
// Logo
|
||||
logo, setLogo,
|
||||
logoPosition, setLogoPosition,
|
||||
logoOpacity, setLogoOpacity,
|
||||
fileInputRef,
|
||||
// Webhook
|
||||
webhookUrl, setWebhookUrl,
|
||||
webhookPollInterval, setWebhookPollInterval,
|
||||
webhookFormat, setWebhookFormat,
|
||||
webhookBodyTemplate, setWebhookBodyTemplate,
|
||||
webhookHeaders, setWebhookHeaders,
|
||||
// Blocks
|
||||
blocks, setBlocks,
|
||||
selectedBlockId, setSelectedBlockId,
|
||||
recyclePolicy, setRecyclePolicy,
|
||||
addBlock,
|
||||
updateBlock,
|
||||
removeBlock,
|
||||
};
|
||||
}
|
||||
39
k-tv-frontend/hooks/use-channel-input.ts
Normal file
39
k-tv-frontend/hooks/use-channel-input.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
|
||||
export function useChannelInput(
|
||||
channelCount: number,
|
||||
switchChannel: (idx: number) => void,
|
||||
resetIdle: () => void,
|
||||
) {
|
||||
const [channelInput, setChannelInput] = useState("");
|
||||
const channelInputTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const handleDigit = useCallback(
|
||||
(digit: string) => {
|
||||
setChannelInput((prev) => {
|
||||
const next = prev + digit;
|
||||
if (channelInputTimer.current) clearTimeout(channelInputTimer.current);
|
||||
channelInputTimer.current = setTimeout(() => {
|
||||
const num = parseInt(next, 10);
|
||||
if (num >= 1 && num <= Math.max(channelCount, 1)) {
|
||||
switchChannel(num - 1);
|
||||
resetIdle();
|
||||
}
|
||||
setChannelInput("");
|
||||
}, 1500);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[channelCount, switchChannel, resetIdle],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (channelInputTimer.current) clearTimeout(channelInputTimer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { channelInput, handleDigit };
|
||||
}
|
||||
53
k-tv-frontend/hooks/use-channel-order.ts
Normal file
53
k-tv-frontend/hooks/use-channel-order.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import type { ChannelResponse } from "@/lib/types";
|
||||
|
||||
export function useChannelOrder(channels: ChannelResponse[] | undefined) {
|
||||
const [channelOrder, setChannelOrder] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem("k-tv-channel-order");
|
||||
if (stored) setChannelOrder(JSON.parse(stored));
|
||||
} catch {}
|
||||
}, []);
|
||||
|
||||
const saveOrder = (order: string[]) => {
|
||||
setChannelOrder(order);
|
||||
try {
|
||||
localStorage.setItem("k-tv-channel-order", JSON.stringify(order));
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const sortedChannels = channels
|
||||
? [...channels].sort((a, b) => {
|
||||
const ai = channelOrder.indexOf(a.id);
|
||||
const bi = channelOrder.indexOf(b.id);
|
||||
if (ai === -1 && bi === -1) return 0;
|
||||
if (ai === -1) return 1;
|
||||
if (bi === -1) return -1;
|
||||
return ai - bi;
|
||||
})
|
||||
: [];
|
||||
|
||||
const handleMoveUp = (channelId: string) => {
|
||||
const ids = sortedChannels.map((c) => c.id);
|
||||
const idx = ids.indexOf(channelId);
|
||||
if (idx <= 0) return;
|
||||
const next = [...ids];
|
||||
[next[idx - 1], next[idx]] = [next[idx], next[idx - 1]];
|
||||
saveOrder(next);
|
||||
};
|
||||
|
||||
const handleMoveDown = (channelId: string) => {
|
||||
const ids = sortedChannels.map((c) => c.id);
|
||||
const idx = ids.indexOf(channelId);
|
||||
if (idx === -1 || idx >= ids.length - 1) return;
|
||||
const next = [...ids];
|
||||
[next[idx], next[idx + 1]] = [next[idx + 1], next[idx]];
|
||||
saveOrder(next);
|
||||
};
|
||||
|
||||
return { channelOrder, sortedChannels, handleMoveUp, handleMoveDown };
|
||||
}
|
||||
74
k-tv-frontend/hooks/use-channel-passwords.ts
Normal file
74
k-tv-frontend/hooks/use-channel-passwords.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
export function useChannelPasswords(channelId?: string) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [channelPasswords, setChannelPasswords] = useState<Record<string, string>>(() => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem("channel_passwords") ?? "{}");
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
});
|
||||
const [blockPasswords, setBlockPasswords] = useState<Record<string, string>>(() => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem("block_passwords") ?? "{}");
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
});
|
||||
const [showChannelPasswordModal, setShowChannelPasswordModal] = useState(false);
|
||||
const [showBlockPasswordModal, setShowBlockPasswordModal] = useState(false);
|
||||
|
||||
// channelPassword only depends on channelId — available immediately from localStorage
|
||||
const channelPassword = channelId ? channelPasswords[channelId] : undefined;
|
||||
|
||||
// blockPassword is derived lazily by the caller (needs broadcast.slot.id)
|
||||
const getBlockPassword = useCallback(
|
||||
(slotId?: string) => (slotId ? blockPasswords[slotId] : undefined),
|
||||
[blockPasswords],
|
||||
);
|
||||
|
||||
const submitChannelPassword = useCallback(
|
||||
(password: string) => {
|
||||
if (!channelId) return;
|
||||
const next = { ...channelPasswords, [channelId]: password };
|
||||
setChannelPasswords(next);
|
||||
try {
|
||||
localStorage.setItem("channel_passwords", JSON.stringify(next));
|
||||
} catch {}
|
||||
setShowChannelPasswordModal(false);
|
||||
queryClient.invalidateQueries({ queryKey: ["broadcast", channelId] });
|
||||
},
|
||||
[channelId, channelPasswords, queryClient],
|
||||
);
|
||||
|
||||
const submitBlockPassword = useCallback(
|
||||
(slotId: string, channelIdForQuery: string | undefined, password: string) => {
|
||||
const next = { ...blockPasswords, [slotId]: password };
|
||||
setBlockPasswords(next);
|
||||
try {
|
||||
localStorage.setItem("block_passwords", JSON.stringify(next));
|
||||
} catch {}
|
||||
setShowBlockPasswordModal(false);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["stream-url", channelIdForQuery, slotId],
|
||||
});
|
||||
},
|
||||
[blockPasswords, queryClient],
|
||||
);
|
||||
|
||||
return {
|
||||
channelPassword,
|
||||
getBlockPassword,
|
||||
showChannelPasswordModal,
|
||||
setShowChannelPasswordModal,
|
||||
showBlockPasswordModal,
|
||||
setShowBlockPasswordModal,
|
||||
submitChannelPassword,
|
||||
submitBlockPassword,
|
||||
};
|
||||
}
|
||||
64
k-tv-frontend/hooks/use-fullscreen.ts
Normal file
64
k-tv-frontend/hooks/use-fullscreen.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, RefObject } from "react";
|
||||
|
||||
export function useFullscreen(
|
||||
videoRef: RefObject<HTMLVideoElement | null>,
|
||||
showOverlays: boolean,
|
||||
streamUrl?: string | null,
|
||||
) {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
// Standard fullscreenchange
|
||||
useEffect(() => {
|
||||
const handler = () => setIsFullscreen(!!document.fullscreenElement);
|
||||
document.addEventListener("fullscreenchange", handler);
|
||||
return () => document.removeEventListener("fullscreenchange", handler);
|
||||
}, []);
|
||||
|
||||
// Body classes for nav hiding
|
||||
useEffect(() => {
|
||||
document.body.classList.toggle("tv-fullscreen", isFullscreen);
|
||||
document.body.classList.toggle("tv-overlays", isFullscreen && showOverlays);
|
||||
return () => document.body.classList.remove("tv-fullscreen", "tv-overlays");
|
||||
}, [isFullscreen, showOverlays]);
|
||||
|
||||
// iOS Safari webkit events — re-attach when stream URL changes (new video element)
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
const onBegin = () => setIsFullscreen(true);
|
||||
const onEnd = () => setIsFullscreen(false);
|
||||
video.addEventListener("webkitbeginfullscreen", onBegin);
|
||||
video.addEventListener("webkitendfullscreen", onEnd);
|
||||
return () => {
|
||||
video.removeEventListener("webkitbeginfullscreen", onBegin);
|
||||
video.removeEventListener("webkitendfullscreen", onEnd);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [streamUrl]);
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
if (document.fullscreenEnabled) {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen().catch(() => {});
|
||||
} else {
|
||||
document.exitFullscreen().catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
const video = videoRef.current as HTMLVideoElement & {
|
||||
webkitEnterFullscreen?: () => void;
|
||||
webkitExitFullscreen?: () => void;
|
||||
webkitDisplayingFullscreen?: boolean;
|
||||
};
|
||||
if (!video?.webkitEnterFullscreen) return;
|
||||
if (video.webkitDisplayingFullscreen) {
|
||||
video.webkitExitFullscreen?.();
|
||||
} else {
|
||||
video.webkitEnterFullscreen();
|
||||
}
|
||||
}, [videoRef]);
|
||||
|
||||
return { isFullscreen, toggleFullscreen };
|
||||
}
|
||||
38
k-tv-frontend/hooks/use-idle.ts
Normal file
38
k-tv-frontend/hooks/use-idle.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef, RefObject } from "react";
|
||||
|
||||
export function useIdle(
|
||||
timeoutMs: number,
|
||||
videoRef: RefObject<HTMLVideoElement | null>,
|
||||
onIdle?: () => void,
|
||||
) {
|
||||
const [showOverlays, setShowOverlays] = useState(true);
|
||||
const [needsInteraction, setNeedsInteraction] = useState(false);
|
||||
const idleTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Keep onIdle in a ref so resetIdle doesn't need it as a dep
|
||||
const onIdleRef = useRef(onIdle);
|
||||
useEffect(() => {
|
||||
onIdleRef.current = onIdle;
|
||||
});
|
||||
|
||||
const resetIdle = useCallback(() => {
|
||||
setShowOverlays(true);
|
||||
setNeedsInteraction(false);
|
||||
if (idleTimer.current) clearTimeout(idleTimer.current);
|
||||
idleTimer.current = setTimeout(() => {
|
||||
setShowOverlays(false);
|
||||
onIdleRef.current?.();
|
||||
}, timeoutMs);
|
||||
videoRef.current?.play().catch(() => {});
|
||||
}, [timeoutMs, videoRef]);
|
||||
|
||||
useEffect(() => {
|
||||
resetIdle();
|
||||
return () => {
|
||||
if (idleTimer.current) clearTimeout(idleTimer.current);
|
||||
};
|
||||
}, [resetIdle]);
|
||||
|
||||
return { showOverlays, needsInteraction, setNeedsInteraction, resetIdle };
|
||||
}
|
||||
47
k-tv-frontend/hooks/use-import-channel.ts
Normal file
47
k-tv-frontend/hooks/use-import-channel.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
import type { ChannelImportData } from "@/app/(main)/dashboard/components/import-channel-dialog";
|
||||
|
||||
export function useImportChannel(token: string | null) {
|
||||
const queryClient = useQueryClient();
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleImport = async (data: ChannelImportData) => {
|
||||
if (!token) return;
|
||||
setIsPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const created = await api.channels.create(
|
||||
{
|
||||
name: data.name,
|
||||
timezone: data.timezone,
|
||||
description: data.description,
|
||||
},
|
||||
token,
|
||||
);
|
||||
await api.channels.update(
|
||||
created.id,
|
||||
{
|
||||
schedule_config: { blocks: data.blocks },
|
||||
recycle_policy: data.recycle_policy,
|
||||
},
|
||||
token,
|
||||
);
|
||||
await queryClient.invalidateQueries({ queryKey: ["channels"] });
|
||||
return true; // success signal for closing dialog
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Import failed");
|
||||
return false;
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clearError = () => setError(null);
|
||||
|
||||
return { handleImport, isPending, error, clearError };
|
||||
}
|
||||
39
k-tv-frontend/hooks/use-quality.ts
Normal file
39
k-tv-frontend/hooks/use-quality.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
export const QUALITY_OPTIONS = [
|
||||
{ value: "direct", label: "Auto" },
|
||||
{ value: "40000000", label: "40 Mbps" },
|
||||
{ value: "8000000", label: "8 Mbps" },
|
||||
{ value: "2000000", label: "2 Mbps" },
|
||||
];
|
||||
|
||||
export function useQuality(channelId?: string, slotId?: string) {
|
||||
const queryClient = useQueryClient();
|
||||
const [quality, setQuality] = useState<string>(() => {
|
||||
try {
|
||||
return localStorage.getItem("quality") ?? "direct";
|
||||
} catch {
|
||||
return "direct";
|
||||
}
|
||||
});
|
||||
const [showQualityPicker, setShowQualityPicker] = useState(false);
|
||||
|
||||
const changeQuality = useCallback(
|
||||
(q: string) => {
|
||||
setQuality(q);
|
||||
try {
|
||||
localStorage.setItem("quality", q);
|
||||
} catch {}
|
||||
setShowQualityPicker(false);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["stream-url", channelId, slotId],
|
||||
});
|
||||
},
|
||||
[queryClient, channelId, slotId],
|
||||
);
|
||||
|
||||
return { quality, showQualityPicker, setShowQualityPicker, changeQuality, QUALITY_OPTIONS };
|
||||
}
|
||||
34
k-tv-frontend/hooks/use-regenerate-all.ts
Normal file
34
k-tv-frontend/hooks/use-regenerate-all.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "@/lib/api";
|
||||
import type { ChannelResponse } from "@/lib/types";
|
||||
|
||||
export function useRegenerateAllSchedules(
|
||||
channels: ChannelResponse[] | undefined,
|
||||
token: string | null,
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
const [isRegeneratingAll, setIsRegeneratingAll] = useState(false);
|
||||
|
||||
const handleRegenerateAll = async () => {
|
||||
if (!token || !channels || channels.length === 0) return;
|
||||
setIsRegeneratingAll(true);
|
||||
let failed = 0;
|
||||
for (const ch of channels) {
|
||||
try {
|
||||
await api.schedule.generate(ch.id, token);
|
||||
queryClient.invalidateQueries({ queryKey: ["schedule", ch.id] });
|
||||
} catch {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
setIsRegeneratingAll(false);
|
||||
if (failed === 0) toast.success(`All ${channels.length} schedules regenerated`);
|
||||
else toast.error(`${failed} schedule(s) failed to generate`);
|
||||
};
|
||||
|
||||
return { isRegeneratingAll, handleRegenerateAll };
|
||||
}
|
||||
26
k-tv-frontend/hooks/use-subtitles.ts
Normal file
26
k-tv-frontend/hooks/use-subtitles.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import type { SubtitleTrack } from "@/app/(main)/tv/components/video-player";
|
||||
|
||||
export function useSubtitlePicker(channelIdx: number, slotId?: string) {
|
||||
const [subtitleTracks, setSubtitleTracks] = useState<SubtitleTrack[]>([]);
|
||||
const [activeSubtitleTrack, setActiveSubtitleTrack] = useState(-1);
|
||||
const [showSubtitlePicker, setShowSubtitlePicker] = useState(false);
|
||||
|
||||
// Reset when channel or slot changes
|
||||
useEffect(() => {
|
||||
setSubtitleTracks([]);
|
||||
setActiveSubtitleTrack(-1);
|
||||
setShowSubtitlePicker(false);
|
||||
}, [channelIdx, slotId]);
|
||||
|
||||
return {
|
||||
subtitleTracks,
|
||||
setSubtitleTracks,
|
||||
activeSubtitleTrack,
|
||||
setActiveSubtitleTrack,
|
||||
showSubtitlePicker,
|
||||
setShowSubtitlePicker,
|
||||
};
|
||||
}
|
||||
78
k-tv-frontend/hooks/use-tv-keyboard.ts
Normal file
78
k-tv-frontend/hooks/use-tv-keyboard.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { SubtitleTrack } from "@/app/(main)/tv/components/video-player";
|
||||
|
||||
interface UseTvKeyboardOptions {
|
||||
nextChannel: () => void;
|
||||
prevChannel: () => void;
|
||||
toggleSchedule: () => void;
|
||||
toggleFullscreen: () => void;
|
||||
toggleMute: () => void;
|
||||
setShowStats: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
subtitleTracks: SubtitleTrack[];
|
||||
setActiveSubtitleTrack: React.Dispatch<React.SetStateAction<number>>;
|
||||
handleDigit: (digit: string) => void;
|
||||
}
|
||||
|
||||
export function useTvKeyboard(opts: UseTvKeyboardOptions) {
|
||||
const optsRef = useRef(opts);
|
||||
useEffect(() => {
|
||||
optsRef.current = opts;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (
|
||||
e.target instanceof HTMLInputElement ||
|
||||
e.target instanceof HTMLTextAreaElement
|
||||
)
|
||||
return;
|
||||
|
||||
const o = optsRef.current;
|
||||
switch (e.key) {
|
||||
case "ArrowUp":
|
||||
case "PageUp":
|
||||
e.preventDefault();
|
||||
o.nextChannel();
|
||||
break;
|
||||
case "ArrowDown":
|
||||
case "PageDown":
|
||||
e.preventDefault();
|
||||
o.prevChannel();
|
||||
break;
|
||||
case "s":
|
||||
case "S":
|
||||
o.setShowStats((v) => !v);
|
||||
break;
|
||||
case "g":
|
||||
case "G":
|
||||
o.toggleSchedule();
|
||||
break;
|
||||
case "f":
|
||||
case "F":
|
||||
o.toggleFullscreen();
|
||||
break;
|
||||
case "m":
|
||||
case "M":
|
||||
o.toggleMute();
|
||||
break;
|
||||
case "c":
|
||||
case "C":
|
||||
if (o.subtitleTracks.length > 0) {
|
||||
o.setActiveSubtitleTrack((prev) =>
|
||||
prev === -1 ? o.subtitleTracks[0].id : -1,
|
||||
);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
if (e.key >= "0" && e.key <= "9") {
|
||||
o.handleDigit(e.key);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKey);
|
||||
return () => window.removeEventListener("keydown", handleKey);
|
||||
}, []);
|
||||
}
|
||||
39
k-tv-frontend/hooks/use-volume.ts
Normal file
39
k-tv-frontend/hooks/use-volume.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef, RefObject } from "react";
|
||||
import { Volume1, Volume2, VolumeX } from "lucide-react";
|
||||
|
||||
export function useVolume(
|
||||
videoRef: RefObject<HTMLVideoElement | null>,
|
||||
isCasting: boolean,
|
||||
) {
|
||||
const [volume, setVolume] = useState(1);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [showSlider, setShowSlider] = useState(false);
|
||||
|
||||
// Sync to video element
|
||||
useEffect(() => {
|
||||
if (!videoRef.current) return;
|
||||
videoRef.current.muted = isMuted;
|
||||
videoRef.current.volume = volume;
|
||||
}, [isMuted, volume, videoRef]);
|
||||
|
||||
// Auto-mute when casting, restore on cast end
|
||||
const prevMutedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (isCasting) {
|
||||
prevMutedRef.current = isMuted;
|
||||
setIsMuted(true);
|
||||
} else {
|
||||
setIsMuted(prevMutedRef.current);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isCasting]);
|
||||
|
||||
const toggleMute = useCallback(() => setIsMuted((m) => !m), []);
|
||||
|
||||
const VolumeIcon =
|
||||
isMuted || volume === 0 ? VolumeX : volume < 0.5 ? Volume1 : Volume2;
|
||||
|
||||
return { volume, setVolume, isMuted, setIsMuted, toggleMute, VolumeIcon, showSlider, setShowSlider };
|
||||
}
|
||||
Reference in New Issue
Block a user