feat: settings tab with language picker, extension fixes, and i18n expansion

- SettingsPanel: new 'settings' section — in-app language picker (all 5
  locales), auto-apply fresh recording zooms toggle, connect-neighboring-
  zooms toggle, keybinds shortcut into KeyboardShortcutsDialog
- VideoEditor: wire autoApplyFreshRecordingAutoZooms preference through to
  SettingsPanel; connect zoom settings into new section
- editorPreferences: add autoApplyFreshRecordingAutoZooms field; update tests
- extensionMarketplace: filter __MACOSX sidecar dirs when unzipping extensions
  uploaded from macOS Finder (fixes manifest-not-found on Finder zips)
- useExtensions: guard window.electronAPI for SSR safety; reformat to tabs
- I18nContext: expose setLocale; persist locale preference
- ShortcutsContext: shortcut registration improvements
- lib/extensions: renderHooks, extensionHost, fileUrls, cursorCoordinates
  updated for new extension capability surface
- i18n: expand extension locale strings across all 5 supported languages
This commit is contained in:
webadderall
2026-04-16 17:52:53 +10:00
parent 0eee64310b
commit 48cdb5c203
20 changed files with 2859 additions and 1875 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -97,6 +97,7 @@ describe("editorPreferences", () => {
cursorSway: DEFAULT_EDITOR_PREFERENCES.cursorSway,
borderRadius: DEFAULT_EDITOR_PREFERENCES.borderRadius,
padding: DEFAULT_EDITOR_PREFERENCES.padding,
frame: DEFAULT_EDITOR_PREFERENCES.frame,
aspectRatio: "native",
exportEncodingMode: DEFAULT_EDITOR_PREFERENCES.exportEncodingMode,
exportBackendPreference: DEFAULT_EDITOR_PREFERENCES.exportBackendPreference,
@@ -111,6 +112,8 @@ describe("editorPreferences", () => {
customAspectWidth: "21",
customAspectHeight: "9",
customWallpapers: ["data:image/jpeg;base64,abc"],
autoApplyFreshRecordingAutoZooms:
DEFAULT_EDITOR_PREFERENCES.autoApplyFreshRecordingAutoZooms,
whisperExecutablePath: DEFAULT_EDITOR_PREFERENCES.whisperExecutablePath,
whisperModelPath: DEFAULT_EDITOR_PREFERENCES.whisperModelPath,
});
@@ -167,6 +170,7 @@ describe("editorPreferences", () => {
cursorSway: DEFAULT_EDITOR_PREFERENCES.cursorSway,
borderRadius: DEFAULT_EDITOR_PREFERENCES.borderRadius,
padding: DEFAULT_EDITOR_PREFERENCES.padding,
frame: DEFAULT_EDITOR_PREFERENCES.frame,
exportEncodingMode: DEFAULT_EDITOR_PREFERENCES.exportEncodingMode,
exportBackendPreference: DEFAULT_EDITOR_PREFERENCES.exportBackendPreference,
exportPipelineModel: DEFAULT_EDITOR_PREFERENCES.exportPipelineModel,
@@ -180,6 +184,8 @@ describe("editorPreferences", () => {
customAspectWidth: "21",
customAspectHeight: "9",
customWallpapers: DEFAULT_EDITOR_PREFERENCES.customWallpapers,
autoApplyFreshRecordingAutoZooms:
DEFAULT_EDITOR_PREFERENCES.autoApplyFreshRecordingAutoZooms,
whisperExecutablePath: DEFAULT_EDITOR_PREFERENCES.whisperExecutablePath,
whisperModelPath: DEFAULT_EDITOR_PREFERENCES.whisperModelPath,
});
@@ -231,6 +237,7 @@ describe("editorPreferences", () => {
cursorSway: 1.5,
borderRadius: 18,
padding: 30,
frame: DEFAULT_EDITOR_PREFERENCES.frame,
aspectRatio: "4:5",
exportEncodingMode: "quality",
exportBackendPreference: DEFAULT_EDITOR_PREFERENCES.exportBackendPreference,
@@ -244,6 +251,7 @@ describe("editorPreferences", () => {
customAspectWidth: "4",
customAspectHeight: "5",
customWallpapers: ["data:image/jpeg;base64,abc", "data:image/jpeg;base64,abc"],
autoApplyFreshRecordingAutoZooms: false,
});
expect(loadEditorPreferences()).toEqual({
@@ -271,6 +279,7 @@ describe("editorPreferences", () => {
cursorSway: 1.5,
borderRadius: 18,
padding: 30,
frame: DEFAULT_EDITOR_PREFERENCES.frame,
aspectRatio: "4:5",
exportEncodingMode: "quality",
exportBackendPreference: DEFAULT_EDITOR_PREFERENCES.exportBackendPreference,
@@ -285,6 +294,7 @@ describe("editorPreferences", () => {
customAspectWidth: "4",
customAspectHeight: "5",
customWallpapers: ["data:image/jpeg;base64,abc"],
autoApplyFreshRecordingAutoZooms: false,
whisperExecutablePath: DEFAULT_EDITOR_PREFERENCES.whisperExecutablePath,
whisperModelPath: DEFAULT_EDITOR_PREFERENCES.whisperModelPath,
});
@@ -52,6 +52,7 @@ export interface EditorPreferences extends PersistedEditorControls {
customAspectWidth: string;
customAspectHeight: string;
customWallpapers: string[];
autoApplyFreshRecordingAutoZooms: boolean;
whisperExecutablePath: string | null;
whisperModelPath: string | null;
}
@@ -100,10 +101,15 @@ export const DEFAULT_EDITOR_PREFERENCES: EditorPreferences = {
customAspectWidth: "16",
customAspectHeight: "9",
customWallpapers: [],
autoApplyFreshRecordingAutoZooms: true,
whisperExecutablePath: null,
whisperModelPath: null,
};
function normalizeBoolean(value: unknown, fallback: boolean): boolean {
return typeof value === "boolean" ? value : fallback;
}
function normalizePositiveIntegerString(value: unknown, fallback: string): string {
if (typeof value !== "string" || value.trim().length === 0) {
return fallback;
@@ -123,7 +129,9 @@ function normalizeCustomWallpapers(value: unknown, fallback: string[]): string[]
}
return Array.from(
new Set(value.filter((item): item is string => typeof item === "string" && item.length > 0)),
new Set(
value.filter((item): item is string => typeof item === "string" && item.length > 0),
),
);
}
@@ -150,12 +158,10 @@ function normalizeEditorControls(
zoomInOverlapMs: raw.zoomInOverlapMs ?? fallback.zoomInOverlapMs,
zoomOutDurationMs: raw.zoomOutDurationMs ?? fallback.zoomOutDurationMs,
connectedZoomGapMs: raw.connectedZoomGapMs ?? fallback.connectedZoomGapMs,
connectedZoomDurationMs:
raw.connectedZoomDurationMs ?? fallback.connectedZoomDurationMs,
connectedZoomDurationMs: raw.connectedZoomDurationMs ?? fallback.connectedZoomDurationMs,
zoomInEasing: raw.zoomInEasing ?? fallback.zoomInEasing,
zoomOutEasing: raw.zoomOutEasing ?? fallback.zoomOutEasing,
connectedZoomEasing:
raw.connectedZoomEasing ?? fallback.connectedZoomEasing,
connectedZoomEasing: raw.connectedZoomEasing ?? fallback.connectedZoomEasing,
showCursor: raw.showCursor ?? fallback.showCursor,
loopCursor: raw.loopCursor ?? fallback.loopCursor,
cursorStyle: raw.cursorStyle ?? fallback.cursorStyle,
@@ -250,11 +256,17 @@ export function normalizeEditorPreferences(
raw.customAspectHeight,
fallback.customAspectHeight,
),
customWallpapers: normalizeCustomWallpapers(raw.customWallpapers, fallback.customWallpapers),
customWallpapers: normalizeCustomWallpapers(
raw.customWallpapers,
fallback.customWallpapers,
),
autoApplyFreshRecordingAutoZooms: normalizeBoolean(
raw.autoApplyFreshRecordingAutoZooms,
fallback.autoApplyFreshRecordingAutoZooms,
),
whisperExecutablePath:
normalizeNullablePath(raw.whisperExecutablePath) ?? fallback.whisperExecutablePath,
whisperModelPath:
normalizeNullablePath(raw.whisperModelPath) ?? fallback.whisperModelPath,
whisperModelPath: normalizeNullablePath(raw.whisperModelPath) ?? fallback.whisperModelPath,
};
}
+129 -46
View File
@@ -18,6 +18,7 @@ import {
type AutoCaptionSettings,
type CaptionCue,
type CaptionCueWord,
type ClipRegion,
type CropRegion,
type CursorStyle,
DEFAULT_ANNOTATION_POSITION,
@@ -57,7 +58,6 @@ import {
getDefaultCaptionFontFamily,
type SpeedRegion,
type TrimRegion,
type ClipRegion,
type WebcamOverlaySettings,
type ZoomRegion,
type ZoomTransitionEasing,
@@ -151,7 +151,7 @@ export function normalizeExportPipelineModel(value: unknown): ExportPipelineMode
return value;
}
return "legacy";
return "modern";
}
export function normalizeExportMp4FrameRate(value: unknown): ExportMp4FrameRate {
@@ -304,10 +304,16 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
const normalizedZoomRegions: ZoomRegion[] = Array.isArray(editor.zoomRegions)
? editor.zoomRegions
.filter((region): region is ZoomRegion => Boolean(region && typeof region.id === "string"))
.filter((region): region is ZoomRegion =>
Boolean(region && typeof region.id === "string"),
)
.map((region) => {
const rawStart = isFiniteNumber(region.startMs) ? Math.round(region.startMs) : 0;
const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000;
const rawStart = isFiniteNumber(region.startMs)
? Math.round(region.startMs)
: 0;
const rawEnd = isFiniteNumber(region.endMs)
? Math.round(region.endMs)
: rawStart + 1000;
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
const endMs = Math.max(startMs + 1, rawEnd);
@@ -315,10 +321,20 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
id: region.id,
startMs,
endMs,
depth: [1, 2, 3, 4, 5, 6].includes(region.depth) ? region.depth : DEFAULT_ZOOM_DEPTH,
depth: [1, 2, 3, 4, 5, 6].includes(region.depth)
? region.depth
: DEFAULT_ZOOM_DEPTH,
focus: {
cx: clamp(isFiniteNumber(region.focus?.cx) ? region.focus.cx : 0.5, 0, 1),
cy: clamp(isFiniteNumber(region.focus?.cy) ? region.focus.cy : 0.5, 0, 1),
cx: clamp(
isFiniteNumber(region.focus?.cx) ? region.focus.cx : 0.5,
0,
1,
),
cy: clamp(
isFiniteNumber(region.focus?.cy) ? region.focus.cy : 0.5,
0,
1,
),
},
};
})
@@ -326,10 +342,16 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
const normalizedTrimRegions: TrimRegion[] = Array.isArray(editor.trimRegions)
? editor.trimRegions
.filter((region): region is TrimRegion => Boolean(region && typeof region.id === "string"))
.filter((region): region is TrimRegion =>
Boolean(region && typeof region.id === "string"),
)
.map((region) => {
const rawStart = isFiniteNumber(region.startMs) ? Math.round(region.startMs) : 0;
const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000;
const rawStart = isFiniteNumber(region.startMs)
? Math.round(region.startMs)
: 0;
const rawEnd = isFiniteNumber(region.endMs)
? Math.round(region.endMs)
: rawStart + 1000;
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
const endMs = Math.max(startMs + 1, rawEnd);
return {
@@ -340,29 +362,41 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
})
: [];
const normalizedClipRegions: ClipRegion[] = Array.isArray((editor as any).clipRegions)
? ((editor as any).clipRegions as ClipRegion[])
.filter((region): region is ClipRegion => Boolean(region && typeof region.id === "string"))
const normalizedClipRegions: ClipRegion[] = Array.isArray(editor.clipRegions)
? editor.clipRegions
.filter((region): region is ClipRegion =>
Boolean(region && typeof region.id === "string"),
)
.map((region) => {
const rawStart = isFiniteNumber(region.startMs) ? Math.round(region.startMs) : 0;
const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000;
const rawStart = isFiniteNumber(region.startMs)
? Math.round(region.startMs)
: 0;
const rawEnd = isFiniteNumber(region.endMs)
? Math.round(region.endMs)
: rawStart + 1000;
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
const endMs = Math.max(startMs + 1, rawEnd);
return {
id: region.id,
startMs,
endMs,
speed: isFiniteNumber((region as any).speed) ? (region as any).speed : 1,
speed: isFiniteNumber(region.speed) ? region.speed : 1,
};
})
: [];
const normalizedSpeedRegions: SpeedRegion[] = Array.isArray(editor.speedRegions)
? editor.speedRegions
.filter((region): region is SpeedRegion => Boolean(region && typeof region.id === "string"))
.filter((region): region is SpeedRegion =>
Boolean(region && typeof region.id === "string"),
)
.map((region) => {
const rawStart = isFiniteNumber(region.startMs) ? Math.round(region.startMs) : 0;
const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000;
const rawStart = isFiniteNumber(region.startMs)
? Math.round(region.startMs)
: 0;
const rawEnd = isFiniteNumber(region.endMs)
? Math.round(region.endMs)
: rawStart + 1000;
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
const endMs = Math.max(startMs + 1, rawEnd);
@@ -392,8 +426,12 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
Boolean(region && typeof region.id === "string"),
)
.map((region, index) => {
const rawStart = isFiniteNumber(region.startMs) ? Math.round(region.startMs) : 0;
const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000;
const rawStart = isFiniteNumber(region.startMs)
? Math.round(region.startMs)
: 0;
const rawEnd = isFiniteNumber(region.endMs)
? Math.round(region.endMs)
: rawStart + 1000;
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
const endMs = Math.max(startMs + 1, rawEnd);
@@ -401,10 +439,19 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
id: region.id,
startMs,
endMs,
type: region.type === "image" || region.type === "figure" || region.type === "blur" ? region.type : "text",
type:
region.type === "image" ||
region.type === "figure" ||
region.type === "blur"
? region.type
: "text",
content: typeof region.content === "string" ? region.content : "",
textContent: typeof region.textContent === "string" ? region.textContent : undefined,
imageContent: typeof region.imageContent === "string" ? region.imageContent : undefined,
textContent:
typeof region.textContent === "string" ? region.textContent : undefined,
imageContent:
typeof region.imageContent === "string"
? region.imageContent
: undefined,
position: {
x: clamp(
isFiniteNumber(region.position?.x)
@@ -439,7 +486,9 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
},
style: {
...DEFAULT_ANNOTATION_STYLE,
...(region.style && typeof region.style === "object" ? region.style : {}),
...(region.style && typeof region.style === "object"
? region.style
: {}),
},
zIndex: isFiniteNumber(region.zIndex) ? region.zIndex : index + 1,
figureData: region.figureData
@@ -448,10 +497,14 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
...region.figureData,
}
: undefined,
blurIntensity: isFiniteNumber(region.blurIntensity)
? clamp(region.blurIntensity, 1, 100)
blurIntensity: isFiniteNumber(region.blurIntensity)
? clamp(region.blurIntensity, 1, 100)
: 20,
blurColor: typeof region.blurColor === "string" ? region.blurColor : undefined,
blurColor:
typeof region.blurColor === "string" ? region.blurColor : undefined,
trackIndex: isFiniteNumber(region.trackIndex)
? Math.max(0, Math.floor(region.trackIndex))
: 0,
};
})
: [];
@@ -460,10 +513,16 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
(editor as Partial<ProjectEditorState>).audioRegions,
)
? ((editor as Partial<ProjectEditorState>).audioRegions as AudioRegion[])
.filter((region): region is AudioRegion => Boolean(region && typeof region.id === "string"))
.filter((region): region is AudioRegion =>
Boolean(region && typeof region.id === "string"),
)
.map((region) => {
const rawStart = isFiniteNumber(region.startMs) ? Math.round(region.startMs) : 0;
const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000;
const rawStart = isFiniteNumber(region.startMs)
? Math.round(region.startMs)
: 0;
const rawEnd = isFiniteNumber(region.endMs)
? Math.round(region.endMs)
: rawStart + 1000;
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
const endMs = Math.max(startMs + 1, rawEnd);
@@ -473,6 +532,9 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
endMs,
audioPath: typeof region.audioPath === "string" ? region.audioPath : "",
volume: isFiniteNumber(region.volume) ? clamp(region.volume, 0, 1) : 1,
trackIndex: isFiniteNumber(region.trackIndex)
? Math.max(0, Math.floor(region.trackIndex))
: 0,
};
})
: [];
@@ -484,7 +546,9 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
.filter((cue): cue is CaptionCue => Boolean(cue && typeof cue.id === "string"))
.map((cue) => {
const rawStart = isFiniteNumber(cue.startMs) ? Math.round(cue.startMs) : 0;
const rawEnd = isFiniteNumber(cue.endMs) ? Math.round(cue.endMs) : rawStart + 1000;
const rawEnd = isFiniteNumber(cue.endMs)
? Math.round(cue.endMs)
: rawStart + 1000;
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
const endMs = Math.max(startMs + 1, rawEnd);
const words: CaptionCueWord[] | undefined = Array.isArray(cue.words)
@@ -499,8 +563,16 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
const rawWordEnd = isFiniteNumber(word.endMs)
? Math.round(word.endMs)
: rawWordStart + 1;
const normalizedWordStart = clamp(rawWordStart, startMs, endMs - 1);
const normalizedWordEnd = clamp(rawWordEnd, normalizedWordStart + 1, endMs);
const normalizedWordStart = clamp(
rawWordStart,
startMs,
endMs - 1,
);
const normalizedWordEnd = clamp(
rawWordEnd,
normalizedWordStart + 1,
endMs,
);
return {
text: word.text.trim(),
@@ -533,7 +605,8 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
? rawAutoCaptionSettings.enabled
: DEFAULT_AUTO_CAPTION_SETTINGS.enabled,
language:
typeof rawAutoCaptionSettings.language === "string" && rawAutoCaptionSettings.language.trim()
typeof rawAutoCaptionSettings.language === "string" &&
rawAutoCaptionSettings.language.trim()
? rawAutoCaptionSettings.language.trim()
: DEFAULT_AUTO_CAPTION_SETTINGS.language,
fontFamily: getDefaultCaptionFontFamily(),
@@ -627,12 +700,11 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
cursorSmoothing: isFiniteNumber(editor.cursorSmoothing)
? clamp(editor.cursorSmoothing, 0, 2)
: DEFAULT_CURSOR_SMOOTHING,
zoomSmoothness: isFiniteNumber((editor as any).zoomSmoothness)
? clamp((editor as any).zoomSmoothness as number, 0, 1)
zoomSmoothness: isFiniteNumber(editor.zoomSmoothness)
? clamp(editor.zoomSmoothness, 0, 1)
: 0.5,
zoomClassicMode: typeof (editor as any).zoomClassicMode === 'boolean'
? (editor as any).zoomClassicMode
: false,
zoomClassicMode:
typeof editor.zoomClassicMode === "boolean" ? editor.zoomClassicMode : false,
cursorMotionBlur: isFiniteNumber((editor as Partial<ProjectEditorState>).cursorMotionBlur)
? clamp((editor as Partial<ProjectEditorState>).cursorMotionBlur as number, 0, 2)
: DEFAULT_CURSOR_MOTION_BLUR,
@@ -642,7 +714,11 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
cursorClickBounceDuration: isFiniteNumber(
(editor as Partial<ProjectEditorState>).cursorClickBounceDuration,
)
? clamp((editor as Partial<ProjectEditorState>).cursorClickBounceDuration as number, 60, 500)
? clamp(
(editor as Partial<ProjectEditorState>).cursorClickBounceDuration as number,
60,
500,
)
: DEFAULT_CURSOR_CLICK_BOUNCE_DURATION,
cursorSway: isFiniteNumber((editor as Partial<ProjectEditorState>).cursorSway)
? clamp((editor as Partial<ProjectEditorState>).cursorSway as number, 0, 2)
@@ -666,9 +742,12 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
autoCaptionSettings: normalizedAutoCaptionSettings,
webcam: {
enabled:
typeof webcam.enabled === "boolean" ? webcam.enabled : DEFAULT_WEBCAM_OVERLAY.enabled,
typeof webcam.enabled === "boolean"
? webcam.enabled
: DEFAULT_WEBCAM_OVERLAY.enabled,
sourcePath: webcamSourcePath,
mirror: typeof webcam.mirror === "boolean" ? webcam.mirror : DEFAULT_WEBCAM_OVERLAY.mirror,
mirror:
typeof webcam.mirror === "boolean" ? webcam.mirror : DEFAULT_WEBCAM_OVERLAY.mirror,
positionPreset:
webcam.positionPreset === "top-left" ||
webcam.positionPreset === "top-center" ||
@@ -710,11 +789,15 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
cornerRadius: isFiniteNumber(webcam.cornerRadius)
? clamp(webcam.cornerRadius, 0, 160)
: DEFAULT_WEBCAM_CORNER_RADIUS,
shadow: isFiniteNumber(webcam.shadow) ? clamp(webcam.shadow, 0, 1) : DEFAULT_WEBCAM_SHADOW,
shadow: isFiniteNumber(webcam.shadow)
? clamp(webcam.shadow, 0, 1)
: DEFAULT_WEBCAM_SHADOW,
timeOffsetMs: isFiniteNumber(webcam.timeOffsetMs)
? Math.round(webcam.timeOffsetMs)
: DEFAULT_WEBCAM_TIME_OFFSET_MS,
margin: isFiniteNumber(webcam.margin) ? clamp(webcam.margin, 0, 96) : DEFAULT_WEBCAM_MARGIN,
margin: isFiniteNumber(webcam.margin)
? clamp(webcam.margin, 0, 96)
: DEFAULT_WEBCAM_MARGIN,
},
aspectRatio:
typeof editor.aspectRatio === "string" &&
+211 -204
View File
@@ -1,261 +1,268 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react'
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import {
DEFAULT_LOCALE,
I18N_NAMESPACES,
SUPPORTED_LOCALES,
type AppLocale,
type I18nNamespace,
} from '@/i18n/config'
import enCommon from '@/i18n/locales/en/common.json'
import enDialogs from '@/i18n/locales/en/dialogs.json'
import enEditor from '@/i18n/locales/en/editor.json'
import enLaunch from '@/i18n/locales/en/launch.json'
import enSettings from '@/i18n/locales/en/settings.json'
import enShortcuts from '@/i18n/locales/en/shortcuts.json'
import enTimeline from '@/i18n/locales/en/timeline.json'
import enExtensions from '@/i18n/locales/en/extensions.json'
import esCommon from '@/i18n/locales/es/common.json'
import esDialogs from '@/i18n/locales/es/dialogs.json'
import esEditor from '@/i18n/locales/es/editor.json'
import esLaunch from '@/i18n/locales/es/launch.json'
import esSettings from '@/i18n/locales/es/settings.json'
import esShortcuts from '@/i18n/locales/es/shortcuts.json'
import esTimeline from '@/i18n/locales/es/timeline.json'
import esExtensions from '@/i18n/locales/es/extensions.json'
import nlCommon from '@/i18n/locales/nl/common.json'
import nlDialogs from '@/i18n/locales/nl/dialogs.json'
import nlEditor from '@/i18n/locales/nl/editor.json'
import nlLaunch from '@/i18n/locales/nl/launch.json'
import nlSettings from '@/i18n/locales/nl/settings.json'
import nlShortcuts from '@/i18n/locales/nl/shortcuts.json'
import nlTimeline from '@/i18n/locales/nl/timeline.json'
import nlExtensions from '@/i18n/locales/nl/extensions.json'
import koCommon from '@/i18n/locales/ko/common.json'
import koDialogs from '@/i18n/locales/ko/dialogs.json'
import koEditor from '@/i18n/locales/ko/editor.json'
import koLaunch from '@/i18n/locales/ko/launch.json'
import koSettings from '@/i18n/locales/ko/settings.json'
import koShortcuts from '@/i18n/locales/ko/shortcuts.json'
import koTimeline from '@/i18n/locales/ko/timeline.json'
import koExtensions from '@/i18n/locales/ko/extensions.json'
import zhCNCommon from '@/i18n/locales/zh-CN/common.json'
import zhCNDialogs from '@/i18n/locales/zh-CN/dialogs.json'
import zhCNEditor from '@/i18n/locales/zh-CN/editor.json'
import zhCNLaunch from '@/i18n/locales/zh-CN/launch.json'
import zhCNSettings from '@/i18n/locales/zh-CN/settings.json'
import zhCNShortcuts from '@/i18n/locales/zh-CN/shortcuts.json'
import zhCNTimeline from '@/i18n/locales/zh-CN/timeline.json'
import zhCNExtensions from '@/i18n/locales/zh-CN/extensions.json'
type AppLocale,
DEFAULT_LOCALE,
I18N_NAMESPACES,
type I18nNamespace,
SUPPORTED_LOCALES,
} from "@/i18n/config";
import enCommon from "@/i18n/locales/en/common.json";
import enDialogs from "@/i18n/locales/en/dialogs.json";
import enEditor from "@/i18n/locales/en/editor.json";
import enExtensions from "@/i18n/locales/en/extensions.json";
import enLaunch from "@/i18n/locales/en/launch.json";
import enSettings from "@/i18n/locales/en/settings.json";
import enShortcuts from "@/i18n/locales/en/shortcuts.json";
import enTimeline from "@/i18n/locales/en/timeline.json";
import esCommon from "@/i18n/locales/es/common.json";
import esDialogs from "@/i18n/locales/es/dialogs.json";
import esEditor from "@/i18n/locales/es/editor.json";
import esExtensions from "@/i18n/locales/es/extensions.json";
import esLaunch from "@/i18n/locales/es/launch.json";
import esSettings from "@/i18n/locales/es/settings.json";
import esShortcuts from "@/i18n/locales/es/shortcuts.json";
import esTimeline from "@/i18n/locales/es/timeline.json";
import koCommon from "@/i18n/locales/ko/common.json";
import koDialogs from "@/i18n/locales/ko/dialogs.json";
import koEditor from "@/i18n/locales/ko/editor.json";
import koExtensions from "@/i18n/locales/ko/extensions.json";
import koLaunch from "@/i18n/locales/ko/launch.json";
import koSettings from "@/i18n/locales/ko/settings.json";
import koShortcuts from "@/i18n/locales/ko/shortcuts.json";
import koTimeline from "@/i18n/locales/ko/timeline.json";
import nlCommon from "@/i18n/locales/nl/common.json";
import nlDialogs from "@/i18n/locales/nl/dialogs.json";
import nlEditor from "@/i18n/locales/nl/editor.json";
import nlExtensions from "@/i18n/locales/nl/extensions.json";
import nlLaunch from "@/i18n/locales/nl/launch.json";
import nlSettings from "@/i18n/locales/nl/settings.json";
import nlShortcuts from "@/i18n/locales/nl/shortcuts.json";
import nlTimeline from "@/i18n/locales/nl/timeline.json";
import zhCNCommon from "@/i18n/locales/zh-CN/common.json";
import zhCNDialogs from "@/i18n/locales/zh-CN/dialogs.json";
import zhCNEditor from "@/i18n/locales/zh-CN/editor.json";
import zhCNExtensions from "@/i18n/locales/zh-CN/extensions.json";
import zhCNLaunch from "@/i18n/locales/zh-CN/launch.json";
import zhCNSettings from "@/i18n/locales/zh-CN/settings.json";
import zhCNShortcuts from "@/i18n/locales/zh-CN/shortcuts.json";
import zhCNTimeline from "@/i18n/locales/zh-CN/timeline.json";
const LOCALE_STORAGE_KEY = 'recordly.locale'
const LOCALE_STORAGE_KEY = "recordly.locale";
type LocaleBundle = Record<I18nNamespace, Record<string, unknown>>
type LocaleBundle = Record<I18nNamespace, Record<string, unknown>>;
const messages: Record<AppLocale, LocaleBundle> = {
en: {
common: enCommon,
launch: enLaunch,
editor: enEditor,
timeline: enTimeline,
settings: enSettings,
dialogs: enDialogs,
shortcuts: enShortcuts,
extensions: enExtensions,
},
es: {
common: esCommon,
launch: esLaunch,
editor: esEditor,
timeline: esTimeline,
settings: esSettings,
dialogs: esDialogs,
shortcuts: esShortcuts,
extensions: esExtensions,
},
nl: {
common: nlCommon,
launch: nlLaunch,
editor: nlEditor,
timeline: nlTimeline,
settings: nlSettings,
dialogs: nlDialogs,
shortcuts: nlShortcuts,
extensions: nlExtensions,
},
ko: {
common: koCommon,
launch: koLaunch,
editor: koEditor,
timeline: koTimeline,
settings: koSettings,
dialogs: koDialogs,
shortcuts: koShortcuts,
extensions: koExtensions,
},
'zh-CN': {
common: zhCNCommon,
launch: zhCNLaunch,
editor: zhCNEditor,
timeline: zhCNTimeline,
settings: zhCNSettings,
dialogs: zhCNDialogs,
shortcuts: zhCNShortcuts,
extensions: zhCNExtensions,
},
} as const
en: {
common: enCommon,
launch: enLaunch,
editor: enEditor,
timeline: enTimeline,
settings: enSettings,
dialogs: enDialogs,
shortcuts: enShortcuts,
extensions: enExtensions,
},
es: {
common: esCommon,
launch: esLaunch,
editor: esEditor,
timeline: esTimeline,
settings: esSettings,
dialogs: esDialogs,
shortcuts: esShortcuts,
extensions: esExtensions,
},
nl: {
common: nlCommon,
launch: nlLaunch,
editor: nlEditor,
timeline: nlTimeline,
settings: nlSettings,
dialogs: nlDialogs,
shortcuts: nlShortcuts,
extensions: nlExtensions,
},
ko: {
common: koCommon,
launch: koLaunch,
editor: koEditor,
timeline: koTimeline,
settings: koSettings,
dialogs: koDialogs,
shortcuts: koShortcuts,
extensions: koExtensions,
},
"zh-CN": {
common: zhCNCommon,
launch: zhCNLaunch,
editor: zhCNEditor,
timeline: zhCNTimeline,
settings: zhCNSettings,
dialogs: zhCNDialogs,
shortcuts: zhCNShortcuts,
extensions: zhCNExtensions,
},
} as const;
interface I18nContextValue {
locale: AppLocale
setLocale: (locale: AppLocale) => void
t: (key: string, fallback?: string, vars?: Record<string, string | number>) => string
locale: AppLocale;
setLocale: (locale: AppLocale) => void;
t: (key: string, fallback?: string, vars?: Record<string, string | number>) => string;
}
const I18nContext = createContext<I18nContextValue | null>(null)
const I18nContext = createContext<I18nContextValue | null>(null);
function isSupportedLocale(locale: string): locale is AppLocale {
return SUPPORTED_LOCALES.includes(locale as AppLocale)
return SUPPORTED_LOCALES.includes(locale as AppLocale);
}
function normalizeLocale(locale: string | null | undefined): AppLocale {
if (!locale) {
return DEFAULT_LOCALE
}
if (!locale) {
return DEFAULT_LOCALE;
}
// Exact match first (e.g. "zh-CN")
if (isSupportedLocale(locale)) return locale
// Exact match first (e.g. "zh-CN")
if (isSupportedLocale(locale)) return locale;
// Canonicalize case (e.g. "zh-cn" → "zh-CN")
const canonical = SUPPORTED_LOCALES.find(
(l) => l.toLowerCase() === locale.toLowerCase(),
)
if (canonical) return canonical
// Canonicalize case (e.g. "zh-cn" → "zh-CN")
const canonical = SUPPORTED_LOCALES.find((l) => l.toLowerCase() === locale.toLowerCase());
if (canonical) return canonical;
// Handle extended subtags like "zh-Hans-CN" → try "zh-CN"
const parts = locale.split('-')
if (parts.length >= 3) {
const langRegion = `${parts[0]}-${parts[parts.length - 1]}`
if (isSupportedLocale(langRegion)) {
return langRegion
}
}
// Handle extended subtags like "zh-Hans-CN" → try "zh-CN"
const parts = locale.split("-");
if (parts.length >= 3) {
const langRegion = `${parts[0]}-${parts[parts.length - 1]}`;
if (isSupportedLocale(langRegion)) {
return langRegion;
}
}
// Language-only fallback (e.g. "zh" matches "zh-CN")
const lang = parts[0].toLowerCase()
const byLang = SUPPORTED_LOCALES.find((l) => l.split('-')[0].toLowerCase() === lang)
if (byLang) return byLang
// Language-only fallback (e.g. "zh" matches "zh-CN")
const lang = parts[0].toLowerCase();
const byLang = SUPPORTED_LOCALES.find((l) => l.split("-")[0].toLowerCase() === lang);
if (byLang) return byLang;
return DEFAULT_LOCALE
return DEFAULT_LOCALE;
}
function getInitialLocale(): AppLocale {
if (typeof window === 'undefined') {
return DEFAULT_LOCALE
}
if (typeof window === "undefined") {
return DEFAULT_LOCALE;
}
const storedLocale = window.localStorage.getItem(LOCALE_STORAGE_KEY)
if (storedLocale) {
return normalizeLocale(storedLocale)
}
const storedLocale = window.localStorage.getItem(LOCALE_STORAGE_KEY);
if (storedLocale) {
return normalizeLocale(storedLocale);
}
// Product default must be English on first launch unless user explicitly
// selected another locale and we persisted it in localStorage.
return DEFAULT_LOCALE
// Product default must be English on first launch unless user explicitly
// selected another locale and we persisted it in localStorage.
return DEFAULT_LOCALE;
}
function getMessageValue(source: unknown, key: string): string | undefined {
const parts = key.split('.')
let current: unknown = source
const parts = key.split(".");
let current: unknown = source;
for (const part of parts) {
if (!current || typeof current !== 'object' || !(part in current)) {
return undefined
}
for (const part of parts) {
if (!current || typeof current !== "object" || !(part in current)) {
return undefined;
}
current = (current as Record<string, unknown>)[part]
}
current = (current as Record<string, unknown>)[part];
}
return typeof current === 'string' ? current : undefined
return typeof current === "string" ? current : undefined;
}
function interpolate(template: string, vars?: Record<string, string | number>) {
if (!vars) return template
return template.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_match, key) => {
const value = vars[key]
return value === undefined ? '' : String(value)
})
if (!vars) return template;
return template.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_match, key) => {
const value = vars[key];
return value === undefined ? "" : String(value);
});
}
function parseKey(key: string): { namespace: I18nNamespace; path: string } {
const [first, ...rest] = key.split('.')
if (I18N_NAMESPACES.includes(first as I18nNamespace) && rest.length > 0) {
return { namespace: first as I18nNamespace, path: rest.join('.') }
}
return { namespace: 'common', path: key }
const [first, ...rest] = key.split(".");
if (I18N_NAMESPACES.includes(first as I18nNamespace) && rest.length > 0) {
return { namespace: first as I18nNamespace, path: rest.join(".") };
}
return { namespace: "common", path: key };
}
function translateForLocale(
locale: AppLocale,
key: string,
fallback?: string,
vars?: Record<string, string | number>,
locale: AppLocale,
key: string,
fallback?: string,
vars?: Record<string, string | number>,
) {
const { namespace, path } = parseKey(key)
const { namespace, path } = parseKey(key);
const rawValue =
getMessageValue(messages[locale][namespace], path)
?? getMessageValue(messages[DEFAULT_LOCALE][namespace], path)
?? fallback
?? key
const rawValue =
getMessageValue(messages[locale][namespace], path) ??
getMessageValue(messages[DEFAULT_LOCALE][namespace], path) ??
fallback ??
key;
return interpolate(rawValue, vars)
return interpolate(rawValue, vars);
}
export function I18nProvider({ children }: { children: ReactNode }) {
const [locale, setLocaleState] = useState<AppLocale>(getInitialLocale)
const [locale, setLocaleState] = useState<AppLocale>(getInitialLocale);
const setLocale = useCallback((nextLocale: AppLocale) => {
setLocaleState(nextLocale)
if (typeof window !== 'undefined') {
window.localStorage.setItem(LOCALE_STORAGE_KEY, nextLocale)
}
}, [])
const setLocale = useCallback((nextLocale: AppLocale) => {
setLocaleState(nextLocale);
if (typeof window !== "undefined") {
window.localStorage.setItem(LOCALE_STORAGE_KEY, nextLocale);
}
}, []);
useEffect(() => {
document.documentElement.lang = locale
}, [locale])
useEffect(() => {
document.documentElement.lang = locale;
}, [locale]);
const t = useCallback((key: string, fallback?: string, vars?: Record<string, string | number>) => {
return translateForLocale(locale, key, fallback, vars)
}, [locale])
const t = useCallback(
(key: string, fallback?: string, vars?: Record<string, string | number>) => {
return translateForLocale(locale, key, fallback, vars);
},
[locale],
);
const value = useMemo<I18nContextValue>(() => ({
locale,
setLocale,
t,
}), [locale, setLocale, t])
const value = useMemo<I18nContextValue>(
() => ({
locale,
setLocale,
t,
}),
[locale, setLocale, t],
);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}
export function useI18n() {
const context = useContext(I18nContext)
if (!context) {
throw new Error('useI18n must be used within <I18nProvider>')
}
return context
const context = useContext(I18nContext);
if (!context) {
throw new Error("useI18n must be used within <I18nProvider>");
}
return context;
}
export function useScopedT(namespace: I18nNamespace) {
const { t } = useI18n()
return useCallback((key: string, fallback?: string, vars?: Record<string, string | number>) => {
return t(`${namespace}.${key}`, fallback, vars)
}, [namespace, t])
}
const { t } = useI18n();
return useCallback(
(key: string, fallback?: string, vars?: Record<string, string | number>) => {
return t(`${namespace}.${key}`, fallback, vars);
},
[namespace, t],
);
}
+58 -44
View File
@@ -1,61 +1,75 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
import { DEFAULT_SHORTCUTS, mergeWithDefaults, type ShortcutsConfig } from '@/lib/shortcuts';
import { isMac as getIsMac } from '@/utils/platformUtils';
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { DEFAULT_SHORTCUTS, mergeWithDefaults, type ShortcutsConfig } from "@/lib/shortcuts";
import { isMac as getIsMac } from "@/utils/platformUtils";
interface ShortcutsContextValue {
shortcuts: ShortcutsConfig;
isMac: boolean;
setShortcuts: (config: ShortcutsConfig) => void;
persistShortcuts: (config?: ShortcutsConfig) => Promise<void>;
isConfigOpen: boolean;
openConfig: () => void;
closeConfig: () => void;
shortcuts: ShortcutsConfig;
isMac: boolean;
setShortcuts: (config: ShortcutsConfig) => void;
persistShortcuts: (config?: ShortcutsConfig) => Promise<void>;
isConfigOpen: boolean;
openConfig: () => void;
closeConfig: () => void;
}
const ShortcutsContext = createContext<ShortcutsContextValue | null>(null);
export function useShortcuts(): ShortcutsContextValue {
const ctx = useContext(ShortcutsContext);
if (!ctx) throw new Error('useShortcuts must be used within <ShortcutsProvider>');
return ctx;
const ctx = useContext(ShortcutsContext);
if (!ctx) throw new Error("useShortcuts must be used within <ShortcutsProvider>");
return ctx;
}
export function ShortcutsProvider({ children }: { children: ReactNode }) {
const [shortcuts, setShortcuts] = useState<ShortcutsConfig>(DEFAULT_SHORTCUTS);
const [isMac, setIsMac] = useState(false);
const [isConfigOpen, setIsConfigOpen] = useState(false);
const [shortcuts, setShortcuts] = useState<ShortcutsConfig>(DEFAULT_SHORTCUTS);
const [isMac, setIsMac] = useState(false);
const [isConfigOpen, setIsConfigOpen] = useState(false);
useEffect(() => {
getIsMac().then(setIsMac).catch(() => {});
useEffect(() => {
getIsMac()
.then(setIsMac)
.catch(() => undefined);
window.electronAPI.getShortcuts?.()
.then((saved) => {
if (saved) {
setShortcuts(mergeWithDefaults(saved as Partial<ShortcutsConfig>));
}
})
.catch(() => {});
}, []);
window.electronAPI
?.getShortcuts?.()
.then((saved) => {
if (saved) {
setShortcuts(mergeWithDefaults(saved as Partial<ShortcutsConfig>));
}
})
.catch(() => undefined);
}, []);
const persistShortcuts = useCallback(
async (config?: ShortcutsConfig) => {
await window.electronAPI.saveShortcuts?.(config ?? shortcuts);
},
[shortcuts],
);
const persistShortcuts = useCallback(
async (config?: ShortcutsConfig) => {
await window.electronAPI?.saveShortcuts?.(config ?? shortcuts);
},
[shortcuts],
);
const openConfig = useCallback(() => setIsConfigOpen(true), []);
const closeConfig = useCallback(() => setIsConfigOpen(false), []);
const openConfig = useCallback(() => setIsConfigOpen(true), []);
const closeConfig = useCallback(() => setIsConfigOpen(false), []);
const value = useMemo<ShortcutsContextValue>(
() => ({ shortcuts, isMac, setShortcuts, persistShortcuts, isConfigOpen, openConfig, closeConfig }),
[shortcuts, isMac, persistShortcuts, isConfigOpen, openConfig, closeConfig],
);
const value = useMemo<ShortcutsContextValue>(
() => ({
shortcuts,
isMac,
setShortcuts,
persistShortcuts,
isConfigOpen,
openConfig,
closeConfig,
}),
[shortcuts, isMac, persistShortcuts, isConfigOpen, openConfig, closeConfig],
);
return (
<ShortcutsContext.Provider value={value}>
{children}
</ShortcutsContext.Provider>
);
return <ShortcutsContext.Provider value={value}>{children}</ShortcutsContext.Provider>;
}
+242 -205
View File
@@ -6,240 +6,277 @@
* that need render hooks.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { extensionHost } from '@/lib/extensions';
import type { ExtensionInfo, MarketplaceSearchResult, ExtensionReview, MarketplaceReviewStatus } from '@/lib/extensions';
import { createExtensionModuleUrl } from '@/lib/extensions/fileUrls';
import { useCallback, useEffect, useRef, useState } from "react";
import type {
ExtensionInfo,
ExtensionReview,
MarketplaceReviewStatus,
MarketplaceSearchResult,
} from "@/lib/extensions";
import { extensionHost } from "@/lib/extensions";
import { createExtensionModuleUrl } from "@/lib/extensions/fileUrls";
const electronAPI = (window as any).electronAPI;
const electronAPI = typeof window === "undefined" ? undefined : window.electronAPI;
export interface UseExtensionsResult {
/** All discovered extensions */
extensions: ExtensionInfo[];
/** Currently active extension IDs */
activeIds: Set<string>;
/** Whether initial discovery is complete */
ready: boolean;
/** Discover/refresh extensions from disk */
refresh: () => Promise<void>;
/** Toggle an extension on/off */
toggleExtension: (id: string) => Promise<void>;
/** Install an extension from a folder */
installFromFolder: () => Promise<boolean>;
/** Uninstall an extension */
uninstall: (id: string) => Promise<boolean>;
/** Open the extensions directory in Finder/Explorer */
openDirectory: () => Promise<void>;
/** Search the marketplace */
marketplaceSearch: (params: {
query?: string;
tags?: string[];
sort?: 'popular' | 'recent' | 'rating';
page?: number;
pageSize?: number;
}) => Promise<MarketplaceSearchResult>;
/** Download and install from marketplace */
marketplaceInstall: (extensionId: string, downloadUrl: string) => Promise<{ success: boolean; error?: string }>;
/** Submit extension for review */
marketplaceSubmit: (extensionId: string) => Promise<{ success: boolean; error?: string }>;
/** Fetch pending reviews (admin) */
fetchReviews: (params: {
status?: MarketplaceReviewStatus;
page?: number;
pageSize?: number;
}) => Promise<{ reviews: ExtensionReview[]; total: number }>;
/** Update review status (admin) */
updateReview: (reviewId: string, status: MarketplaceReviewStatus, notes?: string) => Promise<{ success: boolean }>;
/** All discovered extensions */
extensions: ExtensionInfo[];
/** Currently active extension IDs */
activeIds: Set<string>;
/** Whether initial discovery is complete */
ready: boolean;
/** Discover/refresh extensions from disk */
refresh: () => Promise<void>;
/** Toggle an extension on/off */
toggleExtension: (id: string) => Promise<void>;
/** Install an extension from a folder */
installFromFolder: () => Promise<boolean>;
/** Uninstall an extension */
uninstall: (id: string) => Promise<boolean>;
/** Open the extensions directory in Finder/Explorer */
openDirectory: () => Promise<void>;
/** Search the marketplace */
marketplaceSearch: (params: {
query?: string;
tags?: string[];
sort?: "popular" | "recent" | "rating";
page?: number;
pageSize?: number;
}) => Promise<MarketplaceSearchResult>;
/** Download and install from marketplace */
marketplaceInstall: (
extensionId: string,
downloadUrl: string,
) => Promise<{ success: boolean; error?: string }>;
/** Submit extension for review */
marketplaceSubmit: (extensionId: string) => Promise<{ success: boolean; error?: string }>;
/** Fetch pending reviews (admin) */
fetchReviews: (params: {
status?: MarketplaceReviewStatus;
page?: number;
pageSize?: number;
}) => Promise<{ reviews: ExtensionReview[]; total: number }>;
/** Update review status (admin) */
updateReview: (
reviewId: string,
status: MarketplaceReviewStatus,
notes?: string,
) => Promise<{ success: boolean }>;
}
export function useExtensions(): UseExtensionsResult {
const [extensions, setExtensions] = useState<ExtensionInfo[]>([]);
const [activeIds, setActiveIds] = useState<Set<string>>(new Set());
const [ready, setReady] = useState(false);
const activatingRef = useRef(new Set<string>());
const [extensions, setExtensions] = useState<ExtensionInfo[]>([]);
const [activeIds, setActiveIds] = useState<Set<string>>(new Set());
const [ready, setReady] = useState(false);
const activatingRef = useRef(new Set<string>());
const discoverAndSync = useCallback(async (): Promise<ExtensionInfo[]> => {
if (!electronAPI?.extensionsDiscover) return [];
const discoverAndSync = useCallback(async (): Promise<ExtensionInfo[]> => {
if (!electronAPI?.extensionsDiscover) return [];
const discovered: ExtensionInfo[] = await electronAPI.extensionsDiscover();
setExtensions(discovered);
setReady(true);
await extensionHost.syncConfiguredExtensions(discovered);
const discovered: ExtensionInfo[] = await electronAPI.extensionsDiscover();
setExtensions(discovered);
setReady(true);
await extensionHost.syncConfiguredExtensions(discovered);
return discovered;
}, []);
return discovered;
}, []);
const refresh = useCallback(async () => {
await discoverAndSync();
}, [discoverAndSync]);
const refresh = useCallback(async () => {
await discoverAndSync();
}, [discoverAndSync]);
// Auto-discover on mount and restore extensions marked active.
useEffect(() => {
void discoverAndSync();
}, [discoverAndSync]);
// Auto-discover on mount and restore extensions marked active.
useEffect(() => {
void discoverAndSync();
}, [discoverAndSync]);
// Sync activeIds with extension host (immediate + future changes)
useEffect(() => {
const sync = () => {
const active = extensionHost.getActiveExtensions();
setActiveIds(new Set(active.map(e => e.manifest.id)));
};
// Immediately sync with any already-active extensions
sync();
return extensionHost.onChange(sync);
}, []);
// Sync activeIds with extension host (immediate + future changes)
useEffect(() => {
const sync = () => {
const active = extensionHost.getActiveExtensions();
setActiveIds(new Set(active.map((e) => e.manifest.id)));
};
// Immediately sync with any already-active extensions
sync();
return extensionHost.onChange(sync);
}, []);
const toggleExtension = useCallback(async (id: string) => {
if (activatingRef.current.has(id)) return;
activatingRef.current.add(id);
const toggleExtension = useCallback(
async (id: string) => {
if (activatingRef.current.has(id)) return;
activatingRef.current.add(id);
try {
if (activeIds.has(id)) {
await extensionHost.deactivateExtension(id);
await electronAPI?.extensionsDisable(id);
setExtensions((prev) =>
prev.map((ext) => (ext.manifest.id === id ? { ...ext, status: 'disabled' } : ext)),
);
} else {
const ext = extensions.find(e => e.manifest.id === id);
if (!ext) return;
try {
if (activeIds.has(id)) {
await extensionHost.deactivateExtension(id);
await electronAPI?.extensionsDisable(id);
setExtensions((prev) =>
prev.map((ext) =>
ext.manifest.id === id ? { ...ext, status: "disabled" } : ext,
),
);
} else {
const ext = extensions.find((e) => e.manifest.id === id);
if (!ext) return;
try {
await electronAPI?.extensionsEnable(id);
try {
await electronAPI?.extensionsEnable(id);
const moduleUrl = createExtensionModuleUrl(ext.path, ext.manifest.main);
await extensionHost.activateExtension(ext, moduleUrl);
setExtensions((prev) =>
prev.map((candidate) => (candidate.manifest.id === id ? { ...candidate, status: 'active' } : candidate)),
);
} catch (err) {
await electronAPI?.extensionsDisable(id);
setExtensions((prev) =>
prev.map((candidate) => (candidate.manifest.id === id ? { ...candidate, status: 'disabled' } : candidate)),
);
throw err;
}
}
} catch (err) {
console.error(`[extensions] Failed to toggle ${id}:`, err);
} finally {
activatingRef.current.delete(id);
}
}, [activeIds, extensions]);
const moduleUrl = createExtensionModuleUrl(ext.path, ext.manifest.main);
await extensionHost.activateExtension(ext, moduleUrl);
setExtensions((prev) =>
prev.map((candidate) =>
candidate.manifest.id === id
? { ...candidate, status: "active" }
: candidate,
),
);
} catch (err) {
await electronAPI?.extensionsDisable(id);
setExtensions((prev) =>
prev.map((candidate) =>
candidate.manifest.id === id
? { ...candidate, status: "disabled" }
: candidate,
),
);
throw err;
}
}
} catch (err) {
console.error(`[extensions] Failed to toggle ${id}:`, err); throw err; } finally {
activatingRef.current.delete(id);
}
},
[activeIds, extensions],
);
const installFromFolder = useCallback(async (): Promise<boolean> => {
if (!electronAPI?.extensionsInstallFromFolder) return false;
const result = await electronAPI.extensionsInstallFromFolder();
if (result?.success) {
const extensionId = result.extension?.manifest?.id;
if (typeof extensionId === 'string') {
await electronAPI?.extensionsEnable(extensionId);
}
await discoverAndSync();
return true;
}
return false;
}, [discoverAndSync]);
const installFromFolder = useCallback(async (): Promise<boolean> => {
if (!electronAPI?.extensionsInstallFromFolder) return false;
const result = await electronAPI.extensionsInstallFromFolder();
if (result?.success) {
const extensionId = result.extension?.manifest?.id;
if (typeof extensionId === "string") {
await electronAPI?.extensionsEnable(extensionId);
}
await discoverAndSync();
return true;
}
return false;
}, [discoverAndSync]);
const uninstall = useCallback(async (id: string): Promise<boolean> => {
// Always deactivate — avoids stale closure over activeIds
await extensionHost.deactivateExtension(id);
if (!electronAPI?.extensionsUninstall) return false;
const result = await electronAPI.extensionsUninstall(id);
if (result?.success) {
await discoverAndSync();
return true;
}
return false;
}, [discoverAndSync]);
const uninstall = useCallback(
async (id: string): Promise<boolean> => {
// Always deactivate — avoids stale closure over activeIds
await extensionHost.deactivateExtension(id);
if (!electronAPI?.extensionsUninstall) return false;
const result = await electronAPI.extensionsUninstall(id);
if (result?.success) {
await discoverAndSync();
return true;
}
return false;
},
[discoverAndSync],
);
const openDirectory = useCallback(async () => {
await electronAPI?.extensionsOpenDirectory();
}, []);
const openDirectory = useCallback(async () => {
await electronAPI?.extensionsOpenDirectory();
}, []);
const marketplaceSearch = useCallback(async (params: {
query?: string;
tags?: string[];
sort?: 'popular' | 'recent' | 'rating';
page?: number;
pageSize?: number;
}): Promise<MarketplaceSearchResult> => {
if (!electronAPI?.extensionsMarketplaceSearch) {
return { extensions: [], total: 0, page: 1, pageSize: 20 };
}
const marketplaceSearch = useCallback(
async (params: {
query?: string;
tags?: string[];
sort?: "popular" | "recent" | "rating";
page?: number;
pageSize?: number;
}): Promise<MarketplaceSearchResult> => {
if (!electronAPI?.extensionsMarketplaceSearch) {
return { extensions: [], total: 0, page: 1, pageSize: 20 };
}
const result = await electronAPI.extensionsMarketplaceSearch(params) as MarketplaceSearchResult & {
error?: string;
};
const result = (await electronAPI.extensionsMarketplaceSearch(
params,
)) as MarketplaceSearchResult & {
error?: string;
};
if (result?.error) {
throw new Error(result.error);
}
if (result?.error) {
throw new Error(result.error);
}
return result;
}, []);
return result;
},
[],
);
const marketplaceInstall = useCallback(async (extensionId: string, downloadUrl: string) => {
if (!electronAPI?.extensionsMarketplaceInstall) {
return { success: false, error: 'Not available' };
}
const result = await electronAPI.extensionsMarketplaceInstall(extensionId, downloadUrl);
if (result.success) {
await electronAPI?.extensionsEnable(extensionId);
await discoverAndSync();
}
return result;
}, [discoverAndSync]);
const marketplaceInstall = useCallback(
async (extensionId: string, downloadUrl: string) => {
if (!electronAPI?.extensionsMarketplaceInstall) {
return { success: false, error: "Not available" };
}
const result = await electronAPI.extensionsMarketplaceInstall(extensionId, downloadUrl);
if (result.success) {
await electronAPI?.extensionsEnable(extensionId);
await discoverAndSync();
}
return result;
},
[discoverAndSync],
);
const marketplaceSubmit = useCallback(async (extensionId: string) => {
if (!electronAPI?.extensionsMarketplaceSubmit) {
return { success: false, error: 'Not available' };
}
return electronAPI.extensionsMarketplaceSubmit(extensionId);
}, []);
const marketplaceSubmit = useCallback(async (extensionId: string) => {
if (!electronAPI?.extensionsMarketplaceSubmit) {
return { success: false, error: "Not available" };
}
return electronAPI.extensionsMarketplaceSubmit(extensionId);
}, []);
const fetchReviews = useCallback(async (params: {
status?: MarketplaceReviewStatus;
page?: number;
pageSize?: number;
}) => {
if (!electronAPI?.extensionsReviewsList) {
return { reviews: [] as ExtensionReview[], total: 0 };
}
const fetchReviews = useCallback(
async (params: { status?: MarketplaceReviewStatus; page?: number; pageSize?: number }) => {
if (!electronAPI?.extensionsReviewsList) {
return { reviews: [] as ExtensionReview[], total: 0 };
}
const result = await electronAPI.extensionsReviewsList(params) as {
reviews: ExtensionReview[];
total: number;
error?: string;
};
const result = (await electronAPI.extensionsReviewsList(params)) as {
reviews: ExtensionReview[];
total: number;
error?: string;
};
if (result?.error) {
throw new Error(result.error);
}
if (result?.error) {
throw new Error(result.error);
}
return result;
}, []);
return result;
},
[],
);
const updateReview = useCallback(async (reviewId: string, status: MarketplaceReviewStatus, notes?: string) => {
if (!electronAPI?.extensionsReviewUpdate) {
return { success: false };
}
return electronAPI.extensionsReviewUpdate(reviewId, status, notes);
}, []);
const updateReview = useCallback(
async (reviewId: string, status: MarketplaceReviewStatus, notes?: string) => {
if (!electronAPI?.extensionsReviewUpdate) {
return { success: false };
}
return electronAPI.extensionsReviewUpdate(reviewId, status, notes);
},
[],
);
return {
extensions,
activeIds,
ready,
refresh,
toggleExtension,
installFromFolder,
uninstall,
openDirectory,
marketplaceSearch,
marketplaceInstall,
marketplaceSubmit,
fetchReviews,
updateReview,
};
return {
extensions,
activeIds,
ready,
refresh,
toggleExtension,
installFromFolder,
uninstall,
openDirectory,
marketplaceSearch,
marketplaceInstall,
marketplaceSubmit,
fetchReviews,
updateReview,
};
}
+13 -13
View File
@@ -1,17 +1,17 @@
export const DEFAULT_LOCALE = 'en' as const
export const DEFAULT_LOCALE = "en" as const;
export const SUPPORTED_LOCALES = ['en', 'es', 'nl', 'ko', 'zh-CN'] as const
export const SUPPORTED_LOCALES = ["en", "es", "nl", "ko", "zh-CN"] as const;
export const I18N_NAMESPACES = [
'common',
'launch',
'editor',
'timeline',
'settings',
'dialogs',
'shortcuts',
'extensions',
] as const
"common",
"launch",
"editor",
"timeline",
"settings",
"dialogs",
"shortcuts",
"extensions",
] as const;
export type AppLocale = (typeof SUPPORTED_LOCALES)[number]
export type I18nNamespace = (typeof I18N_NAMESPACES)[number]
export type AppLocale = (typeof SUPPORTED_LOCALES)[number];
export type I18nNamespace = (typeof I18N_NAMESPACES)[number];
+59 -58
View File
@@ -1,59 +1,60 @@
{
"title": "Extensions",
"tabs": {
"browse": "Browse",
"installed": "Installed"
},
"actions": {
"submit": "Submit an extension",
"docs": "Extension docs",
"refresh": "Refresh",
"openFolder": "Open extensions folder",
"uninstall": "Uninstall",
"install": "Install",
"installing": "Installing",
"add": "Add",
"retry": "Retry",
"close": "Close",
"folder": "Folder"
},
"status": {
"enabled": "Enabled",
"disabled": "Disabled",
"installed": "Installed"
},
"detail": {
"by": "By {{author}}",
"unknownAuthor": "Unknown author",
"noDescription": "No description",
"downloads": "{{count}} downloads",
"preview": "Preview",
"screenshotAlt": "Screenshot {{number}}",
"description": "Description",
"tags": "Tags",
"permissions": "Permissions",
"location": "Location",
"error": "Error: {{message}}"
},
"empty": {
"title": "No Extensions",
"description": "Install extensions to add frames, cursor effects, and editor tools."
},
"search": {
"placeholder": "Search extensions...",
"noResults": "No extensions found",
"noMarketplace": "No marketplace extensions available yet",
"count": "{{count}} extension",
"countPlural": "{{count}} extensions"
},
"toast": {
"installedAndEnabled": "Extension installed and enabled",
"uninstalled": "Uninstalled {{name}}",
"uninstallFailed": "Failed to uninstall {{name}}",
"searchFailed": "Failed to search marketplace",
"refreshed": "Extensions refreshed",
"refreshFailed": "Failed to refresh extensions",
"marketplaceInstalled": "Installed and enabled {{name}}",
"marketplaceInstallFailed": "Failed to install {{name}}"
}
}
"title": "Extensions",
"tabs": {
"browse": "Browse",
"installed": "Installed"
},
"actions": {
"submit": "Submit an extension",
"docs": "Extension docs",
"refresh": "Refresh",
"openFolder": "Open extensions folder",
"uninstall": "Uninstall",
"install": "Install",
"installing": "Installing",
"add": "Add",
"retry": "Retry",
"close": "Close",
"folder": "Folder"
},
"status": {
"enabled": "Enabled",
"disabled": "Disabled",
"installed": "Installed"
},
"detail": {
"by": "By {{author}}",
"unknownAuthor": "Unknown author",
"noDescription": "No description",
"downloads": "{{count}} downloads",
"preview": "Preview",
"screenshotAlt": "Screenshot {{number}}",
"description": "Description",
"tags": "Tags",
"permissions": "Permissions",
"location": "Location",
"error": "Error: {{message}}"
},
"empty": {
"title": "No Extensions",
"description": "Install extensions to add frames, cursor effects, and editor tools."
},
"search": {
"placeholder": "Search extensions...",
"noResults": "No extensions found",
"noMarketplace": "No marketplace extensions available yet",
"count": "{{count}} extension",
"countPlural": "{{count}} extensions"
},
"toast": {
"installedAndEnabled": "Extension installed and enabled",
"uninstalled": "Uninstalled {{name}}",
"uninstallFailed": "Failed to uninstall {{name}}",
"searchFailed": "Failed to search marketplace",
"refreshed": "Extensions refreshed",
"refreshFailed": "Failed to refresh extensions",
"marketplaceInstalled": "Installed and enabled {{name}}",
"marketplaceInstallFailed": "Failed to install {{name}}",
"enableFailed": "Failed to enable extension"
}
}
+58 -57
View File
@@ -1,59 +1,60 @@
{
"title": "Extensiones",
"tabs": {
"browse": "Explorar",
"installed": "Instaladas"
},
"actions": {
"submit": "Enviar una extensión",
"docs": "Documentación de extensiones",
"refresh": "Actualizar",
"openFolder": "Abrir carpeta de extensiones",
"uninstall": "Desinstalar",
"install": "Instalar",
"installing": "Instalando",
"add": "Añadir",
"retry": "Reintentar",
"close": "Cerrar",
"folder": "Carpeta"
},
"status": {
"enabled": "Activada",
"disabled": "Desactivada",
"installed": "Instalada"
},
"detail": {
"by": "Por {{author}}",
"unknownAuthor": "Autor desconocido",
"noDescription": "Sin descripción",
"downloads": "{{count}} descargas",
"preview": "Vista previa",
"screenshotAlt": "Captura de pantalla {{number}}",
"description": "Descripción",
"tags": "Etiquetas",
"permissions": "Permisos",
"location": "Ubicación",
"error": "Error: {{message}}"
},
"empty": {
"title": "Sin extensiones",
"description": "Instala extensiones para añadir marcos, efectos de cursor y herramientas de edición."
},
"search": {
"placeholder": "Buscar extensiones...",
"noResults": "No se encontraron extensiones",
"noMarketplace": "Aún no hay extensiones en el marketplace",
"count": "{{count}} extensión",
"countPlural": "{{count}} extensiones"
},
"toast": {
"installedAndEnabled": "Extensión instalada y activada",
"uninstalled": "{{name}} desinstalada",
"uninstallFailed": "Error al desinstalar {{name}}",
"searchFailed": "Error al buscar en el marketplace",
"refreshed": "Extensiones actualizadas",
"refreshFailed": "Error al actualizar extensiones",
"marketplaceInstalled": "{{name}} instalada y activada",
"marketplaceInstallFailed": "Error al instalar {{name}}"
}
"title": "Extensiones",
"tabs": {
"browse": "Explorar",
"installed": "Instaladas"
},
"actions": {
"submit": "Enviar una extensión",
"docs": "Documentación de extensiones",
"refresh": "Actualizar",
"openFolder": "Abrir carpeta de extensiones",
"uninstall": "Desinstalar",
"install": "Instalar",
"installing": "Instalando",
"add": "Añadir",
"retry": "Reintentar",
"close": "Cerrar",
"folder": "Carpeta"
},
"status": {
"enabled": "Activada",
"disabled": "Desactivada",
"installed": "Instalada"
},
"detail": {
"by": "Por {{author}}",
"unknownAuthor": "Autor desconocido",
"noDescription": "Sin descripción",
"downloads": "{{count}} descargas",
"preview": "Vista previa",
"screenshotAlt": "Captura de pantalla {{number}}",
"description": "Descripción",
"tags": "Etiquetas",
"permissions": "Permisos",
"location": "Ubicación",
"error": "Error: {{message}}"
},
"empty": {
"title": "Sin extensiones",
"description": "Instala extensiones para añadir marcos, efectos de cursor y herramientas de edición."
},
"search": {
"placeholder": "Buscar extensiones...",
"noResults": "No se encontraron extensiones",
"noMarketplace": "Aún no hay extensiones en el marketplace",
"count": "{{count}} extensión",
"countPlural": "{{count}} extensiones"
},
"toast": {
"installedAndEnabled": "Extensión instalada y activada",
"uninstalled": "{{name}} desinstalada",
"uninstallFailed": "Error al desinstalar {{name}}",
"searchFailed": "Error al buscar en el marketplace",
"refreshed": "Extensiones actualizadas",
"refreshFailed": "Error al actualizar extensiones",
"marketplaceInstalled": "{{name}} instalada y activada",
"enableFailed": "Error al activar la extensión",
"marketplaceInstallFailed": "Error al instalar {{name}}"
}
}
+58 -57
View File
@@ -1,59 +1,60 @@
{
"title": "확장 프로그램",
"tabs": {
"browse": "둘러보기",
"installed": "설치됨"
},
"actions": {
"submit": "확장 프로그램 제출",
"docs": "확장 프로그램 문서",
"refresh": "새로고침",
"openFolder": "확장 프로그램 폴더 열기",
"uninstall": "제거",
"install": "설치",
"installing": "설치 중",
"add": "추가",
"retry": "다시 시도",
"close": "닫기",
"folder": "폴더"
},
"status": {
"enabled": "활성화",
"disabled": "비활성화",
"installed": "설치됨"
},
"detail": {
"by": "{{author}} 제작",
"unknownAuthor": "알 수 없는 작성자",
"noDescription": "설명 없음",
"downloads": "{{count}}회 다운로드",
"preview": "미리보기",
"screenshotAlt": "스크린샷 {{number}}",
"description": "설명",
"tags": "태그",
"permissions": "권한",
"location": "위치",
"error": "오류: {{message}}"
},
"empty": {
"title": "확장 프로그램 없음",
"description": "프레임, 커서 효과 및 편집 도구를 추가하려면 확장 프로그램을 설치하세요."
},
"search": {
"placeholder": "확장 프로그램 검색...",
"noResults": "확장 프로그램을 찾을 수 없음",
"noMarketplace": "아직 마켓플레이스 확장 프로그램이 없습니다",
"count": "확장 프로그램 {{count}}개",
"countPlural": "확장 프로그램 {{count}}개"
},
"toast": {
"installedAndEnabled": "확장 프로그램이 설치되고 활성화되었습니다",
"uninstalled": "{{name}} 제거됨",
"uninstallFailed": "{{name}} 제거 실패",
"searchFailed": "마켓플레이스 검색 실패",
"refreshed": "확장 프로그램 새로고침 완료",
"refreshFailed": "확장 프로그램 새로고침 실패",
"marketplaceInstalled": "{{name}} 설치 및 활성화됨",
"marketplaceInstallFailed": "{{name}} 설치 실패"
}
"title": "확장 프로그램",
"tabs": {
"browse": "둘러보기",
"installed": "설치됨"
},
"actions": {
"submit": "확장 프로그램 제출",
"docs": "확장 프로그램 문서",
"refresh": "새로고침",
"openFolder": "확장 프로그램 폴더 열기",
"uninstall": "제거",
"install": "설치",
"installing": "설치 중",
"add": "추가",
"retry": "다시 시도",
"close": "닫기",
"folder": "폴더"
},
"status": {
"enabled": "활성화",
"disabled": "비활성화",
"installed": "설치됨"
},
"detail": {
"by": "{{author}} 제작",
"unknownAuthor": "알 수 없는 작성자",
"noDescription": "설명 없음",
"downloads": "{{count}}회 다운로드",
"preview": "미리보기",
"screenshotAlt": "스크린샷 {{number}}",
"description": "설명",
"tags": "태그",
"permissions": "권한",
"location": "위치",
"error": "오류: {{message}}"
},
"empty": {
"title": "확장 프로그램 없음",
"description": "프레임, 커서 효과 및 편집 도구를 추가하려면 확장 프로그램을 설치하세요."
},
"search": {
"placeholder": "확장 프로그램 검색...",
"noResults": "확장 프로그램을 찾을 수 없음",
"noMarketplace": "아직 마켓플레이스 확장 프로그램이 없습니다",
"count": "확장 프로그램 {{count}}개",
"countPlural": "확장 프로그램 {{count}}개"
},
"toast": {
"installedAndEnabled": "확장 프로그램이 설치되고 활성화되었습니다",
"uninstalled": "{{name}} 제거됨",
"uninstallFailed": "{{name}} 제거 실패",
"searchFailed": "마켓플레이스 검색 실패",
"refreshed": "확장 프로그램 새로고침 완료",
"refreshFailed": "확장 프로그램 새로고침 실패",
"marketplaceInstalled": "{{name}} 설치 및 활성화됨",
"enableFailed": "확장 프로그램 활성화 실패",
"marketplaceInstallFailed": "{{name}} 설치 실패"
}
}
+58 -57
View File
@@ -1,59 +1,60 @@
{
"title": "Extensies",
"tabs": {
"browse": "Bladeren",
"installed": "Geïnstalleerd"
},
"actions": {
"submit": "Een extensie indienen",
"docs": "Extensiedocumentatie",
"refresh": "Vernieuwen",
"openFolder": "Extensiemap openen",
"uninstall": "Verwijderen",
"install": "Installeren",
"installing": "Installeren",
"add": "Toevoegen",
"retry": "Opnieuw",
"close": "Sluiten",
"folder": "Map"
},
"status": {
"enabled": "Ingeschakeld",
"disabled": "Uitgeschakeld",
"installed": "Geïnstalleerd"
},
"detail": {
"by": "Door {{author}}",
"unknownAuthor": "Onbekende auteur",
"noDescription": "Geen beschrijving",
"downloads": "{{count}} downloads",
"preview": "Voorbeeld",
"screenshotAlt": "Schermafbeelding {{number}}",
"description": "Beschrijving",
"tags": "Tags",
"permissions": "Machtigingen",
"location": "Locatie",
"error": "Fout: {{message}}"
},
"empty": {
"title": "Geen extensies",
"description": "Installeer extensies om frames, cursoreffecten en bewerkingstools toe te voegen."
},
"search": {
"placeholder": "Extensies zoeken...",
"noResults": "Geen extensies gevonden",
"noMarketplace": "Er zijn nog geen marketplace extensies beschikbaar",
"count": "{{count}} extensie",
"countPlural": "{{count}} extensies"
},
"toast": {
"installedAndEnabled": "Extensie geïnstalleerd en ingeschakeld",
"uninstalled": "{{name}} verwijderd",
"uninstallFailed": "Kan {{name}} niet verwijderen",
"searchFailed": "Kan niet zoeken in marketplace",
"refreshed": "Extensies vernieuwd",
"refreshFailed": "Kan extensies niet vernieuwen",
"marketplaceInstalled": "{{name}} geïnstalleerd en ingeschakeld",
"marketplaceInstallFailed": "Kan {{name}} niet installeren"
}
"title": "Extensies",
"tabs": {
"browse": "Bladeren",
"installed": "Geïnstalleerd"
},
"actions": {
"submit": "Een extensie indienen",
"docs": "Extensiedocumentatie",
"refresh": "Vernieuwen",
"openFolder": "Extensiemap openen",
"uninstall": "Verwijderen",
"install": "Installeren",
"installing": "Installeren",
"add": "Toevoegen",
"retry": "Opnieuw",
"close": "Sluiten",
"folder": "Map"
},
"status": {
"enabled": "Ingeschakeld",
"disabled": "Uitgeschakeld",
"installed": "Geïnstalleerd"
},
"detail": {
"by": "Door {{author}}",
"unknownAuthor": "Onbekende auteur",
"noDescription": "Geen beschrijving",
"downloads": "{{count}} downloads",
"preview": "Voorbeeld",
"screenshotAlt": "Schermafbeelding {{number}}",
"description": "Beschrijving",
"tags": "Tags",
"permissions": "Machtigingen",
"location": "Locatie",
"error": "Fout: {{message}}"
},
"empty": {
"title": "Geen extensies",
"description": "Installeer extensies om frames, cursoreffecten en bewerkingstools toe te voegen."
},
"search": {
"placeholder": "Extensies zoeken...",
"noResults": "Geen extensies gevonden",
"noMarketplace": "Er zijn nog geen marketplace extensies beschikbaar",
"count": "{{count}} extensie",
"countPlural": "{{count}} extensies"
},
"toast": {
"installedAndEnabled": "Extensie geïnstalleerd en ingeschakeld",
"uninstalled": "{{name}} verwijderd",
"uninstallFailed": "Kan {{name}} niet verwijderen",
"searchFailed": "Kan niet zoeken in marketplace",
"refreshed": "Extensies vernieuwd",
"refreshFailed": "Kan extensies niet vernieuwen",
"marketplaceInstalled": "{{name}} geïnstalleerd en ingeschakeld",
"enableFailed": "Kan extensie niet inschakelen",
"marketplaceInstallFailed": "Kan {{name}} niet installeren"
}
}
+58 -57
View File
@@ -1,59 +1,60 @@
{
"title": "扩展",
"tabs": {
"browse": "浏览",
"installed": "已安装"
},
"actions": {
"submit": "提交扩展",
"docs": "扩展文档",
"refresh": "刷新",
"openFolder": "打开扩展文件夹",
"uninstall": "卸载",
"install": "安装",
"installing": "安装中",
"add": "添加",
"retry": "重试",
"close": "关闭",
"folder": "文件夹"
},
"status": {
"enabled": "已启用",
"disabled": "已禁用",
"installed": "已安装"
},
"detail": {
"by": "由 {{author}} 开发",
"unknownAuthor": "未知作者",
"noDescription": "暂无描述",
"downloads": "{{count}} 次下载",
"preview": "预览",
"screenshotAlt": "截图 {{number}}",
"description": "描述",
"tags": "标签",
"permissions": "权限",
"location": "位置",
"error": "错误:{{message}}"
},
"empty": {
"title": "没有扩展",
"description": "安装扩展以添加相框、光标效果和编辑工具。"
},
"search": {
"placeholder": "搜索扩展...",
"noResults": "未找到扩展",
"noMarketplace": "暂无可用的市场扩展",
"count": "{{count}} 个扩展",
"countPlural": "{{count}} 个扩展"
},
"toast": {
"installedAndEnabled": "扩展已安装并启用",
"uninstalled": "已卸载 {{name}}",
"uninstallFailed": "卸载 {{name}} 失败",
"searchFailed": "搜索市场失败",
"refreshed": "扩展已刷新",
"refreshFailed": "刷新扩展失败",
"marketplaceInstalled": "已安装并启用 {{name}}",
"marketplaceInstallFailed": "安装 {{name}} 失败"
}
"title": "扩展",
"tabs": {
"browse": "浏览",
"installed": "已安装"
},
"actions": {
"submit": "提交扩展",
"docs": "扩展文档",
"refresh": "刷新",
"openFolder": "打开扩展文件夹",
"uninstall": "卸载",
"install": "安装",
"installing": "安装中",
"add": "添加",
"retry": "重试",
"close": "关闭",
"folder": "文件夹"
},
"status": {
"enabled": "已启用",
"disabled": "已禁用",
"installed": "已安装"
},
"detail": {
"by": "由 {{author}} 开发",
"unknownAuthor": "未知作者",
"noDescription": "暂无描述",
"downloads": "{{count}} 次下载",
"preview": "预览",
"screenshotAlt": "截图 {{number}}",
"description": "描述",
"tags": "标签",
"permissions": "权限",
"location": "位置",
"error": "错误:{{message}}"
},
"empty": {
"title": "没有扩展",
"description": "安装扩展以添加相框、光标效果和编辑工具。"
},
"search": {
"placeholder": "搜索扩展...",
"noResults": "未找到扩展",
"noMarketplace": "暂无可用的市场扩展",
"count": "{{count}} 个扩展",
"countPlural": "{{count}} 个扩展"
},
"toast": {
"installedAndEnabled": "扩展已安装并启用",
"uninstalled": "已卸载 {{name}}",
"uninstallFailed": "卸载 {{name}} 失败",
"searchFailed": "搜索市场失败",
"refreshed": "扩展已刷新",
"refreshFailed": "刷新扩展失败",
"marketplaceInstalled": "已安装并启用 {{name}}",
"enableFailed": "启用扩展失败",
"marketplaceInstallFailed": "安装 {{name}} 失败"
}
}
+2 -5
View File
@@ -74,10 +74,7 @@ export function mapSmoothedCursorToCanvasNormalized(
return null;
}
const mappedCursor = mapCursorToCanvasNormalized(
{ cx: cursor.cx, cy: cursor.cy },
params,
);
const mappedCursor = mapCursorToCanvasNormalized({ cx: cursor.cx, cy: cursor.cy }, params);
if (!mappedCursor) {
return null;
}
@@ -89,4 +86,4 @@ export function mapSmoothedCursorToCanvasNormalized(
.map((point) => mapCursorToCanvasNormalized(point, params))
.filter((point): point is { cx: number; cy: number } => point !== null),
};
}
}
+35 -21
View File
@@ -5,6 +5,7 @@
* modules, provides the permission-gated host API, and coordinates render hooks.
*/
import { createExtensionModuleUrl, resolveExtensionRelativeFileUrl } from "./fileUrls";
import type {
ContributedCursorStyle,
ContributedFrame,
@@ -23,7 +24,6 @@ import type {
RenderHookFn,
RenderHookPhase,
} from "./types";
import { createExtensionModuleUrl, resolveExtensionRelativeFileUrl } from "./fileUrls";
const EXTENSION_SETTINGS_STORAGE_KEY = "recordly.extension-settings.v1";
@@ -156,7 +156,10 @@ export class ExtensionHost {
} | null = null;
private _zoomState: { scale: number; focusX: number; focusY: number; progress: number } | null =
null;
private _shadowConfig: { enabled: boolean; intensity: number } = { enabled: false, intensity: 0 };
private _shadowConfig: { enabled: boolean; intensity: number } = {
enabled: false,
intensity: 0,
};
private _cursorTelemetry: Array<{
timeMs: number;
cx: number;
@@ -172,8 +175,11 @@ export class ExtensionHost {
} | null = null;
private _keystrokeEvents: Array<{ timeMs: number; key: string; modifiers: string[] }> = [];
private _activeFrame: string | null = null;
private _playbackState: { currentTimeMs: number; durationMs: number; isPlaying: boolean } | null =
null;
private _playbackState: {
currentTimeMs: number;
durationMs: number;
isPlaying: boolean;
} | null = null;
/**
* Activate an extension given its info and resolved module URL.
@@ -293,7 +299,10 @@ export class ExtensionHost {
hook.hook(context);
context.ctx.restore();
} catch (err) {
console.warn(`[extensions] Render hook error (${hook.extensionId}, ${phase}):`, err);
console.warn(
`[extensions] Render hook error (${hook.extensionId}, ${phase}):`,
err,
);
}
}
}
@@ -459,22 +468,20 @@ export class ExtensionHost {
}
setSmoothedCursor(
cursor:
| {
timeMs: number;
cx: number;
cy: number;
trail: Array<{ cx: number; cy: number }>;
}
| null,
cursor: {
timeMs: number;
cx: number;
cy: number;
trail: Array<{ cx: number; cy: number }>;
} | null,
): void {
this._smoothedCursor = cursor
? {
timeMs: cursor.timeMs,
cx: cursor.cx,
cy: cursor.cy,
trail: cursor.trail.map((point) => ({ ...point })),
}
timeMs: cursor.timeMs,
cx: cursor.cx,
cy: cursor.cy,
trail: cursor.trail.map((point) => ({ ...point })),
}
: null;
}
@@ -805,7 +812,9 @@ export class ExtensionHost {
playSound(relativePath: string, options?: { volume?: number }): () => void {
requirePermission("audio", "playSound");
const audio = new Audio(resolveExtensionRelativeFileUrl(extensionPath, relativePath));
const audio = new Audio(
resolveExtensionRelativeFileUrl(extensionPath, relativePath),
);
audio.volume = Math.max(0, Math.min(1, options?.volume ?? 1));
audio.play().catch((err) => {
console.warn(`[ext:${extensionId}] Failed to play sound:`, err);
@@ -902,7 +911,10 @@ export class ExtensionHost {
getCanvasDimensions() {
if (!host._videoLayout) return null;
return { width: host._videoLayout.canvasWidth, height: host._videoLayout.canvasHeight };
return {
width: host._videoLayout.canvasWidth,
height: host._videoLayout.canvasHeight,
};
},
onSettingChange(callback: (settingId: string, value: unknown) => void): () => void {
@@ -931,7 +943,9 @@ export class ExtensionHost {
async syncConfiguredExtensions(discovered: ExtensionInfo[]): Promise<void> {
const desired = new Map(
discovered.filter((ext) => ext.status === "active").map((ext) => [ext.manifest.id, ext]),
discovered
.filter((ext) => ext.status === "active")
.map((ext) => [ext.manifest.id, ext]),
);
for (const activeId of Array.from(this.activeExtensions.keys())) {
+1 -1
View File
@@ -46,4 +46,4 @@ export function createExtensionModuleUrl(extensionPath: string, entryPoint: stri
const base = resolveExtensionRelativeFileUrl(extensionPath, entryPoint);
// Cache-bust so re-installs / updates load the fresh module
return `${base}?v=${Date.now()}`;
}
}
+28 -28
View File
@@ -1,29 +1,29 @@
export { extensionHost, ExtensionHost } from './extensionHost';
export { ExtensionHost, extensionHost } from "./extensionHost";
export type {
ExtensionManifest,
ExtensionInfo,
ExtensionPermission,
ExtensionStatus,
RecordlyExtensionAPI,
RecordlyExtensionModule,
RenderHookPhase,
RenderHookFn,
RenderHookContext,
CursorEffectFn,
CursorEffectContext,
ExtensionEventType,
ExtensionEvent,
ExtensionSettingsPanel,
ExtensionSettingField,
ExtensionContributions,
ContributedCursorStyle,
ContributedSound,
ContributedWallpaper,
ContributedWebcamFrame,
ContributedFrame,
FrameInstance,
MarketplaceExtension,
MarketplaceSearchResult,
MarketplaceReviewStatus,
ExtensionReview,
} from './types';
ContributedCursorStyle,
ContributedFrame,
ContributedSound,
ContributedWallpaper,
ContributedWebcamFrame,
CursorEffectContext,
CursorEffectFn,
ExtensionContributions,
ExtensionEvent,
ExtensionEventType,
ExtensionInfo,
ExtensionManifest,
ExtensionPermission,
ExtensionReview,
ExtensionSettingField,
ExtensionSettingsPanel,
ExtensionStatus,
FrameInstance,
MarketplaceExtension,
MarketplaceReviewStatus,
MarketplaceSearchResult,
RecordlyExtensionAPI,
RecordlyExtensionModule,
RenderHookContext,
RenderHookFn,
RenderHookPhase,
} from "./types";
+208 -196
View File
@@ -6,125 +6,137 @@
* app renderers and extension-registered hooks.
*/
import { extensionHost } from '@/lib/extensions';
import type { RenderHookPhase, RenderHookContext, CursorEffectContext } from '@/lib/extensions';
import type { CursorEffectContext, RenderHookContext, RenderHookPhase } from "@/lib/extensions";
import { extensionHost } from "@/lib/extensions";
// ---------------------------------------------------------------------------
// Scene pixel helpers — created per-context (capture ctx + videoLayout)
// ---------------------------------------------------------------------------
function makePixelHelpers(ctx: CanvasRenderingContext2D, videoLayout?: RenderHookContext['videoLayout']) {
const getPixelColor = (x: number, y: number) => {
const px = Math.round(x);
const py = Math.round(y);
const d = ctx.getImageData(px, py, 1, 1).data;
return { r: d[0], g: d[1], b: d[2], a: d[3] };
};
function makePixelHelpers(
ctx: CanvasRenderingContext2D,
videoLayout?: RenderHookContext["videoLayout"],
) {
const getPixelColor = (x: number, y: number) => {
const px = Math.round(x);
const py = Math.round(y);
const d = ctx.getImageData(px, py, 1, 1).data;
return { r: d[0], g: d[1], b: d[2], a: d[3] };
};
const sampleGrid = (
sx: number, sy: number, sw: number, sh: number, step: number,
) => {
let rSum = 0, gSum = 0, bSum = 0, aSum = 0, count = 0;
const clampedW = Math.max(1, Math.round(sw));
const clampedH = Math.max(1, Math.round(sh));
const data = ctx.getImageData(Math.round(sx), Math.round(sy), clampedW, clampedH).data;
for (let y = 0; y < clampedH; y += step) {
for (let x = 0; x < clampedW; x += step) {
const i = (y * clampedW + x) * 4;
rSum += data[i]; gSum += data[i + 1]; bSum += data[i + 2]; aSum += data[i + 3];
count++;
}
}
if (count === 0) return { r: 0, g: 0, b: 0, a: 0 };
return {
r: Math.round(rSum / count),
g: Math.round(gSum / count),
b: Math.round(bSum / count),
a: Math.round(aSum / count),
};
};
const sampleGrid = (sx: number, sy: number, sw: number, sh: number, step: number) => {
let rSum = 0,
gSum = 0,
bSum = 0,
aSum = 0,
count = 0;
const clampedW = Math.max(1, Math.round(sw));
const clampedH = Math.max(1, Math.round(sh));
const data = ctx.getImageData(Math.round(sx), Math.round(sy), clampedW, clampedH).data;
for (let y = 0; y < clampedH; y += step) {
for (let x = 0; x < clampedW; x += step) {
const i = (y * clampedW + x) * 4;
rSum += data[i];
gSum += data[i + 1];
bSum += data[i + 2];
aSum += data[i + 3];
count++;
}
}
if (count === 0) return { r: 0, g: 0, b: 0, a: 0 };
return {
r: Math.round(rSum / count),
g: Math.round(gSum / count),
b: Math.round(bSum / count),
a: Math.round(aSum / count),
};
};
const getMaskRect = () => {
if (videoLayout) return videoLayout.maskRect;
return { x: 0, y: 0, width: ctx.canvas.width, height: ctx.canvas.height };
};
const getMaskRect = () => {
if (videoLayout) return videoLayout.maskRect;
return { x: 0, y: 0, width: ctx.canvas.width, height: ctx.canvas.height };
};
const getAverageSceneColor = () => {
const m = getMaskRect();
const step = Math.max(1, Math.round(Math.min(m.width, m.height) / 32));
return sampleGrid(m.x, m.y, m.width, m.height, step);
};
const getAverageSceneColor = () => {
const m = getMaskRect();
const step = Math.max(1, Math.round(Math.min(m.width, m.height) / 32));
return sampleGrid(m.x, m.y, m.width, m.height, step);
};
const getEdgeAverageColor = (edgeWidth = 4) => {
const m = getMaskRect();
const ew = Math.max(1, edgeWidth);
let rSum = 0, gSum = 0, bSum = 0, aSum = 0, count = 0;
const getEdgeAverageColor = (edgeWidth = 4) => {
const m = getMaskRect();
const ew = Math.max(1, edgeWidth);
let rSum = 0,
gSum = 0,
bSum = 0,
aSum = 0,
count = 0;
const bands = [
{ x: m.x, y: m.y, w: m.width, h: ew }, // top
{ x: m.x, y: m.y + m.height - ew, w: m.width, h: ew }, // bottom
{ x: m.x, y: m.y + ew, w: ew, h: m.height - ew * 2 }, // left
{ x: m.x + m.width - ew, y: m.y + ew, w: ew, h: m.height - ew * 2 }, // right
];
const step = Math.max(1, Math.round(Math.min(m.width, m.height) / 64));
for (const b of bands) {
if (b.w <= 0 || b.h <= 0) continue;
const avg = sampleGrid(b.x, b.y, b.w, b.h, step);
const bandSamples = Math.ceil(b.w / step) * Math.ceil(b.h / step);
rSum += avg.r * bandSamples;
gSum += avg.g * bandSamples;
bSum += avg.b * bandSamples;
aSum += avg.a * bandSamples;
count += bandSamples;
}
if (count === 0) return { r: 0, g: 0, b: 0, a: 0 };
return {
r: Math.round(rSum / count),
g: Math.round(gSum / count),
b: Math.round(bSum / count),
a: Math.round(aSum / count),
};
};
const bands = [
{ x: m.x, y: m.y, w: m.width, h: ew }, // top
{ x: m.x, y: m.y + m.height - ew, w: m.width, h: ew }, // bottom
{ x: m.x, y: m.y + ew, w: ew, h: m.height - ew * 2 }, // left
{ x: m.x + m.width - ew, y: m.y + ew, w: ew, h: m.height - ew * 2 }, // right
];
const step = Math.max(1, Math.round(Math.min(m.width, m.height) / 64));
for (const b of bands) {
if (b.w <= 0 || b.h <= 0) continue;
const avg = sampleGrid(b.x, b.y, b.w, b.h, step);
const bandSamples = Math.ceil(b.w / step) * Math.ceil(b.h / step);
rSum += avg.r * bandSamples;
gSum += avg.g * bandSamples;
bSum += avg.b * bandSamples;
aSum += avg.a * bandSamples;
count += bandSamples;
}
if (count === 0) return { r: 0, g: 0, b: 0, a: 0 };
return {
r: Math.round(rSum / count),
g: Math.round(gSum / count),
b: Math.round(bSum / count),
a: Math.round(aSum / count),
};
};
const getDominantColors = (maxColors = 5) => {
const m = getMaskRect();
const step = Math.max(1, Math.round(Math.min(m.width, m.height) / 24));
const clampedW = Math.max(1, Math.round(m.width));
const clampedH = Math.max(1, Math.round(m.height));
const data = ctx.getImageData(Math.round(m.x), Math.round(m.y), clampedW, clampedH).data;
// Quantize to 5-bit per channel
const buckets = new Map<number, { r: number; g: number; b: number; count: number }>();
for (let y = 0; y < clampedH; y += step) {
for (let x = 0; x < clampedW; x += step) {
const i = (y * clampedW + x) * 4;
const qr = data[i] >> 3;
const qg = data[i + 1] >> 3;
const qb = data[i + 2] >> 3;
const key = (qr << 10) | (qg << 5) | qb;
const existing = buckets.get(key);
if (existing) {
existing.r += data[i];
existing.g += data[i + 1];
existing.b += data[i + 2];
existing.count++;
} else {
buckets.set(key, { r: data[i], g: data[i + 1], b: data[i + 2], count: 1 });
}
}
}
const totalSamples = Array.from(buckets.values()).reduce((s, b) => s + b.count, 0) || 1;
return Array.from(buckets.values())
.sort((a, b) => b.count - a.count)
.slice(0, maxColors)
.map(b => ({
r: Math.round(b.r / b.count),
g: Math.round(b.g / b.count),
b: Math.round(b.b / b.count),
frequency: b.count / totalSamples,
}));
};
const getDominantColors = (maxColors = 5) => {
const m = getMaskRect();
const step = Math.max(1, Math.round(Math.min(m.width, m.height) / 24));
const clampedW = Math.max(1, Math.round(m.width));
const clampedH = Math.max(1, Math.round(m.height));
const data = ctx.getImageData(Math.round(m.x), Math.round(m.y), clampedW, clampedH).data;
// Quantize to 5-bit per channel
const buckets = new Map<number, { r: number; g: number; b: number; count: number }>();
for (let y = 0; y < clampedH; y += step) {
for (let x = 0; x < clampedW; x += step) {
const i = (y * clampedW + x) * 4;
const qr = data[i] >> 3;
const qg = data[i + 1] >> 3;
const qb = data[i + 2] >> 3;
const key = (qr << 10) | (qg << 5) | qb;
const existing = buckets.get(key);
if (existing) {
existing.r += data[i];
existing.g += data[i + 1];
existing.b += data[i + 2];
existing.count++;
} else {
buckets.set(key, { r: data[i], g: data[i + 1], b: data[i + 2], count: 1 });
}
}
}
const totalSamples = Array.from(buckets.values()).reduce((s, b) => s + b.count, 0) || 1;
return Array.from(buckets.values())
.sort((a, b) => b.count - a.count)
.slice(0, maxColors)
.map((b) => ({
r: Math.round(b.r / b.count),
g: Math.round(b.g / b.count),
b: Math.round(b.b / b.count),
frequency: b.count / totalSamples,
}));
};
return { getPixelColor, getAverageSceneColor, getEdgeAverageColor, getDominantColors };
return { getPixelColor, getAverageSceneColor, getEdgeAverageColor, getDominantColors };
}
/**
@@ -134,54 +146,54 @@ function makePixelHelpers(ctx: CanvasRenderingContext2D, videoLayout?: RenderHoo
* The context is saved/restored around each hook call.
*/
export function executeExtensionRenderHooks(
phase: RenderHookPhase,
ctx: CanvasRenderingContext2D,
params: {
width: number;
height: number;
timeMs: number;
durationMs: number;
cursor?: { cx: number; cy: number; interactionType?: string } | null;
smoothedCursor?: RenderHookContext['smoothedCursor'];
videoLayout?: RenderHookContext['videoLayout'];
zoom?: RenderHookContext['zoom'];
shadow?: RenderHookContext['shadow'];
sceneTransform?: RenderHookContext['sceneTransform'];
},
phase: RenderHookPhase,
ctx: CanvasRenderingContext2D,
params: {
width: number;
height: number;
timeMs: number;
durationMs: number;
cursor?: { cx: number; cy: number; interactionType?: string } | null;
smoothedCursor?: RenderHookContext["smoothedCursor"];
videoLayout?: RenderHookContext["videoLayout"];
zoom?: RenderHookContext["zoom"];
shadow?: RenderHookContext["shadow"];
sceneTransform?: RenderHookContext["sceneTransform"];
},
): void {
if (!extensionHost.hasRenderHooks(phase)) return;
if (!extensionHost.hasRenderHooks(phase)) return;
const helpers = makePixelHelpers(ctx, params.videoLayout);
const helpers = makePixelHelpers(ctx, params.videoLayout);
const context: RenderHookContext = {
width: params.width,
height: params.height,
timeMs: params.timeMs,
durationMs: params.durationMs,
cursor: params.cursor ?? null,
smoothedCursor: params.smoothedCursor ?? null,
ctx,
videoLayout: params.videoLayout,
zoom: params.zoom,
shadow: params.shadow,
sceneTransform: params.sceneTransform,
getPixelColor: helpers.getPixelColor,
getAverageSceneColor: helpers.getAverageSceneColor,
getEdgeAverageColor: helpers.getEdgeAverageColor,
getDominantColors: helpers.getDominantColors,
};
const context: RenderHookContext = {
width: params.width,
height: params.height,
timeMs: params.timeMs,
durationMs: params.durationMs,
cursor: params.cursor ?? null,
smoothedCursor: params.smoothedCursor ?? null,
ctx,
videoLayout: params.videoLayout,
zoom: params.zoom,
shadow: params.shadow,
sceneTransform: params.sceneTransform,
getPixelColor: helpers.getPixelColor,
getAverageSceneColor: helpers.getAverageSceneColor,
getEdgeAverageColor: helpers.getEdgeAverageColor,
getDominantColors: helpers.getDominantColors,
};
extensionHost.executeRenderHooks(phase, context);
extensionHost.executeRenderHooks(phase, context);
}
/**
* Track active cursor effects (animations that persist across frames).
*/
interface ActiveCursorEffectInstance {
interactionTimeMs: number;
cx: number;
cy: number;
interactionType: 'click' | 'double-click' | 'right-click' | 'mouseup';
interactionTimeMs: number;
cx: number;
cy: number;
interactionType: "click" | "double-click" | "right-click" | "mouseup";
}
const activeCursorInteractions: ActiveCursorEffectInstance[] = [];
@@ -191,22 +203,22 @@ const MAX_EFFECT_DURATION_MS = 2000;
* Notify that a cursor interaction occurred (call from cursor telemetry handler).
*/
export function notifyCursorInteraction(
timeMs: number,
cx: number,
cy: number,
interactionType: string,
timeMs: number,
cx: number,
cy: number,
interactionType: string,
): void {
if (!extensionHost.hasCursorEffects()) return;
if (!extensionHost.hasCursorEffects()) return;
const validTypes = new Set(['click', 'double-click', 'right-click', 'mouseup']);
if (!validTypes.has(interactionType)) return;
const validTypes = new Set(["click", "double-click", "right-click", "mouseup"]);
if (!validTypes.has(interactionType)) return;
activeCursorInteractions.push({
interactionTimeMs: timeMs,
cx,
cy,
interactionType: interactionType as ActiveCursorEffectInstance['interactionType'],
});
activeCursorInteractions.push({
interactionTimeMs: timeMs,
cx,
cy,
interactionType: interactionType as ActiveCursorEffectInstance["interactionType"],
});
}
/**
@@ -214,53 +226,53 @@ export function notifyCursorInteraction(
* Called after the cursor is drawn in the render pipeline.
*/
export function executeExtensionCursorEffects(
ctx: CanvasRenderingContext2D,
timeMs: number,
width: number,
height: number,
extra?: {
zoom?: CursorEffectContext['zoom'];
sceneTransform?: CursorEffectContext['sceneTransform'];
videoLayout?: CursorEffectContext['videoLayout'];
},
ctx: CanvasRenderingContext2D,
timeMs: number,
width: number,
height: number,
extra?: {
zoom?: CursorEffectContext["zoom"];
sceneTransform?: CursorEffectContext["sceneTransform"];
videoLayout?: CursorEffectContext["videoLayout"];
},
): void {
if (!extensionHost.hasCursorEffects() || activeCursorInteractions.length === 0) return;
if (!extensionHost.hasCursorEffects() || activeCursorInteractions.length === 0) return;
// Process all active interactions, removing expired ones
for (let i = activeCursorInteractions.length - 1; i >= 0; i--) {
const interaction = activeCursorInteractions[i];
const elapsedMs = timeMs - interaction.interactionTimeMs;
// Process all active interactions, removing expired ones
for (let i = activeCursorInteractions.length - 1; i >= 0; i--) {
const interaction = activeCursorInteractions[i];
const elapsedMs = timeMs - interaction.interactionTimeMs;
// Remove if too old
if (elapsedMs > MAX_EFFECT_DURATION_MS || elapsedMs < 0) {
activeCursorInteractions.splice(i, 1);
continue;
}
// Remove if too old
if (elapsedMs > MAX_EFFECT_DURATION_MS || elapsedMs < 0) {
activeCursorInteractions.splice(i, 1);
continue;
}
const effectCtx: CursorEffectContext = {
timeMs,
cx: interaction.cx,
cy: interaction.cy,
interactionType: interaction.interactionType,
width,
height,
ctx,
elapsedMs,
zoom: extra?.zoom,
sceneTransform: extra?.sceneTransform,
videoLayout: extra?.videoLayout,
};
const effectCtx: CursorEffectContext = {
timeMs,
cx: interaction.cx,
cy: interaction.cy,
interactionType: interaction.interactionType,
width,
height,
ctx,
elapsedMs,
zoom: extra?.zoom,
sceneTransform: extra?.sceneTransform,
videoLayout: extra?.videoLayout,
};
const stillActive = extensionHost.executeCursorEffects(effectCtx);
if (!stillActive) {
activeCursorInteractions.splice(i, 1);
}
}
const stillActive = extensionHost.executeCursorEffects(effectCtx);
if (!stillActive) {
activeCursorInteractions.splice(i, 1);
}
}
}
/**
* Clear all active cursor effect animations (e.g., on seek).
*/
export function clearCursorEffects(): void {
activeCursorInteractions.length = 0;
activeCursorInteractions.length = 0;
}
+3 -1
View File
@@ -297,7 +297,9 @@ export interface RenderHookContext {
* Get dominant colors in the video content area.
* Returns up to `count` colors sorted by frequency.
*/
getDominantColors(count?: number): Array<{ r: number; g: number; b: number; frequency: number }>;
getDominantColors(
count?: number,
): Array<{ r: number; g: number; b: number; frequency: number }>;
}
/** Render hook phases — extensions draw in the registered phase */