mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 03:56:15 -08:00
feat(playback): add embedded mpv (macos) stream recording (#916)
* feat(playback): add embedded mpv stream recording Entire-Checkpoint: f957cd9849e0 * fix(playback): address embedded mpv recording review Entire-Checkpoint: f957cd9849e0 * fix(playback): track mpv recording auto-stop replies Entire-Checkpoint: f957cd9849e0
This commit is contained in:
1 parent
935ed1b1a9
commit
724e4b1ab3
34 files changed
+1413
-126
No files matched your search
@@ -21,6 +21,7 @@
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <ctime>
|
||||
#include <dlfcn.h>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
@@ -100,6 +101,10 @@ struct SessionSnapshot {
|
||||
int64_t selectedSubtitleTrackId = -1;
|
||||
double playbackSpeed = 1.0;
|
||||
std::string aspectOverride = "no";
|
||||
bool recordingActive = false;
|
||||
std::string recordingTargetPath;
|
||||
std::string recordingStartedAt;
|
||||
std::string recordingError;
|
||||
};
|
||||
|
||||
struct Session {
|
||||
@@ -122,6 +127,11 @@ struct Session {
|
||||
std::atomic<uint64_t> totalRenderNanoseconds{0};
|
||||
std::mutex mutex;
|
||||
SessionSnapshot snapshot;
|
||||
uint64_t pendingRecordingStartRequestId = 0;
|
||||
uint64_t pendingRecordingStopRequestId = 0;
|
||||
std::string pendingRecordingTargetPath;
|
||||
std::string pendingRecordingStartedAt;
|
||||
std::string pendingRecordingStopStartedAt;
|
||||
std::weak_ptr<Session>* renderCallbackContext = nullptr;
|
||||
RenderBackend renderBackend = RenderBackend::OpenGL;
|
||||
int renderWidthPixels = 0;
|
||||
@@ -398,6 +408,23 @@ void destroySession(const std::shared_ptr<Session>& session)
|
||||
}
|
||||
|
||||
if (session->handle) {
|
||||
bool recordingActive = false;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(session->mutex);
|
||||
recordingActive = session->snapshot.recordingActive;
|
||||
}
|
||||
if (recordingActive) {
|
||||
const char* disabledValue = "";
|
||||
mpv_set_property(
|
||||
session->handle,
|
||||
"stream-record",
|
||||
MPV_FORMAT_STRING,
|
||||
const_cast<char**>(&disabledValue)
|
||||
);
|
||||
std::lock_guard<std::mutex> lock(session->mutex);
|
||||
session->snapshot.recordingActive = false;
|
||||
session->snapshot.recordingStartedAt.clear();
|
||||
}
|
||||
mpv_wakeup(session->handle);
|
||||
}
|
||||
|
||||
@@ -994,6 +1021,73 @@ uint64_t nextAsyncRequestId()
|
||||
return gNextAsyncRequestId.fetch_add(1);
|
||||
}
|
||||
|
||||
std::string currentUtcTimestamp()
|
||||
{
|
||||
std::time_t now = std::time(nullptr);
|
||||
std::tm timeInfo{};
|
||||
#if defined(_WIN32)
|
||||
gmtime_s(&timeInfo, &now);
|
||||
#else
|
||||
gmtime_r(&now, &timeInfo);
|
||||
#endif
|
||||
char buffer[32]{};
|
||||
std::strftime(buffer, sizeof(buffer), "%Y-%m-%dT%H:%M:%SZ", &timeInfo);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
bool reconcileRecordingPropertyReply(
|
||||
const std::shared_ptr<Session>& session,
|
||||
uint64_t requestId,
|
||||
int error
|
||||
)
|
||||
{
|
||||
if (requestId == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (requestId == session->pendingRecordingStartRequestId) {
|
||||
session->pendingRecordingStartRequestId = 0;
|
||||
const std::string targetPath = session->pendingRecordingTargetPath;
|
||||
const std::string startedAt = session->pendingRecordingStartedAt;
|
||||
session->pendingRecordingTargetPath.clear();
|
||||
session->pendingRecordingStartedAt.clear();
|
||||
|
||||
if (error < 0) {
|
||||
session->snapshot.recordingActive = false;
|
||||
session->snapshot.recordingTargetPath = targetPath;
|
||||
session->snapshot.recordingStartedAt.clear();
|
||||
session->snapshot.recordingError = mpv_error_string(error);
|
||||
return true;
|
||||
}
|
||||
|
||||
session->snapshot.recordingActive = true;
|
||||
session->snapshot.recordingTargetPath = targetPath;
|
||||
session->snapshot.recordingStartedAt = startedAt;
|
||||
session->snapshot.recordingError.clear();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (requestId == session->pendingRecordingStopRequestId) {
|
||||
session->pendingRecordingStopRequestId = 0;
|
||||
const std::string startedAt = session->pendingRecordingStopStartedAt;
|
||||
session->pendingRecordingStopStartedAt.clear();
|
||||
|
||||
if (error < 0) {
|
||||
session->snapshot.recordingActive = true;
|
||||
session->snapshot.recordingStartedAt = startedAt;
|
||||
session->snapshot.recordingError = mpv_error_string(error);
|
||||
return true;
|
||||
}
|
||||
|
||||
session->snapshot.recordingActive = false;
|
||||
session->snapshot.recordingStartedAt.clear();
|
||||
session->snapshot.recordingError.clear();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
double clampVolumePercent(double volume)
|
||||
{
|
||||
return std::clamp(volume, 0.0, 1.0) * 100.0;
|
||||
@@ -1191,6 +1285,13 @@ void runEventLoop(const std::shared_ptr<Session>& session)
|
||||
}
|
||||
case MPV_EVENT_COMMAND_REPLY:
|
||||
case MPV_EVENT_SET_PROPERTY_REPLY:
|
||||
if (reconcileRecordingPropertyReply(
|
||||
session,
|
||||
event->reply_userdata,
|
||||
event->error
|
||||
)) {
|
||||
break;
|
||||
}
|
||||
if (event->error < 0) {
|
||||
session->snapshot.status = SessionStatus::Error;
|
||||
session->snapshot.error = mpv_error_string(event->error);
|
||||
@@ -1623,6 +1724,48 @@ Napi::Value LoadPlayback(const Napi::CallbackInfo& info)
|
||||
const std::string referer = readOptionalString(playback, "referer");
|
||||
const double startTime = readOptionalNumber(playback, "startTime", -1);
|
||||
|
||||
bool recordingActive = false;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(session->mutex);
|
||||
recordingActive = session->snapshot.recordingActive;
|
||||
}
|
||||
if (recordingActive) {
|
||||
const char* disabledValue = "";
|
||||
const uint64_t stopRecordingRequestId = nextAsyncRequestId();
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(session->mutex);
|
||||
session->pendingRecordingStopRequestId = stopRecordingRequestId;
|
||||
session->pendingRecordingStopStartedAt =
|
||||
session->snapshot.recordingStartedAt;
|
||||
}
|
||||
|
||||
const int stopResult = mpv_set_property_async(
|
||||
session->handle,
|
||||
stopRecordingRequestId,
|
||||
"stream-record",
|
||||
MPV_FORMAT_STRING,
|
||||
const_cast<char**>(&disabledValue)
|
||||
);
|
||||
if (stopResult < 0) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(session->mutex);
|
||||
if (
|
||||
session->pendingRecordingStopRequestId ==
|
||||
stopRecordingRequestId
|
||||
) {
|
||||
session->pendingRecordingStopRequestId = 0;
|
||||
session->pendingRecordingStopStartedAt.clear();
|
||||
}
|
||||
}
|
||||
updateSessionError(session, mpv_error_string(stopResult));
|
||||
throw Napi::Error::New(
|
||||
env,
|
||||
std::string("Failed to stop recording before loading playback: ") +
|
||||
mpv_error_string(stopResult)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<std::pair<std::string, std::string>> options;
|
||||
if (!title.empty()) {
|
||||
options.emplace_back("force-media-title", title);
|
||||
@@ -1708,6 +1851,10 @@ Napi::Value LoadPlayback(const Napi::CallbackInfo& info)
|
||||
session->snapshot.streamUrl = streamUrl;
|
||||
session->snapshot.error.clear();
|
||||
session->snapshot.status = SessionStatus::Loading;
|
||||
session->snapshot.recordingActive = false;
|
||||
session->snapshot.recordingTargetPath.clear();
|
||||
session->snapshot.recordingStartedAt.clear();
|
||||
session->snapshot.recordingError.clear();
|
||||
}
|
||||
|
||||
return env.Undefined();
|
||||
@@ -2013,6 +2160,119 @@ Napi::Value SetAspect(const Napi::CallbackInfo& info)
|
||||
return env.Undefined();
|
||||
}
|
||||
|
||||
Napi::Value StartRecording(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 recording target path."
|
||||
);
|
||||
}
|
||||
|
||||
const std::string sessionId = info[0].As<Napi::String>().Utf8Value();
|
||||
const std::string targetPath = info[1].As<Napi::String>().Utf8Value();
|
||||
if (targetPath.empty()) {
|
||||
throw Napi::Error::New(env, "Recording target path is required.");
|
||||
}
|
||||
|
||||
const auto session = getSessionOrThrow(env, sessionId);
|
||||
const char* targetValue = targetPath.c_str();
|
||||
const uint64_t requestId = nextAsyncRequestId();
|
||||
const std::string startedAt = currentUtcTimestamp();
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(session->mutex);
|
||||
session->pendingRecordingStartRequestId = requestId;
|
||||
session->pendingRecordingTargetPath = targetPath;
|
||||
session->pendingRecordingStartedAt = startedAt;
|
||||
session->snapshot.recordingActive = true;
|
||||
session->snapshot.recordingTargetPath = targetPath;
|
||||
session->snapshot.recordingStartedAt = startedAt;
|
||||
session->snapshot.recordingError.clear();
|
||||
}
|
||||
|
||||
const int result = mpv_set_property_async(
|
||||
session->handle,
|
||||
requestId,
|
||||
"stream-record",
|
||||
MPV_FORMAT_STRING,
|
||||
const_cast<char**>(&targetValue)
|
||||
);
|
||||
|
||||
if (result < 0) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(session->mutex);
|
||||
if (session->pendingRecordingStartRequestId == requestId) {
|
||||
session->pendingRecordingStartRequestId = 0;
|
||||
session->pendingRecordingTargetPath.clear();
|
||||
session->pendingRecordingStartedAt.clear();
|
||||
}
|
||||
session->snapshot.recordingActive = false;
|
||||
session->snapshot.recordingStartedAt.clear();
|
||||
session->snapshot.recordingError = mpv_error_string(result);
|
||||
}
|
||||
throw Napi::Error::New(
|
||||
env,
|
||||
std::string("Failed to start stream recording: ") +
|
||||
mpv_error_string(result)
|
||||
);
|
||||
}
|
||||
|
||||
return env.Undefined();
|
||||
}
|
||||
|
||||
Napi::Value StopRecording(const Napi::CallbackInfo& info)
|
||||
{
|
||||
Napi::Env env = info.Env();
|
||||
if (info.Length() < 1 || !info[0].IsString()) {
|
||||
throw Napi::TypeError::New(env, "Expected session id.");
|
||||
}
|
||||
|
||||
const std::string sessionId = info[0].As<Napi::String>().Utf8Value();
|
||||
const auto session = getSessionOrThrow(env, sessionId);
|
||||
const char* disabledValue = "";
|
||||
const uint64_t requestId = nextAsyncRequestId();
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(session->mutex);
|
||||
session->pendingRecordingStopRequestId = requestId;
|
||||
session->pendingRecordingStopStartedAt =
|
||||
session->snapshot.recordingStartedAt;
|
||||
session->snapshot.recordingActive = false;
|
||||
session->snapshot.recordingStartedAt.clear();
|
||||
session->snapshot.recordingError.clear();
|
||||
}
|
||||
|
||||
const int result = mpv_set_property_async(
|
||||
session->handle,
|
||||
requestId,
|
||||
"stream-record",
|
||||
MPV_FORMAT_STRING,
|
||||
const_cast<char**>(&disabledValue)
|
||||
);
|
||||
|
||||
if (result < 0) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(session->mutex);
|
||||
const std::string startedAt =
|
||||
session->pendingRecordingStopStartedAt;
|
||||
if (session->pendingRecordingStopRequestId == requestId) {
|
||||
session->pendingRecordingStopRequestId = 0;
|
||||
session->pendingRecordingStopStartedAt.clear();
|
||||
}
|
||||
session->snapshot.recordingActive = true;
|
||||
session->snapshot.recordingStartedAt = startedAt;
|
||||
session->snapshot.recordingError = mpv_error_string(result);
|
||||
}
|
||||
throw Napi::Error::New(
|
||||
env,
|
||||
std::string("Failed to stop stream recording: ") +
|
||||
mpv_error_string(result)
|
||||
);
|
||||
}
|
||||
|
||||
return env.Undefined();
|
||||
}
|
||||
|
||||
Napi::Value GetSessionSnapshot(const Napi::CallbackInfo& info)
|
||||
{
|
||||
Napi::Env env = info.Env();
|
||||
@@ -2122,6 +2382,34 @@ Napi::Value GetSessionSnapshot(const Napi::CallbackInfo& info)
|
||||
"aspectOverride",
|
||||
Napi::String::New(env, snapshot.aspectOverride)
|
||||
);
|
||||
if (snapshot.recordingActive ||
|
||||
!snapshot.recordingTargetPath.empty() ||
|
||||
!snapshot.recordingError.empty()) {
|
||||
auto recording = Napi::Object::New(env);
|
||||
recording.Set(
|
||||
"active",
|
||||
Napi::Boolean::New(env, snapshot.recordingActive)
|
||||
);
|
||||
if (!snapshot.recordingTargetPath.empty()) {
|
||||
recording.Set(
|
||||
"targetPath",
|
||||
Napi::String::New(env, snapshot.recordingTargetPath)
|
||||
);
|
||||
}
|
||||
if (!snapshot.recordingStartedAt.empty()) {
|
||||
recording.Set(
|
||||
"startedAt",
|
||||
Napi::String::New(env, snapshot.recordingStartedAt)
|
||||
);
|
||||
}
|
||||
if (!snapshot.recordingError.empty()) {
|
||||
recording.Set(
|
||||
"error",
|
||||
Napi::String::New(env, snapshot.recordingError)
|
||||
);
|
||||
}
|
||||
result.Set("recording", recording);
|
||||
}
|
||||
|
||||
if (!snapshot.error.empty()) {
|
||||
result.Set("error", Napi::String::New(env, snapshot.error));
|
||||
@@ -2170,6 +2458,8 @@ Napi::Object Init(Napi::Env env, Napi::Object exports)
|
||||
);
|
||||
exports.Set("setSpeed", Napi::Function::New(env, SetSpeed));
|
||||
exports.Set("setAspect", Napi::Function::New(env, SetAspect));
|
||||
exports.Set("startRecording", Napi::Function::New(env, StartRecording));
|
||||
exports.Set("stopRecording", Napi::Function::New(env, StopRecording));
|
||||
exports.Set(
|
||||
"getSessionSnapshot",
|
||||
Napi::Function::New(env, GetSessionSnapshot)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { contextBridge, ipcRenderer, webUtils } from 'electron';
|
||||
import type {
|
||||
EmbeddedMpvBounds,
|
||||
EmbeddedMpvRecordingStartOptions,
|
||||
EmbeddedMpvSession,
|
||||
EmbeddedMpvSupport,
|
||||
ExternalPlayerSession,
|
||||
@@ -412,6 +413,19 @@ const electronApi = {
|
||||
aspect: string
|
||||
): Promise<EmbeddedMpvSession | null> =>
|
||||
ipcRenderer.invoke('EMBEDDED_MPV_SET_ASPECT', sessionId, aspect),
|
||||
startEmbeddedMpvRecording: (
|
||||
sessionId: string,
|
||||
options: EmbeddedMpvRecordingStartOptions
|
||||
): Promise<EmbeddedMpvSession | null> =>
|
||||
ipcRenderer.invoke('EMBEDDED_MPV_START_RECORDING', sessionId, options),
|
||||
stopEmbeddedMpvRecording: (
|
||||
sessionId: string
|
||||
): Promise<EmbeddedMpvSession | null> =>
|
||||
ipcRenderer.invoke('EMBEDDED_MPV_STOP_RECORDING', sessionId),
|
||||
getEmbeddedMpvDefaultRecordingFolder: (): Promise<string> =>
|
||||
ipcRenderer.invoke('EMBEDDED_MPV_GET_DEFAULT_RECORDING_FOLDER'),
|
||||
selectEmbeddedMpvRecordingFolder: (): Promise<string | null> =>
|
||||
ipcRenderer.invoke('EMBEDDED_MPV_SELECT_RECORDING_FOLDER'),
|
||||
disposeEmbeddedMpvSession: (
|
||||
sessionId: string
|
||||
): Promise<EmbeddedMpvSession | null> =>
|
||||
@@ -640,10 +654,7 @@ const electronApi = {
|
||||
playlistId,
|
||||
contentType
|
||||
),
|
||||
dbSetContentBackdropIfMissing: (
|
||||
contentId: number,
|
||||
backdropUrl?: string
|
||||
) =>
|
||||
dbSetContentBackdropIfMissing: (contentId: number, backdropUrl?: string) =>
|
||||
ipcRenderer.invoke(
|
||||
'DB_SET_CONTENT_BACKDROP_IF_MISSING',
|
||||
contentId,
|
||||
|
||||
@@ -12,8 +12,13 @@ import {
|
||||
EMBEDDED_MPV_SET_SPEED,
|
||||
EMBEDDED_MPV_SET_SUBTITLE_TRACK,
|
||||
EMBEDDED_MPV_SET_VOLUME,
|
||||
EMBEDDED_MPV_GET_DEFAULT_RECORDING_FOLDER,
|
||||
EMBEDDED_MPV_SELECT_RECORDING_FOLDER,
|
||||
EMBEDDED_MPV_START_RECORDING,
|
||||
EMBEDDED_MPV_STOP_RECORDING,
|
||||
EMBEDDED_MPV_SUPPORT,
|
||||
EmbeddedMpvBounds,
|
||||
EmbeddedMpvRecordingStartOptions,
|
||||
ResolvedPortalPlayback,
|
||||
} from 'shared-interfaces';
|
||||
import {
|
||||
@@ -63,8 +68,10 @@ ipcMain.handle(
|
||||
getService().setPaused(sessionId, paused)
|
||||
);
|
||||
|
||||
ipcMain.handle(EMBEDDED_MPV_SEEK, (_event, sessionId: string, seconds: number) =>
|
||||
getService().seek(sessionId, seconds)
|
||||
ipcMain.handle(
|
||||
EMBEDDED_MPV_SEEK,
|
||||
(_event, sessionId: string, seconds: number) =>
|
||||
getService().seek(sessionId, seconds)
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
@@ -97,6 +104,24 @@ ipcMain.handle(
|
||||
getService().setAspect(sessionId, aspect)
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
EMBEDDED_MPV_START_RECORDING,
|
||||
(_event, sessionId: string, options: EmbeddedMpvRecordingStartOptions) =>
|
||||
getService().startRecording(sessionId, options)
|
||||
);
|
||||
|
||||
ipcMain.handle(EMBEDDED_MPV_STOP_RECORDING, (_event, sessionId: string) =>
|
||||
getService().stopRecording(sessionId)
|
||||
);
|
||||
|
||||
ipcMain.handle(EMBEDDED_MPV_GET_DEFAULT_RECORDING_FOLDER, () =>
|
||||
getService().getDefaultRecordingFolder()
|
||||
);
|
||||
|
||||
ipcMain.handle(EMBEDDED_MPV_SELECT_RECORDING_FOLDER, () =>
|
||||
getService().selectRecordingFolder()
|
||||
);
|
||||
|
||||
ipcMain.handle(EMBEDDED_MPV_DISPOSE_SESSION, (_event, sessionId: string) =>
|
||||
getService().disposeSession(sessionId)
|
||||
);
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
describe('Embedded MPV native source recording invariants', () => {
|
||||
const nativeSource = readFileSync(
|
||||
path.resolve(__dirname, '../../../native/src/embedded_mpv.mm'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
function functionBody(name: string): string {
|
||||
const start = nativeSource.indexOf(`Napi::Value ${name}(`);
|
||||
expect(start).toBeGreaterThanOrEqual(0);
|
||||
|
||||
const bodyStart = nativeSource.indexOf('{', start);
|
||||
expect(bodyStart).toBeGreaterThanOrEqual(0);
|
||||
|
||||
let depth = 0;
|
||||
for (let index = bodyStart; index < nativeSource.length; index += 1) {
|
||||
if (nativeSource[index] === '{') {
|
||||
depth += 1;
|
||||
}
|
||||
if (nativeSource[index] === '}') {
|
||||
depth -= 1;
|
||||
}
|
||||
if (depth === 0) {
|
||||
return nativeSource.slice(bodyStart, index + 1);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Unable to read ${name} body.`);
|
||||
}
|
||||
|
||||
it('tracks LoadPlayback recording auto-stop replies through the reconciler', () => {
|
||||
const body = functionBody('LoadPlayback');
|
||||
|
||||
expect(body).toContain(
|
||||
'const uint64_t stopRecordingRequestId = nextAsyncRequestId();'
|
||||
);
|
||||
expect(body).toContain(
|
||||
'session->pendingRecordingStopRequestId = stopRecordingRequestId;'
|
||||
);
|
||||
expect(body).toContain(
|
||||
'session->pendingRecordingStopStartedAt =\n' +
|
||||
' session->snapshot.recordingStartedAt;'
|
||||
);
|
||||
expect(body).toContain('stopRecordingRequestId,');
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,9 @@ import type {
|
||||
EmbeddedMpvSessionStatus,
|
||||
ResolvedPortalPlayback,
|
||||
} from 'shared-interfaces';
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import path from 'path';
|
||||
import type { EmbeddedMpvNativeService as EmbeddedMpvNativeServiceType } from './embedded-mpv-native.service';
|
||||
|
||||
const powerSaveBlockerMock = {
|
||||
@@ -43,6 +46,12 @@ interface MockSnapshot {
|
||||
streamUrl: string;
|
||||
audioTracks?: never[];
|
||||
selectedAudioTrackId?: number | null;
|
||||
recording?: {
|
||||
active: boolean;
|
||||
targetPath?: string;
|
||||
startedAt?: string;
|
||||
error?: string;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@@ -58,6 +67,8 @@ interface MockAddon {
|
||||
seek: jest.Mock<void, [string, number]>;
|
||||
setVolume: jest.Mock<void, [string, number]>;
|
||||
setAudioTrack: jest.Mock<void, [string, number]>;
|
||||
startRecording: jest.Mock<void, [string, string]>;
|
||||
stopRecording: jest.Mock<void, [string]>;
|
||||
getSessionSnapshot: jest.Mock<MockSnapshot | null, [string]>;
|
||||
disposeSession: jest.Mock<void, [string]>;
|
||||
}
|
||||
@@ -72,6 +83,8 @@ function createMockAddon(): MockAddon {
|
||||
seek: jest.fn(),
|
||||
setVolume: jest.fn(),
|
||||
setAudioTrack: jest.fn(),
|
||||
startRecording: jest.fn(),
|
||||
stopRecording: jest.fn(),
|
||||
getSessionSnapshot: jest.fn(),
|
||||
disposeSession: jest.fn(),
|
||||
};
|
||||
@@ -84,6 +97,8 @@ describe('EmbeddedMpvNativeService power blocker', () => {
|
||||
let service: EmbeddedMpvNativeServiceType;
|
||||
let addon: MockAddon;
|
||||
let nextBlockerId: number;
|
||||
let originalPlatform: NodeJS.Platform;
|
||||
let tempDirs: string[];
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.resetModules();
|
||||
@@ -92,13 +107,17 @@ describe('EmbeddedMpvNativeService power blocker', () => {
|
||||
powerSaveBlockerMock.isStarted.mockReset();
|
||||
mainWindowSendMock.mockReset();
|
||||
|
||||
tempDirs = [];
|
||||
nextBlockerId = 1;
|
||||
powerSaveBlockerMock.start.mockImplementation(() => nextBlockerId++);
|
||||
powerSaveBlockerMock.isStarted.mockReturnValue(true);
|
||||
originalPlatform = process.platform;
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'darwin',
|
||||
});
|
||||
|
||||
({ EmbeddedMpvNativeService } = await import(
|
||||
'./embedded-mpv-native.service'
|
||||
));
|
||||
({ EmbeddedMpvNativeService } =
|
||||
await import('./embedded-mpv-native.service'));
|
||||
service = new EmbeddedMpvNativeService();
|
||||
addon = createMockAddon();
|
||||
// The addon is normally loaded via createRequire from a vendored .node
|
||||
@@ -108,12 +127,24 @@ describe('EmbeddedMpvNativeService power blocker', () => {
|
||||
|
||||
afterEach(() => {
|
||||
service.shutdown();
|
||||
jest.useRealTimers();
|
||||
for (const tempDir of tempDirs) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform,
|
||||
});
|
||||
});
|
||||
|
||||
function startSession(
|
||||
sessionId: string,
|
||||
snapshot: MockSnapshot
|
||||
): void {
|
||||
function createTempDir(): string {
|
||||
const tempDir = mkdtempSync(
|
||||
path.join(tmpdir(), 'iptvnator-recording-')
|
||||
);
|
||||
tempDirs.push(tempDir);
|
||||
return tempDir;
|
||||
}
|
||||
|
||||
function startSession(sessionId: string, snapshot: MockSnapshot): void {
|
||||
addon.createSession.mockReturnValueOnce(sessionId);
|
||||
addon.getSessionSnapshot.mockReturnValueOnce(snapshot);
|
||||
service.createSession(BOUNDS, '', 1);
|
||||
@@ -212,4 +243,158 @@ describe('EmbeddedMpvNativeService power blocker', () => {
|
||||
|
||||
expect(powerSaveBlockerMock.stop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports recording support when the addon exposes recording methods', () => {
|
||||
expect(service.getSupport().capabilities?.recording).toBe(true);
|
||||
});
|
||||
|
||||
it('loads the addon before reporting support capabilities', () => {
|
||||
const loadAddonModule = jest.fn().mockReturnValue(addon);
|
||||
Object.assign(service as unknown as Record<string, unknown>, {
|
||||
addon: null,
|
||||
addonLoadError: null,
|
||||
loadAddonModule,
|
||||
getAddonCandidatePaths: () => [__filename],
|
||||
getMissingRuntimeReason: () => null,
|
||||
});
|
||||
|
||||
const support = service.getSupport();
|
||||
|
||||
expect(loadAddonModule).toHaveBeenCalledWith(__filename);
|
||||
expect(support.supported).toBe(true);
|
||||
expect(support.capabilities?.recording).toBe(true);
|
||||
});
|
||||
|
||||
it('starts recording to a sanitized reserved unique ts file in the requested directory', () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date(2026, 4, 9, 10, 11, 12));
|
||||
const directory = createTempDir();
|
||||
startSession('s1', snapshot('playing'));
|
||||
|
||||
const targetPath = path.join(directory, 'News_Live-20260509-101112.ts');
|
||||
addon.getSessionSnapshot.mockReturnValueOnce(
|
||||
snapshot('playing', {
|
||||
recording: {
|
||||
active: true,
|
||||
targetPath,
|
||||
startedAt: '2026-05-09T08:11:12.000Z',
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const updated = service.startRecording('s1', {
|
||||
directory,
|
||||
title: 'News/Live',
|
||||
});
|
||||
|
||||
expect(addon.startRecording).toHaveBeenCalledWith('s1', targetPath);
|
||||
expect(existsSync(targetPath)).toBe(true);
|
||||
expect(updated?.recording?.active).toBe(true);
|
||||
expect(updated?.recording?.targetPath).toBe(targetPath);
|
||||
});
|
||||
|
||||
it('reserves the next unique recording path when the first candidate exists', () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date(2026, 4, 9, 10, 11, 12));
|
||||
const directory = createTempDir();
|
||||
const firstCandidate = path.join(
|
||||
directory,
|
||||
'News_Live-20260509-101112.ts'
|
||||
);
|
||||
const reservedCandidate = path.join(
|
||||
directory,
|
||||
'News_Live-20260509-101112-2.ts'
|
||||
);
|
||||
writeFileSync(firstCandidate, 'existing recording');
|
||||
startSession('s1', snapshot('playing'));
|
||||
|
||||
addon.getSessionSnapshot.mockReturnValueOnce(
|
||||
snapshot('playing', {
|
||||
recording: {
|
||||
active: true,
|
||||
targetPath: reservedCandidate,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
service.startRecording('s1', {
|
||||
directory,
|
||||
title: 'News/Live',
|
||||
});
|
||||
|
||||
expect(addon.startRecording).toHaveBeenCalledWith(
|
||||
's1',
|
||||
reservedCandidate
|
||||
);
|
||||
expect(existsSync(firstCandidate)).toBe(true);
|
||||
expect(existsSync(reservedCandidate)).toBe(true);
|
||||
});
|
||||
|
||||
it('stops recording and keeps the last target path in the session snapshot', () => {
|
||||
const targetPath = '/tmp/News_Live-20260509-101112.ts';
|
||||
startSession(
|
||||
's1',
|
||||
snapshot('playing', {
|
||||
recording: {
|
||||
active: true,
|
||||
targetPath,
|
||||
startedAt: '2026-05-09T08:11:12.000Z',
|
||||
},
|
||||
})
|
||||
);
|
||||
addon.getSessionSnapshot.mockReturnValueOnce(
|
||||
snapshot('playing', {
|
||||
recording: {
|
||||
active: false,
|
||||
targetPath,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const updated = service.stopRecording('s1');
|
||||
|
||||
expect(addon.stopRecording).toHaveBeenCalledWith('s1');
|
||||
expect(updated?.recording?.active).toBe(false);
|
||||
expect(updated?.recording?.targetPath).toBe(targetPath);
|
||||
});
|
||||
|
||||
it('preserves the recording target path when disposing an active recording session', () => {
|
||||
const targetPath = '/tmp/News_Live-20260509-101112.ts';
|
||||
startSession(
|
||||
's1',
|
||||
snapshot('playing', {
|
||||
recording: {
|
||||
active: true,
|
||||
targetPath,
|
||||
startedAt: '2026-05-09T08:11:12.000Z',
|
||||
},
|
||||
})
|
||||
);
|
||||
addon.getSessionSnapshot.mockReturnValueOnce(
|
||||
snapshot('playing', {
|
||||
recording: {
|
||||
active: true,
|
||||
targetPath,
|
||||
startedAt: '2026-05-09T08:11:12.000Z',
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const disposed = service.disposeSession('s1');
|
||||
|
||||
expect(addon.disposeSession).toHaveBeenCalledWith('s1');
|
||||
expect(disposed?.recording).toEqual({
|
||||
active: false,
|
||||
targetPath,
|
||||
});
|
||||
expect(mainWindowSendMock).toHaveBeenLastCalledWith(
|
||||
'EMBEDDED_MPV_SESSION_UPDATE',
|
||||
expect.objectContaining({
|
||||
recording: {
|
||||
active: false,
|
||||
targetPath,
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,12 @@
|
||||
import { app, powerSaveBlocker } from 'electron';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { app, dialog, powerSaveBlocker } from 'electron';
|
||||
import {
|
||||
closeSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
unlinkSync,
|
||||
} from 'fs';
|
||||
import { createRequire } from 'module';
|
||||
import path from 'path';
|
||||
import App from '../app';
|
||||
@@ -7,6 +14,8 @@ import {
|
||||
EmbeddedMpvAudioTrack,
|
||||
EmbeddedMpvBounds,
|
||||
EmbeddedMpvCapabilities,
|
||||
EmbeddedMpvRecordingStartOptions,
|
||||
EmbeddedMpvRecordingState,
|
||||
EmbeddedMpvSession,
|
||||
EmbeddedMpvSessionStatus,
|
||||
EmbeddedMpvSubtitleTrack,
|
||||
@@ -27,6 +36,7 @@ interface NativeEmbeddedMpvSessionSnapshot {
|
||||
selectedSubtitleTrackId?: number | null;
|
||||
playbackSpeed?: number;
|
||||
aspectOverride?: string;
|
||||
recording?: EmbeddedMpvRecordingState;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@@ -47,6 +57,8 @@ interface NativeEmbeddedMpvAddon {
|
||||
setSubtitleTrack?(sessionId: string, trackId: number): void;
|
||||
setSpeed?(sessionId: string, speed: number): void;
|
||||
setAspect?(sessionId: string, aspect: string): void;
|
||||
startRecording?(sessionId: string, targetPath: string): void;
|
||||
stopRecording?(sessionId: string): void;
|
||||
getSessionSnapshot(
|
||||
sessionId: string
|
||||
): NativeEmbeddedMpvSessionSnapshot | null;
|
||||
@@ -63,11 +75,12 @@ interface EmbeddedMpvRuntimeSession {
|
||||
lastStatus: EmbeddedMpvSessionStatus | null;
|
||||
}
|
||||
|
||||
const EMBEDDED_MPV_EXPERIMENT_ENV =
|
||||
'IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT';
|
||||
const EMBEDDED_MPV_EXPERIMENT_ENV = 'IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT';
|
||||
|
||||
function dedupePaths(paths: Array<string | undefined>): string[] {
|
||||
return [...new Set(paths.filter((value): value is string => Boolean(value)))];
|
||||
return [
|
||||
...new Set(paths.filter((value): value is string => Boolean(value))),
|
||||
];
|
||||
}
|
||||
|
||||
export class EmbeddedMpvNativeService {
|
||||
@@ -85,6 +98,9 @@ export class EmbeddedMpvNativeService {
|
||||
playbackSpeed: typeof addon?.setSpeed === 'function',
|
||||
aspectOverride: typeof addon?.setAspect === 'function',
|
||||
screenshot: false,
|
||||
recording:
|
||||
typeof addon?.startRecording === 'function' &&
|
||||
typeof addon?.stopRecording === 'function',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -125,9 +141,7 @@ export class EmbeddedMpvNativeService {
|
||||
supported: false,
|
||||
platform: process.platform,
|
||||
reason:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: String(error),
|
||||
error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -160,8 +174,9 @@ export class EmbeddedMpvNativeService {
|
||||
};
|
||||
}
|
||||
|
||||
const missingRuntimeReason =
|
||||
this.getMissingRuntimeReason(existingCandidatePath);
|
||||
const missingRuntimeReason = this.getMissingRuntimeReason(
|
||||
existingCandidatePath
|
||||
);
|
||||
if (missingRuntimeReason) {
|
||||
return {
|
||||
supported: false,
|
||||
@@ -170,11 +185,28 @@ export class EmbeddedMpvNativeService {
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
supported: true,
|
||||
platform: process.platform,
|
||||
capabilities: this.detectCapabilities(),
|
||||
};
|
||||
try {
|
||||
const addon = this.getAddon();
|
||||
if (!addon.isSupported()) {
|
||||
return {
|
||||
supported: false,
|
||||
platform: process.platform,
|
||||
reason: 'The native embedded MPV addon reported itself as unsupported.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
supported: true,
|
||||
platform: process.platform,
|
||||
capabilities: this.detectCapabilities(),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
supported: false,
|
||||
platform: process.platform,
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
prepareAddon(): EmbeddedMpvSupport {
|
||||
@@ -202,10 +234,7 @@ export class EmbeddedMpvNativeService {
|
||||
return {
|
||||
supported: false,
|
||||
platform: process.platform,
|
||||
reason:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: String(error),
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -237,23 +266,26 @@ export class EmbeddedMpvNativeService {
|
||||
});
|
||||
|
||||
this.ensurePolling();
|
||||
return this.refreshSession(sessionId) ?? {
|
||||
id: sessionId,
|
||||
title,
|
||||
streamUrl: '',
|
||||
status: 'idle',
|
||||
positionSeconds: 0,
|
||||
durationSeconds: null,
|
||||
volume: 1,
|
||||
audioTracks: [],
|
||||
selectedAudioTrackId: null,
|
||||
subtitleTracks: [],
|
||||
selectedSubtitleTrackId: null,
|
||||
playbackSpeed: 1,
|
||||
aspectOverride: 'no',
|
||||
startedAt,
|
||||
updatedAt: startedAt,
|
||||
};
|
||||
return (
|
||||
this.refreshSession(sessionId) ?? {
|
||||
id: sessionId,
|
||||
title,
|
||||
streamUrl: '',
|
||||
status: 'idle',
|
||||
positionSeconds: 0,
|
||||
durationSeconds: null,
|
||||
volume: 1,
|
||||
audioTracks: [],
|
||||
selectedAudioTrackId: null,
|
||||
subtitleTracks: [],
|
||||
selectedSubtitleTrackId: null,
|
||||
playbackSpeed: 1,
|
||||
aspectOverride: 'no',
|
||||
recording: { active: false },
|
||||
startedAt,
|
||||
updatedAt: startedAt,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
loadPlayback(sessionId: string, playback: ResolvedPortalPlayback): void {
|
||||
@@ -290,7 +322,10 @@ export class EmbeddedMpvNativeService {
|
||||
return this.refreshSession(sessionId);
|
||||
}
|
||||
|
||||
setAudioTrack(sessionId: string, trackId: number): EmbeddedMpvSession | null {
|
||||
setAudioTrack(
|
||||
sessionId: string,
|
||||
trackId: number
|
||||
): EmbeddedMpvSession | null {
|
||||
this.assertEmbeddedMpvEnabled();
|
||||
this.getAddon().setAudioTrack(sessionId, trackId);
|
||||
return this.refreshSession(sessionId);
|
||||
@@ -335,14 +370,84 @@ export class EmbeddedMpvNativeService {
|
||||
return this.refreshSession(sessionId);
|
||||
}
|
||||
|
||||
startRecording(
|
||||
sessionId: string,
|
||||
options: EmbeddedMpvRecordingStartOptions = {}
|
||||
): EmbeddedMpvSession | null {
|
||||
this.assertEmbeddedMpvEnabled();
|
||||
const addon = this.getAddon();
|
||||
if (
|
||||
typeof addon.startRecording !== 'function' ||
|
||||
typeof addon.stopRecording !== 'function'
|
||||
) {
|
||||
throw new Error(
|
||||
'Embedded MPV addon does not support stream recording. Rebuild the native addon to enable this feature.'
|
||||
);
|
||||
}
|
||||
|
||||
const session = this.getRuntimeSession(sessionId);
|
||||
const directory =
|
||||
options.directory?.trim() || this.getDefaultRecordingFolder();
|
||||
mkdirSync(directory, { recursive: true });
|
||||
|
||||
const targetPath = this.reserveRecordingTargetPath(
|
||||
directory,
|
||||
options.title || session.title || 'IPTVnator recording'
|
||||
);
|
||||
try {
|
||||
addon.startRecording(sessionId, targetPath);
|
||||
} catch (error) {
|
||||
this.releaseReservedRecordingTargetPath(targetPath);
|
||||
throw error;
|
||||
}
|
||||
return this.refreshSession(sessionId);
|
||||
}
|
||||
|
||||
stopRecording(sessionId: string): EmbeddedMpvSession | null {
|
||||
this.assertEmbeddedMpvEnabled();
|
||||
const addon = this.getAddon();
|
||||
if (typeof addon.stopRecording !== 'function') {
|
||||
throw new Error(
|
||||
'Embedded MPV addon does not support stream recording. Rebuild the native addon to enable this feature.'
|
||||
);
|
||||
}
|
||||
addon.stopRecording(sessionId);
|
||||
return this.refreshSession(sessionId);
|
||||
}
|
||||
|
||||
getDefaultRecordingFolder(): string {
|
||||
return app.getPath('downloads');
|
||||
}
|
||||
|
||||
async selectRecordingFolder(): Promise<string | null> {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ['openDirectory', 'createDirectory'],
|
||||
title: 'Select Recording Folder',
|
||||
defaultPath: this.getDefaultRecordingFolder(),
|
||||
});
|
||||
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.filePaths[0];
|
||||
}
|
||||
|
||||
disposeSession(sessionId: string): EmbeddedMpvSession | null {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let lastRecording: EmbeddedMpvRecordingState | undefined;
|
||||
try {
|
||||
this.getAddon().disposeSession(sessionId);
|
||||
const addon = this.getAddon();
|
||||
try {
|
||||
lastRecording = addon.getSessionSnapshot(sessionId)?.recording;
|
||||
} catch {
|
||||
lastRecording = undefined;
|
||||
}
|
||||
addon.disposeSession(sessionId);
|
||||
} finally {
|
||||
this.sessions.delete(sessionId);
|
||||
const payload: EmbeddedMpvSession = {
|
||||
@@ -359,6 +464,7 @@ export class EmbeddedMpvNativeService {
|
||||
selectedSubtitleTrackId: null,
|
||||
playbackSpeed: 1,
|
||||
aspectOverride: 'no',
|
||||
recording: this.createClosedRecordingState(lastRecording),
|
||||
startedAt: session.startedAt,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
@@ -445,6 +551,7 @@ export class EmbeddedMpvNativeService {
|
||||
typeof snapshot.aspectOverride === 'string'
|
||||
? snapshot.aspectOverride
|
||||
: 'no',
|
||||
recording: snapshot.recording ?? { active: false },
|
||||
startedAt: session.startedAt,
|
||||
updatedAt: new Date().toISOString(),
|
||||
...(snapshot.error ? { error: snapshot.error } : {}),
|
||||
@@ -510,7 +617,9 @@ export class EmbeddedMpvNativeService {
|
||||
private getRuntimeSession(sessionId: string): EmbeddedMpvRuntimeSession {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`Embedded MPV session "${sessionId}" was not found.`);
|
||||
throw new Error(
|
||||
`Embedded MPV session "${sessionId}" was not found.`
|
||||
);
|
||||
}
|
||||
|
||||
return session;
|
||||
@@ -524,6 +633,76 @@ export class EmbeddedMpvNativeService {
|
||||
return App.mainWindow.getNativeWindowHandle();
|
||||
}
|
||||
|
||||
private reserveRecordingTargetPath(
|
||||
directory: string,
|
||||
title: string
|
||||
): string {
|
||||
const baseName = this.sanitizeRecordingFileName(title);
|
||||
const timestamp = this.formatRecordingTimestamp(new Date());
|
||||
let candidate = path.join(directory, `${baseName}-${timestamp}.ts`);
|
||||
let suffix = 2;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const fd = openSync(candidate, 'wx');
|
||||
closeSync(fd);
|
||||
return candidate;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
|
||||
candidate = path.join(
|
||||
directory,
|
||||
`${baseName}-${timestamp}-${suffix}.ts`
|
||||
);
|
||||
suffix += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private releaseReservedRecordingTargetPath(targetPath: string): void {
|
||||
try {
|
||||
unlinkSync(targetPath);
|
||||
} catch {
|
||||
// Ignore cleanup failures; the start error is more useful.
|
||||
}
|
||||
}
|
||||
|
||||
private createClosedRecordingState(
|
||||
recording?: EmbeddedMpvRecordingState
|
||||
): EmbeddedMpvRecordingState {
|
||||
return {
|
||||
active: false,
|
||||
...(recording?.targetPath
|
||||
? { targetPath: recording.targetPath }
|
||||
: {}),
|
||||
...(recording?.error ? { error: recording.error } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
private sanitizeRecordingFileName(title: string): string {
|
||||
const normalized = title
|
||||
.replace(/[<>:"/\\|?*\u0000-\u001f]/g, '_')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
return (normalized || 'IPTVnator recording').slice(0, 120);
|
||||
}
|
||||
|
||||
private formatRecordingTimestamp(date: Date): string {
|
||||
const parts = [
|
||||
date.getFullYear(),
|
||||
date.getMonth() + 1,
|
||||
date.getDate(),
|
||||
date.getHours(),
|
||||
date.getMinutes(),
|
||||
date.getSeconds(),
|
||||
].map((part) => String(part).padStart(2, '0'));
|
||||
|
||||
return `${parts[0]}${parts[1]}${parts[2]}-${parts[3]}${parts[4]}${parts[5]}`;
|
||||
}
|
||||
|
||||
private isExperimentEnabled(): boolean {
|
||||
return ['1', 'true', 'yes', 'on'].includes(
|
||||
(process.env[EMBEDDED_MPV_EXPERIMENT_ENV] ?? '')
|
||||
@@ -563,7 +742,9 @@ export class EmbeddedMpvNativeService {
|
||||
this.addonLoadError = new Error(
|
||||
[
|
||||
'Unable to locate the embedded MPV native addon.',
|
||||
...candidatePaths.map((candidatePath) => `- ${candidatePath}`),
|
||||
...candidatePaths.map(
|
||||
(candidatePath) => `- ${candidatePath}`
|
||||
),
|
||||
].join('\n')
|
||||
);
|
||||
throw this.addonLoadError;
|
||||
@@ -625,8 +806,16 @@ export class EmbeddedMpvNativeService {
|
||||
|
||||
return dedupePaths(
|
||||
app.isPackaged
|
||||
? [...packagedAddonPaths, ...distAddonPaths, localBuildAddonPath]
|
||||
: [localBuildAddonPath, ...distAddonPaths, ...packagedAddonPaths]
|
||||
? [
|
||||
...packagedAddonPaths,
|
||||
...distAddonPaths,
|
||||
localBuildAddonPath,
|
||||
]
|
||||
: [
|
||||
localBuildAddonPath,
|
||||
...distAddonPaths,
|
||||
...packagedAddonPaths,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ const DEFAULT_SETTINGS: Settings = {
|
||||
remoteControl: false,
|
||||
remoteControlPort: 8765,
|
||||
downloadFolder: '',
|
||||
recordingFolder: '',
|
||||
};
|
||||
|
||||
describe('AppComponent', () => {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { Component, signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { ResolvedPortalPlayback } from 'shared-interfaces';
|
||||
import { EmbeddedMpvOverlayVisibilityService } from '../../../../libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-overlay-visibility.service';
|
||||
import { EmbeddedMpvPlayerComponent } from '../../../../libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component';
|
||||
import { EmbeddedMpvSessionController } from '../../../../libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller';
|
||||
|
||||
@Component({
|
||||
imports: [EmbeddedMpvPlayerComponent],
|
||||
template: `<app-embedded-mpv-player [playback]="playback" />`,
|
||||
})
|
||||
class EmbeddedMpvPlayerHostComponent {
|
||||
readonly playback: ResolvedPortalPlayback = {
|
||||
streamUrl: 'https://example.test/live.m3u8',
|
||||
title: 'Live News',
|
||||
isLive: true,
|
||||
};
|
||||
}
|
||||
|
||||
describe('EmbeddedMpvPlayerComponent recording status message', () => {
|
||||
let fixture: ComponentFixture<EmbeddedMpvPlayerHostComponent>;
|
||||
let component: EmbeddedMpvPlayerComponent;
|
||||
let controller: EmbeddedMpvSessionController;
|
||||
|
||||
beforeEach(async () => {
|
||||
window.electron = {
|
||||
onEmbeddedMpvSessionUpdate: jest.fn(() => undefined),
|
||||
} as unknown as typeof window.electron;
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [EmbeddedMpvPlayerHostComponent],
|
||||
providers: [
|
||||
{
|
||||
provide: EmbeddedMpvOverlayVisibilityService,
|
||||
useValue: { overlayActive: signal(false) },
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideComponent(EmbeddedMpvPlayerComponent, {
|
||||
set: { template: '' },
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(EmbeddedMpvPlayerHostComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
const playerDebugElement = fixture.debugElement.query(
|
||||
By.directive(EmbeddedMpvPlayerComponent)
|
||||
);
|
||||
component = playerDebugElement.componentInstance;
|
||||
controller = playerDebugElement.injector.get(
|
||||
EmbeddedMpvSessionController
|
||||
);
|
||||
controller.support.set({
|
||||
supported: true,
|
||||
platform: 'darwin',
|
||||
capabilities: {
|
||||
subtitles: true,
|
||||
playbackSpeed: true,
|
||||
aspectOverride: true,
|
||||
screenshot: false,
|
||||
recording: true,
|
||||
},
|
||||
});
|
||||
controller.session.set({
|
||||
id: 'session-1',
|
||||
title: 'Live News',
|
||||
streamUrl: 'https://example.test/live.m3u8',
|
||||
status: 'playing',
|
||||
positionSeconds: 0,
|
||||
durationSeconds: null,
|
||||
volume: 1,
|
||||
audioTracks: [],
|
||||
selectedAudioTrackId: null,
|
||||
subtitleTracks: [],
|
||||
selectedSubtitleTrackId: null,
|
||||
playbackSpeed: 1,
|
||||
aspectOverride: 'no',
|
||||
recording: {
|
||||
active: true,
|
||||
targetPath: '/tmp/live-news.ts',
|
||||
startedAt: '2026-05-09T18:00:00Z',
|
||||
},
|
||||
startedAt: '2026-05-09T18:00:00Z',
|
||||
updatedAt: '2026-05-09T18:00:00Z',
|
||||
});
|
||||
jest.spyOn(controller, 'stopRecording').mockImplementation(async () => {
|
||||
controller.session.update((session) =>
|
||||
session
|
||||
? {
|
||||
...session,
|
||||
recording: {
|
||||
active: false,
|
||||
targetPath: '/tmp/live-news.ts',
|
||||
},
|
||||
}
|
||||
: session
|
||||
);
|
||||
return {
|
||||
active: false,
|
||||
targetPath: '/tmp/live-news.ts',
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
fixture.destroy();
|
||||
delete window.electron;
|
||||
});
|
||||
|
||||
it('clears the saved recording path after a short delay', async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
await component.toggleRecording();
|
||||
|
||||
expect(component.recordingStatusText()).toBe(
|
||||
'Saved to /tmp/live-news.ts'
|
||||
);
|
||||
|
||||
jest.advanceTimersByTime(5000);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.recordingStatusText()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -36,7 +36,7 @@ export class PlayerService {
|
||||
title: string,
|
||||
thumbnail?: string,
|
||||
hideExternalInfoDialog = true,
|
||||
_isLiveContent = false,
|
||||
isLiveContent = false,
|
||||
userAgent?: string,
|
||||
referer?: string,
|
||||
origin?: string,
|
||||
@@ -44,12 +44,12 @@ export class PlayerService {
|
||||
startTime?: number,
|
||||
headers?: Record<string, string>
|
||||
): Promise<ExternalPlayerSession | void> {
|
||||
void _isLiveContent;
|
||||
return this.openResolvedPlayback(
|
||||
{
|
||||
streamUrl,
|
||||
title,
|
||||
thumbnail,
|
||||
isLive: isLiveContent,
|
||||
startTime,
|
||||
contentInfo,
|
||||
headers,
|
||||
@@ -85,15 +85,15 @@ export class PlayerService {
|
||||
return await this.dataService.sendIpcEvent<ExternalPlayerSession>(
|
||||
OPEN_MPV_PLAYER,
|
||||
{
|
||||
url: streamUrl,
|
||||
title,
|
||||
thumbnail,
|
||||
'user-agent': userAgent,
|
||||
referer: referer,
|
||||
origin: origin,
|
||||
headers,
|
||||
contentInfo,
|
||||
startTime,
|
||||
url: streamUrl,
|
||||
title,
|
||||
thumbnail,
|
||||
'user-agent': userAgent,
|
||||
referer: referer,
|
||||
origin: origin,
|
||||
headers,
|
||||
contentInfo,
|
||||
startTime,
|
||||
}
|
||||
);
|
||||
} else if (player === VideoPlayer.VLC) {
|
||||
@@ -103,15 +103,15 @@ export class PlayerService {
|
||||
return await this.dataService.sendIpcEvent<ExternalPlayerSession>(
|
||||
OPEN_VLC_PLAYER,
|
||||
{
|
||||
url: streamUrl,
|
||||
title,
|
||||
thumbnail,
|
||||
'user-agent': userAgent,
|
||||
referer: referer,
|
||||
origin: origin,
|
||||
headers,
|
||||
contentInfo,
|
||||
startTime,
|
||||
url: streamUrl,
|
||||
title,
|
||||
thumbnail,
|
||||
'user-agent': userAgent,
|
||||
referer: referer,
|
||||
origin: origin,
|
||||
headers,
|
||||
contentInfo,
|
||||
startTime,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -113,6 +113,39 @@
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" data-test-id="recording-folder-setting">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.RECORDING_FOLDER_LABEL' | translate }}</h4>
|
||||
<p>
|
||||
{{ 'SETTINGS.RECORDING_FOLDER_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label for="recordingFolder">{{
|
||||
'SETTINGS.RECORDING_FOLDER' | translate
|
||||
}}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="text"
|
||||
id="recordingFolder"
|
||||
formControlName="recordingFolder"
|
||||
/>
|
||||
<button
|
||||
mat-icon-button
|
||||
matSuffix
|
||||
type="button"
|
||||
[attr.aria-label]="
|
||||
'SETTINGS.RECORDING_FOLDER_SELECT' | translate
|
||||
"
|
||||
(click)="selectRecordingFolder.emit()"
|
||||
>
|
||||
<mat-icon>folder_open</mat-icon>
|
||||
</button>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (form().value.player === 'mpv') {
|
||||
|
||||
@@ -99,6 +99,26 @@ describe('SettingsPlaybackSectionComponent', () => {
|
||||
)
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('shows the recording folder setting only in desktop builds', () => {
|
||||
fixture.componentRef.setInput('isDesktop', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-test-id="recording-folder-setting"]'
|
||||
)
|
||||
).not.toBeNull();
|
||||
|
||||
fixture.componentRef.setInput('isDesktop', false);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-test-id="recording-folder-setting"]'
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
function createForm(player = VideoPlayer.VideoJs): FormGroup {
|
||||
@@ -111,5 +131,6 @@ function createForm(player = VideoPlayer.VideoJs): FormGroup {
|
||||
mpvReuseInstance: new FormControl(false),
|
||||
vlcPlayerPath: new FormControl(''),
|
||||
vlcReuseInstance: new FormControl(false),
|
||||
recordingFolder: new FormControl(''),
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, input, ViewEncapsulation } from '@angular/core';
|
||||
import { Component, input, output, ViewEncapsulation } from '@angular/core';
|
||||
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
@@ -14,6 +15,7 @@ import { SettingsPlayerOption } from './settings.models';
|
||||
selector: 'app-settings-playback-section',
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatButtonModule,
|
||||
MatCheckboxModule,
|
||||
MatFormFieldModule,
|
||||
MatIconModule,
|
||||
@@ -32,6 +34,7 @@ export class SettingsPlaybackSectionComponent {
|
||||
readonly players = input.required<SettingsPlayerOption[]>();
|
||||
readonly streamFormatEnum = input.required<typeof StreamFormat>();
|
||||
readonly isDesktop = input(false);
|
||||
readonly selectRecordingFolder = output<void>();
|
||||
|
||||
isExternalPlayerSelected(): boolean {
|
||||
const player = this.form().value.player;
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
[players]="players()"
|
||||
[streamFormatEnum]="streamFormatEnum"
|
||||
[isDesktop]="isDesktop"
|
||||
(selectRecordingFolder)="selectRecordingFolder()"
|
||||
/>
|
||||
|
||||
@if (isDesktop) {
|
||||
|
||||
@@ -82,6 +82,7 @@ const DEFAULT_SETTINGS = {
|
||||
remoteControl: false,
|
||||
remoteControlPort: 8765,
|
||||
epgUrl: [],
|
||||
recordingFolder: '',
|
||||
coverSize: 'medium',
|
||||
preferUploadedEpgOverXtream: false,
|
||||
};
|
||||
|
||||
@@ -200,10 +200,9 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
Validators.pattern(/^\d+$/),
|
||||
],
|
||||
],
|
||||
recordingFolder: '',
|
||||
coverSize: 'medium' as CoverSize,
|
||||
...(this.isDesktop
|
||||
? { preferUploadedEpgOverXtream: false }
|
||||
: {}),
|
||||
...(this.isDesktop ? { preferUploadedEpgOverXtream: false } : {}),
|
||||
});
|
||||
|
||||
/** Form array with epg sources */
|
||||
@@ -381,6 +380,24 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
this.settingsStore.updateSettings({ coverSize: size });
|
||||
}
|
||||
|
||||
async selectRecordingFolder(): Promise<void> {
|
||||
if (
|
||||
!this.isDesktop ||
|
||||
!window.electron?.selectEmbeddedMpvRecordingFolder
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const folder = await window.electron.selectEmbeddedMpvRecordingFolder();
|
||||
if (!folder) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.settingsForm.patchValue({ recordingFolder: folder });
|
||||
this.settingsForm.get('recordingFolder')?.markAsDirty();
|
||||
this.settingsForm.markAsDirty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the epg urls to the form array
|
||||
* @param epgUrls urls of the EPG sources
|
||||
|
||||
@@ -324,6 +324,10 @@
|
||||
"MPV_PLAYER_PATH": "mpv player path",
|
||||
"MPV_REUSE_INSTANCE_LABEL": "Reuse MPV instance",
|
||||
"MPV_REUSE_INSTANCE_DESCRIPTION": "When enabled, the same MPV window will be reused for playing different streams. When disabled, each stream opens in a new MPV window.",
|
||||
"RECORDING_FOLDER_LABEL": "Recording folder",
|
||||
"RECORDING_FOLDER_DESCRIPTION": "Folder for live stream recordings from Embedded MPV. Leave empty to use the system Downloads folder.",
|
||||
"RECORDING_FOLDER": "Recording folder",
|
||||
"RECORDING_FOLDER_SELECT": "Select recording folder",
|
||||
"VLC_PLAYER_PATH_LABEL": "VLC path",
|
||||
"VLC_PLAYER_PATH_DESCRIPTION": "Set path to VLC player in your system. It will use the provided path to a VLC binary instead of the default path for our system.",
|
||||
"VLC_PLAYER_PATH": "VLC player path",
|
||||
|
||||
@@ -322,6 +322,10 @@
|
||||
"MPV_PLAYER_PATH": "Путь к mpv плееру",
|
||||
"MPV_REUSE_INSTANCE_LABEL": "Переиспользовать экземпляр MPV",
|
||||
"MPV_REUSE_INSTANCE_DESCRIPTION": "Когда включено, одно и то же окно MPV будет использоваться для воспроизведения разных потоков. Когда отключено, каждый поток открывается в новом окне MPV.",
|
||||
"RECORDING_FOLDER_LABEL": "Папка для записей",
|
||||
"RECORDING_FOLDER_DESCRIPTION": "Папка для записей live-потоков из встроенного MPV. Оставьте пустым, чтобы использовать системную папку Загрузки.",
|
||||
"RECORDING_FOLDER": "Папка для записей",
|
||||
"RECORDING_FOLDER_SELECT": "Выбрать папку для записей",
|
||||
"VLC_PLAYER_PATH_LABEL": "Путь к VLC плееру",
|
||||
"VLC_PLAYER_PATH_DESCRIPTION": "Установите путь к проигрывателю VLC в вашей системе. \nОн будет использовать предоставленный путь к binary файлу VLC вместо пути по умолчанию.",
|
||||
"VLC_PLAYER_PATH": "Путь к VLC плееру",
|
||||
|
||||
Vendored
+10
@@ -6,6 +6,7 @@ interface NodeModule {
|
||||
|
||||
import {
|
||||
EmbeddedMpvBounds,
|
||||
EmbeddedMpvRecordingStartOptions,
|
||||
EmbeddedMpvSession,
|
||||
EmbeddedMpvSupport,
|
||||
EpgChannel,
|
||||
@@ -381,6 +382,15 @@ declare global {
|
||||
sessionId: string,
|
||||
aspect: string
|
||||
) => Promise<EmbeddedMpvSession | null>;
|
||||
startEmbeddedMpvRecording?: (
|
||||
sessionId: string,
|
||||
options: EmbeddedMpvRecordingStartOptions
|
||||
) => Promise<EmbeddedMpvSession | null>;
|
||||
stopEmbeddedMpvRecording?: (
|
||||
sessionId: string
|
||||
) => Promise<EmbeddedMpvSession | null>;
|
||||
getEmbeddedMpvDefaultRecordingFolder?: () => Promise<string>;
|
||||
selectEmbeddedMpvRecordingFolder?: () => Promise<string | null>;
|
||||
disposeEmbeddedMpvSession: (
|
||||
sessionId: string
|
||||
) => Promise<EmbeddedMpvSession | null>;
|
||||
|
||||
@@ -48,6 +48,8 @@ The renderer never gets direct native-module access. It can only call the preloa
|
||||
- seek
|
||||
- set volume
|
||||
- set audio track
|
||||
- start/stop live stream recording
|
||||
- resolve/select the live recording folder
|
||||
- dispose session
|
||||
- subscribe to session updates
|
||||
|
||||
@@ -69,7 +71,23 @@ Audio tracks are discovered from MPV's `track-list` property. The selected track
|
||||
|
||||
Subtitle tracks mirror the audio-track contract: same `track-list` source, same parsing pipeline, but selected through MPV's `sid` property. A `trackId` of `-1` from the renderer is interpreted as "disable subtitles" and translated to `sid=no` at the addon boundary. Playback speed is observed and set through MPV's `speed` property, clamped at the addon to `[0.25, 4.0]`. Aspect override uses MPV's `video-aspect-override` property as a passthrough string ("no", "16:9", "4:3", "21:9", "2.35:1"). All four properties (`sid`, `speed`, `video-aspect-override`, plus `aid`) are observed at session init so renderer state stays in sync with the native side without needing extra round-trips.
|
||||
|
||||
The renderer learns which features the loaded addon binary supports through the `EmbeddedMpvSupport.capabilities` field returned from `getEmbeddedMpvSupport()`. The service probes `typeof addon.<method> === 'function'` for each optional native export. Older addon binaries with the original audio-only surface return `capabilities: { subtitles: false, playbackSpeed: false, aspectOverride: false, screenshot: false }`, and the renderer hides the corresponding controls instead of throwing at runtime. After a native rebuild, the new buttons light up automatically without renderer changes.
|
||||
The renderer learns which features the loaded addon binary supports through the `EmbeddedMpvSupport.capabilities` field returned from `getEmbeddedMpvSupport()`. The service probes `typeof addon.<method> === 'function'` for each optional native export. Older addon binaries with the original audio-only surface return `capabilities: { subtitles: false, playbackSpeed: false, aspectOverride: false, screenshot: false, recording: false }`, and the renderer hides the corresponding controls instead of throwing at runtime. After a native rebuild, the new buttons light up automatically without renderer changes.
|
||||
|
||||
## Live Stream Recording
|
||||
|
||||
Embedded MPV can record live streams through mpv's `stream-record` option. IPTVnator exposes this only for `ResolvedPortalPlayback.isLive === true`; VOD, episodes, catchup playback, radio audio playback, and non-embedded players do not show the recording control.
|
||||
|
||||
Recording is session-scoped:
|
||||
|
||||
- `startEmbeddedMpvRecording(sessionId, { directory, title })` resolves a unique `.ts` filename in the requested directory and calls the native addon's `startRecording(sessionId, targetPath)`.
|
||||
- `stopEmbeddedMpvRecording(sessionId)` calls the native addon's `stopRecording(sessionId)`.
|
||||
- The native addon sets mpv's `stream-record` property to the target path on start and to an empty value on stop.
|
||||
- Loading a replacement stream or disposing the embedded session stops any active recording before the MPV handle is reused or destroyed.
|
||||
- `EmbeddedMpvSession.recording` carries `{ active, targetPath, startedAt, error }` so the renderer can show active elapsed time, final save path, or a failure.
|
||||
|
||||
The default recording folder is `app.getPath('downloads')`, matching the desktop download manager's fallback. Users can override it in Settings through `Settings.recordingFolder`; an empty setting means system Downloads. Recordings are intentionally not inserted into the Downloads database or queue in v1 because MPV writes from the active playback session while the download manager owns independent backend download jobs.
|
||||
|
||||
mpv's own caveats apply: the output container is inferred from the target extension, and seeking or switching streams while recording can produce broken output. IPTVnator limits the UI to live streams and stops recording on playback replacement to avoid the most obvious corruption path, but the feature should still be treated as an experimental embedded MPV capability.
|
||||
|
||||
## Renderer Architecture And Reactivity
|
||||
|
||||
@@ -79,7 +97,7 @@ The Angular side of the embedded MPV player is intentionally split so the player
|
||||
- `embedded-mpv-shortcuts.ts` — `EmbeddedMpvShortcuts` class with `attach(handlers)` / `detach()`. Owns the document keydown listener and routes through a callback interface; the component supplies the callbacks. Listens for Space/K (toggle), F (fullscreen), arrow keys (seek/volume), M (mute), Escape (close popovers).
|
||||
- `embedded-mpv-overlay-visibility.service.ts` — singleton service that exposes `overlayActive: signal<boolean>`. Tracks `MatDialog.afterOpened`/`afterAllClosed` for dialog-shaped overlays and falls back to a `MutationObserver` on the CDK overlay container for any remaining backdrop-bearing CDK overlays. The MPV NSView is hidden off-screen while a modal is open so DOM dialogs can paint above it.
|
||||
- `embedded-mpv-ui-state.ts` — `EmbeddedMpvMenuState` (single-open popover state machine with `volumeOpen`, `audioOpen`, `subtitleOpen`, `speedOpen`, `aspectOpen` signals plus `anyOpen` computed; `toggle`/`open`/`close`/`closeAll` helpers) and `EmbeddedMpvFeedback` (transient overlay that auto-clears after a configurable delay; used for keypress feedback).
|
||||
- `embedded-mpv-session-controller.ts` — component-scoped `Injectable` service that owns the `support`, `session`, `sessionId`, `stalled`, and `retryToken` signals. Subscribes to `onEmbeddedMpvSessionUpdate`, runs the polling-driven `stalled` timer, owns bounds-sync (resize, scroll, overlay state), and exposes the imperative IPC surface (`startSession`, `togglePaused`, `seekBy`/`seekTo`, `applyVolume`, `setAudioTrack`, `setSubtitleTrack`, `setSpeed`, `setAspect`, `retry`).
|
||||
- `embedded-mpv-session-controller.ts` — component-scoped `Injectable` service that owns the `support`, `session`, `sessionId`, `stalled`, and `retryToken` signals. Subscribes to `onEmbeddedMpvSessionUpdate`, runs the polling-driven `stalled` timer, owns bounds-sync (resize, scroll, overlay state), and exposes the imperative IPC surface (`startSession`, `togglePaused`, `seekBy`/`seekTo`, `applyVolume`, `setAudioTrack`, `setSubtitleTrack`, `setSpeed`, `setAspect`, `startRecording`, `stopRecording`, `retry`).
|
||||
- `embedded-mpv-player.component.ts` — view-only shell. Holds view children, derived `computed` signals, DOM event listeners (pointermove, pointerdown, fullscreenchange, dblclick), and three `effect()`s.
|
||||
|
||||
### Bounds compositing strategy
|
||||
@@ -107,7 +125,7 @@ Concrete bugs from the audit, recorded so they don't get reintroduced:
|
||||
- **Spurious `timeUpdate` re-emits and `volume.set` calls.** The session-fan-out effect calls `scheduleControlsHide()`, which reads `isPlaying`, `menus.anyOpen`, `statusLabel`, and `controlsVisible`. Those reads became tracked deps, so opening any popover, pausing, or hovering re-ran the body. No loop in isolation, but a parent that wires `timeUpdate` back into `playback.startTime` would have hit the volume-restart bug class. Fix: wrap the side-effect block in `untracked()` so the effect listens only to session changes.
|
||||
- **2 Hz no-op stalled-tracker re-runs.** The controller's stalled effect tracked the full `session` signal, which updates on every position-poll snapshot. `handleStalledTracking` is a no-op for non-loading status, so the re-runs cost nothing useful. Fix: track a `sessionStatus = computed(() => this.session()?.status ?? null)` instead so the effect fires only on real status transitions.
|
||||
|
||||
When adding a new effect, audit it the same way: list every tracked signal read explicitly, justify each one as a *re-trigger source*, and wrap everything else in `untracked()`. When extending an existing helper that is called from inside an effect, treat the helper's signal reads as if they were inline in the effect.
|
||||
When adding a new effect, audit it the same way: list every tracked signal read explicitly, justify each one as a _re-trigger source_, and wrap everything else in `untracked()`. When extending an existing helper that is called from inside an effect, treat the helper's signal reads as if they were inline in the effect.
|
||||
|
||||
### IPC safety
|
||||
|
||||
@@ -239,4 +257,5 @@ Do not expose embedded MPV broadly until these pass on both Apple Silicon and In
|
||||
- VOD resume starts near the saved offset
|
||||
- live HLS, MPEG-TS, MP4/VOD, headers, referrer, volume, seek, fullscreen, route changes, and cleanup work
|
||||
- audio-track switching works on a stream with multiple audio tracks
|
||||
- live stream recording starts, stops, writes a `.ts` file in Downloads/custom recording folder, and stops on route/playback changes
|
||||
- fallback behavior is clear when the addon or native dependencies are unavailable
|
||||
Vendored
+10
@@ -1,5 +1,6 @@
|
||||
import 'jest-extended';
|
||||
import { EmbeddedMpvBounds } from './libs/shared/interfaces/src/lib/embedded-mpv-session.interface';
|
||||
import { EmbeddedMpvRecordingStartOptions } from './libs/shared/interfaces/src/lib/embedded-mpv-session.interface';
|
||||
import { EmbeddedMpvSession } from './libs/shared/interfaces/src/lib/embedded-mpv-session.interface';
|
||||
import { EmbeddedMpvSupport } from './libs/shared/interfaces/src/lib/embedded-mpv-session.interface';
|
||||
import { ExternalPlayerSession } from './libs/shared/interfaces/src/lib/external-player-session.interface';
|
||||
@@ -167,6 +168,15 @@ declare global {
|
||||
sessionId: string,
|
||||
aspect: string
|
||||
) => Promise<EmbeddedMpvSession | null>;
|
||||
startEmbeddedMpvRecording?: (
|
||||
sessionId: string,
|
||||
options: EmbeddedMpvRecordingStartOptions
|
||||
) => Promise<EmbeddedMpvSession | null>;
|
||||
stopEmbeddedMpvRecording?: (
|
||||
sessionId: string
|
||||
) => Promise<EmbeddedMpvSession | null>;
|
||||
getEmbeddedMpvDefaultRecordingFolder?: () => Promise<string>;
|
||||
selectEmbeddedMpvRecordingFolder?: () => Promise<string | null>;
|
||||
disposeEmbeddedMpvSession: (
|
||||
sessionId: string
|
||||
) => Promise<EmbeddedMpvSession | null>;
|
||||
|
||||
@@ -190,6 +190,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
|
||||
activeChannel.tvg?.name ||
|
||||
playbackTarget.url,
|
||||
thumbnail: activeChannel.tvg?.logo ?? null,
|
||||
isLive: true,
|
||||
headers: Object.keys(headers).length > 0 ? headers : undefined,
|
||||
userAgent: playbackTarget.http['user-agent'] || undefined,
|
||||
referer: playbackTarget.http.referrer || undefined,
|
||||
|
||||
@@ -269,6 +269,7 @@ export class StreamResolverService {
|
||||
streamUrl,
|
||||
title: item.name,
|
||||
thumbnail: item.logo ?? null,
|
||||
isLive: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -310,6 +311,7 @@ export class StreamResolverService {
|
||||
streamUrl: this.normalizeStalkerCmd(rawCmd),
|
||||
title: item.name,
|
||||
thumbnail: item.logo ?? null,
|
||||
isLive: true,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -264,6 +264,7 @@ export function withStalkerPlayer() {
|
||||
streamUrl,
|
||||
title: item.o_name || item.name || item.title || '',
|
||||
thumbnail: item.logo ?? item.cover ?? null,
|
||||
isLive: true,
|
||||
headers,
|
||||
userAgent:
|
||||
headers['User-Agent'] ||
|
||||
|
||||
@@ -342,6 +342,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
|
||||
streamUrl,
|
||||
title: item.title ?? item.name ?? '',
|
||||
thumbnail: item.poster_url ?? item.stream_icon ?? null,
|
||||
isLive: true,
|
||||
});
|
||||
if (this.usesEmbeddedPlayer() || !startPlayback) {
|
||||
return;
|
||||
|
||||
@@ -36,6 +36,7 @@ const DEFAULT_SETTINGS: Settings = {
|
||||
remoteControlPort: 8765,
|
||||
epgUrl: [],
|
||||
downloadFolder: '',
|
||||
recordingFolder: '',
|
||||
coverSize: 'medium',
|
||||
preferUploadedEpgOverXtream: false,
|
||||
};
|
||||
@@ -142,6 +143,7 @@ export const SettingsStore = signalStore(
|
||||
remoteControlPort: store.remoteControlPort(),
|
||||
epgUrl: store.epgUrl(),
|
||||
downloadFolder: store.downloadFolder!(),
|
||||
recordingFolder: store.recordingFolder!(),
|
||||
coverSize: store.coverSize!(),
|
||||
preferUploadedEpgOverXtream:
|
||||
store.preferUploadedEpgOverXtream!(),
|
||||
@@ -152,6 +154,10 @@ export const SettingsStore = signalStore(
|
||||
return store.downloadFolder!();
|
||||
},
|
||||
|
||||
getRecordingFolder() {
|
||||
return store.recordingFolder!();
|
||||
},
|
||||
|
||||
getPlayer() {
|
||||
return store.player();
|
||||
},
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface EmbeddedMpvCapabilities {
|
||||
playbackSpeed: boolean;
|
||||
aspectOverride: boolean;
|
||||
screenshot: boolean;
|
||||
recording: boolean;
|
||||
}
|
||||
|
||||
export interface EmbeddedMpvSupport {
|
||||
@@ -38,6 +39,18 @@ export interface EmbeddedMpvAudioTrack {
|
||||
|
||||
export type EmbeddedMpvSubtitleTrack = EmbeddedMpvAudioTrack;
|
||||
|
||||
export interface EmbeddedMpvRecordingState {
|
||||
active: boolean;
|
||||
targetPath?: string;
|
||||
startedAt?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface EmbeddedMpvRecordingStartOptions {
|
||||
directory?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface EmbeddedMpvSession {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -52,6 +65,7 @@ export interface EmbeddedMpvSession {
|
||||
selectedSubtitleTrackId: number | null;
|
||||
playbackSpeed: number;
|
||||
aspectOverride: string;
|
||||
recording?: EmbeddedMpvRecordingState;
|
||||
startedAt: string;
|
||||
updatedAt: string;
|
||||
error?: string;
|
||||
|
||||
@@ -36,8 +36,7 @@ export const OPEN_VLC_PLAYER = 'OPEN_VLC_PLAYER';
|
||||
export const SET_VLC_PLAYER_PATH = 'SET_VLC_PLAYER_PATH';
|
||||
export const SET_VLC_REUSE_INSTANCE = 'SET_VLC_REUSE_INSTANCE';
|
||||
export const CLOSE_EXTERNAL_PLAYER_SESSION = 'CLOSE_EXTERNAL_PLAYER_SESSION';
|
||||
export const EXTERNAL_PLAYER_SESSION_UPDATE =
|
||||
'EXTERNAL_PLAYER_SESSION_UPDATE';
|
||||
export const EXTERNAL_PLAYER_SESSION_UPDATE = 'EXTERNAL_PLAYER_SESSION_UPDATE';
|
||||
export const EMBEDDED_MPV_SUPPORT = 'EMBEDDED_MPV_SUPPORT';
|
||||
export const EMBEDDED_MPV_CREATE_SESSION = 'EMBEDDED_MPV_CREATE_SESSION';
|
||||
export const EMBEDDED_MPV_LOAD_PLAYBACK = 'EMBEDDED_MPV_LOAD_PLAYBACK';
|
||||
@@ -50,6 +49,12 @@ 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_START_RECORDING = 'EMBEDDED_MPV_START_RECORDING';
|
||||
export const EMBEDDED_MPV_STOP_RECORDING = 'EMBEDDED_MPV_STOP_RECORDING';
|
||||
export const EMBEDDED_MPV_GET_DEFAULT_RECORDING_FOLDER =
|
||||
'EMBEDDED_MPV_GET_DEFAULT_RECORDING_FOLDER';
|
||||
export const EMBEDDED_MPV_SELECT_RECORDING_FOLDER =
|
||||
'EMBEDDED_MPV_SELECT_RECORDING_FOLDER';
|
||||
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';
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { PlaybackPositionData } from './playback-position.interface';
|
||||
|
||||
export interface PlayerContentInfo
|
||||
extends Omit<
|
||||
PlaybackPositionData,
|
||||
'positionSeconds' | 'durationSeconds' | 'updatedAt'
|
||||
> {
|
||||
export interface PlayerContentInfo extends Omit<
|
||||
PlaybackPositionData,
|
||||
'positionSeconds' | 'durationSeconds' | 'updatedAt'
|
||||
> {
|
||||
playlistId: string;
|
||||
}
|
||||
|
||||
@@ -12,6 +11,7 @@ export interface ResolvedPortalPlayback {
|
||||
streamUrl: string;
|
||||
title: string;
|
||||
thumbnail?: string | null;
|
||||
isLive?: boolean;
|
||||
startTime?: number;
|
||||
contentInfo?: PlayerContentInfo;
|
||||
headers?: Record<string, string>;
|
||||
|
||||
@@ -44,6 +44,8 @@ export interface Settings {
|
||||
remoteControlPort: number;
|
||||
/** Custom download folder path (uses system Downloads folder if not set) */
|
||||
downloadFolder?: string;
|
||||
/** Custom live recording folder path (uses system Downloads folder if not set) */
|
||||
recordingFolder?: string;
|
||||
/** Cover/poster sizing preset applied across grids and rails */
|
||||
coverSize?: CoverSize;
|
||||
/**
|
||||
|
||||
@@ -80,7 +80,9 @@
|
||||
@if (showControls() && isSupported()) {
|
||||
<div
|
||||
class="embedded-mpv-player__controls"
|
||||
[class.embedded-mpv-player__controls--visible]="controlsAreVisible()"
|
||||
[class.embedded-mpv-player__controls--visible]="
|
||||
controlsAreVisible()
|
||||
"
|
||||
>
|
||||
<div class="embedded-mpv-player__transport">
|
||||
<button
|
||||
@@ -146,7 +148,9 @@
|
||||
<div class="embedded-mpv-player__time">
|
||||
<span>{{ formatTime(session()?.positionSeconds) }}</span>
|
||||
@if (canSeek()) {
|
||||
<span>{{ formatTime(session()?.durationSeconds) }}</span>
|
||||
<span>{{
|
||||
formatTime(session()?.durationSeconds)
|
||||
}}</span>
|
||||
} @else {
|
||||
<span
|
||||
class="embedded-mpv-player__live-badge"
|
||||
@@ -160,6 +164,22 @@
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
@if (recordingStatusText(); as recordingStatus) {
|
||||
<div
|
||||
class="embedded-mpv-player__recording-status"
|
||||
[class.embedded-mpv-player__recording-status--active]="
|
||||
isRecording()
|
||||
"
|
||||
[attr.aria-live]="isRecording() ? 'off' : 'polite'"
|
||||
>
|
||||
<mat-icon>{{
|
||||
isRecording()
|
||||
? 'fiber_manual_record'
|
||||
: 'check_circle'
|
||||
}}</mat-icon>
|
||||
<span>{{ recordingStatus }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="embedded-mpv-player__actions">
|
||||
@@ -176,9 +196,7 @@
|
||||
type="button"
|
||||
class="embedded-mpv-player__control-button"
|
||||
(click)="toggleMute()"
|
||||
[attr.aria-label]="
|
||||
volume() <= 0 ? 'Unmute' : 'Mute'
|
||||
"
|
||||
[attr.aria-label]="volume() <= 0 ? 'Unmute' : 'Mute'"
|
||||
[matTooltip]="volume() <= 0 ? 'Unmute' : 'Mute'"
|
||||
matTooltipPosition="above"
|
||||
>
|
||||
@@ -232,7 +250,9 @@
|
||||
<div class="embedded-mpv-player__menu-title">
|
||||
Audio tracks
|
||||
</div>
|
||||
<div class="embedded-mpv-player__audio-track-list">
|
||||
<div
|
||||
class="embedded-mpv-player__audio-track-list"
|
||||
>
|
||||
@for (
|
||||
track of audioTracks();
|
||||
track track.id;
|
||||
@@ -289,7 +309,9 @@
|
||||
<div class="embedded-mpv-player__menu-title">
|
||||
Subtitles
|
||||
</div>
|
||||
<div class="embedded-mpv-player__audio-track-list">
|
||||
<div
|
||||
class="embedded-mpv-player__audio-track-list"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
@@ -367,8 +389,13 @@
|
||||
<div class="embedded-mpv-player__menu-title">
|
||||
Speed
|
||||
</div>
|
||||
<div class="embedded-mpv-player__audio-track-list">
|
||||
@for (preset of speedPresets; track preset.value) {
|
||||
<div
|
||||
class="embedded-mpv-player__audio-track-list"
|
||||
>
|
||||
@for (
|
||||
preset of speedPresets;
|
||||
track preset.value
|
||||
) {
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
@@ -422,13 +449,19 @@
|
||||
<div class="embedded-mpv-player__menu-title">
|
||||
Aspect
|
||||
</div>
|
||||
<div class="embedded-mpv-player__audio-track-list">
|
||||
@for (preset of aspectPresets; track preset.value) {
|
||||
<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()
|
||||
preset.value ===
|
||||
aspectOverride()
|
||||
"
|
||||
class="embedded-mpv-player__audio-track"
|
||||
[class.embedded-mpv-player__audio-track--selected]="
|
||||
@@ -452,6 +485,33 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (canRecord()) {
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="embedded-mpv-player__control-button embedded-mpv-player__record-button"
|
||||
[class.embedded-mpv-player__record-button--active]="
|
||||
isRecording()
|
||||
"
|
||||
[disabled]="isLoading()"
|
||||
(click)="toggleRecording()"
|
||||
[attr.aria-pressed]="isRecording()"
|
||||
[attr.aria-label]="
|
||||
isRecording() ? 'Stop recording' : 'Start recording'
|
||||
"
|
||||
[matTooltip]="
|
||||
isRecording() ? 'Stop recording' : 'Start recording'
|
||||
"
|
||||
matTooltipPosition="above"
|
||||
>
|
||||
<mat-icon>{{
|
||||
isRecording()
|
||||
? 'stop_circle'
|
||||
: 'fiber_manual_record'
|
||||
}}</mat-icon>
|
||||
</button>
|
||||
}
|
||||
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
|
||||
@@ -214,6 +214,40 @@
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.embedded-mpv-player__recording-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
color: color-mix(in srgb, var(--mat-sys-on-surface) 78%, transparent);
|
||||
font-size: 0.72rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.embedded-mpv-player__recording-status mat-icon {
|
||||
flex: 0 0 auto;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 16px;
|
||||
color: var(--mat-sys-primary);
|
||||
}
|
||||
|
||||
.embedded-mpv-player__recording-status span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.embedded-mpv-player__recording-status--active {
|
||||
color: var(--mat-sys-error);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.embedded-mpv-player__recording-status--active mat-icon {
|
||||
color: var(--mat-sys-error);
|
||||
}
|
||||
|
||||
.embedded-mpv-player__live-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -256,6 +290,10 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.embedded-mpv-player__record-button--active {
|
||||
color: var(--mat-sys-error) !important;
|
||||
}
|
||||
|
||||
.embedded-mpv-player__slider {
|
||||
--slider-progress: 0%;
|
||||
width: 100%;
|
||||
@@ -442,11 +480,7 @@
|
||||
.embedded-mpv-player__audio-track:hover,
|
||||
.embedded-mpv-player__audio-track:focus-visible,
|
||||
.embedded-mpv-player__audio-track--selected {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--embedded-mpv-accent) 16%,
|
||||
transparent
|
||||
);
|
||||
background: color-mix(in srgb, var(--embedded-mpv-accent) 16%, transparent);
|
||||
}
|
||||
|
||||
.embedded-mpv-player__audio-track mat-icon {
|
||||
@@ -462,11 +496,7 @@
|
||||
|
||||
.embedded-mpv-player :is(button[mat-icon-button]:hover) {
|
||||
color: var(--mat-sys-on-surface);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--embedded-mpv-accent) 14%,
|
||||
transparent
|
||||
);
|
||||
background: color-mix(in srgb, var(--embedded-mpv-accent) 14%, transparent);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
} from './embedded-mpv-format.utils';
|
||||
|
||||
const HIDE_CONTROLS_DELAY_MS = 2500;
|
||||
const RECORDING_MESSAGE_DISMISS_DELAY_MS = 5000;
|
||||
const VOLUME_POPOVER_CLOSE_DELAY_MS = 220;
|
||||
|
||||
@Component({
|
||||
@@ -67,6 +68,7 @@ const VOLUME_POPOVER_CLOSE_DELAY_MS = 220;
|
||||
export class EmbeddedMpvPlayerComponent implements OnDestroy {
|
||||
readonly playback = input.required<ResolvedPortalPlayback>();
|
||||
readonly showControls = input(true);
|
||||
readonly recordingFolder = input('');
|
||||
|
||||
@Output() timeUpdate = new EventEmitter<{
|
||||
currentTime: number;
|
||||
@@ -103,6 +105,7 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
|
||||
playbackSpeed: false,
|
||||
aspectOverride: false,
|
||||
screenshot: false,
|
||||
recording: false,
|
||||
}
|
||||
);
|
||||
readonly isLoading = computed(
|
||||
@@ -185,10 +188,41 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
|
||||
this.isPlaying() &&
|
||||
!this.controlsAreVisible()
|
||||
);
|
||||
readonly canRecord = computed(
|
||||
() =>
|
||||
this.capabilities().recording &&
|
||||
this.playback().isLive === true &&
|
||||
this.isSupported() &&
|
||||
!this.isErrored()
|
||||
);
|
||||
readonly isRecording = computed(
|
||||
() => this.session()?.recording?.active === true
|
||||
);
|
||||
readonly recordingElapsed = computed(() => {
|
||||
const startedAt = this.session()?.recording?.startedAt;
|
||||
this.recordingTick();
|
||||
if (!startedAt) {
|
||||
return 0;
|
||||
}
|
||||
const startedAtMs = Date.parse(startedAt);
|
||||
if (!Number.isFinite(startedAtMs)) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(0, Math.floor((Date.now() - startedAtMs) / 1000));
|
||||
});
|
||||
readonly recordingStatusText = computed(() => {
|
||||
if (this.isRecording()) {
|
||||
return `REC ${formatTime(this.recordingElapsed())}`;
|
||||
}
|
||||
return this.recordingMessage();
|
||||
});
|
||||
|
||||
private mutedVolume = 0;
|
||||
private controlsHideTimer: number | null = null;
|
||||
private volumeCloseTimer: number | null = null;
|
||||
private recordingMessageTimer: number | null = null;
|
||||
private readonly recordingTick = signal(Date.now());
|
||||
private readonly recordingMessage = signal<string | null>(null);
|
||||
|
||||
private readonly onDocumentPointerDown = (event: PointerEvent) => {
|
||||
const playerRoot = this.playerRoot()?.nativeElement;
|
||||
@@ -222,7 +256,10 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
|
||||
'fullscreenchange',
|
||||
this.onFullscreenChange
|
||||
);
|
||||
document.addEventListener('pointerdown', this.onDocumentPointerDown);
|
||||
document.addEventListener(
|
||||
'pointerdown',
|
||||
this.onDocumentPointerDown
|
||||
);
|
||||
document.addEventListener(
|
||||
'pointermove',
|
||||
this.onDocumentPointerMove,
|
||||
@@ -260,13 +297,13 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
|
||||
effect((onCleanup) => {
|
||||
const viewport = this.viewport();
|
||||
const playback = this.playback();
|
||||
const support = this.support();
|
||||
const supported = this.isSupported();
|
||||
this.controller.retryToken();
|
||||
|
||||
if (
|
||||
!viewport ||
|
||||
!playback.streamUrl ||
|
||||
!support?.supported ||
|
||||
!supported ||
|
||||
!window.electron
|
||||
) {
|
||||
return;
|
||||
@@ -276,6 +313,7 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
|
||||
// not re-trigger this effect (which would tear down and recreate
|
||||
// the session, restarting the stream from the beginning).
|
||||
// Subsequent volume changes flow through controller.applyVolume.
|
||||
this.setRecordingMessage(null);
|
||||
const teardown = this.controller.startSession(
|
||||
viewport.nativeElement,
|
||||
playback,
|
||||
@@ -308,6 +346,18 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
|
||||
this.scheduleControlsHide();
|
||||
});
|
||||
});
|
||||
|
||||
effect((onCleanup) => {
|
||||
if (!this.isRecording()) {
|
||||
return;
|
||||
}
|
||||
this.recordingTick.set(Date.now());
|
||||
const intervalId = window.setInterval(
|
||||
() => this.recordingTick.set(Date.now()),
|
||||
1000
|
||||
);
|
||||
onCleanup(() => window.clearInterval(intervalId));
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
@@ -331,6 +381,7 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
|
||||
clearTimeout(this.volumeCloseTimer);
|
||||
this.volumeCloseTimer = null;
|
||||
}
|
||||
this.clearRecordingMessageTimer();
|
||||
this.clearControlsHideTimer();
|
||||
}
|
||||
|
||||
@@ -478,6 +529,46 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
|
||||
this.scheduleControlsHide();
|
||||
}
|
||||
|
||||
async toggleRecording(): Promise<void> {
|
||||
if (!this.canRecord()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.revealControls(false);
|
||||
if (this.isRecording()) {
|
||||
const recording = await this.controller.stopRecording();
|
||||
if (recording?.targetPath) {
|
||||
this.setRecordingMessage(`Saved to ${recording.targetPath}`, {
|
||||
autoDismiss: true,
|
||||
});
|
||||
this.feedback.flash('check_circle', 'Recording saved', 1200);
|
||||
} else if (recording?.error) {
|
||||
this.setRecordingMessage(recording.error);
|
||||
this.feedback.flash('error_outline', 'Recording failed', 1200);
|
||||
} else {
|
||||
this.setRecordingMessage('Recording failed to stop.');
|
||||
this.feedback.flash('error_outline', 'Recording failed', 1200);
|
||||
}
|
||||
this.scheduleControlsHide();
|
||||
return;
|
||||
}
|
||||
|
||||
this.setRecordingMessage(null);
|
||||
const recording = await this.controller.startRecording(
|
||||
this.recordingFolder(),
|
||||
this.playback().title
|
||||
);
|
||||
if (recording?.active) {
|
||||
this.feedback.flash('fiber_manual_record', 'Recording', 900);
|
||||
} else if (recording?.error) {
|
||||
this.setRecordingMessage(recording.error);
|
||||
this.feedback.flash('error_outline', 'Recording failed', 1200);
|
||||
} else {
|
||||
this.setRecordingMessage('Recording failed to start.');
|
||||
this.feedback.flash('error_outline', 'Recording failed', 1200);
|
||||
}
|
||||
}
|
||||
|
||||
retry(): void {
|
||||
this.controller.retry();
|
||||
}
|
||||
@@ -488,6 +579,33 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
|
||||
speedLabel = speedLabel;
|
||||
aspectLabel = aspectLabel;
|
||||
|
||||
private setRecordingMessage(
|
||||
message: string | null,
|
||||
options: { autoDismiss?: boolean } = {}
|
||||
): void {
|
||||
this.clearRecordingMessageTimer();
|
||||
this.recordingMessage.set(message);
|
||||
|
||||
if (!message || !options.autoDismiss) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.recordingMessageTimer = window.setTimeout(() => {
|
||||
if (this.recordingMessage() === message) {
|
||||
this.recordingMessage.set(null);
|
||||
}
|
||||
this.recordingMessageTimer = null;
|
||||
}, RECORDING_MESSAGE_DISMISS_DELAY_MS);
|
||||
}
|
||||
|
||||
private clearRecordingMessageTimer(): void {
|
||||
if (this.recordingMessageTimer === null) {
|
||||
return;
|
||||
}
|
||||
window.clearTimeout(this.recordingMessageTimer);
|
||||
this.recordingMessageTimer = null;
|
||||
}
|
||||
|
||||
private adjustVolume(delta: number): void {
|
||||
const next = Math.max(0, Math.min(1, this.volume() + delta));
|
||||
this.applyVolume(next);
|
||||
|
||||
@@ -15,7 +15,9 @@ import {
|
||||
} from 'shared-interfaces';
|
||||
import { measureBounds } from './embedded-mpv-format.utils';
|
||||
|
||||
export type EmbeddedMpvBoundsProvider = (host: HTMLElement) => EmbeddedMpvBounds;
|
||||
export type EmbeddedMpvBoundsProvider = (
|
||||
host: HTMLElement
|
||||
) => EmbeddedMpvBounds;
|
||||
|
||||
const STALLED_TIMEOUT_MS = 30_000;
|
||||
|
||||
@@ -134,7 +136,9 @@ export class EmbeddedMpvSessionController {
|
||||
window.addEventListener('scroll', scheduleBoundsSync, true);
|
||||
|
||||
const create = async () => {
|
||||
this.session.set(this.createLoadingSession(playback, initialVolume));
|
||||
this.session.set(
|
||||
this.createLoadingSession(playback, initialVolume)
|
||||
);
|
||||
await this.waitForStartupPaint();
|
||||
if (disposed) {
|
||||
return;
|
||||
@@ -150,11 +154,9 @@ export class EmbeddedMpvSessionController {
|
||||
'Embedded MPV is not available in this environment.'
|
||||
);
|
||||
}
|
||||
// Do not call this.support.set(prepared) here: support is already
|
||||
// populated by loadSupport() in the constructor, and writing it
|
||||
// again would re-trigger the component's session-creation effect
|
||||
// (which depends on this.support()), tearing down the session we
|
||||
// just created and looping forever.
|
||||
if (prepared?.supported) {
|
||||
this.support.set(prepared);
|
||||
}
|
||||
|
||||
const created = await window.electron!.createEmbeddedMpvSession(
|
||||
measureBounds(host),
|
||||
@@ -170,12 +172,17 @@ export class EmbeddedMpvSessionController {
|
||||
activeSessionId = created.id;
|
||||
this.sessionId.set(created.id);
|
||||
this.session.set(created);
|
||||
await window.electron!.loadEmbeddedMpvPlayback(created.id, playback);
|
||||
await window.electron!.loadEmbeddedMpvPlayback(
|
||||
created.id,
|
||||
playback
|
||||
);
|
||||
scheduleBoundsSync();
|
||||
};
|
||||
|
||||
void create().catch((error) =>
|
||||
this.session.set(this.createErrorSession(playback, initialVolume, error))
|
||||
this.session.set(
|
||||
this.createErrorSession(playback, initialVolume, error)
|
||||
)
|
||||
);
|
||||
|
||||
return () => {
|
||||
@@ -314,9 +321,47 @@ export class EmbeddedMpvSessionController {
|
||||
}
|
||||
}
|
||||
|
||||
private async guardIpc<T>(
|
||||
call: () => Promise<T>
|
||||
): Promise<T | null> {
|
||||
async startRecording(
|
||||
directory: string | undefined,
|
||||
title: string
|
||||
): Promise<EmbeddedMpvSession['recording'] | null> {
|
||||
const id = this.sessionId();
|
||||
if (!id || !window.electron?.startEmbeddedMpvRecording) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolvedDirectory =
|
||||
directory?.trim() ||
|
||||
(await window.electron.getEmbeddedMpvDefaultRecordingFolder?.());
|
||||
const updated = await this.guardIpc(() =>
|
||||
window.electron!.startEmbeddedMpvRecording!(id, {
|
||||
directory: resolvedDirectory,
|
||||
title,
|
||||
})
|
||||
);
|
||||
if (updated) {
|
||||
this.session.set(updated);
|
||||
return updated.recording ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async stopRecording(): Promise<EmbeddedMpvSession['recording'] | null> {
|
||||
const id = this.sessionId();
|
||||
if (!id || !window.electron?.stopEmbeddedMpvRecording) {
|
||||
return null;
|
||||
}
|
||||
const updated = await this.guardIpc(() =>
|
||||
window.electron!.stopEmbeddedMpvRecording!(id)
|
||||
);
|
||||
if (updated) {
|
||||
this.session.set(updated);
|
||||
return updated.recording ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async guardIpc<T>(call: () => Promise<T>): Promise<T | null> {
|
||||
try {
|
||||
return await call();
|
||||
} catch {
|
||||
@@ -383,6 +428,7 @@ export class EmbeddedMpvSessionController {
|
||||
selectedSubtitleTrackId: null,
|
||||
playbackSpeed: 1,
|
||||
aspectOverride: 'no',
|
||||
recording: { active: false },
|
||||
startedAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
@@ -409,6 +455,7 @@ export class EmbeddedMpvSessionController {
|
||||
selectedSubtitleTrackId: null,
|
||||
playbackSpeed: 1,
|
||||
aspectOverride: 'no',
|
||||
recording: { active: false },
|
||||
startedAt: now,
|
||||
updatedAt: now,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
} @else if (player === 'embedded-mpv') {
|
||||
<app-embedded-mpv-player
|
||||
[playback]="resolvedPlayback()"
|
||||
[recordingFolder]="settings()?.recordingFolder ?? ''"
|
||||
(timeUpdate)="timeUpdate.emit($event)"
|
||||
/>
|
||||
} @else {
|
||||
|
||||
Reference in new issue
Block a user