frontend: add OXC lint/format setup and apply semicolon fixes
This commit is contained in:
@@ -4,9 +4,17 @@
|
||||
<div class="collage-grid">
|
||||
<template v-for="(item, index) in collageItems" :key="item.id">
|
||||
<div
|
||||
:ref="el => setItemRef(el as HTMLElement, index)"
|
||||
:ref="(el) => setItemRef(el as HTMLElement, index)"
|
||||
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)"
|
||||
@click="handleItemClick(item, index)"
|
||||
@focus="focusedIndex = index"
|
||||
@@ -21,8 +29,15 @@
|
||||
/>
|
||||
</div>
|
||||
<!-- 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">
|
||||
<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
|
||||
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>
|
||||
<!-- Non-featured items: image, video, or placeholder -->
|
||||
<img
|
||||
@@ -44,124 +59,146 @@
|
||||
:src="source.src"
|
||||
:type="source.type"
|
||||
:codecs="source.codecs"
|
||||
>
|
||||
/>
|
||||
</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>
|
||||
</div>
|
||||
<div class="collage-item-overlay"></div>
|
||||
<!-- 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">
|
||||
<polygon points="43.3,0 86.6,25 86.6,75 43.3,100 0,75 0,25" />
|
||||
</svg>
|
||||
<!-- Item 2 uses a custom flat-bottom hex outline to match its clip-path -->
|
||||
<svg v-if="index === 2" class="hex-focus-outline hex-focus-small" viewBox="0 0 86.6 100" preserveAspectRatio="none">
|
||||
<polygon points="43.3,0 86.6,33.333 86.6,100 0,100 0,33.333" />
|
||||
</svg>
|
||||
<!-- Top-row items use a custom flat-top hex outline to match their clip-path -->
|
||||
<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 class="collage-item-overlay"></div>
|
||||
<!-- 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"
|
||||
>
|
||||
<polygon points="43.3,0 86.6,25 86.6,75 43.3,100 0,75 0,25" />
|
||||
</svg>
|
||||
<!-- Item 2 uses a custom flat-bottom hex outline to match its clip-path -->
|
||||
<svg
|
||||
v-if="index === 2"
|
||||
class="hex-focus-outline hex-focus-small"
|
||||
viewBox="0 0 86.6 100"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<polygon points="43.3,0 86.6,33.333 86.6,100 0,100 0,33.333" />
|
||||
</svg>
|
||||
<!-- Top-row items use a custom flat-top hex outline to match their clip-path -->
|
||||
<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>
|
||||
<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 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>
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted, onUnmounted, nextTick, watch } from 'vue';
|
||||
import type { MediaItem, Movie, Series } from '../types';
|
||||
import { getCoverUrl, getVideoPreviewUrl, getVideoSourceAttributes, isVideoPath } from '../api';
|
||||
import { navAttrs } from '../composables/useKeyboardNavigation';
|
||||
import { computed, ref, onMounted, onUnmounted, nextTick, watch } from "vue"
|
||||
import type { MediaItem, Movie, Series } from "../types"
|
||||
import { getCoverUrl, getVideoPreviewUrl, getVideoSourceAttributes, isVideoPath } from "../api"
|
||||
import { navAttrs } from "../composables/useKeyboardNavigation"
|
||||
|
||||
const focusedIndex = ref<number | null>(null);
|
||||
const itemRefs = ref<(HTMLElement | null)[]>([]);
|
||||
const focusedIndex = ref<number | null>(null)
|
||||
const itemRefs = ref<(HTMLElement | null)[]>([])
|
||||
// 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)
|
||||
watch(focusedIndex, (newVal, oldVal) => {
|
||||
if (newVal === 0 && oldVal === null) {
|
||||
// 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)
|
||||
const visibleItems = ref<Set<number>>(new Set());
|
||||
const visibleItems = ref<Set<number>>(new Set())
|
||||
|
||||
function setItemRef(el: HTMLElement | null, index: number) {
|
||||
itemRefs.value[index] = el;
|
||||
itemRefs.value[index] = el
|
||||
}
|
||||
|
||||
// Check if element is at least 75% visible horizontally
|
||||
function isElementVisible(el: HTMLElement | null): boolean {
|
||||
if (!el) return false;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
if (!el) return false
|
||||
const rect = el.getBoundingClientRect()
|
||||
const viewportWidth = window.innerWidth
|
||||
|
||||
// Calculate how much of the element is visible
|
||||
const visibleLeft = Math.max(0, rect.left);
|
||||
const visibleRight = Math.min(viewportWidth, rect.right);
|
||||
const visibleWidth = Math.max(0, visibleRight - visibleLeft);
|
||||
const visibleRatio = visibleWidth / rect.width;
|
||||
const visibleLeft = Math.max(0, rect.left)
|
||||
const visibleRight = Math.min(viewportWidth, rect.right)
|
||||
const visibleWidth = Math.max(0, visibleRight - visibleLeft)
|
||||
const visibleRatio = visibleWidth / rect.width
|
||||
|
||||
return visibleRatio >= 0.75;
|
||||
return visibleRatio >= 0.75
|
||||
}
|
||||
|
||||
function updateVisibility() {
|
||||
const newVisible = new Set<number>();
|
||||
const newVisible = new Set<number>()
|
||||
|
||||
for (let i = 0; i < itemRefs.value.length; i++) {
|
||||
// Always include items 0, 1, 2 (big image and left side)
|
||||
if (i <= 2 || isElementVisible(itemRefs.value[i])) {
|
||||
newVisible.add(i);
|
||||
newVisible.add(i)
|
||||
}
|
||||
}
|
||||
|
||||
visibleItems.value = newVisible;
|
||||
visibleItems.value = newVisible
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', updateVisibility);
|
||||
document.addEventListener('focusin', handleDocumentFocusIn);
|
||||
window.addEventListener("resize", updateVisibility)
|
||||
document.addEventListener("focusin", handleDocumentFocusIn)
|
||||
// Initial visibility check after render
|
||||
nextTick(() => {
|
||||
updateVisibility();
|
||||
});
|
||||
});
|
||||
updateVisibility()
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', updateVisibility);
|
||||
document.removeEventListener('focusin', handleDocumentFocusIn);
|
||||
});
|
||||
window.removeEventListener("resize", updateVisibility)
|
||||
document.removeEventListener("focusin", handleDocumentFocusIn)
|
||||
})
|
||||
|
||||
function clearHeroFocus() {
|
||||
focusedIndex.value = null;
|
||||
focusedIndex.value = null
|
||||
}
|
||||
|
||||
function handleDocumentFocusIn(event: FocusEvent) {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (!target?.closest('.collage-hero')) {
|
||||
clearHeroFocus();
|
||||
const target = event.target as HTMLElement | null
|
||||
if (!target?.closest(".collage-hero")) {
|
||||
clearHeroFocus()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,20 +217,23 @@ function handleDocumentFocusIn(event: FocusEvent) {
|
||||
// col 4: fourth right column (12, 14, 13)
|
||||
// col 5: fifth right column (15, 16)
|
||||
|
||||
interface NavCoord { row: number; col: number }
|
||||
interface NavCoord {
|
||||
row: number
|
||||
col: number
|
||||
}
|
||||
const coordMap: Record<number, NavCoord> = {
|
||||
0: { row: 1, col: 0 }, // Big image
|
||||
0: { row: 1, col: 0 }, // Big image
|
||||
// Left side (col -1)
|
||||
1: { row: 0, col: -1 }, // top left
|
||||
2: { row: 2, col: -1 }, // bottom left
|
||||
1: { row: 0, col: -1 }, // top left
|
||||
2: { row: 2, col: -1 }, // bottom left
|
||||
// Right side - sequential columns
|
||||
3: { row: 0, col: 1 },
|
||||
4: { row: 2, col: 1 },
|
||||
5: { row: 1, col: 1 },
|
||||
6: { row: 0, col: 2 },
|
||||
7: { row: 2, col: 2 },
|
||||
8: { row: 1, col: 2 },
|
||||
9: { row: 0, col: 3 },
|
||||
3: { row: 0, col: 1 },
|
||||
4: { row: 2, col: 1 },
|
||||
5: { row: 1, col: 1 },
|
||||
6: { row: 0, col: 2 },
|
||||
7: { row: 2, col: 2 },
|
||||
8: { row: 1, col: 2 },
|
||||
9: { row: 0, col: 3 },
|
||||
10: { row: 2, col: 3 },
|
||||
11: { row: 1, col: 3 },
|
||||
12: { row: 0, col: 4 },
|
||||
@@ -211,198 +251,198 @@ const coordMap: Record<number, NavCoord> = {
|
||||
24: { row: 0, col: 8 },
|
||||
25: { row: 2, col: 8 },
|
||||
26: { row: 1, col: 8 },
|
||||
};
|
||||
}
|
||||
|
||||
function isTopRowItem(index: number): boolean {
|
||||
const coord = coordMap[index];
|
||||
return index !== 0 && coord?.row === 0;
|
||||
const coord = coordMap[index]
|
||||
return index !== 0 && coord?.row === 0
|
||||
}
|
||||
|
||||
// Reverse lookup: find item index at given coordinates
|
||||
function findItemAt(row: number, col: number): number | null {
|
||||
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
|
||||
function findNext(currentIdx: number, direction: 'up' | 'down' | 'left' | 'right'): number | null {
|
||||
const current = coordMap[currentIdx];
|
||||
if (!current) return null;
|
||||
function findNext(currentIdx: number, direction: "up" | "down" | "left" | "right"): number | null {
|
||||
const current = coordMap[currentIdx]
|
||||
if (!current) return null
|
||||
|
||||
// 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
|
||||
const targetCol = current.col - 1;
|
||||
const targetCol = current.col - 1
|
||||
|
||||
if (current.col === 0) {
|
||||
// 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)
|
||||
const leftItem = findItemAt(effectiveRow, -1);
|
||||
const leftItem = findItemAt(effectiveRow, -1)
|
||||
if (leftItem !== null) {
|
||||
return leftItem;
|
||||
return leftItem
|
||||
}
|
||||
// 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) {
|
||||
lastRow.value = 0;
|
||||
return topLeftItem;
|
||||
lastRow.value = 0
|
||||
return topLeftItem
|
||||
}
|
||||
// No fallback available - stay put
|
||||
return 0;
|
||||
return 0
|
||||
}
|
||||
|
||||
if (targetCol < -1) {
|
||||
// Already at col -1, can't go further left - stay put
|
||||
return currentIdx;
|
||||
return currentIdx
|
||||
}
|
||||
|
||||
if (targetCol === 0) {
|
||||
// Moving to big image - remember current row
|
||||
lastRow.value = current.row;
|
||||
return 0;
|
||||
lastRow.value = current.row
|
||||
return 0
|
||||
}
|
||||
// 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
|
||||
const targetCol = current.col + 1;
|
||||
const targetCol = current.col + 1
|
||||
|
||||
if (current.col === 0) {
|
||||
// From big image, go to col 1 at remembered row
|
||||
return findItemAt(lastRow.value, 1);
|
||||
return findItemAt(lastRow.value, 1)
|
||||
}
|
||||
if (current.col === -1) {
|
||||
// From left column, go to big image
|
||||
lastRow.value = current.row;
|
||||
return 0;
|
||||
lastRow.value = current.row
|
||||
return 0
|
||||
}
|
||||
// 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)) {
|
||||
return nextItem;
|
||||
return nextItem
|
||||
}
|
||||
// No more visible items to the right - stay put
|
||||
return currentIdx;
|
||||
return currentIdx
|
||||
}
|
||||
|
||||
// Helper to check if item at row/col is visible
|
||||
const isItemVisibleAt = (row: number, col: number) => {
|
||||
const item = findItemAt(row, col);
|
||||
return item !== null && visibleItems.value.has(item);
|
||||
};
|
||||
const item = findItemAt(row, col)
|
||||
return item !== null && visibleItems.value.has(item)
|
||||
}
|
||||
|
||||
if (direction === 'up') {
|
||||
const targetRow = effectiveRow - 1;
|
||||
if (targetRow < 0) return null; // Exit up
|
||||
if (direction === "up") {
|
||||
const targetRow = effectiveRow - 1
|
||||
if (targetRow < 0) return null // Exit up
|
||||
|
||||
if (current.col === 0) {
|
||||
// Big image: up/down always exits the hero section
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
// Check if target row item at same col is visible
|
||||
if (!isItemVisibleAt(targetRow, current.col)) {
|
||||
// Skip to row above if middle row is not visible at this column
|
||||
if (targetRow === 1 && isItemVisibleAt(0, current.col)) {
|
||||
const item = findItemAt(0, current.col);
|
||||
if (item !== null) return item;
|
||||
const item = findItemAt(0, current.col)
|
||||
if (item !== null) return item
|
||||
}
|
||||
return currentIdx; // Stay put
|
||||
return currentIdx // Stay put
|
||||
}
|
||||
|
||||
// Find item at row-1, same col (or nearest)
|
||||
let item = findItemAt(targetRow, current.col);
|
||||
if (item !== null) return item;
|
||||
let item = findItemAt(targetRow, current.col)
|
||||
if (item !== null) return item
|
||||
// Try to find nearest col in target row
|
||||
for (let c = current.col; c >= -1; c--) {
|
||||
item = findItemAt(targetRow, c);
|
||||
if (item !== null && isItemVisibleAt(targetRow, c)) return item;
|
||||
item = findItemAt(targetRow, c)
|
||||
if (item !== null && isItemVisibleAt(targetRow, c)) return item
|
||||
}
|
||||
for (let c = current.col + 1; c <= 10; c++) {
|
||||
item = findItemAt(targetRow, c);
|
||||
if (item !== null && isItemVisibleAt(targetRow, c)) return item;
|
||||
item = findItemAt(targetRow, c)
|
||||
if (item !== null && isItemVisibleAt(targetRow, c)) return item
|
||||
}
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
if (direction === 'down') {
|
||||
const targetRow = effectiveRow + 1;
|
||||
if (targetRow > 2) return null; // Exit down
|
||||
if (direction === "down") {
|
||||
const targetRow = effectiveRow + 1
|
||||
if (targetRow > 2) return null // Exit down
|
||||
|
||||
if (current.col === 0) {
|
||||
// Big image: up/down always exits the hero section
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
// Check if target row item at same col is visible
|
||||
if (!isItemVisibleAt(targetRow, current.col)) {
|
||||
// Skip to row below if middle row is not visible at this column
|
||||
if (targetRow === 1 && isItemVisibleAt(2, current.col)) {
|
||||
const item = findItemAt(2, current.col);
|
||||
if (item !== null) return item;
|
||||
const item = findItemAt(2, current.col)
|
||||
if (item !== null) return item
|
||||
}
|
||||
return currentIdx; // Stay put
|
||||
return currentIdx // Stay put
|
||||
}
|
||||
|
||||
// Find item at row+1, same col (or nearest)
|
||||
let item = findItemAt(targetRow, current.col);
|
||||
if (item !== null) return item;
|
||||
let item = findItemAt(targetRow, current.col)
|
||||
if (item !== null) return item
|
||||
// Try to find nearest col in target row
|
||||
for (let c = current.col; c >= -1; c--) {
|
||||
item = findItemAt(targetRow, c);
|
||||
if (item !== null && isItemVisibleAt(targetRow, c)) return item;
|
||||
item = findItemAt(targetRow, c)
|
||||
if (item !== null && isItemVisibleAt(targetRow, c)) return item
|
||||
}
|
||||
for (let c = current.col + 1; c <= 10; c++) {
|
||||
item = findItemAt(targetRow, c);
|
||||
if (item !== null && isItemVisibleAt(targetRow, c)) return item;
|
||||
item = findItemAt(targetRow, c)
|
||||
if (item !== null && isItemVisibleAt(targetRow, c)) return item
|
||||
}
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
// Only handle if focus is within this component
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest('.collage-hero')) return;
|
||||
const target = e.target as HTMLElement
|
||||
if (!target.closest(".collage-hero")) return
|
||||
|
||||
const direction = {
|
||||
ArrowUp: 'up',
|
||||
ArrowDown: 'down',
|
||||
ArrowLeft: 'left',
|
||||
ArrowRight: 'right',
|
||||
}[e.key] as 'up' | 'down' | 'left' | 'right' | undefined;
|
||||
ArrowUp: "up",
|
||||
ArrowDown: "down",
|
||||
ArrowLeft: "left",
|
||||
ArrowRight: "right",
|
||||
}[e.key] as "up" | "down" | "left" | "right" | undefined
|
||||
|
||||
if (!direction) {
|
||||
if (e.key === 'Enter' && focusedIndex.value !== null) {
|
||||
const item = collageItems.value[focusedIndex.value];
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (item) handleItemClick(item, focusedIndex.value);
|
||||
if (e.key === "Enter" && focusedIndex.value !== null) {
|
||||
const item = collageItems.value[focusedIndex.value]
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (item) handleItemClick(item, focusedIndex.value)
|
||||
}
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
const current = focusedIndex.value ?? 0;
|
||||
const next = findNext(current, direction);
|
||||
const current = focusedIndex.value ?? 0
|
||||
const next = findNext(current, direction)
|
||||
|
||||
if (next !== null && itemRefs.value[next]) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
focusedIndex.value = next;
|
||||
itemRefs.value[next]?.focus({ preventScroll: true });
|
||||
return;
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
focusedIndex.value = next
|
||||
itemRefs.value[next]?.focus({ preventScroll: true })
|
||||
return
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -410,154 +450,167 @@ function handleKeyDown(e: KeyboardEvent) {
|
||||
function getItemAttrs(index: number) {
|
||||
if (index === 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
|
||||
// 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
|
||||
function isItemVisible(index: number): boolean {
|
||||
// Always show the first few items (big image and left side)
|
||||
if (index <= 2) return true;
|
||||
return visibleItems.value.has(index);
|
||||
if (index <= 2) return true
|
||||
return visibleItems.value.has(index)
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
items: MediaItem[];
|
||||
featuredItem?: MediaItem | null;
|
||||
hasResumePosition: (filePath: string | null) => boolean;
|
||||
}>();
|
||||
items: MediaItem[]
|
||||
featuredItem?: MediaItem | null
|
||||
hasResumePosition: (filePath: string | null) => boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
play: [string];
|
||||
info: [MediaItem];
|
||||
select: [MediaItem];
|
||||
}>();
|
||||
play: [string]
|
||||
info: [MediaItem]
|
||||
select: [MediaItem]
|
||||
}>()
|
||||
|
||||
// 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
|
||||
const collageItems = computed(() => {
|
||||
const result: MediaItem[] = [];
|
||||
const result: MediaItem[] = []
|
||||
|
||||
// Add featured item first
|
||||
if (props.featuredItem) {
|
||||
result.push(props.featuredItem);
|
||||
result.push(props.featuredItem)
|
||||
}
|
||||
|
||||
// Add more items, avoiding duplicates, up to max needed
|
||||
for (const item of props.items) {
|
||||
if (result.length >= maxItems) break;
|
||||
if (!result.find(r => r.id === item.id)) {
|
||||
result.push(item);
|
||||
if (result.length >= maxItems) break
|
||||
if (!result.find((r) => r.id === item.id)) {
|
||||
result.push(item)
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
return result
|
||||
})
|
||||
|
||||
function getImageUrl(item: MediaItem): string | undefined {
|
||||
const coverPath = item.cover_path;
|
||||
const backdropPath = item.type === 'movies'
|
||||
? (item.data as Movie).backdrop_path
|
||||
: (item.data as Series).backdrop_path;
|
||||
const imagePath = coverPath || backdropPath;
|
||||
const coverPath = item.cover_path
|
||||
const backdropPath =
|
||||
item.type === "movies"
|
||||
? (item.data as Movie).backdrop_path
|
||||
: (item.data as Series).backdrop_path
|
||||
const imagePath = coverPath || backdropPath
|
||||
|
||||
// Check if the path is an image (not a video)
|
||||
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 }> {
|
||||
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) {
|
||||
return showreelSourceSets[0]
|
||||
.filter(path => isVideoPath(path))
|
||||
.map(path => ({ src: getVideoPreviewUrl(getCoverUrl(path, item.root_id)), ...getVideoSourceAttributes(path) }));
|
||||
.filter((path) => isVideoPath(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) {
|
||||
return showreel
|
||||
.filter(path => isVideoPath(path))
|
||||
.map(path => ({ src: getVideoPreviewUrl(getCoverUrl(path, item.root_id)), ...getVideoSourceAttributes(path) }));
|
||||
.filter((path) => isVideoPath(path))
|
||||
.map((path) => ({
|
||||
src: getVideoPreviewUrl(getCoverUrl(path, item.root_id)),
|
||||
...getVideoSourceAttributes(path),
|
||||
}))
|
||||
}
|
||||
|
||||
return [];
|
||||
return []
|
||||
}
|
||||
|
||||
function getRating(item: MediaItem): number | null {
|
||||
if (item.type === 'movies') {
|
||||
return (item.data as Movie).info?.rating ?? null;
|
||||
if (item.type === "movies") {
|
||||
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 {
|
||||
const rating = getRating(item);
|
||||
if (!rating) return '';
|
||||
if (rating >= 7.5) return 'rating-high';
|
||||
if (rating >= 6) return 'rating-medium';
|
||||
return 'rating-low';
|
||||
const rating = getRating(item)
|
||||
if (!rating) return ""
|
||||
if (rating >= 7.5) return "rating-high"
|
||||
if (rating >= 6) return "rating-medium"
|
||||
return "rating-low"
|
||||
}
|
||||
|
||||
function getResolution(item: MediaItem): string | null {
|
||||
if (item.type === 'movies') {
|
||||
const movie = item.data as Movie;
|
||||
return Object.values(movie.torrents || {})[0]?.resolution ?? null;
|
||||
if (item.type === "movies") {
|
||||
const movie = item.data as Movie
|
||||
return Object.values(movie.torrents || {})[0]?.resolution ?? null
|
||||
}
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
function getOverview(item: MediaItem): string | null {
|
||||
const overview = item.type === 'movies'
|
||||
? (item.data as Movie).info?.overview
|
||||
: (item.data as Series).info?.overview;
|
||||
if (!overview) return null;
|
||||
return overview.length > 150 ? overview.slice(0, 150) + '...' : overview;
|
||||
const overview =
|
||||
item.type === "movies"
|
||||
? (item.data as Movie).info?.overview
|
||||
: (item.data as Series).info?.overview
|
||||
if (!overview) return null
|
||||
return overview.length > 150 ? overview.slice(0, 150) + "..." : overview
|
||||
}
|
||||
|
||||
function getPlayableFile(item: MediaItem): string | null {
|
||||
if (item.type === 'movies') {
|
||||
const movie = item.data as Movie;
|
||||
return Object.values(movie.torrents || {})[0]?.playable_file ?? null;
|
||||
if (item.type === "movies") {
|
||||
const movie = item.data as Movie
|
||||
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 episode of season.episodes || []) {
|
||||
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) {
|
||||
const file = getPlayableFile(item);
|
||||
if (file) emit('play', file);
|
||||
const file = getPlayableFile(item)
|
||||
if (file) emit("play", file)
|
||||
}
|
||||
|
||||
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) {
|
||||
if (index === 0) {
|
||||
emit('info', item);
|
||||
emit("info", item)
|
||||
} else {
|
||||
emit('select', item);
|
||||
emit("select", item)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -571,7 +624,7 @@ function handleItemClick(item: MediaItem, index: number) {
|
||||
overflow: visible;
|
||||
background: var(--bg-primary);
|
||||
--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 */
|
||||
}
|
||||
|
||||
@@ -584,7 +637,11 @@ function handleItemClick(item: MediaItem, index: number) {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
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 */
|
||||
@@ -595,7 +652,11 @@ function handleItemClick(item: MediaItem, index: number) {
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
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 */
|
||||
@@ -637,7 +698,8 @@ html:not(.mouse-active) .collage-item.nav-focused .hex-focus-outline {
|
||||
|
||||
/* Blinking animation for focus outline */
|
||||
@keyframes hex-outline-blink {
|
||||
0%, 100% {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
@@ -677,16 +739,16 @@ html:not(.mouse-active) .collage-item.nav-focused .hex-focus-outline {
|
||||
clip-path: polygon(
|
||||
16.67% 0%,
|
||||
16.67% 25%,
|
||||
0% 37.5%,
|
||||
0% 62.5%,
|
||||
0% 37.5%,
|
||||
0% 62.5%,
|
||||
16.67% 75%,
|
||||
16.67% 100%,
|
||||
|
||||
83.33% 100%,
|
||||
|
||||
83.33% 75%,
|
||||
100% 62.5%,
|
||||
100% 37.5%,
|
||||
100% 62.5%,
|
||||
100% 37.5%,
|
||||
83.33% 25%,
|
||||
83.33% 0%
|
||||
);
|
||||
@@ -709,16 +771,16 @@ html:not(.mouse-active) .collage-item.nav-focused .hex-focus-outline {
|
||||
clip-path: polygon(
|
||||
16.67% 0%,
|
||||
16.67% 25%,
|
||||
0% 37.5%,
|
||||
0% 62.5%,
|
||||
0% 37.5%,
|
||||
0% 62.5%,
|
||||
16.67% 75%,
|
||||
16.67% 100%,
|
||||
|
||||
83.33% 100%,
|
||||
|
||||
83.33% 75%,
|
||||
100% 62.5%,
|
||||
100% 37.5%,
|
||||
100% 62.5%,
|
||||
100% 37.5%,
|
||||
83.33% 25%,
|
||||
83.33% 0%
|
||||
);
|
||||
@@ -731,14 +793,7 @@ html:not(.mouse-active) .collage-item.nav-focused .hex-focus-outline {
|
||||
.collage-item:not(.collage-featured) {
|
||||
height: 50%;
|
||||
aspect-ratio: 0.866 / 1;
|
||||
clip-path: polygon(
|
||||
50% 0%,
|
||||
100% 25%,
|
||||
100% 75%,
|
||||
50% 100%,
|
||||
0% 75%,
|
||||
0% 25%
|
||||
);
|
||||
clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%);
|
||||
}
|
||||
|
||||
/* 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) {
|
||||
height: 37.5%;
|
||||
width: var(--small-w);
|
||||
clip-path: polygon(
|
||||
50% 0%,
|
||||
100% 33.333%,
|
||||
100% 100%,
|
||||
0% 100%,
|
||||
0% 33.333%
|
||||
);
|
||||
clip-path: polygon(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 */
|
||||
@@ -772,13 +821,7 @@ html:not(.mouse-active) .collage-item.nav-focused .hex-focus-outline {
|
||||
top: 0;
|
||||
height: 37.5%;
|
||||
width: var(--small-w);
|
||||
clip-path: polygon(
|
||||
100% 0%,
|
||||
100% 66.667%,
|
||||
50% 100%,
|
||||
0% 66.667%,
|
||||
0% 0%
|
||||
);
|
||||
clip-path: polygon(100% 0%, 100% 66.667%, 50% 100%, 0% 66.667%, 0% 0%);
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: #e50914;
|
||||
@@ -942,7 +985,7 @@ html:not(.mouse-active) .collage-item.nav-focused .hex-focus-outline {
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -987,9 +1030,15 @@ html:not(.mouse-active) .collage-item.nav-focused .hex-focus-outline {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.rating-high { color: #46d369; }
|
||||
.rating-medium { color: #f9a825; }
|
||||
.rating-low { color: #e53935; }
|
||||
.rating-high {
|
||||
color: #46d369;
|
||||
}
|
||||
.rating-medium {
|
||||
color: #f9a825;
|
||||
}
|
||||
.rating-low {
|
||||
color: #e53935;
|
||||
}
|
||||
|
||||
.meta-quality {
|
||||
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);
|
||||
border-radius: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
transition:
|
||||
opacity 0.3s ease,
|
||||
transform 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,43 +1,39 @@
|
||||
<template>
|
||||
<div v-if="showDolbyLogo" class="dolby-badges" :class="{ compact }">
|
||||
<img
|
||||
:src="dolbyLogoSrc"
|
||||
:alt="dolbyLogoAlt"
|
||||
class="dolby-logo"
|
||||
/>
|
||||
<img :src="dolbyLogoSrc" :alt="dolbyLogoAlt" class="dolby-logo" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import dolbyAtmosUrl from '../assets/dolby-atmos.webp';
|
||||
import dolbyVisionUrl from '../assets/dolby-vision.webp';
|
||||
import dolbyVisionAtmosUrl from '../assets/dolby-vision-atmos.webp';
|
||||
import { computed } from "vue"
|
||||
import dolbyAtmosUrl from "../assets/dolby-atmos.webp"
|
||||
import dolbyVisionUrl from "../assets/dolby-vision.webp"
|
||||
import dolbyVisionAtmosUrl from "../assets/dolby-vision-atmos.webp"
|
||||
|
||||
const props = defineProps<{
|
||||
hasDolbyVision?: boolean | null;
|
||||
hasDolbyAtmos?: boolean | null;
|
||||
isHdr?: boolean | null;
|
||||
compact?: boolean;
|
||||
}>();
|
||||
hasDolbyVision?: boolean | null
|
||||
hasDolbyAtmos?: boolean | null
|
||||
isHdr?: boolean | null
|
||||
compact?: boolean
|
||||
}>()
|
||||
|
||||
const hasDolbyVision = computed(() => props.hasDolbyVision === true);
|
||||
const hasDolbyAtmos = computed(() => props.hasDolbyAtmos === true);
|
||||
const compact = computed(() => props.compact === true);
|
||||
const hasDolbyVision = computed(() => props.hasDolbyVision === true)
|
||||
const hasDolbyAtmos = computed(() => props.hasDolbyAtmos === true)
|
||||
const compact = computed(() => props.compact === true)
|
||||
|
||||
const showDolbyLogo = computed(() => hasDolbyVision.value || hasDolbyAtmos.value);
|
||||
const showDolbyLogo = computed(() => hasDolbyVision.value || hasDolbyAtmos.value)
|
||||
|
||||
const dolbyLogoSrc = computed(() => {
|
||||
if (hasDolbyVision.value && hasDolbyAtmos.value) return dolbyVisionAtmosUrl;
|
||||
if (hasDolbyVision.value) return dolbyVisionUrl;
|
||||
return dolbyAtmosUrl;
|
||||
});
|
||||
if (hasDolbyVision.value && hasDolbyAtmos.value) return dolbyVisionAtmosUrl
|
||||
if (hasDolbyVision.value) return dolbyVisionUrl
|
||||
return dolbyAtmosUrl
|
||||
})
|
||||
|
||||
const dolbyLogoAlt = computed(() => {
|
||||
if (hasDolbyVision.value && hasDolbyAtmos.value) return 'Dolby Vision + Dolby Atmos';
|
||||
if (hasDolbyVision.value) return 'Dolby Vision';
|
||||
return 'Dolby Atmos';
|
||||
});
|
||||
if (hasDolbyVision.value && hasDolbyAtmos.value) return "Dolby Vision + Dolby Atmos"
|
||||
if (hasDolbyVision.value) return "Dolby Vision"
|
||||
return "Dolby Atmos"
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
+124
-113
@@ -26,19 +26,10 @@
|
||||
</template>
|
||||
<!-- Detail mode: show current category + Details -->
|
||||
<template v-else>
|
||||
<button
|
||||
class="header-nav-item"
|
||||
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 class="header-nav-item" 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>
|
||||
</template>
|
||||
</nav>
|
||||
</div>
|
||||
@@ -63,14 +54,22 @@
|
||||
</div>
|
||||
|
||||
<div class="header-settings">
|
||||
<button
|
||||
class="header-settings-btn"
|
||||
title="Settings"
|
||||
@click="showSettings = !showSettings"
|
||||
>
|
||||
<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">
|
||||
<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"/>
|
||||
<button class="header-settings-btn" title="Settings" @click="showSettings = !showSettings">
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
|
||||
@@ -78,8 +77,18 @@
|
||||
<div v-if="showSettings" class="settings-view">
|
||||
<div class="settings-header">
|
||||
<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">
|
||||
<path d="M19 12H5M12 19l-7-7 7-7"/>
|
||||
<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"
|
||||
>
|
||||
<path d="M19 12H5M12 19l-7-7 7-7" />
|
||||
</svg>
|
||||
<span>Back</span>
|
||||
</button>
|
||||
@@ -118,11 +127,7 @@
|
||||
</div>
|
||||
|
||||
<div class="roots-actions">
|
||||
<button
|
||||
v-if="isDesktopApp"
|
||||
class="roots-add-btn"
|
||||
@click="addRoot"
|
||||
>
|
||||
<button v-if="isDesktopApp" class="roots-add-btn" @click="addRoot">
|
||||
+ Add Folder…
|
||||
</button>
|
||||
</div>
|
||||
@@ -134,125 +139,127 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed, onMounted, onUnmounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { navAttrs } from '../composables/useKeyboardNavigation';
|
||||
import logoUrl from '../assets/mediahive.webp';
|
||||
import { fetchRoots, replaceRoots, pickFolderAndAddRoot } from '../api';
|
||||
import { ref, watch, computed, onMounted, onUnmounted } from "vue"
|
||||
import { useRouter } from "vue-router"
|
||||
import { navAttrs } from "../composables/useKeyboardNavigation"
|
||||
import logoUrl from "../assets/mediahive.webp"
|
||||
import { fetchRoots, replaceRoots, pickFolderAndAddRoot } from "../api"
|
||||
|
||||
interface RootEntry {
|
||||
root_id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
status: string;
|
||||
root_id: string
|
||||
name: string
|
||||
path: string
|
||||
status: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
currentView: 'movies' | 'series';
|
||||
searchQuery: string;
|
||||
mpcBeConnected: boolean;
|
||||
navRow: number;
|
||||
position: 'top' | 'after-hero' | 'after-movie-header' | 'after-series-hero';
|
||||
}>();
|
||||
currentView: "movies" | "series"
|
||||
searchQuery: string
|
||||
mpcBeConnected: boolean
|
||||
navRow: number
|
||||
position: "top" | "after-hero" | "after-movie-header" | "after-series-hero"
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
search: [string];
|
||||
goBack: [];
|
||||
}>();
|
||||
search: [string]
|
||||
goBack: []
|
||||
}>()
|
||||
|
||||
const router = useRouter();
|
||||
const searchInputRef = ref<HTMLInputElement | null>(null);
|
||||
const localSearch = ref(props.searchQuery);
|
||||
const router = useRouter()
|
||||
const searchInputRef = ref<HTMLInputElement | null>(null)
|
||||
const localSearch = ref(props.searchQuery)
|
||||
|
||||
const isDesktopApp = ref(typeof (window as any).pywebview !== 'undefined');
|
||||
function _onPywebviewReady() { isDesktopApp.value = true; }
|
||||
window.addEventListener('pywebviewready', _onPywebviewReady, { once: true });
|
||||
onUnmounted(() => window.removeEventListener('pywebviewready', _onPywebviewReady));
|
||||
const isDesktopApp = ref(typeof (window as any).pywebview !== "undefined")
|
||||
function _onPywebviewReady() {
|
||||
isDesktopApp.value = true
|
||||
}
|
||||
window.addEventListener("pywebviewready", _onPywebviewReady, { once: true })
|
||||
onUnmounted(() => window.removeEventListener("pywebviewready", _onPywebviewReady))
|
||||
|
||||
const showSettings = ref(false);
|
||||
const roots = ref<RootEntry[]>([]);
|
||||
const showSettings = ref(false)
|
||||
const roots = ref<RootEntry[]>([])
|
||||
|
||||
async function refreshRoots() {
|
||||
try {
|
||||
const data = await fetchRoots();
|
||||
roots.value = data.map(r => ({
|
||||
const data = await fetchRoots()
|
||||
roots.value = data.map((r) => ({
|
||||
root_id: r.root_id,
|
||||
name: r.name,
|
||||
path: r.path,
|
||||
status: r.status,
|
||||
}));
|
||||
}))
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch roots:', e);
|
||||
console.error("Failed to fetch roots:", e)
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRoot(rootId: string) {
|
||||
const filtered = roots.value.filter(r => r.root_id !== rootId);
|
||||
const newRoots = Object.fromEntries(filtered.map(r => [r.name, r.path]));
|
||||
const filtered = roots.value.filter((r) => r.root_id !== rootId)
|
||||
const newRoots = Object.fromEntries(filtered.map((r) => [r.name, r.path]))
|
||||
try {
|
||||
await replaceRoots(newRoots);
|
||||
await refreshRoots();
|
||||
await replaceRoots(newRoots)
|
||||
await refreshRoots()
|
||||
} catch (e) {
|
||||
console.error('Failed to remove root:', e);
|
||||
alert('Failed to remove root');
|
||||
console.error("Failed to remove root:", e)
|
||||
alert("Failed to remove root")
|
||||
}
|
||||
}
|
||||
|
||||
async function addRoot() {
|
||||
const folder = await pickFolderAndAddRoot();
|
||||
if (!folder) return;
|
||||
const name = folder.split('/').pop() || folder.split('\\').pop() || 'media';
|
||||
const folder = await pickFolderAndAddRoot()
|
||||
if (!folder) return
|
||||
const name = folder.split("/").pop() || folder.split("\\").pop() || "media"
|
||||
// Resolve name collisions
|
||||
let uniqueName = name;
|
||||
let suffix = 2;
|
||||
const currentNames = new Set(roots.value.map(r => r.name));
|
||||
let uniqueName = name
|
||||
let suffix = 2
|
||||
const currentNames = new Set(roots.value.map((r) => r.name))
|
||||
while (currentNames.has(uniqueName)) {
|
||||
uniqueName = `${name}${suffix}`;
|
||||
suffix++;
|
||||
uniqueName = `${name}${suffix}`
|
||||
suffix++
|
||||
}
|
||||
const newRoots = Object.fromEntries(roots.value.map(r => [r.name, r.path]));
|
||||
newRoots[uniqueName] = folder;
|
||||
const newRoots = Object.fromEntries(roots.value.map((r) => [r.name, r.path]))
|
||||
newRoots[uniqueName] = folder
|
||||
try {
|
||||
await replaceRoots(newRoots);
|
||||
await refreshRoots();
|
||||
showSettings.value = false;
|
||||
await replaceRoots(newRoots)
|
||||
await refreshRoots()
|
||||
showSettings.value = false
|
||||
} catch (e) {
|
||||
console.error('Failed to add root:', e);
|
||||
alert('Failed to add root');
|
||||
console.error("Failed to add root:", e)
|
||||
alert("Failed to add root")
|
||||
}
|
||||
}
|
||||
|
||||
watch(showSettings, (visible) => {
|
||||
if (visible) void refreshRoots();
|
||||
});
|
||||
if (visible) void refreshRoots()
|
||||
})
|
||||
|
||||
// Check if we're on a detail page
|
||||
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)
|
||||
const isSearchActive = computed(() => {
|
||||
return !isDetailPage.value && !!localSearch.value;
|
||||
});
|
||||
return !isDetailPage.value && !!localSearch.value
|
||||
})
|
||||
|
||||
// Switch views on focus (no Enter required) - only in browse mode
|
||||
function switchToMovies() {
|
||||
if (!isDetailPage.value && props.currentView !== 'movies') {
|
||||
router.push('/movies');
|
||||
if (!isDetailPage.value && props.currentView !== "movies") {
|
||||
router.push("/movies")
|
||||
}
|
||||
}
|
||||
|
||||
function switchToSeries() {
|
||||
if (!isDetailPage.value && props.currentView !== 'series') {
|
||||
router.push('/series');
|
||||
if (!isDetailPage.value && props.currentView !== "series") {
|
||||
router.push("/series")
|
||||
}
|
||||
}
|
||||
|
||||
// Go back to category list from detail page
|
||||
function goToCategory() {
|
||||
// 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
|
||||
@@ -262,52 +269,56 @@ function handleSearchFocus() {
|
||||
|
||||
// Sync local search to parent
|
||||
watch(localSearch, (val) => {
|
||||
emit('search', val);
|
||||
});
|
||||
emit("search", val)
|
||||
})
|
||||
|
||||
// Sync parent search to local (for external clears)
|
||||
watch(() => props.searchQuery, (val) => {
|
||||
if (val !== localSearch.value) {
|
||||
localSearch.value = val;
|
||||
}
|
||||
});
|
||||
watch(
|
||||
() => props.searchQuery,
|
||||
(val) => {
|
||||
if (val !== localSearch.value) {
|
||||
localSearch.value = val
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function handleEscape() {
|
||||
// Clear search and blur
|
||||
localSearch.value = '';
|
||||
searchInputRef.value?.blur();
|
||||
localSearch.value = ""
|
||||
searchInputRef.value?.blur()
|
||||
}
|
||||
|
||||
function focusSearchInput() {
|
||||
searchInputRef.value?.focus();
|
||||
searchInputRef.value?.select();
|
||||
searchInputRef.value?.focus()
|
||||
searchInputRef.value?.select()
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
const target = e.target as HTMLElement | null;
|
||||
const target = e.target as HTMLElement | null
|
||||
const isTypingTarget = Boolean(
|
||||
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';
|
||||
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"
|
||||
|
||||
if (isTypingTarget && !isSearchShortcut) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
if (isSearchShortcut || isSlashShortcut) {
|
||||
e.preventDefault();
|
||||
focusSearchInput();
|
||||
e.preventDefault()
|
||||
focusSearchInput()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', handleKeydown);
|
||||
});
|
||||
window.addEventListener("keydown", handleKeydown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleKeydown);
|
||||
});
|
||||
window.removeEventListener("keydown", handleKeydown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -17,104 +17,98 @@
|
||||
</div>
|
||||
<p v-if="overview" class="hero-overview">{{ overview }}</p>
|
||||
<div class="hero-buttons">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
@click="handlePlay"
|
||||
:disabled="!playableFile"
|
||||
>
|
||||
<button class="btn btn-primary" @click="handlePlay" :disabled="!playableFile">
|
||||
▶ Play
|
||||
</button>
|
||||
<button class="btn btn-secondary" @click="$emit('info', item)">
|
||||
ℹ More Info
|
||||
</button>
|
||||
<button class="btn btn-secondary" @click="$emit('info', item)">ℹ More Info</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type { MediaItem, Movie, Series } from '../types';
|
||||
import { getCoverUrl } from '../api';
|
||||
import { computed } from "vue"
|
||||
import type { MediaItem, Movie, Series } from "../types"
|
||||
import { getCoverUrl } from "../api"
|
||||
|
||||
const props = defineProps<{
|
||||
item: MediaItem;
|
||||
}>();
|
||||
item: MediaItem
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
play: [string];
|
||||
info: [MediaItem];
|
||||
}>();
|
||||
play: [string]
|
||||
info: [MediaItem]
|
||||
}>()
|
||||
|
||||
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(() => {
|
||||
if (props.item.type === 'movies') {
|
||||
const movie = props.item.data as Movie;
|
||||
const torrents = Object.values(movie.torrents || {});
|
||||
return torrents.length > 0 ? torrents[0].resolution : null;
|
||||
if (props.item.type === "movies") {
|
||||
const movie = props.item.data as Movie
|
||||
const torrents = Object.values(movie.torrents || {})
|
||||
return torrents.length > 0 ? torrents[0].resolution : null
|
||||
}
|
||||
return null;
|
||||
});
|
||||
return null
|
||||
})
|
||||
|
||||
const quality = computed(() => {
|
||||
if (props.item.type === 'movies') {
|
||||
const movie = props.item.data as Movie;
|
||||
const torrents = Object.values(movie.torrents || {});
|
||||
return torrents.length > 0 ? torrents[0].quality : null;
|
||||
if (props.item.type === "movies") {
|
||||
const movie = props.item.data as Movie
|
||||
const torrents = Object.values(movie.torrents || {})
|
||||
return torrents.length > 0 ? torrents[0].quality : null
|
||||
}
|
||||
return null;
|
||||
});
|
||||
return null
|
||||
})
|
||||
|
||||
const rating = computed(() => {
|
||||
if (props.item.type === 'movies') {
|
||||
return (props.item.data as Movie).info?.rating;
|
||||
if (props.item.type === "movies") {
|
||||
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(() => {
|
||||
if (!rating.value) return '';
|
||||
if (rating.value >= 7.5) return 'rating-high';
|
||||
if (rating.value >= 6) return 'rating-medium';
|
||||
return 'rating-low';
|
||||
});
|
||||
if (!rating.value) return ""
|
||||
if (rating.value >= 7.5) return "rating-high"
|
||||
if (rating.value >= 6) return "rating-medium"
|
||||
return "rating-low"
|
||||
})
|
||||
|
||||
const overview = computed(() => {
|
||||
if (props.item.type === 'movies') {
|
||||
const o = (props.item.data as Movie).info?.overview;
|
||||
return o ? (o.length > 200 ? o.slice(0, 200) + '...' : o) : null;
|
||||
if (props.item.type === "movies") {
|
||||
const o = (props.item.data as Movie).info?.overview
|
||||
return o ? (o.length > 200 ? o.slice(0, 200) + "..." : o) : null
|
||||
}
|
||||
const o = (props.item.data as Series).info?.overview;
|
||||
return o ? (o.length > 200 ? o.slice(0, 200) + '...' : o) : null;
|
||||
});
|
||||
const o = (props.item.data as Series).info?.overview
|
||||
return o ? (o.length > 200 ? o.slice(0, 200) + "..." : o) : null
|
||||
})
|
||||
|
||||
const playableFile = computed(() => {
|
||||
if (props.item.type === 'movies') {
|
||||
const movie = props.item.data as Movie;
|
||||
const torrents = Object.values(movie.torrents || {});
|
||||
return torrents.length > 0 ? torrents[0].playable_file : null;
|
||||
if (props.item.type === "movies") {
|
||||
const movie = props.item.data as Movie
|
||||
const torrents = Object.values(movie.torrents || {})
|
||||
return torrents.length > 0 ? torrents[0].playable_file : null
|
||||
}
|
||||
// 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 episode of season.episodes || []) {
|
||||
const torrents = Object.values(episode.torrents || {});
|
||||
const torrents = Object.values(episode.torrents || {})
|
||||
for (const torrent of torrents) {
|
||||
if (torrent.playable_file) {
|
||||
return torrent.playable_file;
|
||||
return torrent.playable_file
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
return null
|
||||
})
|
||||
|
||||
function handlePlay() {
|
||||
if (playableFile.value) {
|
||||
emit('play', playableFile.value);
|
||||
emit("play", playableFile.value)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -14,26 +14,27 @@
|
||||
:key="`raw-${code}`"
|
||||
class="language-code-fallback"
|
||||
:title="code"
|
||||
>{{ code }}</span>
|
||||
>{{ code }}</span
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { buildLanguageFlags } from '../utils/languageFlags';
|
||||
import { computed } from "vue"
|
||||
import { buildLanguageFlags } from "../utils/languageFlags"
|
||||
|
||||
const props = defineProps<{
|
||||
label?: string;
|
||||
codes: string[] | null | undefined;
|
||||
compact?: boolean;
|
||||
}>();
|
||||
label?: string
|
||||
codes: string[] | null | undefined
|
||||
compact?: boolean
|
||||
}>()
|
||||
|
||||
const mapped = computed(() => buildLanguageFlags(props.codes));
|
||||
const flagEntries = computed(() => mapped.value.flags);
|
||||
const unmappedCodes = computed(() => mapped.value.unmappedCodes);
|
||||
const hasContent = computed(() => flagEntries.value.length > 0 || unmappedCodes.value.length > 0);
|
||||
const compact = computed(() => props.compact === true);
|
||||
const mapped = computed(() => buildLanguageFlags(props.codes))
|
||||
const flagEntries = computed(() => mapped.value.flags)
|
||||
const unmappedCodes = computed(() => mapped.value.unmappedCodes)
|
||||
const hasContent = computed(() => flagEntries.value.length > 0 || unmappedCodes.value.length > 0)
|
||||
const compact = computed(() => props.compact === true)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
playsinline
|
||||
></video>
|
||||
<div v-else class="media-card-placeholder">
|
||||
{{ item.type === 'movies' ? '🎬' : item.type === 'episode' ? '📺' : '📺' }}
|
||||
{{ item.type === "movies" ? "🎬" : item.type === "episode" ? "📺" : "📺" }}
|
||||
</div>
|
||||
<div v-if="rating" class="media-card-rating" :class="ratingClass">
|
||||
★ {{ rating.toFixed(1) }}
|
||||
@@ -38,24 +38,43 @@
|
||||
<span v-if="item.year" class="media-card-year">{{ item.year }}</span>
|
||||
</div>
|
||||
<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">
|
||||
<span :class="person.highlightRoles ? 'match-dim' : 'match-name'">{{ person.name }}</span>
|
||||
<span :class="person.highlightRoles ? 'match-highlight' : 'match-roles'">({{ person.roles }})</span><span v-if="idx < matchedPeople.length - 1">, </span>
|
||||
<span :class="person.highlightRoles ? 'match-dim' : 'match-name'">{{
|
||||
person.name
|
||||
}}</span>
|
||||
<span :class="person.highlightRoles ? 'match-highlight' : 'match-roles'"
|
||||
>({{ person.roles }})</span
|
||||
><span v-if="idx < matchedPeople.length - 1">, </span>
|
||||
</template>
|
||||
</div>
|
||||
<div 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">
|
||||
<div
|
||||
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-roles"> ({{ ep.location }})</span>
|
||||
</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>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div v-if="subtitle" class="media-card-detail">{{ subtitle }}</div>
|
||||
<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>
|
||||
</template>
|
||||
</div>
|
||||
@@ -63,121 +82,122 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import type { MediaItem, Movie, Series, EpisodeWithSeries } from '../types';
|
||||
import { getCoverUrl, isVideoPath } from '../api';
|
||||
import { navAttrs } from '../composables/useKeyboardNavigation';
|
||||
import { computed, ref } from "vue"
|
||||
import type { MediaItem, Movie, Series, EpisodeWithSeries } from "../types"
|
||||
import { getCoverUrl, isVideoPath } from "../api"
|
||||
import { navAttrs } from "../composables/useKeyboardNavigation"
|
||||
|
||||
const props = defineProps<{
|
||||
item: MediaItem;
|
||||
navRow?: number;
|
||||
navCol?: number;
|
||||
}>();
|
||||
item: MediaItem
|
||||
navRow?: number
|
||||
navCol?: number
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
click: [];
|
||||
}>();
|
||||
click: []
|
||||
}>()
|
||||
|
||||
const navAttributes = computed(() => {
|
||||
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(() => {
|
||||
if (imageError.value) return null;
|
||||
if (imageError.value) return null
|
||||
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(() => {
|
||||
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));
|
||||
return fallbackVideo ? getCoverUrl(fallbackVideo, props.item.root_id) : null;
|
||||
});
|
||||
const fallbackVideo = props.item.showreel_images?.find((path) => isVideoPath(path))
|
||||
return fallbackVideo ? getCoverUrl(fallbackVideo, props.item.root_id) : null
|
||||
})
|
||||
|
||||
const rating = computed(() => {
|
||||
if (props.item.type === 'movies') {
|
||||
return (props.item.data as Movie).info?.rating;
|
||||
if (props.item.type === "movies") {
|
||||
return (props.item.data as Movie).info?.rating
|
||||
}
|
||||
if (props.item.type === 'episode') {
|
||||
const epData = props.item.data as EpisodeWithSeries;
|
||||
return epData.episode.rating ?? epData.series.info?.rating;
|
||||
if (props.item.type === "episode") {
|
||||
const epData = props.item.data as EpisodeWithSeries
|
||||
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(() => {
|
||||
if (!rating.value) return '';
|
||||
if (rating.value >= 7.5) return 'rating-high';
|
||||
if (rating.value >= 6) return 'rating-medium';
|
||||
return 'rating-low';
|
||||
});
|
||||
if (!rating.value) return ""
|
||||
if (rating.value >= 7.5) return "rating-high"
|
||||
if (rating.value >= 6) return "rating-medium"
|
||||
return "rating-low"
|
||||
})
|
||||
|
||||
const displayTitle = computed(() => {
|
||||
if (props.item.type === 'episode') {
|
||||
const epData = props.item.data as EpisodeWithSeries;
|
||||
return epData.episode.name || `Episode ${epData.episode.episode_number}`;
|
||||
if (props.item.type === "episode") {
|
||||
const epData = props.item.data as EpisodeWithSeries
|
||||
return epData.episode.name || `Episode ${epData.episode.episode_number}`
|
||||
}
|
||||
return props.item.title;
|
||||
});
|
||||
return props.item.title
|
||||
})
|
||||
|
||||
const subtitle = computed(() => {
|
||||
if (props.item.type === 'episode') {
|
||||
const epData = props.item.data as EpisodeWithSeries;
|
||||
return `${epData.series.title} S${epData.seasonNumber}E${epData.episode.episode_number}`;
|
||||
if (props.item.type === "episode") {
|
||||
const epData = props.item.data as EpisodeWithSeries
|
||||
return `${epData.series.title} S${epData.seasonNumber}E${epData.episode.episode_number}`
|
||||
}
|
||||
if (props.item.type === 'series') {
|
||||
const creators = (props.item.data as Series).info?.creators;
|
||||
return creators && creators.length > 0 ? creators.join(', ') : null;
|
||||
if (props.item.type === "series") {
|
||||
const creators = (props.item.data as Series).info?.creators
|
||||
return creators && creators.length > 0 ? creators.join(", ") : null
|
||||
}
|
||||
return null;
|
||||
});
|
||||
return null
|
||||
})
|
||||
|
||||
const director = computed(() => {
|
||||
if (props.item.type !== 'movies') return null;
|
||||
return (props.item.data as Movie).info?.director;
|
||||
});
|
||||
if (props.item.type !== "movies") return null
|
||||
return (props.item.data as Movie).info?.director
|
||||
})
|
||||
|
||||
const directorAndCast = computed(() => {
|
||||
if (props.item.type !== 'movies') return false;
|
||||
return director.value || filteredCastNames.value;
|
||||
});
|
||||
if (props.item.type !== "movies") return false
|
||||
return director.value || filteredCastNames.value
|
||||
})
|
||||
|
||||
const filteredCastNames = computed(() => {
|
||||
if (props.item.type !== 'movies') return null;
|
||||
const cast = (props.item.data as Movie).info?.cast;
|
||||
if (!cast || cast.length === 0) return null;
|
||||
if (props.item.type !== "movies") return null
|
||||
const cast = (props.item.data as Movie).info?.cast
|
||||
if (!cast || cast.length === 0) return null
|
||||
|
||||
const directorName = director.value?.toLowerCase();
|
||||
const directorName = director.value?.toLowerCase()
|
||||
const filteredCast = directorName
|
||||
? cast.filter(c => c.name.toLowerCase() !== directorName)
|
||||
: cast;
|
||||
? cast.filter((c) => c.name.toLowerCase() !== directorName)
|
||||
: cast
|
||||
|
||||
if (filteredCast.length === 0) return null;
|
||||
if (filteredCast.length === 0) return null
|
||||
|
||||
const names = filteredCast.slice(0, 3).map(c => c.name);
|
||||
return names.join(', ');
|
||||
});
|
||||
const names = filteredCast.slice(0, 3).map((c) => c.name)
|
||||
return names.join(", ")
|
||||
})
|
||||
|
||||
const matchedPeople = computed(() => {
|
||||
const info = props.item.searchMatchInfo;
|
||||
if (!info || !info.matchedPeople) return null;
|
||||
return info.matchedPeople;
|
||||
});
|
||||
const info = props.item.searchMatchInfo
|
||||
if (!info || !info.matchedPeople) return null
|
||||
return info.matchedPeople
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes card-outline-blink {
|
||||
0%, 100% {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
|
||||
@@ -14,10 +14,8 @@
|
||||
<!-- Full page view for movies -->
|
||||
<div v-else class="movie-page">
|
||||
<div class="movie-page-content">
|
||||
|
||||
<!-- Diagonal collage header -->
|
||||
<div class="collage-header">
|
||||
|
||||
<!-- Background collage of showreel videos -->
|
||||
<div class="collage-grid">
|
||||
<div
|
||||
@@ -27,13 +25,10 @@
|
||||
@mouseenter="handleVideoHover(slot.index, true)"
|
||||
@mouseleave="handleVideoHover(slot.index, false)"
|
||||
>
|
||||
<div
|
||||
class="collage-fallback-tile"
|
||||
:class="`collage-fallback-${slot.index + 1}`"
|
||||
></div>
|
||||
<div class="collage-fallback-tile" :class="`collage-fallback-${slot.index + 1}`"></div>
|
||||
<video
|
||||
v-if="slot.sourcePaths.length > 0"
|
||||
:ref="el => setVideoRef(el as HTMLVideoElement, slot.index)"
|
||||
:ref="(el) => setVideoRef(el as HTMLVideoElement, slot.index)"
|
||||
:class="{ 'is-ready': isVideoReady(slot.index) }"
|
||||
:autoplay="safariAutoplay"
|
||||
loop
|
||||
@@ -48,7 +43,7 @@
|
||||
:src="getShowreelUrl(sourcePath)"
|
||||
:type="getShowreelSourceAttributes(sourcePath).type"
|
||||
:codecs="getShowreelSourceAttributes(sourcePath).codecs"
|
||||
>
|
||||
/>
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
@@ -61,7 +56,9 @@
|
||||
<h1 class="modal-title">{{ item.title }}</h1>
|
||||
<p v-if="movieTagline" class="header-tagline">{{ movieTagline }}</p>
|
||||
<div class="modal-meta">
|
||||
<span v-if="rating" class="meta-rating" :class="ratingClass">★ {{ rating.toFixed(1) }}</span>
|
||||
<span v-if="rating" class="meta-rating" :class="ratingClass"
|
||||
>★ {{ rating.toFixed(1) }}</span
|
||||
>
|
||||
<span v-if="item.year" class="meta-year">{{ item.year }}</span>
|
||||
<span v-if="movieRuntime" class="meta-runtime">{{ formatRuntime(movieRuntime) }}</span>
|
||||
</div>
|
||||
@@ -85,7 +82,7 @@
|
||||
:src="synopsisPosterUrl"
|
||||
:alt="`${item.title} poster`"
|
||||
class="synopsis-poster"
|
||||
>
|
||||
/>
|
||||
</div>
|
||||
<div v-if="movieVersions.length > 0" class="versions-list versions-list-sidebar">
|
||||
<ReleaseVersionCard
|
||||
@@ -99,7 +96,11 @@
|
||||
@activate="handleVersionActivate(version, $event)"
|
||||
@keydown="handleVersionShortcutKeydown($event, version)"
|
||||
@contextmenu="handleVersionContextMenu($event, version)"
|
||||
:title="version.playable_file ? 'Click to play/continue. Alt+Click, Alt+Enter, or Cmd/Ctrl+E to open folder. Right-click for actions.' : 'No playable file'"
|
||||
: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>
|
||||
@@ -119,18 +120,24 @@
|
||||
:src="getCoverUrl(castMember.profile_path, item.root_id)"
|
||||
:alt="castMember.name"
|
||||
class="cast-photo"
|
||||
>
|
||||
<img v-else :src="getCastPlaceholderUrl(castMember.gender)" :alt="`${castMember.name} placeholder portrait`" class="cast-photo cast-photo-fallback">
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
:src="getCastPlaceholderUrl(castMember.gender)"
|
||||
:alt="`${castMember.name} placeholder portrait`"
|
||||
class="cast-photo cast-photo-fallback"
|
||||
/>
|
||||
<div class="cast-copy">
|
||||
<span class="cast-name">{{ castMember.name }}</span>
|
||||
<span v-if="castMember.character" class="cast-character">{{ castMember.character }}</span>
|
||||
<span v-if="castMember.character" class="cast-character">{{
|
||||
castMember.character
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main content -->
|
||||
<div class="content-main">
|
||||
</div>
|
||||
<div class="content-main"></div>
|
||||
|
||||
<!-- Right sidebar - Metadata -->
|
||||
<div v-if="item.type === 'movies'" class="content-sidebar sidebar-right">
|
||||
@@ -144,10 +151,12 @@
|
||||
</div>
|
||||
<div v-if="movieStatus || movieReleaseDate" class="meta-summary">
|
||||
<span v-if="movieStatus" class="meta-summary-item">{{ movieStatus }}</span>
|
||||
<span v-if="movieReleaseDate" class="meta-summary-item">{{ movieReleaseDate }}</span>
|
||||
<span v-if="movieReleaseDate" class="meta-summary-item">{{
|
||||
movieReleaseDate
|
||||
}}</span>
|
||||
</div>
|
||||
<div v-if="movieKeywords && movieKeywords.length > 0" class="meta-keywords-section">
|
||||
<span class="meta-value keywords">{{ movieKeywords.join(', ') }}</span>
|
||||
<span class="meta-value keywords">{{ movieKeywords.join(", ") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -178,315 +187,328 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onMounted, onUnmounted, nextTick } from 'vue';
|
||||
import type { CastMember, MediaItem, Movie, Series, Torrent } from '../types';
|
||||
import { getCoverUrl, getVideoPreviewUrl, getVideoSourceAttributes, isSafariBrowser, type VideoSourceAttributes } from '../api';
|
||||
import castPlaceholderFemaleUrl from '../assets/cast-placeholder-female.svg';
|
||||
import castPlaceholderMaleUrl from '../assets/cast-placeholder-male.svg';
|
||||
import SeriesFullView from './SeriesFullView.vue';
|
||||
import ReleaseVersionCard from './ReleaseVersionCard.vue';
|
||||
import ReleaseActionMenu from './ReleaseActionMenu.vue';
|
||||
import { navAttrs } from '../composables/useKeyboardNavigation';
|
||||
import { computed, ref, watch, onMounted, onUnmounted, nextTick } from "vue"
|
||||
import type { CastMember, MediaItem, Movie, Series, Torrent } from "../types"
|
||||
import {
|
||||
getCoverUrl,
|
||||
getVideoPreviewUrl,
|
||||
getVideoSourceAttributes,
|
||||
isSafariBrowser,
|
||||
type VideoSourceAttributes,
|
||||
} from "../api"
|
||||
import castPlaceholderFemaleUrl from "../assets/cast-placeholder-female.svg"
|
||||
import castPlaceholderMaleUrl from "../assets/cast-placeholder-male.svg"
|
||||
import SeriesFullView from "./SeriesFullView.vue"
|
||||
import ReleaseVersionCard from "./ReleaseVersionCard.vue"
|
||||
import ReleaseActionMenu from "./ReleaseActionMenu.vue"
|
||||
import { navAttrs } from "../composables/useKeyboardNavigation"
|
||||
|
||||
const props = defineProps<{
|
||||
item: MediaItem;
|
||||
focusEpisode?: { seasonNumber: number; episodeNumber: number } | null;
|
||||
hasResumePosition: (filePath: string | null) => boolean;
|
||||
getRootName: (rootId: string | null | undefined) => string | null;
|
||||
}>();
|
||||
item: MediaItem
|
||||
focusEpisode?: { seasonNumber: number; episodeNumber: number } | null
|
||||
hasResumePosition: (filePath: string | null) => boolean
|
||||
getRootName: (rootId: string | null | undefined) => string | null
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
play: [string];
|
||||
openFolder: [string, string | null | undefined];
|
||||
searchActor: [string];
|
||||
}>();
|
||||
close: []
|
||||
play: [string]
|
||||
openFolder: [string, string | null | undefined]
|
||||
searchActor: [string]
|
||||
}>()
|
||||
|
||||
// Track expanded episode for showing multiple releases
|
||||
|
||||
const videoRefs = ref<(HTMLVideoElement | null)[]>([]);
|
||||
const videoStates = ref<string[]>([]);
|
||||
const COLLAGE_SLOT_COUNT = 5;
|
||||
const safariAutoplay = isSafariBrowser();
|
||||
const COLLAGE_START_OFFSETS_SECONDS = [0, 8, 6, 4, 2];
|
||||
const videoRefs = ref<(HTMLVideoElement | null)[]>([])
|
||||
const videoStates = ref<string[]>([])
|
||||
const COLLAGE_SLOT_COUNT = 5
|
||||
const safariAutoplay = isSafariBrowser()
|
||||
const COLLAGE_START_OFFSETS_SECONDS = [0, 8, 6, 4, 2]
|
||||
|
||||
function setVideoRef(el: HTMLVideoElement | null, index: number) {
|
||||
videoRefs.value[index] = el;
|
||||
videoRefs.value[index] = el
|
||||
}
|
||||
|
||||
function handleVideoLoaded(index: number) {
|
||||
videoStates.value[index] = 'ready';
|
||||
videoStates.value[index] = "ready"
|
||||
}
|
||||
|
||||
function handleVideoError(index: number) {
|
||||
videoStates.value[index] = 'error';
|
||||
videoStates.value[index] = "error"
|
||||
}
|
||||
|
||||
function isVideoReady(index: number): boolean {
|
||||
return videoStates.value[index] === 'ready';
|
||||
return videoStates.value[index] === "ready"
|
||||
}
|
||||
|
||||
// Start staggered video playback
|
||||
function startStaggeredPlayback() {
|
||||
const videos = videoRefs.value.filter(v => v !== null) as HTMLVideoElement[];
|
||||
if (videos.length === 0) return;
|
||||
const videos = videoRefs.value.filter((v) => v !== null) as HTMLVideoElement[]
|
||||
if (videos.length === 0) return
|
||||
|
||||
if (safariAutoplay) {
|
||||
videos.forEach((video, index) => {
|
||||
const offset = COLLAGE_START_OFFSETS_SECONDS[index] ?? 0;
|
||||
const offset = COLLAGE_START_OFFSETS_SECONDS[index] ?? 0
|
||||
const startVideo = () => {
|
||||
video.currentTime = offset;
|
||||
video.play().catch(() => {});
|
||||
};
|
||||
video.currentTime = offset
|
||||
video.play().catch(() => {})
|
||||
}
|
||||
|
||||
if (video.readyState >= 1) {
|
||||
startVideo();
|
||||
startVideo()
|
||||
} else {
|
||||
video.addEventListener('loadedmetadata', startVideo, { once: true });
|
||||
video.addEventListener("loadedmetadata", startVideo, { once: true })
|
||||
}
|
||||
});
|
||||
return;
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Start first video immediately
|
||||
// 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
|
||||
for (let i = 1; i < videos.length; i++) {
|
||||
setTimeout(() => {
|
||||
const video = videos[i];
|
||||
if (!video) return;
|
||||
video.play().catch(() => {});
|
||||
}, i * 2000);
|
||||
const video = videos[i]
|
||||
if (!video) return
|
||||
video.play().catch(() => {})
|
||||
}, i * 2000)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
function handleVideoHover(index: number, isEntering: boolean) {
|
||||
const video = videoRefs.value[index];
|
||||
if (!video) return;
|
||||
const video = videoRefs.value[index]
|
||||
if (!video) return
|
||||
|
||||
// Clear any existing fade for this video
|
||||
const existingInterval = volumeFadeIntervals.get(index);
|
||||
const existingInterval = volumeFadeIntervals.get(index)
|
||||
if (existingInterval) {
|
||||
clearInterval(existingInterval);
|
||||
volumeFadeIntervals.delete(index);
|
||||
clearInterval(existingInterval)
|
||||
volumeFadeIntervals.delete(index)
|
||||
}
|
||||
|
||||
if (isEntering) {
|
||||
// Mute all other videos immediately
|
||||
document.querySelectorAll('video').forEach(v => {
|
||||
document.querySelectorAll("video").forEach((v) => {
|
||||
if (v !== video) {
|
||||
v.volume = 0;
|
||||
v.muted = true;
|
||||
v.volume = 0
|
||||
v.muted = true
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
// Fade in this video's audio
|
||||
video.muted = false;
|
||||
video.muted = false
|
||||
const fadeIn = setInterval(() => {
|
||||
if (video.volume < 0.95) {
|
||||
video.volume = Math.min(1, video.volume + 0.1);
|
||||
video.volume = Math.min(1, video.volume + 0.1)
|
||||
} else {
|
||||
video.volume = 1;
|
||||
clearInterval(fadeIn);
|
||||
volumeFadeIntervals.delete(index);
|
||||
video.volume = 1
|
||||
clearInterval(fadeIn)
|
||||
volumeFadeIntervals.delete(index)
|
||||
}
|
||||
}, 30);
|
||||
volumeFadeIntervals.set(index, fadeIn);
|
||||
}, 30)
|
||||
volumeFadeIntervals.set(index, fadeIn)
|
||||
} else {
|
||||
// Fade out this video's audio
|
||||
const fadeOut = setInterval(() => {
|
||||
if (video.volume > 0.05) {
|
||||
video.volume = Math.max(0, video.volume - 0.1);
|
||||
video.volume = Math.max(0, video.volume - 0.1)
|
||||
} else {
|
||||
video.volume = 0;
|
||||
video.muted = true;
|
||||
clearInterval(fadeOut);
|
||||
volumeFadeIntervals.delete(index);
|
||||
video.volume = 0
|
||||
video.muted = true
|
||||
clearInterval(fadeOut)
|
||||
volumeFadeIntervals.delete(index)
|
||||
}
|
||||
}, 30);
|
||||
volumeFadeIntervals.set(index, fadeOut);
|
||||
}, 30)
|
||||
volumeFadeIntervals.set(index, fadeOut)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Wait for videos to be ready, then start staggered playback
|
||||
setTimeout(() => {
|
||||
startStaggeredPlayback();
|
||||
}, 100);
|
||||
});
|
||||
startStaggeredPlayback()
|
||||
}, 100)
|
||||
})
|
||||
|
||||
const showreelSourceSets = computed((): string[][] | null => {
|
||||
if (props.item.type === 'movies') {
|
||||
const movie = props.item.data as Movie;
|
||||
if (props.item.type === "movies") {
|
||||
const movie = props.item.data as Movie
|
||||
if (movie.showreel_source_sets && movie.showreel_source_sets.length > 0) {
|
||||
return movie.showreel_source_sets;
|
||||
return movie.showreel_source_sets
|
||||
}
|
||||
return movie.showreel_images?.map((path) => [path]) ?? null;
|
||||
return movie.showreel_images?.map((path) => [path]) ?? null
|
||||
} else {
|
||||
const series = props.item.data as Series;
|
||||
const sourceSets: string[][] = [];
|
||||
const series = props.item.data as Series
|
||||
const sourceSets: string[][] = []
|
||||
for (const season of series.seasons || []) {
|
||||
for (const episode of season.episodes || []) {
|
||||
if (episode.reel_sources && episode.reel_sources.length > 0) {
|
||||
sourceSets.push(episode.reel_sources);
|
||||
sourceSets.push(episode.reel_sources)
|
||||
} 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[][] => {
|
||||
if (!showreelSourceSets.value || showreelSourceSets.value.length === 0) return [];
|
||||
return showreelSourceSets.value.slice(0, 5);
|
||||
});
|
||||
if (!showreelSourceSets.value || showreelSourceSets.value.length === 0) return []
|
||||
return showreelSourceSets.value.slice(0, 5)
|
||||
})
|
||||
|
||||
const collageSlots = computed(() => {
|
||||
return Array.from({ length: COLLAGE_SLOT_COUNT }, (_, index) => ({
|
||||
index,
|
||||
sourcePaths: collageSourceSets.value[index] ?? [],
|
||||
}));
|
||||
});
|
||||
}))
|
||||
})
|
||||
|
||||
watch(collageSlots, async (slots) => {
|
||||
videoRefs.value = Array.from({ length: COLLAGE_SLOT_COUNT }, (_, index) => videoRefs.value[index] ?? null);
|
||||
videoStates.value = slots.map((slot) => slot.sourcePaths.length > 0 ? 'loading' : 'missing');
|
||||
await nextTick();
|
||||
setTimeout(() => {
|
||||
startStaggeredPlayback();
|
||||
}, 100);
|
||||
}, { immediate: true });
|
||||
watch(
|
||||
collageSlots,
|
||||
async (slots) => {
|
||||
videoRefs.value = Array.from(
|
||||
{ length: COLLAGE_SLOT_COUNT },
|
||||
(_, index) => videoRefs.value[index] ?? null,
|
||||
)
|
||||
videoStates.value = slots.map((slot) => (slot.sourcePaths.length > 0 ? "loading" : "missing"))
|
||||
await nextTick()
|
||||
setTimeout(() => {
|
||||
startStaggeredPlayback()
|
||||
}, 100)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function getShowreelUrl(path: string): string {
|
||||
return getVideoPreviewUrl(getCoverUrl(path, props.item.root_id));
|
||||
return getVideoPreviewUrl(getCoverUrl(path, props.item.root_id))
|
||||
}
|
||||
|
||||
function getShowreelSourceAttributes(path: string): VideoSourceAttributes {
|
||||
return getVideoSourceAttributes(path);
|
||||
return getVideoSourceAttributes(path)
|
||||
}
|
||||
// Movie versions
|
||||
const movieVersions = computed((): Torrent[] => {
|
||||
if (props.item.type !== 'movies') return [];
|
||||
const movie = props.item.data as Movie;
|
||||
return Object.values(movie.torrents || {});
|
||||
});
|
||||
if (props.item.type !== "movies") return []
|
||||
const movie = props.item.data as Movie
|
||||
return Object.values(movie.torrents || {})
|
||||
})
|
||||
|
||||
// Page backdrop background
|
||||
const backdropStyle = computed(() => {
|
||||
if (props.item.type !== 'movies') return {};
|
||||
const movie = props.item.data as Movie;
|
||||
const imagePath = movie.backdrop_path;
|
||||
const imageUrl = getCoverUrl(imagePath, props.item.root_id);
|
||||
if (props.item.type !== "movies") return {}
|
||||
const movie = props.item.data as Movie
|
||||
const imagePath = movie.backdrop_path
|
||||
const imageUrl = getCoverUrl(imagePath, props.item.root_id)
|
||||
if (imageUrl) {
|
||||
return { backgroundImage: `url("${imageUrl}")` };
|
||||
return { backgroundImage: `url("${imageUrl}")` }
|
||||
}
|
||||
return {};
|
||||
});
|
||||
return {}
|
||||
})
|
||||
|
||||
const synopsisPosterUrl = computed(() => {
|
||||
if (props.item.type !== 'movies') return null;
|
||||
return getCoverUrl(props.item.cover_path, props.item.root_id);
|
||||
});
|
||||
if (props.item.type !== "movies") return null
|
||||
return getCoverUrl(props.item.cover_path, props.item.root_id)
|
||||
})
|
||||
|
||||
const movieGenres = computed(() => {
|
||||
if (props.item.type !== 'movies') return null;
|
||||
return (props.item.data as Movie).info?.genres;
|
||||
});
|
||||
if (props.item.type !== "movies") return null
|
||||
return (props.item.data as Movie).info?.genres
|
||||
})
|
||||
|
||||
const movieTagline = computed(() => {
|
||||
if (props.item.type !== 'movies') return null;
|
||||
return (props.item.data as Movie).info?.tagline;
|
||||
});
|
||||
if (props.item.type !== "movies") return null
|
||||
return (props.item.data as Movie).info?.tagline
|
||||
})
|
||||
|
||||
const movieDirector = computed(() => {
|
||||
if (props.item.type !== 'movies') return null;
|
||||
return (props.item.data as Movie).info?.director;
|
||||
});
|
||||
if (props.item.type !== "movies") return null
|
||||
return (props.item.data as Movie).info?.director
|
||||
})
|
||||
|
||||
const movieCast = computed(() => {
|
||||
if (props.item.type !== 'movies') return null;
|
||||
return (props.item.data as Movie).info?.cast as CastMember[] | null;
|
||||
});
|
||||
if (props.item.type !== "movies") return null
|
||||
return (props.item.data as Movie).info?.cast as CastMember[] | null
|
||||
})
|
||||
|
||||
const limitedMovieCast = computed(() => {
|
||||
if (!movieCast.value) return [];
|
||||
return movieCast.value;
|
||||
});
|
||||
if (!movieCast.value) return []
|
||||
return movieCast.value
|
||||
})
|
||||
|
||||
const movieRuntime = computed(() => {
|
||||
if (props.item.type !== 'movies') return null;
|
||||
return (props.item.data as Movie).info?.runtime;
|
||||
});
|
||||
if (props.item.type !== "movies") return null
|
||||
return (props.item.data as Movie).info?.runtime
|
||||
})
|
||||
|
||||
const movieReleaseDate = computed(() => {
|
||||
if (props.item.type !== 'movies') return null;
|
||||
return (props.item.data as Movie).info?.release_date;
|
||||
});
|
||||
if (props.item.type !== "movies") return null
|
||||
return (props.item.data as Movie).info?.release_date
|
||||
})
|
||||
|
||||
const movieStatus = computed(() => {
|
||||
if (props.item.type !== 'movies') return null;
|
||||
return (props.item.data as Movie).info?.status;
|
||||
});
|
||||
if (props.item.type !== "movies") return null
|
||||
return (props.item.data as Movie).info?.status
|
||||
})
|
||||
|
||||
const movieKeywords = computed(() => {
|
||||
if (props.item.type !== 'movies') return null;
|
||||
return (props.item.data as Movie).info?.keywords;
|
||||
});
|
||||
if (props.item.type !== "movies") return null
|
||||
return (props.item.data as Movie).info?.keywords
|
||||
})
|
||||
|
||||
function getCastPlaceholderUrl(gender?: CastMember['gender']): string {
|
||||
return gender === 'female' ? castPlaceholderFemaleUrl : castPlaceholderMaleUrl;
|
||||
function getCastPlaceholderUrl(gender?: CastMember["gender"]): string {
|
||||
return gender === "female" ? castPlaceholderFemaleUrl : castPlaceholderMaleUrl
|
||||
}
|
||||
|
||||
function formatRuntime(minutes: number): string {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
if (hours === 0) return `${mins}m`;
|
||||
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const mins = minutes % 60
|
||||
if (hours === 0) return `${mins}m`
|
||||
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`
|
||||
}
|
||||
|
||||
const rating = computed(() => {
|
||||
if (props.item.type === 'movies') {
|
||||
return (props.item.data as Movie).info?.rating;
|
||||
if (props.item.type === "movies") {
|
||||
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(() => {
|
||||
if (props.item.type === 'movies') {
|
||||
return (props.item.data as Movie).info?.overview;
|
||||
if (props.item.type === "movies") {
|
||||
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(() => {
|
||||
if (!rating.value) return '';
|
||||
if (rating.value >= 7.5) return 'rating-high';
|
||||
if (rating.value >= 6) return 'rating-medium';
|
||||
return 'rating-low';
|
||||
});
|
||||
if (!rating.value) return ""
|
||||
if (rating.value >= 7.5) return "rating-high"
|
||||
if (rating.value >= 6) return "rating-medium"
|
||||
return "rating-low"
|
||||
})
|
||||
|
||||
const seasons = computed(() => {
|
||||
if (props.item.type !== 'series') return [];
|
||||
const series = props.item.data as Series;
|
||||
return series.seasons || [];
|
||||
});
|
||||
if (props.item.type !== "series") return []
|
||||
const series = props.item.data as Series
|
||||
return series.seasons || []
|
||||
})
|
||||
|
||||
const selectedSeasonIndex = ref<number>(0);
|
||||
const selectedSeasonIndex = ref<number>(0)
|
||||
|
||||
const versionActionMenu = ref<{
|
||||
visible: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
filePath: string | null;
|
||||
rootName: string | null;
|
||||
rootId: string | null;
|
||||
visible: boolean
|
||||
x: number
|
||||
y: number
|
||||
filePath: string | null
|
||||
rootName: string | null
|
||||
rootId: string | null
|
||||
}>({
|
||||
visible: false,
|
||||
x: 0,
|
||||
@@ -494,30 +516,30 @@ const versionActionMenu = ref<{
|
||||
filePath: null,
|
||||
rootName: null,
|
||||
rootId: null,
|
||||
});
|
||||
})
|
||||
|
||||
function closeVersionActionMenu() {
|
||||
versionActionMenu.value.visible = false;
|
||||
versionActionMenu.value.filePath = null;
|
||||
versionActionMenu.value.rootName = null;
|
||||
versionActionMenu.value.rootId = null;
|
||||
versionActionMenu.value.visible = false
|
||||
versionActionMenu.value.filePath = null
|
||||
versionActionMenu.value.rootName = null
|
||||
versionActionMenu.value.rootId = null
|
||||
}
|
||||
|
||||
function getPlayLabel(filePath: string | null): string {
|
||||
return props.hasResumePosition(filePath) ? 'Continue' : 'Play';
|
||||
return props.hasResumePosition(filePath) ? "Continue" : "Play"
|
||||
}
|
||||
|
||||
function handlePlayVersion(filePath: string | null) {
|
||||
if (filePath) {
|
||||
emit('play', filePath);
|
||||
emit("play", filePath)
|
||||
}
|
||||
closeVersionActionMenu();
|
||||
closeVersionActionMenu()
|
||||
}
|
||||
|
||||
function handleVersionContextMenu(event: MouseEvent, version: Torrent) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const rootId = version.root_id || ((props.item.data as Movie).root_id ?? props.item.root_id);
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const rootId = version.root_id || ((props.item.data as Movie).root_id ?? props.item.root_id)
|
||||
versionActionMenu.value = {
|
||||
visible: true,
|
||||
x: event.clientX,
|
||||
@@ -525,80 +547,86 @@ function handleVersionContextMenu(event: MouseEvent, version: Torrent) {
|
||||
filePath: version.playable_file || null,
|
||||
rootName: props.getRootName(rootId) || null,
|
||||
rootId,
|
||||
};
|
||||
}
|
||||
nextTick(() => {
|
||||
const firstAction = document.querySelector('.version-action-menu .version-action-item:not(:disabled)') as HTMLElement | null;
|
||||
firstAction?.focus();
|
||||
});
|
||||
const firstAction = document.querySelector(
|
||||
".version-action-menu .version-action-item:not(:disabled)",
|
||||
) as HTMLElement | null
|
||||
firstAction?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
function handleVersionShortcutKeydown(event: KeyboardEvent, version: Torrent) {
|
||||
if (!version.playable_file) return;
|
||||
const rootId = version.root_id || ((props.item.data as Movie).root_id ?? props.item.root_id);
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === 'e' && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handleOpenFolder(version.playable_file, rootId);
|
||||
return;
|
||||
if (!version.playable_file) return
|
||||
const rootId = version.root_id || ((props.item.data as Movie).root_id ?? props.item.root_id)
|
||||
const key = event.key.toLowerCase()
|
||||
if (key === "e" && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
handleOpenFolder(version.playable_file, rootId)
|
||||
return
|
||||
}
|
||||
if (key === 'enter' && event.altKey) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handleOpenFolder(version.playable_file, rootId);
|
||||
if (key === "enter" && event.altKey) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
handleOpenFolder(version.playable_file, rootId)
|
||||
}
|
||||
}
|
||||
|
||||
function handleMovieMenuKeydown(event: KeyboardEvent) {
|
||||
if (!versionActionMenu.value.visible) return;
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
closeVersionActionMenu();
|
||||
if (!versionActionMenu.value.visible) return
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
closeVersionActionMenu()
|
||||
}
|
||||
}
|
||||
|
||||
// Select first season by default
|
||||
watch(seasons, (s) => {
|
||||
if (s.length > 0 && selectedSeasonIndex.value >= s.length) {
|
||||
selectedSeasonIndex.value = 0;
|
||||
}
|
||||
}, { immediate: true });
|
||||
watch(
|
||||
seasons,
|
||||
(s) => {
|
||||
if (s.length > 0 && selectedSeasonIndex.value >= s.length) {
|
||||
selectedSeasonIndex.value = 0
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function handlePlay(filePath: string | null) {
|
||||
if (filePath) {
|
||||
emit('play', filePath);
|
||||
emit("play", filePath)
|
||||
}
|
||||
}
|
||||
|
||||
function handleVersionActivate(version: Torrent, event: MouseEvent | KeyboardEvent) {
|
||||
if (!version.playable_file) return;
|
||||
const rootId = version.root_id || ((props.item.data as Movie).root_id ?? props.item.root_id);
|
||||
if (!version.playable_file) return
|
||||
const rootId = version.root_id || ((props.item.data as Movie).root_id ?? props.item.root_id)
|
||||
if (event.altKey) {
|
||||
handleOpenFolder(version.playable_file, rootId);
|
||||
return;
|
||||
handleOpenFolder(version.playable_file, rootId)
|
||||
return
|
||||
}
|
||||
handlePlay(version.playable_file);
|
||||
handlePlay(version.playable_file)
|
||||
}
|
||||
|
||||
function handleOpenFolder(folderPath: string, rootId?: string | null) {
|
||||
closeVersionActionMenu();
|
||||
emit('openFolder', folderPath, rootId);
|
||||
closeVersionActionMenu()
|
||||
emit("openFolder", folderPath, rootId)
|
||||
}
|
||||
|
||||
function handleCastSelect(castName: string) {
|
||||
const name = castName.trim();
|
||||
if (!name) return;
|
||||
emit('searchActor', name);
|
||||
const name = castName.trim()
|
||||
if (!name) return
|
||||
emit("searchActor", name)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', handleMovieMenuKeydown, true);
|
||||
});
|
||||
document.addEventListener("keydown", handleMovieMenuKeydown, true)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleMovieMenuKeydown, true);
|
||||
});
|
||||
document.removeEventListener("keydown", handleMovieMenuKeydown, true)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -618,7 +646,7 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.movie-page-content::before {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 300px;
|
||||
/* Start clipped edge at reel 2/3 split bottom (y=300): 40vw - 0.8rem. */
|
||||
@@ -626,15 +654,16 @@ onUnmounted(() => {
|
||||
width: calc(40vw + 0.8rem);
|
||||
max-width: calc(100vw - 24px);
|
||||
height: var(--header-height);
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(5, 7, 10, 0.72) 0%,
|
||||
rgba(5, 7, 10, 0.5) 100%
|
||||
);
|
||||
background: linear-gradient(to bottom, rgba(5, 7, 10, 0.72) 0%, rgba(5, 7, 10, 0.5) 100%);
|
||||
-webkit-backdrop-filter: blur(10px) saturate(115%);
|
||||
backdrop-filter: blur(10px) saturate(115%);
|
||||
/* Match reel slant angle: 2rem horizontal shift over 300px reel height. */
|
||||
-webkit-clip-path: polygon(0 0, 100% 0, calc(100% - (var(--header-height) * 0.1067)) 100%, 0 100%);
|
||||
-webkit-clip-path: polygon(
|
||||
0 0,
|
||||
100% 0,
|
||||
calc(100% - (var(--header-height) * 0.1067)) 100%,
|
||||
0 100%
|
||||
);
|
||||
clip-path: polygon(0 0, 100% 0, calc(100% - (var(--header-height) * 0.1067)) 100%, 0 100%);
|
||||
pointer-events: none;
|
||||
z-index: 30;
|
||||
@@ -675,8 +704,8 @@ onUnmounted(() => {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 360px) minmax(0, 1fr) minmax(240px, 320px);
|
||||
grid-template-areas:
|
||||
'left cast cast'
|
||||
'left main right';
|
||||
"left cast cast"
|
||||
"left main right";
|
||||
gap: 32px;
|
||||
align-items: start;
|
||||
position: relative;
|
||||
@@ -921,7 +950,7 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.cast-card::after {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
@@ -961,7 +990,12 @@ html:not(.mouse-active) .cast-card.nav-focused::after {
|
||||
position: absolute;
|
||||
inset: auto 0 0 0;
|
||||
padding: 28px 8px 8px;
|
||||
background: linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.78) 45%, rgba(0, 0, 0, 0.95) 100%);
|
||||
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 {
|
||||
@@ -983,9 +1017,9 @@ html:not(.mouse-active) .cast-card.nav-focused::after {
|
||||
.content-layout {
|
||||
grid-template-columns: minmax(240px, 320px) minmax(0, 1fr);
|
||||
grid-template-areas:
|
||||
'left cast'
|
||||
'left main'
|
||||
'left right';
|
||||
"left cast"
|
||||
"left main"
|
||||
"left right";
|
||||
gap: 24px;
|
||||
}
|
||||
}
|
||||
@@ -994,10 +1028,10 @@ html:not(.mouse-active) .cast-card.nav-focused::after {
|
||||
.content-layout {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-areas:
|
||||
'left'
|
||||
'cast'
|
||||
'main'
|
||||
'right';
|
||||
"left"
|
||||
"cast"
|
||||
"main"
|
||||
"right";
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
@@ -1093,7 +1127,9 @@ html.mouse-active .showreel-image:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 8px;
|
||||
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 {
|
||||
@@ -1290,7 +1326,9 @@ html.mouse-active .version-item:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
transition:
|
||||
background 0.2s,
|
||||
border-color 0.2s;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -1541,14 +1579,10 @@ html.mouse-active .release-item:hover {
|
||||
|
||||
/* Subtle vignette on each collage image */
|
||||
.collage-header .collage-item::before {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
rgba(0, 0, 0, 0.3) 100%
|
||||
);
|
||||
background: linear-gradient(to bottom, transparent 0%, rgba(0, 0, 0, 0.3) 100%);
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
@@ -1646,5 +1680,4 @@ html.mouse-active .release-item:hover {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -16,16 +16,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MediaItem } from '../types';
|
||||
import MediaCard from './MediaCard.vue';
|
||||
import type { MediaItem } from "../types"
|
||||
import MediaCard from "./MediaCard.vue"
|
||||
|
||||
defineProps<{
|
||||
items: MediaItem[];
|
||||
wrap?: boolean;
|
||||
rowIndex?: number;
|
||||
}>();
|
||||
items: MediaItem[]
|
||||
wrap?: boolean
|
||||
rowIndex?: number
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
select: [MediaItem];
|
||||
}>();
|
||||
select: [MediaItem]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -1,33 +1,24 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="visible"
|
||||
ref="menuRef"
|
||||
class="version-action-menu"
|
||||
:style="menuStyle"
|
||||
>
|
||||
<div v-if="visible" ref="menuRef" class="version-action-menu" :style="menuStyle">
|
||||
<div class="version-action-path" :title="resolvedPath">
|
||||
{{ resolvedPath }}
|
||||
</div>
|
||||
<button
|
||||
class="version-action-item"
|
||||
:disabled="disabled"
|
||||
@click="emit('play')"
|
||||
>
|
||||
<button class="version-action-item" :disabled="disabled" @click="emit('play')">
|
||||
<span class="version-action-icon" aria-hidden="true">
|
||||
<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>
|
||||
</span>
|
||||
{{ playLabel }}
|
||||
</button>
|
||||
<button
|
||||
class="version-action-item"
|
||||
:disabled="disabled"
|
||||
@click="emit('openFolder')"
|
||||
>
|
||||
<button class="version-action-item" :disabled="disabled" @click="emit('openFolder')">
|
||||
<span class="version-action-icon" aria-hidden="true">
|
||||
<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>
|
||||
</span>
|
||||
Open Folder
|
||||
@@ -36,93 +27,96 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue"
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
visible: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
filePath: string | null;
|
||||
rootName?: string | null;
|
||||
playLabel?: string;
|
||||
}>(), {
|
||||
rootName: null,
|
||||
playLabel: 'Play',
|
||||
});
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
visible: boolean
|
||||
x: number
|
||||
y: number
|
||||
filePath: string | null
|
||||
rootName?: string | null
|
||||
playLabel?: string
|
||||
}>(),
|
||||
{
|
||||
rootName: null,
|
||||
playLabel: "Play",
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
play: [];
|
||||
openFolder: [];
|
||||
}>();
|
||||
play: []
|
||||
openFolder: []
|
||||
}>()
|
||||
|
||||
const menuRef = ref<HTMLElement | null>(null);
|
||||
const menuLeft = ref(0);
|
||||
const menuTop = ref(0);
|
||||
const VIEWPORT_MARGIN = 12;
|
||||
const menuRef = ref<HTMLElement | null>(null)
|
||||
const menuLeft = ref(0)
|
||||
const menuTop = ref(0)
|
||||
const VIEWPORT_MARGIN = 12
|
||||
|
||||
function toPosixPath(value: string | null | undefined): string {
|
||||
return (value || '').replace(/\\/g, '/');
|
||||
return (value || "").replace(/\\/g, "/")
|
||||
}
|
||||
|
||||
const resolvedPath = computed(() => {
|
||||
if (!props.filePath) return 'No playable file';
|
||||
const normalizedFilePath = toPosixPath(props.filePath);
|
||||
const rootName = toPosixPath((props.rootName || '').trim());
|
||||
if (!rootName) return normalizedFilePath;
|
||||
return `${rootName}/${normalizedFilePath}`;
|
||||
});
|
||||
if (!props.filePath) return "No playable file"
|
||||
const normalizedFilePath = toPosixPath(props.filePath)
|
||||
const rootName = toPosixPath((props.rootName || "").trim())
|
||||
if (!rootName) return normalizedFilePath
|
||||
return `${rootName}/${normalizedFilePath}`
|
||||
})
|
||||
|
||||
const menuStyle = computed(() => ({
|
||||
left: `${menuLeft.value}px`,
|
||||
top: `${menuTop.value}px`,
|
||||
}));
|
||||
}))
|
||||
|
||||
const disabled = computed(() => !props.filePath);
|
||||
const disabled = computed(() => !props.filePath)
|
||||
|
||||
function clampToViewport() {
|
||||
const menu = menuRef.value;
|
||||
if (!menu) return;
|
||||
const menu = menuRef.value
|
||||
if (!menu) return
|
||||
|
||||
const width = menu.offsetWidth;
|
||||
const height = menu.offsetHeight;
|
||||
const width = menu.offsetWidth
|
||||
const height = menu.offsetHeight
|
||||
|
||||
const maxLeft = Math.max(VIEWPORT_MARGIN, window.innerWidth - width - VIEWPORT_MARGIN);
|
||||
const maxTop = Math.max(VIEWPORT_MARGIN, window.innerHeight - height - VIEWPORT_MARGIN);
|
||||
const maxLeft = Math.max(VIEWPORT_MARGIN, window.innerWidth - width - VIEWPORT_MARGIN)
|
||||
const maxTop = Math.max(VIEWPORT_MARGIN, window.innerHeight - height - VIEWPORT_MARGIN)
|
||||
|
||||
menuLeft.value = Math.min(Math.max(props.x, VIEWPORT_MARGIN), maxLeft);
|
||||
menuTop.value = Math.min(Math.max(props.y, VIEWPORT_MARGIN), maxTop);
|
||||
menuLeft.value = Math.min(Math.max(props.x, VIEWPORT_MARGIN), maxLeft)
|
||||
menuTop.value = Math.min(Math.max(props.y, VIEWPORT_MARGIN), maxTop)
|
||||
}
|
||||
|
||||
function handleViewportChange() {
|
||||
if (!props.visible) return;
|
||||
clampToViewport();
|
||||
if (!props.visible) return
|
||||
clampToViewport()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.visible, props.x, props.y, resolvedPath.value],
|
||||
async ([visible]) => {
|
||||
if (!visible) return;
|
||||
await nextTick();
|
||||
clampToViewport();
|
||||
if (!visible) return
|
||||
await nextTick()
|
||||
clampToViewport()
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(visible) => {
|
||||
if (visible) {
|
||||
window.addEventListener('resize', handleViewportChange);
|
||||
return;
|
||||
window.addEventListener("resize", handleViewportChange)
|
||||
return
|
||||
}
|
||||
window.removeEventListener('resize', handleViewportChange);
|
||||
window.removeEventListener("resize", handleViewportChange)
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', handleViewportChange);
|
||||
});
|
||||
window.removeEventListener("resize", handleViewportChange)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -22,43 +22,47 @@
|
||||
<span v-if="displayCodecBadge" class="v-badge codec">{{ displayCodecBadge }}</span>
|
||||
<span v-if="displayQualityBadge" class="v-badge qual">{{ displayQualityBadge }}</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 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
|
||||
v-if="hasLanguageDisplay(torrent.audio_languages) && hasLanguageDisplay(torrent.subtitle_languages)"
|
||||
v-if="
|
||||
hasLanguageDisplay(torrent.audio_languages) &&
|
||||
hasLanguageDisplay(torrent.subtitle_languages)
|
||||
"
|
||||
class="language-separator"
|
||||
>•</span>
|
||||
<LanguageFlags class="language-flags-subs" :codes="torrent.subtitle_languages" :compact="compactFlags" />
|
||||
>•</span
|
||||
>
|
||||
<LanguageFlags
|
||||
class="language-flags-subs"
|
||||
:codes="torrent.subtitle_languages"
|
||||
:compact="compactFlags"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="version-dolby-cell">
|
||||
<img
|
||||
v-if="showBlurayLogo"
|
||||
class="version-disc-logo"
|
||||
:src="blurayLogoUrl"
|
||||
alt="Blu-ray"
|
||||
>
|
||||
<img
|
||||
v-else-if="showDvdLogo"
|
||||
class="version-disc-logo"
|
||||
:src="dvdLogoUrl"
|
||||
alt="DVD"
|
||||
>
|
||||
<img v-if="showBlurayLogo" class="version-disc-logo" :src="blurayLogoUrl" alt="Blu-ray" />
|
||||
<img v-else-if="showDvdLogo" class="version-disc-logo" :src="dvdLogoUrl" alt="DVD" />
|
||||
<img
|
||||
v-if="streamingServiceLogo"
|
||||
class="version-service-logo"
|
||||
:src="streamingServiceLogo.src"
|
||||
:alt="streamingServiceLogo.alt"
|
||||
:title="streamingServiceLogo.alt"
|
||||
>
|
||||
<DolbyBadges
|
||||
class="version-dolby"
|
||||
:has-dolby-vision="hasDolbyVision"
|
||||
:has-dolby-atmos="hasDolbyAtmos"
|
||||
:is-hdr="hasHdr"
|
||||
/>
|
||||
/>
|
||||
<DolbyBadges
|
||||
class="version-dolby"
|
||||
:has-dolby-vision="hasDolbyVision"
|
||||
:has-dolby-atmos="hasDolbyAtmos"
|
||||
:is-hdr="hasHdr"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="showActions" class="version-actions">
|
||||
<button
|
||||
@@ -66,225 +70,273 @@
|
||||
tabindex="0"
|
||||
@click.stop="emit('play')"
|
||||
:disabled="!torrent.playable_file"
|
||||
>▶ {{ playLabel }}</button>
|
||||
>
|
||||
▶ {{ playLabel }}
|
||||
</button>
|
||||
<button
|
||||
class="ctx-btn ctx-btn-folder"
|
||||
tabindex="0"
|
||||
@click.stop="emit('openFolder')"
|
||||
:disabled="!torrent.playable_file"
|
||||
>📁</button>
|
||||
>
|
||||
📁
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type { Torrent } from '../types';
|
||||
import LanguageFlags from './LanguageFlags.vue';
|
||||
import DolbyBadges from './DolbyBadges.vue';
|
||||
import { buildLanguageFlags } from '../utils/languageFlags';
|
||||
import blurayLogoUrl from '../assets/bluray.webp';
|
||||
import dvdLogoUrl from '../assets/dvd.webp';
|
||||
import amazonLogoUrl from '../assets/service-amazon.webp';
|
||||
import appleTvLogoUrl from '../assets/service-apple-tv.webp';
|
||||
import netflixLogoUrl from '../assets/service-netflix.webp';
|
||||
import hboMaxLogoUrl from '../assets/service-hbo-max.webp';
|
||||
import huluLogoUrl from '../assets/service-hulu.webp';
|
||||
import disneyLogoUrl from '../assets/service-disney.svg';
|
||||
import itunesLogoUrl from '../assets/service-itunes.png';
|
||||
import { computed } from "vue"
|
||||
import type { Torrent } from "../types"
|
||||
import LanguageFlags from "./LanguageFlags.vue"
|
||||
import DolbyBadges from "./DolbyBadges.vue"
|
||||
import { buildLanguageFlags } from "../utils/languageFlags"
|
||||
import blurayLogoUrl from "../assets/bluray.webp"
|
||||
import dvdLogoUrl from "../assets/dvd.webp"
|
||||
import amazonLogoUrl from "../assets/service-amazon.webp"
|
||||
import appleTvLogoUrl from "../assets/service-apple-tv.webp"
|
||||
import netflixLogoUrl from "../assets/service-netflix.webp"
|
||||
import hboMaxLogoUrl from "../assets/service-hbo-max.webp"
|
||||
import huluLogoUrl from "../assets/service-hulu.webp"
|
||||
import disneyLogoUrl from "../assets/service-disney.svg"
|
||||
import itunesLogoUrl from "../assets/service-itunes.png"
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
})
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
torrent: Torrent;
|
||||
best?: boolean;
|
||||
selectable?: boolean;
|
||||
disabled?: boolean;
|
||||
compactFlags?: boolean;
|
||||
showActions?: boolean;
|
||||
playLabel?: string;
|
||||
title?: string;
|
||||
variant?: 'default' | 'menu';
|
||||
}>(), {
|
||||
best: false,
|
||||
selectable: undefined,
|
||||
disabled: undefined,
|
||||
compactFlags: false,
|
||||
showActions: false,
|
||||
playLabel: 'Play',
|
||||
title: undefined,
|
||||
variant: 'default',
|
||||
});
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
torrent: Torrent
|
||||
best?: boolean
|
||||
selectable?: boolean
|
||||
disabled?: boolean
|
||||
compactFlags?: boolean
|
||||
showActions?: boolean
|
||||
playLabel?: string
|
||||
title?: string
|
||||
variant?: "default" | "menu"
|
||||
}>(),
|
||||
{
|
||||
best: false,
|
||||
selectable: undefined,
|
||||
disabled: undefined,
|
||||
compactFlags: false,
|
||||
showActions: false,
|
||||
playLabel: "Play",
|
||||
title: undefined,
|
||||
variant: "default",
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
activate: [MouseEvent | KeyboardEvent];
|
||||
play: [];
|
||||
openFolder: [];
|
||||
}>();
|
||||
activate: [MouseEvent | KeyboardEvent]
|
||||
play: []
|
||||
openFolder: []
|
||||
}>()
|
||||
|
||||
const dolbyTagPattern = /\b(dolby|atmos|vision|dovi|dv)\b/i;
|
||||
const dolbyVisionPattern = /\b(dolby\s*vision|dovi|\bdv\b)\b/i;
|
||||
const dolbyAtmosPattern = /\b(dolby\s*atmos|atmos)\b/i;
|
||||
const hdrPattern = /\bhdr\b|smpte\s*2084|bt\s*2020|hlg/i;
|
||||
const blurayTagPattern = /\bblu[\s.-]*ray\b/i;
|
||||
const blurayPlayablePattern = /(?:^|[\\/])(movieobject|index)\.bdmv$/i;
|
||||
const dvdPlayablePattern = /(?:^|[\\/])video_ts\.ifo$/i;
|
||||
const webQualityPattern = /^web(?:[ .-]?dl|[ .-]?rip)$/i;
|
||||
const dolbyTagPattern = /\b(dolby|atmos|vision|dovi|dv)\b/i
|
||||
const dolbyVisionPattern = /\b(dolby\s*vision|dovi|\bdv\b)\b/i
|
||||
const dolbyAtmosPattern = /\b(dolby\s*atmos|atmos)\b/i
|
||||
const hdrPattern = /\bhdr\b|smpte\s*2084|bt\s*2020|hlg/i
|
||||
const blurayTagPattern = /\bblu[\s.-]*ray\b/i
|
||||
const blurayPlayablePattern = /(?:^|[\\/])(movieobject|index)\.bdmv$/i
|
||||
const dvdPlayablePattern = /(?:^|[\\/])video_ts\.ifo$/i
|
||||
const webQualityPattern = /^web(?:[ .-]?dl|[ .-]?rip)$/i
|
||||
|
||||
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: ['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' },
|
||||
];
|
||||
{
|
||||
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: ["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 {
|
||||
return Boolean(value && dolbyTagPattern.test(value));
|
||||
return Boolean(value && dolbyTagPattern.test(value))
|
||||
}
|
||||
|
||||
function hasAnyTag(
|
||||
pattern: RegExp,
|
||||
...values: Array<string | null | undefined>
|
||||
): boolean {
|
||||
return values.some((value) => Boolean(value && pattern.test(value)));
|
||||
function hasAnyTag(pattern: RegExp, ...values: Array<string | null | undefined>): boolean {
|
||||
return values.some((value) => Boolean(value && pattern.test(value)))
|
||||
}
|
||||
|
||||
function hasLanguageDisplay(codes: string[] | null | undefined): boolean {
|
||||
const mapped = buildLanguageFlags(codes);
|
||||
return mapped.flags.length > 0 || mapped.unmappedCodes.length > 0;
|
||||
const mapped = buildLanguageFlags(codes)
|
||||
return mapped.flags.length > 0 || mapped.unmappedCodes.length > 0
|
||||
}
|
||||
|
||||
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 {
|
||||
return (value || '').toLowerCase().replace(/[^a-z0-9+]+/g, ' ').trim();
|
||||
return (value || "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9+]+/g, " ")
|
||||
.trim()
|
||||
}
|
||||
|
||||
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') {
|
||||
return 'TV';
|
||||
if (
|
||||
normalized === "hdtv" ||
|
||||
normalized === "pdtv" ||
|
||||
normalized === "tvrip" ||
|
||||
normalized === "sdtv"
|
||||
) {
|
||||
return "TV"
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.includes('cam')
|
||||
|| normalized === 'telesync'
|
||||
|| normalized === 'ts'
|
||||
|| normalized === 'hdts'
|
||||
|| normalized === 'telecine'
|
||||
|| normalized === 'tc'
|
||||
normalized.includes("cam") ||
|
||||
normalized === "telesync" ||
|
||||
normalized === "ts" ||
|
||||
normalized === "hdts" ||
|
||||
normalized === "telecine" ||
|
||||
normalized === "tc"
|
||||
) {
|
||||
return 'CAM';
|
||||
return "CAM"
|
||||
}
|
||||
|
||||
return value;
|
||||
return value
|
||||
}
|
||||
|
||||
const hasDolbyVision = computed(() => {
|
||||
return (
|
||||
props.torrent.has_dolby_vision === true
|
||||
|| hasAnyTag(dolbyVisionPattern, props.torrent.quality, props.torrent.codec, props.torrent.audio, props.torrent.title)
|
||||
);
|
||||
});
|
||||
props.torrent.has_dolby_vision === true ||
|
||||
hasAnyTag(
|
||||
dolbyVisionPattern,
|
||||
props.torrent.quality,
|
||||
props.torrent.codec,
|
||||
props.torrent.audio,
|
||||
props.torrent.title,
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
const hasDolbyAtmos = computed(() => {
|
||||
return (
|
||||
props.torrent.has_dolby_atmos === true
|
||||
|| hasAnyTag(dolbyAtmosPattern, props.torrent.quality, props.torrent.codec, props.torrent.audio, props.torrent.title)
|
||||
);
|
||||
});
|
||||
props.torrent.has_dolby_atmos === true ||
|
||||
hasAnyTag(
|
||||
dolbyAtmosPattern,
|
||||
props.torrent.quality,
|
||||
props.torrent.codec,
|
||||
props.torrent.audio,
|
||||
props.torrent.title,
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
const hasHdr = computed(() => {
|
||||
return (
|
||||
props.torrent.is_hdr === true
|
||||
|| hasAnyTag(hdrPattern, props.torrent.quality, props.torrent.codec, props.torrent.audio, props.torrent.title)
|
||||
);
|
||||
});
|
||||
props.torrent.is_hdr === true ||
|
||||
hasAnyTag(
|
||||
hdrPattern,
|
||||
props.torrent.quality,
|
||||
props.torrent.codec,
|
||||
props.torrent.audio,
|
||||
props.torrent.title,
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
const isBlurayDisc = computed(() => {
|
||||
if (!props.torrent.playable_file) return false;
|
||||
return blurayPlayablePattern.test(props.torrent.playable_file);
|
||||
});
|
||||
if (!props.torrent.playable_file) return false
|
||||
return blurayPlayablePattern.test(props.torrent.playable_file)
|
||||
})
|
||||
|
||||
const isDvdDisc = computed(() => {
|
||||
if (!props.torrent.playable_file) return false;
|
||||
return dvdPlayablePattern.test(props.torrent.playable_file);
|
||||
});
|
||||
if (!props.torrent.playable_file) return false
|
||||
return dvdPlayablePattern.test(props.torrent.playable_file)
|
||||
})
|
||||
|
||||
const showBlurayLogo = computed(() => {
|
||||
return isBlurayDisc.value;
|
||||
});
|
||||
return isBlurayDisc.value
|
||||
})
|
||||
|
||||
const showDvdLogo = computed(() => {
|
||||
return isDvdDisc.value;
|
||||
});
|
||||
return isDvdDisc.value
|
||||
})
|
||||
|
||||
const streamingServiceLogo = computed(() => {
|
||||
if (!props.torrent.quality || !webQualityPattern.test(props.torrent.quality)) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
const network = normalizeProviderName(props.torrent.network);
|
||||
if (!network) return null;
|
||||
const network = normalizeProviderName(props.torrent.network)
|
||||
if (!network) return null
|
||||
|
||||
const found = serviceLogoMap.find((entry) => entry.aliases.includes(network));
|
||||
return found ? { src: found.src, alt: found.alt } : null;
|
||||
});
|
||||
const found = serviceLogoMap.find((entry) => entry.aliases.includes(network))
|
||||
return found ? { src: found.src, alt: found.alt } : null
|
||||
})
|
||||
|
||||
const displayQualityBadge = computed(() => {
|
||||
if (!props.torrent.quality || hasDolbyTag(props.torrent.quality)) return null;
|
||||
if (streamingServiceLogo.value) return null;
|
||||
if (blurayTagPattern.test(props.torrent.quality)) return null;
|
||||
return normalizeQualityBadge(props.torrent.quality);
|
||||
});
|
||||
if (!props.torrent.quality || hasDolbyTag(props.torrent.quality)) return null
|
||||
if (streamingServiceLogo.value) return null
|
||||
if (blurayTagPattern.test(props.torrent.quality)) return null
|
||||
return normalizeQualityBadge(props.torrent.quality)
|
||||
})
|
||||
|
||||
const displayCodecBadge = computed(() => {
|
||||
if (!props.torrent.codec || hasDolbyTag(props.torrent.codec)) return null;
|
||||
return props.torrent.codec;
|
||||
});
|
||||
if (!props.torrent.codec || hasDolbyTag(props.torrent.codec)) return null
|
||||
return props.torrent.codec
|
||||
})
|
||||
|
||||
const displayAudioBadge = computed(() => {
|
||||
if (!props.torrent.audio || hasDolbyTag(props.torrent.audio)) return null;
|
||||
return props.torrent.audio;
|
||||
});
|
||||
if (!props.torrent.audio || hasDolbyTag(props.torrent.audio)) return null
|
||||
return props.torrent.audio
|
||||
})
|
||||
|
||||
const displayReleaseGroupBadge = computed(() => {
|
||||
const value = props.torrent.encoder?.trim();
|
||||
if (!value) return null;
|
||||
return value;
|
||||
});
|
||||
const value = props.torrent.encoder?.trim()
|
||||
if (!value) return null
|
||||
return value
|
||||
})
|
||||
|
||||
const showHdrBadge = computed(() => {
|
||||
if (!hasHdr.value) return false;
|
||||
return !hasHdrTag(props.torrent.quality) && !hasHdrTag(props.torrent.codec) && !hasHdrTag(props.torrent.audio);
|
||||
});
|
||||
if (!hasHdr.value) return false
|
||||
return (
|
||||
!hasHdrTag(props.torrent.quality) &&
|
||||
!hasHdrTag(props.torrent.codec) &&
|
||||
!hasHdrTag(props.torrent.audio)
|
||||
)
|
||||
})
|
||||
|
||||
const isSelectable = computed(() => {
|
||||
if (props.selectable !== undefined) return props.selectable;
|
||||
return Boolean(props.torrent.playable_file);
|
||||
});
|
||||
if (props.selectable !== undefined) return props.selectable
|
||||
return Boolean(props.torrent.playable_file)
|
||||
})
|
||||
|
||||
const isDisabled = computed(() => {
|
||||
if (props.disabled !== undefined) return props.disabled;
|
||||
return !isSelectable.value;
|
||||
});
|
||||
if (props.disabled !== undefined) return props.disabled
|
||||
return !isSelectable.value
|
||||
})
|
||||
|
||||
const resolvedTitle = computed(() => {
|
||||
if (props.title !== undefined) return props.title;
|
||||
return isSelectable.value ? 'Click to play/continue. Alt+Click to open folder.' : 'No playable file';
|
||||
});
|
||||
if (props.title !== undefined) return props.title
|
||||
return isSelectable.value
|
||||
? "Click to play/continue. Alt+Click to open folder."
|
||||
: "No playable file"
|
||||
})
|
||||
|
||||
function handleActivate(event: MouseEvent | KeyboardEvent) {
|
||||
if (!isSelectable.value || isDisabled.value) return;
|
||||
emit('activate', event);
|
||||
if (!isSelectable.value || isDisabled.value) return
|
||||
emit("activate", event)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -301,7 +353,9 @@ function handleActivate(event: MouseEvent | KeyboardEvent) {
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-radius: 8px;
|
||||
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 {
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
<template>
|
||||
<div class="series-fullscreen">
|
||||
|
||||
<!-- Hero section with backdrop or season collage -->
|
||||
<section class="series-hero">
|
||||
<div class="hero-bg">
|
||||
<!-- 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-for="(season, i) in seasonsWithPosters.slice(0, 5)"
|
||||
@@ -19,10 +23,16 @@
|
||||
<div class="hero-content">
|
||||
<h1 class="series-title">{{ series.title }}</h1>
|
||||
<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?.number_of_seasons" class="meta-item">{{ series.info.number_of_seasons }} Seasons</span>
|
||||
<span v-if="series.info?.rating" class="meta-rating" :class="ratingClass"
|
||||
>★ {{ 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?.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>
|
||||
<p v-if="series.info?.overview" class="series-overview">{{ series.info.overview }}</p>
|
||||
</div>
|
||||
@@ -53,7 +63,9 @@
|
||||
</div>
|
||||
<div class="poster-overlay">
|
||||
<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>
|
||||
@@ -80,7 +92,7 @@
|
||||
<div class="tile-bg">
|
||||
<video
|
||||
v-if="getEpisodeVideoSources(episode).length > 0"
|
||||
:ref="el => setVideoRef(el as HTMLVideoElement, `${sIndex}-${eIndex}`)"
|
||||
:ref="(el) => setVideoRef(el as HTMLVideoElement, `${sIndex}-${eIndex}`)"
|
||||
:autoplay="safariAutoplay"
|
||||
loop
|
||||
muted
|
||||
@@ -92,7 +104,7 @@
|
||||
:src="source.src"
|
||||
:type="source.type"
|
||||
:codecs="source.codecs"
|
||||
>
|
||||
/>
|
||||
</video>
|
||||
<div v-else class="tile-placeholder"></div>
|
||||
</div>
|
||||
@@ -104,8 +116,12 @@
|
||||
<div class="tile-info">
|
||||
<span class="ep-number">{{ episode.episode_number }}</span>
|
||||
<div class="ep-details">
|
||||
<span class="ep-name">{{ episode.name || `Episode ${episode.episode_number}` }}</span>
|
||||
<span v-if="episode.rating" class="ep-rating">★ {{ episode.rating.toFixed(1) }}</span>
|
||||
<span class="ep-name">{{
|
||||
episode.name || `Episode ${episode.episode_number}`
|
||||
}}</span>
|
||||
<span v-if="episode.rating" class="ep-rating"
|
||||
>★ {{ episode.rating.toFixed(1) }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -140,15 +156,17 @@
|
||||
:torrent="torrent"
|
||||
variant="menu"
|
||||
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)"
|
||||
@keydown="handleVersionShortcutKeydown($event, torrent)"
|
||||
@contextmenu="handleVersionContextMenu($event, torrent)"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="context-menu-empty">
|
||||
No versions available
|
||||
</div>
|
||||
<div v-else class="context-menu-empty">No versions available</div>
|
||||
</div>
|
||||
<ReleaseActionMenu
|
||||
:visible="versionActionMenu.visible"
|
||||
@@ -165,71 +183,77 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, nextTick, watch, onMounted, onUnmounted } from 'vue';
|
||||
import type { Series, Season, Episode, Torrent } from '../types';
|
||||
import { getCoverUrl, getVideoPreviewUrl, getVideoSourceAttributes, isSafariBrowser } from '../api';
|
||||
import { navAttrs } from '../composables/useKeyboardNavigation';
|
||||
import ReleaseVersionCard from './ReleaseVersionCard.vue';
|
||||
import ReleaseActionMenu from './ReleaseActionMenu.vue';
|
||||
import { computed, ref, nextTick, watch, onMounted, onUnmounted } from "vue"
|
||||
import type { Series, Season, Episode, Torrent } from "../types"
|
||||
import { getCoverUrl, getVideoPreviewUrl, getVideoSourceAttributes, isSafariBrowser } from "../api"
|
||||
import { navAttrs } from "../composables/useKeyboardNavigation"
|
||||
import ReleaseVersionCard from "./ReleaseVersionCard.vue"
|
||||
import ReleaseActionMenu from "./ReleaseActionMenu.vue"
|
||||
|
||||
const props = defineProps<{
|
||||
series: Series;
|
||||
focusEpisode?: { seasonNumber: number; episodeNumber: number } | null;
|
||||
hasResumePosition: (filePath: string | null) => boolean;
|
||||
getRootName: (rootId: string | null | undefined) => string | null;
|
||||
}>();
|
||||
series: Series
|
||||
focusEpisode?: { seasonNumber: number; episodeNumber: number } | null
|
||||
hasResumePosition: (filePath: string | null) => boolean
|
||||
getRootName: (rootId: string | null | undefined) => string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
play: [string];
|
||||
openFolder: [string, string | null | undefined];
|
||||
}>();
|
||||
close: []
|
||||
play: [string]
|
||||
openFolder: [string, string | null | undefined]
|
||||
}>()
|
||||
|
||||
// Focus on matched episode when provided
|
||||
watch(() => props.focusEpisode, (ep) => {
|
||||
if (ep) {
|
||||
// Delay to ensure DOM is fully rendered after route transition
|
||||
setTimeout(() => {
|
||||
// Find the season index and episode index
|
||||
const seasonIndex = props.series.seasons?.findIndex(s => s.season_number === ep.seasonNumber) ?? -1;
|
||||
if (seasonIndex >= 0) {
|
||||
const episodeIndex = props.series.seasons?.[seasonIndex]?.episodes?.findIndex(
|
||||
e => e.episode_number === ep.episodeNumber
|
||||
) ?? -1;
|
||||
if (episodeIndex >= 0) {
|
||||
// Find the episode tile element using nav attributes
|
||||
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();
|
||||
watch(
|
||||
() => props.focusEpisode,
|
||||
(ep) => {
|
||||
if (ep) {
|
||||
// Delay to ensure DOM is fully rendered after route transition
|
||||
setTimeout(() => {
|
||||
// Find the season index and episode index
|
||||
const seasonIndex =
|
||||
props.series.seasons?.findIndex((s) => s.season_number === ep.seasonNumber) ?? -1
|
||||
if (seasonIndex >= 0) {
|
||||
const episodeIndex =
|
||||
props.series.seasons?.[seasonIndex]?.episodes?.findIndex(
|
||||
(e) => e.episode_number === ep.episodeNumber,
|
||||
) ?? -1
|
||||
if (episodeIndex >= 0) {
|
||||
// Find the episode tile element using nav attributes
|
||||
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);
|
||||
}
|
||||
}, { immediate: true });
|
||||
}, 150)
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// Context menu state
|
||||
const contextMenu = ref<{
|
||||
visible: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
episode: Episode | null;
|
||||
visible: boolean
|
||||
x: number
|
||||
y: number
|
||||
episode: Episode | null
|
||||
}>({
|
||||
visible: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
episode: null,
|
||||
});
|
||||
})
|
||||
|
||||
const versionActionMenu = ref<{
|
||||
visible: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
filePath: string | null;
|
||||
rootName: string | null;
|
||||
rootId: string | null;
|
||||
visible: boolean
|
||||
x: number
|
||||
y: number
|
||||
filePath: string | null
|
||||
rootName: string | null
|
||||
rootId: string | null
|
||||
}>({
|
||||
visible: false,
|
||||
x: 0,
|
||||
@@ -237,183 +261,192 @@ const versionActionMenu = ref<{
|
||||
filePath: null,
|
||||
rootName: null,
|
||||
rootId: null,
|
||||
});
|
||||
})
|
||||
|
||||
const releaseMenuOriginElement = ref<HTMLElement | null>(null);
|
||||
const releaseMenuOriginElement = ref<HTMLElement | null>(null)
|
||||
|
||||
// Show context menu on right-click
|
||||
function handleContextMenu(event: MouseEvent, episode: Episode) {
|
||||
event.preventDefault();
|
||||
releaseMenuOriginElement.value = event.currentTarget as HTMLElement | null;
|
||||
openEpisodeReleaseMenu(episode, event.clientX, event.clientY);
|
||||
event.preventDefault()
|
||||
releaseMenuOriginElement.value = event.currentTarget as HTMLElement | null
|
||||
openEpisodeReleaseMenu(episode, event.clientX, event.clientY)
|
||||
}
|
||||
|
||||
function openEpisodeReleaseMenu(episode: Episode, x: number, y: number) {
|
||||
closeVersionActionMenu();
|
||||
closeVersionActionMenu()
|
||||
contextMenu.value = {
|
||||
visible: true,
|
||||
x,
|
||||
y,
|
||||
episode,
|
||||
};
|
||||
}
|
||||
// Add Escape key listener (capturing phase to intercept before other handlers)
|
||||
nextTick(() => {
|
||||
document.addEventListener('keydown', handleContextMenuKeydown, true);
|
||||
document.addEventListener("keydown", handleContextMenuKeydown, true)
|
||||
// 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) {
|
||||
firstCard.focus();
|
||||
firstCard.focus()
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
function openEpisodeReleaseMenuFromElement(episode: Episode, element: HTMLElement | null) {
|
||||
releaseMenuOriginElement.value = element;
|
||||
releaseMenuOriginElement.value = element
|
||||
if (!element) {
|
||||
openEpisodeReleaseMenu(episode, window.innerWidth / 2, window.innerHeight / 2);
|
||||
return;
|
||||
openEpisodeReleaseMenu(episode, window.innerWidth / 2, window.innerHeight / 2)
|
||||
return
|
||||
}
|
||||
const rect = element.getBoundingClientRect();
|
||||
openEpisodeReleaseMenu(episode, rect.left + rect.width / 2, rect.top + rect.height / 2);
|
||||
const rect = element.getBoundingClientRect()
|
||||
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)
|
||||
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 (popupFocusable.length === 0) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (event.key === "Tab") {
|
||||
if (popupFocusable.length === 0) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
const currentIndex = popupFocusable.findIndex((el) => el === document.activeElement);
|
||||
const delta = event.shiftKey ? -1 : 1;
|
||||
const nextIndex = currentIndex < 0
|
||||
? 0
|
||||
: (currentIndex + delta + popupFocusable.length) % popupFocusable.length;
|
||||
popupFocusable[nextIndex].focus();
|
||||
return;
|
||||
const currentIndex = popupFocusable.findIndex((el) => el === document.activeElement)
|
||||
const delta = event.shiftKey ? -1 : 1
|
||||
const nextIndex =
|
||||
currentIndex < 0 ? 0 : (currentIndex + delta + popupFocusable.length) % popupFocusable.length
|
||||
popupFocusable[nextIndex].focus()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowRight' || event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
|
||||
if (popupFocusable.length === 0) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (
|
||||
event.key === "ArrowDown" ||
|
||||
event.key === "ArrowRight" ||
|
||||
event.key === "ArrowUp" ||
|
||||
event.key === "ArrowLeft"
|
||||
) {
|
||||
if (popupFocusable.length === 0) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
const currentIndex = popupFocusable.findIndex((el) => el === document.activeElement);
|
||||
const delta = (event.key === 'ArrowDown' || event.key === 'ArrowRight') ? 1 : -1;
|
||||
const nextIndex = currentIndex < 0
|
||||
? 0
|
||||
: (currentIndex + delta + popupFocusable.length) % popupFocusable.length;
|
||||
popupFocusable[nextIndex].focus();
|
||||
return;
|
||||
const currentIndex = popupFocusable.findIndex((el) => el === document.activeElement)
|
||||
const delta = event.key === "ArrowDown" || event.key === "ArrowRight" ? 1 : -1
|
||||
const nextIndex =
|
||||
currentIndex < 0 ? 0 : (currentIndex + delta + popupFocusable.length) % popupFocusable.length
|
||||
popupFocusable[nextIndex].focus()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (versionActionMenu.value.visible) {
|
||||
closeVersionActionMenu();
|
||||
return;
|
||||
closeVersionActionMenu()
|
||||
return
|
||||
}
|
||||
closeContextMenu();
|
||||
closeContextMenu()
|
||||
}
|
||||
}
|
||||
|
||||
function getPopupFocusableElements(): HTMLElement[] {
|
||||
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
|
||||
? Array.from(document.querySelectorAll<HTMLElement>('.version-action-menu .version-action-item:not(:disabled)'))
|
||||
: [];
|
||||
return [...releaseItems, ...actionItems];
|
||||
? Array.from(
|
||||
document.querySelectorAll<HTMLElement>(
|
||||
".version-action-menu .version-action-item:not(:disabled)",
|
||||
),
|
||||
)
|
||||
: []
|
||||
return [...releaseItems, ...actionItems]
|
||||
}
|
||||
|
||||
// Close context menu
|
||||
function closeContextMenu() {
|
||||
closeVersionActionMenu();
|
||||
contextMenu.value.visible = false;
|
||||
document.removeEventListener('keydown', handleContextMenuKeydown, true);
|
||||
closeVersionActionMenu()
|
||||
contextMenu.value.visible = false
|
||||
document.removeEventListener("keydown", handleContextMenuKeydown, true)
|
||||
nextTick(() => {
|
||||
releaseMenuOriginElement.value?.focus();
|
||||
});
|
||||
releaseMenuOriginElement.value?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
function closeVersionActionMenu() {
|
||||
versionActionMenu.value.visible = false;
|
||||
versionActionMenu.value.filePath = null;
|
||||
versionActionMenu.value.rootName = null;
|
||||
versionActionMenu.value.rootId = null;
|
||||
versionActionMenu.value.visible = false
|
||||
versionActionMenu.value.filePath = null
|
||||
versionActionMenu.value.rootName = null
|
||||
versionActionMenu.value.rootId = null
|
||||
}
|
||||
|
||||
function handleGamepadAction(event: Event) {
|
||||
const actionEvent = event as CustomEvent<{ action?: string }>;
|
||||
if (actionEvent.detail?.action !== 'menu') return;
|
||||
const actionEvent = event as CustomEvent<{ action?: string }>
|
||||
if (actionEvent.detail?.action !== "menu") return
|
||||
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
if (!active || !active.classList.contains('episode-tile')) return;
|
||||
const active = document.activeElement as HTMLElement | null
|
||||
if (!active || !active.classList.contains("episode-tile")) return
|
||||
|
||||
const row = parseInt(active.getAttribute('data-nav-row') || '-1', 10);
|
||||
const col = parseInt(active.getAttribute('data-nav-col') || '-1', 10);
|
||||
if (row < 2 || col < 0) return;
|
||||
const row = parseInt(active.getAttribute("data-nav-row") || "-1", 10)
|
||||
const col = parseInt(active.getAttribute("data-nav-col") || "-1", 10)
|
||||
if (row < 2 || col < 0) return
|
||||
|
||||
const season = props.series.seasons?.[row - 2];
|
||||
const episode = season?.episodes?.[col];
|
||||
if (!episode) return;
|
||||
const season = props.series.seasons?.[row - 2]
|
||||
const episode = season?.episodes?.[col]
|
||||
if (!episode) return
|
||||
|
||||
actionEvent.preventDefault();
|
||||
openEpisodeReleaseMenuFromElement(episode, active);
|
||||
actionEvent.preventDefault()
|
||||
openEpisodeReleaseMenuFromElement(episode, active)
|
||||
}
|
||||
|
||||
// Play specific version
|
||||
function handlePlayVersion(filePath: string | null) {
|
||||
if (filePath) {
|
||||
emit('play', filePath);
|
||||
emit("play", filePath)
|
||||
}
|
||||
closeVersionActionMenu();
|
||||
closeContextMenu();
|
||||
closeVersionActionMenu()
|
||||
closeContextMenu()
|
||||
}
|
||||
|
||||
function getPlayLabel(filePath: string | null): string {
|
||||
return props.hasResumePosition(filePath) ? 'Continue' : 'Play';
|
||||
return props.hasResumePosition(filePath) ? "Continue" : "Play"
|
||||
}
|
||||
|
||||
// Open folder for a version
|
||||
function handleOpenFolder(folderPath: string, rootId?: string | null) {
|
||||
if (!folderPath) return;
|
||||
emit('openFolder', folderPath, rootId);
|
||||
closeVersionActionMenu();
|
||||
closeContextMenu();
|
||||
if (!folderPath) return
|
||||
emit("openFolder", folderPath, rootId)
|
||||
closeVersionActionMenu()
|
||||
closeContextMenu()
|
||||
}
|
||||
|
||||
function handleVersionActivate(torrent: Torrent, event: MouseEvent | KeyboardEvent) {
|
||||
if (!torrent.playable_file) return;
|
||||
const rootId = torrent.root_id || props.series.root_id;
|
||||
if (!torrent.playable_file) return
|
||||
const rootId = torrent.root_id || props.series.root_id
|
||||
if (event.altKey) {
|
||||
handleOpenFolder(torrent.playable_file, rootId);
|
||||
return;
|
||||
handleOpenFolder(torrent.playable_file, rootId)
|
||||
return
|
||||
}
|
||||
handlePlayVersion(torrent.playable_file);
|
||||
handlePlayVersion(torrent.playable_file)
|
||||
}
|
||||
|
||||
function handleVersionShortcutKeydown(event: KeyboardEvent, torrent: Torrent) {
|
||||
if (!torrent.playable_file) return;
|
||||
const rootId = torrent.root_id || props.series.root_id;
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === 'e' && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handleOpenFolder(torrent.playable_file, rootId);
|
||||
if (!torrent.playable_file) return
|
||||
const rootId = torrent.root_id || props.series.root_id
|
||||
const key = event.key.toLowerCase()
|
||||
if (key === "e" && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
handleOpenFolder(torrent.playable_file, rootId)
|
||||
}
|
||||
}
|
||||
|
||||
function handleVersionContextMenu(event: MouseEvent, torrent: Torrent) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const rootId = torrent.root_id || props.series.root_id;
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const rootId = torrent.root_id || props.series.root_id
|
||||
versionActionMenu.value = {
|
||||
visible: true,
|
||||
x: event.clientX,
|
||||
@@ -421,172 +454,184 @@ function handleVersionContextMenu(event: MouseEvent, torrent: Torrent) {
|
||||
filePath: torrent.playable_file || null,
|
||||
rootName: props.getRootName(rootId) || null,
|
||||
rootId,
|
||||
};
|
||||
}
|
||||
nextTick(() => {
|
||||
const firstAction = document.querySelector('.version-action-menu .version-action-item:not(:disabled)') as HTMLElement | null;
|
||||
firstAction?.focus();
|
||||
});
|
||||
const firstAction = document.querySelector(
|
||||
".version-action-menu .version-action-item:not(:disabled)",
|
||||
) as HTMLElement | null
|
||||
firstAction?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
// Video refs for hover effects
|
||||
const videoRefs = ref<Map<string, HTMLVideoElement>>(new Map());
|
||||
let videoIndex = 0;
|
||||
const safariAutoplay = isSafariBrowser();
|
||||
const videoRefs = ref<Map<string, HTMLVideoElement>>(new Map())
|
||||
let videoIndex = 0
|
||||
const safariAutoplay = isSafariBrowser()
|
||||
|
||||
// Set video ref with staggered playback
|
||||
function setVideoRef(el: HTMLVideoElement | null, key: string) {
|
||||
if (el) {
|
||||
videoRefs.value.set(key, el);
|
||||
videoRefs.value.set(key, el)
|
||||
// Staggered start times with 0.2 second offset
|
||||
const index = videoIndex++;
|
||||
setTimeout(() => {
|
||||
if (safariAutoplay && el.readyState >= 1) {
|
||||
el.currentTime = 0.001 + ((index % 6) * 0.03);
|
||||
}
|
||||
el.play().catch(() => {}); // Ignore autoplay policy errors
|
||||
}, safariAutoplay ? 0 : index * 200);
|
||||
const index = videoIndex++
|
||||
setTimeout(
|
||||
() => {
|
||||
if (safariAutoplay && el.readyState >= 1) {
|
||||
el.currentTime = 0.001 + (index % 6) * 0.03
|
||||
}
|
||||
el.play().catch(() => {}) // Ignore autoplay policy errors
|
||||
},
|
||||
safariAutoplay ? 0 : index * 200,
|
||||
)
|
||||
} else {
|
||||
videoRefs.value.delete(key);
|
||||
videoRefs.value.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
function handleEpisodeHover(key: string, isEntering: boolean) {
|
||||
const video = videoRefs.value.get(key);
|
||||
if (!video) return;
|
||||
const video = videoRefs.value.get(key)
|
||||
if (!video) return
|
||||
|
||||
// Clear any existing fade for this video
|
||||
const existingInterval = volumeFadeIntervals.get(key);
|
||||
const existingInterval = volumeFadeIntervals.get(key)
|
||||
if (existingInterval) {
|
||||
clearInterval(existingInterval);
|
||||
volumeFadeIntervals.delete(key);
|
||||
clearInterval(existingInterval)
|
||||
volumeFadeIntervals.delete(key)
|
||||
}
|
||||
|
||||
if (isEntering) {
|
||||
// Mute all other videos immediately
|
||||
videoRefs.value.forEach((v, k) => {
|
||||
if (k !== key) {
|
||||
v.volume = 0;
|
||||
v.muted = true;
|
||||
v.volume = 0
|
||||
v.muted = true
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
// Fade in this video's audio
|
||||
video.muted = false;
|
||||
video.muted = false
|
||||
const fadeIn = setInterval(() => {
|
||||
if (video.volume < 0.95) {
|
||||
video.volume = Math.min(1, video.volume + 0.1);
|
||||
video.volume = Math.min(1, video.volume + 0.1)
|
||||
} else {
|
||||
video.volume = 1;
|
||||
clearInterval(fadeIn);
|
||||
volumeFadeIntervals.delete(key);
|
||||
video.volume = 1
|
||||
clearInterval(fadeIn)
|
||||
volumeFadeIntervals.delete(key)
|
||||
}
|
||||
}, 30);
|
||||
volumeFadeIntervals.set(key, fadeIn);
|
||||
}, 30)
|
||||
volumeFadeIntervals.set(key, fadeIn)
|
||||
} else {
|
||||
// Fade out this video's audio
|
||||
const fadeOut = setInterval(() => {
|
||||
if (video.volume > 0.05) {
|
||||
video.volume = Math.max(0, video.volume - 0.1);
|
||||
video.volume = Math.max(0, video.volume - 0.1)
|
||||
} else {
|
||||
video.volume = 0;
|
||||
video.muted = true;
|
||||
clearInterval(fadeOut);
|
||||
volumeFadeIntervals.delete(key);
|
||||
video.volume = 0
|
||||
video.muted = true
|
||||
clearInterval(fadeOut)
|
||||
volumeFadeIntervals.delete(key)
|
||||
}
|
||||
}, 30);
|
||||
volumeFadeIntervals.set(key, fadeOut);
|
||||
}, 30)
|
||||
volumeFadeIntervals.set(key, fadeOut)
|
||||
}
|
||||
}
|
||||
|
||||
// Backdrop URL - only use backdrop_path, fall back to collage (handled in template)
|
||||
const backdropUrl = computed(() => {
|
||||
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
|
||||
const seasonsWithPosters = computed(() => {
|
||||
return props.series.seasons.filter(s => s.poster_path);
|
||||
});
|
||||
return props.series.seasons.filter((s) => s.poster_path)
|
||||
})
|
||||
|
||||
// Rating class
|
||||
const ratingClass = computed(() => {
|
||||
if (!props.series.info?.rating) return '';
|
||||
if (props.series.info.rating >= 7.5) return 'rating-high';
|
||||
if (props.series.info.rating >= 6) return 'rating-medium';
|
||||
return 'rating-low';
|
||||
});
|
||||
if (!props.series.info?.rating) return ""
|
||||
if (props.series.info.rating >= 7.5) return "rating-high"
|
||||
if (props.series.info.rating >= 6) return "rating-medium"
|
||||
return "rating-low"
|
||||
})
|
||||
|
||||
// Get season poster
|
||||
function getSeasonPoster(season: Season): string | undefined {
|
||||
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 }> {
|
||||
const sources = episode.reel_sources && episode.reel_sources.length > 0
|
||||
? episode.reel_sources
|
||||
: episode.reel_image
|
||||
? [episode.reel_image]
|
||||
: [];
|
||||
function getEpisodeVideoSources(
|
||||
episode: Episode,
|
||||
): Array<{ src: string; type: string; codecs: string }> {
|
||||
const sources =
|
||||
episode.reel_sources && episode.reel_sources.length > 0
|
||||
? episode.reel_sources
|
||||
: episode.reel_image
|
||||
? [episode.reel_image]
|
||||
: []
|
||||
|
||||
return sources.map((path) => ({
|
||||
src: getVideoPreviewUrl(getCoverUrl(path, props.series.root_id)),
|
||||
...getVideoSourceAttributes(path),
|
||||
}));
|
||||
}))
|
||||
}
|
||||
|
||||
// Collage slice style for season posters
|
||||
function getCollageSliceStyle(season: Season, index: number) {
|
||||
const posterUrl = season.poster_path ? getCoverUrl(season.poster_path, props.series.root_id) : null;
|
||||
const totalSlices = Math.min(seasonsWithPosters.value.length, 5);
|
||||
const sliceWidth = 100 / totalSlices;
|
||||
const posterUrl = season.poster_path
|
||||
? getCoverUrl(season.poster_path, props.series.root_id)
|
||||
: null
|
||||
const totalSlices = Math.min(seasonsWithPosters.value.length, 5)
|
||||
const sliceWidth = 100 / totalSlices
|
||||
|
||||
return {
|
||||
backgroundImage: posterUrl ? `url('${posterUrl}')` : 'linear-gradient(135deg, #1a1a2e, #16213e)',
|
||||
backgroundImage: posterUrl
|
||||
? `url('${posterUrl}')`
|
||||
: "linear-gradient(135deg, #1a1a2e, #16213e)",
|
||||
left: `${index * sliceWidth}%`,
|
||||
width: `${sliceWidth + 5}%`, // overlap slightly
|
||||
clipPath: `polygon(${index * 10}% 0, 100% 0, ${100 - (totalSlices - index - 1) * 10}% 100%, 0% 100%)`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Truncate text
|
||||
function truncate(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.slice(0, maxLength).trim() + '...';
|
||||
if (text.length <= maxLength) return text
|
||||
return text.slice(0, maxLength).trim() + "..."
|
||||
}
|
||||
|
||||
// Handle play
|
||||
function handlePlay(episode: Episode) {
|
||||
const playableFile = Object.values(episode.torrents || {})[0]?.playable_file;
|
||||
const playableFile = Object.values(episode.torrents || {})[0]?.playable_file
|
||||
if (playableFile) {
|
||||
emit('play', playableFile);
|
||||
emit("play", playableFile)
|
||||
}
|
||||
}
|
||||
|
||||
function handleEpisodeEnter(event: KeyboardEvent, episode: Episode) {
|
||||
if (event.altKey || event.metaKey || event.ctrlKey) {
|
||||
openEpisodeReleaseMenuFromElement(episode, event.currentTarget as HTMLElement | null);
|
||||
return;
|
||||
openEpisodeReleaseMenuFromElement(episode, event.currentTarget as HTMLElement | null)
|
||||
return
|
||||
}
|
||||
handlePlay(episode);
|
||||
handlePlay(episode)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('mediahive:gamepad-action', handleGamepadAction as EventListener);
|
||||
});
|
||||
window.addEventListener("mediahive:gamepad-action", handleGamepadAction as EventListener)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('mediahive:gamepad-action', handleGamepadAction as EventListener);
|
||||
});
|
||||
window.removeEventListener("mediahive:gamepad-action", handleGamepadAction as EventListener)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -675,9 +720,15 @@ onUnmounted(() => {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.rating-high { color: #46d369; }
|
||||
.rating-medium { color: #f9a825; }
|
||||
.rating-low { color: #e53935; }
|
||||
.rating-high {
|
||||
color: #46d369;
|
||||
}
|
||||
.rating-medium {
|
||||
color: #f9a825;
|
||||
}
|
||||
.rating-low {
|
||||
color: #e53935;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
@@ -825,7 +876,8 @@ html:not(.mouse-active) .episode-tile.nav-focused {
|
||||
|
||||
/* Blinking animation for focus outline */
|
||||
@keyframes tile-outline-blink {
|
||||
0%, 100% {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
@@ -1061,5 +1113,4 @@ html.mouse-active .episode-tile:hover .tile-play {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user