Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1223b8cee7 | ||
|
|
7e99ec2f16 | ||
|
|
38a10725e0 | ||
|
|
8b0c7a7af1 | ||
|
|
d01bf1d88f | ||
|
|
9b12e53039 | ||
|
|
7e2fcc05f6 | ||
|
|
ecd5d3bc9e | ||
|
|
82d5eb28fb |
@@ -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
|
||||||
|
|||||||
@@ -32,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. |
|

|
||||||
| 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
|
||||||
|
|
||||||
@@ -46,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:
|
||||||
|
|
||||||
|

|
||||||
|
|||||||
@@ -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 |
+16
-145
@@ -45,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"
|
||||||
@@ -209,7 +212,6 @@ import type {
|
|||||||
SeriesUi,
|
SeriesUi,
|
||||||
MediaItem,
|
MediaItem,
|
||||||
EpisodeWithSeries,
|
EpisodeWithSeries,
|
||||||
TaskInfo,
|
|
||||||
SeriesResumePoint,
|
SeriesResumePoint,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
import {
|
import {
|
||||||
@@ -228,6 +230,8 @@ import {
|
|||||||
} from "./composables/useKeyboardNavigation"
|
} 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"
|
||||||
@@ -292,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 {
|
||||||
@@ -313,132 +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
|
||||||
@@ -453,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
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1075,7 +946,7 @@ const activePanelScrollTop = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const headerStyle = computed(() => {
|
const headerStyle = computed(() => {
|
||||||
if (route.path === "/settings") {
|
if (isSettingsView.value) {
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|||||||
+32
-3
@@ -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}`)
|
||||||
|
}
|
||||||
|
|||||||
+664
-123
@@ -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>
|
||||||
|
|||||||
@@ -126,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">
|
||||||
@@ -1845,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 {
|
||||||
@@ -1872,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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+43
-1
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ 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]
|
||||||
|
|||||||
+11
-2
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user