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/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": [],
|
|
||||||
...
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
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 { 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
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;
|
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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
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';
|
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;
|
|
||||||
|
|
|
||||||
65
ui/bun.lock
65
ui/bun.lock
|
|
@ -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=="],
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
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