From cfd382a95434cf84a9cb08a90ab58aba80206922 Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Thu, 30 Jul 2026 14:23:08 +0530 Subject: [PATCH 01/15] feat(web): add PC right click and mobile long-press context menu capability --- web/app/globals.css | 161 +++++++++++++++++++++ web/components/details/DetailsDrawer.tsx | 68 ++++++++- web/components/livetv/LiveTvScreen.tsx | 31 +++- web/components/media/MediaCard.tsx | 37 ++++- web/components/shell/AppShell.tsx | 4 +- web/components/shell/MediaContextMenu.tsx | 164 ++++++++++++++++++++++ web/lib/store.tsx | 122 +++++++++++++++- web/public/version.json | 2 +- 8 files changed, 580 insertions(+), 9 deletions(-) create mode 100644 web/components/shell/MediaContextMenu.tsx diff --git a/web/app/globals.css b/web/app/globals.css index a5062a6c5..177180b1e 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -11815,3 +11815,164 @@ html { background: #000; } outline: 2px solid var(--accent, #fff); outline-offset: 2px; } + +/* ============================================================ + Context Menu (Right-click PC & Mobile long-press) + ============================================================ */ +.context-menu-scrim { + position: fixed; + inset: 0; + z-index: 2000; + background: rgba(0, 0, 0, 0.65); + backdrop-filter: blur(12px); + animation: arvio-fade-in 180ms ease both; + display: flex; + flex-direction: column; + justify-content: flex-end; +} + +@media (min-width: 768px) { + .context-menu-scrim { + justify-content: center; + align-items: center; + } +} + +.context-menu-card { + width: 100%; + max-width: 400px; + background: linear-gradient(180deg, rgba(30, 35, 45, 0.98), rgba(12, 14, 18, 0.99)); + border-top-left-radius: 20px; + border-top-right-radius: 20px; + border: 1px solid rgba(255, 255, 255, 0.12); + box-shadow: 0 -10px 40px rgba(0, 0, 0, 0.7); + padding: 16px 16px 24px; + animation: arvio-slide-up 220ms cubic-bezier(0.16, 1, 0.3, 1) both; + overflow: hidden; +} + +.context-menu-card.is-floating { + border-radius: 16px; + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.8), 0 0 0 1px rgba(255, 255, 255, 0.12); + width: 320px; + padding: 16px; + animation: arvio-scale-in 180ms cubic-bezier(0.16, 1, 0.3, 1) both; +} + +.context-menu-drag-handle { + width: 36px; + height: 4px; + background: rgba(255, 255, 255, 0.25); + border-radius: 2px; + margin: 0 auto 12px; +} + +.context-menu-card.is-floating .context-menu-drag-handle { + display: none; +} + +.context-menu-header { + padding: 4px 8px 8px; +} + +.context-menu-title { + margin: 0; + font-size: 1.1rem; + font-weight: 700; + color: #fff; + line-height: 1.3; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.context-menu-subtitle { + margin: 4px 0 0; + font-size: 0.85rem; + color: rgba(255, 255, 255, 0.6); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.context-menu-divider { + height: 1.5px; + background: rgba(255, 255, 255, 0.08); + margin: 8px 0; +} + +.context-menu-actions { + display: flex; + flex-direction: column; + gap: 4px; +} + +.context-menu-action-btn { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + padding: 12px 14px; + background: transparent; + border: none; + border-radius: 10px; + color: rgba(255, 255, 255, 0.9); + font-size: 0.95rem; + font-weight: 500; + cursor: pointer; + transition: background 150ms ease, color 150ms ease, transform 100ms ease; + text-align: left; +} + +.context-menu-action-btn:hover, +.context-menu-action-btn:focus-visible { + background: rgba(255, 255, 255, 0.1); + color: #fff; + outline: none; +} + +.context-menu-action-btn.is-danger { + color: #ef4444; +} + +.context-menu-action-btn.is-danger:hover, +.context-menu-action-btn.is-danger:focus-visible { + background: rgba(239, 68, 68, 0.15); + color: #f87171; +} + +.context-menu-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + flex-shrink: 0; +} + +.context-menu-label { + flex: 1; +} + +.context-menu-footer-hint { + margin-top: 12px; + text-align: center; + font-size: 0.75rem; + color: rgba(255, 255, 255, 0.4); +} + +@keyframes arvio-fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes arvio-slide-up { + from { transform: translateY(100%); } + to { transform: translateY(0); } +} + +@keyframes arvio-scale-in { + from { opacity: 0; transform: scale(0.92); } + to { opacity: 1; transform: scale(1); } +} + diff --git a/web/components/details/DetailsDrawer.tsx b/web/components/details/DetailsDrawer.tsx index e2b8b7017..09928fd53 100644 --- a/web/components/details/DetailsDrawer.tsx +++ b/web/components/details/DetailsDrawer.tsx @@ -1,6 +1,6 @@ "use client"; -import { BadgeCheck, Bookmark, CalendarDays, Clapperboard, Copy, Download, ExternalLink, Filter, MapPin, Play, Search, Star, Trash2, UserCircle, X } from "lucide-react"; +import { BadgeCheck, Bookmark, CalendarDays, Check, Clapperboard, Copy, Download, ExternalLink, EyeOff, Filter, MapPin, Play, Search, Star, Trash2, UserCircle, X } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { createPortal } from "react-dom"; import { MediaCard } from "@/components/media/MediaCard"; @@ -37,7 +37,7 @@ function needsDetailsHydration(item: MediaItem) { } function DetailsView({ item }: { item: MediaItem }) { - const { streams, selectedEpisode, activeProfile, addons: installedAddons, loadEpisodeStreams, openDetails, playStream, playTrailer, setToast, settings, watchlist, refreshData, busy, isWatched, markWatchedLocally } = useApp(); + const { streams, selectedEpisode, activeProfile, addons: installedAddons, loadEpisodeStreams, openDetails, playStream, playTrailer, setToast, settings, watchlist, refreshData, busy, isWatched, markWatchedLocally, openContextMenu, toggleWatched } = useApp(); const [detailsItem, setDetailsItem] = useState(item); const [detailsLoading, setDetailsLoading] = useState(false); const [reviews, setReviews] = useState([]); @@ -891,6 +891,7 @@ function SeasonEpisodes({ item, loadingDetails, selectedEpisode, isWatched, onPl isWatched: (item: MediaItem, seasonNumber?: number | null, episodeNumber?: number | null) => boolean; onPlayEpisode: (season: number, episode: number) => void; }) { + const { openContextMenu, markWatchedLocally, toggleWatched } = useApp(); const seasons = item.seasons ?? []; const [season, setSeason] = useState(seasons[0]?.seasonNumber ?? 1); const [episodes, setEpisodes] = useState([]); @@ -913,6 +914,67 @@ function SeasonEpisodes({ item, loadingDetails, selectedEpisode, isWatched, onPl return () => { active = false; }; }, [item.id, season, retryNonce]); + const handleSeasonContextMenu = (e: React.MouseEvent, seasonNum: number, seasonName: string) => { + e.preventDefault(); + e.stopPropagation(); + openContextMenu({ + title: seasonName || `Season ${seasonNum}`, + subtitle: item.title, + position: { x: e.clientX, y: e.clientY }, + actions: [ + { + id: "mark_season_watched", + label: "Mark Season Watched", + icon: , + action: () => { + episodes.forEach((ep) => { + markWatchedLocally({ mediaType: "tv", id: item.id, season: seasonNum, episode: ep.episodeNumber }, true); + }); + } + }, + { + id: "mark_season_unwatched", + label: "Mark Season Unwatched", + icon: , + action: () => { + episodes.forEach((ep) => { + markWatchedLocally({ mediaType: "tv", id: item.id, season: seasonNum, episode: ep.episodeNumber }, false); + }); + } + } + ] + }); + }; + + const handleEpisodeContextMenu = (e: React.MouseEvent, ep: EpisodeInfo) => { + e.preventDefault(); + e.stopPropagation(); + const watched = isWatched(item, season, ep.episodeNumber); + openContextMenu({ + title: ep.name || `Episode ${ep.episodeNumber}`, + subtitle: `${item.title} - S${season} E${ep.episodeNumber}`, + position: { x: e.clientX, y: e.clientY }, + actions: [ + { + id: "play_episode", + label: "Play Episode", + icon: , + action: () => { + onPlayEpisode(season, ep.episodeNumber); + } + }, + { + id: "toggle_episode_watched", + label: watched ? "Mark as Unwatched" : "Mark as Watched", + icon: watched ? : , + action: () => { + void toggleWatched(item, season, ep.episodeNumber); + } + } + ] + }); + }; + return (

Episodes

@@ -923,6 +985,7 @@ function SeasonEpisodes({ item, loadingDetails, selectedEpisode, isWatched, onPl key={s.id} className={`season-tab ${s.seasonNumber === season ? "is-active" : ""}`} onClick={() => setSeason(s.seasonNumber)} + onContextMenu={(e) => handleSeasonContextMenu(e, s.seasonNumber, s.name || `Season ${s.seasonNumber}`)} > {s.name || `Season ${s.seasonNumber}`} @@ -947,6 +1010,7 @@ function SeasonEpisodes({ item, loadingDetails, selectedEpisode, isWatched, onPl key={episode.id} className={`episode-row ${active ? "is-active" : ""} ${watched ? "is-watched" : ""}`} onClick={() => onPlayEpisode(season, episode.episodeNumber)} + onContextMenu={(e) => handleEpisodeContextMenu(e, episode)} >
{episode.still ? : } diff --git a/web/components/livetv/LiveTvScreen.tsx b/web/components/livetv/LiveTvScreen.tsx index 77af4c4e4..b6bfd2ae6 100644 --- a/web/components/livetv/LiveTvScreen.tsx +++ b/web/components/livetv/LiveTvScreen.tsx @@ -629,6 +629,7 @@ function ChannelRow({ channel, guide, favorite, selected, onFocus, onVisible, on onPlay: () => void; onToggleFavorite: () => void; }) { + const { openContextMenu } = useApp(); const rowRef = useRef(null); const now = guide?.now; const next = guide?.next ?? guide?.later ?? guide?.upcoming?.[0]; @@ -648,8 +649,36 @@ function ChannelRow({ channel, guide, favorite, selected, onFocus, onVisible, on // eslint-disable-next-line react-hooks/exhaustive-deps }, [channel.id]); + const handleContextMenu = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + openContextMenu({ + title: channel.name, + subtitle: channel.group || "Live TV", + position: { x: e.clientX, y: e.clientY }, + actions: [ + { + id: "play_channel", + label: "Play Channel", + icon: , + action: () => { + onPlay(); + } + }, + { + id: "toggle_favorite", + label: favorite ? "Remove from Favorites" : "Add to Favorites", + icon: , + action: () => { + onToggleFavorite(); + } + } + ] + }); + }; + return ( -
+
+ ))} +
+ +
+ Press ESC to close +
+ + + ); +} diff --git a/web/lib/store.tsx b/web/lib/store.tsx index 89aeec6e5..a6ea7fdae 100644 --- a/web/lib/store.tsx +++ b/web/lib/store.tsx @@ -506,6 +506,28 @@ export interface AppStore { // Watchlist list-source switcher (Trakt custom lists / collection). loadTraktLists: () => Promise>; loadTraktListItems: (source: string) => Promise; + + toggleWatchlist: (item: MediaItem) => Promise; + toggleWatched: (item: MediaItem, seasonNumber?: number | null, episodeNumber?: number | null) => Promise; + removeFromContinueWatching: (item: MediaItem) => Promise; + activeContextMenu: ContextMenuTarget | null; + openContextMenu: (target: ContextMenuTarget) => void; + closeContextMenu: () => void; +} + +export interface ContextMenuTarget { + item?: MediaItem; + title?: string; + subtitle?: string; + isContinueWatching?: boolean; + position?: { x: number; y: number } | null; + actions?: Array<{ + id: string; + label: string; + icon: React.ReactNode; + danger?: boolean; + action: () => void | Promise; + }>; } const AppContext = createContext(null); @@ -1726,6 +1748,95 @@ export function AppProvider({ }, []); const backToProfiles = useCallback(() => setView("profiles"), []); + const [activeContextMenu, setActiveContextMenu] = useState(null); + + const openContextMenu = useCallback((target: ContextMenuTarget) => { + setActiveContextMenu(target); + }, []); + + const closeContextMenu = useCallback(() => { + setActiveContextMenu(null); + }, []); + + const toggleWatchlist = useCallback(async (item: MediaItem) => { + const inWatchlist = watchlist.some((entry) => entry.mediaType === item.mediaType && entry.id === item.id); + const slim = slimCacheItem(item); + const cacheKey = watchlistCacheKeyFor(activeProfileId); + + setWatchlist((prev) => { + const next = inWatchlist + ? prev.filter((entry) => !(entry.mediaType === item.mediaType && entry.id === item.id)) + : [slim, ...prev]; + saveCachedList(cacheKey, next, 60); + return next; + }); + + if (activeSyncProvider() !== "none") { + try { + if (inWatchlist) { + await syncClient().removeFromWatchlist({ mediaType: item.mediaType, tmdbId: item.id }); + setToast("Removed from watchlist."); + } else { + await syncClient().addToWatchlist({ mediaType: item.mediaType, tmdbId: item.id }); + setToast("Added to watchlist."); + } + } catch (err) { + setWatchlist((prev) => { + const next = inWatchlist ? [slim, ...prev] : prev.filter((entry) => !(entry.mediaType === item.mediaType && entry.id === item.id)); + saveCachedList(cacheKey, next, 60); + return next; + }); + setToast(err instanceof Error ? err.message : "Failed to update watchlist."); + } + } else { + setToast(inWatchlist ? "Removed from watchlist." : "Added to watchlist."); + } + }, [watchlist, activeProfileId]); + + const toggleWatched = useCallback(async (item: MediaItem, seasonNumber?: number | null, episodeNumber?: number | null) => { + const currentlyWatched = isWatched(item, seasonNumber, episodeNumber); + markWatchedLocally({ mediaType: item.mediaType, id: item.id, season: seasonNumber, episode: episodeNumber }, !currentlyWatched); + setToast(!currentlyWatched ? "Marked as watched." : "Marked as unwatched."); + + if (activeSyncProvider() !== "none") { + try { + const ref = { mediaType: item.mediaType, tmdbId: item.id, season: seasonNumber, episode: episodeNumber }; + if (!currentlyWatched) { + await syncClient().addToHistory(ref); + } else { + await syncClient().removeFromHistory(ref); + } + } catch { + // Sync best effort + } + } + }, [isWatched, markWatchedLocally]); + + const removeFromContinueWatching = useCallback(async (item: MediaItem) => { + const key = mediaWatchKey(item); + const cacheKey = cwCacheKeyFor(activeProfileId); + + setContinueWatching((prev) => { + const next = prev.filter((entry) => mediaWatchKey(entry) !== key && mediaWatchKey(entry) !== `${item.mediaType}:${item.id}`); + saveCachedList(cacheKey, next, 30); + return next; + }); + + setCategories((prev) => prev.map((cat) => cat.id === "continue_watching" + ? { ...cat, items: cat.items.filter((entry) => mediaWatchKey(entry) !== key && mediaWatchKey(entry) !== `${item.mediaType}:${item.id}`) } + : cat).filter((cat) => cat.id !== "continue_watching" || cat.items.length)); + + setToast("Removed from Continue Watching."); + + if (activeSyncProvider() !== "none") { + try { + await syncClient().removeFromHistory({ mediaType: item.mediaType, tmdbId: item.id, season: item.seasonNumber, episode: item.episodeNumber }); + } catch { + // Sync best effort + } + } + }, [activeProfileId]); + const value = useMemo(() => ({ view, cloudLoginRequired, @@ -1796,7 +1907,13 @@ export function AppProvider({ connectMdblist, disconnectMdblist, loadTraktLists, - loadTraktListItems + loadTraktListItems, + toggleWatchlist, + toggleWatched, + removeFromContinueWatching, + activeContextMenu, + openContextMenu, + closeContextMenu }), [ view, cloudLoginRequired, profiles, activeProfile, avatarImages, manageMode, selectProfile, createProfile, updateProfileAction, deleteProfileAction, switchProfile, goToLogin, backToProfiles, @@ -1806,7 +1923,8 @@ export function AppProvider({ refreshIptv, loadIptvGuide, installAddon, removeAddon, setAddonsState, signIn, signOut, beginTrakt, pollTrakt, disconnectTrakt, connectMdblist, disconnectMdblist, - loadTraktLists, loadTraktListItems + loadTraktLists, loadTraktListItems, + toggleWatchlist, toggleWatched, removeFromContinueWatching, activeContextMenu, openContextMenu, closeContextMenu ]); return {children}; diff --git a/web/public/version.json b/web/public/version.json index 81beef3a7..5d23be40c 100644 --- a/web/public/version.json +++ b/web/public/version.json @@ -1 +1 @@ -{"v":"1785220462255"} \ No newline at end of file +{"v":"1785401255714"} \ No newline at end of file From 93bc2eb153afbe2ff7a8dea6429f0dbf9c8d9a93 Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Thu, 30 Jul 2026 14:27:42 +0530 Subject: [PATCH 02/15] fix(web): pin hero banner slot heights to prevent catalog rails from shifting on movie preview --- web/app/globals.css | 43 ++++++++++++++++++++++++++++-- web/components/home/HomeScreen.tsx | 14 +++++----- web/public/version.json | 2 +- 3 files changed, 50 insertions(+), 9 deletions(-) diff --git a/web/app/globals.css b/web/app/globals.css index 177180b1e..af52f5d81 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -9520,11 +9520,50 @@ button, z-index: 2 !important; width: min(800px, 50vw); margin-top: 0 !important; + display: flex; + flex-direction: column; } -.hero-logo, -.hero h2 { +.hero-title-slot { + height: clamp(100px, 13vh, 150px); + display: flex; + align-items: flex-end; + margin-bottom: clamp(10px, 1.5vh, 16px); +} + +.hero-title-slot h2 { + margin: 0; + max-width: min(760px, 48vw); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.hero-title-slot .hero-logo { + max-height: 100%; max-width: min(760px, 48vw); + object-fit: contain; + object-position: bottom left; +} + +.hero-meta { + height: clamp(28px, 3.5vh, 36px); + display: flex; + align-items: center; + gap: 10px; + overflow: hidden; + white-space: nowrap; +} + +.hero-overview { + height: clamp(48px, 6vh, 70px); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + margin-top: clamp(8px, 1.2vh, 14px); + margin-bottom: 0; } .hero + .rail, diff --git a/web/components/home/HomeScreen.tsx b/web/components/home/HomeScreen.tsx index 3af1076ac..8b1080e1e 100644 --- a/web/components/home/HomeScreen.tsx +++ b/web/components/home/HomeScreen.tsx @@ -146,11 +146,13 @@ export function HomeScreen() { {displayHero && (
- {heroLogo ? ( - {displayHero.title} - ) : ( -

{displayHero.title}

- )} +
+ {heroLogo ? ( + {displayHero.title} + ) : ( +

{displayHero.title}

+ )} +
{heroImdbRating && ( @@ -160,7 +162,7 @@ export function HomeScreen() { )} {metaBits.map((bit) => {bit})}
-

+

{(() => { const desc = displayHero.overview || displayHero.subtitle || "Continue from your ARVIO library."; return desc.length > 150 ? desc.slice(0, 150) + "..." : desc; diff --git a/web/public/version.json b/web/public/version.json index 5d23be40c..cffc55625 100644 --- a/web/public/version.json +++ b/web/public/version.json @@ -1 +1 @@ -{"v":"1785401255714"} \ No newline at end of file +{"v":"1785401835900"} \ No newline at end of file From d075b85d279d4d7b8acb50d0c351633b2054902b Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Thu, 30 Jul 2026 14:40:20 +0530 Subject: [PATCH 03/15] fix(web): adjust hero overview text font size, line height, and bottom margin to prevent overlap with play buttons --- web/app/globals.css | 16 +++++++++------- web/public/version.json | 2 +- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/web/app/globals.css b/web/app/globals.css index af52f5d81..6e336d5c9 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -9557,13 +9557,15 @@ button, } .hero-overview { - height: clamp(48px, 6vh, 70px); - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - margin-top: clamp(8px, 1.2vh, 14px); - margin-bottom: 0; + font-size: clamp(15px, 1.15vw, 20px) !important; + line-height: 1.45 !important; + height: clamp(44px, 5.2vh, 60px) !important; + display: -webkit-box !important; + -webkit-line-clamp: 2 !important; + -webkit-box-orient: vertical !important; + overflow: hidden !important; + margin-top: clamp(8px, 1.2vh, 14px) !important; + margin-bottom: clamp(20px, 2.8vh, 32px) !important; } .hero + .rail, diff --git a/web/public/version.json b/web/public/version.json index cffc55625..f5cc58c5a 100644 --- a/web/public/version.json +++ b/web/public/version.json @@ -1 +1 @@ -{"v":"1785401835900"} \ No newline at end of file +{"v":"1785402591657"} \ No newline at end of file From 474d244961114c6089ca2857d618f2a3a78c8e61 Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Thu, 30 Jul 2026 15:03:47 +0530 Subject: [PATCH 04/15] fix(web): match lazy rail skeleton cards layout and spacing with loaded catalog media cards --- web/app/globals.css | 30 ++++++++++++++++++++++++++++-- web/components/media/LazyRail.tsx | 13 ++++++++++--- web/public/version.json | 2 +- 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/web/app/globals.css b/web/app/globals.css index 6e336d5c9..ee84f9dba 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -2253,8 +2253,14 @@ a.login-submit { .rail-skeleton { min-height: clamp(220px, 28vh, 320px); } +.media-card-skeleton { + width: 100%; + display: flex; + flex-direction: column; + gap: 8px; +} .card-skeleton { - width: clamp(280px, 22.1vw, 424px); + width: 100%; aspect-ratio: 16 / 9; border-radius: clamp(12px, 0.94vw, 18px); background: linear-gradient(100deg, var(--bg-card) 30%, var(--bg-elevated) 50%, var(--bg-card) 70%); @@ -2262,10 +2268,30 @@ a.login-submit { animation: arvio-shimmer 1.4s ease-in-out infinite; } +.rail.is-poster .card-skeleton, .rail-skeleton.is-poster .card-skeleton { - width: clamp(150px, 12vw, 210px); aspect-ratio: 2 / 3; } + +.card-skeleton-title { + width: 75%; + height: 14px; + border-radius: 4px; + margin-top: 4px; + background: linear-gradient(100deg, var(--bg-card) 30%, var(--bg-elevated) 50%, var(--bg-card) 70%); + background-size: 200% 100%; + animation: arvio-shimmer 1.4s ease-in-out infinite; +} + +.card-skeleton-meta { + width: 45%; + height: 11px; + border-radius: 4px; + background: linear-gradient(100deg, var(--bg-card) 30%, var(--bg-elevated) 50%, var(--bg-card) 70%); + background-size: 200% 100%; + animation: arvio-shimmer 1.4s ease-in-out infinite; +} + @keyframes arvio-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } diff --git a/web/components/media/LazyRail.tsx b/web/components/media/LazyRail.tsx index 6438063f9..45b96c247 100644 --- a/web/components/media/LazyRail.tsx +++ b/web/components/media/LazyRail.tsx @@ -5,6 +5,7 @@ import { loadStored, saveStored } from "@/lib/storage"; import { useApp } from "@/lib/store"; import type { CatalogConfig, Category, MediaItem } from "@/lib/types"; import { MediaRail } from "./MediaRail"; +import { RailScroller } from "./RailScroller"; // v3: v2 entries were poisoned by collection rails colliding on a cache key // that omitted collectionSources (all service rows shared one entry). @@ -99,9 +100,15 @@ export function LazyRail({ catalog, eager = false, posterMode, onOpen, onFocus,

{catalog.name}

-
- {loading && Array.from({ length: 6 }).map((_, index) =>
)} -
+ + {Array.from({ length: 6 }).map((_, index) => ( +
+
+
+
+
+ ))} +
); } diff --git a/web/public/version.json b/web/public/version.json index f5cc58c5a..0d5190530 100644 --- a/web/public/version.json +++ b/web/public/version.json @@ -1 +1 @@ -{"v":"1785402591657"} \ No newline at end of file +{"v":"1785404001029"} \ No newline at end of file From ac9154eb881068fb3e056350044e1d38833e04d7 Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Thu, 30 Jul 2026 15:30:51 +0530 Subject: [PATCH 05/15] fix(web): resolve catalog poster mode skeletons and use reliable tmdb.org API endpoint --- web/app/api/tmdb/[...path]/route.ts | 6 +++--- web/components/media/LazyRail.tsx | 2 +- web/public/version.json | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/web/app/api/tmdb/[...path]/route.ts b/web/app/api/tmdb/[...path]/route.ts index d3595bbcc..5152288e5 100644 --- a/web/app/api/tmdb/[...path]/route.ts +++ b/web/app/api/tmdb/[...path]/route.ts @@ -25,7 +25,7 @@ export async function GET(request: NextRequest, context: { params: Promise<{ pat // query-less CDN cache-key bug (fixed upstream with Netlify-Vary: query). target.searchParams.set("cv", "2"); } else if (tmdbKey) { - target = new URL(`https://api.themoviedb.org/3/${path.join("/")}`); + target = new URL(`https://api.tmdb.org/3/${path.join("/")}`); input.searchParams.forEach((value, key) => target.searchParams.set(key, value)); target.searchParams.set("api_key", tmdbKey); } else { @@ -48,8 +48,8 @@ export async function GET(request: NextRequest, context: { params: Promise<{ pat response = null; } - if ((!response || !response.ok) && usesNetlifyProxy && tmdbKey) { - const direct = new URL(`https://api.themoviedb.org/3/${path.join("/")}`); + if ((!response || !response.ok) && tmdbKey) { + const direct = new URL(`https://api.tmdb.org/3/${path.join("/")}`); input.searchParams.forEach((value, key) => direct.searchParams.set(key, value)); direct.searchParams.set("api_key", tmdbKey); try { diff --git a/web/components/media/LazyRail.tsx b/web/components/media/LazyRail.tsx index 45b96c247..f6a39532c 100644 --- a/web/components/media/LazyRail.tsx +++ b/web/components/media/LazyRail.tsx @@ -22,11 +22,11 @@ export function LazyRail({ catalog, eager = false, posterMode, onOpen, onFocus, onLoaded?: (category: Category) => void; }) { const { loadCatalogRow, settings } = useApp(); - const effectivePosterMode = posterMode ?? (catalog.layout ? catalog.layout === "poster" : settings.cardLayoutMode === "poster"); const cacheKey = catalogCacheKey(catalog, settings.language); const ref = useRef(null); const startedRef = useRef(false); const [category, setCategory] = useState(() => readCachedCatalog(cacheKey)); + const effectivePosterMode = catalog.layout === "poster" || category?.layout === "poster" || (posterMode ?? (settings.cardLayoutMode === "poster")); const [loading, setLoading] = useState(false); const [done, setDone] = useState(false); diff --git a/web/public/version.json b/web/public/version.json index 0d5190530..a067fb302 100644 --- a/web/public/version.json +++ b/web/public/version.json @@ -1 +1 @@ -{"v":"1785404001029"} \ No newline at end of file +{"v":"1785406621237"} \ No newline at end of file From 8dd3b8581653a34daf1ffa8db8fffdc6ca6ae979 Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Thu, 30 Jul 2026 15:58:52 +0530 Subject: [PATCH 06/15] fix(web): use rounded corner app icon for loading screen and brand headers --- web/app/globals.css | 1 + web/components/login/LoginScreen.tsx | 2 +- web/components/shell/AppShell.tsx | 2 +- web/components/shell/Paywall.tsx | 2 +- web/components/shell/TopNav.tsx | 6 +++--- 5 files changed, 7 insertions(+), 6 deletions(-) diff --git a/web/app/globals.css b/web/app/globals.css index ee84f9dba..8bbc02763 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -77,6 +77,7 @@ button { width: 76px; height: 76px; object-fit: contain; + border-radius: 18px; filter: drop-shadow(0 0 28px rgba(0, 213, 136, 0.26)); } diff --git a/web/components/login/LoginScreen.tsx b/web/components/login/LoginScreen.tsx index c1d60830a..ccaf572ea 100644 --- a/web/components/login/LoginScreen.tsx +++ b/web/components/login/LoginScreen.tsx @@ -38,7 +38,7 @@ export function LoginScreen() {
- + ARVIO

Cloud sign-in required

diff --git a/web/components/shell/AppShell.tsx b/web/components/shell/AppShell.tsx index 24bfbd7a9..02aa252a0 100644 --- a/web/components/shell/AppShell.tsx +++ b/web/components/shell/AppShell.tsx @@ -63,7 +63,7 @@ export function AppShell() { if (!mounted) { return (
- + ARVIO
); diff --git a/web/components/shell/Paywall.tsx b/web/components/shell/Paywall.tsx index 92fea9aeb..c1e89f637 100644 --- a/web/components/shell/Paywall.tsx +++ b/web/components/shell/Paywall.tsx @@ -114,7 +114,7 @@ function PaywallScreen({
- + ARVIO
diff --git a/web/components/shell/TopNav.tsx b/web/components/shell/TopNav.tsx index 285db75a0..40a2613d2 100644 --- a/web/components/shell/TopNav.tsx +++ b/web/components/shell/TopNav.tsx @@ -31,7 +31,7 @@ export function TopNav() {
From 47a225b5ab2cb3bb9c852a10c23c17241bd2bfe5 Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Thu, 30 Jul 2026 16:03:52 +0530 Subject: [PATCH 07/15] feat(web): enable individual poster skeleton placeholders and per-card image fade-in --- web/app/globals.css | 14 ++++++++++++-- web/components/media/MediaCard.tsx | 20 ++++++++++++++++++-- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/web/app/globals.css b/web/app/globals.css index 8bbc02763..7d2720d95 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -587,12 +587,22 @@ p { will-change: transform; } +.poster.is-loading { + background: linear-gradient(100deg, var(--bg-card) 30%, var(--bg-elevated) 50%, var(--bg-card) 70%); + background-size: 200% 100%; + animation: arvio-shimmer 1.4s ease-in-out infinite; +} + .poster img { width: 100%; height: 100%; object-fit: cover; - transition: transform 380ms cubic-bezier(0.2, 0.7, 0.2, 1); - animation: arvio-img-in 420ms ease both; + opacity: 0; + transition: transform 380ms cubic-bezier(0.2, 0.7, 0.2, 1), opacity 320ms ease; +} + +.poster img.is-loaded { + opacity: 1; } @keyframes arvio-img-in { diff --git a/web/components/media/MediaCard.tsx b/web/components/media/MediaCard.tsx index 03090103d..f23dab23c 100644 --- a/web/components/media/MediaCard.tsx +++ b/web/components/media/MediaCard.tsx @@ -45,6 +45,7 @@ function MediaCardBase({ item, onOpen, onFocus, posterMode }: { const { settings, isWatched, openContextMenu } = useApp(); const effectivePosterMode = posterMode ?? settings.cardLayoutMode === "poster"; const [logo, setLogo] = useState(null); + const [imgLoaded, setImgLoaded] = useState(false); const progress = item.progress ?? 0; const watched = isWatched(item); // "Up next" rows carry SERIES completion (how far through the show you are), @@ -63,6 +64,10 @@ function MediaCardBase({ item, onOpen, onFocus, posterMode }: { const artwork = effectivePosterMode ? (image || backdrop) : (backdrop || image); const year = item.releaseDate?.slice(0, 4) || item.year || (item.mediaType === "tv" ? "Series" : "Movie"); + useEffect(() => { + setImgLoaded(false); + }, [artwork]); + const triggerContextMenu = (posX?: number, posY?: number) => { openContextMenu({ item, @@ -162,8 +167,19 @@ function MediaCardBase({ item, onOpen, onFocus, posterMode }: { onMouseEnter={() => { prefetchDetails(item); onFocus?.(item); }} onFocus={() => { prefetchDetails(item); onFocus?.(item); }} > -
- {artwork ? : } +
+ {artwork ? ( + setImgLoaded(true)} + className={imgLoaded ? "is-loaded" : ""} + /> + ) : ( + + )} {logo && !effectivePosterMode && } {serviceBadges.length > 0 && ( From 15af4694ec951afe0b3c722f8dc4712a0731fd08 Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Thu, 30 Jul 2026 16:57:57 +0530 Subject: [PATCH 08/15] fix(web): scope poster image fade-in to poster-art so IMDb and service logos stay visible --- web/app/globals.css | 10 ++++++++-- web/components/media/MediaCard.tsx | 2 +- web/public/version.json | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/web/app/globals.css b/web/app/globals.css index 7d2720d95..9bb9e3327 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -593,7 +593,7 @@ p { animation: arvio-shimmer 1.4s ease-in-out infinite; } -.poster img { +.poster img.poster-art { width: 100%; height: 100%; object-fit: cover; @@ -601,10 +601,16 @@ p { transition: transform 380ms cubic-bezier(0.2, 0.7, 0.2, 1), opacity 320ms ease; } -.poster img.is-loaded { +.poster img.poster-art.is-loaded { opacity: 1; } +.poster .card-imdb img, +.poster .card-services img, +.poster .card-logo { + opacity: 1 !important; +} + @keyframes arvio-img-in { from { opacity: 0; } to { opacity: 1; } diff --git a/web/components/media/MediaCard.tsx b/web/components/media/MediaCard.tsx index f23dab23c..2384b2d49 100644 --- a/web/components/media/MediaCard.tsx +++ b/web/components/media/MediaCard.tsx @@ -175,7 +175,7 @@ function MediaCardBase({ item, onOpen, onFocus, posterMode }: { loading="lazy" decoding="async" onLoad={() => setImgLoaded(true)} - className={imgLoaded ? "is-loaded" : ""} + className={`poster-art ${imgLoaded ? "is-loaded" : ""}`} /> ) : ( diff --git a/web/public/version.json b/web/public/version.json index a067fb302..6fdf9b6f7 100644 --- a/web/public/version.json +++ b/web/public/version.json @@ -1 +1 @@ -{"v":"1785406621237"} \ No newline at end of file +{"v":"1785410663517"} \ No newline at end of file From 01895d2d33ede1ac649dc0cebce0f63762abbcee Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Thu, 30 Jul 2026 21:10:22 +0530 Subject: [PATCH 09/15] feat(web): add auto-rotating hero carousel, GPU shimmer, touch swipe, and fix text layout --- web/app/globals.css | 175 +++++++++++++++++++++++------ web/components/home/HomeScreen.tsx | 78 +++++++++---- web/components/media/MediaCard.tsx | 20 +++- web/public/version.json | 2 +- 4 files changed, 217 insertions(+), 58 deletions(-) diff --git a/web/app/globals.css b/web/app/globals.css index 9bb9e3327..86f73d02f 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -272,6 +272,7 @@ button { background-size: cover; background-position: center right; overflow: hidden; + transition: background-image 0s; } .hero::before { @@ -287,6 +288,18 @@ button { position: relative; width: min(760px, 52vw); margin-top: 0; + animation: hero-content-in 420ms ease-out; +} + +@keyframes hero-content-in { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: translateY(0); + } } .eyebrow { @@ -331,14 +344,6 @@ p { align-items: center; gap: 16px; margin-top: clamp(14px, 2.4vh, 26px); - /* The first rail after the hero is pulled UP over it (Netflix-style overlap, - `.hero + .rail`, up to -150px). A tall hero block — a title treatment near - `.hero-logo`'s max-height plus a three-line synopsis — then grows down INTO - that overlap and the buttons end up glued to the rail heading. This reserve - mirrors the pull exactly, so the rail can never reach the buttons. - Verified harmless where the overlap doesn't apply: adding it left the - measured button-to-heading gap unchanged (the hero simply grows), so it - costs nothing on layouts that were already fine. */ margin-bottom: clamp(96px, 17vh, 150px); } @@ -588,9 +593,22 @@ p { } .poster.is-loading { - background: linear-gradient(100deg, var(--bg-card) 30%, var(--bg-elevated) 50%, var(--bg-card) 70%); - background-size: 200% 100%; - animation: arvio-shimmer 1.4s ease-in-out infinite; + position: relative; + overflow: hidden; + background: rgba(255, 255, 255, 0.06); +} + +.poster.is-loading::after { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.12), transparent); + transform: translateX(-100%); + animation: arvio-shimmer-gpu 1.5s infinite; + will-change: transform; } .poster img.poster-art { @@ -2276,13 +2294,33 @@ a.login-submit { flex-direction: column; gap: 8px; } +.card-skeleton, +.card-skeleton-title, +.card-skeleton-meta { + position: relative; + overflow: hidden; + background: rgba(255, 255, 255, 0.06); +} + +.card-skeleton::after, +.card-skeleton-title::after, +.card-skeleton-meta::after { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.14), transparent); + transform: translateX(-100%); + animation: arvio-shimmer-gpu 1.5s infinite; + will-change: transform; +} + .card-skeleton { width: 100%; aspect-ratio: 16 / 9; border-radius: clamp(12px, 0.94vw, 18px); - background: linear-gradient(100deg, var(--bg-card) 30%, var(--bg-elevated) 50%, var(--bg-card) 70%); - background-size: 200% 100%; - animation: arvio-shimmer 1.4s ease-in-out infinite; } .rail.is-poster .card-skeleton, @@ -2295,23 +2333,18 @@ a.login-submit { height: 14px; border-radius: 4px; margin-top: 4px; - background: linear-gradient(100deg, var(--bg-card) 30%, var(--bg-elevated) 50%, var(--bg-card) 70%); - background-size: 200% 100%; - animation: arvio-shimmer 1.4s ease-in-out infinite; } .card-skeleton-meta { width: 45%; height: 11px; border-radius: 4px; - background: linear-gradient(100deg, var(--bg-card) 30%, var(--bg-elevated) 50%, var(--bg-card) 70%); - background-size: 200% 100%; - animation: arvio-shimmer 1.4s ease-in-out infinite; } -@keyframes arvio-shimmer { - 0% { background-position: 200% 0; } - 100% { background-position: -200% 0; } +@keyframes arvio-shimmer-gpu { + 100% { + transform: translateX(100%); + } } /* ============================================================ @@ -9600,15 +9633,16 @@ button, } .hero-overview { - font-size: clamp(15px, 1.15vw, 20px) !important; + font-size: clamp(14px, 1.15vw, 18px) !important; line-height: 1.45 !important; - height: clamp(44px, 5.2vh, 60px) !important; + height: auto !important; + max-height: calc(3 * 1.45em) !important; display: -webkit-box !important; - -webkit-line-clamp: 2 !important; + -webkit-line-clamp: 3 !important; -webkit-box-orient: vertical !important; overflow: hidden !important; margin-top: clamp(8px, 1.2vh, 14px) !important; - margin-bottom: clamp(20px, 2.8vh, 32px) !important; + margin-bottom: clamp(16px, 2.4vh, 28px) !important; } .hero + .rail, @@ -12038,11 +12072,88 @@ html { background: #000; } flex: 1; } -.context-menu-footer-hint { - margin-top: 12px; - text-align: center; - font-size: 0.75rem; - color: rgba(255, 255, 255, 0.4); +.hero-carousel-dots { + position: absolute; + right: clamp(24px, 3.35vw, 64px); + bottom: clamp(260px, 30vh, 360px); + display: flex; + align-items: center; + gap: 10px; + z-index: 5; +} + +/* ---- Mobile hero layout ---- */ +@media (max-width: 680px) { + .hero { + min-height: auto; + align-items: flex-end; + padding: 220px 16px 28px; + } + .hero::before { + background: + linear-gradient(180deg, transparent 5%, rgba(0,0,0,0.55) 40%, rgba(0,0,0,0.95) 85%); + } + .hero-copy { + width: 100%; + } + .hero h2 { + font-size: clamp(26px, 7vw, 38px); + margin-bottom: 8px; + line-height: 1.05; + } + .hero-logo { + max-height: 60px; + } + .hero-meta { + font-size: 13px; + gap: 6px; + flex-wrap: wrap; + margin-bottom: 6px; + } + .hero-copy p:not(.eyebrow) { + font-size: 13px !important; + line-height: 1.45 !important; + height: auto !important; + max-height: calc(2 * 1.45em) !important; + -webkit-line-clamp: 2 !important; + margin-bottom: 4px !important; + } + .hero-actions { + margin-top: 10px; + margin-bottom: 16px; + gap: 10px; + } + .hero-actions .primary, + .hero-actions .secondary { + min-height: 42px; + padding: 0 18px; + font-size: 14px; + border-radius: 14px; + } + .hero-carousel-dots { + display: none; + } +} + +.hero-dot { + width: 12px; + height: 12px; + border-radius: 999px; + border: 0; + padding: 0; + background: rgba(255, 255, 255, 0.35); + cursor: pointer; + transition: width 240ms cubic-bezier(0.2, 0.7, 0.2, 1), background-color 240ms ease, opacity 240ms ease; +} + +.hero-dot:hover { + background: rgba(255, 255, 255, 0.75); +} + +.hero-dot.is-active { + width: 32px; + background: #fff; + box-shadow: 0 0 14px rgba(255, 255, 255, 0.5); } @keyframes arvio-fade-in { diff --git a/web/components/home/HomeScreen.tsx b/web/components/home/HomeScreen.tsx index 8b1080e1e..432108483 100644 --- a/web/components/home/HomeScreen.tsx +++ b/web/components/home/HomeScreen.tsx @@ -55,20 +55,40 @@ export function HomeScreen() { }; const heroPool = heroPoolRows; + const [heroIndex, setHeroIndex] = useState(0); + const [heroPaused, setHeroPaused] = useState(false); + const touchStartX = useRef(null); - // Auto-advance the hero every 8s until the user hovers a card (which pins the - // hero to whatever they're pointing at and stops the carousel). + const handleHeroTouchStart = (e: React.TouchEvent) => { + touchStartX.current = e.touches[0].clientX; + }; + + const handleHeroTouchEnd = (e: React.TouchEvent) => { + if (touchStartX.current === null || heroPool.length < 2) return; + const dx = e.changedTouches[0].clientX - touchStartX.current; + const threshold = 50; + if (dx < -threshold) { + setHeroIndex((prev) => (prev + 1) % heroPool.length); + } else if (dx > threshold) { + setHeroIndex((prev) => (prev - 1 + heroPool.length) % heroPool.length); + } + touchStartX.current = null; + }; + + // Auto-advance hero carousel every 6.5 seconds unless hovered directly useEffect(() => { - if (heroPool.length < 2) return undefined; - let index = 0; - if (!userInteractedHero.current) setHeroPreview(heroPool[0]); + if (heroPool.length < 2 || heroPaused) return undefined; const timer = window.setInterval(() => { - if (userInteractedHero.current) return; - index = (index + 1) % heroPool.length; - setHeroPreview(heroPool[index]); - }, 8000); + setHeroIndex((prev) => (prev + 1) % heroPool.length); + }, 6500); return () => window.clearInterval(timer); - }, [heroPool, setHeroPreview]); + }, [heroPool.length, heroPaused]); + + useEffect(() => { + if (heroPool[heroIndex]) { + setHeroPreview(heroPool[heroIndex]); + } + }, [heroIndex, heroPool, setHeroPreview]); // Synchronize hero changes so all content (logo, text, metadata, backdrop) updates together. useEffect(() => { @@ -109,12 +129,6 @@ export function HomeScreen() { }; }, [hero, displayHero]); - const onCardFocus = (item: MediaItem) => { - userInteractedHero.current = true; - if (hoverTimer.current) clearTimeout(hoverTimer.current); - hoverTimer.current = setTimeout(() => setHeroPreview(item), 220); - }; - const heroGenres = (displayHero?.genres?.length ? displayHero.genres : genreNamesFromIds(displayHero?.genreIds)).slice(0, 3); // Real IMDb rating for the hero (Cinemeta by imdb id) — TMDB's vote_average @@ -144,7 +158,14 @@ export function HomeScreen() { return (
{displayHero && ( -
+
setHeroPaused(true)} + onMouseLeave={() => setHeroPaused(false)} + onTouchStart={handleHeroTouchStart} + onTouchEnd={handleHeroTouchEnd} + >
{heroLogo ? ( @@ -163,23 +184,33 @@ export function HomeScreen() { {metaBits.map((bit) => {bit})}

- {(() => { - const desc = displayHero.overview || displayHero.subtitle || "Continue from your ARVIO library."; - return desc.length > 150 ? desc.slice(0, 150) + "..." : desc; - })()} + {displayHero.overview || displayHero.subtitle || "Continue from your ARVIO library."}

+ {heroPool.length > 1 && ( +
+ {heroPool.map((item, idx) => ( +
+ )}
)} {dedupedCategories.map((category) => ( - + ))} {homeServerRows.map((category) => ( - + ))} {catalogConfigs.map((catalog, index) => ( ))} diff --git a/web/components/media/MediaCard.tsx b/web/components/media/MediaCard.tsx index 2384b2d49..f55ed0e3f 100644 --- a/web/components/media/MediaCard.tsx +++ b/web/components/media/MediaCard.tsx @@ -96,6 +96,23 @@ function MediaCardBase({ item, onOpen, onFocus, posterMode }: { if (longPressTimer.current) clearTimeout(longPressTimer.current); }; + const hoverTimer = useRef | null>(null); + + const handleMouseEnter = () => { + if (hoverTimer.current) clearTimeout(hoverTimer.current); + hoverTimer.current = setTimeout(() => { + prefetchDetails(item); + onFocus?.(item); + }, 120); + }; + + const handleMouseLeave = () => { + if (hoverTimer.current) { + clearTimeout(hoverTimer.current); + hoverTimer.current = null; + } + }; + // Rails load lazily, so a card only mounts when its row is near the viewport — // fetch the title-treatment logo on mount (getLogoUrl is cached + persisted). useEffect(() => { @@ -164,7 +181,8 @@ function MediaCardBase({ item, onOpen, onFocus, posterMode }: { onTouchStart={handleTouchStart} onTouchEnd={handleTouchEnd} onTouchMove={handleTouchEnd} - onMouseEnter={() => { prefetchDetails(item); onFocus?.(item); }} + onMouseEnter={handleMouseEnter} + onMouseLeave={handleMouseLeave} onFocus={() => { prefetchDetails(item); onFocus?.(item); }} >
diff --git a/web/public/version.json b/web/public/version.json index 6fdf9b6f7..a4561b949 100644 --- a/web/public/version.json +++ b/web/public/version.json @@ -1 +1 @@ -{"v":"1785410663517"} \ No newline at end of file +{"v":"1785425600261"} \ No newline at end of file From 4ab9757407f09d8c7777801a6b98c19c4d95ea78 Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Thu, 30 Jul 2026 21:13:07 +0530 Subject: [PATCH 10/15] fix(web): position Live TV screen cleanly below top navigation bar --- web/app/globals.css | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/web/app/globals.css b/web/app/globals.css index 86f73d02f..c8df7cb0d 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -256,8 +256,7 @@ button { sets its own. Without these exclusions those pages rendered ~232px of empty space above the first heading instead of ~112px. */ .screen:has(.hero), -.screen:has(> .section-heading:first-child), -.screen.livetv-shell { +.screen:has(> .section-heading:first-child) { padding-top: 0; } @@ -10777,7 +10776,7 @@ button, display: flex; flex-direction: column; gap: 14px; - padding: clamp(84px, 9vh, 104px) clamp(16px, 2.6vw, 40px) 24px; + padding: clamp(112px, 14.3vh, 154px) clamp(16px, 2.6vw, 40px) 24px; min-height: 100vh; } From b68aa02129058524d12276e306f5cea6026bedd1 Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Thu, 30 Jul 2026 21:21:29 +0530 Subject: [PATCH 11/15] perf(web): preserve tab DOM instances and update hero non-blockingly for instant navigation --- web/components/home/HomeScreen.tsx | 23 ++++------------------- web/components/shell/AppShell.tsx | 12 ++++++------ 2 files changed, 10 insertions(+), 25 deletions(-) diff --git a/web/components/home/HomeScreen.tsx b/web/components/home/HomeScreen.tsx index 432108483..3672d4590 100644 --- a/web/components/home/HomeScreen.tsx +++ b/web/components/home/HomeScreen.tsx @@ -99,35 +99,20 @@ export function HomeScreen() { } let active = true; + setDisplayHero(hero); - // Fast path: if no hero is currently displayed, show it immediately so there is no blank screen on first load - if (!displayHero) { - setDisplayHero(hero); - void getLogoUrl({ mediaType: hero.mediaType, id: hero.id }) - .then((url) => { - if (active) setHeroLogo(url); - }) - .catch(() => undefined); - return; - } - - // Normal path: fetch the logo in the background first, then swap all content together void getLogoUrl({ mediaType: hero.mediaType, id: hero.id }) .then((url) => { - if (!active) return; - setHeroLogo(url); - setDisplayHero(hero); + if (active) setHeroLogo(url); }) .catch(() => { - if (!active) return; - setHeroLogo(null); - setDisplayHero(hero); + if (active) setHeroLogo(null); }); return () => { active = false; }; - }, [hero, displayHero]); + }, [hero]); const heroGenres = (displayHero?.genres?.length ? displayHero.genres : genreNamesFromIds(displayHero?.genreIds)).slice(0, 3); diff --git a/web/components/shell/AppShell.tsx b/web/components/shell/AppShell.tsx index 02aa252a0..714cfdda3 100644 --- a/web/components/shell/AppShell.tsx +++ b/web/components/shell/AppShell.tsx @@ -90,12 +90,12 @@ export function AppShell() { ) : ( <> - {section === "home" && } - {section === "search" && } - {section === "watchlist" && } - {section === "tv" && } - {section === "addons" && } - {section === "settings" && } +
+
+
+
+
+
)}
From 1487a4df53321078164725b37f63ecf5b12cbe2b Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Thu, 30 Jul 2026 21:36:20 +0530 Subject: [PATCH 12/15] fix(web): preserve tab screens when DetailsDrawer opens and fix hero overview layout shifts --- web/app/globals.css | 8 ++++---- web/components/shell/AppShell.tsx | 19 +++++++------------ 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/web/app/globals.css b/web/app/globals.css index c8df7cb0d..1a01ea504 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -9634,8 +9634,8 @@ button, .hero-overview { font-size: clamp(14px, 1.15vw, 18px) !important; line-height: 1.45 !important; - height: auto !important; - max-height: calc(3 * 1.45em) !important; + min-height: calc(3 * 1.45em) !important; + height: calc(3 * 1.45em) !important; display: -webkit-box !important; -webkit-line-clamp: 3 !important; -webkit-box-orient: vertical !important; @@ -12112,8 +12112,8 @@ html { background: #000; } .hero-copy p:not(.eyebrow) { font-size: 13px !important; line-height: 1.45 !important; - height: auto !important; - max-height: calc(2 * 1.45em) !important; + min-height: calc(2 * 1.45em) !important; + height: calc(2 * 1.45em) !important; -webkit-line-clamp: 2 !important; margin-bottom: 4px !important; } diff --git a/web/components/shell/AppShell.tsx b/web/components/shell/AppShell.tsx index 714cfdda3..b8d84d144 100644 --- a/web/components/shell/AppShell.tsx +++ b/web/components/shell/AppShell.tsx @@ -86,18 +86,13 @@ export function AppShell() { {!activeStream && }
- {selected ? ( - - ) : ( - <> -
-
-
-
-
-
- - )} +
+
+
+
+
+
+ {selected && }
From 8c8beef8cf3e730d1480d7da764f5a8b95066cca Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Mon, 3 Aug 2026 11:27:25 +0530 Subject: [PATCH 13/15] fix(web): address PR review comments for Live TV refresh, context menu dismissal, and cloud watched sync --- web/components/livetv/LiveTvScreen.tsx | 6 +++--- web/components/shell/AppShell.tsx | 2 +- web/components/shell/MediaContextMenu.tsx | 1 + web/lib/store.tsx | 25 +++++++++++++++++++++-- 4 files changed, 28 insertions(+), 6 deletions(-) diff --git a/web/components/livetv/LiveTvScreen.tsx b/web/components/livetv/LiveTvScreen.tsx index b6bfd2ae6..fd055c2b3 100644 --- a/web/components/livetv/LiveTvScreen.tsx +++ b/web/components/livetv/LiveTvScreen.tsx @@ -27,7 +27,7 @@ function groupLabel(group: string) { return group.trim() || "Uncategorized"; } -export function LiveTvScreen() { +export function LiveTvScreen({ active = true }: { active?: boolean }) { const { iptvSnapshot, settings, setSettings, playChannel, playCatchup, setToast, refreshIptv, loadIptvGuide, busy, auth } = useApp(); // Open a channel straight in VLC/Infuse from the detail panel — the reliable @@ -86,14 +86,14 @@ export function LiveTvScreen() { // is already cached). Reuse the snapshot that is still in memory and only // rebuild when the playlists actually changed, or when it has gone stale. useEffect(() => { - if (!playlists.length) return; + if (!active || !playlists.length) return; const snapshotMatchesPlaylists = iptvSnapshot.channels.length > 0 && iptvSnapshot.signature === playlistSignature; const age = Date.now() - (iptvSnapshot.loadedAt ?? 0); if (snapshotMatchesPlaylists && age < IPTV_SNAPSHOT_TTL_MS) return; void refreshIptv(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [playlistSignature, refreshIptv, playlists.length]); + }, [active, playlistSignature, refreshIptv, playlists.length]); const categories = useMemo(() => { const orderMap = new Map(iptvSnapshot.groupOrder.map((id, index) => [id, index])); diff --git a/web/components/shell/AppShell.tsx b/web/components/shell/AppShell.tsx index b8d84d144..4c108540d 100644 --- a/web/components/shell/AppShell.tsx +++ b/web/components/shell/AppShell.tsx @@ -89,7 +89,7 @@ export function AppShell() {
-
+
{selected && } diff --git a/web/components/shell/MediaContextMenu.tsx b/web/components/shell/MediaContextMenu.tsx index cde6805a6..d18194461 100644 --- a/web/components/shell/MediaContextMenu.tsx +++ b/web/components/shell/MediaContextMenu.tsx @@ -146,6 +146,7 @@ export function MediaContextMenu() { type="button" className={`context-menu-action-btn ${act.danger ? "is-danger" : ""}`} onClick={() => { + closeContextMenu(); act.action(); }} > diff --git a/web/lib/store.tsx b/web/lib/store.tsx index a6ea7fdae..bd1052183 100644 --- a/web/lib/store.tsx +++ b/web/lib/store.tsx @@ -5,7 +5,7 @@ import { getStreams, getStreamsProgressive, installAddon as installAddonManifest import { AuthClient, SESSION_KEY, decodeJwtPayload } from "./auth"; import { getAuthPortalUrl } from "./config"; import { defaultCatalogs, mergeCatalogs } from "./catalogs"; -import { getContinueWatching, isLiveStreamOrSportsItem, pullCloudPayload, pullCloudProfiles, pullCloudTraktToken, pullCloudWatchlist, saveCloudAddons, saveCloudProfiles, saveCloudSettings, saveCloudTraktToken } from "./cloud"; +import { getContinueWatching, isLiveStreamOrSportsItem, pullCloudPayload, pullCloudProfiles, pullCloudTraktToken, pullCloudWatchlist, saveCloudAddons, saveCloudProfiles, saveCloudSettings, saveCloudTraktToken, saveProgress } from "./cloud"; import { cachedDebridDirectUrl, parseDebridStream, resolveDebridDirectUrl, resolveTranscodeStream } from "./debrid"; import { createPendingExternalPlayback } from "./externalPlayback"; import { externalLaunchMode, openExternalPlayer } from "./externalPlayers"; @@ -1798,6 +1798,27 @@ export function AppProvider({ markWatchedLocally({ mediaType: item.mediaType, id: item.id, season: seasonNumber, episode: episodeNumber }, !currentlyWatched); setToast(!currentlyWatched ? "Marked as watched." : "Marked as unwatched."); + if (authClient.session) { + try { + await saveProgress( + authClient, + { + media_type: item.mediaType, + show_tmdb_id: item.id, + season: seasonNumber ?? item.seasonNumber ?? null, + episode: episodeNumber ?? item.episodeNumber ?? null, + title: item.title, + duration_seconds: 0, + position_seconds: 0, + progress: currentlyWatched ? 0 : 1 + }, + activeProfileId + ); + } catch { + // Cloud sync best effort + } + } + if (activeSyncProvider() !== "none") { try { const ref = { mediaType: item.mediaType, tmdbId: item.id, season: seasonNumber, episode: episodeNumber }; @@ -1810,7 +1831,7 @@ export function AppProvider({ // Sync best effort } } - }, [isWatched, markWatchedLocally]); + }, [isWatched, markWatchedLocally, auth, activeProfileId]); const removeFromContinueWatching = useCallback(async (item: MediaItem) => { const key = mediaWatchKey(item); From bcf7ac213fccdd4c4516708c1c8fc80db559a725 Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Mon, 3 Aug 2026 12:13:36 +0530 Subject: [PATCH 14/15] feat(web): add IPTV Provider Sort Order setting option (resolving Issue #420) --- web/components/livetv/LiveTvScreen.tsx | 28 ++++++++++-- web/components/settings/SettingsScreen.tsx | 11 +++++ web/lib/store.tsx | 7 ++- web/lib/types.ts | 52 ++++++++++++++++++++++ 4 files changed, 93 insertions(+), 5 deletions(-) diff --git a/web/components/livetv/LiveTvScreen.tsx b/web/components/livetv/LiveTvScreen.tsx index fd055c2b3..142edb041 100644 --- a/web/components/livetv/LiveTvScreen.tsx +++ b/web/components/livetv/LiveTvScreen.tsx @@ -101,6 +101,7 @@ export function LiveTvScreen({ active = true }: { active?: boolean }) { const keyed = items[0] ? groupKey(items[0]) : group; return orderMap.get(keyed) ?? orderMap.get(group) ?? Number.MAX_SAFE_INTEGER; }; + const sortMode = settings.iptvSortOrder ?? "provider"; const groupRows = Object.entries(groups) .map(([group, items]) => ({ id: `group:${group}`, @@ -111,13 +112,19 @@ export function LiveTvScreen({ active = true }: { active?: boolean }) { rank: groupRank(group, items) })) .filter((group) => !group.hidden) - .sort((a, b) => Number(b.favorite) - Number(a.favorite) || a.rank - b.rank || b.count - a.count || a.label.localeCompare(b.label)); + .sort((a, b) => { + if (Number(b.favorite) !== Number(a.favorite)) return Number(b.favorite) - Number(a.favorite); + if (a.rank !== b.rank) return a.rank - b.rank; + if (sortMode === "name") return a.label.localeCompare(b.label); + if (sortMode === "number") return b.count - a.count || a.label.localeCompare(b.label); + return 0; + }); return [ { id: "all", label: "All Channels", count: channels.length, favorite: false, hidden: false }, { id: "favorites", label: "Favorites", count: favoriteChannels.length, favorite: true, hidden: false }, ...groupRows ]; - }, [channels.length, favoriteChannels.length, favoriteGroups, groups, hiddenGroups, iptvSnapshot.groupOrder]); + }, [channels.length, favoriteChannels.length, favoriteGroups, groups, hiddenGroups, iptvSnapshot.groupOrder, settings.iptvSortOrder]); const visibleChannels = useMemo(() => { const base = activeCategory === "favorites" @@ -126,14 +133,27 @@ export function LiveTvScreen({ active = true }: { active?: boolean }) { ? groups[activeCategory.slice(6)] ?? [] : channels; const needle = query.trim().toLowerCase(); - return needle + const filtered = needle ? base.filter((channel) => channel.name.toLowerCase().includes(needle) || channel.group.toLowerCase().includes(needle) || channel.tvgId?.toLowerCase().includes(needle) ) : base; - }, [activeCategory, channels, favoriteChannels, groups, query]); + const sortMode = settings.iptvSortOrder ?? "provider"; + if (sortMode === "number") { + return [...filtered].sort((a, b) => { + const numA = a.number ? parseInt(a.number, 10) : Number.MAX_SAFE_INTEGER; + const numB = b.number ? parseInt(b.number, 10) : Number.MAX_SAFE_INTEGER; + if (numA !== numB) return numA - numB; + return a.name.localeCompare(b.name); + }); + } + if (sortMode === "name") { + return [...filtered].sort((a, b) => a.name.localeCompare(b.name)); + } + return filtered; + }, [activeCategory, channels, favoriteChannels, groups, query, settings.iptvSortOrder]); useEffect(() => { setVisibleCount(CHANNEL_PAGE_SIZE); diff --git a/web/components/settings/SettingsScreen.tsx b/web/components/settings/SettingsScreen.tsx index 3e9b2df5f..74e9d96a4 100644 --- a/web/components/settings/SettingsScreen.tsx +++ b/web/components/settings/SettingsScreen.tsx @@ -1845,6 +1845,17 @@ function TvSettingsSection() { return ( + + updateSettings({ iptvSortOrder: v as "provider" | "number" | "name" })} - options={[ - ["provider", "Provider Order (Default)"], - ["number", "Channel Number"], - ["name", "Alphabetical (A-Z)"] - ]} - /> -

{playlists.length} playlist(s) configured. These are cloud-saved and used by the TV page. diff --git a/web/components/shell/AppShell.tsx b/web/components/shell/AppShell.tsx index 4c108540d..24bfbd7a9 100644 --- a/web/components/shell/AppShell.tsx +++ b/web/components/shell/AppShell.tsx @@ -63,7 +63,7 @@ export function AppShell() { if (!mounted) { return (

- + ARVIO
); @@ -86,13 +86,18 @@ export function AppShell() { {!activeStream && }
-
-
-
-
-
-
- {selected && } + {selected ? ( + + ) : ( + <> + {section === "home" && } + {section === "search" && } + {section === "watchlist" && } + {section === "tv" && } + {section === "addons" && } + {section === "settings" && } + + )}
diff --git a/web/components/shell/MediaContextMenu.tsx b/web/components/shell/MediaContextMenu.tsx index d18194461..3d9efc6d9 100644 --- a/web/components/shell/MediaContextMenu.tsx +++ b/web/components/shell/MediaContextMenu.tsx @@ -117,7 +117,7 @@ export function MediaContextMenu() { : {}; return ( -
+
- + ARVIO
diff --git a/web/components/shell/TopNav.tsx b/web/components/shell/TopNav.tsx index 40a2613d2..285db75a0 100644 --- a/web/components/shell/TopNav.tsx +++ b/web/components/shell/TopNav.tsx @@ -31,7 +31,7 @@ export function TopNav() {
diff --git a/web/lib/store.tsx b/web/lib/store.tsx index 1dd8b59f6..44f402fe3 100644 --- a/web/lib/store.tsx +++ b/web/lib/store.tsx @@ -181,11 +181,7 @@ export const defaultSettings: AppSettings = { favoriteChannelIds: [], favoriteGroupIds: [], hiddenGroupIds: [], - groupOrder: [], - pluginsEnabled: true, - groupStreamsByRepository: false, - repositories: [], - iptvSortOrder: "provider" + groupOrder: [] }; @@ -1815,7 +1811,7 @@ export function AppProvider({ title: item.title, duration_seconds: 0, position_seconds: 0, - progress: currentlyWatched ? 0 : 1 + progress: currentlyWatched ? 0 : 100 }, activeProfileId ); @@ -1836,7 +1832,7 @@ export function AppProvider({ // Sync best effort } } - }, [isWatched, markWatchedLocally, auth, activeProfileId]); + }, [isWatched, markWatchedLocally, authClient, activeProfileId]); const removeFromContinueWatching = useCallback(async (item: MediaItem) => { const key = mediaWatchKey(item); @@ -1854,6 +1850,27 @@ export function AppProvider({ setToast("Removed from Continue Watching."); + if (authClient.session) { + try { + await saveProgress( + authClient, + { + media_type: item.mediaType, + show_tmdb_id: item.id, + season: item.seasonNumber ?? null, + episode: item.episodeNumber ?? null, + title: item.title, + duration_seconds: 0, + position_seconds: 0, + progress: 100 + }, + activeProfileId + ); + } catch { + // Cloud sync best effort + } + } + if (activeSyncProvider() !== "none") { try { await syncClient().removeFromHistory({ mediaType: item.mediaType, tmdbId: item.id, season: item.seasonNumber, episode: item.episodeNumber }); @@ -1861,7 +1878,7 @@ export function AppProvider({ // Sync best effort } } - }, [activeProfileId]); + }, [activeProfileId, authClient]); const value = useMemo(() => ({ view, diff --git a/web/lib/types.ts b/web/lib/types.ts index 5fc87dded..378fe376a 100644 --- a/web/lib/types.ts +++ b/web/lib/types.ts @@ -467,56 +467,4 @@ export interface AppSettings { favoriteGroupIds: string[]; hiddenGroupIds: string[]; groupOrder: string[]; - iptvSortOrder?: "provider" | "number" | "name"; - // Plugins & Scrapers - - pluginsEnabled: boolean; - groupStreamsByRepository: boolean; - repositories: PluginRepository[]; -} - - -export interface PluginRepository { - id: string; - name: string; - url: string; - scraperCount: number; - version?: string; - description?: string; - updatedAt?: number; - enabled?: boolean; -} - -export interface PluginScraper { - id: string; - name: string; - repoId: string; - repoName: string; - enabled: boolean; - version?: string; - description?: string; - supportedTypes?: MediaType[]; } - -export interface ScraperTestResult { - scraperId: string; - scraperName: string; - status: "success" | "error" | "loading"; - latencyMs?: number; - streamCount?: number; - errorMessage?: string; - streams?: StreamSource[]; -} - -export interface PluginUiState { - pluginsEnabled: boolean; - groupStreamsByRepository: boolean; - repositories: PluginRepository[]; - scrapers: PluginScraper[]; - isLoading?: boolean; - isAddingRepo?: boolean; - errorMessage?: string | null; - successMessage?: string | null; - testResult?: ScraperTestResult | null; -} - diff --git a/web/public/version.json b/web/public/version.json index a4561b949..81beef3a7 100644 --- a/web/public/version.json +++ b/web/public/version.json @@ -1 +1 @@ -{"v":"1785425600261"} \ No newline at end of file +{"v":"1785220462255"} \ No newline at end of file