Scope reel poster fallback to browser cards
This commit is contained in:
+13
-10
@@ -184,7 +184,7 @@
|
|||||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||||
import { useRouter, useRoute } from 'vue-router';
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
import type { Movie, Series, MediaItem, EpisodeWithSeries, MatchedPerson, MatchedEpisode, TaskInfo } from './types';
|
import type { Movie, Series, MediaItem, EpisodeWithSeries, MatchedPerson, MatchedEpisode, TaskInfo } from './types';
|
||||||
import { playMedia, openFolder, isMpcBeReachable, fetchResumePositions, normalizeMediaPath } from './api';
|
import { playMedia, openFolder, isMpcBeReachable, fetchResumePositions, normalizeMediaPath, getPlayerStatus } from './api';
|
||||||
import { useKeyboardNavigation } from './composables/useKeyboardNavigation';
|
import { useKeyboardNavigation } from './composables/useKeyboardNavigation';
|
||||||
import { useMediaWebSocket } from './composables/useMediaWebSocket';
|
import { useMediaWebSocket } from './composables/useMediaWebSocket';
|
||||||
import Header from './components/Header.vue';
|
import Header from './components/Header.vue';
|
||||||
@@ -227,6 +227,15 @@ async function refreshResumePositions() {
|
|||||||
resumePositions.value = await fetchResumePositions();
|
resumePositions.value = await fetchResumePositions();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshPlayerStatus() {
|
||||||
|
try {
|
||||||
|
const status = await getPlayerStatus();
|
||||||
|
mpcBeConnected.value = status.remote;
|
||||||
|
} catch {
|
||||||
|
mpcBeConnected.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function hasResumePosition(filePath: string | null) {
|
function hasResumePosition(filePath: string | null) {
|
||||||
if (!filePath) return false;
|
if (!filePath) return false;
|
||||||
const normalizedPath = normalizeMediaPath(filePath);
|
const normalizedPath = normalizeMediaPath(filePath);
|
||||||
@@ -379,6 +388,7 @@ function handleEscapeKey(event: KeyboardEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
void refreshPlayerStatus();
|
||||||
void refreshResumePositions();
|
void refreshResumePositions();
|
||||||
document.addEventListener('keydown', handleEscapeKey);
|
document.addEventListener('keydown', handleEscapeKey);
|
||||||
window.addEventListener('mediahive:gamepad-action', onGamepadAction as EventListener);
|
window.addEventListener('mediahive:gamepad-action', onGamepadAction as EventListener);
|
||||||
@@ -501,15 +511,11 @@ function movieToMediaItem(movie: Movie): MediaItem {
|
|||||||
const torrents = Object.values(movie.torrents || {});
|
const torrents = Object.values(movie.torrents || {});
|
||||||
const resolution = torrents.length > 0 ? torrents[0].resolution : null;
|
const resolution = torrents.length > 0 ? torrents[0].resolution : null;
|
||||||
|
|
||||||
// Use first showreel image as fallback if no cover
|
|
||||||
const coverPath = movie.cover_path ||
|
|
||||||
(movie.showreel_images && movie.showreel_images.length > 0 ? movie.showreel_images[0] : null);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: movie.id,
|
id: movie.id,
|
||||||
title: movie.title || 'Unknown',
|
title: movie.title || 'Unknown',
|
||||||
year: movie.year,
|
year: movie.year,
|
||||||
cover_path: coverPath,
|
cover_path: movie.cover_path,
|
||||||
showreel_images: movie.showreel_images,
|
showreel_images: movie.showreel_images,
|
||||||
type: 'movies',
|
type: 'movies',
|
||||||
resolution: resolution,
|
resolution: resolution,
|
||||||
@@ -528,14 +534,11 @@ function seriesToMediaItem(series: Series): MediaItem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use first reel image as fallback if no cover
|
|
||||||
const coverPath = series.cover_path || (reelImages.length > 0 ? reelImages[0] : null);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: series.id,
|
id: series.id,
|
||||||
title: series.title || 'Unknown',
|
title: series.title || 'Unknown',
|
||||||
year: null,
|
year: null,
|
||||||
cover_path: coverPath,
|
cover_path: series.cover_path,
|
||||||
showreel_images: reelImages.length > 0 ? reelImages : null,
|
showreel_images: reelImages.length > 0 ? reelImages : null,
|
||||||
type: 'series',
|
type: 'series',
|
||||||
data: series,
|
data: series,
|
||||||
|
|||||||
@@ -7,17 +7,24 @@
|
|||||||
@keydown.enter.prevent="$emit('click')"
|
@keydown.enter.prevent="$emit('click')"
|
||||||
>
|
>
|
||||||
<div class="media-card-poster">
|
<div class="media-card-poster">
|
||||||
<!-- SVG focus outline -->
|
|
||||||
<svg class="card-focus-outline" viewBox="0 0 100 150" preserveAspectRatio="none">
|
<svg class="card-focus-outline" viewBox="0 0 100 150" preserveAspectRatio="none">
|
||||||
<rect x="0" y="0" width="100" height="150" />
|
<rect x="0" y="0" width="100" height="150" />
|
||||||
</svg>
|
</svg>
|
||||||
<img
|
<img
|
||||||
v-if="coverUrl && !imageError"
|
v-if="posterImageUrl && !imageError"
|
||||||
:src="coverUrl"
|
:src="posterImageUrl"
|
||||||
:alt="item.title || 'Unknown'"
|
:alt="item.title || 'Unknown'"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
@error="imageError = true"
|
@error="imageError = true"
|
||||||
/>
|
/>
|
||||||
|
<video
|
||||||
|
v-else-if="posterVideoUrl"
|
||||||
|
:src="posterVideoUrl"
|
||||||
|
:aria-label="item.title || 'Unknown'"
|
||||||
|
loop
|
||||||
|
muted
|
||||||
|
playsinline
|
||||||
|
></video>
|
||||||
<div v-else class="media-card-placeholder">
|
<div v-else class="media-card-placeholder">
|
||||||
{{ item.type === 'movies' ? '🎬' : item.type === 'episode' ? '📺' : '📺' }}
|
{{ item.type === 'movies' ? '🎬' : item.type === 'episode' ? '📺' : '📺' }}
|
||||||
</div>
|
</div>
|
||||||
@@ -30,7 +37,6 @@
|
|||||||
<span class="media-card-title">{{ displayTitle }}</span>
|
<span class="media-card-title">{{ displayTitle }}</span>
|
||||||
<span v-if="item.year" class="media-card-year">{{ item.year }}</span>
|
<span v-if="item.year" class="media-card-year">{{ item.year }}</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- Search match info (when searching) -->
|
|
||||||
<template v-if="item.searchMatchInfo">
|
<template v-if="item.searchMatchInfo">
|
||||||
<div v-if="matchedPeople && matchedPeople.length > 0" class="media-card-detail match-reason">
|
<div v-if="matchedPeople && matchedPeople.length > 0" class="media-card-detail match-reason">
|
||||||
<template v-for="(person, idx) in matchedPeople" :key="person.name">
|
<template v-for="(person, idx) in matchedPeople" :key="person.name">
|
||||||
@@ -46,7 +52,6 @@
|
|||||||
<div v-if="item.searchMatchInfo.matchedEpisodes.length > 3" class="matched-episode-more">+{{ item.searchMatchInfo.matchedEpisodes.length - 3 }} more</div>
|
<div v-if="item.searchMatchInfo.matchedEpisodes.length > 3" class="matched-episode-more">+{{ item.searchMatchInfo.matchedEpisodes.length - 3 }} more</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<!-- Default display (browsing) -->
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div v-if="subtitle" class="media-card-detail">{{ subtitle }}</div>
|
<div v-if="subtitle" class="media-card-detail">{{ subtitle }}</div>
|
||||||
<div v-if="directorAndCast" class="media-card-detail">
|
<div v-if="directorAndCast" class="media-card-detail">
|
||||||
@@ -60,7 +65,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import type { MediaItem, Movie, Series, EpisodeWithSeries } from '../types';
|
import type { MediaItem, Movie, Series, EpisodeWithSeries } from '../types';
|
||||||
import { getCoverUrl } from '../api';
|
import { getCoverUrl, isVideoPath } from '../api';
|
||||||
import { navAttrs } from '../composables/useKeyboardNavigation';
|
import { navAttrs } from '../composables/useKeyboardNavigation';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -73,7 +78,6 @@ defineEmits<{
|
|||||||
click: [];
|
click: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
// Navigation attributes for keyboard navigation
|
|
||||||
const navAttributes = computed(() => {
|
const navAttributes = computed(() => {
|
||||||
if (props.navRow !== undefined && props.navCol !== undefined) {
|
if (props.navRow !== undefined && props.navCol !== undefined) {
|
||||||
return navAttrs(props.navRow, props.navCol);
|
return navAttrs(props.navRow, props.navCol);
|
||||||
@@ -83,11 +87,23 @@ const navAttributes = computed(() => {
|
|||||||
|
|
||||||
const imageError = ref(false);
|
const imageError = ref(false);
|
||||||
|
|
||||||
const coverUrl = computed(() => {
|
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 getCoverUrl(props.item.cover_path);
|
return getCoverUrl(props.item.cover_path);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const posterVideoUrl = computed(() => {
|
||||||
|
if (props.item.cover_path && isVideoPath(props.item.cover_path)) {
|
||||||
|
return getCoverUrl(props.item.cover_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackVideo = props.item.showreel_images?.find(path => isVideoPath(path));
|
||||||
|
return fallbackVideo ? getCoverUrl(fallbackVideo) : null;
|
||||||
|
});
|
||||||
|
|
||||||
const rating = computed(() => {
|
const rating = computed(() => {
|
||||||
if (props.item.type === 'movies') {
|
if (props.item.type === 'movies') {
|
||||||
return (props.item.data as Movie).info?.rating;
|
return (props.item.data as Movie).info?.rating;
|
||||||
@@ -119,7 +135,6 @@ const subtitle = computed(() => {
|
|||||||
const epData = props.item.data as EpisodeWithSeries;
|
const epData = props.item.data as EpisodeWithSeries;
|
||||||
return `${epData.series.title} S${epData.seasonNumber}E${epData.episode.episode_number}`;
|
return `${epData.series.title} S${epData.seasonNumber}E${epData.episode.episode_number}`;
|
||||||
}
|
}
|
||||||
// For series, show creators
|
|
||||||
if (props.item.type === 'series') {
|
if (props.item.type === 'series') {
|
||||||
const creators = (props.item.data as Series).info?.creators;
|
const creators = (props.item.data as Series).info?.creators;
|
||||||
return creators && creators.length > 0 ? creators.join(', ') : null;
|
return creators && creators.length > 0 ? creators.join(', ') : null;
|
||||||
@@ -127,19 +142,16 @@ const subtitle = computed(() => {
|
|||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Director for movies
|
|
||||||
const director = computed(() => {
|
const director = computed(() => {
|
||||||
if (props.item.type !== 'movies') return null;
|
if (props.item.type !== 'movies') return null;
|
||||||
return (props.item.data as Movie).info?.director;
|
return (props.item.data as Movie).info?.director;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check if we have director and/or cast to display
|
|
||||||
const directorAndCast = computed(() => {
|
const directorAndCast = computed(() => {
|
||||||
if (props.item.type !== 'movies') return false;
|
if (props.item.type !== 'movies') return false;
|
||||||
return director.value || filteredCastNames.value;
|
return director.value || filteredCastNames.value;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cast names, excluding director if they appear in cast
|
|
||||||
const filteredCastNames = computed(() => {
|
const filteredCastNames = computed(() => {
|
||||||
if (props.item.type !== 'movies') return null;
|
if (props.item.type !== 'movies') return null;
|
||||||
const cast = (props.item.data as Movie).info?.cast;
|
const cast = (props.item.data as Movie).info?.cast;
|
||||||
@@ -152,12 +164,10 @@ const filteredCastNames = computed(() => {
|
|||||||
|
|
||||||
if (filteredCast.length === 0) return null;
|
if (filteredCast.length === 0) return null;
|
||||||
|
|
||||||
// Show first 3 cast members
|
|
||||||
const names = filteredCast.slice(0, 3).map(c => c.name);
|
const names = filteredCast.slice(0, 3).map(c => c.name);
|
||||||
return names.join(', ');
|
return names.join(', ');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Matched people from search (from searchMatchInfo)
|
|
||||||
const matchedPeople = computed(() => {
|
const matchedPeople = computed(() => {
|
||||||
const info = props.item.searchMatchInfo;
|
const info = props.item.searchMatchInfo;
|
||||||
if (!info || !info.matchedPeople) return null;
|
if (!info || !info.matchedPeople) return null;
|
||||||
@@ -166,7 +176,6 @@ const matchedPeople = computed(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* Blinking animation for focus outline */
|
|
||||||
@keyframes card-outline-blink {
|
@keyframes card-outline-blink {
|
||||||
0%, 100% {
|
0%, 100% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
@@ -176,7 +185,6 @@ const matchedPeople = computed(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* SVG focus outline styles */
|
|
||||||
.card-focus-outline {
|
.card-focus-outline {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -195,14 +203,20 @@ const matchedPeople = computed(() => {
|
|||||||
vector-effect: non-scaling-stroke;
|
vector-effect: non-scaling-stroke;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Show outline on hover and focus */
|
.media-card-poster img,
|
||||||
|
.media-card-poster video {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
.media-card:hover .card-focus-outline,
|
.media-card:hover .card-focus-outline,
|
||||||
.media-card.nav-focused .card-focus-outline {
|
.media-card.nav-focused .card-focus-outline {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
animation: card-outline-blink 1s ease-in-out infinite;
|
animation: card-outline-blink 1s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Brighter outline for keyboard focus */
|
|
||||||
.media-card.nav-focused .card-focus-outline rect {
|
.media-card.nav-focused .card-focus-outline rect {
|
||||||
stroke: #ffffff;
|
stroke: #ffffff;
|
||||||
stroke-width: 5;
|
stroke-width: 5;
|
||||||
@@ -236,7 +250,6 @@ const matchedPeople = computed(() => {
|
|||||||
font-size: 0.65rem;
|
font-size: 0.65rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin-top: 1px;
|
margin-top: 1px;
|
||||||
/* Allow up to 2 lines with ellipsis */
|
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
@@ -249,7 +262,6 @@ const matchedPeople = computed(() => {
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Search match styles */
|
|
||||||
.match-reason {
|
.match-reason {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
@@ -264,7 +276,6 @@ const matchedPeople = computed(() => {
|
|||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* When character name matched - highlight the role, dim the name */
|
|
||||||
.match-dim {
|
.match-dim {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
|||||||
Reference in New Issue
Block a user