feat: custom video player with direct ssa rendering
This commit is contained in:
parent
11bd4204aa
commit
80316df952
21 changed files with 3698 additions and 1738 deletions
60
API.md
60
API.md
|
|
@ -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": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
138
app/features/streaming/tests/test_subtitle_routes.py
Normal file
138
app/features/streaming/tests/test_subtitle_routes.py
Normal 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
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
44
ui/app/composables/usePlayerMediaVolume.ts
Normal file
44
ui/app/composables/usePlayerMediaVolume.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
3
ui/app/composables/usePlayerShortcutHelp.ts
Normal file
3
ui/app/composables/usePlayerShortcutHelp.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export function usePlayerShortcutHelp() {
|
||||
return useState<boolean>('player-shortcut-help', () => false);
|
||||
}
|
||||
247
ui/app/composables/usePlayerShortcuts.ts
Normal file
247
ui/app/composables/usePlayerShortcuts.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
220
ui/app/composables/usePlayerSubtitles.ts
Normal file
220
ui/app/composables/usePlayerSubtitles.ts
Normal 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
12
ui/app/types/subtitles.ts
Normal 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[];
|
||||
};
|
||||
19
ui/app/types/video.d.ts
vendored
19
ui/app/types/video.d.ts
vendored
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
53
ui/app/utils/fullscreen.ts
Normal file
53
ui/app/utils/fullscreen.ts
Normal 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');
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
65
ui/bun.lock
65
ui/bun.lock
|
|
@ -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=="],
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ export default defineNuxtConfig({
|
|||
'marked-alert',
|
||||
'marked-gfm-heading-id',
|
||||
'hls.js',
|
||||
'assjs',
|
||||
'@vue/devtools-core',
|
||||
'@vue/devtools-kit',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
102
ui/tests/composables/usePlayerShortcuts.test.ts
Normal file
102
ui/tests/composables/usePlayerShortcuts.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
261
ui/tests/composables/usePlayerSubtitles.test.ts
Normal file
261
ui/tests/composables/usePlayerSubtitles.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue