frontend: add OXC lint/format setup and apply semicolon fixes

This commit is contained in:
2026-05-25 02:54:30 +00:00
parent 836a563897
commit f6a40babc9
26 changed files with 3452 additions and 3130 deletions
+5 -1
View File
@@ -2,7 +2,11 @@
"tasks": { "tasks": {
"dev": "deno run -A npm:vite", "dev": "deno run -A npm:vite",
"build": "deno run -A npm:vue-tsc --noEmit && deno run -A npm:vite build", "build": "deno run -A npm:vue-tsc --noEmit && deno run -A npm:vite build",
"preview": "deno run -A npm:vite preview" "preview": "deno run -A npm:vite preview",
"lint": "deno run -A npm:oxlint --vue-plugin --import-plugin src",
"lint:fix": "deno task lint --fix",
"format": "deno run -A npm:oxfmt --config oxfmt.json src",
"format:check": "deno run -A npm:oxfmt --config oxfmt.json --check src"
}, },
"imports": { "imports": {
"vue": "npm:vue@^3.4.0" "vue": "npm:vue@^3.4.0"
+5
View File
@@ -0,0 +1,5 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"semi": false,
"ignorePatterns": []
}
+7 -1
View File
@@ -6,7 +6,11 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vue-tsc --noEmit && vite build", "build": "vue-tsc --noEmit && vite build",
"preview": "vite preview" "preview": "vite preview",
"lint": "oxlint --vue-plugin --import-plugin src",
"lint:fix": "npm run lint -- --fix",
"format": "oxfmt --config oxfmt.json src",
"format:check": "oxfmt --config oxfmt.json --check src"
}, },
"dependencies": { "dependencies": {
"country-flag-icons": "^1.6.17", "country-flag-icons": "^1.6.17",
@@ -15,6 +19,8 @@
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.0.0", "@vitejs/plugin-vue": "^5.0.0",
"oxfmt": "^0.51.0",
"oxlint": "^1.66.0",
"typescript": "^5.3.0", "typescript": "^5.3.0",
"vite": "^5.0.0", "vite": "^5.0.0",
"vue-tsc": "^2.0.0" "vue-tsc": "^2.0.0"
+655 -624
View File
File diff suppressed because it is too large Load Diff
+100 -95
View File
@@ -1,99 +1,97 @@
export interface PlayerStatus { export interface PlayerStatus {
remote: boolean; remote: boolean
} }
export interface RootStatus { export interface RootStatus {
root_id: string; root_id: string
name: string; name: string
path: string; path: string
status: string; status: string
error: string | null; error: string | null
movies: number; movies: number
series: number; series: number
} }
export interface RootsResponse { export interface RootsResponse {
roots: RootStatus[]; roots: RootStatus[]
} }
export function normalizeMediaPath(input: string): string { export function normalizeMediaPath(input: string): string {
return input return input
.replace(/\\/g, '/') .replace(/\\/g, "/")
.replace(/^[A-Za-z]:\//, '') .replace(/^[A-Za-z]:\//, "")
.replace(/^\/+/, ''); .replace(/^\/+/, "")
} }
export function isVideoPath(path: string | null | undefined): boolean { export function isVideoPath(path: string | null | undefined): boolean {
return Boolean(path && /\.(webm|mp4|mkv|avi|mov)$/i.test(path)); return Boolean(path && /\.(webm|mp4|mkv|avi|mov)$/i.test(path))
} }
export interface VideoSourceAttributes { export interface VideoSourceAttributes {
type: string; type: string
codecs: string; codecs: string
} }
export function isSafariBrowser(): boolean { export function isSafariBrowser(): boolean {
if (typeof navigator === 'undefined') { if (typeof navigator === "undefined") {
return false; return false
} }
const ua = navigator.userAgent; const ua = navigator.userAgent
return /Safari/i.test(ua) && !/Chrome|Chromium|CriOS|Edg|OPR|FxiOS/i.test(ua); return /Safari/i.test(ua) && !/Chrome|Chromium|CriOS|Edg|OPR|FxiOS/i.test(ua)
} }
export function getVideoPreviewUrl(url: string): string { export function getVideoPreviewUrl(url: string): string {
if (!url || !isSafariBrowser()) { if (!url || !isSafariBrowser()) {
return url; return url
} }
if (url.includes('#')) { if (url.includes("#")) {
return url; return url
} }
// Safari often needs a tiny time offset to paint the first frame before playback. // Safari often needs a tiny time offset to paint the first frame before playback.
return `${url}#t=0.001`; return `${url}#t=0.001`
} }
export function getVideoSourceAttributes(path: string | null | undefined): VideoSourceAttributes { export function getVideoSourceAttributes(path: string | null | undefined): VideoSourceAttributes {
if (!path) { if (!path) {
return { type: 'video/mp4', codecs: 'hvc1' }; return { type: "video/mp4", codecs: "hvc1" }
} }
if (/\.webm$/i.test(path)) { if (/\.webm$/i.test(path)) {
return { type: 'video/webm', codecs: 'av1' }; return { type: "video/webm", codecs: "av1" }
} }
if (/\.mp4$/i.test(path) || /\.m4v$/i.test(path)) { if (/\.mp4$/i.test(path) || /\.m4v$/i.test(path)) {
return { type: 'video/mp4', codecs: 'hvc1' }; return { type: "video/mp4", codecs: "hvc1" }
} }
if (/\.mov$/i.test(path)) { if (/\.mov$/i.test(path)) {
return { type: 'video/quicktime', codecs: 'hvc1' }; return { type: "video/quicktime", codecs: "hvc1" }
} }
if (/\.avi$/i.test(path)) { if (/\.avi$/i.test(path)) {
return { type: 'video/x-msvideo', codecs: '' }; return { type: "video/x-msvideo", codecs: "" }
} }
if (/\.mkv$/i.test(path)) { if (/\.mkv$/i.test(path)) {
return { type: 'video/x-matroska', codecs: '' }; return { type: "video/x-matroska", codecs: "" }
} }
return { type: 'video/mp4', codecs: 'hvc1' }; return { type: "video/mp4", codecs: "hvc1" }
} }
/** /**
* Fetch active roots and their statuses * Fetch active roots and their statuses
*/ */
export async function fetchRoots(): Promise<RootStatus[]> { export async function fetchRoots(): Promise<RootStatus[]> {
const response = await fetch('/api/roots'); const response = await fetch("/api/roots")
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to load roots: ${response.statusText}`); throw new Error(`Failed to load roots: ${response.statusText}`)
} }
const data = await response.json(); const data = await response.json()
return data.roots || []; return data.roots || []
} }
/** /**
@@ -101,59 +99,63 @@ export async function fetchRoots(): Promise<RootStatus[]> {
*/ */
export async function fetchResumePositions(): Promise<Record<string, number>> { export async function fetchResumePositions(): Promise<Record<string, number>> {
try { try {
const roots = await fetchRoots(); const roots = await fetchRoots()
const merged: Record<string, number> = {}; const merged: Record<string, number> = {}
await Promise.all( await Promise.all(
roots.map(async (root) => { roots.map(async (root) => {
const response = await fetch(`/api/roots/${encodeURIComponent(root.root_id)}/playback/resume-positions`); const response = await fetch(
if (!response.ok) return; `/api/roots/${encodeURIComponent(root.root_id)}/playback/resume-positions`,
const data = await response.json().catch(() => ({})); )
const positions = data?.resume_positions; if (!response.ok) return
if (positions && typeof positions === 'object') { const data = await response.json().catch(() => ({}))
Object.assign(merged, positions); const positions = data?.resume_positions
if (positions && typeof positions === "object") {
Object.assign(merged, positions)
} }
}) }),
); )
return merged; return merged
} catch { } catch {
return {}; return {}
} }
} }
/** /**
* Replace the full root set atomically * Replace the full root set atomically
*/ */
export async function replaceRoots(roots: Record<string, string>): Promise<{ accepted: RootStatus[]; failed: unknown[] }> { export async function replaceRoots(
const response = await fetch('/api/roots', { roots: Record<string, string>,
method: 'PUT', ): Promise<{ accepted: RootStatus[]; failed: unknown[] }> {
headers: { 'Content-Type': 'application/json' }, const response = await fetch("/api/roots", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ roots }), body: JSON.stringify({ roots }),
}); })
if (!response.ok) { if (!response.ok) {
const err = await response.json().catch(() => ({ detail: response.statusText })); const err = await response.json().catch(() => ({ detail: response.statusText }))
throw new Error(err.detail || response.statusText); throw new Error(err.detail || response.statusText)
} }
return response.json(); return response.json()
} }
/** /**
* Play a media file with the system's default player * Play a media file with the system's default player
*/ */
export async function playMedia(rootId: string, filePath: string): Promise<void> { export async function playMedia(rootId: string, filePath: string): Promise<void> {
const normalizedPath = normalizeMediaPath(filePath); const normalizedPath = normalizeMediaPath(filePath)
try { try {
const response = await fetch(`/api/roots/${encodeURIComponent(rootId)}/play`, { const response = await fetch(`/api/roots/${encodeURIComponent(rootId)}/play`, {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ file_path: normalizedPath }), body: JSON.stringify({ file_path: normalizedPath }),
}); })
if (!response.ok) { if (!response.ok) {
const error = await response.json(); const error = await response.json()
throw new Error(error.detail || response.statusText); throw new Error(error.detail || response.statusText)
} }
} catch (e) { } catch (e) {
console.error('Play media error:', e); console.error("Play media error:", e)
alert(`Failed to play media.\n\n${e}`); alert(`Failed to play media.\n\n${e}`)
} }
} }
@@ -161,20 +163,20 @@ export async function playMedia(rootId: string, filePath: string): Promise<void>
* Open a folder in the system file manager * Open a folder in the system file manager
*/ */
export async function openFolder(rootId: string, folderPath: string): Promise<void> { export async function openFolder(rootId: string, folderPath: string): Promise<void> {
const normalizedPath = normalizeMediaPath(folderPath); const normalizedPath = normalizeMediaPath(folderPath)
try { try {
const response = await fetch(`/api/roots/${encodeURIComponent(rootId)}/open-folder`, { const response = await fetch(`/api/roots/${encodeURIComponent(rootId)}/open-folder`, {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ folder_path: normalizedPath }), body: JSON.stringify({ folder_path: normalizedPath }),
}); })
if (!response.ok) { if (!response.ok) {
const error = await response.json(); const error = await response.json()
throw new Error(error.detail || response.statusText); throw new Error(error.detail || response.statusText)
} }
} catch (e) { } catch (e) {
console.error('Open folder error:', e); console.error("Open folder error:", e)
alert(`Failed to open folder.\n\n${e}`); alert(`Failed to open folder.\n\n${e}`)
} }
} }
@@ -182,11 +184,11 @@ export async function openFolder(rootId: string, folderPath: string): Promise<vo
* Return player integration capabilities for the current OS. * Return player integration capabilities for the current OS.
*/ */
export async function getPlayerStatus(): Promise<PlayerStatus> { export async function getPlayerStatus(): Promise<PlayerStatus> {
const response = await fetch('/api/player/status'); const response = await fetch("/api/player/status")
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to load player status: ${response.statusText}`); throw new Error(`Failed to load player status: ${response.statusText}`)
} }
return response.json(); return response.json()
} }
/** /**
@@ -194,12 +196,12 @@ export async function getPlayerStatus(): Promise<PlayerStatus> {
*/ */
export async function isMpcBeReachable(): Promise<boolean> { export async function isMpcBeReachable(): Promise<boolean> {
try { try {
const response = await fetch('/api/mpcbe/status'); const response = await fetch("/api/mpcbe/status")
if (!response.ok) return false; if (!response.ok) return false
const data = await response.json().catch(() => ({})); const data = await response.json().catch(() => ({}))
return Boolean(data.reachable); return Boolean(data.reachable)
} catch { } catch {
return false; return false
} }
} }
@@ -212,33 +214,36 @@ export async function isMpcBeReachable(): Promise<boolean> {
*/ */
export function getCoverUrl(coverPath: string | null, rootId?: string | null): string { export function getCoverUrl(coverPath: string | null, rootId?: string | null): string {
if (!coverPath) { if (!coverPath) {
return ''; return ""
} }
// Ignore TMDB relative paths (start with /) - these are bugs in the index // Ignore TMDB relative paths (start with /) - these are bugs in the index
if (coverPath.startsWith('/')) { if (coverPath.startsWith("/")) {
return ''; return ""
} }
// Convert relative path to URL path for FastAPI server // Convert relative path to URL path for FastAPI server
// .mediahive/covers/Movies/... -> /api/media/{root_id}/.mediahive/covers/Movies/... // .mediahive/covers/Movies/... -> /api/media/{root_id}/.mediahive/covers/Movies/...
let urlPath = coverPath; let urlPath = coverPath
// Remove drive letter (Z:) and convert backslashes to forward slashes // Remove drive letter (Z:) and convert backslashes to forward slashes
if (urlPath.match(/^[A-Za-z]:/)) { if (urlPath.match(/^[A-Za-z]:/)) {
urlPath = urlPath.substring(2); urlPath = urlPath.substring(2)
} }
urlPath = urlPath.replace(/\\/g, '/'); urlPath = urlPath.replace(/\\/g, "/")
// Ensure path starts with / // Ensure path starts with /
if (!urlPath.startsWith('/')) { if (!urlPath.startsWith("/")) {
urlPath = '/' + urlPath; urlPath = "/" + urlPath
} }
// Encode URI components but preserve slashes // Encode URI components but preserve slashes
const encodedPath = urlPath.split('/').map(segment => encodeURIComponent(segment)).join('/'); const encodedPath = urlPath
.split("/")
.map((segment) => encodeURIComponent(segment))
.join("/")
const rid = rootId || 'unknown'; const rid = rootId || "unknown"
return `/api/media/${encodeURIComponent(rid)}${encodedPath}`; return `/api/media/${encodeURIComponent(rid)}${encodedPath}`
} }
/** /**
@@ -247,8 +252,8 @@ export function getCoverUrl(coverPath: string | null, rootId?: string | null): s
*/ */
export async function pickFolderAndAddRoot(): Promise<string | null> { export async function pickFolderAndAddRoot(): Promise<string | null> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const api = (window as any).pywebview?.api; const api = (window as any).pywebview?.api
if (!api) return null; if (!api) return null
const folder: string | null = await api.pick_folder(); const folder: string | null = await api.pick_folder()
return folder; return folder
} }
+317 -266
View File
@@ -4,9 +4,17 @@
<div class="collage-grid"> <div class="collage-grid">
<template v-for="(item, index) in collageItems" :key="item.id"> <template v-for="(item, index) in collageItems" :key="item.id">
<div <div
:ref="el => setItemRef(el as HTMLElement, index)" :ref="(el) => setItemRef(el as HTMLElement, index)"
class="collage-item" class="collage-item"
:class="[`collage-item-${index}`, { 'collage-featured': index === 0, 'collage-item-top-row': isTopRowItem(index), 'nav-focused': focusedIndex === index, 'collage-hidden': !isItemVisible(index) }]" :class="[
`collage-item-${index}`,
{
'collage-featured': index === 0,
'collage-item-top-row': isTopRowItem(index),
'nav-focused': focusedIndex === index,
'collage-hidden': !isItemVisible(index),
},
]"
v-bind="getItemAttrs(index)" v-bind="getItemAttrs(index)"
@click="handleItemClick(item, index)" @click="handleItemClick(item, index)"
@focus="focusedIndex = index" @focus="focusedIndex = index"
@@ -21,8 +29,15 @@
/> />
</div> </div>
<!-- SVG focus outline for big hex --> <!-- SVG focus outline for big hex -->
<svg v-if="index === 0" class="hex-focus-outline hex-focus-big" viewBox="0 0 130 100" preserveAspectRatio="none"> <svg
<polygon points="21.67,0 21.67,25 0,37.5 0,62.5 21.67,75 21.67,100 108.33,100 108.33,75 130,62.5 130,37.5 108.33,25 108.33,0" /> v-if="index === 0"
class="hex-focus-outline hex-focus-big"
viewBox="0 0 130 100"
preserveAspectRatio="none"
>
<polygon
points="21.67,0 21.67,25 0,37.5 0,62.5 21.67,75 21.67,100 108.33,100 108.33,75 130,62.5 130,37.5 108.33,25 108.33,0"
/>
</svg> </svg>
<!-- Non-featured items: image, video, or placeholder --> <!-- Non-featured items: image, video, or placeholder -->
<img <img
@@ -44,124 +59,146 @@
:src="source.src" :src="source.src"
:type="source.type" :type="source.type"
:codecs="source.codecs" :codecs="source.codecs"
> />
</video> </video>
<div v-if="index !== 0 && !getImageUrl(item) && getVideoSources(item).length === 0" class="collage-placeholder"> <div
v-if="index !== 0 && !getImageUrl(item) && getVideoSources(item).length === 0"
class="collage-placeholder"
>
<span class="placeholder-title">{{ item.title }}</span> <span class="placeholder-title">{{ item.title }}</span>
</div> </div>
<div class="collage-item-overlay"></div> <div class="collage-item-overlay"></div>
<!-- SVG focus outline for small hex items --> <!-- SVG focus outline for small hex items -->
<svg v-if="index !== 0 && index !== 2 && !isTopRowItem(index)" class="hex-focus-outline hex-focus-small" viewBox="0 0 86.6 100" preserveAspectRatio="none"> <svg
<polygon points="43.3,0 86.6,25 86.6,75 43.3,100 0,75 0,25" /> v-if="index !== 0 && index !== 2 && !isTopRowItem(index)"
</svg> class="hex-focus-outline hex-focus-small"
<!-- Item 2 uses a custom flat-bottom hex outline to match its clip-path --> viewBox="0 0 86.6 100"
<svg v-if="index === 2" class="hex-focus-outline hex-focus-small" viewBox="0 0 86.6 100" preserveAspectRatio="none"> preserveAspectRatio="none"
<polygon points="43.3,0 86.6,33.333 86.6,100 0,100 0,33.333" /> >
</svg> <polygon points="43.3,0 86.6,25 86.6,75 43.3,100 0,75 0,25" />
<!-- Top-row items use a custom flat-top hex outline to match their clip-path --> </svg>
<svg v-if="isTopRowItem(index)" class="hex-focus-outline hex-focus-small" viewBox="0 0 86.6 100" preserveAspectRatio="none"> <!-- Item 2 uses a custom flat-bottom hex outline to match its clip-path -->
<polygon points="86.6,0 86.6,66.667 43.3,100 0,66.667 0,0" /> <svg
</svg> v-if="index === 2"
<div class="collage-item-info" v-if="index === 0"> class="hex-focus-outline hex-focus-small"
<h1 class="collage-title">{{ item.title }}</h1> viewBox="0 0 86.6 100"
<div class="collage-meta"> preserveAspectRatio="none"
<span v-if="item.year" class="meta-year">{{ item.year }}</span> >
<span v-if="getRating(item)" class="meta-rating" :class="getRatingClass(item)"> <polygon points="43.3,0 86.6,33.333 86.6,100 0,100 0,33.333" />
{{ getRating(item)?.toFixed(1) }} </svg>
</span> <!-- Top-row items use a custom flat-top hex outline to match their clip-path -->
<span v-if="getResolution(item)" class="meta-quality">{{ getResolution(item) }}</span> <svg
v-if="isTopRowItem(index)"
class="hex-focus-outline hex-focus-small"
viewBox="0 0 86.6 100"
preserveAspectRatio="none"
>
<polygon points="86.6,0 86.6,66.667 43.3,100 0,66.667 0,0" />
</svg>
<div class="collage-item-info" v-if="index === 0">
<h1 class="collage-title">{{ item.title }}</h1>
<div class="collage-meta">
<span v-if="item.year" class="meta-year">{{ item.year }}</span>
<span v-if="getRating(item)" class="meta-rating" :class="getRatingClass(item)">
{{ getRating(item)?.toFixed(1) }}
</span>
<span v-if="getResolution(item)" class="meta-quality">{{ getResolution(item) }}</span>
</div>
<p v-if="getOverview(item)" class="collage-overview">{{ getOverview(item) }}</p>
<div class="collage-buttons">
<button class="btn btn-primary" @click.stop="handlePlay(item)">
{{ getPlayLabel(item) }}
</button>
<button class="btn btn-secondary" @click.stop="$emit('info', item)"> Info</button>
</div>
</div> </div>
<p v-if="getOverview(item)" class="collage-overview">{{ getOverview(item) }}</p> <div class="collage-item-hover" v-else>
<div class="collage-buttons"> <span class="hover-title">{{ item.title }}</span>
<button class="btn btn-primary" @click.stop="handlePlay(item)"> {{ getPlayLabel(item) }}</button> <span v-if="getRating(item)" class="hover-rating"
<button class="btn btn-secondary" @click.stop="$emit('info', item)"> Info</button> > {{ getRating(item)?.toFixed(1) }}</span
>
</div> </div>
</div> </div>
<div class="collage-item-hover" v-else>
<span class="hover-title">{{ item.title }}</span>
<span v-if="getRating(item)" class="hover-rating"> {{ getRating(item)?.toFixed(1) }}</span>
</div>
</div>
</template> </template>
</div> </div>
</section> </section>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, onMounted, onUnmounted, nextTick, watch } from 'vue'; import { computed, ref, onMounted, onUnmounted, nextTick, watch } from "vue"
import type { MediaItem, Movie, Series } from '../types'; import type { MediaItem, Movie, Series } from "../types"
import { getCoverUrl, getVideoPreviewUrl, getVideoSourceAttributes, isVideoPath } from '../api'; import { getCoverUrl, getVideoPreviewUrl, getVideoSourceAttributes, isVideoPath } from "../api"
import { navAttrs } from '../composables/useKeyboardNavigation'; import { navAttrs } from "../composables/useKeyboardNavigation"
const focusedIndex = ref<number | null>(null); const focusedIndex = ref<number | null>(null)
const itemRefs = ref<(HTMLElement | null)[]>([]); const itemRefs = ref<(HTMLElement | null)[]>([])
// Track last row when on col=0 (big image) for sideways navigation // Track last row when on col=0 (big image) for sideways navigation
const lastRow = ref(1); const lastRow = ref(1)
// Reset lastRow when entering the hero from outside (via global nav) // Reset lastRow when entering the hero from outside (via global nav)
watch(focusedIndex, (newVal, oldVal) => { watch(focusedIndex, (newVal, oldVal) => {
if (newVal === 0 && oldVal === null) { if (newVal === 0 && oldVal === null) {
// Entering big image from outside hero - reset to row 1 // Entering big image from outside hero - reset to row 1
lastRow.value = 1; lastRow.value = 1
} }
}); })
// Track which items are visible (at least 75% in viewport) // Track which items are visible (at least 75% in viewport)
const visibleItems = ref<Set<number>>(new Set()); const visibleItems = ref<Set<number>>(new Set())
function setItemRef(el: HTMLElement | null, index: number) { function setItemRef(el: HTMLElement | null, index: number) {
itemRefs.value[index] = el; itemRefs.value[index] = el
} }
// Check if element is at least 75% visible horizontally // Check if element is at least 75% visible horizontally
function isElementVisible(el: HTMLElement | null): boolean { function isElementVisible(el: HTMLElement | null): boolean {
if (!el) return false; if (!el) return false
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect()
const viewportWidth = window.innerWidth; const viewportWidth = window.innerWidth
// Calculate how much of the element is visible // Calculate how much of the element is visible
const visibleLeft = Math.max(0, rect.left); const visibleLeft = Math.max(0, rect.left)
const visibleRight = Math.min(viewportWidth, rect.right); const visibleRight = Math.min(viewportWidth, rect.right)
const visibleWidth = Math.max(0, visibleRight - visibleLeft); const visibleWidth = Math.max(0, visibleRight - visibleLeft)
const visibleRatio = visibleWidth / rect.width; const visibleRatio = visibleWidth / rect.width
return visibleRatio >= 0.75; return visibleRatio >= 0.75
} }
function updateVisibility() { function updateVisibility() {
const newVisible = new Set<number>(); const newVisible = new Set<number>()
for (let i = 0; i < itemRefs.value.length; i++) { for (let i = 0; i < itemRefs.value.length; i++) {
// Always include items 0, 1, 2 (big image and left side) // Always include items 0, 1, 2 (big image and left side)
if (i <= 2 || isElementVisible(itemRefs.value[i])) { if (i <= 2 || isElementVisible(itemRefs.value[i])) {
newVisible.add(i); newVisible.add(i)
} }
} }
visibleItems.value = newVisible; visibleItems.value = newVisible
} }
onMounted(() => { onMounted(() => {
window.addEventListener('resize', updateVisibility); window.addEventListener("resize", updateVisibility)
document.addEventListener('focusin', handleDocumentFocusIn); document.addEventListener("focusin", handleDocumentFocusIn)
// Initial visibility check after render // Initial visibility check after render
nextTick(() => { nextTick(() => {
updateVisibility(); updateVisibility()
}); })
}); })
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('resize', updateVisibility); window.removeEventListener("resize", updateVisibility)
document.removeEventListener('focusin', handleDocumentFocusIn); document.removeEventListener("focusin", handleDocumentFocusIn)
}); })
function clearHeroFocus() { function clearHeroFocus() {
focusedIndex.value = null; focusedIndex.value = null
} }
function handleDocumentFocusIn(event: FocusEvent) { function handleDocumentFocusIn(event: FocusEvent) {
const target = event.target as HTMLElement | null; const target = event.target as HTMLElement | null
if (!target?.closest('.collage-hero')) { if (!target?.closest(".collage-hero")) {
clearHeroFocus(); clearHeroFocus()
} }
} }
@@ -180,20 +217,23 @@ function handleDocumentFocusIn(event: FocusEvent) {
// col 4: fourth right column (12, 14, 13) // col 4: fourth right column (12, 14, 13)
// col 5: fifth right column (15, 16) // col 5: fifth right column (15, 16)
interface NavCoord { row: number; col: number } interface NavCoord {
row: number
col: number
}
const coordMap: Record<number, NavCoord> = { const coordMap: Record<number, NavCoord> = {
0: { row: 1, col: 0 }, // Big image 0: { row: 1, col: 0 }, // Big image
// Left side (col -1) // Left side (col -1)
1: { row: 0, col: -1 }, // top left 1: { row: 0, col: -1 }, // top left
2: { row: 2, col: -1 }, // bottom left 2: { row: 2, col: -1 }, // bottom left
// Right side - sequential columns // Right side - sequential columns
3: { row: 0, col: 1 }, 3: { row: 0, col: 1 },
4: { row: 2, col: 1 }, 4: { row: 2, col: 1 },
5: { row: 1, col: 1 }, 5: { row: 1, col: 1 },
6: { row: 0, col: 2 }, 6: { row: 0, col: 2 },
7: { row: 2, col: 2 }, 7: { row: 2, col: 2 },
8: { row: 1, col: 2 }, 8: { row: 1, col: 2 },
9: { row: 0, col: 3 }, 9: { row: 0, col: 3 },
10: { row: 2, col: 3 }, 10: { row: 2, col: 3 },
11: { row: 1, col: 3 }, 11: { row: 1, col: 3 },
12: { row: 0, col: 4 }, 12: { row: 0, col: 4 },
@@ -211,198 +251,198 @@ const coordMap: Record<number, NavCoord> = {
24: { row: 0, col: 8 }, 24: { row: 0, col: 8 },
25: { row: 2, col: 8 }, 25: { row: 2, col: 8 },
26: { row: 1, col: 8 }, 26: { row: 1, col: 8 },
}; }
function isTopRowItem(index: number): boolean { function isTopRowItem(index: number): boolean {
const coord = coordMap[index]; const coord = coordMap[index]
return index !== 0 && coord?.row === 0; return index !== 0 && coord?.row === 0
} }
// Reverse lookup: find item index at given coordinates // Reverse lookup: find item index at given coordinates
function findItemAt(row: number, col: number): number | null { function findItemAt(row: number, col: number): number | null {
for (const [idx, coord] of Object.entries(coordMap)) { for (const [idx, coord] of Object.entries(coordMap)) {
if (coord.row === row && coord.col === col) return parseInt(idx); if (coord.row === row && coord.col === col) return parseInt(idx)
} }
return null; return null
} }
// Find nearest item in a direction from current position // Find nearest item in a direction from current position
function findNext(currentIdx: number, direction: 'up' | 'down' | 'left' | 'right'): number | null { function findNext(currentIdx: number, direction: "up" | "down" | "left" | "right"): number | null {
const current = coordMap[currentIdx]; const current = coordMap[currentIdx]
if (!current) return null; if (!current) return null
// Use lastRow for big image vertical navigation // Use lastRow for big image vertical navigation
const effectiveRow = current.col === 0 ? lastRow.value : current.row; const effectiveRow = current.col === 0 ? lastRow.value : current.row
if (direction === 'left') { if (direction === "left") {
// Moving left: decrease col // Moving left: decrease col
const targetCol = current.col - 1; const targetCol = current.col - 1
if (current.col === 0) { if (current.col === 0) {
// From big image, go to col -1 if it exists for the effective row // From big image, go to col -1 if it exists for the effective row
// Row 1 has no col -1, so stay on big image (or could wrap to last item) // Row 1 has no col -1, so stay on big image (or could wrap to last item)
const leftItem = findItemAt(effectiveRow, -1); const leftItem = findItemAt(effectiveRow, -1)
if (leftItem !== null) { if (leftItem !== null) {
return leftItem; return leftItem
} }
// Row 1 has no left item - fall back to top-left tile so left side stays reachable // Row 1 has no left item - fall back to top-left tile so left side stays reachable
const topLeftItem = findItemAt(0, -1); const topLeftItem = findItemAt(0, -1)
if (topLeftItem !== null) { if (topLeftItem !== null) {
lastRow.value = 0; lastRow.value = 0
return topLeftItem; return topLeftItem
} }
// No fallback available - stay put // No fallback available - stay put
return 0; return 0
} }
if (targetCol < -1) { if (targetCol < -1) {
// Already at col -1, can't go further left - stay put // Already at col -1, can't go further left - stay put
return currentIdx; return currentIdx
} }
if (targetCol === 0) { if (targetCol === 0) {
// Moving to big image - remember current row // Moving to big image - remember current row
lastRow.value = current.row; lastRow.value = current.row
return 0; return 0
} }
// Find item at same row, col-1 // Find item at same row, col-1
return findItemAt(current.row, targetCol); return findItemAt(current.row, targetCol)
} }
if (direction === 'right') { if (direction === "right") {
// Moving right: increase col // Moving right: increase col
const targetCol = current.col + 1; const targetCol = current.col + 1
if (current.col === 0) { if (current.col === 0) {
// From big image, go to col 1 at remembered row // From big image, go to col 1 at remembered row
return findItemAt(lastRow.value, 1); return findItemAt(lastRow.value, 1)
} }
if (current.col === -1) { if (current.col === -1) {
// From left column, go to big image // From left column, go to big image
lastRow.value = current.row; lastRow.value = current.row
return 0; return 0
} }
// Find item at same row, col+1, but only if it's visible // Find item at same row, col+1, but only if it's visible
const nextItem = findItemAt(current.row, targetCol); const nextItem = findItemAt(current.row, targetCol)
if (nextItem !== null && visibleItems.value.has(nextItem)) { if (nextItem !== null && visibleItems.value.has(nextItem)) {
return nextItem; return nextItem
} }
// No more visible items to the right - stay put // No more visible items to the right - stay put
return currentIdx; return currentIdx
} }
// Helper to check if item at row/col is visible // Helper to check if item at row/col is visible
const isItemVisibleAt = (row: number, col: number) => { const isItemVisibleAt = (row: number, col: number) => {
const item = findItemAt(row, col); const item = findItemAt(row, col)
return item !== null && visibleItems.value.has(item); return item !== null && visibleItems.value.has(item)
}; }
if (direction === 'up') { if (direction === "up") {
const targetRow = effectiveRow - 1; const targetRow = effectiveRow - 1
if (targetRow < 0) return null; // Exit up if (targetRow < 0) return null // Exit up
if (current.col === 0) { if (current.col === 0) {
// Big image: up/down always exits the hero section // Big image: up/down always exits the hero section
return null; return null
} }
// Check if target row item at same col is visible // Check if target row item at same col is visible
if (!isItemVisibleAt(targetRow, current.col)) { if (!isItemVisibleAt(targetRow, current.col)) {
// Skip to row above if middle row is not visible at this column // Skip to row above if middle row is not visible at this column
if (targetRow === 1 && isItemVisibleAt(0, current.col)) { if (targetRow === 1 && isItemVisibleAt(0, current.col)) {
const item = findItemAt(0, current.col); const item = findItemAt(0, current.col)
if (item !== null) return item; if (item !== null) return item
} }
return currentIdx; // Stay put return currentIdx // Stay put
} }
// Find item at row-1, same col (or nearest) // Find item at row-1, same col (or nearest)
let item = findItemAt(targetRow, current.col); let item = findItemAt(targetRow, current.col)
if (item !== null) return item; if (item !== null) return item
// Try to find nearest col in target row // Try to find nearest col in target row
for (let c = current.col; c >= -1; c--) { for (let c = current.col; c >= -1; c--) {
item = findItemAt(targetRow, c); item = findItemAt(targetRow, c)
if (item !== null && isItemVisibleAt(targetRow, c)) return item; if (item !== null && isItemVisibleAt(targetRow, c)) return item
} }
for (let c = current.col + 1; c <= 10; c++) { for (let c = current.col + 1; c <= 10; c++) {
item = findItemAt(targetRow, c); item = findItemAt(targetRow, c)
if (item !== null && isItemVisibleAt(targetRow, c)) return item; if (item !== null && isItemVisibleAt(targetRow, c)) return item
} }
return null; return null
} }
if (direction === 'down') { if (direction === "down") {
const targetRow = effectiveRow + 1; const targetRow = effectiveRow + 1
if (targetRow > 2) return null; // Exit down if (targetRow > 2) return null // Exit down
if (current.col === 0) { if (current.col === 0) {
// Big image: up/down always exits the hero section // Big image: up/down always exits the hero section
return null; return null
} }
// Check if target row item at same col is visible // Check if target row item at same col is visible
if (!isItemVisibleAt(targetRow, current.col)) { if (!isItemVisibleAt(targetRow, current.col)) {
// Skip to row below if middle row is not visible at this column // Skip to row below if middle row is not visible at this column
if (targetRow === 1 && isItemVisibleAt(2, current.col)) { if (targetRow === 1 && isItemVisibleAt(2, current.col)) {
const item = findItemAt(2, current.col); const item = findItemAt(2, current.col)
if (item !== null) return item; if (item !== null) return item
} }
return currentIdx; // Stay put return currentIdx // Stay put
} }
// Find item at row+1, same col (or nearest) // Find item at row+1, same col (or nearest)
let item = findItemAt(targetRow, current.col); let item = findItemAt(targetRow, current.col)
if (item !== null) return item; if (item !== null) return item
// Try to find nearest col in target row // Try to find nearest col in target row
for (let c = current.col; c >= -1; c--) { for (let c = current.col; c >= -1; c--) {
item = findItemAt(targetRow, c); item = findItemAt(targetRow, c)
if (item !== null && isItemVisibleAt(targetRow, c)) return item; if (item !== null && isItemVisibleAt(targetRow, c)) return item
} }
for (let c = current.col + 1; c <= 10; c++) { for (let c = current.col + 1; c <= 10; c++) {
item = findItemAt(targetRow, c); item = findItemAt(targetRow, c)
if (item !== null && isItemVisibleAt(targetRow, c)) return item; if (item !== null && isItemVisibleAt(targetRow, c)) return item
} }
return null; return null
} }
return null; return null
} }
function handleKeyDown(e: KeyboardEvent) { function handleKeyDown(e: KeyboardEvent) {
// Only handle if focus is within this component // Only handle if focus is within this component
const target = e.target as HTMLElement; const target = e.target as HTMLElement
if (!target.closest('.collage-hero')) return; if (!target.closest(".collage-hero")) return
const direction = { const direction = {
ArrowUp: 'up', ArrowUp: "up",
ArrowDown: 'down', ArrowDown: "down",
ArrowLeft: 'left', ArrowLeft: "left",
ArrowRight: 'right', ArrowRight: "right",
}[e.key] as 'up' | 'down' | 'left' | 'right' | undefined; }[e.key] as "up" | "down" | "left" | "right" | undefined
if (!direction) { if (!direction) {
if (e.key === 'Enter' && focusedIndex.value !== null) { if (e.key === "Enter" && focusedIndex.value !== null) {
const item = collageItems.value[focusedIndex.value]; const item = collageItems.value[focusedIndex.value]
e.preventDefault(); e.preventDefault()
e.stopPropagation(); e.stopPropagation()
if (item) handleItemClick(item, focusedIndex.value); if (item) handleItemClick(item, focusedIndex.value)
} }
return; return
} }
const current = focusedIndex.value ?? 0; const current = focusedIndex.value ?? 0
const next = findNext(current, direction); const next = findNext(current, direction)
if (next !== null && itemRefs.value[next]) { if (next !== null && itemRefs.value[next]) {
e.preventDefault(); e.preventDefault()
e.stopPropagation(); e.stopPropagation()
focusedIndex.value = next; focusedIndex.value = next
itemRefs.value[next]?.focus({ preventScroll: true }); itemRefs.value[next]?.focus({ preventScroll: true })
return; return
} }
// Navigation is leaving this section; clear local highlight and let global handler continue. // Navigation is leaving this section; clear local highlight and let global handler continue.
clearHeroFocus(); clearHeroFocus()
// If next is null, let event bubble to global navigation // If next is null, let event bubble to global navigation
} }
@@ -410,154 +450,167 @@ function handleKeyDown(e: KeyboardEvent) {
function getItemAttrs(index: number) { function getItemAttrs(index: number) {
if (index === 0) { if (index === 0) {
// The hero always enters through the featured item when moving into row 0. // The hero always enters through the featured item when moving into row 0.
return { ...navAttrs(0, index, 0) }; return { ...navAttrs(0, index, 0) }
} }
// All tiles participate in the global focus model so only one visual highlight exists // All tiles participate in the global focus model so only one visual highlight exists
// and Enter/gamepad A targets the currently highlighted tile. // and Enter/gamepad A targets the currently highlighted tile.
return { ...navAttrs(0, index) }; return { ...navAttrs(0, index) }
} }
// Check if an item index should be visible based on its column and row // Check if an item index should be visible based on its column and row
function isItemVisible(index: number): boolean { function isItemVisible(index: number): boolean {
// Always show the first few items (big image and left side) // Always show the first few items (big image and left side)
if (index <= 2) return true; if (index <= 2) return true
return visibleItems.value.has(index); return visibleItems.value.has(index)
} }
const props = defineProps<{ const props = defineProps<{
items: MediaItem[]; items: MediaItem[]
featuredItem?: MediaItem | null; featuredItem?: MediaItem | null
hasResumePosition: (filePath: string | null) => boolean; hasResumePosition: (filePath: string | null) => boolean
}>(); }>()
const emit = defineEmits<{ const emit = defineEmits<{
play: [string]; play: [string]
info: [MediaItem]; info: [MediaItem]
select: [MediaItem]; select: [MediaItem]
}>(); }>()
// Max items we might ever need - use a generous constant // Max items we might ever need - use a generous constant
const maxItems = 27; const maxItems = 27
// Get items for the collage based on visible columns // Get items for the collage based on visible columns
const collageItems = computed(() => { const collageItems = computed(() => {
const result: MediaItem[] = []; const result: MediaItem[] = []
// Add featured item first // Add featured item first
if (props.featuredItem) { if (props.featuredItem) {
result.push(props.featuredItem); result.push(props.featuredItem)
} }
// Add more items, avoiding duplicates, up to max needed // Add more items, avoiding duplicates, up to max needed
for (const item of props.items) { for (const item of props.items) {
if (result.length >= maxItems) break; if (result.length >= maxItems) break
if (!result.find(r => r.id === item.id)) { if (!result.find((r) => r.id === item.id)) {
result.push(item); result.push(item)
} }
} }
return result; return result
}); })
function getImageUrl(item: MediaItem): string | undefined { function getImageUrl(item: MediaItem): string | undefined {
const coverPath = item.cover_path; const coverPath = item.cover_path
const backdropPath = item.type === 'movies' const backdropPath =
? (item.data as Movie).backdrop_path item.type === "movies"
: (item.data as Series).backdrop_path; ? (item.data as Movie).backdrop_path
const imagePath = coverPath || backdropPath; : (item.data as Series).backdrop_path
const imagePath = coverPath || backdropPath
// Check if the path is an image (not a video) // Check if the path is an image (not a video)
if (imagePath && /\.(webm|mp4|mkv|avi|mov)$/i.test(imagePath)) { if (imagePath && /\.(webm|mp4|mkv|avi|mov)$/i.test(imagePath)) {
return undefined; return undefined
} }
return getCoverUrl(imagePath, item.root_id); return getCoverUrl(imagePath, item.root_id)
} }
function getVideoSources(item: MediaItem): Array<{ src: string; type: string; codecs: string }> { function getVideoSources(item: MediaItem): Array<{ src: string; type: string; codecs: string }> {
if (isVideoPath(item.cover_path)) { if (isVideoPath(item.cover_path)) {
return [{ src: getVideoPreviewUrl(getCoverUrl(item.cover_path, item.root_id)), ...getVideoSourceAttributes(item.cover_path) }]; return [
{
src: getVideoPreviewUrl(getCoverUrl(item.cover_path, item.root_id)),
...getVideoSourceAttributes(item.cover_path),
},
]
} }
const showreelSourceSets = item.showreel_source_sets; const showreelSourceSets = item.showreel_source_sets
if (showreelSourceSets && showreelSourceSets.length > 0) { if (showreelSourceSets && showreelSourceSets.length > 0) {
return showreelSourceSets[0] return showreelSourceSets[0]
.filter(path => isVideoPath(path)) .filter((path) => isVideoPath(path))
.map(path => ({ src: getVideoPreviewUrl(getCoverUrl(path, item.root_id)), ...getVideoSourceAttributes(path) })); .map((path) => ({
src: getVideoPreviewUrl(getCoverUrl(path, item.root_id)),
...getVideoSourceAttributes(path),
}))
} }
const showreel = item.showreel_images; const showreel = item.showreel_images
if (showreel && showreel.length > 0) { if (showreel && showreel.length > 0) {
return showreel return showreel
.filter(path => isVideoPath(path)) .filter((path) => isVideoPath(path))
.map(path => ({ src: getVideoPreviewUrl(getCoverUrl(path, item.root_id)), ...getVideoSourceAttributes(path) })); .map((path) => ({
src: getVideoPreviewUrl(getCoverUrl(path, item.root_id)),
...getVideoSourceAttributes(path),
}))
} }
return []; return []
} }
function getRating(item: MediaItem): number | null { function getRating(item: MediaItem): number | null {
if (item.type === 'movies') { if (item.type === "movies") {
return (item.data as Movie).info?.rating ?? null; return (item.data as Movie).info?.rating ?? null
} }
return (item.data as Series).info?.rating ?? null; return (item.data as Series).info?.rating ?? null
} }
function getRatingClass(item: MediaItem): string { function getRatingClass(item: MediaItem): string {
const rating = getRating(item); const rating = getRating(item)
if (!rating) return ''; if (!rating) return ""
if (rating >= 7.5) return 'rating-high'; if (rating >= 7.5) return "rating-high"
if (rating >= 6) return 'rating-medium'; if (rating >= 6) return "rating-medium"
return 'rating-low'; return "rating-low"
} }
function getResolution(item: MediaItem): string | null { function getResolution(item: MediaItem): string | null {
if (item.type === 'movies') { if (item.type === "movies") {
const movie = item.data as Movie; const movie = item.data as Movie
return Object.values(movie.torrents || {})[0]?.resolution ?? null; return Object.values(movie.torrents || {})[0]?.resolution ?? null
} }
return null; return null
} }
function getOverview(item: MediaItem): string | null { function getOverview(item: MediaItem): string | null {
const overview = item.type === 'movies' const overview =
? (item.data as Movie).info?.overview item.type === "movies"
: (item.data as Series).info?.overview; ? (item.data as Movie).info?.overview
if (!overview) return null; : (item.data as Series).info?.overview
return overview.length > 150 ? overview.slice(0, 150) + '...' : overview; if (!overview) return null
return overview.length > 150 ? overview.slice(0, 150) + "..." : overview
} }
function getPlayableFile(item: MediaItem): string | null { function getPlayableFile(item: MediaItem): string | null {
if (item.type === 'movies') { if (item.type === "movies") {
const movie = item.data as Movie; const movie = item.data as Movie
return Object.values(movie.torrents || {})[0]?.playable_file ?? null; return Object.values(movie.torrents || {})[0]?.playable_file ?? null
} }
const series = item.data as Series; const series = item.data as Series
for (const season of series.seasons || []) { for (const season of series.seasons || []) {
for (const episode of season.episodes || []) { for (const episode of season.episodes || []) {
for (const torrent of Object.values(episode.torrents || {})) { for (const torrent of Object.values(episode.torrents || {})) {
if (torrent.playable_file) return torrent.playable_file; if (torrent.playable_file) return torrent.playable_file
} }
} }
} }
return null; return null
} }
function handlePlay(item: MediaItem) { function handlePlay(item: MediaItem) {
const file = getPlayableFile(item); const file = getPlayableFile(item)
if (file) emit('play', file); if (file) emit("play", file)
} }
function getPlayLabel(item: MediaItem): string { function getPlayLabel(item: MediaItem): string {
return props.hasResumePosition(getPlayableFile(item)) ? 'Continue' : 'Play'; return props.hasResumePosition(getPlayableFile(item)) ? "Continue" : "Play"
} }
function handleItemClick(item: MediaItem, index: number) { function handleItemClick(item: MediaItem, index: number) {
if (index === 0) { if (index === 0) {
emit('info', item); emit("info", item)
} else { } else {
emit('select', item); emit("select", item)
} }
} }
</script> </script>
@@ -571,7 +624,7 @@ function handleItemClick(item: MediaItem, index: number) {
overflow: visible; overflow: visible;
background: var(--bg-primary); background: var(--bg-primary);
--h: clamp(450px, 70vh, 600px); --h: clamp(450px, 70vh, 600px);
--small-w: calc(0.433 * var(--h)); /* Small hex width = 0.866 * 50% of height */ --small-w: calc(0.433 * var(--h)); /* Small hex width = 0.866 * 50% of height */
--big-edge: calc(1.0833 * var(--h)); /* Big hex right edge = 1.3 * 0.8333 * height */ --big-edge: calc(1.0833 * var(--h)); /* Big hex right edge = 1.3 * 0.8333 * height */
} }
@@ -584,7 +637,11 @@ function handleItemClick(item: MediaItem, index: number) {
position: absolute; position: absolute;
cursor: pointer; cursor: pointer;
overflow: hidden; overflow: hidden;
transition: transform 0.3s ease, filter 0.3s ease, opacity 0.3s ease, visibility 0.3s ease; transition:
transform 0.3s ease,
filter 0.3s ease,
opacity 0.3s ease,
visibility 0.3s ease;
} }
/* Media (images and videos) fill the collage item */ /* Media (images and videos) fill the collage item */
@@ -595,7 +652,11 @@ function handleItemClick(item: MediaItem, index: number) {
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
object-position: center 30%; object-position: center 30%;
transition: transform 0.3s ease, filter 0.3s ease, opacity 0.3s ease, visibility 0.3s ease; transition:
transform 0.3s ease,
filter 0.3s ease,
opacity 0.3s ease,
visibility 0.3s ease;
} }
/* Hidden items - keep in DOM for measurement but invisible */ /* Hidden items - keep in DOM for measurement but invisible */
@@ -637,7 +698,8 @@ html:not(.mouse-active) .collage-item.nav-focused .hex-focus-outline {
/* Blinking animation for focus outline */ /* Blinking animation for focus outline */
@keyframes hex-outline-blink { @keyframes hex-outline-blink {
0%, 100% { 0%,
100% {
opacity: 1; opacity: 1;
} }
50% { 50% {
@@ -677,16 +739,16 @@ html:not(.mouse-active) .collage-item.nav-focused .hex-focus-outline {
clip-path: polygon( clip-path: polygon(
16.67% 0%, 16.67% 0%,
16.67% 25%, 16.67% 25%,
0% 37.5%, 0% 37.5%,
0% 62.5%, 0% 62.5%,
16.67% 75%, 16.67% 75%,
16.67% 100%, 16.67% 100%,
83.33% 100%, 83.33% 100%,
83.33% 75%, 83.33% 75%,
100% 62.5%, 100% 62.5%,
100% 37.5%, 100% 37.5%,
83.33% 25%, 83.33% 25%,
83.33% 0% 83.33% 0%
); );
@@ -709,16 +771,16 @@ html:not(.mouse-active) .collage-item.nav-focused .hex-focus-outline {
clip-path: polygon( clip-path: polygon(
16.67% 0%, 16.67% 0%,
16.67% 25%, 16.67% 25%,
0% 37.5%, 0% 37.5%,
0% 62.5%, 0% 62.5%,
16.67% 75%, 16.67% 75%,
16.67% 100%, 16.67% 100%,
83.33% 100%, 83.33% 100%,
83.33% 75%, 83.33% 75%,
100% 62.5%, 100% 62.5%,
100% 37.5%, 100% 37.5%,
83.33% 25%, 83.33% 25%,
83.33% 0% 83.33% 0%
); );
@@ -731,14 +793,7 @@ html:not(.mouse-active) .collage-item.nav-focused .hex-focus-outline {
.collage-item:not(.collage-featured) { .collage-item:not(.collage-featured) {
height: 50%; height: 50%;
aspect-ratio: 0.866 / 1; aspect-ratio: 0.866 / 1;
clip-path: polygon( clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%);
50% 0%,
100% 25%,
100% 75%,
50% 100%,
0% 75%,
0% 25%
);
} }
/* LEFT SIDE - positioned at left edge */ /* LEFT SIDE - positioned at left edge */
@@ -758,13 +813,7 @@ html:not(.mouse-active) .collage-item.nav-focused .hex-focus-outline {
.collage-item.collage-item-2:not(.collage-featured) { .collage-item.collage-item-2:not(.collage-featured) {
height: 37.5%; height: 37.5%;
width: var(--small-w); width: var(--small-w);
clip-path: polygon( clip-path: polygon(50% 0%, 100% 33.333%, 100% 100%, 0% 100%, 0% 33.333%);
50% 0%,
100% 33.333%,
100% 100%,
0% 100%,
0% 33.333%
);
} }
/* Top-row items: keep side angles but flatten top edge to avoid top overflow */ /* Top-row items: keep side angles but flatten top edge to avoid top overflow */
@@ -772,13 +821,7 @@ html:not(.mouse-active) .collage-item.nav-focused .hex-focus-outline {
top: 0; top: 0;
height: 37.5%; height: 37.5%;
width: var(--small-w); width: var(--small-w);
clip-path: polygon( clip-path: polygon(100% 0%, 100% 66.667%, 50% 100%, 0% 66.667%, 0% 0%);
100% 0%,
100% 66.667%,
50% 100%,
0% 66.667%,
0% 0%
);
} }
/* RIGHT SIDE - Column 0 (closest to big image) */ /* RIGHT SIDE - Column 0 (closest to big image) */
@@ -929,7 +972,7 @@ html:not(.mouse-active) .collage-item.nav-focused .hex-focus-outline {
} }
.collage-placeholder::before { .collage-placeholder::before {
content: ''; content: "";
position: absolute; position: absolute;
inset: 0; inset: 0;
background: #e50914; background: #e50914;
@@ -942,7 +985,7 @@ html:not(.mouse-active) .collage-item.nav-focused .hex-focus-outline {
font-weight: bold; font-weight: bold;
text-align: center; text-align: center;
padding: 10px; padding: 10px;
text-shadow: 0 2px 4px rgba(0,0,0,0.8); text-shadow: 0 2px 4px rgba(0, 0, 0, 0.8);
z-index: 1; z-index: 1;
} }
@@ -987,9 +1030,15 @@ html:not(.mouse-active) .collage-item.nav-focused .hex-focus-outline {
font-size: 0.85rem; font-size: 0.85rem;
} }
.rating-high { color: #46d369; } .rating-high {
.rating-medium { color: #f9a825; } color: #46d369;
.rating-low { color: #e53935; } }
.rating-medium {
color: #f9a825;
}
.rating-low {
color: #e53935;
}
.meta-quality { .meta-quality {
background: rgba(255, 255, 255, 0.15); background: rgba(255, 255, 255, 0.15);
@@ -1027,7 +1076,9 @@ html:not(.mouse-active) .collage-item.nav-focused .hex-focus-outline {
background: rgba(0, 0, 0, 0.85); background: rgba(0, 0, 0, 0.85);
border-radius: 4px; border-radius: 4px;
opacity: 0; opacity: 0;
transition: opacity 0.3s ease, transform 0.3s ease; transition:
opacity 0.3s ease,
transform 0.3s ease;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
+22 -26
View File
@@ -1,43 +1,39 @@
<template> <template>
<div v-if="showDolbyLogo" class="dolby-badges" :class="{ compact }"> <div v-if="showDolbyLogo" class="dolby-badges" :class="{ compact }">
<img <img :src="dolbyLogoSrc" :alt="dolbyLogoAlt" class="dolby-logo" />
:src="dolbyLogoSrc"
:alt="dolbyLogoAlt"
class="dolby-logo"
/>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from "vue"
import dolbyAtmosUrl from '../assets/dolby-atmos.webp'; import dolbyAtmosUrl from "../assets/dolby-atmos.webp"
import dolbyVisionUrl from '../assets/dolby-vision.webp'; import dolbyVisionUrl from "../assets/dolby-vision.webp"
import dolbyVisionAtmosUrl from '../assets/dolby-vision-atmos.webp'; import dolbyVisionAtmosUrl from "../assets/dolby-vision-atmos.webp"
const props = defineProps<{ const props = defineProps<{
hasDolbyVision?: boolean | null; hasDolbyVision?: boolean | null
hasDolbyAtmos?: boolean | null; hasDolbyAtmos?: boolean | null
isHdr?: boolean | null; isHdr?: boolean | null
compact?: boolean; compact?: boolean
}>(); }>()
const hasDolbyVision = computed(() => props.hasDolbyVision === true); const hasDolbyVision = computed(() => props.hasDolbyVision === true)
const hasDolbyAtmos = computed(() => props.hasDolbyAtmos === true); const hasDolbyAtmos = computed(() => props.hasDolbyAtmos === true)
const compact = computed(() => props.compact === true); const compact = computed(() => props.compact === true)
const showDolbyLogo = computed(() => hasDolbyVision.value || hasDolbyAtmos.value); const showDolbyLogo = computed(() => hasDolbyVision.value || hasDolbyAtmos.value)
const dolbyLogoSrc = computed(() => { const dolbyLogoSrc = computed(() => {
if (hasDolbyVision.value && hasDolbyAtmos.value) return dolbyVisionAtmosUrl; if (hasDolbyVision.value && hasDolbyAtmos.value) return dolbyVisionAtmosUrl
if (hasDolbyVision.value) return dolbyVisionUrl; if (hasDolbyVision.value) return dolbyVisionUrl
return dolbyAtmosUrl; return dolbyAtmosUrl
}); })
const dolbyLogoAlt = computed(() => { const dolbyLogoAlt = computed(() => {
if (hasDolbyVision.value && hasDolbyAtmos.value) return 'Dolby Vision + Dolby Atmos'; if (hasDolbyVision.value && hasDolbyAtmos.value) return "Dolby Vision + Dolby Atmos"
if (hasDolbyVision.value) return 'Dolby Vision'; if (hasDolbyVision.value) return "Dolby Vision"
return 'Dolby Atmos'; return "Dolby Atmos"
}); })
</script> </script>
<style scoped> <style scoped>
+124 -113
View File
@@ -26,19 +26,10 @@
</template> </template>
<!-- Detail mode: show current category + Details --> <!-- Detail mode: show current category + Details -->
<template v-else> <template v-else>
<button <button class="header-nav-item" v-bind="navAttrs(navRow, 0)" @focus="goToCategory">
class="header-nav-item" {{ currentView === "movies" ? "Movies" : "Series" }}
v-bind="navAttrs(navRow, 0)"
@focus="goToCategory"
>
{{ currentView === 'movies' ? 'Movies' : 'Series' }}
</button>
<button
class="header-nav-item active"
v-bind="navAttrs(navRow, 1, 1)"
>
Details
</button> </button>
<button class="header-nav-item active" v-bind="navAttrs(navRow, 1, 1)">Details</button>
</template> </template>
</nav> </nav>
</div> </div>
@@ -63,14 +54,22 @@
</div> </div>
<div class="header-settings"> <div class="header-settings">
<button <button class="header-settings-btn" title="Settings" @click="showSettings = !showSettings">
class="header-settings-btn" <svg
title="Settings" xmlns="http://www.w3.org/2000/svg"
@click="showSettings = !showSettings" width="20"
> height="20"
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> viewBox="0 0 24 24"
<circle cx="12" cy="12" r="3"/> fill="none"
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 5 15.4 1.65 1.65 0 0 0 3.4 15H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.6a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/> stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="3" />
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 5 15.4 1.65 1.65 0 0 0 3.4 15H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.6a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"
/>
</svg> </svg>
</button> </button>
@@ -78,8 +77,18 @@
<div v-if="showSettings" class="settings-view"> <div v-if="showSettings" class="settings-view">
<div class="settings-header"> <div class="settings-header">
<button class="settings-back" @click="showSettings = false"> <button class="settings-back" @click="showSettings = false">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg
<path d="M19 12H5M12 19l-7-7 7-7"/> xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg> </svg>
<span>Back</span> <span>Back</span>
</button> </button>
@@ -118,11 +127,7 @@
</div> </div>
<div class="roots-actions"> <div class="roots-actions">
<button <button v-if="isDesktopApp" class="roots-add-btn" @click="addRoot">
v-if="isDesktopApp"
class="roots-add-btn"
@click="addRoot"
>
+ Add Folder + Add Folder
</button> </button>
</div> </div>
@@ -134,125 +139,127 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, computed, onMounted, onUnmounted } from 'vue'; import { ref, watch, computed, onMounted, onUnmounted } from "vue"
import { useRouter } from 'vue-router'; import { useRouter } from "vue-router"
import { navAttrs } from '../composables/useKeyboardNavigation'; import { navAttrs } from "../composables/useKeyboardNavigation"
import logoUrl from '../assets/mediahive.webp'; import logoUrl from "../assets/mediahive.webp"
import { fetchRoots, replaceRoots, pickFolderAndAddRoot } from '../api'; import { fetchRoots, replaceRoots, pickFolderAndAddRoot } from "../api"
interface RootEntry { interface RootEntry {
root_id: string; root_id: string
name: string; name: string
path: string; path: string
status: string; status: string
} }
const props = defineProps<{ const props = defineProps<{
currentView: 'movies' | 'series'; currentView: "movies" | "series"
searchQuery: string; searchQuery: string
mpcBeConnected: boolean; mpcBeConnected: boolean
navRow: number; navRow: number
position: 'top' | 'after-hero' | 'after-movie-header' | 'after-series-hero'; position: "top" | "after-hero" | "after-movie-header" | "after-series-hero"
}>(); }>()
const emit = defineEmits<{ const emit = defineEmits<{
search: [string]; search: [string]
goBack: []; goBack: []
}>(); }>()
const router = useRouter(); const router = useRouter()
const searchInputRef = ref<HTMLInputElement | null>(null); const searchInputRef = ref<HTMLInputElement | null>(null)
const localSearch = ref(props.searchQuery); const localSearch = ref(props.searchQuery)
const isDesktopApp = ref(typeof (window as any).pywebview !== 'undefined'); const isDesktopApp = ref(typeof (window as any).pywebview !== "undefined")
function _onPywebviewReady() { isDesktopApp.value = true; } function _onPywebviewReady() {
window.addEventListener('pywebviewready', _onPywebviewReady, { once: true }); isDesktopApp.value = true
onUnmounted(() => window.removeEventListener('pywebviewready', _onPywebviewReady)); }
window.addEventListener("pywebviewready", _onPywebviewReady, { once: true })
onUnmounted(() => window.removeEventListener("pywebviewready", _onPywebviewReady))
const showSettings = ref(false); const showSettings = ref(false)
const roots = ref<RootEntry[]>([]); const roots = ref<RootEntry[]>([])
async function refreshRoots() { async function refreshRoots() {
try { try {
const data = await fetchRoots(); const data = await fetchRoots()
roots.value = data.map(r => ({ roots.value = data.map((r) => ({
root_id: r.root_id, root_id: r.root_id,
name: r.name, name: r.name,
path: r.path, path: r.path,
status: r.status, status: r.status,
})); }))
} catch (e) { } catch (e) {
console.error('Failed to fetch roots:', e); console.error("Failed to fetch roots:", e)
} }
} }
async function removeRoot(rootId: string) { async function removeRoot(rootId: string) {
const filtered = roots.value.filter(r => r.root_id !== rootId); const filtered = roots.value.filter((r) => r.root_id !== rootId)
const newRoots = Object.fromEntries(filtered.map(r => [r.name, r.path])); const newRoots = Object.fromEntries(filtered.map((r) => [r.name, r.path]))
try { try {
await replaceRoots(newRoots); await replaceRoots(newRoots)
await refreshRoots(); await refreshRoots()
} catch (e) { } catch (e) {
console.error('Failed to remove root:', e); console.error("Failed to remove root:", e)
alert('Failed to remove root'); alert("Failed to remove root")
} }
} }
async function addRoot() { async function addRoot() {
const folder = await pickFolderAndAddRoot(); const folder = await pickFolderAndAddRoot()
if (!folder) return; if (!folder) return
const name = folder.split('/').pop() || folder.split('\\').pop() || 'media'; const name = folder.split("/").pop() || folder.split("\\").pop() || "media"
// Resolve name collisions // Resolve name collisions
let uniqueName = name; let uniqueName = name
let suffix = 2; let suffix = 2
const currentNames = new Set(roots.value.map(r => r.name)); const currentNames = new Set(roots.value.map((r) => r.name))
while (currentNames.has(uniqueName)) { while (currentNames.has(uniqueName)) {
uniqueName = `${name}${suffix}`; uniqueName = `${name}${suffix}`
suffix++; suffix++
} }
const newRoots = Object.fromEntries(roots.value.map(r => [r.name, r.path])); const newRoots = Object.fromEntries(roots.value.map((r) => [r.name, r.path]))
newRoots[uniqueName] = folder; newRoots[uniqueName] = folder
try { try {
await replaceRoots(newRoots); await replaceRoots(newRoots)
await refreshRoots(); await refreshRoots()
showSettings.value = false; showSettings.value = false
} catch (e) { } catch (e) {
console.error('Failed to add root:', e); console.error("Failed to add root:", e)
alert('Failed to add root'); alert("Failed to add root")
} }
} }
watch(showSettings, (visible) => { watch(showSettings, (visible) => {
if (visible) void refreshRoots(); if (visible) void refreshRoots()
}); })
// Check if we're on a detail page // Check if we're on a detail page
const isDetailPage = computed(() => { const isDetailPage = computed(() => {
return props.position === 'after-movie-header' || props.position === 'after-series-hero'; return props.position === "after-movie-header" || props.position === "after-series-hero"
}); })
// Check if search is active (has query and not on detail page) // Check if search is active (has query and not on detail page)
const isSearchActive = computed(() => { const isSearchActive = computed(() => {
return !isDetailPage.value && !!localSearch.value; return !isDetailPage.value && !!localSearch.value
}); })
// Switch views on focus (no Enter required) - only in browse mode // Switch views on focus (no Enter required) - only in browse mode
function switchToMovies() { function switchToMovies() {
if (!isDetailPage.value && props.currentView !== 'movies') { if (!isDetailPage.value && props.currentView !== "movies") {
router.push('/movies'); router.push("/movies")
} }
} }
function switchToSeries() { function switchToSeries() {
if (!isDetailPage.value && props.currentView !== 'series') { if (!isDetailPage.value && props.currentView !== "series") {
router.push('/series'); router.push("/series")
} }
} }
// Go back to category list from detail page // Go back to category list from detail page
function goToCategory() { function goToCategory() {
// Emit goBack to let App.vue handle navigation and focus restoration // Emit goBack to let App.vue handle navigation and focus restoration
emit('goBack'); emit("goBack")
} }
// Handle search input focus - navigate to search if we have a query // Handle search input focus - navigate to search if we have a query
@@ -262,52 +269,56 @@ function handleSearchFocus() {
// Sync local search to parent // Sync local search to parent
watch(localSearch, (val) => { watch(localSearch, (val) => {
emit('search', val); emit("search", val)
}); })
// Sync parent search to local (for external clears) // Sync parent search to local (for external clears)
watch(() => props.searchQuery, (val) => { watch(
if (val !== localSearch.value) { () => props.searchQuery,
localSearch.value = val; (val) => {
} if (val !== localSearch.value) {
}); localSearch.value = val
}
},
)
function handleEscape() { function handleEscape() {
// Clear search and blur // Clear search and blur
localSearch.value = ''; localSearch.value = ""
searchInputRef.value?.blur(); searchInputRef.value?.blur()
} }
function focusSearchInput() { function focusSearchInput() {
searchInputRef.value?.focus(); searchInputRef.value?.focus()
searchInputRef.value?.select(); searchInputRef.value?.select()
} }
function handleKeydown(e: KeyboardEvent) { function handleKeydown(e: KeyboardEvent) {
const target = e.target as HTMLElement | null; const target = e.target as HTMLElement | null
const isTypingTarget = Boolean( const isTypingTarget = Boolean(
target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) target &&
); (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable),
const isSearchShortcut = (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'f'; )
const isSlashShortcut = !e.ctrlKey && !e.metaKey && !e.altKey && e.code === 'Slash'; const isSearchShortcut = (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "f"
const isSlashShortcut = !e.ctrlKey && !e.metaKey && !e.altKey && e.code === "Slash"
if (isTypingTarget && !isSearchShortcut) { if (isTypingTarget && !isSearchShortcut) {
return; return
} }
if (isSearchShortcut || isSlashShortcut) { if (isSearchShortcut || isSlashShortcut) {
e.preventDefault(); e.preventDefault()
focusSearchInput(); focusSearchInput()
} }
} }
onMounted(() => { onMounted(() => {
window.addEventListener('keydown', handleKeydown); window.addEventListener("keydown", handleKeydown)
}); })
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown); window.removeEventListener("keydown", handleKeydown)
}); })
</script> </script>
<style scoped> <style scoped>
+49 -55
View File
@@ -17,104 +17,98 @@
</div> </div>
<p v-if="overview" class="hero-overview">{{ overview }}</p> <p v-if="overview" class="hero-overview">{{ overview }}</p>
<div class="hero-buttons"> <div class="hero-buttons">
<button <button class="btn btn-primary" @click="handlePlay" :disabled="!playableFile">
class="btn btn-primary"
@click="handlePlay"
:disabled="!playableFile"
>
Play Play
</button> </button>
<button class="btn btn-secondary" @click="$emit('info', item)"> <button class="btn btn-secondary" @click="$emit('info', item)"> More Info</button>
More Info
</button>
</div> </div>
</div> </div>
</section> </section>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from "vue"
import type { MediaItem, Movie, Series } from '../types'; import type { MediaItem, Movie, Series } from "../types"
import { getCoverUrl } from '../api'; import { getCoverUrl } from "../api"
const props = defineProps<{ const props = defineProps<{
item: MediaItem; item: MediaItem
}>(); }>()
const emit = defineEmits<{ const emit = defineEmits<{
play: [string]; play: [string]
info: [MediaItem]; info: [MediaItem]
}>(); }>()
const coverUrl = computed(() => { const coverUrl = computed(() => {
return getCoverUrl(props.item.cover_path, props.item.root_id); return getCoverUrl(props.item.cover_path, props.item.root_id)
}); })
const resolution = computed(() => { const resolution = computed(() => {
if (props.item.type === 'movies') { if (props.item.type === "movies") {
const movie = props.item.data as Movie; const movie = props.item.data as Movie
const torrents = Object.values(movie.torrents || {}); const torrents = Object.values(movie.torrents || {})
return torrents.length > 0 ? torrents[0].resolution : null; return torrents.length > 0 ? torrents[0].resolution : null
} }
return null; return null
}); })
const quality = computed(() => { const quality = computed(() => {
if (props.item.type === 'movies') { if (props.item.type === "movies") {
const movie = props.item.data as Movie; const movie = props.item.data as Movie
const torrents = Object.values(movie.torrents || {}); const torrents = Object.values(movie.torrents || {})
return torrents.length > 0 ? torrents[0].quality : null; return torrents.length > 0 ? torrents[0].quality : null
} }
return null; return null
}); })
const rating = computed(() => { const rating = computed(() => {
if (props.item.type === 'movies') { if (props.item.type === "movies") {
return (props.item.data as Movie).info?.rating; return (props.item.data as Movie).info?.rating
} }
return (props.item.data as Series).info?.rating; return (props.item.data as Series).info?.rating
}); })
const ratingClass = computed(() => { const ratingClass = computed(() => {
if (!rating.value) return ''; if (!rating.value) return ""
if (rating.value >= 7.5) return 'rating-high'; if (rating.value >= 7.5) return "rating-high"
if (rating.value >= 6) return 'rating-medium'; if (rating.value >= 6) return "rating-medium"
return 'rating-low'; return "rating-low"
}); })
const overview = computed(() => { const overview = computed(() => {
if (props.item.type === 'movies') { if (props.item.type === "movies") {
const o = (props.item.data as Movie).info?.overview; const o = (props.item.data as Movie).info?.overview
return o ? (o.length > 200 ? o.slice(0, 200) + '...' : o) : null; return o ? (o.length > 200 ? o.slice(0, 200) + "..." : o) : null
} }
const o = (props.item.data as Series).info?.overview; const o = (props.item.data as Series).info?.overview
return o ? (o.length > 200 ? o.slice(0, 200) + '...' : o) : null; return o ? (o.length > 200 ? o.slice(0, 200) + "..." : o) : null
}); })
const playableFile = computed(() => { const playableFile = computed(() => {
if (props.item.type === 'movies') { if (props.item.type === "movies") {
const movie = props.item.data as Movie; const movie = props.item.data as Movie
const torrents = Object.values(movie.torrents || {}); const torrents = Object.values(movie.torrents || {})
return torrents.length > 0 ? torrents[0].playable_file : null; return torrents.length > 0 ? torrents[0].playable_file : null
} }
// For series, get first available file from episodes // For series, get first available file from episodes
const series = props.item.data as Series; const series = props.item.data as Series
for (const season of series.seasons || []) { for (const season of series.seasons || []) {
for (const episode of season.episodes || []) { for (const episode of season.episodes || []) {
const torrents = Object.values(episode.torrents || {}); const torrents = Object.values(episode.torrents || {})
for (const torrent of torrents) { for (const torrent of torrents) {
if (torrent.playable_file) { if (torrent.playable_file) {
return torrent.playable_file; return torrent.playable_file
} }
} }
} }
} }
return null; return null
}); })
function handlePlay() { function handlePlay() {
if (playableFile.value) { if (playableFile.value) {
emit('play', playableFile.value); emit("play", playableFile.value)
} }
} }
</script> </script>
+13 -12
View File
@@ -14,26 +14,27 @@
:key="`raw-${code}`" :key="`raw-${code}`"
class="language-code-fallback" class="language-code-fallback"
:title="code" :title="code"
>{{ code }}</span> >{{ code }}</span
>
</span> </span>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from "vue"
import { buildLanguageFlags } from '../utils/languageFlags'; import { buildLanguageFlags } from "../utils/languageFlags"
const props = defineProps<{ const props = defineProps<{
label?: string; label?: string
codes: string[] | null | undefined; codes: string[] | null | undefined
compact?: boolean; compact?: boolean
}>(); }>()
const mapped = computed(() => buildLanguageFlags(props.codes)); const mapped = computed(() => buildLanguageFlags(props.codes))
const flagEntries = computed(() => mapped.value.flags); const flagEntries = computed(() => mapped.value.flags)
const unmappedCodes = computed(() => mapped.value.unmappedCodes); const unmappedCodes = computed(() => mapped.value.unmappedCodes)
const hasContent = computed(() => flagEntries.value.length > 0 || unmappedCodes.value.length > 0); const hasContent = computed(() => flagEntries.value.length > 0 || unmappedCodes.value.length > 0)
const compact = computed(() => props.compact === true); const compact = computed(() => props.compact === true)
</script> </script>
<style scoped> <style scoped>
+96 -76
View File
@@ -26,7 +26,7 @@
playsinline playsinline
></video> ></video>
<div v-else class="media-card-placeholder"> <div v-else class="media-card-placeholder">
{{ item.type === 'movies' ? '🎬' : item.type === 'episode' ? '📺' : '📺' }} {{ item.type === "movies" ? "🎬" : item.type === "episode" ? "📺" : "📺" }}
</div> </div>
<div v-if="rating" class="media-card-rating" :class="ratingClass"> <div v-if="rating" class="media-card-rating" :class="ratingClass">
{{ rating.toFixed(1) }} {{ rating.toFixed(1) }}
@@ -38,24 +38,43 @@
<span v-if="item.year" class="media-card-year">{{ item.year }}</span> <span v-if="item.year" class="media-card-year">{{ item.year }}</span>
</div> </div>
<template v-if="item.searchMatchInfo"> <template v-if="item.searchMatchInfo">
<div v-if="matchedPeople && matchedPeople.length > 0" class="media-card-detail match-reason"> <div
v-if="matchedPeople && matchedPeople.length > 0"
class="media-card-detail match-reason"
>
<template v-for="(person, idx) in matchedPeople" :key="person.name"> <template v-for="(person, idx) in matchedPeople" :key="person.name">
<span :class="person.highlightRoles ? 'match-dim' : 'match-name'">{{ person.name }}</span> <span :class="person.highlightRoles ? 'match-dim' : 'match-name'">{{
<span :class="person.highlightRoles ? 'match-highlight' : 'match-roles'">({{ person.roles }})</span><span v-if="idx < matchedPeople.length - 1">, </span> person.name
}}</span>
<span :class="person.highlightRoles ? 'match-highlight' : 'match-roles'"
>({{ person.roles }})</span
><span v-if="idx < matchedPeople.length - 1">, </span>
</template> </template>
</div> </div>
<div v-if="item.searchMatchInfo.matchedEpisodes && item.searchMatchInfo.matchedEpisodes.length > 0" class="media-card-episodes"> <div
<div v-for="ep in item.searchMatchInfo.matchedEpisodes.slice(0, 3)" :key="ep.name" class="matched-episode"> v-if="
item.searchMatchInfo.matchedEpisodes && item.searchMatchInfo.matchedEpisodes.length > 0
"
class="media-card-episodes"
>
<div
v-for="ep in item.searchMatchInfo.matchedEpisodes.slice(0, 3)"
:key="ep.name"
class="matched-episode"
>
<span class="match-name">{{ ep.name }}</span> <span class="match-name">{{ ep.name }}</span>
<span class="match-roles"> ({{ ep.location }})</span> <span class="match-roles"> ({{ ep.location }})</span>
</div> </div>
<div v-if="item.searchMatchInfo.matchedEpisodes.length > 3" class="matched-episode-more">+{{ item.searchMatchInfo.matchedEpisodes.length - 3 }} more</div> <div v-if="item.searchMatchInfo.matchedEpisodes.length > 3" class="matched-episode-more">
+{{ item.searchMatchInfo.matchedEpisodes.length - 3 }} more
</div>
</div> </div>
</template> </template>
<template v-else> <template v-else>
<div v-if="subtitle" class="media-card-detail">{{ subtitle }}</div> <div v-if="subtitle" class="media-card-detail">{{ subtitle }}</div>
<div v-if="directorAndCast" class="media-card-detail"> <div v-if="directorAndCast" class="media-card-detail">
<span v-if="director" class="director-name">{{ director }}</span><span v-if="director && filteredCastNames">, </span>{{ filteredCastNames }} <span v-if="director" class="director-name">{{ director }}</span
><span v-if="director && filteredCastNames">, </span>{{ filteredCastNames }}
</div> </div>
</template> </template>
</div> </div>
@@ -63,121 +82,122 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue'; import { computed, ref } from "vue"
import type { MediaItem, Movie, Series, EpisodeWithSeries } from '../types'; import type { MediaItem, Movie, Series, EpisodeWithSeries } from "../types"
import { getCoverUrl, isVideoPath } from '../api'; import { getCoverUrl, isVideoPath } from "../api"
import { navAttrs } from '../composables/useKeyboardNavigation'; import { navAttrs } from "../composables/useKeyboardNavigation"
const props = defineProps<{ const props = defineProps<{
item: MediaItem; item: MediaItem
navRow?: number; navRow?: number
navCol?: number; navCol?: number
}>(); }>()
defineEmits<{ defineEmits<{
click: []; click: []
}>(); }>()
const navAttributes = computed(() => { const navAttributes = computed(() => {
if (props.navRow !== undefined && props.navCol !== undefined) { if (props.navRow !== undefined && props.navCol !== undefined) {
return navAttrs(props.navRow, props.navCol); return navAttrs(props.navRow, props.navCol)
} }
return {}; return {}
}); })
const imageError = ref(false); const imageError = ref(false)
const posterImageUrl = computed(() => { const posterImageUrl = computed(() => {
if (imageError.value) return null; if (imageError.value) return null
if (!props.item.cover_path || isVideoPath(props.item.cover_path)) { if (!props.item.cover_path || isVideoPath(props.item.cover_path)) {
return null; return null
} }
return getCoverUrl(props.item.cover_path, props.item.root_id); return getCoverUrl(props.item.cover_path, props.item.root_id)
}); })
const posterVideoUrl = computed(() => { const posterVideoUrl = computed(() => {
if (props.item.cover_path && isVideoPath(props.item.cover_path)) { if (props.item.cover_path && isVideoPath(props.item.cover_path)) {
return getCoverUrl(props.item.cover_path, props.item.root_id); return getCoverUrl(props.item.cover_path, props.item.root_id)
} }
const fallbackVideo = props.item.showreel_images?.find(path => isVideoPath(path)); const fallbackVideo = props.item.showreel_images?.find((path) => isVideoPath(path))
return fallbackVideo ? getCoverUrl(fallbackVideo, props.item.root_id) : null; return fallbackVideo ? getCoverUrl(fallbackVideo, props.item.root_id) : null
}); })
const rating = computed(() => { const rating = computed(() => {
if (props.item.type === 'movies') { if (props.item.type === "movies") {
return (props.item.data as Movie).info?.rating; return (props.item.data as Movie).info?.rating
} }
if (props.item.type === 'episode') { if (props.item.type === "episode") {
const epData = props.item.data as EpisodeWithSeries; const epData = props.item.data as EpisodeWithSeries
return epData.episode.rating ?? epData.series.info?.rating; return epData.episode.rating ?? epData.series.info?.rating
} }
return (props.item.data as Series).info?.rating; return (props.item.data as Series).info?.rating
}); })
const ratingClass = computed(() => { const ratingClass = computed(() => {
if (!rating.value) return ''; if (!rating.value) return ""
if (rating.value >= 7.5) return 'rating-high'; if (rating.value >= 7.5) return "rating-high"
if (rating.value >= 6) return 'rating-medium'; if (rating.value >= 6) return "rating-medium"
return 'rating-low'; return "rating-low"
}); })
const displayTitle = computed(() => { const displayTitle = computed(() => {
if (props.item.type === 'episode') { if (props.item.type === "episode") {
const epData = props.item.data as EpisodeWithSeries; const epData = props.item.data as EpisodeWithSeries
return epData.episode.name || `Episode ${epData.episode.episode_number}`; return epData.episode.name || `Episode ${epData.episode.episode_number}`
} }
return props.item.title; return props.item.title
}); })
const subtitle = computed(() => { const subtitle = computed(() => {
if (props.item.type === 'episode') { if (props.item.type === "episode") {
const epData = props.item.data as EpisodeWithSeries; const epData = props.item.data as EpisodeWithSeries
return `${epData.series.title} S${epData.seasonNumber}E${epData.episode.episode_number}`; return `${epData.series.title} S${epData.seasonNumber}E${epData.episode.episode_number}`
} }
if (props.item.type === 'series') { if (props.item.type === "series") {
const creators = (props.item.data as Series).info?.creators; const creators = (props.item.data as Series).info?.creators
return creators && creators.length > 0 ? creators.join(', ') : null; return creators && creators.length > 0 ? creators.join(", ") : null
} }
return null; return null
}); })
const director = computed(() => { const director = computed(() => {
if (props.item.type !== 'movies') return null; if (props.item.type !== "movies") return null
return (props.item.data as Movie).info?.director; return (props.item.data as Movie).info?.director
}); })
const directorAndCast = computed(() => { const directorAndCast = computed(() => {
if (props.item.type !== 'movies') return false; if (props.item.type !== "movies") return false
return director.value || filteredCastNames.value; return director.value || filteredCastNames.value
}); })
const filteredCastNames = computed(() => { const filteredCastNames = computed(() => {
if (props.item.type !== 'movies') return null; if (props.item.type !== "movies") return null
const cast = (props.item.data as Movie).info?.cast; const cast = (props.item.data as Movie).info?.cast
if (!cast || cast.length === 0) return null; if (!cast || cast.length === 0) return null
const directorName = director.value?.toLowerCase(); const directorName = director.value?.toLowerCase()
const filteredCast = directorName const filteredCast = directorName
? cast.filter(c => c.name.toLowerCase() !== directorName) ? cast.filter((c) => c.name.toLowerCase() !== directorName)
: cast; : cast
if (filteredCast.length === 0) return null; if (filteredCast.length === 0) return null
const names = filteredCast.slice(0, 3).map(c => c.name); const names = filteredCast.slice(0, 3).map((c) => c.name)
return names.join(', '); return names.join(", ")
}); })
const matchedPeople = computed(() => { const matchedPeople = computed(() => {
const info = props.item.searchMatchInfo; const info = props.item.searchMatchInfo
if (!info || !info.matchedPeople) return null; if (!info || !info.matchedPeople) return null
return info.matchedPeople; return info.matchedPeople
}); })
</script> </script>
<style scoped> <style scoped>
@keyframes card-outline-blink { @keyframes card-outline-blink {
0%, 100% { 0%,
100% {
opacity: 1; opacity: 1;
} }
50% { 50% {
+296 -263
View File
@@ -14,10 +14,8 @@
<!-- Full page view for movies --> <!-- Full page view for movies -->
<div v-else class="movie-page"> <div v-else class="movie-page">
<div class="movie-page-content"> <div class="movie-page-content">
<!-- Diagonal collage header --> <!-- Diagonal collage header -->
<div class="collage-header"> <div class="collage-header">
<!-- Background collage of showreel videos --> <!-- Background collage of showreel videos -->
<div class="collage-grid"> <div class="collage-grid">
<div <div
@@ -27,13 +25,10 @@
@mouseenter="handleVideoHover(slot.index, true)" @mouseenter="handleVideoHover(slot.index, true)"
@mouseleave="handleVideoHover(slot.index, false)" @mouseleave="handleVideoHover(slot.index, false)"
> >
<div <div class="collage-fallback-tile" :class="`collage-fallback-${slot.index + 1}`"></div>
class="collage-fallback-tile"
:class="`collage-fallback-${slot.index + 1}`"
></div>
<video <video
v-if="slot.sourcePaths.length > 0" v-if="slot.sourcePaths.length > 0"
:ref="el => setVideoRef(el as HTMLVideoElement, slot.index)" :ref="(el) => setVideoRef(el as HTMLVideoElement, slot.index)"
:class="{ 'is-ready': isVideoReady(slot.index) }" :class="{ 'is-ready': isVideoReady(slot.index) }"
:autoplay="safariAutoplay" :autoplay="safariAutoplay"
loop loop
@@ -48,7 +43,7 @@
:src="getShowreelUrl(sourcePath)" :src="getShowreelUrl(sourcePath)"
:type="getShowreelSourceAttributes(sourcePath).type" :type="getShowreelSourceAttributes(sourcePath).type"
:codecs="getShowreelSourceAttributes(sourcePath).codecs" :codecs="getShowreelSourceAttributes(sourcePath).codecs"
> />
</video> </video>
</div> </div>
</div> </div>
@@ -61,7 +56,9 @@
<h1 class="modal-title">{{ item.title }}</h1> <h1 class="modal-title">{{ item.title }}</h1>
<p v-if="movieTagline" class="header-tagline">{{ movieTagline }}</p> <p v-if="movieTagline" class="header-tagline">{{ movieTagline }}</p>
<div class="modal-meta"> <div class="modal-meta">
<span v-if="rating" class="meta-rating" :class="ratingClass"> {{ rating.toFixed(1) }}</span> <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="item.year" class="meta-year">{{ item.year }}</span>
<span v-if="movieRuntime" class="meta-runtime">{{ formatRuntime(movieRuntime) }}</span> <span v-if="movieRuntime" class="meta-runtime">{{ formatRuntime(movieRuntime) }}</span>
</div> </div>
@@ -85,7 +82,7 @@
:src="synopsisPosterUrl" :src="synopsisPosterUrl"
:alt="`${item.title} poster`" :alt="`${item.title} poster`"
class="synopsis-poster" class="synopsis-poster"
> />
</div> </div>
<div v-if="movieVersions.length > 0" class="versions-list versions-list-sidebar"> <div v-if="movieVersions.length > 0" class="versions-list versions-list-sidebar">
<ReleaseVersionCard <ReleaseVersionCard
@@ -99,7 +96,11 @@
@activate="handleVersionActivate(version, $event)" @activate="handleVersionActivate(version, $event)"
@keydown="handleVersionShortcutKeydown($event, version)" @keydown="handleVersionShortcutKeydown($event, version)"
@contextmenu="handleVersionContextMenu($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'" :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> </div>
@@ -119,18 +120,24 @@
:src="getCoverUrl(castMember.profile_path, item.root_id)" :src="getCoverUrl(castMember.profile_path, item.root_id)"
:alt="castMember.name" :alt="castMember.name"
class="cast-photo" class="cast-photo"
> />
<img v-else :src="getCastPlaceholderUrl(castMember.gender)" :alt="`${castMember.name} placeholder portrait`" class="cast-photo cast-photo-fallback"> <img
v-else
:src="getCastPlaceholderUrl(castMember.gender)"
:alt="`${castMember.name} placeholder portrait`"
class="cast-photo cast-photo-fallback"
/>
<div class="cast-copy"> <div class="cast-copy">
<span class="cast-name">{{ castMember.name }}</span> <span class="cast-name">{{ castMember.name }}</span>
<span v-if="castMember.character" class="cast-character">{{ castMember.character }}</span> <span v-if="castMember.character" class="cast-character">{{
castMember.character
}}</span>
</div> </div>
</div> </div>
</div> </div>
<!-- Main content --> <!-- Main content -->
<div class="content-main"> <div class="content-main"></div>
</div>
<!-- Right sidebar - Metadata --> <!-- Right sidebar - Metadata -->
<div v-if="item.type === 'movies'" class="content-sidebar sidebar-right"> <div v-if="item.type === 'movies'" class="content-sidebar sidebar-right">
@@ -144,10 +151,12 @@
</div> </div>
<div v-if="movieStatus || movieReleaseDate" class="meta-summary"> <div v-if="movieStatus || movieReleaseDate" class="meta-summary">
<span v-if="movieStatus" class="meta-summary-item">{{ movieStatus }}</span> <span v-if="movieStatus" class="meta-summary-item">{{ movieStatus }}</span>
<span v-if="movieReleaseDate" class="meta-summary-item">{{ movieReleaseDate }}</span> <span v-if="movieReleaseDate" class="meta-summary-item">{{
movieReleaseDate
}}</span>
</div> </div>
<div v-if="movieKeywords && movieKeywords.length > 0" class="meta-keywords-section"> <div v-if="movieKeywords && movieKeywords.length > 0" class="meta-keywords-section">
<span class="meta-value keywords">{{ movieKeywords.join(', ') }}</span> <span class="meta-value keywords">{{ movieKeywords.join(", ") }}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -178,315 +187,328 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch, onMounted, onUnmounted, nextTick } from 'vue'; import { computed, ref, watch, onMounted, onUnmounted, nextTick } from "vue"
import type { CastMember, MediaItem, Movie, Series, Torrent } from '../types'; import type { CastMember, MediaItem, Movie, Series, Torrent } from "../types"
import { getCoverUrl, getVideoPreviewUrl, getVideoSourceAttributes, isSafariBrowser, type VideoSourceAttributes } from '../api'; import {
import castPlaceholderFemaleUrl from '../assets/cast-placeholder-female.svg'; getCoverUrl,
import castPlaceholderMaleUrl from '../assets/cast-placeholder-male.svg'; getVideoPreviewUrl,
import SeriesFullView from './SeriesFullView.vue'; getVideoSourceAttributes,
import ReleaseVersionCard from './ReleaseVersionCard.vue'; isSafariBrowser,
import ReleaseActionMenu from './ReleaseActionMenu.vue'; type VideoSourceAttributes,
import { navAttrs } from '../composables/useKeyboardNavigation'; } 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<{ const props = defineProps<{
item: MediaItem; item: MediaItem
focusEpisode?: { seasonNumber: number; episodeNumber: number } | null; focusEpisode?: { seasonNumber: number; episodeNumber: number } | null
hasResumePosition: (filePath: string | null) => boolean; hasResumePosition: (filePath: string | null) => boolean
getRootName: (rootId: string | null | undefined) => string | null; getRootName: (rootId: string | null | undefined) => string | null
}>(); }>()
const emit = defineEmits<{ const emit = defineEmits<{
close: []; close: []
play: [string]; play: [string]
openFolder: [string, string | null | undefined]; openFolder: [string, string | null | undefined]
searchActor: [string]; searchActor: [string]
}>(); }>()
// Track expanded episode for showing multiple releases // Track expanded episode for showing multiple releases
const videoRefs = ref<(HTMLVideoElement | null)[]>([]); const videoRefs = ref<(HTMLVideoElement | null)[]>([])
const videoStates = ref<string[]>([]); const videoStates = ref<string[]>([])
const COLLAGE_SLOT_COUNT = 5; const COLLAGE_SLOT_COUNT = 5
const safariAutoplay = isSafariBrowser(); const safariAutoplay = isSafariBrowser()
const COLLAGE_START_OFFSETS_SECONDS = [0, 8, 6, 4, 2]; const COLLAGE_START_OFFSETS_SECONDS = [0, 8, 6, 4, 2]
function setVideoRef(el: HTMLVideoElement | null, index: number) { function setVideoRef(el: HTMLVideoElement | null, index: number) {
videoRefs.value[index] = el; videoRefs.value[index] = el
} }
function handleVideoLoaded(index: number) { function handleVideoLoaded(index: number) {
videoStates.value[index] = 'ready'; videoStates.value[index] = "ready"
} }
function handleVideoError(index: number) { function handleVideoError(index: number) {
videoStates.value[index] = 'error'; videoStates.value[index] = "error"
} }
function isVideoReady(index: number): boolean { function isVideoReady(index: number): boolean {
return videoStates.value[index] === 'ready'; return videoStates.value[index] === "ready"
} }
// Start staggered video playback // Start staggered video playback
function startStaggeredPlayback() { function startStaggeredPlayback() {
const videos = videoRefs.value.filter(v => v !== null) as HTMLVideoElement[]; const videos = videoRefs.value.filter((v) => v !== null) as HTMLVideoElement[]
if (videos.length === 0) return; if (videos.length === 0) return
if (safariAutoplay) { if (safariAutoplay) {
videos.forEach((video, index) => { videos.forEach((video, index) => {
const offset = COLLAGE_START_OFFSETS_SECONDS[index] ?? 0; const offset = COLLAGE_START_OFFSETS_SECONDS[index] ?? 0
const startVideo = () => { const startVideo = () => {
video.currentTime = offset; video.currentTime = offset
video.play().catch(() => {}); video.play().catch(() => {})
}; }
if (video.readyState >= 1) { if (video.readyState >= 1) {
startVideo(); startVideo()
} else { } else {
video.addEventListener('loadedmetadata', startVideo, { once: true }); video.addEventListener("loadedmetadata", startVideo, { once: true })
} }
}); })
return; return
} }
// Start first video immediately // Start first video immediately
// Non-Safari keeps legacy behavior: start without explicit seek offset. // Non-Safari keeps legacy behavior: start without explicit seek offset.
videos[0].play().catch(() => {}); videos[0].play().catch(() => {})
// Set up staggered start for remaining videos // Set up staggered start for remaining videos
for (let i = 1; i < videos.length; i++) { for (let i = 1; i < videos.length; i++) {
setTimeout(() => { setTimeout(() => {
const video = videos[i]; const video = videos[i]
if (!video) return; if (!video) return
video.play().catch(() => {}); video.play().catch(() => {})
}, i * 2000); }, i * 2000)
} }
} }
// Volume fade animation tracking // Volume fade animation tracking
const volumeFadeIntervals = new Map<number, ReturnType<typeof setInterval>>(); const volumeFadeIntervals = new Map<number, ReturnType<typeof setInterval>>()
// Handle hover-based audio fade in/out // Handle hover-based audio fade in/out
function handleVideoHover(index: number, isEntering: boolean) { function handleVideoHover(index: number, isEntering: boolean) {
const video = videoRefs.value[index]; const video = videoRefs.value[index]
if (!video) return; if (!video) return
// Clear any existing fade for this video // Clear any existing fade for this video
const existingInterval = volumeFadeIntervals.get(index); const existingInterval = volumeFadeIntervals.get(index)
if (existingInterval) { if (existingInterval) {
clearInterval(existingInterval); clearInterval(existingInterval)
volumeFadeIntervals.delete(index); volumeFadeIntervals.delete(index)
} }
if (isEntering) { if (isEntering) {
// Mute all other videos immediately // Mute all other videos immediately
document.querySelectorAll('video').forEach(v => { document.querySelectorAll("video").forEach((v) => {
if (v !== video) { if (v !== video) {
v.volume = 0; v.volume = 0
v.muted = true; v.muted = true
} }
}); })
// Fade in this video's audio // Fade in this video's audio
video.muted = false; video.muted = false
const fadeIn = setInterval(() => { const fadeIn = setInterval(() => {
if (video.volume < 0.95) { if (video.volume < 0.95) {
video.volume = Math.min(1, video.volume + 0.1); video.volume = Math.min(1, video.volume + 0.1)
} else { } else {
video.volume = 1; video.volume = 1
clearInterval(fadeIn); clearInterval(fadeIn)
volumeFadeIntervals.delete(index); volumeFadeIntervals.delete(index)
} }
}, 30); }, 30)
volumeFadeIntervals.set(index, fadeIn); volumeFadeIntervals.set(index, fadeIn)
} else { } else {
// Fade out this video's audio // Fade out this video's audio
const fadeOut = setInterval(() => { const fadeOut = setInterval(() => {
if (video.volume > 0.05) { if (video.volume > 0.05) {
video.volume = Math.max(0, video.volume - 0.1); video.volume = Math.max(0, video.volume - 0.1)
} else { } else {
video.volume = 0; video.volume = 0
video.muted = true; video.muted = true
clearInterval(fadeOut); clearInterval(fadeOut)
volumeFadeIntervals.delete(index); volumeFadeIntervals.delete(index)
} }
}, 30); }, 30)
volumeFadeIntervals.set(index, fadeOut); volumeFadeIntervals.set(index, fadeOut)
} }
} }
onMounted(() => { onMounted(() => {
// Wait for videos to be ready, then start staggered playback // Wait for videos to be ready, then start staggered playback
setTimeout(() => { setTimeout(() => {
startStaggeredPlayback(); startStaggeredPlayback()
}, 100); }, 100)
}); })
const showreelSourceSets = computed((): string[][] | null => { const showreelSourceSets = computed((): string[][] | null => {
if (props.item.type === 'movies') { if (props.item.type === "movies") {
const movie = props.item.data as Movie; const movie = props.item.data as Movie
if (movie.showreel_source_sets && movie.showreel_source_sets.length > 0) { if (movie.showreel_source_sets && movie.showreel_source_sets.length > 0) {
return movie.showreel_source_sets; return movie.showreel_source_sets
} }
return movie.showreel_images?.map((path) => [path]) ?? null; return movie.showreel_images?.map((path) => [path]) ?? null
} else { } else {
const series = props.item.data as Series; const series = props.item.data as Series
const sourceSets: string[][] = []; const sourceSets: string[][] = []
for (const season of series.seasons || []) { for (const season of series.seasons || []) {
for (const episode of season.episodes || []) { for (const episode of season.episodes || []) {
if (episode.reel_sources && episode.reel_sources.length > 0) { if (episode.reel_sources && episode.reel_sources.length > 0) {
sourceSets.push(episode.reel_sources); sourceSets.push(episode.reel_sources)
} else if (episode.reel_image) { } else if (episode.reel_image) {
sourceSets.push([episode.reel_image]); sourceSets.push([episode.reel_image])
} }
} }
} }
return sourceSets.length > 0 ? sourceSets : null; return sourceSets.length > 0 ? sourceSets : null
} }
}); })
const collageSourceSets = computed((): string[][] => { const collageSourceSets = computed((): string[][] => {
if (!showreelSourceSets.value || showreelSourceSets.value.length === 0) return []; if (!showreelSourceSets.value || showreelSourceSets.value.length === 0) return []
return showreelSourceSets.value.slice(0, 5); return showreelSourceSets.value.slice(0, 5)
}); })
const collageSlots = computed(() => { const collageSlots = computed(() => {
return Array.from({ length: COLLAGE_SLOT_COUNT }, (_, index) => ({ return Array.from({ length: COLLAGE_SLOT_COUNT }, (_, index) => ({
index, index,
sourcePaths: collageSourceSets.value[index] ?? [], sourcePaths: collageSourceSets.value[index] ?? [],
})); }))
}); })
watch(collageSlots, async (slots) => { watch(
videoRefs.value = Array.from({ length: COLLAGE_SLOT_COUNT }, (_, index) => videoRefs.value[index] ?? null); collageSlots,
videoStates.value = slots.map((slot) => slot.sourcePaths.length > 0 ? 'loading' : 'missing'); async (slots) => {
await nextTick(); videoRefs.value = Array.from(
setTimeout(() => { { length: COLLAGE_SLOT_COUNT },
startStaggeredPlayback(); (_, index) => videoRefs.value[index] ?? null,
}, 100); )
}, { immediate: true }); videoStates.value = slots.map((slot) => (slot.sourcePaths.length > 0 ? "loading" : "missing"))
await nextTick()
setTimeout(() => {
startStaggeredPlayback()
}, 100)
},
{ immediate: true },
)
function getShowreelUrl(path: string): string { function getShowreelUrl(path: string): string {
return getVideoPreviewUrl(getCoverUrl(path, props.item.root_id)); return getVideoPreviewUrl(getCoverUrl(path, props.item.root_id))
} }
function getShowreelSourceAttributes(path: string): VideoSourceAttributes { function getShowreelSourceAttributes(path: string): VideoSourceAttributes {
return getVideoSourceAttributes(path); return getVideoSourceAttributes(path)
} }
// Movie versions // Movie versions
const movieVersions = computed((): Torrent[] => { const movieVersions = computed((): Torrent[] => {
if (props.item.type !== 'movies') return []; if (props.item.type !== "movies") return []
const movie = props.item.data as Movie; const movie = props.item.data as Movie
return Object.values(movie.torrents || {}); return Object.values(movie.torrents || {})
}); })
// Page backdrop background // Page backdrop background
const backdropStyle = computed(() => { const backdropStyle = computed(() => {
if (props.item.type !== 'movies') return {}; if (props.item.type !== "movies") return {}
const movie = props.item.data as Movie; const movie = props.item.data as Movie
const imagePath = movie.backdrop_path; const imagePath = movie.backdrop_path
const imageUrl = getCoverUrl(imagePath, props.item.root_id); const imageUrl = getCoverUrl(imagePath, props.item.root_id)
if (imageUrl) { if (imageUrl) {
return { backgroundImage: `url("${imageUrl}")` }; return { backgroundImage: `url("${imageUrl}")` }
} }
return {}; return {}
}); })
const synopsisPosterUrl = computed(() => { const synopsisPosterUrl = computed(() => {
if (props.item.type !== 'movies') return null; if (props.item.type !== "movies") return null
return getCoverUrl(props.item.cover_path, props.item.root_id); return getCoverUrl(props.item.cover_path, props.item.root_id)
}); })
const movieGenres = computed(() => { const movieGenres = computed(() => {
if (props.item.type !== 'movies') return null; if (props.item.type !== "movies") return null
return (props.item.data as Movie).info?.genres; return (props.item.data as Movie).info?.genres
}); })
const movieTagline = computed(() => { const movieTagline = computed(() => {
if (props.item.type !== 'movies') return null; if (props.item.type !== "movies") return null
return (props.item.data as Movie).info?.tagline; return (props.item.data as Movie).info?.tagline
}); })
const movieDirector = computed(() => { const movieDirector = computed(() => {
if (props.item.type !== 'movies') return null; if (props.item.type !== "movies") return null
return (props.item.data as Movie).info?.director; return (props.item.data as Movie).info?.director
}); })
const movieCast = computed(() => { const movieCast = computed(() => {
if (props.item.type !== 'movies') return null; if (props.item.type !== "movies") return null
return (props.item.data as Movie).info?.cast as CastMember[] | null; return (props.item.data as Movie).info?.cast as CastMember[] | null
}); })
const limitedMovieCast = computed(() => { const limitedMovieCast = computed(() => {
if (!movieCast.value) return []; if (!movieCast.value) return []
return movieCast.value; return movieCast.value
}); })
const movieRuntime = computed(() => { const movieRuntime = computed(() => {
if (props.item.type !== 'movies') return null; if (props.item.type !== "movies") return null
return (props.item.data as Movie).info?.runtime; return (props.item.data as Movie).info?.runtime
}); })
const movieReleaseDate = computed(() => { const movieReleaseDate = computed(() => {
if (props.item.type !== 'movies') return null; if (props.item.type !== "movies") return null
return (props.item.data as Movie).info?.release_date; return (props.item.data as Movie).info?.release_date
}); })
const movieStatus = computed(() => { const movieStatus = computed(() => {
if (props.item.type !== 'movies') return null; if (props.item.type !== "movies") return null
return (props.item.data as Movie).info?.status; return (props.item.data as Movie).info?.status
}); })
const movieKeywords = computed(() => { const movieKeywords = computed(() => {
if (props.item.type !== 'movies') return null; if (props.item.type !== "movies") return null
return (props.item.data as Movie).info?.keywords; return (props.item.data as Movie).info?.keywords
}); })
function getCastPlaceholderUrl(gender?: CastMember['gender']): string { function getCastPlaceholderUrl(gender?: CastMember["gender"]): string {
return gender === 'female' ? castPlaceholderFemaleUrl : castPlaceholderMaleUrl; return gender === "female" ? castPlaceholderFemaleUrl : castPlaceholderMaleUrl
} }
function formatRuntime(minutes: number): string { function formatRuntime(minutes: number): string {
const hours = Math.floor(minutes / 60); const hours = Math.floor(minutes / 60)
const mins = minutes % 60; const mins = minutes % 60
if (hours === 0) return `${mins}m`; if (hours === 0) return `${mins}m`
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`; return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`
} }
const rating = computed(() => { const rating = computed(() => {
if (props.item.type === 'movies') { if (props.item.type === "movies") {
return (props.item.data as Movie).info?.rating; return (props.item.data as Movie).info?.rating
} }
return (props.item.data as Series).info?.rating; return (props.item.data as Series).info?.rating
}); })
const overview = computed(() => { const overview = computed(() => {
if (props.item.type === 'movies') { if (props.item.type === "movies") {
return (props.item.data as Movie).info?.overview; return (props.item.data as Movie).info?.overview
} }
return (props.item.data as Series).info?.overview; return (props.item.data as Series).info?.overview
}); })
const ratingClass = computed(() => { const ratingClass = computed(() => {
if (!rating.value) return ''; if (!rating.value) return ""
if (rating.value >= 7.5) return 'rating-high'; if (rating.value >= 7.5) return "rating-high"
if (rating.value >= 6) return 'rating-medium'; if (rating.value >= 6) return "rating-medium"
return 'rating-low'; return "rating-low"
}); })
const seasons = computed(() => { const seasons = computed(() => {
if (props.item.type !== 'series') return []; if (props.item.type !== "series") return []
const series = props.item.data as Series; const series = props.item.data as Series
return series.seasons || []; return series.seasons || []
}); })
const selectedSeasonIndex = ref<number>(0); const selectedSeasonIndex = ref<number>(0)
const versionActionMenu = ref<{ const versionActionMenu = ref<{
visible: boolean; visible: boolean
x: number; x: number
y: number; y: number
filePath: string | null; filePath: string | null
rootName: string | null; rootName: string | null
rootId: string | null; rootId: string | null
}>({ }>({
visible: false, visible: false,
x: 0, x: 0,
@@ -494,30 +516,30 @@ const versionActionMenu = ref<{
filePath: null, filePath: null,
rootName: null, rootName: null,
rootId: null, rootId: null,
}); })
function closeVersionActionMenu() { function closeVersionActionMenu() {
versionActionMenu.value.visible = false; versionActionMenu.value.visible = false
versionActionMenu.value.filePath = null; versionActionMenu.value.filePath = null
versionActionMenu.value.rootName = null; versionActionMenu.value.rootName = null
versionActionMenu.value.rootId = null; versionActionMenu.value.rootId = null
} }
function getPlayLabel(filePath: string | null): string { function getPlayLabel(filePath: string | null): string {
return props.hasResumePosition(filePath) ? 'Continue' : 'Play'; return props.hasResumePosition(filePath) ? "Continue" : "Play"
} }
function handlePlayVersion(filePath: string | null) { function handlePlayVersion(filePath: string | null) {
if (filePath) { if (filePath) {
emit('play', filePath); emit("play", filePath)
} }
closeVersionActionMenu(); closeVersionActionMenu()
} }
function handleVersionContextMenu(event: MouseEvent, version: Torrent) { function handleVersionContextMenu(event: MouseEvent, version: Torrent) {
event.preventDefault(); event.preventDefault()
event.stopPropagation(); event.stopPropagation()
const rootId = version.root_id || ((props.item.data as Movie).root_id ?? props.item.root_id); const rootId = version.root_id || ((props.item.data as Movie).root_id ?? props.item.root_id)
versionActionMenu.value = { versionActionMenu.value = {
visible: true, visible: true,
x: event.clientX, x: event.clientX,
@@ -525,80 +547,86 @@ function handleVersionContextMenu(event: MouseEvent, version: Torrent) {
filePath: version.playable_file || null, filePath: version.playable_file || null,
rootName: props.getRootName(rootId) || null, rootName: props.getRootName(rootId) || null,
rootId, rootId,
}; }
nextTick(() => { nextTick(() => {
const firstAction = document.querySelector('.version-action-menu .version-action-item:not(:disabled)') as HTMLElement | null; const firstAction = document.querySelector(
firstAction?.focus(); ".version-action-menu .version-action-item:not(:disabled)",
}); ) as HTMLElement | null
firstAction?.focus()
})
} }
function handleVersionShortcutKeydown(event: KeyboardEvent, version: Torrent) { function handleVersionShortcutKeydown(event: KeyboardEvent, version: Torrent) {
if (!version.playable_file) return; if (!version.playable_file) return
const rootId = version.root_id || ((props.item.data as Movie).root_id ?? props.item.root_id); const rootId = version.root_id || ((props.item.data as Movie).root_id ?? props.item.root_id)
const key = event.key.toLowerCase(); const key = event.key.toLowerCase()
if (key === 'e' && (event.metaKey || event.ctrlKey)) { if (key === "e" && (event.metaKey || event.ctrlKey)) {
event.preventDefault(); event.preventDefault()
event.stopPropagation(); event.stopPropagation()
handleOpenFolder(version.playable_file, rootId); handleOpenFolder(version.playable_file, rootId)
return; return
} }
if (key === 'enter' && event.altKey) { if (key === "enter" && event.altKey) {
event.preventDefault(); event.preventDefault()
event.stopPropagation(); event.stopPropagation()
handleOpenFolder(version.playable_file, rootId); handleOpenFolder(version.playable_file, rootId)
} }
} }
function handleMovieMenuKeydown(event: KeyboardEvent) { function handleMovieMenuKeydown(event: KeyboardEvent) {
if (!versionActionMenu.value.visible) return; if (!versionActionMenu.value.visible) return
if (event.key === 'Escape') { if (event.key === "Escape") {
event.preventDefault(); event.preventDefault()
event.stopPropagation(); event.stopPropagation()
closeVersionActionMenu(); closeVersionActionMenu()
} }
} }
// Select first season by default // Select first season by default
watch(seasons, (s) => { watch(
if (s.length > 0 && selectedSeasonIndex.value >= s.length) { seasons,
selectedSeasonIndex.value = 0; (s) => {
} if (s.length > 0 && selectedSeasonIndex.value >= s.length) {
}, { immediate: true }); selectedSeasonIndex.value = 0
}
},
{ immediate: true },
)
function handlePlay(filePath: string | null) { function handlePlay(filePath: string | null) {
if (filePath) { if (filePath) {
emit('play', filePath); emit("play", filePath)
} }
} }
function handleVersionActivate(version: Torrent, event: MouseEvent | KeyboardEvent) { function handleVersionActivate(version: Torrent, event: MouseEvent | KeyboardEvent) {
if (!version.playable_file) return; if (!version.playable_file) return
const rootId = version.root_id || ((props.item.data as Movie).root_id ?? props.item.root_id); const rootId = version.root_id || ((props.item.data as Movie).root_id ?? props.item.root_id)
if (event.altKey) { if (event.altKey) {
handleOpenFolder(version.playable_file, rootId); handleOpenFolder(version.playable_file, rootId)
return; return
} }
handlePlay(version.playable_file); handlePlay(version.playable_file)
} }
function handleOpenFolder(folderPath: string, rootId?: string | null) { function handleOpenFolder(folderPath: string, rootId?: string | null) {
closeVersionActionMenu(); closeVersionActionMenu()
emit('openFolder', folderPath, rootId); emit("openFolder", folderPath, rootId)
} }
function handleCastSelect(castName: string) { function handleCastSelect(castName: string) {
const name = castName.trim(); const name = castName.trim()
if (!name) return; if (!name) return
emit('searchActor', name); emit("searchActor", name)
} }
onMounted(() => { onMounted(() => {
document.addEventListener('keydown', handleMovieMenuKeydown, true); document.addEventListener("keydown", handleMovieMenuKeydown, true)
}); })
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener('keydown', handleMovieMenuKeydown, true); document.removeEventListener("keydown", handleMovieMenuKeydown, true)
}); })
</script> </script>
<style scoped> <style scoped>
@@ -618,7 +646,7 @@ onUnmounted(() => {
} }
.movie-page-content::before { .movie-page-content::before {
content: ''; content: "";
position: absolute; position: absolute;
top: 300px; top: 300px;
/* Start clipped edge at reel 2/3 split bottom (y=300): 40vw - 0.8rem. */ /* Start clipped edge at reel 2/3 split bottom (y=300): 40vw - 0.8rem. */
@@ -626,15 +654,16 @@ onUnmounted(() => {
width: calc(40vw + 0.8rem); width: calc(40vw + 0.8rem);
max-width: calc(100vw - 24px); max-width: calc(100vw - 24px);
height: var(--header-height); height: var(--header-height);
background: linear-gradient( background: linear-gradient(to bottom, rgba(5, 7, 10, 0.72) 0%, rgba(5, 7, 10, 0.5) 100%);
to bottom,
rgba(5, 7, 10, 0.72) 0%,
rgba(5, 7, 10, 0.5) 100%
);
-webkit-backdrop-filter: blur(10px) saturate(115%); -webkit-backdrop-filter: blur(10px) saturate(115%);
backdrop-filter: blur(10px) saturate(115%); backdrop-filter: blur(10px) saturate(115%);
/* Match reel slant angle: 2rem horizontal shift over 300px reel height. */ /* 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%); -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%); clip-path: polygon(0 0, 100% 0, calc(100% - (var(--header-height) * 0.1067)) 100%, 0 100%);
pointer-events: none; pointer-events: none;
z-index: 30; z-index: 30;
@@ -675,8 +704,8 @@ onUnmounted(() => {
display: grid; display: grid;
grid-template-columns: minmax(260px, 360px) minmax(0, 1fr) minmax(240px, 320px); grid-template-columns: minmax(260px, 360px) minmax(0, 1fr) minmax(240px, 320px);
grid-template-areas: grid-template-areas:
'left cast cast' "left cast cast"
'left main right'; "left main right";
gap: 32px; gap: 32px;
align-items: start; align-items: start;
position: relative; position: relative;
@@ -921,7 +950,7 @@ onUnmounted(() => {
} }
.cast-card::after { .cast-card::after {
content: ''; content: "";
position: absolute; position: absolute;
inset: 0; inset: 0;
border-radius: inherit; border-radius: inherit;
@@ -961,7 +990,12 @@ html:not(.mouse-active) .cast-card.nav-focused::after {
position: absolute; position: absolute;
inset: auto 0 0 0; inset: auto 0 0 0;
padding: 28px 8px 8px; 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%); 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 { .cast-name {
@@ -983,9 +1017,9 @@ html:not(.mouse-active) .cast-card.nav-focused::after {
.content-layout { .content-layout {
grid-template-columns: minmax(240px, 320px) minmax(0, 1fr); grid-template-columns: minmax(240px, 320px) minmax(0, 1fr);
grid-template-areas: grid-template-areas:
'left cast' "left cast"
'left main' "left main"
'left right'; "left right";
gap: 24px; gap: 24px;
} }
} }
@@ -994,10 +1028,10 @@ html:not(.mouse-active) .cast-card.nav-focused::after {
.content-layout { .content-layout {
grid-template-columns: 1fr; grid-template-columns: 1fr;
grid-template-areas: grid-template-areas:
'left' "left"
'cast' "cast"
'main' "main"
'right'; "right";
gap: 20px; gap: 20px;
} }
@@ -1093,7 +1127,9 @@ html.mouse-active .showreel-image:hover {
background: rgba(255, 255, 255, 0.05); background: rgba(255, 255, 255, 0.05);
border-radius: 8px; border-radius: 8px;
border: 1px solid transparent; border: 1px solid transparent;
transition: background 0.2s, border-color 0.2s; transition:
background 0.2s,
border-color 0.2s;
} }
html.mouse-active .version-item:hover { html.mouse-active .version-item:hover {
@@ -1290,7 +1326,9 @@ html.mouse-active .version-item:hover {
background: rgba(255, 255, 255, 0.05); background: rgba(255, 255, 255, 0.05);
border-radius: 8px; border-radius: 8px;
border: 1px solid transparent; border: 1px solid transparent;
transition: background 0.2s, border-color 0.2s; transition:
background 0.2s,
border-color 0.2s;
overflow: hidden; overflow: hidden;
} }
@@ -1541,14 +1579,10 @@ html.mouse-active .release-item:hover {
/* Subtle vignette on each collage image */ /* Subtle vignette on each collage image */
.collage-header .collage-item::before { .collage-header .collage-item::before {
content: ''; content: "";
position: absolute; position: absolute;
inset: 0; inset: 0;
background: linear-gradient( background: linear-gradient(to bottom, transparent 0%, rgba(0, 0, 0, 0.3) 100%);
to bottom,
transparent 0%,
rgba(0, 0, 0, 0.3) 100%
);
pointer-events: none; pointer-events: none;
z-index: 2; z-index: 2;
} }
@@ -1646,5 +1680,4 @@ html.mouse-active .release-item:hover {
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
} }
</style> </style>
+8 -8
View File
@@ -16,16 +16,16 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { MediaItem } from '../types'; import type { MediaItem } from "../types"
import MediaCard from './MediaCard.vue'; import MediaCard from "./MediaCard.vue"
defineProps<{ defineProps<{
items: MediaItem[]; items: MediaItem[]
wrap?: boolean; wrap?: boolean
rowIndex?: number; rowIndex?: number
}>(); }>()
defineEmits<{ defineEmits<{
select: [MediaItem]; select: [MediaItem]
}>(); }>()
</script> </script>
+62 -68
View File
@@ -1,33 +1,24 @@
<template> <template>
<div <div v-if="visible" ref="menuRef" class="version-action-menu" :style="menuStyle">
v-if="visible"
ref="menuRef"
class="version-action-menu"
:style="menuStyle"
>
<div class="version-action-path" :title="resolvedPath"> <div class="version-action-path" :title="resolvedPath">
{{ resolvedPath }} {{ resolvedPath }}
</div> </div>
<button <button class="version-action-item" :disabled="disabled" @click="emit('play')">
class="version-action-item"
:disabled="disabled"
@click="emit('play')"
>
<span class="version-action-icon" aria-hidden="true"> <span class="version-action-icon" aria-hidden="true">
<svg viewBox="0 0 16 16" focusable="false"> <svg viewBox="0 0 16 16" focusable="false">
<path d="M4 3.2c0-.54.6-.86 1.05-.56l6.2 4.14a.67.67 0 0 1 0 1.12l-6.2 4.14A.67.67 0 0 1 4 11.44V3.2Z" /> <path
d="M4 3.2c0-.54.6-.86 1.05-.56l6.2 4.14a.67.67 0 0 1 0 1.12l-6.2 4.14A.67.67 0 0 1 4 11.44V3.2Z"
/>
</svg> </svg>
</span> </span>
{{ playLabel }} {{ playLabel }}
</button> </button>
<button <button class="version-action-item" :disabled="disabled" @click="emit('openFolder')">
class="version-action-item"
:disabled="disabled"
@click="emit('openFolder')"
>
<span class="version-action-icon" aria-hidden="true"> <span class="version-action-icon" aria-hidden="true">
<svg viewBox="0 0 16 16" focusable="false"> <svg viewBox="0 0 16 16" focusable="false">
<path d="M1.4 4.3c0-.72.58-1.3 1.3-1.3h3.55c.3 0 .58.13.77.35l.72.85h5.56c.72 0 1.3.58 1.3 1.3v.92H1.4V4.3Zm0 3.22h13.2v4.2c0 .72-.58 1.3-1.3 1.3H2.7c-.72 0-1.3-.58-1.3-1.3v-4.2Z" /> <path
d="M1.4 4.3c0-.72.58-1.3 1.3-1.3h3.55c.3 0 .58.13.77.35l.72.85h5.56c.72 0 1.3.58 1.3 1.3v.92H1.4V4.3Zm0 3.22h13.2v4.2c0 .72-.58 1.3-1.3 1.3H2.7c-.72 0-1.3-.58-1.3-1.3v-4.2Z"
/>
</svg> </svg>
</span> </span>
Open Folder Open Folder
@@ -36,93 +27,96 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'; import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue"
const props = withDefaults(defineProps<{ const props = withDefaults(
visible: boolean; defineProps<{
x: number; visible: boolean
y: number; x: number
filePath: string | null; y: number
rootName?: string | null; filePath: string | null
playLabel?: string; rootName?: string | null
}>(), { playLabel?: string
rootName: null, }>(),
playLabel: 'Play', {
}); rootName: null,
playLabel: "Play",
},
)
const emit = defineEmits<{ const emit = defineEmits<{
play: []; play: []
openFolder: []; openFolder: []
}>(); }>()
const menuRef = ref<HTMLElement | null>(null); const menuRef = ref<HTMLElement | null>(null)
const menuLeft = ref(0); const menuLeft = ref(0)
const menuTop = ref(0); const menuTop = ref(0)
const VIEWPORT_MARGIN = 12; const VIEWPORT_MARGIN = 12
function toPosixPath(value: string | null | undefined): string { function toPosixPath(value: string | null | undefined): string {
return (value || '').replace(/\\/g, '/'); return (value || "").replace(/\\/g, "/")
} }
const resolvedPath = computed(() => { const resolvedPath = computed(() => {
if (!props.filePath) return 'No playable file'; if (!props.filePath) return "No playable file"
const normalizedFilePath = toPosixPath(props.filePath); const normalizedFilePath = toPosixPath(props.filePath)
const rootName = toPosixPath((props.rootName || '').trim()); const rootName = toPosixPath((props.rootName || "").trim())
if (!rootName) return normalizedFilePath; if (!rootName) return normalizedFilePath
return `${rootName}/${normalizedFilePath}`; return `${rootName}/${normalizedFilePath}`
}); })
const menuStyle = computed(() => ({ const menuStyle = computed(() => ({
left: `${menuLeft.value}px`, left: `${menuLeft.value}px`,
top: `${menuTop.value}px`, top: `${menuTop.value}px`,
})); }))
const disabled = computed(() => !props.filePath); const disabled = computed(() => !props.filePath)
function clampToViewport() { function clampToViewport() {
const menu = menuRef.value; const menu = menuRef.value
if (!menu) return; if (!menu) return
const width = menu.offsetWidth; const width = menu.offsetWidth
const height = menu.offsetHeight; const height = menu.offsetHeight
const maxLeft = Math.max(VIEWPORT_MARGIN, window.innerWidth - width - VIEWPORT_MARGIN); const maxLeft = Math.max(VIEWPORT_MARGIN, window.innerWidth - width - VIEWPORT_MARGIN)
const maxTop = Math.max(VIEWPORT_MARGIN, window.innerHeight - height - VIEWPORT_MARGIN); const maxTop = Math.max(VIEWPORT_MARGIN, window.innerHeight - height - VIEWPORT_MARGIN)
menuLeft.value = Math.min(Math.max(props.x, VIEWPORT_MARGIN), maxLeft); menuLeft.value = Math.min(Math.max(props.x, VIEWPORT_MARGIN), maxLeft)
menuTop.value = Math.min(Math.max(props.y, VIEWPORT_MARGIN), maxTop); menuTop.value = Math.min(Math.max(props.y, VIEWPORT_MARGIN), maxTop)
} }
function handleViewportChange() { function handleViewportChange() {
if (!props.visible) return; if (!props.visible) return
clampToViewport(); clampToViewport()
} }
watch( watch(
() => [props.visible, props.x, props.y, resolvedPath.value], () => [props.visible, props.x, props.y, resolvedPath.value],
async ([visible]) => { async ([visible]) => {
if (!visible) return; if (!visible) return
await nextTick(); await nextTick()
clampToViewport(); clampToViewport()
}, },
{ immediate: true } { immediate: true },
); )
watch( watch(
() => props.visible, () => props.visible,
(visible) => { (visible) => {
if (visible) { if (visible) {
window.addEventListener('resize', handleViewportChange); window.addEventListener("resize", handleViewportChange)
return; return
} }
window.removeEventListener('resize', handleViewportChange); window.removeEventListener("resize", handleViewportChange)
}, },
{ immediate: true } { immediate: true },
); )
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener('resize', handleViewportChange); window.removeEventListener("resize", handleViewportChange)
}); })
</script> </script>
<style scoped> <style scoped>
+214 -160
View File
@@ -22,43 +22,47 @@
<span v-if="displayCodecBadge" class="v-badge codec">{{ displayCodecBadge }}</span> <span v-if="displayCodecBadge" class="v-badge codec">{{ displayCodecBadge }}</span>
<span v-if="displayQualityBadge" class="v-badge qual">{{ displayQualityBadge }}</span> <span v-if="displayQualityBadge" class="v-badge qual">{{ displayQualityBadge }}</span>
<span v-if="displayAudioBadge" class="v-badge audio">{{ displayAudioBadge }}</span> <span v-if="displayAudioBadge" class="v-badge audio">{{ displayAudioBadge }}</span>
<span v-if="displayReleaseGroupBadge" class="v-badge group">{{ displayReleaseGroupBadge }}</span> <span v-if="displayReleaseGroupBadge" class="v-badge group">{{
displayReleaseGroupBadge
}}</span>
</div> </div>
<div class="version-language-flags"> <div class="version-language-flags">
<LanguageFlags class="language-flags-audio" :codes="torrent.audio_languages" :compact="compactFlags" /> <LanguageFlags
class="language-flags-audio"
:codes="torrent.audio_languages"
:compact="compactFlags"
/>
<span <span
v-if="hasLanguageDisplay(torrent.audio_languages) && hasLanguageDisplay(torrent.subtitle_languages)" v-if="
hasLanguageDisplay(torrent.audio_languages) &&
hasLanguageDisplay(torrent.subtitle_languages)
"
class="language-separator" class="language-separator"
></span> >•</span
<LanguageFlags class="language-flags-subs" :codes="torrent.subtitle_languages" :compact="compactFlags" /> >
<LanguageFlags
class="language-flags-subs"
:codes="torrent.subtitle_languages"
:compact="compactFlags"
/>
</div> </div>
</div> </div>
<div class="version-dolby-cell"> <div class="version-dolby-cell">
<img <img v-if="showBlurayLogo" class="version-disc-logo" :src="blurayLogoUrl" alt="Blu-ray" />
v-if="showBlurayLogo" <img v-else-if="showDvdLogo" class="version-disc-logo" :src="dvdLogoUrl" alt="DVD" />
class="version-disc-logo"
:src="blurayLogoUrl"
alt="Blu-ray"
>
<img
v-else-if="showDvdLogo"
class="version-disc-logo"
:src="dvdLogoUrl"
alt="DVD"
>
<img <img
v-if="streamingServiceLogo" v-if="streamingServiceLogo"
class="version-service-logo" class="version-service-logo"
:src="streamingServiceLogo.src" :src="streamingServiceLogo.src"
:alt="streamingServiceLogo.alt" :alt="streamingServiceLogo.alt"
:title="streamingServiceLogo.alt" :title="streamingServiceLogo.alt"
> />
<DolbyBadges <DolbyBadges
class="version-dolby" class="version-dolby"
:has-dolby-vision="hasDolbyVision" :has-dolby-vision="hasDolbyVision"
:has-dolby-atmos="hasDolbyAtmos" :has-dolby-atmos="hasDolbyAtmos"
:is-hdr="hasHdr" :is-hdr="hasHdr"
/> />
</div> </div>
<div v-if="showActions" class="version-actions"> <div v-if="showActions" class="version-actions">
<button <button
@@ -66,225 +70,273 @@
tabindex="0" tabindex="0"
@click.stop="emit('play')" @click.stop="emit('play')"
:disabled="!torrent.playable_file" :disabled="!torrent.playable_file"
> {{ playLabel }}</button> >
▶ {{ playLabel }}
</button>
<button <button
class="ctx-btn ctx-btn-folder" class="ctx-btn ctx-btn-folder"
tabindex="0" tabindex="0"
@click.stop="emit('openFolder')" @click.stop="emit('openFolder')"
:disabled="!torrent.playable_file" :disabled="!torrent.playable_file"
>📁</button> >
📁
</button>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from "vue"
import type { Torrent } from '../types'; import type { Torrent } from "../types"
import LanguageFlags from './LanguageFlags.vue'; import LanguageFlags from "./LanguageFlags.vue"
import DolbyBadges from './DolbyBadges.vue'; import DolbyBadges from "./DolbyBadges.vue"
import { buildLanguageFlags } from '../utils/languageFlags'; import { buildLanguageFlags } from "../utils/languageFlags"
import blurayLogoUrl from '../assets/bluray.webp'; import blurayLogoUrl from "../assets/bluray.webp"
import dvdLogoUrl from '../assets/dvd.webp'; import dvdLogoUrl from "../assets/dvd.webp"
import amazonLogoUrl from '../assets/service-amazon.webp'; import amazonLogoUrl from "../assets/service-amazon.webp"
import appleTvLogoUrl from '../assets/service-apple-tv.webp'; import appleTvLogoUrl from "../assets/service-apple-tv.webp"
import netflixLogoUrl from '../assets/service-netflix.webp'; import netflixLogoUrl from "../assets/service-netflix.webp"
import hboMaxLogoUrl from '../assets/service-hbo-max.webp'; import hboMaxLogoUrl from "../assets/service-hbo-max.webp"
import huluLogoUrl from '../assets/service-hulu.webp'; import huluLogoUrl from "../assets/service-hulu.webp"
import disneyLogoUrl from '../assets/service-disney.svg'; import disneyLogoUrl from "../assets/service-disney.svg"
import itunesLogoUrl from '../assets/service-itunes.png'; import itunesLogoUrl from "../assets/service-itunes.png"
defineOptions({ defineOptions({
inheritAttrs: false, inheritAttrs: false,
}); })
const props = withDefaults(defineProps<{ const props = withDefaults(
torrent: Torrent; defineProps<{
best?: boolean; torrent: Torrent
selectable?: boolean; best?: boolean
disabled?: boolean; selectable?: boolean
compactFlags?: boolean; disabled?: boolean
showActions?: boolean; compactFlags?: boolean
playLabel?: string; showActions?: boolean
title?: string; playLabel?: string
variant?: 'default' | 'menu'; title?: string
}>(), { variant?: "default" | "menu"
best: false, }>(),
selectable: undefined, {
disabled: undefined, best: false,
compactFlags: false, selectable: undefined,
showActions: false, disabled: undefined,
playLabel: 'Play', compactFlags: false,
title: undefined, showActions: false,
variant: 'default', playLabel: "Play",
}); title: undefined,
variant: "default",
},
)
const emit = defineEmits<{ const emit = defineEmits<{
activate: [MouseEvent | KeyboardEvent]; activate: [MouseEvent | KeyboardEvent]
play: []; play: []
openFolder: []; openFolder: []
}>(); }>()
const dolbyTagPattern = /\b(dolby|atmos|vision|dovi|dv)\b/i; const dolbyTagPattern = /\b(dolby|atmos|vision|dovi|dv)\b/i
const dolbyVisionPattern = /\b(dolby\s*vision|dovi|\bdv\b)\b/i; const dolbyVisionPattern = /\b(dolby\s*vision|dovi|\bdv\b)\b/i
const dolbyAtmosPattern = /\b(dolby\s*atmos|atmos)\b/i; const dolbyAtmosPattern = /\b(dolby\s*atmos|atmos)\b/i
const hdrPattern = /\bhdr\b|smpte\s*2084|bt\s*2020|hlg/i; const hdrPattern = /\bhdr\b|smpte\s*2084|bt\s*2020|hlg/i
const blurayTagPattern = /\bblu[\s.-]*ray\b/i; const blurayTagPattern = /\bblu[\s.-]*ray\b/i
const blurayPlayablePattern = /(?:^|[\\/])(movieobject|index)\.bdmv$/i; const blurayPlayablePattern = /(?:^|[\\/])(movieobject|index)\.bdmv$/i
const dvdPlayablePattern = /(?:^|[\\/])video_ts\.ifo$/i; const dvdPlayablePattern = /(?:^|[\\/])video_ts\.ifo$/i
const webQualityPattern = /^web(?:[ .-]?dl|[ .-]?rip)$/i; const webQualityPattern = /^web(?:[ .-]?dl|[ .-]?rip)$/i
const serviceLogoMap: Array<{ aliases: string[]; src: string; alt: string }> = [ const serviceLogoMap: Array<{ aliases: string[]; src: string; alt: string }> = [
{ aliases: ['amazon studios', 'amazon prime video', 'prime video', 'amazon', 'amzn'], src: amazonLogoUrl, alt: 'Amazon Prime Video' }, {
{ aliases: ['apple tv+', 'apple tv plus', 'apple tv', 'atvp'], src: appleTvLogoUrl, alt: 'Apple TV+' }, aliases: ["amazon studios", "amazon prime video", "prime video", "amazon", "amzn"],
{ aliases: ['itunes', 'it'], src: itunesLogoUrl, alt: 'iTunes' }, src: amazonLogoUrl,
{ aliases: ['netflix', 'nf', 'nflx'], src: netflixLogoUrl, alt: 'Netflix' }, alt: "Amazon Prime Video",
{ aliases: ['hbo max', 'max', 'hmax'], src: hboMaxLogoUrl, alt: 'HBO Max' }, },
{ aliases: ['disney plus', 'disney+', 'disney plus hotstar', 'dsnp'], src: disneyLogoUrl, alt: 'Disney+' }, {
{ aliases: ['hulu'], src: huluLogoUrl, alt: 'Hulu' }, aliases: ["apple tv+", "apple tv plus", "apple tv", "atvp"],
]; src: appleTvLogoUrl,
alt: "Apple TV+",
},
{ aliases: ["itunes", "it"], src: itunesLogoUrl, alt: "iTunes" },
{ aliases: ["netflix", "nf", "nflx"], src: netflixLogoUrl, alt: "Netflix" },
{ aliases: ["hbo max", "max", "hmax"], src: hboMaxLogoUrl, alt: "HBO Max" },
{
aliases: ["disney plus", "disney+", "disney plus hotstar", "dsnp"],
src: disneyLogoUrl,
alt: "Disney+",
},
{ aliases: ["hulu"], src: huluLogoUrl, alt: "Hulu" },
]
function hasDolbyTag(value: string | null | undefined): boolean { function hasDolbyTag(value: string | null | undefined): boolean {
return Boolean(value && dolbyTagPattern.test(value)); return Boolean(value && dolbyTagPattern.test(value))
} }
function hasAnyTag( function hasAnyTag(pattern: RegExp, ...values: Array<string | null | undefined>): boolean {
pattern: RegExp, return values.some((value) => Boolean(value && pattern.test(value)))
...values: Array<string | null | undefined>
): boolean {
return values.some((value) => Boolean(value && pattern.test(value)));
} }
function hasLanguageDisplay(codes: string[] | null | undefined): boolean { function hasLanguageDisplay(codes: string[] | null | undefined): boolean {
const mapped = buildLanguageFlags(codes); const mapped = buildLanguageFlags(codes)
return mapped.flags.length > 0 || mapped.unmappedCodes.length > 0; return mapped.flags.length > 0 || mapped.unmappedCodes.length > 0
} }
function hasHdrTag(value: string | null | undefined): boolean { function hasHdrTag(value: string | null | undefined): boolean {
return Boolean(value && hdrPattern.test(value)); return Boolean(value && hdrPattern.test(value))
} }
function normalizeProviderName(value: string | null | undefined): string { function normalizeProviderName(value: string | null | undefined): string {
return (value || '').toLowerCase().replace(/[^a-z0-9+]+/g, ' ').trim(); return (value || "")
.toLowerCase()
.replace(/[^a-z0-9+]+/g, " ")
.trim()
} }
function normalizeQualityBadge(value: string): string { function normalizeQualityBadge(value: string): string {
const normalized = value.toLowerCase().replace(/[^a-z0-9]+/g, ''); const normalized = value.toLowerCase().replace(/[^a-z0-9]+/g, "")
if (normalized === 'hdtv' || normalized === 'pdtv' || normalized === 'tvrip' || normalized === 'sdtv') { if (
return 'TV'; normalized === "hdtv" ||
normalized === "pdtv" ||
normalized === "tvrip" ||
normalized === "sdtv"
) {
return "TV"
} }
if ( if (
normalized.includes('cam') normalized.includes("cam") ||
|| normalized === 'telesync' normalized === "telesync" ||
|| normalized === 'ts' normalized === "ts" ||
|| normalized === 'hdts' normalized === "hdts" ||
|| normalized === 'telecine' normalized === "telecine" ||
|| normalized === 'tc' normalized === "tc"
) { ) {
return 'CAM'; return "CAM"
} }
return value; return value
} }
const hasDolbyVision = computed(() => { const hasDolbyVision = computed(() => {
return ( return (
props.torrent.has_dolby_vision === true props.torrent.has_dolby_vision === true ||
|| hasAnyTag(dolbyVisionPattern, props.torrent.quality, props.torrent.codec, props.torrent.audio, props.torrent.title) hasAnyTag(
); dolbyVisionPattern,
}); props.torrent.quality,
props.torrent.codec,
props.torrent.audio,
props.torrent.title,
)
)
})
const hasDolbyAtmos = computed(() => { const hasDolbyAtmos = computed(() => {
return ( return (
props.torrent.has_dolby_atmos === true props.torrent.has_dolby_atmos === true ||
|| hasAnyTag(dolbyAtmosPattern, props.torrent.quality, props.torrent.codec, props.torrent.audio, props.torrent.title) hasAnyTag(
); dolbyAtmosPattern,
}); props.torrent.quality,
props.torrent.codec,
props.torrent.audio,
props.torrent.title,
)
)
})
const hasHdr = computed(() => { const hasHdr = computed(() => {
return ( return (
props.torrent.is_hdr === true props.torrent.is_hdr === true ||
|| hasAnyTag(hdrPattern, props.torrent.quality, props.torrent.codec, props.torrent.audio, props.torrent.title) hasAnyTag(
); hdrPattern,
}); props.torrent.quality,
props.torrent.codec,
props.torrent.audio,
props.torrent.title,
)
)
})
const isBlurayDisc = computed(() => { const isBlurayDisc = computed(() => {
if (!props.torrent.playable_file) return false; if (!props.torrent.playable_file) return false
return blurayPlayablePattern.test(props.torrent.playable_file); return blurayPlayablePattern.test(props.torrent.playable_file)
}); })
const isDvdDisc = computed(() => { const isDvdDisc = computed(() => {
if (!props.torrent.playable_file) return false; if (!props.torrent.playable_file) return false
return dvdPlayablePattern.test(props.torrent.playable_file); return dvdPlayablePattern.test(props.torrent.playable_file)
}); })
const showBlurayLogo = computed(() => { const showBlurayLogo = computed(() => {
return isBlurayDisc.value; return isBlurayDisc.value
}); })
const showDvdLogo = computed(() => { const showDvdLogo = computed(() => {
return isDvdDisc.value; return isDvdDisc.value
}); })
const streamingServiceLogo = computed(() => { const streamingServiceLogo = computed(() => {
if (!props.torrent.quality || !webQualityPattern.test(props.torrent.quality)) { if (!props.torrent.quality || !webQualityPattern.test(props.torrent.quality)) {
return null; return null
} }
const network = normalizeProviderName(props.torrent.network); const network = normalizeProviderName(props.torrent.network)
if (!network) return null; if (!network) return null
const found = serviceLogoMap.find((entry) => entry.aliases.includes(network)); const found = serviceLogoMap.find((entry) => entry.aliases.includes(network))
return found ? { src: found.src, alt: found.alt } : null; return found ? { src: found.src, alt: found.alt } : null
}); })
const displayQualityBadge = computed(() => { const displayQualityBadge = computed(() => {
if (!props.torrent.quality || hasDolbyTag(props.torrent.quality)) return null; if (!props.torrent.quality || hasDolbyTag(props.torrent.quality)) return null
if (streamingServiceLogo.value) return null; if (streamingServiceLogo.value) return null
if (blurayTagPattern.test(props.torrent.quality)) return null; if (blurayTagPattern.test(props.torrent.quality)) return null
return normalizeQualityBadge(props.torrent.quality); return normalizeQualityBadge(props.torrent.quality)
}); })
const displayCodecBadge = computed(() => { const displayCodecBadge = computed(() => {
if (!props.torrent.codec || hasDolbyTag(props.torrent.codec)) return null; if (!props.torrent.codec || hasDolbyTag(props.torrent.codec)) return null
return props.torrent.codec; return props.torrent.codec
}); })
const displayAudioBadge = computed(() => { const displayAudioBadge = computed(() => {
if (!props.torrent.audio || hasDolbyTag(props.torrent.audio)) return null; if (!props.torrent.audio || hasDolbyTag(props.torrent.audio)) return null
return props.torrent.audio; return props.torrent.audio
}); })
const displayReleaseGroupBadge = computed(() => { const displayReleaseGroupBadge = computed(() => {
const value = props.torrent.encoder?.trim(); const value = props.torrent.encoder?.trim()
if (!value) return null; if (!value) return null
return value; return value
}); })
const showHdrBadge = computed(() => { const showHdrBadge = computed(() => {
if (!hasHdr.value) return false; if (!hasHdr.value) return false
return !hasHdrTag(props.torrent.quality) && !hasHdrTag(props.torrent.codec) && !hasHdrTag(props.torrent.audio); return (
}); !hasHdrTag(props.torrent.quality) &&
!hasHdrTag(props.torrent.codec) &&
!hasHdrTag(props.torrent.audio)
)
})
const isSelectable = computed(() => { const isSelectable = computed(() => {
if (props.selectable !== undefined) return props.selectable; if (props.selectable !== undefined) return props.selectable
return Boolean(props.torrent.playable_file); return Boolean(props.torrent.playable_file)
}); })
const isDisabled = computed(() => { const isDisabled = computed(() => {
if (props.disabled !== undefined) return props.disabled; if (props.disabled !== undefined) return props.disabled
return !isSelectable.value; return !isSelectable.value
}); })
const resolvedTitle = computed(() => { const resolvedTitle = computed(() => {
if (props.title !== undefined) return props.title; if (props.title !== undefined) return props.title
return isSelectable.value ? 'Click to play/continue. Alt+Click to open folder.' : 'No playable file'; return isSelectable.value
}); ? "Click to play/continue. Alt+Click to open folder."
: "No playable file"
})
function handleActivate(event: MouseEvent | KeyboardEvent) { function handleActivate(event: MouseEvent | KeyboardEvent) {
if (!isSelectable.value || isDisabled.value) return; if (!isSelectable.value || isDisabled.value) return
emit('activate', event); emit("activate", event)
} }
</script> </script>
@@ -301,7 +353,9 @@ function handleActivate(event: MouseEvent | KeyboardEvent) {
-webkit-backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
border-radius: 8px; border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);
transition: background 0.2s, border-color 0.2s; transition:
background 0.2s,
border-color 0.2s;
} }
.version-row.version-menu { .version-row.version-menu {
+286 -235
View File
@@ -1,11 +1,15 @@
<template> <template>
<div class="series-fullscreen"> <div class="series-fullscreen">
<!-- Hero section with backdrop or season collage --> <!-- Hero section with backdrop or season collage -->
<section class="series-hero"> <section class="series-hero">
<div class="hero-bg"> <div class="hero-bg">
<!-- Use backdrop if available, otherwise create collage from season posters --> <!-- Use backdrop if available, otherwise create collage from season posters -->
<img v-if="backdropUrl" :src="backdropUrl" class="hero-img" :alt="series.title || 'Unknown'" /> <img
v-if="backdropUrl"
:src="backdropUrl"
class="hero-img"
:alt="series.title || 'Unknown'"
/>
<div v-else class="hero-collage"> <div v-else class="hero-collage">
<div <div
v-for="(season, i) in seasonsWithPosters.slice(0, 5)" v-for="(season, i) in seasonsWithPosters.slice(0, 5)"
@@ -19,10 +23,16 @@
<div class="hero-content"> <div class="hero-content">
<h1 class="series-title">{{ series.title }}</h1> <h1 class="series-title">{{ series.title }}</h1>
<div class="series-meta"> <div class="series-meta">
<span v-if="series.info?.rating" class="meta-rating" :class="ratingClass"> {{ series.info.rating.toFixed(1) }}</span> <span v-if="series.info?.rating" class="meta-rating" :class="ratingClass"
<span v-if="series.info?.number_of_seasons" class="meta-item">{{ series.info.number_of_seasons }} Seasons</span> > {{ series.info.rating.toFixed(1) }}</span
>
<span v-if="series.info?.number_of_seasons" class="meta-item"
>{{ series.info.number_of_seasons }} Seasons</span
>
<span v-if="series.info?.status" class="meta-badge">{{ series.info.status }}</span> <span v-if="series.info?.status" class="meta-badge">{{ series.info.status }}</span>
<span v-if="series.info?.genres?.length" class="meta-genres">{{ series.info.genres.slice(0, 3).join(' ') }}</span> <span v-if="series.info?.genres?.length" class="meta-genres">{{
series.info.genres.slice(0, 3).join(" ")
}}</span>
</div> </div>
<p v-if="series.info?.overview" class="series-overview">{{ series.info.overview }}</p> <p v-if="series.info?.overview" class="series-overview">{{ series.info.overview }}</p>
</div> </div>
@@ -53,7 +63,9 @@
</div> </div>
<div class="poster-overlay"> <div class="poster-overlay">
<div class="season-label">{{ season.name || `Season ${season.season_number}` }}</div> <div class="season-label">{{ season.name || `Season ${season.season_number}` }}</div>
<div v-if="season.overview" class="season-overview-short">{{ truncate(season.overview, 120) }}</div> <div v-if="season.overview" class="season-overview-short">
{{ truncate(season.overview, 120) }}
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -80,7 +92,7 @@
<div class="tile-bg"> <div class="tile-bg">
<video <video
v-if="getEpisodeVideoSources(episode).length > 0" v-if="getEpisodeVideoSources(episode).length > 0"
:ref="el => setVideoRef(el as HTMLVideoElement, `${sIndex}-${eIndex}`)" :ref="(el) => setVideoRef(el as HTMLVideoElement, `${sIndex}-${eIndex}`)"
:autoplay="safariAutoplay" :autoplay="safariAutoplay"
loop loop
muted muted
@@ -92,7 +104,7 @@
:src="source.src" :src="source.src"
:type="source.type" :type="source.type"
:codecs="source.codecs" :codecs="source.codecs"
> />
</video> </video>
<div v-else class="tile-placeholder"></div> <div v-else class="tile-placeholder"></div>
</div> </div>
@@ -104,8 +116,12 @@
<div class="tile-info"> <div class="tile-info">
<span class="ep-number">{{ episode.episode_number }}</span> <span class="ep-number">{{ episode.episode_number }}</span>
<div class="ep-details"> <div class="ep-details">
<span class="ep-name">{{ episode.name || `Episode ${episode.episode_number}` }}</span> <span class="ep-name">{{
<span v-if="episode.rating" class="ep-rating"> {{ episode.rating.toFixed(1) }}</span> episode.name || `Episode ${episode.episode_number}`
}}</span>
<span v-if="episode.rating" class="ep-rating"
> {{ episode.rating.toFixed(1) }}</span
>
</div> </div>
</div> </div>
@@ -140,15 +156,17 @@
:torrent="torrent" :torrent="torrent"
variant="menu" variant="menu"
compact-flags compact-flags
:title="torrent.playable_file ? 'Click to play/continue. Alt+Click, Alt+Enter, or Cmd/Ctrl+E to open folder. Right-click for actions.' : 'No playable file'" :title="
torrent.playable_file
? 'Click to play/continue. Alt+Click, Alt+Enter, or Cmd/Ctrl+E to open folder. Right-click for actions.'
: 'No playable file'
"
@activate="handleVersionActivate(torrent, $event)" @activate="handleVersionActivate(torrent, $event)"
@keydown="handleVersionShortcutKeydown($event, torrent)" @keydown="handleVersionShortcutKeydown($event, torrent)"
@contextmenu="handleVersionContextMenu($event, torrent)" @contextmenu="handleVersionContextMenu($event, torrent)"
/> />
</div> </div>
<div v-else class="context-menu-empty"> <div v-else class="context-menu-empty">No versions available</div>
No versions available
</div>
</div> </div>
<ReleaseActionMenu <ReleaseActionMenu
:visible="versionActionMenu.visible" :visible="versionActionMenu.visible"
@@ -165,71 +183,77 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, nextTick, watch, onMounted, onUnmounted } from 'vue'; import { computed, ref, nextTick, watch, onMounted, onUnmounted } from "vue"
import type { Series, Season, Episode, Torrent } from '../types'; import type { Series, Season, Episode, Torrent } from "../types"
import { getCoverUrl, getVideoPreviewUrl, getVideoSourceAttributes, isSafariBrowser } from '../api'; import { getCoverUrl, getVideoPreviewUrl, getVideoSourceAttributes, isSafariBrowser } from "../api"
import { navAttrs } from '../composables/useKeyboardNavigation'; import { navAttrs } from "../composables/useKeyboardNavigation"
import ReleaseVersionCard from './ReleaseVersionCard.vue'; import ReleaseVersionCard from "./ReleaseVersionCard.vue"
import ReleaseActionMenu from './ReleaseActionMenu.vue'; import ReleaseActionMenu from "./ReleaseActionMenu.vue"
const props = defineProps<{ const props = defineProps<{
series: Series; series: Series
focusEpisode?: { seasonNumber: number; episodeNumber: number } | null; focusEpisode?: { seasonNumber: number; episodeNumber: number } | null
hasResumePosition: (filePath: string | null) => boolean; hasResumePosition: (filePath: string | null) => boolean
getRootName: (rootId: string | null | undefined) => string | null; getRootName: (rootId: string | null | undefined) => string | null
}>(); }>()
const emit = defineEmits<{ const emit = defineEmits<{
close: []; close: []
play: [string]; play: [string]
openFolder: [string, string | null | undefined]; openFolder: [string, string | null | undefined]
}>(); }>()
// Focus on matched episode when provided // Focus on matched episode when provided
watch(() => props.focusEpisode, (ep) => { watch(
if (ep) { () => props.focusEpisode,
// Delay to ensure DOM is fully rendered after route transition (ep) => {
setTimeout(() => { if (ep) {
// Find the season index and episode index // Delay to ensure DOM is fully rendered after route transition
const seasonIndex = props.series.seasons?.findIndex(s => s.season_number === ep.seasonNumber) ?? -1; setTimeout(() => {
if (seasonIndex >= 0) { // Find the season index and episode index
const episodeIndex = props.series.seasons?.[seasonIndex]?.episodes?.findIndex( const seasonIndex =
e => e.episode_number === ep.episodeNumber props.series.seasons?.findIndex((s) => s.season_number === ep.seasonNumber) ?? -1
) ?? -1; if (seasonIndex >= 0) {
if (episodeIndex >= 0) { const episodeIndex =
// Find the episode tile element using nav attributes props.series.seasons?.[seasonIndex]?.episodes?.findIndex(
const selector = `[data-nav-row="${seasonIndex + 2}"][data-nav-col="${episodeIndex}"]`; (e) => e.episode_number === ep.episodeNumber,
const element = document.querySelector(selector) as HTMLElement | null; ) ?? -1
if (element) { if (episodeIndex >= 0) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' }); // Find the episode tile element using nav attributes
element.focus(); const selector = `[data-nav-row="${seasonIndex + 2}"][data-nav-col="${episodeIndex}"]`
const element = document.querySelector(selector) as HTMLElement | null
if (element) {
element.scrollIntoView({ behavior: "smooth", block: "center" })
element.focus()
}
} }
} }
} }, 150)
}, 150); }
} },
}, { immediate: true }); { immediate: true },
)
// Context menu state // Context menu state
const contextMenu = ref<{ const contextMenu = ref<{
visible: boolean; visible: boolean
x: number; x: number
y: number; y: number
episode: Episode | null; episode: Episode | null
}>({ }>({
visible: false, visible: false,
x: 0, x: 0,
y: 0, y: 0,
episode: null, episode: null,
}); })
const versionActionMenu = ref<{ const versionActionMenu = ref<{
visible: boolean; visible: boolean
x: number; x: number
y: number; y: number
filePath: string | null; filePath: string | null
rootName: string | null; rootName: string | null
rootId: string | null; rootId: string | null
}>({ }>({
visible: false, visible: false,
x: 0, x: 0,
@@ -237,183 +261,192 @@ const versionActionMenu = ref<{
filePath: null, filePath: null,
rootName: null, rootName: null,
rootId: null, rootId: null,
}); })
const releaseMenuOriginElement = ref<HTMLElement | null>(null); const releaseMenuOriginElement = ref<HTMLElement | null>(null)
// Show context menu on right-click // Show context menu on right-click
function handleContextMenu(event: MouseEvent, episode: Episode) { function handleContextMenu(event: MouseEvent, episode: Episode) {
event.preventDefault(); event.preventDefault()
releaseMenuOriginElement.value = event.currentTarget as HTMLElement | null; releaseMenuOriginElement.value = event.currentTarget as HTMLElement | null
openEpisodeReleaseMenu(episode, event.clientX, event.clientY); openEpisodeReleaseMenu(episode, event.clientX, event.clientY)
} }
function openEpisodeReleaseMenu(episode: Episode, x: number, y: number) { function openEpisodeReleaseMenu(episode: Episode, x: number, y: number) {
closeVersionActionMenu(); closeVersionActionMenu()
contextMenu.value = { contextMenu.value = {
visible: true, visible: true,
x, x,
y, y,
episode, episode,
}; }
// Add Escape key listener (capturing phase to intercept before other handlers) // Add Escape key listener (capturing phase to intercept before other handlers)
nextTick(() => { nextTick(() => {
document.addEventListener('keydown', handleContextMenuKeydown, true); document.addEventListener("keydown", handleContextMenuKeydown, true)
// Focus first selectable version card. // Focus first selectable version card.
const firstCard = document.querySelector('.context-menu .version-row.version-selectable') as HTMLElement; const firstCard = document.querySelector(
".context-menu .version-row.version-selectable",
) as HTMLElement
if (firstCard) { if (firstCard) {
firstCard.focus(); firstCard.focus()
} }
}); })
} }
function openEpisodeReleaseMenuFromElement(episode: Episode, element: HTMLElement | null) { function openEpisodeReleaseMenuFromElement(episode: Episode, element: HTMLElement | null) {
releaseMenuOriginElement.value = element; releaseMenuOriginElement.value = element
if (!element) { if (!element) {
openEpisodeReleaseMenu(episode, window.innerWidth / 2, window.innerHeight / 2); openEpisodeReleaseMenu(episode, window.innerWidth / 2, window.innerHeight / 2)
return; return
} }
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect()
openEpisodeReleaseMenu(episode, rect.left + rect.width / 2, rect.top + rect.height / 2); openEpisodeReleaseMenu(episode, rect.left + rect.width / 2, rect.top + rect.height / 2)
} }
// Handle Escape and arrow keys in context menu (capturing phase to intercept before global handler) // Handle Escape and arrow keys in context menu (capturing phase to intercept before global handler)
function handleContextMenuKeydown(event: KeyboardEvent) { function handleContextMenuKeydown(event: KeyboardEvent) {
if (!contextMenu.value.visible) return; if (!contextMenu.value.visible) return
const popupFocusable = getPopupFocusableElements(); const popupFocusable = getPopupFocusableElements()
if (event.key === 'Tab') { if (event.key === "Tab") {
if (popupFocusable.length === 0) return; if (popupFocusable.length === 0) return
event.preventDefault(); event.preventDefault()
event.stopPropagation(); event.stopPropagation()
const currentIndex = popupFocusable.findIndex((el) => el === document.activeElement); const currentIndex = popupFocusable.findIndex((el) => el === document.activeElement)
const delta = event.shiftKey ? -1 : 1; const delta = event.shiftKey ? -1 : 1
const nextIndex = currentIndex < 0 const nextIndex =
? 0 currentIndex < 0 ? 0 : (currentIndex + delta + popupFocusable.length) % popupFocusable.length
: (currentIndex + delta + popupFocusable.length) % popupFocusable.length; popupFocusable[nextIndex].focus()
popupFocusable[nextIndex].focus(); return
return;
} }
if (event.key === 'ArrowDown' || event.key === 'ArrowRight' || event.key === 'ArrowUp' || event.key === 'ArrowLeft') { if (
if (popupFocusable.length === 0) return; event.key === "ArrowDown" ||
event.preventDefault(); event.key === "ArrowRight" ||
event.stopPropagation(); event.key === "ArrowUp" ||
event.key === "ArrowLeft"
) {
if (popupFocusable.length === 0) return
event.preventDefault()
event.stopPropagation()
const currentIndex = popupFocusable.findIndex((el) => el === document.activeElement); const currentIndex = popupFocusable.findIndex((el) => el === document.activeElement)
const delta = (event.key === 'ArrowDown' || event.key === 'ArrowRight') ? 1 : -1; const delta = event.key === "ArrowDown" || event.key === "ArrowRight" ? 1 : -1
const nextIndex = currentIndex < 0 const nextIndex =
? 0 currentIndex < 0 ? 0 : (currentIndex + delta + popupFocusable.length) % popupFocusable.length
: (currentIndex + delta + popupFocusable.length) % popupFocusable.length; popupFocusable[nextIndex].focus()
popupFocusable[nextIndex].focus(); return
return;
} }
if (event.key === 'Escape') { if (event.key === "Escape") {
event.preventDefault(); event.preventDefault()
event.stopPropagation(); event.stopPropagation()
if (versionActionMenu.value.visible) { if (versionActionMenu.value.visible) {
closeVersionActionMenu(); closeVersionActionMenu()
return; return
} }
closeContextMenu(); closeContextMenu()
} }
} }
function getPopupFocusableElements(): HTMLElement[] { function getPopupFocusableElements(): HTMLElement[] {
const releaseItems = Array.from( const releaseItems = Array.from(
document.querySelectorAll<HTMLElement>('.context-menu .version-row.version-selectable') document.querySelectorAll<HTMLElement>(".context-menu .version-row.version-selectable"),
); )
const actionItems = versionActionMenu.value.visible const actionItems = versionActionMenu.value.visible
? Array.from(document.querySelectorAll<HTMLElement>('.version-action-menu .version-action-item:not(:disabled)')) ? Array.from(
: []; document.querySelectorAll<HTMLElement>(
return [...releaseItems, ...actionItems]; ".version-action-menu .version-action-item:not(:disabled)",
),
)
: []
return [...releaseItems, ...actionItems]
} }
// Close context menu // Close context menu
function closeContextMenu() { function closeContextMenu() {
closeVersionActionMenu(); closeVersionActionMenu()
contextMenu.value.visible = false; contextMenu.value.visible = false
document.removeEventListener('keydown', handleContextMenuKeydown, true); document.removeEventListener("keydown", handleContextMenuKeydown, true)
nextTick(() => { nextTick(() => {
releaseMenuOriginElement.value?.focus(); releaseMenuOriginElement.value?.focus()
}); })
} }
function closeVersionActionMenu() { function closeVersionActionMenu() {
versionActionMenu.value.visible = false; versionActionMenu.value.visible = false
versionActionMenu.value.filePath = null; versionActionMenu.value.filePath = null
versionActionMenu.value.rootName = null; versionActionMenu.value.rootName = null
versionActionMenu.value.rootId = null; versionActionMenu.value.rootId = null
} }
function handleGamepadAction(event: Event) { function handleGamepadAction(event: Event) {
const actionEvent = event as CustomEvent<{ action?: string }>; const actionEvent = event as CustomEvent<{ action?: string }>
if (actionEvent.detail?.action !== 'menu') return; if (actionEvent.detail?.action !== "menu") return
const active = document.activeElement as HTMLElement | null; const active = document.activeElement as HTMLElement | null
if (!active || !active.classList.contains('episode-tile')) return; if (!active || !active.classList.contains("episode-tile")) return
const row = parseInt(active.getAttribute('data-nav-row') || '-1', 10); const row = parseInt(active.getAttribute("data-nav-row") || "-1", 10)
const col = parseInt(active.getAttribute('data-nav-col') || '-1', 10); const col = parseInt(active.getAttribute("data-nav-col") || "-1", 10)
if (row < 2 || col < 0) return; if (row < 2 || col < 0) return
const season = props.series.seasons?.[row - 2]; const season = props.series.seasons?.[row - 2]
const episode = season?.episodes?.[col]; const episode = season?.episodes?.[col]
if (!episode) return; if (!episode) return
actionEvent.preventDefault(); actionEvent.preventDefault()
openEpisodeReleaseMenuFromElement(episode, active); openEpisodeReleaseMenuFromElement(episode, active)
} }
// Play specific version // Play specific version
function handlePlayVersion(filePath: string | null) { function handlePlayVersion(filePath: string | null) {
if (filePath) { if (filePath) {
emit('play', filePath); emit("play", filePath)
} }
closeVersionActionMenu(); closeVersionActionMenu()
closeContextMenu(); closeContextMenu()
} }
function getPlayLabel(filePath: string | null): string { function getPlayLabel(filePath: string | null): string {
return props.hasResumePosition(filePath) ? 'Continue' : 'Play'; return props.hasResumePosition(filePath) ? "Continue" : "Play"
} }
// Open folder for a version // Open folder for a version
function handleOpenFolder(folderPath: string, rootId?: string | null) { function handleOpenFolder(folderPath: string, rootId?: string | null) {
if (!folderPath) return; if (!folderPath) return
emit('openFolder', folderPath, rootId); emit("openFolder", folderPath, rootId)
closeVersionActionMenu(); closeVersionActionMenu()
closeContextMenu(); closeContextMenu()
} }
function handleVersionActivate(torrent: Torrent, event: MouseEvent | KeyboardEvent) { function handleVersionActivate(torrent: Torrent, event: MouseEvent | KeyboardEvent) {
if (!torrent.playable_file) return; if (!torrent.playable_file) return
const rootId = torrent.root_id || props.series.root_id; const rootId = torrent.root_id || props.series.root_id
if (event.altKey) { if (event.altKey) {
handleOpenFolder(torrent.playable_file, rootId); handleOpenFolder(torrent.playable_file, rootId)
return; return
} }
handlePlayVersion(torrent.playable_file); handlePlayVersion(torrent.playable_file)
} }
function handleVersionShortcutKeydown(event: KeyboardEvent, torrent: Torrent) { function handleVersionShortcutKeydown(event: KeyboardEvent, torrent: Torrent) {
if (!torrent.playable_file) return; if (!torrent.playable_file) return
const rootId = torrent.root_id || props.series.root_id; const rootId = torrent.root_id || props.series.root_id
const key = event.key.toLowerCase(); const key = event.key.toLowerCase()
if (key === 'e' && (event.metaKey || event.ctrlKey)) { if (key === "e" && (event.metaKey || event.ctrlKey)) {
event.preventDefault(); event.preventDefault()
event.stopPropagation(); event.stopPropagation()
handleOpenFolder(torrent.playable_file, rootId); handleOpenFolder(torrent.playable_file, rootId)
} }
} }
function handleVersionContextMenu(event: MouseEvent, torrent: Torrent) { function handleVersionContextMenu(event: MouseEvent, torrent: Torrent) {
event.preventDefault(); event.preventDefault()
event.stopPropagation(); event.stopPropagation()
const rootId = torrent.root_id || props.series.root_id; const rootId = torrent.root_id || props.series.root_id
versionActionMenu.value = { versionActionMenu.value = {
visible: true, visible: true,
x: event.clientX, x: event.clientX,
@@ -421,172 +454,184 @@ function handleVersionContextMenu(event: MouseEvent, torrent: Torrent) {
filePath: torrent.playable_file || null, filePath: torrent.playable_file || null,
rootName: props.getRootName(rootId) || null, rootName: props.getRootName(rootId) || null,
rootId, rootId,
}; }
nextTick(() => { nextTick(() => {
const firstAction = document.querySelector('.version-action-menu .version-action-item:not(:disabled)') as HTMLElement | null; const firstAction = document.querySelector(
firstAction?.focus(); ".version-action-menu .version-action-item:not(:disabled)",
}); ) as HTMLElement | null
firstAction?.focus()
})
} }
// Video refs for hover effects // Video refs for hover effects
const videoRefs = ref<Map<string, HTMLVideoElement>>(new Map()); const videoRefs = ref<Map<string, HTMLVideoElement>>(new Map())
let videoIndex = 0; let videoIndex = 0
const safariAutoplay = isSafariBrowser(); const safariAutoplay = isSafariBrowser()
// Set video ref with staggered playback // Set video ref with staggered playback
function setVideoRef(el: HTMLVideoElement | null, key: string) { function setVideoRef(el: HTMLVideoElement | null, key: string) {
if (el) { if (el) {
videoRefs.value.set(key, el); videoRefs.value.set(key, el)
// Staggered start times with 0.2 second offset // Staggered start times with 0.2 second offset
const index = videoIndex++; const index = videoIndex++
setTimeout(() => { setTimeout(
if (safariAutoplay && el.readyState >= 1) { () => {
el.currentTime = 0.001 + ((index % 6) * 0.03); if (safariAutoplay && el.readyState >= 1) {
} el.currentTime = 0.001 + (index % 6) * 0.03
el.play().catch(() => {}); // Ignore autoplay policy errors }
}, safariAutoplay ? 0 : index * 200); el.play().catch(() => {}) // Ignore autoplay policy errors
},
safariAutoplay ? 0 : index * 200,
)
} else { } else {
videoRefs.value.delete(key); videoRefs.value.delete(key)
} }
} }
// Volume fade animation tracking // Volume fade animation tracking
const volumeFadeIntervals = new Map<string, ReturnType<typeof setInterval>>(); const volumeFadeIntervals = new Map<string, ReturnType<typeof setInterval>>()
// Handle hover-based audio fade in/out for episode videos // Handle hover-based audio fade in/out for episode videos
function handleEpisodeHover(key: string, isEntering: boolean) { function handleEpisodeHover(key: string, isEntering: boolean) {
const video = videoRefs.value.get(key); const video = videoRefs.value.get(key)
if (!video) return; if (!video) return
// Clear any existing fade for this video // Clear any existing fade for this video
const existingInterval = volumeFadeIntervals.get(key); const existingInterval = volumeFadeIntervals.get(key)
if (existingInterval) { if (existingInterval) {
clearInterval(existingInterval); clearInterval(existingInterval)
volumeFadeIntervals.delete(key); volumeFadeIntervals.delete(key)
} }
if (isEntering) { if (isEntering) {
// Mute all other videos immediately // Mute all other videos immediately
videoRefs.value.forEach((v, k) => { videoRefs.value.forEach((v, k) => {
if (k !== key) { if (k !== key) {
v.volume = 0; v.volume = 0
v.muted = true; v.muted = true
} }
}); })
// Fade in this video's audio // Fade in this video's audio
video.muted = false; video.muted = false
const fadeIn = setInterval(() => { const fadeIn = setInterval(() => {
if (video.volume < 0.95) { if (video.volume < 0.95) {
video.volume = Math.min(1, video.volume + 0.1); video.volume = Math.min(1, video.volume + 0.1)
} else { } else {
video.volume = 1; video.volume = 1
clearInterval(fadeIn); clearInterval(fadeIn)
volumeFadeIntervals.delete(key); volumeFadeIntervals.delete(key)
} }
}, 30); }, 30)
volumeFadeIntervals.set(key, fadeIn); volumeFadeIntervals.set(key, fadeIn)
} else { } else {
// Fade out this video's audio // Fade out this video's audio
const fadeOut = setInterval(() => { const fadeOut = setInterval(() => {
if (video.volume > 0.05) { if (video.volume > 0.05) {
video.volume = Math.max(0, video.volume - 0.1); video.volume = Math.max(0, video.volume - 0.1)
} else { } else {
video.volume = 0; video.volume = 0
video.muted = true; video.muted = true
clearInterval(fadeOut); clearInterval(fadeOut)
volumeFadeIntervals.delete(key); volumeFadeIntervals.delete(key)
} }
}, 30); }, 30)
volumeFadeIntervals.set(key, fadeOut); volumeFadeIntervals.set(key, fadeOut)
} }
} }
// Backdrop URL - only use backdrop_path, fall back to collage (handled in template) // Backdrop URL - only use backdrop_path, fall back to collage (handled in template)
const backdropUrl = computed(() => { const backdropUrl = computed(() => {
if (props.series.info?.backdrop_path) { if (props.series.info?.backdrop_path) {
return getCoverUrl(props.series.info.backdrop_path, props.series.root_id); return getCoverUrl(props.series.info.backdrop_path, props.series.root_id)
} }
return null; return null
}); })
// Seasons that have poster images // Seasons that have poster images
const seasonsWithPosters = computed(() => { const seasonsWithPosters = computed(() => {
return props.series.seasons.filter(s => s.poster_path); return props.series.seasons.filter((s) => s.poster_path)
}); })
// Rating class // Rating class
const ratingClass = computed(() => { const ratingClass = computed(() => {
if (!props.series.info?.rating) return ''; if (!props.series.info?.rating) return ""
if (props.series.info.rating >= 7.5) return 'rating-high'; if (props.series.info.rating >= 7.5) return "rating-high"
if (props.series.info.rating >= 6) return 'rating-medium'; if (props.series.info.rating >= 6) return "rating-medium"
return 'rating-low'; return "rating-low"
}); })
// Get season poster // Get season poster
function getSeasonPoster(season: Season): string | undefined { function getSeasonPoster(season: Season): string | undefined {
if (season.poster_path) { if (season.poster_path) {
return getCoverUrl(season.poster_path, props.series.root_id); return getCoverUrl(season.poster_path, props.series.root_id)
} }
return undefined; return undefined
} }
function getEpisodeVideoSources(episode: Episode): Array<{ src: string; type: string; codecs: string }> { function getEpisodeVideoSources(
const sources = episode.reel_sources && episode.reel_sources.length > 0 episode: Episode,
? episode.reel_sources ): Array<{ src: string; type: string; codecs: string }> {
: episode.reel_image const sources =
? [episode.reel_image] episode.reel_sources && episode.reel_sources.length > 0
: []; ? episode.reel_sources
: episode.reel_image
? [episode.reel_image]
: []
return sources.map((path) => ({ return sources.map((path) => ({
src: getVideoPreviewUrl(getCoverUrl(path, props.series.root_id)), src: getVideoPreviewUrl(getCoverUrl(path, props.series.root_id)),
...getVideoSourceAttributes(path), ...getVideoSourceAttributes(path),
})); }))
} }
// Collage slice style for season posters // Collage slice style for season posters
function getCollageSliceStyle(season: Season, index: number) { function getCollageSliceStyle(season: Season, index: number) {
const posterUrl = season.poster_path ? getCoverUrl(season.poster_path, props.series.root_id) : null; const posterUrl = season.poster_path
const totalSlices = Math.min(seasonsWithPosters.value.length, 5); ? getCoverUrl(season.poster_path, props.series.root_id)
const sliceWidth = 100 / totalSlices; : null
const totalSlices = Math.min(seasonsWithPosters.value.length, 5)
const sliceWidth = 100 / totalSlices
return { return {
backgroundImage: posterUrl ? `url('${posterUrl}')` : 'linear-gradient(135deg, #1a1a2e, #16213e)', backgroundImage: posterUrl
? `url('${posterUrl}')`
: "linear-gradient(135deg, #1a1a2e, #16213e)",
left: `${index * sliceWidth}%`, left: `${index * sliceWidth}%`,
width: `${sliceWidth + 5}%`, // overlap slightly width: `${sliceWidth + 5}%`, // overlap slightly
clipPath: `polygon(${index * 10}% 0, 100% 0, ${100 - (totalSlices - index - 1) * 10}% 100%, 0% 100%)`, clipPath: `polygon(${index * 10}% 0, 100% 0, ${100 - (totalSlices - index - 1) * 10}% 100%, 0% 100%)`,
}; }
} }
// Truncate text // Truncate text
function truncate(text: string, maxLength: number): string { function truncate(text: string, maxLength: number): string {
if (text.length <= maxLength) return text; if (text.length <= maxLength) return text
return text.slice(0, maxLength).trim() + '...'; return text.slice(0, maxLength).trim() + "..."
} }
// Handle play // Handle play
function handlePlay(episode: Episode) { function handlePlay(episode: Episode) {
const playableFile = Object.values(episode.torrents || {})[0]?.playable_file; const playableFile = Object.values(episode.torrents || {})[0]?.playable_file
if (playableFile) { if (playableFile) {
emit('play', playableFile); emit("play", playableFile)
} }
} }
function handleEpisodeEnter(event: KeyboardEvent, episode: Episode) { function handleEpisodeEnter(event: KeyboardEvent, episode: Episode) {
if (event.altKey || event.metaKey || event.ctrlKey) { if (event.altKey || event.metaKey || event.ctrlKey) {
openEpisodeReleaseMenuFromElement(episode, event.currentTarget as HTMLElement | null); openEpisodeReleaseMenuFromElement(episode, event.currentTarget as HTMLElement | null)
return; return
} }
handlePlay(episode); handlePlay(episode)
} }
onMounted(() => { onMounted(() => {
window.addEventListener('mediahive:gamepad-action', handleGamepadAction as EventListener); window.addEventListener("mediahive:gamepad-action", handleGamepadAction as EventListener)
}); })
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('mediahive:gamepad-action', handleGamepadAction as EventListener); window.removeEventListener("mediahive:gamepad-action", handleGamepadAction as EventListener)
}); })
</script> </script>
<style scoped> <style scoped>
@@ -675,9 +720,15 @@ onUnmounted(() => {
border-radius: 6px; border-radius: 6px;
} }
.rating-high { color: #46d369; } .rating-high {
.rating-medium { color: #f9a825; } color: #46d369;
.rating-low { color: #e53935; } }
.rating-medium {
color: #f9a825;
}
.rating-low {
color: #e53935;
}
.meta-item { .meta-item {
color: rgba(255, 255, 255, 0.8); color: rgba(255, 255, 255, 0.8);
@@ -825,7 +876,8 @@ html:not(.mouse-active) .episode-tile.nav-focused {
/* Blinking animation for focus outline */ /* Blinking animation for focus outline */
@keyframes tile-outline-blink { @keyframes tile-outline-blink {
0%, 100% { 0%,
100% {
opacity: 1; opacity: 1;
} }
50% { 50% {
@@ -1061,5 +1113,4 @@ html.mouse-active .episode-tile:hover .tile-play {
color: rgba(255, 255, 255, 0.5); color: rgba(255, 255, 255, 0.5);
font-size: 0.85rem; font-size: 0.85rem;
} }
</style> </style>
@@ -1,16 +1,16 @@
type GamepadAction = 'up' | 'down' | 'left' | 'right' | 'select' | 'back' | 'menu'; type GamepadAction = "up" | "down" | "left" | "right" | "select" | "back" | "menu"
const GAMEPAD_AXIS_THRESHOLD = 0.55; const GAMEPAD_AXIS_THRESHOLD = 0.55
const GAMEPAD_REPEAT_MS = 180; const GAMEPAD_REPEAT_MS = 180
const KEY_BY_ACTION: Partial<Record<GamepadAction, string>> = { const KEY_BY_ACTION: Partial<Record<GamepadAction, string>> = {
up: 'ArrowUp', up: "ArrowUp",
down: 'ArrowDown', down: "ArrowDown",
left: 'ArrowLeft', left: "ArrowLeft",
right: 'ArrowRight', right: "ArrowRight",
select: 'Enter', select: "Enter",
back: 'Escape', back: "Escape",
}; }
const gamepadPressedState: Record<GamepadAction, boolean> = { const gamepadPressedState: Record<GamepadAction, boolean> = {
up: false, up: false,
@@ -20,7 +20,7 @@ const gamepadPressedState: Record<GamepadAction, boolean> = {
select: false, select: false,
back: false, back: false,
menu: false, menu: false,
}; }
const gamepadLastTriggerAt: Record<GamepadAction, number> = { const gamepadLastTriggerAt: Record<GamepadAction, number> = {
up: 0, up: 0,
@@ -30,111 +30,115 @@ const gamepadLastTriggerAt: Record<GamepadAction, number> = {
select: 0, select: 0,
back: 0, back: 0,
menu: 0, menu: 0,
}; }
let gamepadFrameId: number | null = null; let gamepadFrameId: number | null = null
let gamepadInstalled = false; let gamepadInstalled = false
function dispatchKey(key: string) { function dispatchKey(key: string) {
const active = document.activeElement; const active = document.activeElement
const target = active instanceof HTMLElement ? active : document; const target = active instanceof HTMLElement ? active : document
target.dispatchEvent(new KeyboardEvent('keydown', { target.dispatchEvent(
key, new KeyboardEvent("keydown", {
bubbles: true, key,
cancelable: true, bubbles: true,
})); cancelable: true,
}),
)
} }
function applyGamepadAction(action: GamepadAction, isPressed: boolean, now: number) { function applyGamepadAction(action: GamepadAction, isPressed: boolean, now: number) {
const wasPressed = gamepadPressedState[action]; const wasPressed = gamepadPressedState[action]
gamepadPressedState[action] = isPressed; gamepadPressedState[action] = isPressed
if (!isPressed) return; if (!isPressed) return
const shouldRepeat = action === 'up' || action === 'down' || action === 'left' || action === 'right'; const shouldRepeat =
const canTrigger = !wasPressed || (shouldRepeat && now - gamepadLastTriggerAt[action] >= GAMEPAD_REPEAT_MS); action === "up" || action === "down" || action === "left" || action === "right"
if (!canTrigger) return; const canTrigger =
!wasPressed || (shouldRepeat && now - gamepadLastTriggerAt[action] >= GAMEPAD_REPEAT_MS)
if (!canTrigger) return
gamepadLastTriggerAt[action] = now; gamepadLastTriggerAt[action] = now
const actionEvent = new CustomEvent('mediahive:gamepad-action', { const actionEvent = new CustomEvent("mediahive:gamepad-action", {
detail: { action }, detail: { action },
cancelable: true, cancelable: true,
}); })
const shouldContinueWithKeyboard = window.dispatchEvent(actionEvent); const shouldContinueWithKeyboard = window.dispatchEvent(actionEvent)
if (!shouldContinueWithKeyboard) return; if (!shouldContinueWithKeyboard) return
const key = KEY_BY_ACTION[action]; const key = KEY_BY_ACTION[action]
if (key) { if (key) {
dispatchKey(key); dispatchKey(key)
} }
} }
function resetPressedState() { function resetPressedState() {
gamepadPressedState.up = false; gamepadPressedState.up = false
gamepadPressedState.down = false; gamepadPressedState.down = false
gamepadPressedState.left = false; gamepadPressedState.left = false
gamepadPressedState.right = false; gamepadPressedState.right = false
gamepadPressedState.select = false; gamepadPressedState.select = false
gamepadPressedState.back = false; gamepadPressedState.back = false
gamepadPressedState.menu = false; gamepadPressedState.menu = false
} }
function pollGamepad() { function pollGamepad() {
const gamepads = navigator.getGamepads?.() ?? []; const gamepads = navigator.getGamepads?.() ?? []
const now = performance.now(); const now = performance.now()
const connectedGamepads = gamepads.filter((gp): gp is Gamepad => Boolean(gp && gp.connected)); const connectedGamepads = gamepads.filter((gp): gp is Gamepad => Boolean(gp && gp.connected))
if (connectedGamepads.length > 0) { if (connectedGamepads.length > 0) {
let up = false; let up = false
let down = false; let down = false
let left = false; let left = false
let right = false; let right = false
let select = false; let select = false
let back = false; let back = false
let menu = false; let menu = false
for (const gamepad of connectedGamepads) { for (const gamepad of connectedGamepads) {
const axisX = gamepad.axes[0] ?? 0; const axisX = gamepad.axes[0] ?? 0
const axisY = gamepad.axes[1] ?? 0; const axisY = gamepad.axes[1] ?? 0
up = up || Boolean(gamepad.buttons[12]?.pressed) || axisY <= -GAMEPAD_AXIS_THRESHOLD; up = up || Boolean(gamepad.buttons[12]?.pressed) || axisY <= -GAMEPAD_AXIS_THRESHOLD
down = down || Boolean(gamepad.buttons[13]?.pressed) || axisY >= GAMEPAD_AXIS_THRESHOLD; down = down || Boolean(gamepad.buttons[13]?.pressed) || axisY >= GAMEPAD_AXIS_THRESHOLD
left = left || Boolean(gamepad.buttons[14]?.pressed) || axisX <= -GAMEPAD_AXIS_THRESHOLD; left = left || Boolean(gamepad.buttons[14]?.pressed) || axisX <= -GAMEPAD_AXIS_THRESHOLD
right = right || Boolean(gamepad.buttons[15]?.pressed) || axisX >= GAMEPAD_AXIS_THRESHOLD; right = right || Boolean(gamepad.buttons[15]?.pressed) || axisX >= GAMEPAD_AXIS_THRESHOLD
// Xbox mapping on standard gamepads: A=0, B=1 // Xbox mapping on standard gamepads: A=0, B=1
select = select || Boolean(gamepad.buttons[0]?.pressed); select = select || Boolean(gamepad.buttons[0]?.pressed)
back = back || Boolean(gamepad.buttons[1]?.pressed); back = back || Boolean(gamepad.buttons[1]?.pressed)
// Y/Triangle button opens contextual release list where supported. // Y/Triangle button opens contextual release list where supported.
menu = menu || Boolean(gamepad.buttons[3]?.pressed); menu = menu || Boolean(gamepad.buttons[3]?.pressed)
} }
applyGamepadAction('up', up, now); applyGamepadAction("up", up, now)
applyGamepadAction('down', down, now); applyGamepadAction("down", down, now)
applyGamepadAction('left', left, now); applyGamepadAction("left", left, now)
applyGamepadAction('right', right, now); applyGamepadAction("right", right, now)
applyGamepadAction('select', select, now); applyGamepadAction("select", select, now)
applyGamepadAction('back', back, now); applyGamepadAction("back", back, now)
applyGamepadAction('menu', menu, now); applyGamepadAction("menu", menu, now)
} else { } else {
resetPressedState(); resetPressedState()
} }
gamepadFrameId = window.requestAnimationFrame(pollGamepad); gamepadFrameId = window.requestAnimationFrame(pollGamepad)
} }
export function installGamepadNavigation() { export function installGamepadNavigation() {
if (gamepadInstalled) return; if (gamepadInstalled) return
gamepadInstalled = true; gamepadInstalled = true
gamepadFrameId = window.requestAnimationFrame(pollGamepad); gamepadFrameId = window.requestAnimationFrame(pollGamepad)
} }
export function uninstallGamepadNavigation() { export function uninstallGamepadNavigation() {
if (!gamepadInstalled) return; if (!gamepadInstalled) return
gamepadInstalled = false; gamepadInstalled = false
if (gamepadFrameId !== null) { if (gamepadFrameId !== null) {
window.cancelAnimationFrame(gamepadFrameId); window.cancelAnimationFrame(gamepadFrameId)
gamepadFrameId = null; gamepadFrameId = null
} }
resetPressedState(); resetPressedState()
} }
+81 -70
View File
@@ -1,135 +1,146 @@
type InputModality = 'mouse' | 'keyboard' | 'gamepad'; type InputModality = "mouse" | "keyboard" | "gamepad"
const MOUSE_IDLE_MS = 1400; const MOUSE_IDLE_MS = 1400
const MOUSE_INTENT_DISTANCE_PX = 28; const MOUSE_INTENT_DISTANCE_PX = 28
const MOUSE_INTENT_WINDOW_MS = 700; const MOUSE_INTENT_WINDOW_MS = 700
const MOUSE_INTENT_SELECTOR = [ const MOUSE_INTENT_SELECTOR = [
'[data-nav-focusable]','button','a[href]','input','select','textarea','[role="button"]', "[data-nav-focusable]",
'.media-card','.collage-item','.episode-tile','.version-row','.ctx-btn','.header-nav-item', "button",
].join(','); "a[href]",
"input",
"select",
"textarea",
'[role="button"]',
".media-card",
".collage-item",
".episode-tile",
".version-row",
".ctx-btn",
".header-nav-item",
].join(",")
let installed = false; let installed = false
let modality: InputModality = 'mouse'; let modality: InputModality = "mouse"
let mouseIdleTimer: number | null = null; let mouseIdleTimer: number | null = null
let pointerVisible = false; let pointerVisible = false
let mouseTravelPx = 0; let mouseTravelPx = 0
let lastMouseMoveAt = 0; let lastMouseMoveAt = 0
function clearMouseIdleTimer() { function clearMouseIdleTimer() {
if (mouseIdleTimer !== null) { if (mouseIdleTimer !== null) {
window.clearTimeout(mouseIdleTimer); window.clearTimeout(mouseIdleTimer)
mouseIdleTimer = null; mouseIdleTimer = null
} }
} }
function applyInputState(mouseActive: boolean) { function applyInputState(mouseActive: boolean) {
const root = document.documentElement; const root = document.documentElement
root.classList.toggle('mouse-active', mouseActive); root.classList.toggle("mouse-active", mouseActive)
root.classList.toggle('pointer-visible', pointerVisible); root.classList.toggle("pointer-visible", pointerVisible)
} }
function scheduleMouseIdle() { function scheduleMouseIdle() {
clearMouseIdleTimer(); clearMouseIdleTimer()
mouseIdleTimer = window.setTimeout(() => { mouseIdleTimer = window.setTimeout(() => {
pointerVisible = false; pointerVisible = false
applyInputState(false); applyInputState(false)
}, MOUSE_IDLE_MS); }, MOUSE_IDLE_MS)
} }
function activateMouseInput() { function activateMouseInput() {
modality = 'mouse'; modality = "mouse"
pointerVisible = true; pointerVisible = true
applyInputState(true); applyInputState(true)
scheduleMouseIdle(); scheduleMouseIdle()
} }
function activateNonMouseInput(next: InputModality) { function activateNonMouseInput(next: InputModality) {
modality = next; modality = next
pointerVisible = false; pointerVisible = false
mouseTravelPx = 0; mouseTravelPx = 0
clearMouseIdleTimer(); clearMouseIdleTimer()
applyInputState(false); applyInputState(false)
} }
function showPointerFromMotion() { function showPointerFromMotion() {
pointerVisible = true; pointerVisible = true
applyInputState(modality === 'mouse'); applyInputState(modality === "mouse")
scheduleMouseIdle(); scheduleMouseIdle()
} }
function isMouseIntentTarget(target: EventTarget | null): boolean { function isMouseIntentTarget(target: EventTarget | null): boolean {
if (!(target instanceof Element)) return false; if (!(target instanceof Element)) return false
return Boolean(target.closest(MOUSE_INTENT_SELECTOR)); return Boolean(target.closest(MOUSE_INTENT_SELECTOR))
} }
function registerMouseIntentTravel(event: MouseEvent): boolean { function registerMouseIntentTravel(event: MouseEvent): boolean {
const now = performance.now(); const now = performance.now()
if (now - lastMouseMoveAt > MOUSE_INTENT_WINDOW_MS) { if (now - lastMouseMoveAt > MOUSE_INTENT_WINDOW_MS) {
mouseTravelPx = 0; mouseTravelPx = 0
} }
lastMouseMoveAt = now; lastMouseMoveAt = now
const step = Math.hypot(event.movementX || 0, event.movementY || 0); const step = Math.hypot(event.movementX || 0, event.movementY || 0)
mouseTravelPx += step; mouseTravelPx += step
if (mouseTravelPx >= MOUSE_INTENT_DISTANCE_PX) { if (mouseTravelPx >= MOUSE_INTENT_DISTANCE_PX) {
mouseTravelPx = 0; mouseTravelPx = 0
return true; return true
} }
return false; return false
} }
function handleMouseMove(event: MouseEvent) { function handleMouseMove(event: MouseEvent) {
showPointerFromMotion(); showPointerFromMotion()
if (modality === 'mouse') { if (modality === "mouse") {
applyInputState(true); applyInputState(true)
return; return
} }
if (!isMouseIntentTarget(event.target)) return; if (!isMouseIntentTarget(event.target)) return
if (registerMouseIntentTravel(event)) { if (registerMouseIntentTravel(event)) {
activateMouseInput(); activateMouseInput()
} }
} }
function handleMouseOver(event: MouseEvent) { function handleMouseOver(event: MouseEvent) {
if (modality === 'mouse') return; if (modality === "mouse") return
if (!isMouseIntentTarget(event.target)) return; if (!isMouseIntentTarget(event.target)) return
// Entering an interactive target indicates likely mouse intent. // Entering an interactive target indicates likely mouse intent.
activateMouseInput(); activateMouseInput()
} }
function handleMouseIntentAction(event: MouseEvent | WheelEvent) { function handleMouseIntentAction(event: MouseEvent | WheelEvent) {
pointerVisible = true; pointerVisible = true
if (isMouseIntentTarget(event.target)) { if (isMouseIntentTarget(event.target)) {
activateMouseInput(); activateMouseInput()
return; return
} }
applyInputState(modality === 'mouse'); applyInputState(modality === "mouse")
scheduleMouseIdle(); scheduleMouseIdle()
} }
function handleKeyboardActivity(event: KeyboardEvent) { function handleKeyboardActivity(event: KeyboardEvent) {
if (event.metaKey || event.ctrlKey || event.altKey) return; if (event.metaKey || event.ctrlKey || event.altKey) return
activateNonMouseInput('keyboard'); activateNonMouseInput("keyboard")
} }
function handleGamepadActivity() { function handleGamepadActivity() {
activateNonMouseInput('gamepad'); activateNonMouseInput("gamepad")
} }
export function installInputModalityTracking() { export function installInputModalityTracking() {
if (installed) return; if (installed) return
installed = true; installed = true
applyInputState(false); applyInputState(false)
window.addEventListener('mousemove', handleMouseMove, { passive: true }); window.addEventListener("mousemove", handleMouseMove, { passive: true })
window.addEventListener('mouseover', handleMouseOver, { passive: true }); window.addEventListener("mouseover", handleMouseOver, { passive: true })
window.addEventListener('mousedown', handleMouseIntentAction, { passive: true }); window.addEventListener("mousedown", handleMouseIntentAction, { passive: true })
window.addEventListener('wheel', handleMouseIntentAction, { passive: true }); window.addEventListener("wheel", handleMouseIntentAction, { passive: true })
window.addEventListener('keydown', handleKeyboardActivity, { passive: true }); window.addEventListener("keydown", handleKeyboardActivity, { passive: true })
window.addEventListener('mediahive:gamepad-action', handleGamepadActivity as EventListener); window.addEventListener("mediahive:gamepad-action", handleGamepadActivity as EventListener)
} }
+305 -299
View File
@@ -1,60 +1,58 @@
import { ref } from 'vue'; import { ref } from "vue"
export interface FocusableElement { export interface FocusableElement {
element: HTMLElement; element: HTMLElement
row: number; row: number
col: number; col: number
} }
// Global focus state // Global focus state
const focusedElement = ref<HTMLElement | null>(null); const focusedElement = ref<HTMLElement | null>(null)
const isNavigating = ref(false); const isNavigating = ref(false)
// Track the "desired" column when moving vertically (to maintain column position across rows of different lengths) // Track the "desired" column when moving vertically (to maintain column position across rows of different lengths)
const desiredCol = ref<number | null>(null); const desiredCol = ref<number | null>(null)
// Track if global handlers are installed // Track if global handlers are installed
let handlersInstalled = false; let handlersInstalled = false
// Data attribute names // Data attribute names
const FOCUSABLE_ATTR = 'data-nav-focusable'; const FOCUSABLE_ATTR = "data-nav-focusable"
const ROW_ATTR = 'data-nav-row'; const ROW_ATTR = "data-nav-row"
const COL_ATTR = 'data-nav-col'; const COL_ATTR = "data-nav-col"
const ENTRY_COL_ATTR = 'data-nav-entry-col'; const ENTRY_COL_ATTR = "data-nav-entry-col"
const SYNC_SCROLL_ROW_ATTR = 'data-sync-scroll-row'; const SYNC_SCROLL_ROW_ATTR = "data-sync-scroll-row"
const SYNC_SCROLL_FIRST_CONTENT_ROW = 2; const SYNC_SCROLL_FIRST_CONTENT_ROW = 2
const SYNC_SCROLL_DEADZONE_RATIO = 0.18; const SYNC_SCROLL_DEADZONE_RATIO = 0.18
const SYNC_SCROLL_EASING_MS = 220; const SYNC_SCROLL_EASING_MS = 220
const SYNC_SCROLL_TAIL_VAR = '--sync-row-tail'; const SYNC_SCROLL_TAIL_VAR = "--sync-row-tail"
let syncedRowsFrame: number | null = null; let syncedRowsFrame: number | null = null
let syncedRowsCurrentOffset = 0; let syncedRowsCurrentOffset = 0
let syncedRowsTargetOffset = 0; let syncedRowsTargetOffset = 0
let syncedRowsTailPx = 0; let syncedRowsTailPx = 0
let lastSyncedAnchorCol: number | null = null; let lastSyncedAnchorCol: number | null = null
let lastSyncedRowsAnimationAt: number | null = null; let lastSyncedRowsAnimationAt: number | null = null
function getSyncedRows(): HTMLElement[] { function getSyncedRows(): HTMLElement[] {
return Array.from( return Array.from(document.querySelectorAll<HTMLElement>(`[${SYNC_SCROLL_ROW_ATTR}="true"]`))
document.querySelectorAll<HTMLElement>(`[${SYNC_SCROLL_ROW_ATTR}="true"]`)
);
} }
function getSyncedRowMetrics(rows: HTMLElement[]) { function getSyncedRowMetrics(rows: HTMLElement[]) {
for (const row of rows) { for (const row of rows) {
const cards = Array.from(row.querySelectorAll<HTMLElement>(`.media-card[${FOCUSABLE_ATTR}]`)); const cards = Array.from(row.querySelectorAll<HTMLElement>(`.media-card[${FOCUSABLE_ATTR}]`))
if (cards.length === 0) continue; if (cards.length === 0) continue
const firstRect = cards[0].getBoundingClientRect(); const firstRect = cards[0].getBoundingClientRect()
const cardWidth = firstRect.width; const cardWidth = firstRect.width
if (cardWidth <= 0) continue; if (cardWidth <= 0) continue
const rowStyle = window.getComputedStyle(row); const rowStyle = window.getComputedStyle(row)
const paddingLeft = parseFloat(rowStyle.paddingLeft || '0'); const paddingLeft = parseFloat(rowStyle.paddingLeft || "0")
let gap = parseFloat(rowStyle.columnGap || rowStyle.gap || '0'); let gap = parseFloat(rowStyle.columnGap || rowStyle.gap || "0")
if (cards.length > 1) { if (cards.length > 1) {
const secondRect = cards[1].getBoundingClientRect(); const secondRect = cards[1].getBoundingClientRect()
gap = Math.max(0, secondRect.left - firstRect.left - cardWidth); gap = Math.max(0, secondRect.left - firstRect.left - cardWidth)
} }
return { return {
@@ -62,211 +60,213 @@ function getSyncedRowMetrics(rows: HTMLElement[]) {
stride: cardWidth + gap, stride: cardWidth + gap,
paddingLeft, paddingLeft,
viewportWidth: row.clientWidth, viewportWidth: row.clientWidth,
}; }
} }
return null; return null
} }
function clampRowScrollOffset(row: HTMLElement, offset: number): number { function clampRowScrollOffset(row: HTMLElement, offset: number): number {
const maxOffset = Math.max(0, row.scrollWidth - row.clientWidth); const maxOffset = Math.max(0, row.scrollWidth - row.clientWidth)
return Math.min(Math.max(offset, 0), maxOffset); return Math.min(Math.max(offset, 0), maxOffset)
} }
function getRowMaxOffset(row: HTMLElement): number { function getRowMaxOffset(row: HTMLElement): number {
return Math.max(0, row.scrollWidth - row.clientWidth); return Math.max(0, row.scrollWidth - row.clientWidth)
} }
function getRowNaturalMaxOffset(row: HTMLElement): number { function getRowNaturalMaxOffset(row: HTMLElement): number {
return Math.max(0, getRowMaxOffset(row) - syncedRowsTailPx); return Math.max(0, getRowMaxOffset(row) - syncedRowsTailPx)
} }
function getTailNeededForOffset(offset: number, rows: HTMLElement[]): number { function getTailNeededForOffset(offset: number, rows: HTMLElement[]): number {
if (rows.length === 0) return 0; if (rows.length === 0) return 0
let minNaturalMax = Number.POSITIVE_INFINITY; let minNaturalMax = Number.POSITIVE_INFINITY
for (const row of rows) { for (const row of rows) {
minNaturalMax = Math.min(minNaturalMax, getRowNaturalMaxOffset(row)); minNaturalMax = Math.min(minNaturalMax, getRowNaturalMaxOffset(row))
} }
if (!Number.isFinite(minNaturalMax)) return 0; if (!Number.isFinite(minNaturalMax)) return 0
return Math.max(0, offset - minNaturalMax); return Math.max(0, offset - minNaturalMax)
} }
function setSyncedRowsTail(tailPx: number, rows: HTMLElement[] = getSyncedRows()) { function setSyncedRowsTail(tailPx: number, rows: HTMLElement[] = getSyncedRows()) {
const nextTail = Math.max(0, tailPx); const nextTail = Math.max(0, tailPx)
syncedRowsTailPx = nextTail; syncedRowsTailPx = nextTail
for (const row of rows) { for (const row of rows) {
row.style.setProperty(SYNC_SCROLL_TAIL_VAR, `${nextTail}px`); row.style.setProperty(SYNC_SCROLL_TAIL_VAR, `${nextTail}px`)
} }
} }
function applySyncedRowScroll(offset: number, rows: HTMLElement[] = getSyncedRows()) { function applySyncedRowScroll(offset: number, rows: HTMLElement[] = getSyncedRows()) {
for (const row of rows) { for (const row of rows) {
row.scrollLeft = clampRowScrollOffset(row, offset); row.scrollLeft = clampRowScrollOffset(row, offset)
} }
} }
function resetSyncedRows(immediate: boolean = false) { function resetSyncedRows(immediate: boolean = false) {
lastSyncedAnchorCol = null; lastSyncedAnchorCol = null
syncedRowsTargetOffset = 0; syncedRowsTargetOffset = 0
setSyncedRowsTail(0); setSyncedRowsTail(0)
if (immediate) { if (immediate) {
syncedRowsCurrentOffset = 0; syncedRowsCurrentOffset = 0
applySyncedRowScroll(0); applySyncedRowScroll(0)
lastSyncedRowsAnimationAt = null; lastSyncedRowsAnimationAt = null
stopSyncedRowAnimation(); stopSyncedRowAnimation()
return; return
} }
if (Math.abs(syncedRowsCurrentOffset) < 0.5) { if (Math.abs(syncedRowsCurrentOffset) < 0.5) {
syncedRowsCurrentOffset = 0; syncedRowsCurrentOffset = 0
applySyncedRowScroll(0); applySyncedRowScroll(0)
lastSyncedRowsAnimationAt = null; lastSyncedRowsAnimationAt = null
stopSyncedRowAnimation(); stopSyncedRowAnimation()
return; return
} }
if (syncedRowsFrame === null) { if (syncedRowsFrame === null) {
syncedRowsFrame = window.requestAnimationFrame(animateSyncedRows); syncedRowsFrame = window.requestAnimationFrame(animateSyncedRows)
} }
} }
function stopSyncedRowAnimation() { function stopSyncedRowAnimation() {
if (syncedRowsFrame !== null) { if (syncedRowsFrame !== null) {
window.cancelAnimationFrame(syncedRowsFrame); window.cancelAnimationFrame(syncedRowsFrame)
syncedRowsFrame = null; syncedRowsFrame = null
} }
lastSyncedRowsAnimationAt = null; lastSyncedRowsAnimationAt = null
} }
function animateSyncedRows(now: number) { function animateSyncedRows(now: number) {
const rows = getSyncedRows(); const rows = getSyncedRows()
if (rows.length === 0) { if (rows.length === 0) {
stopSyncedRowAnimation(); stopSyncedRowAnimation()
return; return
} }
const delta = syncedRowsTargetOffset - syncedRowsCurrentOffset; const delta = syncedRowsTargetOffset - syncedRowsCurrentOffset
const elapsedMs = lastSyncedRowsAnimationAt === null ? 16 : Math.max(1, now - lastSyncedRowsAnimationAt); const elapsedMs =
lastSyncedRowsAnimationAt = now; lastSyncedRowsAnimationAt === null ? 16 : Math.max(1, now - lastSyncedRowsAnimationAt)
lastSyncedRowsAnimationAt = now
const alpha = 1 - Math.exp(-elapsedMs / SYNC_SCROLL_EASING_MS); const alpha = 1 - Math.exp(-elapsedMs / SYNC_SCROLL_EASING_MS)
syncedRowsCurrentOffset += delta * alpha; syncedRowsCurrentOffset += delta * alpha
applySyncedRowScroll(syncedRowsCurrentOffset, rows); applySyncedRowScroll(syncedRowsCurrentOffset, rows)
if (Math.abs(delta) < 0.5) { if (Math.abs(delta) < 0.5) {
syncedRowsCurrentOffset = syncedRowsTargetOffset; syncedRowsCurrentOffset = syncedRowsTargetOffset
applySyncedRowScroll(syncedRowsCurrentOffset, rows); applySyncedRowScroll(syncedRowsCurrentOffset, rows)
stopSyncedRowAnimation(); stopSyncedRowAnimation()
return; return
} }
syncedRowsFrame = window.requestAnimationFrame(animateSyncedRows); syncedRowsFrame = window.requestAnimationFrame(animateSyncedRows)
} }
function updateSyncedRowTarget(anchorCol: number, anchorRow: HTMLElement | null = null) { function updateSyncedRowTarget(anchorCol: number, anchorRow: HTMLElement | null = null) {
const rows = getSyncedRows(); const rows = getSyncedRows()
if (rows.length === 0) return; if (rows.length === 0) return
const metrics = getSyncedRowMetrics(rows); const metrics = getSyncedRowMetrics(rows)
if (!metrics) return; if (!metrics) return
const currentOffset = syncedRowsCurrentOffset; const currentOffset = syncedRowsCurrentOffset
const effectiveAnchorOffset = anchorRow const effectiveAnchorOffset = anchorRow
? clampRowScrollOffset(anchorRow, currentOffset) ? clampRowScrollOffset(anchorRow, currentOffset)
: currentOffset; : currentOffset
const deadzoneInset = Math.max( const deadzoneInset = Math.max(
metrics.paddingLeft, metrics.paddingLeft,
(metrics.viewportWidth - metrics.cardWidth) * SYNC_SCROLL_DEADZONE_RATIO, (metrics.viewportWidth - metrics.cardWidth) * SYNC_SCROLL_DEADZONE_RATIO,
); )
const minVisibleLeft = deadzoneInset; const minVisibleLeft = deadzoneInset
const maxVisibleLeft = Math.max( const maxVisibleLeft = Math.max(
minVisibleLeft, minVisibleLeft,
metrics.viewportWidth - metrics.cardWidth - deadzoneInset, metrics.viewportWidth - metrics.cardWidth - deadzoneInset,
); )
const itemLeft = metrics.paddingLeft + anchorCol * metrics.stride; const itemLeft = metrics.paddingLeft + anchorCol * metrics.stride
const viewportLeft = itemLeft - effectiveAnchorOffset; const viewportLeft = itemLeft - effectiveAnchorOffset
const desiredOffset = viewportLeft < minVisibleLeft const desiredOffset =
? Math.max(0, itemLeft - minVisibleLeft) viewportLeft < minVisibleLeft
: (viewportLeft > maxVisibleLeft ? Math.max(0, itemLeft - minVisibleLeft)
? Math.max(0, itemLeft - maxVisibleLeft) : viewportLeft > maxVisibleLeft
: currentOffset); ? Math.max(0, itemLeft - maxVisibleLeft)
: currentOffset
const neededTail = getTailNeededForOffset(desiredOffset, rows); const neededTail = getTailNeededForOffset(desiredOffset, rows)
if (Math.abs(neededTail - syncedRowsTailPx) >= 0.5) { if (Math.abs(neededTail - syncedRowsTailPx) >= 0.5) {
setSyncedRowsTail(neededTail, rows); setSyncedRowsTail(neededTail, rows)
} }
lastSyncedAnchorCol = anchorCol; lastSyncedAnchorCol = anchorCol
syncedRowsTargetOffset = desiredOffset; syncedRowsTargetOffset = desiredOffset
if (anchorRow) { if (anchorRow) {
// Preserve a global virtual offset, bounded by the focused row after tail-space is applied. // Preserve a global virtual offset, bounded by the focused row after tail-space is applied.
syncedRowsTargetOffset = Math.min(syncedRowsTargetOffset, getRowMaxOffset(anchorRow)); syncedRowsTargetOffset = Math.min(syncedRowsTargetOffset, getRowMaxOffset(anchorRow))
} else if (syncedRowsTailPx > 0) { } else if (syncedRowsTailPx > 0) {
setSyncedRowsTail(0, rows); setSyncedRowsTail(0, rows)
} }
if (syncedRowsFrame === null) { if (syncedRowsFrame === null) {
syncedRowsCurrentOffset = currentOffset; syncedRowsCurrentOffset = currentOffset
} }
if (Math.abs(syncedRowsTargetOffset - syncedRowsCurrentOffset) < 0.5) { if (Math.abs(syncedRowsTargetOffset - syncedRowsCurrentOffset) < 0.5) {
syncedRowsCurrentOffset = syncedRowsTargetOffset; syncedRowsCurrentOffset = syncedRowsTargetOffset
applySyncedRowScroll(syncedRowsCurrentOffset, rows); applySyncedRowScroll(syncedRowsCurrentOffset, rows)
stopSyncedRowAnimation(); stopSyncedRowAnimation()
return; return
} }
if (syncedRowsFrame === null) { if (syncedRowsFrame === null) {
syncedRowsFrame = window.requestAnimationFrame(animateSyncedRows); syncedRowsFrame = window.requestAnimationFrame(animateSyncedRows)
} }
} }
function syncRowsToElement(element: HTMLElement) { function syncRowsToElement(element: HTMLElement) {
const row = parseInt(element.getAttribute(ROW_ATTR) || '0', 10); const row = parseInt(element.getAttribute(ROW_ATTR) || "0", 10)
if (row < SYNC_SCROLL_FIRST_CONTENT_ROW) { if (row < SYNC_SCROLL_FIRST_CONTENT_ROW) {
resetSyncedRows(); resetSyncedRows()
return; return
} }
const currentCol = parseInt(element.getAttribute(COL_ATTR) || '0', 10); const currentCol = parseInt(element.getAttribute(COL_ATTR) || "0", 10)
const anchorCol = desiredCol.value ?? currentCol; const anchorCol = desiredCol.value ?? currentCol
const anchorRow = element.closest<HTMLElement>(`[${SYNC_SCROLL_ROW_ATTR}="true"]`); const anchorRow = element.closest<HTMLElement>(`[${SYNC_SCROLL_ROW_ATTR}="true"]`)
updateSyncedRowTarget(anchorCol, anchorRow); updateSyncedRowTarget(anchorCol, anchorRow)
} }
function handleSyncedRowResize() { function handleSyncedRowResize() {
if (lastSyncedAnchorCol === null) { if (lastSyncedAnchorCol === null) {
resetSyncedRows(true); resetSyncedRows(true)
return; return
} }
updateSyncedRowTarget(lastSyncedAnchorCol); updateSyncedRowTarget(lastSyncedAnchorCol)
} }
function ensureElementVisibleVertically(element: HTMLElement) { function ensureElementVisibleVertically(element: HTMLElement) {
const rootStyle = window.getComputedStyle(document.documentElement); const rootStyle = window.getComputedStyle(document.documentElement)
const headerHeight = parseFloat(rootStyle.getPropertyValue('--header-height') || '0'); const headerHeight = parseFloat(rootStyle.getPropertyValue("--header-height") || "0")
const topMargin = headerHeight + 24; const topMargin = headerHeight + 24
const bottomMargin = 24; const bottomMargin = 24
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect()
if (rect.top < topMargin) { if (rect.top < topMargin) {
window.scrollBy({ window.scrollBy({
top: rect.top - topMargin, top: rect.top - topMargin,
behavior: 'smooth', behavior: "smooth",
}); })
return; return
} }
if (rect.bottom > window.innerHeight - bottomMargin) { if (rect.bottom > window.innerHeight - bottomMargin) {
window.scrollBy({ window.scrollBy({
top: rect.bottom - (window.innerHeight - bottomMargin), top: rect.bottom - (window.innerHeight - bottomMargin),
behavior: 'smooth', behavior: "smooth",
}); })
} }
} }
@@ -274,91 +274,95 @@ function ensureElementVisibleVertically(element: HTMLElement) {
* Get all focusable elements in the DOM, grouped by row * Get all focusable elements in the DOM, grouped by row
*/ */
function getFocusableElements(): FocusableElement[] { function getFocusableElements(): FocusableElement[] {
const elements = document.querySelectorAll(`[${FOCUSABLE_ATTR}]`); const elements = document.querySelectorAll(`[${FOCUSABLE_ATTR}]`)
const result: FocusableElement[] = []; const result: FocusableElement[] = []
elements.forEach((el) => { elements.forEach((el) => {
const htmlEl = el as HTMLElement; const htmlEl = el as HTMLElement
// Skip hidden elements // Skip hidden elements
if (htmlEl.offsetParent === null) return; if (htmlEl.offsetParent === null) return
const rect = htmlEl.getBoundingClientRect(); const rect = htmlEl.getBoundingClientRect()
// Skip elements not in viewport or zero-sized // Skip elements not in viewport or zero-sized
if (rect.width === 0 || rect.height === 0) return; if (rect.width === 0 || rect.height === 0) return
const row = parseInt(htmlEl.getAttribute(ROW_ATTR) || '0', 10); const row = parseInt(htmlEl.getAttribute(ROW_ATTR) || "0", 10)
const col = parseInt(htmlEl.getAttribute(COL_ATTR) || '0', 10); const col = parseInt(htmlEl.getAttribute(COL_ATTR) || "0", 10)
result.push({ result.push({
element: htmlEl, element: htmlEl,
row, row,
col, col,
}); })
}); })
return result; return result
} }
/** /**
* Get elements grouped by row * Get elements grouped by row
*/ */
function getElementsByRow(): Map<number, FocusableElement[]> { function getElementsByRow(): Map<number, FocusableElement[]> {
const elements = getFocusableElements(); const elements = getFocusableElements()
const byRow = new Map<number, FocusableElement[]>(); const byRow = new Map<number, FocusableElement[]>()
for (const el of elements) { for (const el of elements) {
if (!byRow.has(el.row)) { if (!byRow.has(el.row)) {
byRow.set(el.row, []); byRow.set(el.row, [])
} }
byRow.get(el.row)!.push(el); byRow.get(el.row)!.push(el)
} }
// Sort each row by column // Sort each row by column
for (const [, rowElements] of byRow) { for (const [, rowElements] of byRow) {
rowElements.sort((a, b) => a.col - b.col); rowElements.sort((a, b) => a.col - b.col)
} }
return byRow; return byRow
} }
/** /**
* Find element by row and col indices * Find element by row and col indices
* @param useEntryCol - if true, check for entry-col override on elements * @param useEntryCol - if true, check for entry-col override on elements
*/ */
function findElementAt(row: number, col: number, useEntryCol: boolean = false): FocusableElement | null { function findElementAt(
const byRow = getElementsByRow(); row: number,
const rowElements = byRow.get(row); col: number,
if (!rowElements || rowElements.length === 0) return null; useEntryCol: boolean = false,
): FocusableElement | null {
const byRow = getElementsByRow()
const rowElements = byRow.get(row)
if (!rowElements || rowElements.length === 0) return null
// Check if any element in this row has an entry-col override // Check if any element in this row has an entry-col override
if (useEntryCol) { if (useEntryCol) {
for (const el of rowElements) { for (const el of rowElements) {
const entryCol = el.element.getAttribute(ENTRY_COL_ATTR); const entryCol = el.element.getAttribute(ENTRY_COL_ATTR)
if (entryCol !== null) { if (entryCol !== null) {
const overrideCol = parseInt(entryCol, 10); const overrideCol = parseInt(entryCol, 10)
const entryTarget = rowElements.find(e => e.col === overrideCol); const entryTarget = rowElements.find((e) => e.col === overrideCol)
if (entryTarget) return entryTarget; if (entryTarget) return entryTarget
} }
} }
} }
// Find exact match or nearest col // Find exact match or nearest col
const exact = rowElements.find(e => e.col === col); const exact = rowElements.find((e) => e.col === col)
if (exact) return exact; if (exact) return exact
// Find nearest col in this row // Find nearest col in this row
let nearest = rowElements[0]; let nearest = rowElements[0]
let nearestDist = Math.abs(nearest.col - col); let nearestDist = Math.abs(nearest.col - col)
for (const el of rowElements) { for (const el of rowElements) {
const dist = Math.abs(el.col - col); const dist = Math.abs(el.col - col)
if (dist < nearestDist) { if (dist < nearestDist) {
nearest = el; nearest = el
nearestDist = dist; nearestDist = dist
} }
} }
return nearest; return nearest
} }
function findElementClosestToLogicalViewportX( function findElementClosestToLogicalViewportX(
@@ -367,43 +371,42 @@ function findElementClosestToLogicalViewportX(
preferredCol: number, preferredCol: number,
metrics: { cardWidth: number; stride: number; paddingLeft: number } | null, metrics: { cardWidth: number; stride: number; paddingLeft: number } | null,
): FocusableElement | null { ): FocusableElement | null {
const byRow = getElementsByRow(); const byRow = getElementsByRow()
const rowElements = byRow.get(row); const rowElements = byRow.get(row)
if (!rowElements || rowElements.length === 0) return null; if (!rowElements || rowElements.length === 0) return null
let nearest: FocusableElement | null = null; let nearest: FocusableElement | null = null
let nearestViewportDist = Number.POSITIVE_INFINITY; let nearestViewportDist = Number.POSITIVE_INFINITY
let nearestColDist = Number.POSITIVE_INFINITY; let nearestColDist = Number.POSITIVE_INFINITY
for (const candidate of rowElements) { for (const candidate of rowElements) {
let candidateCenterX: number; let candidateCenterX: number
if (metrics) { if (metrics) {
// Compare using global synced offset so capped rows do not skew vertical matching. // Compare using global synced offset so capped rows do not skew vertical matching.
candidateCenterX = ( candidateCenterX =
metrics.paddingLeft metrics.paddingLeft +
+ candidate.col * metrics.stride candidate.col * metrics.stride -
- syncedRowsCurrentOffset syncedRowsCurrentOffset +
+ metrics.cardWidth / 2 metrics.cardWidth / 2
);
} else { } else {
const rect = candidate.element.getBoundingClientRect(); const rect = candidate.element.getBoundingClientRect()
candidateCenterX = rect.left + rect.width / 2; candidateCenterX = rect.left + rect.width / 2
} }
const viewportDist = Math.abs(candidateCenterX - logicalViewportCenterX); const viewportDist = Math.abs(candidateCenterX - logicalViewportCenterX)
const colDist = Math.abs(candidate.col - preferredCol); const colDist = Math.abs(candidate.col - preferredCol)
if ( if (
viewportDist < nearestViewportDist viewportDist < nearestViewportDist ||
|| (Math.abs(viewportDist - nearestViewportDist) < 0.5 && colDist < nearestColDist) (Math.abs(viewportDist - nearestViewportDist) < 0.5 && colDist < nearestColDist)
) { ) {
nearest = candidate; nearest = candidate
nearestViewportDist = viewportDist; nearestViewportDist = viewportDist
nearestColDist = colDist; nearestColDist = colDist
} }
} }
return nearest; return nearest
} }
/** /**
@@ -411,70 +414,73 @@ function findElementClosestToLogicalViewportX(
*/ */
function findNextElement( function findNextElement(
current: HTMLElement, current: HTMLElement,
direction: 'up' | 'down' | 'left' | 'right' direction: "up" | "down" | "left" | "right",
): HTMLElement | null { ): HTMLElement | null {
const currentRow = parseInt(current.getAttribute(ROW_ATTR) || '0', 10); const currentRow = parseInt(current.getAttribute(ROW_ATTR) || "0", 10)
const currentCol = parseInt(current.getAttribute(COL_ATTR) || '0', 10); const currentCol = parseInt(current.getAttribute(COL_ATTR) || "0", 10)
const byRow = getElementsByRow(); const byRow = getElementsByRow()
if (direction === 'left' || direction === 'right') { if (direction === "left" || direction === "right") {
// Horizontal: move within same row by col index // Horizontal: move within same row by col index
desiredCol.value = null; // Reset desired col on horizontal movement desiredCol.value = null // Reset desired col on horizontal movement
const rowElements = byRow.get(currentRow); const rowElements = byRow.get(currentRow)
if (!rowElements) return null; if (!rowElements) return null
const delta = direction === 'right' ? 1 : -1; const delta = direction === "right" ? 1 : -1
const targetCol = currentCol + delta; const targetCol = currentCol + delta
// Find element with target col in this row // Find element with target col in this row
const target = rowElements.find(e => e.col === targetCol); const target = rowElements.find((e) => e.col === targetCol)
return target?.element || null; return target?.element || null
} else { } else {
// Vertical: move to adjacent row, try to maintain column // Vertical: move to adjacent row, try to maintain column
const sortedRows = Array.from(byRow.keys()).sort((a, b) => a - b); const sortedRows = Array.from(byRow.keys()).sort((a, b) => a - b)
const currentRowIdx = sortedRows.indexOf(currentRow); const currentRowIdx = sortedRows.indexOf(currentRow)
if (currentRowIdx === -1) return null; if (currentRowIdx === -1) return null
const delta = direction === 'down' ? 1 : -1; const delta = direction === "down" ? 1 : -1
const targetRowIdx = currentRowIdx + delta; const targetRowIdx = currentRowIdx + delta
if (targetRowIdx < 0 || targetRowIdx >= sortedRows.length) return null; if (targetRowIdx < 0 || targetRowIdx >= sortedRows.length) return null
const targetRow = sortedRows[targetRowIdx]; const targetRow = sortedRows[targetRowIdx]
// Use desired col if set, otherwise use current col // Use desired col if set, otherwise use current col
const targetCol = desiredCol.value ?? currentCol; const targetCol = desiredCol.value ?? currentCol
// Set desired col if not already set (first vertical move in a sequence) // Set desired col if not already set (first vertical move in a sequence)
if (desiredCol.value === null) { if (desiredCol.value === null) {
desiredCol.value = currentCol; desiredCol.value = currentCol
} }
// Use entry column hook for vertical navigation when present. // Use entry column hook for vertical navigation when present.
const entryTarget = findElementAt(targetRow, targetCol, true); const entryTarget = findElementAt(targetRow, targetCol, true)
const targetRowElements = byRow.get(targetRow) ?? []; const targetRowElements = byRow.get(targetRow) ?? []
const hasEntryOverride = targetRowElements.some(el => el.element.hasAttribute(ENTRY_COL_ATTR)); const hasEntryOverride = targetRowElements.some((el) => el.element.hasAttribute(ENTRY_COL_ATTR))
if (hasEntryOverride) { if (hasEntryOverride) {
return entryTarget?.element || null; return entryTarget?.element || null
} }
const syncedRows = getSyncedRows(); const syncedRows = getSyncedRows()
const metrics = getSyncedRowMetrics(syncedRows); const metrics = getSyncedRowMetrics(syncedRows)
const logicalCurrentCenterX = metrics const logicalCurrentCenterX = metrics
? metrics.paddingLeft + currentCol * metrics.stride - syncedRowsCurrentOffset + metrics.cardWidth / 2 ? metrics.paddingLeft +
currentCol * metrics.stride -
syncedRowsCurrentOffset +
metrics.cardWidth / 2
: (() => { : (() => {
const currentRect = current.getBoundingClientRect(); const currentRect = current.getBoundingClientRect()
return currentRect.left + currentRect.width / 2; return currentRect.left + currentRect.width / 2
})(); })()
const closestByViewport = findElementClosestToLogicalViewportX( const closestByViewport = findElementClosestToLogicalViewportX(
targetRow, targetRow,
logicalCurrentCenterX, logicalCurrentCenterX,
targetCol, targetCol,
metrics, metrics,
); )
return closestByViewport?.element || entryTarget?.element || null; return closestByViewport?.element || entryTarget?.element || null
} }
} }
@@ -482,45 +488,45 @@ function findNextElement(
* Focus an element and scroll it into view * Focus an element and scroll it into view
*/ */
function focusElement(element: HTMLElement | null) { function focusElement(element: HTMLElement | null) {
if (!element) return; if (!element) return
// Remove focus from previous element // Remove focus from previous element
if (focusedElement.value && focusedElement.value !== element) { if (focusedElement.value && focusedElement.value !== element) {
focusedElement.value.classList.remove('nav-focused'); focusedElement.value.classList.remove("nav-focused")
focusedElement.value.blur(); focusedElement.value.blur()
} }
// Add focus to new element // Add focus to new element
element.classList.add('nav-focused'); element.classList.add("nav-focused")
element.focus({ preventScroll: true }); element.focus({ preventScroll: true })
ensureElementVisibleVertically(element); ensureElementVisibleVertically(element)
syncRowsToElement(element); syncRowsToElement(element)
focusedElement.value = element; focusedElement.value = element
} }
/** /**
* Get current focus state (row, col) for saving * Get current focus state (row, col) for saving
*/ */
function getFocusState(): { row: number; col: number } | null { function getFocusState(): { row: number; col: number } | null {
if (!focusedElement.value) return null; if (!focusedElement.value) return null
const row = parseInt(focusedElement.value.getAttribute(ROW_ATTR) || '0', 10); const row = parseInt(focusedElement.value.getAttribute(ROW_ATTR) || "0", 10)
const col = parseInt(focusedElement.value.getAttribute(COL_ATTR) || '0', 10); const col = parseInt(focusedElement.value.getAttribute(COL_ATTR) || "0", 10)
return { row, col }; return { row, col }
} }
/** /**
* Restore focus to element with given row/col * Restore focus to element with given row/col
*/ */
function restoreFocusState(state: { row: number; col: number } | null) { function restoreFocusState(state: { row: number; col: number } | null) {
if (!state) return; if (!state) return
const target = findElementAt(state.row, state.col); const target = findElementAt(state.row, state.col)
if (target) { if (target) {
setTimeout(() => { setTimeout(() => {
focusElement(target.element); focusElement(target.element)
}, 50); }, 50)
} }
} }
@@ -529,79 +535,79 @@ function restoreFocusState(state: { row: number; col: number } | null) {
*/ */
function focusAt(row: number, col: number, delay: number = 100) { function focusAt(row: number, col: number, delay: number = 100) {
setTimeout(() => { setTimeout(() => {
const target = findElementAt(row, col); const target = findElementAt(row, col)
if (target) { if (target) {
focusElement(target.element); focusElement(target.element)
} }
}, delay); }, delay)
} }
/** /**
* Check if we should allow navigation from an input element * Check if we should allow navigation from an input element
*/ */
function shouldAllowNavigationFromInput(target: HTMLElement, direction: string): boolean { function shouldAllowNavigationFromInput(target: HTMLElement, direction: string): boolean {
if (target.tagName !== 'INPUT' && target.tagName !== 'TEXTAREA' && !target.isContentEditable) { if (target.tagName !== "INPUT" && target.tagName !== "TEXTAREA" && !target.isContentEditable) {
return true; // Not an input, allow navigation return true // Not an input, allow navigation
} }
// Always allow up/down navigation from inputs // Always allow up/down navigation from inputs
if (direction === 'up' || direction === 'down') { if (direction === "up" || direction === "down") {
return true; return true
} }
// For left/right, only capture if input is empty // For left/right, only capture if input is empty
const inputEl = target as HTMLInputElement | HTMLTextAreaElement; const inputEl = target as HTMLInputElement | HTMLTextAreaElement
const value = inputEl.value || ''; const value = inputEl.value || ""
return value.length === 0; return value.length === 0
} }
/** /**
* Handle keyboard navigation * Handle keyboard navigation
*/ */
function handleKeyDown(event: KeyboardEvent) { function handleKeyDown(event: KeyboardEvent) {
const target = event.target as HTMLElement; const target = event.target as HTMLElement
const direction = { const direction = {
ArrowUp: 'up', ArrowUp: "up",
ArrowDown: 'down', ArrowDown: "down",
ArrowLeft: 'left', ArrowLeft: "left",
ArrowRight: 'right', ArrowRight: "right",
}[event.key] as 'up' | 'down' | 'left' | 'right' | undefined; }[event.key] as "up" | "down" | "left" | "right" | undefined
if (!direction) return; if (!direction) return
// Check if we should allow navigation from this element // Check if we should allow navigation from this element
if (!shouldAllowNavigationFromInput(target, direction)) { if (!shouldAllowNavigationFromInput(target, direction)) {
return; return
} }
event.preventDefault(); event.preventDefault()
isNavigating.value = true; isNavigating.value = true
// Get current focused element or find the first one // Get current focused element or find the first one
let current = focusedElement.value; let current = focusedElement.value
// If no element is focused, try to get the currently focused element from DOM // If no element is focused, try to get the currently focused element from DOM
if (!current) { if (!current) {
const activeElement = document.activeElement as HTMLElement; const activeElement = document.activeElement as HTMLElement
if (activeElement && activeElement.hasAttribute(FOCUSABLE_ATTR)) { if (activeElement && activeElement.hasAttribute(FOCUSABLE_ATTR)) {
current = activeElement; current = activeElement
} }
} }
// If still no current, focus the first available element // If still no current, focus the first available element
if (!current) { if (!current) {
const elements = getFocusableElements(); const elements = getFocusableElements()
if (elements.length > 0) { if (elements.length > 0) {
focusElement(elements[0].element); focusElement(elements[0].element)
} }
return; return
} }
// Find and focus the next element using index-based navigation // Find and focus the next element using index-based navigation
const next = findNextElement(current, direction); const next = findNextElement(current, direction)
if (next) { if (next) {
focusElement(next); focusElement(next)
} }
} }
@@ -609,18 +615,18 @@ function handleKeyDown(event: KeyboardEvent) {
* Handle Enter key to activate focused element * Handle Enter key to activate focused element
*/ */
function handleEnterKey(event: KeyboardEvent) { function handleEnterKey(event: KeyboardEvent) {
if (event.key !== 'Enter') return; if (event.key !== "Enter") return
if (event.defaultPrevented) return; if (event.defaultPrevented) return
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return; if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
const target = event.target as HTMLElement; const target = event.target as HTMLElement
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') { if (target.tagName === "INPUT" || target.tagName === "TEXTAREA") {
return; return
} }
if (focusedElement.value) { if (focusedElement.value) {
event.preventDefault(); event.preventDefault()
focusedElement.value.click(); focusedElement.value.click()
} }
} }
@@ -629,40 +635,40 @@ function handleEnterKey(event: KeyboardEvent) {
* Should be called once at app initialization * Should be called once at app initialization
*/ */
export function installKeyboardNavigation() { export function installKeyboardNavigation() {
if (handlersInstalled) return; if (handlersInstalled) return
handlersInstalled = true; handlersInstalled = true
resetSyncedRows(true); resetSyncedRows(true)
document.addEventListener('keydown', handleKeyDown); document.addEventListener("keydown", handleKeyDown)
document.addEventListener('keydown', handleEnterKey); document.addEventListener("keydown", handleEnterKey)
window.addEventListener('resize', handleSyncedRowResize, { passive: true }); window.addEventListener("resize", handleSyncedRowResize, { passive: true })
// Handle mouse clicks to update focus state // Handle mouse clicks to update focus state
document.addEventListener('click', (event) => { document.addEventListener("click", (event) => {
const target = event.target as HTMLElement; const target = event.target as HTMLElement
const focusable = target.closest(`[${FOCUSABLE_ATTR}]`) as HTMLElement | null; const focusable = target.closest(`[${FOCUSABLE_ATTR}]`) as HTMLElement | null
if (focusable) { if (focusable) {
desiredCol.value = null; // Reset desired col on mouse click desiredCol.value = null // Reset desired col on mouse click
focusElement(focusable); focusElement(focusable)
} }
}); })
// Handle focus events from tab navigation // Handle focus events from tab navigation
document.addEventListener('focusin', (event) => { document.addEventListener("focusin", (event) => {
const target = event.target as HTMLElement; const target = event.target as HTMLElement
if (target.hasAttribute(FOCUSABLE_ATTR)) { if (target.hasAttribute(FOCUSABLE_ATTR)) {
if (focusedElement.value && focusedElement.value !== target) { if (focusedElement.value && focusedElement.value !== target) {
focusedElement.value.classList.remove('nav-focused'); focusedElement.value.classList.remove("nav-focused")
} }
focusedElement.value = target; focusedElement.value = target
target.classList.add('nav-focused'); target.classList.add("nav-focused")
desiredCol.value = null; // Reset desired col on focus change desiredCol.value = null // Reset desired col on focus change
syncRowsToElement(target); syncRowsToElement(target)
} else { } else {
resetSyncedRows(); resetSyncedRows()
} }
}); })
} }
/** /**
@@ -677,7 +683,7 @@ export function useKeyboardNavigation() {
focusAt, focusAt,
getFocusState, getFocusState,
restoreFocusState, restoreFocusState,
}; }
} }
/** /**
@@ -686,15 +692,15 @@ export function useKeyboardNavigation() {
*/ */
export function navAttrs(row: number, col: number, entryCol?: number) { export function navAttrs(row: number, col: number, entryCol?: number) {
const attrs: Record<string, string | number> = { const attrs: Record<string, string | number> = {
[FOCUSABLE_ATTR]: 'true', [FOCUSABLE_ATTR]: "true",
[ROW_ATTR]: String(row), [ROW_ATTR]: String(row),
[COL_ATTR]: String(col), [COL_ATTR]: String(col),
tabindex: 0, tabindex: 0,
};
if (entryCol !== undefined) {
attrs[ENTRY_COL_ATTR] = String(entryCol);
} }
return attrs; if (entryCol !== undefined) {
attrs[ENTRY_COL_ATTR] = String(entryCol)
}
return attrs
} }
export { FOCUSABLE_ATTR, ROW_ATTR, COL_ATTR, ENTRY_COL_ATTR }; export { FOCUSABLE_ATTR, ROW_ATTR, COL_ATTR, ENTRY_COL_ATTR }
+217 -182
View File
@@ -1,13 +1,22 @@
import { ref, readonly, onUnmounted } from 'vue'; import { ref, readonly, onUnmounted } from "vue"
import type { Movie, Series, Episode, Season, Torrent, MediaIndex, TaskInfo, WsMessage } from '../types'; import type {
Movie,
Series,
Episode,
Season,
Torrent,
MediaIndex,
TaskInfo,
WsMessage,
} from "../types"
interface RootState { interface RootState {
rootId: string; rootId: string
ws: WebSocket | null; ws: WebSocket | null
movieMap: Map<string, Movie>; movieMap: Map<string, Movie>
seriesMap: Map<string, Series>; seriesMap: Map<string, Series>
connected: boolean; connected: boolean
reconnectTimer: ReturnType<typeof setTimeout> | null; reconnectTimer: ReturnType<typeof setTimeout> | null
} }
/** /**
@@ -21,57 +30,63 @@ interface RootState {
* - "task" → background task progress * - "task" → background task progress
*/ */
export function useMediaWebSocket() { export function useMediaWebSocket() {
const mediaIndex = ref<MediaIndex | null>(null); const mediaIndex = ref<MediaIndex | null>(null)
const loading = ref(true); const loading = ref(true)
const error = ref<string | null>(null); const error = ref<string | null>(null)
const connected = ref(false); const connected = ref(false)
const tasks = ref<Map<string, TaskInfo>>(new Map()); const tasks = ref<Map<string, TaskInfo>>(new Map())
const roots = ref<Map<string, RootState>>(new Map()); const roots = ref<Map<string, RootState>>(new Map())
let disposed = false; let disposed = false
function getContentHash(itemId: string): string { function getContentHash(itemId: string): string {
return itemId.split(':').pop() || itemId; return itemId.split(":").pop() || itemId
} }
function torrentQualityScore(t: Torrent): number { function torrentQualityScore(t: Torrent): number {
let score = 0; let score = 0
const res = (t.resolution || '').toLowerCase(); const res = (t.resolution || "").toLowerCase()
if (res.includes('2160') || res.includes('4k') || res.includes('uhd')) score += 100; if (res.includes("2160") || res.includes("4k") || res.includes("uhd")) score += 100
else if (res.includes('1080') || res.includes('fhd')) score += 80; else if (res.includes("1080") || res.includes("fhd")) score += 80
else if (res.includes('720') || res === 'hd') score += 60; else if (res.includes("720") || res === "hd") score += 60
else if (res.includes('480') || res === 'sd') score += 40; else if (res.includes("480") || res === "sd") score += 40
else if (res.includes('360')) score += 20; else if (res.includes("360")) score += 20
if (t.has_dolby_vision) score += 15; if (t.has_dolby_vision) score += 15
if (t.is_hdr) score += 10; if (t.is_hdr) score += 10
if (t.has_dolby_atmos) score += 5; if (t.has_dolby_atmos) score += 5
return score; return score
} }
function annotateTorrents(torrents: { [key: string]: Torrent }, rootId: string | null): { [key: string]: Torrent } { function annotateTorrents(
torrents: { [key: string]: Torrent },
rootId: string | null,
): { [key: string]: Torrent } {
return Object.fromEntries( return Object.fromEntries(
Object.entries(torrents || {}).map(([k, t]) => [k, { ...t, root_id: t.root_id || rootId }]) Object.entries(torrents || {}).map(([k, t]) => [k, { ...t, root_id: t.root_id || rootId }]),
); )
} }
function mergeTorrentDicts(a: { [key: string]: Torrent }, b: { [key: string]: Torrent }): { [key: string]: Torrent } { function mergeTorrentDicts(
const merged: { [key: string]: Torrent } = { ...a }; a: { [key: string]: Torrent },
b: { [key: string]: Torrent },
): { [key: string]: Torrent } {
const merged: { [key: string]: Torrent } = { ...a }
for (const [k, t] of Object.entries(b)) { for (const [k, t] of Object.entries(b)) {
const uniqueKey = merged[k] ? `${t.root_id || 'unknown'}:${k}` : k; const uniqueKey = merged[k] ? `${t.root_id || "unknown"}:${k}` : k
merged[uniqueKey] = t; merged[uniqueKey] = t
} }
const sorted = Object.entries(merged).sort(([, t1], [, t2]) => { const sorted = Object.entries(merged).sort(([, t1], [, t2]) => {
const s1 = torrentQualityScore(t1); const s1 = torrentQualityScore(t1)
const s2 = torrentQualityScore(t2); const s2 = torrentQualityScore(t2)
if (s2 !== s1) return s2 - s1; if (s2 !== s1) return s2 - s1
return (t2.size || 0) - (t1.size || 0); return (t2.size || 0) - (t1.size || 0)
}); })
return Object.fromEntries(sorted); return Object.fromEntries(sorted)
} }
function mergeMovies(a: Movie, b: Movie): Movie { function mergeMovies(a: Movie, b: Movie): Movie {
const torrentsA = annotateTorrents(a.torrents, a.root_id); const torrentsA = annotateTorrents(a.torrents, a.root_id)
const torrentsB = annotateTorrents(b.torrents, b.root_id); const torrentsB = annotateTorrents(b.torrents, b.root_id)
return { return {
...a, ...a,
torrents: mergeTorrentDicts(torrentsA, torrentsB), torrents: mergeTorrentDicts(torrentsA, torrentsB),
@@ -79,58 +94,79 @@ export function useMediaWebSocket() {
cover_path: a.cover_path || b.cover_path, cover_path: a.cover_path || b.cover_path,
backdrop_path: a.backdrop_path || b.backdrop_path, backdrop_path: a.backdrop_path || b.backdrop_path,
showreel_images: a.showreel_images?.length ? a.showreel_images : b.showreel_images, showreel_images: a.showreel_images?.length ? a.showreel_images : b.showreel_images,
showreel_source_sets: a.showreel_source_sets?.length ? a.showreel_source_sets : b.showreel_source_sets, showreel_source_sets: a.showreel_source_sets?.length
}; ? a.showreel_source_sets
: b.showreel_source_sets,
}
} }
function mergeEpisodes(a: Episode, b: Episode, rootIdA: string | null, rootIdB: string | null): Episode { function mergeEpisodes(
const torrentsA = annotateTorrents(a.torrents, rootIdA); a: Episode,
const torrentsB = annotateTorrents(b.torrents, rootIdB); b: Episode,
rootIdA: string | null,
rootIdB: string | null,
): Episode {
const torrentsA = annotateTorrents(a.torrents, rootIdA)
const torrentsB = annotateTorrents(b.torrents, rootIdB)
return { return {
...a, ...a,
torrents: mergeTorrentDicts(torrentsA, torrentsB), torrents: mergeTorrentDicts(torrentsA, torrentsB),
reel_image: a.reel_image || b.reel_image, reel_image: a.reel_image || b.reel_image,
reel_sources: a.reel_sources?.length ? a.reel_sources : b.reel_sources, reel_sources: a.reel_sources?.length ? a.reel_sources : b.reel_sources,
}; }
} }
function mergeSeasons(a: Season, b: Season, rootIdA: string | null, rootIdB: string | null): Season { function mergeSeasons(
const episodeMap = new Map<number, Episode>(); a: Season,
b: Season,
rootIdA: string | null,
rootIdB: string | null,
): Season {
const episodeMap = new Map<number, Episode>()
for (const ep of a.episodes) { for (const ep of a.episodes) {
episodeMap.set(ep.episode_number, ep); episodeMap.set(ep.episode_number, ep)
} }
for (const ep of b.episodes) { for (const ep of b.episodes) {
const existing = episodeMap.get(ep.episode_number); const existing = episodeMap.get(ep.episode_number)
if (existing) { if (existing) {
episodeMap.set(ep.episode_number, mergeEpisodes(existing, ep, rootIdA, rootIdB)); episodeMap.set(ep.episode_number, mergeEpisodes(existing, ep, rootIdA, rootIdB))
} else { } else {
episodeMap.set(ep.episode_number, { episodeMap.set(ep.episode_number, {
...ep, ...ep,
torrents: annotateTorrents(ep.torrents, rootIdB), torrents: annotateTorrents(ep.torrents, rootIdB),
}); })
} }
} }
return { return {
...a, ...a,
episodes: Array.from(episodeMap.values()).sort((a, b) => a.episode_number - b.episode_number), episodes: Array.from(episodeMap.values()).sort((a, b) => a.episode_number - b.episode_number),
poster_path: a.poster_path || b.poster_path, poster_path: a.poster_path || b.poster_path,
}; }
} }
function mergeSeries(a: Series, b: Series): Series { function mergeSeries(a: Series, b: Series): Series {
const seasonMap = new Map<number, Season>(); const seasonMap = new Map<number, Season>()
for (const season of a.seasons || []) { for (const season of a.seasons || []) {
seasonMap.set(season.season_number, { ...season, episodes: season.episodes.map(ep => ({ ...ep, torrents: annotateTorrents(ep.torrents, a.root_id) })) }); seasonMap.set(season.season_number, {
...season,
episodes: season.episodes.map((ep) => ({
...ep,
torrents: annotateTorrents(ep.torrents, a.root_id),
})),
})
} }
for (const season of b.seasons || []) { for (const season of b.seasons || []) {
const existing = seasonMap.get(season.season_number); const existing = seasonMap.get(season.season_number)
if (existing) { if (existing) {
seasonMap.set(season.season_number, mergeSeasons(existing, season, a.root_id, b.root_id)); seasonMap.set(season.season_number, mergeSeasons(existing, season, a.root_id, b.root_id))
} else { } else {
seasonMap.set(season.season_number, { seasonMap.set(season.season_number, {
...season, ...season,
episodes: season.episodes.map(ep => ({ ...ep, torrents: annotateTorrents(ep.torrents, b.root_id) })), episodes: season.episodes.map((ep) => ({
}); ...ep,
torrents: annotateTorrents(ep.torrents, b.root_id),
})),
})
} }
} }
return { return {
@@ -139,44 +175,41 @@ export function useMediaWebSocket() {
info: a.info || b.info, info: a.info || b.info,
cover_path: a.cover_path || b.cover_path, cover_path: a.cover_path || b.cover_path,
backdrop_path: a.backdrop_path || b.backdrop_path, backdrop_path: a.backdrop_path || b.backdrop_path,
}; }
} }
function mergeItemsByHash<T extends Movie | Series>( function mergeItemsByHash<T extends Movie | Series>(items: T[], mergeFn: (a: T, b: T) => T): T[] {
items: T[], const map = new Map<string, T[]>()
mergeFn: (a: T, b: T) => T
): T[] {
const map = new Map<string, T[]>();
for (const item of items) { for (const item of items) {
const hash = getContentHash(item.id); const hash = getContentHash(item.id)
const arr = map.get(hash) || []; const arr = map.get(hash) || []
arr.push(item); arr.push(item)
map.set(hash, arr); map.set(hash, arr)
} }
const merged: T[] = []; const merged: T[] = []
for (const [, group] of map) { for (const [, group] of map) {
if (group.length === 1) { if (group.length === 1) {
merged.push(group[0]); merged.push(group[0])
} else { } else {
let result = group[0]; let result = group[0]
for (let i = 1; i < group.length; i++) { for (let i = 1; i < group.length; i++) {
result = mergeFn(result, group[i]); result = mergeFn(result, group[i])
} }
merged.push(result); merged.push(result)
} }
} }
return merged; return merged
} }
function buildIndex(): MediaIndex { function buildIndex(): MediaIndex {
const movies: Movie[] = []; const movies: Movie[] = []
const series: Series[] = []; const series: Series[] = []
for (const state of roots.value.values()) { for (const state of roots.value.values()) {
movies.push(...state.movieMap.values()); movies.push(...state.movieMap.values())
series.push(...state.seriesMap.values()); series.push(...state.seriesMap.values())
} }
const mergedMovies = mergeItemsByHash(movies, mergeMovies); const mergedMovies = mergeItemsByHash(movies, mergeMovies)
const mergedSeries = mergeItemsByHash(series, mergeSeries); const mergedSeries = mergeItemsByHash(series, mergeSeries)
return { return {
version: 0, version: 0,
generated_at: new Date().toISOString(), generated_at: new Date().toISOString(),
@@ -186,101 +219,103 @@ export function useMediaWebSocket() {
}, },
movies: mergedMovies, movies: mergedMovies,
series: mergedSeries, series: mergedSeries,
}; }
} }
function updateMergedState() { function updateMergedState() {
mediaIndex.value = buildIndex(); mediaIndex.value = buildIndex()
// Loading is done when at least one root has connected and sent init // Loading is done when at least one root has connected and sent init
let anyConnected = false; let anyConnected = false
for (const state of roots.value.values()) { for (const state of roots.value.values()) {
if (state.connected) { if (state.connected) {
anyConnected = true; anyConnected = true
break; break
} }
} }
if (anyConnected) { if (anyConnected) {
loading.value = false; loading.value = false
error.value = null; error.value = null
} }
connected.value = anyConnected; connected.value = anyConnected
} }
function processJson(state: RootState, text: string) { function processJson(state: RootState, text: string) {
const msg = JSON.parse(text) as WsMessage; const msg = JSON.parse(text) as WsMessage
switch (msg.type) { switch (msg.type) {
case 'init': { case "init": {
state.movieMap.clear(); state.movieMap.clear()
state.seriesMap.clear(); state.seriesMap.clear()
for (const m of msg.data.movies) state.movieMap.set(m.id, m); for (const m of msg.data.movies) state.movieMap.set(m.id, m)
for (const s of msg.data.series) state.seriesMap.set(s.id, s); for (const s of msg.data.series) state.seriesMap.set(s.id, s)
updateMergedState(); updateMergedState()
console.log(`[WS ${state.rootId}] init: ${state.movieMap.size} movies, ${state.seriesMap.size} series`); console.log(
break; `[WS ${state.rootId}] init: ${state.movieMap.size} movies, ${state.seriesMap.size} series`,
)
break
} }
case 'upsert': { case "upsert": {
if (msg.kind === 'movie') { if (msg.kind === "movie") {
state.movieMap.set(msg.item.id, msg.item as Movie); state.movieMap.set(msg.item.id, msg.item as Movie)
} else { } else {
state.seriesMap.set(msg.item.id, msg.item as Series); state.seriesMap.set(msg.item.id, msg.item as Series)
} }
updateMergedState(); updateMergedState()
break; break
} }
case 'remove': { case "remove": {
if (msg.kind === 'movie') { if (msg.kind === "movie") {
state.movieMap.delete(msg.id); state.movieMap.delete(msg.id)
} else { } else {
state.seriesMap.delete(msg.id); state.seriesMap.delete(msg.id)
} }
updateMergedState(); updateMergedState()
break; break
} }
case 'task': { case "task": {
const info = msg.data; const info = msg.data
if (info.status === 'completed' || info.status === 'cancelled' || info.status === 'error') { if (info.status === "completed" || info.status === "cancelled" || info.status === "error") {
tasks.value.set(info.id, info); tasks.value.set(info.id, info)
setTimeout(() => { setTimeout(() => {
tasks.value.delete(info.id); tasks.value.delete(info.id)
tasks.value = new Map(tasks.value); tasks.value = new Map(tasks.value)
}, 3000); }, 3000)
} else { } else {
tasks.value.set(info.id, info); tasks.value.set(info.id, info)
} }
tasks.value = new Map(tasks.value); tasks.value = new Map(tasks.value)
break; break
} }
} }
} }
function handleMessage(state: RootState, event: MessageEvent) { function handleMessage(state: RootState, event: MessageEvent) {
try { try {
let text: string; let text: string
if (event.data instanceof Blob) { if (event.data instanceof Blob) {
event.data.text().then((t) => processJson(state, t)); event.data.text().then((t) => processJson(state, t))
return; return
} else if (event.data instanceof ArrayBuffer) { } else if (event.data instanceof ArrayBuffer) {
text = new TextDecoder().decode(event.data); text = new TextDecoder().decode(event.data)
} else { } else {
text = event.data as string; text = event.data as string
} }
processJson(state, text); processJson(state, text)
} catch (e) { } catch (e) {
console.error(`[WS ${state.rootId}] Failed to handle message:`, e); console.error(`[WS ${state.rootId}] Failed to handle message:`, e)
} }
} }
function connectRoot(rootId: string) { function connectRoot(rootId: string) {
if (disposed) return; if (disposed) return
const existing = roots.value.get(rootId); const existing = roots.value.get(rootId)
if (existing?.ws) { if (existing?.ws) {
// Already connecting or connected // Already connecting or connected
return; return
} }
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; const proto = location.protocol === "https:" ? "wss:" : "ws:"
const url = `${proto}//${location.host}/api/roots/${encodeURIComponent(rootId)}/ws`; const url = `${proto}//${location.host}/api/roots/${encodeURIComponent(rootId)}/ws`
const state: RootState = { const state: RootState = {
rootId, rootId,
@@ -289,103 +324,103 @@ export function useMediaWebSocket() {
seriesMap: new Map(), seriesMap: new Map(),
connected: false, connected: false,
reconnectTimer: null, reconnectTimer: null,
}; }
roots.value.set(rootId, state); roots.value.set(rootId, state)
function doConnect() { function doConnect() {
if (disposed) return; if (disposed) return
console.log(`[WS ${rootId}] Connecting to ${url}...`); console.log(`[WS ${rootId}] Connecting to ${url}...`)
const ws = new WebSocket(url); const ws = new WebSocket(url)
state.ws = ws; state.ws = ws
ws.onopen = () => { ws.onopen = () => {
state.connected = true; state.connected = true
updateMergedState(); updateMergedState()
console.log(`[WS ${rootId}] Connected`); console.log(`[WS ${rootId}] Connected`)
}; }
ws.onmessage = (ev) => handleMessage(state, ev); ws.onmessage = (ev) => handleMessage(state, ev)
ws.onclose = (ev) => { ws.onclose = (ev) => {
state.connected = false; state.connected = false
state.ws = null; state.ws = null
updateMergedState(); updateMergedState()
console.log(`[WS ${rootId}] Closed (code=${ev.code})`); console.log(`[WS ${rootId}] Closed (code=${ev.code})`)
scheduleReconnect(); scheduleReconnect()
}; }
ws.onerror = (ev) => { ws.onerror = (ev) => {
console.error(`[WS ${rootId}] Error:`, ev); console.error(`[WS ${rootId}] Error:`, ev)
if (!mediaIndex.value) { if (!mediaIndex.value) {
error.value = 'WebSocket connection failed'; error.value = "WebSocket connection failed"
} }
}; }
} }
function scheduleReconnect() { function scheduleReconnect() {
if (disposed) return; if (disposed) return
if (state.reconnectTimer) clearTimeout(state.reconnectTimer); if (state.reconnectTimer) clearTimeout(state.reconnectTimer)
state.reconnectTimer = setTimeout(() => { state.reconnectTimer = setTimeout(() => {
console.log(`[WS ${rootId}] Reconnecting...`); console.log(`[WS ${rootId}] Reconnecting...`)
doConnect(); doConnect()
}, 2000); }, 2000)
} }
doConnect(); doConnect()
} }
function disconnectRoot(rootId: string) { function disconnectRoot(rootId: string) {
const state = roots.value.get(rootId); const state = roots.value.get(rootId)
if (!state) return; if (!state) return
if (state.reconnectTimer) { if (state.reconnectTimer) {
clearTimeout(state.reconnectTimer); clearTimeout(state.reconnectTimer)
state.reconnectTimer = null; state.reconnectTimer = null
} }
if (state.ws) { if (state.ws) {
state.ws.onclose = null; state.ws.onclose = null
state.ws.close(); state.ws.close()
state.ws = null; state.ws = null
} }
state.connected = false; state.connected = false
roots.value.delete(rootId); roots.value.delete(rootId)
updateMergedState(); updateMergedState()
} }
function setActiveRoots(rootIds: string[]) { function setActiveRoots(rootIds: string[]) {
if (disposed) return; if (disposed) return
const desired = new Set(rootIds); const desired = new Set(rootIds)
const current = new Set(roots.value.keys()); const current = new Set(roots.value.keys())
// Add new roots // Add new roots
for (const rid of desired) { for (const rid of desired) {
if (!current.has(rid)) { if (!current.has(rid)) {
connectRoot(rid); connectRoot(rid)
} }
} }
// Remove old roots // Remove old roots
for (const rid of current) { for (const rid of current) {
if (!desired.has(rid)) { if (!desired.has(rid)) {
disconnectRoot(rid); disconnectRoot(rid)
} }
} }
} }
function disconnect() { function disconnect() {
disposed = true; disposed = true
for (const state of roots.value.values()) { for (const state of roots.value.values()) {
if (state.reconnectTimer) { if (state.reconnectTimer) {
clearTimeout(state.reconnectTimer); clearTimeout(state.reconnectTimer)
} }
if (state.ws) { if (state.ws) {
state.ws.onclose = null; state.ws.onclose = null
state.ws.close(); state.ws.close()
} }
} }
roots.value.clear(); roots.value.clear()
} }
onUnmounted(disconnect); onUnmounted(disconnect)
return { return {
mediaIndex, mediaIndex,
@@ -395,5 +430,5 @@ export function useMediaWebSocket() {
tasks: readonly(tasks), tasks: readonly(tasks),
setActiveRoots, setActiveRoots,
disconnect, disconnect,
}; }
} }
+14 -14
View File
@@ -1,10 +1,10 @@
import { createApp } from 'vue' import { createApp } from "vue"
import App from './App.vue' import App from "./App.vue"
import router from './router' import router from "./router"
import './styles/main.css' import "./styles/main.css"
import { installKeyboardNavigation } from './composables/useKeyboardNavigation' import { installKeyboardNavigation } from "./composables/useKeyboardNavigation"
import { installGamepadNavigation } from './composables/useGamepadNavigation' import { installGamepadNavigation } from "./composables/useGamepadNavigation"
import { installInputModalityTracking } from './composables/useInputModality' import { installInputModalityTracking } from "./composables/useInputModality"
// Install global keyboard navigation handlers immediately // Install global keyboard navigation handlers immediately
installInputModalityTracking() installInputModalityTracking()
@@ -12,12 +12,12 @@ installKeyboardNavigation()
installGamepadNavigation() installGamepadNavigation()
// Unregister any legacy service workers — MediaHive no longer uses a PWA/SW. // Unregister any legacy service workers — MediaHive no longer uses a PWA/SW.
if ('serviceWorker' in navigator) { if ("serviceWorker" in navigator) {
navigator.serviceWorker.getRegistrations().then((registrations) => { navigator.serviceWorker.getRegistrations().then((registrations) => {
for (const registration of registrations) { for (const registration of registrations) {
registration.unregister() registration.unregister()
} }
}) })
} }
createApp(App).use(router).mount('#app') createApp(App).use(router).mount("#app")
+22 -22
View File
@@ -1,49 +1,49 @@
import { createRouter, createWebHashHistory } from 'vue-router'; import { createRouter, createWebHashHistory } from "vue-router"
import { defineComponent, h } from 'vue'; import { defineComponent, h } from "vue"
// Empty component - App.vue handles all rendering based on route meta // Empty component - App.vue handles all rendering based on route meta
const EmptyRouteComponent = defineComponent({ const EmptyRouteComponent = defineComponent({
render() { render() {
return h('div'); return h("div")
} },
}); })
const router = createRouter({ const router = createRouter({
history: createWebHashHistory(), history: createWebHashHistory(),
scrollBehavior() { scrollBehavior() {
// Always scroll to top on navigation // Always scroll to top on navigation
return { top: 0 }; return { top: 0 }
}, },
routes: [ routes: [
{ {
path: '/', path: "/",
redirect: '/movies', redirect: "/movies",
}, },
{ {
path: '/movies', path: "/movies",
name: 'movies', name: "movies",
component: EmptyRouteComponent, component: EmptyRouteComponent,
meta: { view: 'movies' }, meta: { view: "movies" },
}, },
{ {
path: '/movies/:id', path: "/movies/:id",
name: 'movie-detail', name: "movie-detail",
component: EmptyRouteComponent, component: EmptyRouteComponent,
meta: { view: 'movies' }, meta: { view: "movies" },
}, },
{ {
path: '/series', path: "/series",
name: 'series', name: "series",
component: EmptyRouteComponent, component: EmptyRouteComponent,
meta: { view: 'series' }, meta: { view: "series" },
}, },
{ {
path: '/series/:id', path: "/series/:id",
name: 'series-detail', name: "series-detail",
component: EmptyRouteComponent, component: EmptyRouteComponent,
meta: { view: 'series' }, meta: { view: "series" },
}, },
], ],
}); })
export default router; export default router
+30 -16
View File
@@ -27,8 +27,9 @@
box-sizing: border-box; box-sizing: border-box;
} }
html, body { html,
font-family: 'Segoe UI', 'Helvetica Neue', Arial, sans-serif; body {
font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
background-color: var(--bg-primary); background-color: var(--bg-primary);
color: var(--text-primary); color: var(--text-primary);
min-height: 100vh; min-height: 100vh;
@@ -92,7 +93,7 @@ html.mouse-active ::-webkit-scrollbar-thumb:hover {
} }
.header::before { .header::before {
content: ''; content: "";
position: absolute; position: absolute;
top: 0; top: 0;
bottom: 0; bottom: 0;
@@ -132,11 +133,7 @@ html.mouse-active ::-webkit-scrollbar-thumb:hover {
width: 30em; width: 30em;
max-width: calc(100vw - 24px); max-width: calc(100vw - 24px);
opacity: 1; opacity: 1;
background: linear-gradient( background: linear-gradient(to bottom, rgba(5, 7, 10, 0.72) 0%, rgba(5, 7, 10, 0.5) 100%);
to bottom,
rgba(5, 7, 10, 0.72) 0%,
rgba(5, 7, 10, 0.5) 100%
);
backdrop-filter: blur(10px) saturate(115%); backdrop-filter: blur(10px) saturate(115%);
-webkit-backdrop-filter: blur(10px) saturate(115%); -webkit-backdrop-filter: blur(10px) saturate(115%);
} }
@@ -276,12 +273,22 @@ html.mouse-active .header-settings-btn:hover {
background-size: contain; background-size: contain;
background-position: right top; background-position: right top;
background-repeat: no-repeat; background-repeat: no-repeat;
mask-image: linear-gradient(to left, rgba(0,0,0,0.6) 0%, rgba(0,0,0,0.3) 60%, transparent 100%); mask-image: linear-gradient(
-webkit-mask-image: linear-gradient(to left, rgba(0,0,0,0.6) 0%, rgba(0,0,0,0.3) 60%, transparent 100%); to left,
rgba(0, 0, 0, 0.6) 0%,
rgba(0, 0, 0, 0.3) 60%,
transparent 100%
);
-webkit-mask-image: linear-gradient(
to left,
rgba(0, 0, 0, 0.6) 0%,
rgba(0, 0, 0, 0.3) 60%,
transparent 100%
);
} }
.hero::after { .hero::after {
content: ''; content: "";
position: absolute; position: absolute;
bottom: 0; bottom: 0;
left: 0; left: 0;
@@ -331,7 +338,8 @@ html.mouse-active .header-settings-btn:hover {
/* Blinking animation for button focus */ /* Blinking animation for button focus */
@keyframes btn-outline-blink { @keyframes btn-outline-blink {
0%, 100% { 0%,
100% {
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.9); box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.9);
} }
50% { 50% {
@@ -395,11 +403,15 @@ html:not(.mouse-active) .btn-secondary.nav-focused {
} }
.view-zoom-enter-active { .view-zoom-enter-active {
transition: opacity 0.4s ease-out, transform 0.4s ease-out; transition:
opacity 0.4s ease-out,
transform 0.4s ease-out;
} }
.view-zoom-leave-active { .view-zoom-leave-active {
transition: opacity 0.3s ease-in, transform 0.3s ease-in; transition:
opacity 0.3s ease-in,
transform 0.3s ease-in;
} }
.view-zoom-enter-from { .view-zoom-enter-from {
@@ -456,7 +468,9 @@ html:not(.mouse-active) .btn-secondary.nav-focused {
flex-shrink: 0; flex-shrink: 0;
width: var(--card-width); width: var(--card-width);
cursor: pointer; cursor: pointer;
transition: z-index 0s, box-shadow var(--transition-medium); transition:
z-index 0s,
box-shadow var(--transition-medium);
position: relative; position: relative;
outline: none; outline: none;
} }
@@ -606,7 +620,7 @@ html.mouse-active .media-card:hover .media-card-info {
} }
.modal-header::after { .modal-header::after {
content: ''; content: "";
position: absolute; position: absolute;
bottom: 0; bottom: 0;
left: 0; left: 0;
+132 -132
View File
@@ -1,205 +1,205 @@
// Type definitions for the media browser // Type definitions for the media browser
export type CastGender = 'female' | 'male' | 'non_binary' | 'unknown'; export type CastGender = "female" | "male" | "non_binary" | "unknown"
export interface CastMember { export interface CastMember {
name: string; name: string
character?: string | null; character?: string | null
profile_path: string | null; profile_path: string | null
gender?: CastGender | null; gender?: CastGender | null
} }
export interface SimilarMedia { export interface SimilarMedia {
id: number; id: number
title: string; title: string
poster_path: string | null; poster_path: string | null
} }
export interface Info { export interface Info {
tmdb_id: number; tmdb_id: number
title: string | null; title: string | null
original_title: string | null; original_title: string | null
alternative_titles: string[] | null; alternative_titles: string[] | null
rating: number | null; rating: number | null
vote_count: number | null; vote_count: number | null
overview: string | null; overview: string | null
genres: string[] | null; genres: string[] | null
release_date: string | null; release_date: string | null
runtime: number | null; runtime: number | null
status: string | null; status: string | null
tagline: string | null; tagline: string | null
poster_path: string | null; poster_path: string | null
backdrop_path: string | null; backdrop_path: string | null
similar: SimilarMedia[] | null; similar: SimilarMedia[] | null
keywords: string[] | null; keywords: string[] | null
cast: CastMember[] | null; cast: CastMember[] | null
director: string | null; director: string | null
creators: string[] | null; creators: string[] | null
number_of_seasons: number | null; number_of_seasons: number | null
number_of_episodes: number | null; number_of_episodes: number | null
networks: string[] | null; networks: string[] | null
} }
export interface Torrent { export interface Torrent {
title: string | null; title: string | null
playable_file: string | null; playable_file: string | null
resolution: string | null; resolution: string | null
quality: string | null; quality: string | null
network: string | null; network: string | null
codec: string | null; codec: string | null
audio: string | null; audio: string | null
audio_languages: string[] | null; audio_languages: string[] | null
subtitle_languages: string[] | null; subtitle_languages: string[] | null
is_hdr: boolean; is_hdr: boolean
has_dolby_vision: boolean; has_dolby_vision: boolean
has_dolby_atmos: boolean; has_dolby_atmos: boolean
encoder: string | null; encoder: string | null
size: number | null; size: number | null
added_at: number | null; added_at: number | null
root_id?: string | null; root_id?: string | null
} }
export interface Movie { export interface Movie {
id: string; id: string
title: string | null; title: string | null
info: Info | null; info: Info | null
year: number | null; year: number | null
newest: number | null; newest: number | null
cover_path: string | null; cover_path: string | null
backdrop_path: string | null; backdrop_path: string | null
showreel_images: string[] | null; showreel_images: string[] | null
showreel_source_sets: string[][] | null; showreel_source_sets: string[][] | null
torrents: { [key: string]: Torrent }; torrents: { [key: string]: Torrent }
root_id: string | null; root_id: string | null
} }
export interface Episode { export interface Episode {
episode_number: number; episode_number: number
name: string | null; name: string | null
overview: string | null; overview: string | null
air_date: string | null; air_date: string | null
runtime: number | null; runtime: number | null
still_path: string | null; still_path: string | null
rating: number | null; rating: number | null
director: string | null; director: string | null
reel_image: string | null; reel_image: string | null
reel_sources: string[] | null; reel_sources: string[] | null
torrents: { [key: string]: Torrent }; torrents: { [key: string]: Torrent }
} }
export interface Season { export interface Season {
season_number: number; season_number: number
name: string | null; name: string | null
overview: string | null; overview: string | null
air_date: string | null; air_date: string | null
poster_path: string | null; poster_path: string | null
episode_count: number | null; episode_count: number | null
episodes: Episode[]; episodes: Episode[]
} }
export interface Series { export interface Series {
id: string; id: string
title: string | null; title: string | null
info: Info | null; info: Info | null
alternative_titles: string[] | null; alternative_titles: string[] | null
newest: number | null; newest: number | null
cover_path: string | null; cover_path: string | null
backdrop_path: string | null; backdrop_path: string | null
seasons: Season[]; seasons: Season[]
root_id: string | null; root_id: string | null
} }
export interface MediaStats { export interface MediaStats {
total_movies: number; total_movies: number
total_movie_versions?: number; total_movie_versions?: number
total_series: number; total_series: number
total_series_episodes?: number; total_series_episodes?: number
} }
export interface MediaIndex { export interface MediaIndex {
version: number; version: number
generated_at: string; generated_at: string
stats: MediaStats; stats: MediaStats
movies: Movie[]; movies: Movie[]
series: Series[]; series: Series[]
} }
export type MediaType = 'movies' | 'series' | 'episode'; export type MediaType = "movies" | "series" | "episode"
// Matched person info for search results // Matched person info for search results
export interface MatchedPerson { export interface MatchedPerson {
name: string; name: string
roles: string; // e.g., "Director", "Tony Stark", "Creator" roles: string // e.g., "Director", "Tony Stark", "Creator"
highlightRoles: boolean; // true if the roles/character matched (vs the name) highlightRoles: boolean // true if the roles/character matched (vs the name)
} }
// Matched episode info for search results // Matched episode info for search results
export interface MatchedEpisode { export interface MatchedEpisode {
name: string; // Episode name (highlighted) name: string // Episode name (highlighted)
location: string; // "SN Episode M" (dimmed) location: string // "SN Episode M" (dimmed)
seasonNumber: number; // For navigation to episode seasonNumber: number // For navigation to episode
episodeNumber: number; // For navigation to episode episodeNumber: number // For navigation to episode
} }
// Info about why a search matched this item // Info about why a search matched this item
export interface SearchMatchInfo { export interface SearchMatchInfo {
// Matched people with their roles/characters // Matched people with their roles/characters
matchedPeople?: MatchedPerson[]; matchedPeople?: MatchedPerson[]
// Matched episodes for series // Matched episodes for series
matchedEpisodes?: MatchedEpisode[]; matchedEpisodes?: MatchedEpisode[]
} }
export interface MediaItem { export interface MediaItem {
id: string; id: string
title: string | null; title: string | null
year?: number | null; year?: number | null
cover_path: string | null; cover_path: string | null
showreel_images?: string[] | null; showreel_images?: string[] | null
showreel_source_sets?: string[][] | null; showreel_source_sets?: string[][] | null
type: MediaType; type: MediaType
resolution?: string | null; resolution?: string | null
data: Movie | Series | EpisodeWithSeries; data: Movie | Series | EpisodeWithSeries
root_id: string | null; root_id: string | null
// Optional search match info - only present in search results // Optional search match info - only present in search results
searchMatchInfo?: SearchMatchInfo; searchMatchInfo?: SearchMatchInfo
} }
// Episode with parent series info for standalone display // Episode with parent series info for standalone display
export interface EpisodeWithSeries { export interface EpisodeWithSeries {
episode: Episode; episode: Episode
series: Series; series: Series
seasonNumber: number; seasonNumber: number
} }
// Task progress info from background scanning // Task progress info from background scanning
export interface TaskInfo { export interface TaskInfo {
id: string; id: string
status: string; status: string
progress: number; progress: number
detail: string; detail: string
} }
// WebSocket message types (matching server msgspec tagged structs) // WebSocket message types (matching server msgspec tagged structs)
export interface WsInitMessage { export interface WsInitMessage {
type: 'init'; type: "init"
data: { movies: Movie[]; series: Series[] }; data: { movies: Movie[]; series: Series[] }
} }
export interface WsUpsertMessage { export interface WsUpsertMessage {
type: 'upsert'; type: "upsert"
kind: 'movie' | 'series'; kind: "movie" | "series"
item: Movie | Series; item: Movie | Series
} }
export interface WsRemoveMessage { export interface WsRemoveMessage {
type: 'remove'; type: "remove"
kind: 'movie' | 'series'; kind: "movie" | "series"
id: string; id: string
} }
export interface WsTaskMessage { export interface WsTaskMessage {
type: 'task'; type: "task"
data: TaskInfo; data: TaskInfo
} }
export type WsMessage = WsInitMessage | WsUpsertMessage | WsRemoveMessage | WsTaskMessage; export type WsMessage = WsInitMessage | WsUpsertMessage | WsRemoveMessage | WsTaskMessage
+309 -313
View File
@@ -1,331 +1,327 @@
import * as flagSvgs from 'country-flag-icons/string/3x2'; import * as flagSvgs from "country-flag-icons/string/3x2"
export interface LanguageFlagEntry { export interface LanguageFlagEntry {
countryCode: string; countryCode: string
svg: string; svg: string
sourceCodes: string[]; sourceCodes: string[]
} }
const FLAGS = flagSvgs as Record<string, string>; const FLAGS = flagSvgs as Record<string, string>
const LANGUAGE_TO_COUNTRY: Record<string, string> = { const LANGUAGE_TO_COUNTRY: Record<string, string> = {
// English // English
en: 'GB', en: "GB",
eng: 'GB', eng: "GB",
// Spanish (including LATAM variants collapsed to Spain flag) // Spanish (including LATAM variants collapsed to Spain flag)
es: 'ES', es: "ES",
spa: 'ES', spa: "ES",
esl: 'ES', esl: "ES",
spl: 'ES', spl: "ES",
'es-es': 'ES', "es-es": "ES",
'es-419': 'ES', "es-419": "ES",
'spa-la': 'ES', "spa-la": "ES",
// Portuguese // Portuguese
pt: 'PT', pt: "PT",
por: 'PT', por: "PT",
'pt-pt': 'PT', "pt-pt": "PT",
'pt-br': 'BR', "pt-br": "BR",
// Major European languages // Major European languages
fr: 'FR', fr: "FR",
fra: 'FR', fra: "FR",
fre: 'FR', fre: "FR",
de: 'DE', de: "DE",
deu: 'DE', deu: "DE",
ger: 'DE', ger: "DE",
it: 'IT', it: "IT",
ita: 'IT', ita: "IT",
nl: 'NL', nl: "NL",
nld: 'NL', nld: "NL",
dut: 'NL', dut: "NL",
sv: 'SE', sv: "SE",
swe: 'SE', swe: "SE",
no: 'NO', no: "NO",
nor: 'NO', nor: "NO",
da: 'DK', da: "DK",
dan: 'DK', dan: "DK",
fi: 'FI', fi: "FI",
fin: 'FI', fin: "FI",
pl: 'PL', pl: "PL",
: 'PL', : "PL",
cs: 'CZ', cs: "CZ",
ces: 'CZ', ces: "CZ",
cze: 'CZ', cze: "CZ",
hu: 'HU', hu: "HU",
hun: 'HU', hun: "HU",
ro: 'RO', ro: "RO",
ron: 'RO', ron: "RO",
rum: 'RO', rum: "RO",
el: 'GR', el: "GR",
gre: 'GR', gre: "GR",
ell: 'GR', ell: "GR",
tr: 'TR', tr: "TR",
tur: 'TR', tur: "TR",
// Slavic / Eurasian // Slavic / Eurasian
ru: 'RU', ru: "RU",
rus: 'RU', rus: "RU",
uk: 'UA', uk: "UA",
ukr: 'UA', ukr: "UA",
bg: 'BG', bg: "BG",
bul: 'BG', bul: "BG",
sr: 'RS', sr: "RS",
srp: 'RS', srp: "RS",
hr: 'HR', hr: "HR",
hrv: 'HR', hrv: "HR",
sl: 'SI', sl: "SI",
slv: 'SI', slv: "SI",
sk: 'SK', sk: "SK",
slk: 'SK', slk: "SK",
slo: 'SK', slo: "SK",
// East / South / SE Asia // East / South / SE Asia
ja: 'JP', ja: "JP",
jpn: 'JP', jpn: "JP",
ko: 'KR', ko: "KR",
kor: 'KR', kor: "KR",
zh: 'CN', zh: "CN",
zho: 'CN', zho: "CN",
chi: 'CN', chi: "CN",
yue: 'HK', yue: "HK",
th: 'TH', th: "TH",
tha: 'TH', tha: "TH",
vi: 'VN', vi: "VN",
vie: 'VN', vie: "VN",
id: 'ID', id: "ID",
ind: 'ID', ind: "ID",
ms: 'MY', ms: "MY",
msa: 'MY', msa: "MY",
may: 'MY', may: "MY",
hi: 'IN', hi: "IN",
hin: 'IN', hin: "IN",
// Middle East / Africa // Middle East / Africa
ar: 'SA', ar: "SA",
ara: 'SA', ara: "SA",
he: 'IL', he: "IL",
heb: 'IL', heb: "IL",
fa: 'IR', fa: "IR",
fas: 'IR', fas: "IR",
per: 'IR', per: "IR",
ur: 'PK', ur: "PK",
urd: 'PK', urd: "PK",
sw: 'TZ', sw: "TZ",
swa: 'TZ', swa: "TZ",
// Other common // Other common
ca: 'ES', ca: "ES",
cat: 'ES', cat: "ES",
eu: 'ES', eu: "ES",
baq: 'ES', baq: "ES",
eus: 'ES', eus: "ES",
}; }
function normalizeLanguageCode(code: string): string { function normalizeLanguageCode(code: string): string {
return code.trim().toLowerCase().replace('_', '-'); return code.trim().toLowerCase().replace("_", "-")
} }
function normalizeLanguageName(name: string): string { function normalizeLanguageName(name: string): string {
return name return name.trim().toLowerCase().replace(/[_-]+/g, " ").replace(/\s+/g, " ")
.trim()
.toLowerCase()
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ');
} }
let _browserLanguagePreferences: string[] | null = null; let _browserLanguagePreferences: string[] | null = null
let _browserPreferenceRanks: Map<string, number> | null = null; let _browserPreferenceRanks: Map<string, number> | null = null
const LANGUAGE_NAME_TO_CODE: Record<string, string> = { const LANGUAGE_NAME_TO_CODE: Record<string, string> = {
english: 'en', english: "en",
spanish: 'es', spanish: "es",
portuguese: 'pt', portuguese: "pt",
french: 'fr', french: "fr",
german: 'de', german: "de",
italian: 'it', italian: "it",
dutch: 'nl', dutch: "nl",
swedish: 'sv', swedish: "sv",
norwegian: 'no', norwegian: "no",
danish: 'da', danish: "da",
finnish: 'fi', finnish: "fi",
polish: 'pl', polish: "pl",
czech: 'cs', czech: "cs",
hungarian: 'hu', hungarian: "hu",
romanian: 'ro', romanian: "ro",
greek: 'el', greek: "el",
turkish: 'tr', turkish: "tr",
russian: 'ru', russian: "ru",
ukrainian: 'uk', ukrainian: "uk",
bulgarian: 'bg', bulgarian: "bg",
serbian: 'sr', serbian: "sr",
croatian: 'hr', croatian: "hr",
slovenian: 'sl', slovenian: "sl",
slovak: 'sk', slovak: "sk",
japanese: 'ja', japanese: "ja",
korean: 'ko', korean: "ko",
chinese: 'zh', chinese: "zh",
cantonese: 'yue', cantonese: "yue",
thai: 'th', thai: "th",
vietnamese: 'vi', vietnamese: "vi",
indonesian: 'id', indonesian: "id",
malay: 'ms', malay: "ms",
hindi: 'hi', hindi: "hi",
arabic: 'ar', arabic: "ar",
hebrew: 'he', hebrew: "he",
persian: 'fa', persian: "fa",
urdu: 'ur', urdu: "ur",
swahili: 'sw', swahili: "sw",
catalan: 'ca', catalan: "ca",
basque: 'eu', basque: "eu",
}; }
function getBrowserLanguagePreferences(): string[] { function getBrowserLanguagePreferences(): string[] {
if (_browserLanguagePreferences) return _browserLanguagePreferences; if (_browserLanguagePreferences) return _browserLanguagePreferences
const preferences: string[] = []; const preferences: string[] = []
if (typeof navigator !== 'undefined') { if (typeof navigator !== "undefined") {
if (Array.isArray(navigator.languages)) { if (Array.isArray(navigator.languages)) {
for (const lang of navigator.languages) { for (const lang of navigator.languages) {
if (typeof lang === 'string' && lang.trim()) { if (typeof lang === "string" && lang.trim()) {
preferences.push(normalizeLanguageCode(lang)); preferences.push(normalizeLanguageCode(lang))
} }
} }
} }
if (typeof navigator.language === 'string' && navigator.language.trim()) { if (typeof navigator.language === "string" && navigator.language.trim()) {
preferences.push(normalizeLanguageCode(navigator.language)); preferences.push(normalizeLanguageCode(navigator.language))
} }
} }
const deduped = Array.from(new Set(preferences)); const deduped = Array.from(new Set(preferences))
_browserLanguagePreferences = deduped; _browserLanguagePreferences = deduped
return deduped; return deduped
} }
function getBrowserPreferenceRanks(): Map<string, number> { function getBrowserPreferenceRanks(): Map<string, number> {
if (_browserPreferenceRanks) return _browserPreferenceRanks; if (_browserPreferenceRanks) return _browserPreferenceRanks
const preferences = getBrowserLanguagePreferences(); const preferences = getBrowserLanguagePreferences()
const ranks = new Map<string, number>(); const ranks = new Map<string, number>()
const display = new Intl.DisplayNames(['en'], { type: 'language' }); const display = new Intl.DisplayNames(["en"], { type: "language" })
for (const [index, pref] of preferences.entries()) { for (const [index, pref] of preferences.entries()) {
const base = pref.split('-', 1)[0]; const base = pref.split("-", 1)[0]
if (!ranks.has(pref)) ranks.set(pref, index); if (!ranks.has(pref)) ranks.set(pref, index)
if (!ranks.has(base)) ranks.set(base, index); if (!ranks.has(base)) ranks.set(base, index)
const prefName = display.of(pref); const prefName = display.of(pref)
if (prefName) { if (prefName) {
const key = normalizeLanguageName(prefName); const key = normalizeLanguageName(prefName)
if (!ranks.has(key)) ranks.set(key, index); if (!ranks.has(key)) ranks.set(key, index)
} }
const baseName = display.of(base); const baseName = display.of(base)
if (baseName) { if (baseName) {
const key = normalizeLanguageName(baseName); const key = normalizeLanguageName(baseName)
if (!ranks.has(key)) ranks.set(key, index); if (!ranks.has(key)) ranks.set(key, index)
} }
} }
_browserPreferenceRanks = ranks; _browserPreferenceRanks = ranks
return ranks; return ranks
} }
function resolveLanguageIdentifier(value: string): string { function resolveLanguageIdentifier(value: string): string {
const normalized = normalizeLanguageCode(value); const normalized = normalizeLanguageCode(value)
if (/^[a-z]{2,3}(?:-[a-z0-9]{2,})?$/i.test(normalized)) { if (/^[a-z]{2,3}(?:-[a-z0-9]{2,})?$/i.test(normalized)) {
return normalized; return normalized
} }
const nameKey = normalizeLanguageName(value); const nameKey = normalizeLanguageName(value)
const byName = LANGUAGE_NAME_TO_CODE[nameKey]; const byName = LANGUAGE_NAME_TO_CODE[nameKey]
if (byName) return byName; if (byName) return byName
return normalized; return normalized
} }
function getPreferenceRank(value: string, preferenceRanks: Map<string, number>): number { function getPreferenceRank(value: string, preferenceRanks: Map<string, number>): number {
const normalized = resolveLanguageIdentifier(value); const normalized = resolveLanguageIdentifier(value)
const exact = preferenceRanks.get(normalized); const exact = preferenceRanks.get(normalized)
if (exact !== undefined) return exact; if (exact !== undefined) return exact
const base = normalized.split('-', 1)[0]; const base = normalized.split("-", 1)[0]
const baseRank = preferenceRanks.get(base); const baseRank = preferenceRanks.get(base)
if (baseRank !== undefined) return baseRank; if (baseRank !== undefined) return baseRank
const nameKey = normalizeLanguageName(toLanguageName(normalized)); const nameKey = normalizeLanguageName(toLanguageName(normalized))
const nameRank = preferenceRanks.get(nameKey); const nameRank = preferenceRanks.get(nameKey)
if (nameRank !== undefined) return nameRank; if (nameRank !== undefined) return nameRank
const rawNameRank = preferenceRanks.get(normalizeLanguageName(value)); const rawNameRank = preferenceRanks.get(normalizeLanguageName(value))
if (rawNameRank !== undefined) return rawNameRank; if (rawNameRank !== undefined) return rawNameRank
return Number.MAX_SAFE_INTEGER; return Number.MAX_SAFE_INTEGER
} }
export function mapLanguageToCountry(code: string): string | null { export function mapLanguageToCountry(code: string): string | null {
const normalized = resolveLanguageIdentifier(code); const normalized = resolveLanguageIdentifier(code)
const direct = LANGUAGE_TO_COUNTRY[normalized]; const direct = LANGUAGE_TO_COUNTRY[normalized]
if (direct) return direct; if (direct) return direct
// region-tag style code like en-us / pt-br / es-mx // region-tag style code like en-us / pt-br / es-mx
const hyphenParts = normalized.split('-'); const hyphenParts = normalized.split("-")
if (hyphenParts.length >= 2) { if (hyphenParts.length >= 2) {
const region = hyphenParts[hyphenParts.length - 1]; const region = hyphenParts[hyphenParts.length - 1]
if (/^[a-z]{2}$/i.test(region)) { if (/^[a-z]{2}$/i.test(region)) {
return region.toUpperCase(); return region.toUpperCase()
} }
} }
return null; return null
} }
export function buildLanguageFlags(codes: string[] | null | undefined): { export function buildLanguageFlags(codes: string[] | null | undefined): {
flags: LanguageFlagEntry[]; flags: LanguageFlagEntry[]
unmappedCodes: string[]; unmappedCodes: string[]
} { } {
if (!codes || codes.length === 0) { if (!codes || codes.length === 0) {
return { flags: [], unmappedCodes: [] }; return { flags: [], unmappedCodes: [] }
} }
const byCountry = new Map<string, string[]>(); const byCountry = new Map<string, string[]>()
const unmapped: string[] = []; const unmapped: string[] = []
const seenUnmapped = new Set<string>(); const seenUnmapped = new Set<string>()
const preferenceRanks = getBrowserPreferenceRanks(); const preferenceRanks = getBrowserPreferenceRanks()
const ordered = codes const ordered = codes
.map((raw, index) => ({ raw, index })) .map((raw, index) => ({ raw, index }))
.filter((v) => Boolean(v.raw && String(v.raw).trim())) .filter((v) => Boolean(v.raw && String(v.raw).trim()))
.sort((a, b) => { .sort((a, b) => {
const aRank = getPreferenceRank(String(a.raw), preferenceRanks); const aRank = getPreferenceRank(String(a.raw), preferenceRanks)
const bRank = getPreferenceRank(String(b.raw), preferenceRanks); const bRank = getPreferenceRank(String(b.raw), preferenceRanks)
if (aRank !== bRank) return aRank - bRank; if (aRank !== bRank) return aRank - bRank
return a.index - b.index; return a.index - b.index
}); })
for (const { raw } of ordered) { for (const { raw } of ordered) {
if (!raw) continue; if (!raw) continue
const countryCode = mapLanguageToCountry(raw); const countryCode = mapLanguageToCountry(raw)
if (!countryCode) { if (!countryCode) {
const upper = raw.toUpperCase(); const upper = raw.toUpperCase()
if (!seenUnmapped.has(upper)) { if (!seenUnmapped.has(upper)) {
seenUnmapped.add(upper); seenUnmapped.add(upper)
unmapped.push(upper); unmapped.push(upper)
} }
continue; continue
} }
if (!FLAGS[countryCode]) { if (!FLAGS[countryCode]) {
const upper = raw.toUpperCase(); const upper = raw.toUpperCase()
if (!seenUnmapped.has(upper)) { if (!seenUnmapped.has(upper)) {
seenUnmapped.add(upper); seenUnmapped.add(upper)
unmapped.push(upper); unmapped.push(upper)
} }
continue; continue
} }
const existing = byCountry.get(countryCode) || []; const existing = byCountry.get(countryCode) || []
existing.push(raw); existing.push(raw)
byCountry.set(countryCode, existing); byCountry.set(countryCode, existing)
} }
const flags: LanguageFlagEntry[] = Array.from(byCountry.entries()).map( const flags: LanguageFlagEntry[] = Array.from(byCountry.entries()).map(
@@ -333,114 +329,114 @@ export function buildLanguageFlags(codes: string[] | null | undefined): {
countryCode, countryCode,
svg: FLAGS[countryCode], svg: FLAGS[countryCode],
sourceCodes, sourceCodes,
}) }),
); )
return { return {
flags, flags,
unmappedCodes: unmapped, unmappedCodes: unmapped,
}; }
} }
const LANGUAGE_NAME_OVERRIDES: Record<string, string> = { const LANGUAGE_NAME_OVERRIDES: Record<string, string> = {
eng: 'English', eng: "English",
spa: 'Spanish', spa: "Spanish",
'spa-la': 'Spanish', "spa-la": "Spanish",
esl: 'Spanish', esl: "Spanish",
spl: 'Spanish', spl: "Spanish",
por: 'Portuguese', por: "Portuguese",
fre: 'French', fre: "French",
fra: 'French', fra: "French",
ger: 'German', ger: "German",
deu: 'German', deu: "German",
ita: 'Italian', ita: "Italian",
nld: 'Dutch', nld: "Dutch",
dut: 'Dutch', dut: "Dutch",
swe: 'Swedish', swe: "Swedish",
nor: 'Norwegian', nor: "Norwegian",
dan: 'Danish', dan: "Danish",
fin: 'Finnish', fin: "Finnish",
pol: 'Polish', pol: "Polish",
ces: 'Czech', ces: "Czech",
cze: 'Czech', cze: "Czech",
hun: 'Hungarian', hun: "Hungarian",
ron: 'Romanian', ron: "Romanian",
rum: 'Romanian', rum: "Romanian",
ell: 'Greek', ell: "Greek",
gre: 'Greek', gre: "Greek",
tur: 'Turkish', tur: "Turkish",
rus: 'Russian', rus: "Russian",
ukr: 'Ukrainian', ukr: "Ukrainian",
bul: 'Bulgarian', bul: "Bulgarian",
srp: 'Serbian', srp: "Serbian",
hrv: 'Croatian', hrv: "Croatian",
slv: 'Slovenian', slv: "Slovenian",
slk: 'Slovak', slk: "Slovak",
slo: 'Slovak', slo: "Slovak",
jpn: 'Japanese', jpn: "Japanese",
kor: 'Korean', kor: "Korean",
zho: 'Chinese', zho: "Chinese",
chi: 'Chinese', chi: "Chinese",
yue: 'Cantonese', yue: "Cantonese",
tha: 'Thai', tha: "Thai",
vie: 'Vietnamese', vie: "Vietnamese",
ind: 'Indonesian', ind: "Indonesian",
msa: 'Malay', msa: "Malay",
may: 'Malay', may: "Malay",
hin: 'Hindi', hin: "Hindi",
ara: 'Arabic', ara: "Arabic",
heb: 'Hebrew', heb: "Hebrew",
fas: 'Persian', fas: "Persian",
per: 'Persian', per: "Persian",
urd: 'Urdu', urd: "Urdu",
swa: 'Swahili', swa: "Swahili",
cat: 'Catalan', cat: "Catalan",
eus: 'Basque', eus: "Basque",
baq: 'Basque', baq: "Basque",
}; }
function toLanguageName(code: string): string { function toLanguageName(code: string): string {
const normalized = normalizeLanguageCode(code); const normalized = normalizeLanguageCode(code)
const override = LANGUAGE_NAME_OVERRIDES[normalized]; const override = LANGUAGE_NAME_OVERRIDES[normalized]
if (override) return override; if (override) return override
const display = new Intl.DisplayNames(['en'], { type: 'language' }); const display = new Intl.DisplayNames(["en"], { type: "language" })
const candidate = display.of(normalized); const candidate = display.of(normalized)
if (candidate) return candidate; if (candidate) return candidate
const base = normalized.split('-', 1)[0]; const base = normalized.split("-", 1)[0]
const baseOverride = LANGUAGE_NAME_OVERRIDES[base]; const baseOverride = LANGUAGE_NAME_OVERRIDES[base]
if (baseOverride) return baseOverride; if (baseOverride) return baseOverride
const baseCandidate = display.of(base); const baseCandidate = display.of(base)
if (baseCandidate) return baseCandidate; if (baseCandidate) return baseCandidate
return code.toUpperCase(); return code.toUpperCase()
} }
function summarizeLanguageCodes(codes: string[] | null | undefined): string { function summarizeLanguageCodes(codes: string[] | null | undefined): string {
if (!codes || codes.length === 0) return ''; if (!codes || codes.length === 0) return ""
const names: string[] = []; const names: string[] = []
const seen = new Set<string>(); const seen = new Set<string>()
for (const raw of codes) { for (const raw of codes) {
if (!raw) continue; if (!raw) continue
const name = toLanguageName(raw).trim(); const name = toLanguageName(raw).trim()
if (!name) continue; if (!name) continue
const key = name.toLowerCase(); const key = name.toLowerCase()
if (seen.has(key)) continue; if (seen.has(key)) continue
seen.add(key); seen.add(key)
names.push(name); names.push(name)
} }
return names.join(', '); return names.join(", ")
} }
export function formatAudioSubtitleSummary( export function formatAudioSubtitleSummary(
audioCodes: string[] | null | undefined, audioCodes: string[] | null | undefined,
subtitleCodes: string[] | null | undefined subtitleCodes: string[] | null | undefined,
): string { ): string {
const audio = summarizeLanguageCodes(audioCodes); const audio = summarizeLanguageCodes(audioCodes)
const subs = summarizeLanguageCodes(subtitleCodes); const subs = summarizeLanguageCodes(subtitleCodes)
if (audio && subs) return `${audio} / ${subs}`; if (audio && subs) return `${audio} / ${subs}`
return audio || subs; return audio || subs
} }
+2 -2
View File
@@ -1,7 +1,7 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
declare module '*.vue' { declare module "*.vue" {
import type { DefineComponent } from 'vue' import type { DefineComponent } from "vue"
const component: DefineComponent<{}, {}, any> const component: DefineComponent<{}, {}, any>
export default component export default component
} }