Files
mediahive/frontend/src/components/Header.vue
T

952 lines
25 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<header class="header" :class="[`header-${position}`]">
<div class="header-left">
<RouterLink to="/" class="header-logo-link" aria-label="Go to front page">
<img :src="logoUrl" alt="MediaHive" class="header-logo" />
</RouterLink>
<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 === "search" ? "Search" : 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..."
:spellcheck="false"
autocorrect="off"
autocapitalize="off"
autocomplete="off"
v-model="localSearch"
v-bind="navAttrs(navRow, 2)"
:data-nav-entry-col="localSearch ? 2 : undefined"
@focus="handleSearchFocus"
@keydown.escape="handleEscape"
/>
<HexKeyboard
v-model="localSearch"
:visible="hexKeyboardVisible"
:search-ref="searchInputRef"
@close="hexKeyboardVisible = false"
@submit="hexKeyboardVisible = false"
/>
</div>
<div v-if="mpcBeConnected" class="player-indicator" title="MPC-BE is connected">
<span class="player-indicator-dot" aria-hidden="true"></span>
<span>Player Open</span>
</div>
<div class="header-settings">
<button class="header-settings-btn" title="Settings" @click="openSettings">
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="3" />
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 5 15.4 1.65 1.65 0 0 0 3.4 15H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.6a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"
/>
</svg>
</button>
<!-- Full-screen settings view -->
<div v-if="showSettings" class="settings-view">
<div class="settings-header">
<button class="settings-back" @click="closeSettings">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
<span>Back</span>
</button>
<h1 class="settings-title">Settings</h1>
<div class="settings-header-spacer"></div>
</div>
<div class="settings-content scrollbar-hidden">
<section class="settings-section">
<h2 class="settings-section-title">Media Roots</h2>
<p class="settings-section-desc">Folders scanned and indexed by MediaHive.</p>
<div class="roots-list">
<div
v-for="root in roots"
:key="root.root_id"
class="roots-item"
:class="`roots-item--${root.status}`"
>
<div class="roots-item-info">
<span class="roots-item-name">{{ root.root_id }}</span>
<span class="roots-item-path">{{ root.path }}</span>
</div>
<div class="roots-item-meta">
<span class="roots-item-status">{{ root.status }}</span>
<button
v-if="roots.length > 1"
class="roots-item-remove"
@click="removeRoot(root.root_id)"
title="Remove root"
>
×
</button>
</div>
</div>
</div>
<div class="roots-actions">
<button v-if="isDesktopApp" class="roots-add-btn" @click="addRoot">
+ Add Folder
</button>
</div>
</section>
<section class="settings-section">
<h2 class="settings-section-title">Player</h2>
<p class="settings-section-desc">Choose which media player to launch files with.</p>
<div class="player-layout">
<div class="player-list">
<label
v-for="player in detectedPlayers"
:key="player.id"
class="player-radio-label"
>
<input
class="player-radio"
type="radio"
name="player-selection"
:checked="settings.playerId === player.id"
@change="setPlayer(player.id)"
/>
{{ player.name }}
</label>
</div>
<div class="player-options">
<template v-if="settings.playerId === 'custom'">
<div class="player-option-group">
<label class="player-option-label">Custom Command</label>
<input
class="player-option-input"
type="text"
placeholder='C:\Player\player.exe "%s"'
v-model="customCmd"
@change="setCustomCmd(customCmd)"
/>
<p class="player-option-hint">Use %s as placeholder for the file path.</p>
</div>
</template>
<template v-if="selectedPlayerFamily === 'mpc'">
<div class="player-option-group">
<label class="player-option-label" for="mpc-port">MPC Web UI Port</label>
<input
id="mpc-port"
class="player-option-input player-option-input--short"
type="number"
placeholder="13579"
:value="settings.playerMpcPort ?? ''"
@input="onMpcPortInput"
/>
<p class="player-option-hint">
Port for MPC-BE/HC web interface. Leave empty to disable remote control.
</p>
<p v-if="settings.playerMpcPort !== null" class="player-family-note">
Web remote control enabled
</p>
</div>
</template>
</div>
</div>
</section>
<section class="settings-section">
<h2 class="settings-section-title">Preferred Format</h2>
<p class="settings-section-desc">
Preferred format when multiple versions are available.
</p>
<div class="format-grid">
<div class="format-row format-row-stack">
<label class="format-radio-label" for="resolution-hd">
<input
id="resolution-hd"
class="format-radio"
type="radio"
name="resolution-preference"
:checked="settings.preferredResolution === 'r2'"
@change="setPreferredResolution('r2')"
/>
HD or lower
</label>
<label class="format-radio-label" for="resolution-fhd">
<input
id="resolution-fhd"
class="format-radio"
type="radio"
name="resolution-preference"
:checked="settings.preferredResolution === 'r3'"
@change="setPreferredResolution('r3')"
/>
Full HD
</label>
<label class="format-radio-label" for="resolution-4k">
<input
id="resolution-4k"
class="format-radio"
type="radio"
name="resolution-preference"
:checked="settings.preferredResolution === 'r4'"
@change="setPreferredResolution('r4')"
/>
4K
</label>
<label class="format-radio-label" for="resolution-highest">
<input
id="resolution-highest"
class="format-radio"
type="radio"
name="resolution-preference"
:checked="settings.preferredResolution === 'rmax'"
@change="setPreferredResolution('rmax')"
/>
Highest
</label>
</div>
<div class="format-row format-row-stack">
<label class="format-radio-label" for="hdr-none">
<input
id="hdr-none"
class="format-radio"
type="radio"
name="hdr-preference"
:checked="settings.preferredHdr === 'none'"
@change="setPreferredHdr('none')"
/>
No HDR
</label>
<label class="format-radio-label" for="hdr-hdr10plus">
<input
id="hdr-hdr10plus"
class="format-radio"
type="radio"
name="hdr-preference"
:checked="settings.preferredHdr === 'hdr10plus'"
@change="setPreferredHdr('hdr10plus')"
/>
HDR10+
</label>
<label class="format-radio-label" for="hdr-dovi">
<input
id="hdr-dovi"
class="format-radio"
type="radio"
name="hdr-preference"
:checked="settings.preferredHdr === 'dovi'"
@change="setPreferredHdr('dovi')"
/>
Dolby Vision
</label>
</div>
</div>
</section>
<section class="settings-section">
<h2 class="settings-section-title">Diagnostics</h2>
<p class="settings-section-desc">Application log for troubleshooting.</p>
<div class="diag-log-header">
<span class="diag-label">Application log</span>
</div>
<pre ref="logEl" class="diag-log" @scroll="onLogScroll">{{ appLog }}</pre>
</section>
</div>
</div>
</div>
</header>
</template>
<script setup lang="ts">
import { ref, watch, computed, onMounted, onUnmounted, nextTick } from "vue"
import { useRouter, useRoute } from "vue-router"
import { navAttrs } from "../composables/useKeyboardNavigation"
import logoUrl from "../assets/mediahive.webp"
import { replaceRoots, pickFolderAndAddRoot, fetchPlayers } from "../api"
import type { PlayerInfo } from "../api"
import HexKeyboard from "./HexKeyboard.vue"
import {
useSettings,
type ResolutionPreference,
type HdrPreference,
} from "../composables/useSettings"
const settings = useSettings()
const detectedPlayers = ref<PlayerInfo[]>([])
const customCmd = ref(settings.playerCustomCmd || "")
const selectedPlayerFamily = computed(() => {
const p = detectedPlayers.value.find((p) => p.id === settings.playerId)
return p?.family ?? "default"
})
interface RootEntry {
root_id: string
path: string
status: string
}
const props = defineProps<{
currentView: "movies" | "series" | "search"
searchQuery: string
roots: RootEntry[]
mpcBeConnected: boolean
navRow: number
position: "top" | "after-hero" | "after-movie-header" | "after-series-hero"
}>()
const emit = defineEmits<{
search: [string]
goBack: []
}>()
const router = useRouter()
const route = useRoute()
const searchInputRef = ref<HTMLInputElement | null>(null)
const localSearch = ref(props.searchQuery)
const isDesktopApp = ref(typeof (window as any).pywebview !== "undefined")
function _onPywebviewReady() {
isDesktopApp.value = true
}
window.addEventListener("pywebviewready", _onPywebviewReady, { once: true })
onUnmounted(() => window.removeEventListener("pywebviewready", _onPywebviewReady))
const showSettings = computed(() => route.path === "/settings")
const roots = computed(() => props.roots)
function openSettings() {
if (showSettings.value) return
void router.push("/settings")
}
function closeSettings() {
if (!showSettings.value) return
if (window.history.length > 1) {
router.back()
return
}
void router.replace("/movies")
}
function setPreferredResolution(value: ResolutionPreference) {
settings.preferredResolution = value
}
function setPreferredHdr(value: HdrPreference) {
settings.preferredHdr = value
}
function setPlayer(id: string) {
settings.playerId = id
}
function setCustomCmd(cmd: string) {
settings.playerCustomCmd = cmd.trim() || null
}
function onMpcPortInput(e: Event) {
const target = e.target as HTMLInputElement
const value = target.value.trim()
if (value === "") {
settings.playerMpcPort = null
} else {
const num = parseInt(value, 10)
settings.playerMpcPort = isNaN(num) || num <= 0 ? null : num
}
}
async function refreshPlayers() {
try {
detectedPlayers.value = await fetchPlayers()
} catch (e) {
console.error("Failed to fetch players:", e)
}
}
const appLog = ref("")
const logEl = ref<HTMLElement | null>(null)
let logSocket: WebSocket | null = null
let pinnedToBottom = true
function onLogScroll() {
const el = logEl.value
if (!el) return
pinnedToBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 48
}
function connectLogSocket() {
if (logSocket) return
const proto = location.protocol === "https:" ? "wss" : "ws"
const ws = new WebSocket(`${proto}://${location.host}/api/log/ws`)
logSocket = ws
pinnedToBottom = true
ws.onmessage = async (ev) => {
appLog.value = String(ev.data)
await nextTick()
const el = logEl.value
if (el && pinnedToBottom) el.scrollTop = el.scrollHeight
}
ws.onclose = () => {
if (logSocket === ws) logSocket = null
if (showSettings.value) setTimeout(connectLogSocket, 3000)
}
}
function disconnectLogSocket() {
const ws = logSocket
logSocket = null
ws?.close()
}
async function removeRoot(rootId: string) {
const filtered = roots.value.filter((r) => r.root_id !== rootId)
const newRoots = Object.fromEntries(filtered.map((r) => [r.root_id, r.path]))
try {
await replaceRoots(newRoots)
} catch (e) {
console.error("Failed to remove root:", e)
alert("Failed to remove root")
}
}
async function addRoot() {
const folder = await pickFolderAndAddRoot()
if (!folder) return
const suggestedId = folder.split("/").pop() || folder.split("\\").pop() || "media"
const newRoots = Object.fromEntries(roots.value.map((r) => [r.root_id, r.path]))
newRoots[suggestedId] = folder
try {
await replaceRoots(newRoots)
closeSettings()
} catch (e) {
console.error("Failed to add root:", e)
alert("Failed to add root")
}
}
watch(showSettings, (visible) => {
if (visible) {
void refreshPlayers()
connectLogSocket()
} else {
disconnectLogSocket()
}
})
// 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") {
router.push("/movies")
}
}
function switchToSeries() {
if (!isDetailPage.value && props.currentView !== "series") {
router.push("/series")
}
}
// Go back to category list from detail page
function goToCategory() {
// Emit goBack to let App.vue handle navigation and focus restoration
emit("goBack")
}
// Handle search input focus - navigate to search if we have a query
function handleSearchFocus() {
// Intentionally no-op: focusing search should not navigate away from detail.
}
// 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() {
if (hexKeyboardVisible.value) {
hexKeyboardVisible.value = false
return
}
// Clear search and blur
localSearch.value = ""
searchInputRef.value?.blur()
}
const hexKeyboardVisible = ref(false)
function onGamepadAction(event: Event) {
const customEvent = event as CustomEvent<{ action?: string }>
const action = customEvent.detail?.action
if (!action) return
// Only handle when search input is focused
const active = document.activeElement
if (!active || !searchInputRef.value || active !== searchInputRef.value) return
if (action === "select") {
event.preventDefault()
if (!hexKeyboardVisible.value) {
hexKeyboardVisible.value = true
}
}
}
function focusSearchInput() {
searchInputRef.value?.focus()
searchInputRef.value?.select()
}
function handleKeydown(e: KeyboardEvent) {
const target = e.target as HTMLElement | null
const isTypingTarget = Boolean(
target &&
(target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable),
)
const isSearchShortcut = (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "f"
const isSlashShortcut = !e.ctrlKey && !e.metaKey && !e.altKey && e.code === "Slash"
if (isTypingTarget && !isSearchShortcut) {
return
}
if (isSearchShortcut || isSlashShortcut) {
e.preventDefault()
focusSearchInput()
}
}
onMounted(() => {
window.addEventListener("keydown", handleKeydown)
window.addEventListener("mediahive:gamepad-action", onGamepadAction)
})
onUnmounted(() => {
window.removeEventListener("keydown", handleKeydown)
window.removeEventListener("mediahive:gamepad-action", onGamepadAction)
disconnectLogSocket()
})
</script>
<style scoped>
.player-indicator {
display: inline-flex;
align-items: center;
gap: 8px;
margin-right: 10px;
font-size: 0.82rem;
color: var(--text-secondary);
}
.player-indicator-dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: #22c55e;
box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.18);
}
.header-settings {
position: relative;
}
.settings-view {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: var(--bg-primary);
z-index: 2000;
display: flex;
flex-direction: column;
overflow: hidden;
}
.settings-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
flex-shrink: 0;
}
.settings-back {
display: inline-flex;
align-items: center;
gap: 8px;
background: none;
border: none;
color: var(--text-secondary);
font-size: 0.9rem;
cursor: pointer;
padding: 8px 0;
transition: color 0.2s;
}
.settings-back:hover {
color: var(--text-primary);
}
.settings-title {
font-size: 1.1rem;
font-weight: 600;
margin: 0;
}
.settings-header-spacer {
width: 80px;
}
.settings-content {
flex: 1;
overflow-y: auto;
padding: 32px 24px;
max-width: 720px;
margin: 0 auto;
width: 100%;
}
.settings-section {
margin-bottom: 40px;
}
.settings-section-title {
font-size: 1rem;
font-weight: 600;
margin: 0 0 6px;
}
.settings-section-desc {
font-size: 0.85rem;
color: var(--text-secondary);
margin: 0 0 20px;
}
.roots-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.roots-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 10px;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
gap: 8px;
}
.roots-item--ready {
border-left: 3px solid #22c55e;
}
.roots-item--scanning {
border-left: 3px solid #f59e0b;
}
.roots-item--loading {
border-left: 3px solid #3b82f6;
}
.roots-item--error {
border-left: 3px solid #ef4444;
}
.roots-item-info {
display: flex;
flex-direction: column;
min-width: 0;
}
.roots-item-name {
font-size: 0.85rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.roots-item-path {
font-size: 0.75rem;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.roots-item-meta {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.roots-item-status {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
}
.roots-item-remove {
background: none;
border: none;
color: var(--text-secondary);
font-size: 1rem;
cursor: pointer;
padding: 0 4px;
line-height: 1;
}
.roots-item-remove:hover {
color: #ef4444;
}
.roots-actions {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.roots-add-btn {
width: 100%;
padding: 8px;
background: rgba(255, 255, 255, 0.08);
border: 1px dashed rgba(255, 255, 255, 0.2);
border-radius: 8px;
color: var(--text-primary);
font-size: 0.85rem;
cursor: pointer;
transition: background 0.2s;
}
.roots-add-btn:hover {
background: rgba(255, 255, 255, 0.15);
}
.format-row {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 0;
}
.format-grid {
display: grid;
grid-template-columns: 9.5em 9.5em;
gap: 0.75em 1.25em;
justify-content: start;
}
.format-row-stack {
flex-direction: column;
gap: 8px;
}
.format-radio-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
color: var(--text-primary);
font-size: 0.85rem;
}
.format-radio {
width: 15px;
height: 15px;
cursor: pointer;
accent-color: var(--accent, #3b82f6);
}
.player-layout {
display: grid;
grid-template-columns: 9.5em 1fr;
gap: 1.25em;
align-items: start;
}
.player-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.player-radio-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
color: var(--text-primary);
font-size: 0.85rem;
}
.player-radio {
width: 15px;
height: 15px;
cursor: pointer;
accent-color: var(--accent, #3b82f6);
}
.player-options {
display: flex;
flex-direction: column;
gap: 14px;
}
.player-option-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.player-option-label {
font-size: 0.85rem;
font-weight: 500;
color: var(--text-primary);
}
.player-option-input {
width: 100%;
max-width: 400px;
padding: 8px 10px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 6px;
color: var(--text-primary);
font-size: 0.85rem;
outline: none;
}
.player-option-input:focus {
border-color: var(--accent, #3b82f6);
}
.player-option-input--short {
width: 120px;
max-width: none;
}
.player-option-hint {
margin: 2px 0 0;
font-size: 0.75rem;
color: var(--text-secondary);
}
.player-family-note {
margin: 4px 0 0;
font-size: 0.8rem;
color: #22c55e;
}
.diag-label {
font-size: 0.75rem;
color: var(--text-secondary);
}
.diag-log-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.diag-log {
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 0.75rem;
white-space: pre-wrap;
word-break: break-all;
width: 100%;
max-height: 320px;
overflow-y: auto;
margin: 0;
padding: 10px;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
color: var(--text-secondary);
}
</style>