mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-26 15:55:35 +00:00
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:
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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];
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}} 설치 실패"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}} 失败"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user