From 724e4b1ab39af6b8e68767c46709ce3cb2cfea03 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 10 May 2026 12:27:09 +0200 Subject: [PATCH] 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 --- .../native/src/embedded_mpv.mm | 290 ++++++++++++++++++ .../src/app/api/main.preload.ts | 19 +- .../src/app/events/embedded-mpv.events.ts | 29 +- .../embedded-mpv-native-source.spec.ts | 48 +++ .../embedded-mpv-native.service.spec.ts | 199 +++++++++++- .../services/embedded-mpv-native.service.ts | 273 ++++++++++++++--- apps/web/src/app/app.component.spec.ts | 1 + ...edded-mpv-player-recording-message.spec.ts | 127 ++++++++ apps/web/src/app/services/player.service.ts | 40 +-- .../settings-playback-section.component.html | 33 ++ ...ettings-playback-section.component.spec.ts | 21 ++ .../settings-playback-section.component.ts | 5 +- .../src/app/settings/settings.component.html | 1 + .../app/settings/settings.component.spec.ts | 1 + .../src/app/settings/settings.component.ts | 23 +- apps/web/src/assets/i18n/en.json | 4 + apps/web/src/assets/i18n/ru.json | 4 + apps/web/src/typings.d.ts | 10 + docs/architecture/embedded-mpv-native.md | 25 +- global.d.ts | 10 + .../video-player/video-player.component.ts | 1 + .../lib/collection/stream-resolver.service.ts | 2 + .../features/with-stalker-player.feature.ts | 1 + .../live-stream-layout.component.ts | 1 + .../src/lib/settings-store.service.ts | 6 + .../src/lib/embedded-mpv-session.interface.ts | 14 + .../shared/interfaces/src/lib/ipc-commands.ts | 9 +- .../src/lib/portal-playback.interface.ts | 10 +- .../interfaces/src/lib/settings.interface.ts | 2 + .../embedded-mpv-player.component.html | 84 ++++- .../embedded-mpv-player.component.scss | 50 ++- .../embedded-mpv-player.component.ts | 124 +++++++- .../embedded-mpv-session-controller.ts | 71 ++++- .../web-player-view.component.html | 1 + 34 files changed, 1413 insertions(+), 126 deletions(-) create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts create mode 100644 apps/web/src/app/embedded-mpv-player-recording-message.spec.ts diff --git a/apps/electron-backend/native/src/embedded_mpv.mm b/apps/electron-backend/native/src/embedded_mpv.mm index 775e786ad..d9c7d6e9a 100644 --- a/apps/electron-backend/native/src/embedded_mpv.mm +++ b/apps/electron-backend/native/src/embedded_mpv.mm @@ -21,6 +21,7 @@ #include #include #include +#include #include #include #include @@ -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 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* renderCallbackContext = nullptr; RenderBackend renderBackend = RenderBackend::OpenGL; int renderWidthPixels = 0; @@ -398,6 +408,23 @@ void destroySession(const std::shared_ptr& session) } if (session->handle) { + bool recordingActive = false; + { + std::lock_guard lock(session->mutex); + recordingActive = session->snapshot.recordingActive; + } + if (recordingActive) { + const char* disabledValue = ""; + mpv_set_property( + session->handle, + "stream-record", + MPV_FORMAT_STRING, + const_cast(&disabledValue) + ); + std::lock_guard 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, + 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) } 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 lock(session->mutex); + recordingActive = session->snapshot.recordingActive; + } + if (recordingActive) { + const char* disabledValue = ""; + const uint64_t stopRecordingRequestId = nextAsyncRequestId(); + { + std::lock_guard 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(&disabledValue) + ); + if (stopResult < 0) { + { + std::lock_guard 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> 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().Utf8Value(); + const std::string targetPath = info[1].As().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 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(&targetValue) + ); + + if (result < 0) { + { + std::lock_guard 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().Utf8Value(); + const auto session = getSessionOrThrow(env, sessionId); + const char* disabledValue = ""; + const uint64_t requestId = nextAsyncRequestId(); + { + std::lock_guard 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(&disabledValue) + ); + + if (result < 0) { + { + std::lock_guard 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) diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 80573f24f..a11dc2465 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -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 => ipcRenderer.invoke('EMBEDDED_MPV_SET_ASPECT', sessionId, aspect), + startEmbeddedMpvRecording: ( + sessionId: string, + options: EmbeddedMpvRecordingStartOptions + ): Promise => + ipcRenderer.invoke('EMBEDDED_MPV_START_RECORDING', sessionId, options), + stopEmbeddedMpvRecording: ( + sessionId: string + ): Promise => + ipcRenderer.invoke('EMBEDDED_MPV_STOP_RECORDING', sessionId), + getEmbeddedMpvDefaultRecordingFolder: (): Promise => + ipcRenderer.invoke('EMBEDDED_MPV_GET_DEFAULT_RECORDING_FOLDER'), + selectEmbeddedMpvRecordingFolder: (): Promise => + ipcRenderer.invoke('EMBEDDED_MPV_SELECT_RECORDING_FOLDER'), disposeEmbeddedMpvSession: ( sessionId: string ): Promise => @@ -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, diff --git a/apps/electron-backend/src/app/events/embedded-mpv.events.ts b/apps/electron-backend/src/app/events/embedded-mpv.events.ts index c55628fe4..5f4c3e1bd 100644 --- a/apps/electron-backend/src/app/events/embedded-mpv.events.ts +++ b/apps/electron-backend/src/app/events/embedded-mpv.events.ts @@ -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) ); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts new file mode 100644 index 000000000..6b17a5e6d --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts @@ -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,'); + }); +}); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts index fa731981b..a3c5da0fe 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts @@ -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; setVolume: jest.Mock; setAudioTrack: jest.Mock; + startRecording: jest.Mock; + stopRecording: jest.Mock; getSessionSnapshot: jest.Mock; disposeSession: jest.Mock; } @@ -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, { + 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, + }, + }) + ); + }); }); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts b/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts index d1f8eeaa0..aaa49c35d 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts @@ -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[] { - 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 { + 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, + ] ); } diff --git a/apps/web/src/app/app.component.spec.ts b/apps/web/src/app/app.component.spec.ts index 81150dc9e..7b0d95088 100644 --- a/apps/web/src/app/app.component.spec.ts +++ b/apps/web/src/app/app.component.spec.ts @@ -50,6 +50,7 @@ const DEFAULT_SETTINGS: Settings = { remoteControl: false, remoteControlPort: 8765, downloadFolder: '', + recordingFolder: '', }; describe('AppComponent', () => { diff --git a/apps/web/src/app/embedded-mpv-player-recording-message.spec.ts b/apps/web/src/app/embedded-mpv-player-recording-message.spec.ts new file mode 100644 index 000000000..e43562419 --- /dev/null +++ b/apps/web/src/app/embedded-mpv-player-recording-message.spec.ts @@ -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: ``, +}) +class EmbeddedMpvPlayerHostComponent { + readonly playback: ResolvedPortalPlayback = { + streamUrl: 'https://example.test/live.m3u8', + title: 'Live News', + isLive: true, + }; +} + +describe('EmbeddedMpvPlayerComponent recording status message', () => { + let fixture: ComponentFixture; + 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(); + }); +}); diff --git a/apps/web/src/app/services/player.service.ts b/apps/web/src/app/services/player.service.ts index 6f0402e6f..e686f523e 100644 --- a/apps/web/src/app/services/player.service.ts +++ b/apps/web/src/app/services/player.service.ts @@ -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 ): Promise { - 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( 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( 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, } ); } diff --git a/apps/web/src/app/settings/settings-playback-section.component.html b/apps/web/src/app/settings/settings-playback-section.component.html index 622cd79ae..eb1011636 100644 --- a/apps/web/src/app/settings/settings-playback-section.component.html +++ b/apps/web/src/app/settings/settings-playback-section.component.html @@ -113,6 +113,39 @@ > + +
+
+

{{ 'SETTINGS.RECORDING_FOLDER_LABEL' | translate }}

+

+ {{ 'SETTINGS.RECORDING_FOLDER_DESCRIPTION' | translate }} +

+
+
+ + {{ + 'SETTINGS.RECORDING_FOLDER' | translate + }} + + + +
+
} @if (form().value.player === 'mpv') { diff --git a/apps/web/src/app/settings/settings-playback-section.component.spec.ts b/apps/web/src/app/settings/settings-playback-section.component.spec.ts index 9e59a58c0..8aa611bdf 100644 --- a/apps/web/src/app/settings/settings-playback-section.component.spec.ts +++ b/apps/web/src/app/settings/settings-playback-section.component.spec.ts @@ -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(''), }); } diff --git a/apps/web/src/app/settings/settings-playback-section.component.ts b/apps/web/src/app/settings/settings-playback-section.component.ts index 5db1a12d4..52aa7b29a 100644 --- a/apps/web/src/app/settings/settings-playback-section.component.ts +++ b/apps/web/src/app/settings/settings-playback-section.component.ts @@ -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(); readonly streamFormatEnum = input.required(); readonly isDesktop = input(false); + readonly selectRecordingFolder = output(); isExternalPlayerSelected(): boolean { const player = this.form().value.player; diff --git a/apps/web/src/app/settings/settings.component.html b/apps/web/src/app/settings/settings.component.html index a18a88f17..a23a5308b 100644 --- a/apps/web/src/app/settings/settings.component.html +++ b/apps/web/src/app/settings/settings.component.html @@ -46,6 +46,7 @@ [players]="players()" [streamFormatEnum]="streamFormatEnum" [isDesktop]="isDesktop" + (selectRecordingFolder)="selectRecordingFolder()" /> @if (isDesktop) { diff --git a/apps/web/src/app/settings/settings.component.spec.ts b/apps/web/src/app/settings/settings.component.spec.ts index 4ca77340a..85e16e460 100644 --- a/apps/web/src/app/settings/settings.component.spec.ts +++ b/apps/web/src/app/settings/settings.component.spec.ts @@ -82,6 +82,7 @@ const DEFAULT_SETTINGS = { remoteControl: false, remoteControlPort: 8765, epgUrl: [], + recordingFolder: '', coverSize: 'medium', preferUploadedEpgOverXtream: false, }; diff --git a/apps/web/src/app/settings/settings.component.ts b/apps/web/src/app/settings/settings.component.ts index 8e837d6b6..b8f83ab0b 100644 --- a/apps/web/src/app/settings/settings.component.ts +++ b/apps/web/src/app/settings/settings.component.ts @@ -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 { + 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 diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 1ef422748..cf3fe47f8 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -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", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 2bf52214f..0b26d845c 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -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 плееру", diff --git a/apps/web/src/typings.d.ts b/apps/web/src/typings.d.ts index 5a193fc82..69a430107 100644 --- a/apps/web/src/typings.d.ts +++ b/apps/web/src/typings.d.ts @@ -6,6 +6,7 @@ interface NodeModule { import { EmbeddedMpvBounds, + EmbeddedMpvRecordingStartOptions, EmbeddedMpvSession, EmbeddedMpvSupport, EpgChannel, @@ -381,6 +382,15 @@ declare global { sessionId: string, aspect: string ) => Promise; + startEmbeddedMpvRecording?: ( + sessionId: string, + options: EmbeddedMpvRecordingStartOptions + ) => Promise; + stopEmbeddedMpvRecording?: ( + sessionId: string + ) => Promise; + getEmbeddedMpvDefaultRecordingFolder?: () => Promise; + selectEmbeddedMpvRecordingFolder?: () => Promise; disposeEmbeddedMpvSession: ( sessionId: string ) => Promise; diff --git a/docs/architecture/embedded-mpv-native.md b/docs/architecture/embedded-mpv-native.md index 4eca1b255..8c2905040 100644 --- a/docs/architecture/embedded-mpv-native.md +++ b/docs/architecture/embedded-mpv-native.md @@ -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. === '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. === '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`. 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 diff --git a/global.d.ts b/global.d.ts index fba443ac3..7e40923f2 100644 --- a/global.d.ts +++ b/global.d.ts @@ -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; + startEmbeddedMpvRecording?: ( + sessionId: string, + options: EmbeddedMpvRecordingStartOptions + ) => Promise; + stopEmbeddedMpvRecording?: ( + sessionId: string + ) => Promise; + getEmbeddedMpvDefaultRecordingFolder?: () => Promise; + selectEmbeddedMpvRecordingFolder?: () => Promise; disposeEmbeddedMpvSession: ( sessionId: string ) => Promise; diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts index a44ddb54a..82cf15edd 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts @@ -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, diff --git a/libs/portal/shared/util/src/lib/collection/stream-resolver.service.ts b/libs/portal/shared/util/src/lib/collection/stream-resolver.service.ts index de7e22c3a..8fe5a63f9 100644 --- a/libs/portal/shared/util/src/lib/collection/stream-resolver.service.ts +++ b/libs/portal/shared/util/src/lib/collection/stream-resolver.service.ts @@ -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, }; } diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts index ac0e09f28..624e064b1 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts @@ -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'] || diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts index c88bd2962..0267bfe72 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts @@ -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; diff --git a/libs/services/src/lib/settings-store.service.ts b/libs/services/src/lib/settings-store.service.ts index d31017ced..2b7daa772 100644 --- a/libs/services/src/lib/settings-store.service.ts +++ b/libs/services/src/lib/settings-store.service.ts @@ -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(); }, diff --git a/libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts b/libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts index 173edab45..d682107cd 100644 --- a/libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts +++ b/libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts @@ -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; diff --git a/libs/shared/interfaces/src/lib/ipc-commands.ts b/libs/shared/interfaces/src/lib/ipc-commands.ts index e71af008c..4c5ae551f 100644 --- a/libs/shared/interfaces/src/lib/ipc-commands.ts +++ b/libs/shared/interfaces/src/lib/ipc-commands.ts @@ -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'; diff --git a/libs/shared/interfaces/src/lib/portal-playback.interface.ts b/libs/shared/interfaces/src/lib/portal-playback.interface.ts index 1a4d3c7f1..1ab48fa88 100644 --- a/libs/shared/interfaces/src/lib/portal-playback.interface.ts +++ b/libs/shared/interfaces/src/lib/portal-playback.interface.ts @@ -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; diff --git a/libs/shared/interfaces/src/lib/settings.interface.ts b/libs/shared/interfaces/src/lib/settings.interface.ts index 9b5c5e3d1..c2df9479a 100644 --- a/libs/shared/interfaces/src/lib/settings.interface.ts +++ b/libs/shared/interfaces/src/lib/settings.interface.ts @@ -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; /** diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html index ef50c2ef9..e4b7c017e 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html @@ -80,7 +80,9 @@ @if (showControls() && isSupported()) {
+ @if (recordingStatusText(); as recordingStatus) { +
+ {{ + isRecording() + ? 'fiber_manual_record' + : 'check_circle' + }} + {{ recordingStatus }} +
+ }
@@ -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 @@
Audio tracks
-
+
@for ( track of audioTracks(); track track.id; @@ -289,7 +309,9 @@
Subtitles
-
+
-
- @for (preset of speedPresets; track preset.value) { +
+ @for ( + preset of speedPresets; + track preset.value + ) {
-
- @for (preset of aspectPresets; track preset.value) { +
+ @for ( + preset of aspectPresets; + track preset.value + ) { + } +