Compare commits
20 Commits
8b74d25954
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| d37694e4d3 | |||
| 0ad0df5612 | |||
| 2db133dd1c | |||
| 53d8a6dc2e | |||
| c13bafe96b | |||
| 6135a6f535 | |||
| 99388b6487 | |||
| 53487087a2 | |||
| c4fb23d971 | |||
| 562f2b8612 | |||
| 20f6d4d192 | |||
| 2866d33dec | |||
| de0307539c | |||
| 7655d7aaba | |||
| a9f8509b99 | |||
| 0cb091cb53 | |||
| a9b180f774 | |||
| 2c2b0fdf78 | |||
| 5bf59b2436 | |||
| 9d1f4cc53b |
41
AGENTS.md
41
AGENTS.md
@@ -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 `Next` 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,8 +26,10 @@ 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`.
|
||||
- `PUT /api/favorites`: replaces the global favorites list. Each favorite has a user-provided `title` and stream `url`.
|
||||
- `GET /audio/:sessionId`: serves MP3 audio to the browser audio element.
|
||||
@@ -75,7 +77,7 @@ The regression history matters:
|
||||
|
||||
Default code behavior is `split` when no mode is set. The Compose example uses `relay` because it is the mode to try for IPTV streams.
|
||||
|
||||
Finite-duration sessions are treated as recorded video and seekable. When the configured mode is `relay`, recorded sessions switch to the seek-capable `split` path once duration metadata is known; live/unknown-duration streams stay in relay mode.
|
||||
Finite-duration sessions are treated as recorded video and seekable when metadata probing is enabled. Metadata probing is enabled by default except in `relay`, where it defaults off to preserve the one-upstream-connection behavior for IPTV-style streams. When the configured mode is `relay` and duration metadata is known, recorded sessions switch to the seek-capable `split` path; live/unknown-duration streams stay in relay mode.
|
||||
|
||||
## ffmpeg Pipelines
|
||||
|
||||
@@ -88,6 +90,7 @@ Common HTTP input args:
|
||||
- `-loglevel ${FFMPEG_LOG_LEVEL}`
|
||||
- `-nostats`
|
||||
- `-seekable ${FFMPEG_INPUT_SEEKABLE}`
|
||||
- HTTP reconnect options when `FFMPEG_HTTP_RECONNECT=1` and the input is HTTP(S)
|
||||
- `-re`
|
||||
- `-i <inputUrl>`
|
||||
|
||||
@@ -97,15 +100,17 @@ Audio output:
|
||||
|
||||
- Maps `0:a:0?`
|
||||
- Disables video with `-vn`
|
||||
- Converts to stereo 48 kHz MP3 with `libmp3lame`
|
||||
- Converts to MP3 with `libmp3lame`
|
||||
- Uses `session.options.audioBitrate`, default `160k`
|
||||
- Uses `session.options.audioChannels`, default `2`
|
||||
- Uses `session.options.audioSampleRate`, default `48000`
|
||||
- Outputs to `pipe:1`
|
||||
|
||||
Frame output:
|
||||
|
||||
- Maps `0:v:0`
|
||||
- Disables audio with `-an`
|
||||
- Applies `fps=<fps>,scale=w='min(<width>,iw)':h=-2:flags=bicubic:out_range=pc,format=yuvj420p`
|
||||
- Applies `fps=<fps>,scale=w='min(<width>,iw)':h=-2:flags=bicubic:out_range=pc,format=yuvj420p,realtime`
|
||||
- Encodes `mjpeg`
|
||||
- Uses `-pix_fmt yuvj420p`, `-color_range pc`, `-q:v <quality>`
|
||||
- Outputs `image2pipe` to either `pipe:1` or `pipe:3`
|
||||
@@ -169,27 +174,45 @@ Runtime:
|
||||
|
||||
- `PORT`: HTTP port, default `3000`.
|
||||
- `FFMPEG_PATH`: ffmpeg binary path, default `ffmpeg`.
|
||||
- `YT_DLP_PATH`: yt-dlp binary path, default `yt-dlp`.
|
||||
- `YT_DLP_FORMAT`: yt-dlp format selector for YouTube URLs, default `best[ext=mp4][vcodec!=none][acodec!=none]/best[vcodec!=none][acodec!=none]/best`.
|
||||
- `YT_DLP_TIMEOUT_MS`: yt-dlp resolution timeout, default `45000`.
|
||||
- `FFMPEG_LOG_LEVEL`: ffmpeg log level, default `warning`.
|
||||
- `FFMPEG_INPUT_SEEKABLE`: HTTP input seekable option, default `0`.
|
||||
- `FFMPEG_HTTP_RECONNECT`: enable ffmpeg HTTP reconnect options for HTTP inputs, default `1`.
|
||||
- `FFMPEG_HTTP_RECONNECT_DELAY_MAX`: max ffmpeg reconnect delay, default `2`.
|
||||
- `FFMPEG_HTTP_RECONNECT_MAX_RETRIES`: max ffmpeg reconnect retries, default `4`, applied only when the installed ffmpeg supports `reconnect_max_retries`.
|
||||
- `FFMPEG_HTTP_RECONNECT_ON_HTTP_ERROR`: HTTP status list for reconnect, default `5xx`.
|
||||
- `METADATA_PROBE_ENABLED`: probe session duration with ffprobe, default `1` except in `relay`, where default is `0`.
|
||||
- `METADATA_PROBE_TIMEOUT_MS`: ffprobe duration timeout, default `4000`.
|
||||
- `PLAYBACK_CONNECTION_MODE`: `split`, `relay`, or `single`.
|
||||
- `RECENT_URLS_PATH`: recent URL JSON path.
|
||||
- `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`.
|
||||
- `DEFAULT_AUDIO_BITRATE`: default MP3 audio bitrate, fallback `160k`.
|
||||
- `DEFAULT_AUDIO_CHANNELS`: default MP3 audio channels, fallback `2`, clamped `1..2`.
|
||||
- `DEFAULT_AUDIO_SAMPLE_RATE`: default MP3 audio sample rate, fallback `48000`, clamped `22050..48000`.
|
||||
- `MAX_WS_BUFFER_BYTES`: server-side WebSocket JPEG frame backlog cap, default `2097152`.
|
||||
- `MAX_AUDIO_QUEUE_BYTES`: single-mode audio output queue cap, default `16777216`.
|
||||
- `MAX_RELAY_BRANCH_QUEUE_BYTES`: relay per-branch compressed-input queue cap, default `16777216`.
|
||||
- `MAX_AUDIO_QUEUE_BYTES`: single-mode audio output queue cap, default `4194304`.
|
||||
- `MAX_RELAY_BRANCH_QUEUE_BYTES`: relay per-branch compressed-input queue cap, default `8388608`.
|
||||
|
||||
Session playback options are accepted by `POST /api/session` even though the UI hides them:
|
||||
|
||||
- `fps`: default `24`, clamped `1..30`.
|
||||
- `width`: default `960`, clamped `160..1920`.
|
||||
- `quality`: default `5`, clamped `2..18`; lower is better for ffmpeg `-q:v`.
|
||||
- `quality`: defaults to `JPEG_QUALITY`, clamped `2..18`; lower is better for ffmpeg `-q:v`.
|
||||
- `audioBitrate`: default `160k`, accepts two or three digits followed by `k`.
|
||||
- `audioChannels`: default `2`, clamped `1..2`.
|
||||
- `audioSampleRate`: default `48000`, clamped `22050..48000`.
|
||||
|
||||
## Docker Notes
|
||||
|
||||
The Docker image installs ffmpeg and runs as non-root `node`.
|
||||
The Docker image installs ffmpeg and yt-dlp and runs as non-root `node`. yt-dlp is installed from the upstream master branch when the image is built.
|
||||
|
||||
Hardware acceleration is not required. Device passthrough may help only if server CPU decode is saturated. It does not fix audio/frame coupling issues; `relay` was built for that.
|
||||
|
||||
|
||||
11
Dockerfile
11
Dockerfile
@@ -3,12 +3,19 @@ FROM node:22-bookworm-slim
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
ARG YT_DLP_PIP_SPEC="yt-dlp[default] @ https://github.com/yt-dlp/yt-dlp/archive/master.tar.gz"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ADD https://api.github.com/repos/yt-dlp/yt-dlp/commits/master /tmp/yt-dlp-master.json
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
&& apt-get install -y --no-install-recommends ca-certificates ffmpeg python3 python3-pip \
|
||||
&& python3 -m pip install --no-cache-dir --break-system-packages --upgrade "$YT_DLP_PIP_SPEC" \
|
||||
&& yt-dlp --version \
|
||||
&& apt-get purge -y --auto-remove python3-pip \
|
||||
&& rm -f /tmp/yt-dlp-master.json \
|
||||
&& rm -rf /root/.cache/pip /var/lib/apt/lists/*
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
25
README.md
25
README.md
@@ -14,12 +14,15 @@ npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
Open `http://localhost:3000`, paste a direct HTTP(S) stream URL, and click `Next`.
|
||||
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.
|
||||
|
||||
You need an `ffmpeg` binary with decoders for the stream's video and audio codecs. If your stream is H.264 or HEVC, make sure your installed `ffmpeg` actually includes those decoders. You can point the app at a different binary with:
|
||||
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
|
||||
FFMPEG_PATH=/path/to/ffmpeg npm start
|
||||
YT_DLP_PATH=/path/to/yt-dlp npm start
|
||||
```
|
||||
|
||||
## Docker
|
||||
@@ -41,17 +44,21 @@ 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
|
||||
docker logs -f frame-stream-player
|
||||
```
|
||||
|
||||
The app sets `FFMPEG_INPUT_SEEKABLE=0` by default so `ffmpeg` reads stream inputs sequentially and avoids extra HTTP range connections. If a specific VOD file requires seeking for metadata, set `FFMPEG_INPUT_SEEKABLE=-1` to restore ffmpeg's automatic behavior.
|
||||
The app sets `FFMPEG_INPUT_SEEKABLE=0` by default so `ffmpeg` reads stream inputs sequentially and avoids extra HTTP range connections. If a specific VOD file requires seeking for metadata, set `FFMPEG_INPUT_SEEKABLE=-1` to restore ffmpeg's automatic behavior. For ffmpeg-owned HTTP inputs, reconnect handling is enabled by default with `FFMPEG_HTTP_RECONNECT=1`. `FFMPEG_HTTP_RECONNECT_MAX_RETRIES` is applied only when the installed `ffmpeg` supports that HTTP protocol option.
|
||||
|
||||
YouTube URLs are resolved server-side with `yt-dlp` before they enter the existing ffmpeg pipeline. Recents and favorites keep the original YouTube URL, while the short-lived playback session uses the resolved media URL and headers returned by `yt-dlp`. Tune the selected format with `YT_DLP_FORMAT` and the resolver timeout with `YT_DLP_TIMEOUT_MS`.
|
||||
|
||||
JPEG frames are dropped when the browser WebSocket falls behind instead of letting stale frames queue indefinitely. Tune the server-side backlog cap with `MAX_WS_BUFFER_BYTES`; the default is `2097152`.
|
||||
|
||||
In single mode, audio output from `ffmpeg` is buffered before it is written to the browser so short HTTP backpressure pauses are less likely to stall frame generation. Tune the cap with `MAX_AUDIO_QUEUE_BYTES`; the default is `16777216`.
|
||||
In single mode, audio output from `ffmpeg` is buffered before it is written to the browser so short HTTP backpressure pauses are less likely to stall frame generation. Tune the cap with `MAX_AUDIO_QUEUE_BYTES`; the default is `4194304`.
|
||||
|
||||
Playback uses `PLAYBACK_CONNECTION_MODE=split` by default. The Docker Compose example sets `PLAYBACK_CONNECTION_MODE=relay` so IPTV-style streams can be tested with one upstream connection.
|
||||
|
||||
@@ -61,18 +68,18 @@ Available playback modes:
|
||||
- `relay`: One source connection from the backend, then the compressed input bytes are teed into separate audio and frame `ffmpeg` workers. This is intended for IPTV hosts that stop early or reject multiple active connections.
|
||||
- `single`: One source connection and one `ffmpeg` worker with both audio and frame outputs. This is the simplest one-connection fallback, but audio and frame delivery can affect each other.
|
||||
|
||||
Finite-duration streams are treated as recorded video and become seekable once metadata is available. If the app is configured for `relay`, recorded streams switch to the seek-capable `split` path; live or unknown-duration streams stay in relay mode.
|
||||
Finite-duration streams are treated as recorded video and become seekable once metadata is available. Metadata probing is enabled by default except in `relay`, where `METADATA_PROBE_ENABLED=0` avoids extra upstream connections. Set `METADATA_PROBE_ENABLED=1` if you want recorded relay sessions to become seekable and switch to the seek-capable `split` path.
|
||||
|
||||
Relay mode uses bounded per-worker input queues so one branch can briefly lag without immediately stalling the other. Tune the cap with `MAX_RELAY_BRANCH_QUEUE_BYTES`; the default is `16777216`.
|
||||
Relay mode uses bounded per-worker input queues so one branch can briefly lag without immediately stalling the other. Tune the cap with `MAX_RELAY_BRANCH_QUEUE_BYTES`; the default is `8388608`.
|
||||
|
||||
## Tuning
|
||||
|
||||
The UI intentionally hides these settings, but the backend still supports them through `POST /api/session`.
|
||||
|
||||
- Frame rate defaults to `24fps`. Lower it if the client cannot keep up.
|
||||
- Max width defaults to `960px`. Lower it first if bandwidth or image decode is the bottleneck.
|
||||
- JPEG quality uses ffmpeg's `-q:v` scale, where lower is better. `5` is the default, `2` is high quality, and `18` is rough but lighter.
|
||||
- Audio defaults to MP3 at `160k`.
|
||||
- Max width defaults to `960px`, and the client now caps each session to its viewport width. Lower `DEFAULT_FRAME_WIDTH` first if bandwidth or image decode is the bottleneck.
|
||||
- JPEG quality uses ffmpeg's `-q:v` scale, where lower is better/larger. Set the default with `JPEG_QUALITY`; `7` is the fallback, `2` is high quality, and `18` is rough but much lighter.
|
||||
- Audio defaults to stereo MP3 at `160k`. Tune with `DEFAULT_AUDIO_BITRATE`, `DEFAULT_AUDIO_CHANNELS`, and `DEFAULT_AUDIO_SAMPLE_RATE`.
|
||||
|
||||
## Tradeoffs
|
||||
|
||||
|
||||
@@ -9,21 +9,37 @@ services:
|
||||
environment:
|
||||
PORT: "3000"
|
||||
NODE_ENV: production
|
||||
YT_DLP_TIMEOUT_MS: "45000"
|
||||
# split: smoothest, two upstream connections.
|
||||
# relay: one upstream connection, separate audio/frame ffmpeg workers.
|
||||
# single: one upstream connection and one ffmpeg worker; fallback only.
|
||||
PLAYBACK_CONNECTION_MODE: relay
|
||||
FFMPEG_LOG_LEVEL: warning
|
||||
FFMPEG_INPUT_SEEKABLE: "0"
|
||||
FFMPEG_HTTP_RECONNECT: "1"
|
||||
FFMPEG_HTTP_RECONNECT_DELAY_MAX: "2"
|
||||
FFMPEG_HTTP_RECONNECT_MAX_RETRIES: "4"
|
||||
FFMPEG_HTTP_RECONNECT_ON_HTTP_ERROR: "5xx"
|
||||
METADATA_PROBE_ENABLED: "0"
|
||||
METADATA_PROBE_TIMEOUT_MS: "4000"
|
||||
DEFAULT_FPS: "24"
|
||||
DEFAULT_FRAME_WIDTH: "960"
|
||||
JPEG_QUALITY: "7"
|
||||
DEFAULT_AUDIO_BITRATE: "160k"
|
||||
DEFAULT_AUDIO_CHANNELS: "2"
|
||||
DEFAULT_AUDIO_SAMPLE_RATE: "48000"
|
||||
MAX_WS_BUFFER_BYTES: "2097152"
|
||||
MAX_AUDIO_QUEUE_BYTES: "16777216"
|
||||
MAX_RELAY_BRANCH_QUEUE_BYTES: "16777216"
|
||||
MAX_AUDIO_QUEUE_BYTES: "4194304"
|
||||
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.
|
||||
#
|
||||
|
||||
1283
public/app.js
1283
public/app.js
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Frame Stream Player</title>
|
||||
<title>CarPlay</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
@@ -20,7 +20,9 @@
|
||||
autocomplete="off"
|
||||
required
|
||||
>
|
||||
<button id="next" type="submit">Next</button>
|
||||
<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">
|
||||
<section id="recent-panel" class="url-column" aria-labelledby="recent-heading">
|
||||
@@ -64,18 +66,49 @@
|
||||
<div id="favorites-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="favorites-modal-title" hidden>
|
||||
<form id="favorites-form" class="modal-panel">
|
||||
<div class="modal-heading">
|
||||
<h2 id="favorites-modal-title">Edit Favorites</h2>
|
||||
<h2 id="favorites-modal-title">Favorites</h2>
|
||||
<button id="close-favorites" type="button" class="icon-button" aria-label="Close favorites editor">×</button>
|
||||
</div>
|
||||
<div id="favorites-manager" class="favorites-manager">
|
||||
<div id="favorites-editor-list" class="favorites-editor-list"></div>
|
||||
<button id="add-favorite" type="button" class="secondary-button">Add Favorite</button>
|
||||
</div>
|
||||
<div id="favorite-wizard" class="favorite-wizard" hidden>
|
||||
<div id="favorite-wizard-step" class="wizard-step">Title</div>
|
||||
<label id="favorite-title-field" class="wizard-field" for="favorite-title">
|
||||
<span>Title</span>
|
||||
<input id="favorite-title" type="text" autocomplete="off" maxlength="120">
|
||||
</label>
|
||||
<label id="favorite-url-field" class="wizard-field" for="favorite-url" hidden>
|
||||
<span>URL</span>
|
||||
<input id="favorite-url" type="url" autocomplete="off">
|
||||
</label>
|
||||
</div>
|
||||
<div id="favorites-message" class="modal-message" role="status" aria-live="polite"></div>
|
||||
<div class="modal-actions">
|
||||
<button id="cancel-favorites" type="button" class="secondary-button">Cancel</button>
|
||||
<button id="save-favorites" type="submit" class="primary-button">Save</button>
|
||||
<button id="cancel-favorites" type="button" class="secondary-button">Done</button>
|
||||
<button id="favorite-wizard-back" type="button" class="secondary-button" hidden>Back</button>
|
||||
<button id="save-favorites" type="submit" class="primary-button" hidden>Next</button>
|
||||
</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>
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
--glass-strong: rgba(10, 10, 10, 0.82);
|
||||
--line: rgba(246, 241, 232, 0.18);
|
||||
--accent: #e8a84f;
|
||||
--seek-track-height: 0.5rem;
|
||||
--seek-thumb-size: 2.65rem;
|
||||
--seek-hit-height: 3.25rem;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -101,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);
|
||||
@@ -116,6 +120,11 @@ button:disabled {
|
||||
color: #070707;
|
||||
}
|
||||
|
||||
.url-form .secondary-submit {
|
||||
color: var(--fg);
|
||||
background: rgba(255, 255, 255, 0.10);
|
||||
}
|
||||
|
||||
.library-panel {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
@@ -283,10 +292,10 @@ canvas {
|
||||
|
||||
.playhead {
|
||||
position: absolute;
|
||||
top: -1.75rem;
|
||||
top: -3.2rem;
|
||||
right: 1.1rem;
|
||||
left: 1.1rem;
|
||||
height: 2.45rem;
|
||||
height: 3.85rem;
|
||||
}
|
||||
|
||||
.time-badge {
|
||||
@@ -322,11 +331,12 @@ canvas {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 1.35rem;
|
||||
height: var(--seek-hit-height);
|
||||
margin: 0;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
touch-action: none;
|
||||
--progress: 0%;
|
||||
}
|
||||
|
||||
@@ -335,7 +345,7 @@ canvas {
|
||||
}
|
||||
|
||||
.seek-slider::-webkit-slider-runnable-track {
|
||||
height: 0.28rem;
|
||||
height: var(--seek-track-height);
|
||||
border-radius: 999px;
|
||||
background:
|
||||
linear-gradient(
|
||||
@@ -348,35 +358,45 @@ canvas {
|
||||
}
|
||||
|
||||
.seek-slider::-moz-range-track {
|
||||
height: 0.28rem;
|
||||
height: var(--seek-track-height);
|
||||
border-radius: 999px;
|
||||
background: rgba(246, 241, 232, 0.28);
|
||||
}
|
||||
|
||||
.seek-slider::-moz-range-progress {
|
||||
height: 0.28rem;
|
||||
height: var(--seek-track-height);
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.seek-slider::-webkit-slider-thumb {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
margin-top: -0.36rem;
|
||||
width: var(--seek-thumb-size);
|
||||
height: var(--seek-thumb-size);
|
||||
margin-top: calc((var(--seek-track-height) - var(--seek-thumb-size)) / 2);
|
||||
appearance: none;
|
||||
border: 2px solid rgba(7, 7, 7, 0.75);
|
||||
border: 3px solid rgba(7, 7, 7, 0.78);
|
||||
border-radius: 50%;
|
||||
background: var(--fg);
|
||||
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.48);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.58);
|
||||
}
|
||||
|
||||
.seek-slider::-moz-range-thumb {
|
||||
width: 0.86rem;
|
||||
height: 0.86rem;
|
||||
border: 2px solid rgba(7, 7, 7, 0.75);
|
||||
width: var(--seek-thumb-size);
|
||||
height: var(--seek-thumb-size);
|
||||
border: 3px solid rgba(7, 7, 7, 0.78);
|
||||
border-radius: 50%;
|
||||
background: var(--fg);
|
||||
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.48);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.58);
|
||||
}
|
||||
|
||||
.seek-slider:focus-visible::-webkit-slider-thumb,
|
||||
.playhead.scrubbing .seek-slider::-webkit-slider-thumb {
|
||||
box-shadow: 0 0 0 0.35rem rgba(232, 168, 79, 0.32), 0 10px 30px rgba(0, 0, 0, 0.58);
|
||||
}
|
||||
|
||||
.seek-slider:focus-visible::-moz-range-thumb,
|
||||
.playhead.scrubbing .seek-slider::-moz-range-thumb {
|
||||
box-shadow: 0 0 0 0.35rem rgba(232, 168, 79, 0.32), 0 10px 30px rgba(0, 0, 0, 0.58);
|
||||
}
|
||||
|
||||
.seek-slider:disabled::-webkit-slider-thumb {
|
||||
@@ -423,7 +443,7 @@ audio {
|
||||
|
||||
.modal-panel {
|
||||
display: grid;
|
||||
width: min(860px, 100%);
|
||||
width: min(620px, 100%);
|
||||
max-height: min(760px, calc(100vh - 2rem));
|
||||
overflow: auto;
|
||||
gap: 0.85rem;
|
||||
@@ -453,38 +473,207 @@ audio {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.favorites-editor-list {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.favorite-editor-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(8rem, 0.8fr) minmax(12rem, 1.6fr) auto;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.favorite-editor-row input {
|
||||
.local-search {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 0.72rem 0.8rem;
|
||||
padding: 0.9rem 0.95rem;
|
||||
color: var(--fg);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.favorite-editor-row input::placeholder {
|
||||
color: rgba(246, 241, 232, 0.42);
|
||||
.local-search::placeholder {
|
||||
color: rgba(246, 241, 232, 0.48);
|
||||
}
|
||||
|
||||
.favorite-editor-row input:focus {
|
||||
.local-search:focus {
|
||||
border-color: rgba(232, 168, 79, 0.7);
|
||||
}
|
||||
|
||||
.secondary-button,
|
||||
.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;
|
||||
max-height: min(52vh, 28rem);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.favorites-manager,
|
||||
.favorite-wizard {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.favorite-manager-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-height: 4.7rem;
|
||||
padding: 0.65rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.favorite-summary {
|
||||
min-width: 0;
|
||||
padding-right: 0.15rem;
|
||||
}
|
||||
|
||||
.favorite-title,
|
||||
.favorite-url {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.favorite-title {
|
||||
color: var(--fg);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.favorite-url {
|
||||
margin-top: 0.22rem;
|
||||
color: rgba(246, 241, 232, 0.52);
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.favorite-action {
|
||||
display: grid;
|
||||
width: 2.45rem;
|
||||
height: 2.45rem;
|
||||
min-width: 2.45rem;
|
||||
min-height: 2.45rem;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.favorite-actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
gap: 0.45rem;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.favorite-action svg {
|
||||
display: block;
|
||||
width: 1.05rem;
|
||||
height: 1.05rem;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2.2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.wizard-step {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wizard-field {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
color: var(--soft);
|
||||
font-size: 0.86rem;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.wizard-field input {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 0.9rem 0.95rem;
|
||||
color: var(--fg);
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
text-transform: none;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.wizard-field input:focus {
|
||||
border-color: rgba(232, 168, 79, 0.7);
|
||||
}
|
||||
|
||||
.secondary-button:not(.favorite-action),
|
||||
.primary-button,
|
||||
.remove-favorite {
|
||||
.remove-favorite:not(.favorite-action) {
|
||||
min-height: 2.7rem;
|
||||
padding: 0.65rem 0.95rem;
|
||||
font-weight: 800;
|
||||
@@ -540,8 +729,24 @@ audio {
|
||||
max-height: 28vh;
|
||||
}
|
||||
|
||||
.favorite-editor-row {
|
||||
grid-template-columns: 1fr;
|
||||
.favorite-manager-row {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
min-height: 5rem;
|
||||
padding: 0.7rem;
|
||||
}
|
||||
|
||||
.favorite-actions {
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.favorite-actions .favorite-action {
|
||||
width: 2.35rem;
|
||||
height: 2.35rem;
|
||||
min-width: 2.35rem;
|
||||
min-height: 2.35rem;
|
||||
}
|
||||
|
||||
.remove-favorite,
|
||||
@@ -550,9 +755,13 @@ audio {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.local-back {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr));
|
||||
}
|
||||
|
||||
.controls {
|
||||
|
||||
1213
server/index.js
1213
server/index.js
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user