From 2aed939609d20f4a884b93eae853eab6629dce04 Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Tue, 7 Jul 2026 19:08:59 +0530 Subject: [PATCH 1/2] web: remove clock display from top navigation The clock/time display in the top navigation bar is unnecessary for a web application. Users already have access to the system clock via their OS taskbar, browser tab, or device status bar, making an in-app clock redundant. Changes: - Remove clock state and interval timer from TopNav component - Remove .top-clock CSS rules and responsive media query references --- web/app/globals.css | 13 ++----------- web/components/shell/TopNav.tsx | 15 +-------------- 2 files changed, 3 insertions(+), 25 deletions(-) diff --git a/web/app/globals.css b/web/app/globals.css index ba4fd6d09..17104313e 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -212,12 +212,6 @@ button { transform: scale(1.045); } -.top-clock { - font-size: clamp(30px, 2.4vw, 46px); - font-weight: 350; - color: rgba(255, 255, 255, 0.86); -} - .content { min-height: 100vh; position: relative; @@ -1295,8 +1289,7 @@ input:focus { grid-template-columns: 76px 1fr auto; } - .profile-name, - .top-clock { + .profile-name { display: none; } @@ -3318,8 +3311,7 @@ select:focus { padding-inline: 14px; } - .profile-name-text, - .top-clock { + .profile-name-text { display: none; } @@ -5004,7 +4996,6 @@ button, .sidebar::after, .profile-name-text, - .top-clock, .nav-item span { display: none !important; } diff --git a/web/components/shell/TopNav.tsx b/web/components/shell/TopNav.tsx index c1c1472c1..8aef08835 100644 --- a/web/components/shell/TopNav.tsx +++ b/web/components/shell/TopNav.tsx @@ -15,7 +15,6 @@ const nav = [ export function TopNav() { const { section, setSection, switchProfile, activeProfile, avatarImages, settings, closeDetails, selected } = useApp(); - const [clock, setClock] = useState(""); const [scrolled, setScrolled] = useState(false); useEffect(() => { @@ -25,18 +24,6 @@ export function TopNav() { return () => window.removeEventListener("scroll", onScroll); }, []); - useEffect(() => { - const updateClock = () => { - setClock(new Intl.DateTimeFormat([], { - hour: "2-digit", - minute: "2-digit", - hour12: settings.clockFormat === "12h" - }).format(new Date())); - }; - updateClock(); - const timer = window.setInterval(updateClock, 30_000); - return () => window.clearInterval(timer); - }, [settings.clockFormat]); return ( ); From 35f466a30b5f22a7c0104b268e32a2d399153c6e Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Thu, 9 Jul 2026 15:34:57 +0530 Subject: [PATCH 2/2] Reformat settings screen code --- web/components/settings/SettingsScreen.tsx | 1421 ++++++++++++++++---- 1 file changed, 1163 insertions(+), 258 deletions(-) diff --git a/web/components/settings/SettingsScreen.tsx b/web/components/settings/SettingsScreen.tsx index a4939d376..bb77e872e 100644 --- a/web/components/settings/SettingsScreen.tsx +++ b/web/components/settings/SettingsScreen.tsx @@ -1,14 +1,46 @@ "use client"; import { - ArrowDown, ArrowUp, Captions, Check, ChevronDown, Cloud, Eye, EyeOff, Languages, LayoutGrid, ListVideo, LogOut, - Network, Play, Plus, RefreshCw, RotateCcw, Server, Sparkles, Subtitles, Trash2, Tv, User, UserCircle + ArrowDown, + ArrowUp, + Captions, + Check, + ChevronDown, + Cloud, + Eye, + EyeOff, + Languages, + LayoutGrid, + ListVideo, + LogOut, + Network, + Play, + Plus, + RefreshCw, + RotateCcw, + Server, + Sparkles, + Subtitles, + Trash2, + Tv, + User, + UserCircle, } from "lucide-react"; import { Component, useState, type ReactNode } from "react"; import { defaultCatalogs, mergeCatalogs } from "@/lib/catalogs"; -import { hasNetlifyBackendConfig, hasSupabaseConfig, hasTraktConfig } from "@/lib/config"; +import { + hasNetlifyBackendConfig, + hasSupabaseConfig, + hasTraktConfig, +} from "@/lib/config"; import { defaultSettings, useApp } from "@/lib/store"; -import type { AppSettings, CatalogConfig, HomeServerConfig, IptvPlaylistEntry, QualityFilterConfig } from "@/lib/types"; +import type { + AppSettings, + CatalogConfig, + HomeServerConfig, + IptvPlaylistEntry, + QualityFilterConfig, +} from "@/lib/types"; const settingsKey = "arvio.web.settings"; @@ -24,7 +56,7 @@ const SECTIONS = [ { id: "tv", label: "TV (IPTV)", icon: Tv }, { id: "homeserver", label: "Home Server", icon: Server }, { id: "catalogs", label: "Catalogs", icon: ListVideo }, - { id: "addons", label: "Addons", icon: Sparkles } + { id: "addons", label: "Addons", icon: Sparkles }, ] as const; type SectionId = (typeof SECTIONS)[number]["id"]; @@ -37,15 +69,17 @@ const SUBTITLE_COLOR_HEX: Record = { Red: "#f44336", Orange: "#ff9800", Blue: "#2196f3", - Violet: "#8b5cf6" + Violet: "#8b5cf6", }; -const QUALITY_PRESET_LABELS: Array<[AppSettings["qualityFilterPreset"], string]> = [ +const QUALITY_PRESET_LABELS: Array< + [AppSettings["qualityFilterPreset"], string] +> = [ ["off", "Off"], ["1080p-plus", "1080p and above"], ["1080p-only", "1080p only"], ["720p-plus", "720p and above"], - ["custom", "Custom"] + ["custom", "Custom"], ]; const CONTENT_LANGUAGE_OPTIONS: Array<[string, string]> = [ @@ -66,7 +100,7 @@ const CONTENT_LANGUAGE_OPTIONS: Array<[string, string]> = [ ["no-NO", "Norwegian"], ["ja-JP", "Japanese"], ["ko-KR", "Korean"], - ["zh-CN", "Chinese (Simplified)"] + ["zh-CN", "Chinese (Simplified)"], ]; const TRACK_LANGUAGE_OPTIONS: Array<[string, string]> = [ @@ -86,41 +120,54 @@ const TRACK_LANGUAGE_OPTIONS: Array<[string, string]> = [ ["no", "Norwegian"], ["ja", "Japanese"], ["ko", "Korean"], - ["zh", "Chinese"] + ["zh", "Chinese"], ]; -function optionsWithCurrent(options: Array<[string, string]>, value: string, fallbackLabel = "Current"): Array<[string, string]> { +function optionsWithCurrent( + options: Array<[string, string]>, + value: string, + fallbackLabel = "Current", +): Array<[string, string]> { if (!value || options.some(([option]) => option === value)) return options; return [[value, `${fallbackLabel}: ${value}`], ...options]; } -function qualityPresetFilters(preset: AppSettings["qualityFilterPreset"]): QualityFilterConfig[] { - const poorSources = "cam|hdcam|camrip|ts|hdts|telesync|tc|hdtc|telecine|screener|scr|dvdscr|r5"; +function qualityPresetFilters( + preset: AppSettings["qualityFilterPreset"], +): QualityFilterConfig[] { + const poorSources = + "cam|hdcam|camrip|ts|hdts|telesync|tc|hdtc|telecine|screener|scr|dvdscr|r5"; switch (preset) { case "1080p-plus": - return [{ - id: "preset-quality-1080-plus", - deviceName: "Preset: 1080p+", - regexPattern: `(?:360|480|576|720)p|${poorSources}`, - enabled: true, - createdAt: Date.now() - }]; + return [ + { + id: "preset-quality-1080-plus", + deviceName: "Preset: 1080p+", + regexPattern: `(?:360|480|576|720)p|${poorSources}`, + enabled: true, + createdAt: Date.now(), + }, + ]; case "1080p-only": - return [{ - id: "preset-quality-1080-only", - deviceName: "Preset: 1080p only", - regexPattern: `(?:2160|4k|uhd)|(?:360|480|576|720)p|${poorSources}`, - enabled: true, - createdAt: Date.now() - }]; + return [ + { + id: "preset-quality-1080-only", + deviceName: "Preset: 1080p only", + regexPattern: `(?:2160|4k|uhd)|(?:360|480|576|720)p|${poorSources}`, + enabled: true, + createdAt: Date.now(), + }, + ]; case "720p-plus": - return [{ - id: "preset-quality-720-plus", - deviceName: "Preset: 720p+", - regexPattern: `(?:360|480|576)p|${poorSources}`, - enabled: true, - createdAt: Date.now() - }]; + return [ + { + id: "preset-quality-720-plus", + deviceName: "Preset: 720p+", + regexPattern: `(?:360|480|576)p|${poorSources}`, + enabled: true, + createdAt: Date.now(), + }, + ]; default: return []; } @@ -170,7 +217,10 @@ class SettingsSectionBoundary extends Component< static getDerivedStateFromError(error: unknown) { return { hasError: true, - message: error instanceof Error ? error.message : "This settings section could not be opened." + message: + error instanceof Error + ? error.message + : "This settings section could not be opened.", }; } @@ -190,7 +240,11 @@ class SettingsSectionBoundary extends Component<

Settings section unavailable

{this.state.message}

-
@@ -200,16 +254,35 @@ class SettingsSectionBoundary extends Component< /* ---------- reusable rows ---------- */ -function Row({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) { +function Row({ + label, + hint, + children, +}: { + label: string; + hint?: string; + children: ReactNode; +}) { return (
- {label}{hint && {hint}} + + {label} + {hint && {hint}} + {children}
); } -function Toggle({ value, onChange, disabled }: { value: boolean; onChange: (v: boolean) => void; disabled?: boolean }) { +function Toggle({ + value, + onChange, + disabled, +}: { + value: boolean; + onChange: (v: boolean) => void; + disabled?: boolean; +}) { return ( {open && ( -
setOpen(false)}> -
event.stopPropagation()}> +
setOpen(false)} + > +
event.stopPropagation()} + >
Choose option - +
{options.map(([option, label]) => ( @@ -275,11 +379,16 @@ function SectionBody({ section }: { section: SectionId }) { const set = (patch: Partial) => app.updateSettings(patch); const [qualityFilterName, setQualityFilterName] = useState(""); const [qualityFilterPattern, setQualityFilterPattern] = useState(""); - const setSubtitleColor = (name: AppSettings["subtitleColorName"]) => set({ subtitleColorName: name, subtitleColor: SUBTITLE_COLOR_HEX[name] }); - const setQualityPreset = (preset: AppSettings["qualityFilterPreset"]) => set({ - qualityFilterPreset: preset, - qualityFilters: preset === "custom" ? settings.qualityFilters : qualityPresetFilters(preset) - }); + const setSubtitleColor = (name: AppSettings["subtitleColorName"]) => + set({ subtitleColorName: name, subtitleColor: SUBTITLE_COLOR_HEX[name] }); + const setQualityPreset = (preset: AppSettings["qualityFilterPreset"]) => + set({ + qualityFilterPreset: preset, + qualityFilters: + preset === "custom" + ? settings.qualityFilters + : qualityPresetFilters(preset), + }); const addQualityFilter = () => { const pattern = qualityFilterPattern.trim(); if (!pattern) { @@ -288,13 +397,16 @@ function SectionBody({ section }: { section: SectionId }) { } set({ qualityFilterPreset: "custom", - qualityFilters: [{ - id: crypto.randomUUID(), - deviceName: qualityFilterName.trim() || "Custom quality filter", - regexPattern: pattern, - enabled: true, - createdAt: Date.now() - }, ...safeArray(settings.qualityFilters)] + qualityFilters: [ + { + id: crypto.randomUUID(), + deviceName: qualityFilterName.trim() || "Custom quality filter", + regexPattern: pattern, + enabled: true, + createdAt: Date.now(), + }, + ...safeArray(settings.qualityFilters), + ], }); setQualityFilterName(""); setQualityFilterPattern(""); @@ -306,53 +418,212 @@ function SectionBody({ section }: { section: SectionId }) { case "profiles": return ( - set({ skipProfileSelection: v })} /> - + + set({ skipProfileSelection: v })} + /> + + ); case "playback": return ( - - set({ defaultPlayer: v })} + options={[ + ["browser", "ARVIO player"], + ["vlc", "VLC"], + ["infuse", "Infuse"], + ]} + /> + + + set({ autoPlayNext: v })} + /> + + + set({ autoPlaySingleSource: v })} + /> - set({ autoPlayNext: v })} /> - set({ autoPlaySingleSource: v })} /> - set({ trailerDelaySeconds: Number(e.target.value) })} /> - set({ trailerInCards: v })} /> - - set({ volumeBoostDb: Number(v) })} - options={["0", "3", "6", "9", "12", "15"].map((value) => [value, `${value} dB`])} /> - - set({ includeSpecials: v })} /> + + set({ trailerDelaySeconds: Number(e.target.value) }) + } + /> + + + set({ trailerInCards: v })} + /> + + + set({ volumeBoostDb: Number(v) })} + options={["0", "3", "6", "9", "12", "15"].map((value) => [ + value, + `${value} dB`, + ])} + /> + + + set({ includeSpecials: v })} + /> + -
- setQualityFilterName(e.target.value)} placeholder="Filter name" /> - setQualityFilterPattern(e.target.value)} placeholder="Regex to hide matching sources" /> - + setQualityFilterName(e.target.value)} + placeholder="Filter name" + /> + setQualityFilterPattern(e.target.value)} + placeholder="Regex to hide matching sources" + /> +
{safeArray(settings.qualityFilters).map((filter) => ( -
- - set({ qualityFilterPreset: "custom", qualityFilters: settings.qualityFilters.map((item) => item.id === filter.id ? { ...item, deviceName: e.target.value } : item) })} /> - set({ qualityFilterPreset: "custom", qualityFilters: settings.qualityFilters.map((item) => item.id === filter.id ? { ...item, regexPattern: e.target.value } : item) })} /> - + + set({ + qualityFilterPreset: "custom", + qualityFilters: settings.qualityFilters.map((item) => + item.id === filter.id + ? { ...item, deviceName: e.target.value } + : item, + ), + }) + } + /> + + set({ + qualityFilterPreset: "custom", + qualityFilters: settings.qualityFilters.map((item) => + item.id === filter.id + ? { ...item, regexPattern: e.target.value } + : item, + ), + }) + } + /> +
))}
@@ -362,20 +633,48 @@ function SectionBody({ section }: { section: SectionId }) { return ( - set({ language: v })} + options={optionsWithCurrent( + CONTENT_LANGUAGE_OPTIONS, + settings.language, + "Custom", + )} + /> - set({ defaultSubtitle: v })} + options={optionsWithCurrent( + TRACK_LANGUAGE_OPTIONS, + settings.defaultSubtitle || "", + "Custom", + )} + /> - set({ secondarySubtitle: v })} + options={optionsWithCurrent( + TRACK_LANGUAGE_OPTIONS, + settings.secondarySubtitle || "", + "Custom", + )} + /> - set({ audioLanguage: v })} + options={optionsWithCurrent( + TRACK_LANGUAGE_OPTIONS, + settings.audioLanguage || "", + "Custom", + )} + /> ); @@ -383,57 +682,190 @@ function SectionBody({ section }: { section: SectionId }) { return ( - set({ subtitleSize: Number(e.target.value) })} /> + + set({ subtitleSize: Number(e.target.value) })} + /> + - [name, name])} + /> + + + + set({ + subtitleColor: e.target.value, + subtitleColorName: "White", + }) + } + /> + + + + set({ subtitleOffsetMs: Number(e.target.value) }) + } + /> - set({ subtitleColor: e.target.value, subtitleColorName: "White" })} /> - set({ subtitleOffsetMs: Number(e.target.value) })} /> - set({ subtitleOffset: v })} + options={[ + ["bottom", "Bottom"], + ["low", "Low"], + ["medium", "Medium"], + ["high", "High"], + ]} + /> - set({ subtitleStyle: v })} + options={[ + ["outline", "Bold / outline"], + ["shadow", "Normal / shadow"], + ["background", "Background"], + ["raised", "Raised"], + ]} + /> + + + set({ subtitleStylized: v })} + /> + + + set({ filterSubtitlesByLanguage: v })} + /> + + + set({ removeHearingImpaired: v })} + /> - set({ subtitleStylized: v })} /> - set({ filterSubtitlesByLanguage: v })} /> - set({ removeHearingImpaired: v })} /> ); case "ai": return ( - set({ aiSubtitlesEnabled: v })} /> + + set({ aiSubtitlesEnabled: v })} + /> + - set({ aiSubtitleModel: v })} + options={[ + ["off", "Off"], + ["groq", "Groq"], + ["gemini", "Gemini"], + ]} + /> + + + set({ aiAutoSelect: v })} + /> + + + set({ aiApiKey: e.target.value })} + placeholder="••••••••" + /> - set({ aiAutoSelect: v })} /> - set({ aiApiKey: e.target.value })} placeholder="••••••••" /> ); case "appearance": return ( - set({ cardLayoutMode: v })} + options={[ + ["landscape", "Landscape"], + ["poster", "Poster"], + ]} + /> - set({ deviceModeOverride: v })} + options={[ + ["auto", "Auto"], + ["tv", "TV"], + ["tablet", "Tablet"], + ["phone", "Phone"], + ["desktop", "Desktop / browser"], + ]} + /> - set({ oledBlack: v })} /> - - set({ accentColor: v })} - options={[["arctic", "Arctic"], ["gold", "Gold"], ["green", "Green"], ["blue", "Blue"], ["purple", "Purple"]]} /> + set({ dnsProvider: v })} - options={[["system", "System"], ["cloudflare", "Cloudflare"], ["google", "Google"], ["adguard", "AdGuard"], ["quad9", "Quad9"]]} /> + set({ customUserAgent: e.target.value })} + placeholder="Default" + /> + + + set({ torrServerBaseUrl: e.target.value })} + placeholder="http://127.0.0.1:8090" + /> - set({ showLoadingStats: v })} /> - set({ customUserAgent: e.target.value })} placeholder="Default" /> - set({ torrServerBaseUrl: e.target.value })} placeholder="http://127.0.0.1:8090" /> ); case "tv": @@ -467,7 +928,9 @@ function safeArray(value: T[] | null | undefined): T[] { } function fallbackId(prefix: string, index: number, preferred?: string | null) { - return preferred && String(preferred).trim() ? String(preferred) : `${prefix}-${index}`; + return preferred && String(preferred).trim() + ? String(preferred) + : `${prefix}-${index}`; } function Panel({ title, children }: { title: string; children: ReactNode }) { @@ -482,11 +945,23 @@ function Panel({ title, children }: { title: string; children: ReactNode }) { /* ---------- Accounts ---------- */ function AccountsSection() { - const { auth, traktConnected, deviceCode, signIn, signOut, beginTrakt, pollTrakt, disconnectTrakt, refreshData } = useApp(); + const { + auth, + traktConnected, + deviceCode, + signIn, + signOut, + beginTrakt, + pollTrakt, + disconnectTrakt, + refreshData, + } = useApp(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [loginError, setLoginError] = useState(null); - const [loginBusy, setLoginBusy] = useState<"sign-in" | "sign-up" | null>(null); + const [loginBusy, setLoginBusy] = useState<"sign-in" | "sign-up" | null>( + null, + ); const [traktError, setTraktError] = useState(null); const [traktBusy, setTraktBusy] = useState<"start" | "poll" | null>(null); const [syncBusy, setSyncBusy] = useState(false); @@ -504,7 +979,9 @@ function AccountsSection() { setEmail(""); setPassword(""); } catch (error) { - setLoginError(error instanceof Error ? error.message : "Authentication failed."); + setLoginError( + error instanceof Error ? error.message : "Authentication failed.", + ); } finally { setLoginBusy(null); } @@ -516,7 +993,11 @@ function AccountsSection() { try { await beginTrakt(); } catch (error) { - setTraktError(error instanceof Error ? error.message : "Could not start Trakt device link."); + setTraktError( + error instanceof Error + ? error.message + : "Could not start Trakt device link.", + ); } finally { setTraktBusy(null); } @@ -528,7 +1009,11 @@ function AccountsSection() { try { await pollTrakt(); } catch (error) { - setTraktError(error instanceof Error ? error.message : "Trakt has not approved this device yet."); + setTraktError( + error instanceof Error + ? error.message + : "Trakt has not approved this device yet.", + ); } finally { setTraktBusy(null); } @@ -546,22 +1031,59 @@ function AccountsSection() { return ( <> - {!cloudConfigured &&

ARVIO Cloud backend env is missing. Add backend values in web/.env.local.

} + {!cloudConfigured && ( +

+ ARVIO Cloud backend env is missing. Add backend values in + web/.env.local. +

+ )}
-
Cloud{auth ? "Connected" : cloudConfigured ? "Ready" : "Missing config"}
-
Trakt{traktConnected ? "Connected" : hasTraktConfig() ? "Not linked" : "Missing config"}
-
Sync{auth ? "Cloud saved" : "Local only"}
+
+ Cloud + + {auth + ? "Connected" + : cloudConfigured + ? "Ready" + : "Missing config"} + +
+
+ Trakt + + {traktConnected + ? "Connected" + : hasTraktConfig() + ? "Not linked" + : "Missing config"} + +
+
+ Sync + {auth ? "Cloud saved" : "Local only"} +
{loginError &&

{loginError}

} {auth ? (
-
{auth.email}ARVIO Cloud account
- +
+ {auth.email} + ARVIO Cloud account +
+
) : (
- setEmail(e.target.value)} placeholder="Email" type="email" autoComplete="email" /> + setEmail(e.target.value)} + placeholder="Email" + type="email" + autoComplete="email" + /> setPassword(e.target.value)} @@ -573,10 +1095,20 @@ function AccountsSection() { }} />
- -
@@ -585,20 +1117,34 @@ function AccountsSection() { - {!hasTraktConfig() &&

Trakt client id is missing.

} + {!hasTraktConfig() && ( +

Trakt client id is missing.

+ )} {traktError &&

{traktError}

} {traktConnected ? ( - + ) : ( <> - {deviceCode && (
{deviceCode.user_code}

Open {deviceCode.verification_url}

-
@@ -608,11 +1154,30 @@ function AccountsSection() {
- -

Telegram bot setup is available in the Android app. The web app updates itself when a new version is deployed.

+ +

+ Telegram bot setup is available in the Android app. The web app + updates itself when a new version is deployed. +

- Web build: {process.env.NEXT_PUBLIC_BUILD_STAMP - ? new Date(Number(process.env.NEXT_PUBLIC_BUILD_STAMP)).toLocaleString("en-GB", { day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" }) + Web build:{" "} + {process.env.NEXT_PUBLIC_BUILD_STAMP + ? new Date( + Number(process.env.NEXT_PUBLIC_BUILD_STAMP), + ).toLocaleString("en-GB", { + day: "2-digit", + month: "short", + hour: "2-digit", + minute: "2-digit", + }) : "unknown"}

@@ -633,7 +1198,8 @@ function HomeServerSection() { const [testing, setTesting] = useState(false); const servers = safeArray(settings.homeServers); - const update = (next: HomeServerConfig[]) => updateSettings({ homeServers: next }); + const update = (next: HomeServerConfig[]) => + updateSettings({ homeServers: next }); const buildDraft = (): HomeServerConfig => ({ id: crypto.randomUUID(), @@ -643,7 +1209,7 @@ function HomeServerSection() { token: token.trim() || undefined, username: username.trim() || undefined, password: password || undefined, - enabled: true + enabled: true, }); const testConnection = async () => { @@ -659,11 +1225,15 @@ function HomeServerSection() { try { const { testHomeServerConnection } = await import("@/lib/homeserver"); const result = await testHomeServerConnection(buildDraft()); - setToast(result.ok - ? `Connected to ${result.serverName || "server"}${result.libraryCount ? ` — ${result.libraryCount} libraries` : ""}.` - : `Could not connect: ${result.error}`); + setToast( + result.ok + ? `Connected to ${result.serverName || "server"}${result.libraryCount ? ` — ${result.libraryCount} libraries` : ""}.` + : `Could not connect: ${result.error}`, + ); } catch (error) { - setToast(error instanceof Error ? error.message : "Connection test failed."); + setToast( + error instanceof Error ? error.message : "Connection test failed.", + ); } finally { setTesting(false); } @@ -671,40 +1241,116 @@ function HomeServerSection() { return ( -

Connect Plex, Jellyfin, or Emby. Plex requires an access token (X-Plex-Token). Jellyfin/Emby can use an API token or username + password. Matched movies and episodes appear as sources in the player, and cloud-sync with the Android app.

+

+ Connect Plex, Jellyfin, or Emby. Plex requires an access token + (X-Plex-Token). Jellyfin/Emby can use an API token or username + + password. Matched movies and episodes appear as sources in the player, + and cloud-sync with the Android app. +

- setName(e.target.value)} placeholder="Name" /> - setUrl(e.target.value)} placeholder="https://server:8096" /> - setToken(e.target.value)} placeholder={type === "plex" ? "X-Plex-Token (required)" : "API token (optional)"} /> - {type !== "plex" && setUsername(e.target.value)} placeholder="Username (optional)" />} - {type !== "plex" && setPassword(e.target.value)} placeholder="Password" type="password" />} - - +
{servers.map((server, index) => ( -
- {server.name || server.type || "Home server"} {server.type || "server"} {server.url || "No URL"} - +
))} - {servers.length === 0 &&

No home servers configured.

} + {servers.length === 0 && ( +

No home servers configured.

+ )}
); @@ -716,7 +1362,8 @@ function TvSettingsSection() { const [m3uUrl, setM3uUrl] = useState(""); const [epgUrl, setEpgUrl] = useState(""); const playlists = safeArray(settings.iptvPlaylists); - const updatePlaylists = (next: IptvPlaylistEntry[]) => updateSettings({ iptvPlaylists: next }); + const updatePlaylists = (next: IptvPlaylistEntry[]) => + updateSettings({ iptvPlaylists: next }); const isLoadingTv = Boolean(busy && busy.toLowerCase().includes("tv")); const addPlaylist = () => { @@ -734,13 +1381,16 @@ function TvSettingsSection() { setToast("EPG URL must start with http:// or https://."); return; } - updatePlaylists([{ - id: crypto.randomUUID(), - name: name.trim() || "IPTV Playlist", - m3uUrl: trimmedM3u, - epgUrl: trimmedEpg, - enabled: true - }, ...playlists]); + updatePlaylists([ + { + id: crypto.randomUUID(), + name: name.trim() || "IPTV Playlist", + m3uUrl: trimmedM3u, + epgUrl: trimmedEpg, + enabled: true, + }, + ...playlists, + ]); setName(""); setM3uUrl(""); setEpgUrl(""); @@ -749,30 +1399,128 @@ function TvSettingsSection() { return ( -

{playlists.length} playlist(s) configured. These are cloud-saved and used by the TV page.

+

+ {playlists.length} playlist(s) configured. These are cloud-saved and + used by the TV page. +

- setName(e.target.value)} placeholder="Playlist name" /> - setM3uUrl(e.target.value)} placeholder="M3U playlist URL" /> - setEpgUrl(e.target.value)} placeholder="EPG XMLTV URL (optional)" /> - + setName(e.target.value)} + placeholder="Playlist name" + /> + setM3uUrl(e.target.value)} + placeholder="M3U playlist URL" + /> + setEpgUrl(e.target.value)} + placeholder="EPG XMLTV URL (optional)" + /> +
{playlists.map((playlist, index) => ( -
- - updatePlaylists(playlists.map((item) => item.id === playlist.id ? { ...item, name: e.target.value } : item))} /> - updatePlaylists(playlists.map((item) => item.id === playlist.id ? { ...item, m3uUrl: e.target.value } : item))} /> - updatePlaylists(playlists.map((item) => item.id === playlist.id ? { ...item, epgUrl: e.target.value } : item))} placeholder="EPG URL" /> - + + updatePlaylists( + playlists.map((item) => + item.id === playlist.id + ? { ...item, name: e.target.value } + : item, + ), + ) + } + /> + + updatePlaylists( + playlists.map((item) => + item.id === playlist.id + ? { ...item, m3uUrl: e.target.value } + : item, + ), + ) + } + /> + + updatePlaylists( + playlists.map((item) => + item.id === playlist.id + ? { ...item, epgUrl: e.target.value } + : item, + ), + ) + } + placeholder="EPG URL" + /> +
))} - {!playlists.length &&

No IPTV playlists configured.

} + {!playlists.length && ( +

No IPTV playlists configured.

+ )}
- - updateSettings({ iptvStalkerUrl: e.target.value })} placeholder="http://portal.example.com/c/" /> - updateSettings({ iptvStalkerMac: e.target.value })} placeholder="00:1A:79:00:00:00" /> + + + updateSettings({ iptvStalkerUrl: e.target.value })} + placeholder="http://portal.example.com/c/" + /> + + + updateSettings({ iptvStalkerMac: e.target.value })} + placeholder="00:1A:79:00:00:00" + /> +
); } @@ -781,13 +1529,17 @@ function TvSettingsSection() { function CatalogsSection() { const { settings, updateSettings, setToast } = useApp(); - const catalogs = mergeCatalogs(safeArray(settings.catalogs), safeArray(settings.hiddenCatalogIds)); + const catalogs = mergeCatalogs( + safeArray(settings.catalogs), + safeArray(settings.hiddenCatalogIds), + ); const [customCatalogUrl, setCustomCatalogUrl] = useState(""); - const updateCatalogs = (next: CatalogConfig[]) => updateSettings({ - catalogs: next, - hiddenCatalogIds: next.filter((c) => !c.enabled).map((c) => c.id) - }); + const updateCatalogs = (next: CatalogConfig[]) => + updateSettings({ + catalogs: next, + hiddenCatalogIds: next.filter((c) => !c.enabled).map((c) => c.id), + }); const moveCatalog = (id: string, offset: number) => { const index = catalogs.findIndex((c) => c.id === id); const target = index + offset; @@ -801,32 +1553,124 @@ function CatalogsSection() { return (
- setCustomCatalogUrl(e.target.value)} placeholder="https://mdblist.com/lists/user/list" /> - - + setCustomCatalogUrl(e.target.value)} + placeholder="https://mdblist.com/lists/user/list" + /> + +
{catalogs.map((catalog, index) => ( -
- - updateCatalogs(catalogs.map((c) => c.id === catalog.id ? { ...c, name: e.target.value } : c))} /> + + updateCatalogs( + catalogs.map((c) => + c.id === catalog.id ? { ...c, name: e.target.value } : c, + ), + ) + } + /> {(catalog.sourceType || "custom").toUpperCase()} - + updateCatalogs( + catalogs.map((c) => + c.id === catalog.id ? { ...c, layout } : c, + ), + ) + } + options={[ + ["landscape", "Landscape"], + ["poster", "Poster"], + ]} + /> + + + {!catalog.isPreinstalled && ( + + )} + {(catalog.sourceUrl || + catalog.endpoint || + catalog.addonCatalogId) && ( + + {catalog.sourceUrl || + catalog.endpoint || + catalog.addonCatalogId} + )}
))} @@ -838,7 +1682,8 @@ function CatalogsSection() { /* ---------- Addons ---------- */ function AddonsSection() { - const { addons, installAddon, removeAddon, setAddonsState, setToast } = useApp(); + const { addons, installAddon, removeAddon, setAddonsState, setToast } = + useApp(); const [addonUrl, setAddonUrl] = useState(""); const [installing, setInstalling] = useState(false); const install = async () => { @@ -857,7 +1702,9 @@ function AddonsSection() { setAddonUrl(""); setToast("Addon installed."); } catch (error) { - setToast(error instanceof Error ? error.message : "Could not install addon."); + setToast( + error instanceof Error ? error.message : "Could not install addon.", + ); } finally { setInstalling(false); } @@ -865,41 +1712,97 @@ function AddonsSection() { return (
- setAddonUrl(e.target.value)} placeholder="https://addon.example.com/manifest.json" /> - + setAddonUrl(e.target.value)} + placeholder="https://addon.example.com/manifest.json" + /> +
{safeArray(addons).map((addon, index) => { const resources = safeArray(addon.resources); const addonCatalogs = safeArray(addon.catalogs); - const hasResource = (resource: string) => resources.length === 0 || resources.some((item) => typeof item === "string" ? item === resource : item?.name === resource); + const hasResource = (resource: string) => + resources.length === 0 || + resources.some((item) => + typeof item === "string" + ? item === resource + : item?.name === resource, + ); const canStream = hasResource("stream"); - const resourceLabel = [ - canStream ? "Streams" : "", - hasResource("subtitles") ? "Subtitles" : "", - addonCatalogs.length ? `${addonCatalogs.length} catalogs` : "" - ].filter(Boolean).join(" / ") || "Manifest"; + const resourceLabel = + [ + canStream ? "Streams" : "", + hasResource("subtitles") ? "Subtitles" : "", + addonCatalogs.length ? `${addonCatalogs.length} catalogs` : "", + ] + .filter(Boolean) + .join(" / ") || "Manifest"; return ( -
- -
- {addon.name || "Unnamed addon"} - {addon.manifestUrl} +
+ +
+ {addon.name || "Unnamed addon"} + {addon.manifestUrl} +
+ {resourceLabel} + {addon.version || "1.0.0"} +
- {resourceLabel} - {addon.version || "1.0.0"} - -
); })} - {addons.length === 0 &&

Install Stremio-compatible addons by URL above.

} + {addons.length === 0 && ( +

+ Install Stremio-compatible addons by URL above. +

+ )}
- + ); } @@ -913,14 +1816,16 @@ function SubtitlePreview({ settings }: { settings: AppSettings }) { className={previewClass} style={{ color: settings.subtitleColor, - fontSize: `${Math.max(60, Math.min(200, settings.subtitleSize))}%` + fontSize: `${Math.max(60, Math.min(200, settings.subtitleSize))}%`, }} > This is how subtitles will appear.
-

Preview updates instantly and is saved to cloud like Android subtitle settings.

+

+ Preview updates instantly and is saved to cloud like Android subtitle + settings. +

); } -