mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
1 parent
dea1d40c3d
commit
8e3f559033
10 files changed
+819
-26
No files matched your search
@@ -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 } : {}),
|
||||
|
||||
Vendored
+12
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user