Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
cfd382a
feat(web): add PC right click and mobile long-press context menu capa…
Himanth-reddy Jul 30, 2026
93bc2eb
fix(web): pin hero banner slot heights to prevent catalog rails from …
Himanth-reddy Jul 30, 2026
d075b85
fix(web): adjust hero overview text font size, line height, and botto…
Himanth-reddy Jul 30, 2026
474d244
fix(web): match lazy rail skeleton cards layout and spacing with load…
Himanth-reddy Jul 30, 2026
ac9154e
fix(web): resolve catalog poster mode skeletons and use reliable tmdb…
Himanth-reddy Jul 30, 2026
8dd3b85
fix(web): use rounded corner app icon for loading screen and brand he…
Himanth-reddy Jul 30, 2026
47a225b
feat(web): enable individual poster skeleton placeholders and per-car…
Himanth-reddy Jul 30, 2026
15af469
fix(web): scope poster image fade-in to poster-art so IMDb and servic…
Himanth-reddy Jul 30, 2026
01895d2
feat(web): add auto-rotating hero carousel, GPU shimmer, touch swipe,…
Himanth-reddy Jul 30, 2026
4ab9757
fix(web): position Live TV screen cleanly below top navigation bar
Himanth-reddy Jul 30, 2026
b68aa02
perf(web): preserve tab DOM instances and update hero non-blockingly …
Himanth-reddy Jul 30, 2026
1487a4d
fix(web): preserve tab screens when DetailsDrawer opens and fix hero …
Himanth-reddy Jul 30, 2026
473c244
Merge branch 'ProdigyV21:main' into feature/web-right-click-context-menu
Himanth-reddy Jul 30, 2026
8c8beef
fix(web): address PR review comments for Live TV refresh, context men…
Himanth-reddy Aug 3, 2026
bcf7ac2
feat(web): add IPTV Provider Sort Order setting option (resolving Iss…
Himanth-reddy Aug 3, 2026
e236348
feat(web): add right-click context menu, long-press menu, and fix wat…
Himanth-reddy Aug 4, 2026
2e3f248
Merge main and complete web context menu behavior
Aug 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 186 additions & 0 deletions web/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -11960,3 +11960,189 @@ 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-art {
position: relative;
width: calc(100% + 32px);
margin: -16px -16px 12px -16px;
height: 110px;
overflow: hidden;
}

.context-menu-header-art img {
width: 100%;
height: 100%;
object-fit: cover;
}

.context-menu-header-art-gradient {
position: absolute;
inset: 0;
background: linear-gradient(180deg, transparent 0%, rgba(12, 14, 18, 0.95) 100%);
}

.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;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.4);
text-align: center;
}

.context-menu-card.is-floating .context-menu-footer-hint {
display: none;
}

@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); }
}
78 changes: 76 additions & 2 deletions web/components/details/DetailsDrawer.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<MediaItem>(item);
const [detailsLoading, setDetailsLoading] = useState(false);
const [reviews, setReviews] = useState<ReviewInfo[]>([]);
Expand Down Expand Up @@ -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, setToast, toggleWatched } = useApp();
const seasons = item.seasons ?? [];
const [season, setSeason] = useState(seasons[0]?.seasonNumber ?? 1);
const [episodes, setEpisodes] = useState<EpisodeInfo[]>([]);
Expand All @@ -913,6 +914,77 @@ function SeasonEpisodes({ item, loadingDetails, selectedEpisode, isWatched, onPl
return () => { active = false; };
}, [item.id, season, retryNonce]);

const updateSeasonWatched = async (seasonNum: number, watched: boolean) => {
try {
const targetEpisodes = await getSeasonEpisodes(item.id, seasonNum);
for (const ep of targetEpisodes) {
if (isWatched(item, seasonNum, ep.episodeNumber) !== watched) {
await toggleWatched(item, seasonNum, ep.episodeNumber);
}
}
setToast(`Season ${seasonNum} marked as ${watched ? "watched" : "unwatched"}.`);
} catch {
setToast(`Could not update Season ${seasonNum}. Please try again.`);
}
};

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: <Check size={18} />,
action: async () => {
await updateSeasonWatched(seasonNum, true);
}
},
{
id: "mark_season_unwatched",
label: "Mark Season Unwatched",
icon: <EyeOff size={18} />,
action: async () => {
await updateSeasonWatched(seasonNum, 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: <Play size={18} fill="currentColor" />,
action: () => {
onPlayEpisode(season, ep.episodeNumber);
}
},
{
id: "toggle_episode_watched",
label: watched ? "Mark as Unwatched" : "Mark as Watched",
icon: watched ? <EyeOff size={18} /> : <Check size={18} />,
action: () => {
void toggleWatched(item, season, ep.episodeNumber);
}
}
]
});
};

return (
<section className="detail-section episodes-section detail-wide">
<h3>Episodes</h3>
Expand All @@ -923,6 +995,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}`}
</button>
Expand All @@ -947,6 +1020,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)}
>
<div className="episode-still">
{episode.still ? <img src={episode.still} alt="" /> : <Clapperboard size={24} />}
Expand Down
Loading
Loading