Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1223b8cee7 | ||
|
|
7e99ec2f16 |
@@ -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,9 +45,6 @@ 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.
|
||||||
|
|
||||||

|
|
||||||
**Gamepad controls are currently available only on MPC-BE, with its WebUI enabled.**
|
|
||||||
|
|
||||||
## Background
|
## Background
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 88 KiB |
+222
-102
@@ -91,35 +91,42 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Floating settings window -->
|
<!-- Floating settings/log overlay -->
|
||||||
<div v-if="showSettings" class="settings-view" @click.self="closeSettings">
|
<div v-if="showSettings" class="settings-view" @click.self="closeSettings">
|
||||||
<div class="settings-window">
|
<!-- Floating log window (left side) -->
|
||||||
<div class="settings-header">
|
<div v-if="showLog" ref="logWindowEl" class="log-window" :style="logDragStyle">
|
||||||
<button class="settings-back" @click="closeSettings">
|
<div class="dialog-header" @pointerdown="onLogDragStart">
|
||||||
<svg
|
<h1 class="settings-title">Application log</h1>
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<div class="log-actions">
|
||||||
width="24"
|
<button class="log-copy-btn" title="Copy entire log" @click="copyLog">
|
||||||
height="24"
|
{{ logCopied ? "Copied" : "Copy" }}
|
||||||
viewBox="0 0 24 24"
|
</button>
|
||||||
fill="none"
|
<button class="dialog-close" title="Close log" @click="closeLog">✕</button>
|
||||||
stroke="currentColor"
|
</div>
|
||||||
stroke-width="2"
|
</div>
|
||||||
stroke-linecap="round"
|
<pre ref="logEl" class="diag-log" @scroll="onLogScroll">{{ appLog }}</pre>
|
||||||
stroke-linejoin="round"
|
</div>
|
||||||
>
|
|
||||||
<path d="M19 12H5M12 19l-7-7 7-7" />
|
<!-- Floating settings window (right side) -->
|
||||||
</svg>
|
<div ref="settingsWindowEl" class="settings-window" :style="settingsDragStyle">
|
||||||
<span>Back</span>
|
<div class="dialog-header" @pointerdown="onSettingsDragStart">
|
||||||
</button>
|
|
||||||
<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-scroll">
|
<div class="settings-scroll">
|
||||||
<div class="settings-body" :class="{ 'settings-body--no-log': !hasLog }">
|
<div class="settings-body">
|
||||||
<section class="settings-section settings-update">
|
<section class="settings-section">
|
||||||
<div class="update-row">
|
<div class="update-row">
|
||||||
<span class="update-version"> MediaHive {{ updateStatus?.version ?? "…" }} </span>
|
<span class="update-version"> MediaHive {{ updateStatus?.version ?? "…" }} </span>
|
||||||
|
<button
|
||||||
|
v-if="isDesktopApp"
|
||||||
|
class="log-open-btn"
|
||||||
|
title="View application log"
|
||||||
|
@click="openLog"
|
||||||
|
>
|
||||||
|
Log
|
||||||
|
</button>
|
||||||
<label class="update-auto">
|
<label class="update-auto">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -336,11 +343,6 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="hasLog" class="settings-section settings-diag">
|
|
||||||
<h2 class="settings-section-title">Application log</h2>
|
|
||||||
<pre ref="logEl" class="diag-log" @scroll="onLogScroll">{{ appLog }}</pre>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -486,8 +488,68 @@ function openSettings() {
|
|||||||
|
|
||||||
function closeSettings() {
|
function closeSettings() {
|
||||||
showSettings.value = false
|
showSettings.value = false
|
||||||
|
showLog.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
@@ -557,7 +619,6 @@ async function restartToUpdate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const appLog = ref("")
|
const appLog = ref("")
|
||||||
const hasLog = ref(false)
|
|
||||||
const logEl = ref<HTMLElement | null>(null)
|
const logEl = ref<HTMLElement | null>(null)
|
||||||
let logSocket: WebSocket | null = null
|
let logSocket: WebSocket | null = null
|
||||||
let pinnedToBottom = true
|
let pinnedToBottom = true
|
||||||
@@ -568,22 +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))
|
||||||
hasLog.value = appLog.value.length > 0
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -636,6 +723,14 @@ watch(showSettings, (visible) => {
|
|||||||
if (visible) {
|
if (visible) {
|
||||||
void refreshPlayers()
|
void refreshPlayers()
|
||||||
void refreshUpdateStatus()
|
void refreshUpdateStatus()
|
||||||
|
} else {
|
||||||
|
showLog.value = false
|
||||||
|
disconnectLogSocket()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(showLog, (visible) => {
|
||||||
|
if (visible) {
|
||||||
connectLogSocket()
|
connectLogSocket()
|
||||||
} else {
|
} else {
|
||||||
disconnectLogSocket()
|
disconnectLogSocket()
|
||||||
@@ -823,6 +918,8 @@ onUnmounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
gap: 2.5rem;
|
||||||
|
padding-left: 5vw;
|
||||||
padding-right: 15vw;
|
padding-right: 15vw;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -830,6 +927,21 @@ onUnmounted(() => {
|
|||||||
width: fit-content;
|
width: fit-content;
|
||||||
max-width: 94vw;
|
max-width: 94vw;
|
||||||
height: min(85vh, 56.25rem);
|
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;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-window {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 20rem;
|
||||||
|
max-width: 75rem;
|
||||||
|
height: min(85vh, 56.25rem);
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
@@ -842,42 +954,59 @@ onUnmounted(() => {
|
|||||||
@media (max-width: 43.75rem) {
|
@media (max-width: 43.75rem) {
|
||||||
.settings-view {
|
.settings-view {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
padding-left: 0;
|
||||||
padding-right: 0;
|
padding-right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-window {
|
.settings-window,
|
||||||
|
.log-window {
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.log-window {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-header {
|
.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 {
|
||||||
@@ -886,10 +1015,6 @@ onUnmounted(() => {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-header-spacer {
|
|
||||||
width: 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-scroll {
|
.settings-scroll {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -898,45 +1023,8 @@ onUnmounted(() => {
|
|||||||
.settings-body {
|
.settings-body {
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 2rem 1.5rem;
|
padding: 2rem 1.5rem;
|
||||||
display: grid;
|
width: 32rem;
|
||||||
grid-template-columns: minmax(23.75rem, 28.75rem) minmax(20rem, 37.5rem);
|
max-width: 100%;
|
||||||
column-gap: 3rem;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-body > .settings-section {
|
|
||||||
grid-column: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-body > .settings-update {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
grid-row: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-body > .settings-diag {
|
|
||||||
grid-column: 2;
|
|
||||||
grid-row: 2 / span 3;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-body--no-log {
|
|
||||||
grid-template-columns: minmax(23.75rem, 35rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 56.25rem) {
|
|
||||||
.settings-body,
|
|
||||||
.settings-body--no-log {
|
|
||||||
grid-template-columns: minmax(0, 35rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-body > .settings-section,
|
|
||||||
.settings-body > .settings-diag {
|
|
||||||
grid-column: 1;
|
|
||||||
grid-row: auto;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-section {
|
.settings-section {
|
||||||
@@ -1233,25 +1321,57 @@ onUnmounted(() => {
|
|||||||
.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;
|
|
||||||
width: 100%;
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 15rem;
|
min-height: 0;
|
||||||
max-height: none;
|
overflow: auto;
|
||||||
overflow-y: auto;
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0.625rem;
|
padding: 0.625rem 1.5rem 1.5rem;
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
user-select: text;
|
||||||
|
cursor: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 56.25rem) {
|
.log-actions {
|
||||||
.diag-log {
|
display: inline-flex;
|
||||||
flex: none;
|
align-items: center;
|
||||||
max-height: 60vh;
|
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 */
|
/* Library activity footer strip */
|
||||||
|
|||||||
Reference in New Issue
Block a user