Compare commits

..

24 Commits

Author SHA1 Message Date
d37694e4d3 Refresh local videos on each listing request 2026-06-15 19:44:49 -07:00
0ad0df5612 realtime flag for http urls 2026-06-14 19:07:58 -07:00
2db133dd1c adds local play 2026-06-14 18:10:10 -07:00
53d8a6dc2e fsp -> carplay 2026-06-14 17:35:38 -07:00
c13bafe96b Omit unsupported ffmpeg reconnect retry option 2026-06-12 20:05:05 -07:00
6135a6f535 Keep playhead visible for recorded streams 2026-06-12 20:02:35 -07:00
99388b6487 mobile fixes 2026-06-12 19:49:16 -07:00
53487087a2 Preserve playback position on watchdog recovery 2026-06-12 19:48:04 -07:00
c4fb23d971 Add touch-friendly stream scrubbing 2026-06-12 19:46:50 -07:00
562f2b8612 Fix mobile favorites editor layout 2026-06-12 08:45:34 -07:00
20f6d4d192 Add YouTube playback and queue flow 2026-06-11 21:13:48 -07:00
2866d33dec Tune frame queue and viewport sizing 2026-06-11 10:29:07 -07:00
de0307539c Improve frame sync telemetry 2026-06-11 10:06:56 -07:00
7655d7aaba Improve frame dropping under backpressure 2026-06-11 09:47:17 -07:00
a9f8509b99 Restore stream quality defaults 2026-06-07 00:52:37 -07:00
0cb091cb53 Reduce relay playback overhead 2026-06-07 00:44:45 -07:00
a9b180f774 Keep upcoming frames when trimming queues 2026-06-07 00:28:43 -07:00
2c2b0fdf78 Optimize stream defaults for weak connections 2026-06-05 21:26:31 -07:00
5bf59b2436 Improve frame stall recovery 2026-06-05 21:19:07 -07:00
9d1f4cc53b Make JPEG quality configurable 2026-05-29 10:21:25 -07:00
8b74d25954 Enable recorded stream seeking 2026-05-26 19:11:19 -07:00
5e2a2e1de7 adds favorites 2026-05-04 20:33:14 -07:00
47dae48673 frame watchdog in case it gets stuck 2026-05-04 20:22:21 -07:00
81d9cfc1c2 better frame decoding on server 2026-05-04 17:10:59 -07:00
8 changed files with 3515 additions and 302 deletions

View File

@@ -6,18 +6,18 @@ 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, and globally stored recently played URLs.
- 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.
The backend stores recently played URLs globally, not per-browser. The default path is `data/recent-urls.json`, configurable with `RECENT_URLS_PATH`. Docker Compose persists this through the `frame-stream-data` volume.
The backend stores recently played URLs and favorites globally, not per-browser. The default recent URL path is `data/recent-urls.json`, configurable with `RECENT_URLS_PATH`. The default favorites path is `data/favorites.json`, configurable with `FAVORITES_PATH`. Docker Compose persists both through the `frame-stream-data` volume.
## Core Architecture
The app is plain Node/Express plus browser JavaScript:
- `server/index.js`: API, WebSocket, source proxy/relay, ffmpeg process lifecycle, recent URL persistence.
- `server/index.js`: API, WebSocket, source proxy/relay, ffmpeg process lifecycle, recent URL and favorites persistence.
- `public/index.html`: frontend markup.
- `public/app.js`: URL submission, WebSocket frame receiving, audio element coordination, canvas drawing, overlay controls.
- `public/styles.css`: two-screen player UI.
@@ -26,8 +26,12 @@ 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.
- `WS /frames/:sessionId`: sends timed JPEG frame packets to the browser.
- `GET /api/health`: exposes basic health and active playback connection mode.
@@ -73,6 +77,8 @@ 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 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
All ffmpeg command builders live near the bottom of `server/index.js`.
@@ -84,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>`
@@ -93,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`
@@ -165,25 +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.
@@ -218,6 +247,7 @@ Start a mode locally:
```sh
PORT=3014 RECENT_URLS_PATH=/tmp/carplay-relay-recent.json \
FAVORITES_PATH=/tmp/carplay-relay-favorites.json \
FFMPEG_LOG_LEVEL=warning FFMPEG_INPUT_SEEKABLE=0 \
PLAYBACK_CONNECTION_MODE=relay npm start
```
@@ -225,7 +255,7 @@ PORT=3014 RECENT_URLS_PATH=/tmp/carplay-relay-recent.json \
After smoke testing, remove generated assets:
```sh
rm -f public/_relay-smoke.ts /tmp/carplay-*-recent.json
rm -f public/_relay-smoke.ts /tmp/carplay-*-recent.json /tmp/carplay-*-favorites.json
```
## Security Notes

View File

@@ -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

View File

@@ -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
@@ -39,7 +42,9 @@ docker compose -f docker-compose-example.yml up --build
The app uses CPU decoding by default, so no video device is required. The compose example includes commented VAAPI/NVIDIA passthrough options for future hardware-accelerated `ffmpeg` setups, but hardware acceleration is usually only useful when server CPU is saturated.
Recently played URLs are stored globally by the backend. In Docker Compose, they are persisted in the `frame-stream-data` named volume.
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:
@@ -47,11 +52,13 @@ Recently played URLs are stored globally by the backend. In Docker Compose, they
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,16 +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.
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`.
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 `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

View File

@@ -9,20 +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.
#

File diff suppressed because it is too large Load Diff

View File

@@ -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,10 +20,24 @@
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="recent-panel" class="recent-panel" aria-label="Recently played URLs" hidden>
<div id="recent-list" class="recent-list"></div>
<div id="library-panel" class="library-panel" aria-label="Saved streams">
<section id="recent-panel" class="url-column" aria-labelledby="recent-heading">
<div class="column-heading">
<h2 id="recent-heading">Recents</h2>
</div>
<div id="recent-list" class="url-list"></div>
</section>
<section id="favorites-panel" class="url-column" aria-labelledby="favorites-heading">
<div class="column-heading">
<h2 id="favorites-heading">Favorites</h2>
<button id="edit-favorites" type="button" class="small-button">Edit</button>
</div>
<div id="favorites-list" class="url-list"></div>
</section>
</div>
</div>
<div id="entry-message" class="message" role="status" aria-live="polite"></div>
@@ -48,6 +62,53 @@
</div>
<audio id="audio" preload="none"></audio>
</section>
<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">Favorites</h2>
<button id="close-favorites" type="button" class="icon-button" aria-label="Close favorites editor">&times;</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">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">&times;</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>

View File

@@ -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;
}
* {
@@ -64,7 +67,7 @@ button:disabled {
.entry-stack {
display: grid;
width: min(760px, 100%);
width: min(980px, 100%);
gap: 0.75rem;
}
@@ -96,7 +99,13 @@ button:disabled {
}
.url-form button,
.control-button {
.control-button,
.small-button,
.secondary-button,
.primary-button,
.icon-button,
.remove-favorite,
.local-video-item {
border: 1px solid var(--line);
border-radius: 999px;
color: var(--fg);
@@ -111,28 +120,66 @@ button:disabled {
color: #070707;
}
.recent-panel {
.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);
gap: 0.75rem;
}
.url-column {
overflow: hidden;
border: 1px solid var(--line);
border-radius: 28px;
border-radius: 24px;
background: rgba(255, 255, 255, 0.07);
box-shadow: 0 18px 80px rgba(0, 0, 0, 0.28);
backdrop-filter: blur(18px);
}
.recent-list {
.column-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
min-height: 3.2rem;
padding: 0.55rem 0.75rem 0.2rem 1rem;
}
.column-heading h2,
.modal-heading h2 {
margin: 0;
font-size: 0.86rem;
font-weight: 800;
letter-spacing: 0;
text-transform: uppercase;
color: var(--soft);
}
.small-button {
flex: 0 0 auto;
min-width: 4.2rem;
padding: 0.45rem 0.8rem;
font-size: 0.84rem;
font-weight: 800;
}
.url-list {
display: grid;
max-height: min(42vh, 24rem);
overflow: auto;
padding: 0.35rem;
}
.recent-url {
.url-item {
display: block;
width: 100%;
overflow: hidden;
border: 0;
border-radius: 20px;
border-radius: 16px;
padding: 0.85rem 1rem;
color: var(--soft);
text-align: left;
@@ -141,13 +188,19 @@ button:disabled {
background: transparent;
}
.recent-url:hover,
.recent-url:focus {
.url-item:hover,
.url-item:focus {
color: var(--fg);
background: rgba(255, 255, 255, 0.09);
outline: none;
}
.empty-list {
margin: 0;
padding: 0.85rem 1rem;
color: rgba(246, 241, 232, 0.42);
}
.message {
position: fixed;
bottom: 2rem;
@@ -239,15 +292,16 @@ canvas {
.playhead {
position: absolute;
top: -1.75rem;
top: -3.2rem;
right: 1.1rem;
left: 1.1rem;
height: 2.2rem;
height: 3.85rem;
}
.time-badge {
position: absolute;
top: 0;
pointer-events: none;
min-width: 3.6rem;
padding: 0.22rem 0.45rem;
border: 1px solid var(--line);
@@ -274,14 +328,15 @@ canvas {
.seek-slider {
position: absolute;
right: 0;
bottom: 0.18rem;
bottom: 0;
left: 0;
width: 100%;
height: 1rem;
height: var(--seek-hit-height);
margin: 0;
appearance: none;
background: transparent;
cursor: pointer;
touch-action: none;
--progress: 0%;
}
@@ -290,7 +345,7 @@ canvas {
}
.seek-slider::-webkit-slider-runnable-track {
height: 0.28rem;
height: var(--seek-track-height);
border-radius: 999px;
background:
linear-gradient(
@@ -303,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 {
@@ -365,6 +430,276 @@ audio {
display: none;
}
.modal {
position: fixed;
inset: 0;
z-index: 10;
display: grid;
place-items: center;
padding: 1rem;
background: rgba(0, 0, 0, 0.72);
backdrop-filter: blur(12px);
}
.modal-panel {
display: grid;
width: min(620px, 100%);
max-height: min(760px, calc(100vh - 2rem));
overflow: auto;
gap: 0.85rem;
margin: 0;
padding: 1rem;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--glass-strong);
box-shadow: 0 28px 120px rgba(0, 0, 0, 0.55);
}
.modal-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.icon-button {
display: grid;
width: 2.3rem;
height: 2.3rem;
place-items: center;
padding: 0;
font-size: 1.2rem;
font-weight: 800;
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;
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:not(.favorite-action) {
min-height: 2.7rem;
padding: 0.65rem 0.95rem;
font-weight: 800;
}
.primary-button {
border-color: transparent;
color: #070707;
background: var(--fg);
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.65rem;
}
.modal-message {
min-height: 1.2rem;
color: var(--soft);
}
.modal-message:empty {
display: none;
}
[hidden] {
display: none !important;
}
@@ -386,6 +721,49 @@ audio {
width: 100%;
}
.library-panel {
grid-template-columns: 1fr;
}
.url-list {
max-height: 28vh;
}
.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,
.secondary-button,
.primary-button {
width: 100%;
}
.local-back {
width: auto;
}
.modal-actions {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr));
}
.controls {
right: 0.75rem;
bottom: 0.75rem;

File diff suppressed because it is too large Load Diff