diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7b49cafd1..dea29ea6d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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." diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 8e878b3d7..604716139 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -49,6 +49,7 @@ interface UpdateToastState { phase: "available" | "downloading" | "ready" | "error"; delayMs: number; isPreview?: boolean; + isExperimental?: boolean; progressPercent?: number; transferredBytes?: number; totalBytes?: number; diff --git a/electron/main.ts b/electron/main.ts index 0758c77e2..f46a8ce08 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -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. diff --git a/electron/updateChannel.test.ts b/electron/updateChannel.test.ts new file mode 100644 index 000000000..149e9fba7 --- /dev/null +++ b/electron/updateChannel.test.ts @@ -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.", + ); + }); +}); diff --git a/electron/updateChannel.ts b/electron/updateChannel.ts new file mode 100644 index 000000000..0488db02f --- /dev/null +++ b/electron/updateChannel.ts @@ -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, + }; +} diff --git a/electron/updater.ts b/electron/updater.ts index a89bb6783..c887e6345 100644 --- a/electron/updater.ts +++ b/electron/updater.ts @@ -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, }); } diff --git a/electron/windows.ts b/electron/windows.ts index 195178da2..f45a9d974 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -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 { const query: Record = { @@ -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) { diff --git a/src/App.tsx b/src/App.tsx index cc6346884..6970efb16 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 = diff --git a/src/components/launch/UpdateToastWindow.module.css b/src/components/launch/UpdateToastWindow.module.css new file mode 100644 index 000000000..5b0c76dd5 --- /dev/null +++ b/src/components/launch/UpdateToastWindow.module.css @@ -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); + } +} diff --git a/src/components/launch/UpdateToastWindow.tsx b/src/components/launch/UpdateToastWindow.tsx index e872a25cc..13e964785 100644 --- a/src/components/launch/UpdateToastWindow.tsx +++ b/src/components/launch/UpdateToastWindow.tsx @@ -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["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 ; + return ; case "downloading": - return ; + return ; case "ready": - return ; + return ; case "error": - return ; + return ; } } export function UpdateToastWindow() { const [payload, setPayload] = useState(null); - const [reminderDelayMs, setReminderDelayMs] = useState(DEFAULT_REMINDER_DELAY_MS); + const { t } = useI18n(); useEffect(() => { let mounted = true; - let pollTimer: ReturnType | 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
; } - 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
; - } - return ( -
-
-
{getPhaseIcon(payload)}
-
-
-

{getToastTitle(payload)}

+
+
+
+ +
+ +
+
+

{getTitle(payload, t)}

+ v{payload.version.replace(/^v/, "")} + {payload.isExperimental ? ( + + {t("launch.updateToast.experimentalBadge", "Experimental")} + + ) : null} {payload.isPreview ? ( - - Dev + + {t("launch.updateToast.previewBadge", "Preview")} ) : null}
-

{payload.detail}

+

{getDetail(payload, t)}

{payload.phase === "downloading" ? ( -
-
-
+
+
+
-
- - {normalizedProgress}% complete - - {phaseStats.map((stat) => ( - - {stat.label}: {stat.value} - - ))} +
+ {progress}% + {progressDetail ? {progressDetail} : null}
- ) : null} - -
- {payload.phase !== "downloading" ? ( - <> - - - - - ) : null} -
+ ) : ( +
+ + +
+ )}
-
+
); } diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 5146aa1d0..de5255e3d 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -2566,7 +2566,7 @@ export function SettingsPanel({
{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", )}
diff --git a/src/i18n/locales/de/launch.json b/src/i18n/locales/de/launch.json index a9bd70a57..bae512379 100644 --- a/src/i18n/locales/de/launch.json +++ b/src/i18n/locales/de/launch.json @@ -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" } } diff --git a/src/i18n/locales/de/settings.json b/src/i18n/locales/de/settings.json index 188b4cf57..485e70959 100644 --- a/src/i18n/locales/de/settings.json +++ b/src/i18n/locales/de/settings.json @@ -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." } } diff --git a/src/i18n/locales/en/launch.json b/src/i18n/locales/en/launch.json index 9dfcbe038..d4f7aba94 100644 --- a/src/i18n/locales/en/launch.json +++ b/src/i18n/locales/en/launch.json @@ -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" } } diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 6f18ef1b2..65cb98fb0 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -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": { diff --git a/src/i18n/locales/es/launch.json b/src/i18n/locales/es/launch.json index a458844a2..4edc2d9c6 100644 --- a/src/i18n/locales/es/launch.json +++ b/src/i18n/locales/es/launch.json @@ -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" } } diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index d3c3f9fdd..2e346fc85 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -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." } } diff --git a/src/i18n/locales/fr/launch.json b/src/i18n/locales/fr/launch.json index df2e43b2b..2969355fa 100644 --- a/src/i18n/locales/fr/launch.json +++ b/src/i18n/locales/fr/launch.json @@ -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" } } diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index 45c223b82..5b957a0ab 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -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." } } diff --git a/src/i18n/locales/it/launch.json b/src/i18n/locales/it/launch.json index c27abc3ad..0dbdbea34 100644 --- a/src/i18n/locales/it/launch.json +++ b/src/i18n/locales/it/launch.json @@ -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" } } diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json index 3410299ee..e96a37258 100644 --- a/src/i18n/locales/it/settings.json +++ b/src/i18n/locales/it/settings.json @@ -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." } } diff --git a/src/i18n/locales/ko/launch.json b/src/i18n/locales/ko/launch.json index 345000399..2e4683a56 100644 --- a/src/i18n/locales/ko/launch.json +++ b/src/i18n/locales/ko/launch.json @@ -76,5 +76,20 @@ "microphoneDenied": "마이크 접근이 거부되었습니다. 마이크 오디오 없이 녹화를 계속합니다.", "failedToStart": "녹화 시작에 실패했습니다: {{error}}", "failedToStartGeneric": "녹화 시작에 실패했습니다" + }, + "updateToast": { + "availableTitle": "업데이트 사용 가능", + "experimentalAvailableTitle": "실험적 업데이트 사용 가능", + "experimentalDescription": "실험적 업데이트를 선택했으므로 Recordly의 최신 업데이트가 널리 제공되기 전에 먼저 테스트해 볼 수 있습니다.", + "downloadingTitle": "업데이트 다운로드 중", + "readyTitle": "다시 시작할 준비 완료", + "checkErrorTitle": "업데이트를 확인할 수 없습니다", + "downloadErrorTitle": "업데이트를 다운로드할 수 없습니다", + "experimentalBadge": "실험적", + "previewBadge": "미리보기", + "notNow": "나중에", + "updateNow": "지금 업데이트", + "restartToUpdate": "다시 시작하여 업데이트", + "tryAgain": "다시 시도" } } diff --git a/src/i18n/locales/ko/settings.json b/src/i18n/locales/ko/settings.json index 17c7d6b11..ae22fe11d 100644 --- a/src/i18n/locales/ko/settings.json +++ b/src/i18n/locales/ko/settings.json @@ -254,5 +254,11 @@ "micLabel": "마이크 소스", "mixedLabel": "소스", "deleteRegion": "오디오 삭제" + }, + "updates": { + "experimentalDescription": "실험적 업데이트를 선택했으므로 Recordly의 최신 업데이트가 널리 제공되기 전에 먼저 테스트해 볼 수 있습니다.", + "title": "업데이트", + "experimental": "실험적 업데이트", + "saveFailed": "업데이트 채널을 변경하지 못했습니다." } } diff --git a/src/i18n/locales/nl/launch.json b/src/i18n/locales/nl/launch.json index d3d5870c9..774c7335a 100644 --- a/src/i18n/locales/nl/launch.json +++ b/src/i18n/locales/nl/launch.json @@ -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" } } diff --git a/src/i18n/locales/nl/settings.json b/src/i18n/locales/nl/settings.json index 117620698..0dd527b4c 100644 --- a/src/i18n/locales/nl/settings.json +++ b/src/i18n/locales/nl/settings.json @@ -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." } } diff --git a/src/i18n/locales/pt-BR/launch.json b/src/i18n/locales/pt-BR/launch.json index 8d19ac7db..cc8c35c7e 100644 --- a/src/i18n/locales/pt-BR/launch.json +++ b/src/i18n/locales/pt-BR/launch.json @@ -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" } } diff --git a/src/i18n/locales/pt-BR/settings.json b/src/i18n/locales/pt-BR/settings.json index e295b27f8..223c1e92d 100644 --- a/src/i18n/locales/pt-BR/settings.json +++ b/src/i18n/locales/pt-BR/settings.json @@ -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." } } diff --git a/src/i18n/locales/ru/launch.json b/src/i18n/locales/ru/launch.json index f341464c7..47531cfa1 100644 --- a/src/i18n/locales/ru/launch.json +++ b/src/i18n/locales/ru/launch.json @@ -76,5 +76,20 @@ "microphoneDenied": "Нет доступа к микрофону. Запись продолжится без вашего голоса.", "failedToStart": "Не удалось начать запись: {{error}}", "failedToStartGeneric": "Не удалось начать запись" + }, + "updateToast": { + "availableTitle": "Доступно обновление", + "experimentalAvailableTitle": "Доступно экспериментальное обновление", + "experimentalDescription": "Вы включили экспериментальные обновления, поэтому можете протестировать последнее обновление Recordly до его широкого выпуска.", + "downloadingTitle": "Загрузка обновления", + "readyTitle": "Готово к перезапуску", + "checkErrorTitle": "Не удалось проверить обновления", + "downloadErrorTitle": "Не удалось загрузить обновление", + "experimentalBadge": "Экспериментальное", + "previewBadge": "Предпросмотр", + "notNow": "Не сейчас", + "updateNow": "Обновить сейчас", + "restartToUpdate": "Перезапустить для обновления", + "tryAgain": "Повторить" } } diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index dd347bf8b..8bb1161cf 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -254,5 +254,11 @@ "micLabel": "Микрофон", "mixedLabel": "Источник", "deleteRegion": "Удалить аудио" + }, + "updates": { + "title": "Обновления", + "experimental": "Экспериментальные обновления", + "saveFailed": "Не удалось изменить канал обновлений.", + "experimentalDescription": "Вы включили экспериментальные обновления, поэтому можете протестировать последнее обновление Recordly до его широкого выпуска." } } diff --git a/src/i18n/locales/zh-CN/launch.json b/src/i18n/locales/zh-CN/launch.json index 164c02afe..2b88c6d70 100644 --- a/src/i18n/locales/zh-CN/launch.json +++ b/src/i18n/locales/zh-CN/launch.json @@ -76,5 +76,20 @@ "microphoneDenied": "麦克风访问被拒绝。将继续录制但不包含麦克风音频。", "failedToStart": "录制启动失败:{{error}}", "failedToStartGeneric": "录制启动失败" + }, + "updateToast": { + "availableTitle": "有可用更新", + "experimentalAvailableTitle": "有可用的实验性更新", + "experimentalDescription": "你已选择接收实验性更新,因此可以在 Recordly 最新更新广泛发布之前选择先行测试。", + "downloadingTitle": "正在下载更新", + "readyTitle": "已准备好重新启动", + "checkErrorTitle": "无法检查更新", + "downloadErrorTitle": "无法下载更新", + "experimentalBadge": "实验性", + "previewBadge": "预览", + "notNow": "暂不", + "updateNow": "立即更新", + "restartToUpdate": "重新启动以更新", + "tryAgain": "重试" } } diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index ce740260a..6282016c8 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -254,5 +254,11 @@ "micLabel": "麦克风", "mixedLabel": "来源", "deleteRegion": "删除音频" + }, + "updates": { + "experimentalDescription": "你已选择接收实验性更新,因此可以在 Recordly 最新更新广泛发布之前选择先行测试。", + "title": "更新", + "experimental": "实验性更新", + "saveFailed": "无法更改更新频道。" } } diff --git a/src/i18n/locales/zh-TW/launch.json b/src/i18n/locales/zh-TW/launch.json index 9d70eedc0..9d44d3218 100644 --- a/src/i18n/locales/zh-TW/launch.json +++ b/src/i18n/locales/zh-TW/launch.json @@ -76,5 +76,20 @@ "microphoneDenied": "麥克風存取遭拒,將在沒有麥克風音訊的情況下繼續錄製。", "failedToStart": "無法開始錄製:{{error}}", "failedToStartGeneric": "無法開始錄製" + }, + "updateToast": { + "availableTitle": "有可用更新", + "experimentalAvailableTitle": "有可用的實驗性更新", + "experimentalDescription": "你已選擇接收實驗性更新,因此可以在 Recordly 最新更新廣泛推出之前選擇先行測試。", + "downloadingTitle": "正在下載更新", + "readyTitle": "已準備好重新啟動", + "checkErrorTitle": "無法檢查更新", + "downloadErrorTitle": "無法下載更新", + "experimentalBadge": "實驗性", + "previewBadge": "預覽", + "notNow": "暫不", + "updateNow": "立即更新", + "restartToUpdate": "重新啟動以更新", + "tryAgain": "重試" } } diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index 263e054df..f5028769a 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -254,5 +254,11 @@ "micLabel": "麥克風源", "mixedLabel": "源", "deleteRegion": "刪除音訊" + }, + "updates": { + "experimentalDescription": "你已選擇接收實驗性更新,因此可以在 Recordly 最新更新廣泛推出之前選擇先行測試。", + "title": "更新", + "experimental": "實驗性更新", + "saveFailed": "無法變更更新頻道。" } }