adds local play
This commit is contained in:
382
public/app.js
382
public/app.js
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user