feat(plex): P2 subtitle + audio track selector (closes P2)
Backend: item detail returns audio_streams + subtitle_streams (from Plex Part.Stream
ordinals). The HLS session accepts audio/subtitle stream ordinals — the selected audio
is mapped (transcoded to AAC), a selected subtitle is muxed in as a WebVTT rendition
via a MASTER playlist (-master_pl_name + -var_stream_map sgroup:subs) so ffmpeg keeps
it in sync per-session. Selecting a track forces the HLS path (even for direct files).
Generic /stream/{rk}/hls/{filename} endpoint serves the master/media/vtt/ts artifacts.
Frontend: gear menu lists real audio + subtitle tracks; changing one restarts the
session at the current position (seek-restart mechanism). hls.js subtitle enabled on
SUBTITLE_TRACKS_UPDATED (not just MANIFEST_PARSED, else the VTT is never fetched).
Cues auto-nudged to line 88% (the full-height <video> clips a default bottom-edge cue).
Controls no longer auto-hide while the menu is open. plex.player.* i18n en/hu/de.
Verified in a real browser (2 Broke Girls S1E1, 2 audio + 2 subs): menu shows the real
tracks, enabling English subtitles renders them correctly positioned; backend master
playlist + vtt segments validated over HTTP.
This commit is contained in:
parent
29d306441a
commit
a06f87f5f6
7 changed files with 259 additions and 65 deletions
|
|
@ -39,29 +39,33 @@ _sessions: dict[str, "HlsSession"] = {}
|
||||||
|
|
||||||
|
|
||||||
class HlsSession:
|
class HlsSession:
|
||||||
def __init__(self, key: str, directory: Path, proc: subprocess.Popen, start_s: float):
|
def __init__(self, key: str, directory: Path, proc: subprocess.Popen, start_s: float, entry: str):
|
||||||
self.key = key
|
self.key = key
|
||||||
self.dir = directory
|
self.dir = directory
|
||||||
self.proc = proc
|
self.proc = proc
|
||||||
self.start_s = start_s
|
self.start_s = start_s
|
||||||
|
self.entry = entry # the playlist filename hls.js should load (master.m3u8 with subs, else index.m3u8)
|
||||||
self.last_access = time.time()
|
self.last_access = time.time()
|
||||||
|
|
||||||
|
|
||||||
def _acodec(item: PlexItem) -> str:
|
def _ffmpeg_cmd(
|
||||||
# Copy the audio when it's already AAC; otherwise transcode (ac3/dts/… → aac). Video is always
|
src: Path, start_s: float, out_dir: Path, audio_ord: int | None, sub_ord: int | None
|
||||||
# copied for the remux class.
|
) -> list[str]:
|
||||||
return "copy" if (item.codec_audio or "").lower() == "aac" else "aac"
|
|
||||||
|
|
||||||
|
|
||||||
def _ffmpeg_cmd(src: Path, start_s: float, out_dir: Path, acodec: str) -> list[str]:
|
|
||||||
args = ["ffmpeg", "-nostdin", "-loglevel", "error"]
|
args = ["ffmpeg", "-nostdin", "-loglevel", "error"]
|
||||||
if start_s > 0:
|
if start_s > 0:
|
||||||
args += ["-ss", f"{start_s:.3f}"] # fast keyframe seek before -i
|
args += ["-ss", f"{start_s:.3f}"] # fast keyframe seek before -i
|
||||||
# Map only the first video + first audio; drop subtitles so ffmpeg's HLS muxer doesn't spawn
|
args += ["-i", str(src)]
|
||||||
# WebVTT renditions (subtitle/multi-audio selection is a later refinement served separately).
|
# Video always stream-copied. The selected audio track (default = first) is transcoded to AAC.
|
||||||
args += ["-i", str(src), "-map", "0:v:0", "-map", "0:a:0?", "-sn", "-c:v", "copy", "-c:a", acodec]
|
# A selected subtitle track is muxed in as a WebVTT rendition (ffmpeg aligns it to this session's
|
||||||
if acodec == "aac":
|
# timeline, so it stays in sync even after a seek-restart); no selection → drop subtitles.
|
||||||
args += ["-ac", "2", "-b:a", "192k"]
|
args += ["-map", "0:v:0", "-map", f"0:a:{audio_ord if audio_ord is not None else 0}?"]
|
||||||
|
if sub_ord is not None:
|
||||||
|
args += ["-map", f"0:s:{sub_ord}?", "-c:s", "webvtt"]
|
||||||
|
else:
|
||||||
|
args += ["-sn"]
|
||||||
|
args += ["-c:v", "copy", "-c:a", "aac", "-ac", "2", "-b:a", "192k"]
|
||||||
|
if sub_ord is not None:
|
||||||
|
args += ["-c:s", "webvtt"]
|
||||||
args += [
|
args += [
|
||||||
"-f", "hls",
|
"-f", "hls",
|
||||||
"-hls_time", str(_SEG_SECONDS),
|
"-hls_time", str(_SEG_SECONDS),
|
||||||
|
|
@ -72,9 +76,12 @@ def _ffmpeg_cmd(src: Path, start_s: float, out_dir: Path, acodec: str) -> list[s
|
||||||
"-hls_playlist_type", "event",
|
"-hls_playlist_type", "event",
|
||||||
"-hls_segment_type", "mpegts",
|
"-hls_segment_type", "mpegts",
|
||||||
"-hls_flags", "independent_segments+temp_file",
|
"-hls_flags", "independent_segments+temp_file",
|
||||||
"-hls_segment_filename", str(out_dir / "seg_%d.ts"),
|
|
||||||
str(out_dir / "index.m3u8"),
|
|
||||||
]
|
]
|
||||||
|
if sub_ord is not None:
|
||||||
|
# A subtitle rendition needs a MASTER playlist tying the video variant to the WebVTT group
|
||||||
|
# (ffmpeg generates the subtitle segments per-session, so they stay in sync after a seek).
|
||||||
|
args += ["-master_pl_name", "master.m3u8", "-var_stream_map", "v:0,a:0,s:0,sgroup:subs"]
|
||||||
|
args += ["-hls_segment_filename", str(out_dir / "seg_%d.ts"), str(out_dir / "index.m3u8")]
|
||||||
return args
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -99,31 +106,39 @@ def _enforce_cap() -> None:
|
||||||
_sessions.pop(key, None)
|
_sessions.pop(key, None)
|
||||||
|
|
||||||
|
|
||||||
def start_session(db: DbSession, item: PlexItem, start_s: float) -> HlsSession | None:
|
def start_session(
|
||||||
"""(Re)start the HLS remux for an item at the given offset. Returns None if the local file
|
db: DbSession,
|
||||||
can't be read."""
|
item: PlexItem,
|
||||||
|
start_s: float,
|
||||||
|
audio_ord: int | None = None,
|
||||||
|
sub_ord: int | None = None,
|
||||||
|
) -> HlsSession | None:
|
||||||
|
"""(Re)start the HLS remux for an item at the given offset, with an optional selected audio /
|
||||||
|
subtitle track (ordinals among their stream type). Returns None if the local file can't be read."""
|
||||||
src = paths.local_media_path(db, item.file_path)
|
src = paths.local_media_path(db, item.file_path)
|
||||||
if src is None:
|
if src is None:
|
||||||
return None
|
return None
|
||||||
key = item.rating_key
|
key = item.rating_key
|
||||||
start_s = max(0.0, float(start_s))
|
start_s = max(0.0, float(start_s))
|
||||||
acodec = _acodec(item)
|
|
||||||
with _lock:
|
with _lock:
|
||||||
old = _sessions.pop(key, None)
|
old = _sessions.pop(key, None)
|
||||||
if old is not None:
|
if old is not None:
|
||||||
_kill(old)
|
_kill(old)
|
||||||
directory = _HLS_ROOT / f"{key}_{int(start_s)}"
|
directory = _HLS_ROOT / f"{key}_{int(start_s)}_{audio_ord}_{sub_ord}"
|
||||||
shutil.rmtree(directory, ignore_errors=True)
|
shutil.rmtree(directory, ignore_errors=True)
|
||||||
directory.mkdir(parents=True, exist_ok=True)
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
proc = subprocess.Popen(
|
proc = subprocess.Popen(
|
||||||
_ffmpeg_cmd(src, start_s, directory, acodec),
|
_ffmpeg_cmd(src, start_s, directory, audio_ord, sub_ord),
|
||||||
stdout=subprocess.DEVNULL,
|
stdout=subprocess.DEVNULL,
|
||||||
stderr=subprocess.DEVNULL,
|
stderr=subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
s = HlsSession(key, directory, proc, start_s)
|
entry = "master.m3u8" if sub_ord is not None else "index.m3u8"
|
||||||
|
s = HlsSession(key, directory, proc, start_s, entry)
|
||||||
_sessions[key] = s
|
_sessions[key] = s
|
||||||
_enforce_cap()
|
_enforce_cap()
|
||||||
log.info("plex hls session start key=%s start=%.1f acodec=%s", key, start_s, acodec)
|
log.info(
|
||||||
|
"plex hls session start key=%s start=%.1f audio=%s sub=%s", key, start_s, audio_ord, sub_ord
|
||||||
|
)
|
||||||
return s
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -331,6 +331,8 @@ def item_detail(
|
||||||
|
|
||||||
cast: list[str] = []
|
cast: list[str] = []
|
||||||
markers: list[dict] = []
|
markers: list[dict] = []
|
||||||
|
audio_streams: list[dict] = []
|
||||||
|
subtitle_streams: list[dict] = []
|
||||||
try:
|
try:
|
||||||
with PlexClient(db) as plex:
|
with PlexClient(db) as plex:
|
||||||
meta = plex.metadata(it.rating_key, markers=True) or {}
|
meta = plex.metadata(it.rating_key, markers=True) or {}
|
||||||
|
|
@ -344,6 +346,26 @@ def item_detail(
|
||||||
"end_s": round((m.get("endTimeOffset") or 0) / 1000, 1),
|
"end_s": round((m.get("endTimeOffset") or 0) / 1000, 1),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
# Audio + subtitle tracks (ordinals among their stream type — used to -map the selection).
|
||||||
|
part = ((meta.get("Media") or [{}])[0].get("Part") or [{}])[0]
|
||||||
|
for s in part.get("Stream") or []:
|
||||||
|
if s.get("streamType") == 2:
|
||||||
|
audio_streams.append(
|
||||||
|
{
|
||||||
|
"ord": len(audio_streams),
|
||||||
|
"label": s.get("displayTitle") or s.get("language") or f"Audio {len(audio_streams) + 1}",
|
||||||
|
"language": s.get("languageTag") or s.get("language"),
|
||||||
|
"default": bool(s.get("selected") or s.get("default")),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif s.get("streamType") == 3:
|
||||||
|
subtitle_streams.append(
|
||||||
|
{
|
||||||
|
"ord": len(subtitle_streams),
|
||||||
|
"label": s.get("displayTitle") or s.get("language") or f"Subtitle {len(subtitle_streams) + 1}",
|
||||||
|
"language": s.get("languageTag") or s.get("language"),
|
||||||
|
}
|
||||||
|
)
|
||||||
except (PlexError, PlexNotConfigured):
|
except (PlexError, PlexNotConfigured):
|
||||||
pass # metadata extras are best-effort; playback works without them
|
pass # metadata extras are best-effort; playback works without them
|
||||||
|
|
||||||
|
|
@ -361,6 +383,8 @@ def item_detail(
|
||||||
"art": f"/api/plex/image/{it.rating_key}?variant=art",
|
"art": f"/api/plex/image/{it.rating_key}?variant=art",
|
||||||
"cast": cast,
|
"cast": cast,
|
||||||
"markers": markers,
|
"markers": markers,
|
||||||
|
"audio_streams": audio_streams,
|
||||||
|
"subtitle_streams": subtitle_streams,
|
||||||
"status": st.status if st else "new",
|
"status": st.status if st else "new",
|
||||||
"position_seconds": st.position_seconds if st else 0,
|
"position_seconds": st.position_seconds if st else 0,
|
||||||
"show_title": show.title if show else None,
|
"show_title": show.title if show else None,
|
||||||
|
|
@ -451,23 +475,28 @@ def item_state(
|
||||||
def stream_session(
|
def stream_session(
|
||||||
rating_key: str,
|
rating_key: str,
|
||||||
start: float = 0.0,
|
start: float = 0.0,
|
||||||
|
audio: int | None = None,
|
||||||
|
subtitle: int | None = None,
|
||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Prepare playback from `start` seconds. Direct-playable files are served raw; remux files
|
"""Prepare playback from `start` seconds. Direct-playable files are served raw; remux files
|
||||||
(re)start an HLS session at the offset (seek-restart); transcode (HEVC/VP9) is P3."""
|
(re)start an HLS session at the offset (seek-restart); transcode (HEVC/VP9) is P3. Selecting a
|
||||||
|
non-default audio/subtitle track (`audio`/`subtitle` = stream ordinals) forces the HLS path so
|
||||||
|
the chosen tracks can be muxed in."""
|
||||||
plex_stream.reap_idle() # opportunistic cleanup of idle sessions on each new playback
|
plex_stream.reap_idle() # opportunistic cleanup of idle sessions on each new playback
|
||||||
it = _item_or_404(db, rating_key)
|
it = _item_or_404(db, rating_key)
|
||||||
if it.playable == "direct":
|
|
||||||
return {"mode": "direct", "url": f"/api/plex/stream/{it.rating_key}/file", "start_s": 0.0}
|
|
||||||
if it.playable == "transcode":
|
if it.playable == "transcode":
|
||||||
raise HTTPException(status_code=501, detail="This format needs transcoding (a later phase).")
|
raise HTTPException(status_code=501, detail="This format needs transcoding (a later phase).")
|
||||||
|
select = audio is not None or subtitle is not None
|
||||||
|
if it.playable == "direct" and not select:
|
||||||
|
return {"mode": "direct", "url": f"/api/plex/stream/{it.rating_key}/file", "start_s": 0.0}
|
||||||
if plex_paths.local_media_path(db, it.file_path) is None:
|
if plex_paths.local_media_path(db, it.file_path) is None:
|
||||||
raise HTTPException(status_code=404, detail="Media file not found on disk")
|
raise HTTPException(status_code=404, detail="Media file not found on disk")
|
||||||
s = plex_stream.start_session(db, it, start)
|
s = plex_stream.start_session(db, it, start, audio, subtitle)
|
||||||
if s is None:
|
if s is None:
|
||||||
raise HTTPException(status_code=404, detail="Media file not found on disk")
|
raise HTTPException(status_code=404, detail="Media file not found on disk")
|
||||||
return {"mode": "hls", "start_s": s.start_s, "url": f"/api/plex/stream/{it.rating_key}/index.m3u8"}
|
return {"mode": "hls", "start_s": s.start_s, "url": f"/api/plex/stream/{it.rating_key}/hls/{s.entry}"}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stream/{rating_key}/file")
|
@router.get("/stream/{rating_key}/file")
|
||||||
|
|
@ -492,43 +521,35 @@ def stream_file(
|
||||||
return FileResponse(str(src)) # Starlette honours the Range header (206)
|
return FileResponse(str(src)) # Starlette honours the Range header (206)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stream/{rating_key}/index.m3u8")
|
_HLS_CT = {".m3u8": "application/vnd.apple.mpegurl", ".ts": "video/mp2t", ".vtt": "text/vtt"}
|
||||||
def stream_playlist(
|
|
||||||
|
|
||||||
|
@router.get("/stream/{rating_key}/hls/{filename}")
|
||||||
|
def stream_hls_file(
|
||||||
rating_key: str,
|
rating_key: str,
|
||||||
|
filename: str,
|
||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> Response:
|
) -> Response:
|
||||||
"""The current HLS session's playlist (starts a session at 0 if none is running)."""
|
"""Serve a file (playlist / segment / subtitle) from the current HLS session directory. The
|
||||||
|
playlist references its segments/subtitles by relative name, all resolving back here."""
|
||||||
|
# Filename must be a plain hls artifact — no path separators, only the expected extensions.
|
||||||
|
ext = "." + filename.rsplit(".", 1)[-1] if "." in filename else ""
|
||||||
|
if ext not in _HLS_CT or "/" in filename or "\\" in filename or ".." in filename:
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
s = plex_stream.current_session(str(rating_key))
|
s = plex_stream.current_session(str(rating_key))
|
||||||
if s is None:
|
if s is None:
|
||||||
|
# No session yet: only the entry playlist for a remux item may auto-start one at 0.
|
||||||
it = _item_or_404(db, rating_key)
|
it = _item_or_404(db, rating_key)
|
||||||
if it.playable not in ("remux",):
|
if filename != "index.m3u8" or it.playable != "remux":
|
||||||
raise HTTPException(status_code=400, detail="Not an HLS-remux item")
|
raise HTTPException(status_code=404, detail="No active playback session")
|
||||||
s = plex_stream.start_session(db, it, 0.0)
|
s = plex_stream.start_session(db, it, 0.0)
|
||||||
if s is None:
|
if s is None:
|
||||||
raise HTTPException(status_code=404, detail="Media file not found on disk")
|
raise HTTPException(status_code=404, detail="Media file not found on disk")
|
||||||
if not plex_stream.wait_for(s.dir / "index.m3u8", 30):
|
path = s.dir / filename
|
||||||
raise HTTPException(status_code=503, detail="Playlist not ready")
|
timeout = 30 if ext == ".m3u8" else 20
|
||||||
|
if not plex_stream.wait_for(path, timeout):
|
||||||
|
raise HTTPException(status_code=404, detail="Not available yet")
|
||||||
return Response(
|
return Response(
|
||||||
content=(s.dir / "index.m3u8").read_bytes(),
|
content=path.read_bytes(), media_type=_HLS_CT[ext], headers={"Cache-Control": "no-store"}
|
||||||
media_type="application/vnd.apple.mpegurl",
|
|
||||||
headers={"Cache-Control": "no-store"},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stream/{rating_key}/seg_{n}.ts")
|
|
||||||
def stream_segment(
|
|
||||||
rating_key: str,
|
|
||||||
n: int,
|
|
||||||
user: User = Depends(current_user),
|
|
||||||
) -> Response:
|
|
||||||
"""Serve one HLS segment from the current session (waits briefly if it's just ahead)."""
|
|
||||||
s = plex_stream.current_session(str(rating_key))
|
|
||||||
if s is None:
|
|
||||||
raise HTTPException(status_code=404, detail="No active playback session")
|
|
||||||
seg = s.dir / f"seg_{n}.ts"
|
|
||||||
if not plex_stream.wait_for(seg, 20):
|
|
||||||
raise HTTPException(status_code=404, detail="Segment not available")
|
|
||||||
return Response(
|
|
||||||
content=seg.read_bytes(), media_type="video/mp2t", headers={"Cache-Control": "no-store"}
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
Pause,
|
Pause,
|
||||||
Play,
|
Play,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
|
Settings,
|
||||||
SkipBack,
|
SkipBack,
|
||||||
SkipForward,
|
SkipForward,
|
||||||
Volume2,
|
Volume2,
|
||||||
|
|
@ -57,9 +58,25 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
const [uiVisible, setUiVisible] = useState(true);
|
const [uiVisible, setUiVisible] = useState(true);
|
||||||
const hideTimer = useRef<number | null>(null);
|
const hideTimer = useRef<number | null>(null);
|
||||||
|
// Selected audio / subtitle stream ordinals (null audio = default first; null subtitle = off).
|
||||||
|
const [audioOrd, setAudioOrd] = useState<number | null>(null);
|
||||||
|
const [subOrd, setSubOrd] = useState<number | null>(null);
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const audioRef = useRef<number | null>(null);
|
||||||
|
const subRef = useRef<number | null>(null);
|
||||||
|
const menuOpenRef = useRef(false);
|
||||||
|
audioRef.current = audioOrd;
|
||||||
|
subRef.current = subOrd;
|
||||||
|
menuOpenRef.current = menuOpen;
|
||||||
|
|
||||||
durationRef.current = duration;
|
durationRef.current = duration;
|
||||||
|
|
||||||
|
// A new item starts with default tracks (its stream layout differs from the last one's).
|
||||||
|
useEffect(() => {
|
||||||
|
setAudioOrd(null);
|
||||||
|
setSubOrd(null);
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
// --- media setup: (re)load a session for `id` at a start offset -------------------------------
|
// --- media setup: (re)load a session for `id` at a start offset -------------------------------
|
||||||
const loadSession = useCallback(
|
const loadSession = useCallback(
|
||||||
async (startAt: number) => {
|
async (startAt: number) => {
|
||||||
|
|
@ -68,7 +85,7 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
|
||||||
setReady(false);
|
setReady(false);
|
||||||
let sess;
|
let sess;
|
||||||
try {
|
try {
|
||||||
sess = await api.plexSession(id, startAt);
|
sess = await api.plexSession(id, startAt, audioRef.current, subRef.current);
|
||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -82,7 +99,16 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
|
||||||
hlsRef.current = hls;
|
hlsRef.current = hls;
|
||||||
hls.loadSource(`${sess.url}?t=${Math.floor(startAt)}`);
|
hls.loadSource(`${sess.url}?t=${Math.floor(startAt)}`);
|
||||||
hls.attachMedia(video);
|
hls.attachMedia(video);
|
||||||
|
// Show the (single) muxed subtitle rendition when one was requested, else none. The
|
||||||
|
// subtitle tracks may not be known yet at MANIFEST_PARSED, so also enable on the dedicated
|
||||||
|
// event (otherwise hls.js never fetches the WebVTT segments).
|
||||||
|
const enableSubs = () => {
|
||||||
|
hls.subtitleDisplay = subRef.current != null;
|
||||||
|
hls.subtitleTrack = subRef.current != null ? 0 : -1;
|
||||||
|
};
|
||||||
|
hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, enableSubs);
|
||||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||||
|
enableSubs();
|
||||||
setReady(true);
|
setReady(true);
|
||||||
video.play().catch(() => {});
|
video.play().catch(() => {});
|
||||||
});
|
});
|
||||||
|
|
@ -233,6 +259,61 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
|
||||||
a.remove();
|
a.remove();
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
|
// Switching audio/subtitle re-maps the track server-side, so it restarts the session at the
|
||||||
|
// current position (like a seek). The refs are updated synchronously so loadSession sees them.
|
||||||
|
const changeAudio = useCallback(
|
||||||
|
(ord: number | null) => {
|
||||||
|
audioRef.current = ord;
|
||||||
|
setAudioOrd(ord);
|
||||||
|
setMenuOpen(false);
|
||||||
|
loadSession(absRef.current);
|
||||||
|
},
|
||||||
|
[loadSession],
|
||||||
|
);
|
||||||
|
const changeSubtitle = useCallback(
|
||||||
|
(ord: number | null) => {
|
||||||
|
subRef.current = ord;
|
||||||
|
setSubOrd(ord);
|
||||||
|
setMenuOpen(false);
|
||||||
|
loadSession(absRef.current);
|
||||||
|
},
|
||||||
|
[loadSession],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Lift subtitle cues off the very bottom edge. The video element fills the viewport height, so a
|
||||||
|
// default (line "auto") cue renders at the extreme bottom and gets clipped — nudge every cue up
|
||||||
|
// to ~88% so it sits just above the control bar. Re-applied on each cue change (hls.js streams
|
||||||
|
// cues in as subtitle segments load, and TextTrack has no per-cue "added" event).
|
||||||
|
useEffect(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
const placeAll = () => {
|
||||||
|
for (const tr of Array.from(video.textTracks)) {
|
||||||
|
for (const c of Array.from(tr.cues || [])) {
|
||||||
|
try {
|
||||||
|
(c as VTTCue).snapToLines = false;
|
||||||
|
(c as VTTCue).line = 88;
|
||||||
|
} catch {
|
||||||
|
/* not a positionable cue */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const bind = (tr: TextTrack) => tr.addEventListener("cuechange", placeAll);
|
||||||
|
for (const tr of Array.from(video.textTracks)) bind(tr);
|
||||||
|
const onAddTrack = (e: TrackEvent) => {
|
||||||
|
if (e.track) {
|
||||||
|
bind(e.track as TextTrack);
|
||||||
|
placeAll();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
video.textTracks.addEventListener("addtrack", onAddTrack as EventListener);
|
||||||
|
return () => {
|
||||||
|
video.textTracks.removeEventListener("addtrack", onAddTrack as EventListener);
|
||||||
|
for (const tr of Array.from(video.textTracks)) tr.removeEventListener("cuechange", placeAll);
|
||||||
|
};
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
// Auto-advance to the next episode when one finishes.
|
// Auto-advance to the next episode when one finishes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
|
|
@ -250,7 +331,8 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
|
||||||
setUiVisible(true);
|
setUiVisible(true);
|
||||||
if (hideTimer.current) window.clearTimeout(hideTimer.current);
|
if (hideTimer.current) window.clearTimeout(hideTimer.current);
|
||||||
hideTimer.current = window.setTimeout(() => {
|
hideTimer.current = window.setTimeout(() => {
|
||||||
if (!videoRef.current?.paused) setUiVisible(false);
|
// Keep the controls up while the audio/subtitle menu is open, else it would vanish mid-choice.
|
||||||
|
if (!videoRef.current?.paused && !menuOpenRef.current) setUiVisible(false);
|
||||||
}, 3000);
|
}, 3000);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -446,6 +528,61 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
{detail && (detail.audio_streams.length > 1 || detail.subtitle_streams.length > 0) && (
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setMenuOpen((o) => !o)}
|
||||||
|
aria-label={t("plex.player.tracks")}
|
||||||
|
className={`p-1 hover:text-accent ${menuOpen ? "text-accent" : ""}`}
|
||||||
|
>
|
||||||
|
<Settings className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
{menuOpen && (
|
||||||
|
<div className="absolute bottom-full right-0 mb-2 w-60 max-h-72 overflow-auto rounded-lg border border-white/15 bg-black/95 p-1.5 text-sm">
|
||||||
|
{detail.audio_streams.length > 1 && (
|
||||||
|
<>
|
||||||
|
<div className="px-2 pt-1 pb-0.5 text-[11px] uppercase tracking-wide text-white/50">
|
||||||
|
{t("plex.player.audio")}
|
||||||
|
</div>
|
||||||
|
{detail.audio_streams.map((a) => (
|
||||||
|
<button
|
||||||
|
key={a.ord}
|
||||||
|
onClick={() => changeAudio(a.ord)}
|
||||||
|
className={`block w-full truncate rounded px-2 py-1 text-left hover:bg-white/10 ${
|
||||||
|
(audioOrd ?? 0) === a.ord ? "text-accent" : "text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{a.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="px-2 pt-2 pb-0.5 text-[11px] uppercase tracking-wide text-white/50">
|
||||||
|
{t("plex.player.subtitles")}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => changeSubtitle(null)}
|
||||||
|
className={`block w-full rounded px-2 py-1 text-left hover:bg-white/10 ${
|
||||||
|
subOrd == null ? "text-accent" : "text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t("plex.player.subOff")}
|
||||||
|
</button>
|
||||||
|
{detail.subtitle_streams.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.ord}
|
||||||
|
onClick={() => changeSubtitle(s.ord)}
|
||||||
|
className={`block w-full truncate rounded px-2 py-1 text-left hover:bg-white/10 ${
|
||||||
|
subOrd === s.ord ? "text-accent" : "text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Ctrl label={t("plex.player.download")} onClick={download}>
|
<Ctrl label={t("plex.player.download")} onClick={download}>
|
||||||
<Download className="w-5 h-5" />
|
<Download className="w-5 h-5" />
|
||||||
</Ctrl>
|
</Ctrl>
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,11 @@
|
||||||
"next": "Nächste Folge",
|
"next": "Nächste Folge",
|
||||||
"mute": "Stumm (m)",
|
"mute": "Stumm (m)",
|
||||||
"fullscreen": "Vollbild (f)",
|
"fullscreen": "Vollbild (f)",
|
||||||
"download": "Originaldatei herunterladen"
|
"download": "Originaldatei herunterladen",
|
||||||
|
"tracks": "Audio & Untertitel",
|
||||||
|
"audio": "Audio",
|
||||||
|
"subtitles": "Untertitel",
|
||||||
|
"subOff": "Aus"
|
||||||
},
|
},
|
||||||
"playable": {
|
"playable": {
|
||||||
"direct": "Spielt direkt im Browser",
|
"direct": "Spielt direkt im Browser",
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,11 @@
|
||||||
"next": "Next episode",
|
"next": "Next episode",
|
||||||
"mute": "Mute (m)",
|
"mute": "Mute (m)",
|
||||||
"fullscreen": "Fullscreen (f)",
|
"fullscreen": "Fullscreen (f)",
|
||||||
"download": "Download original file"
|
"download": "Download original file",
|
||||||
|
"tracks": "Audio & subtitles",
|
||||||
|
"audio": "Audio",
|
||||||
|
"subtitles": "Subtitles",
|
||||||
|
"subOff": "Off"
|
||||||
},
|
},
|
||||||
"playable": {
|
"playable": {
|
||||||
"direct": "Plays directly in the browser",
|
"direct": "Plays directly in the browser",
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,11 @@
|
||||||
"next": "Következő rész",
|
"next": "Következő rész",
|
||||||
"mute": "Némítás (m)",
|
"mute": "Némítás (m)",
|
||||||
"fullscreen": "Teljes képernyő (f)",
|
"fullscreen": "Teljes képernyő (f)",
|
||||||
"download": "Eredeti fájl letöltése"
|
"download": "Eredeti fájl letöltése",
|
||||||
|
"tracks": "Audió és felirat",
|
||||||
|
"audio": "Audió",
|
||||||
|
"subtitles": "Felirat",
|
||||||
|
"subOff": "Ki"
|
||||||
},
|
},
|
||||||
"playable": {
|
"playable": {
|
||||||
"direct": "Közvetlenül játszható a böngészőben",
|
"direct": "Közvetlenül játszható a böngészőben",
|
||||||
|
|
|
||||||
|
|
@ -689,6 +689,8 @@ export interface PlexItemDetail {
|
||||||
art: string;
|
art: string;
|
||||||
cast: string[];
|
cast: string[];
|
||||||
markers: PlexMarker[];
|
markers: PlexMarker[];
|
||||||
|
audio_streams: { ord: number; label: string; language?: string | null; default: boolean }[];
|
||||||
|
subtitle_streams: { ord: number; label: string; language?: string | null }[];
|
||||||
status: string;
|
status: string;
|
||||||
position_seconds: number;
|
position_seconds: number;
|
||||||
show_title?: string | null;
|
show_title?: string | null;
|
||||||
|
|
@ -1039,10 +1041,17 @@ export const api = {
|
||||||
req(`/api/plex/show/${encodeURIComponent(id)}`),
|
req(`/api/plex/show/${encodeURIComponent(id)}`),
|
||||||
plexItem: (id: string): Promise<PlexItemDetail> =>
|
plexItem: (id: string): Promise<PlexItemDetail> =>
|
||||||
req(`/api/plex/item/${encodeURIComponent(id)}`),
|
req(`/api/plex/item/${encodeURIComponent(id)}`),
|
||||||
plexSession: (id: string, start = 0): Promise<PlexPlaySession> =>
|
plexSession: (
|
||||||
req(`/api/plex/stream/${encodeURIComponent(id)}/session?start=${Math.max(0, Math.floor(start))}`, {
|
id: string,
|
||||||
method: "POST",
|
start = 0,
|
||||||
}),
|
audio?: number | null,
|
||||||
|
subtitle?: number | null,
|
||||||
|
): Promise<PlexPlaySession> => {
|
||||||
|
const p = new URLSearchParams({ start: String(Math.max(0, Math.floor(start))) });
|
||||||
|
if (audio != null) p.set("audio", String(audio));
|
||||||
|
if (subtitle != null) p.set("subtitle", String(subtitle));
|
||||||
|
return req(`/api/plex/stream/${encodeURIComponent(id)}/session?${p.toString()}`, { method: "POST" });
|
||||||
|
},
|
||||||
plexProgress: (id: string, position_seconds: number, duration_seconds: number): Promise<unknown> =>
|
plexProgress: (id: string, position_seconds: number, duration_seconds: number): Promise<unknown> =>
|
||||||
req(`/api/plex/item/${encodeURIComponent(id)}/progress`, {
|
req(`/api/plex/item/${encodeURIComponent(id)}/progress`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue