mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-27 00:05:39 +00:00
Merge pull request #848 from webadderallorg/codex/experimental-update-modal
Fix experimental update channel and toast
This commit is contained in:
@@ -608,12 +608,18 @@ jobs:
|
||||
shell: bash
|
||||
env:
|
||||
RELEASE_SCOPE: ${{ needs.prepare-release.outputs.release_scope }}
|
||||
IS_PRERELEASE: ${{ needs.prepare-release.outputs.prerelease }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
|
||||
mkdir -p release-assets/upload
|
||||
|
||||
if [ "$IS_PRERELEASE" = "true" ] && [ "$RELEASE_SCOPE" != "all" ]; then
|
||||
echo "Prerelease beta updates must include all platforms so every opted-in client can resolve beta metadata."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
required_metadata=(
|
||||
release-assets/windows-x64/latest.yml
|
||||
release-assets/linux-x64/latest-linux.yml
|
||||
@@ -630,6 +636,29 @@ jobs:
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$IS_PRERELEASE" = "true" ]; then
|
||||
cp release-assets/windows-x64/latest.yml release-assets/windows-x64/beta.yml
|
||||
cp release-assets/linux-x64/latest-linux.yml release-assets/linux-x64/beta-linux.yml
|
||||
update_metadata=(
|
||||
release-assets/windows-x64/beta.yml
|
||||
release-assets/linux-x64/beta-linux.yml
|
||||
)
|
||||
|
||||
if [ "$RELEASE_SCOPE" = "all" ]; then
|
||||
cp release-assets/macos-merged/latest-mac.yml release-assets/macos-merged/beta-mac.yml
|
||||
update_metadata+=(release-assets/macos-merged/beta-mac.yml)
|
||||
fi
|
||||
else
|
||||
update_metadata=(
|
||||
release-assets/windows-x64/latest.yml
|
||||
release-assets/linux-x64/latest-linux.yml
|
||||
)
|
||||
|
||||
if [ "$RELEASE_SCOPE" = "all" ]; then
|
||||
update_metadata+=(release-assets/macos-merged/latest-mac.yml)
|
||||
fi
|
||||
fi
|
||||
|
||||
assets=(
|
||||
release-assets/windows-x64/*.exe
|
||||
release-assets/windows-x64/*.blockmap
|
||||
@@ -648,16 +677,7 @@ jobs:
|
||||
)
|
||||
fi
|
||||
|
||||
assets+=(
|
||||
release-assets/windows-x64/latest.yml
|
||||
release-assets/linux-x64/latest-linux.yml
|
||||
)
|
||||
|
||||
if [ "$RELEASE_SCOPE" = "all" ]; then
|
||||
assets+=(
|
||||
release-assets/macos-merged/latest-mac.yml
|
||||
)
|
||||
fi
|
||||
assets+=("${update_metadata[@]}")
|
||||
|
||||
if [ ${#assets[@]} -eq 0 ]; then
|
||||
echo "No release assets found to checksum."
|
||||
|
||||
Vendored
+1
@@ -49,6 +49,7 @@ interface UpdateToastState {
|
||||
phase: "available" | "downloading" | "ready" | "error";
|
||||
delayMs: number;
|
||||
isPreview?: boolean;
|
||||
isExperimental?: boolean;
|
||||
progressPercent?: number;
|
||||
transferredBytes?: number;
|
||||
totalBytes?: number;
|
||||
|
||||
+23
-118
@@ -9,7 +9,6 @@ import {
|
||||
webContents as electronWebContents,
|
||||
ipcMain,
|
||||
Menu,
|
||||
Notification,
|
||||
nativeImage,
|
||||
session,
|
||||
shell,
|
||||
@@ -30,7 +29,6 @@ import { ensureMediaServer } from "./mediaServer";
|
||||
import { hardenWebContentsNavigation, shouldHardenWebContentsType } from "./navigationPolicy";
|
||||
import { shouldGrantDisplayCapture, shouldGrantMediaPermission } from "./permissionPolicy";
|
||||
import { ensurePackagedRendererServer, getPackagedRendererBaseUrl } from "./rendererServer";
|
||||
import type { UpdateToastPayload } from "./updater";
|
||||
import {
|
||||
checkForAppUpdates,
|
||||
deferUpdateReminder,
|
||||
@@ -179,8 +177,6 @@ let editorHasUnsavedChanges = false;
|
||||
let isForceClosing = false;
|
||||
let isCreatingMainWindow = false;
|
||||
let isCreatingEditorWindow = false;
|
||||
let activeUpdateNotification: Notification | null = null;
|
||||
let activeUpdateNotificationKey: string | null = null;
|
||||
const shouldEnforceSingleInstanceLock = !IS_DEV;
|
||||
const hasSingleInstanceLock = shouldEnforceSingleInstanceLock
|
||||
? app.requestSingleInstanceLock()
|
||||
@@ -256,6 +252,14 @@ function getRecordingTrayIcon() {
|
||||
}
|
||||
|
||||
function showHudOverlayFromTray() {
|
||||
const updateToast = getUpdateToastWindow();
|
||||
if (updateToast?.isVisible()) {
|
||||
updateToast.show();
|
||||
updateToast.moveTop();
|
||||
updateToast.focus();
|
||||
return true;
|
||||
}
|
||||
|
||||
const hud = getHudOverlayWindow();
|
||||
if (!hud) {
|
||||
return false;
|
||||
@@ -327,6 +331,14 @@ function focusOrCreateMainWindow() {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateToast = getUpdateToastWindow();
|
||||
if (updateToast?.isVisible()) {
|
||||
updateToast.show();
|
||||
updateToast.moveTop();
|
||||
updateToast.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
const existingHud = getHudOverlayWindow();
|
||||
if (existingHud && !existingHud.isDestroyed()) {
|
||||
@@ -559,124 +571,12 @@ function syncDockIcon() {
|
||||
}
|
||||
}
|
||||
|
||||
function getUpdateNotificationTitle(payload: UpdateToastPayload) {
|
||||
switch (payload.phase) {
|
||||
case "available":
|
||||
return `Recordly ${payload.version} is available`;
|
||||
case "downloading":
|
||||
return `Downloading Recordly ${payload.version}`;
|
||||
case "ready":
|
||||
return `Recordly ${payload.version} is ready`;
|
||||
case "error":
|
||||
return `Recordly ${payload.version} needs attention`;
|
||||
}
|
||||
}
|
||||
|
||||
function getUpdateNotificationBody(payload: UpdateToastPayload) {
|
||||
switch (payload.phase) {
|
||||
case "available":
|
||||
return "Click to install the update and restart Recordly.";
|
||||
case "downloading":
|
||||
return "Recordly is downloading the update and will restart when it is ready.";
|
||||
case "ready":
|
||||
return "Click to install the downloaded update and restart.";
|
||||
case "error":
|
||||
return payload.primaryAction === "install-and-restart"
|
||||
? "Click to try the install again."
|
||||
: "Click to retry checking for updates.";
|
||||
}
|
||||
}
|
||||
|
||||
function clearActiveUpdateNotification() {
|
||||
if (activeUpdateNotification) {
|
||||
activeUpdateNotification.close();
|
||||
activeUpdateNotification = null;
|
||||
}
|
||||
activeUpdateNotificationKey = null;
|
||||
}
|
||||
|
||||
function sendUpdateToastToWindows(channel: "update-toast-state", payload: unknown) {
|
||||
if (process.platform !== "darwin") {
|
||||
if (!payload) {
|
||||
clearActiveUpdateNotification();
|
||||
return true;
|
||||
}
|
||||
|
||||
const updatePayload = payload as UpdateToastPayload;
|
||||
if (updatePayload.phase === "downloading") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!Notification.isSupported()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const notificationKey = [
|
||||
updatePayload.phase,
|
||||
updatePayload.version,
|
||||
updatePayload.detail,
|
||||
].join(":");
|
||||
if (activeUpdateNotificationKey === notificationKey) {
|
||||
return true;
|
||||
}
|
||||
|
||||
clearActiveUpdateNotification();
|
||||
const notification = new Notification({
|
||||
title: getUpdateNotificationTitle(updatePayload),
|
||||
body: getUpdateNotificationBody(updatePayload),
|
||||
icon: getAppImage(getPlatformAppIconFilename(128)),
|
||||
silent: false,
|
||||
});
|
||||
|
||||
notification.on("click", () => {
|
||||
focusOrCreateMainWindow();
|
||||
switch (updatePayload.phase) {
|
||||
case "available":
|
||||
void downloadAvailableUpdate(sendUpdateToastToWindows, {
|
||||
installAfterDownload: true,
|
||||
});
|
||||
break;
|
||||
case "ready":
|
||||
installDownloadedUpdateNow(sendUpdateToastToWindows);
|
||||
break;
|
||||
case "error":
|
||||
if (updatePayload.primaryAction === "install-and-restart") {
|
||||
void downloadAvailableUpdate(sendUpdateToastToWindows, {
|
||||
installAfterDownload: true,
|
||||
});
|
||||
} else {
|
||||
void checkForAppUpdates(getUpdateDialogWindow, { manual: true });
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
notification.on("close", () => {
|
||||
if (activeUpdateNotification === notification) {
|
||||
activeUpdateNotification = null;
|
||||
activeUpdateNotificationKey = null;
|
||||
}
|
||||
});
|
||||
|
||||
notification.show();
|
||||
// On Win10, showing a native notification can break setIgnoreMouseEvents
|
||||
// forwarding on the transparent HUD overlay. Re-assert it after a short
|
||||
// delay so the renderer's hover detection keeps working.
|
||||
reassertHudOverlayMouseState();
|
||||
activeUpdateNotification = notification;
|
||||
activeUpdateNotificationKey = notificationKey;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!payload) {
|
||||
const existingWindow = getUpdateToastWindow();
|
||||
if (!existingWindow) {
|
||||
return false;
|
||||
if (existingWindow) {
|
||||
existingWindow.webContents.send(channel, null);
|
||||
}
|
||||
|
||||
existingWindow.webContents.send(channel, null);
|
||||
hideUpdateToastWindow();
|
||||
return true;
|
||||
}
|
||||
@@ -1095,6 +995,11 @@ app.whenReady().then(async () => {
|
||||
|
||||
createWindow();
|
||||
setupAutoUpdates(getUpdateDialogWindow, sendUpdateToastToWindows);
|
||||
if (IS_DEV && process.env.RECORDLY_DEV_PREVIEW_UPDATE === "1") {
|
||||
setTimeout(() => {
|
||||
previewUpdateToast(sendUpdateToastToWindows);
|
||||
}, 750);
|
||||
}
|
||||
|
||||
// Register the display media handler so that renderer's getDisplayMedia()
|
||||
// calls land on the pre-selected source without showing a system picker.
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
EXPERIMENTAL_UPDATE_DESCRIPTION,
|
||||
getUpdateChannelConfiguration,
|
||||
} from "./updateChannel";
|
||||
|
||||
describe("getUpdateChannelConfiguration", () => {
|
||||
it("keeps regular clients on stable metadata", () => {
|
||||
expect(getUpdateChannelConfiguration(false)).toEqual({
|
||||
channel: "latest",
|
||||
allowPrerelease: false,
|
||||
allowDowngrade: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses beta metadata only after the client opts in", () => {
|
||||
expect(getUpdateChannelConfiguration(true)).toEqual({
|
||||
channel: "beta",
|
||||
allowPrerelease: true,
|
||||
allowDowngrade: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the approved experimental update description", () => {
|
||||
expect(EXPERIMENTAL_UPDATE_DESCRIPTION).toBe(
|
||||
"You've opted into experimental updates so you have the choice to test the latest update of Recordly before it's widely available.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
export const STABLE_UPDATE_CHANNEL = "latest";
|
||||
export const EXPERIMENTAL_UPDATE_CHANNEL = "beta";
|
||||
export const EXPERIMENTAL_UPDATE_DESCRIPTION =
|
||||
"You've opted into experimental updates so you have the choice to test the latest update of Recordly before it's widely available.";
|
||||
|
||||
export interface UpdateChannelConfiguration {
|
||||
channel: typeof STABLE_UPDATE_CHANNEL | typeof EXPERIMENTAL_UPDATE_CHANNEL;
|
||||
allowPrerelease: boolean;
|
||||
allowDowngrade: false;
|
||||
}
|
||||
|
||||
export function getUpdateChannelConfiguration(
|
||||
experimentalUpdatesEnabled: boolean,
|
||||
): UpdateChannelConfiguration {
|
||||
return {
|
||||
channel: experimentalUpdatesEnabled
|
||||
? EXPERIMENTAL_UPDATE_CHANNEL
|
||||
: STABLE_UPDATE_CHANNEL,
|
||||
allowPrerelease: experimentalUpdatesEnabled,
|
||||
allowDowngrade: false,
|
||||
};
|
||||
}
|
||||
+46
-8
@@ -5,6 +5,10 @@ import { app, BrowserWindow, dialog } from "electron";
|
||||
import { autoUpdater } from "electron-updater";
|
||||
import { USER_DATA_PATH } from "./appPaths";
|
||||
import { readAppSetting, writeAppSetting } from "./appSettingsStore";
|
||||
import {
|
||||
EXPERIMENTAL_UPDATE_DESCRIPTION,
|
||||
getUpdateChannelConfiguration,
|
||||
} from "./updateChannel";
|
||||
|
||||
const UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
||||
export const UPDATE_REMINDER_DELAY_MS = 3 * 60 * 60 * 1000;
|
||||
@@ -14,6 +18,8 @@ const UPDATE_FEED_URL_OVERRIDE = process.env.RECORDLY_UPDATE_FEED_URL?.trim() ??
|
||||
const UPDATER_LOG_PATH =
|
||||
process.env.RECORDLY_UPDATER_LOG_PATH?.trim() || path.join(USER_DATA_PATH, "updater.log");
|
||||
const DEV_UPDATE_PREVIEW_VERSION = "9.9.9";
|
||||
const DEV_UPDATE_PREVIEW_IS_EXPERIMENTAL =
|
||||
process.env.RECORDLY_DEV_PREVIEW_EXPERIMENTAL_UPDATE === "1";
|
||||
const DEV_UPDATE_PREVIEW_PROGRESS_STEP_MS = 300;
|
||||
const DEV_UPDATE_PREVIEW_PROGRESS_INCREMENT = 20;
|
||||
const ONE_MEGABYTE = 1024 * 1024;
|
||||
@@ -43,6 +49,7 @@ export interface UpdateToastPayload {
|
||||
phase: UpdateToastPhase;
|
||||
delayMs: number;
|
||||
isPreview?: boolean;
|
||||
isExperimental?: boolean;
|
||||
progressPercent?: number;
|
||||
transferredBytes?: number;
|
||||
totalBytes?: number;
|
||||
@@ -132,14 +139,21 @@ export function getExperimentalUpdatesEnabled() {
|
||||
|
||||
function applyExperimentalUpdatesPreference() {
|
||||
const enabled = getExperimentalUpdatesEnabled();
|
||||
autoUpdater.allowPrerelease = enabled;
|
||||
writeUpdaterLog(`Update channel configured: ${enabled ? "experimental" : "stable"}.`);
|
||||
const { channel, allowPrerelease, allowDowngrade } = getUpdateChannelConfiguration(enabled);
|
||||
autoUpdater.channel = channel;
|
||||
autoUpdater.allowPrerelease = allowPrerelease;
|
||||
// Changing channels enables downgrades inside electron-updater. Recordly never
|
||||
// needs that behaviour: opting out waits for the next stable version instead.
|
||||
autoUpdater.allowDowngrade = allowDowngrade;
|
||||
writeUpdaterLog(
|
||||
`Update channel configured: ${enabled ? "experimental" : "stable"} (${channel}).`,
|
||||
);
|
||||
return enabled;
|
||||
}
|
||||
|
||||
export function setExperimentalUpdatesEnabled(enabled: boolean) {
|
||||
writeAppSetting(EXPERIMENTAL_UPDATES_SETTING_KEY, enabled);
|
||||
autoUpdater.allowPrerelease = enabled;
|
||||
applyExperimentalUpdatesPreference();
|
||||
skippedVersion = null;
|
||||
writeUpdaterLog(`Experimental updates ${enabled ? "enabled" : "disabled"} by user.`);
|
||||
return enabled;
|
||||
@@ -192,12 +206,22 @@ function emitUpdateToastState(
|
||||
return sendToRenderer("update-toast-state", payload);
|
||||
}
|
||||
|
||||
function createAvailableUpdateToastPayload(version: string): UpdateToastPayload {
|
||||
function getCurrentToastExperimentalFlag() {
|
||||
return currentToastPayload?.isExperimental ?? getExperimentalUpdatesEnabled();
|
||||
}
|
||||
|
||||
function createAvailableUpdateToastPayload(
|
||||
version: string,
|
||||
isExperimental = getExperimentalUpdatesEnabled(),
|
||||
): UpdateToastPayload {
|
||||
return {
|
||||
version,
|
||||
phase: "available",
|
||||
detail: "Install the latest version now, or remind yourself to come back to it later.",
|
||||
detail: isExperimental
|
||||
? EXPERIMENTAL_UPDATE_DESCRIPTION
|
||||
: "Install the latest version now, or remind yourself to come back to it later.",
|
||||
delayMs: UPDATE_REMINDER_DELAY_MS,
|
||||
isExperimental,
|
||||
primaryAction: "install-and-restart",
|
||||
};
|
||||
}
|
||||
@@ -205,6 +229,7 @@ function createAvailableUpdateToastPayload(version: string): UpdateToastPayload
|
||||
function createDownloadingUpdateToastPayload(
|
||||
version: string,
|
||||
progress: DownloadProgressSnapshot = {},
|
||||
isExperimental = getCurrentToastExperimentalFlag(),
|
||||
): UpdateToastPayload {
|
||||
const normalizedProgress = Math.max(
|
||||
0,
|
||||
@@ -238,6 +263,7 @@ function createDownloadingUpdateToastPayload(
|
||||
? `${remainingMb.toFixed(1)} MB left before Recordly restarts.`
|
||||
: "Downloading the update now. Recordly will restart when it finishes.",
|
||||
delayMs: UPDATE_REMINDER_DELAY_MS,
|
||||
isExperimental,
|
||||
progressPercent: normalizedProgress,
|
||||
transferredBytes,
|
||||
totalBytes,
|
||||
@@ -247,22 +273,31 @@ function createDownloadingUpdateToastPayload(
|
||||
};
|
||||
}
|
||||
|
||||
function createDownloadedUpdateToastPayload(version: string): UpdateToastPayload {
|
||||
function createDownloadedUpdateToastPayload(
|
||||
version: string,
|
||||
isExperimental = getCurrentToastExperimentalFlag(),
|
||||
): UpdateToastPayload {
|
||||
return {
|
||||
version,
|
||||
phase: "ready",
|
||||
detail: "The update is ready. Install and restart now, or remind yourself later.",
|
||||
delayMs: UPDATE_REMINDER_DELAY_MS,
|
||||
isExperimental,
|
||||
primaryAction: "install-and-restart",
|
||||
};
|
||||
}
|
||||
|
||||
function createUpdateErrorToastPayload(version: string, error: unknown): UpdateToastPayload {
|
||||
function createUpdateErrorToastPayload(
|
||||
version: string,
|
||||
error: unknown,
|
||||
isExperimental = getCurrentToastExperimentalFlag(),
|
||||
): UpdateToastPayload {
|
||||
return {
|
||||
version,
|
||||
phase: "error",
|
||||
detail: `The update could not be downloaded. ${String(error)}`,
|
||||
delayMs: UPDATE_REMINDER_DELAY_MS,
|
||||
isExperimental,
|
||||
primaryAction: "install-and-restart",
|
||||
};
|
||||
}
|
||||
@@ -531,9 +566,12 @@ export function previewUpdateToast(sendToRenderer: UpdateToastSender) {
|
||||
return emitUpdateToastState(sendToRenderer, {
|
||||
version: DEV_UPDATE_PREVIEW_VERSION,
|
||||
phase: "available",
|
||||
detail: "This is a development preview of the in-app update toast.",
|
||||
detail: DEV_UPDATE_PREVIEW_IS_EXPERIMENTAL
|
||||
? EXPERIMENTAL_UPDATE_DESCRIPTION
|
||||
: "This is a development preview of the in-app update toast.",
|
||||
delayMs: UPDATE_REMINDER_DELAY_MS,
|
||||
isPreview: true,
|
||||
isExperimental: DEV_UPDATE_PREVIEW_IS_EXPERIMENTAL,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+44
-23
@@ -37,12 +37,12 @@ let hudOverlayRecordingActive = false;
|
||||
let hudOverlayWebcamPreviewVisible = false;
|
||||
let countdownWindow: BrowserWindow | null = null;
|
||||
let updateToastWindow: BrowserWindow | null = null;
|
||||
let hudWasVisibleBeforeUpdateToast = false;
|
||||
|
||||
const HUD_OVERLAY_SETTINGS_FILE = path.join(USER_DATA_PATH, "hud-overlay-settings.json");
|
||||
const HUD_EDGE_MARGIN_DIP = 16;
|
||||
const UPDATE_TOAST_WIDTH = 456;
|
||||
const UPDATE_TOAST_HEIGHT = 252;
|
||||
const UPDATE_TOAST_GAP_DIP = 18;
|
||||
const UPDATE_TOAST_WIDTH = 420;
|
||||
const UPDATE_TOAST_HEIGHT = 172;
|
||||
|
||||
function getEditorWindowQuery(): Record<string, string> {
|
||||
const query: Record<string, string> = {
|
||||
@@ -207,11 +207,9 @@ function getUpdateToastBounds() {
|
||||
if (hudWindow) {
|
||||
const hudBounds = hudWindow.getBounds();
|
||||
const display = getScreen().getDisplayMatching(hudBounds);
|
||||
const x = Math.round(hudBounds.x + (hudBounds.width - UPDATE_TOAST_WIDTH) / 2);
|
||||
const y = Math.max(
|
||||
display.workArea.y + HUD_EDGE_MARGIN_DIP,
|
||||
hudBounds.y - UPDATE_TOAST_HEIGHT - UPDATE_TOAST_GAP_DIP,
|
||||
);
|
||||
const { workArea } = display;
|
||||
const x = Math.round(workArea.x + (workArea.width - UPDATE_TOAST_WIDTH) / 2);
|
||||
const y = Math.round(workArea.y + workArea.height - UPDATE_TOAST_HEIGHT - HUD_EDGE_MARGIN_DIP);
|
||||
|
||||
return {
|
||||
x,
|
||||
@@ -225,7 +223,7 @@ function getUpdateToastBounds() {
|
||||
const { workArea } = primaryDisplay;
|
||||
return {
|
||||
x: Math.round(workArea.x + (workArea.width - UPDATE_TOAST_WIDTH) / 2),
|
||||
y: workArea.y + HUD_EDGE_MARGIN_DIP,
|
||||
y: Math.round(workArea.y + workArea.height - UPDATE_TOAST_HEIGHT - HUD_EDGE_MARGIN_DIP),
|
||||
width: UPDATE_TOAST_WIDTH,
|
||||
height: UPDATE_TOAST_HEIGHT,
|
||||
};
|
||||
@@ -462,6 +460,10 @@ export function createHudOverlayWindow(): BrowserWindow {
|
||||
if (hasShownHudWindow || win.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
if (updateToastWindow && !updateToastWindow.isDestroyed() && updateToastWindow.isVisible()) {
|
||||
hudWasVisibleBeforeUpdateToast = true;
|
||||
return;
|
||||
}
|
||||
hasShownHudWindow = true;
|
||||
if (process.platform === "win32") {
|
||||
// A focusable window is required for a Windows taskbar entry, but the
|
||||
@@ -646,11 +648,6 @@ export function setHudOverlayRecordingActive(recording: boolean): void {
|
||||
|
||||
export function createUpdateToastWindow(): BrowserWindow {
|
||||
const initialBounds = getUpdateToastBounds();
|
||||
const parentWindow =
|
||||
process.platform === "darwin" && hudOverlayWindow && !hudOverlayWindow.isDestroyed()
|
||||
? hudOverlayWindow
|
||||
: undefined;
|
||||
const useTransparentToastWindow = process.platform !== "win32";
|
||||
|
||||
const win = new BrowserWindow({
|
||||
width: initialBounds.width,
|
||||
@@ -658,15 +655,14 @@ export function createUpdateToastWindow(): BrowserWindow {
|
||||
x: initialBounds.x,
|
||||
y: initialBounds.y,
|
||||
frame: false,
|
||||
transparent: useTransparentToastWindow,
|
||||
transparent: true,
|
||||
resizable: false,
|
||||
alwaysOnTop: true,
|
||||
skipTaskbar: true,
|
||||
hasShadow: false,
|
||||
show: false,
|
||||
focusable: true,
|
||||
...(parentWindow ? { parent: parentWindow } : {}),
|
||||
backgroundColor: useTransparentToastWindow ? "#00000000" : "#101418",
|
||||
backgroundColor: "#00000000",
|
||||
webPreferences: {
|
||||
preload: path.join(electronWindowsDir, "preload.mjs"),
|
||||
nodeIntegration: false,
|
||||
@@ -691,6 +687,7 @@ export function createUpdateToastWindow(): BrowserWindow {
|
||||
if (updateToastWindow === win) {
|
||||
updateToastWindow = null;
|
||||
}
|
||||
restoreHudAfterUpdateToast();
|
||||
});
|
||||
|
||||
if (VITE_DEV_SERVER_URL) {
|
||||
@@ -710,27 +707,51 @@ export function getUpdateToastWindow(): BrowserWindow | null {
|
||||
|
||||
export function showUpdateToastWindow(): BrowserWindow {
|
||||
const win = getUpdateToastWindow() ?? createUpdateToastWindow();
|
||||
const hud = getHudOverlayWindow();
|
||||
if (!win.isVisible()) {
|
||||
hudWasVisibleBeforeUpdateToast = Boolean(hud?.isVisible());
|
||||
}
|
||||
if (hud?.isVisible()) {
|
||||
hud.hide();
|
||||
}
|
||||
positionUpdateToastWindow();
|
||||
if (!win.isVisible()) {
|
||||
if (process.platform === "win32") {
|
||||
win.show();
|
||||
win.moveTop();
|
||||
} else {
|
||||
win.showInactive();
|
||||
}
|
||||
} else {
|
||||
win.moveTop();
|
||||
}
|
||||
win.moveTop();
|
||||
|
||||
return win;
|
||||
}
|
||||
|
||||
export function hideUpdateToastWindow(): void {
|
||||
if (!updateToastWindow || updateToastWindow.isDestroyed()) {
|
||||
function restoreHudAfterUpdateToast(): void {
|
||||
if (!hudWasVisibleBeforeUpdateToast) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateToastWindow.hide();
|
||||
hudWasVisibleBeforeUpdateToast = false;
|
||||
const hud = getHudOverlayWindow();
|
||||
if (!hud) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.platform === "win32") {
|
||||
hud.showInactive();
|
||||
} else {
|
||||
hud.show();
|
||||
}
|
||||
hud.moveTop();
|
||||
setHudOverlayMousePassthrough(hudOverlayIgnoringMouse);
|
||||
}
|
||||
|
||||
export function hideUpdateToastWindow(): void {
|
||||
if (updateToastWindow && !updateToastWindow.isDestroyed()) {
|
||||
updateToastWindow.hide();
|
||||
}
|
||||
restoreHudAfterUpdateToast();
|
||||
}
|
||||
|
||||
function loadPackagedEditorWindow(win: BrowserWindow) {
|
||||
|
||||
+2
-3
@@ -15,7 +15,6 @@ import { loadAllCustomFonts } from "./lib/customFonts";
|
||||
export default function App() {
|
||||
const [windowType, setWindowType] = useState("");
|
||||
const { t } = useI18n();
|
||||
const isMacOS = /mac/i.test(navigator.platform);
|
||||
const appIconSrc = "/app-icons/recordly-128.png";
|
||||
|
||||
useEffect(() => {
|
||||
@@ -28,7 +27,7 @@ export default function App() {
|
||||
type === "hud-overlay" ||
|
||||
type === "source-selector" ||
|
||||
type === "countdown" ||
|
||||
(type === "update-toast" && isMacOS)
|
||||
type === "update-toast"
|
||||
) {
|
||||
document.body.style.background = "transparent";
|
||||
document.documentElement.style.background = "transparent";
|
||||
@@ -49,7 +48,7 @@ export default function App() {
|
||||
loadAllCustomFonts().catch((error) => {
|
||||
console.error("Failed to load custom fonts:", error);
|
||||
});
|
||||
}, [isMacOS]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.title =
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
.window {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 8px;
|
||||
box-sizing: border-box;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
background: hsl(var(--background));
|
||||
box-shadow: none;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 7px;
|
||||
background: hsl(var(--muted));
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.iconError {
|
||||
background: hsl(var(--destructive) / 0.14);
|
||||
color: hsl(var(--destructive));
|
||||
}
|
||||
|
||||
.content {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.headingRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 19px;
|
||||
}
|
||||
|
||||
.headingRow h1 {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.content > p {
|
||||
margin: 4px 0 0;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.version,
|
||||
.preview {
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
background: hsl(var(--muted));
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.preview {
|
||||
background: hsl(var(--primary) / 0.12);
|
||||
color: hsl(var(--primary));
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.primaryButton,
|
||||
.secondaryButton {
|
||||
height: 30px;
|
||||
padding: 0 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 140ms ease,
|
||||
border-color 140ms ease,
|
||||
transform 140ms ease;
|
||||
}
|
||||
|
||||
.primaryButton:active,
|
||||
.secondaryButton:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.primaryButton {
|
||||
border: 1px solid hsl(var(--primary));
|
||||
background: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
}
|
||||
|
||||
.primaryButton:hover {
|
||||
background: hsl(var(--primary) / 0.9);
|
||||
}
|
||||
|
||||
.secondaryButton {
|
||||
border: 1px solid hsl(var(--input));
|
||||
background: hsl(var(--background));
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.secondaryButton:hover {
|
||||
background: hsl(var(--accent));
|
||||
color: hsl(var(--accent-foreground));
|
||||
}
|
||||
|
||||
.progressBlock {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.progressTrack {
|
||||
height: 5px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: hsl(var(--muted));
|
||||
}
|
||||
|
||||
.progressFill {
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: hsl(var(--primary));
|
||||
transition: width 220ms ease;
|
||||
}
|
||||
|
||||
.progressMeta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 6px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.progressMeta strong {
|
||||
color: hsl(var(--foreground));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 900ms linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import {
|
||||
WarningCircle as AlertCircle,
|
||||
DownloadSimple as Download,
|
||||
Spinner as LoaderCircle,
|
||||
Rocket,
|
||||
ArrowClockwiseIcon,
|
||||
CheckCircleIcon,
|
||||
DownloadSimpleIcon,
|
||||
WarningCircleIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useI18n } from "@/contexts/I18nContext";
|
||||
import styles from "./UpdateToastWindow.module.css";
|
||||
|
||||
type UpdateToastPayload = {
|
||||
version: string;
|
||||
@@ -12,385 +14,186 @@ type UpdateToastPayload = {
|
||||
phase: "available" | "downloading" | "ready" | "error";
|
||||
delayMs: number;
|
||||
isPreview?: boolean;
|
||||
isExperimental?: boolean;
|
||||
progressPercent?: number;
|
||||
transferredBytes?: number;
|
||||
totalBytes?: number;
|
||||
remainingBytes?: number;
|
||||
bytesPerSecond?: number;
|
||||
primaryAction?: "install-and-restart" | "retry-check";
|
||||
};
|
||||
|
||||
const DEFAULT_REMINDER_DELAY_MS = 3 * 60 * 60 * 1000;
|
||||
const REMINDER_OPTIONS = [
|
||||
{ label: "1 hour", value: 1 * 60 * 60 * 1000 },
|
||||
{ label: "3 hours", value: 3 * 60 * 60 * 1000 },
|
||||
{ label: "Tomorrow", value: 24 * 60 * 60 * 1000 },
|
||||
{ label: "3 days", value: 3 * 24 * 60 * 60 * 1000 },
|
||||
];
|
||||
|
||||
function formatBytes(value: number | undefined) {
|
||||
if (value === undefined || !Number.isFinite(value) || value <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const megabytes = value / (1024 * 1024);
|
||||
if (megabytes >= 1024) {
|
||||
return `${(megabytes / 1024).toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
return `${megabytes.toFixed(megabytes >= 100 ? 0 : 1)} MB`;
|
||||
return megabytes >= 1024
|
||||
? `${(megabytes / 1024).toFixed(1)} GB`
|
||||
: `${megabytes.toFixed(megabytes >= 100 ? 0 : 1)} MB`;
|
||||
}
|
||||
|
||||
function getToastTitle(payload: UpdateToastPayload) {
|
||||
if (payload.isPreview) {
|
||||
return "Update Prompt Preview";
|
||||
}
|
||||
type Translate = ReturnType<typeof useI18n>["t"];
|
||||
|
||||
function getTitle(payload: UpdateToastPayload, t: Translate) {
|
||||
switch (payload.phase) {
|
||||
case "available":
|
||||
return `Recordly ${payload.version} is available`;
|
||||
return payload.isExperimental
|
||||
? t(
|
||||
"launch.updateToast.experimentalAvailableTitle",
|
||||
"Experimental update available",
|
||||
)
|
||||
: t("launch.updateToast.availableTitle", "Update available");
|
||||
case "downloading":
|
||||
return `Installing Recordly ${payload.version}`;
|
||||
return t("launch.updateToast.downloadingTitle", "Downloading your update");
|
||||
case "ready":
|
||||
return `Recordly ${payload.version} is ready`;
|
||||
return t("launch.updateToast.readyTitle", "Ready to restart");
|
||||
case "error":
|
||||
return payload.primaryAction === "retry-check"
|
||||
? "Could not check for updates"
|
||||
: `Recordly ${payload.version} needs attention`;
|
||||
? t("launch.updateToast.checkErrorTitle", "Couldn’t check for updates")
|
||||
: t("launch.updateToast.downloadErrorTitle", "Couldn’t download the update");
|
||||
}
|
||||
}
|
||||
|
||||
function getPrimaryButtonLabel(payload: UpdateToastPayload) {
|
||||
return payload.primaryAction === "retry-check" ? "Try Again" : "Install & Restart";
|
||||
function getDetail(payload: UpdateToastPayload, t: Translate) {
|
||||
if (payload.phase === "available" && payload.isExperimental) {
|
||||
return t(
|
||||
"launch.updateToast.experimentalDescription",
|
||||
"You've opted into experimental updates so you have the choice to test the latest update of Recordly before it's widely available.",
|
||||
);
|
||||
}
|
||||
|
||||
return payload.detail;
|
||||
}
|
||||
|
||||
function getPhaseIcon(payload: UpdateToastPayload) {
|
||||
function getPrimaryLabel(payload: UpdateToastPayload, t: Translate) {
|
||||
if (payload.primaryAction === "retry-check") {
|
||||
return t("launch.updateToast.tryAgain", "Try again");
|
||||
}
|
||||
return payload.phase === "ready"
|
||||
? t("launch.updateToast.restartToUpdate", "Restart to update")
|
||||
: t("launch.updateToast.updateNow", "Update now");
|
||||
}
|
||||
|
||||
function PhaseIcon({ payload }: { payload: UpdateToastPayload }) {
|
||||
switch (payload.phase) {
|
||||
case "available":
|
||||
return <Download size={20} />;
|
||||
return <DownloadSimpleIcon size={20} weight="bold" />;
|
||||
case "downloading":
|
||||
return <LoaderCircle size={20} className="animate-spin" />;
|
||||
return <ArrowClockwiseIcon size={20} weight="bold" className={styles.spin} />;
|
||||
case "ready":
|
||||
return <Rocket size={20} />;
|
||||
return <CheckCircleIcon size={20} weight="fill" />;
|
||||
case "error":
|
||||
return <AlertCircle size={20} />;
|
||||
return <WarningCircleIcon size={20} weight="fill" />;
|
||||
}
|
||||
}
|
||||
|
||||
export function UpdateToastWindow() {
|
||||
const [payload, setPayload] = useState<UpdateToastPayload | null>(null);
|
||||
const [reminderDelayMs, setReminderDelayMs] = useState(DEFAULT_REMINDER_DELAY_MS);
|
||||
const { t } = useI18n();
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
void window.electronAPI.getCurrentUpdateToastPayload().then((nextPayload) => {
|
||||
if (mounted) {
|
||||
setPayload(nextPayload);
|
||||
}
|
||||
});
|
||||
|
||||
pollTimer = setInterval(() => {
|
||||
const refresh = () => {
|
||||
void window.electronAPI.getCurrentUpdateToastPayload().then((nextPayload) => {
|
||||
if (mounted) {
|
||||
setPayload(nextPayload);
|
||||
}
|
||||
if (mounted) setPayload(nextPayload);
|
||||
});
|
||||
}, 750);
|
||||
};
|
||||
|
||||
const dispose = window.electronAPI.onUpdateToastStateChanged((nextPayload) => {
|
||||
setPayload(nextPayload);
|
||||
});
|
||||
refresh();
|
||||
const pollTimer = setInterval(refresh, 750);
|
||||
const dispose = window.electronAPI.onUpdateToastStateChanged(setPayload);
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
}
|
||||
clearInterval(pollTimer);
|
||||
dispose();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
setReminderDelayMs(payload.delayMs || DEFAULT_REMINDER_DELAY_MS);
|
||||
}, [payload]);
|
||||
|
||||
const normalizedProgress = Math.max(
|
||||
0,
|
||||
Math.min(100, Math.round(payload?.progressPercent ?? 0)),
|
||||
);
|
||||
const downloadedLabel = formatBytes(payload?.transferredBytes);
|
||||
const totalLabel = formatBytes(payload?.totalBytes);
|
||||
const remainingLabel = formatBytes(payload?.remainingBytes);
|
||||
const speedLabel = formatBytes(payload?.bytesPerSecond);
|
||||
const phaseStats: Array<{ label: string; value: string }> = [];
|
||||
if (payload?.phase === "downloading") {
|
||||
if (downloadedLabel && totalLabel) {
|
||||
phaseStats.push({ label: "Downloaded", value: `${downloadedLabel} / ${totalLabel}` });
|
||||
} else if (downloadedLabel) {
|
||||
phaseStats.push({ label: "Downloaded", value: downloadedLabel });
|
||||
}
|
||||
if (remainingLabel) {
|
||||
phaseStats.push({ label: "Left", value: remainingLabel });
|
||||
}
|
||||
if (speedLabel) {
|
||||
phaseStats.push({ label: "Speed", value: `${speedLabel}/s` });
|
||||
}
|
||||
if (!payload) {
|
||||
return <div className={styles.window} />;
|
||||
}
|
||||
|
||||
const isMacOS = /mac/i.test(navigator.platform);
|
||||
const wrapperStyle = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
padding: 10,
|
||||
boxSizing: "border-box",
|
||||
background: isMacOS ? "transparent" : "#0b1220",
|
||||
} as const;
|
||||
const cardStyle = {
|
||||
width: "100%",
|
||||
maxWidth: 440,
|
||||
display: "flex",
|
||||
gap: 14,
|
||||
alignItems: "flex-start",
|
||||
padding: "18px 18px 16px",
|
||||
borderRadius: 24,
|
||||
background:
|
||||
"linear-gradient(180deg, rgba(12, 19, 34, 0.98) 0%, rgba(10, 17, 30, 0.98) 100%)",
|
||||
border: "1px solid rgba(37, 99, 235, 0.24)",
|
||||
boxShadow: "0 20px 48px rgba(2, 6, 23, 0.5), inset 0 1px 0 rgba(148, 163, 184, 0.08)",
|
||||
color: "#ffffff",
|
||||
fontFamily: "var(--app-font-sans)",
|
||||
} as const;
|
||||
const iconBoxStyle = {
|
||||
width: 42,
|
||||
height: 42,
|
||||
minWidth: 42,
|
||||
borderRadius: 16,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "rgba(37, 99, 235, 0.16)",
|
||||
color: "#60a5fa",
|
||||
boxShadow: "inset 0 0 0 1px rgba(37, 99, 235, 0.18)",
|
||||
} as const;
|
||||
const titleStyle = {
|
||||
fontSize: 15,
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.25,
|
||||
margin: 0,
|
||||
color: "#f8fafc",
|
||||
} as const;
|
||||
const secondaryTextStyle = {
|
||||
color: "rgba(226, 232, 240, 0.78)",
|
||||
fontSize: 13,
|
||||
lineHeight: 1.5,
|
||||
margin: "6px 0 0 0",
|
||||
} as const;
|
||||
const subtleButtonStyle = {
|
||||
height: 38,
|
||||
borderRadius: 12,
|
||||
padding: "0 14px",
|
||||
border: "1px solid rgba(148, 163, 184, 0.16)",
|
||||
background: "rgba(15, 23, 42, 0.72)",
|
||||
color: "#e2e8f0",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
transition: "all 0.15s ease",
|
||||
} as const;
|
||||
const primaryButtonStyle = {
|
||||
...subtleButtonStyle,
|
||||
border: "none",
|
||||
background: "linear-gradient(180deg, #3b82f6 0%, #2563eb 100%)",
|
||||
color: "#ffffff",
|
||||
boxShadow: "0 12px 24px rgba(37, 99, 235, 0.26)",
|
||||
} as const;
|
||||
const selectStyle = {
|
||||
height: 38,
|
||||
borderRadius: 12,
|
||||
padding: "0 34px 0 12px",
|
||||
border: "1px solid rgba(37, 99, 235, 0.22)",
|
||||
background:
|
||||
"linear-gradient(180deg, rgba(18, 29, 51, 0.96) 0%, rgba(12, 22, 42, 0.96) 100%)",
|
||||
color: "#dbeafe",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
outline: "none",
|
||||
boxShadow: "inset 0 0 0 1px rgba(37, 99, 235, 0.06)",
|
||||
cursor: "pointer",
|
||||
} as const;
|
||||
const progress = Math.max(0, Math.min(100, Math.round(payload.progressPercent ?? 0)));
|
||||
const transferred = formatBytes(payload.transferredBytes);
|
||||
const total = formatBytes(payload.totalBytes);
|
||||
const speed = formatBytes(payload.bytesPerSecond);
|
||||
const progressDetail = [
|
||||
transferred && total ? `${transferred} of ${total}` : transferred,
|
||||
speed ? `${speed}/s` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
|
||||
const handlePrimaryAction = async () => {
|
||||
if (!payload || payload.phase === "downloading") {
|
||||
return;
|
||||
}
|
||||
if (payload.phase === "downloading") return;
|
||||
|
||||
if (payload.primaryAction === "retry-check") {
|
||||
await window.electronAPI.checkForAppUpdates();
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.phase === "ready") {
|
||||
await window.electronAPI.installDownloadedUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
await window.electronAPI.downloadAvailableUpdate(true);
|
||||
};
|
||||
|
||||
const handleLater = async () => {
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleNotNow = async () => {
|
||||
if (payload.isPreview) {
|
||||
await window.electronAPI.dismissUpdateToast();
|
||||
return;
|
||||
}
|
||||
|
||||
await window.electronAPI.deferDownloadedUpdate(reminderDelayMs);
|
||||
await window.electronAPI.deferDownloadedUpdate(payload.delayMs);
|
||||
};
|
||||
|
||||
if (!payload) {
|
||||
return <div style={wrapperStyle} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={wrapperStyle}>
|
||||
<div style={cardStyle}>
|
||||
<div style={iconBoxStyle}>{getPhaseIcon(payload)}</div>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<p style={titleStyle}>{getToastTitle(payload)}</p>
|
||||
<div className={`${styles.window} launch-theme`}>
|
||||
<section className={styles.card} aria-live="polite" aria-label="Recordly update">
|
||||
<div className={`${styles.icon} ${payload.phase === "error" ? styles.iconError : ""}`}>
|
||||
<PhaseIcon payload={payload} />
|
||||
</div>
|
||||
|
||||
<div className={styles.content}>
|
||||
<div className={styles.headingRow}>
|
||||
<h1>{getTitle(payload, t)}</h1>
|
||||
<span className={styles.version}>v{payload.version.replace(/^v/, "")}</span>
|
||||
{payload.isExperimental ? (
|
||||
<span className={styles.preview}>
|
||||
{t("launch.updateToast.experimentalBadge", "Experimental")}
|
||||
</span>
|
||||
) : null}
|
||||
{payload.isPreview ? (
|
||||
<span
|
||||
style={{
|
||||
borderRadius: 999,
|
||||
padding: "2px 8px",
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.18em",
|
||||
textTransform: "uppercase",
|
||||
color: "#93c5fd",
|
||||
background: "rgba(37, 99, 235, 0.14)",
|
||||
border: "1px solid rgba(37, 99, 235, 0.18)",
|
||||
}}
|
||||
>
|
||||
Dev
|
||||
<span className={styles.preview}>
|
||||
{t("launch.updateToast.previewBadge", "Preview")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p style={secondaryTextStyle}>{payload.detail}</p>
|
||||
<p>{getDetail(payload, t)}</p>
|
||||
|
||||
{payload.phase === "downloading" ? (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<div
|
||||
style={{
|
||||
height: 10,
|
||||
overflow: "hidden",
|
||||
borderRadius: 999,
|
||||
background: "rgba(148, 163, 184, 0.14)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
width: `${normalizedProgress}%`,
|
||||
borderRadius: 999,
|
||||
background:
|
||||
"linear-gradient(90deg, #60a5fa 0%, #2563eb 45%, #1d4ed8 100%)",
|
||||
boxShadow: "0 0 22px rgba(37, 99, 235, 0.38)",
|
||||
}}
|
||||
/>
|
||||
<div className={styles.progressBlock}>
|
||||
<div className={styles.progressTrack}>
|
||||
<div className={styles.progressFill} style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: 8,
|
||||
marginTop: 10,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: "#dbeafe",
|
||||
}}
|
||||
>
|
||||
{normalizedProgress}% complete
|
||||
</span>
|
||||
{phaseStats.map((stat) => (
|
||||
<span
|
||||
key={stat.label}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: "rgba(191, 219, 254, 0.9)",
|
||||
background: "rgba(37, 99, 235, 0.12)",
|
||||
borderRadius: 999,
|
||||
padding: "4px 8px",
|
||||
border: "1px solid rgba(37, 99, 235, 0.16)",
|
||||
}}
|
||||
>
|
||||
{stat.label}: {stat.value}
|
||||
</span>
|
||||
))}
|
||||
<div className={styles.progressMeta}>
|
||||
<strong>{progress}%</strong>
|
||||
{progressDetail ? <span>{progressDetail}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: 10,
|
||||
marginTop: 14,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{payload.phase !== "downloading" ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePrimaryAction}
|
||||
style={primaryButtonStyle}
|
||||
>
|
||||
{getPrimaryButtonLabel(payload)}
|
||||
</button>
|
||||
<select
|
||||
value={String(reminderDelayMs)}
|
||||
onChange={(event) => {
|
||||
setReminderDelayMs(Number.parseInt(event.target.value, 10));
|
||||
}}
|
||||
style={selectStyle}
|
||||
>
|
||||
{REMINDER_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLater}
|
||||
style={subtleButtonStyle}
|
||||
>
|
||||
Later
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.actions}>
|
||||
<button type="button" className={styles.secondaryButton} onClick={handleNotNow}>
|
||||
{t("launch.updateToast.notNow", "Not now")}
|
||||
</button>
|
||||
<button type="button" className={styles.primaryButton} onClick={handlePrimaryAction}>
|
||||
{getPrimaryLabel(payload, t)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2566,7 +2566,7 @@ export function SettingsPanel({
|
||||
<div className="mt-0.5 text-[10px] text-muted-foreground/70">
|
||||
{tSettings(
|
||||
"updates.experimentalDescription",
|
||||
"Receive first-line test builds published as prereleases. These may be less stable.",
|
||||
"This is the front line of user testing - highly experimental so expect bugs",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -76,5 +76,20 @@
|
||||
"microphoneDenied": "Der Zugriff auf das Mikrofon wurde verweigert. Die Aufzeichnung wird ohne Mikrofonton fortgesetzt.",
|
||||
"failedToStart": "Die Aufzeichnung konnte nicht gestartet werden: {{error}}",
|
||||
"failedToStartGeneric": "Die Aufnahme konnte nicht gestartet werden"
|
||||
},
|
||||
"updateToast": {
|
||||
"availableTitle": "Update verfügbar",
|
||||
"experimentalAvailableTitle": "Experimentelles Update verfügbar",
|
||||
"experimentalDescription": "Sie haben experimentelle Updates aktiviert und können daher das neueste Update von Recordly testen, bevor es allgemein verfügbar ist.",
|
||||
"downloadingTitle": "Update wird heruntergeladen",
|
||||
"readyTitle": "Bereit zum Neustart",
|
||||
"checkErrorTitle": "Updates konnten nicht geprüft werden",
|
||||
"downloadErrorTitle": "Das Update konnte nicht heruntergeladen werden",
|
||||
"experimentalBadge": "Experimentell",
|
||||
"previewBadge": "Vorschau",
|
||||
"notNow": "Nicht jetzt",
|
||||
"updateNow": "Jetzt aktualisieren",
|
||||
"restartToUpdate": "Zum Aktualisieren neu starten",
|
||||
"tryAgain": "Erneut versuchen"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,5 +254,11 @@
|
||||
"micLabel": "Quellmikrofon",
|
||||
"mixedLabel": "Quelle",
|
||||
"deleteRegion": "Audio löschen"
|
||||
},
|
||||
"updates": {
|
||||
"experimentalDescription": "Sie haben experimentelle Updates aktiviert und können daher das neueste Update von Recordly testen, bevor es allgemein verfügbar ist.",
|
||||
"title": "Updates",
|
||||
"experimental": "Experimentelle Updates",
|
||||
"saveFailed": "Update-Kanal konnte nicht geändert werden."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,5 +76,20 @@
|
||||
"microphoneDenied": "Microphone access was denied. Recording will continue without microphone audio.",
|
||||
"failedToStart": "Failed to start recording: {{error}}",
|
||||
"failedToStartGeneric": "Failed to start recording"
|
||||
},
|
||||
"updateToast": {
|
||||
"availableTitle": "Update available",
|
||||
"experimentalAvailableTitle": "Experimental update available",
|
||||
"experimentalDescription": "You've opted into experimental updates so you have the choice to test the latest update of Recordly before it's widely available.",
|
||||
"downloadingTitle": "Downloading your update",
|
||||
"readyTitle": "Ready to restart",
|
||||
"checkErrorTitle": "Couldn't check for updates",
|
||||
"downloadErrorTitle": "Couldn't download the update",
|
||||
"experimentalBadge": "Experimental",
|
||||
"previewBadge": "Preview",
|
||||
"notNow": "Not now",
|
||||
"updateNow": "Update now",
|
||||
"restartToUpdate": "Restart to update",
|
||||
"tryAgain": "Try again"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"updates": {
|
||||
"title": "Updates",
|
||||
"experimental": "Experimental updates",
|
||||
"experimentalDescription": "Receive first-line test builds published as prereleases. These may be less stable.",
|
||||
"experimentalDescription": "You've opted into experimental updates so you have the choice to test the latest update of Recordly before it's widely available.",
|
||||
"saveFailed": "Failed to change the update channel."
|
||||
},
|
||||
"zoom": {
|
||||
|
||||
@@ -76,5 +76,20 @@
|
||||
"microphoneDenied": "Se denegó el acceso al micrófono. La grabación continuará sin audio del micrófono.",
|
||||
"failedToStart": "Error al iniciar la grabación: {{error}}",
|
||||
"failedToStartGeneric": "Error al iniciar la grabación"
|
||||
},
|
||||
"updateToast": {
|
||||
"availableTitle": "Actualización disponible",
|
||||
"experimentalAvailableTitle": "Actualización experimental disponible",
|
||||
"experimentalDescription": "Has activado las actualizaciones experimentales, así que puedes probar la actualización más reciente de Recordly antes de que esté disponible para todos.",
|
||||
"downloadingTitle": "Descargando la actualización",
|
||||
"readyTitle": "Listo para reiniciar",
|
||||
"checkErrorTitle": "No se pudieron buscar actualizaciones",
|
||||
"downloadErrorTitle": "No se pudo descargar la actualización",
|
||||
"experimentalBadge": "Experimental",
|
||||
"previewBadge": "Vista previa",
|
||||
"notNow": "Ahora no",
|
||||
"updateNow": "Actualizar ahora",
|
||||
"restartToUpdate": "Reiniciar para actualizar",
|
||||
"tryAgain": "Intentar de nuevo"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,5 +254,11 @@
|
||||
"micLabel": "Micrófono",
|
||||
"mixedLabel": "Fuente",
|
||||
"deleteRegion": "Eliminar audio"
|
||||
},
|
||||
"updates": {
|
||||
"experimentalDescription": "Has activado las actualizaciones experimentales, así que puedes probar la actualización más reciente de Recordly antes de que esté disponible para todos.",
|
||||
"title": "Actualizaciones",
|
||||
"experimental": "Actualizaciones experimentales",
|
||||
"saveFailed": "No se pudo cambiar el canal de actualización."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,5 +76,20 @@
|
||||
"microphoneDenied": "L’accès au microphone a été refusé. L’enregistrement continuera sans audio du microphone.",
|
||||
"failedToStart": "Échec du démarrage de l’enregistrement : {{error}}",
|
||||
"failedToStartGeneric": "Échec du démarrage de l’enregistrement"
|
||||
},
|
||||
"updateToast": {
|
||||
"availableTitle": "Mise à jour disponible",
|
||||
"experimentalAvailableTitle": "Mise à jour expérimentale disponible",
|
||||
"experimentalDescription": "Vous avez activé les mises à jour expérimentales, vous pouvez donc tester la dernière mise à jour de Recordly avant sa disponibilité générale.",
|
||||
"downloadingTitle": "Téléchargement de la mise à jour",
|
||||
"readyTitle": "Prêt à redémarrer",
|
||||
"checkErrorTitle": "Impossible de vérifier les mises à jour",
|
||||
"downloadErrorTitle": "Impossible de télécharger la mise à jour",
|
||||
"experimentalBadge": "Expérimental",
|
||||
"previewBadge": "Aperçu",
|
||||
"notNow": "Plus tard",
|
||||
"updateNow": "Mettre à jour",
|
||||
"restartToUpdate": "Redémarrer pour mettre à jour",
|
||||
"tryAgain": "Réessayer"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,5 +254,11 @@
|
||||
"micLabel": "Microphone",
|
||||
"mixedLabel": "Source",
|
||||
"deleteRegion": "Supprimer la zone audio"
|
||||
},
|
||||
"updates": {
|
||||
"experimentalDescription": "Vous avez activé les mises à jour expérimentales, vous pouvez donc tester la dernière mise à jour de Recordly avant sa disponibilité générale.",
|
||||
"title": "Mises à jour",
|
||||
"experimental": "Mises à jour expérimentales",
|
||||
"saveFailed": "Impossible de changer le canal de mise à jour."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,5 +76,20 @@
|
||||
"microphoneDenied": "L'accesso al microfono è stato negato. La registrazione continuerà senza audio del microfono.",
|
||||
"failedToStart": "Avvio della registrazione non riuscito: {{error}}",
|
||||
"failedToStartGeneric": "Avvio della registrazione non riuscito"
|
||||
},
|
||||
"updateToast": {
|
||||
"availableTitle": "Aggiornamento disponibile",
|
||||
"experimentalAvailableTitle": "Aggiornamento sperimentale disponibile",
|
||||
"experimentalDescription": "Hai attivato gli aggiornamenti sperimentali, quindi puoi scegliere di provare l'ultimo aggiornamento di Recordly prima che sia disponibile per tutti.",
|
||||
"downloadingTitle": "Download dell'aggiornamento",
|
||||
"readyTitle": "Pronto per il riavvio",
|
||||
"checkErrorTitle": "Impossibile verificare gli aggiornamenti",
|
||||
"downloadErrorTitle": "Impossibile scaricare l'aggiornamento",
|
||||
"experimentalBadge": "Sperimentale",
|
||||
"previewBadge": "Anteprima",
|
||||
"notNow": "Non ora",
|
||||
"updateNow": "Aggiorna ora",
|
||||
"restartToUpdate": "Riavvia per aggiornare",
|
||||
"tryAgain": "Riprova"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,5 +254,11 @@
|
||||
"micLabel": "Sorgente microfono",
|
||||
"mixedLabel": "Sorgente",
|
||||
"deleteRegion": "Elimina audio"
|
||||
},
|
||||
"updates": {
|
||||
"experimentalDescription": "Hai attivato gli aggiornamenti sperimentali, quindi puoi scegliere di provare l'ultimo aggiornamento di Recordly prima che sia disponibile per tutti.",
|
||||
"title": "Aggiornamenti",
|
||||
"experimental": "Aggiornamenti sperimentali",
|
||||
"saveFailed": "Impossibile cambiare il canale di aggiornamento."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,5 +76,20 @@
|
||||
"microphoneDenied": "마이크 접근이 거부되었습니다. 마이크 오디오 없이 녹화를 계속합니다.",
|
||||
"failedToStart": "녹화 시작에 실패했습니다: {{error}}",
|
||||
"failedToStartGeneric": "녹화 시작에 실패했습니다"
|
||||
},
|
||||
"updateToast": {
|
||||
"availableTitle": "업데이트 사용 가능",
|
||||
"experimentalAvailableTitle": "실험적 업데이트 사용 가능",
|
||||
"experimentalDescription": "실험적 업데이트를 선택했으므로 Recordly의 최신 업데이트가 널리 제공되기 전에 먼저 테스트해 볼 수 있습니다.",
|
||||
"downloadingTitle": "업데이트 다운로드 중",
|
||||
"readyTitle": "다시 시작할 준비 완료",
|
||||
"checkErrorTitle": "업데이트를 확인할 수 없습니다",
|
||||
"downloadErrorTitle": "업데이트를 다운로드할 수 없습니다",
|
||||
"experimentalBadge": "실험적",
|
||||
"previewBadge": "미리보기",
|
||||
"notNow": "나중에",
|
||||
"updateNow": "지금 업데이트",
|
||||
"restartToUpdate": "다시 시작하여 업데이트",
|
||||
"tryAgain": "다시 시도"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,5 +254,11 @@
|
||||
"micLabel": "마이크 소스",
|
||||
"mixedLabel": "소스",
|
||||
"deleteRegion": "오디오 삭제"
|
||||
},
|
||||
"updates": {
|
||||
"experimentalDescription": "실험적 업데이트를 선택했으므로 Recordly의 최신 업데이트가 널리 제공되기 전에 먼저 테스트해 볼 수 있습니다.",
|
||||
"title": "업데이트",
|
||||
"experimental": "실험적 업데이트",
|
||||
"saveFailed": "업데이트 채널을 변경하지 못했습니다."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,5 +76,20 @@
|
||||
"microphoneDenied": "Microfoontoegang is geweigerd. De opname gaat verder zonder microfoonaudio.",
|
||||
"failedToStart": "Opname starten mislukt: {{error}}",
|
||||
"failedToStartGeneric": "Opname starten mislukt"
|
||||
},
|
||||
"updateToast": {
|
||||
"availableTitle": "Update beschikbaar",
|
||||
"experimentalAvailableTitle": "Experimentele update beschikbaar",
|
||||
"experimentalDescription": "Je hebt experimentele updates ingeschakeld, dus je kunt de nieuwste update van Recordly testen voordat die breed beschikbaar is.",
|
||||
"downloadingTitle": "Update downloaden",
|
||||
"readyTitle": "Klaar om opnieuw te starten",
|
||||
"checkErrorTitle": "Kan niet controleren op updates",
|
||||
"downloadErrorTitle": "Kan de update niet downloaden",
|
||||
"experimentalBadge": "Experimenteel",
|
||||
"previewBadge": "Preview",
|
||||
"notNow": "Niet nu",
|
||||
"updateNow": "Nu updaten",
|
||||
"restartToUpdate": "Opnieuw starten om te updaten",
|
||||
"tryAgain": "Opnieuw proberen"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,5 +254,11 @@
|
||||
"micLabel": "Microfoon",
|
||||
"mixedLabel": "Bron",
|
||||
"deleteRegion": "Audio verwijderen"
|
||||
},
|
||||
"updates": {
|
||||
"experimentalDescription": "Je hebt experimentele updates ingeschakeld, dus je kunt de nieuwste update van Recordly testen voordat die breed beschikbaar is.",
|
||||
"title": "Updates",
|
||||
"experimental": "Experimentele updates",
|
||||
"saveFailed": "Kan het updatekanaal niet wijzigen."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,5 +76,20 @@
|
||||
"microphoneDenied": "O acesso ao microfone foi negado. A gravação continuará sem áudio do microfone.",
|
||||
"failedToStart": "Falha ao iniciar gravação: {{error}}",
|
||||
"failedToStartGeneric": "Falha ao iniciar gravação"
|
||||
},
|
||||
"updateToast": {
|
||||
"availableTitle": "Atualização disponível",
|
||||
"experimentalAvailableTitle": "Atualização experimental disponível",
|
||||
"experimentalDescription": "Você ativou as atualizações experimentais, então pode escolher testar a atualização mais recente do Recordly antes que ela fique amplamente disponível.",
|
||||
"downloadingTitle": "Baixando sua atualização",
|
||||
"readyTitle": "Pronto para reiniciar",
|
||||
"checkErrorTitle": "Não foi possível verificar atualizações",
|
||||
"downloadErrorTitle": "Não foi possível baixar a atualização",
|
||||
"experimentalBadge": "Experimental",
|
||||
"previewBadge": "Prévia",
|
||||
"notNow": "Agora não",
|
||||
"updateNow": "Atualizar agora",
|
||||
"restartToUpdate": "Reiniciar para atualizar",
|
||||
"tryAgain": "Tentar novamente"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,5 +254,11 @@
|
||||
"micLabel": "Microfone",
|
||||
"mixedLabel": "Fonte",
|
||||
"deleteRegion": "Excluir áudio"
|
||||
},
|
||||
"updates": {
|
||||
"experimentalDescription": "Você ativou as atualizações experimentais, então pode escolher testar a atualização mais recente do Recordly antes que ela fique amplamente disponível.",
|
||||
"title": "Atualizações",
|
||||
"experimental": "Atualizações experimentais",
|
||||
"saveFailed": "Falha ao alterar o canal de atualização."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,5 +76,20 @@
|
||||
"microphoneDenied": "Нет доступа к микрофону. Запись продолжится без вашего голоса.",
|
||||
"failedToStart": "Не удалось начать запись: {{error}}",
|
||||
"failedToStartGeneric": "Не удалось начать запись"
|
||||
},
|
||||
"updateToast": {
|
||||
"availableTitle": "Доступно обновление",
|
||||
"experimentalAvailableTitle": "Доступно экспериментальное обновление",
|
||||
"experimentalDescription": "Вы включили экспериментальные обновления, поэтому можете протестировать последнее обновление Recordly до его широкого выпуска.",
|
||||
"downloadingTitle": "Загрузка обновления",
|
||||
"readyTitle": "Готово к перезапуску",
|
||||
"checkErrorTitle": "Не удалось проверить обновления",
|
||||
"downloadErrorTitle": "Не удалось загрузить обновление",
|
||||
"experimentalBadge": "Экспериментальное",
|
||||
"previewBadge": "Предпросмотр",
|
||||
"notNow": "Не сейчас",
|
||||
"updateNow": "Обновить сейчас",
|
||||
"restartToUpdate": "Перезапустить для обновления",
|
||||
"tryAgain": "Повторить"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,5 +254,11 @@
|
||||
"micLabel": "Микрофон",
|
||||
"mixedLabel": "Источник",
|
||||
"deleteRegion": "Удалить аудио"
|
||||
},
|
||||
"updates": {
|
||||
"title": "Обновления",
|
||||
"experimental": "Экспериментальные обновления",
|
||||
"saveFailed": "Не удалось изменить канал обновлений.",
|
||||
"experimentalDescription": "Вы включили экспериментальные обновления, поэтому можете протестировать последнее обновление Recordly до его широкого выпуска."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,5 +76,20 @@
|
||||
"microphoneDenied": "麦克风访问被拒绝。将继续录制但不包含麦克风音频。",
|
||||
"failedToStart": "录制启动失败:{{error}}",
|
||||
"failedToStartGeneric": "录制启动失败"
|
||||
},
|
||||
"updateToast": {
|
||||
"availableTitle": "有可用更新",
|
||||
"experimentalAvailableTitle": "有可用的实验性更新",
|
||||
"experimentalDescription": "你已选择接收实验性更新,因此可以在 Recordly 最新更新广泛发布之前选择先行测试。",
|
||||
"downloadingTitle": "正在下载更新",
|
||||
"readyTitle": "已准备好重新启动",
|
||||
"checkErrorTitle": "无法检查更新",
|
||||
"downloadErrorTitle": "无法下载更新",
|
||||
"experimentalBadge": "实验性",
|
||||
"previewBadge": "预览",
|
||||
"notNow": "暂不",
|
||||
"updateNow": "立即更新",
|
||||
"restartToUpdate": "重新启动以更新",
|
||||
"tryAgain": "重试"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,5 +254,11 @@
|
||||
"micLabel": "麦克风",
|
||||
"mixedLabel": "来源",
|
||||
"deleteRegion": "删除音频"
|
||||
},
|
||||
"updates": {
|
||||
"experimentalDescription": "你已选择接收实验性更新,因此可以在 Recordly 最新更新广泛发布之前选择先行测试。",
|
||||
"title": "更新",
|
||||
"experimental": "实验性更新",
|
||||
"saveFailed": "无法更改更新频道。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,5 +76,20 @@
|
||||
"microphoneDenied": "麥克風存取遭拒,將在沒有麥克風音訊的情況下繼續錄製。",
|
||||
"failedToStart": "無法開始錄製:{{error}}",
|
||||
"failedToStartGeneric": "無法開始錄製"
|
||||
},
|
||||
"updateToast": {
|
||||
"availableTitle": "有可用更新",
|
||||
"experimentalAvailableTitle": "有可用的實驗性更新",
|
||||
"experimentalDescription": "你已選擇接收實驗性更新,因此可以在 Recordly 最新更新廣泛推出之前選擇先行測試。",
|
||||
"downloadingTitle": "正在下載更新",
|
||||
"readyTitle": "已準備好重新啟動",
|
||||
"checkErrorTitle": "無法檢查更新",
|
||||
"downloadErrorTitle": "無法下載更新",
|
||||
"experimentalBadge": "實驗性",
|
||||
"previewBadge": "預覽",
|
||||
"notNow": "暫不",
|
||||
"updateNow": "立即更新",
|
||||
"restartToUpdate": "重新啟動以更新",
|
||||
"tryAgain": "重試"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,5 +254,11 @@
|
||||
"micLabel": "麥克風源",
|
||||
"mixedLabel": "源",
|
||||
"deleteRegion": "刪除音訊"
|
||||
},
|
||||
"updates": {
|
||||
"experimentalDescription": "你已選擇接收實驗性更新,因此可以在 Recordly 最新更新廣泛推出之前選擇先行測試。",
|
||||
"title": "更新",
|
||||
"experimental": "實驗性更新",
|
||||
"saveFailed": "無法變更更新頻道。"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user