15 Commits
Author SHA1 Message Date
LeoVasanko 1223b8cee7 Rework settings/log into draggable dialogs; selectable, copyable log window
release / gui-build (linux, bash) (push) Successful in 59s
release / gui-build (windows, cmd) (push) Successful in 1m21s
release / gui-build (macos, bash) (push) Successful in 1m54s
release / publish-pypi (push) Successful in 12s
Settings and application log are now separate floating windows with
draggable title bars and top-right close buttons instead of one shared
two-column dialog whose log column stretched the layout. The log opens
from a small Log button on the settings version/update row (GUI mode
only), displays in its own wider window on the left with internal
scrolling, and its text is selectable with a Copy button for the full
log.
2026-09-24 06:30:42 +00:00
LeoVasanko 7e99ec2f16 Updated README and gamepad controls image. 2026-09-24 06:30:39 +00:00
LeoVasanko 38a10725e0 Fix a minor glitch with series episode highlight border.
release / gui-build (windows, cmd) (push) Successful in 1m19s
release / gui-build (linux, bash) (push) Successful in 1m44s
release / gui-build (macos, bash) (push) Successful in 1m54s
release / publish-pypi (push) Successful in 17s
2026-09-24 05:21:01 +00:00
LeoVasanko 8b0c7a7af1 Self-contained guibuild script deps; gate PyPI publish on all GUI builds 2026-09-24 05:07:34 +00:00
LeoVasanko d01bf1d88f Add GUI exit button and F11/Alt+Enter fullscreen toggle 2026-09-24 04:52:33 +00:00
LeoVasanko 9b12e53039 Rework settings dialog: floating window, integrated activity log and scan progress, auto-update controls 2026-09-24 04:27:37 +00:00
LeoVasanko 7e2fcc05f6 Use image for controller help. 2026-09-24 03:31:49 +00:00
LeoVasanko ecd5d3bc9e Update README 2026-09-24 00:59:16 +00:00
LeoVasanko 82d5eb28fb Add screenshot of the new series view. 2026-09-24 00:52:04 +00:00
LeoVasanko 1149e7cdfd Add pre-commit hooks and blame-ignore for the lint/format commit
release / gui-build (linux, bash) (push) Successful in 56s
release / gui-build (windows, cmd) (push) Successful in 1m21s
release / gui-build (macos, bash) (push) Successful in 1m50s
2026-09-24 00:37:25 +00:00
LeoVasanko 38f3ccebd3 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
2026-09-24 00:26:53 +00:00
LeoVasanko c41504e70c Fix stale preview reels when switching between series
Episode tiles were keyed by season/episode index only, so switching
series reused the DOM: text bindings updated but <video> elements were
kept, and browsers ignore patched <source> children without load() —
the previous series' reels kept playing.

- Key SeriesFullView by item id so a series switch fully remounts it.
- Include a seriesIdentity (id, else root_id+title) in episode tile
  keys so tiles/videos remount even on an in-place series swap.
- Reset episode cursor, audio owner and preview startup timers when
  the series identity changes, and re-gate video mounting.
2026-09-23 21:35:03 +00:00
LeoVasanko bb70279e61 Clean up README 2026-09-23 21:30:51 +00:00
LeoVasanko 931f00f647 Fix season browser focus: land on first episode on down, ignore parked-cursor hover
- Add data-nav-entry-col-from-above so moving down from the season
  selector always focuses the first episode instead of the tile
  visually closest to the selected season poster; moving back up
  still returns to the current season.
- Require recent real mouse movement before treating mouseover as
  mouse intent: scroll/re-render under a stationary cursor no longer
  activates mouse mode, so sideways season browsing no longer loses
  focus to the episode tile under the parked pointer.
2026-09-23 21:30:17 +00:00
LeoVasanko 9041061e86 Fix image link in README to work also on PyPI. 2026-09-23 20:43:25 +00:00
38 changed files with 1660 additions and 753 deletions
+3
View File
@@ -0,0 +1,3 @@
# Mass lint/format commits that add noise to git blame.
# Enable locally with: git config blame.ignoreRevsFile .git-blame-ignore-revs
38f3cce # Apply linters and formatters
+27 -10
View File
@@ -29,9 +29,11 @@ jobs:
git clone "${{ gitea.server_url }}/${{ gitea.repository }}.git" . git clone "${{ gitea.server_url }}/${{ gitea.repository }}.git" .
git checkout -f "${{ gitea.sha }}" git checkout -f "${{ gitea.sha }}"
# guibuild.py carries its own inline (PEP 723) deps — mediahive[gui]
# from the local checkout — so no project sync or --extra is needed.
- name: Build GUI app and dist packages - name: Build GUI app and dist packages
shell: ${{ matrix.shell }} shell: ${{ matrix.shell }}
run: uv run --extra gui scripts/guibuild.py run: uv run scripts/guibuild.py
# Every platform converges on the one release for the tag; release.py # Every platform converges on the one release for the tag; release.py
# reuses an existing release and skips already-uploaded assets. # reuses an existing release and skips already-uploaded assets.
@@ -43,18 +45,33 @@ jobs:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: uv run scripts/release.py run: uv run scripts/release.py
# Wheel/sdist are platform-independent; the linux job also pushes them
# to PyPI. Token is the PYPI_TOKEN repository secret.
- name: Publish to PyPI
if: matrix.os == 'linux'
shell: ${{ matrix.shell }}
env:
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }}
run: uv publish
- name: Attach platform artifact to the Gitea release - name: Attach platform artifact to the Gitea release
if: matrix.os != 'linux' if: matrix.os != 'linux'
shell: ${{ matrix.shell }} shell: ${{ matrix.shell }}
env: env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: uv run scripts/release.py --no-dist run: uv run scripts/release.py --no-dist
# Publish the Python package to PyPI only once every platform's GUI build
# has succeeded. Jobs don't share a workspace, so the platform-independent
# wheel/sdist are rebuilt here (same tag version) instead of being passed
# around as artifacts.
publish-pypi:
needs: gui-build
runs-on: linux
steps:
- name: Checkout
shell: bash
run: |
git clone "${{ gitea.server_url }}/${{ gitea.repository }}.git" .
git checkout -f "${{ gitea.sha }}"
- name: Build wheel and sdist
shell: bash
run: uv build
- name: Publish to PyPI
shell: bash
env:
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }}
run: uv publish
+2
View File
@@ -20,3 +20,5 @@ package-lock.json
.* .*
!.gitignore !.gitignore
!.gitea/ !.gitea/
!.git-blame-ignore-revs
!.pre-commit-config.yaml
+11 -21
View File
@@ -1,4 +1,4 @@
![MediaHive](docs/mediahive.avif) ![MediaHive](https://git.zi.fi/LeoVasanko/mediahive/media/branch/main/docs/mediahive.avif)
# MediaHive # MediaHive
@@ -10,14 +10,7 @@ Netflix style browsing of your local media archive. Supports keyboard, mouse and
- **macOS**: [Installer](https://git.zi.fi/LeoVasanko/mediahive/releases/download/latest/MediaHive-macos.pkg) - **macOS**: [Installer](https://git.zi.fi/LeoVasanko/mediahive/releases/download/latest/MediaHive-macos.pkg)
- **Linux**: [AppImage](https://git.zi.fi/LeoVasanko/mediahive/releases/download/latest/MediaHive-linux.AppImage) - **Linux**: [AppImage](https://git.zi.fi/LeoVasanko/mediahive/releases/download/latest/MediaHive-linux.AppImage)
### Linux You may also run or install with [UV](https://docs.astral.sh/uv/getting-started/installation/):
```
wget https://git.zi.fi/LeoVasanko/mediahive/releases/download/latest/MediaHive-linux.AppImage
chmod +x MediaHive-linux.AppImage && ./MediaHive-linux.AppImage
```
You may also run without installing via
``` ```
uvx --from mediahive[gui] mediahive uvx --from mediahive[gui] mediahive
@@ -39,11 +32,10 @@ Note that `.mediahive` folder is created in your media folder to hold all the me
MediaHive is designed to work with a mouse, keyboard, or gamepad. MediaHive is designed to work with a mouse, keyboard, or gamepad.
| Input | Controls | **Keyboard:** Arrow keys, Enter and Escape to navigate, `/` to search and the usual ones you already know.
| --- | --- |
| Mouse | Click posters, rows, search, play, and folder actions directly. | ![Controller bindings](https://git.zi.fi/LeoVasanko/mediahive/media/branch/main/docs/controls.avif)
| Keyboard | Arrow keys move focus, `Enter` activates the focused item, `Escape` goes back, and `/` or `Ctrl`/`Cmd`+`F` jumps to search. | *Navigational controls work across the application. Search bar offers OSD keyboard. In-player controls are currently available only on MPC-BE, with its WebUI enabled.*
| Gamepad | D-pad or left stick moves focus, `A` selects or plays, and `B` goes back. `RB`/`LB` browses adjacent items, and the Search bar has an OSD keyboard. Player controls during playback. |
## Recommended Players ## Recommended Players
@@ -53,12 +45,10 @@ MediaHive is designed to work with a mouse, keyboard, or gamepad.
MediaHive opens files with the OS default player, but one specific player may be configured via settings. You are of course free to use any player instead. MediaHive opens files with the OS default player, but one specific player may be configured via settings. You are of course free to use any player instead.
- `A` toggles play and pause.
- `B` closes the player.
- `Y` toggles mute.
- D-pad up and down change volume.
- D-pad left and right seek during playback, or step frames while paused.
## Background ## Background
This project started as a personal project that I have used for browsing my warez for some time now. It is still in early development, but I have just now made it public for a wider audience. This project started as a personal project that I have used for browsing my warez for some time now. After it grew in number of users, I've put serious development effort into it to provide a truly polished view, while responding to user needs.
Little details include flags for audio and subtitle languages (also srt) and a series view with per episode video previews while avoiding spoilers of the episodes you haven't gotten to yet:
![Series view](https://git.zi.fi/LeoVasanko/mediahive/media/branch/main/docs/seriesview.avif)
+3
View File
@@ -11,6 +11,9 @@ All media paths are scoped to a **root**, identified by a friendly `root_id`
| `GET` | `/api/health` | Lightweight health check. | | `GET` | `/api/health` | Lightweight health check. |
| `GET` | `/api/config` | Returns the current root configuration. | | `GET` | `/api/config` | Returns the current root configuration. |
| `PUT` | `/api/config/roots` | Atomically replace the full root set. Returns `{ "status": "ok", "accepted": [{path, root_id}], "failed": [...] }`. | | `PUT` | `/api/config/roots` | Atomically replace the full root set. Returns `{ "status": "ok", "accepted": [{path, root_id}], "failed": [...] }`. |
| `GET` | `/api/update` | Returns `{ "version", "auto_update", "pending_version" }` — installed version, auto-update preference, and any downloaded update staged for the next launch (Velopack GUI builds only; `null` elsewhere). |
| `PUT` | `/api/config/auto-update` | Enable/disable automatic update downloads. Body `{ "enabled": bool }`, persisted in config. |
| `POST` | `/api/update/restart` | Applies the staged update and restarts into it. `404` when no update is pending. |
| `POST` | `/api/play/{root_id}` | Opens a media file with a media player. Also starts an assumed-playback session (see notes). | | `POST` | `/api/play/{root_id}` | Opens a media file with a media player. Also starts an assumed-playback session (see notes). |
| `GET` | `/api/players` | Lists detected media players. Returns `{ "players": [{id, name, family, path}] }`, including synthetic `default` and `custom` entries. | | `GET` | `/api/players` | Lists detected media players. Returns `{ "players": [{id, name, family, path}] }`, including synthetic `default` and `custom` entries. |
| `POST` | `/api/activity` | Reports user input activity; finalizes any assumed-playback session. Returns `{ "status": "ok", "finalized": bool }`. | | `POST` | `/api/activity` | Reports user input activity; finalizes any assumed-playback session. Returns `{ "status": "ok", "finalized": bool }`. |
Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

+59 -166
View File
@@ -11,12 +11,19 @@
<span>Library activity</span> <span>Library activity</span>
<span v-if="!wsConnected" class="activity-connection">Reconnecting...</span> <span v-if="!wsConnected" class="activity-connection">Reconnecting...</span>
</div> </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 class="activity-root-title">{{ root.rootLabel }}</div>
<div v-if="root.scanTarget" class="activity-root-target">{{ root.scanTarget }}</div> <div v-if="root.scanTarget" class="activity-root-target">{{ root.scanTarget }}</div>
<div class="activity-phase-row"> <div class="activity-phase-row">
<span class="activity-phase">{{ root.phaseLabel }}</span> <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>
<div class="activity-bar" :class="{ 'activity-bar-indeterminate': !root.isDeterminate }"> <div class="activity-bar" :class="{ 'activity-bar-indeterminate': !root.isDeterminate }">
<div <div
@@ -38,6 +45,9 @@
:current-view="headerCurrentView" :current-view="headerCurrentView"
:search-query="searchQuery" :search-query="searchQuery"
:roots="headerRoots" :roots="headerRoots"
:scan-tasks="tasks"
:scan-connected="wsConnected"
:initial-scan-mode="isInitialScanMode"
:mpc-be-connected="mpcBeConnected" :mpc-be-connected="mpcBeConnected"
:nav-row="1" :nav-row="1"
:position="headerPosition" :position="headerPosition"
@@ -202,7 +212,6 @@ import type {
SeriesUi, SeriesUi,
MediaItem, MediaItem,
EpisodeWithSeries, EpisodeWithSeries,
TaskInfo,
SeriesResumePoint, SeriesResumePoint,
} from "./types" } from "./types"
import { import {
@@ -215,9 +224,14 @@ import {
type EpisodeWatchEntry, type EpisodeWatchEntry,
} from "./api" } from "./api"
import { useSettings } from "./composables/useSettings" import { useSettings } from "./composables/useSettings"
import { useKeyboardNavigation, setActiveNavigationScope } from "./composables/useKeyboardNavigation" import {
useKeyboardNavigation,
setActiveNavigationScope,
} from "./composables/useKeyboardNavigation"
import type { SyncedRowScrollSnapshot } from "./composables/useKeyboardNavigation" import type { SyncedRowScrollSnapshot } from "./composables/useKeyboardNavigation"
import { useMediaWebSocket } from "./composables/useMediaWebSocket" import { useMediaWebSocket } from "./composables/useMediaWebSocket"
import { computeProgressRoots, type RootTaskInfo } from "./composables/useScanProgress"
import { useSettingsOpen } from "./composables/useSettingsOpen"
import Header from "./components/Header.vue" import Header from "./components/Header.vue"
import CollageHero from "./components/CollageHero.vue" import CollageHero from "./components/CollageHero.vue"
import MediaRow from "./components/MediaRow.vue" import MediaRow from "./components/MediaRow.vue"
@@ -282,20 +296,6 @@ const {
roots: rootStatuses, roots: rootStatuses,
} = useMediaWebSocket() } = 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())) const activeTasks = computed<RootTaskInfo[]>(() => Array.from(tasks.value.values()))
function getRootName(rootId: string | null | undefined): string | null { function getRootName(rootId: string | null | undefined): string | null {
@@ -303,131 +303,15 @@ function getRootName(rootId: string | null | undefined): string | null {
return rootStatuses.value.get(rootId)?.root_id || null return rootStatuses.value.get(rootId)?.root_id || null
} }
function normalizePosixPath(value: string): string { const progressRoots = computed(() =>
return value.replace(/\\/g, "/") computeProgressRoots(
} activeTasks.value,
(rootId) => rootStatuses.value.get(rootId)?.path || null,
isInitialScanMode.value,
),
)
function extractScanPath(detail: string): string | null { const isSettingsView = useSettingsOpen()
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(() => { const hasLibraryItems = computed(() => {
if (!mediaIndex.value) return false if (!mediaIndex.value) return false
return mediaIndex.value.movies.length > 0 || mediaIndex.value.series.length > 0 return mediaIndex.value.movies.length > 0 || mediaIndex.value.series.length > 0
@@ -442,10 +326,8 @@ const headerRoots = computed(() =>
) )
const showProgressPanel = computed(() => { const showProgressPanel = computed(() => {
if (isInitialScanMode.value) { if (isSettingsView.value) return false
return !wsConnected.value || progressRoots.value.length > 0 if (!isInitialScanMode.value) return false
}
if (!isSettingsView.value) return false
return !wsConnected.value || progressRoots.value.length > 0 return !wsConnected.value || progressRoots.value.length > 0
}) })
@@ -472,7 +354,11 @@ watch(
const key = `${task.root_id}:${task.id}` const key = `${task.root_id}:${task.id}`
nextSeen.set(key, task.status) nextSeen.set(key, task.status)
const previousStatus = seenTaskStates.get(key) 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 detail = (task.detail || "").trim()
const doneMatch = detail.match(/^Done\s+[\u2014-]\s+(\d+)\s+movies,\s+(\d+)\s+series$/i) const doneMatch = detail.match(/^Done\s+[\u2014-]\s+(\d+)\s+movies,\s+(\d+)\s+series$/i)
if (doneMatch) { if (doneMatch) {
@@ -480,7 +366,9 @@ watch(
const series = Number(doneMatch[2] || "0") const series = Number(doneMatch[2] || "0")
if (movies > 0 || series > 0) { if (movies > 0 || series > 0) {
const rootName = getRootName(task.root_id) || task.root_id 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 +466,7 @@ function getResumePoint(mediaId: string | null): SeriesResumePoint | null {
} }
} }
function getResumeEpisodes( function getResumeEpisodes(mediaId: string | null): Record<string, EpisodeWatchEntry> | null {
mediaId: string | null,
): Record<string, EpisodeWatchEntry> | null {
if (!mediaId) return null if (!mediaId) return null
return resumePositions.value[mediaId]?.episodes ?? null return resumePositions.value[mediaId]?.episodes ?? null
} }
@@ -675,7 +561,9 @@ function showAdjacentDetail(offset: -1 | 1): boolean {
const sequence = getDetailAdjacentSequence(current) const sequence = getDetailAdjacentSequence(current)
if (sequence.length < 2) return false 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 if (currentIndex < 0) return false
const nextIndex = currentIndex + offset const nextIndex = currentIndex + offset
@@ -771,7 +659,10 @@ function restoreBrowseFocus(path: string) {
}, 100) }, 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 active = document.activeElement as HTMLElement | null
const focusedCard = active?.closest("[data-item-id]") as HTMLElement | null const focusedCard = active?.closest("[data-item-id]") as HTMLElement | null
if (!focusedCard) return null if (!focusedCard) return null
@@ -832,9 +723,7 @@ function clearSearch(options: { preferBack?: boolean; targetPath?: string } = {}
? normalizeHistoryPath(window.history.state.back) ? normalizeHistoryPath(window.history.state.back)
: "" : ""
const canRestoreWithBack = const canRestoreWithBack =
options.preferBack !== false && options.preferBack !== false && searchReturnPath.value === targetPath && backPath === targetPath
searchReturnPath.value === targetPath &&
backPath === targetPath
searchReturnPath.value = null searchReturnPath.value = null
@@ -1057,7 +946,7 @@ const activePanelScrollTop = computed(() => {
}) })
const headerStyle = computed(() => { const headerStyle = computed(() => {
if (route.path === "/settings") { if (isSettingsView.value) {
return {} return {}
} }
return { return {
@@ -1105,14 +994,18 @@ function showDetail(item: MediaItem) {
handlePlay(playableFile) handlePlay(playableFile)
} else { } else {
const searchPath = searchQuery.value ? getSearchPath(searchQuery.value) : null 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 { } else {
const detailSearchPath = getDetailSearchPath() const detailSearchPath = getDetailSearchPath()
const searchPath = searchQuery.value const searchPath = searchQuery.value ? getSearchPath(searchQuery.value) : detailSearchPath
? getSearchPath(searchQuery.value) router.push({
: detailSearchPath path: `/${item.type}/${item.id}`,
router.push({ path: `/${item.type}/${item.id}`, state: searchPath ? { searchPath } : undefined }) state: searchPath ? { searchPath } : undefined,
})
} }
} }
@@ -1133,9 +1026,7 @@ function handleActorSearch(actorName: string) {
function handleSelectMovieFromDetail(movieId: string) { function handleSelectMovieFromDetail(movieId: string) {
const detailSearchPath = getDetailSearchPath() const detailSearchPath = getDetailSearchPath()
const searchPath = searchQuery.value const searchPath = searchQuery.value ? getSearchPath(searchQuery.value) : detailSearchPath
? getSearchPath(searchQuery.value)
: detailSearchPath
router.push({ path: `/movies/${movieId}`, state: searchPath ? { searchPath } : undefined }) router.push({ path: `/movies/${movieId}`, state: searchPath ? { searchPath } : undefined })
} }
@@ -1174,7 +1065,9 @@ function focusDetailEntryTarget(item: MediaItem): boolean {
return true 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) { if (firstFocusable) {
focusElement(firstFocusable) focusElement(firstFocusable)
return true return true
+32 -3
View File
@@ -426,13 +426,42 @@ export function getCoverUrl(coverPath: string | null, rootId?: string | null): s
} }
/** /**
* Invoke the native OS folder picker via pywebview, then add the selected * Invoke the native OS folder picker via pywebview.
* folder to the server's root list. Only works inside the packaged desktop app. * Only works inside the packaged desktop app; returns null elsewhere.
*/ */
export async function pickFolderAndAddRoot(): Promise<string | null> { export async function pickFolder(): Promise<string | null> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const api = (window as any).pywebview?.api const api = (window as any).pywebview?.api
if (!api) return null if (!api) return null
const folder: string | null = await api.pick_folder() const folder: string | null = await api.pick_folder()
return folder return folder
} }
export interface UpdateStatus {
version: string
auto_update: boolean
pending_version: string | null
}
/** Fetch version, auto-update preference, and any downloaded pending update. */
export async function fetchUpdateStatus(): Promise<UpdateStatus> {
const response = await fetch("/api/update")
if (!response.ok) throw new Error(`Failed to fetch update status: ${response.status}`)
return response.json()
}
/** Enable or disable automatic update downloads (persisted server-side). */
export async function setAutoUpdate(enabled: boolean): Promise<void> {
const response = await fetch("/api/config/auto-update", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
})
if (!response.ok) throw new Error(`Failed to save auto-update setting: ${response.status}`)
}
/** Apply a downloaded update and restart the app into it. */
export async function restartForUpdate(): Promise<void> {
const response = await fetch("/api/update/restart", { method: "POST" })
if (!response.ok) throw new Error(`Failed to restart for update: ${response.status}`)
}
+1 -7
View File
@@ -594,13 +594,7 @@ function activateItem(item: MediaItem) {
function handleItemClick(event: MouseEvent, item: MediaItem) { function handleItemClick(event: MouseEvent, item: MediaItem) {
// Let modified clicks navigate natively // Let modified clicks navigate natively
if ( if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) {
event.button !== 0 ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey ||
event.altKey
) {
return return
} }
event.preventDefault() event.preventDefault()
@@ -65,11 +65,7 @@ function getPlayLabel(filePath: string | null | undefined): string {
function getFocusableElements(): HTMLElement[] { function getFocusableElements(): HTMLElement[] {
if (!menuRef.value) return [] if (!menuRef.value) return []
return Array.from( return Array.from(menuRef.value.querySelectorAll<HTMLElement>(".ctx-btn:not(:disabled)"))
menuRef.value.querySelectorAll<HTMLElement>(
'.ctx-btn:not(:disabled)'
)
)
} }
function focusNext(delta: number) { function focusNext(delta: number) {
@@ -130,9 +126,7 @@ watch(
await nextTick() await nextTick()
clampToViewport() clampToViewport()
// Focus first action button for keyboard navigation // Focus first action button for keyboard navigation
const firstBtn = menuRef.value?.querySelector( const firstBtn = menuRef.value?.querySelector(".ctx-btn:not(:disabled)") as HTMLElement | null
".ctx-btn:not(:disabled)",
) as HTMLElement | null
firstBtn?.focus() firstBtn?.focus()
}, },
{ immediate: true }, { immediate: true },
+663 -122
View File
@@ -1,5 +1,8 @@
<template> <template>
<header class="header" :class="[`header-${position}`]"> <header class="header" :class="[`header-${position}`, { 'header--gui': isDesktopApp }]">
<button v-if="isDesktopApp" class="app-exit-btn" title="Exit MediaHive" @click="exitApp">
</button>
<div class="header-left"> <div class="header-left">
<RouterLink to="/" class="header-logo-link" aria-label="Go to front page"> <RouterLink to="/" class="header-logo-link" aria-label="Go to front page">
<img :src="logoUrl" alt="MediaHive" class="header-logo" /> <img :src="logoUrl" alt="MediaHive" class="header-logo" />
@@ -88,63 +91,63 @@
</svg> </svg>
</button> </button>
<!-- Full-screen settings view --> <!-- Floating settings/log overlay -->
<div v-if="showSettings" class="settings-view"> <div v-if="showSettings" class="settings-view" @click.self="closeSettings">
<div class="settings-header"> <!-- Floating log window (left side) -->
<button class="settings-back" @click="closeSettings"> <div v-if="showLog" ref="logWindowEl" class="log-window" :style="logDragStyle">
<svg <div class="dialog-header" @pointerdown="onLogDragStart">
xmlns="http://www.w3.org/2000/svg" <h1 class="settings-title">Application log</h1>
width="24" <div class="log-actions">
height="24" <button class="log-copy-btn" title="Copy entire log" @click="copyLog">
viewBox="0 0 24 24" {{ logCopied ? "Copied" : "Copy" }}
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> </button>
<button class="dialog-close" title="Close log" @click="closeLog"></button>
</div>
</div>
<pre ref="logEl" class="diag-log" @scroll="onLogScroll">{{ appLog }}</pre>
</div>
<!-- Floating settings window (right side) -->
<div ref="settingsWindowEl" class="settings-window" :style="settingsDragStyle">
<div class="dialog-header" @pointerdown="onSettingsDragStart">
<h1 class="settings-title">Settings</h1> <h1 class="settings-title">Settings</h1>
<div class="settings-header-spacer"></div> <button class="dialog-close" title="Close settings" @click="closeSettings"></button>
</div> </div>
<div class="settings-content scrollbar-hidden"> <div class="settings-scroll">
<div class="settings-body">
<section class="settings-section"> <section class="settings-section">
<h2 class="settings-section-title">Media Roots</h2> <div class="update-row">
<p class="settings-section-desc">Folders scanned and indexed by MediaHive.</p> <span class="update-version"> MediaHive {{ updateStatus?.version ?? "…" }} </span>
<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 <button
v-if="roots.length > 1" v-if="isDesktopApp"
class="roots-item-remove" class="log-open-btn"
@click="removeRoot(root.root_id)" title="View application log"
title="Remove root" @click="openLog"
> >
× Log
</button> </button>
</div> <label class="update-auto">
</div> <input
</div> type="checkbox"
:checked="updateStatus?.auto_update ?? true"
<div class="roots-actions"> :disabled="!updateStatus"
<button v-if="isDesktopApp" class="roots-add-btn" @click="addRoot"> @change="toggleAutoUpdate"
+ Add Folder />
Automatic updates
</label>
<template v-if="updateStatus?.pending_version">
<span class="update-pending">
Update {{ updateStatus.pending_version }} downloaded
</span>
<button
class="update-restart-btn"
:disabled="updateRestarting"
@click="restartToUpdate"
>
{{ updateRestarting ? "Restarting…" : "Restart to update" }}
</button> </button>
</template>
</div> </div>
</section> </section>
@@ -301,27 +304,101 @@
</section> </section>
<section class="settings-section"> <section class="settings-section">
<h2 class="settings-section-title">Diagnostics</h2> <h2 class="settings-section-title">Media folders</h2>
<p class="settings-section-desc">Application log for troubleshooting.</p> <p class="settings-section-desc">Folders scanned and indexed by MediaHive.</p>
<div class="diag-log-header"> <div class="roots-list">
<span class="diag-label">Application log</span> <div
v-for="root in roots"
:key="root.root_id"
class="roots-item"
:class="[
`roots-item--${root.status}`,
{ 'roots-item--clickable': isDesktopApp },
]"
:title="isDesktopApp ? 'Click to choose a different folder' : undefined"
@click="changeRootFolder(root.root_id)"
>
<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.stop="removeRoot(root.root_id)"
title="Remove folder"
>
</button>
</div>
</div>
</div>
<div class="roots-actions">
<button v-if="isDesktopApp" class="roots-add-btn" @click="addRoot">
+ Add Folder
</button>
</div> </div>
<pre ref="logEl" class="diag-log" @scroll="onLogScroll">{{ appLog }}</pre>
</section> </section>
</div> </div>
</div> </div>
<div class="settings-activity">
<div v-if="!scanConnected" class="activity-connection">Reconnecting...</div>
<template v-else>
<div v-if="activityRoots.length === 0" class="activity-idle">
<template v-if="libraryStats">{{ libraryStats }} in library · </template>Rescan will
commence shortly
</div>
<div
v-for="root in activityRoots"
:key="root.rootId"
class="activity-root"
:class="root.toneClass"
>
<div class="activity-root-title" :title="root.rootLabel">{{ root.rootLabel }}</div>
<div v-if="root.scanTarget" class="activity-root-target" :title="root.scanTarget">
{{ 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>
</template>
</div>
</div>
</div>
</div> </div>
</header> </header>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, computed, onMounted, onUnmounted, nextTick } from "vue" import { ref, watch, computed, onMounted, onUnmounted, nextTick } from "vue"
import { useRouter, useRoute } from "vue-router" import { useRouter } from "vue-router"
import { navAttrs } from "../composables/useKeyboardNavigation" import { navAttrs } from "../composables/useKeyboardNavigation"
import { computeProgressRoots, type RootTaskInfo } from "../composables/useScanProgress"
import { useSettingsOpen } from "../composables/useSettingsOpen"
import logoUrl from "../assets/mediahive.webp" import logoUrl from "../assets/mediahive.webp"
import { replaceRoots, pickFolderAndAddRoot, fetchPlayers } from "../api" import { replaceRoots, pickFolder, fetchPlayers } from "../api"
import type { PlayerInfo } from "../api" import type { PlayerInfo } from "../api"
import { fetchUpdateStatus, setAutoUpdate, restartForUpdate } from "../api"
import type { UpdateStatus } from "../api"
import HexKeyboard from "./HexKeyboard.vue" import HexKeyboard from "./HexKeyboard.vue"
import { import {
useSettings, useSettings,
@@ -343,12 +420,17 @@ interface RootEntry {
root_id: string root_id: string
path: string path: string
status: string status: string
movies?: number
series?: number
} }
const props = defineProps<{ const props = defineProps<{
currentView: "movies" | "series" | "search" currentView: "movies" | "series" | "search"
searchQuery: string searchQuery: string
roots: RootEntry[] roots: RootEntry[]
scanTasks: ReadonlyMap<string, RootTaskInfo>
scanConnected: boolean
initialScanMode: boolean
mpcBeConnected: boolean mpcBeConnected: boolean
navRow: number navRow: number
position: "top" | "after-hero" | "after-movie-header" | "after-series-hero" position: "top" | "after-hero" | "after-movie-header" | "after-series-hero"
@@ -360,7 +442,6 @@ const emit = defineEmits<{
}>() }>()
const router = useRouter() const router = useRouter()
const route = useRoute()
const searchInputRef = ref<HTMLInputElement | null>(null) const searchInputRef = ref<HTMLInputElement | null>(null)
const localSearch = ref(props.searchQuery) const localSearch = ref(props.searchQuery)
@@ -371,23 +452,104 @@ function _onPywebviewReady() {
window.addEventListener("pywebviewready", _onPywebviewReady, { once: true }) window.addEventListener("pywebviewready", _onPywebviewReady, { once: true })
onUnmounted(() => window.removeEventListener("pywebviewready", _onPywebviewReady)) onUnmounted(() => window.removeEventListener("pywebviewready", _onPywebviewReady))
const showSettings = computed(() => route.path === "/settings") function exitApp() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
void (window as any).pywebview?.api?.exit_app()
}
const showSettings = useSettingsOpen()
const roots = computed(() => props.roots) const roots = computed(() => props.roots)
const activityRoots = computed(() =>
computeProgressRoots(
props.scanTasks.values(),
(rootId) => roots.value.find((r) => r.root_id === rootId)?.path || null,
props.initialScanMode,
),
)
const libraryStats = computed(() => {
let movies = 0
let series = 0
for (const root of roots.value) {
movies += root.movies ?? 0
series += root.series ?? 0
}
if (movies === 0 && series === 0) return null
const parts: string[] = []
if (movies > 0) parts.push(`${movies} movie${movies === 1 ? "" : "s"}`)
if (series > 0) parts.push(`${series} series`)
return parts.join(", ")
})
function openSettings() { function openSettings() {
if (showSettings.value) return showSettings.value = true
void router.push("/settings")
} }
function closeSettings() { function closeSettings() {
if (!showSettings.value) return showSettings.value = false
if (window.history.length > 1) { showLog.value = false
router.back()
return
} }
void router.replace("/movies")
const showLog = ref(false)
function openLog() {
showLog.value = true
} }
function closeLog() {
showLog.value = false
}
// Draggable floating windows: pointer drag on the dialog header repositions
// the window by switching it to fixed positioning at the dragged offset.
function useDraggable() {
const el = ref<HTMLElement | null>(null)
const pos = ref<{ x: number; y: number } | null>(null)
function onDragStart(e: PointerEvent) {
if ((e.target as HTMLElement).closest("button")) return
const rect = el.value?.getBoundingClientRect()
if (!rect) return
e.preventDefault()
const grabX = e.clientX - rect.left
const grabY = e.clientY - rect.top
pos.value = { x: rect.left, y: rect.top }
const onMove = (ev: PointerEvent) => {
pos.value = {
x: Math.min(Math.max(ev.clientX - grabX, 0), window.innerWidth - 80),
y: Math.min(Math.max(ev.clientY - grabY, 0), window.innerHeight - 40),
}
}
const onUp = () => {
window.removeEventListener("pointermove", onMove)
window.removeEventListener("pointerup", onUp)
}
window.addEventListener("pointermove", onMove)
window.addEventListener("pointerup", onUp)
}
const dragStyle = computed(() =>
pos.value
? {
position: "fixed" as const,
left: `${pos.value.x}px`,
top: `${pos.value.y}px`,
margin: "0",
}
: undefined,
)
return { el, dragStyle, onDragStart }
}
const {
el: settingsWindowEl,
dragStyle: settingsDragStyle,
onDragStart: onSettingsDragStart,
} = useDraggable()
const { el: logWindowEl, dragStyle: logDragStyle, onDragStart: onLogDragStart } = useDraggable()
function setPreferredResolution(value: ResolutionPreference) { function setPreferredResolution(value: ResolutionPreference) {
settings.preferredResolution = value settings.preferredResolution = value
} }
@@ -423,6 +585,39 @@ async function refreshPlayers() {
} }
} }
const updateStatus = ref<UpdateStatus | null>(null)
const updateRestarting = ref(false)
async function refreshUpdateStatus() {
try {
updateStatus.value = await fetchUpdateStatus()
} catch (e) {
console.error("Failed to fetch update status:", e)
}
}
async function toggleAutoUpdate(event: Event) {
const enabled = (event.target as HTMLInputElement).checked
const previous = updateStatus.value?.auto_update ?? true
if (updateStatus.value) updateStatus.value.auto_update = enabled
try {
await setAutoUpdate(enabled)
} catch (e) {
console.error("Failed to save auto-update setting:", e)
if (updateStatus.value) updateStatus.value.auto_update = previous
}
}
async function restartToUpdate() {
updateRestarting.value = true
try {
await restartForUpdate()
} catch (e) {
console.error("Failed to restart for update:", e)
updateRestarting.value = false
}
}
const appLog = ref("") const appLog = ref("")
const logEl = ref<HTMLElement | null>(null) const logEl = ref<HTMLElement | null>(null)
let logSocket: WebSocket | null = null let logSocket: WebSocket | null = null
@@ -434,21 +629,48 @@ function onLogScroll() {
pinnedToBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 48 pinnedToBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 48
} }
async function applyLogText(text: string) {
appLog.value = text
await nextTick()
const el = logEl.value
if (el && pinnedToBottom) el.scrollTop = el.scrollHeight
}
const logCopied = ref(false)
let logCopiedTimer: number | null = null
async function copyLog() {
const text = appLog.value
try {
await navigator.clipboard.writeText(text)
} catch {
// Fallback for contexts where the async clipboard API is unavailable.
const ta = document.createElement("textarea")
ta.value = text
ta.style.position = "fixed"
ta.style.opacity = "0"
document.body.appendChild(ta)
ta.select()
document.execCommand("copy")
ta.remove()
}
logCopied.value = true
if (logCopiedTimer !== null) window.clearTimeout(logCopiedTimer)
logCopiedTimer = window.setTimeout(() => (logCopied.value = false), 1500)
}
function connectLogSocket() { function connectLogSocket() {
if (logSocket) return if (logSocket) return
const proto = location.protocol === "https:" ? "wss" : "ws" const proto = location.protocol === "https:" ? "wss" : "ws"
const ws = new WebSocket(`${proto}://${location.host}/api/log/ws`) const ws = new WebSocket(`${proto}://${location.host}/api/log/ws`)
logSocket = ws logSocket = ws
pinnedToBottom = true pinnedToBottom = true
ws.onmessage = async (ev) => { ws.onmessage = (ev) => {
appLog.value = String(ev.data) void applyLogText(String(ev.data))
await nextTick()
const el = logEl.value
if (el && pinnedToBottom) el.scrollTop = el.scrollHeight
} }
ws.onclose = () => { ws.onclose = () => {
if (logSocket === ws) logSocket = null if (logSocket === ws) logSocket = null
if (showSettings.value) setTimeout(connectLogSocket, 3000) if (showLog.value) setTimeout(connectLogSocket, 3000)
} }
} }
@@ -464,29 +686,51 @@ async function removeRoot(rootId: string) {
try { try {
await replaceRoots(newRoots) await replaceRoots(newRoots)
} catch (e) { } catch (e) {
console.error("Failed to remove root:", e) console.error("Failed to remove folder:", e)
alert("Failed to remove root") alert("Failed to remove folder")
}
}
async function changeRootFolder(rootId: string) {
if (!isDesktopApp.value) return
const folder = await pickFolder()
if (!folder || folder === roots.value.find((r) => r.root_id === rootId)?.path) return
const newRoots = Object.fromEntries(roots.value.map((r) => [r.root_id, r.path]))
newRoots[rootId] = folder
try {
await replaceRoots(newRoots)
} catch (e) {
console.error("Failed to change folder:", e)
alert("Failed to change folder")
} }
} }
async function addRoot() { async function addRoot() {
const folder = await pickFolderAndAddRoot() const folder = await pickFolder()
if (!folder) return if (!folder) return
const suggestedId = folder.split("/").pop() || folder.split("\\").pop() || "media" const suggestedId = folder.split("/").pop() || folder.split("\\").pop() || "media"
const newRoots = Object.fromEntries(roots.value.map((r) => [r.root_id, r.path])) const newRoots = Object.fromEntries(roots.value.map((r) => [r.root_id, r.path]))
newRoots[suggestedId] = folder newRoots[suggestedId] = folder
try { try {
await replaceRoots(newRoots) await replaceRoots(newRoots)
closeSettings()
} catch (e) { } catch (e) {
console.error("Failed to add root:", e) console.error("Failed to add folder:", e)
alert("Failed to add root") alert("Failed to add folder")
} }
} }
watch(showSettings, (visible) => { watch(showSettings, (visible) => {
if (visible) { if (visible) {
void refreshPlayers() void refreshPlayers()
void refreshUpdateStatus()
} else {
showLog.value = false
disconnectLogSocket()
}
})
watch(showLog, (visible) => {
if (visible) {
connectLogSocket() connectLogSocket()
} else { } else {
disconnectLogSocket() disconnectLogSocket()
@@ -577,6 +821,17 @@ function focusSearchInput() {
} }
function handleKeydown(e: KeyboardEvent) { function handleKeydown(e: KeyboardEvent) {
// Fullscreen toggle works even while typing (Alt+Enter / F11 are never text input).
if (
isDesktopApp.value &&
(e.key === "F11" || (e.key === "Enter" && e.altKey && !e.ctrlKey && !e.metaKey))
) {
e.preventDefault()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
void (window as any).pywebview?.api?.toggle_fullscreen()
return
}
const target = e.target as HTMLElement | null const target = e.target as HTMLElement | null
const isTypingTarget = Boolean( const isTypingTarget = Boolean(
target && target &&
@@ -608,6 +863,29 @@ onUnmounted(() => {
</script> </script>
<style scoped> <style scoped>
.app-exit-btn {
position: fixed;
top: 0;
right: 0;
z-index: 3000;
width: 2.875rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
color: #fff;
font-size: 1rem;
line-height: 1;
cursor: pointer;
opacity: 0.7;
transition: opacity 0.15s;
}
.app-exit-btn:hover {
opacity: 1;
}
.player-indicator { .player-indicator {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -635,37 +913,100 @@ onUnmounted(() => {
left: 0; left: 0;
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
background: var(--bg-primary); background: rgba(0, 0, 0, 0.6);
z-index: 2000; z-index: 2000;
display: flex; display: flex;
align-items: center;
justify-content: flex-end;
gap: 2.5rem;
padding-left: 5vw;
padding-right: 15vw;
}
.settings-window {
width: fit-content;
max-width: 94vw;
height: min(85vh, 56.25rem);
flex: 0 0 auto;
background: var(--bg-primary);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 0.75rem;
box-shadow: 0 1.5rem 4rem rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
} }
.settings-header { .log-window {
flex: 1 1 auto;
min-width: 20rem;
max-width: 75rem;
height: min(85vh, 56.25rem);
background: var(--bg-primary);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 0.75rem;
box-shadow: 0 1.5rem 4rem rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
overflow: hidden;
}
@media (max-width: 43.75rem) {
.settings-view {
justify-content: center;
padding-left: 0;
padding-right: 0;
}
.settings-window,
.log-window {
width: 100vw;
max-width: none;
height: 100vh;
border: none;
border-radius: 0;
}
.log-window {
position: absolute;
inset: 0;
}
}
.dialog-header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 1rem;
padding: 16px 24px; padding: 16px 24px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08); border-bottom: 1px solid rgba(255, 255, 255, 0.08);
flex-shrink: 0; flex-shrink: 0;
cursor: grab;
user-select: none;
touch-action: none;
} }
.settings-back { .dialog-header:active {
cursor: grabbing;
}
.dialog-close {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 8px; justify-content: center;
background: none; background: none;
border: none; border: none;
color: var(--text-secondary); color: #fff;
font-size: 0.9rem; font-size: 1rem;
line-height: 1;
cursor: pointer; cursor: pointer;
padding: 8px 0; opacity: 0.7;
transition: color 0.2s; transition: opacity 0.15s;
padding: 4px;
} }
.settings-back:hover { .dialog-close:hover {
color: var(--text-primary); opacity: 1;
} }
.settings-title { .settings-title {
@@ -674,17 +1015,16 @@ onUnmounted(() => {
margin: 0; margin: 0;
} }
.settings-header-spacer { .settings-scroll {
width: 80px;
}
.settings-content {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 32px 24px; }
max-width: 720px;
.settings-body {
margin: 0 auto; margin: 0 auto;
width: 100%; padding: 2rem 1.5rem;
width: 32rem;
max-width: 100%;
} }
.settings-section { .settings-section {
@@ -703,6 +1043,54 @@ onUnmounted(() => {
margin: 0 0 20px; margin: 0 0 20px;
} }
.update-row {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.update-version {
font-size: 0.8rem;
color: var(--text-muted, var(--text-secondary));
}
.update-auto {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.8rem;
color: var(--text-secondary);
cursor: pointer;
}
.update-pending {
font-size: 0.8rem;
font-weight: 600;
color: #22c55e;
}
.update-restart-btn {
padding: 0.375rem 0.875rem;
background: rgba(34, 197, 94, 0.15);
border: 1px solid rgba(34, 197, 94, 0.45);
border-radius: 0.5rem;
color: #22c55e;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.update-restart-btn:hover:not(:disabled) {
background: rgba(34, 197, 94, 0.3);
}
.update-restart-btn:disabled {
opacity: 0.6;
cursor: default;
}
.roots-list { .roots-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -774,21 +1162,29 @@ onUnmounted(() => {
.roots-item-remove { .roots-item-remove {
background: none; background: none;
border: none; border: none;
color: var(--text-secondary); font-size: 1.25rem;
font-size: 1rem;
cursor: pointer; cursor: pointer;
padding: 0 4px; padding: 0 0.25rem;
line-height: 1; line-height: 1;
opacity: 0;
transition: opacity 0.15s;
} }
.roots-item-remove:hover { .roots-item:hover .roots-item-remove,
color: #ef4444; .roots-item-remove:focus-visible {
opacity: 1;
}
.roots-item--clickable {
cursor: pointer;
}
.roots-item--clickable:hover {
background: rgba(255, 255, 255, 0.09);
} }
.roots-actions { .roots-actions {
margin-top: 12px; margin-top: 12px;
padding-top: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
} }
.roots-add-btn { .roots-add-btn {
@@ -922,30 +1318,175 @@ onUnmounted(() => {
color: #22c55e; 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 { .diag-log {
font-family: ui-monospace, Menlo, Consolas, monospace; font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 0.75rem; font-size: 0.75rem;
white-space: pre-wrap; white-space: pre;
word-break: break-all; flex: 1;
width: 100%; min-height: 0;
max-height: 320px; overflow: auto;
overflow-y: auto;
margin: 0; margin: 0;
padding: 10px; padding: 0.625rem 1.5rem 1.5rem;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
color: var(--text-secondary); color: var(--text-secondary);
user-select: text;
cursor: text;
}
.log-actions {
display: inline-flex;
align-items: center;
gap: 0.75rem;
}
.log-copy-btn {
padding: 0.125rem 0.5rem;
background: none;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 0.375rem;
color: var(--text-muted, var(--text-secondary));
font-size: 0.8rem;
cursor: pointer;
transition:
color 0.2s,
border-color 0.2s;
}
.log-copy-btn:hover {
color: var(--text-primary);
border-color: rgba(255, 255, 255, 0.35);
}
.log-open-btn {
padding: 0.125rem 0.5rem;
background: none;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 0.375rem;
color: var(--text-muted, var(--text-secondary));
font-size: 0.8rem;
cursor: pointer;
transition:
color 0.2s,
border-color 0.2s;
}
.log-open-btn:hover {
color: var(--text-primary);
border-color: rgba(255, 255, 255, 0.35);
}
/* Library activity footer strip */
.settings-activity {
flex-shrink: 0;
width: 0;
min-width: 100%;
height: 11rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.625rem 1.5rem 0.875rem;
}
.activity-connection {
flex-shrink: 0;
margin: auto 0;
color: #f59e0b;
font-size: 0.75rem;
font-weight: 600;
}
.activity-idle {
flex-shrink: 0;
margin: auto 0;
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.4);
}
.activity-root {
display: grid;
gap: 0.375rem;
flex-shrink: 0;
min-width: 0;
padding: 0.5rem 0.625rem;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 0.375rem;
}
.activity-root-error {
border-color: rgba(248, 113, 113, 0.45);
}
.activity-root-title {
font-size: 0.75rem;
font-weight: 700;
color: rgba(255, 255, 255, 0.95);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.activity-root-target {
font-size: 0.75rem;
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: 0.5rem;
font-size: 0.75rem;
}
.activity-phase {
color: rgba(255, 255, 255, 0.82);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.activity-progress-label {
color: #60a5fa;
font-weight: 600;
flex-shrink: 0;
}
.activity-bar {
position: relative;
height: 0.375rem;
border-radius: 6.25rem;
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: 0.6875rem;
line-height: 1.35;
}
@keyframes activity-indeterminate {
0% {
transform: translateX(-120%);
}
100% {
transform: translateX(250%);
}
} }
</style> </style>
+51 -21
View File
@@ -1,7 +1,13 @@
<template> <template>
<Teleport to="body"> <Teleport to="body">
<Transition name="hex-keyboard-fade"> <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 ref="gridRef" class="hex-keyboard-grid">
<div <div
v-for="(row, rowIndex) in rows" v-for="(row, rowIndex) in rows"
@@ -26,7 +32,9 @@
tabindex="-1" tabindex="-1"
@click="handleKeyClick(key)" @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> </button>
</div> </div>
<!-- Green focus outline rendered separately on top --> <!-- 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 } { function getRowRange(row: number): { start: number; count: number } {
switch (row) { switch (row) {
case 0: return { start: ROW_0_START, count: ROW_0_COUNT } case 0:
case 1: return { start: ROW_1_START, count: ROW_1_COUNT } return { start: ROW_0_START, count: ROW_0_COUNT }
case 2: return { start: ROW_2_START, count: ROW_2_COUNT } case 1:
case 3: return { start: ROW_3_START, count: ROW_3_COUNT } return { start: ROW_1_START, count: ROW_1_COUNT }
default: return { start: 0, count: 0 } 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") emit("close")
} }
function findNext( function findNext(currentIdx: number, direction: "up" | "down" | "left" | "right"): number | null {
currentIdx: number,
direction: "up" | "down" | "left" | "right",
): number | null {
const current = getCoord(currentIdx) const current = getCoord(currentIdx)
if (direction === "left") { if (direction === "left") {
@@ -510,10 +520,18 @@ onUnmounted(() => {
} }
/* Row horizontal offsets for honeycomb staggering */ /* Row horizontal offsets for honeycomb staggering */
.hex-keyboard-row-0 { margin-left: 0; } .hex-keyboard-row-0 {
.hex-keyboard-row-1 { margin-left: calc(var(--key-w) * 0.5); } margin-left: 0;
.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-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 { .hex-key {
position: relative; position: relative;
@@ -529,7 +547,9 @@ onUnmounted(() => {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%); 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; outline: none;
transform: scale(0.97); transform: scale(0.97);
} }
@@ -538,7 +558,8 @@ onUnmounted(() => {
.hex-key-row-0 { .hex-key-row-0 {
background: linear-gradient(180deg, #626b7bd0 0%, #3a3f4ad0 100%); 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%); background: linear-gradient(180deg, #2a3343d0 0%, #2c3242d0 100%);
} }
.hex-key-row-2 { .hex-key-row-2 {
@@ -553,8 +574,12 @@ onUnmounted(() => {
} }
@keyframes hex-label-fade { @keyframes hex-label-fade {
0% { color: #22c55e; } 0% {
100% { color: #ffffff; } color: #22c55e;
}
100% {
color: #ffffff;
}
} }
/* Special key backgrounds override row gradients */ /* Special key backgrounds override row gradients */
@@ -600,8 +625,13 @@ onUnmounted(() => {
} }
@keyframes hex-outline-blink { @keyframes hex-outline-blink {
0%, 100% { opacity: 1; } 0%,
50% { opacity: 0.4; } 100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
} }
/* Transition */ /* Transition */
+4 -8
View File
@@ -80,7 +80,9 @@
</div> </div>
<div v-else-if="subtitle" class="media-card-detail">{{ subtitle }}</div> <div v-else-if="subtitle" class="media-card-detail">{{ subtitle }}</div>
<div v-if="directorAndCast" class="media-card-detail person-list"> <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 <span
v-for="(castName, castIndex) in formattedCastNames" v-for="(castName, castIndex) in formattedCastNames"
:key="`${castName}-${castIndex}`" :key="`${castName}-${castIndex}`"
@@ -112,13 +114,7 @@ const emit = defineEmits<{
function handleClick(event: MouseEvent) { function handleClick(event: MouseEvent) {
// Let modified clicks (middle-click, ctrl+click, etc.) navigate natively // Let modified clicks (middle-click, ctrl+click, etc.) navigate natively
if ( if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) {
event.button !== 0 ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey ||
event.altKey
) {
return return
} }
// Prevent default navigation for plain left-clicks and synthetic clicks // Prevent default navigation for plain left-clicks and synthetic clicks
+23 -10
View File
@@ -2,6 +2,7 @@
<!-- Full screen view for series --> <!-- Full screen view for series -->
<SeriesFullView <SeriesFullView
v-if="item.type === 'series'" v-if="item.type === 'series'"
:key="item.id"
:series="item.data as Series" :series="item.data as Series"
:all-movies="allMovies" :all-movies="allMovies"
:focus-episode="focusEpisode" :focus-episode="focusEpisode"
@@ -185,7 +186,11 @@
v-if="item.type === 'movies' && collectionMovies.length > 1" v-if="item.type === 'movies' && collectionMovies.length > 1"
class="similar-movies-section" 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 <a
v-for="(movie, collectionIndex) in collectionMovies" v-for="(movie, collectionIndex) in collectionMovies"
:key="movie.localId" :key="movie.localId"
@@ -234,7 +239,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch, onMounted, onUnmounted, nextTick } from "vue" 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 type { EpisodeWatchEntry } from "../api"
import { import {
getCoverUrl, getCoverUrl,
@@ -315,7 +328,9 @@ function getReleaseAtRow(row: number): HTMLElement | null {
} }
function getLastReleaseRowBefore(castRow: number): number | 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 let best: number | null = null
for (const release of releases) { for (const release of releases) {
const row = parseInt(release.getAttribute("data-nav-row") || "", 10) const row = parseInt(release.getAttribute("data-nav-row") || "", 10)
@@ -342,11 +357,7 @@ function registerMovieOutOfBoundsShortcut() {
return null return null
} }
if ( if (direction === "left" && current.hasAttribute("data-nav-cast-item") && currentCol === 0) {
direction === "left" &&
current.hasAttribute("data-nav-cast-item") &&
currentCol === 0
) {
const targetRow = lastReleaseShortcutRow ?? getLastReleaseRowBefore(currentRow) const targetRow = lastReleaseShortcutRow ?? getLastReleaseRowBefore(currentRow)
if (targetRow === null) return null if (targetRow === null) return null
return getReleaseAtRow(targetRow) return getReleaseAtRow(targetRow)
@@ -755,7 +766,8 @@ const castNavRow = computed(() => {
return hasDesktopSimilarShortcut ? 4 + movieVersions.value.length : 2 + movieVersions.value.length return hasDesktopSimilarShortcut ? 4 + movieVersions.value.length : 2 + movieVersions.value.length
}) })
const collectionMovies = computed((): Array<{ const collectionMovies = computed(
(): Array<{
title: string title: string
localId: string localId: string
coverPath: string | null coverPath: string | null
@@ -832,7 +844,8 @@ const collectionMovies = computed((): Array<{
return a.title.localeCompare(b.title) return a.title.localeCompare(b.title)
}) })
.slice(0, 24) .slice(0, 24)
}) },
)
function normalizeHyphenationLang(language: string | null | undefined): string | null { function normalizeHyphenationLang(language: string | null | undefined): string | null {
if (!language) return null if (!language) return null
@@ -84,7 +84,7 @@ const disabled = computed(() => !props.filePath)
function getFocusableElements(): HTMLElement[] { function getFocusableElements(): HTMLElement[] {
if (!menuRef.value) return [] if (!menuRef.value) return []
return Array.from( 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(() => { const hasHdr10Plus = computed(() => {
if (props.torrent.hdr10plus) return true 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) .filter(Boolean)
.join(" ") .join(" ")
return hdr10PlusPattern.test(text) return hdr10PlusPattern.test(text)
+47 -30
View File
@@ -96,7 +96,9 @@
formatDate(selectedSeason.air_date) formatDate(selectedSeason.air_date)
}}</span> }}</span>
<span <span
>{{ selectedSeason.episode_count ?? selectedSeason.episodes.length }} >{{
selectedSeason.episode_count ?? selectedSeason.episodes.length
}}
Episodes</span Episodes</span
> >
</div> </div>
@@ -112,7 +114,7 @@
<div class="episodes-grid"> <div class="episodes-grid">
<div <div
v-for="(episode, eIndex) in selectedSeason?.episodes || []" v-for="(episode, eIndex) in selectedSeason?.episodes || []"
:key="`${selectedSeasonIndex}-${episode.episode_number}`" :key="`${seriesIdentity}-${selectedSeasonIndex}-${episode.episode_number}`"
class="episode-tile" class="episode-tile"
:class="{ 'episode-tile--ahead': isEpisodeAhead(eIndex) }" :class="{ 'episode-tile--ahead': isEpisodeAhead(eIndex) }"
v-bind="getEpisodeNavAttrs(selectedSeasonIndex, eIndex)" v-bind="getEpisodeNavAttrs(selectedSeasonIndex, eIndex)"
@@ -124,9 +126,7 @@
@contextmenu="handleContextMenu($event, episode)" @contextmenu="handleContextMenu($event, episode)"
> >
<!-- SVG focus outline --> <!-- SVG focus outline -->
<svg class="tile-focus-outline" viewBox="0 0 100 100" preserveAspectRatio="none"> <div class="tile-focus-outline"></div>
<rect x="0" y="0" width="100" height="100" />
</svg>
<!-- Episode preview media --> <!-- Episode preview media -->
<div class="tile-media"> <div class="tile-media">
@@ -161,9 +161,7 @@
<span <span
v-if="episodeWatchIndicator(episode)" v-if="episodeWatchIndicator(episode)"
class="ep-watch" class="ep-watch"
:title=" :title="episodeWatchIndicator(episode) === '●' ? 'Watched' : 'Partially watched'"
episodeWatchIndicator(episode) === '●' ? 'Watched' : 'Partially watched'
"
>{{ episodeWatchIndicator(episode) }}</span >{{ episodeWatchIndicator(episode) }}</span
> >
<div class="tile-play"></div> <div class="tile-play"></div>
@@ -258,7 +256,7 @@ import EpisodeReleaseMenu from "./EpisodeReleaseMenu.vue"
import { sortTorrentsByPreference } from "../composables/useSettings" import { sortTorrentsByPreference } from "../composables/useSettings"
const props = defineProps<{ const props = defineProps<{
series: Series & { root_id?: string | null } series: Series & { id?: string; root_id?: string | null }
allMovies: MovieUi[] allMovies: MovieUi[]
focusEpisode?: { seasonNumber: number; episodeNumber: number } | null focusEpisode?: { seasonNumber: number; episodeNumber: number } | null
resumePoint?: SeriesResumePoint | null resumePoint?: SeriesResumePoint | null
@@ -297,6 +295,15 @@ const selectedSeason = computed<Season | null>(
() => props.series.seasons[selectedSeasonIndex.value] || null, () => props.series.seasons[selectedSeasonIndex.value] || null,
) )
// Stable identity of the displayed series. Episode tiles carry it in their
// :key so swapping to another series remounts the tiles (and their <video>
// elements) instead of patching <source> children — which browsers ignore,
// leaving the previous series' preview reels playing.
const seriesIdentity = computed(() => {
const s = props.series
return s.id ?? `${s.root_id ?? ""}:${s.title ?? ""}`
})
function selectSeason(index: number) { function selectSeason(index: number) {
if (index < 0 || index >= props.series.seasons.length) return if (index < 0 || index >= props.series.seasons.length) return
if (selectedSeasonIndex.value === index) return if (selectedSeasonIndex.value === index) return
@@ -358,9 +365,7 @@ const cursorGlobalIndex = computed(() =>
const resumePointGlobalIndex = computed(() => { const resumePointGlobalIndex = computed(() => {
const point = props.resumePoint const point = props.resumePoint
if (!point) return null if (!point) return null
const seasonIndex = props.series.seasons.findIndex( const seasonIndex = props.series.seasons.findIndex((s) => s.season_number === point.seasonNumber)
(s) => s.season_number === point.seasonNumber,
)
if (seasonIndex < 0) return null if (seasonIndex < 0) return null
const episodeIndex = props.series.seasons[seasonIndex]?.episodes.findIndex( const episodeIndex = props.series.seasons[seasonIndex]?.episodes.findIndex(
(e) => e.episode_number === point.episodeNumber, (e) => e.episode_number === point.episodeNumber,
@@ -548,6 +553,9 @@ function getEpisodeNavAttrs(seasonIndex: number, episodeIndex: number) {
...navAttrs(coords.row, coords.col), ...navAttrs(coords.row, coords.col),
"data-season-index": seasonIndex, "data-season-index": seasonIndex,
"data-episode-index": episodeIndex, "data-episode-index": episodeIndex,
// Entering the episode grid from above (the season selector) always
// lands on the first episode instead of the visually closest tile.
...(episodeIndex === 0 ? { "data-nav-entry-col-from-above": "0" } : {}),
} }
} }
@@ -638,10 +646,7 @@ watch(
episodeFocusTarget, episodeFocusTarget,
(ep) => { (ep) => {
if (!ep) return if (!ep) return
if ( if (!props.focusEpisode && (seasonUserInteracted.value || episodeCursorIndex.value !== null)) {
!props.focusEpisode &&
(seasonUserInteracted.value || episodeCursorIndex.value !== null)
) {
return return
} }
const seasonIndex = const seasonIndex =
@@ -1406,6 +1411,17 @@ watch(
}, },
) )
// Series swapped under a reused component instance: tiles remount via the
// :key, but per-episode state keyed by episode index alone (audio owner,
// cursor, startup timers) survives — reset it so the old series' playback
// and audio don't bleed into the new one.
watch(seriesIdentity, () => {
episodeCursorIndex.value = null
setAudioOwner(null)
stopEpisodePreviews()
scheduleEpisodeMediaReady()
})
// Episodes past the spoiler threshold (cursor or continue point) are faded; // Episodes past the spoiler threshold (cursor or continue point) are faded;
// stop their playback too. // stop their playback too.
watch([episodeCursorIndex, resumePointGlobalIndex], () => { watch([episodeCursorIndex, resumePointGlobalIndex], () => {
@@ -1827,25 +1843,21 @@ html:not(.mouse-active) .season-poster-card.nav-focused,
} }
} }
/* SVG focus outline styles for tiles */ /* Focus outline for tiles; border width is half the SVG stroke width used on
poster cards, since a CSS border paints fully inside while an SVG stroke
is centered on the path (half of it clipped away). */
.tile-focus-outline { .tile-focus-outline {
position: absolute; position: absolute;
inset: 0; inset: 0;
width: 100%; border: 2px solid rgba(255, 255, 255, 0.9);
height: 100%; border-radius: inherit;
box-sizing: border-box;
pointer-events: none; pointer-events: none;
z-index: 5; z-index: 5;
opacity: 0; opacity: 0;
transition: opacity 0.2s ease; transition: opacity 0.2s ease;
} }
.tile-focus-outline rect {
fill: none;
stroke: rgba(255, 255, 255, 0.9);
stroke-width: 4;
vector-effect: non-scaling-stroke;
}
/* Show outline on hover and focus */ /* Show outline on hover and focus */
html.mouse-active .episode-tile:hover .tile-focus-outline, html.mouse-active .episode-tile:hover .tile-focus-outline,
html:not(.mouse-active) .episode-tile.nav-focused .tile-focus-outline { html:not(.mouse-active) .episode-tile.nav-focused .tile-focus-outline {
@@ -1854,9 +1866,9 @@ html:not(.mouse-active) .episode-tile.nav-focused .tile-focus-outline {
} }
/* Brighter outline for keyboard focus */ /* Brighter outline for keyboard focus */
html:not(.mouse-active) .episode-tile.nav-focused .tile-focus-outline rect { html:not(.mouse-active) .episode-tile.nav-focused .tile-focus-outline {
stroke: #ffffff; border-color: #ffffff;
stroke-width: 5; border-width: 2.5px;
filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.8)); filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.8));
} }
@@ -1933,7 +1945,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.025) 30%,
rgba(255, 255, 255, 0) 55% 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 { .episode-tile--ahead::after {
@@ -110,7 +110,9 @@ function getDigitalRepeatIntervalMs(holdMs: number): number {
function getAnalogRepeatIntervalMs(intensity: number): number { function getAnalogRepeatIntervalMs(intensity: number): number {
const normalized = Math.min(Math.max(intensity, 0), 1) 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 { function normalizeAxisIntensity(rawValue: number): number {
+10 -1
View File
@@ -2,10 +2,10 @@ import { reportUserActivity } from "../api"
type InputModality = "mouse" | "keyboard" | "gamepad" type InputModality = "mouse" | "keyboard" | "gamepad"
const MOUSE_IDLE_MS = 1400 const MOUSE_IDLE_MS = 1400
const MOUSE_INTENT_DISTANCE_PX = 28 const MOUSE_INTENT_DISTANCE_PX = 28
const MOUSE_INTENT_WINDOW_MS = 700 const MOUSE_INTENT_WINDOW_MS = 700
const MOUSE_OVER_INTENT_RECENCY_MS = 500
const MOUSE_INTENT_SELECTOR = [ const MOUSE_INTENT_SELECTOR = [
"[data-nav-focusable]", "[data-nav-focusable]",
"button", "button",
@@ -28,6 +28,7 @@ let mouseIdleTimer: number | null = null
let pointerVisible = false let pointerVisible = false
let mouseTravelPx = 0 let mouseTravelPx = 0
let lastMouseMoveAt = 0 let lastMouseMoveAt = 0
let lastAnyMouseMoveAt = 0
function clearMouseIdleTimer() { function clearMouseIdleTimer() {
if (mouseIdleTimer !== null) { if (mouseIdleTimer !== null) {
@@ -95,6 +96,7 @@ function registerMouseIntentTravel(event: MouseEvent): boolean {
function handleMouseMove(event: MouseEvent) { function handleMouseMove(event: MouseEvent) {
reportUserActivity() reportUserActivity()
showPointerFromMotion() showPointerFromMotion()
lastAnyMouseMoveAt = performance.now()
if (modality === "mouse") { if (modality === "mouse") {
applyInputState(true) applyInputState(true)
@@ -111,6 +113,13 @@ function handleMouseOver(event: MouseEvent) {
if (modality === "mouse") return if (modality === "mouse") return
if (!isMouseIntentTarget(event.target)) return if (!isMouseIntentTarget(event.target)) return
// Browsers fire mouseover/mouseenter when scrolling or re-rendering moves
// content under a stationary cursor (e.g. sideways season browsing while
// the pointer happens to rest over the episode grid). Without recent real
// pointer motion this is not mouse intent — activating mouse input here
// would let hover handlers steal keyboard/gamepad focus.
if (performance.now() - lastAnyMouseMoveAt > MOUSE_OVER_INTENT_RECENCY_MS) return
// Entering an interactive target indicates likely mouse intent. // Entering an interactive target indicates likely mouse intent.
activateMouseInput() activateMouseInput()
} }
@@ -36,6 +36,10 @@ const FOCUSABLE_ATTR = "data-nav-focusable"
const ROW_ATTR = "data-nav-row" const ROW_ATTR = "data-nav-row"
const COL_ATTR = "data-nav-col" const COL_ATTR = "data-nav-col"
const ENTRY_COL_ATTR = "data-nav-entry-col" const ENTRY_COL_ATTR = "data-nav-entry-col"
// Like ENTRY_COL_ATTR, but only applies when entering the row from above
// (ArrowDown). Lets a row declare a fixed landing column for downward entry
// without hijacking upward or horizontal moves.
const ENTRY_COL_FROM_ABOVE_ATTR = "data-nav-entry-col-from-above"
const SYNC_SCROLL_ROW_ATTR = "data-sync-scroll-row" const SYNC_SCROLL_ROW_ATTR = "data-sync-scroll-row"
const SYNC_SCROLL_GROUP_ATTR = "data-sync-scroll-group" const SYNC_SCROLL_GROUP_ATTR = "data-sync-scroll-group"
const DEFAULT_SYNC_SCROLL_GROUP = "browse" const DEFAULT_SYNC_SCROLL_GROUP = "browse"
@@ -99,7 +103,10 @@ function measureGlobalMetrics(group: string): boolean {
const rowStyle = window.getComputedStyle(row) const rowStyle = window.getComputedStyle(row)
const paddingLeft = parseFloat(rowStyle.paddingLeft || "0") const paddingLeft = parseFloat(rowStyle.paddingLeft || "0")
const viewportWidth = row.clientWidth 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 leftDeadzoneRaw = rowStyle.getPropertyValue(SYNC_SCROLL_LEFT_DEADZONE_VAR).trim()
const leftDeadzone = Number.isFinite(parseFloat(leftDeadzoneRaw)) const leftDeadzone = Number.isFinite(parseFloat(leftDeadzoneRaw))
? Math.max(0, parseFloat(leftDeadzoneRaw)) ? Math.max(0, parseFloat(leftDeadzoneRaw))
@@ -171,10 +178,7 @@ function getRowMaxScroll(row: HTMLElement): number {
if (n === 0) return 0 if (n === 0) return 0
const lastCol = n - 1 const lastCol = n - 1
const lastItemLeft = m.paddingLeft + lastCol * m.stride const lastItemLeft = m.paddingLeft + lastCol * m.stride
const maxVisibleLeft = Math.max( const maxVisibleLeft = Math.max(m.paddingLeft, m.viewportWidth - m.cardWidth - m.rightDeadzone)
m.paddingLeft,
m.viewportWidth - m.cardWidth - m.rightDeadzone,
)
return Math.max(0, lastItemLeft - maxVisibleLeft) return Math.max(0, lastItemLeft - maxVisibleLeft)
} }
@@ -276,14 +280,8 @@ function updateSyncedRowTarget(anchorCol: number, anchorRow: HTMLElement | null
if (!m) return if (!m) return
const itemLeft = m.paddingLeft + anchorCol * m.stride const itemLeft = m.paddingLeft + anchorCol * m.stride
const leftVisibleLimit = Math.max( const leftVisibleLimit = Math.max(m.paddingLeft, m.leftDeadzone)
m.paddingLeft, const rightVisibleLimit = Math.max(m.paddingLeft, m.viewportWidth - m.cardWidth - m.rightDeadzone)
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 // Keep focus inside the deadzone: no scroll while the focused item remains
// between left and right limits. // between left and right limits.
@@ -350,7 +348,9 @@ function getLocalSyncedRowCol(
element: HTMLElement, element: HTMLElement,
requestedCol: number, requestedCol: number,
): 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) if (cards.length === 0) return Math.max(0, requestedCol)
const cardCols = cards const cardCols = cards
@@ -462,9 +462,7 @@ function ensureElementVisibleVertically(element: HTMLElement) {
// Element finding / navigation (unchanged logic, uses getMetrics() now) // Element finding / navigation (unchanged logic, uses getMetrics() now)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function resolveOutOfBoundsNavigation( function resolveOutOfBoundsNavigation(context: OutOfBoundsNavigationContext): HTMLElement | null {
context: OutOfBoundsNavigationContext,
): HTMLElement | null {
const handlers = Array.from(outOfBoundsHandlers) const handlers = Array.from(outOfBoundsHandlers)
for (let i = handlers.length - 1; i >= 0; i--) { for (let i = handlers.length - 1; i >= 0; i--) {
const result = handlers[i]?.(context) const result = handlers[i]?.(context)
@@ -619,10 +617,7 @@ function findElementClosestToLogicalViewportX(
return nearest return nearest
} }
function findNextElement( function findNextElement(current: HTMLElement, direction: NavDirection): HTMLElement | null {
current: HTMLElement,
direction: NavDirection,
): HTMLElement | null {
const currentRow = parseInt(current.getAttribute(ROW_ATTR) || "0", 10) const currentRow = parseInt(current.getAttribute(ROW_ATTR) || "0", 10)
const currentCol = parseInt(current.getAttribute(COL_ATTR) || "0", 10) const currentCol = parseInt(current.getAttribute(COL_ATTR) || "0", 10)
const byRow = getElementsByRow() const byRow = getElementsByRow()
@@ -672,8 +667,21 @@ function findNextElement(
desiredCol.value = currentCol desiredCol.value = currentCol
} }
const entryTarget = findElementAt(targetRow, targetCol, true)
const targetRowElements = byRow.get(targetRow) ?? [] const targetRowElements = byRow.get(targetRow) ?? []
if (direction === "down") {
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))
if (fromAboveTarget) {
desiredCol.value = fromAboveTarget.col
return fromAboveTarget.element
}
}
}
const entryTarget = findElementAt(targetRow, targetCol, true)
const hasEntryOverride = targetRowElements.some((el) => el.element.hasAttribute(ENTRY_COL_ATTR)) const hasEntryOverride = targetRowElements.some((el) => el.element.hasAttribute(ENTRY_COL_ATTR))
if (hasEntryOverride) { if (hasEntryOverride) {
return entryTarget?.element || null return entryTarget?.element || null
+17 -7
View File
@@ -159,10 +159,7 @@ export function useMediaWebSocket() {
return { ...normalizeSeries(series, rootId, people), id, root_id: rootId } return { ...normalizeSeries(series, rootId, people), id, root_id: rootId }
} }
function normalizeCastMember( function normalizeCastMember(member: unknown, people: Map<number, Person>): CastMember {
member: unknown,
people: Map<number, Person>,
): CastMember {
if (!Array.isArray(member)) { if (!Array.isArray(member)) {
return { return {
name: "", 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[]>() const map = new Map<string, T[]>()
for (const item of items) { for (const item of items) {
const hash = getContentHash(item.id) const hash = getContentHash(item.id)
@@ -436,7 +436,14 @@ export function useMediaWebSocket() {
connected.value = wsRef.value?.readyState === WebSocket.OPEN 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) const state = ensureRootState(rootId)
state.peopleMap.clear() state.peopleMap.clear()
@@ -502,7 +509,10 @@ export function useMediaWebSocket() {
const parsed = Number(id) const parsed = Number(id)
const normalized = normalizePerson(person) const normalized = normalizePerson(person)
if (Number.isFinite(parsed)) { 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 },
)
} }
} }
} }
+128
View File
@@ -0,0 +1,128 @@
import type { TaskInfo } from "../types"
export type RootTaskInfo = TaskInfo & { root_id: string }
export interface ProgressRootState {
rootId: string
rootLabel: string
scanTarget: string | null
phaseLabel: string
phaseDetail: string | null
progressPercent: number
progressLabel: string | null
isDeterminate: boolean
toneClass: string
}
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(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
if (posixRoot) {
const lowRaw = posixRaw.toLowerCase()
const lowRoot = posixRoot.toLowerCase()
if (lowRaw === lowRoot) return posixRoot
if (lowRaw.startsWith(`${lowRoot}/`)) {
return posixRaw.slice(posixRoot.length).replace(/^\/+/, "")
}
}
return posixRaw
}
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(rootPath, detail)
if (detail.startsWith("Scanning:")) {
phaseLabel = isInitialScanMode ? "Scanning folders" : "Checking for updates"
} else if (/^Processing\s+\d+\s+(items|movies|series)/i.test(detail)) {
phaseLabel = "Preparing titles"
} else if (/^(Starting scan|No new items|Done|Scan cancelled)/i.test(detail)) {
phaseLabel = isInitialScanMode ? "Scanning folders" : "Checking for updates"
} else {
phaseLabel = "Fetching metadata"
phaseDetail = detail || null
}
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"
} else if (otherRunningCount > 0) {
phaseLabel = "Finalizing updates"
} else if (latestError) {
phaseLabel = "Needs attention"
phaseDetail = latestError.detail || "A background task failed"
toneClass = "activity-root-error"
}
return {
rootId,
rootLabel: rootId,
scanTarget,
phaseLabel,
phaseDetail,
progressPercent,
progressLabel,
isDeterminate,
toneClass,
}
}
export function computeProgressRoots(
tasks: Iterable<RootTaskInfo>,
getRootPath: (rootId: string) => string | null,
isInitialScanMode: boolean,
): ProgressRootState[] {
const byRoot = new Map<string, RootTaskInfo[]>()
for (const task of tasks) {
const list = byRoot.get(task.root_id) || []
list.push(task)
byRoot.set(task.root_id, list)
}
const rows: ProgressRootState[] = []
for (const [rootId, rootTasks] of byRoot) {
const row = describeRootProgress(rootId, getRootPath(rootId), rootTasks, isInitialScanMode)
if (row) rows.push(row)
}
return rows.sort((a, b) => a.rootLabel.localeCompare(b.rootLabel))
}
+10 -4
View File
@@ -17,9 +17,9 @@ const STORAGE_KEY = "MediaHive"
const RESOLUTION_PRIORITY: Record<string, number> = { const RESOLUTION_PRIORITY: Record<string, number> = {
"8K": 5, "8K": 5,
"4K": 4, "4K": 4,
"FHD": 3, FHD: 3,
"HD": 2, HD: 2,
"SD": 1, SD: 1,
} }
// Max resolution priority allowed for each preference level // Max resolution priority allowed for each preference level
@@ -59,7 +59,13 @@ function loadSettings(): MediaHiveSettings {
} catch { } catch {
// ignore parse errors // 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()) const settings = reactive<MediaHiveSettings>(loadSettings())
@@ -0,0 +1,9 @@
import { ref } from "vue"
// Settings is an overlay, not a route: opening it must not change the URL or
// the view behind it, so the open state is plain shared local state.
const settingsOpen = ref(false)
export function useSettingsOpen() {
return settingsOpen
}
+2 -2
View File
@@ -37,9 +37,9 @@ const router = createRouter({
component: EmptyRouteComponent, component: EmptyRouteComponent,
}, },
{ {
// Settings is now an overlay with no URL of its own; keep old links working.
path: "/settings", path: "/settings",
name: "settings", redirect: "/movies",
component: EmptyRouteComponent,
}, },
{ {
path: "/series", path: "/series",
+6 -12
View File
@@ -1,13 +1,7 @@
// Search Web Worker - runs search off the main thread // Search Web Worker - runs search off the main thread
// This file is loaded as a Web Worker, not imported as a module. // This file is loaded as a Web Worker, not imported as a module.
import type { import type { MovieUi, SeriesUi, MatchedPerson, MatchedEpisode, SearchMatchInfo } from "./types"
MovieUi,
SeriesUi,
MatchedPerson,
MatchedEpisode,
SearchMatchInfo,
} from "./types"
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Message types // Message types
@@ -65,7 +59,7 @@ let series: SeriesUi[] = []
function normalizeSearchText(value: string): string { function normalizeSearchText(value: string): string {
return value return value
.toLowerCase() .toLowerCase()
.replace(/[^a-z0-9\-]+/g, " ") .replace(/[^a-z0-9-]+/g, " ")
.trim() .trim()
.replace(/\s+/g, " ") .replace(/\s+/g, " ")
} }
@@ -277,7 +271,9 @@ function getMatchedWordIndexes(queryWords: string[], value: string): number[] {
} }
function mergePersonMatch(target: PersonMatch[], candidate: PersonCandidate): void { 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) {
if (!existing.roles.includes(candidate.role)) existing.roles.push(candidate.role) if (!existing.roles.includes(candidate.role)) existing.roles.push(candidate.role)
if (candidate.highlightRoles) existing.highlightRoles = true 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[] { function getBestContiguousWordRun(indexes: number[], availableIndexes: Set<number>): number[] {
const sorted = indexes const sorted = indexes.filter((index) => availableIndexes.has(index)).sort((a, b) => a - b)
.filter((index) => availableIndexes.has(index))
.sort((a, b) => a - b)
if (sorted.length === 0) return [] if (sorted.length === 0) return []
+5
View File
@@ -106,6 +106,11 @@ html.mouse-active ::-webkit-scrollbar-thumb:hover {
transition: top 0.6s cubic-bezier(0.4, 0, 0.2, 1); transition: top 0.6s cubic-bezier(0.4, 0, 0.2, 1);
} }
/* Leave room for the fixed app-exit button in desktop (pywebview) mode. */
.header--gui {
padding-right: 3.5rem;
}
.header::before { .header::before {
content: ""; content: "";
position: absolute; position: absolute;
+3 -4
View File
@@ -566,9 +566,7 @@ export function formatLanguageFlagTitle(
): string { ): string {
const names: string[] = [] const names: string[] = []
const variants: string[] = [] const variants: string[] = []
const external = new Set( const external = new Set((externalCodes ?? []).map((c) => resolveLanguageIdentifier(c)))
(externalCodes ?? []).map((c) => resolveLanguageIdentifier(c)),
)
let hasExternal = false let hasExternal = false
for (const code of entry.sourceCodes) { for (const code of entry.sourceCodes) {
const normalized = resolveLanguageIdentifier(code) const normalized = resolveLanguageIdentifier(code)
@@ -578,7 +576,8 @@ export function formatLanguageFlagTitle(
// Explicit region tags (en-us, es-419) become parenthesized variants; // Explicit region tags (en-us, es-419) become parenthesized variants;
// plain codes contribute their host country. // plain codes contribute their host country.
const suffix = normalized.split("-").pop() ?? "" const suffix = normalized.split("-").pop() ?? ""
const region = /^[a-z]{2}$|^\d{3}$/i.test(suffix) && normalized.includes("-") const region =
/^[a-z]{2}$|^\d{3}$/i.test(suffix) && normalized.includes("-")
? suffix.toUpperCase() ? suffix.toUpperCase()
: mapLanguageToCountry(code) : mapLanguageToCountry(code)
const regionName = region ? toRegionName(region) : null const regionName = region ? toRegionName(region) : null
+19
View File
@@ -0,0 +1,19 @@
pre-commit:
parallel: true
commands:
ruff-check:
glob: "*.py"
run: .venv/bin/ruff check --fix {staged_files}
stage_fixed: true
ruff-format:
glob: "*.py"
run: .venv/bin/ruff format {staged_files}
stage_fixed: true
oxlint:
glob: "frontend/src/**"
run: npm --prefix frontend run lint
stage_fixed: true
oxfmt:
glob: "frontend/src/**"
run: npm --prefix frontend run format
stage_fixed: true
+1
View File
@@ -19,6 +19,7 @@ from platformdirs import user_config_path, user_log_path
class Config(msgspec.Struct, omit_defaults=True): class Config(msgspec.Struct, omit_defaults=True):
roots: dict[str, str] | None = None roots: dict[str, str] | None = None
auto_update: bool = True
# Runtime config shared between the CLI entrypoint and the server process via # Runtime config shared between the CLI entrypoint and the server process via
+27 -6
View File
@@ -37,12 +37,33 @@ _SUBTITLE_FLAG_TOKENS = {"forced", "sdh", "cc", "hi", "dhi", "commentary", "sign
# ISO 639-1 -> ISO 639-2/B for common sidecar language tags, so they merge # ISO 639-1 -> ISO 639-2/B for common sidecar language tags, so they merge
# with the codes ffmpeg reports for embedded tracks. # with the codes ffmpeg reports for embedded tracks.
_ISO_639_1_TO_639_2 = { _ISO_639_1_TO_639_2 = {
"ar": "ara", "cs": "ces", "da": "dan", "de": "deu", "el": "ell", "ar": "ara",
"en": "eng", "es": "esp", "fi": "fin", "fr": "fra", "he": "heb", "cs": "ces",
"hi": "hin", "hu": "hun", "id": "ind", "it": "ita", "ja": "jpn", "da": "dan",
"ko": "kor", "nl": "nld", "no": "nor", "pl": "pol", "pt": "por", "de": "deu",
"ru": "rus", "sv": "swe", "th": "tha", "tr": "tur", "uk": "ukr", "el": "ell",
"vi": "vie", "zh": "zho", "en": "eng",
"es": "esp",
"fi": "fin",
"fr": "fra",
"he": "heb",
"hi": "hin",
"hu": "hun",
"id": "ind",
"it": "ita",
"ja": "jpn",
"ko": "kor",
"nl": "nld",
"no": "nor",
"pl": "pol",
"pt": "por",
"ru": "rus",
"sv": "swe",
"th": "tha",
"tr": "tur",
"uk": "ukr",
"vi": "vie",
"zh": "zho",
} }
# Caches for expensive operations. These are per-scan only: the scanner # Caches for expensive operations. These are per-scan only: the scanner
+43 -1
View File
@@ -29,6 +29,7 @@ from pathlib import Path
import aiofiles import aiofiles
import msgspec import msgspec
import msgspec.structs
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import ( from fastapi.responses import (
@@ -39,7 +40,8 @@ from fastapi.responses import (
) )
from fastapi_vue import Frontend, env from fastapi_vue import Frontend, env
from mediahive.config import config, load_config, log_dir from mediahive import updater
from mediahive.config import config, load_config, log_dir, save_config
from mediahive.hivescan.images import close_image_client from mediahive.hivescan.images import close_image_client
from mediahive.hivescan.scanner import RootScanner from mediahive.hivescan.scanner import RootScanner
from mediahive.hivescan.tmdb_client import close_http_client from mediahive.hivescan.tmdb_client import close_http_client
@@ -1079,6 +1081,46 @@ async def get_version():
return {"version": version} return {"version": version}
# --- Auto-update (Velopack, GUI builds only) ---
@app.get("/api/update")
async def get_update_status():
"""Version, auto-update preference, and any staged (downloaded) update."""
version = (await get_version())["version"]
cfg = load_config()
pending = await asyncio.to_thread(updater.pending_update)
return {
"version": version,
"auto_update": cfg.auto_update,
"pending_version": pending,
}
@app.put("/api/config/auto-update")
async def put_auto_update(request: Request):
"""Enable/disable automatic update downloads (persisted in config)."""
body = msgspec.json.decode(await request.body())
enabled = bool(body.get("enabled", True))
cfg = load_config()
save_config(msgspec.structs.replace(cfg, auto_update=enabled))
with suppress(AttributeError, TypeError):
config.auto_update = enabled
if enabled:
# Catch up on anything missed while updates were disabled.
asyncio.create_task(asyncio.to_thread(updater.check_and_download))
return {"auto_update": enabled}
@app.post("/api/update/restart")
async def restart_for_update():
"""Apply the staged update and restart into it (never returns on success)."""
applied = await asyncio.to_thread(updater.apply_pending_and_restart)
if not applied:
raise HTTPException(status_code=404, detail="No downloaded update to apply")
return {"status": "restarting"}
def _read_log() -> str: def _read_log() -> str:
"""Return the full application log file.""" """Return the full application log file."""
path = log_dir() / "mediahive.log" path = log_dir() / "mediahive.log"
+82
View File
@@ -0,0 +1,82 @@
"""Optional Velopack auto-update integration (GUI builds only).
In development and portable-ZIP runs Velopack is either not installed or the
app is not a Velopack installation; every helper degrades to a no-op then, so
callers never need to special-case those environments.
"""
import logging
from mediahive.config import load_config
logger = logging.getLogger(__name__)
VELOPACK_REPO_URL = "https://git.zi.fi/LeoVasanko/mediahive"
def _manager():
"""Return a Velopack UpdateManager, or None when updates are unavailable."""
try:
import velopack
except ImportError:
return None
try:
return velopack.UpdateManager(velopack.GiteaSource(VELOPACK_REPO_URL))
except RuntimeError, OSError:
# Not a Velopack installation (dev / portable run).
return None
def pending_update() -> str | None:
"""Version of a downloaded update staged for the next launch, if any."""
mgr = _manager()
if mgr is None:
return None
try:
asset = mgr.get_update_pending_restart()
except RuntimeError, OSError:
return None
return str(asset.Version) if asset is not None else None
def apply_pending_and_restart() -> bool:
"""Apply the staged update and restart into it. False when nothing pending."""
mgr = _manager()
if mgr is None:
return False
try:
asset = mgr.get_update_pending_restart()
if asset is None:
return False
logger.info("Velopack: applying staged update %s and restarting", asset.Version)
mgr.apply_updates_and_restart(asset)
except (RuntimeError, OSError) as exc:
logger.warning("Velopack: failed to apply staged update: %s", exc)
return False
return True
def check_and_download() -> None:
"""Download available updates in the background, unless disabled in config.
Downloaded updates are applied automatically by Velopack on the next app
start, so the running session is never interrupted. Network failures and
non-Velopack runs are expected and skipped quietly.
"""
if not load_config().auto_update:
logger.info("Velopack: automatic updates disabled, skipping check")
return
mgr = _manager()
if mgr is None:
return
try:
info = mgr.check_for_updates()
if info is None:
logger.info("Velopack: no update available")
return
version = info.TargetFullRelease.Version
logger.info("Velopack: downloading update %s", version)
mgr.download_updates(info)
logger.info("Velopack: update %s staged, applies on next launch", version)
except (RuntimeError, OSError) as exc:
logger.info("Velopack update check skipped: %s", exc)
+13 -14
View File
@@ -37,6 +37,7 @@ from fastapi_vue.logging import patch_log_config
from fastapi_vue.startupbox import print_box from fastapi_vue.startupbox import print_box
from tracerite.html import html_traceback from tracerite.html import html_traceback
from mediahive import updater
from mediahive.config import config, load_config, log_dir, save_config from mediahive.config import config, load_config, log_dir, save_config
from mediahive.volume_control import get_volume, set_volume, volume_max from mediahive.volume_control import get_volume, set_volume, volume_max
@@ -48,7 +49,6 @@ HEALTH_TIMEOUT = 2 # seconds
BACKEND_HEALTH_REQUEST_TIMEOUT = 2 # seconds BACKEND_HEALTH_REQUEST_TIMEOUT = 2 # seconds
BACKEND_HEALTH_POLL_SECONDS = 0.25 BACKEND_HEALTH_POLL_SECONDS = 0.25
MPC_BE_URL = "http://127.0.0.1:13579" MPC_BE_URL = "http://127.0.0.1:13579"
VELOPACK_REPO_URL = "https://git.zi.fi/LeoVasanko/mediahive"
GAMEPAD_REPEAT_SECONDS = 0.008 GAMEPAD_REPEAT_SECONDS = 0.008
GAMEPAD_POLL_SECONDS = 0.008 GAMEPAD_POLL_SECONDS = 0.008
MPC_BE_FRAME_REPEAT_SECONDS = 0.016 MPC_BE_FRAME_REPEAT_SECONDS = 0.016
@@ -989,25 +989,14 @@ def _velopack_startup() -> None:
def _check_for_updates() -> None: def _check_for_updates() -> None:
"""Download available updates in the background. """Download available updates in the background (unless disabled in config).
Downloaded updates are applied automatically by Velopack on the next app Downloaded updates are applied automatically by Velopack on the next app
start (via _velopack_startup), so the running session is never start (via _velopack_startup), so the running session is never
interrupted. Not a Velopack install (dev/portable) and network failures interrupted. Not a Velopack install (dev/portable) and network failures
are expected and skipped quietly. are expected and skipped quietly.
""" """
try: updater.check_and_download()
mgr = velopack.UpdateManager(velopack.GiteaSource(VELOPACK_REPO_URL))
info = mgr.check_for_updates()
if info is None:
logger.info("Velopack: no update available")
return
version = info.TargetFullRelease.Version
logger.info("Velopack: downloading update %s", version)
mgr.download_updates(info)
logger.info("Velopack: update %s staged, applies on next launch", version)
except (RuntimeError, OSError) as exc:
logger.info("Velopack update check skipped: %s", exc)
def gui_main() -> None: def gui_main() -> None:
@@ -1033,6 +1022,16 @@ class JsApi:
result = self._window.create_file_dialog(webview.FOLDER_DIALOG) result = self._window.create_file_dialog(webview.FOLDER_DIALOG)
return result[0] if result else None return result[0] if result else None
def exit_app(self) -> None:
"""Close the window, shutting the app down (like the OS close button)."""
if self._window:
self._window.destroy()
def toggle_fullscreen(self) -> None:
"""Switch between fullscreen and windowed mode in place."""
if self._window:
self._window.toggle_fullscreen()
def set_volume(self, x: float) -> None: def set_volume(self, x: float) -> None:
"""Set system master volume from slider position ``x`` (0.0 .. 1.5).""" """Set system master volume from slider position ``x`` (0.0 .. 1.5)."""
# Clamp to the platform's maximum so the slider never exceeds what # Clamp to the platform's maximum so the slider never exceeds what
+41 -38
View File
@@ -58,11 +58,14 @@ gui = [
"velopack>=1.2", "velopack>=1.2",
"pythonnet>=3.1.0rc0; platform_system == 'Windows' and python_version >= '3.14'", "pythonnet>=3.1.0rc0; platform_system == 'Windows' and python_version >= '3.14'",
"pyinstaller>=6.0", "pyinstaller>=6.0",
# scripts/guibuild.py reads the version with it (same logic as hatch-vcs).
"setuptools_scm>=8",
] ]
[dependency-groups] [dependency-groups]
dev = [ dev = [
"httpx>=0.28.1", "httpx>=0.28.1",
"lefthook>=2.1.14",
"ruff>=0.15.14", "ruff>=0.15.14",
"setuptools-scm>=8", "setuptools-scm>=8",
] ]
@@ -73,52 +76,52 @@ preview = true
[tool.ruff.lint] [tool.ruff.lint]
select = ["ALL"] select = ["ALL"]
ignore = [ ignore = [
"D203", "incorrect-blank-line-before-class",
"D213", "multi-line-summary-second-line",
"DOC201", "docstring-missing-returns",
"COM812", "missing-trailing-comma",
"T201", "print",
"EM", "EM",
"TC", "TC",
"TRY003", "raise-vanilla-args",
"S", "S",
"CPY001", "missing-copyright-notice",
"PLR", "PLR",
"PLW", "PLW",
# TEMP suppressions - revisit and remove after focused cleanup passes. # TEMP suppressions - revisit and remove after focused cleanup passes.
"C901", "complex-structure",
"DOC501", "docstring-missing-exception",
"ANN201", "missing-return-type-undocumented-public-function",
"D103", "undocumented-public-function",
"FBT001", "boolean-type-hint-positional-argument",
"ANN001", "missing-type-function-argument",
"D102", "undocumented-public-method",
"TRY300", "try-consider-else",
"D107", "undocumented-public-init",
"ANN202", "missing-return-type-private-function",
"B904", "raise-without-from-inside-except",
"ASYNC220", "create-subprocess-in-async-function",
"E501", "line-too-long",
"INP001", "implicit-namespace-package",
"FBT002", "boolean-default-value-positional-argument",
"PLC0415", "import-outside-top-level",
"D101", "undocumented-public-class",
"RUF006", "asyncio-dangling-task",
"SLF001", "private-member-access",
"ASYNC240", "blocking-path-method-in-async-function",
"N801", "invalid-class-name",
"SIM102", "collapsible-if",
"DTZ005", "call-datetime-now-without-tzinfo",
"RUF034", "useless-if-else",
"D415", "missing-terminal-punctuation",
"D400", "missing-trailing-period",
"ANN401", "any-type",
# Allow en-dash in docstrings (used for list formatting) # Allow en-dash in docstrings (used for list formatting)
"RUF002", "ambiguous-unicode-character-docstring",
# Allow ctypes COM variable names (CLSID_*, IID_*, etc.) # Allow ctypes COM variable names (CLSID_*, IID_*, etc.)
"N806", "non-lowercase-variable-in-function",
# Allow inline comments that describe output formats # Allow inline comments that describe output formats
"ERA001", "commented-out-code",
# Allow unused local variables in ctypes COM boilerplate # Allow unused local variables in ctypes COM boilerplate
"F841", "unused-variable",
] ]
+48 -9
View File
@@ -1,11 +1,20 @@
#!/usr/bin/env -S uv run #!/usr/bin/env -S uv run
# /// script
# requires-python = ">=3.14"
# dependencies = [
# "mediahive[gui]",
# ]
#
# [tool.uv.sources]
# mediahive = { path = "../" }
# ///
"""Build the desktop GUI application and package it with Velopack. """Build the desktop GUI application and package it with Velopack.
Usage: Usage:
uv run scripts/guibuild.py uv run scripts/guibuild.py
This runs in the project environment where dependencies Self-contained: inline script dependencies above make uv resolve the
are available via pyproject.toml. package (with the gui extra) plus this script's own direct imports.
This script: This script:
1. Reads the version from pyproject.toml 1. Reads the version from pyproject.toml
@@ -82,10 +91,34 @@ class _Platform(NamedTuple):
def _platform() -> _Platform: def _platform() -> _Platform:
if sys.platform == "win32": if sys.platform == "win32":
return _Platform("win64", "win", "win-x64", "MediaHive", "mediahive.ico", "MediaHive.exe", ".exe") return _Platform(
"win64",
"win",
"win-x64",
"MediaHive",
"mediahive.ico",
"MediaHive.exe",
".exe",
)
if sys.platform == "darwin": if sys.platform == "darwin":
return _Platform("macos", "osx", "osx-arm64", "MediaHive.app", "mediahive.icns", "MediaHive", ".pkg") return _Platform(
return _Platform("linux", "linux", "linux-x64", "MediaHive", "mediahive.png", "MediaHive", ".AppImage") "macos",
"osx",
"osx-arm64",
"MediaHive.app",
"mediahive.icns",
"MediaHive",
".pkg",
)
return _Platform(
"linux",
"linux",
"linux-x64",
"MediaHive",
"mediahive.png",
"MediaHive",
".AppImage",
)
def setup_artifact_name() -> str: def setup_artifact_name() -> str:
@@ -253,7 +286,7 @@ def _dotnet_runtime_major(exe: Path) -> int | None:
result = subprocess.run( result = subprocess.run(
[str(exe), "--list-runtimes"], capture_output=True, text=True, timeout=30 [str(exe), "--list-runtimes"], capture_output=True, text=True, timeout=30
) )
except (OSError, subprocess.TimeoutExpired): except OSError, subprocess.TimeoutExpired:
return None return None
if result.returncode != 0: if result.returncode != 0:
return None return None
@@ -455,20 +488,26 @@ def force_macos_user_install(pkg: Path) -> None:
components = list(expanded.glob("*.pkg")) components = list(expanded.glob("*.pkg"))
if len(components) != 1: if len(components) != 1:
contents = sorted(p.name for p in expanded.iterdir()) contents = sorted(p.name for p in expanded.iterdir())
raise RuntimeError(f"Unexpected pkg layout: components={components} in {contents}") raise RuntimeError(
f"Unexpected pkg layout: components={components} in {contents}"
)
component = components[0] component = components[0]
if component.is_dir(): if component.is_dir():
comp_dir = component comp_dir = component
else: else:
comp_dir = expanded / (component.stem + "-component") comp_dir = expanded / (component.stem + "-component")
subprocess.run(["pkgutil", "--expand", str(component), str(comp_dir)], check=True) subprocess.run(
["pkgutil", "--expand", str(component), str(comp_dir)], check=True
)
postinstall = comp_dir / "Scripts" / "postinstall" postinstall = comp_dir / "Scripts" / "postinstall"
script = postinstall.read_text() script = postinstall.read_text()
if 'sudo -u "$USER" ' not in script: if 'sudo -u "$USER" ' not in script:
raise RuntimeError("Unexpected postinstall script: sudo prefix not found") raise RuntimeError("Unexpected postinstall script: sudo prefix not found")
postinstall.write_text(script.replace('sudo -u "$USER" ', "")) postinstall.write_text(script.replace('sudo -u "$USER" ', ""))
if comp_dir is not component: if comp_dir is not component:
subprocess.run(["pkgutil", "--flatten", str(comp_dir), str(component)], check=True) subprocess.run(
["pkgutil", "--flatten", str(comp_dir), str(component)], check=True
)
shutil.rmtree(comp_dir) shutil.rmtree(comp_dir)
subprocess.run(["pkgutil", "--flatten", str(expanded), str(pkg)], check=True) subprocess.run(["pkgutil", "--flatten", str(expanded), str(pkg)], check=True)
+6 -2
View File
@@ -73,7 +73,9 @@ def load_token() -> str:
# MediaHive-macos-setup.pkg, MediaHive-linux-setup.AppImage, # MediaHive-macos-setup.pkg, MediaHive-linux-setup.AppImage,
# MediaHive-win64-portable.zip) so /releases/download/latest/<name> links # MediaHive-win64-portable.zip) so /releases/download/latest/<name> links
# stay valid. The version comes from setuptools_scm instead. # stay valid. The version comes from setuptools_scm instead.
_ARTIFACT_RE = re.compile(r"^MediaHive-(?!\d)[A-Za-z0-9._-]+\.(?:zip|dmg|exe|pkg|AppImage)$") _ARTIFACT_RE = re.compile(
r"^MediaHive-(?!\d)[A-Za-z0-9._-]+\.(?:zip|dmg|exe|pkg|AppImage)$"
)
def read_version() -> str: def read_version() -> str:
@@ -89,7 +91,9 @@ def read_version() -> str:
def find_releasable_artifacts() -> list[Path]: def find_releasable_artifacts() -> list[Path]:
"""Return platform artifact paths in build/.""" """Return platform artifact paths in build/."""
build_dir = REPO_ROOT / "build" build_dir = REPO_ROOT / "build"
return [p for p in sorted(build_dir.glob("MediaHive-*")) if _ARTIFACT_RE.match(p.name)] return [
p for p in sorted(build_dir.glob("MediaHive-*")) if _ARTIFACT_RE.match(p.name)
]
def find_dist_files(version: str) -> list[Path]: def find_dist_files(version: str) -> list[Path]: