2 Commits
Author SHA1 Message Date
LeoVasanko 1223b8cee7 Rework settings/log into draggable dialogs; selectable, copyable log window
release / gui-build (linux, bash) (push) Successful in 59s
release / gui-build (windows, cmd) (push) Successful in 1m21s
release / gui-build (macos, bash) (push) Successful in 1m54s
release / publish-pypi (push) Successful in 12s
Settings and application log are now separate floating windows with
draggable title bars and top-right close buttons instead of one shared
two-column dialog whose log column stretched the layout. The log opens
from a small Log button on the settings version/update row (GUI mode
only), displays in its own wider window on the left with internal
scrolling, and its text is selectable with a Copy button for the full
log.
2026-09-24 06:30:42 +00:00
LeoVasanko 7e99ec2f16 Updated README and gamepad controls image. 2026-09-24 06:30:39 +00:00
3 changed files with 226 additions and 110 deletions
+4 -8
View File
@@ -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. | ![Controller bindings](https://git.zi.fi/LeoVasanko/mediahive/media/branch/main/docs/controls.avif)
| Keyboard | Arrow keys move focus, `Enter` activates the focused item, `Escape` goes back, and `/` or `Ctrl`/`Cmd`+`F` jumps to search. | *Navigational controls work across the application. Search bar offers OSD keyboard. In-player controls are currently available only on MPC-BE, with its WebUI enabled.*
| Gamepad | D-pad or left stick moves focus, `A` selects or plays, and `B` goes back. `RB`/`LB` browses adjacent items, and the Search bar has an OSD keyboard. Player controls during playback. |
## Recommended Players ## Recommended Players
@@ -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.
![Controller bindings](https://git.zi.fi/LeoVasanko/mediahive/media/branch/main/docs/controls.avif)
**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

+221 -101
View File
@@ -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"
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-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 */