feat: custom video player with direct ssa rendering

This commit is contained in:
arabcoders 2026-05-04 09:21:43 +03:00
parent 11bd4204aa
commit 80316df952
21 changed files with 3698 additions and 1738 deletions

60
API.md
View file

@ -55,6 +55,8 @@ This document describes the available endpoints and their usage. All endpoints r
- [GET /api/player/m3u8/{mode}/{file:.\*}.m3u8](#get-apiplayerm3u8modefilem3u8)
- [GET /api/player/segments/{segment}/{file:.\*}.ts](#get-apiplayersegmentssegmentfilets)
- [GET /api/player/subtitle/{file:.\*}.vtt](#get-apiplayersubtitlefilevtt)
- [GET /api/player/subtitles/manifest/{file:.\*}](#get-apiplayersubtitlesmanifestfile)
- [GET /api/player/subtitles/{source_format}/{file:.\*}](#get-apiplayersubtitlessource_formatfile)
- [GET /api/thumbnail](#get-apithumbnail)
- [GET /api/file/ffprobe/{file:.\*}](#get-apifileffprobefile)
- [GET /api/file/info/{file:.\*}](#get-apifileinfofile)
@ -1551,6 +1553,51 @@ Binary TS data (`Content-Type: video/mpegts`).
---
### GET /api/player/subtitles/manifest/{file:.*}
**Purpose**: Returns subtitle track metadata for a local media file.
**Path Parameter**:
- `file` = Relative path of the media file within the `download_path`.
**Response**:
```json
{
"subtitles": [
{
"lang": "en",
"name": "VTT (0) - en",
"source_format": "vtt",
"delivery_format": "vtt",
"renderer": "native",
"url": "/api/player/subtitles/vtt/path/to/video.en.vtt"
},
{
"lang": "en",
"name": "ASS (1) - en",
"source_format": "ass",
"delivery_format": "ass",
"renderer": "assjs",
"url": "/api/player/subtitles/ass/path/to/video.en.ass"
}
]
}
```
---
### GET /api/player/subtitles/{source_format}/{file:.*}
**Purpose**: Delivers a subtitle file using its preferred playback format.
**Path Parameters**:
- `source_format` = `vtt`, `srt`, or `ass`.
- `file` = Relative path of the subtitle file.
**Response**:
- `text/vtt; charset=UTF-8` for `vtt` and `srt` sources.
- `text/x-ssa; charset=UTF-8` for `ass` sources.
---
### GET /api/thumbnail
**Purpose**: Proxy/fetch a remote thumbnail image.
@ -1594,21 +1641,16 @@ Binary image data with the appropriate `Content-Type`.
},
"mimetype": "video/mp4",
"sidecar": {
"subtitles": [
"subtitle": [
{
"file": "filename.xxx.vtt",
"file": "filename.xxx.ass",
"lang": "xxx",
"name": "VTT 0 - XXX|end",
"name": "ASS (0) - xxx"
},
...
}
],
"video": [],
"audio": [],
"image": [],
"text": [],
"metadata": [],
...
"text": []
}
}
```

View file

@ -1,4 +1,7 @@
from __future__ import annotations
import logging
from dataclasses import dataclass
from pathlib import Path
import anyio
@ -6,10 +9,37 @@ import pysubs2
from pysubs2.formats.substation import SubstationFormat
from pysubs2.time import ms_to_times
from app.library.Utils import ALLOWED_SUBS_EXTENSIONS
from app.library.Utils import ALLOWED_SUBS_EXTENSIONS, get_file_sidecar
LOG: logging.Logger = logging.getLogger("player.subtitle")
SOURCE_FORMATS: tuple[str, ...] = ("vtt", "srt", "ass")
DELIVERY_FORMATS: dict[str, str] = {
"vtt": "vtt",
"srt": "vtt",
"ass": "ass",
}
RENDERERS: dict[str, str] = {
"vtt": "native",
"srt": "native",
"ass": "assjs",
}
MEDIA_TYPES: dict[str, str] = {
"vtt": "text/vtt; charset=UTF-8",
"ass": "text/x-ssa; charset=UTF-8",
}
TEXT_ENCODINGS: tuple[str, ...] = ("utf-8-sig", "utf-16", "utf-16-le", "utf-16-be", "cp1252")
@dataclass(frozen=True, slots=True)
class SubtitleTrack:
file: Path
lang: str
name: str
source_format: str
delivery_format: str
renderer: str
def ms_to_timestamp(ms: int) -> str:
ms = max(0, ms)
@ -22,14 +52,46 @@ SubstationFormat.ms_to_timestamp = ms_to_timestamp
class Subtitle:
@staticmethod
def normalize_format(source_format: str) -> str | None:
fmt = source_format.strip().lower().removeprefix(".")
if fmt not in SOURCE_FORMATS:
return None
return fmt
async def read_text(self, file: Path) -> str:
async with await anyio.open_file(file, "rb") as f:
subtitle_bytes = await f.read()
return self.decode_bytes(subtitle_bytes)
@staticmethod
def decode_bytes(subtitle_bytes: bytes) -> str:
for encoding in TEXT_ENCODINGS:
try:
return subtitle_bytes.decode(encoding)
except UnicodeDecodeError:
continue
return subtitle_bytes.decode("utf-8", errors="replace")
def media_type(self, file: Path) -> str:
fmt = self.normalize_format(file.suffix)
if fmt is None:
msg = f"File '{file}' subtitle type is not supported."
raise Exception(msg)
return MEDIA_TYPES[DELIVERY_FORMATS[fmt]]
async def make(self, file: Path) -> str:
if file.suffix not in ALLOWED_SUBS_EXTENSIONS:
fmt = self.normalize_format(file.suffix)
if fmt is None or file.suffix not in ALLOWED_SUBS_EXTENSIONS:
msg: str = f"File '{file}' subtitle type is not supported."
raise Exception(msg)
if file.suffix == ".vtt":
async with await anyio.open_file(file) as f:
return await f.read()
if fmt == "vtt":
return await self.read_text(file)
subs: pysubs2.SSAFile = pysubs2.load(path=str(file))
@ -47,3 +109,45 @@ class Subtitle:
pass
return subs.to_string("vtt")
async def make_delivery(self, file: Path) -> tuple[str, str]:
fmt = self.normalize_format(file.suffix)
if fmt is None or file.suffix not in ALLOWED_SUBS_EXTENSIONS:
msg: str = f"File '{file}' subtitle type is not supported."
raise Exception(msg)
if fmt == "ass":
return await self.read_text(file), self.media_type(file)
return await self.make(file), self.media_type(file)
def get_subtitle_tracks(file: Path) -> list[SubtitleTrack]:
sidecars = get_file_sidecar(file).get("subtitle", [])
indexed_tracks: list[tuple[int, SubtitleTrack]] = []
for index, item in enumerate(sidecars):
track_file = item.get("file")
if not isinstance(track_file, Path):
continue
fmt = Subtitle.normalize_format(track_file.suffix)
if fmt is None:
continue
indexed_tracks.append(
(
index,
SubtitleTrack(
file=track_file,
lang=str(item.get("lang") or "und"),
name=str(item.get("name") or track_file.name),
source_format=fmt,
delivery_format=DELIVERY_FORMATS[fmt],
renderer=RENDERERS[fmt],
),
)
)
indexed_tracks.sort(key=lambda item: (SOURCE_FORMATS.index(item[1].source_format), item[0]))
return [track for _, track in indexed_tracks]

View file

@ -9,7 +9,7 @@ from aiohttp.web import Request, Response
from app.features.streaming.library.m3u8 import M3u8
from app.features.streaming.library.playlist import Playlist
from app.features.streaming.library.segments import Segments
from app.features.streaming.library.subtitle import Subtitle
from app.features.streaming.library.subtitle import Subtitle, get_subtitle_tracks
from app.features.streaming.types import StreamingError
from app.library.config import Config
from app.library.router import route
@ -277,3 +277,134 @@ async def subtitles_get(request: Request, config: Config, app: web.Application)
},
status=web.HTTPOk.status_code,
)
@route("GET", "api/player/subtitles/manifest/{file:.*}", "subtitles_manifest_get")
async def subtitles_manifest_get(request: Request, config: Config, app: web.Application) -> Response:
"""
Get subtitle track metadata for a media file.
Args:
request (Request): The request object.
config (Config): The configuration instance.
app (web.Application): The aiohttp application instance.
Returns:
Response: The response object.
"""
file: str = request.match_info.get("file")
if not file:
return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code)
realFile, status = get_file(download_path=config.download_path, file=file)
if web.HTTPFound.status_code == status:
return Response(
status=status,
headers={
"Location": str(
app.router["subtitles_manifest_get"].url_for(
file=str(realFile).replace(config.download_path, "").strip("/")
)
),
},
)
if web.HTTPNotFound.status_code == status:
return web.json_response(data={"error": f"File '{file}' does not exist."}, status=status)
tracks = [
{
"lang": track.lang,
"name": track.name,
"source_format": track.source_format,
"delivery_format": track.delivery_format,
"renderer": track.renderer,
"url": str(
app.router["subtitles_track_get"].url_for(
source_format=track.source_format,
file=str(track.file).replace(config.download_path, "").strip("/"),
)
),
}
for track in get_subtitle_tracks(realFile)
]
return web.json_response(data={"subtitles": tracks}, status=web.HTTPOk.status_code)
@route("GET", "api/player/subtitles/{source_format}/{file:.*}", "subtitles_track_get")
async def subtitles_track_get(request: Request, config: Config, app: web.Application) -> Response:
"""
Get a subtitle file using its preferred delivery format.
Args:
request (Request): The request object.
config (Config): The configuration instance.
app (web.Application): The aiohttp application instance.
Returns:
Response: The response object.
"""
file: str = request.match_info.get("file")
source_format: str | None = request.match_info.get("source_format")
if not file:
return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code)
fmt = Subtitle.normalize_format(source_format or "")
if fmt is None:
return web.json_response(
data={"error": "Only vtt, srt, and ass subtitle formats are supported."},
status=web.HTTPBadRequest.status_code,
)
realFile, status = get_file(download_path=config.download_path, file=file)
if web.HTTPFound.status_code == status:
return Response(
status=status,
headers={
"Location": str(
app.router["subtitles_track_get"].url_for(
source_format=fmt,
file=str(realFile).replace(config.download_path, "").strip("/"),
)
),
},
)
if web.HTTPNotFound.status_code == status:
return web.json_response(data={"error": f"File '{file}' does not exist."}, status=status)
if Subtitle.normalize_format(realFile.suffix) != fmt:
return web.json_response(
data={"error": f"Subtitle file '{file}' does not match requested source format '{fmt}'."},
status=web.HTTPBadRequest.status_code,
)
mtime = realFile.stat().st_mtime
if request.if_modified_since and request.if_modified_since.timestamp() == mtime:
lastMod = time.strftime("%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(mtime, tz=UTC).timetuple())
return web.Response(status=web.HTTPNotModified.status_code, headers={"Last-Modified": lastMod})
body, content_type = await Subtitle().make_delivery(file=realFile)
return web.Response(
body=body,
headers={
"Content-Type": content_type,
"X-Accel-Buffering": "no",
"Access-Control-Allow-Origin": "*",
"Pragma": "public",
"Cache-Control": f"public, max-age={time.time() + 31536000}",
"Last-Modified": time.strftime(
"%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(mtime, tz=UTC).timetuple()
),
"Expires": time.strftime(
"%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(time.time() + 31536000, tz=UTC).timetuple()
),
},
status=web.HTTPOk.status_code,
)

View file

@ -4,7 +4,7 @@ from unittest.mock import patch
import pytest
from app.features.streaming.library.subtitle import Subtitle, ms_to_timestamp
from app.features.streaming.library.subtitle import Subtitle, get_subtitle_tracks, ms_to_timestamp
class TestMsToTimestamp:
@ -24,12 +24,12 @@ class TestMsToTimestamp:
@pytest.mark.asyncio
async def test_make_unsupported_extension(tmp_path: Path) -> None:
srt = tmp_path / "sub.txt"
srt.write_text("not a subtitle")
file = tmp_path / "sub.txt"
file.write_text("not a subtitle")
sub = Subtitle()
subtitle = Subtitle()
with pytest.raises(Exception, match="subtitle type is not supported"):
await sub.make(srt)
await subtitle.make(file)
@pytest.mark.asyncio
@ -38,11 +38,51 @@ async def test_make_vtt_reads_file(tmp_path: Path) -> None:
content = "WEBVTT\n\n00:00:00.00 --> 00:00:01.00\nHello"
vtt.write_text(content)
sub = Subtitle()
out = await sub.make(vtt)
subtitle = Subtitle()
out = await subtitle.make(vtt)
assert out == content
@pytest.mark.asyncio
async def test_make_delivery_returns_raw_ass_with_ass_content_type(tmp_path: Path) -> None:
ass = tmp_path / "file.ass"
content = "[Script Info]\nTitle: Demo\n"
ass.write_text(content, encoding="utf-8")
subtitle = Subtitle()
out, media_type = await subtitle.make_delivery(ass)
assert out == content
assert media_type == "text/x-ssa; charset=UTF-8"
def test_get_subtitle_tracks_prefers_native_then_ass(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
media = tmp_path / "video.mkv"
media.write_text("x", encoding="utf-8")
ass_file = tmp_path / "video.ass"
ass_file.write_text("ass", encoding="utf-8")
vtt_file = tmp_path / "video.vtt"
vtt_file.write_text("WEBVTT\n\n", encoding="utf-8")
srt_file = tmp_path / "video.en.srt"
srt_file.write_text("1\n00:00:00,000 --> 00:00:01,000\nHello\n", encoding="utf-8")
monkeypatch.setattr(
"app.features.streaming.library.subtitle.get_file_sidecar",
lambda _file: {
"subtitle": [
{"file": ass_file, "lang": "en", "name": "ASS"},
{"file": srt_file, "lang": "en", "name": "SRT"},
{"file": vtt_file, "lang": "en", "name": "VTT"},
]
},
)
tracks = get_subtitle_tracks(media)
assert [track.source_format for track in tracks] == ["vtt", "srt", "ass"]
assert [track.delivery_format for track in tracks] == ["vtt", "vtt", "ass"]
assert [track.renderer for track in tracks] == ["native", "native", "assjs"]
class _DummySubs:
def __init__(self, events):
self.events = events
@ -61,9 +101,9 @@ async def test_make_no_events_raises(tmp_path: Path) -> None:
with patch("app.features.streaming.library.subtitle.pysubs2.load") as mock_load:
mock_load.return_value = _DummySubs(events=[])
sub = Subtitle()
subtitle = Subtitle()
with pytest.raises(Exception, match="No subtitle events were found"):
await sub.make(srt)
await subtitle.make(srt)
@pytest.mark.asyncio
@ -75,8 +115,8 @@ async def test_make_single_event_returns_vtt(tmp_path: Path) -> None:
d = _DummySubs(events=[single])
with patch("app.features.streaming.library.subtitle.pysubs2.load", return_value=d):
sub = Subtitle()
out = await sub.make(srt)
subtitle = Subtitle()
out = await subtitle.make(srt)
assert out == "OUT"
assert d.snapshot == [1000], "Snapshot should contain the single event"
@ -91,8 +131,8 @@ async def test_make_two_events_pop_first_when_ends_equal(tmp_path: Path) -> None
d = _DummySubs(events=[e1, e2])
with patch("app.features.streaming.library.subtitle.pysubs2.load", return_value=d):
sub = Subtitle()
out = await sub.make(srt)
subtitle = Subtitle()
out = await subtitle.make(srt)
assert out == "OUT"
assert d.snapshot == [5000], "Since ends are equal, first should be popped => only last remains"
@ -107,7 +147,7 @@ async def test_make_two_events_no_pop_when_different(tmp_path: Path) -> None:
d = _DummySubs(events=[e1, e2])
with patch("app.features.streaming.library.subtitle.pysubs2.load", return_value=d):
sub = Subtitle()
out = await sub.make(srt)
subtitle = Subtitle()
out = await subtitle.make(srt)
assert out == "OUT"
assert d.snapshot == [5000, 6000], "Both remain since ends differ"

View file

@ -0,0 +1,138 @@
from pathlib import Path
from types import SimpleNamespace
import pytest
from aiohttp import web
from aiohttp.test_utils import make_mocked_request
from app.features.streaming import router
from app.library.config import Config
class _DummyRoute:
def __init__(self, pattern: str) -> None:
self.pattern = pattern
def url_for(self, **params: str) -> str:
path = self.pattern
for key, value in params.items():
path = path.replace(f"{{{key}:.*}}", value)
path = path.replace(f"{{{key}}}", value)
return path
class _DummyRouter(dict[str, _DummyRoute]):
def __init__(self) -> None:
super().__init__(
{
"subtitles_manifest_get": _DummyRoute("/api/player/subtitles/manifest/{file:.*}"),
"subtitles_track_get": _DummyRoute("/api/player/subtitles/{source_format}/{file:.*}"),
"subtitles_get": _DummyRoute("/api/player/subtitle/{file:.*}.vtt"),
}
)
def _make_request(path: str, *, match_info: dict[str, str]) -> web.Request:
return make_mocked_request("GET", path, app=SimpleNamespace(router=_DummyRouter()), match_info=match_info)
@pytest.mark.asyncio
async def test_subtitles_manifest_lists_tracks_in_native_first_order(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
config = Config.get_instance()
config.download_path = str(tmp_path)
media = tmp_path / "video.mp4"
media.write_text("x", encoding="utf-8")
monkeypatch.setattr(
"app.features.streaming.router.get_file",
lambda **_kwargs: (media, web.HTTPOk.status_code),
)
monkeypatch.setattr(
"app.features.streaming.router.get_subtitle_tracks",
lambda _file: [
SimpleNamespace(
lang="en",
name="English VTT",
source_format="vtt",
delivery_format="vtt",
renderer="native",
file=tmp_path / "video.vtt",
),
SimpleNamespace(
lang="en",
name="English ASS",
source_format="ass",
delivery_format="ass",
renderer="assjs",
file=tmp_path / "video.ass",
),
],
)
req = _make_request(
"/api/player/subtitles/manifest/video.mp4",
match_info={"file": "video.mp4"},
)
response = await router.subtitles_manifest_get(req, config, req.app)
assert response.status == web.HTTPOk.status_code
assert response.body is not None
body = response.text
assert '"source_format": "vtt"' in body
assert '"renderer": "native"' in body
assert '"source_format": "ass"' in body
assert '"renderer": "assjs"' in body
assert body.index('"source_format": "vtt"') < body.index('"source_format": "ass"')
@pytest.mark.asyncio
async def test_subtitles_track_get_returns_raw_ass_delivery(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
config = Config.get_instance()
config.download_path = str(tmp_path)
subtitle = tmp_path / "video.ass"
subtitle.write_text("[Script Info]\nTitle: Demo\n", encoding="utf-8")
monkeypatch.setattr(
"app.features.streaming.router.get_file",
lambda **_kwargs: (subtitle, web.HTTPOk.status_code),
)
req = _make_request(
"/api/player/subtitles/ass/video.ass",
match_info={"source_format": "ass", "file": "video.ass"},
)
response = await router.subtitles_track_get(req, config, req.app)
assert response.status == web.HTTPOk.status_code
assert response.text == "[Script Info]\nTitle: Demo\n"
assert response.headers["Content-Type"] == "text/x-ssa; charset=UTF-8"
@pytest.mark.asyncio
async def test_subtitles_track_get_rejects_mismatched_source_format(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
config = Config.get_instance()
config.download_path = str(tmp_path)
subtitle = tmp_path / "video.ass"
subtitle.write_text("[Script Info]\nTitle: Demo\n", encoding="utf-8")
monkeypatch.setattr(
"app.features.streaming.router.get_file",
lambda **_kwargs: (subtitle, web.HTTPOk.status_code),
)
req = _make_request(
"/api/player/subtitles/vtt/video.ass",
match_info={"source_format": "vtt", "file": "video.ass"},
)
response = await router.subtitles_track_get(req, config, req.app)
assert response.status == web.HTTPBadRequest.status_code
assert response.body is not None
assert b"does not match requested source format" in response.body

File diff suppressed because it is too large Load diff

View file

@ -5,21 +5,21 @@
import { ref } from 'vue';
import type { Ref } from 'vue';
import {
handlePlayPause,
handleRewind,
handleForward,
handleMute,
handleVolumeChange,
handlePlaybackSpeedChange,
handleFrameStep,
handleSeekToPercent,
handleSeekBackward,
handleSeekForward,
handleFullscreen,
handlePictureInPicture,
handleToggleCaptions,
playPause,
rewind,
forward,
mute,
changeVolume,
changeSpeed,
frameStep,
seekToPercent,
seekBackward,
seekForward,
fullscreen,
pictureInPicture,
toggleCaptions,
shouldHandleKeyboardShortcut,
isModifierKey,
modifierKey,
} from '~/utils/keyboard';
import type { KeyboardShortcutContext } from '~/types/video';
@ -51,7 +51,7 @@ export const useKeyboardShortcuts = (options: UseKeyboardShortcutsOptions) => {
}
// Skip if modifier keys are pressed (except for shortcuts that need them)
if (isModifierKey(event) && !['f', 'p', '?'].includes(event.key.toLowerCase())) {
if (modifierKey(event) && !['f', 'p', '?'].includes(event.key.toLowerCase())) {
return;
}
@ -64,67 +64,67 @@ export const useKeyboardShortcuts = (options: UseKeyboardShortcutsOptions) => {
case ' ':
case 'k':
event.preventDefault();
handlePlayPause(ctx);
playPause(ctx);
break;
// Rewind 10 seconds (J key)
case 'j':
event.preventDefault();
handleRewind(ctx, 10);
rewind(ctx, 10);
break;
// Forward 10 seconds (L key)
case 'l':
event.preventDefault();
handleForward(ctx, 10);
forward(ctx, 10);
break;
// Seek backward 5 seconds (left arrow)
case 'arrowleft':
event.preventDefault();
handleSeekBackward(ctx, 5);
seekBackward(ctx, 5);
break;
// Seek forward 5 seconds (right arrow)
case 'arrowright':
event.preventDefault();
handleSeekForward(ctx, 5);
seekForward(ctx, 5);
break;
// Increase volume (up arrow)
case 'arrowup':
event.preventDefault();
handleVolumeChange(ctx, 0.1);
changeVolume(ctx, 0.1);
break;
// Decrease volume (down arrow)
case 'arrowdown':
event.preventDefault();
handleVolumeChange(ctx, -0.1);
changeVolume(ctx, -0.1);
break;
// Mute/Unmute
case 'm':
event.preventDefault();
handleMute(ctx);
mute(ctx);
break;
// Toggle fullscreen
case 'f':
event.preventDefault();
handleFullscreen(video);
fullscreen(video);
break;
// Picture-in-Picture
case 'p':
event.preventDefault();
await handlePictureInPicture(video);
await pictureInPicture(video);
break;
// Toggle captions
case 'c':
event.preventDefault();
handleToggleCaptions(video);
toggleCaptions(video);
break;
// Frame advance (period key) / Increase playback speed (> or ')
@ -132,9 +132,9 @@ export const useKeyboardShortcuts = (options: UseKeyboardShortcutsOptions) => {
case "'": {
event.preventDefault();
if ('.' === key) {
handleFrameStep(ctx, 'forward');
frameStep(ctx, 'forward');
} else {
handlePlaybackSpeedChange(ctx, 0.25);
changeSpeed(ctx, 0.25);
}
break;
}
@ -144,9 +144,9 @@ export const useKeyboardShortcuts = (options: UseKeyboardShortcutsOptions) => {
case ';': {
event.preventDefault();
if (',' === key) {
handleFrameStep(ctx, 'backward');
frameStep(ctx, 'backward');
} else {
handlePlaybackSpeedChange(ctx, -0.25);
changeSpeed(ctx, -0.25);
}
break;
}
@ -164,7 +164,7 @@ export const useKeyboardShortcuts = (options: UseKeyboardShortcutsOptions) => {
case '9': {
event.preventDefault();
const percent = parseInt(key) * 10;
handleSeekToPercent(ctx, percent);
seekToPercent(ctx, percent);
break;
}

View file

@ -0,0 +1,44 @@
import { computed } from 'vue';
import { useStorage } from '@vueuse/core';
function clampVolume(volume: number): number {
if (!Number.isFinite(volume)) return 1;
return Math.min(1, Math.max(0, volume));
}
export function usePlayerMediaVolume() {
const volume = useStorage<number>('player_volume', 1);
const muted = useStorage<boolean>('player_muted', false);
const effectiveVolume = computed(() => {
return muted.value ? 0 : clampVolume(volume.value);
});
function setVolume(nextVolume: number) {
const value = clampVolume(nextVolume);
volume.value = value;
muted.value = value <= 0;
}
function changeVolume(delta: number) {
setVolume(volume.value + delta);
}
function toggleMute() {
if (muted.value || effectiveVolume.value <= 0) {
volume.value = volume.value > 0 ? clampVolume(volume.value) : 1;
muted.value = false;
return;
}
muted.value = true;
}
return {
volume,
muted,
effectiveVolume,
setVolume,
changeVolume,
toggleMute,
};
}

View file

@ -0,0 +1,3 @@
export function usePlayerShortcutHelp() {
return useState<boolean>('player-shortcut-help', () => false);
}

View file

@ -0,0 +1,247 @@
import {
getCurrentScope,
onScopeDispose,
ref,
watch,
type MaybeRefOrGetter,
type Ref,
toValue,
} from 'vue';
import {
clampMediaTime,
clampMediaVolume,
hasModifierKey,
shouldHandleKeyboardShortcut,
} from '~/utils/keyboard';
type UsePlayerShortcutsOptions = {
enabled: MaybeRefOrGetter<boolean>;
media: MaybeRefOrGetter<HTMLMediaElement | null>;
video: MaybeRefOrGetter<HTMLVideoElement | null>;
adjustVolume?: (delta: number) => void;
canToggleSubs: MaybeRefOrGetter<boolean>;
helpOpen?: Ref<boolean>;
toggleSubtitles: () => void;
toggleFullscreen: () => Promise<void> | void;
toggleMute?: () => void;
closePlayer?: () => void;
};
export function usePlayerShortcuts(options: UsePlayerShortcutsOptions) {
const showHelp = options.helpOpen || ref(false);
function togglePlayPause(media: HTMLMediaElement) {
if (media.paused) {
void media.play().catch(() => {});
return;
}
media.pause();
}
function stepFrame(media: HTMLMediaElement, direction: 'forward' | 'backward') {
if (!media.paused) {
media.pause();
}
const frameStep = direction === 'forward' ? 0.033 : -0.033;
clampMediaTime(media, media.currentTime + frameStep);
}
function toggleNativeSubtitles(video: HTMLVideoElement) {
const tracks = Array.from(video.textTracks);
const subtitleTrack = tracks.find(
(track) => track.kind === 'subtitles' || track.kind === 'captions',
);
if (!subtitleTrack) {
return;
}
subtitleTrack.mode = subtitleTrack.mode === 'showing' ? 'hidden' : 'showing';
}
async function handleKeyDown(event: KeyboardEvent) {
if (!toValue(options.enabled) || !shouldHandleKeyboardShortcut(event)) {
return;
}
const media = toValue(options.media);
if (!media) {
return;
}
const key = event.key.toLowerCase();
if (hasModifierKey(event) && !['f', '?', '/'].includes(key)) {
return;
}
switch (key) {
case ' ':
case 'k':
event.preventDefault();
event.stopPropagation();
togglePlayPause(media);
break;
case 'j':
event.preventDefault();
event.stopPropagation();
clampMediaTime(media, media.currentTime - 10);
break;
case 'l':
event.preventDefault();
event.stopPropagation();
clampMediaTime(media, media.currentTime + 10);
break;
case 'arrowleft':
event.preventDefault();
event.stopPropagation();
clampMediaTime(media, media.currentTime - 5);
break;
case 'arrowright':
event.preventDefault();
event.stopPropagation();
clampMediaTime(media, media.currentTime + 5);
break;
case 'home':
event.preventDefault();
event.stopPropagation();
media.currentTime = 0;
break;
case 'end':
event.preventDefault();
event.stopPropagation();
if (Number.isFinite(media.duration)) {
media.currentTime = media.duration;
}
break;
case '0':
case '1':
case '2':
case '3':
case '4':
case '5':
case '6':
case '7':
case '8':
case '9': {
event.preventDefault();
event.stopPropagation();
if (Number.isFinite(media.duration) && media.duration > 0) {
media.currentTime = (parseInt(key, 10) / 10) * media.duration;
}
break;
}
case 'arrowup':
event.preventDefault();
event.stopPropagation();
if (options.adjustVolume) {
options.adjustVolume(0.1);
break;
}
media.volume = clampMediaVolume(media.volume + 0.1);
media.muted = false;
break;
case 'arrowdown':
event.preventDefault();
event.stopPropagation();
if (options.adjustVolume) {
options.adjustVolume(-0.1);
break;
}
media.volume = clampMediaVolume(media.volume - 0.1);
if (media.volume <= 0) {
media.muted = true;
}
break;
case 'm':
event.preventDefault();
event.stopPropagation();
if (options.toggleMute) {
options.toggleMute();
break;
}
media.muted = !media.muted;
break;
case ';':
event.preventDefault();
event.stopPropagation();
media.playbackRate = Math.max(0.25, media.playbackRate - 0.25);
break;
case "'":
event.preventDefault();
event.stopPropagation();
media.playbackRate = Math.min(2, media.playbackRate + 0.25);
break;
case ',':
event.preventDefault();
event.stopPropagation();
stepFrame(media, 'backward');
break;
case '.':
event.preventDefault();
event.stopPropagation();
stepFrame(media, 'forward');
break;
case 'f':
event.preventDefault();
event.stopPropagation();
await options.toggleFullscreen();
break;
case 'c': {
if (!toValue(options.canToggleSubs)) {
return;
}
event.preventDefault();
event.stopPropagation();
const video = toValue(options.video);
if (video?.textTracks.length) {
toggleNativeSubtitles(video);
}
options.toggleSubtitles();
break;
}
case '?':
case '/':
event.preventDefault();
event.stopPropagation();
showHelp.value = !showHelp.value;
break;
case 'escape':
event.preventDefault();
event.stopPropagation();
if (showHelp.value) {
showHelp.value = false;
break;
}
options.closePlayer?.();
break;
default:
break;
}
}
document.addEventListener('keydown', handleKeyDown, { capture: true });
watch(
() => toValue(options.enabled),
(enabled) => {
if (!enabled) {
showHelp.value = false;
}
},
);
if (getCurrentScope()) {
onScopeDispose(() => {
document.removeEventListener('keydown', handleKeyDown, { capture: true });
});
}
return {
showHelp,
};
}

View file

@ -0,0 +1,220 @@
import {
computed,
getCurrentScope,
onScopeDispose,
ref,
watch,
type MaybeRefOrGetter,
toValue,
} from 'vue';
import type { SubtitleManifestResponse, SubtitleTrack } from '~/types/subtitles';
import { encodePath, parse_api_error, request, uri } from '~/utils';
type AssRendererInstance = {
destroy(): unknown;
show(): unknown;
};
type AssRendererConstructor = new (
content: string,
video: HTMLVideoElement,
options: { container: HTMLElement; resampling: 'video_height' },
) => AssRendererInstance;
type UsePlayerSubtitlesOptions = {
mediaFile: MaybeRefOrGetter<string>;
isVideo: MaybeRefOrGetter<boolean>;
canPlay: MaybeRefOrGetter<boolean>;
shouldRender: MaybeRefOrGetter<boolean>;
assLayoutVersion?: MaybeRefOrGetter<number>;
video: MaybeRefOrGetter<HTMLVideoElement | null>;
overlay: MaybeRefOrGetter<HTMLElement | null>;
fetchText?: (url: string) => Promise<string>;
loadRenderer?: () => Promise<AssRendererConstructor>;
};
async function defaultFetchSubtitleText(url: string): Promise<string> {
const res = await request(url, { headers: { Accept: 'text/plain, text/vtt, text/x-ssa' } });
if (!res.ok) {
throw new Error('Subtitle fetch failed');
}
return res.text();
}
async function defaultLoadAssRenderer(): Promise<AssRendererConstructor> {
const mod = await import('assjs');
return mod.default as AssRendererConstructor;
}
export function usePlayerSubtitles(options: UsePlayerSubtitlesOptions) {
const fetchText = options.fetchText || defaultFetchSubtitleText;
const loadRenderer = options.loadRenderer || defaultLoadAssRenderer;
const tracks = ref<SubtitleTrack[]>([]);
const subtitleLoading = ref(false);
const subtitleLoadError = ref('');
const subtitleEnabled = ref(true);
const selectedTrack = computed(() => tracks.value[0] || null);
const nativeSubtitleTrack = computed(() => {
const track = selectedTrack.value;
return subtitleEnabled.value && track?.renderer === 'native' ? track : null;
});
const usesAssTrack = computed(() => selectedTrack.value?.renderer === 'assjs');
const hasSubtitles = computed(() => tracks.value.length > 0);
let assRenderer: AssRendererInstance | null = null;
let subtitleRequestId = 0;
let assRequestId = 0;
let cachedAssSubtitleUrl = '';
let cachedAssSubtitleContent = '';
function destroyAssRenderer() {
assRenderer?.destroy();
assRenderer = null;
}
async function loadTracks() {
const mediaFile = toValue(options.mediaFile);
const isVideo = toValue(options.isVideo);
const canPlay = toValue(options.canPlay);
const requestId = ++subtitleRequestId;
assRequestId += 1;
destroyAssRenderer();
tracks.value = [];
subtitleLoadError.value = '';
if (!mediaFile || !isVideo || !canPlay) {
subtitleLoading.value = false;
return;
}
subtitleLoading.value = true;
try {
const res = await request(uri(`/api/player/subtitles/manifest/${encodePath(mediaFile)}`));
const payload = (await res.json()) as SubtitleManifestResponse | { error?: string };
if (!res.ok) {
throw new Error(await parse_api_error(payload));
}
if (requestId !== subtitleRequestId) {
return;
}
tracks.value = (payload as SubtitleManifestResponse).subtitles || [];
if (tracks.value.length > 0) {
subtitleEnabled.value = true;
}
} catch {
if (requestId !== subtitleRequestId) {
return;
}
subtitleLoadError.value = 'Failed to load subtitles for this video.';
} finally {
if (requestId === subtitleRequestId) {
subtitleLoading.value = false;
}
}
}
async function syncAssRenderer() {
const track = selectedTrack.value;
const shouldRender = toValue(options.shouldRender);
const video = toValue(options.video);
const overlay = toValue(options.overlay);
const requestId = ++assRequestId;
destroyAssRenderer();
if (
!track ||
track.renderer !== 'assjs' ||
!subtitleEnabled.value ||
!shouldRender ||
!video ||
!overlay
) {
return;
}
try {
const subtitleContent =
cachedAssSubtitleUrl === track.url ? cachedAssSubtitleContent : await fetchText(track.url);
if (requestId !== assRequestId) {
return;
}
if (cachedAssSubtitleUrl !== track.url) {
cachedAssSubtitleUrl = track.url;
cachedAssSubtitleContent = subtitleContent;
}
const Ass = await loadRenderer();
if (requestId !== assRequestId) {
return;
}
assRenderer = new Ass(subtitleContent, video, {
container: overlay,
resampling: 'video_height',
}) as AssRendererInstance;
assRenderer.show();
video.dispatchEvent(new Event('seeking'));
if (!video.paused) {
video.dispatchEvent(new Event('playing'));
}
subtitleLoadError.value = '';
} catch {
if (requestId === assRequestId) {
subtitleLoadError.value = 'Failed to render ASS subtitles in the browser.';
}
destroyAssRenderer();
}
}
watch(
() => [toValue(options.mediaFile), toValue(options.isVideo), toValue(options.canPlay)],
() => {
void loadTracks();
},
{ immediate: true },
);
watch(
() => [
selectedTrack.value?.url || '',
selectedTrack.value?.renderer || '',
subtitleEnabled.value,
toValue(options.shouldRender),
toValue(options.assLayoutVersion) || 0,
toValue(options.video),
toValue(options.overlay),
],
() => {
void syncAssRenderer();
},
{ immediate: true },
);
if (getCurrentScope()) {
onScopeDispose(() => {
assRequestId += 1;
destroyAssRenderer();
});
}
return {
subtitleTracks: tracks,
subtitleLoading,
subtitleLoadError,
subtitleEnabled,
selectedSubtitleTrack: selectedTrack,
nativeSubtitleTrack,
usesAssSubtitleTrack: usesAssTrack,
hasSubtitles,
loadSelectedSubtitles: loadTracks,
};
}

12
ui/app/types/subtitles.ts Normal file
View file

@ -0,0 +1,12 @@
export type SubtitleTrack = {
lang: string;
name: string;
source_format: 'vtt' | 'srt' | 'ass';
delivery_format: 'vtt' | 'ass';
renderer: 'native' | 'assjs';
url: string;
};
export type SubtitleManifestResponse = {
subtitles: SubtitleTrack[];
};

View file

@ -2,14 +2,14 @@ type KeyboardShortcutContext = {
video: HTMLVideoElement;
};
type video_track_element = {
type VideoTrackElement = {
file: string;
kind: string;
label: string;
lang: string;
};
type video_source_element = {
type VideoSourceElement = {
src: string;
type: string;
onerror: (e: Event) => void;
@ -68,7 +68,7 @@ type FFProbeResult = {
is_audio: boolean;
};
type file_info = {
type FileInfo = {
title: string;
ffprobe: FFProbeResult;
mimetype: string;
@ -80,10 +80,17 @@ type file_info = {
error?: string;
};
type PlayerSourceElement = {
src: string;
type?: string;
onerror?: (e: Event) => void;
};
export type {
video_track_element,
video_source_element,
VideoTrackElement,
VideoSourceElement,
PlayerSourceElement,
FFProbeResult,
file_info,
FileInfo,
KeyboardShortcutContext,
};

View file

@ -0,0 +1,53 @@
type FullscreenCapableElement = HTMLElement & {
webkitRequestFullscreen?: () => Promise<void> | void;
};
type FullscreenCapableDocument = Document & {
webkitFullscreenElement?: Element | null;
webkitExitFullscreen?: () => Promise<void> | void;
};
export function getFullscreenElement(doc: Document = document): Element | null {
const fullscreenDocument = doc as FullscreenCapableDocument;
return fullscreenDocument.fullscreenElement || fullscreenDocument.webkitFullscreenElement || null;
}
export function canRequestFullscreen(
element: HTMLElement | null | undefined,
): element is HTMLElement {
return Boolean(
element &&
(typeof element.requestFullscreen === 'function' ||
typeof (element as FullscreenCapableElement).webkitRequestFullscreen === 'function'),
);
}
export async function requestElementFullscreen(element: HTMLElement): Promise<void> {
const fullscreenElement = element as FullscreenCapableElement;
if (typeof fullscreenElement.requestFullscreen === 'function') {
await fullscreenElement.requestFullscreen();
return;
}
if (typeof fullscreenElement.webkitRequestFullscreen === 'function') {
await fullscreenElement.webkitRequestFullscreen();
return;
}
throw new Error('Fullscreen API unavailable');
}
export async function exitDocumentFullscreen(doc: Document = document): Promise<void> {
const fullscreenDocument = doc as FullscreenCapableDocument;
if (typeof fullscreenDocument.exitFullscreen === 'function') {
await fullscreenDocument.exitFullscreen();
return;
}
if (typeof fullscreenDocument.webkitExitFullscreen === 'function') {
await fullscreenDocument.webkitExitFullscreen();
return;
}
throw new Error('Fullscreen API unavailable');
}

View file

@ -1,6 +1,19 @@
import type { KeyboardShortcutContext } from '~/types/video';
export const handlePlayPause = (ctx: KeyboardShortcutContext) => {
export const clampMediaTime = (media: HTMLMediaElement, nextTime: number) => {
const duration =
Number.isFinite(media.duration) && media.duration > 0 ? media.duration : Infinity;
media.currentTime = Math.min(Math.max(nextTime, 0), duration);
};
export const clampMediaVolume = (volume: number) => {
return Math.min(1, Math.max(0, volume));
};
export const hasModifierKey = (event: KeyboardEvent): boolean =>
event.ctrlKey || event.metaKey || event.altKey;
export const playPause = (ctx: KeyboardShortcutContext) => {
if (ctx.video.paused) {
ctx.video.play();
} else {
@ -8,30 +21,29 @@ export const handlePlayPause = (ctx: KeyboardShortcutContext) => {
}
};
export const handleRewind = (ctx: KeyboardShortcutContext, seconds: number = 10) => {
export const rewind = (ctx: KeyboardShortcutContext, seconds: number = 10) => {
ctx.video.currentTime = Math.max(0, ctx.video.currentTime - seconds);
};
export const handleForward = (ctx: KeyboardShortcutContext, seconds: number = 10) => {
export const forward = (ctx: KeyboardShortcutContext, seconds: number = 10) => {
ctx.video.currentTime = Math.min(ctx.video.duration, ctx.video.currentTime + seconds);
};
export const handleMute = (ctx: KeyboardShortcutContext) => {
export const mute = (ctx: KeyboardShortcutContext) => {
ctx.video.muted = !ctx.video.muted;
};
export const handleVolumeChange = (ctx: KeyboardShortcutContext, delta: number) => {
const newVolume = Math.max(0, Math.min(1, ctx.video.volume + delta));
ctx.video.volume = newVolume;
export const changeVolume = (ctx: KeyboardShortcutContext, delta: number) => {
const volume = clampMediaVolume(ctx.video.volume + delta);
ctx.video.volume = volume;
};
export const handlePlaybackSpeedChange = (ctx: KeyboardShortcutContext, delta: number) => {
const currentSpeed = ctx.video.playbackRate;
const newSpeed = Math.max(0.25, Math.min(2, currentSpeed + delta));
ctx.video.playbackRate = newSpeed;
export const changeSpeed = (ctx: KeyboardShortcutContext, delta: number) => {
const speed = ctx.video.playbackRate;
ctx.video.playbackRate = Math.max(0.25, Math.min(2, speed + delta));
};
export const handleFrameStep = (
export const frameStep = (
ctx: KeyboardShortcutContext,
direction: 'forward' | 'backward' = 'forward',
) => {
@ -47,21 +59,21 @@ export const handleFrameStep = (
);
};
export const handleSeekToPercent = (ctx: KeyboardShortcutContext, percent: number) => {
export const seekToPercent = (ctx: KeyboardShortcutContext, percent: number) => {
ctx.video.currentTime = (percent / 100) * ctx.video.duration;
};
export const handleSeekBackward = (ctx: KeyboardShortcutContext, seconds: number = 5) => {
ctx.video.currentTime = Math.max(0, ctx.video.currentTime - seconds);
export const seekBackward = (ctx: KeyboardShortcutContext, seconds: number = 5) => {
clampMediaTime(ctx.video, ctx.video.currentTime - seconds);
};
export const handleSeekForward = (ctx: KeyboardShortcutContext, seconds: number = 5) => {
ctx.video.currentTime = Math.min(ctx.video.duration, ctx.video.currentTime + seconds);
export const seekForward = (ctx: KeyboardShortcutContext, seconds: number = 5) => {
clampMediaTime(ctx.video, ctx.video.currentTime + seconds);
};
export const handleFullscreen = (videoElement: HTMLVideoElement) => {
export const fullscreen = (video: HTMLVideoElement) => {
if (!document.fullscreenElement) {
videoElement.requestFullscreen().catch((err) => {
video.requestFullscreen().catch((err) => {
console.error(`Error attempting to enable fullscreen: ${err.message}`);
});
} else {
@ -69,20 +81,20 @@ export const handleFullscreen = (videoElement: HTMLVideoElement) => {
}
};
export const handlePictureInPicture = async (videoElement: HTMLVideoElement) => {
export const pictureInPicture = async (video: HTMLVideoElement) => {
try {
if (document.pictureInPictureElement) {
await document.exitPictureInPicture();
} else if (document.pictureInPictureEnabled) {
await videoElement.requestPictureInPicture();
await video.requestPictureInPicture();
}
} catch (error) {
console.error(`Picture-in-Picture error: ${error}`);
}
};
export const handleToggleCaptions = (videoElement: HTMLVideoElement) => {
const textTracks = videoElement.textTracks;
export const toggleCaptions = (video: HTMLVideoElement) => {
const textTracks = video.textTracks;
if (0 === textTracks.length) {
return;
@ -112,5 +124,4 @@ export const shouldHandleKeyboardShortcut = (event: KeyboardEvent): boolean => {
return true;
};
export const isModifierKey = (event: KeyboardEvent): boolean =>
event.ctrlKey || event.metaKey || event.altKey;
export const modifierKey = (event: KeyboardEvent): boolean => hasModifierKey(event);

View file

@ -5,39 +5,40 @@
"": {
"name": "nuxt-app",
"dependencies": {
"@iconify-json/lucide": "latest",
"@microsoft/fetch-event-source": "latest",
"@nuxt/eslint": "latest",
"@nuxt/eslint-config": "latest",
"@nuxt/ui": "latest",
"@vueuse/core": "latest",
"@vueuse/nuxt": "latest",
"@xterm/addon-fit": "latest",
"@xterm/xterm": "latest",
"cron-parser": "latest",
"cronstrue": "latest",
"hls.js": "latest",
"marked": "latest",
"marked-alert": "latest",
"marked-base-url": "latest",
"marked-gfm-heading-id": "latest",
"moment": "latest",
"nuxt": "latest",
"tailwindcss": "latest",
"vue": "latest",
"vue-router": "latest",
"@iconify-json/lucide": "^1.2.103",
"@microsoft/fetch-event-source": "^2.0.1",
"@nuxt/eslint": "^1.15.2",
"@nuxt/eslint-config": "^1.15.2",
"@nuxt/ui": "^4.7.0",
"@vueuse/core": "^14.2.1",
"@vueuse/nuxt": "^14.2.1",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"assjs": "^0.1.6",
"cron-parser": "^5.5.0",
"cronstrue": "^3.14.0",
"hls.js": "^1.6.16",
"marked": "^18.0.2",
"marked-alert": "^2.1.2",
"marked-base-url": "^1.1.9",
"marked-gfm-heading-id": "^4.1.4",
"moment": "^2.30.1",
"nuxt": "^4.4.2",
"tailwindcss": "^4.2.4",
"vue": "^3.5.33",
"vue-router": "^5.0.6",
},
"devDependencies": {
"@types/bun": "latest",
"@types/jsdom": "latest",
"@types/node": "latest",
"@typescript-eslint/parser": "latest",
"eslint": "latest",
"jsdom": "latest",
"oxfmt": "latest",
"typescript": "latest",
"vue-eslint-parser": "latest",
"vue-tsc": "latest",
"@types/bun": "^1.3.13",
"@types/jsdom": "^28.0.1",
"@types/node": "25.6.0",
"@typescript-eslint/parser": "^8.59.0",
"eslint": "^10.2.1",
"jsdom": "^29.0.2",
"oxfmt": "^0.46.0",
"typescript": "^6.0.3",
"vue-eslint-parser": "^10.4.0",
"vue-tsc": "^3.2.7",
},
},
},
@ -894,6 +895,8 @@
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
"assjs": ["assjs@0.1.6", "", {}, "sha512-sHdLRIsZsXEdURGKlyJf+AbgoHhhOELh8Wl6K6TL/au9p4b4EQed62OPKayWFeFIj8W7I9OzvYtHV/+/MwDvkg=="],
"ast-kit": ["ast-kit@2.2.0", "", { "dependencies": { "@babel/parser": "^7.28.5", "pathe": "^2.0.3" } }, "sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw=="],
"ast-walker-scope": ["ast-walker-scope@0.8.3", "", { "dependencies": { "@babel/parser": "^7.28.4", "ast-kit": "^2.1.3" } }, "sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg=="],

View file

@ -91,6 +91,7 @@ export default defineNuxtConfig({
'marked-alert',
'marked-gfm-heading-id',
'hls.js',
'assjs',
'@vue/devtools-core',
'@vue/devtools-kit',
],

View file

@ -30,6 +30,7 @@
"@vueuse/nuxt": "^14.2.1",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"assjs": "^0.1.6",
"cron-parser": "^5.5.0",
"cronstrue": "^3.14.0",
"hls.js": "^1.6.16",

View file

@ -0,0 +1,102 @@
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
import { ref } from 'vue';
describe('usePlayerShortcuts', () => {
beforeEach(() => {
document.body.innerHTML = '';
});
afterEach(() => {
document.body.innerHTML = '';
});
it('toggles native text tracks and subtitle state on c', async () => {
const { usePlayerShortcuts } = await import('~/composables/usePlayerShortcuts');
const addEventListenerSpy = spyOn(document, 'addEventListener');
const subtitleTrack = { kind: 'subtitles', mode: 'showing' } as TextTrack;
const videoElement = {
paused: true,
currentTime: 0,
duration: 100,
playbackRate: 1,
volume: 1,
muted: false,
play: async () => {},
pause: () => {},
textTracks: [subtitleTrack],
} as unknown as HTMLVideoElement;
const subtitleEnabled = ref(true);
usePlayerShortcuts({
enabled: ref(true),
media: ref(videoElement),
video: ref(videoElement),
canToggleSubs: ref(true),
toggleSubtitles: () => {
subtitleEnabled.value = !subtitleEnabled.value;
},
toggleFullscreen: () => {},
});
const handler = addEventListenerSpy.mock.calls.find((call) => call[0] === 'keydown')?.[1];
if (!handler || typeof handler !== 'function') {
throw new Error('Expected keydown handler to be registered');
}
const preventDefault = mock(() => {});
const stopPropagation = mock(() => {});
handler({
key: 'c',
target: document.body,
preventDefault,
stopPropagation,
ctrlKey: false,
metaKey: false,
altKey: false,
} as unknown as KeyboardEvent);
expect(subtitleTrack.mode).toBe('hidden');
expect(subtitleEnabled.value).toBe(false);
addEventListenerSpy.mockRestore();
});
it('closes help before closing the player on escape', async () => {
const { usePlayerShortcuts } = await import('~/composables/usePlayerShortcuts');
const media = {
paused: true,
currentTime: 0,
duration: 100,
playbackRate: 1,
volume: 1,
muted: false,
play: async () => {},
pause: () => {},
textTracks: [],
} as unknown as HTMLMediaElement;
const showHelp = ref(true);
const closePlayer = mock(() => {});
usePlayerShortcuts({
enabled: ref(true),
media: ref(media),
video: ref(null),
canToggleSubs: ref(false),
helpOpen: showHelp,
toggleSubtitles: () => {},
toggleFullscreen: () => {},
closePlayer,
});
document.body.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(showHelp.value).toBe(false);
expect(closePlayer).toHaveBeenCalledTimes(0);
document.body.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(closePlayer).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,261 @@
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
import { nextTick, ref } from 'vue';
type MockResponseInput = {
ok: boolean;
status: number;
jsonData?: unknown;
textData?: string;
};
const runtimeConfig = {
app: {
baseURL: '/',
},
};
const testGlobals = globalThis as typeof globalThis & {
useRuntimeConfig?: () => typeof runtimeConfig;
useNotification?: () => { error: ReturnType<typeof mock> };
};
const notificationErrorMock = mock(() => {});
testGlobals.useRuntimeConfig = () => runtimeConfig;
testGlobals.useNotification = () => ({ error: notificationErrorMock });
mock.module('#imports', () => ({
useRuntimeConfig: () => runtimeConfig,
useNotification: () => ({ error: notificationErrorMock }),
}));
function createMockResponse({ ok, status, jsonData, textData }: MockResponseInput): Response {
return {
ok,
status,
headers: new Headers({ 'Content-Type': 'application/json' }),
redirected: false,
statusText: ok ? 'OK' : 'Error',
type: 'basic',
url: '',
body: null,
bodyUsed: false,
clone() {
return this;
},
async json() {
return jsonData;
},
async text() {
return textData ?? JSON.stringify(jsonData ?? {});
},
arrayBuffer: async () => new ArrayBuffer(0),
blob: async () => new Blob(),
formData: async () => new FormData(),
} as Response;
}
async function flushPromises(times = 4) {
for (let index = 0; index < times; index += 1) {
await Promise.resolve();
await nextTick();
}
}
describe('usePlayerSubtitles', () => {
const fetchMock = mock(async (_input: RequestInfo | URL) => createMockResponse({ ok: true, status: 200, jsonData: {} }));
const assShowMock = mock(() => {});
const assDestroyMock = mock(() => {});
const assConstructorMock = mock(() => ({
show: assShowMock,
destroy: assDestroyMock,
}));
beforeEach(() => {
runtimeConfig.app.baseURL = '/';
fetchMock.mockClear();
assShowMock.mockClear();
assDestroyMock.mockClear();
assConstructorMock.mockClear();
notificationErrorMock.mockClear();
globalThis.fetch = fetchMock as typeof fetch;
});
afterEach(() => {
delete (globalThis as { fetch?: typeof fetch }).fetch;
});
it('loads subtitle manifest and exposes the preferred native track', async () => {
fetchMock.mockResolvedValueOnce(
createMockResponse({
ok: true,
status: 200,
jsonData: {
subtitles: [
{
lang: 'en',
name: 'English',
source_format: 'vtt',
delivery_format: 'vtt',
renderer: 'native',
url: '/api/player/subtitles/vtt/video.vtt',
},
{
lang: 'en',
name: 'Styled',
source_format: 'ass',
delivery_format: 'ass',
renderer: 'assjs',
url: '/api/player/subtitles/ass/video.ass',
},
],
},
}),
);
const { usePlayerSubtitles } = await import('~/composables/usePlayerSubtitles');
const mediaFile = ref('video file.mkv');
const isVideo = ref(true);
const canPlay = ref(true);
const shouldRender = ref(false);
const video = ref<HTMLVideoElement | null>(document.createElement('video'));
const overlay = ref<HTMLElement | null>(document.createElement('div'));
const { hasSubtitles, nativeSubtitleTrack, selectedSubtitleTrack, usesAssSubtitleTrack } =
usePlayerSubtitles({
mediaFile,
isVideo,
canPlay,
shouldRender,
video,
overlay,
});
await flushPromises();
expect(fetchMock).toHaveBeenCalledWith('/api/player/subtitles/manifest/video%20file.mkv', expect.anything());
expect(hasSubtitles.value).toBe(true);
expect(selectedSubtitleTrack.value?.source_format).toBe('vtt');
expect(nativeSubtitleTrack.value?.url).toBe('/api/player/subtitles/vtt/video.vtt');
expect(usesAssSubtitleTrack.value).toBe(false);
});
it('creates and destroys an ASS renderer for ASS subtitles when playback becomes active', async () => {
fetchMock.mockResolvedValueOnce(
createMockResponse({
ok: true,
status: 200,
jsonData: {
subtitles: [
{
lang: 'en',
name: 'Styled',
source_format: 'ass',
delivery_format: 'ass',
renderer: 'assjs',
url: '/api/player/subtitles/ass/video.ass',
},
],
},
}),
);
const fetchText = mock(async () => '[Script Info]\nTitle: Demo\n');
const loadRenderer = mock(async () => assConstructorMock as any);
const { usePlayerSubtitles } = await import('~/composables/usePlayerSubtitles');
const mediaFile = ref('video.mkv');
const isVideo = ref(true);
const canPlay = ref(true);
const shouldRender = ref(false);
const video = ref<HTMLVideoElement | null>(document.createElement('video'));
const overlay = ref<HTMLElement | null>(document.createElement('div'));
const { usesAssSubtitleTrack } = usePlayerSubtitles({
mediaFile,
isVideo,
canPlay,
shouldRender,
video,
overlay,
fetchText,
loadRenderer,
});
await flushPromises();
expect(usesAssSubtitleTrack.value).toBe(true);
expect(assConstructorMock).not.toHaveBeenCalled();
shouldRender.value = true;
await flushPromises(5);
expect(fetchText).toHaveBeenCalledWith('/api/player/subtitles/ass/video.ass');
expect(loadRenderer).toHaveBeenCalledTimes(1);
expect(assConstructorMock).toHaveBeenCalledTimes(1);
expect(assShowMock).toHaveBeenCalledTimes(1);
mediaFile.value = 'second.mkv';
fetchMock.mockResolvedValueOnce(
createMockResponse({
ok: true,
status: 200,
jsonData: { subtitles: [] },
}),
);
await flushPromises();
expect(assDestroyMock.mock.calls.length).toBeGreaterThanOrEqual(1);
});
it('recreates an ASS renderer when the layout version changes without refetching subtitles', async () => {
fetchMock.mockResolvedValueOnce(
createMockResponse({
ok: true,
status: 200,
jsonData: {
subtitles: [
{
lang: 'en',
name: 'Styled',
source_format: 'ass',
delivery_format: 'ass',
renderer: 'assjs',
url: '/api/player/subtitles/ass/video.ass',
},
],
},
}),
);
const fetchText = mock(async () => '[Script Info]\nTitle: Demo\n');
const loadRenderer = mock(async () => assConstructorMock as any);
const { usePlayerSubtitles } = await import('~/composables/usePlayerSubtitles');
const assLayoutVersion = ref(0);
usePlayerSubtitles({
mediaFile: ref('video.mkv'),
isVideo: ref(true),
canPlay: ref(true),
shouldRender: ref(true),
assLayoutVersion,
video: ref<HTMLVideoElement | null>(document.createElement('video')),
overlay: ref<HTMLElement | null>(document.createElement('div')),
fetchText,
loadRenderer,
});
await flushPromises(5);
expect(fetchText).toHaveBeenCalledTimes(1);
expect(assConstructorMock).toHaveBeenCalledTimes(1);
assLayoutVersion.value += 1;
await flushPromises(5);
expect(fetchText).toHaveBeenCalledTimes(1);
expect(assDestroyMock.mock.calls.length).toBeGreaterThanOrEqual(1);
expect(assConstructorMock).toHaveBeenCalledTimes(2);
});
});

2216
uv.lock

File diff suppressed because it is too large Load diff