Files
mediahive/frontend/src/App.vue
T
LeoVasanko 9ec4f877eb Resume tracking: per-episode positions, player-agnostic fallback, series continue point
- Fix merged playback-state dropping season/episode, which broke series
  resume restore entirely.
- Track watch progress per episode (episodes map keyed S..E.., with done
  markers on completion) while keeping one continue point per series as
  the last watched episode, used for spoiler protection and season
  selection. MPC-BE tracker resumes from each episode's own position.
- Assumed-playback fallback for any player and server-only mode: a launch
  starts a session and the guessed position (base + elapsed, capped at
  runtime) is written when frontend activity resumes; the real MPC-BE
  tracker overrides guesses via timestamps. Frontend reports input
  activity (throttled) via POST /api/activity.
- Ignore watches under 5 minutes in both trackers.
- Episode tiles show a small quadrant-circle watch indicator; near end
  counts as fully watched, no data shows nothing.
- Fix episode audio ownership: single audioOwnerKey shared by mouse hover
  and keyboard/gamepad focus, no longer clobbered by playback sync;
  idle fade can be re-armed by continued activity.
2026-09-10 20:56:03 +00:00

1904 lines
58 KiB
Vue

<template>
<div class="app">
<!-- Hidden router-view to make vue-router work -->
<router-view v-slot="{ Component }">
<component :is="Component" v-show="false" />
</router-view>
<div v-if="showProgressPanel" class="activity-overlay">
<div class="activity-card">
<div class="activity-header">
<span>Library activity</span>
<span v-if="!wsConnected" class="activity-connection">Reconnecting...</span>
</div>
<div v-for="root in progressRoots" :key="root.rootId" class="activity-root" :class="root.toneClass">
<div class="activity-root-title">{{ root.rootLabel }}</div>
<div v-if="root.scanTarget" class="activity-root-target">{{ root.scanTarget }}</div>
<div class="activity-phase-row">
<span class="activity-phase">{{ root.phaseLabel }}</span>
<span v-if="root.progressLabel" class="activity-progress-label">{{ root.progressLabel }}</span>
</div>
<div class="activity-bar" :class="{ 'activity-bar-indeterminate': !root.isDeterminate }">
<div
class="activity-bar-fill"
:style="root.isDeterminate ? { width: `${root.progressPercent}%` } : undefined"
></div>
</div>
<div v-if="root.phaseDetail" class="activity-detail">{{ root.phaseDetail }}</div>
</div>
</div>
</div>
<div v-if="libraryUpdateMessage" class="library-update-toast">
{{ libraryUpdateMessage }}
</div>
<!-- Persistent Header overlay - single instance -->
<Header
:current-view="headerCurrentView"
:search-query="searchQuery"
:roots="headerRoots"
:mpc-be-connected="mpcBeConnected"
:nav-row="1"
:position="headerPosition"
:style="headerStyle"
@search="updateSearchQuery"
@go-back="goBack"
/>
<!-- Loading state -->
<main v-if="loading" class="main-content main-content-no-hero">
<div class="header-spacer"></div>
<div class="loading">
<div class="loading-spinner"></div>
<p class="loading-text">Loading your media collection...</p>
</div>
</main>
<!-- Error state -->
<main v-else-if="error" class="main-content main-content-no-hero">
<div class="header-spacer"></div>
<div class="error">
<div class="error-icon">⚠️</div>
<h2 class="error-title">Failed to load media index</h2>
<p class="error-message">{{ error }}</p>
<button class="btn btn-primary" @click="reloadPage">Try Again</button>
</div>
</main>
<div v-else class="page-slider">
<div class="page-slider-track" :class="{ 'is-detail-open': isDetailOpen }">
<!-- Browse/Search page (left panel) -->
<main
ref="browsePanelRef"
class="main-content page-slider-panel scrollbar-hidden"
data-nav-scope="browse"
@scroll.passive="handlePanelScroll('browse')"
>
<!-- Movies and Series views - both always rendered for smooth transitions -->
<div v-if="!searchQuery" class="view-container">
<Transition name="view-zoom" mode="out-in">
<div v-if="currentView === 'movies'" key="movies" class="view-content">
<!-- Hero for movies -->
<CollageHero
v-if="movieCollageItems.length > 0"
:key="`movie-hero-${movieCollageItems.length}-${movieFeaturedItem?.id || 'none'}`"
:items="movieCollageItems"
:featured-item="movieFeaturedItem"
@select="showDetail"
/>
<!-- Spacer for header overlay -->
<div class="header-spacer"></div>
<!-- Movie categories -->
<template v-for="(category, categoryIndex) in moviesByGenre" :key="category.name">
<section class="media-section" v-if="category.items.length > 0">
<h2 class="section-title">{{ category.name }} ({{ category.items.length }})</h2>
<MediaRow
:items="category.items"
:row-index="categoryIndex + 2"
@select="showDetail"
/>
</section>
</template>
</div>
<div v-else-if="currentView === 'series'" key="series" class="view-content">
<!-- Hero for series -->
<CollageHero
v-if="seriesCollageItems.length > 0"
:key="`series-hero-${seriesCollageItems.length}-${seriesFeaturedItem?.id || 'none'}`"
:items="seriesCollageItems"
:featured-item="seriesFeaturedItem"
@select="showDetail"
/>
<!-- Spacer for header overlay -->
<div class="header-spacer"></div>
<!-- Series categories -->
<template v-for="(category, categoryIndex) in seriesByGenre" :key="category.name">
<section class="media-section" v-if="category.items.length > 0">
<h2 class="section-title">{{ category.name }} ({{ category.items.length }})</h2>
<MediaRow
:items="category.items"
:row-index="categoryIndex + 2"
@select="showDetail"
/>
</section>
</template>
</div>
</Transition>
</div>
<!-- Search results -->
<template v-if="searchQuery">
<template v-if="searchResults.length > 0">
<!-- Hero with all results ranked by relevance -->
<CollageHero
:key="`search-hero-${searchCollageItems.length}-${searchFeaturedItem?.id || 'none'}`"
:items="searchCollageItems"
:featured-item="searchFeaturedItem"
@select="showDetail"
/>
<!-- Spacer for header overlay -->
<div class="header-spacer"></div>
<!-- Category sections -->
<template v-for="(category, categoryIndex) in searchCategories" :key="category.name">
<section class="media-section">
<h2 class="section-title">{{ category.name }} ({{ category.items.length }})</h2>
<MediaRow
:items="category.items"
:row-index="categoryIndex + 2"
@select="showDetail"
/>
</section>
</template>
</template>
<template v-else-if="!isSearching">
<!-- Empty hero area to maintain layout -->
<div class="empty-hero"></div>
<!-- Spacer for header overlay -->
<div class="header-spacer"></div>
<div class="no-results">
<p>No results found for "{{ searchQuery }}"</p>
</div>
</template>
</template>
</main>
<!-- Detail page (right panel) -->
<main
ref="detailPanelRef"
class="main-content page-slider-panel page-slider-detail-panel scrollbar-hidden"
data-nav-scope="detail"
@scroll.passive="handlePanelScroll('detail')"
>
<MediaDetail
v-if="detailItemForRender"
v-show="isDetailOpen"
:item="detailItemForRender"
:all-movies="mediaIndex?.movies ?? []"
:focus-episode="focusEpisode"
:has-resume-position="hasResumePosition"
:get-resume-point="getResumePoint"
:get-resume-episodes="getResumeEpisodes"
:get-root-name="getRootName"
@close="closeDetail"
@play="handlePlay"
@open-folder="handleOpenFolder"
@search-actor="handleActorSearch"
@select-movie="handleSelectMovieFromDetail"
/>
</main>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from "vue"
import { useRouter, useRoute } from "vue-router"
import type {
Movie,
MovieUi,
Series,
SeriesUi,
MediaItem,
EpisodeWithSeries,
TaskInfo,
SeriesResumePoint,
} from "./types"
import {
playMedia,
openFolder,
isMpcBeReachable,
fetchResumePositions,
getPlayerStatus,
type ResumePositionEntry,
type EpisodeWatchEntry,
} from "./api"
import { useSettings } from "./composables/useSettings"
import { useKeyboardNavigation, setActiveNavigationScope } from "./composables/useKeyboardNavigation"
import type { SyncedRowScrollSnapshot } from "./composables/useKeyboardNavigation"
import { useMediaWebSocket } from "./composables/useMediaWebSocket"
import Header from "./components/Header.vue"
import CollageHero from "./components/CollageHero.vue"
import MediaRow from "./components/MediaRow.vue"
import MediaDetail from "./components/MediaDetail.vue"
import type { SearchResultItem, SearchResponseMessage } from "./search-worker"
// Initialize keyboard navigation
const {
getFocusState,
restoreFocusState,
focusElement,
snapshotSyncedRowScroll,
restoreSyncedRowScroll,
} = useKeyboardNavigation()
const router = useRouter()
const route = useRoute()
function normalizeSearchQuery(value: unknown): string {
if (Array.isArray(value)) {
return normalizeSearchQuery(value[0])
}
return typeof value === "string" ? value.trim() : ""
}
function getRouteSearchQuery() {
if (route.name !== "search") {
return ""
}
return normalizeSearchQuery(route.params.term)
}
function getSearchPath(term: string) {
return router.resolve({ name: "search", params: { term } }).path
}
function getBrowsePath() {
if (route.meta.view === "series") return "/series"
if (route.meta.view === "movies") return "/movies"
return searchReturnPath.value ?? "/movies"
}
function getBrowseViewFromPath(path: string): "movies" | "series" {
return path === "/series" ? "series" : "movies"
}
function normalizeHistoryPath(value: string): string {
const hashIndex = value.indexOf("#")
const fromHash = hashIndex >= 0 ? value.slice(hashIndex + 1) : value
const pathWithQuery = fromHash.startsWith("/") ? fromHash : `/${fromHash}`
const queryIndex = pathWithQuery.indexOf("?")
return queryIndex >= 0 ? pathWithQuery.slice(0, queryIndex) : pathWithQuery
}
// WebSocket-driven media index
const {
mediaIndex,
loading,
error,
connected: wsConnected,
tasks,
roots: rootStatuses,
} = useMediaWebSocket()
type RootTaskInfo = TaskInfo & { root_id: string }
interface ProgressRootState {
rootId: string
rootLabel: string
scanTarget: string | null
phaseLabel: string
phaseDetail: string | null
progressPercent: number
progressLabel: string | null
isDeterminate: boolean
toneClass: string
}
const activeTasks = computed<RootTaskInfo[]>(() => Array.from(tasks.value.values()))
function getRootName(rootId: string | null | undefined): string | null {
if (!rootId) return null
return rootStatuses.value.get(rootId)?.root_id || null
}
function normalizePosixPath(value: string): string {
return value.replace(/\\/g, "/")
}
function extractScanPath(detail: string): string | null {
if (!detail.startsWith("Scanning:")) return null
let value = detail.replace(/^Scanning:\s*/i, "").trim()
value = value.replace(/\s*\(\d+\s+found\)\s*$/i, "").trim()
return value || null
}
function buildScanTarget(rootId: string, rootPath: string | null, detail: string): string | null {
const rawPath = extractScanPath(detail)
if (!rawPath) return null
const posixRaw = normalizePosixPath(rawPath)
const posixRoot = rootPath ? normalizePosixPath(rootPath) : null
let relative = posixRaw
if (posixRoot) {
const lowRaw = posixRaw.toLowerCase()
const lowRoot = posixRoot.toLowerCase()
if (lowRaw === lowRoot) {
relative = ""
} else if (lowRaw.startsWith(`${lowRoot}/`)) {
relative = posixRaw.slice(posixRoot.length).replace(/^\/+/, "")
}
}
if (!relative) return rootId
if (relative.toLowerCase().startsWith(`${rootId.toLowerCase()}/`)) return relative
return `${rootId}/${relative}`
}
function describeRootProgress(
rootId: string,
rootPath: string | null,
tasksForRoot: RootTaskInfo[],
isInitialScanMode: boolean,
): ProgressRootState | null {
const running = tasksForRoot.filter((task) => task.status === "running")
const latestError = [...tasksForRoot].reverse().find((task) => task.status === "error") || null
if (running.length === 0 && !latestError) return null
const scanTask = running.find((task) => task.id.startsWith("scan-")) || null
const showreelCount = running.filter((task) => task.id.startsWith("showreel-")).length
const otherRunningCount = running.length - (scanTask ? 1 : 0) - showreelCount
let phaseLabel = "Processing media"
let phaseDetail: string | null = null
let scanTarget: string | null = null
let isDeterminate = false
let progressPercent = 0
let progressLabel: string | null = null
let toneClass = ""
if (scanTask) {
const detail = (scanTask.detail || "").trim()
scanTarget = buildScanTarget(rootId, rootPath, detail)
if (detail.startsWith("Scanning:")) {
phaseLabel = isInitialScanMode ? "Scanning folders" : "Checking for updates"
phaseDetail = isInitialScanMode ? "Looking for new files" : "Running background scan"
} else if (/^Processing\s+\d+\s+(items|movies|series)/i.test(detail)) {
phaseLabel = "Preparing titles"
phaseDetail = "Matching files and grouping releases"
} else {
phaseLabel = "Fetching metadata"
phaseDetail = detail ? `Current title: ${detail}` : "Updating titles and artwork"
}
if (scanTask.progress > 0 && scanTask.progress <= 1) {
isDeterminate = true
progressPercent = Math.max(1, Math.round(scanTask.progress * 100))
progressLabel = `${progressPercent}%`
}
} else if (showreelCount > 0) {
phaseLabel = "Generating previews"
phaseDetail = showreelCount === 1 ? "Building 1 preview reel" : `Building ${showreelCount} preview reels`
} else if (otherRunningCount > 0) {
phaseLabel = "Finalizing updates"
phaseDetail = "Applying library changes"
} else if (latestError) {
phaseLabel = "Needs attention"
phaseDetail = latestError.detail || "A background task failed"
toneClass = "activity-root-error"
}
if (showreelCount > 0 && scanTask) {
phaseDetail = phaseDetail
? `${phaseDetail}. Preview generation is running in parallel.`
: "Preview generation is running in parallel"
}
return {
rootId,
rootLabel: rootId,
scanTarget,
phaseLabel,
phaseDetail,
progressPercent,
progressLabel,
isDeterminate,
toneClass,
}
}
const progressRoots = computed<ProgressRootState[]>(() => {
const byRoot = new Map<string, RootTaskInfo[]>()
for (const task of activeTasks.value) {
const list = byRoot.get(task.root_id) || []
list.push(task)
byRoot.set(task.root_id, list)
}
const rows: ProgressRootState[] = []
const initial = isInitialScanMode.value
for (const [rootId, rootTasks] of byRoot) {
const rootPath = rootStatuses.value.get(rootId)?.path || null
const row = describeRootProgress(rootId, rootPath, rootTasks, initial)
if (row) rows.push(row)
}
return rows.sort((a, b) => a.rootLabel.localeCompare(b.rootLabel))
})
const isSettingsView = computed(() => route.path === "/settings")
const hasLibraryItems = computed(() => {
if (!mediaIndex.value) return false
return mediaIndex.value.movies.length > 0 || mediaIndex.value.series.length > 0
})
const hasAnySnapshotLoaded = computed(() =>
Array.from(rootStatuses.value.values()).some((root) => root.snapshot_loaded),
)
const isInitialScanMode = computed(() => !hasLibraryItems.value && !hasAnySnapshotLoaded.value)
const headerRoots = computed(() =>
Array.from(rootStatuses.value.values()).sort((a, b) => a.root_id.localeCompare(b.root_id)),
)
const showProgressPanel = computed(() => {
if (isInitialScanMode.value) {
return !wsConnected.value || progressRoots.value.length > 0
}
if (!isSettingsView.value) return false
return !wsConnected.value || progressRoots.value.length > 0
})
const libraryUpdateMessage = ref<string | null>(null)
let libraryUpdateToastTimer: number | null = null
let seenTaskStates = new Map<string, string>()
function showLibraryUpdateToast(message: string) {
libraryUpdateMessage.value = message
if (libraryUpdateToastTimer !== null) {
window.clearTimeout(libraryUpdateToastTimer)
}
libraryUpdateToastTimer = window.setTimeout(() => {
libraryUpdateMessage.value = null
libraryUpdateToastTimer = null
}, 5500)
}
watch(
activeTasks,
(tasksNow) => {
const nextSeen = new Map<string, string>()
for (const task of tasksNow) {
const key = `${task.root_id}:${task.id}`
nextSeen.set(key, task.status)
const previousStatus = seenTaskStates.get(key)
if (task.id.startsWith("scan-") && task.status === "completed" && previousStatus !== "completed") {
const detail = (task.detail || "").trim()
const doneMatch = detail.match(/^Done\s+[\u2014-]\s+(\d+)\s+movies,\s+(\d+)\s+series$/i)
if (doneMatch) {
const movies = Number(doneMatch[1] || "0")
const series = Number(doneMatch[2] || "0")
if (movies > 0 || series > 0) {
const rootName = getRootName(task.root_id) || task.root_id
showLibraryUpdateToast(`Library updated in ${rootName}: ${movies} movies, ${series} series`)
}
}
}
}
seenTaskStates = nextSeen
},
{ deep: false },
)
onUnmounted(() => {
if (libraryUpdateToastTimer !== null) {
window.clearTimeout(libraryUpdateToastTimer)
libraryUpdateToastTimer = null
}
})
const settings = useSettings()
const searchResults = ref<MediaItem[]>([])
const isSearching = ref(false)
const mpcBeConnected = ref(false)
const resumePositions = ref<Record<string, ResumePositionEntry>>({})
const searchQuery = ref(getRouteSearchQuery())
const searchReturnPath = ref<string | null>(null)
const browsePanelRef = ref<HTMLElement | null>(null)
const detailPanelRef = ref<HTMLElement | null>(null)
const browsePanelScrollTop = ref(0)
const detailPanelScrollTop = ref(0)
const MPC_BE_OPENING_GRACE_MS = 4000
const mpcBeOpeningUntil = ref(0)
let mpcBePollTimer: number | null = null
function handlePanelScroll(scope: "browse" | "detail") {
if (scope === "browse") {
browsePanelScrollTop.value = browsePanelRef.value?.scrollTop || 0
return
}
detailPanelScrollTop.value = detailPanelRef.value?.scrollTop || 0
}
function isMpcFamilySelected(): boolean {
// Empty port means Web UI is disabled
if (settings.playerMpcPort === null) return false
// If default is selected, we opportunistically try MPC-BE (backward compat)
// If a specific player is selected, only poll for MPC family
if (!settings.playerId || settings.playerId === "default") return true
return settings.playerId === "mpc-be" || settings.playerId === "mpc-hc"
}
function isMpcBeGamepadCaptured() {
return mpcBeConnected.value || Date.now() < mpcBeOpeningUntil.value
}
function stopMpcBePolling() {
if (mpcBePollTimer !== null) {
window.clearInterval(mpcBePollTimer)
mpcBePollTimer = null
}
}
async function refreshResumePositions() {
resumePositions.value = await fetchResumePositions()
}
function refreshResumePositionsAsEvent() {
void refreshResumePositions()
}
async function refreshPlayerStatus() {
if (!isMpcFamilySelected()) {
mpcBeConnected.value = false
return
}
try {
const status = await getPlayerStatus(settings.playerMpcPort)
mpcBeConnected.value = status.remote
} catch {
mpcBeConnected.value = false
}
}
function hasResumePosition(mediaId: string | null) {
if (!mediaId) return false
return (resumePositions.value[mediaId]?.pos || 0) > 0
}
function getResumePoint(mediaId: string | null): SeriesResumePoint | null {
if (!mediaId) return null
const entry = resumePositions.value[mediaId]
if (!entry || entry.season === null || entry.episode === null) return null
return {
seasonNumber: entry.season,
episodeNumber: entry.episode,
positionSeconds: entry.pos,
}
}
function getResumeEpisodes(
mediaId: string | null,
): Record<string, EpisodeWatchEntry> | null {
if (!mediaId) return null
return resumePositions.value[mediaId]?.episodes ?? null
}
function startMpcBePolling() {
if (mpcBePollTimer !== null) return
mpcBePollTimer = window.setInterval(async () => {
const reachable = await isMpcBeReachable(settings.playerMpcPort)
const wasConnected = mpcBeConnected.value
mpcBeConnected.value = reachable
if (!reachable) {
stopMpcBePolling()
if (wasConnected) {
void refreshResumePositions()
}
}
}, 3000)
}
async function tryConnectMpcBe(attempts = 8, delayMs = 400): Promise<boolean> {
for (let i = 0; i < attempts; i++) {
const reachable = await isMpcBeReachable(settings.playerMpcPort)
if (reachable) return true
if (i < attempts - 1) {
await new Promise((resolve) => window.setTimeout(resolve, delayMs))
}
}
return false
}
type GamepadAction = "up" | "down" | "left" | "right" | "select" | "back"
function onGamepadAction(event: Event) {
const customEvent = event as CustomEvent<{ action?: GamepadAction }>
const action = customEvent.detail?.action
if (!action) return
if (isMpcBeGamepadCaptured()) {
event.preventDefault()
}
}
function getSearchQueryFromPath(path: string | null): string {
if (!path || !path.startsWith("/search/")) return ""
const encoded = path.slice("/search/".length)
if (!encoded) return ""
try {
return normalizeSearchQuery(decodeURIComponent(encoded))
} catch {
return normalizeSearchQuery(encoded)
}
}
const searchResultsCache = new Map<string, MediaItem[]>()
function getSearchResultsForTerm(term: string): MediaItem[] {
const key = normalizeSearchQuery(term).toLowerCase()
if (!key) return []
const liveQuery = normalizeSearchQuery(searchQuery.value).toLowerCase()
if (liveQuery === key && searchResults.value.length > 0) {
return searchResults.value
}
return searchResultsCache.get(key) || []
}
function getBrowseSequenceForItem(item: MediaItem): MediaItem[] {
if (item.type === "movies") {
return moviesByGenre.value.flatMap((category) => category.items)
}
return seriesByGenre.value.flatMap((category) => category.items)
}
function getDetailAdjacentSequence(item: MediaItem): MediaItem[] {
const detailSearchPath = getDetailSearchPath()
if (detailSearchPath) {
const term = getSearchQueryFromPath(detailSearchPath)
const searchItems = getSearchResultsForTerm(term)
if (searchItems.length > 0) {
return searchItems
}
}
return getBrowseSequenceForItem(item)
}
function showAdjacentDetail(offset: -1 | 1): boolean {
const current = selectedItem.value
if (!current) return false
const sequence = getDetailAdjacentSequence(current)
if (sequence.length < 2) return false
const currentIndex = sequence.findIndex((item) => item.id === current.id && item.type === current.type)
if (currentIndex < 0) return false
const nextIndex = currentIndex + offset
if (nextIndex < 0 || nextIndex >= sequence.length) return false
showDetail(sequence[nextIndex])
return true
}
function onRawGamepadButton(event: Event) {
if (event.defaultPrevented || isMpcBeGamepadCaptured()) return
if (!selectedItem.value) return
const customEvent = event as CustomEvent<{ button?: number }>
const button = customEvent.detail?.button
if (button !== 4 && button !== 5) return
const moved = showAdjacentDetail(button === 4 ? -1 : 1)
if (moved) {
event.preventDefault()
}
}
// Focus episode info for navigating to series detail from search
const focusEpisode = ref<{ seasonNumber: number; episodeNumber: number } | null>(null)
const searchCategories = ref<{ name: string; items: MediaItem[] }[]>([])
// Focus state per page for Escape navigation
const focusStateMap = new Map<string, { row: number; col: number }>()
// Track the last viewed item ID to restore focus to the right card
const lastViewedItemId = ref<string | null>(null)
let browseScrollSnapshot: { panelTop: number; rows: SyncedRowScrollSnapshot } | null = null
function captureBrowseScrollSnapshot() {
browseScrollSnapshot = {
panelTop: browsePanelRef.value?.scrollTop ?? 0,
rows: snapshotSyncedRowScroll(),
}
}
function restoreBrowseScrollSnapshot() {
if (!browseScrollSnapshot) return
if (browsePanelRef.value) {
browsePanelRef.value.scrollTop = browseScrollSnapshot.panelTop
}
restoreSyncedRowScroll(browseScrollSnapshot.rows)
browseScrollSnapshot = null
}
// Save current focus state for a page
function saveFocusForPage(page: string) {
const state = getFocusState()
if (state) {
focusStateMap.set(page, state)
}
}
// Restore focus state for a page, or find the last viewed item
function restoreFocusForPage(page: string) {
// Don't steal focus from the search input while the user is typing
const active = document.activeElement
if (active?.closest(".header-search")) return
// First try to find the last viewed item and focus it
if (lastViewedItemId.value) {
// Use nextTick + timeout to ensure DOM is updated after navigation
setTimeout(() => {
restoreBrowseScrollSnapshot()
const itemId = lastViewedItemId.value
// Find the element with matching item id
const element = document.querySelector(`[data-item-id="${itemId}"]`) as HTMLElement | null
if (element) {
focusElement(element, { preserveScroll: true })
lastViewedItemId.value = null
return
}
// Fallback to saved focus state
const state = focusStateMap.get(page)
restoreFocusState(state || null, { preserveScroll: true })
lastViewedItemId.value = null
}, 100)
} else {
restoreBrowseScrollSnapshot()
const state = focusStateMap.get(page)
restoreFocusState(state || null, { preserveScroll: true })
}
}
function restoreBrowseFocus(path: string) {
window.setTimeout(() => {
restoreFocusForPage(getBrowseViewFromPath(path))
}, 100)
}
function getSearchExitTargetFromFocusedCard(): { path: "/movies" | "/series"; itemId: string } | null {
const active = document.activeElement as HTMLElement | null
const focusedCard = active?.closest("[data-item-id]") as HTMLElement | null
if (!focusedCard) return null
const itemId = focusedCard.getAttribute("data-item-id")
const itemType = focusedCard.getAttribute("data-item-type")
if (!itemId || !itemType) return null
if (itemType === "movies") {
return { path: "/movies", itemId }
}
if (itemType === "series") {
return { path: "/series", itemId }
}
return null
}
function getDetailSearchPath(): string | null {
if (!route.params.id) return null
const stateSearchPathRaw = window.history.state?.searchPath
if (typeof stateSearchPathRaw === "string") {
const stateSearchPath = normalizeHistoryPath(stateSearchPathRaw)
if (stateSearchPath.startsWith("/search/")) {
return stateSearchPath
}
}
const backPath =
typeof window.history.state?.back === "string"
? normalizeHistoryPath(window.history.state.back)
: ""
if (backPath.startsWith("/search/")) {
return backPath
}
return null
}
function clearSearch(options: { preferBack?: boolean; targetPath?: string } = {}) {
const currentQuery = getRouteSearchQuery()
const targetPath = options.targetPath ?? getBrowsePath()
searchQuery.value = ""
// If there's no active search query in the URL, just clear state and don't navigate.
// This prevents unwanted navigation when the search is cleared reactively (e.g.
// route changes to a detail page, which triggers the route watcher to clear
// searchQuery, which flows through Header and back to updateSearchQuery).
if (!currentQuery) {
searchReturnPath.value = null
return
}
const backPath =
typeof window.history.state?.back === "string"
? normalizeHistoryPath(window.history.state.back)
: ""
const canRestoreWithBack =
options.preferBack !== false &&
searchReturnPath.value === targetPath &&
backPath === targetPath
searchReturnPath.value = null
if (canRestoreWithBack) {
router.back()
restoreBrowseFocus(targetPath)
} else {
void router.replace({ path: targetPath }).finally(() => {
restoreBrowseFocus(targetPath)
})
}
}
function updateSearchQuery(nextValue: string) {
const nextQuery = normalizeSearchQuery(nextValue)
const currentQuery = getRouteSearchQuery()
const browsePath = getBrowsePath()
if (!nextQuery) {
clearSearch({ preferBack: true })
return
}
searchQuery.value = nextQuery
if (!currentQuery) {
if (!route.params.id) {
saveFocusForPage(currentView.value)
}
searchReturnPath.value = browsePath
void router.push(getSearchPath(nextQuery))
return
}
void router.replace(getSearchPath(nextQuery))
}
// Handle Escape key for navigation hierarchy
function handleEscapeKey(event: KeyboardEvent) {
if (event.key !== "Escape") return
// Ignore if typing in an input
const target = event.target as HTMLElement
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) {
return
}
const path = route.path
const detailSearchPath = getDetailSearchPath()
if (route.params.id && detailSearchPath) {
event.preventDefault()
void router.push(detailSearchPath).finally(() => {
restoreFocusForPage("movies")
})
return
}
// From movie detail -> movies list
if (path.startsWith("/movies/") && route.params.id) {
event.preventDefault()
router.push("/movies")
restoreFocusForPage("movies")
return
}
// From series detail -> series list
if (path.startsWith("/series/") && route.params.id) {
event.preventDefault()
router.push("/series")
restoreFocusForPage("series")
return
}
if (getRouteSearchQuery()) {
event.preventDefault()
const target = getSearchExitTargetFromFocusedCard()
if (target) {
lastViewedItemId.value = target.itemId
clearSearch({ preferBack: false, targetPath: target.path })
return
}
clearSearch({ preferBack: true })
return
}
// From series list -> movies list
if (path === "/series") {
event.preventDefault()
saveFocusForPage("series")
router.push("/movies")
restoreFocusForPage("movies")
return
}
// From movies list -> do nothing (stop here)
}
function handleDetailAdjacentKey(event: KeyboardEvent) {
if (!selectedItem.value) return
// Don't hijack typing/navigation in editable controls.
const target = event.target as HTMLElement | null
if (target) {
const tag = target.tagName
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || target.isContentEditable) {
return
}
}
if (event.key !== "PageUp" && event.key !== "PageDown") return
const moved = showAdjacentDetail(event.key === "PageUp" ? -1 : 1)
if (moved) {
event.preventDefault()
}
}
onMounted(() => {
void refreshPlayerStatus()
void refreshResumePositions()
browsePanelScrollTop.value = browsePanelRef.value?.scrollTop || 0
detailPanelScrollTop.value = detailPanelRef.value?.scrollTop || 0
setActiveNavigationScope(isDetailOpen.value ? "detail" : "browse")
document.addEventListener("keydown", handleEscapeKey)
document.addEventListener("keydown", handleDetailAdjacentKey)
window.addEventListener("mediahive:gamepad-action", onGamepadAction as EventListener)
window.addEventListener("mediahive:gamepad-button", onRawGamepadButton as EventListener)
window.addEventListener("mediahive:resume-updated", refreshResumePositionsAsEvent)
})
onUnmounted(() => {
document.removeEventListener("keydown", handleEscapeKey)
document.removeEventListener("keydown", handleDetailAdjacentKey)
window.removeEventListener("mediahive:gamepad-action", onGamepadAction as EventListener)
window.removeEventListener("mediahive:gamepad-button", onRawGamepadButton as EventListener)
window.removeEventListener("mediahive:resume-updated", refreshResumePositionsAsEvent)
stopMpcBePolling()
})
// Handle back navigation (Escape key or Back button)
function goBack() {
const path = route.path
const detailSearchPath = getDetailSearchPath()
if (route.params.id && detailSearchPath) {
void router.push(detailSearchPath).finally(() => {
restoreFocusForPage("movies")
})
return
}
// From movie detail -> movies list with focus restoration
if (path.startsWith("/movies/") && route.params.id) {
router.push("/movies")
restoreFocusForPage("movies")
return
}
// From series detail -> series list with focus restoration
if (path.startsWith("/series/") && route.params.id) {
router.push("/series")
restoreFocusForPage("series")
return
}
// Default: just go back
router.back()
}
// Derive currentView from route
const currentView = computed(() => {
return (route.meta.view as "movies" | "series") || "movies"
})
const headerCurrentView = computed<"movies" | "series" | "search">(() => {
return selectedItem.value && getDetailSearchPath() ? "search" : currentView.value
})
// Header position based on current page
const headerPosition = computed(() => {
if (selectedItem.value) {
// Detail pages - position after the header section
return selectedItem.value.type === "series" ? "after-series-hero" : "after-movie-header"
}
if (loading.value || error.value) {
return "top"
}
// Search page always uses after-hero position for consistent layout
if (searchQuery.value) {
return "after-hero"
}
return "after-hero"
})
// Derive selectedItem from route params
const selectedItem = computed(() => {
const id = route.params.id as string | undefined
if (!id || !mediaIndex.value) return null
// Check route path to determine type, not currentView (which could be 'search')
const path = route.path
if (path.startsWith("/movies/")) {
const movie = mediaIndex.value.movies.find((m) => m.id === id)
return movie ? movieToMediaItem(movie) : null
} else if (path.startsWith("/series/")) {
const series = mediaIndex.value.series.find((s) => s.id === id)
return series ? seriesToMediaItem(series) : null
}
return null
})
const isDetailOpen = computed(() => selectedItem.value !== null)
const lastDetailItem = ref<MediaItem | null>(null)
const detailItemForRender = computed(() => selectedItem.value ?? lastDetailItem.value)
const activePanelScrollTop = computed(() => {
if (loading.value || error.value) return 0
return isDetailOpen.value ? detailPanelScrollTop.value : browsePanelScrollTop.value
})
const headerStyle = computed(() => {
if (route.path === "/settings") {
return {}
}
return {
transform: `translateY(${-activePanelScrollTop.value}px)`,
}
})
watch(selectedItem, (item) => {
if (item) {
lastDetailItem.value = item
}
})
// Show detail by navigating to URL
function showDetail(item: MediaItem) {
// Save the item ID to restore focus when returning
lastViewedItemId.value = item.id
// Save focus state before navigating to detail
const currentPage = route.path === "/series" ? "series" : "movies"
saveFocusForPage(currentPage)
// Capture the exact browse scroll positions to restore on return,
// but only when leaving the browse page (not for detail-to-detail hops)
if (!isDetailOpen.value) {
captureBrowseScrollSnapshot()
}
// Check if there are matched episodes to focus on
if (item.type === "series" && item.searchMatchInfo?.matchedEpisodes?.length) {
const firstMatch = item.searchMatchInfo.matchedEpisodes[0]
focusEpisode.value = {
seasonNumber: firstMatch.seasonNumber,
episodeNumber: firstMatch.episodeNumber,
}
} else {
focusEpisode.value = null
}
if (item.type === "episode") {
// For episodes, play directly if possible, otherwise show the series
const epData = item.data as EpisodeWithSeries
const playableFile = Object.values(epData.episode.files || {})[0]?.playable_file
if (playableFile) {
handlePlay(playableFile)
} else {
const searchPath = searchQuery.value ? getSearchPath(searchQuery.value) : null
router.push({ path: `/series/${epData.series.id}`, state: searchPath ? { searchPath } : undefined })
}
} else {
const detailSearchPath = getDetailSearchPath()
const searchPath = searchQuery.value
? getSearchPath(searchQuery.value)
: detailSearchPath
router.push({ path: `/${item.type}/${item.id}`, state: searchPath ? { searchPath } : undefined })
}
}
// Close detail by navigating back to list
function closeDetail() {
const detailSearchPath = getDetailSearchPath()
const targetPath = getBrowsePath()
if (detailSearchPath) {
router.push(detailSearchPath)
} else {
router.push(targetPath)
}
}
function handleActorSearch(actorName: string) {
updateSearchQuery(actorName)
}
function handleSelectMovieFromDetail(movieId: string) {
const detailSearchPath = getDetailSearchPath()
const searchPath = searchQuery.value
? getSearchPath(searchQuery.value)
: detailSearchPath
router.push({ path: `/movies/${movieId}`, state: searchPath ? { searchPath } : undefined })
}
function focusDetailEntryTarget(item: MediaItem): boolean {
const detailPanel = document.querySelector('[data-nav-scope="detail"]') as HTMLElement | null
if (!detailPanel) return false
let target: HTMLElement | null = null
if (item.type === "movies") {
// Best release is rendered first and mapped to row 2 / col 0.
target = detailPanel.querySelector(
'[data-nav-release-item="true"][data-nav-row="2"][data-nav-col="0"][data-nav-focusable="true"]',
) as HTMLElement | null
} else if (item.type === "series") {
// Row 2 is the season selector strip; land on the selected season poster.
target = detailPanel.querySelector(
'.season-poster-card.season-poster-card--selected[data-nav-focusable="true"]',
) as HTMLElement | null
if (!target) {
target = detailPanel.querySelector(
'.episode-tile[data-nav-focusable="true"]',
) as HTMLElement | null
}
}
if (target) {
focusElement(target)
return true
}
const navButtons = Array.from(document.querySelectorAll<HTMLElement>(".header-nav-item.active"))
const detailsNav = navButtons.find((button) => button.textContent?.trim() === "Details")
if (detailsNav?.hasAttribute("data-nav-focusable")) {
focusElement(detailsNav)
return true
}
const firstFocusable = detailPanel.querySelector('[data-nav-focusable="true"]') as HTMLElement | null
if (firstFocusable) {
focusElement(firstFocusable)
return true
}
return false
}
// Convert raw data to MediaItem format
function movieToMediaItem(movie: MovieUi): MediaItem {
// Get resolution from first torrent if available
const files = Object.values(movie.files || {})
const resolution = files.length > 0 ? files[0].resolution : null
return {
id: movie.id,
title: movie.title || "Unknown",
year: movie.year,
cover_path: movie.cover_path,
showreel_images: movie.showreel_images,
showreel_source_sets: movie.showreel_source_sets,
type: "movies",
resolution: resolution,
data: movie,
root_id: movie.root_id,
}
}
function seriesToMediaItem(series: SeriesUi): MediaItem {
// For series, collect reel images from all episodes
const reelImages: string[] = []
const reelSourceSets: string[][] = []
for (const season of series.seasons || []) {
for (const episode of season.episodes || []) {
if (episode.reel_sources && episode.reel_sources.length > 0) {
reelImages.push(episode.reel_sources[0])
reelSourceSets.push(episode.reel_sources)
} else if (episode.reel_image) {
reelImages.push(episode.reel_image)
reelSourceSets.push([episode.reel_image])
}
}
}
return {
id: series.id,
title: series.title || "Unknown",
year: null,
cover_path: series.cover_path,
showreel_images: reelImages.length > 0 ? reelImages : null,
showreel_source_sets: reelSourceSets.length > 0 ? reelSourceSets : null,
type: "series",
data: series,
root_id: series.root_id,
}
}
// Genre categories for display order (array position determines UI order)
// priority: lower number = higher matching priority (movies assigned to highest priority match)
// exclude: if item has any of these genres, it won't match this category (negative match)
const GENRE_CATEGORIES = [
{ name: "Action", keywords: ["Action", "Adventure"], priority: 40, exclude: [] },
{ name: "Comedy", keywords: ["Comedy"], priority: 30, exclude: ["Drama"] },
{ name: "Romance", keywords: ["Romance"], priority: 20, exclude: [] },
{ name: "Drama", keywords: ["Drama"], priority: 50, exclude: [] },
{ name: "Crime", keywords: ["Crime"], priority: 45, exclude: [] },
{ name: "Thriller", keywords: ["Thriller", "Mystery"], priority: 20, exclude: [] },
{ name: "Horror", keywords: ["Horror"], priority: 10, exclude: [] },
{ name: "Science Fiction", keywords: ["Science Fiction", "Sci-Fi"], priority: 15, exclude: [] },
{ name: "Animation", keywords: ["Animation"], priority: 8, exclude: [] },
{ name: "Family", keywords: ["Family"], priority: 10, exclude: [] },
{ name: "Sports", keywords: ["Sports"], priority: 30, exclude: [] },
{ name: "Documentary", keywords: ["Documentary"], priority: 20, exclude: [] },
{ name: "War/History", keywords: ["War", "History"], priority: 30, exclude: ["Fantasy"] },
{ name: "Music", keywords: ["Music", "Musical"], priority: 15, exclude: [] },
] as const
// Categories sorted by priority for matching (lowest priority number first)
const GENRE_CATEGORIES_BY_PRIORITY = [...GENRE_CATEGORIES].sort((a, b) => a.priority - b.priority)
// Helper to sort by rating (highest first)
function sortByRating(items: MediaItem[]): MediaItem[] {
return [...items].sort((a, b) => {
let ratingA = 0
let ratingB = 0
if (a.type === "episode") {
const epData = a.data as EpisodeWithSeries
ratingA = epData.episode.rating ?? epData.series.info?.rating ?? 0
} else {
ratingA = (a.data as Movie | Series).info?.rating ?? 0
}
if (b.type === "episode") {
const epData = b.data as EpisodeWithSeries
ratingB = epData.episode.rating ?? epData.series.info?.rating ?? 0
} else {
ratingB = (b.data as Movie | Series).info?.rating ?? 0
}
return ratingB - ratingA
})
}
// Categorize movies by genre
const moviesByGenre = computed(() => {
if (!mediaIndex.value) return []
const allMovies = mediaIndex.value.movies.map(movieToMediaItem)
const assignedIds = new Set<string>()
const categories: { name: string; items: MediaItem[] }[] = []
// Apply search filter
const searchFilter = (m: MediaItem) => {
if (!searchQuery.value) return true
return (m.title || "").toLowerCase().includes(searchQuery.value.toLowerCase())
}
// Assign movies to categories by matching priority (lowest priority number first)
const categoryMap = new Map<string, MediaItem[]>()
for (const category of GENRE_CATEGORIES_BY_PRIORITY) {
for (const movie of allMovies) {
if (assignedIds.has(movie.id)) continue
if (!searchFilter(movie)) continue
const movieData = movie.data as Movie
const genres = movieData.info?.genres || []
// Check if movie matches this category (has keyword and no excluded genres)
const hasKeyword = category.keywords.some((keyword) =>
genres.some((g) => g.toLowerCase().includes(keyword.toLowerCase())),
)
const hasExcluded =
category.exclude.length > 0 &&
category.exclude.some((excl) =>
genres.some((g) => g.toLowerCase().includes(excl.toLowerCase())),
)
if (hasKeyword && !hasExcluded) {
if (!categoryMap.has(category.name)) {
categoryMap.set(category.name, [])
}
categoryMap.get(category.name)!.push(movie)
assignedIds.add(movie.id)
}
}
}
// Build categories in display order (GENRE_CATEGORIES array order)
for (const category of GENRE_CATEGORIES) {
const categoryMovies = categoryMap.get(category.name)
if (categoryMovies && categoryMovies.length > 0) {
categories.push({
name: category.name,
items: sortByRating(categoryMovies),
})
}
}
// Collect remaining movies into Miscellaneous
const miscMovies: MediaItem[] = []
for (const movie of allMovies) {
if (assignedIds.has(movie.id)) continue
if (!searchFilter(movie)) continue
miscMovies.push(movie)
}
if (miscMovies.length > 0) {
categories.push({
name: "Miscellaneous",
items: sortByRating(miscMovies),
})
}
return categories
})
// Categorize series by genre
const seriesByGenre = computed(() => {
if (!mediaIndex.value) return []
const allSeries = mediaIndex.value.series.map(seriesToMediaItem)
const assignedIds = new Set<string>()
const categories: { name: string; items: MediaItem[] }[] = []
// Apply search filter
const searchFilter = (s: MediaItem) => {
if (!searchQuery.value) return true
return (s.title || "").toLowerCase().includes(searchQuery.value.toLowerCase())
}
// Assign series to categories by matching priority (lowest priority number first)
const categoryMap = new Map<string, MediaItem[]>()
for (const category of GENRE_CATEGORIES_BY_PRIORITY) {
for (const series of allSeries) {
if (assignedIds.has(series.id)) continue
if (!searchFilter(series)) continue
const seriesData = series.data as Series
const genres = seriesData.info?.genres || []
// Check if series matches this category (has keyword and no excluded genres)
const hasKeyword = category.keywords.some((keyword) =>
genres.some((g) => g.toLowerCase().includes(keyword.toLowerCase())),
)
const hasExcluded =
category.exclude.length > 0 &&
category.exclude.some((excl) =>
genres.some((g) => g.toLowerCase().includes(excl.toLowerCase())),
)
if (hasKeyword && !hasExcluded) {
if (!categoryMap.has(category.name)) {
categoryMap.set(category.name, [])
}
categoryMap.get(category.name)!.push(series)
assignedIds.add(series.id)
}
}
}
// Build categories in display order (GENRE_CATEGORIES array order)
for (const category of GENRE_CATEGORIES) {
const categorySeries = categoryMap.get(category.name)
if (categorySeries && categorySeries.length > 0) {
categories.push({
name: category.name,
items: sortByRating(categorySeries),
})
}
}
// Collect remaining series into Miscellaneous
const miscSeries: MediaItem[] = []
for (const series of allSeries) {
if (assignedIds.has(series.id)) continue
if (!searchFilter(series)) continue
miscSeries.push(series)
}
if (miscSeries.length > 0) {
categories.push({
name: "Miscellaneous",
items: sortByRating(miscSeries),
})
}
return categories
})
// Search worker
let searchWorker: Worker | null = null
let pendingSearchId = 0
let workerHasIndex = false
function getSearchWorker(): Worker {
if (!searchWorker) {
searchWorker = new Worker(new URL("./search-worker.ts", import.meta.url), {
type: "module",
})
searchWorker.onmessage = (event: MessageEvent<SearchResponseMessage>) => {
const { id, results, categories } = event.data
// Ignore stale results
if (id !== pendingSearchId) return
searchResults.value = results.map(rehydrateSearchResult)
searchCategories.value = categories.map((cat) => ({
name: cat.name,
items: cat.items.map(rehydrateSearchResult),
}))
isSearching.value = false
}
}
return searchWorker
}
function syncWorkerIndex() {
if (!mediaIndex.value) return
const worker = getSearchWorker()
worker.postMessage({
type: "index",
movies: JSON.parse(JSON.stringify(mediaIndex.value.movies)),
series: JSON.parse(JSON.stringify(mediaIndex.value.series)),
})
workerHasIndex = true
}
// Rehydrate a lightweight SearchResultItem back into a full MediaItem
function rehydrateSearchResult(result: SearchResultItem): MediaItem {
const base: MediaItem = {
id: result.id,
title: result.title,
year: result.year,
cover_path: result.cover_path,
showreel_images: result.showreel_images,
showreel_source_sets: result.showreel_source_sets,
type: result.type,
resolution: result.resolution,
data: {} as Movie | Series, // placeholder; lookup on demand if needed
root_id: result.root_id,
searchMatchInfo: result.searchMatchInfo,
}
return base
}
// Watch for detail page entry/exit to manage focus
watch(selectedItem, (item, oldItem) => {
if (item && !oldItem) {
// Entering detail page - constrain navigation to detail panel immediately,
// then shift focus into it while the slide transition runs.
setActiveNavigationScope("detail")
setTimeout(() => {
focusDetailEntryTarget(item)
}, 20)
} else if (!item && oldItem) {
setActiveNavigationScope("browse")
// Leaving detail page (browser back, Escape, etc.) - restore focus to the item card
const page = currentView.value === "series" ? "series" : "movies"
restoreFocusForPage(page)
}
})
// Focus search input by default on initial movies page load
let initialFocusDone = false
watch([mediaIndex, currentView, searchQuery, selectedItem], ([index, view, query, item]) => {
if (!initialFocusDone && index && view === "movies" && !query && !item) {
initialFocusDone = true
// Focus search input on first movies page load
setTimeout(() => {
const searchInput = document.querySelector(".search-input") as HTMLInputElement
if (searchInput) {
searchInput.focus()
}
}, 100)
}
})
watch(
() => route.fullPath,
() => {
const nextQuery = getRouteSearchQuery()
if (nextQuery !== searchQuery.value) {
searchQuery.value = nextQuery
}
if (!nextQuery && route.name !== "search") {
searchReturnPath.value = null
}
},
{ immediate: true },
)
function runSearch() {
const query = searchQuery.value
if (!query || !mediaIndex.value) {
searchResults.value = []
searchCategories.value = []
isSearching.value = false
pendingSearchId += 1
return
}
if (!workerHasIndex) {
syncWorkerIndex()
}
isSearching.value = true
pendingSearchId += 1
const id = pendingSearchId
const worker = getSearchWorker()
worker.postMessage({
type: "query",
id,
query: query.toLowerCase(),
})
}
watch([searchQuery, searchResults], ([query, results]) => {
const key = normalizeSearchQuery(query).toLowerCase()
if (!key || results.length === 0) return
searchResultsCache.set(key, [...results])
})
watch(searchQuery, runSearch)
watch(mediaIndex, () => {
workerHasIndex = false
if (searchQuery.value) {
syncWorkerIndex()
runSearch()
}
})
// Sort by newest timestamp (descending)
function sortByNewest(items: MediaItem[]): MediaItem[] {
return [...items].sort((a, b) => {
const aNewest = (a.data as Movie | Series).newest ?? 0
const bNewest = (b.data as Movie | Series).newest ?? 0
return bNewest - aNewest
})
}
// Newest items from all genre categories for showcase
const newestMovies = computed(() => {
const allItems: MediaItem[] = []
for (const category of moviesByGenre.value) {
allItems.push(...category.items)
}
return sortByNewest(allItems).slice(0, 20)
})
const newestSeries = computed(() => {
const allItems: MediaItem[] = []
for (const category of seriesByGenre.value) {
allItems.push(...category.items)
}
return sortByNewest(allItems).slice(0, 20)
})
// Items for the collage hero - separate for movies and series to enable smooth transitions
const movieCollageItems = computed(() => {
const items = newestMovies.value
const withCovers = items.filter((m) => m.cover_path)
const withoutCovers = items.filter((m) => !m.cover_path)
return [...withCovers, ...withoutCovers].slice(0, 100)
})
const seriesCollageItems = computed(() => {
const items = newestSeries.value
const withCovers = items.filter((m) => m.cover_path)
const withoutCovers = items.filter((m) => !m.cover_path)
return [...withCovers, ...withoutCovers].slice(0, 100)
})
// Featured items for hero - separate for movies and series
const movieFeaturedItem = computed(() => {
const withCovers = newestMovies.value.filter((m) => m.cover_path)
return withCovers[0] || newestMovies.value[0] || null
})
const seriesFeaturedItem = computed(() => {
const withCovers = newestSeries.value.filter((s) => s.cover_path)
return withCovers[0] || newestSeries.value[0] || null
})
// Search results collage items (already ranked by relevance)
const searchCollageItems = computed(() => {
const items = searchResults.value
const withCovers = items.filter((m) => m.cover_path)
const withoutCovers = items.filter((m) => !m.cover_path)
return [...withCovers, ...withoutCovers].slice(0, 100)
})
// Search featured item (highest relevance with cover)
const searchFeaturedItem = computed(() => {
const withCovers = searchResults.value.filter((m) => m.cover_path)
return withCovers[0] || searchResults.value[0] || null
})
function reloadPage() {
window.location.reload()
}
function findRootIdForPath(filePath: string): string | null {
if (!mediaIndex.value) return null
for (const movie of mediaIndex.value.movies) {
for (const torrent of Object.values(movie.files || {})) {
if (torrent.playable_file === filePath) {
return torrent.root_id || movie.root_id
}
}
}
for (const series of mediaIndex.value.series) {
for (const season of series.seasons || []) {
for (const episode of season.episodes || []) {
for (const torrent of Object.values(episode.files || {})) {
if (torrent.playable_file === filePath) {
return torrent.root_id || series.root_id
}
}
}
}
}
return null
}
async function handlePlay(filePath: string) {
const actionStart = performance.now()
const rootId = findRootIdForPath(filePath)
if (!rootId) {
console.error("Cannot play: unknown root for path", filePath)
return
}
if (isMpcFamilySelected()) {
mpcBeOpeningUntil.value = Date.now() + MPC_BE_OPENING_GRACE_MS
}
try {
await playMedia(rootId, filePath, settings.playerId, settings.playerCustomCmd, {
actionStartedAt: actionStart,
source: "App.handlePlay",
})
if (isMpcFamilySelected()) {
const connected = await tryConnectMpcBe()
if (connected) {
mpcBeConnected.value = true
startMpcBePolling()
}
}
} catch (e) {
console.error("Failed to play media:", e)
}
}
async function handleOpenFolder(folderPath: string, explicitRootId?: string | null) {
const actionStart = performance.now()
const rootId = explicitRootId || findRootIdForPath(folderPath)
if (!rootId) {
console.error("Cannot open folder: unknown root for path", folderPath)
return
}
try {
await openFolder(rootId, folderPath, {
actionStartedAt: actionStart,
source: "App.handleOpenFolder",
})
} catch (e) {
console.error("Failed to open folder:", e)
}
}
</script>
<style scoped>
.app {
height: 100vh;
min-height: 100vh;
overflow: hidden;
}
/* Unified library activity panel */
.activity-overlay {
position: fixed;
bottom: 12px;
right: 12px;
z-index: 9999;
pointer-events: none;
}
.library-update-toast {
position: fixed;
bottom: 12px;
left: 50%;
transform: translateX(-50%);
z-index: 10000;
max-width: min(680px, calc(100vw - 24px));
padding: 9px 14px;
border-radius: 999px;
border: 1px solid rgba(52, 211, 153, 0.45);
background: rgba(6, 95, 70, 0.92);
color: rgba(255, 255, 255, 0.95);
font-size: 12px;
font-weight: 600;
text-align: center;
pointer-events: none;
backdrop-filter: blur(8px);
}
.activity-card {
display: flex;
flex-direction: column;
gap: 10px;
width: min(440px, calc(100vw - 24px));
padding: 12px;
background: rgba(0, 0, 0, 0.86);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 10px;
backdrop-filter: blur(10px);
}
.activity-header {
display: flex;
justify-content: space-between;
align-items: baseline;
color: rgba(255, 255, 255, 0.9);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.02em;
}
.activity-connection {
color: #f59e0b;
font-weight: 600;
}
.activity-root {
display: grid;
gap: 6px;
padding: 10px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
}
.activity-root-error {
border-color: rgba(248, 113, 113, 0.45);
}
.activity-root-title {
font-size: 12px;
font-weight: 700;
color: rgba(255, 255, 255, 0.95);
}
.activity-root-target {
font-size: 12px;
color: rgba(147, 197, 253, 0.95);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.activity-phase-row {
display: flex;
justify-content: space-between;
gap: 8px;
font-size: 12px;
}
.activity-phase {
color: rgba(255, 255, 255, 0.82);
}
.activity-progress-label {
color: #60a5fa;
font-weight: 600;
}
.activity-bar {
position: relative;
height: 6px;
border-radius: 99px;
background: rgba(255, 255, 255, 0.14);
overflow: hidden;
}
.activity-bar-fill {
position: absolute;
inset: 0 auto 0 0;
width: 100%;
background: linear-gradient(90deg, #3b82f6, #22d3ee);
border-radius: inherit;
}
.activity-bar-indeterminate .activity-bar-fill {
width: 45%;
animation: activity-indeterminate 1.3s ease-in-out infinite;
}
.activity-detail {
color: rgba(255, 255, 255, 0.58);
font-size: 11px;
line-height: 1.35;
}
@keyframes activity-indeterminate {
0% {
transform: translateX(-120%);
}
100% {
transform: translateX(250%);
}
}
.empty-hero {
height: 70vh;
min-height: 450px;
max-height: 600px;
background: var(--bg-primary);
}
.no-results {
display: flex;
justify-content: center;
align-items: center;
margin-top: 5rem;
padding: 60px 20px;
color: var(--text-secondary);
font-size: 2rem;
}
.page-slider {
width: 100vw;
height: 100vh;
overflow: hidden;
}
.page-slider-track {
display: flex;
width: 200vw;
height: 100vh;
transition: transform 420ms cubic-bezier(0.22, 0.61, 0.36, 1);
will-change: transform;
}
.page-slider-track.is-detail-open {
transform: translate3d(-100vw, 0, 0);
}
.page-slider-panel {
flex: 0 0 100vw;
width: 100vw;
min-width: 100vw;
height: 100vh;
overflow-y: auto;
overflow-x: hidden;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.page-slider-detail-panel {
background: var(--bg-primary);
}
@media (prefers-reduced-motion: reduce) {
.page-slider-track {
transition: none;
}
}
</style>