diff --git a/k-tv-backend/compose.yml b/k-tv-backend/compose.yml index ba6ca5b..616a61f 100644 --- a/k-tv-backend/compose.yml +++ b/k-tv-backend/compose.yml @@ -4,86 +4,43 @@ services: ports: - "3000:3000" environment: - - SESSION_SECRET=dev_secret_key_12345 - - DATABASE_URL=sqlite:///app/data/notes.db - - CORS_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5173 + # Server - HOST=0.0.0.0 - PORT=3000 + # Database — SQLite by default; swap for a postgres:// URL to use PostgreSQL + - DATABASE_URL=sqlite:///app/data/k-tv.db?mode=rwc + # CORS — set to your frontend origin(s), comma-separated + - CORS_ALLOWED_ORIGINS=http://localhost:3001 + # Auth — CHANGE BOTH before going to production + # Generate JWT_SECRET with: openssl rand -hex 32 + # Generate COOKIE_SECRET with: openssl rand -base64 64 + - JWT_SECRET=change-me-generate-with-openssl-rand-hex-32 + - COOKIE_SECRET=change-me-must-be-at-least-64-characters-long-for-production!! + - JWT_EXPIRY_HOURS=24 + - SECURE_COOKIE=false # set to true when serving over HTTPS + - PRODUCTION=false + # Database pool - DB_MAX_CONNECTIONS=5 - DB_MIN_CONNECTIONS=1 - - SECURE_COOKIE=true + # Jellyfin media provider — all three are required to enable schedule generation + - JELLYFIN_BASE_URL=http://jellyfin:8096 + - JELLYFIN_API_KEY=your-jellyfin-api-key-here + - JELLYFIN_USER_ID=your-jellyfin-user-id-here volumes: - - ./data:/app/data + - ./data:/app/data # SQLite database + any other persistent files + restart: unless-stopped - # nats: - # image: nats:alpine + # ── Optional: PostgreSQL ──────────────────────────────────────────────────── + # Uncomment and set DATABASE_URL=postgres://ktv:password@db:5432/ktv above. + # + # db: + # image: postgres:16-alpine + # environment: + # POSTGRES_USER: ktv + # POSTGRES_PASSWORD: password + # POSTGRES_DB: ktv # ports: - # - "4222:4222" - # - "6222:6222" - # - "8222:8222" + # - "5432:5432" + # volumes: + # - db_data:/var/lib/postgresql/data # restart: unless-stopped - - db: - image: postgres:15-alpine - environment: - POSTGRES_USER: user - POSTGRES_PASSWORD: password - POSTGRES_DB: k_template_db - ports: - - "5439:5432" - volumes: - - db_data:/var/lib/postgresql/data - - zitadel-db: - image: postgres:16-alpine - container_name: zitadel_db - environment: - POSTGRES_USER: zitadel - POSTGRES_PASSWORD: zitadel_password - POSTGRES_DB: zitadel - healthcheck: - test: ["CMD-SHELL", "pg_isready -U zitadel -d zitadel"] - interval: 10s - timeout: 5s - retries: 5 - volumes: - - zitadel_db_data:/var/lib/postgresql/data - - zitadel: - image: ghcr.io/zitadel/zitadel:latest - container_name: zitadel_local - depends_on: - zitadel-db: - condition: service_healthy - ports: - - "8086:8080" - # USE start-from-init (Fixes the "relation does not exist" bug) - command: 'start-from-init --masterkey "MasterkeyNeedsToBeExactly32Bytes"' - environment: - # Database Connection - ZITADEL_DATABASE_POSTGRES_HOST: zitadel-db - ZITADEL_DATABASE_POSTGRES_PORT: 5432 - ZITADEL_DATABASE_POSTGRES_DATABASE: zitadel - - # APPLICATION USER (Zitadel uses this to run) - ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel - ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: zitadel_password - ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable - - # ADMIN USER (Zitadel uses this to create tables/migrations) - # We use 'zitadel' because it is the owner of the DB in your postgres container. - ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: zitadel - ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: zitadel_password - ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable - - # General Config - ZITADEL_EXTERNALDOMAIN: localhost - ZITADEL_EXTERNALPORT: 8086 - ZITADEL_EXTERNALSECURE: "false" - ZITADEL_TLS_ENABLED: "false" - - ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED: "false" - -volumes: - db_data: - zitadel_db_data: \ No newline at end of file diff --git a/k-tv-frontend/app/(auth)/login/page.tsx b/k-tv-frontend/app/(auth)/login/page.tsx index 2c9b133..e069227 100644 --- a/k-tv-frontend/app/(auth)/login/page.tsx +++ b/k-tv-frontend/app/(auth)/login/page.tsx @@ -3,11 +3,13 @@ import Link from "next/link"; import { useState } from "react"; import { useLogin } from "@/hooks/use-auth"; +import { useConfig } from "@/hooks/use-channels"; export default function LoginPage() { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const { mutate: login, isPending, error } = useLogin(); + const { data: config } = useConfig(); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); @@ -63,12 +65,14 @@ export default function LoginPage() { -

- No account?{" "} - - Create one - -

+ {config?.allow_registration !== false && ( +

+ No account?{" "} + + Create one + +

+ )} ); } diff --git a/k-tv-frontend/app/(auth)/register/page.tsx b/k-tv-frontend/app/(auth)/register/page.tsx index ccd4f68..63abc71 100644 --- a/k-tv-frontend/app/(auth)/register/page.tsx +++ b/k-tv-frontend/app/(auth)/register/page.tsx @@ -3,11 +3,27 @@ import Link from "next/link"; import { useState } from "react"; import { useRegister } from "@/hooks/use-auth"; +import { useConfig } from "@/hooks/use-channels"; export default function RegisterPage() { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const { mutate: register, isPending, error } = useRegister(); + const { data: config } = useConfig(); + + if (config && !config.allow_registration) { + return ( +
+

Registration disabled

+

+ The administrator has disabled new account registration. +

+ + Sign in instead + +
+ ); + } const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); 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 7231e91..e9c6e2e 100644 --- a/k-tv-frontend/app/(main)/dashboard/components/channel-card.tsx +++ b/k-tv-frontend/app/(main)/dashboard/components/channel-card.tsx @@ -1,28 +1,64 @@ +"use client"; + import Link from "next/link"; -import { Pencil, Trash2, RefreshCw, Tv2, CalendarDays, Download } from "lucide-react"; +import { Pencil, Trash2, RefreshCw, Tv2, CalendarDays, Download, ChevronUp, ChevronDown } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { useActiveSchedule } from "@/hooks/use-channels"; import type { ChannelResponse } from "@/lib/types"; interface ChannelCardProps { channel: ChannelResponse; isGenerating: boolean; + isFirst: boolean; + isLast: boolean; onEdit: () => void; onDelete: () => void; onGenerateSchedule: () => void; onViewSchedule: () => void; onExport: () => void; + onMoveUp: () => void; + onMoveDown: () => void; +} + +function useScheduleStatus(channelId: string) { + const { data: schedule } = useActiveSchedule(channelId); + 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); + + if (hoursLeft < 0) { + return { status: "expired" as const, label: "Schedule expired" }; + } + if (hoursLeft < 6) { + const h = Math.ceil(hoursLeft); + return { status: "expiring" as const, label: `Expires in ${h}h` }; + } + const fmt = expiresAt.toLocaleDateString(undefined, { weekday: "short", hour: "2-digit", minute: "2-digit", hour12: false }); + return { status: "ok" as const, label: `Until ${fmt}` }; } export function ChannelCard({ channel, isGenerating, + isFirst, + isLast, onEdit, onDelete, onGenerateSchedule, onViewSchedule, onExport, + onMoveUp, + onMoveDown, }: ChannelCardProps) { const blockCount = channel.schedule_config.blocks.length; + const { status, label } = useScheduleStatus(channel.id); + + const scheduleColor = + status === "expired" ? "text-red-400" : + status === "expiring" ? "text-amber-400" : + status === "ok" ? "text-zinc-500" : + "text-zinc-600"; return (
@@ -40,6 +76,26 @@ export function ChannelCard({
+ {/* Order controls */} +
+ + +
+
{/* Actions */} @@ -85,7 +142,7 @@ export function ChannelCard({ size="sm" onClick={onGenerateSchedule} disabled={isGenerating} - className="flex-1" + className={`flex-1 ${status === "expired" ? "border border-red-800/50 bg-red-950/30 text-red-300 hover:bg-red-900/40" : ""}`} > {isGenerating ? "Generating…" : "Generate schedule"} diff --git a/k-tv-frontend/app/(main)/dashboard/components/schedule-sheet.tsx b/k-tv-frontend/app/(main)/dashboard/components/schedule-sheet.tsx index 8f1ccde..37bd604 100644 --- a/k-tv-frontend/app/(main)/dashboard/components/schedule-sheet.tsx +++ b/k-tv-frontend/app/(main)/dashboard/components/schedule-sheet.tsx @@ -1,5 +1,6 @@ "use client"; +import { useEffect, useState } from "react"; import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"; import { useActiveSchedule } from "@/hooks/use-channels"; import type { ChannelResponse, ScheduledSlotResponse } from "@/lib/types"; @@ -21,11 +22,13 @@ interface DayRowProps { dayStart: Date; slots: ScheduledSlotResponse[]; colorMap: Map; + now: Date; } -function DayRow({ label, dayStart, slots, colorMap }: DayRowProps) { +function DayRow({ label, dayStart, slots, colorMap, now }: DayRowProps) { const DAY_MS = 24 * 60 * 60 * 1000; const dayEnd = new Date(dayStart.getTime() + DAY_MS); + const nowPct = ((now.getTime() - dayStart.getTime()) / DAY_MS) * 100; // Only include slots that overlap this day const daySlots = slots.filter((s) => { @@ -46,6 +49,15 @@ function DayRow({ label, dayStart, slots, colorMap }: DayRowProps) { style={{ left: `${(i / 24) * 100}%` }} /> ))} + {/* Current time marker */} + {nowPct >= 0 && nowPct <= 100 && ( +
+
+
+ )} {daySlots.map((slot) => { const slotStart = new Date(slot.start_at); const slotEnd = new Date(slot.end_at); @@ -102,6 +114,13 @@ interface ScheduleSheetProps { export function ScheduleSheet({ channel, open, onOpenChange }: ScheduleSheetProps) { const { data: schedule, isLoading, error } = useActiveSchedule(channel?.id ?? ""); + // Live clock for the current-time marker — updates every minute + const [now, setNow] = useState(() => new Date()); + useEffect(() => { + const id = setInterval(() => setNow(new Date()), 60_000); + return () => clearInterval(id); + }, []); + const colorMap = schedule ? makeColorMap(schedule.slots) : new Map(); // Build day rows from valid_from to valid_until @@ -172,6 +191,7 @@ export function ScheduleSheet({ channel, open, onOpenChange }: ScheduleSheetProp dayStart={dayStart} slots={schedule.slots} colorMap={colorMap} + now={now} /> ))}
diff --git a/k-tv-frontend/app/(main)/dashboard/page.tsx b/k-tv-frontend/app/(main)/dashboard/page.tsx index 437b1a7..bd08af4 100644 --- a/k-tv-frontend/app/(main)/dashboard/page.tsx +++ b/k-tv-frontend/app/(main)/dashboard/page.tsx @@ -1,7 +1,7 @@ "use client"; -import { useState } from "react"; -import { Plus, Upload } from "lucide-react"; +import { useState, useEffect } from "react"; +import { Plus, Upload, RefreshCw } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useChannels, @@ -12,6 +12,7 @@ import { } from "@/hooks/use-channels"; import { useAuthContext } from "@/context/auth-context"; import { api } from "@/lib/api"; +import { toast } from "sonner"; import { useQueryClient } from "@tanstack/react-query"; import { ChannelCard } from "./components/channel-card"; import { CreateChannelDialog } from "./components/create-channel-dialog"; @@ -31,6 +32,69 @@ export default function DashboardPage() { const deleteChannel = useDeleteChannel(); const generateSchedule = useGenerateSchedule(); + // Channel ordering — persisted to localStorage + const [channelOrder, setChannelOrder] = useState([]); + 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 {} + }; + + // Sort channels by stored order; new channels appear at the end + 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); + }; + + // Regenerate all channels + 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`); + }; + const [createOpen, setCreateOpen] = useState(false); const [importOpen, setImportOpen] = useState(false); const [importPending, setImportPending] = useState(false); @@ -124,6 +188,18 @@ export default function DashboardPage() {

+ {channels && channels.length > 0 && ( + + )}
)} - {channels && channels.length === 0 && ( + {!isLoading && channels && channels.length === 0 && (

No channels yet

)} - {channels && channels.length > 0 && ( + {sortedChannels.length > 0 && (
- {channels.map((channel) => ( + {sortedChannels.map((channel, idx) => ( setEditChannel(channel)} onDelete={() => setDeleteTarget(channel)} onGenerateSchedule={() => generateSchedule.mutate(channel.id)} onViewSchedule={() => setScheduleChannel(channel)} onExport={() => handleExport(channel)} + onMoveUp={() => handleMoveUp(channel.id)} + onMoveDown={() => handleMoveDown(channel.id)} /> ))}
diff --git a/k-tv-frontend/app/(main)/docs/page.tsx b/k-tv-frontend/app/(main)/docs/page.tsx index 5dde28c..0dccd45 100644 --- a/k-tv-frontend/app/(main)/docs/page.tsx +++ b/k-tv-frontend/app/(main)/docs/page.tsx @@ -758,7 +758,10 @@ Output only valid JSON matching this structure: rows={[ ["Arrow Up / Page Up", "Next channel"], ["Arrow Down / Page Down", "Previous channel"], + ["0–9", "Type a channel number and jump to it after 1.5 s (e.g. press 1 then 4 → channel 14)"], ["G", "Toggle the program guide"], + ["M", "Mute / unmute"], + ["F", "Toggle fullscreen"], ]} /> diff --git a/k-tv-frontend/app/(main)/tv/page.tsx b/k-tv-frontend/app/(main)/tv/page.tsx index b69c6d0..82d8845 100644 --- a/k-tv-frontend/app/(main)/tv/page.tsx +++ b/k-tv-frontend/app/(main)/tv/page.tsx @@ -11,6 +11,7 @@ import { NoSignal, } from "./components"; import type { SubtitleTrack } from "./components/video-player"; +import { Maximize2, Minimize2, Volume2, VolumeX } from "lucide-react"; import { useAuthContext } from "@/context/auth-context"; import { useChannels, useCurrentBroadcast, useEpg } from "@/hooks/use-channels"; import { @@ -59,6 +60,36 @@ export default function TvPage() { const [subtitleTracks, setSubtitleTracks] = useState([]); const [activeSubtitleTrack, setActiveSubtitleTrack] = useState(-1); const [showSubtitlePicker, setShowSubtitlePicker] = useState(false); + + // Fullscreen + const [isFullscreen, setIsFullscreen] = useState(false); + useEffect(() => { + const handler = () => setIsFullscreen(!!document.fullscreenElement); + document.addEventListener("fullscreenchange", handler); + return () => document.removeEventListener("fullscreenchange", handler); + }, []); + const toggleFullscreen = useCallback(() => { + if (!document.fullscreenElement) { + document.documentElement.requestFullscreen().catch(() => {}); + } else { + document.exitFullscreen().catch(() => {}); + } + }, []); + + // Volume / mute + const [isMuted, setIsMuted] = useState(false); + useEffect(() => { + if (videoRef.current) videoRef.current.muted = isMuted; + }, [isMuted]); + const toggleMute = useCallback(() => setIsMuted((m) => !m), []); + + // Channel jump by number (e.g. press "1","4" → jump to ch 14 after 1.5 s) + const [channelInput, setChannelInput] = useState(""); + const channelInputTimer = useRef | null>(null); + + // Touch-swipe state + const touchStartY = useRef(null); + const queryClient = useQueryClient(); // Tick for live progress calculation (every 30 s is fine for the progress bar) @@ -169,12 +200,59 @@ export default function TvPage() { case "G": toggleSchedule(); break; + case "f": + case "F": + toggleFullscreen(); + break; + case "m": + case "M": + toggleMute(); + break; + default: { + if (e.key >= "0" && e.key <= "9") { + setChannelInput((prev) => { + const next = prev + e.key; + if (channelInputTimer.current) clearTimeout(channelInputTimer.current); + channelInputTimer.current = setTimeout(() => { + const num = parseInt(next, 10); + if (num >= 1 && num <= Math.max(channelCount, 1)) { + setChannelIdx(num - 1); + resetIdle(); + } + setChannelInput(""); + }, 1500); + return next; + }); + } + } } }; window.addEventListener("keydown", handleKey); - return () => window.removeEventListener("keydown", handleKey); - }, [nextChannel, prevChannel, toggleSchedule]); + return () => { + window.removeEventListener("keydown", handleKey); + if (channelInputTimer.current) clearTimeout(channelInputTimer.current); + }; + }, [nextChannel, prevChannel, toggleSchedule, toggleFullscreen, toggleMute, channelCount, resetIdle]); + + // ------------------------------------------------------------------ + // Touch swipe (swipe up = next channel, swipe down = prev channel) + // ------------------------------------------------------------------ + + const handleTouchStart = useCallback((e: React.TouchEvent) => { + touchStartY.current = e.touches[0].clientY; + resetIdle(); + }, [resetIdle]); + + const handleTouchEnd = useCallback((e: React.TouchEvent) => { + if (touchStartY.current === null) return; + const dy = touchStartY.current - e.changedTouches[0].clientY; + touchStartY.current = null; + if (Math.abs(dy) > 60) { + if (dy > 0) nextChannel(); + else prevChannel(); + } + }, [nextChannel, prevChannel]); // ------------------------------------------------------------------ // Stream error recovery @@ -258,6 +336,8 @@ export default function TvPage() { style={{ cursor: showOverlays ? "default" : "none" }} onMouseMove={resetIdle} onClick={resetIdle} + onTouchStart={handleTouchStart} + onTouchEnd={handleTouchEnd} > {/* ── Base layer ─────────────────────────────────────────────── */}
{renderBase()}
@@ -315,6 +395,26 @@ export default function TvPage() { )} + + + +