Compare commits
24 Commits
13b1d768dc
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| d37694e4d3 | |||
| 0ad0df5612 | |||
| 2db133dd1c | |||
| 53d8a6dc2e | |||
| c13bafe96b | |||
| 6135a6f535 | |||
| 99388b6487 | |||
| 53487087a2 | |||
| c4fb23d971 | |||
| 562f2b8612 | |||
| 20f6d4d192 | |||
| 2866d33dec | |||
| de0307539c | |||
| 7655d7aaba | |||
| a9f8509b99 | |||
| 0cb091cb53 | |||
| a9b180f774 | |||
| 2c2b0fdf78 | |||
| 5bf59b2436 | |||
| 9d1f4cc53b | |||
| 8b74d25954 | |||
| 5e2a2e1de7 | |||
| 47dae48673 | |||
| 81d9cfc1c2 |
52
AGENTS.md
52
AGENTS.md
@@ -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:
|
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.
|
- 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.
|
- 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.
|
- 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
|
## Core Architecture
|
||||||
|
|
||||||
The app is plain Node/Express plus browser JavaScript:
|
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/index.html`: frontend markup.
|
||||||
- `public/app.js`: URL submission, WebSocket frame receiving, audio element coordination, canvas drawing, overlay controls.
|
- `public/app.js`: URL submission, WebSocket frame receiving, audio element coordination, canvas drawing, overlay controls.
|
||||||
- `public/styles.css`: two-screen player UI.
|
- `public/styles.css`: two-screen player UI.
|
||||||
@@ -26,8 +26,12 @@ The app is plain Node/Express plus browser JavaScript:
|
|||||||
|
|
||||||
Main public endpoints:
|
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`.
|
- `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.
|
- `GET /audio/:sessionId`: serves MP3 audio to the browser audio element.
|
||||||
- `WS /frames/:sessionId`: sends timed JPEG frame packets to the browser.
|
- `WS /frames/:sessionId`: sends timed JPEG frame packets to the browser.
|
||||||
- `GET /api/health`: exposes basic health and active playback connection mode.
|
- `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.
|
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
|
## ffmpeg Pipelines
|
||||||
|
|
||||||
All ffmpeg command builders live near the bottom of `server/index.js`.
|
All ffmpeg command builders live near the bottom of `server/index.js`.
|
||||||
@@ -84,6 +90,7 @@ Common HTTP input args:
|
|||||||
- `-loglevel ${FFMPEG_LOG_LEVEL}`
|
- `-loglevel ${FFMPEG_LOG_LEVEL}`
|
||||||
- `-nostats`
|
- `-nostats`
|
||||||
- `-seekable ${FFMPEG_INPUT_SEEKABLE}`
|
- `-seekable ${FFMPEG_INPUT_SEEKABLE}`
|
||||||
|
- HTTP reconnect options when `FFMPEG_HTTP_RECONNECT=1` and the input is HTTP(S)
|
||||||
- `-re`
|
- `-re`
|
||||||
- `-i <inputUrl>`
|
- `-i <inputUrl>`
|
||||||
|
|
||||||
@@ -93,15 +100,17 @@ Audio output:
|
|||||||
|
|
||||||
- Maps `0:a:0?`
|
- Maps `0:a:0?`
|
||||||
- Disables video with `-vn`
|
- 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.audioBitrate`, default `160k`
|
||||||
|
- Uses `session.options.audioChannels`, default `2`
|
||||||
|
- Uses `session.options.audioSampleRate`, default `48000`
|
||||||
- Outputs to `pipe:1`
|
- Outputs to `pipe:1`
|
||||||
|
|
||||||
Frame output:
|
Frame output:
|
||||||
|
|
||||||
- Maps `0:v:0`
|
- Maps `0:v:0`
|
||||||
- Disables audio with `-an`
|
- 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`
|
- Encodes `mjpeg`
|
||||||
- Uses `-pix_fmt yuvj420p`, `-color_range pc`, `-q:v <quality>`
|
- Uses `-pix_fmt yuvj420p`, `-color_range pc`, `-q:v <quality>`
|
||||||
- Outputs `image2pipe` to either `pipe:1` or `pipe:3`
|
- Outputs `image2pipe` to either `pipe:1` or `pipe:3`
|
||||||
@@ -165,25 +174,45 @@ Runtime:
|
|||||||
|
|
||||||
- `PORT`: HTTP port, default `3000`.
|
- `PORT`: HTTP port, default `3000`.
|
||||||
- `FFMPEG_PATH`: ffmpeg binary path, default `ffmpeg`.
|
- `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_LOG_LEVEL`: ffmpeg log level, default `warning`.
|
||||||
- `FFMPEG_INPUT_SEEKABLE`: HTTP input seekable option, default `0`.
|
- `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`.
|
- `PLAYBACK_CONNECTION_MODE`: `split`, `relay`, or `single`.
|
||||||
- `RECENT_URLS_PATH`: recent URL JSON path.
|
- `RECENT_URLS_PATH`: recent URL JSON path.
|
||||||
- `RECENT_URL_LIMIT`: recent URL count, default `12`.
|
- `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_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_AUDIO_QUEUE_BYTES`: single-mode audio output queue cap, default `4194304`.
|
||||||
- `MAX_RELAY_BRANCH_QUEUE_BYTES`: relay per-branch compressed-input queue cap, default `16777216`.
|
- `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:
|
Session playback options are accepted by `POST /api/session` even though the UI hides them:
|
||||||
|
|
||||||
- `fps`: default `24`, clamped `1..30`.
|
- `fps`: default `24`, clamped `1..30`.
|
||||||
- `width`: default `960`, clamped `160..1920`.
|
- `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`.
|
- `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
|
## 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.
|
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
|
```sh
|
||||||
PORT=3014 RECENT_URLS_PATH=/tmp/carplay-relay-recent.json \
|
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 \
|
FFMPEG_LOG_LEVEL=warning FFMPEG_INPUT_SEEKABLE=0 \
|
||||||
PLAYBACK_CONNECTION_MODE=relay npm start
|
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:
|
After smoke testing, remove generated assets:
|
||||||
|
|
||||||
```sh
|
```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
|
## Security Notes
|
||||||
|
|||||||
11
Dockerfile
11
Dockerfile
@@ -3,12 +3,19 @@ FROM node:22-bookworm-slim
|
|||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
ARG DEBIAN_FRONTEND=noninteractive
|
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
|
WORKDIR /app
|
||||||
|
|
||||||
|
ADD https://api.github.com/repos/yt-dlp/yt-dlp/commits/master /tmp/yt-dlp-master.json
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends ca-certificates ffmpeg \
|
&& apt-get install -y --no-install-recommends ca-certificates ffmpeg python3 python3-pip \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& 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 ./
|
COPY package*.json ./
|
||||||
RUN npm ci --omit=dev
|
RUN npm ci --omit=dev
|
||||||
|
|||||||
27
README.md
27
README.md
@@ -14,12 +14,15 @@ npm install
|
|||||||
npm start
|
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
|
```sh
|
||||||
FFMPEG_PATH=/path/to/ffmpeg npm start
|
FFMPEG_PATH=/path/to/ffmpeg npm start
|
||||||
|
YT_DLP_PATH=/path/to/yt-dlp npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
## Docker
|
## 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.
|
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:
|
`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
|
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`.
|
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.
|
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.
|
- `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.
|
- `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
|
## Tuning
|
||||||
|
|
||||||
The UI intentionally hides these settings, but the backend still supports them through `POST /api/session`.
|
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.
|
- 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.
|
- 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. `5` is the default, `2` is high quality, and `18` is rough but lighter.
|
- 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 MP3 at `160k`.
|
- Audio defaults to stereo MP3 at `160k`. Tune with `DEFAULT_AUDIO_BITRATE`, `DEFAULT_AUDIO_CHANNELS`, and `DEFAULT_AUDIO_SAMPLE_RATE`.
|
||||||
|
|
||||||
## Tradeoffs
|
## Tradeoffs
|
||||||
|
|
||||||
|
|||||||
@@ -9,20 +9,37 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
PORT: "3000"
|
PORT: "3000"
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
|
YT_DLP_TIMEOUT_MS: "45000"
|
||||||
# split: smoothest, two upstream connections.
|
# split: smoothest, two upstream connections.
|
||||||
# relay: one upstream connection, separate audio/frame ffmpeg workers.
|
# relay: one upstream connection, separate audio/frame ffmpeg workers.
|
||||||
# single: one upstream connection and one ffmpeg worker; fallback only.
|
# single: one upstream connection and one ffmpeg worker; fallback only.
|
||||||
PLAYBACK_CONNECTION_MODE: relay
|
PLAYBACK_CONNECTION_MODE: relay
|
||||||
FFMPEG_LOG_LEVEL: warning
|
FFMPEG_LOG_LEVEL: warning
|
||||||
FFMPEG_INPUT_SEEKABLE: "0"
|
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_WS_BUFFER_BYTES: "2097152"
|
||||||
MAX_AUDIO_QUEUE_BYTES: "16777216"
|
MAX_AUDIO_QUEUE_BYTES: "4194304"
|
||||||
MAX_RELAY_BRANCH_QUEUE_BYTES: "16777216"
|
MAX_RELAY_BRANCH_QUEUE_BYTES: "8388608"
|
||||||
RECENT_URLS_PATH: /app/data/recent-urls.json
|
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:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
volumes:
|
volumes:
|
||||||
- frame-stream-data:/app/data
|
- frame-stream-data:/app/data
|
||||||
|
# - /path/on/host/videos:/app/local-videos:ro
|
||||||
|
|
||||||
# CPU decoding is the default and does not need device passthrough.
|
# CPU decoding is the default and does not need device passthrough.
|
||||||
#
|
#
|
||||||
|
|||||||
1526
public/app.js
1526
public/app.js
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Frame Stream Player</title>
|
<title>CarPlay</title>
|
||||||
<link rel="stylesheet" href="/styles.css">
|
<link rel="stylesheet" href="/styles.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -20,10 +20,24 @@
|
|||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
required
|
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>
|
</form>
|
||||||
<div id="recent-panel" class="recent-panel" aria-label="Recently played URLs" hidden>
|
<div id="library-panel" class="library-panel" aria-label="Saved streams">
|
||||||
<div id="recent-list" class="recent-list"></div>
|
<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>
|
</div>
|
||||||
<div id="entry-message" class="message" role="status" aria-live="polite"></div>
|
<div id="entry-message" class="message" role="status" aria-live="polite"></div>
|
||||||
@@ -48,6 +62,53 @@
|
|||||||
</div>
|
</div>
|
||||||
<audio id="audio" preload="none"></audio>
|
<audio id="audio" preload="none"></audio>
|
||||||
</section>
|
</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">×</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">×</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>
|
</main>
|
||||||
|
|
||||||
<script src="/app.js" type="module"></script>
|
<script src="/app.js" type="module"></script>
|
||||||
|
|||||||
@@ -6,6 +6,9 @@
|
|||||||
--glass-strong: rgba(10, 10, 10, 0.82);
|
--glass-strong: rgba(10, 10, 10, 0.82);
|
||||||
--line: rgba(246, 241, 232, 0.18);
|
--line: rgba(246, 241, 232, 0.18);
|
||||||
--accent: #e8a84f;
|
--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 {
|
.entry-stack {
|
||||||
display: grid;
|
display: grid;
|
||||||
width: min(760px, 100%);
|
width: min(980px, 100%);
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,7 +99,13 @@ button:disabled {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.url-form button,
|
.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: 1px solid var(--line);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
@@ -111,28 +120,66 @@ button:disabled {
|
|||||||
color: #070707;
|
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;
|
overflow: hidden;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 28px;
|
border-radius: 24px;
|
||||||
background: rgba(255, 255, 255, 0.07);
|
background: rgba(255, 255, 255, 0.07);
|
||||||
box-shadow: 0 18px 80px rgba(0, 0, 0, 0.28);
|
box-shadow: 0 18px 80px rgba(0, 0, 0, 0.28);
|
||||||
backdrop-filter: blur(18px);
|
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;
|
display: grid;
|
||||||
max-height: min(42vh, 24rem);
|
max-height: min(42vh, 24rem);
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 0.35rem;
|
padding: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recent-url {
|
.url-item {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 20px;
|
border-radius: 16px;
|
||||||
padding: 0.85rem 1rem;
|
padding: 0.85rem 1rem;
|
||||||
color: var(--soft);
|
color: var(--soft);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@@ -141,13 +188,19 @@ button:disabled {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recent-url:hover,
|
.url-item:hover,
|
||||||
.recent-url:focus {
|
.url-item:focus {
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
background: rgba(255, 255, 255, 0.09);
|
background: rgba(255, 255, 255, 0.09);
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.empty-list {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
color: rgba(246, 241, 232, 0.42);
|
||||||
|
}
|
||||||
|
|
||||||
.message {
|
.message {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 2rem;
|
bottom: 2rem;
|
||||||
@@ -239,15 +292,16 @@ canvas {
|
|||||||
|
|
||||||
.playhead {
|
.playhead {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -1.75rem;
|
top: -3.2rem;
|
||||||
right: 1.1rem;
|
right: 1.1rem;
|
||||||
left: 1.1rem;
|
left: 1.1rem;
|
||||||
height: 2.2rem;
|
height: 3.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.time-badge {
|
.time-badge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
pointer-events: none;
|
||||||
min-width: 3.6rem;
|
min-width: 3.6rem;
|
||||||
padding: 0.22rem 0.45rem;
|
padding: 0.22rem 0.45rem;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
@@ -274,14 +328,15 @@ canvas {
|
|||||||
.seek-slider {
|
.seek-slider {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0.18rem;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 1rem;
|
height: var(--seek-hit-height);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
touch-action: none;
|
||||||
--progress: 0%;
|
--progress: 0%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,7 +345,7 @@ canvas {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.seek-slider::-webkit-slider-runnable-track {
|
.seek-slider::-webkit-slider-runnable-track {
|
||||||
height: 0.28rem;
|
height: var(--seek-track-height);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background:
|
background:
|
||||||
linear-gradient(
|
linear-gradient(
|
||||||
@@ -303,35 +358,45 @@ canvas {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.seek-slider::-moz-range-track {
|
.seek-slider::-moz-range-track {
|
||||||
height: 0.28rem;
|
height: var(--seek-track-height);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(246, 241, 232, 0.28);
|
background: rgba(246, 241, 232, 0.28);
|
||||||
}
|
}
|
||||||
|
|
||||||
.seek-slider::-moz-range-progress {
|
.seek-slider::-moz-range-progress {
|
||||||
height: 0.28rem;
|
height: var(--seek-track-height);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.seek-slider::-webkit-slider-thumb {
|
.seek-slider::-webkit-slider-thumb {
|
||||||
width: 1rem;
|
width: var(--seek-thumb-size);
|
||||||
height: 1rem;
|
height: var(--seek-thumb-size);
|
||||||
margin-top: -0.36rem;
|
margin-top: calc((var(--seek-track-height) - var(--seek-thumb-size)) / 2);
|
||||||
appearance: none;
|
appearance: none;
|
||||||
border: 2px solid rgba(7, 7, 7, 0.75);
|
border: 3px solid rgba(7, 7, 7, 0.78);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--fg);
|
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 {
|
.seek-slider::-moz-range-thumb {
|
||||||
width: 0.86rem;
|
width: var(--seek-thumb-size);
|
||||||
height: 0.86rem;
|
height: var(--seek-thumb-size);
|
||||||
border: 2px solid rgba(7, 7, 7, 0.75);
|
border: 3px solid rgba(7, 7, 7, 0.78);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--fg);
|
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 {
|
.seek-slider:disabled::-webkit-slider-thumb {
|
||||||
@@ -365,6 +430,276 @@ audio {
|
|||||||
display: none;
|
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] {
|
[hidden] {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
@@ -386,6 +721,49 @@ audio {
|
|||||||
width: 100%;
|
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 {
|
.controls {
|
||||||
right: 0.75rem;
|
right: 0.75rem;
|
||||||
bottom: 0.75rem;
|
bottom: 0.75rem;
|
||||||
|
|||||||
1605
server/index.js
1605
server/index.js
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user