feat(embedded-mpv): subtitles, playback speed, aspect override, retry on error

Native addon (apps/electron-backend/native/src/embedded_mpv.mm)
- Extend SessionSnapshot with subtitleTracks, selectedSubtitleTrackId,
  playbackSpeed, aspectOverride.
- Refactor track parsing into a shared updateTracksFromNode helper that
  filters by mpv "type" so audio and subtitle tracks share the code path.
- Observe sid, speed, video-aspect-override; clear sub state on
  MPV_EVENT_START_FILE.
- Export setSubtitleTrack (handles trackId === -1 as "no" to disable),
  setSpeed (clamped to 0.25–4.0), setAspect (passthrough string for
  video-aspect-override).
- Snapshot output now includes the new fields.

Service (embedded-mpv-native.service.ts) + IPC + preload
- Mirror methods on EmbeddedMpvNativeService with capability detection: each
  method throws a descriptive error if the loaded addon doesn't expose the
  underlying native function (i.e. user is running an older build).
- New IPC channels EMBEDDED_MPV_SET_SUBTITLE_TRACK, _SET_SPEED, _SET_ASPECT
  registered in events file and exposed via preload.
- Extend EmbeddedMpvSupport with a capabilities probe so the renderer can
  hide controls for features the current addon build doesn't ship.

Renderer (embedded-mpv-player.component.{ts,html})
- Three new popovers anchored above their buttons (subtitle / speed /
  aspect), gated by capabilities() and (for subtitles) by track count.
- Subtitle popover includes an Off entry; speed/aspect use fixed presets.
- Error state now surfaces the same overlay as the stalled state, with a
  Retry button that bumps the existing retryNonce signal — covers #8 from
  the audit.
- All session-payload defaults (loading stub, error stub, refresh fallback,
  dispose payload, native createSession default) updated for the new
  required fields.

NOTE: Existing addon binaries do not expose the new methods. Until the
addon is rebuilt (pnpm run serve:backend:embedded-mpv or the release
build script), capabilities will report subtitles/playbackSpeed/
aspectOverride as false and the new buttons will simply not appear.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: f957cd9849e0
This commit is contained in:
4grayandClaude Opus 4.7 committed 2026-05-05 09:53:32 +02:00
1 parent dea1d40c3d
commit 8e3f559033
10 files changed
+819 -26

No files matched your search

+281 -18
View File
@@ -96,6 +96,10 @@ struct SessionSnapshot {
std::string error;
std::vector<AudioTrack> audioTracks;
int64_t selectedAudioTrackId = -1;
std::vector<AudioTrack> subtitleTracks;
int64_t selectedSubtitleTrackId = -1;
double playbackSpeed = 1.0;
std::string aspectOverride = "no";
};
struct Session {
@@ -880,17 +884,31 @@ void updateSelectedAudioTrack(SessionSnapshot& snapshot, int64_t selectedTrackId
}
}
void updateAudioTracksFromNode(SessionSnapshot& snapshot, const mpv_node& node)
void updateSelectedSubtitleTrack(SessionSnapshot& snapshot, int64_t selectedTrackId)
{
snapshot.selectedSubtitleTrackId =
selectedTrackId >= 0 ? selectedTrackId : -1;
for (auto& track : snapshot.subtitleTracks) {
track.selected = track.id == snapshot.selectedSubtitleTrackId;
}
}
void updateTracksFromNode(
SessionSnapshot& snapshot,
const mpv_node& node,
const std::string& typeFilter,
std::vector<AudioTrack>& outTracks,
int64_t& outSelectedId)
{
outTracks.clear();
outSelectedId = -1;
if (node.format != MPV_FORMAT_NODE_ARRAY ||
!node.u.list ||
!node.u.list->values) {
return;
}
std::vector<AudioTrack> audioTracks;
int64_t selectedTrackId = -1;
for (int index = 0; index < node.u.list->num; index += 1) {
const mpv_node& trackNode = node.u.list->values[index];
if (trackNode.format != MPV_FORMAT_NODE_MAP) {
@@ -898,7 +916,7 @@ void updateAudioTracksFromNode(SessionSnapshot& snapshot, const mpv_node& node)
}
const mpv_node* typeNode = getNodeMapValue(trackNode, "type");
if (!typeNode || readNodeString(*typeNode) != "audio") {
if (!typeNode || readNodeString(*typeNode) != typeFilter) {
continue;
}
@@ -908,32 +926,51 @@ void updateAudioTracksFromNode(SessionSnapshot& snapshot, const mpv_node& node)
continue;
}
AudioTrack audioTrack;
audioTrack.id = id;
AudioTrack track;
track.id = id;
if (const mpv_node* titleNode = getNodeMapValue(trackNode, "title")) {
audioTrack.title = readNodeString(*titleNode);
track.title = readNodeString(*titleNode);
}
if (const mpv_node* languageNode = getNodeMapValue(trackNode, "lang")) {
audioTrack.language = readNodeString(*languageNode);
track.language = readNodeString(*languageNode);
}
if (const mpv_node* selectedNode = getNodeMapValue(trackNode, "selected")) {
audioTrack.selected = readNodeFlag(*selectedNode);
track.selected = readNodeFlag(*selectedNode);
}
if (const mpv_node* defaultNode = getNodeMapValue(trackNode, "default")) {
audioTrack.defaultTrack = readNodeFlag(*defaultNode);
track.defaultTrack = readNodeFlag(*defaultNode);
}
if (const mpv_node* forcedNode = getNodeMapValue(trackNode, "forced")) {
audioTrack.forced = readNodeFlag(*forcedNode);
track.forced = readNodeFlag(*forcedNode);
}
if (audioTrack.selected) {
selectedTrackId = audioTrack.id;
if (track.selected) {
outSelectedId = track.id;
}
audioTracks.push_back(audioTrack);
outTracks.push_back(track);
}
}
snapshot.audioTracks = std::move(audioTracks);
snapshot.selectedAudioTrackId = selectedTrackId;
void updateAudioTracksFromNode(SessionSnapshot& snapshot, const mpv_node& node)
{
updateTracksFromNode(
snapshot,
node,
"audio",
snapshot.audioTracks,
snapshot.selectedAudioTrackId
);
}
void updateSubtitleTracksFromNode(SessionSnapshot& snapshot, const mpv_node& node)
{
updateTracksFromNode(
snapshot,
node,
"sub",
snapshot.subtitleTracks,
snapshot.selectedSubtitleTrackId
);
}
void updateSessionError(const std::shared_ptr<Session>& session, const std::string& error)
@@ -969,6 +1006,8 @@ void runEventLoop(const std::shared_ptr<Session>& session)
session->snapshot.error.clear();
session->snapshot.audioTracks.clear();
session->snapshot.selectedAudioTrackId = -1;
session->snapshot.subtitleTracks.clear();
session->snapshot.selectedSubtitleTrackId = -1;
session->loadedPath = false;
break;
case MPV_EVENT_FILE_LOADED:
@@ -1050,9 +1089,15 @@ void runEventLoop(const std::shared_ptr<Session>& session)
if (propertyName == "track-list" &&
property->format == MPV_FORMAT_NODE &&
property->data) {
const auto& trackListNode =
*static_cast<mpv_node*>(property->data);
updateAudioTracksFromNode(
session->snapshot,
*static_cast<mpv_node*>(property->data)
trackListNode
);
updateSubtitleTracksFromNode(
session->snapshot,
trackListNode
);
break;
}
@@ -1071,6 +1116,40 @@ void runEventLoop(const std::shared_ptr<Session>& session)
break;
}
if (propertyName == "sid" &&
property->format == MPV_FORMAT_STRING &&
property->data) {
const auto* sidValue = static_cast<char*>(property->data);
int64_t selectedTrackId = -1;
if (sidValue && parseIntegerString(sidValue, selectedTrackId)) {
updateSelectedSubtitleTrack(
session->snapshot,
selectedTrackId
);
} else {
updateSelectedSubtitleTrack(session->snapshot, -1);
}
break;
}
if (propertyName == "speed" &&
property->format == MPV_FORMAT_DOUBLE &&
property->data) {
session->snapshot.playbackSpeed =
*static_cast<double*>(property->data);
break;
}
if (propertyName == "video-aspect-override" &&
property->format == MPV_FORMAT_STRING &&
property->data) {
const auto* aspectValue =
static_cast<char*>(property->data);
session->snapshot.aspectOverride =
aspectValue ? aspectValue : "no";
break;
}
break;
}
case MPV_EVENT_LOG_MESSAGE: {
@@ -1488,6 +1567,14 @@ Napi::Value CreateSession(const Napi::CallbackInfo& info)
mpv_observe_property(session->handle, 5, "path", MPV_FORMAT_STRING);
mpv_observe_property(session->handle, 6, "track-list", MPV_FORMAT_NODE);
mpv_observe_property(session->handle, 7, "aid", MPV_FORMAT_STRING);
mpv_observe_property(session->handle, 8, "sid", MPV_FORMAT_STRING);
mpv_observe_property(session->handle, 9, "speed", MPV_FORMAT_DOUBLE);
mpv_observe_property(
session->handle,
10,
"video-aspect-override",
MPV_FORMAT_STRING
);
session->running.store(true);
session->eventThread = std::thread(runEventLoop, session);
@@ -1788,6 +1875,135 @@ Napi::Value SetAudioTrack(const Napi::CallbackInfo& info)
return env.Undefined();
}
Napi::Value SetSubtitleTrack(const Napi::CallbackInfo& info)
{
Napi::Env env = info.Env();
if (info.Length() < 2 || !info[0].IsString() || !info[1].IsNumber()) {
throw Napi::TypeError::New(
env,
"Expected session id and subtitle track id."
);
}
const std::string sessionId = info[0].As<Napi::String>().Utf8Value();
const auto session = getSessionOrThrow(sessionId);
int64_t trackId = info[1].As<Napi::Number>().Int64Value();
int result = -1;
if (trackId < 0) {
const char* disabledValue = "no";
std::string disabled = disabledValue;
result = mpv_set_property_async(
session->handle,
nextAsyncRequestId(),
"sid",
MPV_FORMAT_STRING,
const_cast<char**>(&disabledValue)
);
} else {
result = mpv_set_property_async(
session->handle,
nextAsyncRequestId(),
"sid",
MPV_FORMAT_INT64,
&trackId
);
}
if (result < 0) {
throw Napi::Error::New(
env,
std::string("Failed to update subtitle track: ") +
mpv_error_string(result)
);
}
{
std::lock_guard<std::mutex> lock(session->mutex);
updateSelectedSubtitleTrack(session->snapshot, trackId);
}
return env.Undefined();
}
Napi::Value SetSpeed(const Napi::CallbackInfo& info)
{
Napi::Env env = info.Env();
if (info.Length() < 2 || !info[0].IsString() || !info[1].IsNumber()) {
throw Napi::TypeError::New(env, "Expected session id and speed.");
}
const std::string sessionId = info[0].As<Napi::String>().Utf8Value();
const auto session = getSessionOrThrow(sessionId);
double speed = info[1].As<Napi::Number>().DoubleValue();
speed = std::clamp(speed, 0.25, 4.0);
const int result = mpv_set_property_async(
session->handle,
nextAsyncRequestId(),
"speed",
MPV_FORMAT_DOUBLE,
&speed
);
if (result < 0) {
throw Napi::Error::New(
env,
std::string("Failed to update playback speed: ") +
mpv_error_string(result)
);
}
{
std::lock_guard<std::mutex> lock(session->mutex);
session->snapshot.playbackSpeed = speed;
}
return env.Undefined();
}
Napi::Value SetAspect(const Napi::CallbackInfo& info)
{
Napi::Env env = info.Env();
if (info.Length() < 2 || !info[0].IsString() || !info[1].IsString()) {
throw Napi::TypeError::New(
env,
"Expected session id and aspect override."
);
}
const std::string sessionId = info[0].As<Napi::String>().Utf8Value();
const auto session = getSessionOrThrow(sessionId);
std::string aspect = info[1].As<Napi::String>().Utf8Value();
if (aspect.empty()) {
aspect = "no";
}
const char* aspectValue = aspect.c_str();
const int result = mpv_set_property_async(
session->handle,
nextAsyncRequestId(),
"video-aspect-override",
MPV_FORMAT_STRING,
const_cast<char**>(&aspectValue)
);
if (result < 0) {
throw Napi::Error::New(
env,
std::string("Failed to update aspect override: ") +
mpv_error_string(result)
);
}
{
std::lock_guard<std::mutex> lock(session->mutex);
session->snapshot.aspectOverride = aspect;
}
return env.Undefined();
}
Napi::Value GetSessionSnapshot(const Napi::CallbackInfo& info)
{
Napi::Env env = info.Env();
@@ -1857,6 +2073,47 @@ Napi::Value GetSessionSnapshot(const Napi::CallbackInfo& info)
audioTracks.Set(index, trackObject);
}
result.Set("audioTracks", audioTracks);
if (snapshot.selectedSubtitleTrackId >= 0) {
result.Set(
"selectedSubtitleTrackId",
Napi::Number::New(env, snapshot.selectedSubtitleTrackId)
);
} else {
result.Set("selectedSubtitleTrackId", env.Null());
}
auto subtitleTracks =
Napi::Array::New(env, snapshot.subtitleTracks.size());
for (size_t index = 0; index < snapshot.subtitleTracks.size();
index += 1) {
const AudioTrack& track = snapshot.subtitleTracks[index];
auto trackObject = Napi::Object::New(env);
trackObject.Set("id", Napi::Number::New(env, track.id));
if (!track.title.empty()) {
trackObject.Set("title", Napi::String::New(env, track.title));
}
if (!track.language.empty()) {
trackObject.Set("language", Napi::String::New(env, track.language));
}
trackObject.Set("selected", Napi::Boolean::New(env, track.selected));
trackObject.Set(
"defaultTrack",
Napi::Boolean::New(env, track.defaultTrack)
);
trackObject.Set("forced", Napi::Boolean::New(env, track.forced));
subtitleTracks.Set(index, trackObject);
}
result.Set("subtitleTracks", subtitleTracks);
result.Set(
"playbackSpeed",
Napi::Number::New(env, snapshot.playbackSpeed)
);
result.Set(
"aspectOverride",
Napi::String::New(env, snapshot.aspectOverride)
);
if (!snapshot.error.empty()) {
result.Set("error", Napi::String::New(env, snapshot.error));
}
@@ -1898,6 +2155,12 @@ Napi::Object Init(Napi::Env env, Napi::Object exports)
exports.Set("seek", Napi::Function::New(env, Seek));
exports.Set("setVolume", Napi::Function::New(env, SetVolume));
exports.Set("setAudioTrack", Napi::Function::New(env, SetAudioTrack));
exports.Set(
"setSubtitleTrack",
Napi::Function::New(env, SetSubtitleTrack)
);
exports.Set("setSpeed", Napi::Function::New(env, SetSpeed));
exports.Set("setAspect", Napi::Function::New(env, SetAspect));
exports.Set(
"getSessionSnapshot",
Napi::Function::New(env, GetSessionSnapshot)
@@ -393,6 +393,25 @@ const electronApi = {
trackId: number
): Promise<EmbeddedMpvSession | null> =>
ipcRenderer.invoke('EMBEDDED_MPV_SET_AUDIO_TRACK', sessionId, trackId),
setEmbeddedMpvSubtitleTrack: (
sessionId: string,
trackId: number
): Promise<EmbeddedMpvSession | null> =>
ipcRenderer.invoke(
'EMBEDDED_MPV_SET_SUBTITLE_TRACK',
sessionId,
trackId
),
setEmbeddedMpvSpeed: (
sessionId: string,
speed: number
): Promise<EmbeddedMpvSession | null> =>
ipcRenderer.invoke('EMBEDDED_MPV_SET_SPEED', sessionId, speed),
setEmbeddedMpvAspect: (
sessionId: string,
aspect: string
): Promise<EmbeddedMpvSession | null> =>
ipcRenderer.invoke('EMBEDDED_MPV_SET_ASPECT', sessionId, aspect),
disposeEmbeddedMpvSession: (
sessionId: string
): Promise<EmbeddedMpvSession | null> =>
@@ -5,9 +5,12 @@ import {
EMBEDDED_MPV_LOAD_PLAYBACK,
EMBEDDED_MPV_PREPARE,
EMBEDDED_MPV_SEEK,
EMBEDDED_MPV_SET_ASPECT,
EMBEDDED_MPV_SET_AUDIO_TRACK,
EMBEDDED_MPV_SET_BOUNDS,
EMBEDDED_MPV_SET_PAUSED,
EMBEDDED_MPV_SET_SPEED,
EMBEDDED_MPV_SET_SUBTITLE_TRACK,
EMBEDDED_MPV_SET_VOLUME,
EMBEDDED_MPV_SUPPORT,
EmbeddedMpvBounds,
@@ -76,6 +79,24 @@ ipcMain.handle(
getService().setAudioTrack(sessionId, trackId)
);
ipcMain.handle(
EMBEDDED_MPV_SET_SUBTITLE_TRACK,
(_event, sessionId: string, trackId: number) =>
getService().setSubtitleTrack(sessionId, trackId)
);
ipcMain.handle(
EMBEDDED_MPV_SET_SPEED,
(_event, sessionId: string, speed: number) =>
getService().setSpeed(sessionId, speed)
);
ipcMain.handle(
EMBEDDED_MPV_SET_ASPECT,
(_event, sessionId: string, aspect: string) =>
getService().setAspect(sessionId, aspect)
);
ipcMain.handle(EMBEDDED_MPV_DISPOSE_SESSION, (_event, sessionId: string) =>
getService().disposeSession(sessionId)
);
@@ -6,8 +6,10 @@ import App from '../app';
import {
EmbeddedMpvAudioTrack,
EmbeddedMpvBounds,
EmbeddedMpvCapabilities,
EmbeddedMpvSession,
EmbeddedMpvSessionStatus,
EmbeddedMpvSubtitleTrack,
EmbeddedMpvSupport,
EMBEDDED_MPV_SESSION_UPDATE,
ResolvedPortalPlayback,
@@ -21,6 +23,10 @@ interface NativeEmbeddedMpvSessionSnapshot {
streamUrl: string;
audioTracks?: EmbeddedMpvAudioTrack[];
selectedAudioTrackId?: number | null;
subtitleTracks?: EmbeddedMpvSubtitleTrack[];
selectedSubtitleTrackId?: number | null;
playbackSpeed?: number;
aspectOverride?: string;
error?: string;
}
@@ -38,6 +44,9 @@ interface NativeEmbeddedMpvAddon {
seek(sessionId: string, seconds: number): void;
setVolume(sessionId: string, volume: number): void;
setAudioTrack(sessionId: string, trackId: number): void;
setSubtitleTrack?(sessionId: string, trackId: number): void;
setSpeed?(sessionId: string, speed: number): void;
setAspect?(sessionId: string, aspect: string): void;
getSessionSnapshot(
sessionId: string
): NativeEmbeddedMpvSessionSnapshot | null;
@@ -69,6 +78,16 @@ export class EmbeddedMpvNativeService {
private powerBlockerId: number | null = null;
private readonly loadAddonModule = createRequire(__filename);
private detectCapabilities(): EmbeddedMpvCapabilities {
const addon = this.addon;
return {
subtitles: typeof addon?.setSubtitleTrack === 'function',
playbackSpeed: typeof addon?.setSpeed === 'function',
aspectOverride: typeof addon?.setAspect === 'function',
screenshot: false,
};
}
getSupport(): EmbeddedMpvSupport {
if (process.platform !== 'darwin') {
return {
@@ -99,6 +118,7 @@ export class EmbeddedMpvNativeService {
return {
supported: true,
platform: process.platform,
capabilities: this.detectCapabilities(),
};
} catch (error) {
return {
@@ -153,6 +173,7 @@ export class EmbeddedMpvNativeService {
return {
supported: true,
platform: process.platform,
capabilities: this.detectCapabilities(),
};
}
@@ -175,6 +196,7 @@ export class EmbeddedMpvNativeService {
return {
supported: true,
platform: process.platform,
capabilities: this.detectCapabilities(),
};
} catch (error) {
return {
@@ -225,6 +247,10 @@ export class EmbeddedMpvNativeService {
volume: 1,
audioTracks: [],
selectedAudioTrackId: null,
subtitleTracks: [],
selectedSubtitleTrackId: null,
playbackSpeed: 1,
aspectOverride: 'no',
startedAt,
updatedAt: startedAt,
};
@@ -270,6 +296,45 @@ export class EmbeddedMpvNativeService {
return this.refreshSession(sessionId);
}
setSubtitleTrack(
sessionId: string,
trackId: number
): EmbeddedMpvSession | null {
this.assertEmbeddedMpvEnabled();
const addon = this.getAddon();
if (typeof addon.setSubtitleTrack !== 'function') {
throw new Error(
'Embedded MPV addon does not support subtitle tracks. Rebuild the native addon to enable this feature.'
);
}
addon.setSubtitleTrack(sessionId, trackId);
return this.refreshSession(sessionId);
}
setSpeed(sessionId: string, speed: number): EmbeddedMpvSession | null {
this.assertEmbeddedMpvEnabled();
const addon = this.getAddon();
if (typeof addon.setSpeed !== 'function') {
throw new Error(
'Embedded MPV addon does not support playback speed. Rebuild the native addon to enable this feature.'
);
}
addon.setSpeed(sessionId, speed);
return this.refreshSession(sessionId);
}
setAspect(sessionId: string, aspect: string): EmbeddedMpvSession | null {
this.assertEmbeddedMpvEnabled();
const addon = this.getAddon();
if (typeof addon.setAspect !== 'function') {
throw new Error(
'Embedded MPV addon does not support aspect override. Rebuild the native addon to enable this feature.'
);
}
addon.setAspect(sessionId, aspect);
return this.refreshSession(sessionId);
}
disposeSession(sessionId: string): EmbeddedMpvSession | null {
const session = this.sessions.get(sessionId);
if (!session) {
@@ -290,6 +355,10 @@ export class EmbeddedMpvNativeService {
volume: 1,
audioTracks: [],
selectedAudioTrackId: null,
subtitleTracks: [],
selectedSubtitleTrackId: null,
playbackSpeed: 1,
aspectOverride: 'no',
startedAt: session.startedAt,
updatedAt: new Date().toISOString(),
};
@@ -361,6 +430,21 @@ export class EmbeddedMpvNativeService {
typeof snapshot.selectedAudioTrackId === 'number'
? snapshot.selectedAudioTrackId
: null,
subtitleTracks: Array.isArray(snapshot.subtitleTracks)
? snapshot.subtitleTracks
: [],
selectedSubtitleTrackId:
typeof snapshot.selectedSubtitleTrackId === 'number'
? snapshot.selectedSubtitleTrackId
: null,
playbackSpeed:
typeof snapshot.playbackSpeed === 'number'
? snapshot.playbackSpeed
: 1,
aspectOverride:
typeof snapshot.aspectOverride === 'string'
? snapshot.aspectOverride
: 'no',
startedAt: session.startedAt,
updatedAt: new Date().toISOString(),
...(snapshot.error ? { error: snapshot.error } : {}),
+12
View File
@@ -369,6 +369,18 @@ declare global {
sessionId: string,
trackId: number
) => Promise<EmbeddedMpvSession | null>;
setEmbeddedMpvSubtitleTrack?: (
sessionId: string,
trackId: number
) => Promise<EmbeddedMpvSession | null>;
setEmbeddedMpvSpeed?: (
sessionId: string,
speed: number
) => Promise<EmbeddedMpvSession | null>;
setEmbeddedMpvAspect?: (
sessionId: string,
aspect: string
) => Promise<EmbeddedMpvSession | null>;
disposeEmbeddedMpvSession: (
sessionId: string
) => Promise<EmbeddedMpvSession | null>;
Vendored
+12
View File
@@ -155,6 +155,18 @@ declare global {
sessionId: string,
trackId: number
) => Promise<EmbeddedMpvSession | null>;
setEmbeddedMpvSubtitleTrack?: (
sessionId: string,
trackId: number
) => Promise<EmbeddedMpvSession | null>;
setEmbeddedMpvSpeed?: (
sessionId: string,
speed: number
) => Promise<EmbeddedMpvSession | null>;
setEmbeddedMpvAspect?: (
sessionId: string,
aspect: string
) => Promise<EmbeddedMpvSession | null>;
disposeEmbeddedMpvSession: (
sessionId: string
) => Promise<EmbeddedMpvSession | null>;
@@ -13,10 +13,18 @@ export interface EmbeddedMpvBounds {
height: number;
}
export interface EmbeddedMpvCapabilities {
subtitles: boolean;
playbackSpeed: boolean;
aspectOverride: boolean;
screenshot: boolean;
}
export interface EmbeddedMpvSupport {
supported: boolean;
platform: string;
reason?: string;
capabilities?: EmbeddedMpvCapabilities;
}
export interface EmbeddedMpvAudioTrack {
@@ -28,6 +36,8 @@ export interface EmbeddedMpvAudioTrack {
forced?: boolean;
}
export type EmbeddedMpvSubtitleTrack = EmbeddedMpvAudioTrack;
export interface EmbeddedMpvSession {
id: string;
title: string;
@@ -38,6 +48,10 @@ export interface EmbeddedMpvSession {
volume: number;
audioTracks: EmbeddedMpvAudioTrack[];
selectedAudioTrackId: number | null;
subtitleTracks: EmbeddedMpvSubtitleTrack[];
selectedSubtitleTrackId: number | null;
playbackSpeed: number;
aspectOverride: string;
startedAt: string;
updatedAt: string;
error?: string;
@@ -46,6 +46,10 @@ export const EMBEDDED_MPV_SET_PAUSED = 'EMBEDDED_MPV_SET_PAUSED';
export const EMBEDDED_MPV_SEEK = 'EMBEDDED_MPV_SEEK';
export const EMBEDDED_MPV_SET_VOLUME = 'EMBEDDED_MPV_SET_VOLUME';
export const EMBEDDED_MPV_SET_AUDIO_TRACK = 'EMBEDDED_MPV_SET_AUDIO_TRACK';
export const EMBEDDED_MPV_SET_SUBTITLE_TRACK =
'EMBEDDED_MPV_SET_SUBTITLE_TRACK';
export const EMBEDDED_MPV_SET_SPEED = 'EMBEDDED_MPV_SET_SPEED';
export const EMBEDDED_MPV_SET_ASPECT = 'EMBEDDED_MPV_SET_ASPECT';
export const EMBEDDED_MPV_DISPOSE_SESSION = 'EMBEDDED_MPV_DISPOSE_SESSION';
export const EMBEDDED_MPV_SESSION_UPDATE = 'EMBEDDED_MPV_SESSION_UPDATE';
export const EMBEDDED_MPV_PREPARE = 'EMBEDDED_MPV_PREPARE';
@@ -14,7 +14,20 @@
(focusin)="onPlayerInteraction()"
>
<div #viewport class="embedded-mpv-player__viewport">
@if (isLoading() && !stalled()) {
@if (isErrored()) {
<div
class="embedded-mpv-player__stalled"
role="alert"
aria-live="assertive"
>
<mat-icon>error_outline</mat-icon>
<p>{{ statusLabel() }}</p>
<button mat-stroked-button type="button" (click)="retry()">
<mat-icon>refresh</mat-icon>
<span>Retry</span>
</button>
</div>
} @else if (isLoading() && !stalled()) {
<div
class="embedded-mpv-player__loader"
role="status"
@@ -241,6 +254,196 @@
</div>
}
@if (capabilities().subtitles && hasSubtitleTracks()) {
<div class="embedded-mpv-player__popover-anchor">
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
(click)="toggleSubtitleMenu()"
[attr.aria-expanded]="subtitleMenuOpen()"
aria-label="Subtitles"
matTooltip="Subtitles"
matTooltipPosition="above"
>
<mat-icon>{{
selectedSubtitleTrackId() === null
? 'subtitles_off'
: 'subtitles'
}}</mat-icon>
</button>
@if (subtitleMenuOpen()) {
<div
class="embedded-mpv-player__control-panel embedded-mpv-player__audio-popover"
role="menu"
aria-label="Subtitles"
>
<div class="embedded-mpv-player__menu-title">
Subtitles
</div>
<div class="embedded-mpv-player__audio-track-list">
<button
type="button"
role="menuitemradio"
[attr.aria-checked]="
selectedSubtitleTrackId() === null
"
class="embedded-mpv-player__audio-track"
[class.embedded-mpv-player__audio-track--selected]="
selectedSubtitleTrackId() === null
"
(click)="selectSubtitleTrack(-1)"
>
<span>Off</span>
@if (
selectedSubtitleTrackId() === null
) {
<mat-icon>check</mat-icon>
}
</button>
@for (
track of subtitleTracks();
track track.id;
let index = $index
) {
<button
type="button"
role="menuitemradio"
[attr.aria-checked]="track.selected"
class="embedded-mpv-player__audio-track"
[class.embedded-mpv-player__audio-track--selected]="
track.selected
"
(click)="
selectSubtitleTrack(track.id)
"
>
<span>{{
subtitleLabel(track, index)
}}</span>
@if (track.selected) {
<mat-icon>check</mat-icon>
}
</button>
}
</div>
</div>
}
</div>
}
@if (capabilities().playbackSpeed) {
<div class="embedded-mpv-player__popover-anchor">
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
(click)="toggleSpeedMenu()"
[attr.aria-expanded]="speedMenuOpen()"
[attr.aria-label]="
'Playback speed ' + speedLabel(playbackSpeed())
"
[matTooltip]="
'Speed: ' + speedLabel(playbackSpeed())
"
matTooltipPosition="above"
>
<mat-icon>speed</mat-icon>
</button>
@if (speedMenuOpen()) {
<div
class="embedded-mpv-player__control-panel embedded-mpv-player__audio-popover"
role="menu"
aria-label="Playback speed"
>
<div class="embedded-mpv-player__menu-title">
Speed
</div>
<div class="embedded-mpv-player__audio-track-list">
@for (preset of speedPresets; track preset.value) {
<button
type="button"
role="menuitemradio"
[attr.aria-checked]="
preset.value === playbackSpeed()
"
class="embedded-mpv-player__audio-track"
[class.embedded-mpv-player__audio-track--selected]="
preset.value === playbackSpeed()
"
(click)="selectSpeed(preset.value)"
>
<span>{{ preset.label }}</span>
@if (
preset.value === playbackSpeed()
) {
<mat-icon>check</mat-icon>
}
</button>
}
</div>
</div>
}
</div>
}
@if (capabilities().aspectOverride) {
<div class="embedded-mpv-player__popover-anchor">
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
(click)="toggleAspectMenu()"
[attr.aria-expanded]="aspectMenuOpen()"
[attr.aria-label]="
'Aspect ratio ' + aspectLabel(aspectOverride())
"
[matTooltip]="
'Aspect: ' + aspectLabel(aspectOverride())
"
matTooltipPosition="above"
>
<mat-icon>aspect_ratio</mat-icon>
</button>
@if (aspectMenuOpen()) {
<div
class="embedded-mpv-player__control-panel embedded-mpv-player__audio-popover"
role="menu"
aria-label="Aspect ratio"
>
<div class="embedded-mpv-player__menu-title">
Aspect
</div>
<div class="embedded-mpv-player__audio-track-list">
@for (preset of aspectPresets; track preset.value) {
<button
type="button"
role="menuitemradio"
[attr.aria-checked]="
preset.value === aspectOverride()
"
class="embedded-mpv-player__audio-track"
[class.embedded-mpv-player__audio-track--selected]="
preset.value ===
aspectOverride()
"
(click)="selectAspect(preset.value)"
>
<span>{{ preset.label }}</span>
@if (
preset.value ===
aspectOverride()
) {
<mat-icon>check</mat-icon>
}
</button>
}
</div>
</div>
}
</div>
}
<button
mat-icon-button
type="button"
@@ -71,6 +71,9 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
readonly controlsVisible = signal(true);
readonly volumePopoverOpen = signal(false);
readonly audioMenuOpen = signal(false);
readonly subtitleMenuOpen = signal(false);
readonly speedMenuOpen = signal(false);
readonly aspectMenuOpen = signal(false);
readonly stalled = signal(false);
readonly feedbackOverlay = signal<{
icon: string;
@@ -128,6 +131,50 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
readonly audioTracks = computed(
() => this.session()?.audioTracks ?? []
);
readonly subtitleTracks = computed(
() => this.session()?.subtitleTracks ?? []
);
readonly hasSubtitleTracks = computed(
() => this.subtitleTracks().length > 0
);
readonly selectedSubtitleTrackId = computed(
() => this.session()?.selectedSubtitleTrackId ?? null
);
readonly playbackSpeed = computed(
() => this.session()?.playbackSpeed ?? 1
);
readonly aspectOverride = computed(
() => this.session()?.aspectOverride ?? 'no'
);
readonly capabilities = computed(
() =>
this.support()?.capabilities ?? {
subtitles: false,
playbackSpeed: false,
aspectOverride: false,
screenshot: false,
}
);
readonly isErrored = computed(
() => this.session()?.status === 'error'
);
readonly speedPresets: ReadonlyArray<{ value: number; label: string }> = [
{ value: 0.5, label: '0.5×' },
{ value: 0.75, label: '0.75×' },
{ value: 1, label: '1×' },
{ value: 1.25, label: '1.25×' },
{ value: 1.5, label: '1.5×' },
{ value: 2, label: '2×' },
];
readonly aspectPresets: ReadonlyArray<{ value: string; label: string }> = [
{ value: 'no', label: 'Default' },
{ value: '16:9', label: '16:9' },
{ value: '4:3', label: '4:3' },
{ value: '21:9', label: '21:9' },
{ value: '2.35:1', label: '2.35:1' },
];
readonly hasAudioTracks = computed(() => this.audioTracks().length > 1);
readonly selectedAudioTrack = computed(
() => this.audioTracks().find((track) => track.selected) ?? null
@@ -141,6 +188,9 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
this.isPaused() ||
this.volumePopoverOpen() ||
this.audioMenuOpen() ||
this.subtitleMenuOpen() ||
this.speedMenuOpen() ||
this.aspectMenuOpen() ||
Boolean(this.statusLabel()))
);
readonly hideCursor = computed(
@@ -403,6 +453,10 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
volume: initialVolume,
audioTracks: [],
selectedAudioTrackId: null,
subtitleTracks: [],
selectedSubtitleTrackId: null,
playbackSpeed: 1,
aspectOverride: 'no',
startedAt,
updatedAt: startedAt,
error:
@@ -586,13 +640,41 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
}
toggleAudioMenu(): void {
this.audioMenuOpen.update((open) => !open);
if (this.audioMenuOpen()) {
this.volumePopoverOpen.set(false);
}
const next = !this.audioMenuOpen();
this.closeAllMenus();
this.audioMenuOpen.set(next);
this.revealControls();
}
toggleSubtitleMenu(): void {
const next = !this.subtitleMenuOpen();
this.closeAllMenus();
this.subtitleMenuOpen.set(next);
this.revealControls();
}
toggleSpeedMenu(): void {
const next = !this.speedMenuOpen();
this.closeAllMenus();
this.speedMenuOpen.set(next);
this.revealControls();
}
toggleAspectMenu(): void {
const next = !this.aspectMenuOpen();
this.closeAllMenus();
this.aspectMenuOpen.set(next);
this.revealControls();
}
private closeAllMenus(): void {
this.audioMenuOpen.set(false);
this.subtitleMenuOpen.set(false);
this.speedMenuOpen.set(false);
this.aspectMenuOpen.set(false);
this.volumePopoverOpen.set(false);
}
onVolumeInput(event: Event): void {
const nextVolume = Number((event.target as HTMLInputElement).value);
this.applyVolume(nextVolume);
@@ -742,6 +824,76 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
this.scheduleControlsHide();
}
async selectSubtitleTrack(trackId: number): Promise<void> {
const session = this.session();
if (!session?.id || !window.electron?.setEmbeddedMpvSubtitleTrack) {
return;
}
this.revealControls(false);
const updatedSession =
await window.electron.setEmbeddedMpvSubtitleTrack(
session.id,
trackId
);
if (updatedSession) {
this.session.set(updatedSession);
}
this.subtitleMenuOpen.set(false);
this.scheduleControlsHide();
}
async selectSpeed(speed: number): Promise<void> {
const session = this.session();
if (!session?.id || !window.electron?.setEmbeddedMpvSpeed) {
return;
}
this.revealControls(false);
const updatedSession = await window.electron.setEmbeddedMpvSpeed(
session.id,
speed
);
if (updatedSession) {
this.session.set(updatedSession);
}
this.speedMenuOpen.set(false);
this.scheduleControlsHide();
}
async selectAspect(aspect: string): Promise<void> {
const session = this.session();
if (!session?.id || !window.electron?.setEmbeddedMpvAspect) {
return;
}
this.revealControls(false);
const updatedSession = await window.electron.setEmbeddedMpvAspect(
session.id,
aspect
);
if (updatedSession) {
this.session.set(updatedSession);
}
this.aspectMenuOpen.set(false);
this.scheduleControlsHide();
}
speedLabel(speed: number): string {
const value = Math.round(speed * 100) / 100;
return Number.isInteger(value) ? `${value}×` : `${value}×`;
}
aspectLabel(aspect: string): string {
const preset = this.aspectPresets.find((p) => p.value === aspect);
return preset?.label ?? aspect;
}
subtitleLabel(track: EmbeddedMpvAudioTrack, index: number): string {
const label = track.title || track.language || `Subtitle ${index + 1}`;
return track.defaultTrack ? `${label} · Default` : label;
}
formatTime(value: number | null | undefined): string {
const safeValue = Math.max(0, Math.floor(value ?? 0));
const hours = Math.floor(safeValue / 3600);
@@ -761,12 +913,17 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
}
private closePopovers(): void {
if (!this.volumePopoverOpen() && !this.audioMenuOpen()) {
if (
!this.volumePopoverOpen() &&
!this.audioMenuOpen() &&
!this.subtitleMenuOpen() &&
!this.speedMenuOpen() &&
!this.aspectMenuOpen()
) {
return;
}
this.volumePopoverOpen.set(false);
this.audioMenuOpen.set(false);
this.closeAllMenus();
this.activeBoundsSync?.();
this.scheduleControlsHide();
}
@@ -860,6 +1017,10 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
volume,
audioTracks: [],
selectedAudioTrackId: null,
subtitleTracks: [],
selectedSubtitleTrackId: null,
playbackSpeed: 1,
aspectOverride: 'no',
startedAt: now,
updatedAt: now,
};