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

224 lines
6.6 KiB
Vue

<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>
<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 v-if="isDesktopApp" class="header-settings">
<button
class="header-settings-btn"
title="Change media folder"
@click="changeFolder"
>
<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">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
</button>
</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';
import { pickFolderAndRestart } from '../api';
const props = defineProps<{
currentView: 'movies' | 'series';
searchQuery: string;
mpcBeConnected: boolean;
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);
// True only when running inside the packaged pywebview desktop app.
// pywebview injects window.pywebview asynchronously, so we listen for the
// 'pywebviewready' event rather than checking at component creation time.
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));
async function changeFolder() {
await pickFolderAndRestart();
}
// 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() {
// Intentionally no-op: focusing search should not navigate away from detail.
}
// Sync local search to parent
watch(localSearch, (val, prevVal) => {
// When user starts typing from detail page, exit detail and show search results.
if (isDetailPage.value && !prevVal && !!val.trim()) {
goToCategory();
}
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 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);
});
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown);
});
</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);
}
</style>