Files
mediahive/frontend/src/components/MediaDetail.vue
T

1651 lines
38 KiB
Vue

<template>
<!-- Full screen view for series -->
<SeriesFullView
v-if="item.type === 'series'"
:series="item.data as Series"
:focus-episode="focusEpisode"
:has-resume-position="hasResumePosition"
:get-root-name="getRootName"
@close="$emit('close')"
@play="handlePlay"
@openFolder="handleOpenFolder"
/>
<!-- Full page view for movies -->
<div v-else class="movie-page">
<div class="movie-page-content">
<!-- Diagonal collage header -->
<div class="collage-header">
<!-- Background collage of showreel videos -->
<div class="collage-grid">
<div
v-for="slot in collageSlots"
:key="slot.index"
class="collage-item"
@mouseenter="handleVideoHover(slot.index, true)"
@mouseleave="handleVideoHover(slot.index, false)"
>
<div
class="collage-fallback-tile"
:class="`collage-fallback-${slot.index + 1}`"
></div>
<video
v-if="slot.sourcePaths.length > 0"
:ref="el => setVideoRef(el as HTMLVideoElement, slot.index)"
:class="{ 'is-ready': isVideoReady(slot.index) }"
:autoplay="safariAutoplay"
loop
muted
playsinline
@loadeddata="handleVideoLoaded(slot.index)"
@error="handleVideoError(slot.index)"
>
<source
v-for="sourcePath in slot.sourcePaths"
:key="sourcePath"
:src="getShowreelUrl(sourcePath)"
:type="getShowreelSourceAttributes(sourcePath).type"
:codecs="getShowreelSourceAttributes(sourcePath).codecs"
>
</video>
</div>
</div>
<!-- Diagonal overlay -->
<div class="collage-overlay"></div>
<!-- Title and meta on top -->
<div class="collage-content">
<h1 class="modal-title">{{ item.title }}</h1>
<p v-if="movieTagline" class="header-tagline">{{ movieTagline }}</p>
<div class="modal-meta">
<span v-if="rating" class="meta-rating" :class="ratingClass"> {{ rating.toFixed(1) }}</span>
<span v-if="item.year" class="meta-year">{{ item.year }}</span>
<span v-if="movieRuntime" class="meta-runtime">{{ formatRuntime(movieRuntime) }}</span>
</div>
<!-- Genre tags in header -->
<div v-if="movieGenres && movieGenres.length > 0" class="header-genres">
<span v-for="genre in movieGenres" :key="genre" class="genre-tag">{{ genre }}</span>
</div>
</div>
</div>
<div class="modal-body" :style="backdropStyle">
<!-- Backdrop overlay for contrast -->
<div class="backdrop-overlay"></div>
<div class="modal-body-inner">
<div class="content-layout">
<!-- Left sidebar - Synopsis -->
<div v-if="synopsisPosterUrl" class="content-sidebar sidebar-left">
<div class="synopsis-box">
<img
:src="synopsisPosterUrl"
:alt="`${item.title} poster`"
class="synopsis-poster"
>
</div>
<div v-if="movieVersions.length > 0" class="versions-list versions-list-sidebar">
<ReleaseVersionCard
v-for="(version, index) in movieVersions"
:key="index"
:torrent="version"
:best="index === 0"
:selectable="!!version.playable_file"
:disabled="!version.playable_file"
v-bind="navAttrs(2, index, index === 0 ? 0 : undefined)"
@activate="handleVersionActivate(version, $event)"
@keydown="handleVersionShortcutKeydown($event, version)"
@contextmenu="handleVersionContextMenu($event, version)"
:title="version.playable_file ? 'Click to play/continue. Alt+Click, Alt+Enter, or Cmd/Ctrl+E to open folder. Right-click for actions.' : 'No playable file'"
/>
</div>
</div>
<div v-if="limitedMovieCast.length > 0" class="cast-list" data-sync-scroll-row="true">
<div
v-for="(castMember, castIndex) in limitedMovieCast"
:key="`${castMember.name}-${castMember.character || ''}`"
class="cast-card media-card"
v-bind="navAttrs(2, movieVersions.length + castIndex)"
role="button"
:title="`Search for ${castMember.name}`"
@click="handleCastSelect(castMember.name)"
>
<img
v-if="castMember.profile_path && !castMember.profile_path.startsWith('/')"
:src="getCoverUrl(castMember.profile_path, item.root_id)"
:alt="castMember.name"
class="cast-photo"
>
<img v-else :src="getCastPlaceholderUrl(castMember.gender)" :alt="`${castMember.name} placeholder portrait`" class="cast-photo cast-photo-fallback">
<div class="cast-copy">
<span class="cast-name">{{ castMember.name }}</span>
<span v-if="castMember.character" class="cast-character">{{ castMember.character }}</span>
</div>
</div>
</div>
<!-- Main content -->
<div class="content-main">
</div>
<!-- Right sidebar - Metadata -->
<div v-if="item.type === 'movies'" class="content-sidebar sidebar-right">
<div class="movie-metadata">
<div v-if="overview" class="meta-row">
<span class="meta-value meta-synopsis">{{ overview }}</span>
</div>
<div v-if="movieDirector" class="meta-row">
<span class="meta-label">Director</span>
<span class="meta-value">{{ movieDirector }}</span>
</div>
<div v-if="movieStatus || movieReleaseDate" class="meta-summary">
<span v-if="movieStatus" class="meta-summary-item">{{ movieStatus }}</span>
<span v-if="movieReleaseDate" class="meta-summary-item">{{ movieReleaseDate }}</span>
</div>
<div v-if="movieKeywords && movieKeywords.length > 0" class="meta-keywords-section">
<span class="meta-value keywords">{{ movieKeywords.join(', ') }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<Teleport to="body">
<div
v-if="versionActionMenu.visible"
class="movie-menu-backdrop"
@click="closeVersionActionMenu"
@contextmenu.prevent="closeVersionActionMenu"
></div>
<ReleaseActionMenu
:visible="versionActionMenu.visible"
:x="versionActionMenu.x"
:y="versionActionMenu.y"
:file-path="versionActionMenu.filePath"
:root-name="versionActionMenu.rootName"
:play-label="getPlayLabel(versionActionMenu.filePath)"
@play="handlePlayVersion(versionActionMenu.filePath)"
@open-folder="handleOpenFolder(versionActionMenu.filePath || '', versionActionMenu.rootId)"
/>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch, onMounted, onUnmounted, nextTick } from 'vue';
import type { CastMember, MediaItem, Movie, Series, Torrent } from '../types';
import { getCoverUrl, getVideoPreviewUrl, getVideoSourceAttributes, isSafariBrowser, type VideoSourceAttributes } from '../api';
import castPlaceholderFemaleUrl from '../assets/cast-placeholder-female.svg';
import castPlaceholderMaleUrl from '../assets/cast-placeholder-male.svg';
import SeriesFullView from './SeriesFullView.vue';
import ReleaseVersionCard from './ReleaseVersionCard.vue';
import ReleaseActionMenu from './ReleaseActionMenu.vue';
import { navAttrs } from '../composables/useKeyboardNavigation';
const props = defineProps<{
item: MediaItem;
focusEpisode?: { seasonNumber: number; episodeNumber: number } | null;
hasResumePosition: (filePath: string | null) => boolean;
getRootName: (rootId: string | null | undefined) => string | null;
}>();
const emit = defineEmits<{
close: [];
play: [string];
openFolder: [string, string | null | undefined];
searchActor: [string];
}>();
// Track expanded episode for showing multiple releases
const videoRefs = ref<(HTMLVideoElement | null)[]>([]);
const videoStates = ref<string[]>([]);
const COLLAGE_SLOT_COUNT = 5;
const safariAutoplay = isSafariBrowser();
const COLLAGE_START_OFFSETS_SECONDS = [0, 8, 6, 4, 2];
function setVideoRef(el: HTMLVideoElement | null, index: number) {
videoRefs.value[index] = el;
}
function handleVideoLoaded(index: number) {
videoStates.value[index] = 'ready';
}
function handleVideoError(index: number) {
videoStates.value[index] = 'error';
}
function isVideoReady(index: number): boolean {
return videoStates.value[index] === 'ready';
}
// Start staggered video playback
function startStaggeredPlayback() {
const videos = videoRefs.value.filter(v => v !== null) as HTMLVideoElement[];
if (videos.length === 0) return;
if (safariAutoplay) {
videos.forEach((video, index) => {
const offset = COLLAGE_START_OFFSETS_SECONDS[index] ?? 0;
const startVideo = () => {
video.currentTime = offset;
video.play().catch(() => {});
};
if (video.readyState >= 1) {
startVideo();
} else {
video.addEventListener('loadedmetadata', startVideo, { once: true });
}
});
return;
}
// Start first video immediately
// Non-Safari keeps legacy behavior: start without explicit seek offset.
videos[0].play().catch(() => {});
// Set up staggered start for remaining videos
for (let i = 1; i < videos.length; i++) {
setTimeout(() => {
const video = videos[i];
if (!video) return;
video.play().catch(() => {});
}, i * 2000);
}
}
// Volume fade animation tracking
const volumeFadeIntervals = new Map<number, ReturnType<typeof setInterval>>();
// Handle hover-based audio fade in/out
function handleVideoHover(index: number, isEntering: boolean) {
const video = videoRefs.value[index];
if (!video) return;
// Clear any existing fade for this video
const existingInterval = volumeFadeIntervals.get(index);
if (existingInterval) {
clearInterval(existingInterval);
volumeFadeIntervals.delete(index);
}
if (isEntering) {
// Mute all other videos immediately
document.querySelectorAll('video').forEach(v => {
if (v !== video) {
v.volume = 0;
v.muted = true;
}
});
// Fade in this video's audio
video.muted = false;
const fadeIn = setInterval(() => {
if (video.volume < 0.95) {
video.volume = Math.min(1, video.volume + 0.1);
} else {
video.volume = 1;
clearInterval(fadeIn);
volumeFadeIntervals.delete(index);
}
}, 30);
volumeFadeIntervals.set(index, fadeIn);
} else {
// Fade out this video's audio
const fadeOut = setInterval(() => {
if (video.volume > 0.05) {
video.volume = Math.max(0, video.volume - 0.1);
} else {
video.volume = 0;
video.muted = true;
clearInterval(fadeOut);
volumeFadeIntervals.delete(index);
}
}, 30);
volumeFadeIntervals.set(index, fadeOut);
}
}
onMounted(() => {
// Wait for videos to be ready, then start staggered playback
setTimeout(() => {
startStaggeredPlayback();
}, 100);
});
const showreelSourceSets = computed((): string[][] | null => {
if (props.item.type === 'movies') {
const movie = props.item.data as Movie;
if (movie.showreel_source_sets && movie.showreel_source_sets.length > 0) {
return movie.showreel_source_sets;
}
return movie.showreel_images?.map((path) => [path]) ?? null;
} else {
const series = props.item.data as Series;
const sourceSets: string[][] = [];
for (const season of series.seasons || []) {
for (const episode of season.episodes || []) {
if (episode.reel_sources && episode.reel_sources.length > 0) {
sourceSets.push(episode.reel_sources);
} else if (episode.reel_image) {
sourceSets.push([episode.reel_image]);
}
}
}
return sourceSets.length > 0 ? sourceSets : null;
}
});
const collageSourceSets = computed((): string[][] => {
if (!showreelSourceSets.value || showreelSourceSets.value.length === 0) return [];
return showreelSourceSets.value.slice(0, 5);
});
const collageSlots = computed(() => {
return Array.from({ length: COLLAGE_SLOT_COUNT }, (_, index) => ({
index,
sourcePaths: collageSourceSets.value[index] ?? [],
}));
});
watch(collageSlots, async (slots) => {
videoRefs.value = Array.from({ length: COLLAGE_SLOT_COUNT }, (_, index) => videoRefs.value[index] ?? null);
videoStates.value = slots.map((slot) => slot.sourcePaths.length > 0 ? 'loading' : 'missing');
await nextTick();
setTimeout(() => {
startStaggeredPlayback();
}, 100);
}, { immediate: true });
function getShowreelUrl(path: string): string {
return getVideoPreviewUrl(getCoverUrl(path, props.item.root_id));
}
function getShowreelSourceAttributes(path: string): VideoSourceAttributes {
return getVideoSourceAttributes(path);
}
// Movie versions
const movieVersions = computed((): Torrent[] => {
if (props.item.type !== 'movies') return [];
const movie = props.item.data as Movie;
return Object.values(movie.torrents || {});
});
// Page backdrop background
const backdropStyle = computed(() => {
if (props.item.type !== 'movies') return {};
const movie = props.item.data as Movie;
const imagePath = movie.backdrop_path;
const imageUrl = getCoverUrl(imagePath, props.item.root_id);
if (imageUrl) {
return { backgroundImage: `url("${imageUrl}")` };
}
return {};
});
const synopsisPosterUrl = computed(() => {
if (props.item.type !== 'movies') return null;
return getCoverUrl(props.item.cover_path, props.item.root_id);
});
const movieGenres = computed(() => {
if (props.item.type !== 'movies') return null;
return (props.item.data as Movie).info?.genres;
});
const movieTagline = computed(() => {
if (props.item.type !== 'movies') return null;
return (props.item.data as Movie).info?.tagline;
});
const movieDirector = computed(() => {
if (props.item.type !== 'movies') return null;
return (props.item.data as Movie).info?.director;
});
const movieCast = computed(() => {
if (props.item.type !== 'movies') return null;
return (props.item.data as Movie).info?.cast as CastMember[] | null;
});
const limitedMovieCast = computed(() => {
if (!movieCast.value) return [];
return movieCast.value;
});
const movieRuntime = computed(() => {
if (props.item.type !== 'movies') return null;
return (props.item.data as Movie).info?.runtime;
});
const movieReleaseDate = computed(() => {
if (props.item.type !== 'movies') return null;
return (props.item.data as Movie).info?.release_date;
});
const movieStatus = computed(() => {
if (props.item.type !== 'movies') return null;
return (props.item.data as Movie).info?.status;
});
const movieKeywords = computed(() => {
if (props.item.type !== 'movies') return null;
return (props.item.data as Movie).info?.keywords;
});
function getCastPlaceholderUrl(gender?: CastMember['gender']): string {
return gender === 'female' ? castPlaceholderFemaleUrl : castPlaceholderMaleUrl;
}
function formatRuntime(minutes: number): string {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
if (hours === 0) return `${mins}m`;
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
}
const rating = computed(() => {
if (props.item.type === 'movies') {
return (props.item.data as Movie).info?.rating;
}
return (props.item.data as Series).info?.rating;
});
const overview = computed(() => {
if (props.item.type === 'movies') {
return (props.item.data as Movie).info?.overview;
}
return (props.item.data as Series).info?.overview;
});
const ratingClass = computed(() => {
if (!rating.value) return '';
if (rating.value >= 7.5) return 'rating-high';
if (rating.value >= 6) return 'rating-medium';
return 'rating-low';
});
const seasons = computed(() => {
if (props.item.type !== 'series') return [];
const series = props.item.data as Series;
return series.seasons || [];
});
const selectedSeasonIndex = ref<number>(0);
const versionActionMenu = ref<{
visible: boolean;
x: number;
y: number;
filePath: string | null;
rootName: string | null;
rootId: string | null;
}>({
visible: false,
x: 0,
y: 0,
filePath: null,
rootName: null,
rootId: null,
});
function closeVersionActionMenu() {
versionActionMenu.value.visible = false;
versionActionMenu.value.filePath = null;
versionActionMenu.value.rootName = null;
versionActionMenu.value.rootId = null;
}
function getPlayLabel(filePath: string | null): string {
return props.hasResumePosition(filePath) ? 'Continue' : 'Play';
}
function handlePlayVersion(filePath: string | null) {
if (filePath) {
emit('play', filePath);
}
closeVersionActionMenu();
}
function handleVersionContextMenu(event: MouseEvent, version: Torrent) {
event.preventDefault();
event.stopPropagation();
const rootId = version.root_id || ((props.item.data as Movie).root_id ?? props.item.root_id);
versionActionMenu.value = {
visible: true,
x: event.clientX,
y: event.clientY,
filePath: version.playable_file || null,
rootName: props.getRootName(rootId) || null,
rootId,
};
nextTick(() => {
const firstAction = document.querySelector('.version-action-menu .version-action-item:not(:disabled)') as HTMLElement | null;
firstAction?.focus();
});
}
function handleVersionShortcutKeydown(event: KeyboardEvent, version: Torrent) {
if (!version.playable_file) return;
const rootId = version.root_id || ((props.item.data as Movie).root_id ?? props.item.root_id);
const key = event.key.toLowerCase();
if (key === 'e' && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
event.stopPropagation();
handleOpenFolder(version.playable_file, rootId);
return;
}
if (key === 'enter' && event.altKey) {
event.preventDefault();
event.stopPropagation();
handleOpenFolder(version.playable_file, rootId);
}
}
function handleMovieMenuKeydown(event: KeyboardEvent) {
if (!versionActionMenu.value.visible) return;
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
closeVersionActionMenu();
}
}
// Select first season by default
watch(seasons, (s) => {
if (s.length > 0 && selectedSeasonIndex.value >= s.length) {
selectedSeasonIndex.value = 0;
}
}, { immediate: true });
function handlePlay(filePath: string | null) {
if (filePath) {
emit('play', filePath);
}
}
function handleVersionActivate(version: Torrent, event: MouseEvent | KeyboardEvent) {
if (!version.playable_file) return;
const rootId = version.root_id || ((props.item.data as Movie).root_id ?? props.item.root_id);
if (event.altKey) {
handleOpenFolder(version.playable_file, rootId);
return;
}
handlePlay(version.playable_file);
}
function handleOpenFolder(folderPath: string, rootId?: string | null) {
closeVersionActionMenu();
emit('openFolder', folderPath, rootId);
}
function handleCastSelect(castName: string) {
const name = castName.trim();
if (!name) return;
emit('searchActor', name);
}
onMounted(() => {
document.addEventListener('keydown', handleMovieMenuKeydown, true);
});
onUnmounted(() => {
document.removeEventListener('keydown', handleMovieMenuKeydown, true);
});
</script>
<style scoped>
/* Movie page layout (inline within main content) */
.movie-page {
background-color: var(--bg-primary);
}
.movie-menu-backdrop {
position: fixed;
inset: 0;
z-index: 999;
}
.movie-page-content {
position: relative;
}
.movie-page-content::before {
content: '';
position: absolute;
top: 300px;
/* Start clipped edge at reel 2/3 split bottom (y=300): 40vw - 0.8rem. */
left: calc(-50vw + 50% - 1.6rem);
width: calc(40vw + 0.8rem);
max-width: calc(100vw - 24px);
height: var(--header-height);
background: linear-gradient(
to bottom,
rgba(5, 7, 10, 0.72) 0%,
rgba(5, 7, 10, 0.5) 100%
);
-webkit-backdrop-filter: blur(10px) saturate(115%);
backdrop-filter: blur(10px) saturate(115%);
/* Match reel slant angle: 2rem horizontal shift over 300px reel height. */
-webkit-clip-path: polygon(0 0, 100% 0, calc(100% - (var(--header-height) * 0.1067)) 100%, 0 100%);
clip-path: polygon(0 0, 100% 0, calc(100% - (var(--header-height) * 0.1067)) 100%, 0 100%);
pointer-events: none;
z-index: 30;
}
/* Modal body with backdrop - full viewport width, fits backdrop height */
.modal-body {
position: relative;
padding: 24px 0 40px;
/* Add top padding to make room for header bar overlay */
padding-top: calc(var(--header-height) + 40px);
background-size: cover;
background-position: center top;
min-height: max(500px, 56.25vw); /* 16:9 aspect ratio as minimum */
/* Expand to full viewport width */
width: 100vw;
margin-left: calc(-50vw + 50%);
box-sizing: border-box;
}
.backdrop-overlay {
position: absolute;
inset: 0;
pointer-events: none;
}
/* Ensure content is above backdrop overlay */
.modal-body-inner {
position: relative;
z-index: 1;
width: 100%;
padding: 0 32px;
box-sizing: border-box;
}
/* Three-column layout */
.content-layout {
display: grid;
grid-template-columns: minmax(260px, 360px) minmax(0, 1fr) minmax(240px, 320px);
grid-template-areas:
'left cast cast'
'left main right';
gap: 32px;
align-items: start;
position: relative;
}
.content-main {
grid-area: main;
min-width: 0;
}
.content-sidebar {
min-width: 0;
}
.sidebar-left {
grid-area: left;
align-self: start;
display: flex;
flex-direction: column;
gap: 12px;
}
.sidebar-right {
grid-area: right;
}
/* Synopsis box */
.synopsis-box {
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-radius: 12px;
overflow: hidden;
}
.synopsis-poster {
display: block;
width: 100%;
height: auto;
object-fit: contain;
}
.synopsis-text {
color: var(--text-secondary);
font-size: 0.9rem;
line-height: 1.6;
}
.section-label {
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
margin-bottom: 12px;
}
/* Header tagline */
.header-tagline {
color: rgba(255, 255, 255, 0.7);
font-style: italic;
font-size: 1rem;
margin: 4px 0 12px;
}
/* Header genres */
.header-genres {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.header-genres .genre-tag {
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
color: var(--text-primary);
padding: 6px 14px;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 500;
}
.meta-runtime {
color: rgba(255, 255, 255, 0.8);
font-size: 0.85rem;
}
.modal-rating {
font-weight: 600;
font-size: 1rem;
display: flex;
align-items: center;
gap: 6px;
}
.rating-high {
color: #46d369;
}
.rating-medium {
color: #f9a825;
}
.rating-low {
color: #e53935;
}
.vote-count {
font-weight: 400;
font-size: 0.85rem;
color: var(--text-muted);
}
/* Genre tags */
.genre-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.genre-tag {
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
color: var(--text-primary);
padding: 6px 14px;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 500;
}
/* Movie tagline */
.movie-tagline {
color: var(--text-secondary);
font-style: italic;
font-size: 1rem;
margin-bottom: 16px;
background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
padding: 12px 16px;
border-radius: 8px;
display: inline-block;
}
/* Movie metadata - sidebar */
.movie-metadata {
display: flex;
flex-direction: column;
gap: 12px;
padding: 20px 24px;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-radius: 12px;
}
.meta-row {
display: flex;
flex-direction: column;
gap: 4px;
}
.meta-label {
color: #77d38a;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.meta-value {
color: var(--text-primary);
font-size: 0.9rem;
line-height: 1.4;
}
.meta-synopsis {
font-size: 0.84rem;
line-height: 1.55;
}
.meta-summary {
display: flex;
flex-wrap: wrap;
gap: 10px;
color: #8ee59b;
font-size: 0.82rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.meta-summary-item {
white-space: nowrap;
}
.meta-keywords-section {
padding-top: 2px;
}
.meta-value.keywords {
color: var(--text-primary);
font-size: 0.72rem;
line-height: 1.6;
}
.cast-list {
position: absolute;
top: calc(-1 * (var(--header-height) + 30px));
left: calc(-50vw + 50% + 40vw - 0.8rem);
width: calc(100vw - (40vw - 0.8rem));
margin: 0;
padding-top: 4px;
padding-right: 32px;
padding-bottom: 8px;
padding-left: 0;
z-index: 2;
display: flex;
flex-wrap: nowrap;
gap: 6px;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
-ms-overflow-style: none;
}
.cast-list::-webkit-scrollbar {
height: 0;
display: none;
}
.cast-card {
flex: 0 0 94px;
width: 94px;
position: relative;
overflow: hidden;
border-radius: 12px;
aspect-ratio: 2 / 3;
cursor: pointer;
}
.cast-card::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
border: 0 solid rgba(255, 255, 255, 0.95);
pointer-events: none;
transition: border-width 120ms ease;
}
.cast-card:focus-visible,
html:not(.mouse-active) .cast-card.nav-focused {
outline: none;
}
.cast-card:focus-visible::after,
html:not(.mouse-active) .cast-card.nav-focused::after {
border-width: 2px;
}
.cast-photo {
display: block;
width: 100%;
height: 100%;
border-radius: inherit;
object-fit: cover;
background: rgba(255, 255, 255, 0.08);
}
.cast-photo-fallback {
filter: saturate(0.9) contrast(1.05);
}
.cast-copy {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
position: absolute;
inset: auto 0 0 0;
padding: 28px 8px 8px;
background: linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.78) 45%, rgba(0, 0, 0, 0.95) 100%);
}
.cast-name {
color: var(--text-primary);
font-size: 0.72rem;
font-weight: 600;
line-height: 1.2;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.7);
}
.cast-character {
color: #8ee59b;
font-size: 0.64rem;
line-height: 1.25;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.65);
}
@media (max-width: 1200px) {
.content-layout {
grid-template-columns: minmax(240px, 320px) minmax(0, 1fr);
grid-template-areas:
'left cast'
'left main'
'left right';
gap: 24px;
}
}
@media (max-width: 900px) {
.content-layout {
grid-template-columns: 1fr;
grid-template-areas:
'left'
'cast'
'main'
'right';
gap: 20px;
}
.sidebar-left {
align-self: auto;
}
.cast-list {
position: static;
top: auto;
left: auto;
width: auto;
margin-left: 0;
margin-top: 0;
padding-left: 0;
padding-right: 0;
}
}
/* Showreel gallery */
.showreel-gallery {
margin-bottom: 24px;
}
.showreel-title {
font-size: 1rem;
font-weight: 600;
margin-bottom: 12px;
color: var(--text-primary, #fff);
}
.showreel-images {
display: flex;
gap: 8px;
overflow-x: auto;
padding-bottom: 8px;
scroll-behavior: smooth;
}
.showreel-images::-webkit-scrollbar {
height: 6px;
}
.showreel-images::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
border-radius: 3px;
}
.showreel-images::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 3px;
}
.showreel-image {
height: 120px;
width: auto;
border-radius: 6px;
flex-shrink: 0;
object-fit: cover;
transition: box-shadow 0.2s;
cursor: pointer;
}
html.mouse-active .showreel-image:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
}
/* Movie versions styling */
.versions-section {
margin-top: 24px;
border-top: 1px solid var(--border-color, rgba(255, 255, 255, 0.1));
padding-top: 20px;
}
.versions-title {
font-size: 1.1rem;
font-weight: 600;
margin-bottom: 16px;
color: var(--text-primary, #fff);
}
.version-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.version-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
border: 1px solid transparent;
transition: background 0.2s, border-color 0.2s;
}
html.mouse-active .version-item:hover {
background: rgba(255, 255, 255, 0.08);
}
.version-item.version-best {
border-color: rgba(70, 211, 105, 0.3);
background: rgba(70, 211, 105, 0.05);
}
.version-info {
display: flex;
flex-direction: column;
gap: 6px;
flex: 1;
min-width: 0;
}
.version-badges {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.version-language-flags {
display: inline-flex;
align-items: center;
gap: 2px;
width: 100%;
white-space: nowrap;
overflow: hidden;
min-width: 0;
}
.version-language-flags > .language-flags-audio {
flex: 0 0 auto;
}
.version-language-flags > .language-flags-subs {
flex: 1 1 auto;
min-width: 0;
-webkit-mask-image: linear-gradient(to right, black calc(100% - 18px), transparent);
mask-image: linear-gradient(to right, black calc(100% - 18px), transparent);
}
.version-language-flags > .language-flags-subs :deep(.language-flags) {
display: inline-flex;
max-width: 100%;
overflow: hidden;
}
.version-language-flags > .language-flags-subs :deep(.language-flag-list) {
width: max-content;
max-width: none;
overflow: hidden;
}
.language-separator {
color: rgba(255, 255, 255, 0.8);
font-size: 0.98rem;
font-weight: 700;
line-height: 1;
margin: 0;
}
.version-meta-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) max-content;
grid-template-rows: auto auto;
column-gap: 8px;
row-gap: 6px;
align-items: stretch;
}
.version-meta-grid .version-badges {
grid-column: 1;
grid-row: 1;
}
.version-meta-grid .version-language-flags {
grid-column: 1;
grid-row: 2;
}
.version-dolby {
grid-column: 2;
grid-row: 1 / span 2;
align-self: stretch;
justify-self: end;
display: flex;
min-width: 0;
}
.version-badge {
font-size: 0.75rem;
padding: 2px 8px;
border-radius: 4px;
font-weight: 500;
text-transform: uppercase;
}
.version-badge.resolution {
background: rgba(66, 133, 244, 0.2);
color: #4285f4;
}
.version-badge.quality {
background: rgba(156, 39, 176, 0.2);
color: #ce93d8;
}
.version-badge.codec {
background: rgba(255, 152, 0, 0.2);
color: #ffb74d;
}
.version-badge.audio {
background: rgba(0, 188, 212, 0.2);
color: #4dd0e1;
}
.version-badge.best {
background: rgba(70, 211, 105, 0.2);
color: #46d369;
}
.version-path {
font-size: 0.8rem;
color: var(--text-muted, rgba(255, 255, 255, 0.5));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.version-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
margin-left: 0;
}
/* Season header styling */
.season-header {
display: flex;
gap: 20px;
margin-bottom: 20px;
align-items: flex-start;
}
.season-selector {
display: flex;
flex-direction: column;
gap: 8px;
}
.season-selector label {
font-size: 0.85rem;
color: var(--text-muted, rgba(255, 255, 255, 0.5));
}
.season-selector select {
background: var(--bg-secondary, #1f1f1f);
border: 1px solid var(--border-color, #333);
border-radius: 6px;
padding: 8px 12px;
color: var(--text-primary, #fff);
font-size: 0.9rem;
min-width: 180px;
cursor: pointer;
}
.season-poster {
flex-shrink: 0;
}
.season-poster img {
height: 150px;
width: auto;
border-radius: 6px;
object-fit: cover;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
/* Episode list styling */
.episode-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 16px;
}
.episode-item {
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
border: 1px solid transparent;
transition: background 0.2s, border-color 0.2s;
overflow: hidden;
}
html.mouse-active .episode-item:hover {
background: rgba(255, 255, 255, 0.08);
}
.episode-item.episode-expanded {
border-color: rgba(66, 133, 244, 0.3);
}
.episode-header {
display: flex;
align-items: center;
padding: 12px 16px;
cursor: pointer;
gap: 16px;
}
.episode-thumbnail {
flex-shrink: 0;
width: 120px;
height: 68px;
border-radius: 4px;
overflow: hidden;
background: rgba(255, 255, 255, 0.1);
}
.episode-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.episode-thumbnail-placeholder {
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
font-weight: 600;
color: var(--text-muted, rgba(255, 255, 255, 0.3));
}
.episode-info {
display: flex;
align-items: center;
gap: 16px;
flex: 1;
min-width: 0;
}
.episode-details {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
min-width: 0;
}
.episode-name {
font-weight: 500;
color: var(--text-primary, #fff);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.episode-meta {
display: flex;
gap: 12px;
font-size: 0.8rem;
color: var(--text-muted, rgba(255, 255, 255, 0.5));
}
.episode-runtime {
color: var(--text-secondary, rgba(255, 255, 255, 0.7));
}
.episode-rating {
color: #f9a825;
}
.episode-versions {
color: #4285f4;
}
.episode-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
margin-left: 16px;
}
.episode-overview {
padding: 0 16px 12px 64px;
font-size: 0.85rem;
color: var(--text-secondary, rgba(255, 255, 255, 0.7));
line-height: 1.5;
}
/* Release list within episodes */
.release-list {
padding: 8px 16px 16px 64px;
display: flex;
flex-direction: column;
gap: 8px;
}
.release-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
background: rgba(255, 255, 255, 0.03);
border-radius: 6px;
border: 1px solid transparent;
}
html.mouse-active .release-item:hover {
background: rgba(255, 255, 255, 0.06);
}
.release-item.release-best {
border-color: rgba(70, 211, 105, 0.3);
background: rgba(70, 211, 105, 0.05);
}
.release-info {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
min-width: 0;
}
.release-badges {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.release-badge {
font-size: 0.7rem;
padding: 2px 6px;
border-radius: 4px;
font-weight: 500;
text-transform: uppercase;
}
.release-badge.resolution {
background: rgba(66, 133, 244, 0.2);
color: #4285f4;
}
.release-badge.quality {
background: rgba(156, 39, 176, 0.2);
color: #ce93d8;
}
.release-badge.codec {
background: rgba(255, 152, 0, 0.2);
color: #ffb74d;
}
.release-badge.audio {
background: rgba(0, 188, 212, 0.2);
color: #4dd0e1;
}
.release-badge.best {
background: rgba(70, 211, 105, 0.2);
color: #46d369;
}
.release-path {
font-size: 0.75rem;
color: var(--text-muted, rgba(255, 255, 255, 0.5));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.release-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
margin-left: 12px;
}
/* Collage header styles - full viewport width */
.collage-header {
position: relative;
height: 300px;
overflow: hidden;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
/* Expand to full viewport width */
width: 100vw;
margin-left: calc(-50vw + 50%);
}
.collage-header .collage-grid {
display: flex;
height: 100%;
width: 100%;
}
.collage-header .collage-item {
flex: 1;
min-width: 0;
position: relative;
margin-left: -2rem;
/* Slanted clip - parallelogram shape with 2rem slant */
clip-path: polygon(2rem 0, 100% 0, 100% 100%, 0 100%);
cursor: pointer;
overflow: hidden;
}
.collage-header .collage-item video {
position: relative;
z-index: 1;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
transition: opacity 0.25s ease;
}
.collage-header .collage-item video.is-ready {
opacity: 1;
}
/* First item - no slant, straight left edge */
.collage-header .collage-item:first-child {
margin-left: 0;
clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%);
}
/* Last item - slant on right edge for visual interest */
.collage-header .collage-item:last-child {
clip-path: polygon(2rem 0, 100% 0, 100% 100%, 0 100%);
}
/* Single item - no slant */
.collage-header .collage-item:only-child {
clip-path: none;
margin-left: 0;
}
/* Subtle vignette on each collage image */
.collage-header .collage-item::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
to bottom,
transparent 0%,
rgba(0, 0, 0, 0.3) 100%
);
pointer-events: none;
z-index: 2;
}
.collage-fallback-tile {
position: absolute;
inset: 0;
z-index: 0;
}
.collage-fallback-1 {
background: linear-gradient(135deg, #1b2738 0%, #0f1724 100%);
}
.collage-fallback-2 {
background: linear-gradient(135deg, #1b2738 0%, #0f1724 100%);
}
.collage-fallback-3 {
background: linear-gradient(135deg, #1b2738 0%, #0f1724 100%);
}
.collage-fallback-4 {
background: linear-gradient(135deg, #1b2738 0%, #0f1724 100%);
}
.collage-fallback-5 {
background: linear-gradient(135deg, #1b2738 0%, #0f1724 100%);
}
.collage-overlay {
display: none;
}
.collage-content {
position: absolute;
bottom: 16px;
left: 24px;
right: 24px;
z-index: 2;
pointer-events: none;
}
.collage-content .modal-title {
font-size: 1.8rem;
margin-bottom: 8px;
}
.collage-content .modal-meta {
display: flex;
gap: 10px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.meta-rating {
font-weight: 600;
padding: 2px 6px;
border-radius: 3px;
background: rgba(0, 0, 0, 0.5);
font-size: 0.8rem;
}
.meta-year {
color: rgba(255, 255, 255, 0.8);
font-size: 0.85rem;
}
.meta-disc {
font-size: 1rem;
line-height: 1;
}
.meta-badge {
background: rgba(255, 255, 255, 0.15);
padding: 2px 6px;
border-radius: 3px;
font-size: 0.7rem;
font-weight: 600;
}
.header-actions {
display: flex;
gap: 8px;
}
.header-actions .btn {
padding: 8px 16px;
font-size: 0.85rem;
}
/* Versions list - vertical layout */
.versions-list {
display: flex;
flex-direction: column;
gap: 8px;
}
</style>