Merge mediahive.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user