adds local play

This commit is contained in:
2026-06-14 18:10:10 -07:00
parent 53d8a6dc2e
commit 2db133dd1c
7 changed files with 755 additions and 14 deletions

View File

@@ -5,6 +5,7 @@ const elements = {
url: document.querySelector('#stream-url'),
play: document.querySelector('#play'),
queue: document.querySelector('#queue'),
playLocal: document.querySelector('#play-local'),
entryMessage: document.querySelector('#entry-message'),
libraryPanel: document.querySelector('#library-panel'),
recentPanel: document.querySelector('#recent-panel'),
@@ -28,6 +29,13 @@ const elements = {
favoriteTitle: document.querySelector('#favorite-title'),
favoriteUrl: document.querySelector('#favorite-url'),
favoritesMessage: document.querySelector('#favorites-message'),
localModal: document.querySelector('#local-modal'),
closeLocal: document.querySelector('#close-local'),
localSearch: document.querySelector('#local-search'),
localBack: document.querySelector('#local-back'),
localPath: document.querySelector('#local-path'),
localList: document.querySelector('#local-list'),
localMessage: document.querySelector('#local-message'),
audio: document.querySelector('#audio'),
canvas: document.querySelector('#screen'),
stage: document.querySelector('#video-stage'),
@@ -78,6 +86,11 @@ const state = {
hideControlsTimer: 0,
recentUrls: [],
favorites: [],
localVideos: [],
localDirectory: '',
localVideosEnabled: false,
localVideosLoading: false,
localModalTrigger: null,
favoriteModalTrigger: null,
favoriteWizard: createFavoriteWizardState(),
playbackOffset: 0,
@@ -110,6 +123,45 @@ elements.queue.addEventListener('click', () => {
void queueCurrentUrl();
});
elements.playLocal.addEventListener('click', () => {
void openLocalModal();
});
elements.closeLocal.addEventListener('click', () => {
closeLocalModal();
});
elements.localSearch.addEventListener('input', () => {
renderLocalVideos();
});
elements.localBack.addEventListener('click', () => {
navigateLocalDirectory(getParentLocalDirectory(state.localDirectory));
});
elements.localList.addEventListener('click', (event) => {
const folderButton = event.target.closest('[data-local-folder]');
if (folderButton) {
navigateLocalDirectory(folderButton.dataset.localFolder);
return;
}
const button = event.target.closest('[data-local-index]');
if (!button) {
return;
}
const item = state.localVideos[Number(button.dataset.localIndex)];
if (!item) {
return;
}
void playLocalVideo(item);
});
async function playCurrentUrl() {
setEntryMessage('');
setFormBusy(true);
@@ -260,9 +312,24 @@ elements.favoritesModal.addEventListener('click', (event) => {
}
});
elements.localModal.addEventListener('click', (event) => {
if (event.target === elements.localModal) {
closeLocalModal();
}
});
window.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && !elements.favoritesModal.hidden) {
if (event.key !== 'Escape') {
return;
}
if (!elements.favoritesModal.hidden) {
closeFavoritesModal();
return;
}
if (!elements.localModal.hidden) {
closeLocalModal();
}
});
@@ -1584,6 +1651,7 @@ function setFormBusy(isBusy) {
elements.url.disabled = isBusy;
elements.play.disabled = isBusy;
elements.queue.disabled = isBusy;
elements.playLocal.disabled = isBusy;
for (const button of elements.libraryPanel.querySelectorAll('button')) {
button.disabled = isBusy;
@@ -1594,6 +1662,7 @@ async function loadEntryLists() {
await Promise.all([
loadRecentUrls(),
loadFavorites(),
loadLocalVideos(),
]);
}
@@ -1665,6 +1734,317 @@ function renderFavorites() {
}
}
async function loadLocalVideos({ showLoading = false } = {}) {
if (showLoading) {
state.localVideosLoading = true;
renderLocalVideos();
}
try {
const response = await fetch('/api/local-videos', { cache: 'no-store' });
if (!response.ok) {
throw new Error('Failed to load local videos.');
}
const payload = await response.json();
state.localVideosEnabled = Boolean(payload.enabled);
state.localVideos = Array.isArray(payload.videos) ? payload.videos : [];
elements.playLocal.hidden = !state.localVideosEnabled;
if (!elements.localModal.hidden && payload.error) {
elements.localMessage.textContent = payload.error;
}
} catch {
state.localVideosEnabled = false;
state.localVideos = [];
elements.playLocal.hidden = true;
if (!elements.localModal.hidden) {
elements.localMessage.textContent = 'Failed to load local videos.';
}
} finally {
state.localVideosLoading = false;
if (!elements.localModal.hidden) {
renderLocalVideos();
}
}
}
async function openLocalModal() {
state.localModalTrigger = document.activeElement instanceof HTMLElement ? document.activeElement : null;
state.localDirectory = '';
elements.localSearch.value = '';
elements.localMessage.textContent = '';
elements.localModal.hidden = false;
elements.localModal.classList.add('is-open');
renderLocalVideos();
elements.localSearch.focus();
await loadLocalVideos({ showLoading: true });
}
function closeLocalModal({ restoreFocus = true } = {}) {
elements.localModal.hidden = true;
elements.localModal.classList.remove('is-open');
elements.localMessage.textContent = '';
elements.localSearch.value = '';
state.localDirectory = '';
if (restoreFocus) {
state.localModalTrigger?.focus();
}
state.localModalTrigger = null;
}
function renderLocalVideos() {
if (state.localVideosLoading) {
syncLocalNavigationHeader();
elements.localList.replaceChildren(createEmptyListMessage('Loading...'));
return;
}
const query = elements.localSearch.value.trim().toLowerCase();
syncLocalNavigationHeader(query);
if (query) {
renderLocalSearchResults(query);
return;
}
const { folders, files } = getLocalDirectoryEntries(state.localDirectory);
elements.localList.replaceChildren(
...folders.map((folder) => createLocalFolderButton(folder)),
...files.map(({ item, index }) => createLocalVideoButton(item, index)),
);
if (folders.length === 0 && files.length === 0) {
elements.localList.replaceChildren(createEmptyListMessage(state.localDirectory ? 'Folder is empty' : 'No local files'));
}
}
function renderLocalSearchResults(query) {
const matches = state.localVideos
.map((item, index) => ({ item, index }))
.filter(({ item }) => getLocalVideoSearchText(item).includes(query));
elements.localList.replaceChildren(
...matches.map(({ item, index }) => createLocalVideoButton(item, index, { showPath: true })),
);
if (matches.length === 0) {
elements.localList.replaceChildren(createEmptyListMessage('No matches'));
}
}
function getLocalDirectoryEntries(directory) {
const folderMap = new Map();
const files = [];
for (const [index, item] of state.localVideos.entries()) {
const itemPath = normalizeLocalPath(item.path);
const relativePath = getRelativeLocalPath(itemPath, directory);
if (!relativePath) {
continue;
}
const separatorIndex = relativePath.indexOf('/');
if (separatorIndex === -1) {
files.push({ item, index });
continue;
}
const folderName = relativePath.slice(0, separatorIndex);
const folderPath = directory ? `${directory}/${folderName}` : folderName;
const existing = folderMap.get(folderPath);
if (existing) {
existing.count += 1;
} else {
folderMap.set(folderPath, { name: folderName, path: folderPath, count: 1 });
}
}
return {
folders: [...folderMap.values()].sort(compareLocalEntryNames),
files: files.sort((first, second) => compareLocalEntryNames(
{ name: first.item.title || first.item.path || '' },
{ name: second.item.title || second.item.path || '' },
)),
};
}
function createLocalFolderButton(folder) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'local-video-item local-folder-item';
button.dataset.localFolder = folder.path;
const title = document.createElement('span');
title.className = 'local-video-title';
title.textContent = folder.name;
const meta = document.createElement('span');
meta.className = 'local-video-meta';
meta.textContent = `${folder.count} ${folder.count === 1 ? 'file' : 'files'}`;
button.append(title, meta);
return button;
}
function createLocalVideoButton(item, index, { showPath = false } = {}) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'local-video-item';
button.dataset.localIndex = String(index);
const title = document.createElement('span');
title.className = 'local-video-title';
title.textContent = item.title || item.path || 'Untitled';
const meta = document.createElement('span');
meta.className = 'local-video-meta';
meta.textContent = formatLocalVideoMeta(item, { showPath });
button.append(title, meta);
return button;
}
function navigateLocalDirectory(directory) {
state.localDirectory = normalizeLocalPath(directory);
elements.localSearch.value = '';
renderLocalVideos();
}
function getParentLocalDirectory(directory) {
const normalized = normalizeLocalPath(directory);
const separatorIndex = normalized.lastIndexOf('/');
return separatorIndex === -1 ? '' : normalized.slice(0, separatorIndex);
}
function syncLocalNavigationHeader(query = elements.localSearch.value.trim().toLowerCase()) {
elements.localBack.hidden = !state.localDirectory;
elements.localPath.textContent = query ? 'Search results' : state.localDirectory || 'Library';
}
function getRelativeLocalPath(itemPath, directory) {
const normalizedItemPath = normalizeLocalPath(itemPath);
const normalizedDirectory = normalizeLocalPath(directory);
if (!normalizedDirectory) {
return normalizedItemPath;
}
const prefix = `${normalizedDirectory}/`;
return normalizedItemPath.startsWith(prefix) ? normalizedItemPath.slice(prefix.length) : '';
}
function normalizeLocalPath(value) {
return String(value ?? '')
.split(/[\\/]+/)
.filter(Boolean)
.join('/');
}
function compareLocalEntryNames(first, second) {
return first.name.localeCompare(second.name, undefined, {
numeric: true,
sensitivity: 'base',
});
}
function getLocalVideoSearchText(item) {
return `${item.title ?? ''} ${item.folder ?? ''} ${item.path ?? ''}`.toLowerCase();
}
function formatLocalVideoMeta(item, { showPath = false } = {}) {
const parts = [];
if (showPath && item.path) {
parts.push(item.path);
} else if (item.folder) {
parts.push(item.folder);
}
const size = formatFileSize(item.size);
if (size) {
parts.push(size);
}
const modifiedAt = new Date(item.modifiedAt);
if (Number.isFinite(modifiedAt.getTime())) {
parts.push(modifiedAt.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
}));
}
return parts.join(' ');
}
function formatFileSize(value) {
const bytes = Number(value);
if (!Number.isFinite(bytes) || bytes < 0) {
return '';
}
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex += 1;
}
const precision = size >= 10 || unitIndex === 0 ? 0 : 1;
return `${size.toFixed(precision)} ${units[unitIndex]}`;
}
async function playLocalVideo(item) {
elements.localMessage.textContent = '';
setLocalModalBusy(true);
setFormBusy(true);
try {
stopSession({ showEntry: false });
const response = await fetch('/api/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ localPath: item.path, width: getViewportFrameWidth() }),
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error ?? 'Failed to create local playback session.');
}
closeLocalModal({ restoreFocus: false });
showPlayer();
startSession(payload);
} catch (error) {
stopSession({ showEntry: false });
elements.localMessage.textContent = error.message;
} finally {
setLocalModalBusy(false);
setFormBusy(false);
}
}
function setLocalModalBusy(isBusy) {
for (const field of elements.localModal.querySelectorAll('input, button')) {
field.disabled = isBusy;
}
}
function createEmptyListMessage(text) {
const message = document.createElement('p');
message.className = 'empty-list';