1882 lines
44 KiB
Vue
1882 lines
44 KiB
Vue
<template>
|
|
<!-- Full screen view for series -->
|
|
<SeriesFullView
|
|
v-if="item.type === 'series'"
|
|
:series="item.data as Series"
|
|
:all-movies="allMovies"
|
|
:focus-episode="focusEpisode"
|
|
:has-resume-position="hasResumePosition"
|
|
:get-root-name="getRootName"
|
|
@close="$emit('close')"
|
|
@play="handlePlay"
|
|
@openFolder="handleOpenFolder"
|
|
@select-movie="handleSelectMovie"
|
|
/>
|
|
|
|
<!-- 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"
|
|
data-nav-release-item="true"
|
|
v-bind="navAttrs(2 + index, 0, 0)"
|
|
@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"
|
|
data-sync-scroll-group="cast"
|
|
data-nav-cast-row="true"
|
|
>
|
|
<a
|
|
v-for="(castMember, castIndex) in limitedMovieCast"
|
|
:key="`${castMember.name}-${castMember.character || ''}`"
|
|
class="cast-card media-card"
|
|
data-nav-cast-item="true"
|
|
v-bind="navAttrs(2 + movieVersions.length, castIndex)"
|
|
:href="`/search/${encodeURIComponent(castMember.name)}`"
|
|
:title="`Search for ${castMember.name}`"
|
|
@click.prevent="handleCastSelect(castMember.name)"
|
|
>
|
|
<img
|
|
v-if="castMember.profile_path && !castMember.profile_path.startsWith('/')"
|
|
:src="getCastProfileUrl(castMember.profile_path)"
|
|
: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>
|
|
</a>
|
|
</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 || movieStatus || movieReleaseDate" class="meta-grid">
|
|
<template v-if="movieDirector">
|
|
<span class="meta-label">Directed by</span>
|
|
<span class="meta-value">{{ movieDirector }}</span>
|
|
</template>
|
|
<template v-if="movieStatus || movieReleaseDate">
|
|
<span v-if="movieStatus" class="meta-label">{{ movieStatus }}</span>
|
|
<span v-else class="meta-label"></span>
|
|
<span v-if="movieReleaseDate" class="meta-value">{{ movieReleaseDate }}</span>
|
|
<span v-else class="meta-value"></span>
|
|
</template>
|
|
</div>
|
|
<div v-if="movieKeywords && movieKeywords.length > 0" class="meta-keywords-section">
|
|
<span
|
|
v-for="(keyword, keywordIndex) in movieKeywords"
|
|
:key="`${keyword}-${keywordIndex}`"
|
|
class="meta-keyword"
|
|
>{{ formatKeywordLabel(keyword) }}</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, MovieUi, 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 { sortTorrentsByPreference } from "../composables/useSettings"
|
|
import ReleaseVersionCard from "./ReleaseVersionCard.vue"
|
|
import ReleaseActionMenu from "./ReleaseActionMenu.vue"
|
|
import {
|
|
navAttrs,
|
|
registerOutOfBoundsNavigationHandler,
|
|
FOCUSABLE_ATTR,
|
|
} from "../composables/useKeyboardNavigation"
|
|
|
|
const props = defineProps<{
|
|
item: MediaItem
|
|
allMovies: MovieUi[]
|
|
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]
|
|
selectMovie: [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]
|
|
const DESKTOP_NAV_SHORTCUT_MIN_WIDTH = 900
|
|
|
|
let disposeOutOfBoundsHandler: (() => void) | null = null
|
|
let lastReleaseShortcutRow: number | null = null
|
|
|
|
function getDesktopCastFirstItem(): HTMLElement | null {
|
|
const castRow = document.querySelector<HTMLElement>('[data-nav-cast-row="true"]')
|
|
if (!castRow) return null
|
|
return castRow.querySelector<HTMLElement>(`[data-nav-cast-item="true"][${FOCUSABLE_ATTR}="true"]`)
|
|
}
|
|
|
|
function getReleaseAtRow(row: number): HTMLElement | null {
|
|
return document.querySelector<HTMLElement>(
|
|
`[data-nav-release-item="true"][data-nav-row="${row}"][data-nav-col="0"][${FOCUSABLE_ATTR}="true"]`,
|
|
)
|
|
}
|
|
|
|
function getLastReleaseRowBefore(castRow: number): number | null {
|
|
const releases = Array.from(document.querySelectorAll<HTMLElement>('[data-nav-release-item="true"]'))
|
|
let best: number | null = null
|
|
for (const release of releases) {
|
|
const row = parseInt(release.getAttribute("data-nav-row") || "", 10)
|
|
if (!Number.isFinite(row) || row >= castRow) continue
|
|
if (best === null || row > best) best = row
|
|
}
|
|
return best
|
|
}
|
|
|
|
function registerMovieOutOfBoundsShortcut() {
|
|
disposeOutOfBoundsHandler?.()
|
|
disposeOutOfBoundsHandler = registerOutOfBoundsNavigationHandler((context) => {
|
|
if (window.innerWidth <= DESKTOP_NAV_SHORTCUT_MIN_WIDTH) return null
|
|
if (props.item.type !== "movies") return null
|
|
|
|
const { current, direction, currentRow, currentCol } = context
|
|
|
|
if (direction === "right" && current.hasAttribute("data-nav-release-item")) {
|
|
const firstCast = getDesktopCastFirstItem()
|
|
if (firstCast) {
|
|
lastReleaseShortcutRow = currentRow
|
|
return firstCast
|
|
}
|
|
return null
|
|
}
|
|
|
|
if (
|
|
direction === "left" &&
|
|
current.hasAttribute("data-nav-cast-item") &&
|
|
currentCol === 0
|
|
) {
|
|
const targetRow = lastReleaseShortcutRow ?? getLastReleaseRowBefore(currentRow)
|
|
if (targetRow === null) return null
|
|
return getReleaseAtRow(targetRow)
|
|
}
|
|
|
|
return null
|
|
})
|
|
}
|
|
|
|
function cleanupVideo(video: HTMLVideoElement | null | undefined) {
|
|
if (!video) return
|
|
video.pause()
|
|
video.src = ""
|
|
video.load()
|
|
}
|
|
|
|
function setVideoRef(el: HTMLVideoElement | null, index: number) {
|
|
const old = videoRefs.value[index]
|
|
if (old && old !== el) {
|
|
cleanupVideo(old)
|
|
}
|
|
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)
|
|
|
|
registerMovieOutOfBoundsShortcut()
|
|
})
|
|
|
|
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, oldSlots) => {
|
|
// Pause and unload videos that are no longer referenced before reassigning refs
|
|
if (oldSlots) {
|
|
for (let i = 0; i < oldSlots.length; i++) {
|
|
const oldPaths = oldSlots[i]?.sourcePaths ?? []
|
|
const newPaths = slots[i]?.sourcePaths ?? []
|
|
const changed =
|
|
oldPaths.length !== newPaths.length || oldPaths.some((p, idx) => p !== newPaths[idx])
|
|
if (changed) {
|
|
cleanupVideo(videoRefs.value[i])
|
|
}
|
|
}
|
|
}
|
|
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 sortTorrentsByPreference(Object.values(movie.files || {}))
|
|
})
|
|
|
|
// 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
|
|
const releaseDate = (props.item.data as Movie).info?.release_date
|
|
if (!releaseDate) return null
|
|
|
|
const parsedDate = new Date(releaseDate)
|
|
if (Number.isNaN(parsedDate.getTime())) return releaseDate
|
|
|
|
return new Intl.DateTimeFormat(undefined, {
|
|
month: "short",
|
|
day: "numeric",
|
|
year: "numeric",
|
|
}).format(parsedDate)
|
|
})
|
|
|
|
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 formatKeywordLabel(keyword: string): string {
|
|
// Keep multi-word keywords together while visually narrowing internal spacing.
|
|
return keyword.trim().replace(/\s+/g, "\u202F")
|
|
}
|
|
|
|
function getCastPlaceholderUrl(gender?: CastMember["gender"]): string {
|
|
return gender === "female" ? castPlaceholderFemaleUrl : castPlaceholderMaleUrl
|
|
}
|
|
|
|
function getCastProfileUrl(profilePath: string | null): string {
|
|
if (!profilePath) return ""
|
|
if (profilePath.includes("/")) {
|
|
return getCoverUrl(profilePath, props.item.root_id)
|
|
}
|
|
const castPath = `.mediahive/people/${profilePath}`
|
|
return getCoverUrl(castPath, props.item.root_id)
|
|
}
|
|
|
|
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 MovieUi).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 MovieUi).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 MovieUi).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)
|
|
}
|
|
|
|
function handleSelectMovie(movieId: string) {
|
|
emit("selectMovie", movieId)
|
|
}
|
|
|
|
onMounted(() => {
|
|
document.addEventListener("keydown", handleMovieMenuKeydown, true)
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
document.removeEventListener("keydown", handleMovieMenuKeydown, true)
|
|
disposeOutOfBoundsHandler?.()
|
|
disposeOutOfBoundsHandler = null
|
|
lastReleaseShortcutRow = null
|
|
// Clear all volume fade intervals
|
|
for (const interval of volumeFadeIntervals.values()) {
|
|
clearInterval(interval)
|
|
}
|
|
volumeFadeIntervals.clear()
|
|
// Pause and unload all video elements
|
|
for (const video of videoRefs.value) {
|
|
cleanupVideo(video)
|
|
}
|
|
videoRefs.value = []
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* Movie page layout (inline within main content) */
|
|
.movie-page {
|
|
background-color: var(--bg-primary);
|
|
--movie-nav-bar-width: calc(40vw + 0.8rem);
|
|
}
|
|
|
|
.movie-menu-backdrop {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 999;
|
|
}
|
|
|
|
.movie-page-content {
|
|
position: relative;
|
|
}
|
|
|
|
.movie-page-content::before {
|
|
content: "";
|
|
position: absolute;
|
|
top: 300px;
|
|
/* Align clipped edge after reel 2/5; breakpoints move to 3/5 and 4/5 with proportional slant compensation. */
|
|
left: calc(-50vw + 50% - 1.6rem);
|
|
width: var(--movie-nav-bar-width);
|
|
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;
|
|
}
|
|
|
|
@media (max-width: 1280px) {
|
|
.movie-page {
|
|
--movie-nav-bar-width: calc(60vw + 0.4rem);
|
|
}
|
|
}
|
|
|
|
@media (max-width: 900px) {
|
|
.movie-page {
|
|
--movie-nav-bar-width: 80vw;
|
|
}
|
|
}
|
|
|
|
/* 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;
|
|
position: relative;
|
|
z-index: 2;
|
|
}
|
|
|
|
.content-sidebar {
|
|
min-width: 0;
|
|
position: relative;
|
|
z-index: 2;
|
|
}
|
|
|
|
.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-grid {
|
|
display: grid;
|
|
grid-template-columns: auto minmax(0, 1fr);
|
|
column-gap: 12px;
|
|
row-gap: 8px;
|
|
align-items: baseline;
|
|
}
|
|
|
|
.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 {
|
|
color: #8ee59b;
|
|
font-size: 0.82rem;
|
|
font-weight: 600;
|
|
letter-spacing: 0.04em;
|
|
text-transform: uppercase;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.meta-keywords-section {
|
|
padding-top: 2px;
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
justify-content: space-between;
|
|
row-gap: 1px;
|
|
}
|
|
|
|
.meta-keyword {
|
|
color: var(--text-primary);
|
|
font-size: 0.72rem;
|
|
line-height: 1.1;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.cast-list {
|
|
--sync-row-tail: 0px;
|
|
--cast-safe-start: var(--movie-nav-bar-width);
|
|
--cast-safe-end: 32px;
|
|
--sync-row-right-deadzone: 32px;
|
|
position: absolute;
|
|
top: calc(-1 * (var(--header-height) + 2.5rem));
|
|
left: calc(-50vw + 50%);
|
|
width: 100vw;
|
|
margin: 0;
|
|
padding: 4px calc(var(--cast-safe-end) + var(--sync-row-tail)) 8px var(--cast-safe-start);
|
|
z-index: 0;
|
|
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;
|
|
display: grid;
|
|
grid-template-columns: clamp(120px, 36vw, 180px) minmax(0, 1fr);
|
|
align-items: start;
|
|
gap: 12px;
|
|
}
|
|
|
|
.synopsis-box {
|
|
width: 100%;
|
|
margin: 0;
|
|
}
|
|
|
|
.versions-list-sidebar {
|
|
min-width: 0;
|
|
}
|
|
|
|
.cast-list {
|
|
--cast-safe-start: 32px;
|
|
--cast-safe-end: 32px;
|
|
--sync-row-right-deadzone: 32px;
|
|
position: relative;
|
|
top: auto;
|
|
left: -32px;
|
|
width: calc(100% + 64px);
|
|
margin-left: 0;
|
|
margin-top: 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>
|