adds local play
This commit is contained in:
@@ -6,7 +6,7 @@ This project is a web video player for clients that can decode audio and still i
|
||||
|
||||
The UI intentionally has only two screens:
|
||||
|
||||
- URL entry screen with a stream URL input, a `Play` button, a `Queue` button, globally stored recently played URLs, and globally stored favorites.
|
||||
- URL entry screen with a stream URL input, a `Play` button, a `Queue` button, an env-gated `Play Local` button when `LOCAL_VIDEOS` is set, globally stored recently played URLs, and globally stored favorites.
|
||||
- Fullscreen player screen with JPEG frames drawn to a canvas and native audio playback through an `<audio>` element.
|
||||
- Playback controls are overlay controls toggled by tapping/clicking the frame area, similar to YouTube.
|
||||
- Do not reintroduce debug panels, frame counters, settings forms, explanatory marketing copy, or visible ffmpeg details into the normal UI.
|
||||
@@ -26,7 +26,8 @@ The app is plain Node/Express plus browser JavaScript:
|
||||
|
||||
Main public endpoints:
|
||||
|
||||
- `POST /api/session`: validates the stream URL, stores recent URL, creates a short-lived playback session.
|
||||
- `POST /api/session`: validates the stream URL, stores recent URL, creates a short-lived playback session. Also accepts `localPath` for files selected from `LOCAL_VIDEOS`; local selections are not stored in recents.
|
||||
- `GET /api/local-videos`: when `LOCAL_VIDEOS` is set, returns the recursive local file picker list.
|
||||
- `GET /api/recent-urls`: returns global recent URL entries with `url`, redacted `displayUrl`, and `lastPlayedAt`.
|
||||
- `POST /api/recent-urls`: validates a stream URL and stores it globally without creating a playback session.
|
||||
- `GET /api/favorites`: returns global favorite entries with `title` and `url`.
|
||||
@@ -189,6 +190,7 @@ Runtime:
|
||||
- `RECENT_URL_LIMIT`: recent URL count, default `12`.
|
||||
- `FAVORITES_PATH`: favorites JSON path.
|
||||
- `FAVORITES_LIMIT`: favorites count, default `50`.
|
||||
- `LOCAL_VIDEOS`: optional local video directory. When set, the UI shows `Play Local` and lists regular files under this directory recursively.
|
||||
- `DEFAULT_FPS`: default frame rate, fallback `24`, clamped `1..30`.
|
||||
- `DEFAULT_FRAME_WIDTH`: default maximum frame width, fallback `960`, clamped `160..1920`.
|
||||
- `JPEG_QUALITY`: default JPEG quality, fallback `7`, clamped `2..18`; lower is better for ffmpeg `-q:v`.
|
||||
|
||||
@@ -16,6 +16,8 @@ npm start
|
||||
|
||||
Open `http://localhost:3000`, paste a direct HTTP(S) stream URL, and click `Play`. Use `Queue` to add a URL to Recents without starting playback.
|
||||
|
||||
Set `LOCAL_VIDEOS=/path/to/videos` before starting the server to enable `Play Local`. The picker lets you navigate directories under that root, search the library, and play selected files through the server-side ffmpeg pipeline as MP3 audio plus JPEG canvas frames.
|
||||
|
||||
You need an `ffmpeg` binary with decoders for the stream's video and audio codecs. YouTube page links also need `yt-dlp`; Docker installs it automatically from the upstream master branch at image build time. If your stream is H.264 or HEVC, make sure your installed `ffmpeg` actually includes those decoders. You can point the app at different binaries with:
|
||||
|
||||
```sh
|
||||
@@ -42,6 +44,8 @@ The app uses CPU decoding by default, so no video device is required. The compos
|
||||
|
||||
Recently played URLs and favorites are stored globally by the backend. In Docker Compose, they are persisted in the `frame-stream-data` named volume.
|
||||
|
||||
To use local playback in Docker, mount a host video directory into the container and set `LOCAL_VIDEOS` to the container path, for example `/app/local-videos`.
|
||||
|
||||
`ffmpeg` worker lifecycle, stderr warnings/errors, and source proxy open/close events are written to stdout/stderr, so they appear in `docker logs`. For more detail while debugging a stream, set `FFMPEG_LOG_LEVEL=info` in Docker Compose and run:
|
||||
|
||||
```sh
|
||||
|
||||
@@ -33,10 +33,13 @@ services:
|
||||
MAX_RELAY_BRANCH_QUEUE_BYTES: "8388608"
|
||||
RECENT_URLS_PATH: /app/data/recent-urls.json
|
||||
FAVORITES_PATH: /app/data/favorites.json
|
||||
# Set this and mount a host directory below to enable Play Local.
|
||||
# LOCAL_VIDEOS: /app/local-videos
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
- frame-stream-data:/app/data
|
||||
# - /path/on/host/videos:/app/local-videos:ro
|
||||
|
||||
# CPU decoding is the default and does not need device passthrough.
|
||||
#
|
||||
|
||||
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';
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
required
|
||||
>
|
||||
<button id="queue" type="button" class="secondary-submit">Queue</button>
|
||||
<button id="play-local" type="button" class="secondary-submit" hidden>Play Local</button>
|
||||
<button id="play" type="submit">Play</button>
|
||||
</form>
|
||||
<div id="library-panel" class="library-panel" aria-label="Saved streams">
|
||||
@@ -91,6 +92,23 @@
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="local-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="local-modal-title" hidden>
|
||||
<div class="modal-panel local-modal-panel">
|
||||
<div class="modal-heading">
|
||||
<h2 id="local-modal-title">Local Videos</h2>
|
||||
<button id="close-local" type="button" class="icon-button" aria-label="Close local video picker">×</button>
|
||||
</div>
|
||||
<label class="sr-only" for="local-search">Search local videos</label>
|
||||
<input id="local-search" class="local-search" type="search" placeholder="Search files" autocomplete="off">
|
||||
<div class="local-toolbar">
|
||||
<button id="local-back" type="button" class="secondary-button local-back" hidden>Back</button>
|
||||
<div id="local-path" class="local-path">Library</div>
|
||||
</div>
|
||||
<div id="local-list" class="local-list"></div>
|
||||
<div id="local-message" class="modal-message" role="status" aria-live="polite"></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="/app.js" type="module"></script>
|
||||
|
||||
@@ -104,7 +104,8 @@ button:disabled {
|
||||
.secondary-button,
|
||||
.primary-button,
|
||||
.icon-button,
|
||||
.remove-favorite {
|
||||
.remove-favorite,
|
||||
.local-video-item {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
color: var(--fg);
|
||||
@@ -472,6 +473,95 @@ audio {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.local-search {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 0.9rem 0.95rem;
|
||||
color: var(--fg);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.local-search::placeholder {
|
||||
color: rgba(246, 241, 232, 0.48);
|
||||
}
|
||||
|
||||
.local-search:focus {
|
||||
border-color: rgba(232, 168, 79, 0.7);
|
||||
}
|
||||
|
||||
.local-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
min-height: 2.7rem;
|
||||
}
|
||||
|
||||
.local-back {
|
||||
flex: 0 0 auto;
|
||||
min-height: 2.45rem;
|
||||
padding: 0.55rem 0.9rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.local-path {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--soft);
|
||||
font-size: 0.86rem;
|
||||
font-weight: 800;
|
||||
text-overflow: ellipsis;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.local-list {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
max-height: min(58vh, 32rem);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.local-video-item {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
gap: 0.22rem;
|
||||
min-height: 4.35rem;
|
||||
padding: 0.75rem 0.85rem;
|
||||
border-radius: 8px;
|
||||
text-align: left;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.local-folder-item {
|
||||
border-color: rgba(232, 168, 79, 0.34);
|
||||
}
|
||||
|
||||
.local-video-item:hover,
|
||||
.local-video-item:focus {
|
||||
border-color: rgba(246, 241, 232, 0.3);
|
||||
background: rgba(255, 255, 255, 0.10);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.local-video-title,
|
||||
.local-video-meta {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.local-video-title {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.local-video-meta {
|
||||
color: rgba(246, 241, 232, 0.56);
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.favorites-editor-list {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
@@ -665,6 +755,10 @@ audio {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.local-back {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr));
|
||||
|
||||
260
server/index.js
260
server/index.js
@@ -36,6 +36,7 @@ const RECENT_URLS_PATH = process.env.RECENT_URLS_PATH ?? path.join(__dirname, '.
|
||||
const RECENT_URL_LIMIT = clampInteger(process.env.RECENT_URL_LIMIT, 12, 1, 50);
|
||||
const FAVORITES_PATH = process.env.FAVORITES_PATH ?? path.join(__dirname, '..', 'data', 'favorites.json');
|
||||
const FAVORITES_LIMIT = clampInteger(process.env.FAVORITES_LIMIT, 50, 1, 200);
|
||||
const LOCAL_VIDEOS_ROOT = parseLocalVideosRoot(process.env.LOCAL_VIDEOS);
|
||||
const SESSION_TTL_MS = 60 * 60 * 1000;
|
||||
const PLAYBACK_READY_TIMEOUT_MS = 15 * 1000;
|
||||
const CLIENT_CLOCK_FRAME_LATE_GRACE_SECONDS = 0.25;
|
||||
@@ -79,6 +80,7 @@ let recentWrite = Promise.resolve();
|
||||
let favorites = [];
|
||||
let favoritesWrite = Promise.resolve();
|
||||
let ffmpegSupportsReconnectMaxRetries = false;
|
||||
let localVideosRealRootPromise = null;
|
||||
|
||||
app.disable('x-powered-by');
|
||||
app.use(express.json({ limit: '256kb' }));
|
||||
@@ -129,6 +131,29 @@ app.get('/api/favorites', (_request, response) => {
|
||||
response.json({ favorites: formatFavoritesPayload() });
|
||||
});
|
||||
|
||||
app.get('/api/local-videos', async (_request, response) => {
|
||||
if (!LOCAL_VIDEOS_ROOT) {
|
||||
response.json({ enabled: false, videos: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
response.json({
|
||||
enabled: true,
|
||||
rootName: formatLocalVideosRootName(),
|
||||
videos: await listLocalVideos(),
|
||||
});
|
||||
} catch (error) {
|
||||
logWarn(`local videos listing failed error=${oneLine(error.message)}`);
|
||||
response.json({
|
||||
enabled: true,
|
||||
rootName: formatLocalVideosRootName(),
|
||||
videos: [],
|
||||
error: 'Failed to read local videos.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/favorites', async (request, response) => {
|
||||
let nextFavorites;
|
||||
|
||||
@@ -154,18 +179,20 @@ app.put('/api/favorites', async (request, response) => {
|
||||
});
|
||||
|
||||
app.post('/api/session', async (request, response) => {
|
||||
let url;
|
||||
let requestedSource;
|
||||
let source;
|
||||
|
||||
try {
|
||||
url = parseStreamUrl(request.body?.url);
|
||||
requestedSource = parseSessionSource(request.body);
|
||||
} catch (error) {
|
||||
response.status(400).json({ error: error.message });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
source = await resolvePlaybackSource(url);
|
||||
source = requestedSource.type === 'local'
|
||||
? await resolveLocalPlaybackSource(requestedSource.path)
|
||||
: await resolvePlaybackSource(requestedSource.url);
|
||||
} catch (error) {
|
||||
response.status(400).json({ error: error.message });
|
||||
return;
|
||||
@@ -175,14 +202,14 @@ app.post('/api/session', async (request, response) => {
|
||||
const id = randomUUID();
|
||||
const shouldProbeMetadata = METADATA_PROBE_ENABLED || canBestEffortResumeWithoutDuration({
|
||||
sourceKind: source.kind,
|
||||
originalUrl: url,
|
||||
originalUrl: source.originalUrl,
|
||||
url: source.url,
|
||||
});
|
||||
const metadataStatus = shouldProbeMetadata ? 'pending' : 'disabled';
|
||||
const session = {
|
||||
id,
|
||||
url: source.url,
|
||||
originalUrl: url,
|
||||
originalUrl: source.originalUrl,
|
||||
sourceKind: source.kind,
|
||||
sourceHeaders: source.headers,
|
||||
options,
|
||||
@@ -204,10 +231,12 @@ app.post('/api/session', async (request, response) => {
|
||||
startSessionMetadataProbe(session);
|
||||
}
|
||||
|
||||
try {
|
||||
await addRecentUrl(url);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to store recent URL: ${error.message}`);
|
||||
if (requestedSource.type === 'url') {
|
||||
try {
|
||||
await addRecentUrl(requestedSource.url);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to store recent URL: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
response.status(201).json(formatSessionPayload(sessions.get(id)));
|
||||
@@ -438,6 +467,20 @@ server.listen(PORT, () => {
|
||||
console.log(`Frame stream app listening at http://localhost:${PORT} mode=${PLAYBACK_CONNECTION_MODE}`);
|
||||
});
|
||||
|
||||
function parseSessionSource(body) {
|
||||
if (typeof body?.localPath === 'string' && body.localPath.trim()) {
|
||||
return {
|
||||
type: 'local',
|
||||
path: parseLocalVideoPath(body.localPath),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'url',
|
||||
url: parseStreamUrl(body?.url),
|
||||
};
|
||||
}
|
||||
|
||||
function parseStreamUrl(value) {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
throw new Error('A stream URL is required.');
|
||||
@@ -456,6 +499,187 @@ function parseStreamUrl(value) {
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
function parseLocalVideosRoot(value) {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return path.resolve(value.trim());
|
||||
}
|
||||
|
||||
function parseLocalVideoPath(value) {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
throw new Error('A local file is required.');
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
|
||||
if (trimmed.length > 4096) {
|
||||
throw new Error('The local file path is too long.');
|
||||
}
|
||||
|
||||
if (trimmed.includes('\0')) {
|
||||
throw new Error('The local file path is invalid.');
|
||||
}
|
||||
|
||||
if (path.isAbsolute(trimmed)) {
|
||||
throw new Error('Local file paths must be relative.');
|
||||
}
|
||||
|
||||
const segments = trimmed.split(/[\\/]+/);
|
||||
|
||||
if (segments.some((segment) => !segment || segment === '.' || segment === '..')) {
|
||||
throw new Error('The local file path is invalid.');
|
||||
}
|
||||
|
||||
return path.join(...segments);
|
||||
}
|
||||
|
||||
async function resolveLocalPlaybackSource(relativePath) {
|
||||
const { absolutePath, displayPath } = await resolveLocalVideoPath(relativePath);
|
||||
|
||||
return {
|
||||
kind: 'local',
|
||||
url: absolutePath,
|
||||
originalUrl: displayPath,
|
||||
headers: {},
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveLocalVideoPath(relativePath) {
|
||||
if (!LOCAL_VIDEOS_ROOT) {
|
||||
throw new Error('Local video playback is not enabled.');
|
||||
}
|
||||
|
||||
const parsedPath = parseLocalVideoPath(relativePath);
|
||||
const rootRealPath = await getLocalVideosRealRoot();
|
||||
const candidatePath = path.resolve(LOCAL_VIDEOS_ROOT, parsedPath);
|
||||
|
||||
if (!isPathInside(candidatePath, LOCAL_VIDEOS_ROOT)) {
|
||||
throw new Error('The local file path is invalid.');
|
||||
}
|
||||
|
||||
let realPath;
|
||||
let stats;
|
||||
|
||||
try {
|
||||
realPath = await fs.realpath(candidatePath);
|
||||
stats = await fs.stat(realPath);
|
||||
} catch {
|
||||
throw new Error('Local file was not found.');
|
||||
}
|
||||
|
||||
if (!isPathInside(realPath, rootRealPath)) {
|
||||
throw new Error('The local file path is outside the configured library.');
|
||||
}
|
||||
|
||||
if (!stats.isFile()) {
|
||||
throw new Error('Local selection is not a file.');
|
||||
}
|
||||
|
||||
return {
|
||||
absolutePath: realPath,
|
||||
displayPath: formatLocalVideoPath(parsedPath),
|
||||
};
|
||||
}
|
||||
|
||||
async function listLocalVideos() {
|
||||
if (!LOCAL_VIDEOS_ROOT) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rootRealPath = await getLocalVideosRealRoot();
|
||||
const videos = [];
|
||||
|
||||
await walkLocalVideosDirectory(LOCAL_VIDEOS_ROOT, '', rootRealPath, videos);
|
||||
|
||||
return videos.sort((first, second) => first.path.localeCompare(second.path, undefined, {
|
||||
numeric: true,
|
||||
sensitivity: 'base',
|
||||
}));
|
||||
}
|
||||
|
||||
async function walkLocalVideosDirectory(directoryPath, relativeDirectory, rootRealPath, videos) {
|
||||
let entries;
|
||||
|
||||
try {
|
||||
entries = await fs.readdir(directoryPath, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
const label = relativeDirectory ? formatLocalVideoPath(relativeDirectory) : '.';
|
||||
logWarn(`local videos directory unreadable path=${label} error=${oneLine(error.message)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const relativePath = relativeDirectory ? path.join(relativeDirectory, entry.name) : entry.name;
|
||||
const absolutePath = path.join(directoryPath, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
await walkLocalVideosDirectory(absolutePath, relativePath, rootRealPath, videos);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isFile() || entry.isSymbolicLink()) {
|
||||
await addLocalVideoEntry(absolutePath, relativePath, rootRealPath, videos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function addLocalVideoEntry(absolutePath, relativePath, rootRealPath, videos) {
|
||||
let realPath;
|
||||
let stats;
|
||||
|
||||
try {
|
||||
realPath = await fs.realpath(absolutePath);
|
||||
stats = await fs.stat(realPath);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stats.isFile() || !isPathInside(realPath, rootRealPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const displayPath = formatLocalVideoPath(relativePath);
|
||||
const folder = formatLocalVideoPath(path.dirname(relativePath));
|
||||
|
||||
videos.push({
|
||||
path: displayPath,
|
||||
title: path.basename(relativePath),
|
||||
folder: folder === '.' ? '' : folder,
|
||||
size: stats.size,
|
||||
modifiedAt: stats.mtime.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
function getLocalVideosRealRoot() {
|
||||
if (!LOCAL_VIDEOS_ROOT) {
|
||||
return Promise.reject(new Error('Local video playback is not enabled.'));
|
||||
}
|
||||
|
||||
if (!localVideosRealRootPromise) {
|
||||
localVideosRealRootPromise = fs.realpath(LOCAL_VIDEOS_ROOT).catch((error) => {
|
||||
localVideosRealRootPromise = null;
|
||||
throw new Error(`Local videos directory is unavailable: ${error.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
return localVideosRealRootPromise;
|
||||
}
|
||||
|
||||
function isPathInside(candidatePath, rootPath) {
|
||||
const relativePath = path.relative(rootPath, candidatePath);
|
||||
return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath));
|
||||
}
|
||||
|
||||
function formatLocalVideoPath(value) {
|
||||
return value.split(path.sep).join('/');
|
||||
}
|
||||
|
||||
function formatLocalVideosRootName() {
|
||||
return path.basename(LOCAL_VIDEOS_ROOT) || LOCAL_VIDEOS_ROOT;
|
||||
}
|
||||
|
||||
function parseResolvedMediaUrl(value) {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
throw new Error('Resolved media URL is empty.');
|
||||
@@ -479,6 +703,7 @@ async function resolvePlaybackSource(url) {
|
||||
return {
|
||||
kind: 'direct',
|
||||
url,
|
||||
originalUrl: url,
|
||||
headers: {},
|
||||
};
|
||||
}
|
||||
@@ -487,6 +712,7 @@ async function resolvePlaybackSource(url) {
|
||||
return {
|
||||
kind: 'youtube',
|
||||
url: resolved.url,
|
||||
originalUrl: url,
|
||||
headers: resolved.headers,
|
||||
};
|
||||
}
|
||||
@@ -742,7 +968,8 @@ function hasRecordedDuration(session) {
|
||||
}
|
||||
|
||||
function canBestEffortResumeWithoutDuration(session) {
|
||||
return session.sourceKind === 'youtube'
|
||||
return session.sourceKind === 'local'
|
||||
|| session.sourceKind === 'youtube'
|
||||
|| isLikelyRecordedMediaUrl(session.originalUrl)
|
||||
|| isLikelyRecordedMediaUrl(session.url);
|
||||
}
|
||||
@@ -757,6 +984,10 @@ function isLikelyRecordedMediaUrl(value) {
|
||||
}
|
||||
|
||||
function getSessionPlaybackConnectionMode(session) {
|
||||
if (session.sourceKind === 'local') {
|
||||
return 'split';
|
||||
}
|
||||
|
||||
if (session.forceSplitPlayback) {
|
||||
return 'split';
|
||||
}
|
||||
@@ -2465,6 +2696,15 @@ function toBufferView(chunk) {
|
||||
}
|
||||
|
||||
function createSourceInput(sessionId, kind) {
|
||||
const session = getSession(sessionId);
|
||||
|
||||
if (session?.sourceKind === 'local') {
|
||||
return {
|
||||
url: session.url,
|
||||
release: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
const token = randomUUID();
|
||||
sourceTokens.set(token, { sessionId, kind, createdAt: Date.now() });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user