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/m3u8/{mode}/{file:.\*}.m3u8](#get-apiplayerm3u8modefilem3u8)
- [GET /api/player/segments/{segment}/{file:.\*}.ts](#get-apiplayersegmentssegmentfilets) - [GET /api/player/segments/{segment}/{file:.\*}.ts](#get-apiplayersegmentssegmentfilets)
- [GET /api/player/subtitle/{file:.\*}.vtt](#get-apiplayersubtitlefilevtt) - [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/thumbnail](#get-apithumbnail)
- [GET /api/file/ffprobe/{file:.\*}](#get-apifileffprobefile) - [GET /api/file/ffprobe/{file:.\*}](#get-apifileffprobefile)
- [GET /api/file/info/{file:.\*}](#get-apifileinfofile) - [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 ### GET /api/thumbnail
**Purpose**: Proxy/fetch a remote thumbnail image. **Purpose**: Proxy/fetch a remote thumbnail image.
@ -1594,21 +1641,16 @@ Binary image data with the appropriate `Content-Type`.
}, },
"mimetype": "video/mp4", "mimetype": "video/mp4",
"sidecar": { "sidecar": {
"subtitles": [ "subtitle": [
{ {
"file": "filename.xxx.vtt", "file": "filename.xxx.ass",
"lang": "xxx", "lang": "xxx",
"name": "VTT 0 - XXX|end", "name": "ASS (0) - xxx"
}, },
... ...
}
], ],
"video": [],
"audio": [],
"image": [], "image": [],
"text": [], "text": []
"metadata": [],
...
} }
} }
``` ```

View file

@ -1,4 +1,7 @@
from __future__ import annotations
import logging import logging
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
import anyio import anyio
@ -6,10 +9,37 @@ import pysubs2
from pysubs2.formats.substation import SubstationFormat from pysubs2.formats.substation import SubstationFormat
from pysubs2.time import ms_to_times 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") 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: def ms_to_timestamp(ms: int) -> str:
ms = max(0, ms) ms = max(0, ms)
@ -22,14 +52,46 @@ SubstationFormat.ms_to_timestamp = ms_to_timestamp
class Subtitle: 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: 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." msg: str = f"File '{file}' subtitle type is not supported."
raise Exception(msg) raise Exception(msg)
if file.suffix == ".vtt": if fmt == "vtt":
async with await anyio.open_file(file) as f: return await self.read_text(file)
return await f.read()
subs: pysubs2.SSAFile = pysubs2.load(path=str(file)) subs: pysubs2.SSAFile = pysubs2.load(path=str(file))
@ -47,3 +109,45 @@ class Subtitle:
pass pass
return subs.to_string("vtt") 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.m3u8 import M3u8
from app.features.streaming.library.playlist import Playlist from app.features.streaming.library.playlist import Playlist
from app.features.streaming.library.segments import Segments 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.features.streaming.types import StreamingError
from app.library.config import Config from app.library.config import Config
from app.library.router import route 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, 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 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: class TestMsToTimestamp:
@ -24,12 +24,12 @@ class TestMsToTimestamp:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_make_unsupported_extension(tmp_path: Path) -> None: async def test_make_unsupported_extension(tmp_path: Path) -> None:
srt = tmp_path / "sub.txt" file = tmp_path / "sub.txt"
srt.write_text("not a subtitle") file.write_text("not a subtitle")
sub = Subtitle() subtitle = Subtitle()
with pytest.raises(Exception, match="subtitle type is not supported"): with pytest.raises(Exception, match="subtitle type is not supported"):
await sub.make(srt) await subtitle.make(file)
@pytest.mark.asyncio @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" content = "WEBVTT\n\n00:00:00.00 --> 00:00:01.00\nHello"
vtt.write_text(content) vtt.write_text(content)
sub = Subtitle() subtitle = Subtitle()
out = await sub.make(vtt) out = await subtitle.make(vtt)
assert out == content 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: class _DummySubs:
def __init__(self, events): def __init__(self, events):
self.events = 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: with patch("app.features.streaming.library.subtitle.pysubs2.load") as mock_load:
mock_load.return_value = _DummySubs(events=[]) mock_load.return_value = _DummySubs(events=[])
sub = Subtitle() subtitle = Subtitle()
with pytest.raises(Exception, match="No subtitle events were found"): with pytest.raises(Exception, match="No subtitle events were found"):
await sub.make(srt) await subtitle.make(srt)
@pytest.mark.asyncio @pytest.mark.asyncio
@ -75,8 +115,8 @@ async def test_make_single_event_returns_vtt(tmp_path: Path) -> None:
d = _DummySubs(events=[single]) d = _DummySubs(events=[single])
with patch("app.features.streaming.library.subtitle.pysubs2.load", return_value=d): with patch("app.features.streaming.library.subtitle.pysubs2.load", return_value=d):
sub = Subtitle() subtitle = Subtitle()
out = await sub.make(srt) out = await subtitle.make(srt)
assert out == "OUT" assert out == "OUT"
assert d.snapshot == [1000], "Snapshot should contain the single event" 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]) d = _DummySubs(events=[e1, e2])
with patch("app.features.streaming.library.subtitle.pysubs2.load", return_value=d): with patch("app.features.streaming.library.subtitle.pysubs2.load", return_value=d):
sub = Subtitle() subtitle = Subtitle()
out = await sub.make(srt) out = await subtitle.make(srt)
assert out == "OUT" assert out == "OUT"
assert d.snapshot == [5000], "Since ends are equal, first should be popped => only last remains" 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]) d = _DummySubs(events=[e1, e2])
with patch("app.features.streaming.library.subtitle.pysubs2.load", return_value=d): with patch("app.features.streaming.library.subtitle.pysubs2.load", return_value=d):
sub = Subtitle() subtitle = Subtitle()
out = await sub.make(srt) out = await subtitle.make(srt)
assert out == "OUT" assert out == "OUT"
assert d.snapshot == [5000, 6000], "Both remain since ends differ" 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 { ref } from 'vue';
import type { Ref } from 'vue'; import type { Ref } from 'vue';
import { import {
handlePlayPause, playPause,
handleRewind, rewind,
handleForward, forward,
handleMute, mute,
handleVolumeChange, changeVolume,
handlePlaybackSpeedChange, changeSpeed,
handleFrameStep, frameStep,
handleSeekToPercent, seekToPercent,
handleSeekBackward, seekBackward,
handleSeekForward, seekForward,
handleFullscreen, fullscreen,
handlePictureInPicture, pictureInPicture,
handleToggleCaptions, toggleCaptions,
shouldHandleKeyboardShortcut, shouldHandleKeyboardShortcut,
isModifierKey, modifierKey,
} from '~/utils/keyboard'; } from '~/utils/keyboard';
import type { KeyboardShortcutContext } from '~/types/video'; 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) // 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; return;
} }
@ -64,67 +64,67 @@ export const useKeyboardShortcuts = (options: UseKeyboardShortcutsOptions) => {
case ' ': case ' ':
case 'k': case 'k':
event.preventDefault(); event.preventDefault();
handlePlayPause(ctx); playPause(ctx);
break; break;
// Rewind 10 seconds (J key) // Rewind 10 seconds (J key)
case 'j': case 'j':
event.preventDefault(); event.preventDefault();
handleRewind(ctx, 10); rewind(ctx, 10);
break; break;
// Forward 10 seconds (L key) // Forward 10 seconds (L key)
case 'l': case 'l':
event.preventDefault(); event.preventDefault();
handleForward(ctx, 10); forward(ctx, 10);
break; break;
// Seek backward 5 seconds (left arrow) // Seek backward 5 seconds (left arrow)
case 'arrowleft': case 'arrowleft':
event.preventDefault(); event.preventDefault();
handleSeekBackward(ctx, 5); seekBackward(ctx, 5);
break; break;
// Seek forward 5 seconds (right arrow) // Seek forward 5 seconds (right arrow)
case 'arrowright': case 'arrowright':
event.preventDefault(); event.preventDefault();
handleSeekForward(ctx, 5); seekForward(ctx, 5);
break; break;
// Increase volume (up arrow) // Increase volume (up arrow)
case 'arrowup': case 'arrowup':
event.preventDefault(); event.preventDefault();
handleVolumeChange(ctx, 0.1); changeVolume(ctx, 0.1);
break; break;
// Decrease volume (down arrow) // Decrease volume (down arrow)
case 'arrowdown': case 'arrowdown':
event.preventDefault(); event.preventDefault();
handleVolumeChange(ctx, -0.1); changeVolume(ctx, -0.1);
break; break;
// Mute/Unmute // Mute/Unmute
case 'm': case 'm':
event.preventDefault(); event.preventDefault();
handleMute(ctx); mute(ctx);
break; break;
// Toggle fullscreen // Toggle fullscreen
case 'f': case 'f':
event.preventDefault(); event.preventDefault();
handleFullscreen(video); fullscreen(video);
break; break;
// Picture-in-Picture // Picture-in-Picture
case 'p': case 'p':
event.preventDefault(); event.preventDefault();
await handlePictureInPicture(video); await pictureInPicture(video);
break; break;
// Toggle captions // Toggle captions
case 'c': case 'c':
event.preventDefault(); event.preventDefault();
handleToggleCaptions(video); toggleCaptions(video);
break; break;
// Frame advance (period key) / Increase playback speed (> or ') // Frame advance (period key) / Increase playback speed (> or ')
@ -132,9 +132,9 @@ export const useKeyboardShortcuts = (options: UseKeyboardShortcutsOptions) => {
case "'": { case "'": {
event.preventDefault(); event.preventDefault();
if ('.' === key) { if ('.' === key) {
handleFrameStep(ctx, 'forward'); frameStep(ctx, 'forward');
} else { } else {
handlePlaybackSpeedChange(ctx, 0.25); changeSpeed(ctx, 0.25);
} }
break; break;
} }
@ -144,9 +144,9 @@ export const useKeyboardShortcuts = (options: UseKeyboardShortcutsOptions) => {
case ';': { case ';': {
event.preventDefault(); event.preventDefault();
if (',' === key) { if (',' === key) {
handleFrameStep(ctx, 'backward'); frameStep(ctx, 'backward');
} else { } else {
handlePlaybackSpeedChange(ctx, -0.25); changeSpeed(ctx, -0.25);
} }
break; break;
} }
@ -164,7 +164,7 @@ export const useKeyboardShortcuts = (options: UseKeyboardShortcutsOptions) => {
case '9': { case '9': {
event.preventDefault(); event.preventDefault();
const percent = parseInt(key) * 10; const percent = parseInt(key) * 10;
handleSeekToPercent(ctx, percent); seekToPercent(ctx, percent);
break; 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; video: HTMLVideoElement;
}; };
type video_track_element = { type VideoTrackElement = {
file: string; file: string;
kind: string; kind: string;
label: string; label: string;
lang: string; lang: string;
}; };
type video_source_element = { type VideoSourceElement = {
src: string; src: string;
type: string; type: string;
onerror: (e: Event) => void; onerror: (e: Event) => void;
@ -68,7 +68,7 @@ type FFProbeResult = {
is_audio: boolean; is_audio: boolean;
}; };
type file_info = { type FileInfo = {
title: string; title: string;
ffprobe: FFProbeResult; ffprobe: FFProbeResult;
mimetype: string; mimetype: string;
@ -80,10 +80,17 @@ type file_info = {
error?: string; error?: string;
}; };
type PlayerSourceElement = {
src: string;
type?: string;
onerror?: (e: Event) => void;
};
export type { export type {
video_track_element, VideoTrackElement,
video_source_element, VideoSourceElement,
PlayerSourceElement,
FFProbeResult, FFProbeResult,
file_info, FileInfo,
KeyboardShortcutContext, 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'; 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) { if (ctx.video.paused) {
ctx.video.play(); ctx.video.play();
} else { } 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); 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); 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; ctx.video.muted = !ctx.video.muted;
}; };
export const handleVolumeChange = (ctx: KeyboardShortcutContext, delta: number) => { export const changeVolume = (ctx: KeyboardShortcutContext, delta: number) => {
const newVolume = Math.max(0, Math.min(1, ctx.video.volume + delta)); const volume = clampMediaVolume(ctx.video.volume + delta);
ctx.video.volume = newVolume; ctx.video.volume = volume;
}; };
export const handlePlaybackSpeedChange = (ctx: KeyboardShortcutContext, delta: number) => { export const changeSpeed = (ctx: KeyboardShortcutContext, delta: number) => {
const currentSpeed = ctx.video.playbackRate; const speed = ctx.video.playbackRate;
const newSpeed = Math.max(0.25, Math.min(2, currentSpeed + delta)); ctx.video.playbackRate = Math.max(0.25, Math.min(2, speed + delta));
ctx.video.playbackRate = newSpeed;
}; };
export const handleFrameStep = ( export const frameStep = (
ctx: KeyboardShortcutContext, ctx: KeyboardShortcutContext,
direction: 'forward' | 'backward' = 'forward', 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; ctx.video.currentTime = (percent / 100) * ctx.video.duration;
}; };
export const handleSeekBackward = (ctx: KeyboardShortcutContext, seconds: number = 5) => { export const seekBackward = (ctx: KeyboardShortcutContext, seconds: number = 5) => {
ctx.video.currentTime = Math.max(0, ctx.video.currentTime - seconds); clampMediaTime(ctx.video, ctx.video.currentTime - seconds);
}; };
export const handleSeekForward = (ctx: KeyboardShortcutContext, seconds: number = 5) => { export const seekForward = (ctx: KeyboardShortcutContext, seconds: number = 5) => {
ctx.video.currentTime = Math.min(ctx.video.duration, ctx.video.currentTime + seconds); clampMediaTime(ctx.video, ctx.video.currentTime + seconds);
}; };
export const handleFullscreen = (videoElement: HTMLVideoElement) => { export const fullscreen = (video: HTMLVideoElement) => {
if (!document.fullscreenElement) { if (!document.fullscreenElement) {
videoElement.requestFullscreen().catch((err) => { video.requestFullscreen().catch((err) => {
console.error(`Error attempting to enable fullscreen: ${err.message}`); console.error(`Error attempting to enable fullscreen: ${err.message}`);
}); });
} else { } else {
@ -69,20 +81,20 @@ export const handleFullscreen = (videoElement: HTMLVideoElement) => {
} }
}; };
export const handlePictureInPicture = async (videoElement: HTMLVideoElement) => { export const pictureInPicture = async (video: HTMLVideoElement) => {
try { try {
if (document.pictureInPictureElement) { if (document.pictureInPictureElement) {
await document.exitPictureInPicture(); await document.exitPictureInPicture();
} else if (document.pictureInPictureEnabled) { } else if (document.pictureInPictureEnabled) {
await videoElement.requestPictureInPicture(); await video.requestPictureInPicture();
} }
} catch (error) { } catch (error) {
console.error(`Picture-in-Picture error: ${error}`); console.error(`Picture-in-Picture error: ${error}`);
} }
}; };
export const handleToggleCaptions = (videoElement: HTMLVideoElement) => { export const toggleCaptions = (video: HTMLVideoElement) => {
const textTracks = videoElement.textTracks; const textTracks = video.textTracks;
if (0 === textTracks.length) { if (0 === textTracks.length) {
return; return;
@ -112,5 +124,4 @@ export const shouldHandleKeyboardShortcut = (event: KeyboardEvent): boolean => {
return true; return true;
}; };
export const isModifierKey = (event: KeyboardEvent): boolean => export const modifierKey = (event: KeyboardEvent): boolean => hasModifierKey(event);
event.ctrlKey || event.metaKey || event.altKey;

View file

@ -5,39 +5,40 @@
"": { "": {
"name": "nuxt-app", "name": "nuxt-app",
"dependencies": { "dependencies": {
"@iconify-json/lucide": "latest", "@iconify-json/lucide": "^1.2.103",
"@microsoft/fetch-event-source": "latest", "@microsoft/fetch-event-source": "^2.0.1",
"@nuxt/eslint": "latest", "@nuxt/eslint": "^1.15.2",
"@nuxt/eslint-config": "latest", "@nuxt/eslint-config": "^1.15.2",
"@nuxt/ui": "latest", "@nuxt/ui": "^4.7.0",
"@vueuse/core": "latest", "@vueuse/core": "^14.2.1",
"@vueuse/nuxt": "latest", "@vueuse/nuxt": "^14.2.1",
"@xterm/addon-fit": "latest", "@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "latest", "@xterm/xterm": "^6.0.0",
"cron-parser": "latest", "assjs": "^0.1.6",
"cronstrue": "latest", "cron-parser": "^5.5.0",
"hls.js": "latest", "cronstrue": "^3.14.0",
"marked": "latest", "hls.js": "^1.6.16",
"marked-alert": "latest", "marked": "^18.0.2",
"marked-base-url": "latest", "marked-alert": "^2.1.2",
"marked-gfm-heading-id": "latest", "marked-base-url": "^1.1.9",
"moment": "latest", "marked-gfm-heading-id": "^4.1.4",
"nuxt": "latest", "moment": "^2.30.1",
"tailwindcss": "latest", "nuxt": "^4.4.2",
"vue": "latest", "tailwindcss": "^4.2.4",
"vue-router": "latest", "vue": "^3.5.33",
"vue-router": "^5.0.6",
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "^1.3.13",
"@types/jsdom": "latest", "@types/jsdom": "^28.0.1",
"@types/node": "latest", "@types/node": "25.6.0",
"@typescript-eslint/parser": "latest", "@typescript-eslint/parser": "^8.59.0",
"eslint": "latest", "eslint": "^10.2.1",
"jsdom": "latest", "jsdom": "^29.0.2",
"oxfmt": "latest", "oxfmt": "^0.46.0",
"typescript": "latest", "typescript": "^6.0.3",
"vue-eslint-parser": "latest", "vue-eslint-parser": "^10.4.0",
"vue-tsc": "latest", "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=="], "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-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=="], "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-alert',
'marked-gfm-heading-id', 'marked-gfm-heading-id',
'hls.js', 'hls.js',
'assjs',
'@vue/devtools-core', '@vue/devtools-core',
'@vue/devtools-kit', '@vue/devtools-kit',
], ],

View file

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