Compare commits
8 Commits
92a22fe253
...
0c2fa2479c
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c2fa2479c | |||
| 7e4b078d1f | |||
| 04fa5ab054 | |||
| e6dabbe0a6 | |||
| 507be5eeab | |||
| a4291964de | |||
| 5c28a69055 | |||
| de303e9327 |
|
|
@ -5,6 +5,7 @@
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
|
"postbuild": "node scripts/gzip-dist.mjs",
|
||||||
"dev": "vite"
|
"dev": "vite"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
|
||||||
32
scripts/gzip-dist.mjs
Normal file
32
scripts/gzip-dist.mjs
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { createReadStream, createWriteStream } from 'node:fs';
|
||||||
|
import { readdir, unlink } from 'node:fs/promises';
|
||||||
|
import { extname, join } from 'node:path';
|
||||||
|
import { pipeline } from 'node:stream/promises';
|
||||||
|
import { createGzip } from 'node:zlib';
|
||||||
|
|
||||||
|
const EXTS = new Set(['.html', '.js', '.css', '.svg']);
|
||||||
|
const DIST = 'dist';
|
||||||
|
|
||||||
|
async function* walk(dir) {
|
||||||
|
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
||||||
|
const full = join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
yield* walk(full);
|
||||||
|
} else if (EXTS.has(extname(entry.name))) {
|
||||||
|
yield full;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
for await (const file of walk(DIST)) {
|
||||||
|
await pipeline(
|
||||||
|
createReadStream(file),
|
||||||
|
createGzip({ level: 9 }),
|
||||||
|
createWriteStream(`${file}.gz`),
|
||||||
|
);
|
||||||
|
await unlink(file);
|
||||||
|
count++;
|
||||||
|
console.log(` gzip ${file}`);
|
||||||
|
}
|
||||||
|
console.log(`\nGzipped ${count} file${count === 1 ? '' : 's'}.`);
|
||||||
|
|
@ -125,33 +125,40 @@ export default function DeviceDetailOverlay({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Prefer an explicit .lst-derived mediaSet stored in config; fall back to pattern detection.
|
|
||||||
const [mediaSetFiles, setMediaSetFiles] = useState<MediaSetEntry[] | null>(null);
|
const [mediaSetFiles, setMediaSetFiles] = useState<MediaSetEntry[] | null>(null);
|
||||||
const prevDeviceNumberRef = useRef(device.number);
|
const detectTokenRef = useRef(0);
|
||||||
|
|
||||||
|
// Cancel any in-flight detection and reset when navigating to a different device.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const deviceChanged = prevDeviceNumberRef.current !== device.number;
|
++detectTokenRef.current;
|
||||||
prevDeviceNumberRef.current = device.number;
|
setMediaSetFiles(null);
|
||||||
|
}, [device.number]);
|
||||||
|
|
||||||
|
// Sync config-backed media_set to display state whenever it is set explicitly
|
||||||
|
// (e.g. after a playlist is mounted).
|
||||||
|
useEffect(() => {
|
||||||
if (Array.isArray(deviceData.media_set) && deviceData.media_set.length > 1) {
|
if (Array.isArray(deviceData.media_set) && deviceData.media_set.length > 1) {
|
||||||
setMediaSetFiles(deviceData.media_set as MediaSetEntry[]);
|
setMediaSetFiles(deviceData.media_set as MediaSetEntry[]);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
// Don't run pattern detection when switching devices — only when the URL
|
}, [deviceData.media_set]);
|
||||||
// is actively being changed within the same device.
|
|
||||||
if (deviceChanged || !deviceData.url) { setMediaSetFiles(null); return; }
|
// Pattern-detect sibling numbered files for the given URL. Called only when
|
||||||
const match = (deviceData.url as string).match(/^(.+?)(\d+)(\.[^.]+)$/);
|
// the user actively sets a URL — never on overlay load or device switch.
|
||||||
if (!match) { setMediaSetFiles(null); return; }
|
const detectMediaSet = async (url: string) => {
|
||||||
|
const token = ++detectTokenRef.current;
|
||||||
|
setMediaSetFiles(null);
|
||||||
|
const match = url.match(/^(.+?)(\d+)(\.[^.]+)$/);
|
||||||
|
if (!match) return;
|
||||||
const [, prefix, , ext] = match;
|
const [, prefix, , ext] = match;
|
||||||
const candidates: string[] = [];
|
const candidates: string[] = [];
|
||||||
for (let i = 1; i <= 10; i++) candidates.push(`${prefix}${i}${ext}`);
|
for (let i = 1; i <= 10; i++) candidates.push(`${prefix}${i}${ext}`);
|
||||||
let cancelled = false;
|
const flags = await Promise.all(
|
||||||
Promise.all(candidates.map(f => stat(f).then(r => r !== null).catch(() => false))).then(flags => {
|
candidates.map(f => stat(f).then(r => r !== null).catch(() => false))
|
||||||
|
);
|
||||||
|
if (detectTokenRef.current !== token) return;
|
||||||
const found = candidates.filter((_, i) => flags[i]);
|
const found = candidates.filter((_, i) => flags[i]);
|
||||||
if (!cancelled) setMediaSetFiles(found.length > 1 ? found : null);
|
setMediaSetFiles(found.length > 1 ? found : null);
|
||||||
});
|
};
|
||||||
return () => { cancelled = true; };
|
|
||||||
}, [device.number, deviceData.url, deviceData.media_set]);
|
|
||||||
|
|
||||||
const switchMedia = (file: string) => {
|
const switchMedia = (file: string) => {
|
||||||
const path = getDevicePath();
|
const path = getDevicePath();
|
||||||
|
|
@ -218,6 +225,7 @@ export default function DeviceDetailOverlay({
|
||||||
dev.url = selectedPath;
|
dev.url = selectedPath;
|
||||||
delete dev.media_set;
|
delete dev.media_set;
|
||||||
setConfig(newConfig);
|
setConfig(newConfig);
|
||||||
|
void detectMediaSet(selectedPath);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -393,7 +401,9 @@ export default function DeviceDetailOverlay({
|
||||||
for (const k of devicePath) dev = dev[k];
|
for (const k of devicePath) dev = dev[k];
|
||||||
if (isOutsideBase(newUrl, dev.base_url || '')) clearBaseAndCache(dev);
|
if (isOutsideBase(newUrl, dev.base_url || '')) clearBaseAndCache(dev);
|
||||||
dev.url = newUrl;
|
dev.url = newUrl;
|
||||||
|
delete dev.media_set;
|
||||||
setConfig(newConfig);
|
setConfig(newConfig);
|
||||||
|
if (newUrl) void detectMediaSet(newUrl);
|
||||||
}}
|
}}
|
||||||
onClear={() => {
|
onClear={() => {
|
||||||
const devicePath = getDevicePath();
|
const devicePath = getDevicePath();
|
||||||
|
|
@ -403,6 +413,8 @@ export default function DeviceDetailOverlay({
|
||||||
delete dev.url;
|
delete dev.url;
|
||||||
delete dev.media_set;
|
delete dev.media_set;
|
||||||
setConfig(newConfig);
|
setConfig(newConfig);
|
||||||
|
++detectTokenRef.current;
|
||||||
|
setMediaSetFiles(null);
|
||||||
}}
|
}}
|
||||||
containerClassName="flex-1"
|
containerClassName="flex-1"
|
||||||
className="px-3 py-2 border border-neutral-300 rounded-lg"
|
className="px-3 py-2 border border-neutral-300 rounded-lg"
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,77 @@ function ViewerModeIcon({ mode, className }: { mode: ViewMode; className?: strin
|
||||||
|
|
||||||
// EntryIcon is imported from MediaEntry.
|
// EntryIcon is imported from MediaEntry.
|
||||||
|
|
||||||
|
// ─── MarqueeText ──────────────────────────────────────────────────────────────
|
||||||
|
// Scrolls text left→right when it overflows its container, then back — ping-pong.
|
||||||
|
// Injects a unique per-instance @keyframes rule with the exact pixel offset so
|
||||||
|
// no CSS variables are needed (more reliable inside Radix portals).
|
||||||
|
|
||||||
|
let _mmSeq = 0;
|
||||||
|
|
||||||
|
function MarqueeText({ children, className }: { children?: string; className?: string }) {
|
||||||
|
const wrapRef = useRef<HTMLSpanElement>(null);
|
||||||
|
const idRef = useRef(`mm${++_mmSeq}`);
|
||||||
|
const [animCSS, setAnimCSS] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const id = idRef.current;
|
||||||
|
const wrap = wrapRef.current;
|
||||||
|
if (!wrap) return;
|
||||||
|
|
||||||
|
const measure = () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const ov = wrap.scrollWidth - wrap.clientWidth;
|
||||||
|
if (ov <= 0) { setAnimCSS(''); return; }
|
||||||
|
|
||||||
|
// Inject / update the keyframe with the exact measured pixel offset.
|
||||||
|
let el = document.getElementById(`kf-${id}`) as HTMLStyleElement | null;
|
||||||
|
if (!el) {
|
||||||
|
el = document.createElement('style');
|
||||||
|
el.id = `kf-${id}`;
|
||||||
|
document.head.appendChild(el);
|
||||||
|
}
|
||||||
|
el.textContent = `@keyframes ${id}{0%,12%{transform:translateX(0)}88%,100%{transform:translateX(-${ov}px)}}`;
|
||||||
|
|
||||||
|
const secs = (Math.max(2000, ov * 20) / 1000).toFixed(2);
|
||||||
|
setAnimCSS(`${id} ${secs}s ease-in-out 0.8s infinite alternate`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Two nested RAFs ensure the portal element has a stable layout before we
|
||||||
|
// measure (one RAF fires before browser layout; two guarantees post-layout).
|
||||||
|
const r1 = requestAnimationFrame(() => {
|
||||||
|
const r2 = requestAnimationFrame(measure);
|
||||||
|
return () => cancelAnimationFrame(r2);
|
||||||
|
});
|
||||||
|
|
||||||
|
const ro = new ResizeObserver(measure);
|
||||||
|
ro.observe(wrap);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
cancelAnimationFrame(r1);
|
||||||
|
ro.disconnect();
|
||||||
|
document.getElementById(`kf-${id}`)?.remove();
|
||||||
|
setAnimCSS('');
|
||||||
|
};
|
||||||
|
}, [children]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
// overflow:hidden clips the scrolling text; minWidth:0 lets the element
|
||||||
|
// shrink inside grid/flex parents without forcing them to expand to the
|
||||||
|
// full text width (white-space:nowrap lives only on the inner span).
|
||||||
|
<span
|
||||||
|
ref={wrapRef}
|
||||||
|
style={{ overflow: 'hidden', display: 'block', minWidth: 0 }}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<span style={{ display: 'inline-block', whiteSpace: 'nowrap', animation: animCSS || undefined }}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── ActionsModal ─────────────────────────────────────────────────────────────
|
// ─── ActionsModal ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface FolderManagementActions {
|
interface FolderManagementActions {
|
||||||
|
|
@ -163,10 +234,14 @@ function ActionsModal({ entry, onClose, onOpen, onMount, onDownload, onDuplicate
|
||||||
return (
|
return (
|
||||||
<Dialog open={entry !== null} onOpenChange={open => !open && onClose()}>
|
<Dialog open={entry !== null} onOpenChange={open => !open && onClose()}>
|
||||||
<DialogContent className="max-w-sm">
|
<DialogContent className="max-w-sm">
|
||||||
<DialogHeader>
|
<DialogHeader className="overflow-hidden min-w-0">
|
||||||
<DialogTitle className="truncate">{entry?.name || '/'}</DialogTitle>
|
{/* Keep DialogTitle for accessibility (aria-labelledby); styled text lives in the p below */}
|
||||||
|
<DialogTitle className="sr-only">{entry?.name || '/'}</DialogTitle>
|
||||||
|
<p className="text-lg font-semibold leading-none pr-6 overflow-hidden min-w-0">
|
||||||
|
<MarqueeText>{entry?.name || '/'}</MarqueeText>
|
||||||
|
</p>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{isFolder ? 'Folder' : humanFileSize(entry?.size ?? 0)}
|
<MarqueeText>{isFolder ? 'Folder' : humanFileSize(entry?.size ?? 0)}</MarqueeText>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{entry && (
|
{entry && (
|
||||||
|
|
@ -1266,11 +1341,14 @@ export default function MediaManager({ initialPath, rootPath, title, config, set
|
||||||
|
|
||||||
{/* ── Mount dialog ── */}
|
{/* ── Mount dialog ── */}
|
||||||
<Dialog open={mountEntry !== null} onOpenChange={open => !open && setMountEntry(null)}>
|
<Dialog open={mountEntry !== null} onOpenChange={open => !open && setMountEntry(null)}>
|
||||||
<DialogContent className="max-w-sm">
|
<DialogContent className="max-w-sm flex flex-col max-h-[90dvh]">
|
||||||
<DialogHeader>
|
<DialogHeader className="flex-shrink-0">
|
||||||
<DialogTitle>Mount on Virtual Drive</DialogTitle>
|
<DialogTitle>Mount on Virtual Drive</DialogTitle>
|
||||||
<DialogDescription className="truncate">{mountEntry?.name}</DialogDescription>
|
<DialogDescription title={mountEntry?.name}>
|
||||||
|
<MarqueeText>{mountEntry?.name}</MarqueeText>
|
||||||
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
<div className="overflow-y-auto flex-1 min-h-0">
|
||||||
{(() => {
|
{(() => {
|
||||||
const allDevices = Object.entries(config?.devices?.iec ?? {});
|
const allDevices = Object.entries(config?.devices?.iec ?? {});
|
||||||
const drives = allDevices
|
const drives = allDevices
|
||||||
|
|
@ -1288,16 +1366,25 @@ export default function MediaManager({ initialPath, rootPath, title, config, set
|
||||||
<button
|
<button
|
||||||
key={`${dev.type}-${dev.key}`}
|
key={`${dev.type}-${dev.key}`}
|
||||||
onClick={() => void mountOnDevice(dev.type, dev.key)}
|
onClick={() => void mountOnDevice(dev.type, dev.key)}
|
||||||
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-blue-50 hover:border-blue-300 inline-flex items-center gap-3"
|
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-blue-50 hover:border-blue-300 flex items-center gap-3"
|
||||||
>
|
>
|
||||||
<HardDrive className={`w-5 h-5 flex-shrink-0 ${dev.enabled ? 'text-blue-500' : 'text-neutral-400'}`} />
|
<HardDrive className={`w-5 h-5 flex-shrink-0 ${dev.enabled ? 'text-blue-500' : 'text-neutral-400'}`} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="font-medium text-sm">Device #{dev.key}</div>
|
<div className="font-medium text-sm">Device #{dev.key}</div>
|
||||||
{(dev.base_url || dev.url) && (
|
{(dev.base_url || dev.url) && (() => {
|
||||||
<div className="text-xs text-neutral-500 truncate">
|
const displayUrl = [dev.base_url, dev.url]
|
||||||
{[dev.base_url, dev.url].filter(Boolean).join('')}
|
.filter(Boolean).join('');
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="text-xs text-neutral-500 overflow-hidden whitespace-nowrap"
|
||||||
|
style={{ direction: 'rtl', textOverflow: 'ellipsis' }}
|
||||||
|
title={displayUrl}
|
||||||
|
>
|
||||||
|
{/* LTR embed prevents BiDi from reordering the leading '/' to the visual right */}
|
||||||
|
<span style={{ direction: 'ltr', unicodeBidi: 'embed' }}>{displayUrl}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
{!dev.enabled && <span className="text-xs text-neutral-400 flex-shrink-0">disabled</span>}
|
{!dev.enabled && <span className="text-xs text-neutral-400 flex-shrink-0">disabled</span>}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -1305,6 +1392,7 @@ export default function MediaManager({ initialPath, rootPath, title, config, set
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user