frontend: add OXC lint/format setup and apply semicolon fixes
This commit is contained in:
+5
-1
@@ -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"
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||||
|
"semi": false,
|
||||||
|
"ignorePatterns": []
|
||||||
|
}
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
+100
-95
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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% {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+2
-2
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user