Apply linters and formatters
- frontend: oxfmt across src, fix no-useless-escape in search-worker - ruff check --fix: replace numeric rule codes with text names in pyproject.toml lint.ignore - ruff format: scanning.py, guibuild.py, release.py
This commit is contained in:
+45
-23
@@ -11,12 +11,19 @@
|
||||
<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
|
||||
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>
|
||||
<span v-if="root.progressLabel" class="activity-progress-label">{{
|
||||
root.progressLabel
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="activity-bar" :class="{ 'activity-bar-indeterminate': !root.isDeterminate }">
|
||||
<div
|
||||
@@ -215,7 +222,10 @@ import {
|
||||
type EpisodeWatchEntry,
|
||||
} from "./api"
|
||||
import { useSettings } from "./composables/useSettings"
|
||||
import { useKeyboardNavigation, setActiveNavigationScope } from "./composables/useKeyboardNavigation"
|
||||
import {
|
||||
useKeyboardNavigation,
|
||||
setActiveNavigationScope,
|
||||
} from "./composables/useKeyboardNavigation"
|
||||
import type { SyncedRowScrollSnapshot } from "./composables/useKeyboardNavigation"
|
||||
import { useMediaWebSocket } from "./composables/useMediaWebSocket"
|
||||
import Header from "./components/Header.vue"
|
||||
@@ -380,7 +390,8 @@ function describeRootProgress(
|
||||
}
|
||||
} else if (showreelCount > 0) {
|
||||
phaseLabel = "Generating previews"
|
||||
phaseDetail = showreelCount === 1 ? "Building 1 preview reel" : `Building ${showreelCount} preview reels`
|
||||
phaseDetail =
|
||||
showreelCount === 1 ? "Building 1 preview reel" : `Building ${showreelCount} preview reels`
|
||||
} else if (otherRunningCount > 0) {
|
||||
phaseLabel = "Finalizing updates"
|
||||
phaseDetail = "Applying library changes"
|
||||
@@ -472,7 +483,11 @@ watch(
|
||||
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") {
|
||||
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) {
|
||||
@@ -480,7 +495,9 @@ watch(
|
||||
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`)
|
||||
showLibraryUpdateToast(
|
||||
`Library updated in ${rootName}: ${movies} movies, ${series} series`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -578,9 +595,7 @@ function getResumePoint(mediaId: string | null): SeriesResumePoint | null {
|
||||
}
|
||||
}
|
||||
|
||||
function getResumeEpisodes(
|
||||
mediaId: string | null,
|
||||
): Record<string, EpisodeWatchEntry> | null {
|
||||
function getResumeEpisodes(mediaId: string | null): Record<string, EpisodeWatchEntry> | null {
|
||||
if (!mediaId) return null
|
||||
return resumePositions.value[mediaId]?.episodes ?? null
|
||||
}
|
||||
@@ -675,7 +690,9 @@ function showAdjacentDetail(offset: -1 | 1): boolean {
|
||||
const sequence = getDetailAdjacentSequence(current)
|
||||
if (sequence.length < 2) return false
|
||||
|
||||
const currentIndex = sequence.findIndex((item) => item.id === current.id && item.type === current.type)
|
||||
const currentIndex = sequence.findIndex(
|
||||
(item) => item.id === current.id && item.type === current.type,
|
||||
)
|
||||
if (currentIndex < 0) return false
|
||||
|
||||
const nextIndex = currentIndex + offset
|
||||
@@ -771,7 +788,10 @@ function restoreBrowseFocus(path: string) {
|
||||
}, 100)
|
||||
}
|
||||
|
||||
function getSearchExitTargetFromFocusedCard(): { path: "/movies" | "/series"; itemId: string } | null {
|
||||
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
|
||||
@@ -832,9 +852,7 @@ function clearSearch(options: { preferBack?: boolean; targetPath?: string } = {}
|
||||
? normalizeHistoryPath(window.history.state.back)
|
||||
: ""
|
||||
const canRestoreWithBack =
|
||||
options.preferBack !== false &&
|
||||
searchReturnPath.value === targetPath &&
|
||||
backPath === targetPath
|
||||
options.preferBack !== false && searchReturnPath.value === targetPath && backPath === targetPath
|
||||
|
||||
searchReturnPath.value = null
|
||||
|
||||
@@ -1105,14 +1123,18 @@ function showDetail(item: MediaItem) {
|
||||
handlePlay(playableFile)
|
||||
} else {
|
||||
const searchPath = searchQuery.value ? getSearchPath(searchQuery.value) : null
|
||||
router.push({ path: `/series/${epData.series.id}`, state: searchPath ? { searchPath } : undefined })
|
||||
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 })
|
||||
const searchPath = searchQuery.value ? getSearchPath(searchQuery.value) : detailSearchPath
|
||||
router.push({
|
||||
path: `/${item.type}/${item.id}`,
|
||||
state: searchPath ? { searchPath } : undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1133,9 +1155,7 @@ function handleActorSearch(actorName: string) {
|
||||
|
||||
function handleSelectMovieFromDetail(movieId: string) {
|
||||
const detailSearchPath = getDetailSearchPath()
|
||||
const searchPath = searchQuery.value
|
||||
? getSearchPath(searchQuery.value)
|
||||
: detailSearchPath
|
||||
const searchPath = searchQuery.value ? getSearchPath(searchQuery.value) : detailSearchPath
|
||||
router.push({ path: `/movies/${movieId}`, state: searchPath ? { searchPath } : undefined })
|
||||
}
|
||||
|
||||
@@ -1174,7 +1194,9 @@ function focusDetailEntryTarget(item: MediaItem): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
const firstFocusable = detailPanel.querySelector('[data-nav-focusable="true"]') as HTMLElement | null
|
||||
const firstFocusable = detailPanel.querySelector(
|
||||
'[data-nav-focusable="true"]',
|
||||
) as HTMLElement | null
|
||||
if (firstFocusable) {
|
||||
focusElement(firstFocusable)
|
||||
return true
|
||||
|
||||
@@ -594,13 +594,7 @@ function activateItem(item: MediaItem) {
|
||||
|
||||
function handleItemClick(event: MouseEvent, item: MediaItem) {
|
||||
// Let modified clicks navigate natively
|
||||
if (
|
||||
event.button !== 0 ||
|
||||
event.ctrlKey ||
|
||||
event.metaKey ||
|
||||
event.shiftKey ||
|
||||
event.altKey
|
||||
) {
|
||||
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
|
||||
@@ -65,11 +65,7 @@ function getPlayLabel(filePath: string | null | undefined): string {
|
||||
|
||||
function getFocusableElements(): HTMLElement[] {
|
||||
if (!menuRef.value) return []
|
||||
return Array.from(
|
||||
menuRef.value.querySelectorAll<HTMLElement>(
|
||||
'.ctx-btn:not(:disabled)'
|
||||
)
|
||||
)
|
||||
return Array.from(menuRef.value.querySelectorAll<HTMLElement>(".ctx-btn:not(:disabled)"))
|
||||
}
|
||||
|
||||
function focusNext(delta: number) {
|
||||
@@ -130,9 +126,7 @@ watch(
|
||||
await nextTick()
|
||||
clampToViewport()
|
||||
// Focus first action button for keyboard navigation
|
||||
const firstBtn = menuRef.value?.querySelector(
|
||||
".ctx-btn:not(:disabled)",
|
||||
) as HTMLElement | null
|
||||
const firstBtn = menuRef.value?.querySelector(".ctx-btn:not(:disabled)") as HTMLElement | null
|
||||
firstBtn?.focus()
|
||||
},
|
||||
{ immediate: true },
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="hex-keyboard-fade">
|
||||
<div v-if="visible" ref="keyboardRef" class="hex-keyboard" @click.stop @keydown="handleKeyDown">
|
||||
<div
|
||||
v-if="visible"
|
||||
ref="keyboardRef"
|
||||
class="hex-keyboard"
|
||||
@click.stop
|
||||
@keydown="handleKeyDown"
|
||||
>
|
||||
<div ref="gridRef" class="hex-keyboard-grid">
|
||||
<div
|
||||
v-for="(row, rowIndex) in rows"
|
||||
@@ -26,7 +32,9 @@
|
||||
tabindex="-1"
|
||||
@click="handleKeyClick(key)"
|
||||
>
|
||||
<span class="hex-key-label" :class="{ 'hex-key-label-large': key.id === 'sp' }">{{ key.label }}</span>
|
||||
<span class="hex-key-label" :class="{ 'hex-key-label-large': key.id === 'sp' }">{{
|
||||
key.label
|
||||
}}</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Green focus outline rendered separately on top -->
|
||||
@@ -174,11 +182,16 @@ function getCoord(index: number): { row: number; col: number } {
|
||||
|
||||
function getRowRange(row: number): { start: number; count: number } {
|
||||
switch (row) {
|
||||
case 0: return { start: ROW_0_START, count: ROW_0_COUNT }
|
||||
case 1: return { start: ROW_1_START, count: ROW_1_COUNT }
|
||||
case 2: return { start: ROW_2_START, count: ROW_2_COUNT }
|
||||
case 3: return { start: ROW_3_START, count: ROW_3_COUNT }
|
||||
default: return { start: 0, count: 0 }
|
||||
case 0:
|
||||
return { start: ROW_0_START, count: ROW_0_COUNT }
|
||||
case 1:
|
||||
return { start: ROW_1_START, count: ROW_1_COUNT }
|
||||
case 2:
|
||||
return { start: ROW_2_START, count: ROW_2_COUNT }
|
||||
case 3:
|
||||
return { start: ROW_3_START, count: ROW_3_COUNT }
|
||||
default:
|
||||
return { start: 0, count: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,10 +260,7 @@ function close() {
|
||||
emit("close")
|
||||
}
|
||||
|
||||
function findNext(
|
||||
currentIdx: number,
|
||||
direction: "up" | "down" | "left" | "right",
|
||||
): number | null {
|
||||
function findNext(currentIdx: number, direction: "up" | "down" | "left" | "right"): number | null {
|
||||
const current = getCoord(currentIdx)
|
||||
|
||||
if (direction === "left") {
|
||||
@@ -510,10 +520,18 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
/* Row horizontal offsets for honeycomb staggering */
|
||||
.hex-keyboard-row-0 { margin-left: 0; }
|
||||
.hex-keyboard-row-1 { margin-left: calc(var(--key-w) * 0.5); }
|
||||
.hex-keyboard-row-2 { margin-left: calc(var(--key-w) * 1.0); }
|
||||
.hex-keyboard-row-3 { margin-left: calc(var(--key-w) * 1.5); }
|
||||
.hex-keyboard-row-0 {
|
||||
margin-left: 0;
|
||||
}
|
||||
.hex-keyboard-row-1 {
|
||||
margin-left: calc(var(--key-w) * 0.5);
|
||||
}
|
||||
.hex-keyboard-row-2 {
|
||||
margin-left: calc(var(--key-w) * 1);
|
||||
}
|
||||
.hex-keyboard-row-3 {
|
||||
margin-left: calc(var(--key-w) * 1.5);
|
||||
}
|
||||
|
||||
.hex-key {
|
||||
position: relative;
|
||||
@@ -529,7 +547,9 @@ onUnmounted(() => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%);
|
||||
transition: transform 0.15s ease, color 0.3s ease;
|
||||
transition:
|
||||
transform 0.15s ease,
|
||||
color 0.3s ease;
|
||||
outline: none;
|
||||
transform: scale(0.97);
|
||||
}
|
||||
@@ -538,7 +558,8 @@ onUnmounted(() => {
|
||||
.hex-key-row-0 {
|
||||
background: linear-gradient(180deg, #626b7bd0 0%, #3a3f4ad0 100%);
|
||||
}
|
||||
.hex-key-row-1, .hex-key-row-3 {
|
||||
.hex-key-row-1,
|
||||
.hex-key-row-3 {
|
||||
background: linear-gradient(180deg, #2a3343d0 0%, #2c3242d0 100%);
|
||||
}
|
||||
.hex-key-row-2 {
|
||||
@@ -553,8 +574,12 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
@keyframes hex-label-fade {
|
||||
0% { color: #22c55e; }
|
||||
100% { color: #ffffff; }
|
||||
0% {
|
||||
color: #22c55e;
|
||||
}
|
||||
100% {
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
/* Special key backgrounds override row gradients */
|
||||
@@ -600,8 +625,13 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
@keyframes hex-outline-blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
/* Transition */
|
||||
|
||||
@@ -80,7 +80,9 @@
|
||||
</div>
|
||||
<div v-else-if="subtitle" class="media-card-detail">{{ subtitle }}</div>
|
||||
<div v-if="directorAndCast" class="media-card-detail person-list">
|
||||
<span v-if="director" class="director-name person-token">{{ formatPersonLabel(director) }}</span>
|
||||
<span v-if="director" class="director-name person-token">{{
|
||||
formatPersonLabel(director)
|
||||
}}</span>
|
||||
<span
|
||||
v-for="(castName, castIndex) in formattedCastNames"
|
||||
:key="`${castName}-${castIndex}`"
|
||||
@@ -112,13 +114,7 @@ const emit = defineEmits<{
|
||||
|
||||
function handleClick(event: MouseEvent) {
|
||||
// Let modified clicks (middle-click, ctrl+click, etc.) navigate natively
|
||||
if (
|
||||
event.button !== 0 ||
|
||||
event.ctrlKey ||
|
||||
event.metaKey ||
|
||||
event.shiftKey ||
|
||||
event.altKey
|
||||
) {
|
||||
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) {
|
||||
return
|
||||
}
|
||||
// Prevent default navigation for plain left-clicks and synthetic clicks
|
||||
|
||||
@@ -186,7 +186,11 @@
|
||||
v-if="item.type === 'movies' && collectionMovies.length > 1"
|
||||
class="similar-movies-section"
|
||||
>
|
||||
<div class="similar-movies-grid" data-sync-scroll-row="true" data-sync-scroll-group="similar">
|
||||
<div
|
||||
class="similar-movies-grid"
|
||||
data-sync-scroll-row="true"
|
||||
data-sync-scroll-group="similar"
|
||||
>
|
||||
<a
|
||||
v-for="(movie, collectionIndex) in collectionMovies"
|
||||
:key="movie.localId"
|
||||
@@ -235,7 +239,15 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onMounted, onUnmounted, nextTick } from "vue"
|
||||
import type { CastMember, MediaItem, Movie, MovieUi, Series, SeriesResumePoint, Torrent } from "../types"
|
||||
import type {
|
||||
CastMember,
|
||||
MediaItem,
|
||||
Movie,
|
||||
MovieUi,
|
||||
Series,
|
||||
SeriesResumePoint,
|
||||
Torrent,
|
||||
} from "../types"
|
||||
import type { EpisodeWatchEntry } from "../api"
|
||||
import {
|
||||
getCoverUrl,
|
||||
@@ -316,7 +328,9 @@ function getReleaseAtRow(row: number): HTMLElement | null {
|
||||
}
|
||||
|
||||
function getLastReleaseRowBefore(castRow: number): number | null {
|
||||
const releases = Array.from(document.querySelectorAll<HTMLElement>('[data-nav-release-item="true"]'))
|
||||
const releases = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[data-nav-release-item="true"]'),
|
||||
)
|
||||
let best: number | null = null
|
||||
for (const release of releases) {
|
||||
const row = parseInt(release.getAttribute("data-nav-row") || "", 10)
|
||||
@@ -343,11 +357,7 @@ function registerMovieOutOfBoundsShortcut() {
|
||||
return null
|
||||
}
|
||||
|
||||
if (
|
||||
direction === "left" &&
|
||||
current.hasAttribute("data-nav-cast-item") &&
|
||||
currentCol === 0
|
||||
) {
|
||||
if (direction === "left" && current.hasAttribute("data-nav-cast-item") && currentCol === 0) {
|
||||
const targetRow = lastReleaseShortcutRow ?? getLastReleaseRowBefore(currentRow)
|
||||
if (targetRow === null) return null
|
||||
return getReleaseAtRow(targetRow)
|
||||
@@ -756,23 +766,8 @@ const castNavRow = computed(() => {
|
||||
return hasDesktopSimilarShortcut ? 4 + movieVersions.value.length : 2 + movieVersions.value.length
|
||||
})
|
||||
|
||||
const collectionMovies = computed((): Array<{
|
||||
title: string
|
||||
localId: string
|
||||
coverPath: string | null
|
||||
rootId: string | null
|
||||
year: string | null
|
||||
hyphenLang: string | null
|
||||
isCurrent: boolean
|
||||
}> => {
|
||||
if (props.item.type !== "movies") return []
|
||||
|
||||
const movie = props.item.data as Movie
|
||||
const collectionName = movie.info?.collection?.trim()
|
||||
if (!collectionName) return []
|
||||
const normalizedCollectionName = collectionName.toLowerCase()
|
||||
|
||||
const matches: Array<{
|
||||
const collectionMovies = computed(
|
||||
(): Array<{
|
||||
title: string
|
||||
localId: string
|
||||
coverPath: string | null
|
||||
@@ -780,60 +775,77 @@ const collectionMovies = computed((): Array<{
|
||||
year: string | null
|
||||
hyphenLang: string | null
|
||||
isCurrent: boolean
|
||||
}> = []
|
||||
}> => {
|
||||
if (props.item.type !== "movies") return []
|
||||
|
||||
let hasCurrentInMatches = false
|
||||
const movie = props.item.data as Movie
|
||||
const collectionName = movie.info?.collection?.trim()
|
||||
if (!collectionName) return []
|
||||
const normalizedCollectionName = collectionName.toLowerCase()
|
||||
|
||||
for (const libraryMovie of props.allMovies || []) {
|
||||
const otherCollectionName = libraryMovie.info?.collection?.trim().toLowerCase()
|
||||
if (otherCollectionName !== normalizedCollectionName) continue
|
||||
const matches: Array<{
|
||||
title: string
|
||||
localId: string
|
||||
coverPath: string | null
|
||||
rootId: string | null
|
||||
year: string | null
|
||||
hyphenLang: string | null
|
||||
isCurrent: boolean
|
||||
}> = []
|
||||
|
||||
const title = libraryMovie.title || libraryMovie.info?.title
|
||||
if (!title) continue
|
||||
let hasCurrentInMatches = false
|
||||
|
||||
const isCurrent = libraryMovie.id === props.item.id
|
||||
if (isCurrent) hasCurrentInMatches = true
|
||||
for (const libraryMovie of props.allMovies || []) {
|
||||
const otherCollectionName = libraryMovie.info?.collection?.trim().toLowerCase()
|
||||
if (otherCollectionName !== normalizedCollectionName) continue
|
||||
|
||||
matches.push({
|
||||
title,
|
||||
localId: libraryMovie.id,
|
||||
coverPath: libraryMovie.cover_path || null,
|
||||
rootId: libraryMovie.root_id || null,
|
||||
year: libraryMovie.year
|
||||
? String(libraryMovie.year)
|
||||
: libraryMovie.info?.release_date?.slice(0, 4) || null,
|
||||
hyphenLang: normalizeHyphenationLang(libraryMovie.info?.original_language),
|
||||
isCurrent,
|
||||
})
|
||||
}
|
||||
const title = libraryMovie.title || libraryMovie.info?.title
|
||||
if (!title) continue
|
||||
|
||||
if (!hasCurrentInMatches) {
|
||||
matches.push({
|
||||
title: props.item.title || (props.item.data as Movie).info?.title || "Current movie",
|
||||
localId: props.item.id,
|
||||
coverPath: props.item.cover_path || null,
|
||||
rootId: props.item.root_id || null,
|
||||
year: props.item.year
|
||||
? String(props.item.year)
|
||||
: (props.item.data as Movie).info?.release_date?.slice(0, 4) || null,
|
||||
hyphenLang: normalizeHyphenationLang((props.item.data as Movie).info?.original_language),
|
||||
isCurrent: true,
|
||||
})
|
||||
}
|
||||
const isCurrent = libraryMovie.id === props.item.id
|
||||
if (isCurrent) hasCurrentInMatches = true
|
||||
|
||||
return matches
|
||||
.sort((a, b) => {
|
||||
const yearA = parseInt(a.year || "", 10)
|
||||
const yearB = parseInt(b.year || "", 10)
|
||||
const hasYearA = Number.isFinite(yearA)
|
||||
const hasYearB = Number.isFinite(yearB)
|
||||
matches.push({
|
||||
title,
|
||||
localId: libraryMovie.id,
|
||||
coverPath: libraryMovie.cover_path || null,
|
||||
rootId: libraryMovie.root_id || null,
|
||||
year: libraryMovie.year
|
||||
? String(libraryMovie.year)
|
||||
: libraryMovie.info?.release_date?.slice(0, 4) || null,
|
||||
hyphenLang: normalizeHyphenationLang(libraryMovie.info?.original_language),
|
||||
isCurrent,
|
||||
})
|
||||
}
|
||||
|
||||
if (hasYearA && hasYearB && yearA !== yearB) return yearA - yearB
|
||||
if (hasYearA !== hasYearB) return hasYearA ? -1 : 1
|
||||
return a.title.localeCompare(b.title)
|
||||
})
|
||||
.slice(0, 24)
|
||||
})
|
||||
if (!hasCurrentInMatches) {
|
||||
matches.push({
|
||||
title: props.item.title || (props.item.data as Movie).info?.title || "Current movie",
|
||||
localId: props.item.id,
|
||||
coverPath: props.item.cover_path || null,
|
||||
rootId: props.item.root_id || null,
|
||||
year: props.item.year
|
||||
? String(props.item.year)
|
||||
: (props.item.data as Movie).info?.release_date?.slice(0, 4) || null,
|
||||
hyphenLang: normalizeHyphenationLang((props.item.data as Movie).info?.original_language),
|
||||
isCurrent: true,
|
||||
})
|
||||
}
|
||||
|
||||
return matches
|
||||
.sort((a, b) => {
|
||||
const yearA = parseInt(a.year || "", 10)
|
||||
const yearB = parseInt(b.year || "", 10)
|
||||
const hasYearA = Number.isFinite(yearA)
|
||||
const hasYearB = Number.isFinite(yearB)
|
||||
|
||||
if (hasYearA && hasYearB && yearA !== yearB) return yearA - yearB
|
||||
if (hasYearA !== hasYearB) return hasYearA ? -1 : 1
|
||||
return a.title.localeCompare(b.title)
|
||||
})
|
||||
.slice(0, 24)
|
||||
},
|
||||
)
|
||||
|
||||
function normalizeHyphenationLang(language: string | null | undefined): string | null {
|
||||
if (!language) return null
|
||||
|
||||
@@ -84,7 +84,7 @@ const disabled = computed(() => !props.filePath)
|
||||
function getFocusableElements(): HTMLElement[] {
|
||||
if (!menuRef.value) return []
|
||||
return Array.from(
|
||||
menuRef.value.querySelectorAll<HTMLElement>(".version-action-item:not(:disabled)")
|
||||
menuRef.value.querySelectorAll<HTMLElement>(".version-action-item:not(:disabled)"),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -336,7 +336,12 @@ const hdr10PlusPattern = /hdr10\+|hdr10plus/i
|
||||
|
||||
const hasHdr10Plus = computed(() => {
|
||||
if (props.torrent.hdr10plus) return true
|
||||
const text = [props.torrent.title, props.torrent.quality, props.torrent.codec, props.torrent.audio]
|
||||
const text = [
|
||||
props.torrent.title,
|
||||
props.torrent.quality,
|
||||
props.torrent.codec,
|
||||
props.torrent.audio,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
return hdr10PlusPattern.test(text)
|
||||
|
||||
@@ -96,7 +96,9 @@
|
||||
formatDate(selectedSeason.air_date)
|
||||
}}</span>
|
||||
<span
|
||||
>{{ selectedSeason.episode_count ?? selectedSeason.episodes.length }}
|
||||
>{{
|
||||
selectedSeason.episode_count ?? selectedSeason.episodes.length
|
||||
}}
|
||||
Episodes</span
|
||||
>
|
||||
</div>
|
||||
@@ -161,9 +163,7 @@
|
||||
<span
|
||||
v-if="episodeWatchIndicator(episode)"
|
||||
class="ep-watch"
|
||||
:title="
|
||||
episodeWatchIndicator(episode) === '●' ? 'Watched' : 'Partially watched'
|
||||
"
|
||||
:title="episodeWatchIndicator(episode) === '●' ? 'Watched' : 'Partially watched'"
|
||||
>{{ episodeWatchIndicator(episode) }}</span
|
||||
>
|
||||
<div class="tile-play">▶</div>
|
||||
@@ -367,9 +367,7 @@ const cursorGlobalIndex = computed(() =>
|
||||
const resumePointGlobalIndex = computed(() => {
|
||||
const point = props.resumePoint
|
||||
if (!point) return null
|
||||
const seasonIndex = props.series.seasons.findIndex(
|
||||
(s) => s.season_number === point.seasonNumber,
|
||||
)
|
||||
const seasonIndex = props.series.seasons.findIndex((s) => s.season_number === point.seasonNumber)
|
||||
if (seasonIndex < 0) return null
|
||||
const episodeIndex = props.series.seasons[seasonIndex]?.episodes.findIndex(
|
||||
(e) => e.episode_number === point.episodeNumber,
|
||||
@@ -650,10 +648,7 @@ watch(
|
||||
episodeFocusTarget,
|
||||
(ep) => {
|
||||
if (!ep) return
|
||||
if (
|
||||
!props.focusEpisode &&
|
||||
(seasonUserInteracted.value || episodeCursorIndex.value !== null)
|
||||
) {
|
||||
if (!props.focusEpisode && (seasonUserInteracted.value || episodeCursorIndex.value !== null)) {
|
||||
return
|
||||
}
|
||||
const seasonIndex =
|
||||
@@ -1956,7 +1951,12 @@ html:not(.mouse-active) .episode-tile.nav-focused .tile-focus-outline rect {
|
||||
rgba(255, 255, 255, 0.025) 30%,
|
||||
rgba(255, 255, 255, 0) 55%
|
||||
),
|
||||
linear-gradient(to bottom, rgba(20, 20, 28, 0.5) 0%, rgba(0, 0, 0, 0) 40%, rgba(0, 0, 0, 0.45) 100%);
|
||||
linear-gradient(
|
||||
to bottom,
|
||||
rgba(20, 20, 28, 0.5) 0%,
|
||||
rgba(0, 0, 0, 0) 40%,
|
||||
rgba(0, 0, 0, 0.45) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.episode-tile--ahead::after {
|
||||
|
||||
@@ -110,7 +110,9 @@ function getDigitalRepeatIntervalMs(holdMs: number): number {
|
||||
|
||||
function getAnalogRepeatIntervalMs(intensity: number): number {
|
||||
const normalized = Math.min(Math.max(intensity, 0), 1)
|
||||
return Math.round(ANALOG_REPEAT_MAX_MS - (ANALOG_REPEAT_MAX_MS - ANALOG_REPEAT_MIN_MS) * normalized)
|
||||
return Math.round(
|
||||
ANALOG_REPEAT_MAX_MS - (ANALOG_REPEAT_MAX_MS - ANALOG_REPEAT_MIN_MS) * normalized,
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeAxisIntensity(rawValue: number): number {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { reportUserActivity } from "../api"
|
||||
|
||||
type InputModality = "mouse" | "keyboard" | "gamepad"
|
||||
|
||||
|
||||
const MOUSE_IDLE_MS = 1400
|
||||
const MOUSE_INTENT_DISTANCE_PX = 28
|
||||
const MOUSE_INTENT_WINDOW_MS = 700
|
||||
|
||||
@@ -103,7 +103,10 @@ function measureGlobalMetrics(group: string): boolean {
|
||||
const rowStyle = window.getComputedStyle(row)
|
||||
const paddingLeft = parseFloat(rowStyle.paddingLeft || "0")
|
||||
const viewportWidth = row.clientWidth
|
||||
const deadzoneInset = Math.max(paddingLeft, (viewportWidth - cardWidth) * SYNC_SCROLL_DEADZONE_RATIO)
|
||||
const deadzoneInset = Math.max(
|
||||
paddingLeft,
|
||||
(viewportWidth - cardWidth) * SYNC_SCROLL_DEADZONE_RATIO,
|
||||
)
|
||||
const leftDeadzoneRaw = rowStyle.getPropertyValue(SYNC_SCROLL_LEFT_DEADZONE_VAR).trim()
|
||||
const leftDeadzone = Number.isFinite(parseFloat(leftDeadzoneRaw))
|
||||
? Math.max(0, parseFloat(leftDeadzoneRaw))
|
||||
@@ -175,10 +178,7 @@ function getRowMaxScroll(row: HTMLElement): number {
|
||||
if (n === 0) return 0
|
||||
const lastCol = n - 1
|
||||
const lastItemLeft = m.paddingLeft + lastCol * m.stride
|
||||
const maxVisibleLeft = Math.max(
|
||||
m.paddingLeft,
|
||||
m.viewportWidth - m.cardWidth - m.rightDeadzone,
|
||||
)
|
||||
const maxVisibleLeft = Math.max(m.paddingLeft, m.viewportWidth - m.cardWidth - m.rightDeadzone)
|
||||
return Math.max(0, lastItemLeft - maxVisibleLeft)
|
||||
}
|
||||
|
||||
@@ -280,14 +280,8 @@ function updateSyncedRowTarget(anchorCol: number, anchorRow: HTMLElement | null
|
||||
if (!m) return
|
||||
|
||||
const itemLeft = m.paddingLeft + anchorCol * m.stride
|
||||
const leftVisibleLimit = Math.max(
|
||||
m.paddingLeft,
|
||||
m.leftDeadzone,
|
||||
)
|
||||
const rightVisibleLimit = Math.max(
|
||||
m.paddingLeft,
|
||||
m.viewportWidth - m.cardWidth - m.rightDeadzone,
|
||||
)
|
||||
const leftVisibleLimit = Math.max(m.paddingLeft, m.leftDeadzone)
|
||||
const rightVisibleLimit = Math.max(m.paddingLeft, m.viewportWidth - m.cardWidth - m.rightDeadzone)
|
||||
|
||||
// Keep focus inside the deadzone: no scroll while the focused item remains
|
||||
// between left and right limits.
|
||||
@@ -354,7 +348,9 @@ function getLocalSyncedRowCol(
|
||||
element: HTMLElement,
|
||||
requestedCol: number,
|
||||
): number {
|
||||
const cards = Array.from(anchorRow.querySelectorAll<HTMLElement>(`.media-card[${FOCUSABLE_ATTR}]`))
|
||||
const cards = Array.from(
|
||||
anchorRow.querySelectorAll<HTMLElement>(`.media-card[${FOCUSABLE_ATTR}]`),
|
||||
)
|
||||
if (cards.length === 0) return Math.max(0, requestedCol)
|
||||
|
||||
const cardCols = cards
|
||||
@@ -466,9 +462,7 @@ function ensureElementVisibleVertically(element: HTMLElement) {
|
||||
// Element finding / navigation (unchanged logic, uses getMetrics() now)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function resolveOutOfBoundsNavigation(
|
||||
context: OutOfBoundsNavigationContext,
|
||||
): HTMLElement | null {
|
||||
function resolveOutOfBoundsNavigation(context: OutOfBoundsNavigationContext): HTMLElement | null {
|
||||
const handlers = Array.from(outOfBoundsHandlers)
|
||||
for (let i = handlers.length - 1; i >= 0; i--) {
|
||||
const result = handlers[i]?.(context)
|
||||
@@ -623,10 +617,7 @@ function findElementClosestToLogicalViewportX(
|
||||
return nearest
|
||||
}
|
||||
|
||||
function findNextElement(
|
||||
current: HTMLElement,
|
||||
direction: NavDirection,
|
||||
): HTMLElement | null {
|
||||
function findNextElement(current: HTMLElement, direction: NavDirection): HTMLElement | null {
|
||||
const currentRow = parseInt(current.getAttribute(ROW_ATTR) || "0", 10)
|
||||
const currentCol = parseInt(current.getAttribute(COL_ATTR) || "0", 10)
|
||||
const byRow = getElementsByRow()
|
||||
@@ -682,9 +673,7 @@ function findNextElement(
|
||||
for (const el of targetRowElements) {
|
||||
const fromAboveCol = el.element.getAttribute(ENTRY_COL_FROM_ABOVE_ATTR)
|
||||
if (fromAboveCol === null) continue
|
||||
const fromAboveTarget = targetRowElements.find(
|
||||
(e) => e.col === parseInt(fromAboveCol, 10),
|
||||
)
|
||||
const fromAboveTarget = targetRowElements.find((e) => e.col === parseInt(fromAboveCol, 10))
|
||||
if (fromAboveTarget) {
|
||||
desiredCol.value = fromAboveTarget.col
|
||||
return fromAboveTarget.element
|
||||
|
||||
@@ -159,10 +159,7 @@ export function useMediaWebSocket() {
|
||||
return { ...normalizeSeries(series, rootId, people), id, root_id: rootId }
|
||||
}
|
||||
|
||||
function normalizeCastMember(
|
||||
member: unknown,
|
||||
people: Map<number, Person>,
|
||||
): CastMember {
|
||||
function normalizeCastMember(member: unknown, people: Map<number, Person>): CastMember {
|
||||
if (!Array.isArray(member)) {
|
||||
return {
|
||||
name: "",
|
||||
@@ -346,7 +343,10 @@ export function useMediaWebSocket() {
|
||||
}
|
||||
}
|
||||
|
||||
function mergeItemsByHash<T extends MovieUi | SeriesUi>(items: T[], mergeFn: (a: T, b: T) => T): T[] {
|
||||
function mergeItemsByHash<T extends MovieUi | SeriesUi>(
|
||||
items: T[],
|
||||
mergeFn: (a: T, b: T) => T,
|
||||
): T[] {
|
||||
const map = new Map<string, T[]>()
|
||||
for (const item of items) {
|
||||
const hash = getContentHash(item.id)
|
||||
@@ -436,7 +436,14 @@ export function useMediaWebSocket() {
|
||||
connected.value = wsRef.value?.readyState === WebSocket.OPEN
|
||||
}
|
||||
|
||||
function applyRootInit(rootId: string, rootData: { movies: Record<string, Movie>; series: Record<string, Series>; people?: Record<string, unknown> }) {
|
||||
function applyRootInit(
|
||||
rootId: string,
|
||||
rootData: {
|
||||
movies: Record<string, Movie>
|
||||
series: Record<string, Series>
|
||||
people?: Record<string, unknown>
|
||||
},
|
||||
) {
|
||||
const state = ensureRootState(rootId)
|
||||
|
||||
state.peopleMap.clear()
|
||||
@@ -502,7 +509,10 @@ export function useMediaWebSocket() {
|
||||
const parsed = Number(id)
|
||||
const normalized = normalizePerson(person)
|
||||
if (Number.isFinite(parsed)) {
|
||||
state.peopleMap.set(parsed, normalized || { name: "", profile_path: null, gender: null })
|
||||
state.peopleMap.set(
|
||||
parsed,
|
||||
normalized || { name: "", profile_path: null, gender: null },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,9 +17,9 @@ const STORAGE_KEY = "MediaHive"
|
||||
const RESOLUTION_PRIORITY: Record<string, number> = {
|
||||
"8K": 5,
|
||||
"4K": 4,
|
||||
"FHD": 3,
|
||||
"HD": 2,
|
||||
"SD": 1,
|
||||
FHD: 3,
|
||||
HD: 2,
|
||||
SD: 1,
|
||||
}
|
||||
|
||||
// Max resolution priority allowed for each preference level
|
||||
@@ -59,7 +59,13 @@ function loadSettings(): MediaHiveSettings {
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
return { preferredResolution: "rmax", preferredHdr: "none", playerId: "default", playerCustomCmd: null, playerMpcPort: null }
|
||||
return {
|
||||
preferredResolution: "rmax",
|
||||
preferredHdr: "none",
|
||||
playerId: "default",
|
||||
playerCustomCmd: null,
|
||||
playerMpcPort: null,
|
||||
}
|
||||
}
|
||||
|
||||
const settings = reactive<MediaHiveSettings>(loadSettings())
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
// Search Web Worker - runs search off the main thread
|
||||
// This file is loaded as a Web Worker, not imported as a module.
|
||||
|
||||
import type {
|
||||
MovieUi,
|
||||
SeriesUi,
|
||||
MatchedPerson,
|
||||
MatchedEpisode,
|
||||
SearchMatchInfo,
|
||||
} from "./types"
|
||||
import type { MovieUi, SeriesUi, MatchedPerson, MatchedEpisode, SearchMatchInfo } from "./types"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Message types
|
||||
@@ -65,7 +59,7 @@ let series: SeriesUi[] = []
|
||||
function normalizeSearchText(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\-]+/g, " ")
|
||||
.replace(/[^a-z0-9-]+/g, " ")
|
||||
.trim()
|
||||
.replace(/\s+/g, " ")
|
||||
}
|
||||
@@ -277,7 +271,9 @@ function getMatchedWordIndexes(queryWords: string[], value: string): number[] {
|
||||
}
|
||||
|
||||
function mergePersonMatch(target: PersonMatch[], candidate: PersonCandidate): void {
|
||||
const existing = target.find((person) => person.name.toLowerCase() === candidate.name.toLowerCase())
|
||||
const existing = target.find(
|
||||
(person) => person.name.toLowerCase() === candidate.name.toLowerCase(),
|
||||
)
|
||||
if (existing) {
|
||||
if (!existing.roles.includes(candidate.role)) existing.roles.push(candidate.role)
|
||||
if (candidate.highlightRoles) existing.highlightRoles = true
|
||||
@@ -292,9 +288,7 @@ function mergePersonMatch(target: PersonMatch[], candidate: PersonCandidate): vo
|
||||
}
|
||||
|
||||
function getBestContiguousWordRun(indexes: number[], availableIndexes: Set<number>): number[] {
|
||||
const sorted = indexes
|
||||
.filter((index) => availableIndexes.has(index))
|
||||
.sort((a, b) => a - b)
|
||||
const sorted = indexes.filter((index) => availableIndexes.has(index)).sort((a, b) => a - b)
|
||||
|
||||
if (sorted.length === 0) return []
|
||||
|
||||
|
||||
@@ -566,9 +566,7 @@ export function formatLanguageFlagTitle(
|
||||
): string {
|
||||
const names: string[] = []
|
||||
const variants: string[] = []
|
||||
const external = new Set(
|
||||
(externalCodes ?? []).map((c) => resolveLanguageIdentifier(c)),
|
||||
)
|
||||
const external = new Set((externalCodes ?? []).map((c) => resolveLanguageIdentifier(c)))
|
||||
let hasExternal = false
|
||||
for (const code of entry.sourceCodes) {
|
||||
const normalized = resolveLanguageIdentifier(code)
|
||||
@@ -578,9 +576,10 @@ export function formatLanguageFlagTitle(
|
||||
// Explicit region tags (en-us, es-419) become parenthesized variants;
|
||||
// plain codes contribute their host country.
|
||||
const suffix = normalized.split("-").pop() ?? ""
|
||||
const region = /^[a-z]{2}$|^\d{3}$/i.test(suffix) && normalized.includes("-")
|
||||
? suffix.toUpperCase()
|
||||
: mapLanguageToCountry(code)
|
||||
const region =
|
||||
/^[a-z]{2}$|^\d{3}$/i.test(suffix) && normalized.includes("-")
|
||||
? suffix.toUpperCase()
|
||||
: mapLanguageToCountry(code)
|
||||
const regionName = region ? toRegionName(region) : null
|
||||
const variant = external.has(normalized)
|
||||
? regionName
|
||||
|
||||
Reference in New Issue
Block a user