Merge mediahive.

This commit is contained in:
2026-02-07 00:21:32 +00:00
parent 470adbbbb7
commit daf4eb5e0c
35 changed files with 9025 additions and 43 deletions
+15
View File
@@ -0,0 +1,15 @@
{
"tasks": {
"dev": "deno run -A npm:vite",
"build": "deno run -A npm:vue-tsc --noEmit && deno run -A npm:vite build",
"preview": "deno run -A npm:vite preview"
},
"imports": {
"vue": "npm:vue@^3.4.0"
},
"compilerOptions": {
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"jsx": "preserve"
},
"nodeModulesDir": "auto"
}
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/webp" sizes="32x32" href="/src/assets/mediahive-32.webp" />
<link rel="icon" type="image/webp" sizes="192x192" href="/src/assets/mediahive.webp" />
<link rel="apple-touch-icon" href="/src/assets/mediahive.webp" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MediaHive</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1405
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
{
"name": "mediahive-frontend",
"version": "1.0.0",
"description": "Netflix-style media streaming for your collection",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"vue-tsc": "^2.0.0"
}
}
+1106
View File
File diff suppressed because it is too large Load Diff
+89
View File
@@ -0,0 +1,89 @@
import type { MediaIndex } from './types';
/**
* Load the media index from the server
*/
export async function loadMediaIndex(): Promise<MediaIndex> {
const response = await fetch('/api/index');
if (!response.ok) {
throw new Error(`Failed to load media index: ${response.statusText}`);
}
return response.json();
}
/**
* Play a media file with the system's default player
*/
export async function playMedia(filePath: string): Promise<void> {
try {
const response = await fetch('/api/play', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_path: filePath }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || response.statusText);
}
} catch (e) {
console.error('Play media error:', e);
alert(`Failed to play: ${e}`);
}
}
/**
* Open a folder in Windows Explorer
*/
export async function openFolder(folderPath: string): Promise<void> {
try {
const response = await fetch('/api/open-folder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ folder_path: folderPath }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || response.statusText);
}
} catch (e) {
console.error('Open folder error:', e);
alert(`Failed to open folder: ${e}`);
}
}
/**
* Convert a cover path to a displayable URL.
* Uses FastAPI server for async file serving.
*
* The path comes from the server already converted to Windows format (Z:\...)
* Paths starting with '/' are TMDB relative paths that weren't fetched - ignore them
*/
export function getCoverUrl(coverPath: string | null): string {
if (!coverPath) {
return '';
}
// Ignore TMDB relative paths (start with /) - these are bugs in the index
if (coverPath.startsWith('/')) {
return '';
}
// Convert relative path to URL path for FastAPI server
// .mediahive/covers/Movies/... -> /media/.mediahive/covers/Movies/...
let urlPath = coverPath;
// Remove drive letter (Z:) and convert backslashes to forward slashes
if (urlPath.match(/^[A-Za-z]:/)) {
urlPath = urlPath.substring(2);
}
urlPath = urlPath.replace(/\\/g, '/');
// Ensure path starts with /
if (!urlPath.startsWith('/')) {
urlPath = '/' + urlPath;
}
// Encode URI components but preserve slashes
const encodedPath = urlPath.split('/').map(segment => encodeURIComponent(segment)).join('/');
return `/api/media${encodedPath}`;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 874 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

File diff suppressed because it is too large Load Diff
+157
View File
@@ -0,0 +1,157 @@
<template>
<header class="header" :class="[`header-${position}`]">
<div class="header-left">
<img :src="logoUrl" alt="MediaHive" class="header-logo" />
<nav class="header-nav">
<!-- Browse mode: show both Movies and Series -->
<template v-if="!isDetailPage">
<button
class="header-nav-item"
:class="{ active: !isSearchActive && currentView === 'movies' }"
v-bind="navAttrs(navRow, 0)"
:data-nav-entry-col="!isSearchActive && currentView === 'movies' ? 0 : undefined"
@focus="switchToMovies"
>
Movies
</button>
<button
class="header-nav-item"
:class="{ active: !isSearchActive && currentView === 'series' }"
v-bind="navAttrs(navRow, 1)"
:data-nav-entry-col="!isSearchActive && currentView === 'series' ? 1 : undefined"
@focus="switchToSeries"
>
Series
</button>
</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>
</template>
</nav>
</div>
<div class="header-search">
<input
ref="searchInputRef"
type="search"
class="search-input"
placeholder="Search..."
v-model="localSearch"
v-bind="navAttrs(navRow, 2)"
:data-nav-entry-col="localSearch ? 2 : undefined"
@focus="handleSearchFocus"
@keydown.escape="handleEscape"
/>
</div>
</header>
</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';
const props = defineProps<{
currentView: 'movies' | 'series';
searchQuery: string;
navRow: number;
position: 'top' | 'after-hero' | 'after-movie-header' | 'after-series-hero';
}>();
const emit = defineEmits<{
search: [string];
clearSearch: [];
goBack: [];
}>();
const router = useRouter();
const searchInputRef = ref<HTMLInputElement | null>(null);
const localSearch = ref(props.searchQuery);
// Check if we're on a detail page
const isDetailPage = computed(() => {
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;
});
// Switch views on focus (no Enter required) - only in browse mode
function switchToMovies() {
if (!isDetailPage.value && props.currentView !== 'movies') {
emit('clearSearch');
router.push('/movies');
}
}
function switchToSeries() {
if (!isDetailPage.value && props.currentView !== 'series') {
emit('clearSearch');
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');
}
// Handle search input focus - navigate to search if we have a query
function handleSearchFocus() {
// If on detail page, go back to browse first
if (isDetailPage.value) {
goToCategory();
}
}
// Sync local search to parent
watch(localSearch, (val) => {
emit('search', val);
});
// Sync parent search to local (for external clears)
watch(() => props.searchQuery, (val) => {
if (val !== localSearch.value) {
localSearch.value = val;
}
});
function handleEscape() {
// Clear search and blur
localSearch.value = '';
searchInputRef.value?.blur();
}
function handleKeydown(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
e.preventDefault();
searchInputRef.value?.focus();
searchInputRef.value?.select();
}
}
onMounted(() => {
window.addEventListener('keydown', handleKeydown);
});
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown);
});
</script>
+149
View File
@@ -0,0 +1,149 @@
<template>
<section class="hero">
<div
v-if="coverUrl"
class="hero-background"
:style="{ backgroundImage: `url('${coverUrl}')` }"
></div>
<div class="hero-content">
<h1 class="hero-title">{{ item.title }}</h1>
<div class="hero-meta">
<span v-if="item.year" class="hero-year">{{ item.year }}</span>
<span v-if="rating" class="hero-rating" :class="ratingClass">
{{ rating.toFixed(1) }}
</span>
<span v-if="resolution" class="hero-quality">{{ resolution }}</span>
<span v-if="quality" class="hero-quality">{{ quality }}</span>
</div>
<p v-if="overview" class="hero-overview">{{ overview }}</p>
<div class="hero-buttons">
<button
class="btn btn-primary"
@click="handlePlay"
:disabled="!playableFile"
>
Play
</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';
const props = defineProps<{
item: MediaItem;
}>();
const emit = defineEmits<{
play: [string];
info: [MediaItem];
}>();
const coverUrl = computed(() => {
return getCoverUrl(props.item.cover_path);
});
const resolution = computed(() => {
if (props.item.type === 'movies') {
const movie = props.item.data as Movie;
return movie.versions && movie.versions.length > 0 ? movie.versions[0].resolution : null;
}
return null;
});
const quality = computed(() => {
if (props.item.type === 'movies') {
const movie = props.item.data as Movie;
return movie.versions && movie.versions.length > 0 ? movie.versions[0].quality : null;
}
return null;
});
const rating = computed(() => {
if (props.item.type === 'movies') {
return (props.item.data as Movie).rating;
}
return (props.item.data as Series).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';
});
const overview = computed(() => {
if (props.item.type === 'movies') {
const o = (props.item.data as Movie).overview;
return o ? (o.length > 200 ? o.slice(0, 200) + '...' : o) : null;
}
const o = (props.item.data as Series).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;
// Get the first version's playable file
if (movie.versions && movie.versions.length > 0) {
return movie.versions[0].playable_file;
}
return null;
}
// For series, get first available file from episodes
const series = props.item.data as Series;
for (const season of series.seasons || []) {
for (const episode of season.episodes || []) {
for (const release of episode.releases || []) {
if (release.playable_file) {
return release.playable_file;
}
}
}
}
return null;
});
function handlePlay() {
if (playableFile.value) {
emit('play', playableFile.value);
}
}
</script>
<style scoped>
.hero-rating {
font-weight: 600;
padding: 4px 10px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.6);
}
.rating-high {
color: #46d369;
}
.rating-medium {
color: #f9a825;
}
.rating-low {
color: #e53935;
}
.hero-overview {
max-width: 500px;
color: var(--text-secondary);
font-size: 0.95rem;
line-height: 1.5;
margin-top: 12px;
}
</style>
+297
View File
@@ -0,0 +1,297 @@
<template>
<div
class="media-card"
v-bind="navAttributes"
:data-item-id="item.id"
@click="$emit('click')"
@keydown.enter.prevent="$emit('click')"
>
<div class="media-card-poster">
<!-- SVG focus outline -->
<svg class="card-focus-outline" viewBox="0 0 100 150" preserveAspectRatio="none">
<rect x="0" y="0" width="100" height="150" />
</svg>
<img
v-if="coverUrl && !imageError"
:src="coverUrl"
:alt="item.title"
loading="lazy"
@error="imageError = true"
/>
<div v-else class="media-card-placeholder">
{{ item.type === 'movies' ? '🎬' : item.type === 'episode' ? '📺' : '📺' }}
</div>
<div v-if="rating" class="media-card-rating" :class="ratingClass">
{{ rating.toFixed(1) }}
</div>
</div>
<div class="media-card-info">
<div class="media-card-title-row">
<span class="media-card-title">{{ displayTitle }}</span>
<span v-if="item.year" class="media-card-year">{{ item.year }}</span>
</div>
<!-- Search match info (when searching) -->
<template v-if="item.searchMatchInfo">
<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>
</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">
<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>
</template>
<!-- Default display (browsing) -->
<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 }}
</div>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import type { MediaItem, Movie, Series, EpisodeWithSeries } from '../types';
import { getCoverUrl } from '../api';
import { navAttrs } from '../composables/useKeyboardNavigation';
const props = defineProps<{
item: MediaItem;
navRow?: number;
navCol?: number;
}>();
defineEmits<{
click: [];
}>();
// Navigation attributes for keyboard navigation
const navAttributes = computed(() => {
if (props.navRow !== undefined && props.navCol !== undefined) {
return navAttrs(props.navRow, props.navCol);
}
return {};
});
const imageError = ref(false);
const coverUrl = computed(() => {
if (imageError.value) return null;
return getCoverUrl(props.item.cover_path);
});
const rating = computed(() => {
if (props.item.type === 'movies') {
return (props.item.data as Movie).rating;
}
if (props.item.type === 'episode') {
const epData = props.item.data as EpisodeWithSeries;
return epData.episode.rating ?? epData.series.rating;
}
return (props.item.data as Series).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';
});
const displayTitle = computed(() => {
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;
});
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}`;
}
// For series, show creators
if (props.item.type === 'series') {
const creators = (props.item.data as Series).creators;
return creators && creators.length > 0 ? creators.join(', ') : null;
}
return null;
});
// Director for movies
const director = computed(() => {
if (props.item.type !== 'movies') return null;
return (props.item.data as Movie).director;
});
// Check if we have director and/or cast to display
const directorAndCast = computed(() => {
if (props.item.type !== 'movies') return false;
return director.value || filteredCastNames.value;
});
// Cast names, excluding director if they appear in cast
const filteredCastNames = computed(() => {
if (props.item.type !== 'movies') return null;
const cast = (props.item.data as Movie).cast;
if (!cast || cast.length === 0) return null;
const directorName = director.value?.toLowerCase();
const filteredCast = directorName
? cast.filter(c => c.name.toLowerCase() !== directorName)
: cast;
if (filteredCast.length === 0) return null;
// Show first 3 cast members
const names = filteredCast.slice(0, 3).map(c => c.name);
return names.join(', ');
});
// Matched people from search (from searchMatchInfo)
const matchedPeople = computed(() => {
const info = props.item.searchMatchInfo;
if (!info || !info.matchedPeople) return null;
return info.matchedPeople;
});
</script>
<style scoped>
/* Blinking animation for focus outline */
@keyframes card-outline-blink {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
/* SVG focus outline styles */
.card-focus-outline {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 5;
opacity: 0;
transition: opacity 0.2s ease;
}
.card-focus-outline rect {
fill: none;
stroke: rgba(255, 255, 255, 0.9);
stroke-width: 4;
vector-effect: non-scaling-stroke;
}
/* Show outline on hover and focus */
.media-card:hover .card-focus-outline,
.media-card.nav-focused .card-focus-outline {
opacity: 1;
animation: card-outline-blink 1s ease-in-out infinite;
}
/* Brighter outline for keyboard focus */
.media-card.nav-focused .card-focus-outline rect {
stroke: #ffffff;
stroke-width: 5;
filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.8));
}
.media-card-rating {
position: absolute;
top: 6px;
right: 6px;
background: rgba(0, 0, 0, 0.85);
padding: 3px 6px;
border-radius: 3px;
font-size: 0.65rem;
font-weight: 600;
}
.rating-high {
color: #46d369;
}
.rating-medium {
color: #f9a825;
}
.rating-low {
color: #e53935;
}
.media-card-detail {
font-size: 0.65rem;
color: var(--text-muted);
margin-top: 1px;
/* Allow up to 2 lines with ellipsis */
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.3;
}
.director-name {
font-weight: 600;
color: var(--text-secondary);
}
/* Search match styles */
.match-reason {
color: var(--text-secondary);
}
.match-name {
font-weight: 600;
color: var(--text-secondary);
}
.match-roles {
color: var(--text-muted);
font-weight: 400;
}
/* When character name matched - highlight the role, dim the name */
.match-dim {
color: var(--text-muted);
font-weight: 400;
}
.match-highlight {
font-weight: 600;
color: var(--text-secondary);
}
.media-card-episodes {
margin-top: 2px;
display: flex;
flex-direction: column;
gap: 1px;
}
.matched-episode {
font-size: 0.6rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.matched-episode-more {
font-size: 0.55rem;
color: var(--text-muted);
font-style: italic;
}
</style>
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
<template>
<div class="media-row" :class="{ 'media-row-wrap': wrap }">
<MediaCard
v-for="(item, index) in items"
:key="item.id"
:item="item"
:nav-row="rowIndex"
:nav-col="index"
@click="$emit('select', item)"
/>
</div>
</template>
<script setup lang="ts">
import type { MediaItem } from '../types';
import MediaCard from './MediaCard.vue';
defineProps<{
items: MediaItem[];
wrap?: boolean;
rowIndex?: number;
}>();
defineEmits<{
select: [MediaItem];
}>();
</script>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,381 @@
import { ref } from 'vue';
export interface FocusableElement {
element: HTMLElement;
row: number;
col: number;
}
// Global focus state
const focusedElement = ref<HTMLElement | null>(null);
const isNavigating = ref(false);
// Track the "desired" column when moving vertically (to maintain column position across rows of different lengths)
const desiredCol = ref<number | null>(null);
// Track if global handlers are installed
let handlersInstalled = false;
// Data attribute names
const FOCUSABLE_ATTR = 'data-nav-focusable';
const ROW_ATTR = 'data-nav-row';
const COL_ATTR = 'data-nav-col';
const ENTRY_COL_ATTR = 'data-nav-entry-col';
/**
* Get all focusable elements in the DOM, grouped by row
*/
function getFocusableElements(): FocusableElement[] {
const elements = document.querySelectorAll(`[${FOCUSABLE_ATTR}]`);
const result: FocusableElement[] = [];
elements.forEach((el) => {
const htmlEl = el as HTMLElement;
// Skip hidden elements
if (htmlEl.offsetParent === null) return;
const rect = htmlEl.getBoundingClientRect();
// Skip elements not in viewport or zero-sized
if (rect.width === 0 || rect.height === 0) return;
const row = parseInt(htmlEl.getAttribute(ROW_ATTR) || '0', 10);
const col = parseInt(htmlEl.getAttribute(COL_ATTR) || '0', 10);
result.push({
element: htmlEl,
row,
col,
});
});
return result;
}
/**
* Get elements grouped by row
*/
function getElementsByRow(): Map<number, FocusableElement[]> {
const elements = getFocusableElements();
const byRow = new Map<number, FocusableElement[]>();
for (const el of elements) {
if (!byRow.has(el.row)) {
byRow.set(el.row, []);
}
byRow.get(el.row)!.push(el);
}
// Sort each row by column
for (const [, rowElements] of byRow) {
rowElements.sort((a, b) => a.col - b.col);
}
return byRow;
}
/**
* Find element by row and col indices
* @param useEntryCol - if true, check for entry-col override on elements
*/
function findElementAt(row: number, col: number, useEntryCol: boolean = false): FocusableElement | null {
const byRow = getElementsByRow();
const rowElements = byRow.get(row);
if (!rowElements || rowElements.length === 0) return null;
// Check if any element in this row has an entry-col override
if (useEntryCol) {
for (const el of rowElements) {
const entryCol = el.element.getAttribute(ENTRY_COL_ATTR);
if (entryCol !== null) {
const overrideCol = parseInt(entryCol, 10);
const entryTarget = rowElements.find(e => e.col === overrideCol);
if (entryTarget) return entryTarget;
}
}
}
// Find exact match or nearest col
const exact = rowElements.find(e => e.col === col);
if (exact) return exact;
// Find nearest col in this row
let nearest = rowElements[0];
let nearestDist = Math.abs(nearest.col - col);
for (const el of rowElements) {
const dist = Math.abs(el.col - col);
if (dist < nearestDist) {
nearest = el;
nearestDist = dist;
}
}
return nearest;
}
/**
* Find next element in direction using row/col indices
*/
function findNextElement(
current: HTMLElement,
direction: 'up' | 'down' | 'left' | 'right'
): HTMLElement | null {
const currentRow = parseInt(current.getAttribute(ROW_ATTR) || '0', 10);
const currentCol = parseInt(current.getAttribute(COL_ATTR) || '0', 10);
const byRow = getElementsByRow();
if (direction === 'left' || direction === 'right') {
// Horizontal: move within same row by col index
desiredCol.value = null; // Reset desired col on horizontal movement
const rowElements = byRow.get(currentRow);
if (!rowElements) return null;
const delta = direction === 'right' ? 1 : -1;
const targetCol = currentCol + delta;
// Find element with target col in this row
const target = rowElements.find(e => e.col === targetCol);
return target?.element || null;
} else {
// Vertical: move to adjacent row, try to maintain column
const sortedRows = Array.from(byRow.keys()).sort((a, b) => a - b);
const currentRowIdx = sortedRows.indexOf(currentRow);
if (currentRowIdx === -1) return null;
const delta = direction === 'down' ? 1 : -1;
const targetRowIdx = currentRowIdx + delta;
if (targetRowIdx < 0 || targetRowIdx >= sortedRows.length) return null;
const targetRow = sortedRows[targetRowIdx];
// Use desired col if set, otherwise use current col
const targetCol = desiredCol.value ?? currentCol;
// Set desired col if not already set (first vertical move in a sequence)
if (desiredCol.value === null) {
desiredCol.value = currentCol;
}
// Use entry column hook for vertical navigation
const target = findElementAt(targetRow, targetCol, true);
return target?.element || null;
}
}
/**
* Focus an element and scroll it into view
*/
function focusElement(element: HTMLElement | null) {
if (!element) return;
// Remove focus from previous element
if (focusedElement.value && focusedElement.value !== element) {
focusedElement.value.classList.remove('nav-focused');
focusedElement.value.blur();
}
// Add focus to new element
element.classList.add('nav-focused');
element.focus({ preventScroll: true });
// Smooth scroll for vertical (block), instant for horizontal (inline)
element.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'nearest',
});
focusedElement.value = element;
}
/**
* Get current focus state (row, col) for saving
*/
function getFocusState(): { row: number; col: number } | null {
if (!focusedElement.value) return null;
const row = parseInt(focusedElement.value.getAttribute(ROW_ATTR) || '0', 10);
const col = parseInt(focusedElement.value.getAttribute(COL_ATTR) || '0', 10);
return { row, col };
}
/**
* Restore focus to element with given row/col
*/
function restoreFocusState(state: { row: number; col: number } | null) {
if (!state) return;
const target = findElementAt(state.row, state.col);
if (target) {
setTimeout(() => {
focusElement(target.element);
}, 50);
}
}
/**
* Focus element at specific row/col after a delay (for page transitions)
*/
function focusAt(row: number, col: number, delay: number = 100) {
setTimeout(() => {
const target = findElementAt(row, col);
if (target) {
focusElement(target.element);
}
}, delay);
}
/**
* Check if we should allow navigation from an input element
*/
function shouldAllowNavigationFromInput(target: HTMLElement, direction: string): boolean {
if (target.tagName !== 'INPUT' && target.tagName !== 'TEXTAREA' && !target.isContentEditable) {
return true; // Not an input, allow navigation
}
// Always allow up/down navigation from inputs
if (direction === 'up' || direction === 'down') {
return true;
}
// For left/right, only capture if input is empty
const inputEl = target as HTMLInputElement | HTMLTextAreaElement;
const value = inputEl.value || '';
return value.length === 0;
}
/**
* Handle keyboard navigation
*/
function handleKeyDown(event: KeyboardEvent) {
const target = event.target as HTMLElement;
const direction = {
ArrowUp: 'up',
ArrowDown: 'down',
ArrowLeft: 'left',
ArrowRight: 'right',
}[event.key] as 'up' | 'down' | 'left' | 'right' | undefined;
if (!direction) return;
// Check if we should allow navigation from this element
if (!shouldAllowNavigationFromInput(target, direction)) {
return;
}
event.preventDefault();
isNavigating.value = true;
// Get current focused element or find the first one
let current = focusedElement.value;
// If no element is focused, try to get the currently focused element from DOM
if (!current) {
const activeElement = document.activeElement as HTMLElement;
if (activeElement && activeElement.hasAttribute(FOCUSABLE_ATTR)) {
current = activeElement;
}
}
// If still no current, focus the first available element
if (!current) {
const elements = getFocusableElements();
if (elements.length > 0) {
focusElement(elements[0].element);
}
return;
}
// Find and focus the next element using index-based navigation
const next = findNextElement(current, direction);
if (next) {
focusElement(next);
}
}
/**
* Handle Enter key to activate focused element
*/
function handleEnterKey(event: KeyboardEvent) {
if (event.key !== 'Enter') return;
const target = event.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
return;
}
if (focusedElement.value) {
event.preventDefault();
focusedElement.value.click();
}
}
/**
* Install global keyboard navigation handlers
* Should be called once at app initialization
*/
export function installKeyboardNavigation() {
if (handlersInstalled) return;
handlersInstalled = true;
document.addEventListener('keydown', handleKeyDown);
document.addEventListener('keydown', handleEnterKey);
// Handle mouse clicks to update focus state
document.addEventListener('click', (event) => {
const target = event.target as HTMLElement;
const focusable = target.closest(`[${FOCUSABLE_ATTR}]`) as HTMLElement | null;
if (focusable) {
desiredCol.value = null; // Reset desired col on mouse click
focusElement(focusable);
}
});
// Handle focus events from tab navigation
document.addEventListener('focusin', (event) => {
const target = event.target as HTMLElement;
if (target.hasAttribute(FOCUSABLE_ATTR)) {
if (focusedElement.value && focusedElement.value !== target) {
focusedElement.value.classList.remove('nav-focused');
}
focusedElement.value = target;
target.classList.add('nav-focused');
desiredCol.value = null; // Reset desired col on focus change
}
});
}
/**
* Composable to access keyboard navigation state
* @deprecated Use installKeyboardNavigation() at app init instead
*/
export function useKeyboardNavigation() {
return {
focusedElement,
isNavigating,
focusElement,
focusAt,
getFocusState,
restoreFocusState,
};
}
/**
* Helper to generate navigation attributes for a focusable element
* @param entryCol - optional column to focus when entering this row vertically
*/
export function navAttrs(row: number, col: number, entryCol?: number) {
const attrs: Record<string, string | number> = {
[FOCUSABLE_ATTR]: 'true',
[ROW_ATTR]: String(row),
[COL_ATTR]: String(col),
tabindex: 0,
};
if (entryCol !== undefined) {
attrs[ENTRY_COL_ATTR] = String(entryCol);
}
return attrs;
}
export { FOCUSABLE_ATTR, ROW_ATTR, COL_ATTR, ENTRY_COL_ATTR };
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import './styles/main.css'
import { installKeyboardNavigation } from './composables/useKeyboardNavigation'
// Install global keyboard navigation handlers immediately
installKeyboardNavigation()
createApp(App).use(router).mount('#app')
+49
View File
@@ -0,0 +1,49 @@
import { createRouter, createWebHashHistory } from 'vue-router';
import { defineComponent, h } from 'vue';
// Empty component - App.vue handles all rendering based on route meta
const EmptyRouteComponent = defineComponent({
render() {
return h('div');
}
});
const router = createRouter({
history: createWebHashHistory(),
scrollBehavior() {
// Always scroll to top on navigation
return { top: 0 };
},
routes: [
{
path: '/',
redirect: '/movies',
},
{
path: '/movies',
name: 'movies',
component: EmptyRouteComponent,
meta: { view: 'movies' },
},
{
path: '/movies/:id',
name: 'movie-detail',
component: EmptyRouteComponent,
meta: { view: 'movies' },
},
{
path: '/series',
name: 'series',
component: EmptyRouteComponent,
meta: { view: 'series' },
},
{
path: '/series/:id',
name: 'series-detail',
component: EmptyRouteComponent,
meta: { view: 'series' },
},
],
});
export default router;
+810
View File
@@ -0,0 +1,810 @@
/* Netflix-style dark theme */
:root {
--bg-primary: #0a0a0a;
--bg-secondary: #141414;
--bg-card: #1f1f1f;
--bg-card-hover: #2a2a2a;
--text-primary: #ffffff;
--text-secondary: #b3b3b3;
--text-muted: #808080;
--accent-red: #e50914;
--accent-red-hover: #f40612;
--border-color: #333333;
--shadow-color: rgba(0, 0, 0, 0.75);
--gradient-fade: linear-gradient(to top, var(--bg-primary) 0%, transparent 100%);
--header-height: 56px;
--card-width: 160px;
--card-aspect-ratio: 2/3;
--section-padding: 2.5%;
--transition-fast: 150ms ease;
--transition-medium: 300ms ease;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
font-family: 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
background-color: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
overflow-x: hidden;
-webkit-font-smoothing: antialiased;
text-align: justify;
hyphens: auto;
-webkit-hyphens: auto;
-ms-hyphens: auto;
}
#app {
min-height: 100vh;
position: relative;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-secondary);
}
::-webkit-scrollbar-thumb {
background: var(--text-muted);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
}
/* Header - persistent overlay that scrolls with content */
.header {
position: absolute;
left: 0;
right: 0;
height: var(--header-height);
background: transparent;
z-index: 50;
display: flex;
align-items: center;
padding: 0 12px;
transition: top 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}
.header-top {
top: 40px;
}
.header-after-hero {
/* Position after browse collage-hero (70vh, max 600px) */
top: clamp(450px, 70vh, 600px);
}
.header-after-movie-header {
/* Position after movie detail collage-header (300px) */
top: 320px;
}
.header-after-series-hero {
/* Position after series detail hero (70vh, 450-600px) */
top: clamp(450px, 70vh, 600px);
}
/* Spacer to reserve space for header in layout */
.header-spacer {
height: calc(var(--header-height) + 20px);
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.header-logo {
height: 40px;
width: 40px;
margin-right: 20px;
object-fit: contain;
}
.header-nav {
display: flex;
gap: 20px;
min-width: 200px;
}
.header-nav-item {
color: var(--text-secondary);
text-decoration: none;
font-size: 1.6rem;
font-weight: 500;
cursor: pointer;
transition: color var(--transition-fast);
background: none;
border: none;
padding: 0;
}
.header-nav-item:hover,
.header-nav-item.active {
color: var(--text-primary);
}
.header-nav-item:focus,
.header-nav-item.nav-focused {
color: var(--text-primary);
outline: none;
text-decoration: underline;
text-underline-offset: 4px;
}
.header-search {
display: flex;
align-items: center;
gap: 12px;
}
.search-input {
background: rgba(20, 20, 20, 0.9);
border: 2px solid var(--border-color);
border-radius: 4px;
padding: 4px 10px;
color: var(--text-primary);
font-size: 1.6rem;
width: 6em;
transition: all var(--transition-fast);
}
.search-input:focus {
outline: none;
border-color: var(--text-secondary);
border-width: 3px;
}
.search-input::placeholder {
color: var(--text-muted);
}
/* Main content */
.main-content {
min-height: 100vh;
background: var(--bg-primary);
}
.main-content-no-hero {
padding-top: 40px;
}
/* Hero section - kept for backwards compatibility but not used */
.hero {
position: relative;
height: 70vh;
max-height: 600px;
min-height: 400px;
display: flex;
align-items: flex-end;
padding: 0 4% 8%;
background-color: var(--bg-primary);
overflow: hidden;
}
.hero-background {
position: absolute;
top: 0;
right: 0;
width: 60%;
height: 100%;
background-size: contain;
background-position: right top;
background-repeat: no-repeat;
mask-image: linear-gradient(to left, rgba(0,0,0,0.6) 0%, rgba(0,0,0,0.3) 60%, transparent 100%);
-webkit-mask-image: linear-gradient(to left, rgba(0,0,0,0.6) 0%, rgba(0,0,0,0.3) 60%, transparent 100%);
}
.hero::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 200px;
background: var(--gradient-fade);
}
.hero-content {
position: relative;
z-index: 1;
max-width: 600px;
}
.hero-title {
font-size: 3rem;
font-weight: 700;
margin-bottom: 16px;
text-shadow: 2px 2px 4px var(--shadow-color);
}
.hero-meta {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 16px;
font-size: 1rem;
}
.hero-year {
color: var(--text-secondary);
}
.hero-quality {
background: var(--bg-secondary);
padding: 4px 8px;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 600;
}
.hero-buttons {
display: flex;
gap: 12px;
margin-top: 24px;
}
/* Blinking animation for button focus */
@keyframes btn-outline-blink {
0%, 100% {
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.9);
}
50% {
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.4);
}
}
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 28px;
border-radius: 4px;
font-size: 1.1rem;
font-weight: 600;
cursor: pointer;
border: none;
transition: all var(--transition-fast);
outline: none;
}
.btn:focus {
outline: none;
}
.btn.nav-focused,
.btn:focus-visible {
outline: none;
}
.btn-primary {
background: var(--text-primary);
color: var(--bg-primary);
}
.btn-primary:hover,
.btn-primary.nav-focused {
background: rgba(255, 255, 255, 0.85);
animation: btn-outline-blink 1s ease-in-out infinite;
}
.btn-secondary {
background: rgba(109, 109, 110, 0.7);
color: var(--text-primary);
}
.btn-secondary:hover,
.btn-secondary.nav-focused {
background: rgba(109, 109, 110, 0.5);
animation: btn-outline-blink 1s ease-in-out infinite;
}
/* View transitions - cinematic zoom effect */
.view-container {
position: relative;
overflow: hidden;
}
.view-content {
width: 100%;
}
.view-zoom-enter-active {
transition: opacity 0.4s ease-out, transform 0.4s ease-out;
}
.view-zoom-leave-active {
transition: opacity 0.3s ease-in, transform 0.3s ease-in;
}
.view-zoom-enter-from {
opacity: 0;
transform: scale(1.02) translateY(-10px);
}
.view-zoom-leave-to {
opacity: 0;
transform: scale(0.98) translateY(10px);
}
/* Media rows */
.media-section {
padding: 0 var(--section-padding);
margin-bottom: 20px;
}
.section-title {
font-size: 1.1rem;
font-weight: 600;
margin-bottom: 10px;
color: var(--text-primary);
}
.media-row {
display: flex;
gap: 6px;
overflow-x: auto;
overflow-y: hidden;
scroll-behavior: smooth;
padding-bottom: 8px;
margin: 0 -2px;
padding: 4px 2px 8px;
scrollbar-width: none;
-ms-overflow-style: none;
}
.media-row::-webkit-scrollbar {
height: 0;
display: none;
}
.media-row-wrap {
flex-wrap: wrap;
overflow-x: visible;
overflow-y: visible;
gap: 12px;
padding-bottom: 20px;
}
/* Media cards */
.media-card {
flex-shrink: 0;
width: var(--card-width);
cursor: pointer;
transition: z-index 0s, box-shadow var(--transition-medium);
position: relative;
outline: none;
}
.media-card:hover,
.media-card.nav-focused {
z-index: 10;
}
.media-card:focus {
outline: none;
}
.media-card:focus-visible {
outline: none;
}
.media-card-poster {
width: 100%;
aspect-ratio: var(--card-aspect-ratio);
background: var(--bg-card);
border-radius: 3px;
overflow: hidden;
position: relative;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
}
.media-card:hover .media-card-poster {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6);
}
.media-card-poster img {
width: 100%;
height: 100%;
object-fit: cover;
transition: opacity var(--transition-fast);
}
.media-card-placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background:
repeating-linear-gradient(
45deg,
rgba(100, 100, 100, 0.4),
rgba(100, 100, 100, 0.4) 10px,
rgba(80, 80, 80, 0.4) 10px,
rgba(80, 80, 80, 0.4) 20px
),
linear-gradient(135deg, #3a3a3a 0%, #2a2a2a 100%);
color: #888;
font-size: 3rem;
border: 2px dashed #555;
gap: 8px;
}
.media-card-placeholder::after {
content: "No Cover";
font-size: 0.7rem;
color: #666;
letter-spacing: 0.5px;
}
.media-card-info {
padding: 4px 2px 2px;
opacity: 1;
transition: opacity var(--transition-fast);
}
.media-card:hover .media-card-info {
opacity: 1;
}
.media-card-title-row {
display: flex;
align-items: baseline;
gap: 4px;
margin-bottom: 2px;
}
.media-card-title {
font-size: 0.75rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
}
.media-card-year {
font-size: 0.7rem;
color: var(--text-muted);
flex-shrink: 0;
}
.media-card-meta {
font-size: 0.7rem;
color: var(--text-muted);
display: flex;
gap: 6px;
}
/* Modal / Detail view */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.85);
z-index: 1000;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 40px 20px;
overflow-y: auto;
}
.modal-content {
background: var(--bg-secondary);
border-radius: 8px;
width: 100%;
max-width: 900px;
overflow: hidden;
box-shadow: 0 20px 60px var(--shadow-color);
animation: modalIn 0.3s ease;
}
@keyframes modalIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
.modal-header {
position: relative;
height: 400px;
background-size: cover;
background-position: center;
}
.modal-header::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 200px;
background: linear-gradient(to top, var(--bg-secondary) 0%, transparent 100%);
}
.modal-close {
position: absolute;
top: 16px;
right: 16px;
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--bg-primary);
border: none;
color: var(--text-primary);
font-size: 1.5rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
transition: background var(--transition-fast);
}
.modal-close:hover {
background: var(--bg-card);
}
.modal-header-content {
position: absolute;
bottom: 24px;
left: 32px;
right: 32px;
z-index: 5;
}
.modal-title {
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 16px;
}
.modal-body {
padding: 24px 32px 32px;
}
.modal-meta {
display: flex;
gap: 16px;
margin-bottom: 24px;
flex-wrap: wrap;
}
.modal-meta-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.9rem;
color: var(--text-secondary);
}
.modal-meta-badge {
background: var(--bg-card);
padding: 4px 10px;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 600;
}
.modal-actions {
display: flex;
gap: 12px;
margin-bottom: 32px;
}
/* Season selector for series */
.season-selector {
margin-bottom: 24px;
}
.season-selector label {
display: block;
font-size: 0.9rem;
color: var(--text-secondary);
margin-bottom: 8px;
}
.season-selector select {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 10px 16px;
color: var(--text-primary);
font-size: 1rem;
cursor: pointer;
min-width: 200px;
}
.season-selector select:focus {
outline: none;
border-color: var(--text-secondary);
}
/* Release list */
.release-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.release-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
background: var(--bg-card);
border-radius: 4px;
transition: background var(--transition-fast);
}
.release-item:hover {
background: var(--bg-card-hover);
}
.release-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.release-name {
font-weight: 500;
}
.release-meta {
display: flex;
gap: 12px;
font-size: 0.85rem;
color: var(--text-muted);
}
.release-actions {
display: flex;
gap: 8px;
}
.btn-small {
padding: 8px 16px;
font-size: 0.85rem;
}
/* Loading state */
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
gap: 16px;
}
.loading-spinner {
width: 48px;
height: 48px;
border: 3px solid var(--bg-card);
border-top-color: var(--accent-red);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading-text {
color: var(--text-secondary);
font-size: 1rem;
}
/* Error state */
.error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
gap: 16px;
padding: 40px;
text-align: center;
}
.error-icon {
font-size: 4rem;
color: var(--accent-red);
}
.error-title {
font-size: 1.5rem;
font-weight: 600;
}
.error-message {
color: var(--text-secondary);
max-width: 500px;
}
/* Empty state */
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-muted);
}
.empty-state-icon {
font-size: 4rem;
margin-bottom: 16px;
}
/* Responsive */
@media (max-width: 768px) {
:root {
--card-width: 120px;
--section-padding: 3%;
}
.hero {
height: 50vh;
min-height: 300px;
}
.hero-title {
font-size: 1.5rem;
}
.header-nav {
gap: 10px;
}
.search-input {
width: 140px;
}
.modal-title {
font-size: 1.5rem;
}
.section-title {
font-size: 1rem;
}
}
@media (max-width: 480px) {
:root {
--card-width: 100px;
--header-height: 48px;
}
.header-logo {
height: 32px;
width: 32px;
margin-right: 12px;
}
.media-card-info {
display: none;
}
}
+179
View File
@@ -0,0 +1,179 @@
// Type definitions for the media browser
export interface MovieVersion {
path: string;
playable_file: string | null;
resolution: string | null;
quality: string | null;
codec: string | null;
audio: string | null;
encoder: string | null;
size: number | null;
in_rtorrent: boolean | null;
torrent_path: string | null;
}
export interface TmdbPerson {
name: string;
character?: string | null;
profile_path: string | null;
}
export interface SimilarMedia {
id: number;
title: string;
poster_path: string | null;
}
export interface Movie {
id: string;
title: string;
original_title: string | null;
alternative_titles: string[] | null;
torrent_title: string | null;
year: number | null;
cover_path: string | null;
showreel_images: string[] | null;
versions: MovieVersion[];
tmdb_id: number | null;
tmdb_title: string | null;
rating: number | null;
vote_count: number | null;
overview: string | null;
genres: string[] | null;
release_date: string | null;
runtime: number | null;
status: string | null;
tagline: string | null;
poster_path: string | null;
backdrop_path: string | null;
similar: SimilarMedia[] | null;
keywords: string[] | null;
cast: TmdbPerson[] | null;
director: string | null;
newest: number | null;
}
export interface EpisodeRelease {
path: string;
playable_file: string | null;
resolution: string | null;
quality: string | null;
codec: string | null;
audio: string | null;
encoder: string | null;
size: number | null;
in_rtorrent: boolean | null;
}
export interface Episode {
episode_number: number;
name: string | null;
overview: string | null;
air_date: string | null;
runtime: number | null;
still_path: string | null;
rating: number | null;
director: string | null;
reel_image: string | null;
releases: EpisodeRelease[];
}
export interface Season {
season_number: number;
name: string | null;
overview: string | null;
air_date: string | null;
poster_path: string | null;
episode_count: number | null;
episodes: Episode[];
}
export interface Series {
id: string;
title: string;
original_title: string | null;
alternative_titles: string[] | null;
torrent_title: string | null;
cover_path: string | null;
seasons: Season[];
tmdb_id: number | null;
tmdb_title: string | null;
rating: number | null;
vote_count: number | null;
overview: string | null;
genres: string[] | null;
release_date: string | null;
status: string | null;
tagline: string | null;
poster_path: string | null;
backdrop_path: string | null;
similar: SimilarMedia[] | null;
keywords: string[] | null;
cast: TmdbPerson[] | null;
creators: string[] | null;
number_of_seasons: number | null;
number_of_episodes: number | null;
networks: string[] | null;
newest: number | null;
}
export interface MediaStats {
total_movies: number;
total_movie_versions?: number;
total_series: number;
total_series_episodes?: number;
}
export interface MediaIndex {
version: number;
generated_at: string;
stats: MediaStats;
movies: Movie[];
series: Series[];
}
export type MediaType = 'movies' | 'series' | 'episode';
// Matched person info for search results
export interface MatchedPerson {
name: string;
roles: string; // e.g., "Director", "Tony Stark", "Creator"
highlightRoles: boolean; // true if the roles/character matched (vs the name)
}
// Matched episode info for search results
export interface MatchedEpisode {
name: string; // Episode name (highlighted)
location: string; // "SN Episode M" (dimmed)
seasonNumber: number; // For navigation to episode
episodeNumber: number; // For navigation to episode
}
// Info about why a search matched this item
export interface SearchMatchInfo {
// Matched people with their roles/characters
matchedPeople?: MatchedPerson[];
// Matched episodes for series
matchedEpisodes?: MatchedEpisode[];
}
export interface MediaItem {
id: string;
title: string;
year?: number | null;
cover_path: string | null;
showreel_images?: string[] | null;
type: MediaType;
resolution?: string | null;
data: Movie | Series | EpisodeWithSeries;
// Optional search match info - only present in search results
searchMatchInfo?: SearchMatchInfo;
}
// Episode with parent series info for standalone display
export interface EpisodeWithSeries {
episode: Episode;
series: Series;
seasonNumber: number;
}
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+36
View File
@@ -0,0 +1,36 @@
/**
* FastAPI-Vue Vite Plugin
* auto-upgrade@fastapi-vue-setup -- remove this if you edit the plugin
*
* Configures Vite for FastAPI backend integration:
* - Proxies /api/* requests to the FastAPI backend
* - Builds to the Python module's frontend-build directory
*
* Options:
* paths - Array of paths to proxy (default: ["/api"])
*/
export default function fastapiVue({ paths = ["/api"] } = {}) {
const backendUrl = process.env.MEDIAHIVE_BACKEND_URL || "http://localhost:8420"
// Build proxy configuration for each path
const proxy = {}
for (const path of paths) {
proxy[path] = {
target: backendUrl,
changeOrigin: false,
ws: true,
}
}
return {
name: "vite-plugin-fastapi-mediahive",
config: () => ({
server: { proxy },
build: {
outDir: "../mediahive/frontend-build",
emptyOutDir: true,
},
}),
}
}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import fastapiVue from './vite-plugin-fastapi.js'
// https://vitejs.dev/config/
export default defineConfig(async () => ({
plugins: [fastapiVue(), vue()],
// Vite dev server options
clearScreen: false,
server: {
port: 8420,
strictPort: true,
},
}));