mirror of
https://github.com/siddharthvaddem/openscreen.git
synced 2026-11-04 15:21:55 +08:00
Compare commits
397
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
899504f8e2 | ||
|
|
6a6caf618b | ||
|
|
b6af435e7f | ||
|
|
c13ec0df7d | ||
|
|
40f18a9bdf | ||
|
|
81b1eb3e8a | ||
|
|
190d5d8ecb | ||
|
|
6fc19314dd | ||
|
|
7e00cdb1a9 | ||
|
|
a0d1cfe8c8 | ||
|
|
f7d1bc6f05 | ||
|
|
59ecedb0ac | ||
|
|
bb30e20df7 | ||
|
|
b5d37c4270 | ||
|
|
679e306d31 | ||
|
|
b7d3563272 | ||
|
|
78f57970e9 | ||
|
|
bba5fd34cf | ||
|
|
876378b622 | ||
|
|
b7d1864a0b | ||
|
|
8d79a14e3b | ||
|
|
e4eeff0ea3 | ||
|
|
c8d4e867b2 | ||
|
|
279320d3ef | ||
|
|
0f28cc0f38 | ||
|
|
d59db3d839 | ||
|
|
716002f1a9 | ||
|
|
8e8b194454 | ||
|
|
916d649037 | ||
|
|
884021c7d6 | ||
|
|
93466fdda1 | ||
|
|
786165208f | ||
|
|
0768c449d7 | ||
|
|
a6fe33a0f6 | ||
|
|
608e0abe87 | ||
|
|
f9401f051c | ||
|
|
cae71ed49c | ||
|
|
6577a54418 | ||
|
|
3b9b4192bf | ||
|
|
1fefde8881 | ||
|
|
5e994d214e | ||
|
|
49213960e2 | ||
|
|
8458cbb40e | ||
|
|
e1c67c4e92 | ||
|
|
92f0ed8efe | ||
|
|
67e7048636 | ||
|
|
13c982618a | ||
|
|
657d55bd72 | ||
|
|
c53dd2df93 | ||
|
|
e06e40dbc2 | ||
|
|
373319808e | ||
|
|
af159e8a2b | ||
|
|
f2ff7fb21c | ||
|
|
702b733074 | ||
|
|
86c1c483d4 | ||
|
|
adf3855ac8 | ||
|
|
d145f80041 | ||
|
|
466fad399a | ||
|
|
1673daabe4 | ||
|
|
cffca5f2ff | ||
|
|
d1087af63c | ||
|
|
8e1c7e035a | ||
|
|
a26eb3cbab | ||
|
|
f4e10b28cc | ||
|
|
9a361a9f2e | ||
|
|
67ec57751f | ||
|
|
0264d8cb9e | ||
|
|
fafe8ff82d | ||
|
|
d59ef6a8dd | ||
|
|
d823f3f011 | ||
|
|
d6d872e529 | ||
|
|
31f0483c65 | ||
|
|
9613e714e1 | ||
|
|
7573d8822c | ||
|
|
659affa88c | ||
|
|
f60a11820e | ||
|
|
b472c768ce | ||
|
|
018ba08eb9 | ||
|
|
41a26f3e66 | ||
|
|
9d365ca406 | ||
|
|
a1762b2691 | ||
|
|
9e345660e6 | ||
|
|
96765e483d | ||
|
|
adc610544c | ||
|
|
95c7b7fc2b | ||
|
|
cccb966fda | ||
|
|
c033984ccb | ||
|
|
ae6b6ca860 | ||
|
|
1f99fcb4ad | ||
|
|
0bb14f3a33 | ||
|
|
97fdefa433 | ||
|
|
2b1c93146d | ||
|
|
dd622f83c1 | ||
|
|
d6bf31cb3f | ||
|
|
f04c2b7c14 | ||
|
|
4a65ab8171 | ||
|
|
7e02856836 | ||
|
|
65b9d189e8 | ||
|
|
3ba9e901c9 | ||
|
|
331e126d3c | ||
|
|
d04bab732b | ||
|
|
ea68e4cfc3 | ||
|
|
6b08a0a72a | ||
|
|
1670db41a8 | ||
|
|
fd6a0778fb | ||
|
|
cfc6579e37 | ||
|
|
10463f882f | ||
|
|
3e436087b7 | ||
|
|
63c850bc08 | ||
|
|
dc74db13ad | ||
|
|
33eb245aea | ||
|
|
d22c4190cf | ||
|
|
57c6a590a9 | ||
|
|
88ab1eabdd | ||
|
|
a20a31f27d | ||
|
|
9ef1f756b4 | ||
|
|
b0529c87a6 | ||
|
|
974fde4f1d | ||
|
|
e7247d880d | ||
|
|
56d3d59598 | ||
|
|
0ec18358d5 | ||
|
|
e85d07ba78 | ||
|
|
721e8f4759 | ||
|
|
9e4ec790f3 | ||
|
|
2f24038cb5 | ||
|
|
934f05cc80 | ||
|
|
d12f3980f9 | ||
|
|
485c95b672 | ||
|
|
dd8c001f6d | ||
|
|
0c01db7afa | ||
|
|
4d4b08db07 | ||
|
|
61e895a75a | ||
|
|
83ea025ed8 | ||
|
|
337838294d | ||
|
|
5e62ad3215 | ||
|
|
7264b9989e | ||
|
|
501c4f20a1 | ||
|
|
64e011f798 | ||
|
|
8b7047365c | ||
|
|
5caee9bc2d | ||
|
|
61b3182f87 | ||
|
|
cb44dec81e | ||
|
|
17bed0956d | ||
|
|
6d449a46c4 | ||
|
|
e2c4f3f62a | ||
|
|
ff52e55fa1 | ||
|
|
8aa85413f9 | ||
|
|
4f05cf572e | ||
|
|
cefcf443e4 | ||
|
|
20aecc13ae | ||
|
|
89fce713e5 | ||
|
|
847647d310 | ||
|
|
97d1957b78 | ||
|
|
fac0b405d3 | ||
|
|
12f3be02f2 | ||
|
|
84ec5a7e68 | ||
|
|
ee395b7896 | ||
|
|
9998b43acc | ||
|
|
1cdb8ed1cd | ||
|
|
566830a866 | ||
|
|
143cd1e772 | ||
|
|
14bbe8f183 | ||
|
|
5bcdf4c558 | ||
|
|
df06369b75 | ||
|
|
6441e96035 | ||
|
|
46c611bd3f | ||
|
|
d20a062150 | ||
|
|
515baf1d84 | ||
|
|
f106cc6835 | ||
|
|
456816ab2e | ||
|
|
64cdc0dd3c | ||
|
|
8bcce473d5 | ||
|
|
0efd2d64ed | ||
|
|
a6ae0e6d98 | ||
|
|
d1c9555464 | ||
|
|
1ef30ff1c7 | ||
|
|
e96478e813 | ||
|
|
97fbb01801 | ||
|
|
c9c2634db4 | ||
|
|
0c627da22c | ||
|
|
e8d6fe3d1b | ||
|
|
db10f92c49 | ||
|
|
bbf75a27e7 | ||
|
|
5781be0ba1 | ||
|
|
26c243950a | ||
|
|
0e4fc249ce | ||
|
|
321c4983ca | ||
|
|
d9114877ff | ||
|
|
e4d4ce284b | ||
|
|
b713b6a9e8 | ||
|
|
40028cfd55 | ||
|
|
7169e583c7 | ||
|
|
d40f40d69d | ||
|
|
de7518549c | ||
|
|
05da56fdc8 | ||
|
|
b1a1f45e93 | ||
|
|
bce1957505 | ||
|
|
08aff31351 | ||
|
|
24b4b4254a | ||
|
|
16cba73cb2 | ||
|
|
c55f462f1c | ||
|
|
d526ab4cda | ||
|
|
363683d288 | ||
|
|
71cdd5f0e0 | ||
|
|
90d04c734e | ||
|
|
33a60fed8c | ||
|
|
d21dd1cbf1 | ||
|
|
68295b21ec | ||
|
|
0bde359421 | ||
|
|
e7d82e1478 | ||
|
|
d512f59826 | ||
|
|
3232918197 | ||
|
|
cf6dce552e | ||
|
|
8118a0cf89 | ||
|
|
283fa406b2 | ||
|
|
33c384a827 | ||
|
|
c3faca19fd | ||
|
|
38d72217c2 | ||
|
|
765434b935 | ||
|
|
f6b7c463f0 | ||
|
|
545c02b5bb | ||
|
|
f8232d9c76 | ||
|
|
5a9c85c345 | ||
|
|
a4f1c6a2ee | ||
|
|
fdfeb51c00 | ||
|
|
5494acb5ba | ||
|
|
85bd215f1f | ||
|
|
e7d5f51740 | ||
|
|
9e6b05815f | ||
|
|
7bd993a97b | ||
|
|
558379702a | ||
|
|
09b99563f5 | ||
|
|
b34961f6af | ||
|
|
5a36179454 | ||
|
|
10a8feb71d | ||
|
|
3482be9864 | ||
|
|
b8fe1a1ec8 | ||
|
|
33609432e1 | ||
|
|
b65c68d139 | ||
|
|
6bff2a2a2c | ||
|
|
dfbaf3f176 | ||
|
|
3709342c6c | ||
|
|
0489d7b9f5 | ||
|
|
0cb298d20b | ||
|
|
7409631207 | ||
|
|
8f35cf090c | ||
|
|
1f56bb42c3 | ||
|
|
7a8fb807e6 | ||
|
|
0e1a69a7b2 | ||
|
|
e739653b3f | ||
|
|
9024eaae61 | ||
|
|
c5882b06b1 | ||
|
|
306b61a902 | ||
|
|
c36349d950 | ||
|
|
112f02fe03 | ||
|
|
2c10073d30 | ||
|
|
7e563166a3 | ||
|
|
4e2a53b200 | ||
|
|
90ba713323 | ||
|
|
24928164ca | ||
|
|
3d20c67c63 | ||
|
|
4e43b59b42 | ||
|
|
08b5580ca2 | ||
|
|
36453d740f | ||
|
|
5320f76aae | ||
|
|
e571ecbf8d | ||
|
|
d4c50c9a5e | ||
|
|
3e6dff9c34 | ||
|
|
1b6f4cce46 | ||
|
|
ec946f0807 | ||
|
|
37e1a82d05 | ||
|
|
2ce4dc49b3 | ||
|
|
db815e362a | ||
|
|
ed9b8689f7 | ||
|
|
dc0856282f | ||
|
|
da79dab756 | ||
|
|
1dc2c06ee4 | ||
|
|
b6803eb6e3 | ||
|
|
8013cc97bb | ||
|
|
e45611ade4 | ||
|
|
b986148d5d | ||
|
|
fe0c2829a7 | ||
|
|
e4672811de | ||
|
|
3e28e5860d | ||
|
|
925a7e532d | ||
|
|
f3d761b28d | ||
|
|
c9861dbef8 | ||
|
|
1591edbeca | ||
|
|
a3c2ed8ed1 | ||
|
|
ae971bc480 | ||
|
|
213637967e | ||
|
|
a8427b950e | ||
|
|
c868469be5 | ||
|
|
e90bba82ef | ||
|
|
475cbcd76c | ||
|
|
08f66c7c25 | ||
|
|
013312be1f | ||
|
|
735dd2a191 | ||
|
|
7072c05edd | ||
|
|
0bc3bbca6b | ||
|
|
5340272530 | ||
|
|
2ee7ccd89c | ||
|
|
79201569c5 | ||
|
|
ca962ff16b | ||
|
|
5426b6284c | ||
|
|
da16872809 | ||
|
|
11788ad703 | ||
|
|
2712d8a41b | ||
|
|
21361d9bf8 | ||
|
|
66f9172a35 | ||
|
|
bd604cb658 | ||
|
|
3b5ad5064e | ||
|
|
210baee0da | ||
|
|
0e3106f7ec | ||
|
|
5ff613922f | ||
|
|
b270affb25 | ||
|
|
1b980d6264 | ||
|
|
954b99e962 | ||
|
|
4f48ecd4bc | ||
|
|
7d746196d2 | ||
|
|
d5f59a7b8e | ||
|
|
478fe316dc | ||
|
|
20b0899c05 | ||
|
|
b451bdc03d | ||
|
|
43ec6ee9cd | ||
|
|
98da431da0 | ||
|
|
21893f07af | ||
|
|
763c187f87 | ||
|
|
20567db245 | ||
|
|
7a1113827c | ||
|
|
7e298d3bbf | ||
|
|
f972556443 | ||
|
|
97c9a73578 | ||
|
|
3bfcd8576b | ||
|
|
b002f2a485 | ||
|
|
cd0f2ab318 | ||
|
|
54df597160 | ||
|
|
e5430eed39 | ||
|
|
baa30a9d6a | ||
|
|
b709d0d240 | ||
|
|
11bad60eb2 | ||
|
|
5259ae5d87 | ||
|
|
14cd045e65 | ||
|
|
78901a8076 | ||
|
|
27853cc2c3 | ||
|
|
d6933813bd | ||
|
|
2b471783c0 | ||
|
|
b101820ab8 | ||
|
|
3061c141c6 | ||
|
|
846cf71e09 | ||
|
|
3895ca985f | ||
|
|
9d0ccf3bde | ||
|
|
914a3c7f7b | ||
|
|
2f36160174 | ||
|
|
05a87a8ab1 | ||
|
|
5c6621293a | ||
|
|
163b12d6fc | ||
|
|
3be195cc15 | ||
|
|
baec9a7585 | ||
|
|
9817c85acf | ||
|
|
9762448929 | ||
|
|
eade28079d | ||
|
|
fed5a44b5a | ||
|
|
a9222c9484 | ||
|
|
e72851d4ef | ||
|
|
317089d57f | ||
|
|
0a5e57ce76 | ||
|
|
e35780bd85 | ||
|
|
eae3f119a4 | ||
|
|
5d561ff06f | ||
|
|
a8bb0e88d5 | ||
|
|
cbbe2d7fbf | ||
|
|
7aca8b8bc1 | ||
|
|
4a299063c3 | ||
|
|
3d680e8521 | ||
|
|
c322825969 | ||
|
|
ece93683b8 | ||
|
|
203282be43 | ||
|
|
d8871d9228 | ||
|
|
dd0b7d6586 | ||
|
|
459b71f792 | ||
|
|
adc0cf795c | ||
|
|
796506819d | ||
|
|
2a2d7e7aba | ||
|
|
3eeecc46cf | ||
|
|
6236d2a13d | ||
|
|
c84c244761 | ||
|
|
83a60926d8 | ||
|
|
579887e2f8 | ||
|
|
a0682e6716 | ||
|
|
038d6c40ab | ||
|
|
45636410fe | ||
|
|
d968689975 | ||
|
|
7e65d52847 | ||
|
|
1680ef9b77 | ||
|
|
b7070f3ac8 | ||
|
|
de18a2f46f |
@@ -0,0 +1,10 @@
|
|||||||
|
APP_NAME=Openscreen
|
||||||
|
BUNDLE_ID=com.siddharthvaddem.openscreen
|
||||||
|
|
||||||
|
APPLE_ID=
|
||||||
|
TEAM_ID=
|
||||||
|
SIGN_IDENTITY="Developer ID Application: Samir Patil ()"
|
||||||
|
CSC_NAME="Samir Patil ()"
|
||||||
|
|
||||||
|
NOTARY_PROFILE=OpenScreen-notary
|
||||||
|
APPLE_APP_SPECIFIC_PASSWORD=
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
* @siddharthvaddem
|
||||||
+176
-22
@@ -3,6 +3,16 @@ name: Build Electron App
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
arch:
|
||||||
|
description: 'Architecture to build'
|
||||||
|
required: true
|
||||||
|
default: 'both'
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- arm64
|
||||||
|
- x64
|
||||||
|
- both
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-windows:
|
build-windows:
|
||||||
@@ -10,18 +20,15 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Install app dependencies
|
|
||||||
run: npx electron-builder install-app-deps
|
|
||||||
|
|
||||||
- name: Build Windows app
|
- name: Build Windows app
|
||||||
run: npm run build:win
|
run: npm run build:win
|
||||||
env:
|
env:
|
||||||
@@ -36,38 +43,180 @@ jobs:
|
|||||||
|
|
||||||
build-macos:
|
build-macos:
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
arch: ${{ github.event.inputs.arch == 'both' && fromJSON('["arm64", "x64"]') || fromJSON(format('["{0}"]', github.event.inputs.arch)) }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
# ─── Checkout ─────────────────────────────────────────────
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# ─── Setup Node.js ────────────────────────────────────────
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
# ─── Setup Python (needed by some native deps) ────────────
|
||||||
- name: Setup Python
|
- name: Setup Python
|
||||||
uses: actions/setup-python@v4
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: '3.11'
|
python-version: '3.11'
|
||||||
|
|
||||||
|
# ─── Install Dependencies ─────────────────────────────────
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Install app dependencies
|
# ─── Import Code Signing Certificate ──────────────────────
|
||||||
run: npx electron-builder install-app-deps
|
# This is the KEY step that makes CI signing work.
|
||||||
|
# We create a temporary keychain, import the .p12 cert into it,
|
||||||
- name: Build macOS app
|
# and set it as the default so codesign can find it.
|
||||||
run: npm run build:mac
|
- name: Import code signing certificate
|
||||||
env:
|
env:
|
||||||
|
MAC_CERTIFICATE_P12: ${{ secrets.MAC_CERTIFICATE_P12 }}
|
||||||
|
MAC_CERTIFICATE_PASSWORD: ${{ secrets.MAC_CERTIFICATE_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
# Create a temporary keychain
|
||||||
|
KEYCHAIN_PATH=$RUNNER_TEMP/build.keychain-db
|
||||||
|
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
|
||||||
|
|
||||||
|
# Create and configure keychain
|
||||||
|
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||||
|
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
|
||||||
|
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||||
|
|
||||||
|
# Decode and import certificate
|
||||||
|
echo "$MAC_CERTIFICATE_P12" | base64 --decode > $RUNNER_TEMP/certificate.p12
|
||||||
|
security import $RUNNER_TEMP/certificate.p12 \
|
||||||
|
-k "$KEYCHAIN_PATH" \
|
||||||
|
-P "$MAC_CERTIFICATE_PASSWORD" \
|
||||||
|
-T /usr/bin/codesign \
|
||||||
|
-T /usr/bin/security
|
||||||
|
|
||||||
|
# Allow codesign to access the keychain without UI prompt
|
||||||
|
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||||
|
|
||||||
|
# Add to keychain search path (makes it the default)
|
||||||
|
security list-keychains -d user -s "$KEYCHAIN_PATH" $(security list-keychains -d user | tr -d '"')
|
||||||
|
|
||||||
|
# Verify the identity is available
|
||||||
|
security find-identity -v -p codesigning "$KEYCHAIN_PATH"
|
||||||
|
|
||||||
|
# Clean up the .p12 file
|
||||||
|
rm -f $RUNNER_TEMP/certificate.p12
|
||||||
|
|
||||||
|
# ─── Build Vite + Electron ────────────────────────────────
|
||||||
|
- name: Build Vite + Electron
|
||||||
|
run: npx tsc && npx vite build
|
||||||
|
|
||||||
|
# ─── Package with electron-builder ────────────────────────
|
||||||
|
# electron-builder handles deep codesigning the .app bundle
|
||||||
|
# "notarize: false" in electron-builder.json5 prevents it from
|
||||||
|
# trying its own notarization flow
|
||||||
|
- name: Package .app bundle
|
||||||
|
run: npx electron-builder --mac --${{ matrix.arch }} --dir
|
||||||
|
env:
|
||||||
|
CSC_NAME: "Samir Patil (N26FZ4GW28)"
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Upload macOS build
|
# ─── Read version from package.json ───────────────────────
|
||||||
|
- name: Get version
|
||||||
|
id: version
|
||||||
|
run: echo "version=$(node -p 'require(\"./package.json\").version')" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
# ─── Locate the .app bundle ───────────────────────────────
|
||||||
|
- name: Find .app bundle
|
||||||
|
id: find_app
|
||||||
|
run: |
|
||||||
|
VERSION="${{ steps.version.outputs.version }}"
|
||||||
|
echo "=== Release directory contents ==="
|
||||||
|
ls -laR "release/${VERSION}/" || echo "release/${VERSION}/ not found"
|
||||||
|
echo "=== Searching for .app bundle ==="
|
||||||
|
APP_BUNDLE=$(find "release/${VERSION}" -maxdepth 4 -name "*.app" -type d | head -n1)
|
||||||
|
if [ -z "$APP_BUNDLE" ]; then
|
||||||
|
echo "::error::No .app bundle found in release/${VERSION}/"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "app_bundle=$APP_BUNDLE" >> $GITHUB_OUTPUT
|
||||||
|
echo "Found: $APP_BUNDLE"
|
||||||
|
|
||||||
|
# ─── Verify .app signature ────────────────────────────────
|
||||||
|
- name: Verify .app code signature
|
||||||
|
run: codesign --verify --deep --strict "${{ steps.find_app.outputs.app_bundle }}"
|
||||||
|
|
||||||
|
# ─── Create DMG ───────────────────────────────────────────
|
||||||
|
- name: Create DMG
|
||||||
|
id: dmg
|
||||||
|
run: |
|
||||||
|
VERSION="${{ steps.version.outputs.version }}"
|
||||||
|
ARCH="${{ matrix.arch }}"
|
||||||
|
DMG_NAME="Openscreen-Mac-${ARCH}-${VERSION}.dmg"
|
||||||
|
RELEASE_DIR="release/${VERSION}"
|
||||||
|
DMG_OUTPUT="${RELEASE_DIR}/${DMG_NAME}"
|
||||||
|
STAGING="${RELEASE_DIR}/dmg-staging"
|
||||||
|
|
||||||
|
mkdir -p "$STAGING"
|
||||||
|
cp -R "${{ steps.find_app.outputs.app_bundle }}" "$STAGING/"
|
||||||
|
ln -s /Applications "$STAGING/Applications"
|
||||||
|
|
||||||
|
hdiutil create \
|
||||||
|
-srcfolder "$STAGING" \
|
||||||
|
-volname "Openscreen" \
|
||||||
|
-fs HFS+ \
|
||||||
|
-fsargs "-c c=64,a=16,e=16" \
|
||||||
|
-format UDBZ \
|
||||||
|
"$DMG_OUTPUT"
|
||||||
|
|
||||||
|
rm -rf "$STAGING"
|
||||||
|
|
||||||
|
echo "dmg_path=$DMG_OUTPUT" >> $GITHUB_OUTPUT
|
||||||
|
echo "dmg_name=$DMG_NAME" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
# ─── Sign DMG ─────────────────────────────────────────────
|
||||||
|
- name: Sign DMG
|
||||||
|
run: |
|
||||||
|
codesign --force \
|
||||||
|
--sign "Developer ID Application: Samir Patil (N26FZ4GW28)" \
|
||||||
|
--timestamp \
|
||||||
|
"${{ steps.dmg.outputs.dmg_path }}"
|
||||||
|
|
||||||
|
# ─── Notarize DMG ────────────────────────────────────────
|
||||||
|
# On CI we can't use keychain profiles for notarytool, so we
|
||||||
|
# pass credentials directly via env vars / flags
|
||||||
|
- name: Notarize DMG
|
||||||
|
run: |
|
||||||
|
xcrun notarytool submit "${{ steps.dmg.outputs.dmg_path }}" \
|
||||||
|
--apple-id "${{ secrets.APPLE_ID }}" \
|
||||||
|
--team-id "${{ secrets.APPLE_TEAM_ID }}" \
|
||||||
|
--password "${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}" \
|
||||||
|
--wait
|
||||||
|
timeout-minutes: 15
|
||||||
|
|
||||||
|
# ─── Staple ───────────────────────────────────────────────
|
||||||
|
- name: Staple notarization ticket
|
||||||
|
run: xcrun stapler staple "${{ steps.dmg.outputs.dmg_path }}"
|
||||||
|
|
||||||
|
# ─── Validate ─────────────────────────────────────────────
|
||||||
|
- name: Validate stapled DMG
|
||||||
|
run: |
|
||||||
|
xcrun stapler validate "${{ steps.dmg.outputs.dmg_path }}"
|
||||||
|
spctl -a -vv -t install "${{ steps.dmg.outputs.dmg_path }}"
|
||||||
|
|
||||||
|
# ─── Upload Artifact ──────────────────────────────────────
|
||||||
|
- name: Upload notarized DMG
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: macos-installer
|
name: openscreen-mac-${{ matrix.arch }}
|
||||||
path: release/**/*.dmg
|
path: ${{ steps.dmg.outputs.dmg_path }}
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
|
# ─── Cleanup Keychain ─────────────────────────────────────
|
||||||
|
- name: Cleanup keychain
|
||||||
|
if: always()
|
||||||
|
run: security delete-keychain $RUNNER_TEMP/build.keychain-db || true
|
||||||
|
|
||||||
build-linux:
|
build-linux:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
@@ -82,8 +231,10 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Install app dependencies
|
# bsdtar (from libarchive-tools) is required by fpm to build pacman
|
||||||
run: npx electron-builder install-app-deps
|
# packages. AppImage and deb don't need it; ubuntu-latest doesn't ship it.
|
||||||
|
- name: Install pacman build dependencies
|
||||||
|
run: sudo apt-get update && sudo apt-get install -y libarchive-tools
|
||||||
|
|
||||||
- name: Build Linux app
|
- name: Build Linux app
|
||||||
run: npm run build:linux
|
run: npm run build:linux
|
||||||
@@ -94,6 +245,9 @@ jobs:
|
|||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: linux-installer
|
name: linux-installer
|
||||||
path: release/**/*.AppImage
|
path: |
|
||||||
|
release/**/*.AppImage
|
||||||
|
release/**/*.zsync
|
||||||
|
release/**/*.deb
|
||||||
|
release/**/*.pacman
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
|
|||||||
+13
-23
@@ -31,6 +31,19 @@ jobs:
|
|||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: npx tsc --noEmit
|
- run: npx tsc --noEmit
|
||||||
|
|
||||||
|
test:
|
||||||
|
name: Test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run test:browser:install
|
||||||
|
- run: npm run test:browser
|
||||||
|
|
||||||
build:
|
build:
|
||||||
name: Build
|
name: Build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -42,26 +55,3 @@ jobs:
|
|||||||
cache: npm
|
cache: npm
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: npx vite build
|
- run: npx vite build
|
||||||
|
|
||||||
e2e:
|
|
||||||
name: E2E Tests
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
cache: npm
|
|
||||||
- run: npm ci
|
|
||||||
- run: npx playwright install --with-deps chromium
|
|
||||||
# Install Electron system dependencies not covered by Playwright's chromium deps
|
|
||||||
- run: npx electron . --version || sudo apt-get install -y libgbm-dev
|
|
||||||
- run: npm run build-vite
|
|
||||||
# xvfb provides a virtual display; Electron needs one on Linux even with show:false
|
|
||||||
- run: xvfb-run --auto-servernum npm run test:e2e
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
if: failure()
|
|
||||||
with:
|
|
||||||
name: playwright-report
|
|
||||||
path: playwright-report/
|
|
||||||
retention-days: 7
|
|
||||||
|
|||||||
@@ -0,0 +1,519 @@
|
|||||||
|
name: PR to Discord Forum
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, edited, labeled, unlabeled, closed]
|
||||||
|
pull_request_review:
|
||||||
|
types: [submitted]
|
||||||
|
issue_comment:
|
||||||
|
types: [created]
|
||||||
|
schedule:
|
||||||
|
- cron: "0 12 * * 1"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
issues: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
notify:
|
||||||
|
if: github.event_name != 'schedule' && github.actor != 'github-actions[bot]'
|
||||||
|
concurrency:
|
||||||
|
group: discord-pr-sync-${{ github.repository }}-${{ github.event.pull_request.number || github.event.issue.number || github.run_id }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Sync PR activity to Discord forum thread
|
||||||
|
id: sync
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
env:
|
||||||
|
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||||
|
DISCORD_PR_FORUM_WEBHOOK: ${{ secrets.DISCORD_PR_FORUM_WEBHOOK }}
|
||||||
|
DISCORD_WEBHOOK_USERNAME: ${{ secrets.DISCORD_WEBHOOK_USERNAME }}
|
||||||
|
DISCORD_WEBHOOK_AVATAR_URL: ${{ secrets.DISCORD_WEBHOOK_AVATAR_URL }}
|
||||||
|
DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }}
|
||||||
|
DISCORD_REVIEWER_ROLE_ID: ${{ secrets.DISCORD_REVIEWER_ROLE_ID }}
|
||||||
|
DISCORD_ALERT_WEBHOOK_URL: ${{ secrets.DISCORD_ALERT_WEBHOOK_URL }}
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const WEBHOOK_USERNAME = (process.env.DISCORD_WEBHOOK_USERNAME || "OpenScreen").trim();
|
||||||
|
const WEBHOOK_AVATAR = (process.env.DISCORD_WEBHOOK_AVATAR_URL || "").trim();
|
||||||
|
|
||||||
|
const THREAD_MARKER_REGEX = /<!--\s*discord-thread-id:(\d+)\s*-->/i;
|
||||||
|
const webhookUrl = (process.env.DISCORD_WEBHOOK_URL || process.env.DISCORD_PR_FORUM_WEBHOOK || "").trim();
|
||||||
|
const botToken = (process.env.DISCORD_BOT_TOKEN || "").trim();
|
||||||
|
const reviewerRoleId = (process.env.DISCORD_REVIEWER_ROLE_ID || "").trim();
|
||||||
|
const alertWebhookUrl = (process.env.DISCORD_ALERT_WEBHOOK_URL || "").trim();
|
||||||
|
|
||||||
|
const TAGS = {
|
||||||
|
open: "1493976692967080096",
|
||||||
|
draft: "1493976782028935279",
|
||||||
|
ready: "1493976833626996756",
|
||||||
|
changes: "1493976909875515564",
|
||||||
|
approved: "1493976951038152764",
|
||||||
|
merged: "1493977049709281320",
|
||||||
|
closed: "1493977108102516786",
|
||||||
|
};
|
||||||
|
|
||||||
|
const labelTagMap = {
|
||||||
|
bug: "1493977562773458975",
|
||||||
|
enhancement: "1493977619216207993",
|
||||||
|
documentation: "1493978565153394830",
|
||||||
|
};
|
||||||
|
|
||||||
|
function cleanDescription(text, maxLen = 3500) {
|
||||||
|
if (!text) return "No description provided.";
|
||||||
|
const normalized = text
|
||||||
|
.replace(/\r\n/g, "\n")
|
||||||
|
.replace(/\n{3,}/g, "\n\n")
|
||||||
|
.trim();
|
||||||
|
if (normalized.length <= maxLen) return normalized;
|
||||||
|
return `${normalized.slice(0, maxLen - 1)}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimThreadName(name) {
|
||||||
|
return name.length > 95 ? name.slice(0, 95) : name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractThreadId(body) {
|
||||||
|
if (!body) return null;
|
||||||
|
const match = body.match(THREAD_MARKER_REGEX);
|
||||||
|
return match ? match[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertThreadMarker(body, threadId) {
|
||||||
|
const cleaned = (body || "").replace(THREAD_MARKER_REGEX, "").trim();
|
||||||
|
return `${cleaned}\n\n<!-- discord-thread-id:${threadId} -->`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function discordPost(payload, options = {}) {
|
||||||
|
const endpoint = new URL(webhookUrl);
|
||||||
|
endpoint.searchParams.set("wait", "true");
|
||||||
|
if (options.threadId) endpoint.searchParams.set("thread_id", String(options.threadId));
|
||||||
|
|
||||||
|
const response = await fetch(endpoint.toString(), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: WEBHOOK_USERNAME,
|
||||||
|
avatar_url: WEBHOOK_AVATAR,
|
||||||
|
allowed_mentions: { parse: [] },
|
||||||
|
...payload,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentType = (response.headers.get("content-type") || "").toLowerCase();
|
||||||
|
const text = await response.text();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Discord API error ${response.status}: ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!text) return {};
|
||||||
|
if (contentType.includes("application/json")) return JSON.parse(text);
|
||||||
|
|
||||||
|
// Some proxy/CDN edge responses may return HTML with 2xx; avoid crashing on JSON parse.
|
||||||
|
core.warning(`Discord webhook returned non-JSON response (content-type: ${contentType || "unknown"}).`);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function patchDiscordThread(threadId, patchBody) {
|
||||||
|
if (!botToken || !threadId) return;
|
||||||
|
const response = await fetch(`https://discord.com/api/v10/channels/${threadId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bot ${botToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(patchBody),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
core.warning(`Discord thread patch failed (${response.status}): ${text}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function desiredStatusTag(prState) {
|
||||||
|
if (prState.merged && TAGS.merged) return TAGS.merged;
|
||||||
|
if (prState.closed && !prState.merged && TAGS.closed) return TAGS.closed;
|
||||||
|
if (prState.reviewState === "CHANGES_REQUESTED" && TAGS.changes) return TAGS.changes;
|
||||||
|
if (prState.reviewState === "APPROVED" && TAGS.approved) return TAGS.approved;
|
||||||
|
if (prState.draft && TAGS.draft) return TAGS.draft;
|
||||||
|
if (!prState.draft && TAGS.ready) return TAGS.ready;
|
||||||
|
return TAGS.open || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tagIdsFromLabels(labels) {
|
||||||
|
const out = [];
|
||||||
|
for (const label of labels) {
|
||||||
|
const mapped = labelTagMap[label.toLowerCase()] || labelTagMap[label];
|
||||||
|
if (mapped) out.push(String(mapped));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPullRequest() {
|
||||||
|
if (context.eventName === "pull_request_target" || context.eventName === "pull_request_review") {
|
||||||
|
return context.payload.pull_request || null;
|
||||||
|
}
|
||||||
|
if (context.eventName === "issue_comment") {
|
||||||
|
const issue = context.payload.issue;
|
||||||
|
if (!issue?.pull_request) return null;
|
||||||
|
const { data } = await github.rest.pulls.get({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
pull_number: issue.number,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getReviewState(owner, repo, pullNumber) {
|
||||||
|
const { data } = await github.rest.pulls.listReviews({ owner, repo, pull_number: pullNumber, per_page: 100 });
|
||||||
|
let hasChanges = false;
|
||||||
|
let hasApproved = false;
|
||||||
|
for (const r of data) {
|
||||||
|
const s = (r.state || "").toUpperCase();
|
||||||
|
if (s === "CHANGES_REQUESTED") hasChanges = true;
|
||||||
|
if (s === "APPROVED") hasApproved = true;
|
||||||
|
}
|
||||||
|
if (hasChanges) return "CHANGES_REQUESTED";
|
||||||
|
if (hasApproved) return "APPROVED";
|
||||||
|
return "NONE";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendFailureAlert(message) {
|
||||||
|
if (!alertWebhookUrl) return;
|
||||||
|
try {
|
||||||
|
await fetch(alertWebhookUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: "OpenScreen",
|
||||||
|
avatar_url: WEBHOOK_AVATAR,
|
||||||
|
content: `⚠️ PR Discord sync failed\n${message}\nRun: ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
|
||||||
|
allowed_mentions: { parse: [] }
|
||||||
|
})
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
core.warning("Failed to send failure alert webhook.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pr = await getPullRequest();
|
||||||
|
if (!pr) {
|
||||||
|
core.info("No PR context found. Skipping.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!webhookUrl) {
|
||||||
|
const strictEvents = new Set(["pull_request_target", "workflow_dispatch"]);
|
||||||
|
const msg =
|
||||||
|
`Discord sync skipped: webhook secret unavailable for event '${context.eventName}'. ` +
|
||||||
|
"Set either DISCORD_WEBHOOK_URL or DISCORD_PR_FORUM_WEBHOOK in repository secrets.";
|
||||||
|
if (strictEvents.has(context.eventName)) {
|
||||||
|
core.setFailed(msg);
|
||||||
|
} else {
|
||||||
|
core.warning(msg);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const action = context.payload.action || "";
|
||||||
|
const owner = context.repo.owner;
|
||||||
|
const repo = context.repo.repo;
|
||||||
|
const number = pr.number;
|
||||||
|
const title = pr.title;
|
||||||
|
const author = pr.user?.login || "unknown";
|
||||||
|
const url = pr.html_url;
|
||||||
|
const authorUrl = pr.user?.html_url || "";
|
||||||
|
const authorAvatar = pr.user?.avatar_url || "";
|
||||||
|
const base = pr.base?.ref || "";
|
||||||
|
const head = pr.head?.ref || "";
|
||||||
|
const repoFullName = pr.base?.repo?.full_name || `${owner}/${repo}`;
|
||||||
|
const labels = (pr.labels || []).map((l) => l.name);
|
||||||
|
const body = (pr.body || "").trim();
|
||||||
|
const reviewState = await getReviewState(owner, repo, number);
|
||||||
|
|
||||||
|
let threadId = extractThreadId(body);
|
||||||
|
const shouldCreateThread =
|
||||||
|
context.eventName === "pull_request_target" &&
|
||||||
|
["opened", "reopened", "ready_for_review"].includes(action) &&
|
||||||
|
!threadId;
|
||||||
|
|
||||||
|
if (shouldCreateThread) {
|
||||||
|
const fields = [
|
||||||
|
{ name: "PR", value: `[#${number}](${url})`, inline: true },
|
||||||
|
{ name: "Author", value: `[${author}](${authorUrl || url})`, inline: true },
|
||||||
|
{ name: "Status", value: pr.draft ? "Draft" : "Open", inline: true },
|
||||||
|
{ name: "Branches", value: `\`${head}\` -> \`${base}\``, inline: true },
|
||||||
|
{ name: "Changes", value: `+${pr.additions} / -${pr.deletions}`, inline: true },
|
||||||
|
{ name: "Files Changed", value: String(pr.changed_files), inline: true }
|
||||||
|
];
|
||||||
|
|
||||||
|
if (labels.length) {
|
||||||
|
fields.push({
|
||||||
|
name: "Labels",
|
||||||
|
value: labels.map((l) => `\`${l}\``).join(" "),
|
||||||
|
inline: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusTag = desiredStatusTag({ draft: pr.draft, reviewState, merged: false, closed: false });
|
||||||
|
const mappedLabelTags = tagIdsFromLabels(labels);
|
||||||
|
const appliedTags = [...new Set([statusTag, ...mappedLabelTags].filter(Boolean))];
|
||||||
|
|
||||||
|
const createPayload = {
|
||||||
|
content: action === "ready_for_review" ? "🔔 PR is now ready for review" : "🔔 New pull request opened",
|
||||||
|
thread_name: trimThreadName(`PR #${number} - ${title}`),
|
||||||
|
applied_tags: appliedTags,
|
||||||
|
embeds: [
|
||||||
|
{
|
||||||
|
title: `PR #${number}: ${title}`,
|
||||||
|
url,
|
||||||
|
description: cleanDescription(body),
|
||||||
|
color: pr.draft ? 15105570 : 1998671,
|
||||||
|
author: {
|
||||||
|
name: author,
|
||||||
|
url: authorUrl || undefined,
|
||||||
|
icon_url: authorAvatar || undefined,
|
||||||
|
},
|
||||||
|
fields,
|
||||||
|
footer: { text: repoFullName },
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await discordPost(createPayload);
|
||||||
|
const createdThreadId = result.channel_id || null;
|
||||||
|
if (createdThreadId) {
|
||||||
|
const updatedBody = upsertThreadMarker(body, createdThreadId);
|
||||||
|
await github.rest.pulls.update({ owner, repo, pull_number: number, body: updatedBody });
|
||||||
|
core.info(`Created Discord thread ${createdThreadId} and stored mapping.`);
|
||||||
|
} else {
|
||||||
|
core.warning("Discord thread created but channel_id missing in response.");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!threadId) {
|
||||||
|
core.info("No mapped Discord thread ID found; skipping update event.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.eventName === "pull_request_target" && ["edited", "labeled", "unlabeled", "ready_for_review", "converted_to_draft"].includes(action)) {
|
||||||
|
const statusTag = desiredStatusTag({
|
||||||
|
draft: action === "converted_to_draft" ? true : pr.draft,
|
||||||
|
reviewState,
|
||||||
|
merged: false,
|
||||||
|
closed: false,
|
||||||
|
});
|
||||||
|
const mappedLabelTags = tagIdsFromLabels(labels);
|
||||||
|
const appliedTags = [...new Set([statusTag, ...mappedLabelTags].filter(Boolean))];
|
||||||
|
await patchDiscordThread(threadId, {
|
||||||
|
name: trimThreadName(`PR #${number} - ${title}`),
|
||||||
|
...(appliedTags.length ? { applied_tags: appliedTags } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let updateMessage = null;
|
||||||
|
let updateEmbed = null;
|
||||||
|
|
||||||
|
if (context.eventName === "pull_request_target") {
|
||||||
|
if (action === "synchronize") {
|
||||||
|
const { data: commits } = await github.rest.pulls.listCommits({ owner, repo, pull_number: number, per_page: 5 });
|
||||||
|
const list = commits.map((c) => `- \`${c.sha.slice(0, 7)}\` ${c.commit.message.split("\n")[0]}`).join("\n") || "- No commit details";
|
||||||
|
updateMessage = `🧩 New commits pushed to PR #${number}`;
|
||||||
|
updateEmbed = {
|
||||||
|
title: `Commit Update • PR #${number}`,
|
||||||
|
url: `${url}/files`,
|
||||||
|
description: `${list}`,
|
||||||
|
color: 1998671,
|
||||||
|
footer: { text: repoFullName },
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
} else if (action === "edited") {
|
||||||
|
updateMessage = `✏️ PR #${number} details were edited`;
|
||||||
|
updateEmbed = {
|
||||||
|
title: `PR Updated • #${number}`,
|
||||||
|
url,
|
||||||
|
description: cleanDescription(body, 1200),
|
||||||
|
color: 1998671,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
} else if (action === "closed") {
|
||||||
|
const isMerged = !!pr.merged;
|
||||||
|
const statusTag = desiredStatusTag({ draft: false, reviewState, merged: isMerged, closed: true });
|
||||||
|
const mappedLabelTags = tagIdsFromLabels(labels);
|
||||||
|
const appliedTags = [...new Set([statusTag, ...mappedLabelTags].filter(Boolean))];
|
||||||
|
await patchDiscordThread(threadId, {
|
||||||
|
...(appliedTags.length ? { applied_tags: appliedTags } : {}),
|
||||||
|
...(isMerged ? { archived: true, locked: true } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
updateMessage = isMerged
|
||||||
|
? `✅ PR #${number} was merged`
|
||||||
|
: `🛑 PR #${number} was closed without merge`;
|
||||||
|
updateEmbed = {
|
||||||
|
title: isMerged ? `Merged • PR #${number}` : `Closed • PR #${number}`,
|
||||||
|
url,
|
||||||
|
description: isMerged ? "This PR has been merged into the base branch." : "This PR was closed before merge.",
|
||||||
|
color: isMerged ? 5763719 : 15158332,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
} else if (action === "ready_for_review") {
|
||||||
|
updateMessage = `🚀 PR #${number} moved from draft to ready for review`;
|
||||||
|
if (reviewerRoleId) updateMessage += ` <@&${reviewerRoleId}>`;
|
||||||
|
} else if (action === "converted_to_draft") {
|
||||||
|
updateMessage = `📝 PR #${number} converted to draft`;
|
||||||
|
}
|
||||||
|
} else if (context.eventName === "pull_request_review") {
|
||||||
|
const review = context.payload.review;
|
||||||
|
if (review) {
|
||||||
|
const state = (review.state || "commented").toUpperCase();
|
||||||
|
const reviewer = review.user?.login || "reviewer";
|
||||||
|
updateMessage = `🧪 Review ${state} by **${reviewer}** on PR #${number}`;
|
||||||
|
if (state === "CHANGES_REQUESTED" && reviewerRoleId) updateMessage += ` <@&${reviewerRoleId}>`;
|
||||||
|
updateEmbed = {
|
||||||
|
title: `Review ${state} • PR #${number}`,
|
||||||
|
url: review.html_url || url,
|
||||||
|
description: cleanDescription(review.body || "No review note.", 1000),
|
||||||
|
color: state === "APPROVED" ? 5763719 : state === "CHANGES_REQUESTED" ? 15158332 : 1998671,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (state === "CHANGES_REQUESTED" || state === "APPROVED") {
|
||||||
|
const statusTag = desiredStatusTag({ draft: pr.draft, reviewState: state, merged: false, closed: false });
|
||||||
|
const mappedLabelTags = tagIdsFromLabels(labels);
|
||||||
|
const appliedTags = [...new Set([statusTag, ...mappedLabelTags].filter(Boolean))];
|
||||||
|
await patchDiscordThread(threadId, {
|
||||||
|
...(appliedTags.length ? { applied_tags: appliedTags } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (context.eventName === "issue_comment") {
|
||||||
|
const comment = context.payload.comment;
|
||||||
|
if (comment) {
|
||||||
|
const commenter = comment.user?.login || "user";
|
||||||
|
updateMessage = `💬 New comment by **${commenter}** on PR #${number}`;
|
||||||
|
updateEmbed = {
|
||||||
|
title: `New PR Comment • #${number}`,
|
||||||
|
url: comment.html_url || url,
|
||||||
|
description: cleanDescription(comment.body || "No comment body.", 1000),
|
||||||
|
color: 1998671,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!updateMessage && !updateEmbed) {
|
||||||
|
core.info("No Discord update message for this event/action. Skipping.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = { content: updateMessage || "" };
|
||||||
|
if (updateEmbed) payload.embeds = [updateEmbed];
|
||||||
|
await discordPost(payload, { threadId });
|
||||||
|
core.info(`Posted update to Discord thread ${threadId}.`);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err && err.message ? err.message : String(err);
|
||||||
|
core.setFailed(msg);
|
||||||
|
|
||||||
|
const alertWebhook = process.env.DISCORD_ALERT_WEBHOOK_URL;
|
||||||
|
if (alertWebhook) {
|
||||||
|
try {
|
||||||
|
await fetch(alertWebhook, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: "OpenScreen",
|
||||||
|
avatar_url: WEBHOOK_AVATAR,
|
||||||
|
content: `⚠️ PR->Discord sync failed\n${msg}\nRun: ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
|
||||||
|
allowed_mentions: { parse: [] }
|
||||||
|
})
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
core.warning("Failed to send alert webhook.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
weekly-contributor-leaderboard:
|
||||||
|
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Post weekly contributor leaderboard
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
env:
|
||||||
|
DISCORD_SPOTLIGHT_WEBHOOK_URL: ${{ secrets.DISCORD_SPOTLIGHT_WEBHOOK_URL }}
|
||||||
|
DISCORD_WEBHOOK_USERNAME: ${{ secrets.DISCORD_WEBHOOK_USERNAME }}
|
||||||
|
DISCORD_WEBHOOK_AVATAR_URL: ${{ secrets.DISCORD_WEBHOOK_AVATAR_URL }}
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const spotlightWebhook = (process.env.DISCORD_SPOTLIGHT_WEBHOOK_URL || "").trim();
|
||||||
|
const webhookUsername = (process.env.DISCORD_WEBHOOK_USERNAME || "OpenScreen").trim();
|
||||||
|
const webhookAvatar = (process.env.DISCORD_WEBHOOK_AVATAR_URL || "").trim();
|
||||||
|
if (!spotlightWebhook) {
|
||||||
|
core.info("DISCORD_SPOTLIGHT_WEBHOOK_URL missing. Skipping leaderboard post.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
const owner = context.repo.owner;
|
||||||
|
const repo = context.repo.repo;
|
||||||
|
|
||||||
|
const q = `repo:${owner}/${repo} is:pr is:merged merged:>=${since.substring(0, 10)}`;
|
||||||
|
const search = await github.rest.search.issuesAndPullRequests({
|
||||||
|
q,
|
||||||
|
per_page: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const counter = new Map();
|
||||||
|
for (const item of search.data.items) {
|
||||||
|
const login = item.user?.login;
|
||||||
|
if (!login) continue;
|
||||||
|
counter.set(login, (counter.get(login) || 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ranked = [...counter.entries()]
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
|
const totalMerged = search.data.items.length;
|
||||||
|
const lines = ranked.length
|
||||||
|
? ranked.map(([user, count], idx) => `${idx + 1}. **${user}** - ${count} merged PR(s)`).join("\n")
|
||||||
|
: "No merged PRs this week.";
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
username: webhookUsername,
|
||||||
|
...(webhookAvatar ? { avatar_url: webhookAvatar } : {}),
|
||||||
|
embeds: [
|
||||||
|
{
|
||||||
|
title: "🌟 Weekly Contributor Leaderboard",
|
||||||
|
description: lines,
|
||||||
|
color: 1998671,
|
||||||
|
fields: [
|
||||||
|
{ name: "Merged PRs (7d)", value: String(totalMerged), inline: true },
|
||||||
|
{ name: "Repository", value: `${owner}/${repo}`, inline: true },
|
||||||
|
{ name: "Period", value: "Last 7 days", inline: true }
|
||||||
|
],
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}
|
||||||
|
],
|
||||||
|
allowed_mentions: { parse: [] }
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetch(`${spotlightWebhook}?wait=true`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const txt = await res.text();
|
||||||
|
core.setFailed(`Leaderboard post failed ${res.status}: ${txt}`);
|
||||||
|
}
|
||||||
+14
-1
@@ -12,9 +12,11 @@ dist
|
|||||||
dist-electron
|
dist-electron
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
.env
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
|
.zed/
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
.idea
|
.idea
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -25,8 +27,19 @@ dist-ssr
|
|||||||
*.sw?
|
*.sw?
|
||||||
release/**
|
release/**
|
||||||
*.kiro/
|
*.kiro/
|
||||||
|
.claude/
|
||||||
# npx electron-builder --mac --win
|
# npx electron-builder --mac --win
|
||||||
|
|
||||||
# Playwright
|
# Playwright
|
||||||
test-results
|
test-results
|
||||||
playwright-report/
|
playwright-report/
|
||||||
|
|
||||||
|
# Vitest browser mode screenshots
|
||||||
|
__screenshots__/
|
||||||
|
|
||||||
|
# shell files
|
||||||
|
/shell.sh
|
||||||
|
# Nix
|
||||||
|
result
|
||||||
|
result-*
|
||||||
|
.direnv/
|
||||||
|
|||||||
@@ -5,9 +5,16 @@
|
|||||||
<img src="public/openscreen.png" alt="OpenScreen Logo" width="64" />
|
<img src="public/openscreen.png" alt="OpenScreen Logo" width="64" />
|
||||||
<br />
|
<br />
|
||||||
<br />
|
<br />
|
||||||
|
<a href="https://trendshift.io/repositories/17427" target="_blank"><img src="https://trendshift.io/api/badge/repositories/17427" alt="siddharthvaddem%2Fopenscreen | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
<a href="https://deepwiki.com/siddharthvaddem/openscreen">
|
<a href="https://deepwiki.com/siddharthvaddem/openscreen">
|
||||||
<img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki" />
|
<img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<a href="https://discord.gg/yAQQhRaEeg">
|
||||||
|
<img src="https://dcbadge.limes.pink/api/server/https://discord.gg/yAQQhRaEeg?style=flat" alt="Join Discord" />
|
||||||
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
# <p align="center">OpenScreen</p>
|
# <p align="center">OpenScreen</p>
|
||||||
@@ -21,21 +28,20 @@ Screen Studio is an awesome product and this is definitely not a 1:1 clone. Open
|
|||||||
OpenScreen is 100% free for personal and commercial use. Use it, modify it, distribute it. (Just be cool 😁 and give a shoutout if you feel like it !)
|
OpenScreen is 100% free for personal and commercial use. Use it, modify it, distribute it. (Just be cool 😁 and give a shoutout if you feel like it !)
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="public/preview3.png" alt="OpenScreen App Preview 3" style="height: 320px; margin-right: 12px;" />
|
<img src="public/preview3.png" alt="OpenScreen App Preview 3" style="height: 0.2467; margin-right: 12px;" />
|
||||||
<img src="public/preview4.png" alt="OpenScreen App Preview 4" style="height: 320px; margin-right: 12px;" />
|
<img src="public/preview4.png" alt="OpenScreen App Preview 4" style="height: 0.1678; margin-right: 12px;" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## Core Features
|
## Core Features
|
||||||
- Record your whole screen or specific windows.
|
- Record specific windows or your whole screen.
|
||||||
- Add Automatic zooms or manual zooms (customizable depth levels).
|
- Add automatic or manual zooms (adjustable depth levels) and customize their durarion and position.
|
||||||
- Record microphone audio and system audio capture.
|
- Record microphone and system audio.
|
||||||
- Customize the duration and position of zooms however you please.
|
|
||||||
- Crop video recordings to hide parts.
|
- Crop video recordings to hide parts.
|
||||||
- Choose between wallpapers, solid colors, gradients or a custom background.
|
- Choose between wallpapers, solid colors, gradients or a custom background.
|
||||||
- Motion blur for smoother pan and zoom effects.
|
- Motion blur for smoother pan and zoom effects.
|
||||||
- Add annotations (text, arrows, images).
|
- Add annotations (text, arrows, images).
|
||||||
- Trim sections of the clip.
|
- Trim sections of the clip.
|
||||||
- Customize speed at different segments.
|
- Customize the speed of different segments.
|
||||||
- Export in different aspect ratios and resolutions.
|
- Export in different aspect ratios and resolutions.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
@@ -74,9 +80,9 @@ You may need to grant screen recording permissions depending on your desktop env
|
|||||||
|
|
||||||
System audio capture relies on Electron's [desktopCapturer](https://www.electronjs.org/docs/latest/api/desktop-capturer) and has some platform-specific quirks:
|
System audio capture relies on Electron's [desktopCapturer](https://www.electronjs.org/docs/latest/api/desktop-capturer) and has some platform-specific quirks:
|
||||||
|
|
||||||
- **macOS**: Requires macOS 13+. On macOS 14.2+ you'll be prompted to grant audio capture permission. macOS 12 and below does not support system audio (mic still work).
|
- **macOS**: Requires macOS 13+. On macOS 14.2+ you'll be prompted to grant audio capture permission. macOS 12 and below does not support system audio (mic still works).
|
||||||
- **Windows**: Works out of the box.
|
- **Windows**: Works out of the box.
|
||||||
- **Linux**: Needs PipeWire (default on Ubuntu 22.04+, Fedora 34+). Older PulseAudio-only setups may not support system audio (mic should still works).
|
- **Linux**: Needs PipeWire (default on Ubuntu 22.04+, Fedora 34+). Older PulseAudio-only setups may not support system audio (mic should still work).
|
||||||
|
|
||||||
## Built with
|
## Built with
|
||||||
- Electron
|
- Electron
|
||||||
@@ -90,10 +96,25 @@ System audio capture relies on Electron's [desktopCapturer](https://www.electron
|
|||||||
|
|
||||||
_I'm new to open source, idk what I'm doing lol. If something is wrong please raise an issue 🙏_
|
_I'm new to open source, idk what I'm doing lol. If something is wrong please raise an issue 🙏_
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
See the documentation here:
|
||||||
|
[OpenScreen Docs](https://deepwiki.com/siddharthvaddem/openscreen)
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Contributions are welcome! If you’d like to help out or see what’s currently being worked on, take a look at the open issues and the [project roadmap](https://github.com/users/siddharthvaddem/projects/3) to understand the current direction of the project and find ways to contribute.
|
Contributions are welcome! If you’d like to help out or see what’s currently being worked on, take a look at the open issues and the [project roadmap](https://github.com/users/siddharthvaddem/projects/3) to understand the current direction of the project and find ways to contribute.
|
||||||
|
|
||||||
|
## Star History
|
||||||
|
|
||||||
|
<a href="https://www.star-history.com/?repos=siddharthvaddem%2Fopenscreen&type=date&legend=top-left">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=siddharthvaddem/openscreen&type=date&theme=dark&legend=top-left" />
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=siddharthvaddem/openscreen&type=date&legend=top-left" />
|
||||||
|
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=siddharthvaddem/openscreen&type=date&legend=top-left" />
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
This project is licensed under the [MIT License](./LICENSE). By using this software, you agree that the authors are not liable for any issues, damages, or claims arising from its use.
|
This project is licensed under the [MIT License](./LICENSE). By using this software, you agree that the authors are not liable for any issues, damages, or claims arising from its use.
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.3.13/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.4.12/schema.json",
|
||||||
"vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
|
"vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
|
||||||
"files": { "ignoreUnknown": false, "includes": ["**", "!**/*.css"] },
|
"files": { "ignoreUnknown": false, "includes": ["**", "!**/*.css"] },
|
||||||
"formatter": {
|
"formatter": {
|
||||||
|
|||||||
+33
-21
@@ -3,6 +3,11 @@
|
|||||||
"$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json",
|
"$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json",
|
||||||
"appId": "com.siddharthvaddem.openscreen",
|
"appId": "com.siddharthvaddem.openscreen",
|
||||||
"asar": true,
|
"asar": true,
|
||||||
|
// .node binaries can't be dlopen'd from inside an asar — must live unpacked.
|
||||||
|
"asarUnpack": [
|
||||||
|
"node_modules/uiohook-napi/**/*",
|
||||||
|
"**/*.node"
|
||||||
|
],
|
||||||
"productName": "Openscreen",
|
"productName": "Openscreen",
|
||||||
"npmRebuild": true,
|
"npmRebuild": true,
|
||||||
"buildDependenciesFromSource": true,
|
"buildDependenciesFromSource": true,
|
||||||
@@ -20,15 +25,20 @@
|
|||||||
"!CONTRIBUTING.md",
|
"!CONTRIBUTING.md",
|
||||||
"!LICENSE"
|
"!LICENSE"
|
||||||
],
|
],
|
||||||
|
// Asset layout contract: "wallpapers/" under resourcesPath must align with
|
||||||
|
// assetBaseDir in electron/preload.ts (packaged branch).
|
||||||
"extraResources": [
|
"extraResources": [
|
||||||
{
|
{
|
||||||
"from": "public/wallpapers",
|
"from": "public/wallpapers",
|
||||||
"to": "assets/wallpapers"
|
"to": "wallpapers"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
||||||
"mac": {
|
"mac": {
|
||||||
"hardenedRuntime": false,
|
"notarize": false,
|
||||||
|
"hardenedRuntime": true,
|
||||||
|
"entitlements": "macos.entitlements",
|
||||||
|
"entitlementsInherit": "macos.entitlements",
|
||||||
"target": [
|
"target": [
|
||||||
{
|
{
|
||||||
"target": "dmg",
|
"target": "dmg",
|
||||||
@@ -37,30 +47,32 @@
|
|||||||
],
|
],
|
||||||
"icon": "icons/icons/mac/icon.icns",
|
"icon": "icons/icons/mac/icon.icns",
|
||||||
"artifactName": "${productName}-Mac-${arch}-${version}-Installer.${ext}",
|
"artifactName": "${productName}-Mac-${arch}-${version}-Installer.${ext}",
|
||||||
"extendInfo": {
|
"extendInfo": {
|
||||||
"NSAudioCaptureUsageDescription": "OpenScreen needs audio capture permission to record system audio.",
|
"NSAudioCaptureUsageDescription": "OpenScreen needs audio capture permission to record system audio.",
|
||||||
"NSMicrophoneUsageDescription": "OpenScreen needs microphone access to record voice audio.",
|
"NSMicrophoneUsageDescription": "OpenScreen needs microphone access to record voice audio.",
|
||||||
"NSCameraUsageDescription": "OpenScreen needs camera access to record webcam video.",
|
"NSCameraUsageDescription": "OpenScreen needs camera access to record webcam video.",
|
||||||
"NSCameraUseContinuityCameraDeviceType": true,
|
"NSCameraUseContinuityCameraDeviceType": true,
|
||||||
"com.apple.security.device.audio-input": true
|
"com.apple.security.device.audio-input": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"linux": {
|
"linux": {
|
||||||
"target": [
|
"target": [
|
||||||
"AppImage"
|
"AppImage",
|
||||||
|
"deb",
|
||||||
|
"pacman"
|
||||||
],
|
],
|
||||||
"icon": "icons/icons/png",
|
"icon": "icons/icons/png",
|
||||||
"artifactName": "${productName}-Linux-${version}.${ext}",
|
"artifactName": "${productName}-Linux-${version}.${ext}",
|
||||||
"category": "AudioVideo"
|
"category": "AudioVideo"
|
||||||
},
|
},
|
||||||
"win": {
|
"win": {
|
||||||
"target": [
|
"target": [
|
||||||
"nsis"
|
"nsis"
|
||||||
],
|
],
|
||||||
"icon": "icons/icons/win/icon.ico"
|
"icon": "icons/icons/win/icon.ico"
|
||||||
},
|
},
|
||||||
"nsis": {
|
"nsis": {
|
||||||
"oneClick": false,
|
"oneClick": false,
|
||||||
"allowToChangeInstallationDirectory": true
|
"allowToChangeInstallationDirectory": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+16
-2
@@ -26,6 +26,8 @@ interface Window {
|
|||||||
electronAPI: {
|
electronAPI: {
|
||||||
getSources: (opts: Electron.SourcesOptions) => Promise<ProcessedDesktopSource[]>;
|
getSources: (opts: Electron.SourcesOptions) => Promise<ProcessedDesktopSource[]>;
|
||||||
switchToEditor: () => Promise<void>;
|
switchToEditor: () => Promise<void>;
|
||||||
|
switchToHud: () => Promise<void>;
|
||||||
|
startNewRecording: () => Promise<{ success: boolean; error?: string }>;
|
||||||
openSourceSelector: () => Promise<void>;
|
openSourceSelector: () => Promise<void>;
|
||||||
selectSource: (source: ProcessedDesktopSource) => Promise<ProcessedDesktopSource | null>;
|
selectSource: (source: ProcessedDesktopSource) => Promise<ProcessedDesktopSource | null>;
|
||||||
getSelectedSource: () => Promise<ProcessedDesktopSource | null>;
|
getSelectedSource: () => Promise<ProcessedDesktopSource | null>;
|
||||||
@@ -35,7 +37,12 @@ interface Window {
|
|||||||
status: string;
|
status: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
}>;
|
}>;
|
||||||
getAssetBasePath: () => Promise<string | null>;
|
requestAccessibilityAccess: () => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
granted: boolean;
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
|
assetBaseUrl: string;
|
||||||
storeRecordedVideo: (
|
storeRecordedVideo: (
|
||||||
videoData: ArrayBuffer,
|
videoData: ArrayBuffer,
|
||||||
fileName: string,
|
fileName: string,
|
||||||
@@ -61,10 +68,12 @@ interface Window {
|
|||||||
message?: string;
|
message?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
}>;
|
}>;
|
||||||
setRecordingState: (recording: boolean) => Promise<void>;
|
setRecordingState: (recording: boolean, recordingId?: number) => Promise<void>;
|
||||||
|
discardCursorTelemetry: (recordingId: number) => Promise<void>;
|
||||||
getCursorTelemetry: (videoPath?: string) => Promise<{
|
getCursorTelemetry: (videoPath?: string) => Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
samples: CursorTelemetryPoint[];
|
samples: CursorTelemetryPoint[];
|
||||||
|
clicks: number[];
|
||||||
message?: string;
|
message?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
}>;
|
}>;
|
||||||
@@ -133,9 +142,14 @@ interface Window {
|
|||||||
saveShortcuts: (shortcuts: unknown) => Promise<{ success: boolean; error?: string }>;
|
saveShortcuts: (shortcuts: unknown) => Promise<{ success: boolean; error?: string }>;
|
||||||
hudOverlayHide: () => void;
|
hudOverlayHide: () => void;
|
||||||
hudOverlayClose: () => void;
|
hudOverlayClose: () => void;
|
||||||
|
showCountdownOverlay: (value: number, runId: number) => Promise<void>;
|
||||||
|
setCountdownOverlayValue: (value: number, runId: number) => Promise<void>;
|
||||||
|
hideCountdownOverlay: (runId: number) => Promise<void>;
|
||||||
|
onCountdownOverlayValue: (callback: (value: number | null) => void) => () => void;
|
||||||
setMicrophoneExpanded: (expanded: boolean) => void;
|
setMicrophoneExpanded: (expanded: boolean) => void;
|
||||||
setHasUnsavedChanges: (hasChanges: boolean) => void;
|
setHasUnsavedChanges: (hasChanges: boolean) => void;
|
||||||
onRequestSaveBeforeClose: (callback: () => Promise<boolean> | boolean) => () => void;
|
onRequestSaveBeforeClose: (callback: () => Promise<boolean> | boolean) => () => void;
|
||||||
|
setLocale: (locale: string) => Promise<void>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
// Lightweight i18n for the Electron main process.
|
||||||
|
// Imports the same JSON translation files used by the renderer.
|
||||||
|
|
||||||
|
import commonAr from "../src/i18n/locales/ar/common.json";
|
||||||
|
import dialogsAr from "../src/i18n/locales/ar/dialogs.json";
|
||||||
|
import commonEn from "../src/i18n/locales/en/common.json";
|
||||||
|
import dialogsEn from "../src/i18n/locales/en/dialogs.json";
|
||||||
|
import commonEs from "../src/i18n/locales/es/common.json";
|
||||||
|
import dialogsEs from "../src/i18n/locales/es/dialogs.json";
|
||||||
|
import commonFr from "../src/i18n/locales/fr/common.json";
|
||||||
|
import dialogsFr from "../src/i18n/locales/fr/dialogs.json";
|
||||||
|
import commonJa from "../src/i18n/locales/ja-JP/common.json";
|
||||||
|
import dialogsJa from "../src/i18n/locales/ja-JP/dialogs.json";
|
||||||
|
import commonKo from "../src/i18n/locales/ko-KR/common.json";
|
||||||
|
import dialogsKo from "../src/i18n/locales/ko-KR/dialogs.json";
|
||||||
|
import commonTr from "../src/i18n/locales/tr/common.json";
|
||||||
|
import dialogsTr from "../src/i18n/locales/tr/dialogs.json";
|
||||||
|
import commonZh from "../src/i18n/locales/zh-CN/common.json";
|
||||||
|
import dialogsZh from "../src/i18n/locales/zh-CN/dialogs.json";
|
||||||
|
import commonZhTw from "../src/i18n/locales/zh-TW/common.json";
|
||||||
|
import dialogsZhTw from "../src/i18n/locales/zh-TW/dialogs.json";
|
||||||
|
|
||||||
|
type Locale = "en" | "zh-CN" | "zh-TW" | "es" | "fr" | "ja-JP" | "ko-KR" | "tr" | "ar";
|
||||||
|
type Namespace = "common" | "dialogs";
|
||||||
|
type MessageMap = Record<string, unknown>;
|
||||||
|
|
||||||
|
const messages: Record<Locale, Record<Namespace, MessageMap>> = {
|
||||||
|
en: { common: commonEn, dialogs: dialogsEn },
|
||||||
|
"zh-CN": { common: commonZh, dialogs: dialogsZh },
|
||||||
|
"zh-TW": { common: commonZhTw, dialogs: dialogsZhTw },
|
||||||
|
es: { common: commonEs, dialogs: dialogsEs },
|
||||||
|
fr: { common: commonFr, dialogs: dialogsFr },
|
||||||
|
"ja-JP": { common: commonJa, dialogs: dialogsJa },
|
||||||
|
"ko-KR": { common: commonKo, dialogs: dialogsKo },
|
||||||
|
tr: { common: commonTr, dialogs: dialogsTr },
|
||||||
|
ar: { common: commonAr, dialogs: dialogsAr },
|
||||||
|
};
|
||||||
|
|
||||||
|
let currentLocale: Locale = "en";
|
||||||
|
|
||||||
|
export function setMainLocale(locale: string) {
|
||||||
|
if (
|
||||||
|
locale === "en" ||
|
||||||
|
locale === "zh-CN" ||
|
||||||
|
locale === "zh-TW" ||
|
||||||
|
locale === "es" ||
|
||||||
|
locale === "fr" ||
|
||||||
|
locale === "ja-JP" ||
|
||||||
|
locale === "ko-KR" ||
|
||||||
|
locale === "tr" ||
|
||||||
|
locale === "ar"
|
||||||
|
) {
|
||||||
|
currentLocale = locale;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMainLocale(): Locale {
|
||||||
|
return currentLocale;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMessageValue(obj: unknown, dotPath: string): string | undefined {
|
||||||
|
const keys = dotPath.split(".");
|
||||||
|
let current: unknown = obj;
|
||||||
|
for (const key of keys) {
|
||||||
|
if (current == null || typeof current !== "object") return undefined;
|
||||||
|
current = (current as Record<string, unknown>)[key];
|
||||||
|
}
|
||||||
|
return typeof current === "string" ? current : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function interpolate(str: string, vars?: Record<string, string | number>): string {
|
||||||
|
if (!vars) return str;
|
||||||
|
return str.replace(/\{\{(\w+)\}\}/g, (_, key: string) => String(vars[key] ?? `{{${key}}}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mainT(
|
||||||
|
namespace: Namespace,
|
||||||
|
key: string,
|
||||||
|
vars?: Record<string, string | number>,
|
||||||
|
): string {
|
||||||
|
const value =
|
||||||
|
getMessageValue(messages[currentLocale]?.[namespace], key) ??
|
||||||
|
getMessageValue(messages.en?.[namespace], key);
|
||||||
|
|
||||||
|
if (value == null) return `${namespace}.${key}`;
|
||||||
|
return interpolate(value, vars);
|
||||||
|
}
|
||||||
+667
-119
File diff suppressed because it is too large
Load Diff
+189
-48
@@ -12,8 +12,14 @@ import {
|
|||||||
systemPreferences,
|
systemPreferences,
|
||||||
Tray,
|
Tray,
|
||||||
} from "electron";
|
} from "electron";
|
||||||
|
import { mainT, setMainLocale } from "./i18n";
|
||||||
import { registerIpcHandlers } from "./ipc/handlers";
|
import { registerIpcHandlers } from "./ipc/handlers";
|
||||||
import { createEditorWindow, createHudOverlayWindow, createSourceSelectorWindow } from "./windows";
|
import {
|
||||||
|
createCountdownOverlayWindow,
|
||||||
|
createEditorWindow,
|
||||||
|
createHudOverlayWindow,
|
||||||
|
createSourceSelectorWindow,
|
||||||
|
} from "./windows";
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
@@ -24,6 +30,18 @@ if (process.platform === "darwin") {
|
|||||||
app.commandLine.appendSwitch("disable-features", "MacCatapLoopbackAudioForScreenShare");
|
app.commandLine.appendSwitch("disable-features", "MacCatapLoopbackAudioForScreenShare");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enable Wayland support for proper screen capture and window management
|
||||||
|
// on Wayland compositors (Hyprland, GNOME, KDE, etc.)
|
||||||
|
if (process.platform === "linux") {
|
||||||
|
const isWayland =
|
||||||
|
process.env.XDG_SESSION_TYPE === "wayland" || process.env.WAYLAND_DISPLAY !== undefined;
|
||||||
|
if (isWayland) {
|
||||||
|
app.commandLine.appendSwitch("ozone-platform", "wayland");
|
||||||
|
// Enable WebRTCPipeWireCapturer for screen capture on Wayland
|
||||||
|
app.commandLine.appendSwitch("enable-features", "WaylandWindowDrag,WebRTCPipeWireCapturer");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const RECORDINGS_DIR = path.join(app.getPath("userData"), "recordings");
|
export const RECORDINGS_DIR = path.join(app.getPath("userData"), "recordings");
|
||||||
|
|
||||||
async function ensureRecordingsDir() {
|
async function ensureRecordingsDir() {
|
||||||
@@ -59,12 +77,15 @@ process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL
|
|||||||
// Window references
|
// Window references
|
||||||
let mainWindow: BrowserWindow | null = null;
|
let mainWindow: BrowserWindow | null = null;
|
||||||
let sourceSelectorWindow: BrowserWindow | null = null;
|
let sourceSelectorWindow: BrowserWindow | null = null;
|
||||||
|
let countdownOverlayWindow: BrowserWindow | null = null;
|
||||||
let tray: Tray | null = null;
|
let tray: Tray | null = null;
|
||||||
let selectedSourceName = "";
|
let selectedSourceName = "";
|
||||||
|
const isMac = process.platform === "darwin";
|
||||||
|
const trayIconSize = isMac ? 16 : 24;
|
||||||
|
|
||||||
// Tray Icons
|
// Tray Icons
|
||||||
const defaultTrayIcon = getTrayIcon("openscreen.png");
|
const defaultTrayIcon = getTrayIcon("openscreen.png", trayIconSize);
|
||||||
const recordingTrayIcon = getTrayIcon("rec-button.png");
|
const recordingTrayIcon = getTrayIcon("rec-button.png", trayIconSize);
|
||||||
|
|
||||||
function createWindow() {
|
function createWindow() {
|
||||||
mainWindow = createHudOverlayWindow();
|
mainWindow = createHudOverlayWindow();
|
||||||
@@ -115,72 +136,136 @@ function setupApplicationMenu() {
|
|||||||
template.push({
|
template.push({
|
||||||
label: app.name,
|
label: app.name,
|
||||||
submenu: [
|
submenu: [
|
||||||
{ role: "about" },
|
{
|
||||||
|
role: "about",
|
||||||
|
label: mainT("common", "actions.about") || "About OpenScreen",
|
||||||
|
},
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ role: "services" },
|
{
|
||||||
|
role: "services",
|
||||||
|
label: mainT("common", "actions.services") || "Services",
|
||||||
|
},
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ role: "hide" },
|
{
|
||||||
{ role: "hideOthers" },
|
role: "hide",
|
||||||
{ role: "unhide" },
|
label: mainT("common", "actions.hide") || "Hide OpenScreen",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "hideOthers",
|
||||||
|
label: mainT("common", "actions.hideOthers") || "Hide Others",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "unhide",
|
||||||
|
label: mainT("common", "actions.unhide") || "Show All",
|
||||||
|
},
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ role: "quit" },
|
{ role: "quit", label: mainT("common", "actions.quit") || "Quit" },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
template.push(
|
template.push(
|
||||||
{
|
{
|
||||||
label: "File",
|
label: mainT("common", "actions.file") || "File",
|
||||||
submenu: [
|
submenu: [
|
||||||
{
|
{
|
||||||
label: "Load Project…",
|
label: mainT("dialogs", "unsavedChanges.loadProject") || "Load Project…",
|
||||||
accelerator: "CmdOrCtrl+O",
|
accelerator: "CmdOrCtrl+O",
|
||||||
click: () => sendEditorMenuAction("menu-load-project"),
|
click: () => sendEditorMenuAction("menu-load-project"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Save Project…",
|
label: mainT("dialogs", "unsavedChanges.saveProject") || "Save Project…",
|
||||||
accelerator: "CmdOrCtrl+S",
|
accelerator: "CmdOrCtrl+S",
|
||||||
click: () => sendEditorMenuAction("menu-save-project"),
|
click: () => sendEditorMenuAction("menu-save-project"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Save Project As…",
|
label: mainT("dialogs", "unsavedChanges.saveProjectAs") || "Save Project As…",
|
||||||
accelerator: "CmdOrCtrl+Shift+S",
|
accelerator: "CmdOrCtrl+Shift+S",
|
||||||
click: () => sendEditorMenuAction("menu-save-project-as"),
|
click: () => sendEditorMenuAction("menu-save-project-as"),
|
||||||
},
|
},
|
||||||
...(isMac ? [] : [{ type: "separator" as const }, { role: "quit" as const }]),
|
...(isMac
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
{ type: "separator" as const },
|
||||||
|
{
|
||||||
|
role: "quit" as const,
|
||||||
|
label: mainT("common", "actions.quit") || "Quit",
|
||||||
|
},
|
||||||
|
]),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Edit",
|
label: mainT("common", "actions.edit") || "Edit",
|
||||||
submenu: [
|
submenu: [
|
||||||
{ role: "undo" },
|
{ role: "undo", label: mainT("common", "actions.undo") || "Undo" },
|
||||||
{ role: "redo" },
|
{ role: "redo", label: mainT("common", "actions.redo") || "Redo" },
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ role: "cut" },
|
{ role: "cut", label: mainT("common", "actions.cut") || "Cut" },
|
||||||
{ role: "copy" },
|
{ role: "copy", label: mainT("common", "actions.copy") || "Copy" },
|
||||||
{ role: "paste" },
|
{ role: "paste", label: mainT("common", "actions.paste") || "Paste" },
|
||||||
{ role: "selectAll" },
|
{
|
||||||
|
role: "selectAll",
|
||||||
|
label: mainT("common", "actions.selectAll") || "Select All",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "View",
|
label: mainT("common", "actions.view") || "View",
|
||||||
submenu: [
|
submenu: [
|
||||||
{ role: "reload" },
|
{
|
||||||
{ role: "forceReload" },
|
role: "reload",
|
||||||
{ role: "toggleDevTools" },
|
label: mainT("common", "actions.reload") || "Reload",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "forceReload",
|
||||||
|
label: mainT("common", "actions.forceReload") || "Force Reload",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "toggleDevTools",
|
||||||
|
label: mainT("common", "actions.toggleDevTools") || "Toggle Developer Tools",
|
||||||
|
},
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ role: "resetZoom" },
|
{
|
||||||
{ role: "zoomIn" },
|
role: "resetZoom",
|
||||||
{ role: "zoomOut" },
|
label: mainT("common", "actions.actualSize") || "Actual Size",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "zoomIn",
|
||||||
|
label: mainT("common", "actions.zoomIn") || "Zoom In",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "zoomOut",
|
||||||
|
label: mainT("common", "actions.zoomOut") || "Zoom Out",
|
||||||
|
},
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ role: "togglefullscreen" },
|
{
|
||||||
|
role: "togglefullscreen",
|
||||||
|
label: mainT("common", "actions.toggleFullScreen") || "Toggle Full Screen",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Window",
|
label: mainT("common", "actions.window") || "Window",
|
||||||
submenu: isMac
|
submenu: isMac
|
||||||
? [{ role: "minimize" }, { role: "zoom" }, { type: "separator" }, { role: "front" }]
|
? [
|
||||||
: [{ role: "minimize" }, { role: "close" }],
|
{
|
||||||
|
role: "minimize",
|
||||||
|
label: mainT("common", "actions.minimize") || "Minimize",
|
||||||
|
},
|
||||||
|
{ role: "zoom" },
|
||||||
|
{ type: "separator" },
|
||||||
|
{ role: "front" },
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
role: "minimize",
|
||||||
|
label: mainT("common", "actions.minimize") || "Minimize",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "close",
|
||||||
|
label: mainT("common", "actions.close") || "Close",
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -198,12 +283,12 @@ function createTray() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTrayIcon(filename: string) {
|
function getTrayIcon(filename: string, size: number) {
|
||||||
return nativeImage
|
return nativeImage
|
||||||
.createFromPath(path.join(process.env.VITE_PUBLIC || RENDERER_DIST, filename))
|
.createFromPath(path.join(process.env.VITE_PUBLIC || RENDERER_DIST, filename))
|
||||||
.resize({
|
.resize({
|
||||||
width: 24,
|
width: size,
|
||||||
height: 24,
|
height: size,
|
||||||
quality: "best",
|
quality: "best",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -211,11 +296,15 @@ function getTrayIcon(filename: string) {
|
|||||||
function updateTrayMenu(recording: boolean = false) {
|
function updateTrayMenu(recording: boolean = false) {
|
||||||
if (!tray) return;
|
if (!tray) return;
|
||||||
const trayIcon = recording ? recordingTrayIcon : defaultTrayIcon;
|
const trayIcon = recording ? recordingTrayIcon : defaultTrayIcon;
|
||||||
const trayToolTip = recording ? `Recording: ${selectedSourceName}` : "OpenScreen";
|
const trayToolTip = recording
|
||||||
|
? mainT("common", "actions.recordingStatus", {
|
||||||
|
source: selectedSourceName,
|
||||||
|
}) || `Recording: ${selectedSourceName}`
|
||||||
|
: "OpenScreen";
|
||||||
const menuTemplate = recording
|
const menuTemplate = recording
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
label: "Stop Recording",
|
label: mainT("common", "actions.stopRecording") || "Stop Recording",
|
||||||
click: () => {
|
click: () => {
|
||||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
mainWindow.webContents.send("stop-recording-from-tray");
|
mainWindow.webContents.send("stop-recording-from-tray");
|
||||||
@@ -225,13 +314,13 @@ function updateTrayMenu(recording: boolean = false) {
|
|||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
{
|
{
|
||||||
label: "Open",
|
label: mainT("common", "actions.open") || "Open",
|
||||||
click: () => {
|
click: () => {
|
||||||
showMainWindow();
|
showMainWindow();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Quit",
|
label: mainT("common", "actions.quit") || "Quit",
|
||||||
click: () => {
|
click: () => {
|
||||||
app.quit();
|
app.quit();
|
||||||
},
|
},
|
||||||
@@ -281,12 +370,16 @@ function createEditorWindowWrapper() {
|
|||||||
|
|
||||||
const choice = dialog.showMessageBoxSync(mainWindow!, {
|
const choice = dialog.showMessageBoxSync(mainWindow!, {
|
||||||
type: "warning",
|
type: "warning",
|
||||||
buttons: ["Save & Close", "Discard & Close", "Cancel"],
|
buttons: [
|
||||||
|
mainT("dialogs", "unsavedChanges.saveAndClose"),
|
||||||
|
mainT("dialogs", "unsavedChanges.discardAndClose"),
|
||||||
|
mainT("common", "actions.cancel"),
|
||||||
|
],
|
||||||
defaultId: 0,
|
defaultId: 0,
|
||||||
cancelId: 2,
|
cancelId: 2,
|
||||||
title: "Unsaved Changes",
|
title: mainT("dialogs", "unsavedChanges.title"),
|
||||||
message: "You have unsaved changes.",
|
message: mainT("dialogs", "unsavedChanges.message"),
|
||||||
detail: "Do you want to save your project before closing?",
|
detail: mainT("dialogs", "unsavedChanges.detail"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const windowToClose = mainWindow;
|
const windowToClose = mainWindow;
|
||||||
@@ -315,22 +408,51 @@ function createSourceSelectorWindowWrapper() {
|
|||||||
return sourceSelectorWindow;
|
return sourceSelectorWindow;
|
||||||
}
|
}
|
||||||
|
|
||||||
// On macOS, applications and their menu bar stay active until the user quits
|
function createCountdownOverlayWindowWrapper() {
|
||||||
// explicitly with Cmd + Q.
|
if (countdownOverlayWindow && !countdownOverlayWindow.isDestroyed()) {
|
||||||
|
return countdownOverlayWindow;
|
||||||
|
}
|
||||||
|
|
||||||
|
countdownOverlayWindow = createCountdownOverlayWindow();
|
||||||
|
countdownOverlayWindow.on("closed", () => {
|
||||||
|
countdownOverlayWindow = null;
|
||||||
|
});
|
||||||
|
return countdownOverlayWindow;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Closing every window quits the app entirely (tray icon goes too).
|
||||||
|
// The in-app "Return to Recorder" button covers the editor → HUD round-trip,
|
||||||
|
// so closing the last window is an explicit "I'm done" signal.
|
||||||
app.on("window-all-closed", () => {
|
app.on("window-all-closed", () => {
|
||||||
// Keep app running (macOS behavior)
|
app.quit();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on("activate", () => {
|
app.on("activate", () => {
|
||||||
// On OS X it's common to re-create a window in the app when the
|
// On OS X it's common to re-create a window in the app when the
|
||||||
// dock icon is clicked and there are no other windows open.
|
// dock icon is clicked and there are no other windows open.
|
||||||
if (BrowserWindow.getAllWindows().length === 0) {
|
const hasVisibleWindow = BrowserWindow.getAllWindows().some((window) => {
|
||||||
createWindow();
|
if (window.isDestroyed() || !window.isVisible()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = window.webContents.getURL();
|
||||||
|
const isCountdownOverlayWindow = url.includes("windowType=countdown-overlay");
|
||||||
|
return !isCountdownOverlayWindow;
|
||||||
|
});
|
||||||
|
if (!hasVisibleWindow) {
|
||||||
|
showMainWindow();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Register all IPC handlers when app is ready
|
// Register all IPC handlers when app is ready
|
||||||
app.whenReady().then(async () => {
|
app.whenReady().then(async () => {
|
||||||
|
// Force the app into "regular" activation policy so the Dock icon appears.
|
||||||
|
// The HUD overlay (transparent + frameless + skipTaskbar) is the first
|
||||||
|
// window we open, and AppKit otherwise classifies us as an accessory app.
|
||||||
|
if (process.platform === "darwin") {
|
||||||
|
app.dock?.show();
|
||||||
|
}
|
||||||
|
|
||||||
// Allow microphone/media permission checks
|
// Allow microphone/media permission checks
|
||||||
session.defaultSession.setPermissionCheckHandler((_webContents, permission) => {
|
session.defaultSession.setPermissionCheckHandler((_webContents, permission) => {
|
||||||
const allowed = ["media", "audioCapture", "microphone", "videoCapture", "camera"];
|
const allowed = ["media", "audioCapture", "microphone", "videoCapture", "camera"];
|
||||||
@@ -354,17 +476,35 @@ app.whenReady().then(async () => {
|
|||||||
ipcMain.on("hud-overlay-close", () => {
|
ipcMain.on("hud-overlay-close", () => {
|
||||||
app.quit();
|
app.quit();
|
||||||
});
|
});
|
||||||
|
ipcMain.handle("set-locale", (_, locale: string) => {
|
||||||
|
setMainLocale(locale);
|
||||||
|
setupApplicationMenu();
|
||||||
|
updateTrayMenu();
|
||||||
|
});
|
||||||
|
|
||||||
createTray();
|
createTray();
|
||||||
updateTrayMenu();
|
updateTrayMenu();
|
||||||
setupApplicationMenu();
|
setupApplicationMenu();
|
||||||
// Ensure recordings directory exists
|
// Ensure recordings directory exists
|
||||||
await ensureRecordingsDir();
|
await ensureRecordingsDir();
|
||||||
|
|
||||||
|
function switchToHudWrapper() {
|
||||||
|
if (mainWindow) {
|
||||||
|
isForceClosing = true;
|
||||||
|
mainWindow.close();
|
||||||
|
isForceClosing = false;
|
||||||
|
mainWindow = null;
|
||||||
|
}
|
||||||
|
showMainWindow();
|
||||||
|
}
|
||||||
|
|
||||||
registerIpcHandlers(
|
registerIpcHandlers(
|
||||||
createEditorWindowWrapper,
|
createEditorWindowWrapper,
|
||||||
createSourceSelectorWindowWrapper,
|
createSourceSelectorWindowWrapper,
|
||||||
|
createCountdownOverlayWindowWrapper,
|
||||||
() => mainWindow,
|
() => mainWindow,
|
||||||
() => sourceSelectorWindow,
|
() => sourceSelectorWindow,
|
||||||
|
() => countdownOverlayWindow,
|
||||||
(recording: boolean, sourceName: string) => {
|
(recording: boolean, sourceName: string) => {
|
||||||
selectedSourceName = sourceName;
|
selectedSourceName = sourceName;
|
||||||
if (!tray) createTray();
|
if (!tray) createTray();
|
||||||
@@ -373,6 +513,7 @@ app.whenReady().then(async () => {
|
|||||||
showMainWindow();
|
showMainWindow();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
switchToHudWrapper,
|
||||||
);
|
);
|
||||||
createWindow();
|
createWindow();
|
||||||
});
|
});
|
||||||
|
|||||||
+39
-6
@@ -1,23 +1,33 @@
|
|||||||
import { contextBridge, ipcRenderer } from "electron";
|
import { contextBridge, ipcRenderer } from "electron";
|
||||||
import type { RecordingSession, StoreRecordedSessionInput } from "../src/lib/recordingSession";
|
import type { RecordingSession, StoreRecordedSessionInput } from "../src/lib/recordingSession";
|
||||||
|
|
||||||
|
// Asset base URL is passed from the main process via webPreferences.additionalArguments
|
||||||
|
// (see windows.ts). Sandboxed preloads cannot import node:path / node:url, so we
|
||||||
|
// can't compute it here.
|
||||||
|
const ASSET_BASE_URL_ARG_PREFIX = "--asset-base-url=";
|
||||||
|
const assetBaseUrlArg = process.argv.find((arg) => arg.startsWith(ASSET_BASE_URL_ARG_PREFIX));
|
||||||
|
const assetBaseUrl = assetBaseUrlArg ? assetBaseUrlArg.slice(ASSET_BASE_URL_ARG_PREFIX.length) : "";
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld("electronAPI", {
|
contextBridge.exposeInMainWorld("electronAPI", {
|
||||||
|
assetBaseUrl,
|
||||||
hudOverlayHide: () => {
|
hudOverlayHide: () => {
|
||||||
ipcRenderer.send("hud-overlay-hide");
|
ipcRenderer.send("hud-overlay-hide");
|
||||||
},
|
},
|
||||||
hudOverlayClose: () => {
|
hudOverlayClose: () => {
|
||||||
ipcRenderer.send("hud-overlay-close");
|
ipcRenderer.send("hud-overlay-close");
|
||||||
},
|
},
|
||||||
getAssetBasePath: async () => {
|
|
||||||
// ask main process for the correct base path (production vs dev)
|
|
||||||
return await ipcRenderer.invoke("get-asset-base-path");
|
|
||||||
},
|
|
||||||
getSources: async (opts: Electron.SourcesOptions) => {
|
getSources: async (opts: Electron.SourcesOptions) => {
|
||||||
return await ipcRenderer.invoke("get-sources", opts);
|
return await ipcRenderer.invoke("get-sources", opts);
|
||||||
},
|
},
|
||||||
switchToEditor: () => {
|
switchToEditor: () => {
|
||||||
return ipcRenderer.invoke("switch-to-editor");
|
return ipcRenderer.invoke("switch-to-editor");
|
||||||
},
|
},
|
||||||
|
switchToHud: () => {
|
||||||
|
return ipcRenderer.invoke("switch-to-hud");
|
||||||
|
},
|
||||||
|
startNewRecording: () => {
|
||||||
|
return ipcRenderer.invoke("start-new-recording");
|
||||||
|
},
|
||||||
openSourceSelector: () => {
|
openSourceSelector: () => {
|
||||||
return ipcRenderer.invoke("open-source-selector");
|
return ipcRenderer.invoke("open-source-selector");
|
||||||
},
|
},
|
||||||
@@ -30,6 +40,9 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
|||||||
requestCameraAccess: () => {
|
requestCameraAccess: () => {
|
||||||
return ipcRenderer.invoke("request-camera-access");
|
return ipcRenderer.invoke("request-camera-access");
|
||||||
},
|
},
|
||||||
|
requestAccessibilityAccess: () => {
|
||||||
|
return ipcRenderer.invoke("request-accessibility-access");
|
||||||
|
},
|
||||||
|
|
||||||
storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => {
|
storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => {
|
||||||
return ipcRenderer.invoke("store-recorded-video", videoData, fileName);
|
return ipcRenderer.invoke("store-recorded-video", videoData, fileName);
|
||||||
@@ -41,12 +54,15 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
|||||||
getRecordedVideoPath: () => {
|
getRecordedVideoPath: () => {
|
||||||
return ipcRenderer.invoke("get-recorded-video-path");
|
return ipcRenderer.invoke("get-recorded-video-path");
|
||||||
},
|
},
|
||||||
setRecordingState: (recording: boolean) => {
|
setRecordingState: (recording: boolean, recordingId?: number) => {
|
||||||
return ipcRenderer.invoke("set-recording-state", recording);
|
return ipcRenderer.invoke("set-recording-state", recording, recordingId);
|
||||||
},
|
},
|
||||||
getCursorTelemetry: (videoPath?: string) => {
|
getCursorTelemetry: (videoPath?: string) => {
|
||||||
return ipcRenderer.invoke("get-cursor-telemetry", videoPath);
|
return ipcRenderer.invoke("get-cursor-telemetry", videoPath);
|
||||||
},
|
},
|
||||||
|
discardCursorTelemetry: (recordingId: number) => {
|
||||||
|
return ipcRenderer.invoke("discard-cursor-telemetry", recordingId);
|
||||||
|
},
|
||||||
onStopRecordingFromTray: (callback: () => void) => {
|
onStopRecordingFromTray: (callback: () => void) => {
|
||||||
const listener = () => callback();
|
const listener = () => callback();
|
||||||
ipcRenderer.on("stop-recording-from-tray", listener);
|
ipcRenderer.on("stop-recording-from-tray", listener);
|
||||||
@@ -115,12 +131,29 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
|||||||
saveShortcuts: (shortcuts: unknown) => {
|
saveShortcuts: (shortcuts: unknown) => {
|
||||||
return ipcRenderer.invoke("save-shortcuts", shortcuts);
|
return ipcRenderer.invoke("save-shortcuts", shortcuts);
|
||||||
},
|
},
|
||||||
|
setLocale: (locale: string) => {
|
||||||
|
return ipcRenderer.invoke("set-locale", locale);
|
||||||
|
},
|
||||||
setMicrophoneExpanded: (expanded: boolean) => {
|
setMicrophoneExpanded: (expanded: boolean) => {
|
||||||
ipcRenderer.send("hud:setMicrophoneExpanded", expanded);
|
ipcRenderer.send("hud:setMicrophoneExpanded", expanded);
|
||||||
},
|
},
|
||||||
setHasUnsavedChanges: (hasChanges: boolean) => {
|
setHasUnsavedChanges: (hasChanges: boolean) => {
|
||||||
ipcRenderer.send("set-has-unsaved-changes", hasChanges);
|
ipcRenderer.send("set-has-unsaved-changes", hasChanges);
|
||||||
},
|
},
|
||||||
|
showCountdownOverlay: (value: number, runId: number) => {
|
||||||
|
return ipcRenderer.invoke("countdown-overlay-show", value, runId);
|
||||||
|
},
|
||||||
|
setCountdownOverlayValue: (value: number, runId: number) => {
|
||||||
|
return ipcRenderer.invoke("countdown-overlay-set-value", value, runId);
|
||||||
|
},
|
||||||
|
hideCountdownOverlay: (runId: number) => {
|
||||||
|
return ipcRenderer.invoke("countdown-overlay-hide", runId);
|
||||||
|
},
|
||||||
|
onCountdownOverlayValue: (callback: (value: number | null) => void) => {
|
||||||
|
const listener = (_event: unknown, value: number | null) => callback(value);
|
||||||
|
ipcRenderer.on("countdown-overlay-value", listener);
|
||||||
|
return () => ipcRenderer.removeListener("countdown-overlay-value", listener);
|
||||||
|
},
|
||||||
onRequestSaveBeforeClose: (callback: () => Promise<boolean> | boolean) => {
|
onRequestSaveBeforeClose: (callback: () => Promise<boolean> | boolean) => {
|
||||||
const listener = async () => {
|
const listener = async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
+95
-7
@@ -1,5 +1,5 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
import { BrowserWindow, ipcMain, screen } from "electron";
|
import { BrowserWindow, ipcMain, screen } from "electron";
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
@@ -9,6 +9,13 @@ const VITE_DEV_SERVER_URL = process.env["VITE_DEV_SERVER_URL"];
|
|||||||
const RENDERER_DIST = path.join(APP_ROOT, "dist");
|
const RENDERER_DIST = path.join(APP_ROOT, "dist");
|
||||||
const HEADLESS = process.env["HEADLESS"] === "true";
|
const HEADLESS = process.env["HEADLESS"] === "true";
|
||||||
|
|
||||||
|
// Asset base URL for renderer (wallpapers, etc.). Packaged: extraResources copies
|
||||||
|
// public/wallpapers -> resources/wallpapers. Unpackaged: <appRoot>/public/.
|
||||||
|
const ASSET_BASE_DIR = process.defaultApp
|
||||||
|
? path.join(__dirname, "..", "public")
|
||||||
|
: process.resourcesPath;
|
||||||
|
const ASSET_BASE_URL_ARG = `--asset-base-url=${pathToFileURL(`${ASSET_BASE_DIR}${path.sep}`).toString()}`;
|
||||||
|
|
||||||
let hudOverlayWindow: BrowserWindow | null = null;
|
let hudOverlayWindow: BrowserWindow | null = null;
|
||||||
|
|
||||||
ipcMain.on("hud-overlay-hide", () => {
|
ipcMain.on("hud-overlay-hide", () => {
|
||||||
@@ -17,12 +24,17 @@ ipcMain.on("hud-overlay-hide", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the always-on-top HUD overlay window centred at the bottom of the
|
||||||
|
* primary display. The window is frameless, transparent, and follows the user
|
||||||
|
* across macOS Spaces so it is never lost when switching virtual desktops.
|
||||||
|
*/
|
||||||
export function createHudOverlayWindow(): BrowserWindow {
|
export function createHudOverlayWindow(): BrowserWindow {
|
||||||
const primaryDisplay = screen.getPrimaryDisplay();
|
const primaryDisplay = screen.getPrimaryDisplay();
|
||||||
const { workArea } = primaryDisplay;
|
const { workArea } = primaryDisplay;
|
||||||
|
|
||||||
const windowWidth = 500;
|
const windowWidth = 600;
|
||||||
const windowHeight = 155;
|
const windowHeight = 160;
|
||||||
|
|
||||||
const x = Math.floor(workArea.x + (workArea.width - windowWidth) / 2);
|
const x = Math.floor(workArea.x + (workArea.width - windowWidth) / 2);
|
||||||
const y = Math.floor(workArea.y + workArea.height - windowHeight - 5);
|
const y = Math.floor(workArea.y + workArea.height - windowHeight - 5);
|
||||||
@@ -30,10 +42,10 @@ export function createHudOverlayWindow(): BrowserWindow {
|
|||||||
const win = new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
width: windowWidth,
|
width: windowWidth,
|
||||||
height: windowHeight,
|
height: windowHeight,
|
||||||
minWidth: 500,
|
minWidth: 600,
|
||||||
maxWidth: 500,
|
maxWidth: 600,
|
||||||
minHeight: 155,
|
minHeight: 160,
|
||||||
maxHeight: 155,
|
maxHeight: 160,
|
||||||
x: x,
|
x: x,
|
||||||
y: y,
|
y: y,
|
||||||
frame: false,
|
frame: false,
|
||||||
@@ -45,12 +57,19 @@ export function createHudOverlayWindow(): BrowserWindow {
|
|||||||
show: !HEADLESS,
|
show: !HEADLESS,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: path.join(__dirname, "preload.mjs"),
|
preload: path.join(__dirname, "preload.mjs"),
|
||||||
|
additionalArguments: [ASSET_BASE_URL_ARG],
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
backgroundThrottling: false,
|
backgroundThrottling: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Follow the user across macOS Spaces (virtual desktops).
|
||||||
|
// Without this the HUD stays pinned to the Space it was first opened on.
|
||||||
|
if (process.platform === "darwin") {
|
||||||
|
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
|
||||||
|
}
|
||||||
|
|
||||||
win.webContents.on("did-finish-load", () => {
|
win.webContents.on("did-finish-load", () => {
|
||||||
win?.webContents.send("main-process-message", new Date().toLocaleString());
|
win?.webContents.send("main-process-message", new Date().toLocaleString());
|
||||||
});
|
});
|
||||||
@@ -74,6 +93,10 @@ export function createHudOverlayWindow(): BrowserWindow {
|
|||||||
return win;
|
return win;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the main editor window. Starts maximised with a hidden title bar on
|
||||||
|
* macOS. This window is not always-on-top and appears in the taskbar/dock.
|
||||||
|
*/
|
||||||
export function createEditorWindow(): BrowserWindow {
|
export function createEditorWindow(): BrowserWindow {
|
||||||
const isMac = process.platform === "darwin";
|
const isMac = process.platform === "darwin";
|
||||||
|
|
||||||
@@ -95,6 +118,7 @@ export function createEditorWindow(): BrowserWindow {
|
|||||||
show: !HEADLESS,
|
show: !HEADLESS,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: path.join(__dirname, "preload.mjs"),
|
preload: path.join(__dirname, "preload.mjs"),
|
||||||
|
additionalArguments: [ASSET_BASE_URL_ARG],
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
webSecurity: false,
|
webSecurity: false,
|
||||||
@@ -120,6 +144,10 @@ export function createEditorWindow(): BrowserWindow {
|
|||||||
return win;
|
return win;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the floating source-selector window used to pick a screen or window
|
||||||
|
* to record. Frameless, transparent, and follows the user across macOS Spaces.
|
||||||
|
*/
|
||||||
export function createSourceSelectorWindow(): BrowserWindow {
|
export function createSourceSelectorWindow(): BrowserWindow {
|
||||||
const { width, height } = screen.getPrimaryDisplay().workAreaSize;
|
const { width, height } = screen.getPrimaryDisplay().workAreaSize;
|
||||||
|
|
||||||
@@ -137,11 +165,18 @@ export function createSourceSelectorWindow(): BrowserWindow {
|
|||||||
backgroundColor: "#00000000",
|
backgroundColor: "#00000000",
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: path.join(__dirname, "preload.mjs"),
|
preload: path.join(__dirname, "preload.mjs"),
|
||||||
|
additionalArguments: [ASSET_BASE_URL_ARG],
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Follow the user across macOS Spaces so the selector appears on the
|
||||||
|
// active desktop regardless of where the HUD was originally opened.
|
||||||
|
if (process.platform === "darwin") {
|
||||||
|
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
|
||||||
|
}
|
||||||
|
|
||||||
if (VITE_DEV_SERVER_URL) {
|
if (VITE_DEV_SERVER_URL) {
|
||||||
win.loadURL(VITE_DEV_SERVER_URL + "?windowType=source-selector");
|
win.loadURL(VITE_DEV_SERVER_URL + "?windowType=source-selector");
|
||||||
} else {
|
} else {
|
||||||
@@ -152,3 +187,56 @@ export function createSourceSelectorWindow(): BrowserWindow {
|
|||||||
|
|
||||||
return win;
|
return win;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a centered transparent countdown overlay window that sits above the
|
||||||
|
* HUD while recording pre-roll is running.
|
||||||
|
*/
|
||||||
|
export function createCountdownOverlayWindow(): BrowserWindow {
|
||||||
|
const { workArea } = screen.getPrimaryDisplay();
|
||||||
|
const overlayWidth = 420;
|
||||||
|
const overlayHeight = 260;
|
||||||
|
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
width: overlayWidth,
|
||||||
|
height: overlayHeight,
|
||||||
|
minWidth: overlayWidth,
|
||||||
|
maxWidth: overlayWidth,
|
||||||
|
minHeight: overlayHeight,
|
||||||
|
maxHeight: overlayHeight,
|
||||||
|
x: Math.round(workArea.x + (workArea.width - overlayWidth) / 2),
|
||||||
|
y: Math.round(workArea.y + (workArea.height - overlayHeight) / 2),
|
||||||
|
frame: false,
|
||||||
|
resizable: false,
|
||||||
|
alwaysOnTop: true,
|
||||||
|
skipTaskbar: true,
|
||||||
|
focusable: false,
|
||||||
|
transparent: true,
|
||||||
|
backgroundColor: "#00000000",
|
||||||
|
hasShadow: false,
|
||||||
|
show: false,
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(__dirname, "preload.mjs"),
|
||||||
|
additionalArguments: [ASSET_BASE_URL_ARG],
|
||||||
|
nodeIntegration: false,
|
||||||
|
contextIsolation: true,
|
||||||
|
backgroundThrottling: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
win.setIgnoreMouseEvents(true);
|
||||||
|
|
||||||
|
if (process.platform === "darwin") {
|
||||||
|
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (VITE_DEV_SERVER_URL) {
|
||||||
|
win.loadURL(VITE_DEV_SERVER_URL + "?windowType=countdown-overlay");
|
||||||
|
} else {
|
||||||
|
win.loadFile(path.join(RENDERER_DIST, "index.html"), {
|
||||||
|
query: { windowType: "countdown-overlay" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return win;
|
||||||
|
}
|
||||||
|
|||||||
Generated
+27
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1775710090,
|
||||||
|
"narHash": "sha256-ar3rofg+awPB8QXDaFJhJ2jJhu+KqN/PRCXeyuXR76E=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "4c1018dae018162ec878d42fec712642d214fdfa",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
{
|
||||||
|
description = "OpenScreen — desktop screen recorder with built-in editor";
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs =
|
||||||
|
{ self, nixpkgs }:
|
||||||
|
let
|
||||||
|
systems = [
|
||||||
|
"x86_64-linux"
|
||||||
|
"aarch64-linux"
|
||||||
|
];
|
||||||
|
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
|
||||||
|
in
|
||||||
|
{
|
||||||
|
# -- Per-system outputs (packages, dev shells) --
|
||||||
|
|
||||||
|
packages = forAllSystems (pkgs: {
|
||||||
|
openscreen = pkgs.callPackage ./nix/package.nix { };
|
||||||
|
default = self.packages.${pkgs.stdenv.hostPlatform.system}.openscreen;
|
||||||
|
});
|
||||||
|
|
||||||
|
devShells = forAllSystems (
|
||||||
|
pkgs:
|
||||||
|
let
|
||||||
|
electron = pkgs.electron;
|
||||||
|
|
||||||
|
# Libraries Electron needs at runtime on Linux
|
||||||
|
runtimeLibs = with pkgs; [
|
||||||
|
# X11
|
||||||
|
libx11
|
||||||
|
libxcomposite
|
||||||
|
libxdamage
|
||||||
|
libxext
|
||||||
|
libxfixes
|
||||||
|
libxrandr
|
||||||
|
libxtst
|
||||||
|
libxcb
|
||||||
|
libxshmfence
|
||||||
|
|
||||||
|
# Wayland
|
||||||
|
wayland
|
||||||
|
|
||||||
|
# GTK / UI toolkit
|
||||||
|
gtk3
|
||||||
|
glib
|
||||||
|
pango
|
||||||
|
cairo
|
||||||
|
gdk-pixbuf
|
||||||
|
atk
|
||||||
|
at-spi2-atk
|
||||||
|
at-spi2-core
|
||||||
|
|
||||||
|
# Graphics
|
||||||
|
mesa
|
||||||
|
libGL
|
||||||
|
libdrm
|
||||||
|
vulkan-loader
|
||||||
|
|
||||||
|
# Networking / crypto (NSS for Chromium)
|
||||||
|
nss
|
||||||
|
nspr
|
||||||
|
|
||||||
|
# Audio
|
||||||
|
alsa-lib
|
||||||
|
pipewire
|
||||||
|
pulseaudio
|
||||||
|
|
||||||
|
# System
|
||||||
|
dbus
|
||||||
|
cups
|
||||||
|
expat
|
||||||
|
libnotify
|
||||||
|
libsecret
|
||||||
|
util-linux # libuuid
|
||||||
|
];
|
||||||
|
in
|
||||||
|
{
|
||||||
|
default = pkgs.mkShell {
|
||||||
|
packages = with pkgs; [
|
||||||
|
nodejs_22
|
||||||
|
electron
|
||||||
|
|
||||||
|
# Native module compilation
|
||||||
|
python3
|
||||||
|
pkg-config
|
||||||
|
gcc
|
||||||
|
|
||||||
|
# Playwright browser tests
|
||||||
|
playwright-driver.browsers
|
||||||
|
];
|
||||||
|
|
||||||
|
# Electron's prebuilt binary needs these at runtime
|
||||||
|
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath runtimeLibs;
|
||||||
|
|
||||||
|
# Tell the npm `electron` package to use the Nix-provided binary
|
||||||
|
# instead of downloading its own. vite-plugin-electron respects this.
|
||||||
|
ELECTRON_OVERRIDE_DIST_PATH = "${electron}/libexec/electron";
|
||||||
|
|
||||||
|
# Playwright browser path for test:browser / test:e2e
|
||||||
|
PLAYWRIGHT_BROWSERS_PATH = "${pkgs.playwright-driver.browsers}";
|
||||||
|
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1";
|
||||||
|
|
||||||
|
shellHook = ''
|
||||||
|
echo "OpenScreen dev shell — node $(node --version), electron v$(electron --version 2>/dev/null | tr -d 'v')"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
# -- System-wide outputs (modules, overlay) --
|
||||||
|
|
||||||
|
overlays.default = final: _prev: {
|
||||||
|
openscreen = self.packages.${final.stdenv.hostPlatform.system}.openscreen;
|
||||||
|
};
|
||||||
|
|
||||||
|
nixosModules.default = import ./nix/module.nix self;
|
||||||
|
homeManagerModules.default = import ./nix/hm-module.nix self;
|
||||||
|
};
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -0,0 +1,25 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<!-- Required for Electron's V8 JIT compilation -->
|
||||||
|
<key>com.apple.security.cs.allow-jit</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<!-- Required for Electron's native module loading -->
|
||||||
|
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<!-- Required for loading Electron's bundled frameworks/dylibs -->
|
||||||
|
<key>com.apple.security.cs.disable-library-validation</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<!-- Audio input (microphone / system audio capture) -->
|
||||||
|
<key>com.apple.security.device.audio-input</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<!-- Camera (webcam capture) -->
|
||||||
|
<key>com.apple.security.device.camera</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Home Manager module for OpenScreen
|
||||||
|
# Usage in flake-based Home Manager config:
|
||||||
|
#
|
||||||
|
# inputs.openscreen.url = "github:siddharthvaddem/openscreen";
|
||||||
|
#
|
||||||
|
# { inputs, ... }: {
|
||||||
|
# imports = [ inputs.openscreen.homeManagerModules.default ];
|
||||||
|
# programs.openscreen.enable = true;
|
||||||
|
# }
|
||||||
|
self:
|
||||||
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
pkgs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
|
||||||
|
let
|
||||||
|
cfg = config.programs.openscreen;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.programs.openscreen = {
|
||||||
|
enable = lib.mkEnableOption "OpenScreen screen recorder";
|
||||||
|
|
||||||
|
package = lib.mkOption {
|
||||||
|
type = lib.types.package;
|
||||||
|
default = self.packages.${pkgs.stdenv.hostPlatform.system}.openscreen;
|
||||||
|
defaultText = lib.literalExpression "inputs.openscreen.packages.\${pkgs.stdenv.hostPlatform.system}.openscreen";
|
||||||
|
description = "The OpenScreen package to use.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = lib.mkIf cfg.enable {
|
||||||
|
home.packages = [ cfg.package ];
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# NixOS module for OpenScreen
|
||||||
|
# Usage in flake-based NixOS config:
|
||||||
|
#
|
||||||
|
# inputs.openscreen.url = "github:siddharthvaddem/openscreen";
|
||||||
|
#
|
||||||
|
# { inputs, ... }: {
|
||||||
|
# imports = [ inputs.openscreen.nixosModules.default ];
|
||||||
|
# programs.openscreen.enable = true;
|
||||||
|
# }
|
||||||
|
self:
|
||||||
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
pkgs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
|
||||||
|
let
|
||||||
|
cfg = config.programs.openscreen;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.programs.openscreen = {
|
||||||
|
enable = lib.mkEnableOption "OpenScreen screen recorder";
|
||||||
|
|
||||||
|
package = lib.mkOption {
|
||||||
|
type = lib.types.package;
|
||||||
|
default = self.packages.${pkgs.stdenv.hostPlatform.system}.openscreen;
|
||||||
|
defaultText = lib.literalExpression "inputs.openscreen.packages.\${pkgs.stdenv.hostPlatform.system}.openscreen";
|
||||||
|
description = "The OpenScreen package to use.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = lib.mkIf cfg.enable {
|
||||||
|
environment.systemPackages = [ cfg.package ];
|
||||||
|
|
||||||
|
# Screen capture on Wayland requires xdg-desktop-portal.
|
||||||
|
# We enable the base portal; users should also enable a
|
||||||
|
# desktop-specific portal (e.g. xdg-desktop-portal-gtk,
|
||||||
|
# xdg-desktop-portal-hyprland) in their DE config.
|
||||||
|
xdg.portal.enable = lib.mkDefault true;
|
||||||
|
};
|
||||||
|
}
|
||||||
+124
@@ -0,0 +1,124 @@
|
|||||||
|
{
|
||||||
|
lib,
|
||||||
|
buildNpmPackage,
|
||||||
|
nodejs_22,
|
||||||
|
electron,
|
||||||
|
makeWrapper,
|
||||||
|
makeDesktopItem,
|
||||||
|
copyDesktopItems,
|
||||||
|
}:
|
||||||
|
|
||||||
|
buildNpmPackage {
|
||||||
|
nodejs = nodejs_22;
|
||||||
|
pname = "openscreen";
|
||||||
|
version = "1.4.0";
|
||||||
|
|
||||||
|
src =
|
||||||
|
let
|
||||||
|
fs = lib.fileset;
|
||||||
|
# gitTracked fails when source is already a store path (path: flake inputs).
|
||||||
|
# Detect this and fall back to cleanSource which handles both cases.
|
||||||
|
isStorePath = builtins.storeDir == builtins.substring 0 (builtins.stringLength builtins.storeDir) (toString ../.);
|
||||||
|
baseFiles = if isStorePath then fs.fromSource (lib.cleanSource ../.) else fs.gitTracked ../.;
|
||||||
|
in
|
||||||
|
fs.toSource {
|
||||||
|
root = ../.;
|
||||||
|
fileset = fs.difference baseFiles (
|
||||||
|
fs.unions [
|
||||||
|
../nix
|
||||||
|
../flake.nix
|
||||||
|
../flake.lock
|
||||||
|
(fs.fileFilter (file: file.hasExt "md") ../.)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
npmDepsHash = "sha256-Pd6J9TuggA9vM4s/LjdoK4MoBEivSzAWc/G2+pFOM2U=";
|
||||||
|
|
||||||
|
env.ELECTRON_SKIP_BINARY_DOWNLOAD = "1";
|
||||||
|
|
||||||
|
# electron-builder is not needed — we wrap system electron directly
|
||||||
|
npmFlags = [ "--ignore-scripts" ];
|
||||||
|
makeCacheWritable = true;
|
||||||
|
|
||||||
|
# vite-plugin-electron compiles electron/ sources into dist-electron/
|
||||||
|
# tsconfig has noEmit — tsc is type-check only
|
||||||
|
buildPhase = ''
|
||||||
|
runHook preBuild
|
||||||
|
npx vite build
|
||||||
|
runHook postBuild
|
||||||
|
'';
|
||||||
|
|
||||||
|
installPhase = ''
|
||||||
|
runHook preInstall
|
||||||
|
|
||||||
|
mkdir -p "$out/lib/openscreen"
|
||||||
|
|
||||||
|
# Renderer build output (index.html, JS chunks, copied public/ assets)
|
||||||
|
cp -r dist "$out/lib/openscreen/"
|
||||||
|
|
||||||
|
# Main process + preload (compiled by vite-plugin-electron)
|
||||||
|
cp -r dist-electron "$out/lib/openscreen/"
|
||||||
|
|
||||||
|
# Package manifest (electron reads "main" field to find entry point)
|
||||||
|
cp package.json "$out/lib/openscreen/"
|
||||||
|
|
||||||
|
# Strip devDependencies (electron, vitest, biome, playwright, etc.)
|
||||||
|
npm prune --omit=dev --no-save
|
||||||
|
cp -r node_modules "$out/lib/openscreen/"
|
||||||
|
|
||||||
|
# Asset resolution: when app.isPackaged is false, the main process resolves
|
||||||
|
# assets at <appPath>/public/. Place wallpapers at that root to match the
|
||||||
|
# packaged layout (electron-builder extraResources -> resources/wallpapers).
|
||||||
|
mkdir -p "$out/lib/openscreen/public"
|
||||||
|
cp -r public/wallpapers "$out/lib/openscreen/public/wallpapers"
|
||||||
|
|
||||||
|
# Wrap system electron with the app directory
|
||||||
|
mkdir -p "$out/bin"
|
||||||
|
makeWrapper "${electron}/bin/electron" "$out/bin/openscreen" \
|
||||||
|
--add-flags "$out/lib/openscreen" \
|
||||||
|
--set ELECTRON_IS_DEV 0
|
||||||
|
|
||||||
|
# Install icons to hicolor theme
|
||||||
|
for size in 16 24 32 48 64 128 256 512 1024; do
|
||||||
|
icon="icons/icons/png/''${size}x''${size}.png"
|
||||||
|
if [ -f "$icon" ]; then
|
||||||
|
install -Dm644 "$icon" \
|
||||||
|
"$out/share/icons/hicolor/''${size}x''${size}/apps/openscreen.png"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
runHook postInstall
|
||||||
|
'';
|
||||||
|
|
||||||
|
nativeBuildInputs = [
|
||||||
|
makeWrapper
|
||||||
|
copyDesktopItems
|
||||||
|
];
|
||||||
|
|
||||||
|
desktopItems = [
|
||||||
|
(makeDesktopItem {
|
||||||
|
name = "openscreen";
|
||||||
|
desktopName = "OpenScreen";
|
||||||
|
genericName = "Screen Recorder";
|
||||||
|
exec = "openscreen %U";
|
||||||
|
icon = "openscreen";
|
||||||
|
comment = "Desktop screen recorder with built-in editor";
|
||||||
|
categories = [
|
||||||
|
"AudioVideo"
|
||||||
|
"Video"
|
||||||
|
"Recorder"
|
||||||
|
];
|
||||||
|
startupWMClass = "Openscreen";
|
||||||
|
terminal = false;
|
||||||
|
})
|
||||||
|
];
|
||||||
|
|
||||||
|
meta = {
|
||||||
|
description = "Desktop screen recorder with built-in editor";
|
||||||
|
homepage = "https://github.com/siddharthvaddem/openscreen";
|
||||||
|
license = lib.licenses.mit;
|
||||||
|
mainProgram = "openscreen";
|
||||||
|
platforms = lib.platforms.linux;
|
||||||
|
};
|
||||||
|
}
|
||||||
Generated
+2663
-6232
File diff suppressed because it is too large
Load Diff
+62
-41
@@ -1,23 +1,37 @@
|
|||||||
{
|
{
|
||||||
"name": "openscreen",
|
"name": "openscreen",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.2.0",
|
"version": "1.4.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"packageManager": "npm@10.9.4",
|
||||||
|
"engines": {
|
||||||
|
"node": "22.22.1",
|
||||||
|
"npm": "10.9.4"
|
||||||
|
},
|
||||||
|
"author": {
|
||||||
|
"name": "Sid",
|
||||||
|
"email": "svaddem@asu.edu"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build && electron-builder",
|
"build": "tsc && vite build && electron-builder",
|
||||||
"lint": "biome check .",
|
"lint": "biome check .",
|
||||||
"lint:fix": "biome check --write .",
|
"lint:fix": "biome check --write .",
|
||||||
"format": "biome format --write .",
|
"format": "biome format --write .",
|
||||||
|
"i18n:check": "node scripts/i18n-check.mjs",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"build:mac": "tsc && vite build && electron-builder --mac",
|
"build:mac": "tsc && vite build && electron-builder --mac",
|
||||||
"build:win": "tsc && vite build && electron-builder --win",
|
"build:win": "tsc && vite build && electron-builder --win --config.npmRebuild=false",
|
||||||
"build:linux": "tsc && vite build && electron-builder --linux",
|
"build:linux": "tsc && vite build && electron-builder --linux AppImage deb pacman --config.npmRebuild=false",
|
||||||
"test": "vitest --run",
|
"test": "vitest --run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"build-vite": "tsc && vite build",
|
"build-vite": "tsc && vite build",
|
||||||
|
"test:browser": "vitest --config vitest.browser.config.ts --run",
|
||||||
|
"test:browser:install": "playwright install --with-deps chromium-headless-shell",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"prepare": "husky"
|
"prepare": "husky",
|
||||||
|
"rebuild:native": "node ./scripts/rebuild-native.mjs",
|
||||||
|
"postinstall": "npm run rebuild:native"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fix-webm-duration/fix": "^1.0.1",
|
"@fix-webm-duration/fix": "^1.0.1",
|
||||||
@@ -28,63 +42,70 @@
|
|||||||
"@radix-ui/react-popover": "^1.1.15",
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-slider": "^1.3.6",
|
"@radix-ui/react-slider": "^1.3.6",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-toggle": "^1.1.10",
|
"@radix-ui/react-toggle": "^1.1.10",
|
||||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@types/gif.js": "^0.2.5",
|
"@types/gif.js": "^0.2.5",
|
||||||
"@uiw/color-convert": "^2.9.2",
|
"@uiw/color-convert": "^2.10.1",
|
||||||
"@uiw/react-color-block": "^2.9.2",
|
"@uiw/react-color-block": "^2.10.1",
|
||||||
|
"@uiw/react-color-colorful": "^2.9.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dnd-timeline": "^2.2.0",
|
"dnd-timeline": "^2.4.0",
|
||||||
"emoji-picker-react": "^4.16.1",
|
"emoji-picker-react": "^4.18.0",
|
||||||
"fix-webm-duration": "^1.0.6",
|
"fix-webm-duration": "^1.0.6",
|
||||||
"gif.js": "^0.2.0",
|
"gif.js": "^0.2.0",
|
||||||
"gsap": "^3.13.0",
|
"gsap": "^3.15.0",
|
||||||
"lucide-react": "^0.545.0",
|
"lucide-react": "^0.545.0",
|
||||||
"mediabunny": "^1.25.1",
|
"mediabunny": "^1.40.1",
|
||||||
"motion": "^12.23.24",
|
"motion": "^12.38.0",
|
||||||
"mp4box": "^2.2.0",
|
"mp4box": "^2.3.0",
|
||||||
"pixi-filters": "^6.1.5",
|
"pixi-filters": "^6.1.5",
|
||||||
"pixi.js": "^8.14.0",
|
"pixi.js": "^8.18.1",
|
||||||
"react": "^18.2.0",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.3.1",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.6.0",
|
||||||
"react-resizable-panels": "^3.0.6",
|
"react-resizable-panels": "^3.0.6",
|
||||||
"react-rnd": "^10.5.2",
|
"react-rnd": "^10.5.3",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"uiohook-napi": "^1.5.5",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
"web-demuxer": "^4.0.0"
|
"web-demuxer": "^4.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.3.13",
|
"@biomejs/biome": "^2.4.12",
|
||||||
"@playwright/test": "^1.58.2",
|
"@electron/rebuild": "^4.0.4",
|
||||||
"@types/node": "^25.0.3",
|
"@playwright/test": "^1.59.1",
|
||||||
"@types/react": "^18.2.64",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@types/react-dom": "^18.2.21",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/uuid": "^10.0.0",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@types/node": "^22.19.17",
|
||||||
"autoprefixer": "^10.4.21",
|
"@types/react": "^18.3.28",
|
||||||
"electron": "^39.2.7",
|
"@types/react-dom": "^18.3.7",
|
||||||
"electron-builder": "^26.7.0",
|
"@vitejs/plugin-react": "^5.2.0",
|
||||||
"electron-icon-builder": "^2.0.1",
|
"@vitest/browser": "^4.1.4",
|
||||||
"electron-rebuild": "^3.2.9",
|
"@vitest/browser-playwright": "^4.1.4",
|
||||||
"fast-check": "^4.5.2",
|
"autoprefixer": "^10.5.0",
|
||||||
|
"electron": "^41.2.1",
|
||||||
|
"electron-builder": "^26.8.1",
|
||||||
|
"esbuild": "^0.27.0",
|
||||||
|
"fast-check": "^4.7.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"lint-staged": "^16.3.2",
|
"jsdom": "^29.0.2",
|
||||||
"postcss": "^8.5.6",
|
"lint-staged": "^16.4.0",
|
||||||
"tailwindcss": "^3.4.18",
|
"postcss": "^8.5.10",
|
||||||
"terser": "^5.44.1",
|
"tailwindcss": "^3.4.19",
|
||||||
"typescript": "^5.2.2",
|
"terser": "^5.46.1",
|
||||||
"vite": "^5.1.6",
|
"typescript": "^5.9.3",
|
||||||
"vite-plugin-electron": "^0.28.6",
|
"vite": "^7.3.2",
|
||||||
"vite-plugin-electron-renderer": "^0.14.5",
|
"vite-plugin-electron": "^0.29.1",
|
||||||
"vitest": "^4.0.16"
|
"vite-plugin-electron-renderer": "^0.14.6",
|
||||||
|
"vitest": "^4.1.4"
|
||||||
},
|
},
|
||||||
"main": "dist-electron/main.js",
|
"main": "dist-electron/main.js",
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
|
|||||||
Executable
+216
@@ -0,0 +1,216 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# OpenScreen macOS Build Script
|
||||||
|
# Produces: release/<version>/OpenScreen-Mac-<arch>-<version>.dmg
|
||||||
|
#
|
||||||
|
# Usage: chmod +x scripts/build_macos.sh && ./scripts/build_macos.sh
|
||||||
|
#
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ── Load .env ─────────────────────────────────────────────────────────
|
||||||
|
PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
ENV_FILE="${PROJECT_ROOT}/.env"
|
||||||
|
|
||||||
|
if [ -f "$ENV_FILE" ]; then
|
||||||
|
set -a
|
||||||
|
source "$ENV_FILE"
|
||||||
|
set +a
|
||||||
|
else
|
||||||
|
echo "ERROR: .env file not found at ${ENV_FILE}"
|
||||||
|
echo "Create one with APP_NAME, SIGN_IDENTITY, NOTARY_PROFILE, etc."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Config ────────────────────────────────────────────────────────────
|
||||||
|
VERSION=$(node -p "require('${PROJECT_ROOT}/package.json').version")
|
||||||
|
RELEASE_DIR="${PROJECT_ROOT}/release/${VERSION}"
|
||||||
|
ENTITLEMENTS="${PROJECT_ROOT}/macos.entitlements"
|
||||||
|
ARCHS=("arm64" "x64")
|
||||||
|
|
||||||
|
# ── Colors ────────────────────────────────────────────────────────────
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
BOLD='\033[1m'
|
||||||
|
|
||||||
|
print_step() { echo -e "\n${CYAN}${BOLD}▸ $1${NC}"; }
|
||||||
|
print_ok() { echo -e "${GREEN}✓ $1${NC}"; }
|
||||||
|
print_warn() { echo -e "${YELLOW}⚠ $1${NC}"; }
|
||||||
|
print_err() { echo -e "${RED}✗ $1${NC}"; }
|
||||||
|
|
||||||
|
# ── Preflight ─────────────────────────────────────────────────────────
|
||||||
|
echo -e "\n${BOLD}╔══════════════════════════════════════════╗${NC}"
|
||||||
|
echo -e "${BOLD}║ ${APP_NAME} macOS Build Script v${VERSION} ║${NC}"
|
||||||
|
echo -e "${BOLD}╚══════════════════════════════════════════╝${NC}"
|
||||||
|
|
||||||
|
print_step "Checking prerequisites..."
|
||||||
|
|
||||||
|
if [[ "$(uname)" != "Darwin" ]]; then
|
||||||
|
print_err "This script must be run on macOS."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
print_ok "Running on macOS ($(uname -m))"
|
||||||
|
|
||||||
|
if ! command -v node &> /dev/null; then
|
||||||
|
print_err "Node.js not found. Please install Node.js first."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
print_ok "Node.js found: $(node -v)"
|
||||||
|
|
||||||
|
if ! command -v npm &> /dev/null; then
|
||||||
|
print_err "npm not found."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
print_ok "npm found: $(npm -v)"
|
||||||
|
|
||||||
|
# Check signing identity
|
||||||
|
if ! security find-identity -v -p codesigning | grep -q "$SIGN_IDENTITY"; then
|
||||||
|
print_err "Signing identity not found: ${SIGN_IDENTITY}"
|
||||||
|
print_err "Run 'security find-identity -v -p codesigning' to see available identities."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
print_ok "Signing identity found: ${SIGN_IDENTITY}"
|
||||||
|
|
||||||
|
# Check notary profile
|
||||||
|
if ! xcrun notarytool history --keychain-profile "$NOTARY_PROFILE" &> /dev/null; then
|
||||||
|
print_err "Notary profile '${NOTARY_PROFILE}' not found in keychain."
|
||||||
|
print_err "Run: xcrun notarytool store-credentials \"${NOTARY_PROFILE}\" --apple-id \"${APPLE_ID}\" --team-id \"${TEAM_ID}\""
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
print_ok "Notary profile found: ${NOTARY_PROFILE}"
|
||||||
|
|
||||||
|
# Check entitlements
|
||||||
|
if [ ! -f "$ENTITLEMENTS" ]; then
|
||||||
|
print_err "Entitlements file not found: ${ENTITLEMENTS}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
print_ok "Entitlements file found"
|
||||||
|
|
||||||
|
# ── Clean ─────────────────────────────────────────────────────────────
|
||||||
|
cd "$PROJECT_ROOT"
|
||||||
|
|
||||||
|
print_step "Cleaning previous build artifacts..."
|
||||||
|
rm -rf dist dist-electron "${RELEASE_DIR}"
|
||||||
|
print_ok "Clean complete"
|
||||||
|
|
||||||
|
# ── Install Dependencies ─────────────────────────────────────────────
|
||||||
|
print_step "Installing dependencies..."
|
||||||
|
npm ci
|
||||||
|
print_ok "Dependencies installed"
|
||||||
|
|
||||||
|
# ── Build Vite + Electron ────────────────────────────────────────────
|
||||||
|
print_step "Building Vite + Electron... (this may take a minute)"
|
||||||
|
npx tsc && npx vite build
|
||||||
|
print_ok "Vite + Electron build complete"
|
||||||
|
|
||||||
|
# ── Package, Sign, Notarize per Architecture ─────────────────────────
|
||||||
|
for ARCH in "${ARCHS[@]}"; do
|
||||||
|
echo ""
|
||||||
|
echo -e "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||||
|
echo -e "${BOLD} Building for: ${ARCH}${NC}"
|
||||||
|
echo -e "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||||
|
|
||||||
|
# ── Package with electron-builder ─────────────────────────────
|
||||||
|
print_step "[${ARCH}] Packaging with electron-builder..."
|
||||||
|
|
||||||
|
# Build .app only (--dir), electron-builder handles codesigning
|
||||||
|
# with hardenedRuntime + entitlements from electron-builder.json5
|
||||||
|
CSC_NAME="$CSC_NAME" npx electron-builder --mac --${ARCH} --dir
|
||||||
|
|
||||||
|
# Find the .app bundle
|
||||||
|
APP_BUNDLE=$(find "${RELEASE_DIR}" -maxdepth 2 -name "*.app" -type d | grep -i "${ARCH}\|mac" | head -n1)
|
||||||
|
if [ -z "$APP_BUNDLE" ]; then
|
||||||
|
# Fallback: find any .app in the output
|
||||||
|
APP_BUNDLE=$(find "${RELEASE_DIR}" -maxdepth 2 -name "*.app" -type d | head -n1)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$APP_BUNDLE" ]; then
|
||||||
|
print_err "[${ARCH}] Could not find .app bundle in ${RELEASE_DIR}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
print_ok "[${ARCH}] App bundle: $(basename "$APP_BUNDLE")"
|
||||||
|
|
||||||
|
# ── Verify codesign on .app ───────────────────────────────────
|
||||||
|
print_step "[${ARCH}] Verifying .app code signature..."
|
||||||
|
codesign --verify --deep --strict "$APP_BUNDLE" 2>&1 || print_warn "[${ARCH}] Deep verify had warnings (may be expected pre-notarization)"
|
||||||
|
print_ok "[${ARCH}] .app signature verified"
|
||||||
|
|
||||||
|
# ── Create DMG ────────────────────────────────────────────────
|
||||||
|
DMG_NAME="${APP_NAME}-Mac-${ARCH}-${VERSION}.dmg"
|
||||||
|
DMG_OUTPUT="${RELEASE_DIR}/${DMG_NAME}"
|
||||||
|
DMG_STAGING="${RELEASE_DIR}/dmg-staging-${ARCH}"
|
||||||
|
|
||||||
|
print_step "[${ARCH}] Creating DMG..."
|
||||||
|
|
||||||
|
rm -f "$DMG_OUTPUT"
|
||||||
|
rm -rf "$DMG_STAGING"
|
||||||
|
|
||||||
|
# Stage: app + Applications shortcut for drag-to-install
|
||||||
|
mkdir -p "$DMG_STAGING"
|
||||||
|
cp -R "$APP_BUNDLE" "$DMG_STAGING/"
|
||||||
|
ln -s /Applications "$DMG_STAGING/Applications"
|
||||||
|
|
||||||
|
hdiutil create \
|
||||||
|
-srcfolder "$DMG_STAGING" \
|
||||||
|
-volname "${APP_NAME}" \
|
||||||
|
-fs HFS+ \
|
||||||
|
-fsargs "-c c=64,a=16,e=16" \
|
||||||
|
-format UDBZ \
|
||||||
|
"$DMG_OUTPUT"
|
||||||
|
|
||||||
|
print_ok "[${ARCH}] DMG created: ${DMG_NAME}"
|
||||||
|
rm -rf "$DMG_STAGING"
|
||||||
|
|
||||||
|
# ── Sign DMG ──────────────────────────────────────────────────
|
||||||
|
print_step "[${ARCH}] Signing DMG..."
|
||||||
|
codesign --force --sign "$SIGN_IDENTITY" --timestamp "$DMG_OUTPUT"
|
||||||
|
print_ok "[${ARCH}] DMG signed"
|
||||||
|
|
||||||
|
# ── Notarize DMG ──────────────────────────────────────────────
|
||||||
|
print_step "[${ARCH}] Notarizing DMG with Apple... (this may take several minutes)"
|
||||||
|
xcrun notarytool submit "$DMG_OUTPUT" \
|
||||||
|
--keychain-profile "$NOTARY_PROFILE" \
|
||||||
|
--wait
|
||||||
|
print_ok "[${ARCH}] DMG notarized"
|
||||||
|
|
||||||
|
# ── Staple ────────────────────────────────────────────────────
|
||||||
|
print_step "[${ARCH}] Stapling notarization ticket..."
|
||||||
|
xcrun stapler staple "$DMG_OUTPUT"
|
||||||
|
print_ok "[${ARCH}] Ticket stapled"
|
||||||
|
|
||||||
|
# ── Validate ──────────────────────────────────────────────────
|
||||||
|
print_step "[${ARCH}] Validating stapled DMG..."
|
||||||
|
xcrun stapler validate "$DMG_OUTPUT"
|
||||||
|
print_ok "[${ARCH}] Validation passed"
|
||||||
|
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── Clean up unpacked dirs (keep only DMGs) ───────────────────────────
|
||||||
|
print_step "Cleaning up intermediate directories..."
|
||||||
|
find "${RELEASE_DIR}" -maxdepth 1 -type d ! -name "$(basename "$RELEASE_DIR")" -exec rm -rf {} + 2>/dev/null || true
|
||||||
|
print_ok "Cleanup complete"
|
||||||
|
|
||||||
|
# ── Done ──────────────────────────────────────────────────────────────
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}${BOLD}════════════════════════════════════════════${NC}"
|
||||||
|
echo -e "${GREEN}${BOLD} Build & Notarization Complete!${NC}"
|
||||||
|
echo -e "${GREEN}${BOLD}════════════════════════════════════════════${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
for ARCH in "${ARCHS[@]}"; do
|
||||||
|
DMG_NAME="${APP_NAME}-Mac-${ARCH}-${VERSION}.dmg"
|
||||||
|
DMG_PATH="${RELEASE_DIR}/${DMG_NAME}"
|
||||||
|
if [ -f "$DMG_PATH" ]; then
|
||||||
|
DMG_SIZE=$(du -h "$DMG_PATH" | cut -f1)
|
||||||
|
echo -e " 📦 ${BOLD}${ARCH}:${NC} ${DMG_PATH}"
|
||||||
|
echo -e " 📏 ${BOLD}Size:${NC} ${DMG_SIZE}"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo -e " ${GREEN}All DMGs are fully signed, notarized, and stapled!${NC}"
|
||||||
|
echo -e " ${GREEN}Ready for distribution outside the Mac App Store.${NC}"
|
||||||
|
echo ""
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Validates that all locale translation files have identical key structures.
|
||||||
|
* Compares all locale folders (except en) against the en baseline for every namespace.
|
||||||
|
*
|
||||||
|
* Usage: node scripts/i18n-check.mjs
|
||||||
|
*/
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const LOCALES_DIR = path.resolve("src/i18n/locales");
|
||||||
|
const BASE_LOCALE = "en";
|
||||||
|
|
||||||
|
function getKeys(obj, prefix = "") {
|
||||||
|
const keys = [];
|
||||||
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
|
const fullKey = prefix ? `${prefix}.${key}` : key;
|
||||||
|
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||||
|
keys.push(...getKeys(value, fullKey));
|
||||||
|
} else {
|
||||||
|
keys.push(fullKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keys.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasErrors = false;
|
||||||
|
|
||||||
|
const baseDir = path.join(LOCALES_DIR, BASE_LOCALE);
|
||||||
|
const namespaces = fs
|
||||||
|
.readdirSync(baseDir)
|
||||||
|
.filter((f) => f.endsWith(".json"))
|
||||||
|
.map((f) => f.replace(".json", ""));
|
||||||
|
|
||||||
|
const compareLocales = fs
|
||||||
|
.readdirSync(LOCALES_DIR, { withFileTypes: true })
|
||||||
|
.filter((entry) => entry.isDirectory())
|
||||||
|
.map((entry) => entry.name)
|
||||||
|
.filter((locale) => locale !== BASE_LOCALE)
|
||||||
|
.sort((a, b) => a.localeCompare(b));
|
||||||
|
|
||||||
|
for (const namespace of namespaces) {
|
||||||
|
const basePath = path.join(baseDir, `${namespace}.json`);
|
||||||
|
const baseData = JSON.parse(fs.readFileSync(basePath, "utf-8"));
|
||||||
|
const baseKeys = getKeys(baseData);
|
||||||
|
|
||||||
|
for (const locale of compareLocales) {
|
||||||
|
const localePath = path.join(LOCALES_DIR, locale, `${namespace}.json`);
|
||||||
|
|
||||||
|
if (!fs.existsSync(localePath)) {
|
||||||
|
console.error(`MISSING: ${locale}/${namespace}.json does not exist`);
|
||||||
|
hasErrors = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const localeData = JSON.parse(fs.readFileSync(localePath, "utf-8"));
|
||||||
|
const localeKeys = getKeys(localeData);
|
||||||
|
|
||||||
|
const missing = baseKeys.filter((k) => !localeKeys.includes(k));
|
||||||
|
const extra = localeKeys.filter((k) => !baseKeys.includes(k));
|
||||||
|
|
||||||
|
if (missing.length > 0) {
|
||||||
|
console.error(`MISSING in ${locale}/${namespace}.json:`);
|
||||||
|
for (const key of missing) {
|
||||||
|
console.error(` - ${key}`);
|
||||||
|
}
|
||||||
|
hasErrors = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extra.length > 0) {
|
||||||
|
console.error(`EXTRA in ${locale}/${namespace}.json:`);
|
||||||
|
for (const key of extra) {
|
||||||
|
console.error(` + ${key}`);
|
||||||
|
}
|
||||||
|
hasErrors = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasErrors) {
|
||||||
|
console.error("\ni18n check FAILED — translation files are out of sync.");
|
||||||
|
process.exit(1);
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
`i18n check PASSED — all ${compareLocales.length} locales match ${BASE_LOCALE} across ${namespaces.length} namespaces.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import process from "node:process";
|
||||||
|
|
||||||
|
// uiohook-napi click capture is macOS-only at runtime (gated in
|
||||||
|
// electron/ipc/handlers.ts). Skip the rebuild on other platforms so CI runners
|
||||||
|
// without X11 dev headers don't fail npm install. The library's prebuilt
|
||||||
|
// .node binaries are still bundled and loadable; we just don't need a fresh
|
||||||
|
// build against Electron's ABI on platforms where we don't load it.
|
||||||
|
if (process.platform !== "darwin") {
|
||||||
|
console.log(
|
||||||
|
`[rebuild:native] Skipping uiohook-napi rebuild on ${process.platform} (macOS-only).`,
|
||||||
|
);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
["./node_modules/@electron/rebuild/lib/cli.js", "--force", "--only", "uiohook-napi"],
|
||||||
|
{ stdio: "inherit" },
|
||||||
|
);
|
||||||
|
process.exit(result.status ?? 0);
|
||||||
+35
-6
@@ -1,6 +1,8 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { CountdownOverlay } from "./components/launch/CountdownOverlay.tsx";
|
||||||
import { LaunchWindow } from "./components/launch/LaunchWindow";
|
import { LaunchWindow } from "./components/launch/LaunchWindow";
|
||||||
import { SourceSelector } from "./components/launch/SourceSelector";
|
import { SourceSelector } from "./components/launch/SourceSelector";
|
||||||
|
import { Toaster } from "./components/ui/sonner";
|
||||||
import { TooltipProvider } from "./components/ui/tooltip";
|
import { TooltipProvider } from "./components/ui/tooltip";
|
||||||
import { ShortcutsConfigDialog } from "./components/video-editor/ShortcutsConfigDialog";
|
import { ShortcutsConfigDialog } from "./components/video-editor/ShortcutsConfigDialog";
|
||||||
import VideoEditor from "./components/video-editor/VideoEditor";
|
import VideoEditor from "./components/video-editor/VideoEditor";
|
||||||
@@ -8,18 +10,38 @@ import { ShortcutsProvider } from "./contexts/ShortcutsContext";
|
|||||||
import { loadAllCustomFonts } from "./lib/customFonts";
|
import { loadAllCustomFonts } from "./lib/customFonts";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [windowType, setWindowType] = useState("");
|
const [windowType, setWindowType] = useState(
|
||||||
|
() => new URLSearchParams(window.location.search).get("windowType") || "",
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const type = new URLSearchParams(window.location.search).get("windowType") || "";
|
||||||
const type = params.get("windowType") || "";
|
if (type !== windowType) {
|
||||||
setWindowType(type);
|
setWindowType(type);
|
||||||
if (type === "hud-overlay" || type === "source-selector") {
|
}
|
||||||
|
|
||||||
|
if (type === "hud-overlay" || type === "source-selector" || type === "countdown-overlay") {
|
||||||
document.body.style.background = "transparent";
|
document.body.style.background = "transparent";
|
||||||
document.documentElement.style.background = "transparent";
|
document.documentElement.style.background = "transparent";
|
||||||
document.getElementById("root")?.style.setProperty("background", "transparent");
|
document.getElementById("root")?.style.setProperty("background", "transparent");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HUD is a fixed-size BrowserWindow; pin the document shell and hide overflow
|
||||||
|
// so the renderer can't introduce scrollbars (see issue #305).
|
||||||
|
if (type === "hud-overlay") {
|
||||||
|
document.documentElement.style.height = "100%";
|
||||||
|
document.documentElement.style.overflow = "hidden";
|
||||||
|
document.body.style.height = "100%";
|
||||||
|
document.body.style.margin = "0";
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
const root = document.getElementById("root");
|
||||||
|
root?.style.setProperty("height", "100%");
|
||||||
|
root?.style.setProperty("min-height", "0");
|
||||||
|
root?.style.setProperty("overflow", "hidden");
|
||||||
|
}
|
||||||
|
}, [windowType]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
// Load custom fonts on app initialization
|
// Load custom fonts on app initialization
|
||||||
loadAllCustomFonts().catch((error) => {
|
loadAllCustomFonts().catch((error) => {
|
||||||
console.error("Failed to load custom fonts:", error);
|
console.error("Failed to load custom fonts:", error);
|
||||||
@@ -32,6 +54,8 @@ export default function App() {
|
|||||||
return <LaunchWindow />;
|
return <LaunchWindow />;
|
||||||
case "source-selector":
|
case "source-selector":
|
||||||
return <SourceSelector />;
|
return <SourceSelector />;
|
||||||
|
case "countdown-overlay":
|
||||||
|
return <CountdownOverlay />;
|
||||||
case "editor":
|
case "editor":
|
||||||
return (
|
return (
|
||||||
<ShortcutsProvider>
|
<ShortcutsProvider>
|
||||||
@@ -48,5 +72,10 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return <TooltipProvider>{content}</TooltipProvider>;
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
{content}
|
||||||
|
<Toaster theme="dark" className="pointer-events-auto" />
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export function CountdownOverlay() {
|
||||||
|
const [value, setValue] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = window.electronAPI.onCountdownOverlayValue((nextValue) => {
|
||||||
|
setValue(nextValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => unsubscribe();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-screen h-screen bg-transparent flex items-center justify-center pointer-events-none select-none">
|
||||||
|
<div className="flex items-center justify-center w-40 h-40 rounded-full bg-black/50">
|
||||||
|
<div
|
||||||
|
className="text-white/90 text-[80px] font-bold leading-none tabular-nums"
|
||||||
|
style={{ textShadow: "0 4px 24px rgba(0, 0, 0, 0.65)" }}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,3 +6,78 @@
|
|||||||
.electronNoDrag {
|
.electronNoDrag {
|
||||||
-webkit-app-region: no-drag;
|
-webkit-app-region: no-drag;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.languageMenuScroll {
|
||||||
|
max-height: 16rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
touch-action: pan-y;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.languageMenuScroll::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.languageMenuScroll::-webkit-scrollbar-track {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.languageMenuScroll::-webkit-scrollbar-thumb {
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.35), rgba(255, 255, 255, 0.2));
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.languageMenuScroll::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.3));
|
||||||
|
}
|
||||||
|
|
||||||
|
.languageMenuContainer {
|
||||||
|
position: relative;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.languageMenuPanel {
|
||||||
|
position: fixed;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 12rem;
|
||||||
|
padding: 0.375rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
|
background: linear-gradient(160deg, rgba(28, 29, 42, 0.98), rgba(18, 19, 28, 0.98));
|
||||||
|
box-shadow: 0 20px 45px rgba(0, 0, 0, 0.55);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
pointer-events: auto;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.languageMenuItem {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.5rem 0.625rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(255, 255, 255, 0.88);
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 120ms ease, color 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.languageMenuItem:hover,
|
||||||
|
.languageMenuItem:focus-visible {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: #ffffff;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.languageMenuItemActive {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { ChevronDown } from "lucide-react";
|
import { Check, ChevronDown, Languages } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { BsRecordCircle } from "react-icons/bs";
|
import { createPortal } from "react-dom";
|
||||||
|
import { BsPauseCircle, BsPlayCircle, BsRecordCircle } from "react-icons/bs";
|
||||||
import { FaRegStopCircle } from "react-icons/fa";
|
import { FaRegStopCircle } from "react-icons/fa";
|
||||||
import { FaFolderOpen } from "react-icons/fa6";
|
import { FaFolderOpen } from "react-icons/fa6";
|
||||||
import { FiMinus, FiX } from "react-icons/fi";
|
import { FiMinus, FiX } from "react-icons/fi";
|
||||||
import {
|
import {
|
||||||
|
MdCancel,
|
||||||
MdMic,
|
MdMic,
|
||||||
MdMicOff,
|
MdMicOff,
|
||||||
MdMonitor,
|
MdMonitor,
|
||||||
@@ -16,11 +18,16 @@ import {
|
|||||||
MdVolumeUp,
|
MdVolumeUp,
|
||||||
} from "react-icons/md";
|
} from "react-icons/md";
|
||||||
import { RxDragHandleDots2 } from "react-icons/rx";
|
import { RxDragHandleDots2 } from "react-icons/rx";
|
||||||
|
import { useI18n, useScopedT } from "@/contexts/I18nContext";
|
||||||
|
import { getAvailableLocales, getLocaleName } from "@/i18n/loader";
|
||||||
import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter";
|
import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter";
|
||||||
|
import { useCameraDevices } from "../../hooks/useCameraDevices";
|
||||||
import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices";
|
import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices";
|
||||||
import { useScreenRecorder } from "../../hooks/useScreenRecorder";
|
import { useScreenRecorder } from "../../hooks/useScreenRecorder";
|
||||||
|
import { requestCameraAccess } from "../../lib/requestCameraAccess";
|
||||||
import { formatTimePadded } from "../../utils/timeUtils";
|
import { formatTimePadded } from "../../utils/timeUtils";
|
||||||
import { AudioLevelMeter } from "../ui/audio-level-meter";
|
import { AudioLevelMeter } from "../ui/audio-level-meter";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
import { Tooltip } from "../ui/tooltip";
|
import { Tooltip } from "../ui/tooltip";
|
||||||
import styles from "./LaunchWindow.module.css";
|
import styles from "./LaunchWindow.module.css";
|
||||||
|
|
||||||
@@ -35,8 +42,11 @@ const ICON_CONFIG = {
|
|||||||
micOff: { icon: MdMicOff, size: ICON_SIZE },
|
micOff: { icon: MdMicOff, size: ICON_SIZE },
|
||||||
webcamOn: { icon: MdVideocam, size: ICON_SIZE },
|
webcamOn: { icon: MdVideocam, size: ICON_SIZE },
|
||||||
webcamOff: { icon: MdVideocamOff, size: ICON_SIZE },
|
webcamOff: { icon: MdVideocamOff, size: ICON_SIZE },
|
||||||
|
pause: { icon: BsPauseCircle, size: ICON_SIZE },
|
||||||
|
resume: { icon: BsPlayCircle, size: ICON_SIZE },
|
||||||
stop: { icon: FaRegStopCircle, size: ICON_SIZE },
|
stop: { icon: FaRegStopCircle, size: ICON_SIZE },
|
||||||
restart: { icon: MdRestartAlt, size: ICON_SIZE },
|
restart: { icon: MdRestartAlt, size: ICON_SIZE },
|
||||||
|
cancel: { icon: MdCancel, size: ICON_SIZE },
|
||||||
record: { icon: BsRecordCircle, size: ICON_SIZE },
|
record: { icon: BsRecordCircle, size: ICON_SIZE },
|
||||||
videoFile: { icon: MdVideoFile, size: ICON_SIZE },
|
videoFile: { icon: MdVideoFile, size: ICON_SIZE },
|
||||||
folder: { icon: FaFolderOpen, size: ICON_SIZE },
|
folder: { icon: FaFolderOpen, size: ICON_SIZE },
|
||||||
@@ -57,14 +67,35 @@ const hudGroupClasses =
|
|||||||
const hudIconBtnClasses =
|
const hudIconBtnClasses =
|
||||||
"flex items-center justify-center p-2 rounded-full transition-all duration-150 cursor-pointer text-white hover:bg-white/10 hover:scale-[1.08] active:scale-95";
|
"flex items-center justify-center p-2 rounded-full transition-all duration-150 cursor-pointer text-white hover:bg-white/10 hover:scale-[1.08] active:scale-95";
|
||||||
|
|
||||||
|
const hudAuxIconBtnClasses =
|
||||||
|
"flex items-center justify-center p-1.5 rounded-full transition-colors duration-150 text-white/55 hover:bg-white/10 disabled:opacity-30 disabled:cursor-not-allowed";
|
||||||
|
|
||||||
const windowBtnClasses =
|
const windowBtnClasses =
|
||||||
"flex items-center justify-center p-2 rounded-full transition-all duration-150 cursor-pointer opacity-50 hover:opacity-90 hover:bg-white/[0.08]";
|
"flex items-center justify-center p-2 rounded-full transition-all duration-150 cursor-pointer opacity-50 hover:opacity-90 hover:bg-white/[0.08]";
|
||||||
|
|
||||||
|
const hudSidebarClasses = "ml-0.5 pl-1.5 border-l border-white/10 flex items-center gap-0.5";
|
||||||
|
|
||||||
export function LaunchWindow() {
|
export function LaunchWindow() {
|
||||||
|
const t = useScopedT("launch");
|
||||||
|
const availableLocales = getAvailableLocales();
|
||||||
|
const {
|
||||||
|
locale,
|
||||||
|
setLocale,
|
||||||
|
systemLocaleSuggestion,
|
||||||
|
acceptSystemLocaleSuggestion,
|
||||||
|
dismissSystemLocaleSuggestion,
|
||||||
|
resolveSystemLocaleSuggestion,
|
||||||
|
} = useI18n();
|
||||||
|
const suggestedLanguageName = systemLocaleSuggestion ? getLocaleName(systemLocaleSuggestion) : "";
|
||||||
|
|
||||||
const {
|
const {
|
||||||
recording,
|
recording,
|
||||||
|
paused,
|
||||||
|
elapsedSeconds,
|
||||||
toggleRecording,
|
toggleRecording,
|
||||||
|
togglePaused,
|
||||||
restartRecording,
|
restartRecording,
|
||||||
|
cancelRecording,
|
||||||
microphoneEnabled,
|
microphoneEnabled,
|
||||||
setMicrophoneEnabled,
|
setMicrophoneEnabled,
|
||||||
microphoneDeviceId,
|
microphoneDeviceId,
|
||||||
@@ -73,42 +104,149 @@ export function LaunchWindow() {
|
|||||||
setSystemAudioEnabled,
|
setSystemAudioEnabled,
|
||||||
webcamEnabled,
|
webcamEnabled,
|
||||||
setWebcamEnabled,
|
setWebcamEnabled,
|
||||||
|
webcamDeviceId,
|
||||||
|
setWebcamDeviceId,
|
||||||
} = useScreenRecorder();
|
} = useScreenRecorder();
|
||||||
const [recordingStart, setRecordingStart] = useState<number | null>(null);
|
|
||||||
const [elapsed, setElapsed] = useState(0);
|
|
||||||
|
|
||||||
const showMicControls = microphoneEnabled && !recording;
|
const showMicControls = microphoneEnabled && !recording;
|
||||||
const { devices, selectedDeviceId, setSelectedDeviceId } =
|
const showWebcamControls = webcamEnabled && !recording;
|
||||||
useMicrophoneDevices(microphoneEnabled);
|
|
||||||
|
const [isMicHovered, setIsMicHovered] = useState(false);
|
||||||
|
const [isMicFocused, setIsMicFocused] = useState(false);
|
||||||
|
const micExpanded = isMicHovered || isMicFocused;
|
||||||
|
|
||||||
|
const [isWebcamHovered, setIsWebcamHovered] = useState(false);
|
||||||
|
const [isWebcamFocused, setIsWebcamFocused] = useState(false);
|
||||||
|
const webcamExpanded = isWebcamHovered || isWebcamFocused;
|
||||||
|
const [isLanguageMenuOpen, setIsLanguageMenuOpen] = useState(false);
|
||||||
|
const languageTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const languageMenuPanelRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [languageMenuStyle, setLanguageMenuStyle] = useState<{
|
||||||
|
right: number;
|
||||||
|
top: number;
|
||||||
|
maxHeight: number;
|
||||||
|
}>({
|
||||||
|
right: 12,
|
||||||
|
top: 12,
|
||||||
|
maxHeight: 240,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
devices: micDevices,
|
||||||
|
selectedDeviceId: selectedMicId,
|
||||||
|
setSelectedDeviceId: setSelectedMicId,
|
||||||
|
} = useMicrophoneDevices(microphoneEnabled);
|
||||||
|
const {
|
||||||
|
devices: cameraDevices,
|
||||||
|
selectedDeviceId: selectedCameraId,
|
||||||
|
setSelectedDeviceId: setSelectedCameraId,
|
||||||
|
isLoading: isCameraDevicesLoading,
|
||||||
|
error: cameraDevicesError,
|
||||||
|
} = useCameraDevices(webcamEnabled);
|
||||||
|
|
||||||
|
const selectedMicLabel =
|
||||||
|
micDevices.find((d) => d.deviceId === (microphoneDeviceId || selectedMicId))?.label ||
|
||||||
|
t("audio.defaultMicrophone");
|
||||||
|
const selectedCameraLabel = isCameraDevicesLoading
|
||||||
|
? t("webcam.searching")
|
||||||
|
: cameraDevicesError
|
||||||
|
? t("webcam.unavailable")
|
||||||
|
: cameraDevices.length === 0
|
||||||
|
? t("webcam.noneFound")
|
||||||
|
: cameraDevices.find((d) => d.deviceId === (webcamDeviceId || selectedCameraId))?.label ||
|
||||||
|
t("webcam.defaultCamera");
|
||||||
|
|
||||||
const { level } = useAudioLevelMeter({
|
const { level } = useAudioLevelMeter({
|
||||||
enabled: showMicControls,
|
enabled: showMicControls,
|
||||||
deviceId: microphoneDeviceId,
|
deviceId: microphoneDeviceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedDeviceId && selectedDeviceId !== "default") {
|
if (selectedMicId && selectedMicId !== "default") {
|
||||||
setMicrophoneDeviceId(selectedDeviceId);
|
setMicrophoneDeviceId(selectedMicId);
|
||||||
}
|
}
|
||||||
}, [selectedDeviceId, setMicrophoneDeviceId]);
|
}, [selectedMicId, setMicrophoneDeviceId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let timer: NodeJS.Timeout | null = null;
|
if (selectedCameraId) {
|
||||||
if (recording) {
|
setWebcamDeviceId(selectedCameraId);
|
||||||
if (!recordingStart) setRecordingStart(Date.now());
|
|
||||||
timer = setInterval(() => {
|
|
||||||
if (recordingStart) {
|
|
||||||
setElapsed(Math.floor((Date.now() - recordingStart) / 1000));
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
} else {
|
|
||||||
setRecordingStart(null);
|
|
||||||
setElapsed(0);
|
|
||||||
if (timer) clearInterval(timer);
|
|
||||||
}
|
}
|
||||||
return () => {
|
}, [selectedCameraId, setWebcamDeviceId]);
|
||||||
if (timer) clearInterval(timer);
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!import.meta.env.DEV) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void requestCameraAccess().catch((error) => {
|
||||||
|
console.warn("Failed to trigger camera access request during development:", error);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLanguageMenuOpen) return;
|
||||||
|
|
||||||
|
const handlePointerDown = (event: PointerEvent) => {
|
||||||
|
const target = event.target as Node;
|
||||||
|
const clickedTrigger = languageTriggerRef.current?.contains(target);
|
||||||
|
const clickedMenu = languageMenuPanelRef.current?.contains(target);
|
||||||
|
if (!clickedTrigger && !clickedMenu) {
|
||||||
|
setIsLanguageMenuOpen(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [recording, recordingStart]);
|
|
||||||
|
const handleEscape = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
setIsLanguageMenuOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("pointerdown", handlePointerDown);
|
||||||
|
window.addEventListener("keydown", handleEscape);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("pointerdown", handlePointerDown);
|
||||||
|
window.removeEventListener("keydown", handleEscape);
|
||||||
|
};
|
||||||
|
}, [isLanguageMenuOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLanguageMenuOpen || !languageTriggerRef.current) return;
|
||||||
|
|
||||||
|
const updatePosition = () => {
|
||||||
|
if (!languageTriggerRef.current) return;
|
||||||
|
const rect = languageTriggerRef.current.getBoundingClientRect();
|
||||||
|
const gap = 8;
|
||||||
|
const viewportPadding = 8;
|
||||||
|
const availableHeight = Math.max(80, rect.top - viewportPadding - gap);
|
||||||
|
const top = Math.max(viewportPadding, rect.top - gap - availableHeight);
|
||||||
|
|
||||||
|
setLanguageMenuStyle({
|
||||||
|
right: Math.max(viewportPadding, window.innerWidth - rect.right),
|
||||||
|
top,
|
||||||
|
maxHeight: availableHeight,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
updatePosition();
|
||||||
|
window.addEventListener("resize", updatePosition);
|
||||||
|
window.addEventListener("scroll", updatePosition, true);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", updatePosition);
|
||||||
|
window.removeEventListener("scroll", updatePosition, true);
|
||||||
|
};
|
||||||
|
}, [isLanguageMenuOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLanguageMenuOpen || !languageMenuPanelRef.current) return;
|
||||||
|
const id = requestAnimationFrame(() => {
|
||||||
|
if (languageMenuPanelRef.current) {
|
||||||
|
languageMenuPanelRef.current.scrollTop = 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => cancelAnimationFrame(id);
|
||||||
|
}, [isLanguageMenuOpen]);
|
||||||
|
|
||||||
const [selectedSource, setSelectedSource] = useState("Screen");
|
const [selectedSource, setSelectedSource] = useState("Screen");
|
||||||
const [hasSelectedSource, setHasSelectedSource] = useState(false);
|
const [hasSelectedSource, setHasSelectedSource] = useState(false);
|
||||||
@@ -176,153 +314,377 @@ export function LaunchWindow() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full flex items-end justify-center bg-transparent">
|
// Root fills the HUD window only. Avoid w-screen/h-screen (100vw/100vh):
|
||||||
<div className={`flex flex-col items-center gap-2 mx-auto ${styles.electronDrag}`}>
|
// 100vw can exceed the inner layout width when scrollbars affect the
|
||||||
{/* Mic controls panel */}
|
// viewport (notably on Windows), causing a horizontal scrollbar once the
|
||||||
{showMicControls && (
|
// recording toolbar widened (issue #305).
|
||||||
<div
|
<div
|
||||||
className={`flex items-center gap-2 px-4 py-2 bg-gradient-to-br from-[rgba(28,28,36,0.97)] to-[rgba(18,18,26,0.96)] backdrop-blur-[16px] backdrop-saturate-[140%] border border-[rgba(80,80,120,0.25)] rounded-2xl shadow-mic-panel animate-mic-panel-in ${styles.electronNoDrag}`}
|
className={`h-full w-full min-w-0 max-w-full overflow-x-hidden overflow-y-hidden bg-transparent ${styles.electronDrag}`}
|
||||||
>
|
>
|
||||||
<div className="relative flex-1" style={{ maxWidth: "70%" }}>
|
{systemLocaleSuggestion && (
|
||||||
<select
|
<div
|
||||||
value={microphoneDeviceId || selectedDeviceId}
|
className={`fixed top-8 left-1/2 z-30 w-[calc(100vw-1rem)] max-w-[520px] -translate-x-1/2 rounded-xl border border-white/15 bg-[rgba(20,20,28,0.95)] p-3 shadow-2xl backdrop-blur-xl text-white animate-in fade-in-0 zoom-in-95 duration-200 ${styles.electronNoDrag}`}
|
||||||
onChange={(e) => {
|
>
|
||||||
setSelectedDeviceId(e.target.value);
|
<div className="text-[13px] font-semibold text-white">
|
||||||
setMicrophoneDeviceId(e.target.value);
|
{t("systemLanguagePrompt.title")}
|
||||||
}}
|
</div>
|
||||||
className="w-full appearance-none bg-white/10 text-white text-xs rounded-full pl-3 pr-7 py-2 border border-white/20 outline-none truncate"
|
<div className="mt-1 text-[11px] leading-relaxed text-white/75">
|
||||||
>
|
{t("systemLanguagePrompt.description", {
|
||||||
{devices.map((device) => (
|
language: suggestedLanguageName,
|
||||||
<option key={device.deviceId} value={device.deviceId}>
|
})}
|
||||||
{device.label}
|
</div>
|
||||||
</option>
|
<div className="mt-3 flex items-center justify-end gap-2">
|
||||||
))}
|
<Button
|
||||||
</select>
|
type="button"
|
||||||
<ChevronDown
|
variant="ghost"
|
||||||
size={14}
|
size="sm"
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-white/60 pointer-events-none"
|
onClick={dismissSystemLocaleSuggestion}
|
||||||
|
className="h-7 text-xs text-white/80 hover:bg-white/10 hover:text-white"
|
||||||
|
>
|
||||||
|
{t("systemLanguagePrompt.keepDefault")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
onClick={acceptSystemLocaleSuggestion}
|
||||||
|
className="h-7 text-xs bg-white text-[#10121b] hover:bg-white/90"
|
||||||
|
>
|
||||||
|
{t("systemLanguagePrompt.switch", {
|
||||||
|
language: suggestedLanguageName,
|
||||||
|
})}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Device selectors — fixed above HUD bar, viewport-relative, never clipped */}
|
||||||
|
{(showMicControls || showWebcamControls) && (
|
||||||
|
<div
|
||||||
|
className={`fixed bottom-[60px] left-1/2 -translate-x-1/2 flex items-center gap-2 animate-mic-panel-in ${styles.electronNoDrag}`}
|
||||||
|
>
|
||||||
|
{/* Mic selector */}
|
||||||
|
{showMicControls && (
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 px-3 py-1.5 h-[36px] bg-gradient-to-br from-[rgba(28,28,36,0.97)] to-[rgba(18,18,26,0.96)] backdrop-blur-[24px] border border-white/10 rounded-xl shadow-2xl transition-all duration-300 overflow-hidden ${!micExpanded ? "opacity-60 grayscale-[0.5]" : "opacity-100"}`}
|
||||||
|
onMouseEnter={() => setIsMicHovered(true)}
|
||||||
|
onMouseLeave={() => setIsMicHovered(false)}
|
||||||
|
onFocus={() => setIsMicFocused(true)}
|
||||||
|
onBlur={() => setIsMicFocused(false)}
|
||||||
|
style={{ width: micExpanded ? "240px" : "140px", transition: "width 300ms ease" }}
|
||||||
|
>
|
||||||
|
<div className="relative flex-1 min-w-0">
|
||||||
|
{!micExpanded && (
|
||||||
|
<div className="text-white/60 text-[10px] font-medium truncate">
|
||||||
|
{selectedMicLabel}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<select
|
||||||
|
value={microphoneDeviceId || selectedMicId}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSelectedMicId(e.target.value);
|
||||||
|
setMicrophoneDeviceId(e.target.value);
|
||||||
|
}}
|
||||||
|
className={`w-full appearance-none bg-white/5 text-white text-[11px] rounded-lg pl-2 pr-6 py-1 border border-white/10 outline-none hover:bg-white/10 transition-colors cursor-pointer ${!micExpanded ? "sr-only" : ""}`}
|
||||||
|
>
|
||||||
|
{micDevices.map((device) => (
|
||||||
|
<option key={device.deviceId} value={device.deviceId} className="bg-[#1c1c24]">
|
||||||
|
{device.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{micExpanded && (
|
||||||
|
<ChevronDown
|
||||||
|
size={12}
|
||||||
|
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-white/40 pointer-events-none"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<AudioLevelMeter
|
||||||
|
level={level}
|
||||||
|
className={`${micExpanded ? "w-16" : "w-8"} h-2 transition-all duration-300`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<AudioLevelMeter level={level} className="w-24 h-4" />
|
)}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Main pill bar */}
|
{/* Webcam selector */}
|
||||||
<div className="flex items-center gap-1.5 px-2 py-1.5 isolate rounded-full shadow-hud-bar bg-gradient-to-br from-[rgba(28,28,36,0.97)] to-[rgba(18,18,26,0.96)] backdrop-blur-[16px] backdrop-saturate-[140%] border border-[rgba(80,80,120,0.25)]">
|
{showWebcamControls && (
|
||||||
{/* Drag handle */}
|
<div
|
||||||
<div className={`flex items-center px-1 ${styles.electronDrag}`}>
|
className={`flex items-center gap-2 px-3 py-1.5 h-[36px] bg-gradient-to-br from-[rgba(28,28,36,0.97)] to-[rgba(18,18,26,0.96)] backdrop-blur-[24px] border border-white/10 rounded-xl shadow-2xl transition-all duration-300 overflow-hidden ${!webcamExpanded ? "opacity-60 grayscale-[0.5]" : "opacity-100"}`}
|
||||||
{getIcon("drag", "text-white/30")}
|
onMouseEnter={() => setIsWebcamHovered(true)}
|
||||||
</div>
|
onMouseLeave={() => setIsWebcamHovered(false)}
|
||||||
|
onFocus={() => setIsWebcamFocused(true)}
|
||||||
|
onBlur={() => setIsWebcamFocused(false)}
|
||||||
|
style={{ width: webcamExpanded ? "240px" : "140px", transition: "width 300ms ease" }}
|
||||||
|
>
|
||||||
|
<div className="relative flex-1 min-w-0">
|
||||||
|
{!webcamExpanded && (
|
||||||
|
<div className="text-white/60 text-[10px] font-medium truncate">
|
||||||
|
{selectedCameraLabel}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{webcamExpanded &&
|
||||||
|
(isCameraDevicesLoading ? (
|
||||||
|
<span className="text-white/40 text-[10px] italic">
|
||||||
|
{t("webcam.searching")}
|
||||||
|
</span>
|
||||||
|
) : cameraDevicesError ? (
|
||||||
|
<span className="text-white/40 text-[10px] italic">
|
||||||
|
{t("webcam.unavailable")}
|
||||||
|
</span>
|
||||||
|
) : cameraDevices.length === 0 ? (
|
||||||
|
<span className="text-white/40 text-[10px] italic">
|
||||||
|
{t("webcam.noneFound")}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<select
|
||||||
|
value={webcamDeviceId || selectedCameraId}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSelectedCameraId(e.target.value);
|
||||||
|
setWebcamDeviceId(e.target.value);
|
||||||
|
}}
|
||||||
|
className="w-full appearance-none bg-white/5 text-white text-[11px] rounded-lg pl-2 pr-6 py-1 border border-white/10 outline-none hover:bg-white/10 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
{cameraDevices.map((device) => (
|
||||||
|
<option
|
||||||
|
key={device.deviceId}
|
||||||
|
value={device.deviceId}
|
||||||
|
className="bg-[#1c1c24]"
|
||||||
|
>
|
||||||
|
{device.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<ChevronDown
|
||||||
|
size={12}
|
||||||
|
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-white/40 pointer-events-none"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
{(!webcamExpanded || cameraDevices.length === 0) && (
|
||||||
|
<select
|
||||||
|
value={webcamDeviceId || selectedCameraId}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSelectedCameraId(e.target.value);
|
||||||
|
setWebcamDeviceId(e.target.value);
|
||||||
|
}}
|
||||||
|
className="sr-only"
|
||||||
|
>
|
||||||
|
{cameraDevices.map((device) => (
|
||||||
|
<option key={device.deviceId} value={device.deviceId}>
|
||||||
|
{device.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Source selector */}
|
{/* HUD bar — fixed at bottom center, viewport-relative, never moves */}
|
||||||
|
<div
|
||||||
|
className={`fixed bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-1.5 px-2 py-1.5 rounded-full shadow-hud-bar bg-gradient-to-br from-[rgba(28,28,36,0.97)] to-[rgba(18,18,26,0.96)] backdrop-blur-[16px] backdrop-saturate-[140%] border border-[rgba(80,80,120,0.25)]`}
|
||||||
|
>
|
||||||
|
{/* Drag handle */}
|
||||||
|
<div className={`flex items-center px-1 ${styles.electronDrag}`}>
|
||||||
|
{getIcon("drag", "text-white/30")}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Source selector */}
|
||||||
|
<button
|
||||||
|
className={`${hudGroupClasses} p-2 ${styles.electronNoDrag}`}
|
||||||
|
onClick={openSourceSelector}
|
||||||
|
disabled={recording}
|
||||||
|
title={selectedSource}
|
||||||
|
>
|
||||||
|
{getIcon("monitor", "text-white/80")}
|
||||||
|
<span className="text-white/70 text-[11px] max-w-[72px] truncate">{selectedSource}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Audio controls group */}
|
||||||
|
<div className={`${hudGroupClasses} ${styles.electronNoDrag}`}>
|
||||||
<button
|
<button
|
||||||
className={`${hudGroupClasses} p-2 ${styles.electronNoDrag}`}
|
className={`${hudIconBtnClasses} ${systemAudioEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
|
||||||
onClick={openSourceSelector}
|
onClick={() => !recording && setSystemAudioEnabled(!systemAudioEnabled)}
|
||||||
disabled={recording}
|
disabled={recording}
|
||||||
title={selectedSource}
|
title={
|
||||||
|
systemAudioEnabled ? t("audio.disableSystemAudio") : t("audio.enableSystemAudio")
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{getIcon("monitor", "text-white/80")}
|
{systemAudioEnabled
|
||||||
<span className="text-white/70 text-[11px] max-w-[72px] truncate">
|
? getIcon("volumeOn", "text-green-400")
|
||||||
{selectedSource}
|
: getIcon("volumeOff", "text-white/40")}
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Audio controls group */}
|
|
||||||
<div className={`${hudGroupClasses} ${styles.electronNoDrag}`}>
|
|
||||||
<button
|
|
||||||
className={`${hudIconBtnClasses} ${systemAudioEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
|
|
||||||
onClick={() => !recording && setSystemAudioEnabled(!systemAudioEnabled)}
|
|
||||||
disabled={recording}
|
|
||||||
title={systemAudioEnabled ? "Disable system audio" : "Enable system audio"}
|
|
||||||
>
|
|
||||||
{systemAudioEnabled
|
|
||||||
? getIcon("volumeOn", "text-green-400")
|
|
||||||
: getIcon("volumeOff", "text-white/40")}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`${hudIconBtnClasses} ${microphoneEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
|
|
||||||
onClick={toggleMicrophone}
|
|
||||||
disabled={recording}
|
|
||||||
title={microphoneEnabled ? "Disable microphone" : "Enable microphone"}
|
|
||||||
>
|
|
||||||
{microphoneEnabled
|
|
||||||
? getIcon("micOn", "text-green-400")
|
|
||||||
: getIcon("micOff", "text-white/40")}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`${hudIconBtnClasses} ${webcamEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
|
|
||||||
onClick={() => {
|
|
||||||
void setWebcamEnabled(!webcamEnabled);
|
|
||||||
}}
|
|
||||||
title={webcamEnabled ? "Disable webcam" : "Enable webcam"}
|
|
||||||
>
|
|
||||||
{webcamEnabled
|
|
||||||
? getIcon("webcamOn", "text-green-400")
|
|
||||||
: getIcon("webcamOff", "text-white/40")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Record/Stop group */}
|
|
||||||
<button
|
<button
|
||||||
className={`flex items-center gap-0.5 rounded-full p-2 transition-colors duration-150 ${styles.electronNoDrag} ${
|
className={`${hudIconBtnClasses} ${microphoneEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
|
||||||
recording ? "animate-record-pulse bg-red-500/10" : "bg-white/5 hover:bg-white/[0.08]"
|
onClick={toggleMicrophone}
|
||||||
}`}
|
disabled={recording}
|
||||||
onClick={hasSelectedSource ? toggleRecording : openSourceSelector}
|
title={microphoneEnabled ? t("audio.disableMicrophone") : t("audio.enableMicrophone")}
|
||||||
disabled={!hasSelectedSource && !recording}
|
|
||||||
style={{ flex: "0 0 auto" }}
|
|
||||||
>
|
>
|
||||||
{recording ? (
|
{microphoneEnabled
|
||||||
<>
|
? getIcon("micOn", "text-green-400")
|
||||||
{getIcon("stop", "text-red-400")}
|
: getIcon("micOff", "text-white/40")}
|
||||||
<span className="text-red-400 text-xs font-semibold tabular-nums">
|
|
||||||
{formatTimePadded(elapsed)}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
getIcon("record", hasSelectedSource ? "text-white/80" : "text-white/30")
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className={`${hudIconBtnClasses} ${webcamEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
|
||||||
|
onClick={async () => {
|
||||||
|
await setWebcamEnabled(!webcamEnabled);
|
||||||
|
}}
|
||||||
|
disabled={recording}
|
||||||
|
title={webcamEnabled ? t("webcam.disableWebcam") : t("webcam.enableWebcam")}
|
||||||
|
>
|
||||||
|
{webcamEnabled
|
||||||
|
? getIcon("webcamOn", "text-green-400")
|
||||||
|
: getIcon("webcamOff", "text-white/40")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Restart recording */}
|
{/* Record/Stop group */}
|
||||||
{recording && (
|
<button
|
||||||
<Tooltip content="Restart recording">
|
className={`flex items-center justify-center rounded-full p-2 transition-[min-width,background-color] duration-150 ${recording ? "min-w-[78px]" : "min-w-[36px]"} ${styles.electronNoDrag} ${
|
||||||
<button
|
recording
|
||||||
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
? paused
|
||||||
onClick={restartRecording}
|
? "bg-amber-500/10 hover:bg-amber-500/15"
|
||||||
|
: "bg-red-500/12 hover:bg-red-500/16"
|
||||||
|
: "bg-white/5 hover:bg-white/[0.08]"
|
||||||
|
}`}
|
||||||
|
onClick={toggleRecording}
|
||||||
|
disabled={!hasSelectedSource && !recording}
|
||||||
|
style={{ flex: "0 0 auto" }}
|
||||||
|
>
|
||||||
|
<div className={`flex items-center justify-center ${recording ? "gap-1.5" : ""}`}>
|
||||||
|
{recording
|
||||||
|
? getIcon("stop", paused ? "text-amber-400" : "text-red-400")
|
||||||
|
: getIcon("record", hasSelectedSource ? "text-white/80" : "text-white/30")}
|
||||||
|
{recording && (
|
||||||
|
<span
|
||||||
|
className={`${paused ? "text-amber-400" : "text-red-400"} inline-block w-[34px] text-left text-xs font-semibold tabular-nums`}
|
||||||
>
|
>
|
||||||
|
{formatTimePadded(elapsedSeconds)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{recording && (
|
||||||
|
<div className={`flex items-center gap-0.5 ${styles.electronNoDrag}`}>
|
||||||
|
<Tooltip
|
||||||
|
content={paused ? t("tooltips.resumeRecording") : t("tooltips.pauseRecording")}
|
||||||
|
>
|
||||||
|
<button className={hudAuxIconBtnClasses} onClick={togglePaused}>
|
||||||
|
{getIcon(paused ? "resume" : "pause", paused ? "text-amber-400" : "text-white/60")}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip content={t("tooltips.restartRecording")}>
|
||||||
|
<button className={hudAuxIconBtnClasses} onClick={restartRecording}>
|
||||||
{getIcon("restart", "text-white/60")}
|
{getIcon("restart", "text-white/60")}
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
<Tooltip content={t("tooltips.cancelRecording")}>
|
||||||
|
<button className={hudAuxIconBtnClasses} onClick={cancelRecording}>
|
||||||
|
{getIcon("cancel", "text-white/60")}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Open video file */}
|
{!recording && (
|
||||||
<Tooltip content="Open video file">
|
<>
|
||||||
<button
|
{/* Open video file */}
|
||||||
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
<Tooltip content={t("tooltips.openVideoFile")}>
|
||||||
onClick={openVideoFile}
|
<button
|
||||||
disabled={recording}
|
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
||||||
>
|
onClick={openVideoFile}
|
||||||
{getIcon("videoFile", "text-white/60")}
|
>
|
||||||
</button>
|
{getIcon("videoFile", "text-white/60")}
|
||||||
</Tooltip>
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
{/* Open project */}
|
{/* Open project */}
|
||||||
<Tooltip content="Open project">
|
<Tooltip content={t("tooltips.openProject")}>
|
||||||
|
<button
|
||||||
|
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
||||||
|
onClick={openProjectFile}
|
||||||
|
>
|
||||||
|
{getIcon("folder", "text-white/60")}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Right sidebar controls */}
|
||||||
|
<div className={`${hudSidebarClasses} ${styles.electronNoDrag}`}>
|
||||||
|
<div className={`${styles.languageMenuContainer} ${styles.electronNoDrag}`}>
|
||||||
<button
|
<button
|
||||||
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
ref={languageTriggerRef}
|
||||||
onClick={openProjectFile}
|
type="button"
|
||||||
disabled={recording}
|
aria-label={t("language")}
|
||||||
|
aria-expanded={isLanguageMenuOpen}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
onClick={() => setIsLanguageMenuOpen((open) => !open)}
|
||||||
|
className={`h-8 w-8 rounded-lg border border-white/10 bg-white/5 text-white/85 shadow-none transition-colors hover:bg-white/10 ${styles.electronNoDrag}`}
|
||||||
>
|
>
|
||||||
{getIcon("folder", "text-white/60")}
|
<div className="flex w-full items-center justify-center">
|
||||||
|
<Languages size={13} className="text-white/75" />
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</div>
|
||||||
|
|
||||||
|
{isLanguageMenuOpen
|
||||||
|
? createPortal(
|
||||||
|
<div
|
||||||
|
ref={languageMenuPanelRef}
|
||||||
|
role="menu"
|
||||||
|
className={`${styles.languageMenuPanel} ${styles.languageMenuScroll} ${styles.electronNoDrag}`}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
WebkitAppRegion: "no-drag",
|
||||||
|
pointerEvents: "auto",
|
||||||
|
right: `${languageMenuStyle.right}px`,
|
||||||
|
top: `${languageMenuStyle.top}px`,
|
||||||
|
maxHeight: `${languageMenuStyle.maxHeight}px`,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
{availableLocales.map((loc) => (
|
||||||
|
<button
|
||||||
|
key={loc}
|
||||||
|
type="button"
|
||||||
|
role="menuitemradio"
|
||||||
|
aria-checked={loc === locale}
|
||||||
|
onClick={() => {
|
||||||
|
setLocale(loc);
|
||||||
|
resolveSystemLocaleSuggestion();
|
||||||
|
setIsLanguageMenuOpen(false);
|
||||||
|
}}
|
||||||
|
className={`${styles.languageMenuItem} ${loc === locale ? styles.languageMenuItemActive : ""}`}
|
||||||
|
>
|
||||||
|
<span className="truncate">{getLocaleName(loc)}</span>
|
||||||
|
{loc === locale ? <Check size={11} className="text-white/85" /> : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
|
||||||
{/* Window controls */}
|
{/* Window controls */}
|
||||||
<div className={`flex items-center gap-0.5 ${styles.electronNoDrag}`}>
|
<div className="flex items-center gap-0.5">
|
||||||
<button className={windowBtnClasses} title="Hide HUD" onClick={sendHudOverlayHide}>
|
<button
|
||||||
|
className={windowBtnClasses}
|
||||||
|
title={t("tooltips.hideHUD")}
|
||||||
|
onClick={sendHudOverlayHide}
|
||||||
|
>
|
||||||
{getIcon("minimize", "text-white")}
|
{getIcon("minimize", "text-white")}
|
||||||
</button>
|
</button>
|
||||||
<button className={windowBtnClasses} title="Close App" onClick={sendHudOverlayClose}>
|
<button
|
||||||
|
className={windowBtnClasses}
|
||||||
|
title={t("tooltips.closeApp")}
|
||||||
|
onClick={sendHudOverlayClose}
|
||||||
|
>
|
||||||
{getIcon("close", "text-white")}
|
{getIcon("close", "text-white")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,15 +2,21 @@
|
|||||||
background: linear-gradient(135deg, rgba(28, 28, 34, 0.92) 0%, rgba(18, 18, 22, 0.88) 100%);
|
background: linear-gradient(135deg, rgba(28, 28, 34, 0.92) 0%, rgba(18, 18, 22, 0.88) 100%);
|
||||||
backdrop-filter: blur(20px) saturate(160%);
|
backdrop-filter: blur(20px) saturate(160%);
|
||||||
-webkit-backdrop-filter: blur(20px) saturate(160%);
|
-webkit-backdrop-filter: blur(20px) saturate(160%);
|
||||||
border-radius: 14px;
|
border-radius: 30px;
|
||||||
box-shadow:
|
corner-shape: squircle;
|
||||||
0 4px 16px 0 rgba(0, 0, 0, 0.32),
|
/*
|
||||||
0 1px 3px 0 rgba(0, 0, 0, 0.18) inset;
|
Removed box-shadow here because electron doesn't round corners of the shadow, thereby leaving a square border shadow conflicting with the rounded corners of the SourceSelector.
|
||||||
border: 1px solid rgba(60, 60, 80, 0.18);
|
The result is easily visible when you place a white window just behind the SourceSelector
|
||||||
|
*/
|
||||||
|
/* box-shadow:
|
||||||
|
0 0px 16px 0 rgba(0, 0, 0, 0.32),
|
||||||
|
0 1px 3px 0 rgba(0, 0, 0, 0.18) inset; */
|
||||||
|
border: 1.5px solid rgba(60, 60, 80, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sourceCard {
|
.sourceCard {
|
||||||
border-radius: 12px;
|
corner-shape: squircle;
|
||||||
|
border-radius: 20px;
|
||||||
background: linear-gradient(120deg, rgba(38, 38, 48, 0.98) 0%, rgba(24, 24, 32, 0.96) 100%);
|
background: linear-gradient(120deg, rgba(38, 38, 48, 0.98) 0%, rgba(24, 24, 32, 0.96) 100%);
|
||||||
border: 1px solid rgba(60, 60, 80, 0.22);
|
border: 1px solid rgba(60, 60, 80, 0.22);
|
||||||
box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.18);
|
box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.18);
|
||||||
@@ -28,7 +34,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.selected {
|
.selected {
|
||||||
border: 2px solid #34b27b;
|
border: 1.5px solid #34b27b;
|
||||||
background: linear-gradient(120deg, rgba(52, 178, 123, 0.08) 0%, rgba(38, 38, 48, 0.98) 100%);
|
background: linear-gradient(120deg, rgba(52, 178, 123, 0.08) 0%, rgba(38, 38, 48, 0.98) 100%);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 12px rgba(52, 178, 123, 0.15),
|
0 0 12px rgba(52, 178, 123, 0.15),
|
||||||
@@ -70,30 +76,27 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* scrollbar */
|
/* scrollbar */
|
||||||
.sourceGridScroll {
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: rgba(52, 178, 123, 0.5) rgba(40, 40, 50, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sourceGridScroll::-webkit-scrollbar {
|
.sourceGridScroll::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sourceGridScroll::-webkit-scrollbar-track {
|
.sourceGridScroll::-webkit-scrollbar-track {
|
||||||
background: rgba(30, 30, 38, 0.5);
|
background: rgba(30, 30, 38, 0.3);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
margin: 4px 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sourceGridScroll::-webkit-scrollbar-thumb {
|
.sourceGridScroll::-webkit-scrollbar-thumb {
|
||||||
background: rgba(80, 80, 100, 0.6);
|
background: rgba(52, 178, 123, 0.5);
|
||||||
border-radius: 4px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sourceGridScroll::-webkit-scrollbar-thumb:hover {
|
.sourceGridScroll::-webkit-scrollbar-thumb:hover {
|
||||||
background: rgba(52, 178, 123, 0.6);
|
background: rgba(52, 178, 123, 0.6);
|
||||||
|
cursor: grab;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sourceGridScroll::-webkit-scrollbar-thumb:active {
|
.sourceGridScroll::-webkit-scrollbar-thumb:active {
|
||||||
background: rgba(52, 178, 123, 0.8);
|
background: rgba(52, 178, 123, 0.8);
|
||||||
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { MdCheck } from "react-icons/md";
|
import { MdCheck } from "react-icons/md";
|
||||||
|
import { useScopedT } from "@/contexts/I18nContext";
|
||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
|
||||||
import styles from "./SourceSelector.module.css";
|
import styles from "./SourceSelector.module.css";
|
||||||
@@ -13,6 +14,8 @@ interface DesktopSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SourceSelector() {
|
export function SourceSelector() {
|
||||||
|
const t = useScopedT("launch");
|
||||||
|
const tc = useScopedT("common");
|
||||||
const [sources, setSources] = useState<DesktopSource[]>([]);
|
const [sources, setSources] = useState<DesktopSource[]>([]);
|
||||||
const [selectedSource, setSelectedSource] = useState<DesktopSource | null>(null);
|
const [selectedSource, setSelectedSource] = useState<DesktopSource | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -62,8 +65,8 @@ export function SourceSelector() {
|
|||||||
style={{ minHeight: "100vh" }}
|
style={{ minHeight: "100vh" }}
|
||||||
>
|
>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-[#34B27B] mx-auto mb-2" />
|
<div className="animate-spin duration-500 rounded-[50%] h-6 w-6 border-2 border-b-transparent border-[#34B27B] mx-auto mb-2" />
|
||||||
<p className="text-xs text-zinc-400">Loading sources...</p>
|
<p className="text-xs text-zinc-400">{t("sourceSelector.loading")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -81,10 +84,10 @@ export function SourceSelector() {
|
|||||||
<img
|
<img
|
||||||
src={source.thumbnail || ""}
|
src={source.thumbnail || ""}
|
||||||
alt={source.name}
|
alt={source.name}
|
||||||
className="w-full aspect-video object-cover rounded-lg"
|
className="w-full aspect-video object-cover rounded-xl [corner-shape:squircle] "
|
||||||
/>
|
/>
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<div className="absolute -top-1.5 -right-1.5">
|
<div className="absolute -top-1 -right-1">
|
||||||
<div className={styles.checkBadge}>
|
<div className={styles.checkBadge}>
|
||||||
<MdCheck size={12} className="text-white" />
|
<MdCheck size={12} className="text-white" />
|
||||||
</div>
|
</div>
|
||||||
@@ -108,31 +111,31 @@ export function SourceSelector() {
|
|||||||
defaultValue={screenSources.length === 0 ? "windows" : "screens"}
|
defaultValue={screenSources.length === 0 ? "windows" : "screens"}
|
||||||
className="flex-1 flex flex-col"
|
className="flex-1 flex flex-col"
|
||||||
>
|
>
|
||||||
<TabsList className="grid grid-cols-2 mb-3 bg-white/5 rounded-full">
|
<TabsList className="grid grid-cols-2 mb-3 bg-white/5 rounded-[14px] squircle ">
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="screens"
|
value="screens"
|
||||||
className="data-[state=active]:bg-white/15 data-[state=active]:text-white text-zinc-400 rounded-full text-xs py-1 transition-all"
|
className="data-[state=active]:bg-white/15 data-[state=active]:text-white text-zinc-400 rounded-[12px] squircle text-xs py-1.5 transition-all"
|
||||||
>
|
>
|
||||||
Screens ({screenSources.length})
|
{t("sourceSelector.screens", { count: String(screenSources.length) })}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="windows"
|
value="windows"
|
||||||
className="data-[state=active]:bg-white/15 data-[state=active]:text-white text-zinc-400 rounded-full text-xs py-1 transition-all"
|
className="data-[state=active]:bg-white/15 data-[state=active]:text-white text-zinc-400 rounded-[12px] squircle text-xs py-1.5 transition-all"
|
||||||
>
|
>
|
||||||
Windows ({windowSources.length})
|
{t("sourceSelector.windows", { count: String(windowSources.length) })}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<div className="flex-1 min-h-0">
|
<div className="flex-1 min-h-0">
|
||||||
<TabsContent value="screens" className="h-full mt-0">
|
<TabsContent value="screens" className="h-full mt-0">
|
||||||
<div
|
<div
|
||||||
className={`grid grid-cols-2 gap-3 h-[280px] overflow-y-auto pr-1 auto-rows-min ${styles.sourceGridScroll}`}
|
className={`grid grid-cols-2 gap-3 h-[280px] overflow-y-auto pt-1 pr-1.5 auto-rows-min ${styles.sourceGridScroll}`}
|
||||||
>
|
>
|
||||||
{screenSources.map(renderSourceCard)}
|
{screenSources.map(renderSourceCard)}
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="windows" className="h-full mt-0">
|
<TabsContent value="windows" className="h-full mt-0">
|
||||||
<div
|
<div
|
||||||
className={`grid grid-cols-2 gap-3 h-[280px] overflow-y-auto pr-1 auto-rows-min ${styles.sourceGridScroll}`}
|
className={`grid grid-cols-2 gap-3 h-[280px] overflow-y-auto pt-1 pr-1.5 auto-rows-min ${styles.sourceGridScroll}`}
|
||||||
>
|
>
|
||||||
{windowSources.map(renderSourceCard)}
|
{windowSources.map(renderSourceCard)}
|
||||||
</div>
|
</div>
|
||||||
@@ -140,20 +143,20 @@ export function SourceSelector() {
|
|||||||
</div>
|
</div>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 flex justify-center gap-2">
|
<div className="p-3 justify-center flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => window.close()}
|
onClick={() => window.close()}
|
||||||
className="px-5 py-1 text-xs text-zinc-400 hover:text-white hover:bg-white/5 rounded-full"
|
className="px-5 py-1 text-xs text-zinc-400 hover:text-white active:scale-95 transition-transform duration-150 hover:bg-white/5 rounded-full"
|
||||||
>
|
>
|
||||||
Cancel
|
{tc("actions.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleShare}
|
onClick={handleShare}
|
||||||
disabled={!selectedSource}
|
disabled={!selectedSource}
|
||||||
className="px-5 py-1 text-xs bg-[#34B27B] text-white hover:bg-[#34B27B]/80 disabled:opacity-30 disabled:bg-zinc-700 rounded-full"
|
className="px-5 py-1 text-xs bg-[#34B27B] text-white active:scale-95 transition-transform duration-150 hover:bg-[#34B27B]/80 disabled:opacity-30 disabled:bg-zinc-700 rounded-full"
|
||||||
>
|
>
|
||||||
Share
|
{tc("actions.share")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -52,4 +52,4 @@ const AccordionContent = React.forwardRef<
|
|||||||
));
|
));
|
||||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
|
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
|
||||||
|
|
||||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };
|
||||||
|
|||||||
@@ -52,4 +52,4 @@ const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDiv
|
|||||||
);
|
);
|
||||||
CardFooter.displayName = "CardFooter";
|
CardFooter.displayName = "CardFooter";
|
||||||
|
|
||||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { HsvaColor, hexToHsva } from "@uiw/color-convert";
|
||||||
|
import Block from "@uiw/react-color-block";
|
||||||
|
import Colorful from "@uiw/react-color-colorful";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Button } from "./button";
|
||||||
|
import { Input } from "./input";
|
||||||
|
|
||||||
|
type BaseProps = {
|
||||||
|
selectedColor: string;
|
||||||
|
colorPalette: string[];
|
||||||
|
onUpdateColor: (color: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ColorPickerProps =
|
||||||
|
| (BaseProps & {
|
||||||
|
clearBackgroundOption?: false;
|
||||||
|
translations: Record<"colorWheel" | "colorPalette", string>;
|
||||||
|
})
|
||||||
|
| (BaseProps & {
|
||||||
|
clearBackgroundOption: true;
|
||||||
|
translations: Record<"colorWheel" | "colorPalette" | "clearBackground", string>;
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function ColorPicker(props: ColorPickerProps) {
|
||||||
|
const { selectedColor, colorPalette, translations, onUpdateColor } = props;
|
||||||
|
const [colorMode, setColorMode] = useState<"wheel" | "palette">("wheel");
|
||||||
|
const [hexInput, setHexInput] = useState(selectedColor);
|
||||||
|
const [transparentColorHSVA, setTransparentColorHSVA] = useState<HsvaColor>({
|
||||||
|
h: 0,
|
||||||
|
s: 0,
|
||||||
|
v: 0,
|
||||||
|
a: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setHexInput(selectedColor);
|
||||||
|
}, [selectedColor]);
|
||||||
|
|
||||||
|
const getTextColor = (color: string) => {
|
||||||
|
if (color === "transparent") return "#ffffff";
|
||||||
|
const r = parseInt(color.slice(1, 3), 16);
|
||||||
|
const g = parseInt(color.slice(3, 5), 16);
|
||||||
|
const b = parseInt(color.slice(5, 7), 16);
|
||||||
|
const luminance = 0.299 * r + 0.587 * g + 0.114 * b;
|
||||||
|
if (luminance > 186) return "#000000";
|
||||||
|
return "#ffffff";
|
||||||
|
};
|
||||||
|
|
||||||
|
// Normalize the hex input.
|
||||||
|
// Adds a # at the beginning of the input if it's not there.
|
||||||
|
const normalizeHexDraft = (raw: string) => {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (trimmed === "") return "";
|
||||||
|
if (/^[0-9A-Fa-f]/.test(trimmed[0])) return `#${trimmed}`;
|
||||||
|
return trimmed;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleColorInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const normalized = normalizeHexDraft(e.target.value);
|
||||||
|
setHexInput(normalized);
|
||||||
|
// Check if the normalized hex is a valid hex color.
|
||||||
|
// It should follow the format #RRGGBB or #RGB.
|
||||||
|
const isValidHexColor =
|
||||||
|
/^#[0-9A-Fa-f]{3}$/.test(normalized) || /^#[0-9A-Fa-f]{6}$/.test(normalized);
|
||||||
|
if (isValidHexColor) {
|
||||||
|
onUpdateColor(normalized);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toTransparent = (color: string) => {
|
||||||
|
if (color === "transparent") return;
|
||||||
|
const hsva = hexToHsva(color);
|
||||||
|
hsva.a = 0;
|
||||||
|
return hsva;
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="p-1 flex flex-col gap-4 items-center">
|
||||||
|
<div className="flex items-center gap-2 w-full">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full h-9 justify-start gap-2 bg-white/5 border-white/10 hover:bg-white/10 px-2"
|
||||||
|
onClick={() => setColorMode("wheel")}
|
||||||
|
style={{
|
||||||
|
backgroundColor: colorMode === "wheel" ? "#34B27B" : "transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-xs text-slate-300 truncate flex-1 text-left">
|
||||||
|
{translations.colorWheel}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full h-9 justify-start gap-2 bg-white/5 border-white/10 hover:bg-white/10 px-2"
|
||||||
|
onClick={() => setColorMode("palette")}
|
||||||
|
style={{
|
||||||
|
backgroundColor: colorMode === "palette" ? "#34B27B" : "transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-xs text-slate-300 truncate flex-1 text-left">
|
||||||
|
{translations.colorPalette}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{colorMode === "wheel" && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={`w-full h-20 flex items-center justify-center border border-white/10 rounded-lg`}
|
||||||
|
style={{ backgroundColor: selectedColor }}
|
||||||
|
>
|
||||||
|
<span style={{ color: getTextColor(selectedColor) }}>{selectedColor}</span>
|
||||||
|
</div>
|
||||||
|
<Colorful
|
||||||
|
color={selectedColor !== "transparent" ? selectedColor : transparentColorHSVA}
|
||||||
|
onChange={(color) => {
|
||||||
|
onUpdateColor(color.hex);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
borderRadius: "8px",
|
||||||
|
}}
|
||||||
|
disableAlpha={true}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={hexInput}
|
||||||
|
className="w-full h-9 rounded-md border border-white/10 bg-white/5 px-2 text-xs text-slate-200 outline-none focus:border-[#34B27B]/50 focus:ring-1 focus:ring-[#34B27B]/30 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||||
|
onChange={handleColorInputChange}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{colorMode === "palette" && (
|
||||||
|
<Block
|
||||||
|
color={selectedColor !== "transparent" ? selectedColor : transparentColorHSVA}
|
||||||
|
colors={colorPalette}
|
||||||
|
onChange={(color) => {
|
||||||
|
onUpdateColor(color.hex);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
borderRadius: "8px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{props.clearBackgroundOption === true && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="w-full mt-2 text-xs h-7 hover:bg-white/5 text-slate-400"
|
||||||
|
onClick={() => {
|
||||||
|
const hsva = toTransparent(selectedColor);
|
||||||
|
if (hsva) setTransparentColorHSVA(hsva);
|
||||||
|
onUpdateColor("transparent");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.translations.clearBackground}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -90,13 +90,13 @@ DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogPortal,
|
|
||||||
DialogOverlay,
|
|
||||||
DialogTrigger,
|
|
||||||
DialogClose,
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogHeader,
|
|
||||||
DialogFooter,
|
|
||||||
DialogTitle,
|
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -54,9 +54,11 @@ DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayNam
|
|||||||
|
|
||||||
const DropdownMenuContent = React.forwardRef<
|
const DropdownMenuContent = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> & {
|
||||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
portalled?: boolean;
|
||||||
<DropdownMenuPrimitive.Portal>
|
}
|
||||||
|
>(({ className, sideOffset = 4, portalled = true, ...props }, ref) => {
|
||||||
|
const content = (
|
||||||
<DropdownMenuPrimitive.Content
|
<DropdownMenuPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
@@ -67,8 +69,14 @@ const DropdownMenuContent = React.forwardRef<
|
|||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</DropdownMenuPrimitive.Portal>
|
);
|
||||||
));
|
|
||||||
|
if (!portalled) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <DropdownMenuPrimitive.Portal>{content}</DropdownMenuPrimitive.Portal>;
|
||||||
|
});
|
||||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||||
|
|
||||||
const DropdownMenuItem = React.forwardRef<
|
const DropdownMenuItem = React.forwardRef<
|
||||||
@@ -169,18 +177,18 @@ DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuTrigger,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuCheckboxItem,
|
DropdownMenuCheckboxItem,
|
||||||
DropdownMenuRadioItem,
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuItem,
|
||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuShortcut,
|
DropdownMenuShortcut,
|
||||||
DropdownMenuGroup,
|
|
||||||
DropdownMenuPortal,
|
|
||||||
DropdownMenuSub,
|
DropdownMenuSub,
|
||||||
DropdownMenuSubContent,
|
DropdownMenuSubContent,
|
||||||
DropdownMenuSubTrigger,
|
DropdownMenuSubTrigger,
|
||||||
DropdownMenuRadioGroup,
|
DropdownMenuTrigger,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -57,4 +57,4 @@ function PopoverArrow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor, PopoverArrow };
|
export { Popover, PopoverAnchor, PopoverArrow, PopoverContent, PopoverTrigger };
|
||||||
|
|||||||
@@ -62,34 +62,50 @@ SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayNam
|
|||||||
|
|
||||||
const SelectContent = React.forwardRef<
|
const SelectContent = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> & {
|
||||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
showScrollButtons?: boolean;
|
||||||
<SelectPrimitive.Portal>
|
viewportClassName?: string;
|
||||||
<SelectPrimitive.Content
|
}
|
||||||
ref={ref}
|
>(
|
||||||
className={cn(
|
(
|
||||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
{
|
||||||
position === "popper" &&
|
className,
|
||||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
children,
|
||||||
className,
|
position = "popper",
|
||||||
)}
|
showScrollButtons = true,
|
||||||
position={position}
|
viewportClassName,
|
||||||
{...props}
|
...props
|
||||||
>
|
},
|
||||||
<SelectScrollUpButton />
|
ref,
|
||||||
<SelectPrimitive.Viewport
|
) => (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-1",
|
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
position === "popper" &&
|
position === "popper" &&
|
||||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1",
|
||||||
|
className,
|
||||||
)}
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{showScrollButtons ? <SelectScrollUpButton /> : null}
|
||||||
</SelectPrimitive.Viewport>
|
<SelectPrimitive.Viewport
|
||||||
<SelectScrollDownButton />
|
className={cn(
|
||||||
</SelectPrimitive.Content>
|
"p-1",
|
||||||
</SelectPrimitive.Portal>
|
position === "popper" &&
|
||||||
));
|
"max-h-[var(--radix-select-content-available-height)] w-full min-w-[var(--radix-select-trigger-width)]",
|
||||||
|
viewportClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
{showScrollButtons ? <SelectScrollDownButton /> : null}
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
),
|
||||||
|
);
|
||||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||||
|
|
||||||
const SelectLabel = React.forwardRef<
|
const SelectLabel = React.forwardRef<
|
||||||
@@ -141,13 +157,13 @@ SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
Select,
|
Select,
|
||||||
SelectGroup,
|
|
||||||
SelectValue,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectLabel,
|
SelectGroup,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectSeparator,
|
SelectLabel,
|
||||||
SelectScrollUpButton,
|
|
||||||
SelectScrollDownButton,
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -50,4 +50,4 @@ const TabsContent = React.forwardRef<
|
|||||||
));
|
));
|
||||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||||
|
|
||||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
export { Tabs, TabsContent, TabsList, TabsTrigger };
|
||||||
|
|||||||
@@ -67,4 +67,4 @@ function Tooltip({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { TooltipProvider, TooltipRoot, TooltipTrigger, TooltipContent, Tooltip };
|
export { Tooltip, TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger };
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { useScopedT } from "@/contexts/I18nContext";
|
||||||
import {
|
import {
|
||||||
addCustomFont,
|
addCustomFont,
|
||||||
type CustomFont,
|
type CustomFont,
|
||||||
@@ -25,6 +26,8 @@ interface AddCustomFontDialogProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
|
export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
|
||||||
|
const t = useScopedT("settings");
|
||||||
|
const tc = useScopedT("common");
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [importUrl, setImportUrl] = useState("");
|
const [importUrl, setImportUrl] = useState("");
|
||||||
const [fontName, setFontName] = useState("");
|
const [fontName, setFontName] = useState("");
|
||||||
@@ -45,17 +48,17 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
|
|||||||
const handleAdd = async () => {
|
const handleAdd = async () => {
|
||||||
// Validate inputs
|
// Validate inputs
|
||||||
if (!importUrl.trim()) {
|
if (!importUrl.trim()) {
|
||||||
toast.error("Please enter a Google Fonts import URL");
|
toast.error(t("customFont.errorEmptyUrl"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isValidGoogleFontsUrl(importUrl)) {
|
if (!isValidGoogleFontsUrl(importUrl)) {
|
||||||
toast.error("Please enter a valid Google Fonts URL");
|
toast.error(t("customFont.errorInvalidUrl"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!fontName.trim()) {
|
if (!fontName.trim()) {
|
||||||
toast.error("Please enter a font name");
|
toast.error(t("customFont.errorEmptyName"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +68,7 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
|
|||||||
// Extract font family from URL
|
// Extract font family from URL
|
||||||
const fontFamily = parseFontFamilyFromImport(importUrl);
|
const fontFamily = parseFontFamilyFromImport(importUrl);
|
||||||
if (!fontFamily) {
|
if (!fontFamily) {
|
||||||
toast.error("Could not extract font family from URL");
|
toast.error(t("customFont.errorExtractFailed"));
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -86,7 +89,7 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
|
|||||||
onFontAdded(newFont);
|
onFontAdded(newFont);
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.success(`Font "${fontName}" added successfully`);
|
toast.success(t("customFont.successMessage", { fontName }));
|
||||||
|
|
||||||
// Reset and close
|
// Reset and close
|
||||||
setImportUrl("");
|
setImportUrl("");
|
||||||
@@ -95,10 +98,10 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to add custom font:", error);
|
console.error("Failed to add custom font:", error);
|
||||||
const errorMessage = error instanceof Error ? error.message : "Failed to load font";
|
const errorMessage = error instanceof Error ? error.message : "Failed to load font";
|
||||||
toast.error("Failed to add font", {
|
toast.error(t("customFont.failedToAdd"), {
|
||||||
description: errorMessage.includes("timeout")
|
description: errorMessage.includes("timeout")
|
||||||
? "Font took too long to load. Please check the URL and try again."
|
? t("customFont.errorTimeout")
|
||||||
: "The font could not be loaded. Please verify the Google Fonts URL is correct.",
|
: t("customFont.errorLoadFailed"),
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -114,12 +117,12 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
|
|||||||
className="w-full bg-white/5 border-white/10 text-slate-200 hover:bg-white/10 h-9 text-xs"
|
className="w-full bg-white/5 border-white/10 text-slate-200 hover:bg-white/10 h-9 text-xs"
|
||||||
>
|
>
|
||||||
<Plus className="w-3 h-3 mr-1" />
|
<Plus className="w-3 h-3 mr-1" />
|
||||||
Add Google Font
|
{t("customFont.dialogTitle")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="bg-[#1a1a1c] border-white/10 text-slate-200">
|
<DialogContent className="bg-[#1a1a1c] border-white/10 text-slate-200">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Add Google Font</DialogTitle>
|
<DialogTitle>{t("customFont.dialogTitle")}</DialogTitle>
|
||||||
<DialogDescription className="text-slate-400">
|
<DialogDescription className="text-slate-400">
|
||||||
Add a custom font from Google Fonts to use in your annotations.
|
Add a custom font from Google Fonts to use in your annotations.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
@@ -128,34 +131,30 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
|
|||||||
<div className="space-y-4 mt-4">
|
<div className="space-y-4 mt-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="import-url" className="text-slate-200">
|
<Label htmlFor="import-url" className="text-slate-200">
|
||||||
Google Fonts Import URL
|
{t("customFont.urlLabel")}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="import-url"
|
id="import-url"
|
||||||
placeholder="https://fonts.googleapis.com/css2?family=Roboto&display=swap"
|
placeholder={t("customFont.urlPlaceholder")}
|
||||||
value={importUrl}
|
value={importUrl}
|
||||||
onChange={(e) => handleImportUrlChange(e.target.value)}
|
onChange={(e) => handleImportUrlChange(e.target.value)}
|
||||||
className="bg-white/5 border-white/10 text-slate-200"
|
className="bg-white/5 border-white/10 text-slate-200"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-slate-400">
|
<p className="text-xs text-slate-400">{t("customFont.urlHelp")}</p>
|
||||||
Get this from Google Fonts: Select a font → Click "Get font" → Copy the @import URL
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="font-name" className="text-slate-200">
|
<Label htmlFor="font-name" className="text-slate-200">
|
||||||
Display Name
|
{t("customFont.nameLabel")}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="font-name"
|
id="font-name"
|
||||||
placeholder="My Custom Font"
|
placeholder={t("customFont.namePlaceholder")}
|
||||||
value={fontName}
|
value={fontName}
|
||||||
onChange={(e) => setFontName(e.target.value)}
|
onChange={(e) => setFontName(e.target.value)}
|
||||||
className="bg-white/5 border-white/10 text-slate-200"
|
className="bg-white/5 border-white/10 text-slate-200"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-slate-400">
|
<p className="text-xs text-slate-400">{t("customFont.nameHelp")}</p>
|
||||||
This is how the font will appear in the font selector
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 mt-6">
|
<div className="flex justify-end gap-2 mt-6">
|
||||||
@@ -164,14 +163,14 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
|
|||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
className="bg-white/5 border-white/10 text-slate-200 hover:bg-white/10"
|
className="bg-white/5 border-white/10 text-slate-200 hover:bg-white/10"
|
||||||
>
|
>
|
||||||
Cancel
|
{tc("actions.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleAdd}
|
onClick={handleAdd}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="bg-blue-600 hover:bg-blue-700 text-white"
|
className="bg-blue-600 hover:bg-blue-700 text-white"
|
||||||
>
|
>
|
||||||
{loading ? "Adding..." : "Add Font"}
|
{loading ? t("customFont.addingButton") : t("customFont.addButton")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,40 @@
|
|||||||
import { useRef } from "react";
|
import { type CSSProperties, type PointerEvent, useEffect, useRef, useState } from "react";
|
||||||
import { Rnd } from "react-rnd";
|
import { Rnd } from "react-rnd";
|
||||||
|
import {
|
||||||
|
getBlurOverlayColor,
|
||||||
|
getMosaicGridOverlayColor,
|
||||||
|
getNormalizedMosaicBlockSize,
|
||||||
|
} from "@/lib/blurEffects";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { getArrowComponent } from "./ArrowSvgs";
|
import { getArrowComponent } from "./ArrowSvgs";
|
||||||
import type { AnnotationRegion } from "./types";
|
import {
|
||||||
|
type AnnotationRegion,
|
||||||
|
type BlurData,
|
||||||
|
DEFAULT_BLUR_BLOCK_SIZE,
|
||||||
|
DEFAULT_BLUR_DATA,
|
||||||
|
DEFAULT_BLUR_INTENSITY,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
const FREEHAND_POINT_THRESHOLD = 1;
|
||||||
|
type PreviewCanvasSource = {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
clientWidth?: number;
|
||||||
|
clientHeight?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildBlurPolygonClipPath(points: Array<{ x: number; y: number }>) {
|
||||||
|
if (points.length < 3) return undefined;
|
||||||
|
const polygon = points.map((point) => `${point.x}% ${point.y}%`).join(", ");
|
||||||
|
return `polygon(${polygon})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBlurFreehandPath(points: Array<{ x: number; y: number }>, closed = true) {
|
||||||
|
if (closed ? points.length < 3 : points.length < 2) return null;
|
||||||
|
const [firstPoint, ...rest] = points;
|
||||||
|
const path = `M ${firstPoint.x} ${firstPoint.y} ${rest.map((point) => `L ${point.x} ${point.y}`).join(" ")}`;
|
||||||
|
return closed ? `${path} Z` : path;
|
||||||
|
}
|
||||||
|
|
||||||
interface AnnotationOverlayProps {
|
interface AnnotationOverlayProps {
|
||||||
annotation: AnnotationRegion;
|
annotation: AnnotationRegion;
|
||||||
@@ -11,9 +43,13 @@ interface AnnotationOverlayProps {
|
|||||||
containerHeight: number;
|
containerHeight: number;
|
||||||
onPositionChange: (id: string, position: { x: number; y: number }) => void;
|
onPositionChange: (id: string, position: { x: number; y: number }) => void;
|
||||||
onSizeChange: (id: string, size: { width: number; height: number }) => void;
|
onSizeChange: (id: string, size: { width: number; height: number }) => void;
|
||||||
|
onBlurDataChange?: (id: string, blurData: BlurData) => void;
|
||||||
|
onBlurDataCommit?: () => void;
|
||||||
onClick: (id: string) => void;
|
onClick: (id: string) => void;
|
||||||
zIndex: number;
|
zIndex: number;
|
||||||
isSelectedBoost: boolean; // Boost z-index when selected for easy editing
|
isSelectedBoost: boolean; // Boost z-index when selected for easy editing
|
||||||
|
previewSourceCanvas?: PreviewCanvasSource | null;
|
||||||
|
previewFrameVersion?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AnnotationOverlay({
|
export function AnnotationOverlay({
|
||||||
@@ -23,16 +59,130 @@ export function AnnotationOverlay({
|
|||||||
containerHeight,
|
containerHeight,
|
||||||
onPositionChange,
|
onPositionChange,
|
||||||
onSizeChange,
|
onSizeChange,
|
||||||
|
onBlurDataChange,
|
||||||
|
onBlurDataCommit,
|
||||||
onClick,
|
onClick,
|
||||||
zIndex,
|
zIndex,
|
||||||
isSelectedBoost,
|
isSelectedBoost,
|
||||||
|
previewSourceCanvas,
|
||||||
|
previewFrameVersion,
|
||||||
}: AnnotationOverlayProps) {
|
}: AnnotationOverlayProps) {
|
||||||
const x = (annotation.position.x / 100) * containerWidth;
|
const committedX = (annotation.position.x / 100) * containerWidth;
|
||||||
const y = (annotation.position.y / 100) * containerHeight;
|
const committedY = (annotation.position.y / 100) * containerHeight;
|
||||||
const width = (annotation.size.width / 100) * containerWidth;
|
const committedWidth = (annotation.size.width / 100) * containerWidth;
|
||||||
const height = (annotation.size.height / 100) * containerHeight;
|
const committedHeight = (annotation.size.height / 100) * containerHeight;
|
||||||
|
const blurShape = annotation.type === "blur" ? (annotation.blurData?.shape ?? "rectangle") : null;
|
||||||
|
const isSelectedFreehandBlur = isSelected && blurShape === "freehand";
|
||||||
const isDraggingRef = useRef(false);
|
const isDraggingRef = useRef(false);
|
||||||
|
const isDrawingFreehandRef = useRef(false);
|
||||||
|
const freehandPointsRef = useRef<Array<{ x: number; y: number }>>([]);
|
||||||
|
const [isFreehandDrawing, setIsFreehandDrawing] = useState(false);
|
||||||
|
const [draftFreehandPoints, setDraftFreehandPoints] = useState<Array<{ x: number; y: number }>>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const [livePointerPoint, setLivePointerPoint] = useState<{ x: number; y: number } | null>(null);
|
||||||
|
const mosaicCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
const blurType = annotation.type === "blur" ? (annotation.blurData?.type ?? "blur") : "blur";
|
||||||
|
const blurOverlayColor =
|
||||||
|
annotation.type === "blur" ? getBlurOverlayColor(annotation.blurData) : "";
|
||||||
|
const mosaicGridOverlayColor =
|
||||||
|
annotation.type === "blur" ? getMosaicGridOverlayColor(annotation.blurData) : "";
|
||||||
|
const [liveRect, setLiveRect] = useState({
|
||||||
|
x: committedX,
|
||||||
|
y: committedY,
|
||||||
|
width: committedWidth,
|
||||||
|
height: committedHeight,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLiveRect({
|
||||||
|
x: committedX,
|
||||||
|
y: committedY,
|
||||||
|
width: committedWidth,
|
||||||
|
height: committedHeight,
|
||||||
|
});
|
||||||
|
}, [committedHeight, committedWidth, committedX, committedY]);
|
||||||
|
|
||||||
|
const { x, y, width, height } = liveRect;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (annotation.type !== "blur" || blurType !== "mosaic") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void previewFrameVersion;
|
||||||
|
|
||||||
|
const canvas = mosaicCanvasRef.current;
|
||||||
|
const sourceCanvas = previewSourceCanvas;
|
||||||
|
if (!canvas || !sourceCanvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceWidth = sourceCanvas.width;
|
||||||
|
const sourceHeight = sourceCanvas.height;
|
||||||
|
const sourceClientWidth = sourceCanvas.clientWidth || containerWidth || sourceWidth;
|
||||||
|
const sourceClientHeight = sourceCanvas.clientHeight || containerHeight || sourceHeight;
|
||||||
|
if (
|
||||||
|
sourceWidth <= 0 ||
|
||||||
|
sourceHeight <= 0 ||
|
||||||
|
sourceClientWidth <= 0 ||
|
||||||
|
sourceClientHeight <= 0
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const drawWidth = Math.max(1, Math.round(width));
|
||||||
|
const drawHeight = Math.max(1, Math.round(height));
|
||||||
|
if (drawWidth <= 0 || drawHeight <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.width = drawWidth;
|
||||||
|
canvas.height = drawHeight;
|
||||||
|
|
||||||
|
const context = canvas.getContext("2d", { willReadFrequently: true });
|
||||||
|
if (!context) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scaleX = sourceWidth / sourceClientWidth;
|
||||||
|
const scaleY = sourceHeight / sourceClientHeight;
|
||||||
|
const sourceX = Math.max(0, Math.floor(x * scaleX));
|
||||||
|
const sourceY = Math.max(0, Math.floor(y * scaleY));
|
||||||
|
const sourceSampleWidth = Math.max(1, Math.ceil(drawWidth * scaleX));
|
||||||
|
const sourceSampleHeight = Math.max(1, Math.ceil(drawHeight * scaleY));
|
||||||
|
const clampedSampleWidth = Math.max(1, Math.min(sourceSampleWidth, sourceWidth - sourceX));
|
||||||
|
const clampedSampleHeight = Math.max(1, Math.min(sourceSampleHeight, sourceHeight - sourceY));
|
||||||
|
const blockSize = getNormalizedMosaicBlockSize(annotation.blurData);
|
||||||
|
const downscaledWidth = Math.max(1, Math.round(drawWidth / blockSize));
|
||||||
|
const downscaledHeight = Math.max(1, Math.round(drawHeight / blockSize));
|
||||||
|
canvas.width = downscaledWidth;
|
||||||
|
canvas.height = downscaledHeight;
|
||||||
|
|
||||||
|
context.clearRect(0, 0, downscaledWidth, downscaledHeight);
|
||||||
|
context.imageSmoothingEnabled = true;
|
||||||
|
context.drawImage(
|
||||||
|
sourceCanvas as CanvasImageSource,
|
||||||
|
sourceX,
|
||||||
|
sourceY,
|
||||||
|
clampedSampleWidth,
|
||||||
|
clampedSampleHeight,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
downscaledWidth,
|
||||||
|
downscaledHeight,
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
annotation,
|
||||||
|
blurType,
|
||||||
|
containerHeight,
|
||||||
|
containerWidth,
|
||||||
|
height,
|
||||||
|
previewFrameVersion,
|
||||||
|
previewSourceCanvas,
|
||||||
|
width,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
]);
|
||||||
|
|
||||||
const renderArrow = () => {
|
const renderArrow = () => {
|
||||||
const direction = annotation.figureData?.arrowDirection || "right";
|
const direction = annotation.figureData?.arrowDirection || "right";
|
||||||
@@ -43,6 +193,95 @@ export function AnnotationOverlay({
|
|||||||
return <ArrowComponent color={color} strokeWidth={strokeWidth} />;
|
return <ArrowComponent color={color} strokeWidth={strokeWidth} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizePoint = (event: PointerEvent<HTMLDivElement>) => {
|
||||||
|
const rect = event.currentTarget.getBoundingClientRect();
|
||||||
|
const x = ((event.clientX - rect.left) / rect.width) * 100;
|
||||||
|
const y = ((event.clientY - rect.top) / rect.height) * 100;
|
||||||
|
return {
|
||||||
|
x: Math.max(0, Math.min(100, x)),
|
||||||
|
y: Math.max(0, Math.min(100, y)),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const appendFreehandPoint = (point: { x: number; y: number }) => {
|
||||||
|
const points = freehandPointsRef.current;
|
||||||
|
const lastPoint = points[points.length - 1];
|
||||||
|
if (!lastPoint) {
|
||||||
|
points.push(point);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dx = point.x - lastPoint.x;
|
||||||
|
const dy = point.y - lastPoint.y;
|
||||||
|
// Sample freehand points in annotation-space percent units to avoid overly dense paths.
|
||||||
|
if (Math.hypot(dx, dy) >= FREEHAND_POINT_THRESHOLD) {
|
||||||
|
points.push(point);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFreehandPointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (
|
||||||
|
!isSelected ||
|
||||||
|
annotation.type !== "blur" ||
|
||||||
|
annotation.blurData?.shape !== "freehand" ||
|
||||||
|
!onBlurDataChange
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
|
isDrawingFreehandRef.current = true;
|
||||||
|
setIsFreehandDrawing(true);
|
||||||
|
const point = normalizePoint(event);
|
||||||
|
freehandPointsRef.current = [point];
|
||||||
|
setDraftFreehandPoints([point]);
|
||||||
|
setLivePointerPoint(point);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFreehandPointerMove = (event: PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (!isDrawingFreehandRef.current) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
const point = normalizePoint(event);
|
||||||
|
setLivePointerPoint(point);
|
||||||
|
appendFreehandPoint(point);
|
||||||
|
setDraftFreehandPoints([...freehandPointsRef.current]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const finishFreehandPointer = (event: PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (!isDrawingFreehandRef.current || !onBlurDataChange) return;
|
||||||
|
isDrawingFreehandRef.current = false;
|
||||||
|
setIsFreehandDrawing(false);
|
||||||
|
try {
|
||||||
|
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||||
|
} catch {
|
||||||
|
// no-op if already released
|
||||||
|
}
|
||||||
|
const points = [...freehandPointsRef.current];
|
||||||
|
if (livePointerPoint) {
|
||||||
|
const last = points[points.length - 1];
|
||||||
|
if (!last || Math.hypot(last.x - livePointerPoint.x, last.y - livePointerPoint.y) > 0.001) {
|
||||||
|
points.push(livePointerPoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (points.length >= 3) {
|
||||||
|
const closedPoints = [...points];
|
||||||
|
const first = closedPoints[0];
|
||||||
|
const last = closedPoints[closedPoints.length - 1];
|
||||||
|
if (Math.hypot(last.x - first.x, last.y - first.y) > 0.001) {
|
||||||
|
closedPoints.push({ ...first });
|
||||||
|
}
|
||||||
|
onBlurDataChange(annotation.id, {
|
||||||
|
...(annotation.blurData || { ...DEFAULT_BLUR_DATA, shape: "freehand" }),
|
||||||
|
shape: "freehand",
|
||||||
|
freehandPoints: closedPoints,
|
||||||
|
});
|
||||||
|
setDraftFreehandPoints(closedPoints);
|
||||||
|
onBlurDataCommit?.();
|
||||||
|
}
|
||||||
|
setLivePointerPoint(null);
|
||||||
|
};
|
||||||
|
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
switch (annotation.type) {
|
switch (annotation.type) {
|
||||||
case "text":
|
case "text":
|
||||||
@@ -113,6 +352,149 @@ export function AnnotationOverlay({
|
|||||||
<div className="w-full h-full flex items-center justify-center p-2">{renderArrow()}</div>
|
<div className="w-full h-full flex items-center justify-center p-2">{renderArrow()}</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case "blur": {
|
||||||
|
const shape = annotation.blurData?.shape ?? "rectangle";
|
||||||
|
const blurIntensity = Math.max(
|
||||||
|
1,
|
||||||
|
Math.round(annotation.blurData?.intensity ?? DEFAULT_BLUR_INTENSITY),
|
||||||
|
);
|
||||||
|
const blockSize = Math.max(
|
||||||
|
1,
|
||||||
|
Math.round(annotation.blurData?.blockSize ?? DEFAULT_BLUR_BLOCK_SIZE),
|
||||||
|
);
|
||||||
|
const activeFreehandPoints =
|
||||||
|
shape === "freehand"
|
||||||
|
? isFreehandDrawing
|
||||||
|
? draftFreehandPoints
|
||||||
|
: (annotation.blurData?.freehandPoints ?? [])
|
||||||
|
: [];
|
||||||
|
const drawingPoints =
|
||||||
|
isFreehandDrawing && livePointerPoint
|
||||||
|
? (() => {
|
||||||
|
const last = activeFreehandPoints[activeFreehandPoints.length - 1];
|
||||||
|
if (!last) return [livePointerPoint];
|
||||||
|
const dx = livePointerPoint.x - last.x;
|
||||||
|
const dy = livePointerPoint.y - last.y;
|
||||||
|
return Math.hypot(dx, dy) > 0.01
|
||||||
|
? [...activeFreehandPoints, livePointerPoint]
|
||||||
|
: activeFreehandPoints;
|
||||||
|
})()
|
||||||
|
: activeFreehandPoints;
|
||||||
|
const clipPath =
|
||||||
|
shape === "freehand" ? buildBlurPolygonClipPath(activeFreehandPoints) : undefined;
|
||||||
|
const freehandPath =
|
||||||
|
shape === "freehand"
|
||||||
|
? buildBlurFreehandPath(
|
||||||
|
isFreehandDrawing ? drawingPoints : activeFreehandPoints,
|
||||||
|
!isFreehandDrawing,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
const currentPointerPoint = isFreehandDrawing
|
||||||
|
? livePointerPoint || drawingPoints[drawingPoints.length - 1] || null
|
||||||
|
: null;
|
||||||
|
const shapeBorderRadius = shape === "oval" ? "50%" : shape === "rectangle" ? "8px" : "0";
|
||||||
|
const shouldShowFreehandBlurFill =
|
||||||
|
shape !== "freehand" || (!!clipPath && !isFreehandDrawing);
|
||||||
|
const shapeMaskStyle: CSSProperties = {
|
||||||
|
borderRadius: shapeBorderRadius,
|
||||||
|
clipPath: isFreehandDrawing ? undefined : clipPath,
|
||||||
|
WebkitClipPath: isFreehandDrawing ? undefined : clipPath,
|
||||||
|
};
|
||||||
|
const isFreehandSelected = isSelectedFreehandBlur;
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full relative">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 overflow-hidden"
|
||||||
|
style={{
|
||||||
|
...shapeMaskStyle,
|
||||||
|
isolation: "isolate",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{
|
||||||
|
...shapeMaskStyle,
|
||||||
|
backdropFilter: blurType === "mosaic" ? "none" : `blur(${blurIntensity}px)`,
|
||||||
|
WebkitBackdropFilter: blurType === "mosaic" ? "none" : `blur(${blurIntensity}px)`,
|
||||||
|
backgroundColor: blurOverlayColor,
|
||||||
|
opacity: shouldShowFreehandBlurFill ? 1 : 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{blurType === "mosaic" && shouldShowFreehandBlurFill && (
|
||||||
|
<canvas
|
||||||
|
ref={mosaicCanvasRef}
|
||||||
|
className="absolute inset-0 w-full h-full"
|
||||||
|
style={{
|
||||||
|
...shapeMaskStyle,
|
||||||
|
imageRendering: "pixelated",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{blurType === "mosaic" && shouldShowFreehandBlurFill && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 pointer-events-none"
|
||||||
|
style={{
|
||||||
|
...shapeMaskStyle,
|
||||||
|
backgroundColor: blurOverlayColor,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{blurType === "mosaic" && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 pointer-events-none"
|
||||||
|
style={{
|
||||||
|
...shapeMaskStyle,
|
||||||
|
backgroundImage: `linear-gradient(${mosaicGridOverlayColor} 1px, transparent 1px), linear-gradient(90deg, ${mosaicGridOverlayColor} 1px, transparent 1px)`,
|
||||||
|
backgroundSize: `${blockSize}px ${blockSize}px`,
|
||||||
|
mixBlendMode: "screen",
|
||||||
|
opacity: 0.35,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isSelected && shape !== "freehand" && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 pointer-events-none border-2 border-[#34B27B]/80"
|
||||||
|
style={{ borderRadius: shapeBorderRadius }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isSelected && shape === "freehand" && freehandPath && (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 100 100"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
className="absolute inset-0 pointer-events-none"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d={freehandPath}
|
||||||
|
fill="none"
|
||||||
|
stroke="#34B27B"
|
||||||
|
strokeWidth="0.55"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
{currentPointerPoint && (
|
||||||
|
<circle
|
||||||
|
cx={currentPointerPoint.x}
|
||||||
|
cy={currentPointerPoint.y}
|
||||||
|
r="0.6"
|
||||||
|
fill="#34B27B"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{isFreehandSelected && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 cursor-crosshair"
|
||||||
|
onPointerDown={handleFreehandPointerDown}
|
||||||
|
onPointerMove={handleFreehandPointerMove}
|
||||||
|
onPointerUp={finishFreehandPointer}
|
||||||
|
onPointerCancel={finishFreehandPointer}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -125,7 +507,19 @@ export function AnnotationOverlay({
|
|||||||
onDragStart={() => {
|
onDragStart={() => {
|
||||||
isDraggingRef.current = true;
|
isDraggingRef.current = true;
|
||||||
}}
|
}}
|
||||||
|
onDrag={(_e, d) => {
|
||||||
|
setLiveRect((prev) => ({
|
||||||
|
...prev,
|
||||||
|
x: d.x,
|
||||||
|
y: d.y,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
onDragStop={(_e, d) => {
|
onDragStop={(_e, d) => {
|
||||||
|
setLiveRect((prev) => ({
|
||||||
|
...prev,
|
||||||
|
x: d.x,
|
||||||
|
y: d.y,
|
||||||
|
}));
|
||||||
const xPercent = (d.x / containerWidth) * 100;
|
const xPercent = (d.x / containerWidth) * 100;
|
||||||
const yPercent = (d.y / containerHeight) * 100;
|
const yPercent = (d.y / containerHeight) * 100;
|
||||||
onPositionChange(annotation.id, { x: xPercent, y: yPercent });
|
onPositionChange(annotation.id, { x: xPercent, y: yPercent });
|
||||||
@@ -135,7 +529,21 @@ export function AnnotationOverlay({
|
|||||||
isDraggingRef.current = false;
|
isDraggingRef.current = false;
|
||||||
}, 100);
|
}, 100);
|
||||||
}}
|
}}
|
||||||
|
onResize={(_e, _direction, ref, _delta, position) => {
|
||||||
|
setLiveRect({
|
||||||
|
x: position.x,
|
||||||
|
y: position.y,
|
||||||
|
width: ref.offsetWidth,
|
||||||
|
height: ref.offsetHeight,
|
||||||
|
});
|
||||||
|
}}
|
||||||
onResizeStop={(_e, _direction, ref, _delta, position) => {
|
onResizeStop={(_e, _direction, ref, _delta, position) => {
|
||||||
|
setLiveRect({
|
||||||
|
x: position.x,
|
||||||
|
y: position.y,
|
||||||
|
width: ref.offsetWidth,
|
||||||
|
height: ref.offsetHeight,
|
||||||
|
});
|
||||||
const xPercent = (position.x / containerWidth) * 100;
|
const xPercent = (position.x / containerWidth) * 100;
|
||||||
const yPercent = (position.y / containerHeight) * 100;
|
const yPercent = (position.y / containerHeight) * 100;
|
||||||
const widthPercent = (ref.offsetWidth / containerWidth) * 100;
|
const widthPercent = (ref.offsetWidth / containerWidth) * 100;
|
||||||
@@ -149,18 +557,23 @@ export function AnnotationOverlay({
|
|||||||
}}
|
}}
|
||||||
bounds="parent"
|
bounds="parent"
|
||||||
className={cn(
|
className={cn(
|
||||||
"cursor-move transition-all",
|
"cursor-move",
|
||||||
isSelected && "ring-2 ring-[#34B27B] ring-offset-2 ring-offset-transparent",
|
isSelected &&
|
||||||
|
annotation.type !== "blur" &&
|
||||||
|
"ring-2 ring-[#34B27B] ring-offset-2 ring-offset-transparent",
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
zIndex: isSelectedBoost ? zIndex + 1000 : zIndex, // Boost selected annotation to ensure it's on top
|
zIndex: isSelectedBoost ? zIndex + 1000 : zIndex, // Boost selected annotation to ensure it's on top
|
||||||
pointerEvents: isSelected ? "auto" : "none",
|
pointerEvents: isSelected ? "auto" : "none",
|
||||||
border: isSelected ? "2px solid rgba(52, 178, 123, 0.8)" : "none",
|
border:
|
||||||
backgroundColor: isSelected ? "rgba(52, 178, 123, 0.1)" : "transparent",
|
isSelected && annotation.type !== "blur" ? "2px solid rgba(52, 178, 123, 0.8)" : "none",
|
||||||
boxShadow: isSelected ? "0 0 0 1px rgba(52, 178, 123, 0.35)" : "none",
|
backgroundColor:
|
||||||
|
isSelected && annotation.type !== "blur" ? "rgba(52, 178, 123, 0.1)" : "transparent",
|
||||||
|
boxShadow:
|
||||||
|
isSelected && annotation.type !== "blur" ? "0 0 0 1px rgba(52, 178, 123, 0.35)" : "none",
|
||||||
}}
|
}}
|
||||||
enableResizing={isSelected}
|
enableResizing={isSelected && !isSelectedFreehandBlur}
|
||||||
disableDragging={!isSelected}
|
disableDragging={!isSelected || isSelectedFreehandBlur}
|
||||||
resizeHandleStyles={{
|
resizeHandleStyles={{
|
||||||
topLeft: {
|
topLeft: {
|
||||||
width: "12px",
|
width: "12px",
|
||||||
@@ -206,11 +619,13 @@ export function AnnotationOverlay({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full h-full rounded-lg",
|
"w-full h-full",
|
||||||
|
annotation.type !== "blur" && "rounded-lg",
|
||||||
annotation.type === "text" && "bg-transparent",
|
annotation.type === "text" && "bg-transparent",
|
||||||
annotation.type === "image" && "bg-transparent",
|
annotation.type === "image" && "bg-transparent",
|
||||||
annotation.type === "figure" && "bg-transparent",
|
annotation.type === "figure" && "bg-transparent",
|
||||||
isSelected && "shadow-lg",
|
annotation.type === "blur" && "bg-transparent",
|
||||||
|
isSelected && annotation.type !== "blur" && "shadow-lg",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{renderContent()}
|
{renderContent()}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
AlignRight,
|
AlignRight,
|
||||||
Bold,
|
Bold,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
Copy,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
Info,
|
Info,
|
||||||
Italic,
|
Italic,
|
||||||
@@ -27,11 +28,18 @@ import {
|
|||||||
import { Slider } from "@/components/ui/slider";
|
import { Slider } from "@/components/ui/slider";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||||
|
import { useScopedT } from "@/contexts/I18nContext";
|
||||||
import { type CustomFont, getCustomFonts } from "@/lib/customFonts";
|
import { type CustomFont, getCustomFonts } from "@/lib/customFonts";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import ColorPicker from "../ui/color-picker";
|
||||||
import { AddCustomFontDialog } from "./AddCustomFontDialog";
|
import { AddCustomFontDialog } from "./AddCustomFontDialog";
|
||||||
import { getArrowComponent } from "./ArrowSvgs";
|
import { getArrowComponent } from "./ArrowSvgs";
|
||||||
import type { AnnotationRegion, AnnotationType, ArrowDirection, FigureData } from "./types";
|
import {
|
||||||
|
type AnnotationRegion,
|
||||||
|
type AnnotationType,
|
||||||
|
type ArrowDirection,
|
||||||
|
type FigureData,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
interface AnnotationSettingsPanelProps {
|
interface AnnotationSettingsPanelProps {
|
||||||
annotation: AnnotationRegion;
|
annotation: AnnotationRegion;
|
||||||
@@ -39,18 +47,19 @@ interface AnnotationSettingsPanelProps {
|
|||||||
onTypeChange: (type: AnnotationType) => void;
|
onTypeChange: (type: AnnotationType) => void;
|
||||||
onStyleChange: (style: Partial<AnnotationRegion["style"]>) => void;
|
onStyleChange: (style: Partial<AnnotationRegion["style"]>) => void;
|
||||||
onFigureDataChange?: (figureData: FigureData) => void;
|
onFigureDataChange?: (figureData: FigureData) => void;
|
||||||
|
onDuplicate?: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FONT_FAMILIES = [
|
const FONT_FAMILIES = [
|
||||||
{ value: "system-ui, -apple-system, sans-serif", label: "Classic" },
|
{ value: "system-ui, -apple-system, sans-serif", labelKey: "classic" },
|
||||||
{ value: "Georgia, serif", label: "Editor" },
|
{ value: "Georgia, serif", labelKey: "editor" },
|
||||||
{ value: "Impact, Arial Black, sans-serif", label: "Strong" },
|
{ value: "Impact, Arial Black, sans-serif", labelKey: "strong" },
|
||||||
{ value: "Courier New, monospace", label: "Typewriter" },
|
{ value: "Courier New, monospace", labelKey: "typewriter" },
|
||||||
{ value: "Brush Script MT, cursive", label: "Deco" },
|
{ value: "Brush Script MT, cursive", labelKey: "deco" },
|
||||||
{ value: "Arial, sans-serif", label: "Simple" },
|
{ value: "Arial, sans-serif", labelKey: "simple" },
|
||||||
{ value: "Verdana, sans-serif", label: "Modern" },
|
{ value: "Verdana, sans-serif", labelKey: "modern" },
|
||||||
{ value: "Trebuchet MS, sans-serif", label: "Clean" },
|
{ value: "Trebuchet MS, sans-serif", labelKey: "clean" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const FONT_SIZES = [12, 14, 16, 18, 20, 24, 28, 32, 36, 40, 48, 56, 64, 72, 80, 96, 128];
|
const FONT_SIZES = [12, 14, 16, 18, 20, 24, 28, 32, 36, 40, 48, 56, 64, 72, 80, 96, 128];
|
||||||
@@ -61,10 +70,22 @@ export function AnnotationSettingsPanel({
|
|||||||
onTypeChange,
|
onTypeChange,
|
||||||
onStyleChange,
|
onStyleChange,
|
||||||
onFigureDataChange,
|
onFigureDataChange,
|
||||||
|
onDuplicate,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: AnnotationSettingsPanelProps) {
|
}: AnnotationSettingsPanelProps) {
|
||||||
|
const t = useScopedT("settings");
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [customFonts, setCustomFonts] = useState<CustomFont[]>([]);
|
const [customFonts, setCustomFonts] = useState<CustomFont[]>([]);
|
||||||
|
const fontStyleLabels: Record<string, string> = {
|
||||||
|
classic: t("fontStyles.classic"),
|
||||||
|
editor: t("fontStyles.editor"),
|
||||||
|
strong: t("fontStyles.strong"),
|
||||||
|
typewriter: t("fontStyles.typewriter"),
|
||||||
|
deco: t("fontStyles.deco"),
|
||||||
|
simple: t("fontStyles.simple"),
|
||||||
|
modern: t("fontStyles.modern"),
|
||||||
|
clean: t("fontStyles.clean"),
|
||||||
|
};
|
||||||
|
|
||||||
// Load custom fonts on mount
|
// Load custom fonts on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -99,8 +120,8 @@ export function AnnotationSettingsPanel({
|
|||||||
// Validate file type
|
// Validate file type
|
||||||
const validTypes = ["image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp"];
|
const validTypes = ["image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp"];
|
||||||
if (!validTypes.includes(file.type)) {
|
if (!validTypes.includes(file.type)) {
|
||||||
toast.error("Invalid file type", {
|
toast.error(t("annotation.invalidImageType"), {
|
||||||
description: "Please upload a JPG, PNG, GIF, or WebP image file.",
|
description: t("annotation.imageFormatsOnly"),
|
||||||
});
|
});
|
||||||
event.target.value = "";
|
event.target.value = "";
|
||||||
return;
|
return;
|
||||||
@@ -112,12 +133,12 @@ export function AnnotationSettingsPanel({
|
|||||||
const dataUrl = e.target?.result as string;
|
const dataUrl = e.target?.result as string;
|
||||||
if (dataUrl) {
|
if (dataUrl) {
|
||||||
onContentChange(dataUrl);
|
onContentChange(dataUrl);
|
||||||
toast.success("Image uploaded successfully!");
|
toast.success(t("annotation.imageUploadSuccess"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
reader.onerror = () => {
|
reader.onerror = () => {
|
||||||
toast.error("Failed to upload image", {
|
toast.error(t("annotation.failedImageUpload"), {
|
||||||
description: "There was an error reading the file.",
|
description: "There was an error reading the file.",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -130,9 +151,9 @@ export function AnnotationSettingsPanel({
|
|||||||
<div className="flex-[2] min-w-0 bg-[#09090b] border border-white/5 rounded-2xl p-4 flex flex-col shadow-xl h-full overflow-y-auto custom-scrollbar">
|
<div className="flex-[2] min-w-0 bg-[#09090b] border border-white/5 rounded-2xl p-4 flex flex-col shadow-xl h-full overflow-y-auto custom-scrollbar">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<span className="text-sm font-medium text-slate-200">Annotation Settings</span>
|
<span className="text-sm font-medium text-slate-200">{t("annotation.title")}</span>
|
||||||
<span className="text-[10px] uppercase tracking-wider font-medium text-[#34B27B] bg-[#34B27B]/10 px-2 py-1 rounded-full">
|
<span className="text-[10px] uppercase tracking-wider font-medium text-[#34B27B] bg-[#34B27B]/10 px-2 py-1 rounded-full">
|
||||||
Active
|
{t("annotation.active")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -148,14 +169,14 @@ export function AnnotationSettingsPanel({
|
|||||||
className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 py-2 rounded-lg transition-all gap-2"
|
className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 py-2 rounded-lg transition-all gap-2"
|
||||||
>
|
>
|
||||||
<Type className="w-4 h-4" />
|
<Type className="w-4 h-4" />
|
||||||
Text
|
{t("annotation.typeText")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="image"
|
value="image"
|
||||||
className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 py-2 rounded-lg transition-all gap-2"
|
className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 py-2 rounded-lg transition-all gap-2"
|
||||||
>
|
>
|
||||||
<ImageIcon className="w-4 h-4" />
|
<ImageIcon className="w-4 h-4" />
|
||||||
Image
|
{t("annotation.typeImage")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="figure"
|
value="figure"
|
||||||
@@ -170,18 +191,20 @@ export function AnnotationSettingsPanel({
|
|||||||
>
|
>
|
||||||
<path d="M4 12h16m0 0l-6-6m6 6l-6 6" strokeLinecap="round" strokeLinejoin="round" />
|
<path d="M4 12h16m0 0l-6-6m6 6l-6 6" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
</svg>
|
</svg>
|
||||||
Arrow
|
{t("annotation.typeArrow")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* Text Content */}
|
{/* Text Content */}
|
||||||
<TabsContent value="text" className="mt-0 space-y-4">
|
<TabsContent value="text" className="mt-0 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-medium text-slate-200 mb-2 block">Text Content</label>
|
<label className="text-xs font-medium text-slate-200 mb-2 block">
|
||||||
|
{t("annotation.textContent")}
|
||||||
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={annotation.textContent || annotation.content}
|
value={annotation.textContent || annotation.content}
|
||||||
onChange={(e) => onContentChange(e.target.value)}
|
onChange={(e) => onContentChange(e.target.value)}
|
||||||
placeholder="Enter your text..."
|
placeholder={t("annotation.textPlaceholder")}
|
||||||
rows={5}
|
rows={5}
|
||||||
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-slate-200 text-sm placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-[#34B27B] focus:border-transparent resize-none"
|
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-slate-200 text-sm placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-[#34B27B] focus:border-transparent resize-none"
|
||||||
/>
|
/>
|
||||||
@@ -193,14 +216,14 @@ export function AnnotationSettingsPanel({
|
|||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-medium text-slate-200 mb-2 block">
|
<label className="text-xs font-medium text-slate-200 mb-2 block">
|
||||||
Font Style
|
{t("annotation.fontStyle")}
|
||||||
</label>
|
</label>
|
||||||
<Select
|
<Select
|
||||||
value={annotation.style.fontFamily}
|
value={annotation.style.fontFamily}
|
||||||
onValueChange={(value) => onStyleChange({ fontFamily: value })}
|
onValueChange={(value) => onStyleChange({ fontFamily: value })}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full bg-white/5 border-white/10 text-slate-200 h-9 text-xs">
|
<SelectTrigger className="w-full bg-white/5 border-white/10 text-slate-200 h-9 text-xs">
|
||||||
<SelectValue placeholder="Select style" />
|
<SelectValue placeholder={t("annotation.selectStyle")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="bg-[#1a1a1c] border-white/10 text-slate-200 max-h-[300px]">
|
<SelectContent className="bg-[#1a1a1c] border-white/10 text-slate-200 max-h-[300px]">
|
||||||
{FONT_FAMILIES.map((font) => (
|
{FONT_FAMILIES.map((font) => (
|
||||||
@@ -209,13 +232,13 @@ export function AnnotationSettingsPanel({
|
|||||||
value={font.value}
|
value={font.value}
|
||||||
style={{ fontFamily: font.value }}
|
style={{ fontFamily: font.value }}
|
||||||
>
|
>
|
||||||
{font.label}
|
{fontStyleLabels[font.labelKey]}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
{customFonts.length > 0 && (
|
{customFonts.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="px-2 py-1.5 text-[10px] font-medium text-slate-400 uppercase tracking-wider">
|
<div className="px-2 py-1.5 text-[10px] font-medium text-slate-400 uppercase tracking-wider">
|
||||||
Custom Fonts
|
{t("annotation.customFonts")}
|
||||||
</div>
|
</div>
|
||||||
{customFonts.map((font) => (
|
{customFonts.map((font) => (
|
||||||
<SelectItem
|
<SelectItem
|
||||||
@@ -232,13 +255,15 @@ export function AnnotationSettingsPanel({
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-medium text-slate-200 mb-2 block">Size</label>
|
<label className="text-xs font-medium text-slate-200 mb-2 block">
|
||||||
|
{t("annotation.size")}
|
||||||
|
</label>
|
||||||
<Select
|
<Select
|
||||||
value={annotation.style.fontSize.toString()}
|
value={annotation.style.fontSize.toString()}
|
||||||
onValueChange={(value) => onStyleChange({ fontSize: parseInt(value) })}
|
onValueChange={(value) => onStyleChange({ fontSize: parseInt(value) })}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full bg-white/5 border-white/10 text-slate-200 h-9 text-xs">
|
<SelectTrigger className="w-full bg-white/5 border-white/10 text-slate-200 h-9 text-xs">
|
||||||
<SelectValue placeholder="Size" />
|
<SelectValue placeholder={t("annotation.size")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="bg-[#1a1a1c] border-white/10 text-slate-200 max-h-[200px]">
|
<SelectContent className="bg-[#1a1a1c] border-white/10 text-slate-200 max-h-[200px]">
|
||||||
{FONT_SIZES.map((size) => (
|
{FONT_SIZES.map((size) => (
|
||||||
@@ -345,7 +370,7 @@ export function AnnotationSettingsPanel({
|
|||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-medium text-slate-200 mb-2 block">
|
<label className="text-xs font-medium text-slate-200 mb-2 block">
|
||||||
Text Color
|
{t("annotation.textColor")}
|
||||||
</label>
|
</label>
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
@@ -363,15 +388,19 @@ export function AnnotationSettingsPanel({
|
|||||||
<ChevronDown className="h-3 w-3 opacity-50" />
|
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-[260px] p-3 bg-[#1a1a1c] border border-white/10 rounded-xl shadow-xl">
|
<PopoverContent
|
||||||
<Block
|
side="top"
|
||||||
color={annotation.style.color}
|
className="w-[260px] p-3 bg-[#1a1a1c] border border-white/10 rounded-xl shadow-xl"
|
||||||
colors={colorPalette}
|
>
|
||||||
onChange={(color) => {
|
<ColorPicker
|
||||||
onStyleChange({ color: color.hex });
|
selectedColor={annotation.style.color}
|
||||||
|
colorPalette={colorPalette}
|
||||||
|
translations={{
|
||||||
|
colorWheel: t("annotation.colorWheel"),
|
||||||
|
colorPalette: t("annotation.colorPalette"),
|
||||||
}}
|
}}
|
||||||
style={{
|
onUpdateColor={(color) => {
|
||||||
borderRadius: "8px",
|
onStyleChange({ color: color });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
@@ -379,7 +408,7 @@ export function AnnotationSettingsPanel({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-medium text-slate-200 mb-2 block">
|
<label className="text-xs font-medium text-slate-200 mb-2 block">
|
||||||
Background
|
{t("annotation.background")}
|
||||||
</label>
|
</label>
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
@@ -395,36 +424,30 @@ export function AnnotationSettingsPanel({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-slate-300 truncate flex-1 text-left">
|
<span className="text-xs text-slate-300 truncate flex-1 text-left">
|
||||||
{annotation.style.backgroundColor === "transparent" ? "None" : "Color"}
|
{annotation.style.backgroundColor === "transparent"
|
||||||
|
? t("annotation.none")
|
||||||
|
: t("annotation.color")}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown className="h-3 w-3 opacity-50" />
|
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-[260px] p-3 bg-[#1a1a1c] border border-white/10 rounded-xl shadow-xl">
|
<PopoverContent
|
||||||
<Block
|
side="top"
|
||||||
color={
|
className="w-[260px] p-3 bg-[#1a1a1c] border border-white/10 rounded-xl shadow-xl"
|
||||||
annotation.style.backgroundColor === "transparent"
|
>
|
||||||
? "#000000"
|
<ColorPicker
|
||||||
: annotation.style.backgroundColor
|
selectedColor={annotation.style.backgroundColor}
|
||||||
}
|
colorPalette={colorPalette}
|
||||||
colors={colorPalette}
|
translations={{
|
||||||
onChange={(color) => {
|
colorWheel: t("annotation.colorWheel"),
|
||||||
onStyleChange({ backgroundColor: color.hex });
|
colorPalette: t("annotation.colorPalette"),
|
||||||
|
clearBackground: t("annotation.clearBackground"),
|
||||||
}}
|
}}
|
||||||
style={{
|
clearBackgroundOption={true}
|
||||||
borderRadius: "8px",
|
onUpdateColor={(color) => {
|
||||||
|
onStyleChange({ backgroundColor: color });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="w-full mt-2 text-xs h-7 hover:bg-white/5 text-slate-400"
|
|
||||||
onClick={() => {
|
|
||||||
onStyleChange({ backgroundColor: "transparent" });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Clear Background
|
|
||||||
</Button>
|
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
@@ -447,7 +470,7 @@ export function AnnotationSettingsPanel({
|
|||||||
className="w-full gap-2 bg-white/5 text-slate-200 border-white/10 hover:bg-[#34B27B] hover:text-white hover:border-[#34B27B] transition-all py-8"
|
className="w-full gap-2 bg-white/5 text-slate-200 border-white/10 hover:bg-[#34B27B] hover:text-white hover:border-[#34B27B] transition-all py-8"
|
||||||
>
|
>
|
||||||
<Upload className="w-5 h-5" />
|
<Upload className="w-5 h-5" />
|
||||||
Upload Image
|
{t("annotation.uploadImage")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{annotation.content && annotation.content.startsWith("data:image") && (
|
{annotation.content && annotation.content.startsWith("data:image") && (
|
||||||
@@ -461,14 +484,14 @@ export function AnnotationSettingsPanel({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="text-xs text-slate-500 text-center leading-relaxed">
|
<p className="text-xs text-slate-500 text-center leading-relaxed">
|
||||||
Supported formats: JPG, PNG, GIF, WebP
|
{t("annotation.supportedFormats")}
|
||||||
</p>
|
</p>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="figure" className="mt-0 space-y-4">
|
<TabsContent value="figure" className="mt-0 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-medium text-slate-200 mb-3 block">
|
<label className="text-xs font-medium text-slate-200 mb-3 block">
|
||||||
Arrow Direction
|
{t("annotation.arrowDirection")}
|
||||||
</label>
|
</label>
|
||||||
<div className="grid grid-cols-4 gap-2">
|
<div className="grid grid-cols-4 gap-2">
|
||||||
{(
|
{(
|
||||||
@@ -517,7 +540,9 @@ export function AnnotationSettingsPanel({
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-medium text-slate-200 mb-2 block">
|
<label className="text-xs font-medium text-slate-200 mb-2 block">
|
||||||
Stroke Width: {annotation.figureData?.strokeWidth || 4}px
|
{t("annotation.strokeWidth", {
|
||||||
|
width: String(annotation.figureData?.strokeWidth || 4),
|
||||||
|
})}
|
||||||
</label>
|
</label>
|
||||||
<Slider
|
<Slider
|
||||||
value={[annotation.figureData?.strokeWidth || 4]}
|
value={[annotation.figureData?.strokeWidth || 4]}
|
||||||
@@ -536,7 +561,9 @@ export function AnnotationSettingsPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-medium text-slate-200 mb-2 block">Arrow Color</label>
|
<label className="text-xs font-medium text-slate-200 mb-2 block">
|
||||||
|
{t("annotation.arrowColor")}
|
||||||
|
</label>
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@@ -574,35 +601,38 @@ export function AnnotationSettingsPanel({
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<Button
|
<div className="mt-4 grid grid-cols-2 gap-2">
|
||||||
onClick={onDelete}
|
<Button
|
||||||
variant="destructive"
|
onClick={() => onDuplicate?.()}
|
||||||
size="sm"
|
variant="outline"
|
||||||
className="w-full gap-2 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all mt-4"
|
size="sm"
|
||||||
>
|
disabled={!onDuplicate}
|
||||||
<Trash2 className="w-4 h-4" />
|
className="w-full gap-2 bg-white/5 text-slate-200 border border-white/10 hover:bg-white/10 hover:border-white/20 transition-all"
|
||||||
Delete Annotation
|
>
|
||||||
</Button>
|
<Copy className="w-4 h-4" />
|
||||||
|
Duplicate
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={onDelete}
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
className="w-full gap-2 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
{t("annotation.deleteAnnotation")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 p-3 bg-white/5 rounded-lg border border-white/5">
|
<div className="mt-6 p-3 bg-white/5 rounded-lg border border-white/5">
|
||||||
<div className="flex items-center gap-2 mb-2 text-slate-300">
|
<div className="flex items-center gap-2 mb-2 text-slate-300">
|
||||||
<Info className="w-3.5 h-3.5" />
|
<Info className="w-3.5 h-3.5" />
|
||||||
<span className="text-xs font-medium">Shortcuts & Tips</span>
|
<span className="text-xs font-medium">{t("annotation.shortcutsAndTips")}</span>
|
||||||
</div>
|
</div>
|
||||||
<ul className="text-[10px] text-slate-400 space-y-1.5 list-disc pl-3 leading-relaxed">
|
<ul className="text-[10px] text-slate-400 space-y-1.5 list-disc pl-3 leading-relaxed">
|
||||||
<li>Move playhead to overlapping annotation section and select an item.</li>
|
<li>{t("annotation.tipMovePlayhead")}</li>
|
||||||
<li>
|
<li>{t("annotation.tipTabCycle")}</li>
|
||||||
Use{" "}
|
<li>{t("annotation.tipShiftTabCycle")}</li>
|
||||||
<kbd className="px-1 py-0.5 bg-white/10 rounded text-slate-300 font-mono">Tab</kbd> to
|
|
||||||
cycle through overlapping items.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Use{" "}
|
|
||||||
<kbd className="px-1 py-0.5 bg-white/10 rounded text-slate-300 font-mono">
|
|
||||||
Shift+Tab
|
|
||||||
</kbd>{" "}
|
|
||||||
to cycle backwards.
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import { Info, Trash2 } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Slider } from "@/components/ui/slider";
|
||||||
|
import { useScopedT } from "@/contexts/I18nContext";
|
||||||
|
import { getBlurOverlayColor } from "@/lib/blurEffects";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
type AnnotationRegion,
|
||||||
|
type BlurColor,
|
||||||
|
type BlurData,
|
||||||
|
type BlurShape,
|
||||||
|
DEFAULT_BLUR_BLOCK_SIZE,
|
||||||
|
DEFAULT_BLUR_DATA,
|
||||||
|
MAX_BLUR_BLOCK_SIZE,
|
||||||
|
MAX_BLUR_INTENSITY,
|
||||||
|
MIN_BLUR_BLOCK_SIZE,
|
||||||
|
MIN_BLUR_INTENSITY,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
interface BlurSettingsPanelProps {
|
||||||
|
blurRegion: AnnotationRegion;
|
||||||
|
onBlurDataChange: (blurData: BlurData) => void;
|
||||||
|
onBlurDataCommit?: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BlurSettingsPanel({
|
||||||
|
blurRegion,
|
||||||
|
onBlurDataChange,
|
||||||
|
onBlurDataCommit,
|
||||||
|
onDelete,
|
||||||
|
}: BlurSettingsPanelProps) {
|
||||||
|
const t = useScopedT("settings");
|
||||||
|
|
||||||
|
const blurShapeOptions: Array<{ value: BlurShape; labelKey: string }> = [
|
||||||
|
{ value: "rectangle", labelKey: "blurShapeRectangle" },
|
||||||
|
{ value: "oval", labelKey: "blurShapeOval" },
|
||||||
|
];
|
||||||
|
const blurColorOptions: Array<{ value: BlurColor; labelKey: string }> = [
|
||||||
|
{ value: "white", labelKey: "blurColorWhite" },
|
||||||
|
{ value: "black", labelKey: "blurColorBlack" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-[2] min-w-0 bg-[#09090b] border border-white/5 rounded-2xl p-4 flex flex-col shadow-xl h-full overflow-y-auto custom-scrollbar">
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<span className="text-sm font-medium text-slate-200">{t("annotation.blurShape")}</span>
|
||||||
|
<span className="text-[10px] uppercase tracking-wider font-medium text-[#34B27B] bg-[#34B27B]/10 px-2 py-1 rounded-full">
|
||||||
|
{t("annotation.active")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{blurShapeOptions.map((shape) => {
|
||||||
|
const activeShape = blurRegion.blurData?.shape || DEFAULT_BLUR_DATA.shape;
|
||||||
|
const isActive = activeShape === shape.value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={shape.value}
|
||||||
|
onClick={() => {
|
||||||
|
const nextBlurData: BlurData = {
|
||||||
|
...DEFAULT_BLUR_DATA,
|
||||||
|
...blurRegion.blurData,
|
||||||
|
shape: shape.value,
|
||||||
|
};
|
||||||
|
onBlurDataChange(nextBlurData);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
onBlurDataCommit?.();
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"h-16 rounded-lg border flex flex-col items-center justify-center transition-all p-2 gap-1",
|
||||||
|
isActive
|
||||||
|
? "bg-[#34B27B] border-[#34B27B]"
|
||||||
|
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{shape.value === "rectangle" && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"w-8 h-5 border-2 rounded-sm",
|
||||||
|
isActive ? "border-white" : "border-slate-400",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{shape.value === "oval" && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"w-8 h-5 border-2 rounded-full",
|
||||||
|
isActive ? "border-white" : "border-slate-400",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="text-[10px] leading-none">
|
||||||
|
{t(`annotation.${shape.labelKey}`)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<label className="text-xs font-medium text-slate-300 mb-2 block">
|
||||||
|
{t("annotation.blurType")}
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={blurRegion.blurData?.type ?? DEFAULT_BLUR_DATA.type}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const nextBlurData: BlurData = {
|
||||||
|
...DEFAULT_BLUR_DATA,
|
||||||
|
...blurRegion.blurData,
|
||||||
|
type: value === "mosaic" ? "mosaic" : "blur",
|
||||||
|
};
|
||||||
|
onBlurDataChange(nextBlurData);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
onBlurDataCommit?.();
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full bg-white/5 border-white/10 text-slate-200 h-9 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="bg-[#1a1a1c] border-white/10 text-slate-200">
|
||||||
|
<SelectItem value="blur">{t("annotation.blurTypeBlur")}</SelectItem>
|
||||||
|
<SelectItem value="mosaic">{t("annotation.blurTypeMosaic")}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<label className="text-xs font-medium text-slate-300 mb-2 block">
|
||||||
|
{t("annotation.blurColor")}
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{blurColorOptions.map((option) => {
|
||||||
|
const activeColor = blurRegion.blurData?.color ?? DEFAULT_BLUR_DATA.color;
|
||||||
|
const isActive = activeColor === option.value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
onClick={() => {
|
||||||
|
const nextBlurData: BlurData = {
|
||||||
|
...DEFAULT_BLUR_DATA,
|
||||||
|
...blurRegion.blurData,
|
||||||
|
color: option.value,
|
||||||
|
};
|
||||||
|
onBlurDataChange(nextBlurData);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
onBlurDataCommit?.();
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"h-10 rounded-lg border flex items-center gap-2 px-3 transition-all",
|
||||||
|
isActive
|
||||||
|
? "bg-[#34B27B] border-[#34B27B]"
|
||||||
|
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-4 h-4 rounded-full border border-white/20"
|
||||||
|
style={{
|
||||||
|
backgroundColor: getBlurOverlayColor({
|
||||||
|
...DEFAULT_BLUR_DATA,
|
||||||
|
...blurRegion.blurData,
|
||||||
|
color: option.value,
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-slate-200">
|
||||||
|
{t(`annotation.${option.labelKey}`)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 p-3 rounded-lg bg-white/5 border border-white/10">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-xs font-medium text-slate-300">
|
||||||
|
{blurRegion.blurData?.type === "mosaic"
|
||||||
|
? t("annotation.mosaicBlockSize")
|
||||||
|
: t("annotation.blurIntensity")}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-slate-400 font-mono">
|
||||||
|
{Math.round(
|
||||||
|
blurRegion.blurData?.type === "mosaic"
|
||||||
|
? (blurRegion.blurData?.blockSize ?? DEFAULT_BLUR_BLOCK_SIZE)
|
||||||
|
: (blurRegion.blurData?.intensity ?? DEFAULT_BLUR_DATA.intensity),
|
||||||
|
)}
|
||||||
|
px
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
value={[
|
||||||
|
blurRegion.blurData?.type === "mosaic"
|
||||||
|
? (blurRegion.blurData?.blockSize ?? DEFAULT_BLUR_BLOCK_SIZE)
|
||||||
|
: (blurRegion.blurData?.intensity ?? DEFAULT_BLUR_DATA.intensity),
|
||||||
|
]}
|
||||||
|
onValueChange={(values) => {
|
||||||
|
onBlurDataChange({
|
||||||
|
...DEFAULT_BLUR_DATA,
|
||||||
|
...blurRegion.blurData,
|
||||||
|
...(blurRegion.blurData?.type === "mosaic"
|
||||||
|
? { blockSize: values[0] }
|
||||||
|
: { intensity: values[0] }),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onValueCommit={() => onBlurDataCommit?.()}
|
||||||
|
min={blurRegion.blurData?.type === "mosaic" ? MIN_BLUR_BLOCK_SIZE : MIN_BLUR_INTENSITY}
|
||||||
|
max={blurRegion.blurData?.type === "mosaic" ? MAX_BLUR_BLOCK_SIZE : MAX_BLUR_INTENSITY}
|
||||||
|
step={1}
|
||||||
|
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={onDelete}
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
className="w-full gap-2 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all mt-4"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
{t("annotation.deleteAnnotation")}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="mt-6 p-3 bg-white/5 rounded-lg border border-white/5">
|
||||||
|
<div className="flex items-center gap-2 mb-2 text-slate-300">
|
||||||
|
<Info className="w-3.5 h-3.5" />
|
||||||
|
<span className="text-xs font-medium">{t("annotation.shortcutsAndTips")}</span>
|
||||||
|
</div>
|
||||||
|
<ul className="text-[10px] text-slate-400 space-y-1.5 list-disc pl-3 leading-relaxed">
|
||||||
|
<li>{t("annotation.tipMovePlayhead")}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Download, Loader2, X } from "lucide-react";
|
import { Download, Loader2, X } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useScopedT } from "@/contexts/I18nContext";
|
||||||
import type { ExportProgress } from "@/lib/exporter";
|
import type { ExportProgress } from "@/lib/exporter";
|
||||||
|
|
||||||
interface ExportDialogProps {
|
interface ExportDialogProps {
|
||||||
@@ -26,6 +27,7 @@ export function ExportDialog({
|
|||||||
exportedFilePath,
|
exportedFilePath,
|
||||||
onShowInFolder,
|
onShowInFolder,
|
||||||
}: ExportDialogProps) {
|
}: ExportDialogProps) {
|
||||||
|
const t = useScopedT("dialogs");
|
||||||
const [showSuccess, setShowSuccess] = useState(false);
|
const [showSuccess, setShowSuccess] = useState(false);
|
||||||
|
|
||||||
// Reset showSuccess when a new export starts or dialog reopens
|
// Reset showSuccess when a new export starts or dialog reopens
|
||||||
@@ -65,25 +67,25 @@ export function ExportDialog({
|
|||||||
|
|
||||||
// Get status message based on phase
|
// Get status message based on phase
|
||||||
const getStatusMessage = () => {
|
const getStatusMessage = () => {
|
||||||
if (error) return "Please try again";
|
if (error) return t("export.tryAgain");
|
||||||
if (isCompiling || isFinalizing) {
|
if (isCompiling || isFinalizing) {
|
||||||
if (exportFormat === "mp4") {
|
if (exportFormat === "mp4") {
|
||||||
return "Finalizing video export...";
|
return t("export.finalizingVideo");
|
||||||
}
|
}
|
||||||
if (renderProgress !== undefined && renderProgress > 0) {
|
if (renderProgress !== undefined && renderProgress > 0) {
|
||||||
return `Compiling GIF... ${renderProgress}%`;
|
return t("export.compilingGifProgress", { progress: String(renderProgress) });
|
||||||
}
|
}
|
||||||
return "Compiling GIF... This may take a while";
|
return t("export.compilingGifWait");
|
||||||
}
|
}
|
||||||
return "This may take a moment...";
|
return t("export.takeMoment");
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get title based on phase
|
// Get title based on phase
|
||||||
const getTitle = () => {
|
const getTitle = () => {
|
||||||
if (error) return "Export Failed";
|
if (error) return t("export.failed");
|
||||||
if (isFinalizing && exportFormat === "mp4") return "Finalizing Video";
|
if (isFinalizing && exportFormat === "mp4") return t("export.finalizingVideoTitle");
|
||||||
if (isCompiling || isFinalizing) return "Compiling GIF";
|
if (isCompiling || isFinalizing) return t("export.compilingGif");
|
||||||
return `Exporting ${formatLabel}`;
|
return t("export.exportingFormat", { format: formatLabel });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -101,9 +103,11 @@ export function ExportDialog({
|
|||||||
<Download className="w-6 h-6 text-[#34B27B]" />
|
<Download className="w-6 h-6 text-[#34B27B]" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span className="text-xl font-bold text-slate-200 block">Export Complete</span>
|
<span className="text-xl font-bold text-slate-200 block">
|
||||||
|
{t("export.complete")}
|
||||||
|
</span>
|
||||||
<span className="text-sm text-slate-400">
|
<span className="text-sm text-slate-400">
|
||||||
Your {formatLabel.toLowerCase()} is ready
|
{t("export.yourFormatReady", { format: formatLabel.toLowerCase() })}
|
||||||
</span>
|
</span>
|
||||||
{exportedFilePath && (
|
{exportedFilePath && (
|
||||||
<Button
|
<Button
|
||||||
@@ -111,7 +115,7 @@ export function ExportDialog({
|
|||||||
onClick={onShowInFolder}
|
onClick={onShowInFolder}
|
||||||
className="mt-2 w-fit px-3 py-1 text-sm rounded-md bg-white/10 hover:bg-white/20 text-slate-200"
|
className="mt-2 w-fit px-3 py-1 text-sm rounded-md bg-white/10 hover:bg-white/20 text-slate-200"
|
||||||
>
|
>
|
||||||
Show in Folder
|
{t("export.showInFolder")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{exportedFilePath && (
|
{exportedFilePath && (
|
||||||
@@ -166,7 +170,11 @@ export function ExportDialog({
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex justify-between text-xs font-medium text-slate-400 uppercase tracking-wider">
|
<div className="flex justify-between text-xs font-medium text-slate-400 uppercase tracking-wider">
|
||||||
<span>{isCompiling || isFinalizing ? "Compiling" : "Rendering Frames"}</span>
|
<span>
|
||||||
|
{isCompiling || isFinalizing
|
||||||
|
? t("export.compiling")
|
||||||
|
: t("export.renderingFrames")}
|
||||||
|
</span>
|
||||||
<span className="font-mono text-slate-200">
|
<span className="font-mono text-slate-200">
|
||||||
{isCompiling || isFinalizing ? (
|
{isCompiling || isFinalizing ? (
|
||||||
renderProgress !== undefined && renderProgress > 0 ? (
|
renderProgress !== undefined && renderProgress > 0 ? (
|
||||||
@@ -174,7 +182,7 @@ export function ExportDialog({
|
|||||||
) : (
|
) : (
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<Loader2 className="w-3 h-3 animate-spin" />
|
<Loader2 className="w-3 h-3 animate-spin" />
|
||||||
Processing...
|
{t("export.processing")}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
@@ -218,19 +226,19 @@ export function ExportDialog({
|
|||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="bg-white/5 rounded-xl p-3 border border-white/5">
|
<div className="bg-white/5 rounded-xl p-3 border border-white/5">
|
||||||
<div className="text-[10px] text-slate-500 uppercase tracking-wider mb-1">
|
<div className="text-[10px] text-slate-500 uppercase tracking-wider mb-1">
|
||||||
{isCompiling || isFinalizing ? "Status" : "Format"}
|
{isCompiling || isFinalizing ? t("export.status") : t("export.format")}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-slate-200 font-medium text-sm">
|
<div className="text-slate-200 font-medium text-sm">
|
||||||
{isFinalizing && exportFormat === "mp4"
|
{isFinalizing && exportFormat === "mp4"
|
||||||
? "Finalizing..."
|
? t("export.finalizing")
|
||||||
: isCompiling || isFinalizing
|
: isCompiling || isFinalizing
|
||||||
? "Compiling..."
|
? t("export.compilingStatus")
|
||||||
: formatLabel}
|
: formatLabel}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white/5 rounded-xl p-3 border border-white/5">
|
<div className="bg-white/5 rounded-xl p-3 border border-white/5">
|
||||||
<div className="text-[10px] text-slate-500 uppercase tracking-wider mb-1">
|
<div className="text-[10px] text-slate-500 uppercase tracking-wider mb-1">
|
||||||
Frames
|
{t("export.frames")}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-slate-200 font-medium text-sm">
|
<div className="text-slate-200 font-medium text-sm">
|
||||||
{progress.currentFrame} / {progress.totalFrames}
|
{progress.currentFrame} / {progress.totalFrames}
|
||||||
@@ -245,7 +253,7 @@ export function ExportDialog({
|
|||||||
variant="destructive"
|
variant="destructive"
|
||||||
className="w-full py-6 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all rounded-xl"
|
className="w-full py-6 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all rounded-xl"
|
||||||
>
|
>
|
||||||
Cancel Export
|
{t("export.cancelExport")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -254,7 +262,9 @@ export function ExportDialog({
|
|||||||
|
|
||||||
{showSuccess && (
|
{showSuccess && (
|
||||||
<div className="text-center py-4 animate-in zoom-in-95">
|
<div className="text-center py-4 animate-in zoom-in-95">
|
||||||
<p className="text-lg text-slate-200 font-medium">{formatLabel} saved successfully!</p>
|
<p className="text-lg text-slate-200 font-medium">
|
||||||
|
{t("export.savedSuccessfully", { format: formatLabel })}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Film, Image } from "lucide-react";
|
import { Film, Image } from "lucide-react";
|
||||||
|
import { useScopedT } from "@/contexts/I18nContext";
|
||||||
import type { ExportFormat } from "@/lib/exporter/types";
|
import type { ExportFormat } from "@/lib/exporter/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -8,26 +9,9 @@ interface FormatSelectorProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FormatOption {
|
const formatOptions: Array<{ value: ExportFormat; icon: React.ReactNode }> = [
|
||||||
value: ExportFormat;
|
{ value: "mp4", icon: <Film className="w-5 h-5" /> },
|
||||||
label: string;
|
{ value: "gif", icon: <Image className="w-5 h-5" /> },
|
||||||
description: string;
|
|
||||||
icon: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatOptions: FormatOption[] = [
|
|
||||||
{
|
|
||||||
value: "mp4",
|
|
||||||
label: "MP4 Video",
|
|
||||||
description: "High quality video file",
|
|
||||||
icon: <Film className="w-5 h-5" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "gif",
|
|
||||||
label: "GIF Animation",
|
|
||||||
description: "Animated image for sharing",
|
|
||||||
icon: <Image className="w-5 h-5" />,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export function FormatSelector({
|
export function FormatSelector({
|
||||||
@@ -35,10 +19,18 @@ export function FormatSelector({
|
|||||||
onFormatChange,
|
onFormatChange,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}: FormatSelectorProps) {
|
}: FormatSelectorProps) {
|
||||||
|
const t = useScopedT("settings");
|
||||||
|
|
||||||
|
const formatLabels: Record<ExportFormat, { label: string; description: string }> = {
|
||||||
|
mp4: { label: t("exportFormat.mp4Video"), description: t("exportFormat.mp4Description") },
|
||||||
|
gif: { label: t("exportFormat.gifAnimation"), description: t("exportFormat.gifDescription") },
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
{formatOptions.map((option) => {
|
{formatOptions.map((option) => {
|
||||||
const isSelected = selectedFormat === option.value;
|
const isSelected = selectedFormat === option.value;
|
||||||
|
const labels = formatLabels[option.value];
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={option.value}
|
key={option.value}
|
||||||
@@ -63,8 +55,8 @@ export function FormatSelector({
|
|||||||
{option.icon}
|
{option.icon}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="font-medium text-sm">{option.label}</div>
|
<div className="font-medium text-sm">{labels.label}</div>
|
||||||
<div className="text-xs text-slate-500 mt-0.5">{option.description}</div>
|
<div className="text-xs text-slate-500 mt-0.5">{labels.description}</div>
|
||||||
</div>
|
</div>
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<div className="absolute top-2 right-2 w-2 h-2 rounded-full bg-[#34B27B]" />
|
<div className="absolute top-2 right-2 w-2 h-2 rounded-full bg-[#34B27B]" />
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { HelpCircle, Settings2 } from "lucide-react";
|
import { HelpCircle, Settings2 } from "lucide-react";
|
||||||
|
import { useScopedT } from "@/contexts/I18nContext";
|
||||||
import { useShortcuts } from "@/contexts/ShortcutsContext";
|
import { useShortcuts } from "@/contexts/ShortcutsContext";
|
||||||
import { FIXED_SHORTCUTS, formatBinding, SHORTCUT_ACTIONS, SHORTCUT_LABELS } from "@/lib/shortcuts";
|
import { FIXED_SHORTCUTS, formatBinding, SHORTCUT_ACTIONS } from "@/lib/shortcuts";
|
||||||
|
|
||||||
export function KeyboardShortcutsHelp() {
|
export function KeyboardShortcutsHelp() {
|
||||||
const { shortcuts, isMac, openConfig } = useShortcuts();
|
const { shortcuts, isMac, openConfig } = useShortcuts();
|
||||||
|
const t = useScopedT("shortcuts");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative group">
|
<div className="relative group">
|
||||||
@@ -11,7 +13,7 @@ export function KeyboardShortcutsHelp() {
|
|||||||
|
|
||||||
<div className="absolute right-0 top-full mt-2 w-64 bg-[#09090b] border border-white/10 rounded-lg p-3 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 shadow-xl z-50">
|
<div className="absolute right-0 top-full mt-2 w-64 bg-[#09090b] border border-white/10 rounded-lg p-3 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 shadow-xl z-50">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="text-xs font-semibold text-slate-200">Keyboard Shortcuts</span>
|
<span className="text-xs font-semibold text-slate-200">{t("title")}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={openConfig}
|
onClick={openConfig}
|
||||||
@@ -19,14 +21,14 @@ export function KeyboardShortcutsHelp() {
|
|||||||
className="flex items-center gap-1 text-[10px] text-slate-500 hover:text-[#34B27B] transition-colors"
|
className="flex items-center gap-1 text-[10px] text-slate-500 hover:text-[#34B27B] transition-colors"
|
||||||
>
|
>
|
||||||
<Settings2 className="w-3 h-3" />
|
<Settings2 className="w-3 h-3" />
|
||||||
Customize
|
{t("customize")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5 text-[10px]">
|
<div className="space-y-1.5 text-[10px]">
|
||||||
{SHORTCUT_ACTIONS.map((action) => (
|
{SHORTCUT_ACTIONS.map((action) => (
|
||||||
<div key={action} className="flex items-center justify-between">
|
<div key={action} className="flex items-center justify-between">
|
||||||
<span className="text-slate-400">{SHORTCUT_LABELS[action]}</span>
|
<span className="text-slate-400">{t(`actions.${action}`)}</span>
|
||||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">
|
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">
|
||||||
{formatBinding(shortcuts[action], isMac)}
|
{formatBinding(shortcuts[action], isMac)}
|
||||||
</kbd>
|
</kbd>
|
||||||
@@ -35,8 +37,10 @@ export function KeyboardShortcutsHelp() {
|
|||||||
|
|
||||||
<div className="pt-1 border-t border-white/5 mt-1 space-y-1.5">
|
<div className="pt-1 border-t border-white/5 mt-1 space-y-1.5">
|
||||||
{FIXED_SHORTCUTS.map((fixed) => (
|
{FIXED_SHORTCUTS.map((fixed) => (
|
||||||
<div key={fixed.label} className="flex items-center justify-between">
|
<div key={fixed.i18nKey} className="flex items-center justify-between">
|
||||||
<span className="text-slate-400">{fixed.label}</span>
|
<span className="text-slate-400">
|
||||||
|
{t(`fixedActions.${fixed.i18nKey}`, { defaultValue: fixed.label })}
|
||||||
|
</span>
|
||||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">
|
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">
|
||||||
{isMac
|
{isMac
|
||||||
? fixed.display
|
? fixed.display
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Pause, Play } from "lucide-react";
|
import { Maximize, Minimize, Pause, Play } from "lucide-react";
|
||||||
|
import { useScopedT } from "@/contexts/I18nContext";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
|
|
||||||
@@ -6,6 +7,8 @@ interface PlaybackControlsProps {
|
|||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
currentTime: number;
|
currentTime: number;
|
||||||
duration: number;
|
duration: number;
|
||||||
|
isFullscreen?: boolean;
|
||||||
|
onToggleFullscreen?: () => void;
|
||||||
onTogglePlayPause: () => void;
|
onTogglePlayPause: () => void;
|
||||||
onSeek: (time: number) => void;
|
onSeek: (time: number) => void;
|
||||||
}
|
}
|
||||||
@@ -14,9 +17,13 @@ export default function PlaybackControls({
|
|||||||
isPlaying,
|
isPlaying,
|
||||||
currentTime,
|
currentTime,
|
||||||
duration,
|
duration,
|
||||||
|
isFullscreen = false,
|
||||||
|
onToggleFullscreen,
|
||||||
onTogglePlayPause,
|
onTogglePlayPause,
|
||||||
onSeek,
|
onSeek,
|
||||||
}: PlaybackControlsProps) {
|
}: PlaybackControlsProps) {
|
||||||
|
const t = useScopedT("common");
|
||||||
|
|
||||||
function formatTime(seconds: number) {
|
function formatTime(seconds: number) {
|
||||||
if (!isFinite(seconds) || isNaN(seconds) || seconds < 0) return "0:00";
|
if (!isFinite(seconds) || isNaN(seconds) || seconds < 0) return "0:00";
|
||||||
const mins = Math.floor(seconds / 60);
|
const mins = Math.floor(seconds / 60);
|
||||||
@@ -41,7 +48,7 @@ export default function PlaybackControls({
|
|||||||
? "bg-white/10 text-white hover:bg-white/20"
|
? "bg-white/10 text-white hover:bg-white/20"
|
||||||
: "bg-white text-black hover:bg-white/90 hover:scale-105 shadow-[0_0_15px_rgba(255,255,255,0.3)]",
|
: "bg-white text-black hover:bg-white/90 hover:scale-105 shadow-[0_0_15px_rgba(255,255,255,0.3)]",
|
||||||
)}
|
)}
|
||||||
aria-label={isPlaying ? "Pause" : "Play"}
|
aria-label={isPlaying ? t("playback.pause") : t("playback.play")}
|
||||||
>
|
>
|
||||||
{isPlaying ? (
|
{isPlaying ? (
|
||||||
<Pause className="w-3.5 h-3.5 fill-current" />
|
<Pause className="w-3.5 h-3.5 fill-current" />
|
||||||
@@ -84,6 +91,21 @@ export default function PlaybackControls({
|
|||||||
<span className="text-[9px] font-medium text-slate-500 tabular-nums w-[30px]">
|
<span className="text-[9px] font-medium text-slate-500 tabular-nums w-[30px]">
|
||||||
{formatTime(duration)}
|
{formatTime(duration)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
{onToggleFullscreen && (
|
||||||
|
<Button
|
||||||
|
onClick={onToggleFullscreen}
|
||||||
|
size="icon"
|
||||||
|
className="w-7 h-7 rounded-full transition-all duration-200 border border-transparent hover:bg-white/10 text-white hover:border-white/10 shrink-0 shadow-none ml-0.5"
|
||||||
|
aria-label={isFullscreen ? t("playback.exitFullscreen") : t("playback.fullscreen")}
|
||||||
|
>
|
||||||
|
{isFullscreen ? (
|
||||||
|
<Minimize className="w-3.5 h-3.5" />
|
||||||
|
) : (
|
||||||
|
<Maximize className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
import { useScopedT } from "@/contexts/I18nContext";
|
||||||
import { useShortcuts } from "@/contexts/ShortcutsContext";
|
import { useShortcuts } from "@/contexts/ShortcutsContext";
|
||||||
import {
|
import {
|
||||||
DEFAULT_SHORTCUTS,
|
DEFAULT_SHORTCUTS,
|
||||||
@@ -16,7 +17,6 @@ import {
|
|||||||
findConflict,
|
findConflict,
|
||||||
formatBinding,
|
formatBinding,
|
||||||
SHORTCUT_ACTIONS,
|
SHORTCUT_ACTIONS,
|
||||||
SHORTCUT_LABELS,
|
|
||||||
type ShortcutAction,
|
type ShortcutAction,
|
||||||
type ShortcutBinding,
|
type ShortcutBinding,
|
||||||
type ShortcutConflict,
|
type ShortcutConflict,
|
||||||
@@ -28,6 +28,8 @@ const MODIFIER_KEYS = new Set(["Control", "Shift", "Alt", "Meta"]);
|
|||||||
export function ShortcutsConfigDialog() {
|
export function ShortcutsConfigDialog() {
|
||||||
const { shortcuts, isMac, isConfigOpen, closeConfig, setShortcuts, persistShortcuts } =
|
const { shortcuts, isMac, isConfigOpen, closeConfig, setShortcuts, persistShortcuts } =
|
||||||
useShortcuts();
|
useShortcuts();
|
||||||
|
const t = useScopedT("shortcuts");
|
||||||
|
const tc = useScopedT("common");
|
||||||
|
|
||||||
const [draft, setDraft] = useState<ShortcutsConfig>(shortcuts);
|
const [draft, setDraft] = useState<ShortcutsConfig>(shortcuts);
|
||||||
const [captureFor, setCaptureFor] = useState<ShortcutAction | null>(null);
|
const [captureFor, setCaptureFor] = useState<ShortcutAction | null>(null);
|
||||||
@@ -70,7 +72,7 @@ export function ShortcutsConfigDialog() {
|
|||||||
setCaptureFor(null);
|
setCaptureFor(null);
|
||||||
|
|
||||||
if (found?.type === "fixed") {
|
if (found?.type === "fixed") {
|
||||||
toast.error(`This shortcut is reserved for "${found.label}" and cannot be reassigned.`);
|
toast.error(t("reservedShortcut", { label: found.label }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +86,7 @@ export function ShortcutsConfigDialog() {
|
|||||||
|
|
||||||
window.addEventListener("keydown", handleCapture, { capture: true });
|
window.addEventListener("keydown", handleCapture, { capture: true });
|
||||||
return () => window.removeEventListener("keydown", handleCapture, { capture: true });
|
return () => window.removeEventListener("keydown", handleCapture, { capture: true });
|
||||||
}, [captureFor, draft]);
|
}, [captureFor, draft, t]);
|
||||||
|
|
||||||
const handleSwap = useCallback(() => {
|
const handleSwap = useCallback(() => {
|
||||||
if (!conflict || conflict.conflictWith.type !== "configurable") return;
|
if (!conflict || conflict.conflictWith.type !== "configurable") return;
|
||||||
@@ -102,14 +104,14 @@ export function ShortcutsConfigDialog() {
|
|||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
setShortcuts(draft);
|
setShortcuts(draft);
|
||||||
await persistShortcuts(draft);
|
await persistShortcuts(draft);
|
||||||
toast.success("Keyboard shortcuts saved");
|
toast.success(t("savedToast"));
|
||||||
closeConfig();
|
closeConfig();
|
||||||
}, [draft, setShortcuts, persistShortcuts, closeConfig]);
|
}, [draft, setShortcuts, persistShortcuts, closeConfig, t]);
|
||||||
|
|
||||||
const handleReset = useCallback(() => {
|
const handleReset = useCallback(() => {
|
||||||
setDraft({ ...DEFAULT_SHORTCUTS });
|
setDraft({ ...DEFAULT_SHORTCUTS });
|
||||||
toast.info("Reset to default shortcuts — click Save to apply");
|
toast.info(t("resetToast"));
|
||||||
}, []);
|
}, [t]);
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
setCaptureFor(null);
|
setCaptureFor(null);
|
||||||
@@ -124,96 +126,99 @@ export function ShortcutsConfigDialog() {
|
|||||||
if (!open) handleClose();
|
if (!open) handleClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent className="bg-[#09090b] border-white/10 text-white max-w-[420px]">
|
<DialogContent className="bg-[#09090b] border-white/10 text-white max-w-[420px] max-h-[85vh] flex flex-col">
|
||||||
<DialogHeader>
|
<DialogHeader className="shrink-0">
|
||||||
<DialogTitle className="flex items-center gap-2 text-sm">
|
<DialogTitle className="flex items-center gap-2 text-sm">
|
||||||
<Keyboard className="w-4 h-4 text-[#34B27B]" />
|
<Keyboard className="w-4 h-4 text-[#34B27B]" />
|
||||||
Keyboard Shortcuts
|
{t("title")}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-0.5">
|
<div className="flex-1 min-h-0 overflow-y-auto pr-1 -mr-1">
|
||||||
<p className="text-[10px] text-slate-500 mb-2 uppercase tracking-wide font-semibold">
|
<div className="space-y-0.5">
|
||||||
Configurable
|
<p className="text-[10px] text-slate-500 mb-2 uppercase tracking-wide font-semibold">
|
||||||
</p>
|
{t("configurable")}
|
||||||
{SHORTCUT_ACTIONS.map((action) => {
|
</p>
|
||||||
const isCapturing = captureFor === action;
|
{SHORTCUT_ACTIONS.map((action) => {
|
||||||
const hasConflict = conflict?.forAction === action;
|
const isCapturing = captureFor === action;
|
||||||
return (
|
const hasConflict = conflict?.forAction === action;
|
||||||
<div key={action}>
|
return (
|
||||||
<div className="flex items-center justify-between py-1.5 px-1 border-b border-white/5">
|
<div key={action}>
|
||||||
<span className="text-sm text-slate-300">{SHORTCUT_LABELS[action]}</span>
|
<div className="flex items-center justify-between py-1.5 px-1 border-b border-white/5">
|
||||||
<button
|
<span className="text-sm text-slate-300">{t(`actions.${action}`)}</span>
|
||||||
type="button"
|
<button
|
||||||
onClick={() => {
|
type="button"
|
||||||
setConflict(null);
|
onClick={() => {
|
||||||
setCaptureFor(isCapturing ? null : action);
|
setConflict(null);
|
||||||
}}
|
setCaptureFor(isCapturing ? null : action);
|
||||||
title={isCapturing ? "Press Esc to cancel" : "Click to change"}
|
}}
|
||||||
className={[
|
title={isCapturing ? t("pressEscToCancel") : t("clickToChange")}
|
||||||
"px-2 py-1 rounded text-xs font-mono border transition-all min-w-[90px] text-center select-none",
|
className={[
|
||||||
isCapturing
|
"px-2 py-1 rounded text-xs font-mono border transition-all min-w-[90px] text-center select-none",
|
||||||
? "bg-[#34B27B]/20 border-[#34B27B] text-[#34B27B] animate-pulse"
|
isCapturing
|
||||||
: hasConflict
|
? "bg-[#34B27B]/20 border-[#34B27B] text-[#34B27B] animate-pulse"
|
||||||
? "bg-amber-500/10 border-amber-500/50 text-amber-400"
|
: hasConflict
|
||||||
: "bg-white/5 border-white/10 text-slate-200 hover:border-[#34B27B]/50 hover:text-[#34B27B] cursor-pointer",
|
? "bg-amber-500/10 border-amber-500/50 text-amber-400"
|
||||||
].join(" ")}
|
: "bg-white/5 border-white/10 text-slate-200 hover:border-[#34B27B]/50 hover:text-[#34B27B] cursor-pointer",
|
||||||
>
|
].join(" ")}
|
||||||
{isCapturing ? "Press a key…" : formatBinding(draft[action], isMac)}
|
>
|
||||||
</button>
|
{isCapturing ? t("pressKey") : formatBinding(draft[action], isMac)}
|
||||||
</div>
|
</button>
|
||||||
{hasConflict && conflict?.conflictWith.type === "configurable" && (
|
|
||||||
<div className="flex items-center justify-between px-1 py-1.5 mb-0.5 bg-amber-500/10 border border-amber-500/20 rounded text-xs">
|
|
||||||
<span className="text-amber-400">
|
|
||||||
⚠ Already used by{" "}
|
|
||||||
<strong>{SHORTCUT_LABELS[conflict.conflictWith.action]}</strong>
|
|
||||||
</span>
|
|
||||||
<div className="flex gap-1.5">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleSwap}
|
|
||||||
className="px-2 py-0.5 bg-amber-500/20 hover:bg-amber-500/30 border border-amber-500/40 rounded text-amber-300 font-medium transition-colors"
|
|
||||||
>
|
|
||||||
Swap
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleCancelConflict}
|
|
||||||
className="px-2 py-0.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded text-slate-400 transition-colors"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
{hasConflict && conflict?.conflictWith.type === "configurable" && (
|
||||||
|
<div className="flex items-center justify-between px-1 py-1.5 mb-0.5 bg-amber-500/10 border border-amber-500/20 rounded text-xs">
|
||||||
|
<span className="text-amber-400">
|
||||||
|
⚠{" "}
|
||||||
|
{t("alreadyUsedBy", {
|
||||||
|
action: t(`actions.${conflict.conflictWith.action}`),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSwap}
|
||||||
|
className="px-2 py-0.5 bg-amber-500/20 hover:bg-amber-500/30 border border-amber-500/40 rounded text-amber-300 font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{t("swap")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCancelConflict}
|
||||||
|
className="px-2 py-0.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded text-slate-400 transition-colors"
|
||||||
|
>
|
||||||
|
{tc("actions.cancel")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-0.5 mt-2">
|
||||||
|
<p className="text-[10px] text-slate-500 mb-2 uppercase tracking-wide font-semibold">
|
||||||
|
{t("fixed")}
|
||||||
|
</p>
|
||||||
|
{FIXED_SHORTCUTS.map(({ i18nKey, label, display }) => (
|
||||||
|
<div
|
||||||
|
key={i18nKey}
|
||||||
|
className="flex items-center justify-between py-1.5 px-1 border-b border-white/5 last:border-0"
|
||||||
|
>
|
||||||
|
<span className="text-sm text-slate-400">
|
||||||
|
{t(`fixedActions.${i18nKey}`, { defaultValue: label })}
|
||||||
|
</span>
|
||||||
|
<kbd className="px-2 py-1 bg-white/5 border border-white/10 rounded text-xs font-mono text-slate-400 min-w-[90px] text-center">
|
||||||
|
{display}
|
||||||
|
</kbd>
|
||||||
</div>
|
</div>
|
||||||
);
|
))}
|
||||||
})}
|
</div>
|
||||||
|
|
||||||
|
<p className="text-[10px] text-slate-500 mt-1">{t("helpText")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-0.5 mt-2">
|
<DialogFooter className="shrink-0 flex gap-2 sm:justify-between mt-2">
|
||||||
<p className="text-[10px] text-slate-500 mb-2 uppercase tracking-wide font-semibold">
|
|
||||||
Fixed
|
|
||||||
</p>
|
|
||||||
{FIXED_SHORTCUTS.map(({ label, display }) => (
|
|
||||||
<div
|
|
||||||
key={label}
|
|
||||||
className="flex items-center justify-between py-1.5 px-1 border-b border-white/5 last:border-0"
|
|
||||||
>
|
|
||||||
<span className="text-sm text-slate-400">{label}</span>
|
|
||||||
<kbd className="px-2 py-1 bg-white/5 border border-white/10 rounded text-xs font-mono text-slate-400 min-w-[90px] text-center">
|
|
||||||
{display}
|
|
||||||
</kbd>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-[10px] text-slate-500 mt-1">
|
|
||||||
Click a shortcut then press the new key combination. Press{" "}
|
|
||||||
<span className="font-mono border border-white/10 rounded px-1">Esc</span> to cancel.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<DialogFooter className="flex gap-2 sm:justify-between mt-2">
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -221,18 +226,18 @@ export function ShortcutsConfigDialog() {
|
|||||||
onClick={handleReset}
|
onClick={handleReset}
|
||||||
>
|
>
|
||||||
<RotateCcw className="w-3 h-3" />
|
<RotateCcw className="w-3 h-3" />
|
||||||
Reset to defaults
|
{t("resetToDefaults")}
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="ghost" size="sm" onClick={handleClose}>
|
<Button variant="ghost" size="sm" onClick={handleClose}>
|
||||||
Cancel
|
{tc("actions.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
className="bg-[#34B27B] hover:bg-[#2d9e6c] text-white"
|
className="bg-[#34B27B] hover:bg-[#2d9e6c] text-white"
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
>
|
>
|
||||||
Save
|
{tc("actions.save")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
import { useScopedT } from "@/contexts/I18nContext";
|
||||||
|
|
||||||
export function TutorialHelp() {
|
export function TutorialHelp() {
|
||||||
|
const t = useScopedT("dialogs");
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
@@ -19,33 +21,33 @@ export function TutorialHelp() {
|
|||||||
className="h-7 px-2 text-xs text-slate-400 hover:text-slate-200 hover:bg-white/10 transition-all gap-1.5"
|
className="h-7 px-2 text-xs text-slate-400 hover:text-slate-200 hover:bg-white/10 transition-all gap-1.5"
|
||||||
>
|
>
|
||||||
<HelpCircle className="w-3.5 h-3.5" />
|
<HelpCircle className="w-3.5 h-3.5" />
|
||||||
<span className="font-medium">How trimming works</span>
|
<span className="font-medium">{t("tutorial.triggerLabel")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="max-w-2xl bg-[#09090b] border-white/10 [&>button]:text-slate-400 [&>button:hover]:text-white">
|
<DialogContent className="max-w-2xl bg-[#09090b] border-white/10 [&>button]:text-slate-400 [&>button:hover]:text-white">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-xl font-semibold text-slate-200 flex items-center gap-2">
|
<DialogTitle className="text-xl font-semibold text-slate-200 flex items-center gap-2">
|
||||||
<Scissors className="w-5 h-5 text-[#ef4444]" /> How Trimming Works
|
<Scissors className="w-5 h-5 text-[#ef4444]" /> {t("tutorial.title")}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="text-slate-400">
|
<DialogDescription className="text-slate-400">
|
||||||
Understanding how to cut out unwanted parts of your video.
|
{t("tutorial.description")}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="mt-4 space-y-8">
|
<div className="mt-4 space-y-8">
|
||||||
{/* Explanation */}
|
{/* Explanation */}
|
||||||
<div className="bg-white/5 rounded-lg p-4 border border-white/5">
|
<div className="bg-white/5 rounded-lg p-4 border border-white/5">
|
||||||
<p className="text-slate-300 leading-relaxed">
|
<p className="text-slate-300 leading-relaxed">
|
||||||
The Trim tool works by defining the segments you want to
|
{t("tutorial.explanationBefore")}
|
||||||
<span className="text-[#ef4444] font-bold"> remove</span>. Any part of the timeline
|
<span className="text-[#ef4444] font-bold"> {t("tutorial.remove")}</span>
|
||||||
that is
|
{t("tutorial.explanationMiddle")}
|
||||||
<span className="text-[#ef4444] font-bold"> covered</span> by a red trim segment will
|
<span className="text-[#ef4444] font-bold"> {t("tutorial.covered")}</span>
|
||||||
be cut out when you export.
|
{t("tutorial.explanationAfter")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{/* Visual Illustration */}
|
{/* Visual Illustration */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h3 className="text-sm font-medium text-slate-400 uppercase tracking-wider">
|
<h3 className="text-sm font-medium text-slate-400 uppercase tracking-wider">
|
||||||
Visual Example
|
{t("tutorial.visualExample")}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="relative h-24 bg-[#000] rounded-lg border border-white/10 flex items-center px-4 overflow-hidden select-none">
|
<div className="relative h-24 bg-[#000] rounded-lg border border-white/10 flex items-center px-4 overflow-hidden select-none">
|
||||||
{/* Background track (Kept parts) */}
|
{/* Background track (Kept parts) */}
|
||||||
@@ -58,7 +60,7 @@ export function TutorialHelp() {
|
|||||||
style={{ width: "20%" }}
|
style={{ width: "20%" }}
|
||||||
>
|
>
|
||||||
<span className="text-[10px] font-bold text-[#ef4444] bg-black/50 px-1 rounded">
|
<span className="text-[10px] font-bold text-[#ef4444] bg-black/50 px-1 rounded">
|
||||||
REMOVED
|
{t("tutorial.removed")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/* Removed Segment 2 */}
|
{/* Removed Segment 2 */}
|
||||||
@@ -67,13 +69,19 @@ export function TutorialHelp() {
|
|||||||
style={{ width: "15%" }}
|
style={{ width: "15%" }}
|
||||||
>
|
>
|
||||||
<span className="text-[10px] font-bold text-[#ef4444] bg-black/50 px-1 rounded">
|
<span className="text-[10px] font-bold text-[#ef4444] bg-black/50 px-1 rounded">
|
||||||
REMOVED
|
{t("tutorial.removed")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/* Labels for kept parts */}
|
{/* Labels for kept parts */}
|
||||||
<div className="absolute left-[5%] text-[10px] text-slate-400 font-medium">Kept</div>
|
<div className="absolute left-[5%] text-[10px] text-slate-400 font-medium">
|
||||||
<div className="absolute left-[50%] text-[10px] text-slate-400 font-medium">Kept</div>
|
{t("tutorial.kept")}
|
||||||
<div className="absolute left-[90%] text-[10px] text-slate-400 font-medium">Kept</div>
|
</div>
|
||||||
|
<div className="absolute left-[50%] text-[10px] text-slate-400 font-medium">
|
||||||
|
{t("tutorial.kept")}
|
||||||
|
</div>
|
||||||
|
<div className="absolute left-[90%] text-[10px] text-slate-400 font-medium">
|
||||||
|
{t("tutorial.kept")}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-center mt-2">
|
<div className="flex justify-center mt-2">
|
||||||
<ArrowRight className="w-4 h-4 text-slate-600 rotate-90" />
|
<ArrowRight className="w-4 h-4 text-slate-600 rotate-90" />
|
||||||
@@ -84,38 +92,38 @@ export function TutorialHelp() {
|
|||||||
className="h-8 bg-slate-700 rounded flex items-center justify-center opacity-80"
|
className="h-8 bg-slate-700 rounded flex items-center justify-center opacity-80"
|
||||||
style={{ width: "30%" }}
|
style={{ width: "30%" }}
|
||||||
>
|
>
|
||||||
<span className="text-[10px] text-white font-medium">Part 1</span>
|
<span className="text-[10px] text-white font-medium">{t("tutorial.part1")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="h-8 bg-slate-700 rounded flex items-center justify-center opacity-80"
|
className="h-8 bg-slate-700 rounded flex items-center justify-center opacity-80"
|
||||||
style={{ width: "30%" }}
|
style={{ width: "30%" }}
|
||||||
>
|
>
|
||||||
<span className="text-[10px] text-white font-medium">Part 2</span>
|
<span className="text-[10px] text-white font-medium">{t("tutorial.part2")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="h-8 bg-slate-700 rounded flex items-center justify-center opacity-80"
|
className="h-8 bg-slate-700 rounded flex items-center justify-center opacity-80"
|
||||||
style={{ width: "30%" }}
|
style={{ width: "30%" }}
|
||||||
>
|
>
|
||||||
<span className="text-[10px] text-white font-medium">Part 3</span>
|
<span className="text-[10px] text-white font-medium">{t("tutorial.part3")}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="absolute right-4 text-xs text-slate-400">Final Video</span>
|
<span className="absolute right-4 text-xs text-slate-400">
|
||||||
|
{t("tutorial.finalVideo")}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Steps */}
|
{/* Steps */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="p-3 rounded bg-white/5 border border-white/5">
|
<div className="p-3 rounded bg-white/5 border border-white/5">
|
||||||
<div className="text-[#ef4444] font-bold mb-1">1. Add Trim</div>
|
<div className="text-[#ef4444] font-bold mb-1">{t("tutorial.step1Title")}</div>
|
||||||
<p className="text-xs text-slate-400">
|
<p className="text-xs text-slate-400">
|
||||||
Press
|
{t("tutorial.step1DescriptionBefore")}
|
||||||
<kbd className="bg-white/10 px-1 rounded text-slate-300">T</kbd>
|
<kbd className="bg-white/10 px-1 rounded text-slate-300">T</kbd>
|
||||||
or click the scissors icon to mark a section for removal.
|
{t("tutorial.step1DescriptionAfter")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 rounded bg-white/5 border border-white/5">
|
<div className="p-3 rounded bg-white/5 border border-white/5">
|
||||||
<div className="text-[#ef4444] font-bold mb-1">2. Adjust</div>
|
<div className="text-[#ef4444] font-bold mb-1">{t("tutorial.step2Title")}</div>
|
||||||
<p className="text-xs text-slate-400">
|
<p className="text-xs text-slate-400">{t("tutorial.step2Description")}</p>
|
||||||
Drag the edges of the red region to cover exactly what you want to cut out.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,9 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
createProjectData,
|
createProjectData,
|
||||||
|
createProjectSnapshot,
|
||||||
|
hasProjectUnsavedChanges,
|
||||||
|
normalizeProjectEditor,
|
||||||
PROJECT_VERSION,
|
PROJECT_VERSION,
|
||||||
resolveProjectMedia,
|
resolveProjectMedia,
|
||||||
validateProjectData,
|
validateProjectData,
|
||||||
@@ -39,6 +42,9 @@ describe("projectPersistence media compatibility", () => {
|
|||||||
speedRegions: [],
|
speedRegions: [],
|
||||||
annotationRegions: [],
|
annotationRegions: [],
|
||||||
aspectRatio: "16:9",
|
aspectRatio: "16:9",
|
||||||
|
webcamLayoutPreset: "picture-in-picture",
|
||||||
|
webcamMaskShape: "circle",
|
||||||
|
webcamPosition: null,
|
||||||
exportQuality: "good",
|
exportQuality: "good",
|
||||||
exportFormat: "mp4",
|
exportFormat: "mp4",
|
||||||
gifFrameRate: 15,
|
gifFrameRate: 15,
|
||||||
@@ -54,4 +60,199 @@ describe("projectPersistence media compatibility", () => {
|
|||||||
});
|
});
|
||||||
expect(validateProjectData(project)).toBe(true);
|
expect(validateProjectData(project)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("normalizes webcam mask shape values safely", () => {
|
||||||
|
expect(normalizeProjectEditor({ webcamMaskShape: "rounded" }).webcamMaskShape).toBe("rounded");
|
||||||
|
expect(
|
||||||
|
normalizeProjectEditor({ webcamMaskShape: "not-a-real-shape" as never }).webcamMaskShape,
|
||||||
|
).toBe("rectangle");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes blur region type and mosaic block size safely", () => {
|
||||||
|
const editor = normalizeProjectEditor({
|
||||||
|
annotationRegions: [
|
||||||
|
{
|
||||||
|
id: "annotation-1",
|
||||||
|
startMs: 0,
|
||||||
|
endMs: 500,
|
||||||
|
type: "blur",
|
||||||
|
content: "",
|
||||||
|
position: { x: 10, y: 10 },
|
||||||
|
size: { width: 20, height: 20 },
|
||||||
|
style: {
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
fontSize: 32,
|
||||||
|
fontFamily: "Inter",
|
||||||
|
fontWeight: "bold",
|
||||||
|
fontStyle: "normal",
|
||||||
|
textDecoration: "none",
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
zIndex: 1,
|
||||||
|
blurData: {
|
||||||
|
type: "mosaic",
|
||||||
|
shape: "rectangle",
|
||||||
|
color: "black",
|
||||||
|
intensity: 999,
|
||||||
|
blockSize: 999,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "annotation-2",
|
||||||
|
startMs: 0,
|
||||||
|
endMs: 500,
|
||||||
|
type: "blur",
|
||||||
|
content: "",
|
||||||
|
position: { x: 10, y: 10 },
|
||||||
|
size: { width: 20, height: 20 },
|
||||||
|
style: {
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
fontSize: 32,
|
||||||
|
fontFamily: "Inter",
|
||||||
|
fontWeight: "bold",
|
||||||
|
fontStyle: "normal",
|
||||||
|
textDecoration: "none",
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
zIndex: 2,
|
||||||
|
blurData: {
|
||||||
|
type: "invalid" as never,
|
||||||
|
shape: "rectangle",
|
||||||
|
color: "invalid" as never,
|
||||||
|
intensity: 10,
|
||||||
|
blockSize: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(editor.annotationRegions[0].blurData?.type).toBe("mosaic");
|
||||||
|
expect(editor.annotationRegions[0].blurData?.color).toBe("black");
|
||||||
|
expect(editor.annotationRegions[0].blurData?.intensity).toBe(40);
|
||||||
|
expect(editor.annotationRegions[0].blurData?.blockSize).toBe(48);
|
||||||
|
expect(editor.annotationRegions[1].blurData?.type).toBe("blur");
|
||||||
|
expect(editor.annotationRegions[1].blurData?.color).toBe("white");
|
||||||
|
expect(editor.annotationRegions[1].blurData?.blockSize).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts the dual frame webcam layout preset", () => {
|
||||||
|
expect(normalizeProjectEditor({ webcamLayoutPreset: "dual-frame" }).webcamLayoutPreset).toBe(
|
||||||
|
"dual-frame",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back from dual frame to picture in picture for portrait aspect ratios", () => {
|
||||||
|
expect(
|
||||||
|
normalizeProjectEditor({
|
||||||
|
aspectRatio: "9:16",
|
||||||
|
webcamLayoutPreset: "dual-frame",
|
||||||
|
}).webcamLayoutPreset,
|
||||||
|
).toBe("picture-in-picture");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears webcamPosition when the normalized preset is not picture in picture", () => {
|
||||||
|
expect(
|
||||||
|
normalizeProjectEditor({
|
||||||
|
webcamLayoutPreset: "dual-frame",
|
||||||
|
webcamPosition: { cx: 0.2, cy: 0.8 },
|
||||||
|
}).webcamPosition,
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates stable snapshots for identical project state", () => {
|
||||||
|
const media = {
|
||||||
|
screenVideoPath: "/tmp/screen.webm",
|
||||||
|
webcamVideoPath: "/tmp/webcam.webm",
|
||||||
|
};
|
||||||
|
const editor = normalizeProjectEditor({
|
||||||
|
wallpaper: "/wallpapers/wallpaper1.jpg",
|
||||||
|
shadowIntensity: 0,
|
||||||
|
showBlur: false,
|
||||||
|
motionBlurAmount: 0,
|
||||||
|
borderRadius: 0,
|
||||||
|
padding: 50,
|
||||||
|
cropRegion: { x: 0, y: 0, width: 1, height: 1 },
|
||||||
|
zoomRegions: [],
|
||||||
|
trimRegions: [],
|
||||||
|
speedRegions: [],
|
||||||
|
annotationRegions: [],
|
||||||
|
aspectRatio: "16:9",
|
||||||
|
webcamLayoutPreset: "picture-in-picture",
|
||||||
|
webcamMaskShape: "circle",
|
||||||
|
exportQuality: "good",
|
||||||
|
exportFormat: "mp4",
|
||||||
|
gifFrameRate: 15,
|
||||||
|
gifLoop: true,
|
||||||
|
gifSizePreset: "medium",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(createProjectSnapshot(media, editor)).toBe(createProjectSnapshot(media, editor));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects unsaved changes from differing snapshots", () => {
|
||||||
|
expect(hasProjectUnsavedChanges(null, null)).toBe(false);
|
||||||
|
expect(hasProjectUnsavedChanges("same", "same")).toBe(false);
|
||||||
|
expect(hasProjectUnsavedChanges("current", "baseline")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("wallpaper legacy normalization", () => {
|
||||||
|
it("rewrites pre-fix packaged paths (resources/assets/wallpapers/…)", () => {
|
||||||
|
const normalized = normalizeProjectEditor({
|
||||||
|
wallpaper: "file:///opt/Openscreen/resources/assets/wallpapers/wallpaper5.jpg",
|
||||||
|
});
|
||||||
|
expect(normalized.wallpaper).toBe("/wallpapers/wallpaper5.jpg");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rewrites new packaged layout (resources/wallpapers/…)", () => {
|
||||||
|
const normalized = normalizeProjectEditor({
|
||||||
|
wallpaper: "file:///opt/Openscreen/resources/wallpapers/wallpaper3.jpg",
|
||||||
|
});
|
||||||
|
expect(normalized.wallpaper).toBe("/wallpapers/wallpaper3.jpg");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rewrites unpackaged dev layout (public/wallpapers/…)", () => {
|
||||||
|
const normalized = normalizeProjectEditor({
|
||||||
|
wallpaper: "file:///home/user/project/public/wallpapers/wallpaper1.jpg",
|
||||||
|
});
|
||||||
|
expect(normalized.wallpaper).toBe("/wallpapers/wallpaper1.jpg");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rewrites Windows-style file URLs with drive letter", () => {
|
||||||
|
const normalized = normalizeProjectEditor({
|
||||||
|
wallpaper: "file:///C:/Users/me/openscreen/resources/wallpapers/wallpaper2.jpg",
|
||||||
|
});
|
||||||
|
expect(normalized.wallpaper).toBe("/wallpapers/wallpaper2.jpg");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves canonical relative paths untouched", () => {
|
||||||
|
const normalized = normalizeProjectEditor({ wallpaper: "/wallpapers/wallpaper2.jpg" });
|
||||||
|
expect(normalized.wallpaper).toBe("/wallpapers/wallpaper2.jpg");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves data URIs untouched", () => {
|
||||||
|
const dataUri = "data:image/png;base64,AAA";
|
||||||
|
expect(normalizeProjectEditor({ wallpaper: dataUri }).wallpaper).toBe(dataUri);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves colors and gradients untouched", () => {
|
||||||
|
expect(normalizeProjectEditor({ wallpaper: "#1a1a2e" }).wallpaper).toBe("#1a1a2e");
|
||||||
|
expect(
|
||||||
|
normalizeProjectEditor({ wallpaper: "linear-gradient(90deg, red, blue)" }).wallpaper,
|
||||||
|
).toBe("linear-gradient(90deg, red, blue)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT rewrite user files outside the known install layout", () => {
|
||||||
|
const userPath = "file:///home/user/Pictures/wallpapers/wallpaper1.jpg";
|
||||||
|
expect(normalizeProjectEditor({ wallpaper: userPath }).wallpaper).toBe(userPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to default for bundled paths outside WALLPAPER_PATHS", () => {
|
||||||
|
const normalized = normalizeProjectEditor({
|
||||||
|
wallpaper: "file:///opt/Openscreen/resources/wallpapers/wallpaper99.jpg",
|
||||||
|
});
|
||||||
|
expect(normalized.wallpaper).toBe("/wallpapers/wallpaper1.jpg");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,28 +1,60 @@
|
|||||||
|
import { normalizeBlurColor, normalizeBlurType } from "@/lib/blurEffects";
|
||||||
import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter";
|
import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter";
|
||||||
import type { ProjectMedia } from "@/lib/recordingSession";
|
import type { ProjectMedia } from "@/lib/recordingSession";
|
||||||
import { normalizeProjectMedia } from "@/lib/recordingSession";
|
import { normalizeProjectMedia } from "@/lib/recordingSession";
|
||||||
import { ASPECT_RATIOS, type AspectRatio } from "@/utils/aspectRatioUtils";
|
import { DEFAULT_WALLPAPER, WALLPAPER_PATHS } from "@/lib/wallpaper";
|
||||||
|
import { ASPECT_RATIOS, type AspectRatio, isPortraitAspectRatio } from "@/utils/aspectRatioUtils";
|
||||||
import {
|
import {
|
||||||
type AnnotationRegion,
|
type AnnotationRegion,
|
||||||
type CropRegion,
|
type CropRegion,
|
||||||
|
clampPlaybackSpeed,
|
||||||
DEFAULT_ANNOTATION_POSITION,
|
DEFAULT_ANNOTATION_POSITION,
|
||||||
DEFAULT_ANNOTATION_SIZE,
|
DEFAULT_ANNOTATION_SIZE,
|
||||||
DEFAULT_ANNOTATION_STYLE,
|
DEFAULT_ANNOTATION_STYLE,
|
||||||
|
DEFAULT_BLUR_BLOCK_SIZE,
|
||||||
|
DEFAULT_BLUR_DATA,
|
||||||
|
DEFAULT_BLUR_FREEHAND_POINTS,
|
||||||
|
DEFAULT_BLUR_INTENSITY,
|
||||||
DEFAULT_CROP_REGION,
|
DEFAULT_CROP_REGION,
|
||||||
DEFAULT_FIGURE_DATA,
|
DEFAULT_FIGURE_DATA,
|
||||||
DEFAULT_PLAYBACK_SPEED,
|
DEFAULT_PLAYBACK_SPEED,
|
||||||
|
DEFAULT_WEBCAM_LAYOUT_PRESET,
|
||||||
|
DEFAULT_WEBCAM_MASK_SHAPE,
|
||||||
|
DEFAULT_WEBCAM_POSITION,
|
||||||
|
DEFAULT_WEBCAM_SIZE_PRESET,
|
||||||
DEFAULT_ZOOM_DEPTH,
|
DEFAULT_ZOOM_DEPTH,
|
||||||
|
MAX_BLUR_BLOCK_SIZE,
|
||||||
|
MAX_BLUR_INTENSITY,
|
||||||
|
MAX_PLAYBACK_SPEED,
|
||||||
|
MIN_BLUR_BLOCK_SIZE,
|
||||||
|
MIN_BLUR_INTENSITY,
|
||||||
|
MIN_PLAYBACK_SPEED,
|
||||||
type SpeedRegion,
|
type SpeedRegion,
|
||||||
type TrimRegion,
|
type TrimRegion,
|
||||||
|
type WebcamLayoutPreset,
|
||||||
|
type WebcamMaskShape,
|
||||||
|
type WebcamPosition,
|
||||||
|
type WebcamSizePreset,
|
||||||
type ZoomRegion,
|
type ZoomRegion,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
const WALLPAPER_COUNT = 18;
|
const VALID_BLUR_SHAPES = new Set(["rectangle", "oval", "freehand"] as const);
|
||||||
|
|
||||||
export const WALLPAPER_PATHS = Array.from(
|
// Pre-fix projects could persist resolved file:// URLs (machine-specific) for
|
||||||
{ length: WALLPAPER_COUNT },
|
// bundled wallpapers. Rewrite only paths that match a known install layout
|
||||||
(_, i) => `/wallpapers/wallpaper${i + 1}.jpg`,
|
// (resources/[assets/]wallpapers for packaged, public/wallpapers for dev) so
|
||||||
);
|
// a legitimate user file that happens to live in a folder named "wallpapers"
|
||||||
|
// elsewhere is never silently replaced.
|
||||||
|
const LEGACY_FILE_WALLPAPER_RE =
|
||||||
|
/^file:\/\/.*?\/(?:resources\/(?:assets\/)?|public\/)wallpapers\/(wallpaper\d+\.jpg)$/i;
|
||||||
|
const CANONICAL_WALLPAPERS = new Set(WALLPAPER_PATHS);
|
||||||
|
|
||||||
|
function normalizeWallpaperValue(value: string): string {
|
||||||
|
const match = LEGACY_FILE_WALLPAPER_RE.exec(value);
|
||||||
|
if (!match) return value;
|
||||||
|
const canonical = `/wallpapers/${match[1]}`;
|
||||||
|
return CANONICAL_WALLPAPERS.has(canonical) ? canonical : DEFAULT_WALLPAPER;
|
||||||
|
}
|
||||||
|
|
||||||
export const PROJECT_VERSION = 2;
|
export const PROJECT_VERSION = 2;
|
||||||
|
|
||||||
@@ -39,11 +71,16 @@ export interface ProjectEditorState {
|
|||||||
speedRegions: SpeedRegion[];
|
speedRegions: SpeedRegion[];
|
||||||
annotationRegions: AnnotationRegion[];
|
annotationRegions: AnnotationRegion[];
|
||||||
aspectRatio: AspectRatio;
|
aspectRatio: AspectRatio;
|
||||||
|
webcamLayoutPreset: WebcamLayoutPreset;
|
||||||
|
webcamMaskShape: WebcamMaskShape;
|
||||||
|
webcamSizePreset: WebcamSizePreset;
|
||||||
|
webcamPosition: WebcamPosition | null;
|
||||||
exportQuality: ExportQuality;
|
exportQuality: ExportQuality;
|
||||||
exportFormat: ExportFormat;
|
exportFormat: ExportFormat;
|
||||||
gifFrameRate: GifFrameRate;
|
gifFrameRate: GifFrameRate;
|
||||||
gifLoop: boolean;
|
gifLoop: boolean;
|
||||||
gifSizePreset: GifSizePreset;
|
gifSizePreset: GifSizePreset;
|
||||||
|
cursorHighlight: import("./videoPlayback/cursorHighlight").CursorHighlightConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EditorProjectData {
|
export interface EditorProjectData {
|
||||||
@@ -57,6 +94,26 @@ function isFiniteNumber(value: unknown): value is number {
|
|||||||
return typeof value === "number" && Number.isFinite(value);
|
return typeof value === "number" && Number.isFinite(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function computeNormalizedWebcamLayoutPreset(
|
||||||
|
webcamLayoutPreset: Partial<ProjectEditorState>["webcamLayoutPreset"],
|
||||||
|
normalizedAspectRatio: AspectRatio,
|
||||||
|
): WebcamLayoutPreset {
|
||||||
|
switch (webcamLayoutPreset) {
|
||||||
|
case "picture-in-picture":
|
||||||
|
return webcamLayoutPreset;
|
||||||
|
case "vertical-stack":
|
||||||
|
return isPortraitAspectRatio(normalizedAspectRatio)
|
||||||
|
? webcamLayoutPreset
|
||||||
|
: DEFAULT_WEBCAM_LAYOUT_PRESET;
|
||||||
|
case "dual-frame":
|
||||||
|
return isPortraitAspectRatio(normalizedAspectRatio)
|
||||||
|
? DEFAULT_WEBCAM_LAYOUT_PRESET
|
||||||
|
: webcamLayoutPreset;
|
||||||
|
default:
|
||||||
|
return DEFAULT_WEBCAM_LAYOUT_PRESET;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function clamp(value: number, min: number, max: number) {
|
function clamp(value: number, min: number, max: number) {
|
||||||
return Math.min(max, Math.max(min, value));
|
return Math.min(max, Math.max(min, value));
|
||||||
}
|
}
|
||||||
@@ -164,6 +221,26 @@ export function resolveProjectMedia(
|
|||||||
|
|
||||||
export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): ProjectEditorState {
|
export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): ProjectEditorState {
|
||||||
const validAspectRatios = new Set<AspectRatio>(ASPECT_RATIOS);
|
const validAspectRatios = new Set<AspectRatio>(ASPECT_RATIOS);
|
||||||
|
const normalizedAspectRatio: AspectRatio = validAspectRatios.has(
|
||||||
|
editor.aspectRatio as AspectRatio,
|
||||||
|
)
|
||||||
|
? (editor.aspectRatio as AspectRatio)
|
||||||
|
: "16:9";
|
||||||
|
const normalizedWebcamLayoutPreset = computeNormalizedWebcamLayoutPreset(
|
||||||
|
editor.webcamLayoutPreset,
|
||||||
|
normalizedAspectRatio,
|
||||||
|
);
|
||||||
|
const normalizedWebcamPosition: WebcamPosition | null =
|
||||||
|
normalizedWebcamLayoutPreset === "picture-in-picture" &&
|
||||||
|
editor.webcamPosition &&
|
||||||
|
typeof editor.webcamPosition === "object" &&
|
||||||
|
isFiniteNumber((editor.webcamPosition as WebcamPosition).cx) &&
|
||||||
|
isFiniteNumber((editor.webcamPosition as WebcamPosition).cy)
|
||||||
|
? {
|
||||||
|
cx: clamp((editor.webcamPosition as WebcamPosition).cx, 0, 1),
|
||||||
|
cy: clamp((editor.webcamPosition as WebcamPosition).cy, 0, 1),
|
||||||
|
}
|
||||||
|
: DEFAULT_WEBCAM_POSITION;
|
||||||
|
|
||||||
const normalizedZoomRegions: ZoomRegion[] = Array.isArray(editor.zoomRegions)
|
const normalizedZoomRegions: ZoomRegion[] = Array.isArray(editor.zoomRegions)
|
||||||
? editor.zoomRegions
|
? editor.zoomRegions
|
||||||
@@ -174,6 +251,12 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
|||||||
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
|
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
|
||||||
const endMs = Math.max(startMs + 1, rawEnd);
|
const endMs = Math.max(startMs + 1, rawEnd);
|
||||||
|
|
||||||
|
const validPreset =
|
||||||
|
region.rotationPreset === "iso" ||
|
||||||
|
region.rotationPreset === "left" ||
|
||||||
|
region.rotationPreset === "right"
|
||||||
|
? region.rotationPreset
|
||||||
|
: undefined;
|
||||||
return {
|
return {
|
||||||
id: region.id,
|
id: region.id,
|
||||||
startMs,
|
startMs,
|
||||||
@@ -183,6 +266,8 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
|||||||
cx: clamp(isFiniteNumber(region.focus?.cx) ? region.focus.cx : 0.5, 0, 1),
|
cx: clamp(isFiniteNumber(region.focus?.cx) ? region.focus.cx : 0.5, 0, 1),
|
||||||
cy: clamp(isFiniteNumber(region.focus?.cy) ? region.focus.cy : 0.5, 0, 1),
|
cy: clamp(isFiniteNumber(region.focus?.cy) ? region.focus.cy : 0.5, 0, 1),
|
||||||
},
|
},
|
||||||
|
focusMode: region.focusMode === "auto" ? "auto" : "manual",
|
||||||
|
...(validPreset ? { rotationPreset: validPreset } : {}),
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
@@ -213,14 +298,10 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
|||||||
const endMs = Math.max(startMs + 1, rawEnd);
|
const endMs = Math.max(startMs + 1, rawEnd);
|
||||||
|
|
||||||
const speed =
|
const speed =
|
||||||
region.speed === 0.25 ||
|
isFiniteNumber(region.speed) &&
|
||||||
region.speed === 0.5 ||
|
region.speed >= MIN_PLAYBACK_SPEED &&
|
||||||
region.speed === 0.75 ||
|
region.speed <= MAX_PLAYBACK_SPEED
|
||||||
region.speed === 1.25 ||
|
? clampPlaybackSpeed(region.speed)
|
||||||
region.speed === 1.5 ||
|
|
||||||
region.speed === 1.75 ||
|
|
||||||
region.speed === 2
|
|
||||||
? region.speed
|
|
||||||
: DEFAULT_PLAYBACK_SPEED;
|
: DEFAULT_PLAYBACK_SPEED;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -242,12 +323,22 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
|||||||
const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000;
|
const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000;
|
||||||
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
|
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
|
||||||
const endMs = Math.max(startMs + 1, rawEnd);
|
const endMs = Math.max(startMs + 1, rawEnd);
|
||||||
|
const blurShape =
|
||||||
|
typeof region.blurData?.shape === "string" &&
|
||||||
|
VALID_BLUR_SHAPES.has(region.blurData.shape)
|
||||||
|
? region.blurData.shape
|
||||||
|
: DEFAULT_BLUR_DATA.shape;
|
||||||
|
const blurType = normalizeBlurType(region.blurData?.type);
|
||||||
|
const blurColor = normalizeBlurColor(region.blurData?.color);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: region.id,
|
id: region.id,
|
||||||
startMs,
|
startMs,
|
||||||
endMs,
|
endMs,
|
||||||
type: region.type === "image" || region.type === "figure" ? region.type : "text",
|
type:
|
||||||
|
region.type === "image" || region.type === "figure" || region.type === "blur"
|
||||||
|
? region.type
|
||||||
|
: "text",
|
||||||
content: typeof region.content === "string" ? region.content : "",
|
content: typeof region.content === "string" ? region.content : "",
|
||||||
textContent: typeof region.textContent === "string" ? region.textContent : undefined,
|
textContent: typeof region.textContent === "string" ? region.textContent : undefined,
|
||||||
imageContent: typeof region.imageContent === "string" ? region.imageContent : undefined,
|
imageContent: typeof region.imageContent === "string" ? region.imageContent : undefined,
|
||||||
@@ -294,6 +385,42 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
|||||||
...region.figureData,
|
...region.figureData,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
|
blurData:
|
||||||
|
region.blurData && typeof region.blurData === "object"
|
||||||
|
? {
|
||||||
|
...DEFAULT_BLUR_DATA,
|
||||||
|
...region.blurData,
|
||||||
|
type: blurType,
|
||||||
|
shape: blurShape,
|
||||||
|
color: blurColor,
|
||||||
|
intensity: isFiniteNumber(region.blurData.intensity)
|
||||||
|
? clamp(region.blurData.intensity, MIN_BLUR_INTENSITY, MAX_BLUR_INTENSITY)
|
||||||
|
: DEFAULT_BLUR_INTENSITY,
|
||||||
|
blockSize: isFiniteNumber(region.blurData.blockSize)
|
||||||
|
? clamp(region.blurData.blockSize, MIN_BLUR_BLOCK_SIZE, MAX_BLUR_BLOCK_SIZE)
|
||||||
|
: DEFAULT_BLUR_BLOCK_SIZE,
|
||||||
|
freehandPoints: Array.isArray(region.blurData.freehandPoints)
|
||||||
|
? region.blurData.freehandPoints
|
||||||
|
.filter(
|
||||||
|
(
|
||||||
|
point,
|
||||||
|
): point is {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
} =>
|
||||||
|
Boolean(
|
||||||
|
point &&
|
||||||
|
isFiniteNumber((point as { x?: unknown }).x) &&
|
||||||
|
isFiniteNumber((point as { y?: unknown }).y),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.map((point) => ({
|
||||||
|
x: clamp(point.x, 0, 100),
|
||||||
|
y: clamp(point.y, 0, 100),
|
||||||
|
}))
|
||||||
|
: DEFAULT_BLUR_FREEHAND_POINTS,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
@@ -317,7 +444,10 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
|||||||
const cropHeight = clamp(rawCropHeight, 0.01, 1 - cropY);
|
const cropHeight = clamp(rawCropHeight, 0.01, 1 - cropY);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
wallpaper: typeof editor.wallpaper === "string" ? editor.wallpaper : WALLPAPER_PATHS[0],
|
wallpaper:
|
||||||
|
typeof editor.wallpaper === "string"
|
||||||
|
? normalizeWallpaperValue(editor.wallpaper)
|
||||||
|
: DEFAULT_WALLPAPER,
|
||||||
shadowIntensity: typeof editor.shadowIntensity === "number" ? editor.shadowIntensity : 0,
|
shadowIntensity: typeof editor.shadowIntensity === "number" ? editor.shadowIntensity : 0,
|
||||||
showBlur: typeof editor.showBlur === "boolean" ? editor.showBlur : false,
|
showBlur: typeof editor.showBlur === "boolean" ? editor.showBlur : false,
|
||||||
motionBlurAmount: isFiniteNumber(editor.motionBlurAmount)
|
motionBlurAmount: isFiniteNumber(editor.motionBlurAmount)
|
||||||
@@ -339,8 +469,20 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
|||||||
trimRegions: normalizedTrimRegions,
|
trimRegions: normalizedTrimRegions,
|
||||||
speedRegions: normalizedSpeedRegions,
|
speedRegions: normalizedSpeedRegions,
|
||||||
annotationRegions: normalizedAnnotationRegions,
|
annotationRegions: normalizedAnnotationRegions,
|
||||||
aspectRatio:
|
aspectRatio: normalizedAspectRatio,
|
||||||
editor.aspectRatio && validAspectRatios.has(editor.aspectRatio) ? editor.aspectRatio : "16:9",
|
webcamLayoutPreset: normalizedWebcamLayoutPreset,
|
||||||
|
webcamMaskShape:
|
||||||
|
editor.webcamMaskShape === "rectangle" ||
|
||||||
|
editor.webcamMaskShape === "circle" ||
|
||||||
|
editor.webcamMaskShape === "square" ||
|
||||||
|
editor.webcamMaskShape === "rounded"
|
||||||
|
? editor.webcamMaskShape
|
||||||
|
: DEFAULT_WEBCAM_MASK_SHAPE,
|
||||||
|
webcamSizePreset:
|
||||||
|
typeof editor.webcamSizePreset === "number" && isFiniteNumber(editor.webcamSizePreset)
|
||||||
|
? Math.max(10, Math.min(50, editor.webcamSizePreset))
|
||||||
|
: DEFAULT_WEBCAM_SIZE_PRESET,
|
||||||
|
webcamPosition: normalizedWebcamPosition,
|
||||||
exportQuality:
|
exportQuality:
|
||||||
editor.exportQuality === "medium" || editor.exportQuality === "source"
|
editor.exportQuality === "medium" || editor.exportQuality === "source"
|
||||||
? editor.exportQuality
|
? editor.exportQuality
|
||||||
@@ -360,6 +502,52 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
|||||||
editor.gifSizePreset === "original"
|
editor.gifSizePreset === "original"
|
||||||
? editor.gifSizePreset
|
? editor.gifSizePreset
|
||||||
: "medium",
|
: "medium",
|
||||||
|
cursorHighlight: normalizeCursorHighlight(editor.cursorHighlight),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCursorHighlight(
|
||||||
|
value: unknown,
|
||||||
|
): import("./videoPlayback/cursorHighlight").CursorHighlightConfig {
|
||||||
|
const fallback: import("./videoPlayback/cursorHighlight").CursorHighlightConfig = {
|
||||||
|
enabled: false,
|
||||||
|
style: "ring",
|
||||||
|
sizePx: 24,
|
||||||
|
color: "#FFD700",
|
||||||
|
opacity: 0.9,
|
||||||
|
onlyOnClicks: false,
|
||||||
|
clickEmphasisDurationMs: 350,
|
||||||
|
offsetXNorm: 0,
|
||||||
|
offsetYNorm: 0,
|
||||||
|
};
|
||||||
|
if (!value || typeof value !== "object") return fallback;
|
||||||
|
const v = value as Partial<import("./videoPlayback/cursorHighlight").CursorHighlightConfig>;
|
||||||
|
return {
|
||||||
|
enabled: typeof v.enabled === "boolean" ? v.enabled : fallback.enabled,
|
||||||
|
style: v.style === "dot" || v.style === "ring" ? v.style : fallback.style,
|
||||||
|
sizePx:
|
||||||
|
typeof v.sizePx === "number" && v.sizePx >= 10 && v.sizePx <= 36 ? v.sizePx : fallback.sizePx,
|
||||||
|
color:
|
||||||
|
typeof v.color === "string" && /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(v.color)
|
||||||
|
? v.color
|
||||||
|
: fallback.color,
|
||||||
|
opacity:
|
||||||
|
typeof v.opacity === "number" && v.opacity >= 0 && v.opacity <= 1
|
||||||
|
? v.opacity
|
||||||
|
: fallback.opacity,
|
||||||
|
onlyOnClicks: typeof v.onlyOnClicks === "boolean" ? v.onlyOnClicks : fallback.onlyOnClicks,
|
||||||
|
clickEmphasisDurationMs:
|
||||||
|
typeof v.clickEmphasisDurationMs === "number" && v.clickEmphasisDurationMs > 0
|
||||||
|
? v.clickEmphasisDurationMs
|
||||||
|
: fallback.clickEmphasisDurationMs,
|
||||||
|
offsetXNorm:
|
||||||
|
typeof v.offsetXNorm === "number" && Number.isFinite(v.offsetXNorm)
|
||||||
|
? Math.max(-1, Math.min(1, v.offsetXNorm))
|
||||||
|
: fallback.offsetXNorm,
|
||||||
|
offsetYNorm:
|
||||||
|
typeof v.offsetYNorm === "number" && Number.isFinite(v.offsetYNorm)
|
||||||
|
? Math.max(-1, Math.min(1, v.offsetYNorm))
|
||||||
|
: fallback.offsetYNorm,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,3 +561,19 @@ export function createProjectData(
|
|||||||
editor,
|
editor,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createProjectSnapshot(
|
||||||
|
media: ProjectMedia,
|
||||||
|
editor: Partial<ProjectEditorState>,
|
||||||
|
): string {
|
||||||
|
return JSON.stringify(createProjectData(media, normalizeProjectEditor(editor)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasProjectUnsavedChanges(
|
||||||
|
currentSnapshot: string | null,
|
||||||
|
baselineSnapshot: string | null,
|
||||||
|
): boolean {
|
||||||
|
return Boolean(
|
||||||
|
currentSnapshot !== null && baselineSnapshot !== null && currentSnapshot !== baselineSnapshot,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { Span } from "dnd-timeline";
|
import type { Span } from "dnd-timeline";
|
||||||
import { useItem } from "dnd-timeline";
|
import { useItem } from "dnd-timeline";
|
||||||
import { Gauge, MessageSquare, Scissors, ZoomIn } from "lucide-react";
|
import { Gauge, MessageSquare, MousePointer2, Scissors, ZoomIn } from "lucide-react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import { useScopedT } from "@/contexts/I18nContext";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import glassStyles from "./ItemGlass.module.css";
|
import glassStyles from "./ItemGlass.module.css";
|
||||||
|
|
||||||
@@ -14,7 +15,8 @@ interface ItemProps {
|
|||||||
onSelect?: () => void;
|
onSelect?: () => void;
|
||||||
zoomDepth?: number;
|
zoomDepth?: number;
|
||||||
speedValue?: number;
|
speedValue?: number;
|
||||||
variant?: "zoom" | "trim" | "annotation" | "speed";
|
isAutoFocus?: boolean;
|
||||||
|
variant?: "zoom" | "trim" | "annotation" | "speed" | "blur";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map zoom depth to multiplier labels
|
// Map zoom depth to multiplier labels
|
||||||
@@ -45,9 +47,11 @@ export default function Item({
|
|||||||
onSelect,
|
onSelect,
|
||||||
zoomDepth = 1,
|
zoomDepth = 1,
|
||||||
speedValue,
|
speedValue,
|
||||||
|
isAutoFocus = false,
|
||||||
variant = "zoom",
|
variant = "zoom",
|
||||||
children,
|
children,
|
||||||
}: ItemProps) {
|
}: ItemProps) {
|
||||||
|
const t = useScopedT("timeline");
|
||||||
const { setNodeRef, attributes, listeners, itemStyle, itemContentStyle } = useItem({
|
const { setNodeRef, attributes, listeners, itemStyle, itemContentStyle } = useItem({
|
||||||
id,
|
id,
|
||||||
span,
|
span,
|
||||||
@@ -132,19 +136,25 @@ export default function Item({
|
|||||||
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
|
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
|
||||||
{ZOOM_LABELS[zoomDepth] || `${zoomDepth}×`}
|
{ZOOM_LABELS[zoomDepth] || `${zoomDepth}×`}
|
||||||
</span>
|
</span>
|
||||||
|
{isAutoFocus && (
|
||||||
|
<MousePointer2
|
||||||
|
className="w-3 h-3 shrink-0 opacity-90"
|
||||||
|
aria-label="Cursor-follow"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
) : isTrim ? (
|
) : isTrim ? (
|
||||||
<>
|
<>
|
||||||
<Scissors className="w-3.5 h-3.5 shrink-0" />
|
<Scissors className="w-3.5 h-3.5 shrink-0" />
|
||||||
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
|
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
|
||||||
Trim
|
{t("labels.trim")}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
) : isSpeed ? (
|
) : isSpeed ? (
|
||||||
<>
|
<>
|
||||||
<Gauge className="w-3.5 h-3.5 shrink-0" />
|
<Gauge className="w-3.5 h-3.5 shrink-0" />
|
||||||
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
|
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
|
||||||
{speedValue !== undefined ? `${speedValue}×` : "Speed"}
|
{speedValue !== undefined ? `${speedValue}×` : t("labels.speed")}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { useScopedT } from "@/contexts/I18nContext";
|
||||||
import { useShortcuts } from "@/contexts/ShortcutsContext";
|
import { useShortcuts } from "@/contexts/ShortcutsContext";
|
||||||
import { matchesShortcut } from "@/lib/shortcuts";
|
import { matchesShortcut } from "@/lib/shortcuts";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -43,6 +44,7 @@ import { detectZoomDwellCandidates, normalizeCursorTelemetry } from "./zoomSugge
|
|||||||
const ZOOM_ROW_ID = "row-zoom";
|
const ZOOM_ROW_ID = "row-zoom";
|
||||||
const TRIM_ROW_ID = "row-trim";
|
const TRIM_ROW_ID = "row-trim";
|
||||||
const ANNOTATION_ROW_ID = "row-annotation";
|
const ANNOTATION_ROW_ID = "row-annotation";
|
||||||
|
const BLUR_ROW_ID = "row-blur";
|
||||||
const SPEED_ROW_ID = "row-speed";
|
const SPEED_ROW_ID = "row-speed";
|
||||||
const FALLBACK_RANGE_MS = 1000;
|
const FALLBACK_RANGE_MS = 1000;
|
||||||
const TARGET_MARKER_COUNT = 12;
|
const TARGET_MARKER_COUNT = 12;
|
||||||
@@ -72,6 +74,12 @@ interface TimelineEditorProps {
|
|||||||
onAnnotationDelete?: (id: string) => void;
|
onAnnotationDelete?: (id: string) => void;
|
||||||
selectedAnnotationId?: string | null;
|
selectedAnnotationId?: string | null;
|
||||||
onSelectAnnotation?: (id: string | null) => void;
|
onSelectAnnotation?: (id: string | null) => void;
|
||||||
|
blurRegions?: AnnotationRegion[];
|
||||||
|
onBlurAdded?: (span: Span) => void;
|
||||||
|
onBlurSpanChange?: (id: string, span: Span) => void;
|
||||||
|
onBlurDelete?: (id: string) => void;
|
||||||
|
selectedBlurId?: string | null;
|
||||||
|
onSelectBlur?: (id: string | null) => void;
|
||||||
speedRegions?: SpeedRegion[];
|
speedRegions?: SpeedRegion[];
|
||||||
onSpeedAdded?: (span: Span) => void;
|
onSpeedAdded?: (span: Span) => void;
|
||||||
onSpeedSpanChange?: (id: string, span: Span) => void;
|
onSpeedSpanChange?: (id: string, span: Span) => void;
|
||||||
@@ -95,7 +103,8 @@ interface TimelineRenderItem {
|
|||||||
label: string;
|
label: string;
|
||||||
zoomDepth?: number;
|
zoomDepth?: number;
|
||||||
speedValue?: number;
|
speedValue?: number;
|
||||||
variant: "zoom" | "trim" | "annotation" | "speed";
|
isAutoFocus?: boolean;
|
||||||
|
variant: "zoom" | "trim" | "annotation" | "speed" | "blur";
|
||||||
}
|
}
|
||||||
|
|
||||||
const SCALE_CANDIDATES = [
|
const SCALE_CANDIDATES = [
|
||||||
@@ -169,6 +178,33 @@ function createInitialRange(totalMs: number): Range {
|
|||||||
return { start: 0, end: FALLBACK_RANGE_MS };
|
return { start: 0, end: FALLBACK_RANGE_MS };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clampVisibleRange(candidate: Range, totalMs: number): Range {
|
||||||
|
if (totalMs <= 0) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
const span = Math.max(candidate.end - candidate.start, 1);
|
||||||
|
|
||||||
|
if (span >= totalMs) {
|
||||||
|
return { start: 0, end: totalMs };
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = Math.max(0, Math.min(candidate.start, totalMs - span));
|
||||||
|
return { start, end: start + span };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWheelDelta(delta: number, deltaMode: number, pageSizePx: number): number {
|
||||||
|
if (deltaMode === WheelEvent.DOM_DELTA_LINE) {
|
||||||
|
return delta * 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deltaMode === WheelEvent.DOM_DELTA_PAGE) {
|
||||||
|
return delta * pageSizePx;
|
||||||
|
}
|
||||||
|
|
||||||
|
return delta;
|
||||||
|
}
|
||||||
|
|
||||||
function formatTimeLabel(milliseconds: number, intervalMs: number) {
|
function formatTimeLabel(milliseconds: number, intervalMs: number) {
|
||||||
const totalSeconds = milliseconds / 1000;
|
const totalSeconds = milliseconds / 1000;
|
||||||
const hours = Math.floor(totalSeconds / 3600);
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
@@ -204,18 +240,21 @@ function PlaybackCursor({
|
|||||||
currentTimeMs,
|
currentTimeMs,
|
||||||
videoDurationMs,
|
videoDurationMs,
|
||||||
onSeek,
|
onSeek,
|
||||||
|
onRangeChange,
|
||||||
timelineRef,
|
timelineRef,
|
||||||
keyframes = [],
|
keyframes = [],
|
||||||
}: {
|
}: {
|
||||||
currentTimeMs: number;
|
currentTimeMs: number;
|
||||||
videoDurationMs: number;
|
videoDurationMs: number;
|
||||||
onSeek?: (time: number) => void;
|
onSeek?: (time: number) => void;
|
||||||
|
onRangeChange?: (updater: (previous: Range) => Range) => void;
|
||||||
timelineRef: React.RefObject<HTMLDivElement>;
|
timelineRef: React.RefObject<HTMLDivElement>;
|
||||||
keyframes?: { id: string; time: number }[];
|
keyframes?: { id: string; time: number }[];
|
||||||
}) {
|
}) {
|
||||||
const { sidebarWidth, direction, range, valueToPixels, pixelsToValue } = useTimelineContext();
|
const { sidebarWidth, direction, range, valueToPixels, pixelsToValue } = useTimelineContext();
|
||||||
const sideProperty = direction === "rtl" ? "right" : "left";
|
const sideProperty = direction === "rtl" ? "right" : "left";
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [dragPreviewTimeMs, setDragPreviewTimeMs] = useState<number | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isDragging) return;
|
if (!isDragging) return;
|
||||||
@@ -225,6 +264,7 @@ function PlaybackCursor({
|
|||||||
|
|
||||||
const rect = timelineRef.current.getBoundingClientRect();
|
const rect = timelineRef.current.getBoundingClientRect();
|
||||||
const clickX = e.clientX - rect.left - sidebarWidth;
|
const clickX = e.clientX - rect.left - sidebarWidth;
|
||||||
|
const contentWidth = Math.max(rect.width - sidebarWidth, 1);
|
||||||
|
|
||||||
// Allow dragging outside to 0 or max, but clamp the value
|
// Allow dragging outside to 0 or max, but clamp the value
|
||||||
const relativeMs = pixelsToValue(clickX);
|
const relativeMs = pixelsToValue(clickX);
|
||||||
@@ -243,11 +283,51 @@ function PlaybackCursor({
|
|||||||
absoluteMs = nearbyKeyframe.time;
|
absoluteMs = nearbyKeyframe.time;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setDragPreviewTimeMs(absoluteMs);
|
||||||
|
|
||||||
|
const visibleMs = range.end - range.start;
|
||||||
|
if (onRangeChange && visibleMs > 0 && videoDurationMs > visibleMs) {
|
||||||
|
const msPerPixel = visibleMs / contentWidth;
|
||||||
|
const overflowLeftPx = Math.max(0, -clickX);
|
||||||
|
const overflowRightPx = Math.max(0, clickX - contentWidth);
|
||||||
|
|
||||||
|
if (overflowLeftPx > 0 && range.start > 0) {
|
||||||
|
const shiftMs = overflowLeftPx * msPerPixel;
|
||||||
|
onRangeChange((previous) => {
|
||||||
|
const nextRange = clampVisibleRange(
|
||||||
|
{
|
||||||
|
start: previous.start - shiftMs,
|
||||||
|
end: previous.end - shiftMs,
|
||||||
|
},
|
||||||
|
videoDurationMs,
|
||||||
|
);
|
||||||
|
return nextRange.start === previous.start && nextRange.end === previous.end
|
||||||
|
? previous
|
||||||
|
: nextRange;
|
||||||
|
});
|
||||||
|
} else if (overflowRightPx > 0 && range.end < videoDurationMs) {
|
||||||
|
const shiftMs = overflowRightPx * msPerPixel;
|
||||||
|
onRangeChange((previous) => {
|
||||||
|
const nextRange = clampVisibleRange(
|
||||||
|
{
|
||||||
|
start: previous.start + shiftMs,
|
||||||
|
end: previous.end + shiftMs,
|
||||||
|
},
|
||||||
|
videoDurationMs,
|
||||||
|
);
|
||||||
|
return nextRange.start === previous.start && nextRange.end === previous.end
|
||||||
|
? previous
|
||||||
|
: nextRange;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onSeek(absoluteMs / 1000);
|
onSeek(absoluteMs / 1000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
const handleMouseUp = () => {
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
|
setDragPreviewTimeMs(null);
|
||||||
document.body.style.cursor = "";
|
document.body.style.cursor = "";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -263,6 +343,7 @@ function PlaybackCursor({
|
|||||||
}, [
|
}, [
|
||||||
isDragging,
|
isDragging,
|
||||||
onSeek,
|
onSeek,
|
||||||
|
onRangeChange,
|
||||||
timelineRef,
|
timelineRef,
|
||||||
sidebarWidth,
|
sidebarWidth,
|
||||||
range.start,
|
range.start,
|
||||||
@@ -272,11 +353,14 @@ function PlaybackCursor({
|
|||||||
keyframes,
|
keyframes,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (videoDurationMs <= 0 || currentTimeMs < 0) {
|
const displayTimeMs =
|
||||||
|
isDragging && dragPreviewTimeMs !== null ? dragPreviewTimeMs : currentTimeMs;
|
||||||
|
|
||||||
|
if (videoDurationMs <= 0 || displayTimeMs < 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const clampedTime = Math.min(currentTimeMs, videoDurationMs);
|
const clampedTime = Math.min(displayTimeMs, videoDurationMs);
|
||||||
|
|
||||||
if (clampedTime < range.start || clampedTime > range.end) {
|
if (clampedTime < range.start || clampedTime > range.end) {
|
||||||
return null;
|
return null;
|
||||||
@@ -299,6 +383,7 @@ function PlaybackCursor({
|
|||||||
}}
|
}}
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
e.stopPropagation(); // Prevent timeline click
|
e.stopPropagation(); // Prevent timeline click
|
||||||
|
setDragPreviewTimeMs(currentTimeMs);
|
||||||
setIsDragging(true);
|
setIsDragging(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -444,13 +529,16 @@ function Timeline({
|
|||||||
videoDurationMs,
|
videoDurationMs,
|
||||||
currentTimeMs,
|
currentTimeMs,
|
||||||
onSeek,
|
onSeek,
|
||||||
|
onRangeChange,
|
||||||
onSelectZoom,
|
onSelectZoom,
|
||||||
onSelectTrim,
|
onSelectTrim,
|
||||||
onSelectAnnotation,
|
onSelectAnnotation,
|
||||||
|
onSelectBlur,
|
||||||
onSelectSpeed,
|
onSelectSpeed,
|
||||||
selectedZoomId,
|
selectedZoomId,
|
||||||
selectedTrimId,
|
selectedTrimId,
|
||||||
selectedAnnotationId,
|
selectedAnnotationId,
|
||||||
|
selectedBlurId,
|
||||||
selectedSpeedId,
|
selectedSpeedId,
|
||||||
keyframes = [],
|
keyframes = [],
|
||||||
}: {
|
}: {
|
||||||
@@ -458,16 +546,20 @@ function Timeline({
|
|||||||
videoDurationMs: number;
|
videoDurationMs: number;
|
||||||
currentTimeMs: number;
|
currentTimeMs: number;
|
||||||
onSeek?: (time: number) => void;
|
onSeek?: (time: number) => void;
|
||||||
|
onRangeChange?: (updater: (previous: Range) => Range) => void;
|
||||||
onSelectZoom?: (id: string | null) => void;
|
onSelectZoom?: (id: string | null) => void;
|
||||||
onSelectTrim?: (id: string | null) => void;
|
onSelectTrim?: (id: string | null) => void;
|
||||||
onSelectAnnotation?: (id: string | null) => void;
|
onSelectAnnotation?: (id: string | null) => void;
|
||||||
|
onSelectBlur?: (id: string | null) => void;
|
||||||
onSelectSpeed?: (id: string | null) => void;
|
onSelectSpeed?: (id: string | null) => void;
|
||||||
selectedZoomId: string | null;
|
selectedZoomId: string | null;
|
||||||
selectedTrimId?: string | null;
|
selectedTrimId?: string | null;
|
||||||
selectedAnnotationId?: string | null;
|
selectedAnnotationId?: string | null;
|
||||||
|
selectedBlurId?: string | null;
|
||||||
selectedSpeedId?: string | null;
|
selectedSpeedId?: string | null;
|
||||||
keyframes?: { id: string; time: number }[];
|
keyframes?: { id: string; time: number }[];
|
||||||
}) {
|
}) {
|
||||||
|
const t = useScopedT("timeline");
|
||||||
const { setTimelineRef, style, sidebarWidth, range, pixelsToValue } = useTimelineContext();
|
const { setTimelineRef, style, sidebarWidth, range, pixelsToValue } = useTimelineContext();
|
||||||
const localTimelineRef = useRef<HTMLDivElement | null>(null);
|
const localTimelineRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
@@ -488,6 +580,7 @@ function Timeline({
|
|||||||
onSelectZoom?.(null);
|
onSelectZoom?.(null);
|
||||||
onSelectTrim?.(null);
|
onSelectTrim?.(null);
|
||||||
onSelectAnnotation?.(null);
|
onSelectAnnotation?.(null);
|
||||||
|
onSelectBlur?.(null);
|
||||||
onSelectSpeed?.(null);
|
onSelectSpeed?.(null);
|
||||||
|
|
||||||
const rect = e.currentTarget.getBoundingClientRect();
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
@@ -506,6 +599,7 @@ function Timeline({
|
|||||||
onSelectZoom,
|
onSelectZoom,
|
||||||
onSelectTrim,
|
onSelectTrim,
|
||||||
onSelectAnnotation,
|
onSelectAnnotation,
|
||||||
|
onSelectBlur,
|
||||||
onSelectSpeed,
|
onSelectSpeed,
|
||||||
videoDurationMs,
|
videoDurationMs,
|
||||||
sidebarWidth,
|
sidebarWidth,
|
||||||
@@ -514,9 +608,50 @@ function Timeline({
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleTimelineWheel = useCallback(
|
||||||
|
(event: React.WheelEvent<HTMLDivElement>) => {
|
||||||
|
if (!onRangeChange || event.ctrlKey || event.metaKey || videoDurationMs <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleMs = range.end - range.start;
|
||||||
|
if (visibleMs <= 0 || videoDurationMs <= visibleMs) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dominantDelta =
|
||||||
|
Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY;
|
||||||
|
if (dominantDelta === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const pageWidthPx = Math.max(event.currentTarget.clientWidth - sidebarWidth, 1);
|
||||||
|
const normalizedDeltaPx = normalizeWheelDelta(dominantDelta, event.deltaMode, pageWidthPx);
|
||||||
|
const shiftMs = pixelsToValue(normalizedDeltaPx);
|
||||||
|
|
||||||
|
onRangeChange((previous) => {
|
||||||
|
const nextRange = clampVisibleRange(
|
||||||
|
{
|
||||||
|
start: previous.start + shiftMs,
|
||||||
|
end: previous.end + shiftMs,
|
||||||
|
},
|
||||||
|
videoDurationMs,
|
||||||
|
);
|
||||||
|
|
||||||
|
return nextRange.start === previous.start && nextRange.end === previous.end
|
||||||
|
? previous
|
||||||
|
: nextRange;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[onRangeChange, videoDurationMs, range.end, range.start, sidebarWidth, pixelsToValue],
|
||||||
|
);
|
||||||
|
|
||||||
const zoomItems = items.filter((item) => item.rowId === ZOOM_ROW_ID);
|
const zoomItems = items.filter((item) => item.rowId === ZOOM_ROW_ID);
|
||||||
const trimItems = items.filter((item) => item.rowId === TRIM_ROW_ID);
|
const trimItems = items.filter((item) => item.rowId === TRIM_ROW_ID);
|
||||||
const annotationItems = items.filter((item) => item.rowId === ANNOTATION_ROW_ID);
|
const annotationItems = items.filter((item) => item.rowId === ANNOTATION_ROW_ID);
|
||||||
|
const blurItems = items.filter((item) => item.rowId === BLUR_ROW_ID);
|
||||||
const speedItems = items.filter((item) => item.rowId === SPEED_ROW_ID);
|
const speedItems = items.filter((item) => item.rowId === SPEED_ROW_ID);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -525,6 +660,7 @@ function Timeline({
|
|||||||
style={style}
|
style={style}
|
||||||
className="select-none bg-[#09090b] min-h-[140px] relative cursor-pointer group"
|
className="select-none bg-[#09090b] min-h-[140px] relative cursor-pointer group"
|
||||||
onClick={handleTimelineClick}
|
onClick={handleTimelineClick}
|
||||||
|
onWheel={handleTimelineWheel}
|
||||||
>
|
>
|
||||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,#ffffff03_1px,transparent_1px)] bg-[length:20px_100%] pointer-events-none" />
|
<div className="absolute inset-0 bg-[linear-gradient(to_right,#ffffff03_1px,transparent_1px)] bg-[length:20px_100%] pointer-events-none" />
|
||||||
<TimelineAxis videoDurationMs={videoDurationMs} currentTimeMs={currentTimeMs} />
|
<TimelineAxis videoDurationMs={videoDurationMs} currentTimeMs={currentTimeMs} />
|
||||||
@@ -532,11 +668,12 @@ function Timeline({
|
|||||||
currentTimeMs={currentTimeMs}
|
currentTimeMs={currentTimeMs}
|
||||||
videoDurationMs={videoDurationMs}
|
videoDurationMs={videoDurationMs}
|
||||||
onSeek={onSeek}
|
onSeek={onSeek}
|
||||||
|
onRangeChange={onRangeChange}
|
||||||
timelineRef={localTimelineRef}
|
timelineRef={localTimelineRef}
|
||||||
keyframes={keyframes}
|
keyframes={keyframes}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Row id={ZOOM_ROW_ID} isEmpty={zoomItems.length === 0} hint="Press Z to add zoom">
|
<Row id={ZOOM_ROW_ID} isEmpty={zoomItems.length === 0} hint={t("hints.pressZoom")}>
|
||||||
{zoomItems.map((item) => (
|
{zoomItems.map((item) => (
|
||||||
<Item
|
<Item
|
||||||
id={item.id}
|
id={item.id}
|
||||||
@@ -546,6 +683,7 @@ function Timeline({
|
|||||||
isSelected={item.id === selectedZoomId}
|
isSelected={item.id === selectedZoomId}
|
||||||
onSelect={() => onSelectZoom?.(item.id)}
|
onSelect={() => onSelectZoom?.(item.id)}
|
||||||
zoomDepth={item.zoomDepth}
|
zoomDepth={item.zoomDepth}
|
||||||
|
isAutoFocus={item.isAutoFocus}
|
||||||
variant="zoom"
|
variant="zoom"
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
@@ -553,7 +691,7 @@ function Timeline({
|
|||||||
))}
|
))}
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Row id={TRIM_ROW_ID} isEmpty={trimItems.length === 0} hint="Press T to add trim">
|
<Row id={TRIM_ROW_ID} isEmpty={trimItems.length === 0} hint={t("hints.pressTrim")}>
|
||||||
{trimItems.map((item) => (
|
{trimItems.map((item) => (
|
||||||
<Item
|
<Item
|
||||||
id={item.id}
|
id={item.id}
|
||||||
@@ -572,7 +710,7 @@ function Timeline({
|
|||||||
<Row
|
<Row
|
||||||
id={ANNOTATION_ROW_ID}
|
id={ANNOTATION_ROW_ID}
|
||||||
isEmpty={annotationItems.length === 0}
|
isEmpty={annotationItems.length === 0}
|
||||||
hint="Press A to add annotation"
|
hint={t("hints.pressAnnotation")}
|
||||||
>
|
>
|
||||||
{annotationItems.map((item) => (
|
{annotationItems.map((item) => (
|
||||||
<Item
|
<Item
|
||||||
@@ -589,7 +727,23 @@ function Timeline({
|
|||||||
))}
|
))}
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Row id={SPEED_ROW_ID} isEmpty={speedItems.length === 0} hint="Press S to add speed">
|
<Row id={BLUR_ROW_ID} isEmpty={blurItems.length === 0} hint={t("hints.pressBlur")}>
|
||||||
|
{blurItems.map((item) => (
|
||||||
|
<Item
|
||||||
|
id={item.id}
|
||||||
|
key={item.id}
|
||||||
|
rowId={item.rowId}
|
||||||
|
span={item.span}
|
||||||
|
isSelected={item.id === selectedBlurId}
|
||||||
|
onSelect={() => onSelectBlur?.(item.id)}
|
||||||
|
variant={item.variant}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Item>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row id={SPEED_ROW_ID} isEmpty={speedItems.length === 0} hint={t("hints.pressSpeed")}>
|
||||||
{speedItems.map((item) => (
|
{speedItems.map((item) => (
|
||||||
<Item
|
<Item
|
||||||
id={item.id}
|
id={item.id}
|
||||||
@@ -633,6 +787,12 @@ export default function TimelineEditor({
|
|||||||
onAnnotationDelete,
|
onAnnotationDelete,
|
||||||
selectedAnnotationId,
|
selectedAnnotationId,
|
||||||
onSelectAnnotation,
|
onSelectAnnotation,
|
||||||
|
blurRegions = [],
|
||||||
|
onBlurAdded,
|
||||||
|
onBlurSpanChange,
|
||||||
|
onBlurDelete,
|
||||||
|
selectedBlurId,
|
||||||
|
onSelectBlur,
|
||||||
speedRegions = [],
|
speedRegions = [],
|
||||||
onSpeedAdded,
|
onSpeedAdded,
|
||||||
onSpeedSpanChange,
|
onSpeedSpanChange,
|
||||||
@@ -642,6 +802,7 @@ export default function TimelineEditor({
|
|||||||
aspectRatio,
|
aspectRatio,
|
||||||
onAspectRatioChange,
|
onAspectRatioChange,
|
||||||
}: TimelineEditorProps) {
|
}: TimelineEditorProps) {
|
||||||
|
const t = useScopedT("timeline");
|
||||||
const totalMs = useMemo(() => Math.max(0, Math.round(videoDuration * 1000)), [videoDuration]);
|
const totalMs = useMemo(() => Math.max(0, Math.round(videoDuration * 1000)), [videoDuration]);
|
||||||
const currentTimeMs = useMemo(() => Math.round(currentTime * 1000), [currentTime]);
|
const currentTimeMs = useMemo(() => Math.round(currentTime * 1000), [currentTime]);
|
||||||
const timelineScale = useMemo(() => calculateTimelineScale(videoDuration), [videoDuration]);
|
const timelineScale = useMemo(() => calculateTimelineScale(videoDuration), [videoDuration]);
|
||||||
@@ -657,17 +818,15 @@ export default function TimelineEditor({
|
|||||||
const [keyframes, setKeyframes] = useState<{ id: string; time: number }[]>([]);
|
const [keyframes, setKeyframes] = useState<{ id: string; time: number }[]>([]);
|
||||||
const [selectedKeyframeId, setSelectedKeyframeId] = useState<string | null>(null);
|
const [selectedKeyframeId, setSelectedKeyframeId] = useState<string | null>(null);
|
||||||
const [scrollLabels, setScrollLabels] = useState({
|
const [scrollLabels, setScrollLabels] = useState({
|
||||||
pan: "Shift + Ctrl + Scroll",
|
pan: "Scroll",
|
||||||
zoom: "Ctrl + Scroll",
|
zoom: "Ctrl + Scroll",
|
||||||
});
|
});
|
||||||
const timelineContainerRef = useRef<HTMLDivElement>(null);
|
const timelineContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const { shortcuts: keyShortcuts, isMac } = useShortcuts();
|
const { shortcuts: keyShortcuts, isMac } = useShortcuts();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
formatShortcut(["shift", "mod", "Scroll"]).then((pan) => {
|
formatShortcut(["mod", "Scroll"]).then((zoom) => {
|
||||||
formatShortcut(["mod", "Scroll"]).then((zoom) => {
|
setScrollLabels({ pan: "Scroll", zoom });
|
||||||
setScrollLabels({ pan, zoom });
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -718,6 +877,12 @@ export default function TimelineEditor({
|
|||||||
onSelectAnnotation(null);
|
onSelectAnnotation(null);
|
||||||
}, [selectedAnnotationId, onAnnotationDelete, onSelectAnnotation]);
|
}, [selectedAnnotationId, onAnnotationDelete, onSelectAnnotation]);
|
||||||
|
|
||||||
|
const deleteSelectedBlur = useCallback(() => {
|
||||||
|
if (!selectedBlurId || !onBlurDelete || !onSelectBlur) return;
|
||||||
|
onBlurDelete(selectedBlurId);
|
||||||
|
onSelectBlur(null);
|
||||||
|
}, [selectedBlurId, onBlurDelete, onSelectBlur]);
|
||||||
|
|
||||||
const deleteSelectedSpeed = useCallback(() => {
|
const deleteSelectedSpeed = useCallback(() => {
|
||||||
if (!selectedSpeedId || !onSpeedDelete || !onSelectSpeed) return;
|
if (!selectedSpeedId || !onSpeedDelete || !onSelectSpeed) return;
|
||||||
onSpeedDelete(selectedSpeedId);
|
onSpeedDelete(selectedSpeedId);
|
||||||
@@ -787,9 +952,10 @@ export default function TimelineEditor({
|
|||||||
const isZoomItem = zoomRegions.some((r) => r.id === excludeId);
|
const isZoomItem = zoomRegions.some((r) => r.id === excludeId);
|
||||||
const isTrimItem = trimRegions.some((r) => r.id === excludeId);
|
const isTrimItem = trimRegions.some((r) => r.id === excludeId);
|
||||||
const isAnnotationItem = annotationRegions.some((r) => r.id === excludeId);
|
const isAnnotationItem = annotationRegions.some((r) => r.id === excludeId);
|
||||||
|
const isBlurItem = blurRegions.some((r) => r.id === excludeId);
|
||||||
const isSpeedItem = speedRegions.some((r) => r.id === excludeId);
|
const isSpeedItem = speedRegions.some((r) => r.id === excludeId);
|
||||||
|
|
||||||
if (isAnnotationItem) {
|
if (isAnnotationItem || isBlurItem) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -816,7 +982,7 @@ export default function TimelineEditor({
|
|||||||
|
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
[zoomRegions, trimRegions, annotationRegions, speedRegions],
|
[zoomRegions, trimRegions, annotationRegions, blurRegions, speedRegions],
|
||||||
);
|
);
|
||||||
|
|
||||||
// At least 5% of the timeline or 1000ms, whichever is larger, so the region
|
// At least 5% of the timeline or 1000ms, whichever is larger, so the region
|
||||||
@@ -848,15 +1014,15 @@ export default function TimelineEditor({
|
|||||||
(region) => startPos >= region.startMs && startPos < region.endMs,
|
(region) => startPos >= region.startMs && startPos < region.endMs,
|
||||||
);
|
);
|
||||||
if (isOverlapping || gapToNext <= 0) {
|
if (isOverlapping || gapToNext <= 0) {
|
||||||
toast.error("Cannot place zoom here", {
|
toast.error(t("errors.cannotPlaceZoom"), {
|
||||||
description: "Zoom already exists at this location or not enough space available.",
|
description: t("errors.zoomExistsAtLocation"),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const actualDuration = Math.min(defaultRegionDurationMs, gapToNext);
|
const actualDuration = Math.min(defaultRegionDurationMs, gapToNext);
|
||||||
onZoomAdded({ start: startPos, end: startPos + actualDuration });
|
onZoomAdded({ start: startPos, end: startPos + actualDuration });
|
||||||
}, [videoDuration, totalMs, currentTimeMs, zoomRegions, onZoomAdded, defaultRegionDurationMs]);
|
}, [videoDuration, totalMs, currentTimeMs, zoomRegions, onZoomAdded, defaultRegionDurationMs, t]);
|
||||||
|
|
||||||
const handleSuggestZooms = useCallback(() => {
|
const handleSuggestZooms = useCallback(() => {
|
||||||
if (!videoDuration || videoDuration === 0 || totalMs === 0) {
|
if (!videoDuration || videoDuration === 0 || totalMs === 0) {
|
||||||
@@ -864,13 +1030,13 @@ export default function TimelineEditor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!onZoomSuggested) {
|
if (!onZoomSuggested) {
|
||||||
toast.error("Zoom suggestion handler unavailable");
|
toast.error(t("errors.zoomSuggestionUnavailable"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cursorTelemetry.length < 2) {
|
if (cursorTelemetry.length < 2) {
|
||||||
toast.info("No cursor telemetry available", {
|
toast.info(t("errors.noCursorTelemetry"), {
|
||||||
description: "Record a screencast first to generate cursor-based suggestions.",
|
description: t("errors.noCursorTelemetryDescription"),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -887,8 +1053,8 @@ export default function TimelineEditor({
|
|||||||
const normalizedSamples = normalizeCursorTelemetry(cursorTelemetry, totalMs);
|
const normalizedSamples = normalizeCursorTelemetry(cursorTelemetry, totalMs);
|
||||||
|
|
||||||
if (normalizedSamples.length < 2) {
|
if (normalizedSamples.length < 2) {
|
||||||
toast.info("No usable cursor telemetry", {
|
toast.info(t("errors.noUsableTelemetry"), {
|
||||||
description: "The recording does not include enough cursor movement data.",
|
description: t("errors.noUsableTelemetryDescription"),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -896,8 +1062,8 @@ export default function TimelineEditor({
|
|||||||
const dwellCandidates = detectZoomDwellCandidates(normalizedSamples);
|
const dwellCandidates = detectZoomDwellCandidates(normalizedSamples);
|
||||||
|
|
||||||
if (dwellCandidates.length === 0) {
|
if (dwellCandidates.length === 0) {
|
||||||
toast.info("No clear cursor dwell moments found", {
|
toast.info(t("errors.noDwellMoments"), {
|
||||||
description: "Try a recording with slower cursor pauses on important actions.",
|
description: t("errors.noDwellMomentsDescription"),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -934,13 +1100,17 @@ export default function TimelineEditor({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (addedCount === 0) {
|
if (addedCount === 0) {
|
||||||
toast.info("No auto-zoom slots available", {
|
toast.info(t("errors.noAutoZoomSlots"), {
|
||||||
description: "Detected dwell points overlap existing zoom regions.",
|
description: t("errors.noAutoZoomSlotsDescription"),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.success(`Added ${addedCount} cursor-based zoom suggestion${addedCount === 1 ? "" : "s"}`);
|
toast.success(
|
||||||
|
addedCount === 1
|
||||||
|
? t("success.addedZoomSuggestions", { count: String(addedCount) })
|
||||||
|
: t("success.addedZoomSuggestionsPlural", { count: String(addedCount) }),
|
||||||
|
);
|
||||||
}, [
|
}, [
|
||||||
videoDuration,
|
videoDuration,
|
||||||
totalMs,
|
totalMs,
|
||||||
@@ -948,6 +1118,7 @@ export default function TimelineEditor({
|
|||||||
zoomRegions,
|
zoomRegions,
|
||||||
onZoomSuggested,
|
onZoomSuggested,
|
||||||
cursorTelemetry,
|
cursorTelemetry,
|
||||||
|
t,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleAddTrim = useCallback(() => {
|
const handleAddTrim = useCallback(() => {
|
||||||
@@ -972,15 +1143,15 @@ export default function TimelineEditor({
|
|||||||
(region) => startPos >= region.startMs && startPos < region.endMs,
|
(region) => startPos >= region.startMs && startPos < region.endMs,
|
||||||
);
|
);
|
||||||
if (isOverlapping || gapToNext <= 0) {
|
if (isOverlapping || gapToNext <= 0) {
|
||||||
toast.error("Cannot place trim here", {
|
toast.error(t("errors.cannotPlaceTrim"), {
|
||||||
description: "Trim already exists at this location or not enough space available.",
|
description: t("errors.trimExistsAtLocation"),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const actualDuration = Math.min(defaultRegionDurationMs, gapToNext);
|
const actualDuration = Math.min(defaultRegionDurationMs, gapToNext);
|
||||||
onTrimAdded({ start: startPos, end: startPos + actualDuration });
|
onTrimAdded({ start: startPos, end: startPos + actualDuration });
|
||||||
}, [videoDuration, totalMs, currentTimeMs, trimRegions, onTrimAdded, defaultRegionDurationMs]);
|
}, [videoDuration, totalMs, currentTimeMs, trimRegions, onTrimAdded, defaultRegionDurationMs, t]);
|
||||||
|
|
||||||
const handleAddSpeed = useCallback(() => {
|
const handleAddSpeed = useCallback(() => {
|
||||||
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onSpeedAdded) {
|
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onSpeedAdded) {
|
||||||
@@ -1004,15 +1175,23 @@ export default function TimelineEditor({
|
|||||||
(region) => startPos >= region.startMs && startPos < region.endMs,
|
(region) => startPos >= region.startMs && startPos < region.endMs,
|
||||||
);
|
);
|
||||||
if (isOverlapping || gapToNext <= 0) {
|
if (isOverlapping || gapToNext <= 0) {
|
||||||
toast.error("Cannot place speed here", {
|
toast.error(t("errors.cannotPlaceSpeed"), {
|
||||||
description: "Speed region already exists at this location or not enough space available.",
|
description: t("errors.speedExistsAtLocation"),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const actualDuration = Math.min(defaultRegionDurationMs, gapToNext);
|
const actualDuration = Math.min(defaultRegionDurationMs, gapToNext);
|
||||||
onSpeedAdded({ start: startPos, end: startPos + actualDuration });
|
onSpeedAdded({ start: startPos, end: startPos + actualDuration });
|
||||||
}, [videoDuration, totalMs, currentTimeMs, speedRegions, onSpeedAdded, defaultRegionDurationMs]);
|
}, [
|
||||||
|
videoDuration,
|
||||||
|
totalMs,
|
||||||
|
currentTimeMs,
|
||||||
|
speedRegions,
|
||||||
|
onSpeedAdded,
|
||||||
|
defaultRegionDurationMs,
|
||||||
|
t,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleAddAnnotation = useCallback(() => {
|
const handleAddAnnotation = useCallback(() => {
|
||||||
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onAnnotationAdded) {
|
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onAnnotationAdded) {
|
||||||
@@ -1031,6 +1210,21 @@ export default function TimelineEditor({
|
|||||||
onAnnotationAdded({ start: startPos, end: endPos });
|
onAnnotationAdded({ start: startPos, end: endPos });
|
||||||
}, [videoDuration, totalMs, currentTimeMs, onAnnotationAdded, defaultRegionDurationMs]);
|
}, [videoDuration, totalMs, currentTimeMs, onAnnotationAdded, defaultRegionDurationMs]);
|
||||||
|
|
||||||
|
const handleAddBlur = useCallback(() => {
|
||||||
|
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onBlurAdded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultDuration = Math.min(defaultRegionDurationMs, totalMs);
|
||||||
|
if (defaultDuration <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startPos = Math.max(0, Math.min(currentTimeMs, totalMs));
|
||||||
|
const endPos = Math.min(startPos + defaultDuration, totalMs);
|
||||||
|
onBlurAdded({ start: startPos, end: endPos });
|
||||||
|
}, [videoDuration, totalMs, currentTimeMs, onBlurAdded, defaultRegionDurationMs]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
||||||
@@ -1049,6 +1243,9 @@ export default function TimelineEditor({
|
|||||||
if (matchesShortcut(e, keyShortcuts.addAnnotation, isMac)) {
|
if (matchesShortcut(e, keyShortcuts.addAnnotation, isMac)) {
|
||||||
handleAddAnnotation();
|
handleAddAnnotation();
|
||||||
}
|
}
|
||||||
|
if (matchesShortcut(e, keyShortcuts.addBlur, isMac)) {
|
||||||
|
handleAddBlur();
|
||||||
|
}
|
||||||
if (matchesShortcut(e, keyShortcuts.addSpeed, isMac)) {
|
if (matchesShortcut(e, keyShortcuts.addSpeed, isMac)) {
|
||||||
handleAddSpeed();
|
handleAddSpeed();
|
||||||
}
|
}
|
||||||
@@ -1089,6 +1286,8 @@ export default function TimelineEditor({
|
|||||||
deleteSelectedTrim();
|
deleteSelectedTrim();
|
||||||
} else if (selectedAnnotationId) {
|
} else if (selectedAnnotationId) {
|
||||||
deleteSelectedAnnotation();
|
deleteSelectedAnnotation();
|
||||||
|
} else if (selectedBlurId) {
|
||||||
|
deleteSelectedBlur();
|
||||||
} else if (selectedSpeedId) {
|
} else if (selectedSpeedId) {
|
||||||
deleteSelectedSpeed();
|
deleteSelectedSpeed();
|
||||||
}
|
}
|
||||||
@@ -1101,16 +1300,19 @@ export default function TimelineEditor({
|
|||||||
handleAddZoom,
|
handleAddZoom,
|
||||||
handleAddTrim,
|
handleAddTrim,
|
||||||
handleAddAnnotation,
|
handleAddAnnotation,
|
||||||
|
handleAddBlur,
|
||||||
handleAddSpeed,
|
handleAddSpeed,
|
||||||
deleteSelectedKeyframe,
|
deleteSelectedKeyframe,
|
||||||
deleteSelectedZoom,
|
deleteSelectedZoom,
|
||||||
deleteSelectedTrim,
|
deleteSelectedTrim,
|
||||||
deleteSelectedAnnotation,
|
deleteSelectedAnnotation,
|
||||||
|
deleteSelectedBlur,
|
||||||
deleteSelectedSpeed,
|
deleteSelectedSpeed,
|
||||||
selectedKeyframeId,
|
selectedKeyframeId,
|
||||||
selectedZoomId,
|
selectedZoomId,
|
||||||
selectedTrimId,
|
selectedTrimId,
|
||||||
selectedAnnotationId,
|
selectedAnnotationId,
|
||||||
|
selectedBlurId,
|
||||||
selectedSpeedId,
|
selectedSpeedId,
|
||||||
annotationRegions,
|
annotationRegions,
|
||||||
currentTime,
|
currentTime,
|
||||||
@@ -1135,8 +1337,9 @@ export default function TimelineEditor({
|
|||||||
id: region.id,
|
id: region.id,
|
||||||
rowId: ZOOM_ROW_ID,
|
rowId: ZOOM_ROW_ID,
|
||||||
span: { start: region.startMs, end: region.endMs },
|
span: { start: region.startMs, end: region.endMs },
|
||||||
label: `Zoom ${index + 1}`,
|
label: t("labels.zoomItem", { index: String(index + 1) }),
|
||||||
zoomDepth: region.depth,
|
zoomDepth: region.depth,
|
||||||
|
isAutoFocus: region.focusMode === "auto",
|
||||||
variant: "zoom",
|
variant: "zoom",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -1144,7 +1347,7 @@ export default function TimelineEditor({
|
|||||||
id: region.id,
|
id: region.id,
|
||||||
rowId: TRIM_ROW_ID,
|
rowId: TRIM_ROW_ID,
|
||||||
span: { start: region.startMs, end: region.endMs },
|
span: { start: region.startMs, end: region.endMs },
|
||||||
label: `Trim ${index + 1}`,
|
label: t("labels.trimItem", { index: String(index + 1) }),
|
||||||
variant: "trim",
|
variant: "trim",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -1153,12 +1356,12 @@ export default function TimelineEditor({
|
|||||||
|
|
||||||
if (region.type === "text") {
|
if (region.type === "text") {
|
||||||
// Show text preview
|
// Show text preview
|
||||||
const preview = region.content.trim() || "Empty text";
|
const preview = region.content.trim() || t("labels.emptyText");
|
||||||
label = preview.length > 20 ? `${preview.substring(0, 20)}...` : preview;
|
label = preview.length > 20 ? `${preview.substring(0, 20)}...` : preview;
|
||||||
} else if (region.type === "image") {
|
} else if (region.type === "image") {
|
||||||
label = "Image";
|
label = t("labels.imageItem");
|
||||||
} else {
|
} else {
|
||||||
label = "Annotation";
|
label = t("labels.annotationItem");
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1170,17 +1373,25 @@ export default function TimelineEditor({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const blurs: TimelineRenderItem[] = blurRegions.map((region, index) => ({
|
||||||
|
id: region.id,
|
||||||
|
rowId: BLUR_ROW_ID,
|
||||||
|
span: { start: region.startMs, end: region.endMs },
|
||||||
|
label: t("labels.blurItem", { index: String(index + 1) }),
|
||||||
|
variant: "blur",
|
||||||
|
}));
|
||||||
|
|
||||||
const speeds: TimelineRenderItem[] = speedRegions.map((region, index) => ({
|
const speeds: TimelineRenderItem[] = speedRegions.map((region, index) => ({
|
||||||
id: region.id,
|
id: region.id,
|
||||||
rowId: SPEED_ROW_ID,
|
rowId: SPEED_ROW_ID,
|
||||||
span: { start: region.startMs, end: region.endMs },
|
span: { start: region.startMs, end: region.endMs },
|
||||||
label: `Speed ${index + 1}`,
|
label: t("labels.speedItem", { index: String(index + 1) }),
|
||||||
speedValue: region.speed,
|
speedValue: region.speed,
|
||||||
variant: "speed",
|
variant: "speed",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return [...zooms, ...trims, ...annotations, ...speeds];
|
return [...zooms, ...trims, ...annotations, ...blurs, ...speeds];
|
||||||
}, [zoomRegions, trimRegions, annotationRegions, speedRegions]);
|
}, [zoomRegions, trimRegions, annotationRegions, blurRegions, speedRegions, t]);
|
||||||
|
|
||||||
// Flat list of all non-annotation region spans for neighbour-clamping during drag/resize
|
// Flat list of all non-annotation region spans for neighbour-clamping during drag/resize
|
||||||
const allRegionSpans = useMemo(() => {
|
const allRegionSpans = useMemo(() => {
|
||||||
@@ -1201,6 +1412,8 @@ export default function TimelineEditor({
|
|||||||
onSpeedSpanChange?.(id, span);
|
onSpeedSpanChange?.(id, span);
|
||||||
} else if (annotationRegions.some((r) => r.id === id)) {
|
} else if (annotationRegions.some((r) => r.id === id)) {
|
||||||
onAnnotationSpanChange?.(id, span);
|
onAnnotationSpanChange?.(id, span);
|
||||||
|
} else if (blurRegions.some((r) => r.id === id)) {
|
||||||
|
onBlurSpanChange?.(id, span);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
@@ -1208,10 +1421,12 @@ export default function TimelineEditor({
|
|||||||
trimRegions,
|
trimRegions,
|
||||||
speedRegions,
|
speedRegions,
|
||||||
annotationRegions,
|
annotationRegions,
|
||||||
|
blurRegions,
|
||||||
onZoomSpanChange,
|
onZoomSpanChange,
|
||||||
onTrimSpanChange,
|
onTrimSpanChange,
|
||||||
onSpeedSpanChange,
|
onSpeedSpanChange,
|
||||||
onAnnotationSpanChange,
|
onAnnotationSpanChange,
|
||||||
|
onBlurSpanChange,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1222,8 +1437,8 @@ export default function TimelineEditor({
|
|||||||
<Plus className="w-6 h-6 text-slate-600" />
|
<Plus className="w-6 h-6 text-slate-600" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-sm font-medium text-slate-300">No Video Loaded</p>
|
<p className="text-sm font-medium text-slate-300">{t("emptyState.noVideo")}</p>
|
||||||
<p className="text-xs text-slate-500 mt-1">Drag and drop a video to start editing</p>
|
<p className="text-xs text-slate-500 mt-1">{t("emptyState.dragAndDrop")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1238,7 +1453,7 @@ export default function TimelineEditor({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 text-slate-400 hover:text-[#34B27B] hover:bg-[#34B27B]/10 transition-all"
|
className="h-7 w-7 text-slate-400 hover:text-[#34B27B] hover:bg-[#34B27B]/10 transition-all"
|
||||||
title="Add Zoom (Z)"
|
title={t("buttons.addZoom")}
|
||||||
>
|
>
|
||||||
<ZoomIn className="w-4 h-4" />
|
<ZoomIn className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1247,7 +1462,7 @@ export default function TimelineEditor({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 text-slate-400 hover:text-[#34B27B] hover:bg-[#34B27B]/10 transition-all"
|
className="h-7 w-7 text-slate-400 hover:text-[#34B27B] hover:bg-[#34B27B]/10 transition-all"
|
||||||
title="Suggest Zooms from Cursor"
|
title={t("buttons.suggestZooms")}
|
||||||
>
|
>
|
||||||
<WandSparkles className="w-4 h-4" />
|
<WandSparkles className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1256,7 +1471,7 @@ export default function TimelineEditor({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 text-slate-400 hover:text-[#ef4444] hover:bg-[#ef4444]/10 transition-all"
|
className="h-7 w-7 text-slate-400 hover:text-[#ef4444] hover:bg-[#ef4444]/10 transition-all"
|
||||||
title="Add Trim (T)"
|
title={t("buttons.addTrim")}
|
||||||
>
|
>
|
||||||
<Scissors className="w-4 h-4" />
|
<Scissors className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1265,16 +1480,35 @@ export default function TimelineEditor({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 text-slate-400 hover:text-[#B4A046] hover:bg-[#B4A046]/10 transition-all"
|
className="h-7 w-7 text-slate-400 hover:text-[#B4A046] hover:bg-[#B4A046]/10 transition-all"
|
||||||
title="Add Annotation (A)"
|
title={t("buttons.addAnnotation")}
|
||||||
>
|
>
|
||||||
<MessageSquare className="w-4 h-4" />
|
<MessageSquare className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleAddBlur}
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-slate-400 hover:text-[#7dd3fc] hover:bg-[#7dd3fc]/10 transition-all"
|
||||||
|
title={t("buttons.addBlur")}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<circle cx="8" cy="12" r="3" />
|
||||||
|
<circle cx="16" cy="12" r="3" />
|
||||||
|
<path d="M6 6h12M6 18h12" />
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleAddSpeed}
|
onClick={handleAddSpeed}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 text-slate-400 hover:text-[#d97706] hover:bg-[#d97706]/10 transition-all"
|
className="h-7 w-7 text-slate-400 hover:text-[#d97706] hover:bg-[#d97706]/10 transition-all"
|
||||||
title="Add Speed (S)"
|
title={t("buttons.addSpeed")}
|
||||||
>
|
>
|
||||||
<Gauge className="w-4 h-4" />
|
<Gauge className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1313,13 +1547,13 @@ export default function TimelineEditor({
|
|||||||
<kbd className="px-1.5 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-sans">
|
<kbd className="px-1.5 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-sans">
|
||||||
{scrollLabels.pan}
|
{scrollLabels.pan}
|
||||||
</kbd>
|
</kbd>
|
||||||
<span>Pan</span>
|
<span>{t("labels.pan")}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<kbd className="px-1.5 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-sans">
|
<kbd className="px-1.5 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-sans">
|
||||||
{scrollLabels.zoom}
|
{scrollLabels.zoom}
|
||||||
</kbd>
|
</kbd>
|
||||||
<span>Zoom</span>
|
<span>{t("labels.zoom")}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1351,13 +1585,16 @@ export default function TimelineEditor({
|
|||||||
videoDurationMs={totalMs}
|
videoDurationMs={totalMs}
|
||||||
currentTimeMs={currentTimeMs}
|
currentTimeMs={currentTimeMs}
|
||||||
onSeek={onSeek}
|
onSeek={onSeek}
|
||||||
|
onRangeChange={setRange}
|
||||||
onSelectZoom={onSelectZoom}
|
onSelectZoom={onSelectZoom}
|
||||||
onSelectTrim={onSelectTrim}
|
onSelectTrim={onSelectTrim}
|
||||||
onSelectAnnotation={onSelectAnnotation}
|
onSelectAnnotation={onSelectAnnotation}
|
||||||
|
onSelectBlur={onSelectBlur}
|
||||||
onSelectSpeed={onSelectSpeed}
|
onSelectSpeed={onSelectSpeed}
|
||||||
selectedZoomId={selectedZoomId}
|
selectedZoomId={selectedZoomId}
|
||||||
selectedTrimId={selectedTrimId}
|
selectedTrimId={selectedTrimId}
|
||||||
selectedAnnotationId={selectedAnnotationId}
|
selectedAnnotationId={selectedAnnotationId}
|
||||||
|
selectedBlurId={selectedBlurId}
|
||||||
selectedSpeedId={selectedSpeedId}
|
selectedSpeedId={selectedSpeedId}
|
||||||
keyframes={keyframes}
|
keyframes={keyframes}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,16 +1,167 @@
|
|||||||
|
import type { WebcamLayoutPreset } from "@/lib/compositeLayout";
|
||||||
|
|
||||||
export type ZoomDepth = 1 | 2 | 3 | 4 | 5 | 6;
|
export type ZoomDepth = 1 | 2 | 3 | 4 | 5 | 6;
|
||||||
|
export type ZoomFocusMode = "manual" | "auto";
|
||||||
|
export type { WebcamLayoutPreset };
|
||||||
|
/** Webcam size as a percentage of the canvas reference dimension (10–50). */
|
||||||
|
export type WebcamSizePreset = number;
|
||||||
|
|
||||||
|
export const DEFAULT_WEBCAM_SIZE_PRESET: WebcamSizePreset = 25;
|
||||||
|
|
||||||
|
export const DEFAULT_WEBCAM_LAYOUT_PRESET: WebcamLayoutPreset = "picture-in-picture";
|
||||||
|
|
||||||
|
export type WebcamMaskShape = "rectangle" | "circle" | "square" | "rounded";
|
||||||
|
|
||||||
|
export const DEFAULT_WEBCAM_MASK_SHAPE: WebcamMaskShape = "rectangle";
|
||||||
|
|
||||||
|
export interface WebcamPosition {
|
||||||
|
cx: number; // normalized horizontal center (0-1)
|
||||||
|
cy: number; // normalized vertical center (0-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_WEBCAM_POSITION: WebcamPosition | null = null;
|
||||||
|
|
||||||
export interface ZoomFocus {
|
export interface ZoomFocus {
|
||||||
cx: number; // normalized horizontal center (0-1)
|
cx: number; // normalized horizontal center (0-1)
|
||||||
cy: number; // normalized vertical center (0-1)
|
cy: number; // normalized vertical center (0-1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Rotation3D {
|
||||||
|
rotationX: number;
|
||||||
|
rotationY: number;
|
||||||
|
rotationZ: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_ROTATION_3D: Rotation3D = {
|
||||||
|
rotationX: 0,
|
||||||
|
rotationY: 0,
|
||||||
|
rotationZ: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Rotation3DPreset = "iso" | "left" | "right";
|
||||||
|
|
||||||
|
export const ROTATION_3D_PRESETS: Record<Rotation3DPreset, Rotation3D> = {
|
||||||
|
iso: { rotationX: -10, rotationY: -16, rotationZ: 0 },
|
||||||
|
left: { rotationX: 0, rotationY: -22, rotationZ: 0 },
|
||||||
|
right: { rotationX: 0, rotationY: 22, rotationZ: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ROTATION_3D_PRESET_ORDER: Rotation3DPreset[] = ["iso", "left", "right"];
|
||||||
|
|
||||||
|
/** Perspective distance in CSS px is computed at render-time as this factor times
|
||||||
|
* min(viewport width, viewport height). Same factor used in preview and export so
|
||||||
|
* the visual look is identical regardless of canvas resolution. */
|
||||||
|
export const ROTATION_3D_PERSPECTIVE_FACTOR = 2.6;
|
||||||
|
|
||||||
|
export function rotation3DPerspective(width: number, height: number): number {
|
||||||
|
return Math.min(width, height) * ROTATION_3D_PERSPECTIVE_FACTOR;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ZoomRegion {
|
export interface ZoomRegion {
|
||||||
id: string;
|
id: string;
|
||||||
startMs: number;
|
startMs: number;
|
||||||
endMs: number;
|
endMs: number;
|
||||||
depth: ZoomDepth;
|
depth: ZoomDepth;
|
||||||
focus: ZoomFocus;
|
focus: ZoomFocus;
|
||||||
|
focusMode?: ZoomFocusMode;
|
||||||
|
rotationPreset?: Rotation3DPreset;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRotation3D(region: Pick<ZoomRegion, "rotationPreset">): Rotation3D {
|
||||||
|
if (!region.rotationPreset) return DEFAULT_ROTATION_3D;
|
||||||
|
return ROTATION_3D_PRESETS[region.rotationPreset];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRotation3DIdentity(r: Rotation3D, eps = 0.01): boolean {
|
||||||
|
return Math.abs(r.rotationX) < eps && Math.abs(r.rotationY) < eps && Math.abs(r.rotationZ) < eps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lerpRotation3D(a: Rotation3D, b: Rotation3D, t: number): Rotation3D {
|
||||||
|
return {
|
||||||
|
rotationX: a.rotationX + (b.rotationX - a.rotationX) * t,
|
||||||
|
rotationY: a.rotationY + (b.rotationY - a.rotationY) * t,
|
||||||
|
rotationZ: a.rotationZ + (b.rotationZ - a.rotationZ) * t,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the maximum uniform scale that, when applied alongside `rot` and a perspective
|
||||||
|
* of `perspective` CSS px, keeps the projected bounding box of a `width × height` element
|
||||||
|
* inside its original `width × height` rectangle. Returns 1 when no scaling is needed.
|
||||||
|
*
|
||||||
|
* Math: project each rotated corner onto the screen via x' = x·P/(P−z); take the worst-case
|
||||||
|
* |x'|/|y'| against the half-extents and return the limiting ratio. This makes the rotated
|
||||||
|
* recording sit *inside* the zoom window instead of bleeding past it.
|
||||||
|
*/
|
||||||
|
export function computeRotation3DContainScale(
|
||||||
|
rot: Rotation3D,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
perspective: number,
|
||||||
|
): number {
|
||||||
|
const a = (rot.rotationX * Math.PI) / 180;
|
||||||
|
const b = (rot.rotationY * Math.PI) / 180;
|
||||||
|
const g = (rot.rotationZ * Math.PI) / 180;
|
||||||
|
const ca = Math.cos(a);
|
||||||
|
const sa = Math.sin(a);
|
||||||
|
const cb = Math.cos(b);
|
||||||
|
const sb = Math.sin(b);
|
||||||
|
const cg = Math.cos(g);
|
||||||
|
const sg = Math.sin(g);
|
||||||
|
const halfW = width / 2;
|
||||||
|
const halfH = height / 2;
|
||||||
|
const corners: Array<[number, number]> = [
|
||||||
|
[-halfW, -halfH],
|
||||||
|
[halfW, -halfH],
|
||||||
|
[halfW, halfH],
|
||||||
|
[-halfW, halfH],
|
||||||
|
];
|
||||||
|
|
||||||
|
let maxAbsX = 0;
|
||||||
|
let maxAbsY = 0;
|
||||||
|
|
||||||
|
for (const [x0, y0] of corners) {
|
||||||
|
// CSS "rotateX(α) rotateY(β) rotateZ(γ)" reads right-to-left: Z first, then Y, then X.
|
||||||
|
let px = x0;
|
||||||
|
let py = y0;
|
||||||
|
let pz = 0;
|
||||||
|
|
||||||
|
// rotateZ
|
||||||
|
const zx = px * cg - py * sg;
|
||||||
|
const zy = px * sg + py * cg;
|
||||||
|
px = zx;
|
||||||
|
py = zy;
|
||||||
|
|
||||||
|
// rotateY
|
||||||
|
const yx = px * cb + pz * sb;
|
||||||
|
const yz = -px * sb + pz * cb;
|
||||||
|
px = yx;
|
||||||
|
pz = yz;
|
||||||
|
|
||||||
|
// rotateX
|
||||||
|
const xy = py * ca - pz * sa;
|
||||||
|
const xz = py * sa + pz * ca;
|
||||||
|
py = xy;
|
||||||
|
pz = xz;
|
||||||
|
|
||||||
|
// Perspective projection: viewer at (0, 0, P), looking toward −z. A point at z=pz
|
||||||
|
// is scaled by P / (P − pz). When perspective ≤ 0 we treat as orthographic.
|
||||||
|
if (perspective > 0) {
|
||||||
|
const denom = perspective - pz;
|
||||||
|
if (denom <= 0) return 1; // pathological — skip scaling rather than crash
|
||||||
|
const f = perspective / denom;
|
||||||
|
px *= f;
|
||||||
|
py *= f;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Math.abs(px) > maxAbsX) maxAbsX = Math.abs(px);
|
||||||
|
if (Math.abs(py) > maxAbsY) maxAbsY = Math.abs(py);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxAbsX === 0 || maxAbsY === 0) return 1;
|
||||||
|
const sx = halfW / maxAbsX;
|
||||||
|
const sy = halfH / maxAbsY;
|
||||||
|
return Math.min(sx, sy, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CursorTelemetryPoint {
|
export interface CursorTelemetryPoint {
|
||||||
@@ -25,7 +176,7 @@ export interface TrimRegion {
|
|||||||
endMs: number;
|
endMs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AnnotationType = "text" | "image" | "figure";
|
export type AnnotationType = "text" | "image" | "figure" | "blur";
|
||||||
|
|
||||||
export type ArrowDirection =
|
export type ArrowDirection =
|
||||||
| "up"
|
| "up"
|
||||||
@@ -43,6 +194,27 @@ export interface FigureData {
|
|||||||
strokeWidth: number;
|
strokeWidth: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type BlurShape = "rectangle" | "oval" | "freehand";
|
||||||
|
export type BlurType = "blur" | "mosaic";
|
||||||
|
export type BlurColor = "white" | "black";
|
||||||
|
|
||||||
|
export const MIN_BLUR_INTENSITY = 2;
|
||||||
|
export const MAX_BLUR_INTENSITY = 40;
|
||||||
|
export const DEFAULT_BLUR_INTENSITY = 12;
|
||||||
|
export const MIN_BLUR_BLOCK_SIZE = 4;
|
||||||
|
export const MAX_BLUR_BLOCK_SIZE = 48;
|
||||||
|
export const DEFAULT_BLUR_BLOCK_SIZE = 12;
|
||||||
|
|
||||||
|
export interface BlurData {
|
||||||
|
type: BlurType;
|
||||||
|
shape: BlurShape;
|
||||||
|
color: BlurColor;
|
||||||
|
intensity: number;
|
||||||
|
blockSize: number;
|
||||||
|
// Points are normalized (0-100) within the annotation bounds.
|
||||||
|
freehandPoints?: Array<{ x: number; y: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AnnotationPosition {
|
export interface AnnotationPosition {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
@@ -77,6 +249,7 @@ export interface AnnotationRegion {
|
|||||||
style: AnnotationTextStyle;
|
style: AnnotationTextStyle;
|
||||||
zIndex: number;
|
zIndex: number;
|
||||||
figureData?: FigureData;
|
figureData?: FigureData;
|
||||||
|
blurData?: BlurData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_ANNOTATION_POSITION: AnnotationPosition = {
|
export const DEFAULT_ANNOTATION_POSITION: AnnotationPosition = {
|
||||||
@@ -106,6 +279,27 @@ export const DEFAULT_FIGURE_DATA: FigureData = {
|
|||||||
strokeWidth: 4,
|
strokeWidth: 4,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_BLUR_FREEHAND_POINTS: Array<{ x: number; y: number }> = [
|
||||||
|
{ x: 10, y: 30 },
|
||||||
|
{ x: 25, y: 10 },
|
||||||
|
{ x: 55, y: 8 },
|
||||||
|
{ x: 82, y: 20 },
|
||||||
|
{ x: 90, y: 45 },
|
||||||
|
{ x: 78, y: 72 },
|
||||||
|
{ x: 52, y: 90 },
|
||||||
|
{ x: 22, y: 84 },
|
||||||
|
{ x: 8, y: 58 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DEFAULT_BLUR_DATA: BlurData = {
|
||||||
|
type: "blur",
|
||||||
|
shape: "rectangle",
|
||||||
|
color: "white",
|
||||||
|
intensity: DEFAULT_BLUR_INTENSITY,
|
||||||
|
blockSize: DEFAULT_BLUR_BLOCK_SIZE,
|
||||||
|
freehandPoints: DEFAULT_BLUR_FREEHAND_POINTS,
|
||||||
|
};
|
||||||
|
|
||||||
export interface CropRegion {
|
export interface CropRegion {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
@@ -120,7 +314,16 @@ export const DEFAULT_CROP_REGION: CropRegion = {
|
|||||||
height: 1,
|
height: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PlaybackSpeed = 0.25 | 0.5 | 0.75 | 1.25 | 1.5 | 1.75 | 2;
|
export type PlaybackSpeed = number;
|
||||||
|
|
||||||
|
export const MIN_PLAYBACK_SPEED = 0.1;
|
||||||
|
// Anything above 16x causes the playhead to stall during preview
|
||||||
|
// due to the video decoder not being able to keep up.
|
||||||
|
export const MAX_PLAYBACK_SPEED = 16;
|
||||||
|
|
||||||
|
export function clampPlaybackSpeed(speed: number): PlaybackSpeed {
|
||||||
|
return Math.round(Math.min(MAX_PLAYBACK_SPEED, Math.max(MIN_PLAYBACK_SPEED, speed)) * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SpeedRegion {
|
export interface SpeedRegion {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -137,6 +340,9 @@ export const SPEED_OPTIONS: Array<{ speed: PlaybackSpeed; label: string }> = [
|
|||||||
{ speed: 1.5, label: "1.5×" },
|
{ speed: 1.5, label: "1.5×" },
|
||||||
{ speed: 1.75, label: "1.75×" },
|
{ speed: 1.75, label: "1.75×" },
|
||||||
{ speed: 2, label: "2×" },
|
{ speed: 2, label: "2×" },
|
||||||
|
{ speed: 3, label: "3×" },
|
||||||
|
{ speed: 4, label: "4×" },
|
||||||
|
{ speed: 5, label: "5×" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const DEFAULT_PLAYBACK_SPEED: PlaybackSpeed = 1.5;
|
export const DEFAULT_PLAYBACK_SPEED: PlaybackSpeed = 1.5;
|
||||||
|
|||||||
@@ -8,3 +8,6 @@ export const VIEWPORT_SCALE = 0.8;
|
|||||||
export const SMOOTHING_FACTOR = 0.12;
|
export const SMOOTHING_FACTOR = 0.12;
|
||||||
export const ZOOM_TRANSLATION_DEADZONE_PX = 1.25;
|
export const ZOOM_TRANSLATION_DEADZONE_PX = 1.25;
|
||||||
export const ZOOM_SCALE_DEADZONE = 0.002;
|
export const ZOOM_SCALE_DEADZONE = 0.002;
|
||||||
|
export const AUTO_FOLLOW_SMOOTHING_FACTOR = 0.1;
|
||||||
|
export const AUTO_FOLLOW_SMOOTHING_FACTOR_MAX = 0.25;
|
||||||
|
export const AUTO_FOLLOW_RAMP_DISTANCE = 0.15;
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import type { CursorTelemetryPoint, ZoomFocus } from "../types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Binary-search the sorted telemetry array and linearly interpolate
|
||||||
|
* the cursor position at the given playback time.
|
||||||
|
*/
|
||||||
|
export function interpolateCursorAt(
|
||||||
|
telemetry: CursorTelemetryPoint[],
|
||||||
|
timeMs: number,
|
||||||
|
): ZoomFocus | null {
|
||||||
|
if (telemetry.length === 0) return null;
|
||||||
|
|
||||||
|
if (timeMs <= telemetry[0].timeMs) {
|
||||||
|
return { cx: telemetry[0].cx, cy: telemetry[0].cy };
|
||||||
|
}
|
||||||
|
|
||||||
|
const last = telemetry[telemetry.length - 1];
|
||||||
|
if (timeMs >= last.timeMs) {
|
||||||
|
return { cx: last.cx, cy: last.cy };
|
||||||
|
}
|
||||||
|
|
||||||
|
let lo = 0;
|
||||||
|
let hi = telemetry.length - 1;
|
||||||
|
|
||||||
|
while (lo < hi - 1) {
|
||||||
|
const mid = (lo + hi) >>> 1;
|
||||||
|
if (telemetry[mid].timeMs <= timeMs) {
|
||||||
|
lo = mid;
|
||||||
|
} else {
|
||||||
|
hi = mid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const before = telemetry[lo];
|
||||||
|
const after = telemetry[hi];
|
||||||
|
const span = after.timeMs - before.timeMs;
|
||||||
|
const t = span > 0 ? (timeMs - before.timeMs) / span : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
cx: before.cx + (after.cx - before.cx) * t,
|
||||||
|
cy: before.cy + (after.cy - before.cy) * t,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exponential smoothing to reduce jitter from high-frequency cursor data.
|
||||||
|
* Lower factor = smoother / more lag, higher = more responsive.
|
||||||
|
*/
|
||||||
|
export function smoothCursorFocus(raw: ZoomFocus, prev: ZoomFocus, factor: number): ZoomFocus {
|
||||||
|
return {
|
||||||
|
cx: prev.cx + (raw.cx - prev.cx) * factor,
|
||||||
|
cy: prev.cy + (raw.cy - prev.cy) * factor,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute an adaptive smoothing factor that scales with distance:
|
||||||
|
* far from target → faster (maxFactor), close → slower (minFactor).
|
||||||
|
* This replaces the hard deadzone with a natural deceleration curve.
|
||||||
|
*/
|
||||||
|
export function adaptiveSmoothFactor(
|
||||||
|
raw: ZoomFocus,
|
||||||
|
prev: ZoomFocus,
|
||||||
|
minFactor: number,
|
||||||
|
maxFactor: number,
|
||||||
|
rampDistance: number,
|
||||||
|
): number {
|
||||||
|
const dx = raw.cx - prev.cx;
|
||||||
|
const dy = raw.cy - prev.cy;
|
||||||
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
const t = Math.min(1, distance / rampDistance);
|
||||||
|
return minFactor + (maxFactor - minFactor) * t;
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import type { Graphics } from "pixi.js";
|
||||||
|
|
||||||
|
export type CursorHighlightStyle = "dot" | "ring";
|
||||||
|
|
||||||
|
export interface CursorHighlightConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
style: CursorHighlightStyle;
|
||||||
|
sizePx: number;
|
||||||
|
color: string;
|
||||||
|
opacity: number;
|
||||||
|
// Show only on clicks (macOS — depends on click telemetry from uiohook).
|
||||||
|
onlyOnClicks: boolean;
|
||||||
|
clickEmphasisDurationMs: number;
|
||||||
|
// Per-recording manual nudge. Cursor telemetry is normalized to the display,
|
||||||
|
// but window recordings frame a subset of the display so the highlight
|
||||||
|
// lands offset. Users dial these in once to align with the actual cursor.
|
||||||
|
offsetXNorm: number;
|
||||||
|
offsetYNorm: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CURSOR_HIGHLIGHT_MIN_SIZE_PX = 10;
|
||||||
|
export const CURSOR_HIGHLIGHT_MAX_SIZE_PX = 36;
|
||||||
|
|
||||||
|
export const DEFAULT_CURSOR_HIGHLIGHT: CursorHighlightConfig = {
|
||||||
|
enabled: false,
|
||||||
|
style: "ring",
|
||||||
|
sizePx: 24,
|
||||||
|
color: "#FFD700",
|
||||||
|
opacity: 0.9,
|
||||||
|
onlyOnClicks: false,
|
||||||
|
clickEmphasisDurationMs: 350,
|
||||||
|
offsetXNorm: 0,
|
||||||
|
offsetYNorm: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CURSOR_HIGHLIGHT_OFFSET_RANGE = 0.25; // ±25% of recorded surface
|
||||||
|
|
||||||
|
// Alpha multiplier for the highlight at `timeMs`. Returns 1 when not in
|
||||||
|
// click-only mode; in click-only mode fades 1→0 across each click's window.
|
||||||
|
export function clickEmphasisAlpha(
|
||||||
|
timeMs: number,
|
||||||
|
clickTimestampsMs: number[] | undefined,
|
||||||
|
config: CursorHighlightConfig,
|
||||||
|
): number {
|
||||||
|
if (!config.onlyOnClicks) return 1;
|
||||||
|
if (!clickTimestampsMs || clickTimestampsMs.length === 0) return 0;
|
||||||
|
const window = Math.max(1, config.clickEmphasisDurationMs);
|
||||||
|
for (let i = 0; i < clickTimestampsMs.length; i++) {
|
||||||
|
const dt = timeMs - clickTimestampsMs[i];
|
||||||
|
if (dt >= 0 && dt <= window) {
|
||||||
|
return 1 - dt / window;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHexColor(hex: string): number {
|
||||||
|
const cleaned = hex.replace("#", "");
|
||||||
|
if (cleaned.length === 3) {
|
||||||
|
const r = cleaned[0];
|
||||||
|
const g = cleaned[1];
|
||||||
|
const b = cleaned[2];
|
||||||
|
return Number.parseInt(`${r}${r}${g}${g}${b}${b}`, 16);
|
||||||
|
}
|
||||||
|
return Number.parseInt(cleaned.slice(0, 6), 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function drawCursorHighlightGraphics(g: Graphics, config: CursorHighlightConfig): void {
|
||||||
|
g.clear();
|
||||||
|
if (!config.enabled) return;
|
||||||
|
|
||||||
|
const color = parseHexColor(config.color);
|
||||||
|
const radius = Math.max(1, config.sizePx / 2);
|
||||||
|
const alpha = Math.max(0, Math.min(1, config.opacity));
|
||||||
|
|
||||||
|
switch (config.style) {
|
||||||
|
case "dot": {
|
||||||
|
g.circle(0, 0, radius);
|
||||||
|
g.fill({ color, alpha });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "ring": {
|
||||||
|
g.circle(0, 0, radius);
|
||||||
|
g.stroke({ color, alpha, width: Math.max(2, radius * 0.18) });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function drawCursorHighlightCanvas(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
cx: number,
|
||||||
|
cy: number,
|
||||||
|
config: CursorHighlightConfig,
|
||||||
|
pixelScale = 1,
|
||||||
|
): void {
|
||||||
|
if (!config.enabled) return;
|
||||||
|
|
||||||
|
const radius = Math.max(1, (config.sizePx / 2) * pixelScale);
|
||||||
|
const alpha = Math.max(0, Math.min(1, config.opacity));
|
||||||
|
const color = config.color;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalAlpha = alpha;
|
||||||
|
|
||||||
|
switch (config.style) {
|
||||||
|
case "dot": {
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "ring": {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.lineWidth = Math.max(2, radius * 0.18);
|
||||||
|
ctx.stroke();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
@@ -39,9 +39,16 @@ function getFocusBounds(depth: ZoomDepth) {
|
|||||||
return getFocusBoundsForScale(zoomScale);
|
return getFocusBoundsForScale(zoomScale);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFocusBoundsForScale(zoomScale: number) {
|
interface ViewportRatio {
|
||||||
const marginX = 1 / (2 * zoomScale);
|
widthRatio: number;
|
||||||
const marginY = 1 / (2 * zoomScale);
|
heightRatio: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFocusBoundsForScale(zoomScale: number, viewportRatio?: ViewportRatio) {
|
||||||
|
const wr = viewportRatio?.widthRatio ?? 1;
|
||||||
|
const hr = viewportRatio?.heightRatio ?? 1;
|
||||||
|
const marginX = Math.min(0.5, wr / (2 * zoomScale));
|
||||||
|
const marginY = Math.min(0.5, hr / (2 * zoomScale));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
minX: marginX,
|
minX: marginX,
|
||||||
@@ -65,12 +72,16 @@ export function clampFocusToStage(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clampFocusToScale(focus: ZoomFocus, zoomScale: number): ZoomFocus {
|
export function clampFocusToScale(
|
||||||
|
focus: ZoomFocus,
|
||||||
|
zoomScale: number,
|
||||||
|
viewportRatio?: ViewportRatio,
|
||||||
|
): ZoomFocus {
|
||||||
const baseFocus = {
|
const baseFocus = {
|
||||||
cx: clamp(focus.cx, 0, 1),
|
cx: clamp(focus.cx, 0, 1),
|
||||||
cy: clamp(focus.cy, 0, 1),
|
cy: clamp(focus.cy, 0, 1),
|
||||||
};
|
};
|
||||||
const bounds = getFocusBoundsForScale(zoomScale);
|
const bounds = getFocusBoundsForScale(zoomScale, viewportRatio);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cx: clamp(baseFocus.cx, bounds.minX, bounds.maxX),
|
cx: clamp(baseFocus.cx, bounds.minX, bounds.maxX),
|
||||||
@@ -78,12 +89,16 @@ export function clampFocusToScale(focus: ZoomFocus, zoomScale: number): ZoomFocu
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function softenFocusToScale(focus: ZoomFocus, zoomScale: number): ZoomFocus {
|
export function softenFocusToScale(
|
||||||
|
focus: ZoomFocus,
|
||||||
|
zoomScale: number,
|
||||||
|
viewportRatio?: ViewportRatio,
|
||||||
|
): ZoomFocus {
|
||||||
const baseFocus = {
|
const baseFocus = {
|
||||||
cx: clamp(focus.cx, 0, 1),
|
cx: clamp(focus.cx, 0, 1),
|
||||||
cy: clamp(focus.cy, 0, 1),
|
cy: clamp(focus.cy, 0, 1),
|
||||||
};
|
};
|
||||||
const bounds = getFocusBoundsForScale(zoomScale);
|
const bounds = getFocusBoundsForScale(zoomScale, viewportRatio);
|
||||||
const horizontalRange = bounds.maxX - bounds.minX;
|
const horizontalRange = bounds.maxX - bounds.minX;
|
||||||
const verticalRange = bounds.maxY - bounds.minY;
|
const verticalRange = bounds.maxY - bounds.minY;
|
||||||
const horizontalSoftness = Math.min(0.12, horizontalRange * 0.35);
|
const horizontalSoftness = Math.min(0.12, horizontalRange * 0.35);
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { Application, Graphics, Sprite } from "pixi.js";
|
import { Application, Graphics, Sprite } from "pixi.js";
|
||||||
import type { CropRegion } from "../types";
|
import {
|
||||||
|
computeCompositeLayout,
|
||||||
|
type RenderRect,
|
||||||
|
type Size,
|
||||||
|
type StyledRenderRect,
|
||||||
|
type WebcamLayoutPreset,
|
||||||
|
type WebcamSizePreset,
|
||||||
|
} from "@/lib/compositeLayout";
|
||||||
|
import type { CropRegion, WebcamMaskShape } from "../types";
|
||||||
|
|
||||||
interface LayoutParams {
|
interface LayoutParams {
|
||||||
container: HTMLDivElement;
|
container: HTMLDivElement;
|
||||||
@@ -11,6 +19,11 @@ interface LayoutParams {
|
|||||||
lockedVideoDimensions?: { width: number; height: number } | null;
|
lockedVideoDimensions?: { width: number; height: number } | null;
|
||||||
borderRadius?: number;
|
borderRadius?: number;
|
||||||
padding?: number;
|
padding?: number;
|
||||||
|
webcamDimensions?: Size | null;
|
||||||
|
webcamLayoutPreset?: WebcamLayoutPreset;
|
||||||
|
webcamSizePreset?: WebcamSizePreset;
|
||||||
|
webcamPosition?: { cx: number; cy: number } | null;
|
||||||
|
webcamMaskShape?: WebcamMaskShape;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LayoutResult {
|
interface LayoutResult {
|
||||||
@@ -18,7 +31,8 @@ interface LayoutResult {
|
|||||||
videoSize: { width: number; height: number };
|
videoSize: { width: number; height: number };
|
||||||
baseScale: number;
|
baseScale: number;
|
||||||
baseOffset: { x: number; y: number };
|
baseOffset: { x: number; y: number };
|
||||||
maskRect: { x: number; y: number; width: number; height: number };
|
maskRect: RenderRect;
|
||||||
|
webcamRect: StyledRenderRect | null;
|
||||||
cropBounds: { startX: number; endX: number; startY: number; endY: number };
|
cropBounds: { startX: number; endX: number; startY: number; endY: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +47,11 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
|
|||||||
lockedVideoDimensions,
|
lockedVideoDimensions,
|
||||||
borderRadius = 0,
|
borderRadius = 0,
|
||||||
padding = 0,
|
padding = 0,
|
||||||
|
webcamDimensions,
|
||||||
|
webcamLayoutPreset,
|
||||||
|
webcamSizePreset,
|
||||||
|
webcamPosition,
|
||||||
|
webcamMaskShape,
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
const videoWidth = lockedVideoDimensions?.width || videoElement.videoWidth;
|
const videoWidth = lockedVideoDimensions?.width || videoElement.videoWidth;
|
||||||
@@ -67,15 +86,36 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
|
|||||||
|
|
||||||
// Calculate scale to fit the cropped area in the viewport
|
// Calculate scale to fit the cropped area in the viewport
|
||||||
// Padding is a percentage (0-100), where 50 matches the original VIEWPORT_SCALE of 0.8
|
// Padding is a percentage (0-100), where 50 matches the original VIEWPORT_SCALE of 0.8
|
||||||
const paddingScale = 1.0 - (padding / 100) * 0.4;
|
// Vertical stack ignores padding — it's full-bleed
|
||||||
|
const effectivePadding = webcamLayoutPreset === "vertical-stack" ? 0 : padding;
|
||||||
|
const paddingScale = 1.0 - (effectivePadding / 100) * 0.4;
|
||||||
const maxDisplayWidth = width * paddingScale;
|
const maxDisplayWidth = width * paddingScale;
|
||||||
const maxDisplayHeight = height * paddingScale;
|
const maxDisplayHeight = height * paddingScale;
|
||||||
|
|
||||||
const scale = Math.min(
|
const compositeLayout = computeCompositeLayout({
|
||||||
maxDisplayWidth / croppedVideoWidth,
|
canvasSize: { width, height },
|
||||||
maxDisplayHeight / croppedVideoHeight,
|
maxContentSize: { width: maxDisplayWidth, height: maxDisplayHeight },
|
||||||
1,
|
screenSize: { width: croppedVideoWidth, height: croppedVideoHeight },
|
||||||
);
|
webcamSize: webcamDimensions,
|
||||||
|
layoutPreset: webcamLayoutPreset,
|
||||||
|
webcamSizePreset,
|
||||||
|
webcamPosition,
|
||||||
|
webcamMaskShape,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!compositeLayout) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const screenRect = compositeLayout.screenRect;
|
||||||
|
|
||||||
|
// Cover mode: scale to fill the rect (may crop), otherwise fit-to-width
|
||||||
|
let scale: number;
|
||||||
|
if (compositeLayout.screenCover) {
|
||||||
|
scale = Math.max(screenRect.width / croppedVideoWidth, screenRect.height / croppedVideoHeight);
|
||||||
|
} else {
|
||||||
|
scale = screenRect.width / croppedVideoWidth;
|
||||||
|
}
|
||||||
|
|
||||||
videoSprite.scale.set(scale);
|
videoSprite.scale.set(scale);
|
||||||
|
|
||||||
@@ -83,31 +123,25 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
|
|||||||
const fullVideoDisplayWidth = videoWidth * scale;
|
const fullVideoDisplayWidth = videoWidth * scale;
|
||||||
const fullVideoDisplayHeight = videoHeight * scale;
|
const fullVideoDisplayHeight = videoHeight * scale;
|
||||||
|
|
||||||
// Calculate display size of just the cropped region
|
// Position the video so the cropped region is centered within the screenRect
|
||||||
const croppedDisplayWidth = croppedVideoWidth * scale;
|
const croppedDisplayWidth = croppedVideoWidth * scale;
|
||||||
const croppedDisplayHeight = croppedVideoHeight * scale;
|
const croppedDisplayHeight = croppedVideoHeight * scale;
|
||||||
|
const offsetX = screenRect.x + (screenRect.width - croppedDisplayWidth) / 2;
|
||||||
// Center the cropped region in the container
|
const offsetY = screenRect.y + (screenRect.height - croppedDisplayHeight) / 2;
|
||||||
const centerOffsetX = (width - croppedDisplayWidth) / 2;
|
const spriteX = offsetX - crop.x * fullVideoDisplayWidth;
|
||||||
const centerOffsetY = (height - croppedDisplayHeight) / 2;
|
const spriteY = offsetY - crop.y * fullVideoDisplayHeight;
|
||||||
|
|
||||||
// Position the full video sprite so that when we apply the mask,
|
|
||||||
// the cropped region appears centered
|
|
||||||
// The crop starts at (crop.x * videoWidth, crop.y * videoHeight) in video coordinates
|
|
||||||
// In display coordinates, that's (crop.x * fullVideoDisplayWidth, crop.y * fullVideoDisplayHeight)
|
|
||||||
// We want that point to be at centerOffsetX, centerOffsetY
|
|
||||||
const spriteX = centerOffsetX - crop.x * fullVideoDisplayWidth;
|
|
||||||
const spriteY = centerOffsetY - crop.y * fullVideoDisplayHeight;
|
|
||||||
|
|
||||||
videoSprite.position.set(spriteX, spriteY);
|
videoSprite.position.set(spriteX, spriteY);
|
||||||
|
|
||||||
// Create a mask that only shows the cropped region (centered in container)
|
// Apply border radius — mask clips the video to the screenRect
|
||||||
const maskX = centerOffsetX;
|
|
||||||
const maskY = centerOffsetY;
|
|
||||||
|
|
||||||
// Apply border radius
|
|
||||||
maskGraphics.clear();
|
maskGraphics.clear();
|
||||||
maskGraphics.roundRect(maskX, maskY, croppedDisplayWidth, croppedDisplayHeight, borderRadius);
|
maskGraphics.roundRect(
|
||||||
|
screenRect.x,
|
||||||
|
screenRect.y,
|
||||||
|
screenRect.width,
|
||||||
|
screenRect.height,
|
||||||
|
compositeLayout.screenBorderRadius ?? (compositeLayout.screenCover ? 0 : borderRadius),
|
||||||
|
);
|
||||||
maskGraphics.fill({ color: 0xffffff });
|
maskGraphics.fill({ color: 0xffffff });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -115,7 +149,8 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
|
|||||||
videoSize: { width: croppedVideoWidth, height: croppedVideoHeight },
|
videoSize: { width: croppedVideoWidth, height: croppedVideoHeight },
|
||||||
baseScale: scale,
|
baseScale: scale,
|
||||||
baseOffset: { x: spriteX, y: spriteY },
|
baseOffset: { x: spriteX, y: spriteY },
|
||||||
maskRect: { x: maskX, y: maskY, width: croppedDisplayWidth, height: croppedDisplayHeight },
|
maskRect: compositeLayout.screenRect,
|
||||||
|
webcamRect: compositeLayout.webcamRect,
|
||||||
cropBounds: { startX: cropStartX, endX: cropEndX, startY: cropStartY, endY: cropEndY },
|
cropBounds: { startX: cropStartX, endX: cropEndX, startY: cropStartY, endY: cropEndY },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ interface OverlayUpdateParams {
|
|||||||
export function updateOverlayIndicator(params: OverlayUpdateParams) {
|
export function updateOverlayIndicator(params: OverlayUpdateParams) {
|
||||||
const { overlayEl, indicatorEl, region, focusOverride, videoSize, baseScale, isPlaying } = params;
|
const { overlayEl, indicatorEl, region, focusOverride, videoSize, baseScale, isPlaying } = params;
|
||||||
|
|
||||||
if (!region) {
|
if (!region || region.focusMode === "auto") {
|
||||||
indicatorEl.style.display = "none";
|
indicatorEl.style.display = "none";
|
||||||
overlayEl.style.pointerEvents = "none";
|
overlayEl.style.pointerEvents = "none";
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import type { SpeedRegion, TrimRegion } from "../types";
|
import type { SpeedRegion, TrimRegion } from "../types";
|
||||||
|
|
||||||
|
// Keep "scrub mode" on for a brief tail after `seeked` — rapid drag-scrubbing
|
||||||
|
// fires `seeking`/`seeked` dozens of times per second, and toggling effects
|
||||||
|
// each time would flicker.
|
||||||
|
const SCRUB_END_DEBOUNCE_MS = 150;
|
||||||
|
|
||||||
interface VideoEventHandlersParams {
|
interface VideoEventHandlersParams {
|
||||||
video: HTMLVideoElement;
|
video: HTMLVideoElement;
|
||||||
isSeekingRef: React.MutableRefObject<boolean>;
|
isSeekingRef: React.MutableRefObject<boolean>;
|
||||||
@@ -12,6 +17,9 @@ interface VideoEventHandlersParams {
|
|||||||
onTimeUpdate: (time: number) => void;
|
onTimeUpdate: (time: number) => void;
|
||||||
trimRegionsRef: React.MutableRefObject<TrimRegion[]>;
|
trimRegionsRef: React.MutableRefObject<TrimRegion[]>;
|
||||||
speedRegionsRef: React.MutableRefObject<SpeedRegion[]>;
|
speedRegionsRef: React.MutableRefObject<SpeedRegion[]>;
|
||||||
|
isScrubbingRef?: React.MutableRefObject<boolean>;
|
||||||
|
scrubEndTimerRef?: React.MutableRefObject<number | null>;
|
||||||
|
onScrubChange?: (scrubbing: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createVideoEventHandlers(params: VideoEventHandlersParams) {
|
export function createVideoEventHandlers(params: VideoEventHandlersParams) {
|
||||||
@@ -26,8 +34,18 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
|
|||||||
onTimeUpdate,
|
onTimeUpdate,
|
||||||
trimRegionsRef,
|
trimRegionsRef,
|
||||||
speedRegionsRef,
|
speedRegionsRef,
|
||||||
|
isScrubbingRef,
|
||||||
|
scrubEndTimerRef,
|
||||||
|
onScrubChange,
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
|
const clearScrubEndTimer = () => {
|
||||||
|
if (scrubEndTimerRef && scrubEndTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(scrubEndTimerRef.current);
|
||||||
|
scrubEndTimerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const emitTime = (timeValue: number) => {
|
const emitTime = (timeValue: number) => {
|
||||||
currentTimeRef.current = timeValue * 1000;
|
currentTimeRef.current = timeValue * 1000;
|
||||||
onTimeUpdate(timeValue);
|
onTimeUpdate(timeValue);
|
||||||
@@ -113,6 +131,15 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
|
|||||||
const handleSeeked = () => {
|
const handleSeeked = () => {
|
||||||
isSeekingRef.current = false;
|
isSeekingRef.current = false;
|
||||||
|
|
||||||
|
if (isScrubbingRef && scrubEndTimerRef) {
|
||||||
|
clearScrubEndTimer();
|
||||||
|
scrubEndTimerRef.current = window.setTimeout(() => {
|
||||||
|
isScrubbingRef.current = false;
|
||||||
|
scrubEndTimerRef.current = null;
|
||||||
|
onScrubChange?.(false);
|
||||||
|
}, SCRUB_END_DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
const currentTimeMs = video.currentTime * 1000;
|
const currentTimeMs = video.currentTime * 1000;
|
||||||
const activeTrimRegion = findActiveTrimRegion(currentTimeMs);
|
const activeTrimRegion = findActiveTrimRegion(currentTimeMs);
|
||||||
|
|
||||||
@@ -137,6 +164,14 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
|
|||||||
const handleSeeking = () => {
|
const handleSeeking = () => {
|
||||||
isSeekingRef.current = true;
|
isSeekingRef.current = true;
|
||||||
|
|
||||||
|
if (isScrubbingRef) {
|
||||||
|
clearScrubEndTimer();
|
||||||
|
if (!isScrubbingRef.current) {
|
||||||
|
isScrubbingRef.current = true;
|
||||||
|
onScrubChange?.(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!isPlayingRef.current && !video.paused) {
|
if (!isPlayingRef.current && !video.paused) {
|
||||||
video.pause();
|
video.pause();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ZoomFocus, ZoomRegion } from "../types";
|
import type { CursorTelemetryPoint, Rotation3D, ZoomFocus, ZoomRegion } from "../types";
|
||||||
import { ZOOM_DEPTH_SCALES } from "../types";
|
import { DEFAULT_ROTATION_3D, getRotation3D, lerpRotation3D, ZOOM_DEPTH_SCALES } from "../types";
|
||||||
import { TRANSITION_WINDOW_MS, ZOOM_IN_TRANSITION_WINDOW_MS } from "./constants";
|
import { TRANSITION_WINDOW_MS, ZOOM_IN_TRANSITION_WINDOW_MS } from "./constants";
|
||||||
|
import { interpolateCursorAt } from "./cursorFollowUtils";
|
||||||
import { clampFocusToScale } from "./focusUtils";
|
import { clampFocusToScale } from "./focusUtils";
|
||||||
import { clamp01, cubicBezier, easeOutScreenStudio } from "./mathUtils";
|
import { clamp01, cubicBezier, easeOutScreenStudio } from "./mathUtils";
|
||||||
|
|
||||||
@@ -10,6 +11,8 @@ const ZOOM_IN_OVERLAP_MS = 500;
|
|||||||
|
|
||||||
type DominantRegionOptions = {
|
type DominantRegionOptions = {
|
||||||
connectZooms?: boolean;
|
connectZooms?: boolean;
|
||||||
|
cursorTelemetry?: CursorTelemetryPoint[];
|
||||||
|
viewportRatio?: ViewportRatio;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ConnectedRegionPair = {
|
type ConnectedRegionPair = {
|
||||||
@@ -64,8 +67,33 @@ function getLinearFocus(start: ZoomFocus, end: ZoomFocus, amount: number): ZoomF
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getResolvedFocus(region: ZoomRegion, zoomScale: number): ZoomFocus {
|
interface ViewportRatio {
|
||||||
return clampFocusToScale(region.focus, zoomScale);
|
widthRatio: number;
|
||||||
|
heightRatio: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getResolvedFocus(
|
||||||
|
region: ZoomRegion,
|
||||||
|
zoomScale: number,
|
||||||
|
timeMs?: number,
|
||||||
|
cursorTelemetry?: CursorTelemetryPoint[],
|
||||||
|
viewportRatio?: ViewportRatio,
|
||||||
|
): ZoomFocus {
|
||||||
|
let focus = region.focus;
|
||||||
|
|
||||||
|
if (
|
||||||
|
region.focusMode === "auto" &&
|
||||||
|
cursorTelemetry &&
|
||||||
|
cursorTelemetry.length > 0 &&
|
||||||
|
timeMs !== undefined
|
||||||
|
) {
|
||||||
|
const cursorFocus = interpolateCursorAt(cursorTelemetry, timeMs);
|
||||||
|
if (cursorFocus) {
|
||||||
|
focus = cursorFocus;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return clampFocusToScale(focus, zoomScale, viewportRatio);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getConnectedRegionPairs(regions: ZoomRegion[]) {
|
function getConnectedRegionPairs(regions: ZoomRegion[]) {
|
||||||
@@ -96,6 +124,8 @@ function getActiveRegion(
|
|||||||
regions: ZoomRegion[],
|
regions: ZoomRegion[],
|
||||||
timeMs: number,
|
timeMs: number,
|
||||||
connectedPairs: ConnectedRegionPair[],
|
connectedPairs: ConnectedRegionPair[],
|
||||||
|
cursorTelemetry?: CursorTelemetryPoint[],
|
||||||
|
viewportRatio?: ViewportRatio,
|
||||||
) {
|
) {
|
||||||
const activeRegions = regions
|
const activeRegions = regions
|
||||||
.map((region) => {
|
.map((region) => {
|
||||||
@@ -130,24 +160,37 @@ function getActiveRegion(
|
|||||||
return {
|
return {
|
||||||
region: {
|
region: {
|
||||||
...activeRegion,
|
...activeRegion,
|
||||||
focus: getResolvedFocus(activeRegion, activeScale),
|
focus: getResolvedFocus(activeRegion, activeScale, timeMs, cursorTelemetry, viewportRatio),
|
||||||
},
|
},
|
||||||
strength: activeRegions[0].strength,
|
strength: activeRegions[0].strength,
|
||||||
blendedScale: null,
|
blendedScale: null,
|
||||||
|
rotation3D: getRotation3D(activeRegion),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getConnectedRegionHold(timeMs: number, connectedPairs: ConnectedRegionPair[]) {
|
function getConnectedRegionHold(
|
||||||
|
timeMs: number,
|
||||||
|
connectedPairs: ConnectedRegionPair[],
|
||||||
|
cursorTelemetry?: CursorTelemetryPoint[],
|
||||||
|
viewportRatio?: ViewportRatio,
|
||||||
|
) {
|
||||||
for (const pair of connectedPairs) {
|
for (const pair of connectedPairs) {
|
||||||
if (timeMs > pair.transitionEnd && timeMs < pair.nextRegion.startMs) {
|
if (timeMs > pair.transitionEnd && timeMs < pair.nextRegion.startMs) {
|
||||||
const nextScale = ZOOM_DEPTH_SCALES[pair.nextRegion.depth];
|
const nextScale = ZOOM_DEPTH_SCALES[pair.nextRegion.depth];
|
||||||
return {
|
return {
|
||||||
region: {
|
region: {
|
||||||
...pair.nextRegion,
|
...pair.nextRegion,
|
||||||
focus: getResolvedFocus(pair.nextRegion, nextScale),
|
focus: getResolvedFocus(
|
||||||
|
pair.nextRegion,
|
||||||
|
nextScale,
|
||||||
|
timeMs,
|
||||||
|
cursorTelemetry,
|
||||||
|
viewportRatio,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
strength: 1,
|
strength: 1,
|
||||||
blendedScale: null,
|
blendedScale: null,
|
||||||
|
rotation3D: getRotation3D(pair.nextRegion),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -155,7 +198,12 @@ function getConnectedRegionHold(timeMs: number, connectedPairs: ConnectedRegionP
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getConnectedRegionTransition(connectedPairs: ConnectedRegionPair[], timeMs: number) {
|
function getConnectedRegionTransition(
|
||||||
|
connectedPairs: ConnectedRegionPair[],
|
||||||
|
timeMs: number,
|
||||||
|
cursorTelemetry?: CursorTelemetryPoint[],
|
||||||
|
viewportRatio?: ViewportRatio,
|
||||||
|
) {
|
||||||
for (const pair of connectedPairs) {
|
for (const pair of connectedPairs) {
|
||||||
const { currentRegion, nextRegion, transitionStart, transitionEnd } = pair;
|
const { currentRegion, nextRegion, transitionStart, transitionEnd } = pair;
|
||||||
|
|
||||||
@@ -169,9 +217,29 @@ function getConnectedRegionTransition(connectedPairs: ConnectedRegionPair[], tim
|
|||||||
const currentScale = ZOOM_DEPTH_SCALES[currentRegion.depth];
|
const currentScale = ZOOM_DEPTH_SCALES[currentRegion.depth];
|
||||||
const nextScale = ZOOM_DEPTH_SCALES[nextRegion.depth];
|
const nextScale = ZOOM_DEPTH_SCALES[nextRegion.depth];
|
||||||
const transitionScale = lerp(currentScale, nextScale, transitionProgress);
|
const transitionScale = lerp(currentScale, nextScale, transitionProgress);
|
||||||
const currentFocus = getResolvedFocus(currentRegion, currentScale);
|
// Both regions share the same timeMs, so interpolate cursor once and reuse.
|
||||||
const nextFocus = getResolvedFocus(nextRegion, nextScale);
|
const sharedCursorFocus =
|
||||||
|
cursorTelemetry && cursorTelemetry.length > 0
|
||||||
|
? interpolateCursorAt(cursorTelemetry, timeMs)
|
||||||
|
: null;
|
||||||
|
const currentFocus = clampFocusToScale(
|
||||||
|
currentRegion.focusMode === "auto" && sharedCursorFocus
|
||||||
|
? sharedCursorFocus
|
||||||
|
: currentRegion.focus,
|
||||||
|
currentScale,
|
||||||
|
viewportRatio,
|
||||||
|
);
|
||||||
|
const nextFocus = clampFocusToScale(
|
||||||
|
nextRegion.focusMode === "auto" && sharedCursorFocus ? sharedCursorFocus : nextRegion.focus,
|
||||||
|
nextScale,
|
||||||
|
viewportRatio,
|
||||||
|
);
|
||||||
const transitionFocus = getLinearFocus(currentFocus, nextFocus, transitionProgress);
|
const transitionFocus = getLinearFocus(currentFocus, nextFocus, transitionProgress);
|
||||||
|
const transitionRotation = lerpRotation3D(
|
||||||
|
getRotation3D(currentRegion),
|
||||||
|
getRotation3D(nextRegion),
|
||||||
|
transitionProgress,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
region: {
|
region: {
|
||||||
@@ -180,6 +248,7 @@ function getConnectedRegionTransition(connectedPairs: ConnectedRegionPair[], tim
|
|||||||
},
|
},
|
||||||
strength: 1,
|
strength: 1,
|
||||||
blendedScale: transitionScale,
|
blendedScale: transitionScale,
|
||||||
|
rotation3D: transitionRotation,
|
||||||
transition: {
|
transition: {
|
||||||
progress: transitionProgress,
|
progress: transitionProgress,
|
||||||
startFocus: currentFocus,
|
startFocus: currentFocus,
|
||||||
@@ -193,32 +262,92 @@ function getConnectedRegionTransition(connectedPairs: ConnectedRegionPair[], tim
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DominantRegionResult = {
|
||||||
|
region: ZoomRegion | null;
|
||||||
|
strength: number;
|
||||||
|
blendedScale: number | null;
|
||||||
|
rotation3D: Rotation3D;
|
||||||
|
transition: ConnectedPanTransition | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Single-slot cache: the ticker calls findDominantRegion at 60fps with mostly
|
||||||
|
// unchanged inputs (especially while paused). Reusing the previous result when
|
||||||
|
// inputs match avoids the per-frame O(N) region scan + allocations.
|
||||||
|
let dominantRegionCache: {
|
||||||
|
regions: ZoomRegion[];
|
||||||
|
timeMsKey: number;
|
||||||
|
telemetry: CursorTelemetryPoint[] | undefined;
|
||||||
|
connectZooms: boolean;
|
||||||
|
viewportRatio: ViewportRatio | undefined;
|
||||||
|
result: DominantRegionResult;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
export function findDominantRegion(
|
export function findDominantRegion(
|
||||||
regions: ZoomRegion[],
|
regions: ZoomRegion[],
|
||||||
timeMs: number,
|
timeMs: number,
|
||||||
options: DominantRegionOptions = {},
|
options: DominantRegionOptions = {},
|
||||||
): {
|
): DominantRegionResult {
|
||||||
region: ZoomRegion | null;
|
const connectZooms = !!options.connectZooms;
|
||||||
strength: number;
|
const telemetry = options.cursorTelemetry;
|
||||||
blendedScale: number | null;
|
const vr = options.viewportRatio;
|
||||||
transition: ConnectedPanTransition | null;
|
const timeMsKey = Math.round(timeMs);
|
||||||
} {
|
|
||||||
const connectedPairs = options.connectZooms ? getConnectedRegionPairs(regions) : [];
|
|
||||||
|
|
||||||
if (options.connectZooms) {
|
if (
|
||||||
const connectedTransition = getConnectedRegionTransition(connectedPairs, timeMs);
|
dominantRegionCache &&
|
||||||
if (connectedTransition) {
|
dominantRegionCache.regions === regions &&
|
||||||
return connectedTransition;
|
dominantRegionCache.timeMsKey === timeMsKey &&
|
||||||
}
|
dominantRegionCache.telemetry === telemetry &&
|
||||||
|
dominantRegionCache.connectZooms === connectZooms &&
|
||||||
const connectedHold = getConnectedRegionHold(timeMs, connectedPairs);
|
dominantRegionCache.viewportRatio === vr
|
||||||
if (connectedHold) {
|
) {
|
||||||
return { ...connectedHold, transition: null };
|
return dominantRegionCache.result;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeRegion = getActiveRegion(regions, timeMs, connectedPairs);
|
const connectedPairs = connectZooms ? getConnectedRegionPairs(regions) : [];
|
||||||
return activeRegion
|
|
||||||
? { ...activeRegion, transition: null }
|
let result: DominantRegionResult;
|
||||||
: { region: null, strength: 0, blendedScale: null, transition: null };
|
if (connectZooms) {
|
||||||
|
const connectedTransition = getConnectedRegionTransition(connectedPairs, timeMs, telemetry, vr);
|
||||||
|
if (connectedTransition) {
|
||||||
|
result = connectedTransition;
|
||||||
|
} else {
|
||||||
|
const connectedHold = getConnectedRegionHold(timeMs, connectedPairs, telemetry, vr);
|
||||||
|
if (connectedHold) {
|
||||||
|
result = { ...connectedHold, transition: null };
|
||||||
|
} else {
|
||||||
|
const activeRegion = getActiveRegion(regions, timeMs, connectedPairs, telemetry, vr);
|
||||||
|
result = activeRegion
|
||||||
|
? { ...activeRegion, transition: null }
|
||||||
|
: {
|
||||||
|
region: null,
|
||||||
|
strength: 0,
|
||||||
|
blendedScale: null,
|
||||||
|
rotation3D: DEFAULT_ROTATION_3D,
|
||||||
|
transition: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const activeRegion = getActiveRegion(regions, timeMs, connectedPairs, telemetry, vr);
|
||||||
|
result = activeRegion
|
||||||
|
? { ...activeRegion, transition: null }
|
||||||
|
: {
|
||||||
|
region: null,
|
||||||
|
strength: 0,
|
||||||
|
blendedScale: null,
|
||||||
|
rotation3D: DEFAULT_ROTATION_3D,
|
||||||
|
transition: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
dominantRegionCache = {
|
||||||
|
regions,
|
||||||
|
timeMsKey,
|
||||||
|
telemetry,
|
||||||
|
connectZooms,
|
||||||
|
viewportRatio: vr,
|
||||||
|
result,
|
||||||
|
};
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,8 +90,10 @@ export function computeZoomTransform({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const progress = Math.min(1, Math.max(0, zoomProgress));
|
const progress = Math.min(1, Math.max(0, zoomProgress));
|
||||||
const focusStagePxX = baseMask.x + focusX * baseMask.width;
|
// Focus coordinates are stage-normalized (0-1 of full canvas),
|
||||||
const focusStagePxY = baseMask.y + focusY * baseMask.height;
|
// so map directly to stage pixels, not through baseMask.
|
||||||
|
const focusStagePxX = focusX * stageSize.width;
|
||||||
|
const focusStagePxY = focusY * stageSize.height;
|
||||||
const stageCenterX = stageSize.width / 2;
|
const stageCenterX = stageSize.width / 2;
|
||||||
const stageCenterY = stageSize.height / 2;
|
const stageCenterY = stageSize.height / 2;
|
||||||
const scale = 1 + (zoomScale - 1) * progress;
|
const scale = 1 + (zoomScale - 1) * progress;
|
||||||
@@ -128,8 +130,8 @@ export function computeFocusFromTransform({
|
|||||||
const focusStagePxY = (stageCenterY - y) / zoomScale;
|
const focusStagePxY = (stageCenterY - y) / zoomScale;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cx: (focusStagePxX - baseMask.x) / baseMask.width,
|
cx: focusStagePxX / stageSize.width,
|
||||||
cy: (focusStagePxY - baseMask.y) / baseMask.height,
|
cy: focusStagePxY / stageSize.height,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
type ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { DEFAULT_LOCALE, type I18nNamespace, LOCALE_STORAGE_KEY, type Locale } from "@/i18n/config";
|
||||||
|
import { getAvailableLocales, translate } from "@/i18n/loader";
|
||||||
|
|
||||||
|
type TranslateVars = Record<string, string | number>;
|
||||||
|
|
||||||
|
interface I18nContextValue {
|
||||||
|
locale: Locale;
|
||||||
|
setLocale: (locale: Locale) => void;
|
||||||
|
t: (qualifiedKey: string, vars?: TranslateVars) => string;
|
||||||
|
systemLocaleSuggestion: Locale | null;
|
||||||
|
acceptSystemLocaleSuggestion: () => void;
|
||||||
|
dismissSystemLocaleSuggestion: () => void;
|
||||||
|
resolveSystemLocaleSuggestion: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SYSTEM_LANGUAGE_PROMPT_SEEN_KEY = "openscreen-system-language-prompt-seen";
|
||||||
|
|
||||||
|
const I18nContext = createContext<I18nContextValue | null>(null);
|
||||||
|
|
||||||
|
export function useI18n(): I18nContextValue {
|
||||||
|
const ctx = useContext(I18nContext);
|
||||||
|
if (!ctx) throw new Error("useI18n must be used within <I18nProvider>");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useScopedT(namespace: I18nNamespace) {
|
||||||
|
const { locale } = useI18n();
|
||||||
|
return useCallback(
|
||||||
|
(key: string, vars?: TranslateVars): string => translate(locale, namespace, key, vars),
|
||||||
|
[locale, namespace],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSupportedLocale(value: string): value is Locale {
|
||||||
|
return getAvailableLocales().includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSupportedSystemLocale(): Locale | null {
|
||||||
|
if (typeof navigator === "undefined") return null;
|
||||||
|
const availableLocales = getAvailableLocales();
|
||||||
|
|
||||||
|
const candidates =
|
||||||
|
Array.isArray(navigator.languages) && navigator.languages.length > 0
|
||||||
|
? navigator.languages
|
||||||
|
: [navigator.language];
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (!candidate) continue;
|
||||||
|
if (isSupportedLocale(candidate)) return candidate;
|
||||||
|
|
||||||
|
const exactMatch = availableLocales.find(
|
||||||
|
(locale) => locale.toLowerCase() === candidate.toLowerCase(),
|
||||||
|
);
|
||||||
|
if (exactMatch) return exactMatch;
|
||||||
|
|
||||||
|
const baseLanguage = candidate.split("-")[0]?.toLowerCase();
|
||||||
|
if (!baseLanguage) continue;
|
||||||
|
|
||||||
|
if (baseLanguage === "zh" && availableLocales.includes("zh-CN")) return "zh-CN";
|
||||||
|
|
||||||
|
const baseMatch = availableLocales.find((locale) => locale.toLowerCase() === baseLanguage);
|
||||||
|
if (baseMatch) return baseMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitialLocale(): Locale {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(LOCALE_STORAGE_KEY);
|
||||||
|
if (stored && isSupportedLocale(stored)) return stored;
|
||||||
|
} catch {
|
||||||
|
// localStorage may be unavailable
|
||||||
|
}
|
||||||
|
return DEFAULT_LOCALE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function I18nProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [locale, setLocaleState] = useState<Locale>(getInitialLocale);
|
||||||
|
const [systemLocaleSuggestion, setSystemLocaleSuggestion] = useState<Locale | null>(null);
|
||||||
|
const hasRunSystemLocaleCheckRef = useRef(false);
|
||||||
|
|
||||||
|
const markPromptAsHandled = useCallback(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SYSTEM_LANGUAGE_PROMPT_SEEN_KEY, "1");
|
||||||
|
} catch {
|
||||||
|
// localStorage may be unavailable
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setLocale = useCallback((newLocale: Locale) => {
|
||||||
|
setLocaleState(newLocale);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LOCALE_STORAGE_KEY, newLocale);
|
||||||
|
} catch {
|
||||||
|
// localStorage may be unavailable
|
||||||
|
}
|
||||||
|
document.documentElement.lang = newLocale;
|
||||||
|
// Notify Electron main process
|
||||||
|
window.electronAPI?.setLocale?.(newLocale);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.lang = locale;
|
||||||
|
}, [locale]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasRunSystemLocaleCheckRef.current) return;
|
||||||
|
hasRunSystemLocaleCheckRef.current = true;
|
||||||
|
|
||||||
|
let hasStoredLocale = false;
|
||||||
|
let hasHandledSystemPrompt = false;
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(LOCALE_STORAGE_KEY);
|
||||||
|
hasStoredLocale = Boolean(stored && isSupportedLocale(stored));
|
||||||
|
hasHandledSystemPrompt = localStorage.getItem(SYSTEM_LANGUAGE_PROMPT_SEEN_KEY) === "1";
|
||||||
|
} catch {
|
||||||
|
// localStorage may be unavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasStoredLocale || hasHandledSystemPrompt) return;
|
||||||
|
|
||||||
|
const detectedSystemLocale = getSupportedSystemLocale();
|
||||||
|
if (!detectedSystemLocale || detectedSystemLocale === DEFAULT_LOCALE) {
|
||||||
|
markPromptAsHandled();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSystemLocaleSuggestion(detectedSystemLocale);
|
||||||
|
}, [markPromptAsHandled]);
|
||||||
|
|
||||||
|
const acceptSystemLocaleSuggestion = useCallback(() => {
|
||||||
|
if (!systemLocaleSuggestion) return;
|
||||||
|
setLocale(systemLocaleSuggestion);
|
||||||
|
setSystemLocaleSuggestion(null);
|
||||||
|
markPromptAsHandled();
|
||||||
|
}, [markPromptAsHandled, setLocale, systemLocaleSuggestion]);
|
||||||
|
|
||||||
|
const dismissSystemLocaleSuggestion = useCallback(() => {
|
||||||
|
setSystemLocaleSuggestion(null);
|
||||||
|
markPromptAsHandled();
|
||||||
|
}, [markPromptAsHandled]);
|
||||||
|
|
||||||
|
const resolveSystemLocaleSuggestion = useCallback(() => {
|
||||||
|
setSystemLocaleSuggestion(null);
|
||||||
|
markPromptAsHandled();
|
||||||
|
}, [markPromptAsHandled]);
|
||||||
|
|
||||||
|
const t = useCallback(
|
||||||
|
(qualifiedKey: string, vars?: TranslateVars): string => {
|
||||||
|
const dotIndex = qualifiedKey.indexOf(".");
|
||||||
|
if (dotIndex === -1) return qualifiedKey;
|
||||||
|
const namespace = qualifiedKey.slice(0, dotIndex) as I18nNamespace;
|
||||||
|
const key = qualifiedKey.slice(dotIndex + 1);
|
||||||
|
return translate(locale, namespace, key, vars);
|
||||||
|
},
|
||||||
|
[locale],
|
||||||
|
);
|
||||||
|
|
||||||
|
const value = useMemo<I18nContextValue>(
|
||||||
|
() => ({
|
||||||
|
locale,
|
||||||
|
setLocale,
|
||||||
|
t,
|
||||||
|
systemLocaleSuggestion,
|
||||||
|
acceptSystemLocaleSuggestion,
|
||||||
|
dismissSystemLocaleSuggestion,
|
||||||
|
resolveSystemLocaleSuggestion,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
locale,
|
||||||
|
setLocale,
|
||||||
|
t,
|
||||||
|
systemLocaleSuggestion,
|
||||||
|
acceptSystemLocaleSuggestion,
|
||||||
|
dismissSystemLocaleSuggestion,
|
||||||
|
resolveSystemLocaleSuggestion,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { useCameraDevices } from "./useCameraDevices";
|
||||||
|
|
||||||
|
// Mock navigator.mediaDevices
|
||||||
|
const mockDevices = [
|
||||||
|
{ kind: "videoinput", deviceId: "cam1", label: "Camera 1", groupId: "group1" },
|
||||||
|
{ kind: "videoinput", deviceId: "cam2", label: "Camera 2", groupId: "group1" },
|
||||||
|
{ kind: "audioinput", deviceId: "mic1", label: "Mic 1", groupId: "group2" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockGetUserMedia = vi.fn().mockResolvedValue({
|
||||||
|
getTracks: () => [{ stop: vi.fn() }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockEnumerateDevices = vi.fn().mockResolvedValue(mockDevices);
|
||||||
|
|
||||||
|
Object.defineProperty(global.navigator, "mediaDevices", {
|
||||||
|
value: {
|
||||||
|
enumerateDevices: mockEnumerateDevices,
|
||||||
|
getUserMedia: mockGetUserMedia,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
},
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useCameraDevices", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockEnumerateDevices.mockResolvedValue(mockDevices);
|
||||||
|
mockGetUserMedia.mockResolvedValue({
|
||||||
|
getTracks: () => [{ stop: vi.fn() }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should list video input devices", async () => {
|
||||||
|
const { result } = renderHook(() => useCameraDevices(true));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.devices).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.devices[0].label).toBe("Camera 1");
|
||||||
|
expect(result.current.devices[1].deviceId).toBe("cam2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should set first device as default", async () => {
|
||||||
|
const { result } = renderHook(() => useCameraDevices(true));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.selectedDeviceId).toBe("cam1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use device ID as fallback label when label is missing", async () => {
|
||||||
|
mockEnumerateDevices.mockResolvedValueOnce([
|
||||||
|
{ kind: "videoinput", deviceId: "cam1abc123456", label: "", groupId: "group1" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useCameraDevices(true));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.devices[0]?.label).toBe("Camera cam1abc1");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockGetUserMedia).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should set error state when enumeration fails", async () => {
|
||||||
|
mockEnumerateDevices.mockRejectedValueOnce(new Error("Permission denied"));
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useCameraDevices(true));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.error).toBe("Permission denied");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.devices).toHaveLength(0);
|
||||||
|
expect(result.current.isLoading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fall back to first available device when selected device is unplugged", async () => {
|
||||||
|
const { result } = renderHook(() => useCameraDevices(true));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.selectedDeviceId).toBe("cam1");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simulate cam1 being unplugged — only cam2 remains
|
||||||
|
const cam2Only = [
|
||||||
|
{ kind: "videoinput", deviceId: "cam2", label: "Camera 2", groupId: "group1" },
|
||||||
|
];
|
||||||
|
mockEnumerateDevices.mockResolvedValueOnce(cam2Only);
|
||||||
|
|
||||||
|
// Trigger devicechange event via the registered handler
|
||||||
|
const devicechangeHandler = (
|
||||||
|
navigator.mediaDevices.addEventListener as ReturnType<typeof vi.fn>
|
||||||
|
).mock.calls[0]?.[1] as (() => void) | undefined;
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
devicechangeHandler?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.selectedDeviceId).toBe("cam2");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
export interface CameraDevice {
|
||||||
|
deviceId: string;
|
||||||
|
label: string;
|
||||||
|
groupId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCameraDevices(enabled: boolean = false) {
|
||||||
|
const [devices, setDevices] = useState<CameraDevice[]>([]);
|
||||||
|
const [selectedDeviceId, setSelectedDeviceId] = useState<string>("");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const selectedDeviceIdRef = useRef(selectedDeviceId);
|
||||||
|
selectedDeviceIdRef.current = selectedDeviceId;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
let mounted = true;
|
||||||
|
|
||||||
|
const loadDevices = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// Enumerate without requesting a second stream — the recorder handles
|
||||||
|
// the real acquisition; unlabeled devices fall back to their device ID.
|
||||||
|
const allDevices = await navigator.mediaDevices.enumerateDevices();
|
||||||
|
const videoInputs = allDevices
|
||||||
|
.filter((device) => device.kind === "videoinput")
|
||||||
|
.map((device) => ({
|
||||||
|
deviceId: device.deviceId,
|
||||||
|
label: device.label || `Camera ${device.deviceId.slice(0, 8)}`,
|
||||||
|
groupId: device.groupId,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setDevices(videoInputs);
|
||||||
|
const currentId = selectedDeviceIdRef.current;
|
||||||
|
const stillAvailable = videoInputs.some((d) => d.deviceId === currentId);
|
||||||
|
if (!currentId || !stillAvailable) {
|
||||||
|
setSelectedDeviceId(videoInputs[0]?.deviceId ?? "");
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (mounted) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to load cameras");
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadDevices();
|
||||||
|
|
||||||
|
navigator.mediaDevices.addEventListener("devicechange", loadDevices);
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
navigator.mediaDevices.removeEventListener("devicechange", loadDevices);
|
||||||
|
};
|
||||||
|
}, [enabled]);
|
||||||
|
|
||||||
|
return { devices, selectedDeviceId, setSelectedDeviceId, isLoading, error };
|
||||||
|
}
|
||||||
@@ -4,9 +4,24 @@ import type {
|
|||||||
CropRegion,
|
CropRegion,
|
||||||
SpeedRegion,
|
SpeedRegion,
|
||||||
TrimRegion,
|
TrimRegion,
|
||||||
|
WebcamLayoutPreset,
|
||||||
|
WebcamMaskShape,
|
||||||
|
WebcamPosition,
|
||||||
|
WebcamSizePreset,
|
||||||
ZoomRegion,
|
ZoomRegion,
|
||||||
} from "@/components/video-editor/types";
|
} from "@/components/video-editor/types";
|
||||||
import { DEFAULT_CROP_REGION } from "@/components/video-editor/types";
|
import {
|
||||||
|
DEFAULT_CROP_REGION,
|
||||||
|
DEFAULT_WEBCAM_LAYOUT_PRESET,
|
||||||
|
DEFAULT_WEBCAM_MASK_SHAPE,
|
||||||
|
DEFAULT_WEBCAM_POSITION,
|
||||||
|
DEFAULT_WEBCAM_SIZE_PRESET,
|
||||||
|
} from "@/components/video-editor/types";
|
||||||
|
import {
|
||||||
|
type CursorHighlightConfig,
|
||||||
|
DEFAULT_CURSOR_HIGHLIGHT,
|
||||||
|
} from "@/components/video-editor/videoPlayback/cursorHighlight";
|
||||||
|
import { DEFAULT_WALLPAPER } from "@/lib/wallpaper";
|
||||||
import type { AspectRatio } from "@/utils/aspectRatioUtils";
|
import type { AspectRatio } from "@/utils/aspectRatioUtils";
|
||||||
|
|
||||||
// Undoable state — selection IDs are intentionally excluded (undoing a
|
// Undoable state — selection IDs are intentionally excluded (undoing a
|
||||||
@@ -24,6 +39,11 @@ export interface EditorState {
|
|||||||
borderRadius: number;
|
borderRadius: number;
|
||||||
padding: number;
|
padding: number;
|
||||||
aspectRatio: AspectRatio;
|
aspectRatio: AspectRatio;
|
||||||
|
webcamLayoutPreset: WebcamLayoutPreset;
|
||||||
|
webcamMaskShape: WebcamMaskShape;
|
||||||
|
webcamSizePreset: WebcamSizePreset;
|
||||||
|
webcamPosition: WebcamPosition | null;
|
||||||
|
cursorHighlight: CursorHighlightConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const INITIAL_EDITOR_STATE: EditorState = {
|
export const INITIAL_EDITOR_STATE: EditorState = {
|
||||||
@@ -32,13 +52,18 @@ export const INITIAL_EDITOR_STATE: EditorState = {
|
|||||||
speedRegions: [],
|
speedRegions: [],
|
||||||
annotationRegions: [],
|
annotationRegions: [],
|
||||||
cropRegion: DEFAULT_CROP_REGION,
|
cropRegion: DEFAULT_CROP_REGION,
|
||||||
wallpaper: "/wallpapers/wallpaper1.jpg",
|
wallpaper: DEFAULT_WALLPAPER,
|
||||||
shadowIntensity: 0,
|
shadowIntensity: 0,
|
||||||
showBlur: false,
|
showBlur: false,
|
||||||
motionBlurAmount: 0,
|
motionBlurAmount: 0,
|
||||||
borderRadius: 0,
|
borderRadius: 0,
|
||||||
padding: 50,
|
padding: 50,
|
||||||
aspectRatio: "16:9",
|
aspectRatio: "16:9",
|
||||||
|
webcamLayoutPreset: DEFAULT_WEBCAM_LAYOUT_PRESET,
|
||||||
|
webcamMaskShape: DEFAULT_WEBCAM_MASK_SHAPE,
|
||||||
|
webcamSizePreset: DEFAULT_WEBCAM_SIZE_PRESET,
|
||||||
|
webcamPosition: DEFAULT_WEBCAM_POSITION,
|
||||||
|
cursorHighlight: DEFAULT_CURSOR_HIGHLIGHT,
|
||||||
};
|
};
|
||||||
|
|
||||||
type StateUpdate = Partial<EditorState> | ((prev: EditorState) => Partial<EditorState>);
|
type StateUpdate = Partial<EditorState> | ((prev: EditorState) => Partial<EditorState>);
|
||||||
|
|||||||
+423
-65
@@ -1,6 +1,8 @@
|
|||||||
import { fixWebmDuration } from "@fix-webm-duration/fix";
|
import { fixWebmDuration } from "@fix-webm-duration/fix";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { useScopedT } from "@/contexts/I18nContext";
|
||||||
|
import { requestCameraAccess } from "@/lib/requestCameraAccess";
|
||||||
|
|
||||||
const TARGET_FRAME_RATE = 60;
|
const TARGET_FRAME_RATE = 60;
|
||||||
const MIN_FRAME_RATE = 30;
|
const MIN_FRAME_RATE = 30;
|
||||||
@@ -39,12 +41,18 @@ const WEBCAM_TARGET_FRAME_RATE = 30;
|
|||||||
|
|
||||||
type UseScreenRecorderReturn = {
|
type UseScreenRecorderReturn = {
|
||||||
recording: boolean;
|
recording: boolean;
|
||||||
|
paused: boolean;
|
||||||
|
elapsedSeconds: number;
|
||||||
toggleRecording: () => void;
|
toggleRecording: () => void;
|
||||||
|
togglePaused: () => void;
|
||||||
restartRecording: () => void;
|
restartRecording: () => void;
|
||||||
|
cancelRecording: () => void;
|
||||||
microphoneEnabled: boolean;
|
microphoneEnabled: boolean;
|
||||||
setMicrophoneEnabled: (enabled: boolean) => void;
|
setMicrophoneEnabled: (enabled: boolean) => void;
|
||||||
microphoneDeviceId: string | undefined;
|
microphoneDeviceId: string | undefined;
|
||||||
setMicrophoneDeviceId: (deviceId: string | undefined) => void;
|
setMicrophoneDeviceId: (deviceId: string | undefined) => void;
|
||||||
|
webcamDeviceId: string | undefined;
|
||||||
|
setWebcamDeviceId: (deviceId: string | undefined) => void;
|
||||||
systemAudioEnabled: boolean;
|
systemAudioEnabled: boolean;
|
||||||
setSystemAudioEnabled: (enabled: boolean) => void;
|
setSystemAudioEnabled: (enabled: boolean) => void;
|
||||||
webcamEnabled: boolean;
|
webcamEnabled: boolean;
|
||||||
@@ -79,9 +87,13 @@ function createRecorderHandle(stream: MediaStream, options: MediaRecorderOptions
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useScreenRecorder(): UseScreenRecorderReturn {
|
export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||||
|
const t = useScopedT("editor");
|
||||||
const [recording, setRecording] = useState(false);
|
const [recording, setRecording] = useState(false);
|
||||||
|
const [paused, setPaused] = useState(false);
|
||||||
|
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
||||||
const [microphoneEnabled, setMicrophoneEnabled] = useState(false);
|
const [microphoneEnabled, setMicrophoneEnabled] = useState(false);
|
||||||
const [microphoneDeviceId, setMicrophoneDeviceId] = useState<string | undefined>(undefined);
|
const [microphoneDeviceId, setMicrophoneDeviceId] = useState<string | undefined>(undefined);
|
||||||
|
const [webcamDeviceId, setWebcamDeviceId] = useState<string | undefined>(undefined);
|
||||||
const [systemAudioEnabled, setSystemAudioEnabled] = useState(false);
|
const [systemAudioEnabled, setSystemAudioEnabled] = useState(false);
|
||||||
const [webcamEnabled, setWebcamEnabledState] = useState(false);
|
const [webcamEnabled, setWebcamEnabledState] = useState(false);
|
||||||
const screenRecorder = useRef<RecorderHandle | null>(null);
|
const screenRecorder = useRef<RecorderHandle | null>(null);
|
||||||
@@ -91,19 +103,34 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
|||||||
const microphoneStream = useRef<MediaStream | null>(null);
|
const microphoneStream = useRef<MediaStream | null>(null);
|
||||||
const webcamStream = useRef<MediaStream | null>(null);
|
const webcamStream = useRef<MediaStream | null>(null);
|
||||||
const mixingContext = useRef<AudioContext | null>(null);
|
const mixingContext = useRef<AudioContext | null>(null);
|
||||||
const startTime = useRef<number>(0);
|
|
||||||
const recordingId = useRef<number>(0);
|
const recordingId = useRef<number>(0);
|
||||||
|
const accumulatedDurationMs = useRef(0);
|
||||||
|
const segmentStartedAt = useRef<number | null>(null);
|
||||||
const finalizingRecordingId = useRef<number | null>(null);
|
const finalizingRecordingId = useRef<number | null>(null);
|
||||||
const allowAutoFinalize = useRef(false);
|
const allowAutoFinalize = useRef(false);
|
||||||
const discardRecordingId = useRef<number | null>(null);
|
const discardRecordingId = useRef<number | null>(null);
|
||||||
const restarting = useRef(false);
|
const restarting = useRef(false);
|
||||||
|
const countdownRunId = useRef(0);
|
||||||
|
const [countdownActive, setCountdownActive] = useState(false);
|
||||||
|
const webcamReady = useRef(false);
|
||||||
|
const webcamAcquireId = useRef(0);
|
||||||
|
|
||||||
|
const getRecordingDurationMs = useCallback(() => {
|
||||||
|
const segmentDuration =
|
||||||
|
segmentStartedAt.current === null ? 0 : Date.now() - segmentStartedAt.current;
|
||||||
|
return accumulatedDurationMs.current + segmentDuration;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const selectMimeType = () => {
|
const selectMimeType = () => {
|
||||||
|
// H.264 first: hardware-accelerated on all modern devices, gives sharp
|
||||||
|
// real-time output. AV1/VP9 are great for distribution but too
|
||||||
|
// CPU-intensive for live 60 fps capture — they produce blurry frames
|
||||||
|
// when the software encoder can't keep up.
|
||||||
const preferred = [
|
const preferred = [
|
||||||
"video/webm;codecs=av1",
|
|
||||||
"video/webm;codecs=h264",
|
"video/webm;codecs=h264",
|
||||||
"video/webm;codecs=vp9",
|
|
||||||
"video/webm;codecs=vp8",
|
"video/webm;codecs=vp8",
|
||||||
|
"video/webm;codecs=vp9",
|
||||||
|
"video/webm;codecs=av1",
|
||||||
"video/webm",
|
"video/webm",
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -139,10 +166,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
|||||||
microphoneStream.current.getTracks().forEach((track) => track.stop());
|
microphoneStream.current.getTracks().forEach((track) => track.stop());
|
||||||
microphoneStream.current = null;
|
microphoneStream.current = null;
|
||||||
}
|
}
|
||||||
if (webcamStream.current) {
|
|
||||||
webcamStream.current.getTracks().forEach((track) => track.stop());
|
|
||||||
webcamStream.current = null;
|
|
||||||
}
|
|
||||||
if (mixingContext.current) {
|
if (mixingContext.current) {
|
||||||
mixingContext.current.close().catch(() => {
|
mixingContext.current.close().catch(() => {
|
||||||
// Ignore close errors during recorder teardown.
|
// Ignore close errors during recorder teardown.
|
||||||
@@ -151,37 +174,108 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setWebcamEnabled = useCallback(async (enabled: boolean) => {
|
const setWebcamEnabled = useCallback(
|
||||||
if (!enabled) {
|
async (enabled: boolean) => {
|
||||||
setWebcamEnabledState(false);
|
if (!enabled) {
|
||||||
return true;
|
setWebcamEnabledState(false);
|
||||||
}
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const accessResult = await window.electronAPI.requestCameraAccess();
|
const accessResult = await requestCameraAccess();
|
||||||
if (!accessResult.success) {
|
if (!accessResult.success) {
|
||||||
toast.error("Failed to request camera access.");
|
toast.error(t("recording.failedCameraAccess"));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!accessResult.granted) {
|
if (!accessResult.granted) {
|
||||||
toast.error("Camera access is blocked. Enable it in system settings to use the webcam.");
|
toast.error(t("recording.cameraBlocked"));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
const probeStream = await navigator.mediaDevices.getUserMedia({
|
|
||||||
audio: false,
|
|
||||||
video: true,
|
|
||||||
});
|
|
||||||
probeStream.getTracks().forEach((track) => track.stop());
|
|
||||||
setWebcamEnabledState(true);
|
setWebcamEnabledState(true);
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
},
|
||||||
console.warn("Failed to preflight webcam access:", error);
|
[t],
|
||||||
toast.error("Camera access denied. Webcam overlay will stay disabled.");
|
);
|
||||||
return false;
|
|
||||||
}
|
useEffect(() => {
|
||||||
}, []);
|
if (!webcamEnabled) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
let acquiredStream: MediaStream | null = null;
|
||||||
|
const thisAcquireId = ++webcamAcquireId.current;
|
||||||
|
webcamReady.current = false;
|
||||||
|
|
||||||
|
const acquire = async () => {
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: false,
|
||||||
|
video: webcamDeviceId
|
||||||
|
? {
|
||||||
|
deviceId: { exact: webcamDeviceId },
|
||||||
|
width: { ideal: WEBCAM_TARGET_WIDTH },
|
||||||
|
height: { ideal: WEBCAM_TARGET_HEIGHT },
|
||||||
|
frameRate: { ideal: WEBCAM_TARGET_FRAME_RATE, max: WEBCAM_TARGET_FRAME_RATE },
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
width: { ideal: WEBCAM_TARGET_WIDTH },
|
||||||
|
height: { ideal: WEBCAM_TARGET_HEIGHT },
|
||||||
|
frameRate: { ideal: WEBCAM_TARGET_FRAME_RATE, max: WEBCAM_TARGET_FRAME_RATE },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cancelled || thisAcquireId !== webcamAcquireId.current) {
|
||||||
|
stream.getTracks().forEach((track) => {
|
||||||
|
track.onended = null;
|
||||||
|
track.stop();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
acquiredStream = stream;
|
||||||
|
stream.getVideoTracks().forEach((track) => {
|
||||||
|
track.onended = () => {
|
||||||
|
webcamStream.current = null;
|
||||||
|
if (!restarting.current) {
|
||||||
|
setWebcamEnabledState(false);
|
||||||
|
toast.error(t("recording.cameraDisconnected"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
webcamStream.current = stream;
|
||||||
|
webcamReady.current = true;
|
||||||
|
} catch (cameraError) {
|
||||||
|
if (!cancelled) {
|
||||||
|
console.warn("Failed to get webcam access:", cameraError);
|
||||||
|
setWebcamEnabledState(false);
|
||||||
|
const isDeviceError =
|
||||||
|
cameraError instanceof DOMException &&
|
||||||
|
[
|
||||||
|
"NotFoundError",
|
||||||
|
"DevicesNotFoundError",
|
||||||
|
"OverconstrainedError",
|
||||||
|
"NotReadableError",
|
||||||
|
].includes(cameraError.name);
|
||||||
|
toast.error(t(isDeviceError ? "recording.cameraNotFound" : "recording.cameraBlocked"));
|
||||||
|
webcamReady.current = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void acquire();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
webcamReady.current = false;
|
||||||
|
if (acquiredStream) {
|
||||||
|
acquiredStream.getTracks().forEach((track) => {
|
||||||
|
track.onended = null;
|
||||||
|
track.stop();
|
||||||
|
});
|
||||||
|
webcamStream.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [webcamEnabled, webcamDeviceId, t]);
|
||||||
|
|
||||||
const finalizeRecording = useCallback(
|
const finalizeRecording = useCallback(
|
||||||
(
|
(
|
||||||
@@ -204,12 +298,17 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
|||||||
|
|
||||||
teardownMedia();
|
teardownMedia();
|
||||||
setRecording(false);
|
setRecording(false);
|
||||||
|
setPaused(false);
|
||||||
|
setElapsedSeconds(0);
|
||||||
|
accumulatedDurationMs.current = 0;
|
||||||
|
segmentStartedAt.current = null;
|
||||||
window.electronAPI?.setRecordingState(false);
|
window.electronAPI?.setRecordingState(false);
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const screenBlob = await activeScreenRecorder.recordedBlobPromise;
|
const screenBlob = await activeScreenRecorder.recordedBlobPromise;
|
||||||
if (discardRecordingId.current === activeRecordingId) {
|
if (discardRecordingId.current === activeRecordingId) {
|
||||||
|
window.electronAPI?.discardCursorTelemetry(activeRecordingId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (screenBlob.size === 0) {
|
if (screenBlob.size === 0) {
|
||||||
@@ -275,7 +374,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const activeWebcamRecorder = webcamRecorder.current;
|
const activeWebcamRecorder = webcamRecorder.current;
|
||||||
const duration = Date.now() - startTime.current;
|
const duration = getRecordingDurationMs();
|
||||||
const activeRecordingId = recordingId.current;
|
const activeRecordingId = recordingId.current;
|
||||||
|
|
||||||
finalizeRecording(
|
finalizeRecording(
|
||||||
@@ -285,7 +384,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
|||||||
activeRecordingId,
|
activeRecordingId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (activeScreenRecorder.recorder.state === "recording") {
|
if (
|
||||||
|
activeScreenRecorder.recorder.state === "recording" ||
|
||||||
|
activeScreenRecorder.recorder.state === "paused"
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
activeScreenRecorder.recorder.stop();
|
activeScreenRecorder.recorder.stop();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -293,7 +395,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (activeWebcamRecorder) {
|
if (activeWebcamRecorder) {
|
||||||
if (activeWebcamRecorder.recorder.state === "recording") {
|
if (
|
||||||
|
activeWebcamRecorder.recorder.state === "recording" ||
|
||||||
|
activeWebcamRecorder.recorder.state === "paused"
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
activeWebcamRecorder.recorder.stop();
|
activeWebcamRecorder.recorder.stop();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -313,19 +418,28 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
const activeRunId = countdownRunId.current;
|
||||||
if (cleanup) cleanup();
|
if (cleanup) cleanup();
|
||||||
|
countdownRunId.current += 1;
|
||||||
|
void safeHideCountdownOverlay(activeRunId);
|
||||||
allowAutoFinalize.current = false;
|
allowAutoFinalize.current = false;
|
||||||
restarting.current = false;
|
restarting.current = false;
|
||||||
discardRecordingId.current = null;
|
discardRecordingId.current = null;
|
||||||
|
|
||||||
if (screenRecorder.current?.recorder.state === "recording") {
|
if (
|
||||||
|
screenRecorder.current?.recorder.state === "recording" ||
|
||||||
|
screenRecorder.current?.recorder.state === "paused"
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
screenRecorder.current.recorder.stop();
|
screenRecorder.current.recorder.stop();
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore recorder teardown errors during cleanup.
|
// Ignore recorder teardown errors during cleanup.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (webcamRecorder.current?.recorder.state === "recording") {
|
if (
|
||||||
|
webcamRecorder.current?.recorder.state === "recording" ||
|
||||||
|
webcamRecorder.current?.recorder.state === "paused"
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
webcamRecorder.current.recorder.stop();
|
webcamRecorder.current.recorder.stop();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -338,11 +452,126 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
|||||||
};
|
};
|
||||||
}, [teardownMedia]);
|
}, [teardownMedia]);
|
||||||
|
|
||||||
const startRecording = async () => {
|
const safeShowCountdownOverlay = async (value: number, runId: number) => {
|
||||||
|
try {
|
||||||
|
await window.electronAPI.showCountdownOverlay(value, runId);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Failed to show countdown overlay:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelCountdown = () => {
|
||||||
|
const activeRunId = countdownRunId.current;
|
||||||
|
countdownRunId.current += 1;
|
||||||
|
setCountdownActive(false);
|
||||||
|
void safeHideCountdownOverlay(activeRunId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const safeSetCountdownOverlayValue = async (value: number, runId: number) => {
|
||||||
|
try {
|
||||||
|
await window.electronAPI.setCountdownOverlayValue(value, runId);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Failed to update countdown overlay value:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const safeHideCountdownOverlay = async (runId: number) => {
|
||||||
|
try {
|
||||||
|
await window.electronAPI.hideCountdownOverlay(runId);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Failed to hide countdown overlay:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isCountdownRunActive = (runId?: number) =>
|
||||||
|
runId === undefined || countdownRunId.current === runId;
|
||||||
|
|
||||||
|
const startRecordCountdown = async () => {
|
||||||
|
if (countdownActive || recording) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const runId = countdownRunId.current + 1;
|
||||||
|
countdownRunId.current = runId;
|
||||||
|
setCountdownActive(true);
|
||||||
|
|
||||||
|
let selectedSource: ProcessedDesktopSource | null = null;
|
||||||
|
try {
|
||||||
|
selectedSource = await window.electronAPI.getSelectedSource();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Failed to read selected source before countdown:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isCountdownRunActive(runId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedSource) {
|
||||||
|
if (countdownRunId.current === runId) {
|
||||||
|
setCountdownActive(false);
|
||||||
|
}
|
||||||
|
alert(t("recording.selectSource"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let overlayHiddenBeforeStart = false;
|
||||||
|
try {
|
||||||
|
const values = [3, 2, 1];
|
||||||
|
const overlayShown = await safeShowCountdownOverlay(values[0], runId);
|
||||||
|
|
||||||
|
if (countdownRunId.current !== runId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const value of values) {
|
||||||
|
if (countdownRunId.current !== runId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overlayShown && value !== values[0]) {
|
||||||
|
await safeSetCountdownOverlayValue(value, runId);
|
||||||
|
|
||||||
|
if (countdownRunId.current !== runId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((resolve) => window.setTimeout(resolve, 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (countdownRunId.current !== runId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCountdownActive(false);
|
||||||
|
await safeHideCountdownOverlay(runId);
|
||||||
|
overlayHiddenBeforeStart = true;
|
||||||
|
|
||||||
|
if (countdownRunId.current !== runId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await startRecording(runId);
|
||||||
|
} finally {
|
||||||
|
if (!overlayHiddenBeforeStart && countdownRunId.current === runId) {
|
||||||
|
setCountdownActive(false);
|
||||||
|
await safeHideCountdownOverlay(runId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startRecording = async (countdownRunToken?: number) => {
|
||||||
try {
|
try {
|
||||||
const selectedSource = await window.electronAPI.getSelectedSource();
|
const selectedSource = await window.electronAPI.getSelectedSource();
|
||||||
if (!selectedSource) {
|
if (!selectedSource) {
|
||||||
alert("Please select a source to record");
|
alert(t("recording.selectSource"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isCountdownRunActive(countdownRunToken)) {
|
||||||
|
teardownMedia();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,7 +601,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
|||||||
} as unknown as MediaStreamConstraints);
|
} as unknown as MediaStreamConstraints);
|
||||||
} catch (audioErr) {
|
} catch (audioErr) {
|
||||||
console.warn("System audio capture failed, falling back to video-only:", audioErr);
|
console.warn("System audio capture failed, falling back to video-only:", audioErr);
|
||||||
toast.error("System audio not available. Recording without system audio.");
|
toast.error(t("recording.systemAudioUnavailable"));
|
||||||
screenMediaStream = await navigator.mediaDevices.getUserMedia({
|
screenMediaStream = await navigator.mediaDevices.getUserMedia({
|
||||||
audio: false,
|
audio: false,
|
||||||
video: videoConstraints,
|
video: videoConstraints,
|
||||||
@@ -386,6 +615,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
|||||||
}
|
}
|
||||||
screenStream.current = screenMediaStream;
|
screenStream.current = screenMediaStream;
|
||||||
|
|
||||||
|
if (!isCountdownRunActive(countdownRunToken)) {
|
||||||
|
teardownMedia();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (microphoneEnabled) {
|
if (microphoneEnabled) {
|
||||||
try {
|
try {
|
||||||
microphoneStream.current = await navigator.mediaDevices.getUserMedia({
|
microphoneStream.current = await navigator.mediaDevices.getUserMedia({
|
||||||
@@ -405,30 +639,40 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
|||||||
});
|
});
|
||||||
} catch (audioError) {
|
} catch (audioError) {
|
||||||
console.warn("Failed to get microphone access:", audioError);
|
console.warn("Failed to get microphone access:", audioError);
|
||||||
toast.error("Microphone access denied. Recording will continue without audio.");
|
toast.error(t("recording.microphoneDenied"));
|
||||||
setMicrophoneEnabled(false);
|
setMicrophoneEnabled(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isCountdownRunActive(countdownRunToken)) {
|
||||||
|
teardownMedia();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (webcamEnabled) {
|
if (webcamEnabled) {
|
||||||
try {
|
if (!webcamReady.current) {
|
||||||
webcamStream.current = await navigator.mediaDevices.getUserMedia({
|
await new Promise<void>((resolve) => {
|
||||||
audio: false,
|
const interval = setInterval(() => {
|
||||||
video: {
|
if (webcamReady.current) {
|
||||||
width: { ideal: WEBCAM_TARGET_WIDTH },
|
clearInterval(interval);
|
||||||
height: { ideal: WEBCAM_TARGET_HEIGHT },
|
resolve();
|
||||||
frameRate: { ideal: WEBCAM_TARGET_FRAME_RATE, max: WEBCAM_TARGET_FRAME_RATE },
|
}
|
||||||
},
|
}, 50);
|
||||||
|
setTimeout(() => {
|
||||||
|
clearInterval(interval);
|
||||||
|
resolve();
|
||||||
|
}, 5000);
|
||||||
});
|
});
|
||||||
} catch (cameraError) {
|
|
||||||
console.warn("Failed to get webcam access:", cameraError);
|
|
||||||
if (webcamStream.current) {
|
|
||||||
webcamStream.current.getTracks().forEach((track) => track.stop());
|
|
||||||
webcamStream.current = null;
|
|
||||||
}
|
|
||||||
setWebcamEnabledState(false);
|
|
||||||
toast.error("Camera access denied. Recording will continue without webcam.");
|
|
||||||
}
|
}
|
||||||
|
if (!webcamStream.current) {
|
||||||
|
webcamAcquireId.current++;
|
||||||
|
setWebcamEnabledState(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isCountdownRunActive(countdownRunToken)) {
|
||||||
|
teardownMedia();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
stream.current = new MediaStream();
|
stream.current = new MediaStream();
|
||||||
@@ -471,6 +715,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isCountdownRunActive(countdownRunToken)) {
|
||||||
|
teardownMedia();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
width = DEFAULT_WIDTH,
|
width = DEFAULT_WIDTH,
|
||||||
height = DEFAULT_HEIGHT,
|
height = DEFAULT_HEIGHT,
|
||||||
@@ -490,6 +739,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const hasAudio = stream.current.getAudioTracks().length > 0;
|
const hasAudio = stream.current.getAudioTracks().length > 0;
|
||||||
|
if (!isCountdownRunActive(countdownRunToken)) {
|
||||||
|
teardownMedia();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
screenRecorder.current = createRecorderHandle(stream.current, {
|
screenRecorder.current = createRecorderHandle(stream.current, {
|
||||||
mimeType,
|
mimeType,
|
||||||
videoBitsPerSecond,
|
videoBitsPerSecond,
|
||||||
@@ -513,10 +767,13 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
|||||||
}
|
}
|
||||||
|
|
||||||
recordingId.current = Date.now();
|
recordingId.current = Date.now();
|
||||||
startTime.current = recordingId.current;
|
accumulatedDurationMs.current = 0;
|
||||||
|
segmentStartedAt.current = Date.now();
|
||||||
allowAutoFinalize.current = true;
|
allowAutoFinalize.current = true;
|
||||||
setRecording(true);
|
setRecording(true);
|
||||||
window.electronAPI?.setRecordingState(true);
|
setPaused(false);
|
||||||
|
setElapsedSeconds(0);
|
||||||
|
window.electronAPI?.setRecordingState(true, recordingId.current);
|
||||||
|
|
||||||
const activeScreenRecorder = screenRecorder.current;
|
const activeScreenRecorder = screenRecorder.current;
|
||||||
const activeWebcamRecorder = webcamRecorder.current;
|
const activeWebcamRecorder = webcamRecorder.current;
|
||||||
@@ -531,7 +788,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
|||||||
finalizeRecording(
|
finalizeRecording(
|
||||||
activeScreenRecorder,
|
activeScreenRecorder,
|
||||||
activeWebcamRecorder ?? null,
|
activeWebcamRecorder ?? null,
|
||||||
Math.max(0, Date.now() - startTime.current),
|
Math.max(0, getRecordingDurationMs()),
|
||||||
activeRecordingId,
|
activeRecordingId,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -542,26 +799,80 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
|||||||
console.error("Failed to start recording:", error);
|
console.error("Failed to start recording:", error);
|
||||||
const errorMsg = error instanceof Error ? error.message : "Failed to start recording";
|
const errorMsg = error instanceof Error ? error.message : "Failed to start recording";
|
||||||
if (errorMsg.includes("Permission denied") || errorMsg.includes("NotAllowedError")) {
|
if (errorMsg.includes("Permission denied") || errorMsg.includes("NotAllowedError")) {
|
||||||
toast.error("Recording permission denied. Please allow screen recording.");
|
toast.error(t("recording.permissionDenied"));
|
||||||
} else {
|
} else {
|
||||||
toast.error(errorMsg);
|
toast.error(errorMsg);
|
||||||
}
|
}
|
||||||
setRecording(false);
|
setRecording(false);
|
||||||
|
setPaused(false);
|
||||||
|
setElapsedSeconds(0);
|
||||||
|
accumulatedDurationMs.current = 0;
|
||||||
|
segmentStartedAt.current = null;
|
||||||
screenRecorder.current = null;
|
screenRecorder.current = null;
|
||||||
webcamRecorder.current = null;
|
webcamRecorder.current = null;
|
||||||
teardownMedia();
|
teardownMedia();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const togglePaused = () => {
|
||||||
|
const activeScreenRecorder = screenRecorder.current?.recorder;
|
||||||
|
if (!activeScreenRecorder || activeScreenRecorder.state === "inactive") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeWebcamRecorder = webcamRecorder.current?.recorder;
|
||||||
|
|
||||||
|
if (activeScreenRecorder.state === "paused") {
|
||||||
|
try {
|
||||||
|
activeScreenRecorder.resume();
|
||||||
|
if (activeWebcamRecorder?.state === "paused") {
|
||||||
|
activeWebcamRecorder.resume();
|
||||||
|
}
|
||||||
|
segmentStartedAt.current = Date.now();
|
||||||
|
setPaused(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to resume recording:", error);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeScreenRecorder.state !== "recording") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
accumulatedDurationMs.current = getRecordingDurationMs();
|
||||||
|
segmentStartedAt.current = null;
|
||||||
|
setElapsedSeconds(Math.floor(accumulatedDurationMs.current / 1000));
|
||||||
|
activeScreenRecorder.pause();
|
||||||
|
if (activeWebcamRecorder?.state === "recording") {
|
||||||
|
activeWebcamRecorder.pause();
|
||||||
|
}
|
||||||
|
setPaused(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to pause recording:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const toggleRecording = () => {
|
const toggleRecording = () => {
|
||||||
recording ? stopRecording.current() : startRecording();
|
if (recording) {
|
||||||
|
stopRecording.current();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (countdownActive) {
|
||||||
|
cancelCountdown();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void startRecordCountdown();
|
||||||
};
|
};
|
||||||
|
|
||||||
const restartRecording = async () => {
|
const restartRecording = async () => {
|
||||||
if (restarting.current) return;
|
if (restarting.current) return;
|
||||||
|
|
||||||
const activeScreenRecorder = screenRecorder.current;
|
const activeScreenRecorder = screenRecorder.current;
|
||||||
if (!activeScreenRecorder || activeScreenRecorder.recorder.state !== "recording") return;
|
if (!activeScreenRecorder || activeScreenRecorder.recorder.state === "inactive") return;
|
||||||
|
|
||||||
const activeWebcamRecorder = webcamRecorder.current;
|
const activeWebcamRecorder = webcamRecorder.current;
|
||||||
const activeRecordingId = recordingId.current;
|
const activeRecordingId = recordingId.current;
|
||||||
@@ -575,7 +886,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
|||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
if (activeWebcamRecorder?.recorder.state === "recording") {
|
if (
|
||||||
|
activeWebcamRecorder?.recorder.state === "recording" ||
|
||||||
|
activeWebcamRecorder?.recorder.state === "paused"
|
||||||
|
) {
|
||||||
stopPromises.push(
|
stopPromises.push(
|
||||||
new Promise<void>((resolve) => {
|
new Promise<void>((resolve) => {
|
||||||
activeWebcamRecorder.recorder.addEventListener("stop", () => resolve(), {
|
activeWebcamRecorder.recorder.addEventListener("stop", () => resolve(), {
|
||||||
@@ -595,14 +909,58 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!recording) {
|
||||||
|
setElapsedSeconds(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setElapsedSeconds(Math.floor(getRecordingDurationMs() / 1000));
|
||||||
|
if (paused) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const interval = window.setInterval(() => {
|
||||||
|
setElapsedSeconds(Math.floor(getRecordingDurationMs() / 1000));
|
||||||
|
}, 250);
|
||||||
|
|
||||||
|
return () => window.clearInterval(interval);
|
||||||
|
}, [getRecordingDurationMs, paused, recording]);
|
||||||
|
|
||||||
|
const cancelRecording = () => {
|
||||||
|
const activeScreenRecorder = screenRecorder.current;
|
||||||
|
if (
|
||||||
|
activeScreenRecorder?.recorder.state === "recording" ||
|
||||||
|
activeScreenRecorder?.recorder.state === "paused"
|
||||||
|
) {
|
||||||
|
const activeRecordingId = recordingId.current;
|
||||||
|
discardRecordingId.current = activeRecordingId;
|
||||||
|
allowAutoFinalize.current = false;
|
||||||
|
|
||||||
|
stopRecording.current();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (countdownActive) {
|
||||||
|
cancelCountdown();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
recording,
|
recording,
|
||||||
|
paused,
|
||||||
|
elapsedSeconds,
|
||||||
toggleRecording,
|
toggleRecording,
|
||||||
|
togglePaused,
|
||||||
restartRecording,
|
restartRecording,
|
||||||
|
cancelRecording,
|
||||||
microphoneEnabled,
|
microphoneEnabled,
|
||||||
setMicrophoneEnabled,
|
setMicrophoneEnabled,
|
||||||
microphoneDeviceId,
|
microphoneDeviceId,
|
||||||
setMicrophoneDeviceId,
|
setMicrophoneDeviceId,
|
||||||
|
webcamDeviceId,
|
||||||
|
setWebcamDeviceId,
|
||||||
systemAudioEnabled,
|
systemAudioEnabled,
|
||||||
setSystemAudioEnabled,
|
setSystemAudioEnabled,
|
||||||
webcamEnabled,
|
webcamEnabled,
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { type Locale, SUPPORTED_LOCALES } from "@/i18n/config";
|
||||||
|
import enDialogs from "@/i18n/locales/en/dialogs.json";
|
||||||
|
import esDialogs from "@/i18n/locales/es/dialogs.json";
|
||||||
|
import frDialogs from "@/i18n/locales/fr/dialogs.json";
|
||||||
|
import koKRDialogs from "@/i18n/locales/ko-KR/dialogs.json";
|
||||||
|
import trDialogs from "@/i18n/locales/tr/dialogs.json";
|
||||||
|
import zhCNDialogs from "@/i18n/locales/zh-CN/dialogs.json";
|
||||||
|
|
||||||
|
const tutorialHelpKeys = [
|
||||||
|
"triggerLabel",
|
||||||
|
"title",
|
||||||
|
"description",
|
||||||
|
"explanationBefore",
|
||||||
|
"remove",
|
||||||
|
"explanationMiddle",
|
||||||
|
"covered",
|
||||||
|
"explanationAfter",
|
||||||
|
"visualExample",
|
||||||
|
"removed",
|
||||||
|
"kept",
|
||||||
|
"part1",
|
||||||
|
"part2",
|
||||||
|
"part3",
|
||||||
|
"finalVideo",
|
||||||
|
"step1Title",
|
||||||
|
"step1DescriptionBefore",
|
||||||
|
"step1DescriptionAfter",
|
||||||
|
"step2Title",
|
||||||
|
"step2Description",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const keysThatMayBeEmpty = new Set<(typeof tutorialHelpKeys)[number]>(["step1DescriptionBefore"]);
|
||||||
|
|
||||||
|
const dialogsByLocale = {
|
||||||
|
en: enDialogs,
|
||||||
|
"zh-CN": zhCNDialogs,
|
||||||
|
es: esDialogs,
|
||||||
|
fr: frDialogs,
|
||||||
|
tr: trDialogs,
|
||||||
|
"ko-KR": koKRDialogs,
|
||||||
|
} satisfies Record<Locale, { tutorial: Record<string, unknown> }>;
|
||||||
|
|
||||||
|
describe("TutorialHelp translations", () => {
|
||||||
|
it("defines every tutorial help key for each supported locale", () => {
|
||||||
|
for (const locale of SUPPORTED_LOCALES) {
|
||||||
|
const tutorial = dialogsByLocale[locale].tutorial;
|
||||||
|
|
||||||
|
for (const key of tutorialHelpKeys) {
|
||||||
|
const message = tutorial[key];
|
||||||
|
const label = `${locale} dialogs.tutorial.${key}`;
|
||||||
|
expect(message, label).toEqual(expect.any(String));
|
||||||
|
if (!keysThatMayBeEmpty.has(key)) {
|
||||||
|
expect((message as string).trim().length, label).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
export const DEFAULT_LOCALE = "en" as const;
|
||||||
|
export const SUPPORTED_LOCALES = [
|
||||||
|
"en",
|
||||||
|
"zh-CN",
|
||||||
|
"zh-TW",
|
||||||
|
"es",
|
||||||
|
"fr",
|
||||||
|
"tr",
|
||||||
|
"ko-KR",
|
||||||
|
"ja-JP",
|
||||||
|
"ar",
|
||||||
|
] as const;
|
||||||
|
export const I18N_NAMESPACES = [
|
||||||
|
"common",
|
||||||
|
"dialogs",
|
||||||
|
"editor",
|
||||||
|
"launch",
|
||||||
|
"settings",
|
||||||
|
"shortcuts",
|
||||||
|
"timeline",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type Locale = string;
|
||||||
|
export type I18nNamespace = (typeof I18N_NAMESPACES)[number];
|
||||||
|
|
||||||
|
export const LOCALE_STORAGE_KEY = "openscreen-locale";
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { DEFAULT_LOCALE, I18N_NAMESPACES, type I18nNamespace, type Locale } from "./config";
|
||||||
|
|
||||||
|
type MessageMap = Record<string, unknown>;
|
||||||
|
type LocaleValidationError = {
|
||||||
|
locale: string;
|
||||||
|
missingNamespaces: I18nNamespace[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const modules = import.meta.glob("./locales/**/*.json", { eager: true }) as Record<
|
||||||
|
string,
|
||||||
|
{ default: MessageMap }
|
||||||
|
>;
|
||||||
|
|
||||||
|
const messages: Record<string, Record<string, MessageMap>> = {};
|
||||||
|
|
||||||
|
for (const [path, mod] of Object.entries(modules)) {
|
||||||
|
// path looks like "./locales/en/common.json"
|
||||||
|
const parts = path.replace("./locales/", "").replace(".json", "").split("/");
|
||||||
|
const locale = parts[0];
|
||||||
|
const namespace = parts[1];
|
||||||
|
if (!messages[locale]) messages[locale] = {};
|
||||||
|
messages[locale][namespace] = mod.default;
|
||||||
|
}
|
||||||
|
|
||||||
|
const REQUIRED_NAMESPACES = new Set<string>(I18N_NAMESPACES);
|
||||||
|
|
||||||
|
const localeValidationErrors: LocaleValidationError[] = Object.keys(messages)
|
||||||
|
.map((locale) => {
|
||||||
|
const localeMessages = messages[locale] ?? {};
|
||||||
|
const missingNamespaces = I18N_NAMESPACES.filter((namespace) => !localeMessages[namespace]);
|
||||||
|
return {
|
||||||
|
locale,
|
||||||
|
missingNamespaces,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((entry) => entry.missingNamespaces.length > 0);
|
||||||
|
|
||||||
|
const invalidLocales = new Set(localeValidationErrors.map((entry) => entry.locale));
|
||||||
|
|
||||||
|
const availableLocales = Object.keys(messages)
|
||||||
|
.filter((locale) => REQUIRED_NAMESPACES.size > 0 && hasRequiredNamespaces(messages[locale]))
|
||||||
|
.filter((locale) => !invalidLocales.has(locale))
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a === DEFAULT_LOCALE) return -1;
|
||||||
|
if (b === DEFAULT_LOCALE) return 1;
|
||||||
|
return a.localeCompare(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (localeValidationErrors.length > 0) {
|
||||||
|
console.error("[i18n] Incomplete locale folders were excluded:");
|
||||||
|
for (const entry of localeValidationErrors) {
|
||||||
|
console.error(
|
||||||
|
`[i18n] ${entry.locale}: missing ${entry.missingNamespaces.map((ns) => `${ns}.json`).join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasRequiredNamespaces(localeMessages: Record<string, MessageMap> | undefined): boolean {
|
||||||
|
if (!localeMessages) return false;
|
||||||
|
for (const namespace of REQUIRED_NAMESPACES) {
|
||||||
|
if (!localeMessages[namespace]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAvailableLocale(locale: string): locale is Locale {
|
||||||
|
return availableLocales.includes(locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAvailableLocales(): Locale[] {
|
||||||
|
if (availableLocales.length === 0) {
|
||||||
|
return [DEFAULT_LOCALE];
|
||||||
|
}
|
||||||
|
return availableLocales;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLocaleValidationErrors(): LocaleValidationError[] {
|
||||||
|
return localeValidationErrors;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMessageValue(obj: unknown, dotPath: string): string | undefined {
|
||||||
|
const keys = dotPath.split(".");
|
||||||
|
let current: unknown = obj;
|
||||||
|
for (const key of keys) {
|
||||||
|
if (current == null || typeof current !== "object") return undefined;
|
||||||
|
current = (current as Record<string, unknown>)[key];
|
||||||
|
}
|
||||||
|
return typeof current === "string" ? current : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function interpolate(str: string, vars?: Record<string, string | number>): string {
|
||||||
|
if (!vars) return str;
|
||||||
|
return str.replace(/\{\{(\w+)\}\}/g, (_, key: string) => String(vars[key] ?? `{{${key}}}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMessages(locale: Locale, namespace: I18nNamespace): MessageMap {
|
||||||
|
const resolvedLocale = isAvailableLocale(locale) ? locale : DEFAULT_LOCALE;
|
||||||
|
return messages[resolvedLocale]?.[namespace] ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLocaleName(locale: Locale): string {
|
||||||
|
const resolvedLocale = isAvailableLocale(locale) ? locale : DEFAULT_LOCALE;
|
||||||
|
return getMessageValue(messages[resolvedLocale]?.common, "locale.name") ?? locale;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLocaleShort(locale: Locale): string {
|
||||||
|
const resolvedLocale = isAvailableLocale(locale) ? locale : DEFAULT_LOCALE;
|
||||||
|
return getMessageValue(messages[resolvedLocale]?.common, "locale.short") ?? locale;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function translate(
|
||||||
|
locale: Locale,
|
||||||
|
namespace: I18nNamespace,
|
||||||
|
key: string,
|
||||||
|
vars?: Record<string, string | number>,
|
||||||
|
): string {
|
||||||
|
const value =
|
||||||
|
getMessageValue(
|
||||||
|
messages[isAvailableLocale(locale) ? locale : DEFAULT_LOCALE]?.[namespace],
|
||||||
|
key,
|
||||||
|
) ?? getMessageValue(messages[DEFAULT_LOCALE]?.[namespace], key);
|
||||||
|
|
||||||
|
if (value == null) return `${namespace}.${key}`;
|
||||||
|
return interpolate(value, vars);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"actions": {
|
||||||
|
"cancel": "الغاء",
|
||||||
|
"save": "حفظ",
|
||||||
|
"delete": "حذف",
|
||||||
|
"close": "اغلاق",
|
||||||
|
"share": "مشاركة",
|
||||||
|
"done": "تم",
|
||||||
|
"open": "فتح",
|
||||||
|
"upload": "رفع",
|
||||||
|
"export": "تصدير",
|
||||||
|
"showInFolder": "عرض في المجلد",
|
||||||
|
"file": "ملف",
|
||||||
|
"edit": "تعديل",
|
||||||
|
"view": "عرض",
|
||||||
|
"window": "نافذة",
|
||||||
|
"quit": "خروج",
|
||||||
|
"stopRecording": "إيقاف التسجيل",
|
||||||
|
"undo": "تراجع",
|
||||||
|
"redo": "إعادة",
|
||||||
|
"cut": "قص",
|
||||||
|
"copy": "نسخ",
|
||||||
|
"paste": "لصق",
|
||||||
|
"selectAll": "تحديد الكل",
|
||||||
|
"minimize": "تصغير",
|
||||||
|
"reload": "إعادة تحميل",
|
||||||
|
"forceReload": "إعادة تحميل إجبارية",
|
||||||
|
"toggleDevTools": "أدوات المطور",
|
||||||
|
"actualSize": "الحجم الفعلي",
|
||||||
|
"zoomIn": "تكبير",
|
||||||
|
"zoomOut": "تصغير",
|
||||||
|
"toggleFullScreen": "ملء الشاشة",
|
||||||
|
"recordingStatus": "جاري التسجيل: {{source}}",
|
||||||
|
"about": "حول OpenScreen",
|
||||||
|
"services": "خدمات",
|
||||||
|
"hide": "إخفاء OpenScreen",
|
||||||
|
"hideOthers": "إخفاء الآخرين",
|
||||||
|
"unhide": "إظهار الكل"
|
||||||
|
},
|
||||||
|
"playback": {
|
||||||
|
"play": "تشغيل",
|
||||||
|
"pause": "ايقاف مؤقت",
|
||||||
|
"fullscreen": "ملء الشاشة",
|
||||||
|
"exitFullscreen": "خروج من ملء الشاشة"
|
||||||
|
},
|
||||||
|
"locale": {
|
||||||
|
"name": "عربي",
|
||||||
|
"short": "AR"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
{
|
||||||
|
"export": {
|
||||||
|
"complete": "اكتمل التصدير",
|
||||||
|
"yourFormatReady": "{{format}} الخاص بك جاهز",
|
||||||
|
"showInFolder": "عرض في المجلد",
|
||||||
|
"finalizingVideo": "جاري إنهاء تصدير الفيديو...",
|
||||||
|
"compilingGifProgress": "جاري تجميع GIF... {{progress}}%",
|
||||||
|
"compilingGifWait": "جاري تجميع GIF... قد يستغرق هذا بعض الوقت",
|
||||||
|
"takeMoment": "قد يستغرق هذا لحظة...",
|
||||||
|
"failed": "فشل التصدير",
|
||||||
|
"tryAgain": "يرجى المحاولة مرة أخرى",
|
||||||
|
"finalizingVideoTitle": "إنهاء الفيديو",
|
||||||
|
"compilingGif": "تجميع GIF",
|
||||||
|
"exportingFormat": "تصدير {{format}}",
|
||||||
|
"compiling": "تجميع",
|
||||||
|
"renderingFrames": "تصيير الإطارات",
|
||||||
|
"processing": "جاري المعالجة...",
|
||||||
|
"finalizing": "جاري الإنهاء...",
|
||||||
|
"compilingStatus": "جاري التجميع...",
|
||||||
|
"status": "الحالة",
|
||||||
|
"format": "الصيغة",
|
||||||
|
"frames": "الإطارات",
|
||||||
|
"cancelExport": "إلغاء التصدير",
|
||||||
|
"savedSuccessfully": "تم حفظ {{format}} بنجاح!"
|
||||||
|
},
|
||||||
|
"tutorial": {
|
||||||
|
"triggerLabel": "كيف يعمل القص",
|
||||||
|
"title": "كيف يعمل القص",
|
||||||
|
"description": "فهم كيفية قص الأجزاء غير المرغوب فيها من الفيديو الخاص بك.",
|
||||||
|
"explanationBefore": "تعمل أداة القص من خلال تحديد المقاطع التي تريد",
|
||||||
|
"remove": "إزالتها",
|
||||||
|
"explanationMiddle": " — أي شيء",
|
||||||
|
"covered": "مغطى",
|
||||||
|
"explanationAfter": "بمقطع قص أحمر سيتم قصه عند التصدير.",
|
||||||
|
"visualExample": "مثال مرئي",
|
||||||
|
"removed": "مُزال",
|
||||||
|
"kept": "مُحتفظ به",
|
||||||
|
"part1": "الجزء 1",
|
||||||
|
"part2": "الجزء 2",
|
||||||
|
"part3": "الجزء 3",
|
||||||
|
"finalVideo": "الفيديو النهائي",
|
||||||
|
"step1Title": "1. إضافة قص",
|
||||||
|
"step1DescriptionBefore": "اضغط على ",
|
||||||
|
"step1DescriptionAfter": " أو انقر على أيقونة المقص لتحديد قسم لإزالته.",
|
||||||
|
"step2Title": "2. تعديل",
|
||||||
|
"step2Description": "اسحب حواف المنطقة الحمراء لتغطي بالضبط ما تريد قصه."
|
||||||
|
},
|
||||||
|
"unsavedChanges": {
|
||||||
|
"title": "تغييرات غير محفوظة",
|
||||||
|
"message": "لديك تغييرات غير محفوظة.",
|
||||||
|
"detail": "هل تريد حفظ مشروعك قبل الإغلاق؟",
|
||||||
|
"saveAndClose": "حفظ وإغلاق",
|
||||||
|
"discardAndClose": "تجاهل وإغلاق",
|
||||||
|
"loadProject": "تحميل مشروع...",
|
||||||
|
"saveProject": "حفظ المشروع...",
|
||||||
|
"saveProjectAs": "حفظ المشروع باسم..."
|
||||||
|
},
|
||||||
|
"fileDialogs": {
|
||||||
|
"saveGif": "حفظ GIF المصدر",
|
||||||
|
"saveVideo": "حفظ الفيديو المصدر",
|
||||||
|
"selectVideo": "حدد ملف فيديو",
|
||||||
|
"saveProject": "حفظ مشروع OpenScreen",
|
||||||
|
"openProject": "فتح مشروع OpenScreen",
|
||||||
|
"gifImage": "صورة GIF",
|
||||||
|
"mp4Video": "فيديو MP4",
|
||||||
|
"videoFiles": "ملفات فيديو",
|
||||||
|
"openscreenProject": "مشروع OpenScreen",
|
||||||
|
"allFiles": "جميع الملفات"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"newRecording": {
|
||||||
|
"title": "العودة إلى المسجل",
|
||||||
|
"description": "تم حفظ جلستك الحالية.",
|
||||||
|
"cancel": "إلغاء",
|
||||||
|
"confirm": "تأكيد"
|
||||||
|
},
|
||||||
|
"loadingVideo": "جاري تحميل الفيديو...",
|
||||||
|
"errors": {
|
||||||
|
"noVideoLoaded": "لم يتم تحميل أي فيديو",
|
||||||
|
"videoNotReady": "الفيديو غير جاهز",
|
||||||
|
"unableToDetermineSourcePath": "تعذر تحديد مسار الفيديو المصدر",
|
||||||
|
"failedToSaveGif": "فشل حفظ GIF",
|
||||||
|
"gifExportFailed": "فشل تصدير GIF",
|
||||||
|
"failedToSaveVideo": "فشل حفظ الفيديو",
|
||||||
|
"exportFailed": "فشل التصدير",
|
||||||
|
"exportFailedWithError": "فشل التصدير: {{error}}",
|
||||||
|
"exportBackgroundLoadFailed": "فشل التصدير: تعذر تحميل صورة الخلفية ({{url}})",
|
||||||
|
"failedToSaveExport": "فشل حفظ التصدير",
|
||||||
|
"failedToSaveExportedVideo": "فشل حفظ الفيديو المُصدَّر",
|
||||||
|
"failedToRevealInFolder": "خطأ في الكشف في المجلد: {{error}}"
|
||||||
|
},
|
||||||
|
"export": {
|
||||||
|
"canceled": "تم إلغاء التصدير",
|
||||||
|
"exportedSuccessfully": "تم تصدير {{format}} بنجاح"
|
||||||
|
},
|
||||||
|
"project": {
|
||||||
|
"saveCanceled": "تم إلغاء حفظ المشروع",
|
||||||
|
"failedToSave": "فشل حفظ المشروع",
|
||||||
|
"savedTo": "تم حفظ المشروع في {{path}}",
|
||||||
|
"failedToLoad": "فشل تحميل المشروع",
|
||||||
|
"invalidFormat": "تنسيق ملف المشروع غير صالح",
|
||||||
|
"loadedFrom": "تم تحميل المشروع من {{path}}"
|
||||||
|
},
|
||||||
|
"recording": {
|
||||||
|
"failedCameraAccess": "فشل طلب الوصول إلى الكاميرا.",
|
||||||
|
"cameraBlocked": "الوصول إلى الكاميرا محظور. قم بتمكينه في إعدادات النظام لاستخدام كاميرا الويب.",
|
||||||
|
"systemAudioUnavailable": "صوت النظام غير متوفر. يتم التسجيل بدون صوت النظام.",
|
||||||
|
"microphoneDenied": "تم رفض الوصول إلى الميكروفون. سيستمر التسجيل بدون صوت.",
|
||||||
|
"cameraDenied": "تم رفض الوصول إلى الكاميرا. سيستمر التسجيل بدون كاميرا الويب.",
|
||||||
|
"cameraDisconnected": "تم فصل كاميرا الويب.",
|
||||||
|
"cameraNotFound": "لم يتم العثور على كاميرا.",
|
||||||
|
"permissionDenied": "تم رفض إذن التسجيل. يرجى السماح بتسجيل الشاشة."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"tooltips": {
|
||||||
|
"hideHUD": "إخفاء واجهة العرض",
|
||||||
|
"closeApp": "إغلاق التطبيق",
|
||||||
|
"restartRecording": "إعادة تشغيل التسجيل",
|
||||||
|
"cancelRecording": "إلغاء التسجيل",
|
||||||
|
"pauseRecording": "إيقاف التسجيل مؤقتاً",
|
||||||
|
"resumeRecording": "استئناف التسجيل",
|
||||||
|
"openVideoFile": "فتح ملف فيديو",
|
||||||
|
"openProject": "فتح مشروع"
|
||||||
|
},
|
||||||
|
"audio": {
|
||||||
|
"enableSystemAudio": "تفعيل صوت النظام",
|
||||||
|
"disableSystemAudio": "تعطيل صوت النظام",
|
||||||
|
"enableMicrophone": "تفعيل الميكروفون",
|
||||||
|
"disableMicrophone": "تعطيل الميكروفون",
|
||||||
|
"defaultMicrophone": "الميكروفون الافتراضي"
|
||||||
|
},
|
||||||
|
"webcam": {
|
||||||
|
"enableWebcam": "تفعيل كاميرا الويب",
|
||||||
|
"disableWebcam": "تعطيل كاميرا الويب",
|
||||||
|
"defaultCamera": "الكاميرا الافتراضية",
|
||||||
|
"searching": "جاري البحث...",
|
||||||
|
"noneFound": "لم يتم العثور على كاميرا",
|
||||||
|
"unavailable": "الكاميرا غير متوفرة"
|
||||||
|
},
|
||||||
|
"sourceSelector": {
|
||||||
|
"loading": "جاري تحميل المصادر...",
|
||||||
|
"screens": "الشاشات ({{count}})",
|
||||||
|
"windows": "النوافذ ({{count}})",
|
||||||
|
"defaultSourceName": "الشاشة"
|
||||||
|
},
|
||||||
|
"recording": {
|
||||||
|
"selectSource": "يرجى تحديد مصدر للتسجيل"
|
||||||
|
},
|
||||||
|
"language": "اللغة",
|
||||||
|
"systemLanguagePrompt": {
|
||||||
|
"title": "هل تريد استخدام لغة نظامك؟",
|
||||||
|
"description": "اكتشفنا أن {{language}} هي لغة نظامك. هل تريد تبديل OpenScreen إلى {{language}}؟",
|
||||||
|
"switch": "التبديل إلى {{language}}",
|
||||||
|
"keepDefault": "الاحتفاظ باللغة الحالية"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
{
|
||||||
|
"zoom": {
|
||||||
|
"level": "مستوى التكبير",
|
||||||
|
"selectRegion": "حدد منطقة التكبير للتعديل",
|
||||||
|
"deleteZoom": "حذف التكبير",
|
||||||
|
"focusMode": {
|
||||||
|
"title": "وضع التركيز",
|
||||||
|
"manual": "يدوي",
|
||||||
|
"auto": "تلقائي",
|
||||||
|
"autoDescription": "الكاميرا تتبع موضع المؤشر المسجل"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"speed": {
|
||||||
|
"playbackSpeed": "سرعة التشغيل",
|
||||||
|
"selectRegion": "حدد منطقة السرعة للتعديل",
|
||||||
|
"deleteRegion": "حذف منطقة السرعة",
|
||||||
|
"customPlaybackSpeed": "سرعة تشغيل مخصصة",
|
||||||
|
"maxSpeedError": "لا يمكن للسرعة أن تتجاوز 16×"
|
||||||
|
},
|
||||||
|
"trim": {
|
||||||
|
"deleteRegion": "حذف منطقة القص"
|
||||||
|
},
|
||||||
|
"layout": {
|
||||||
|
"title": "التخطيط",
|
||||||
|
"preset": "الإعداد المسبق",
|
||||||
|
"selectPreset": "حدد إعدادًا مسبقًا",
|
||||||
|
"pictureInPicture": "صورة داخل صورة",
|
||||||
|
"verticalStack": "تكدس عمودي",
|
||||||
|
"dualFrame": "إطار مزدوج",
|
||||||
|
"webcamShape": "شكل الكاميرا",
|
||||||
|
"webcamSize": "حجم كاميرا الويب"
|
||||||
|
},
|
||||||
|
"effects": {
|
||||||
|
"title": "تأثيرات الفيديو",
|
||||||
|
"blurBg": "تمويه الخلفية",
|
||||||
|
"motionBlur": "ضبابية الحركة",
|
||||||
|
"off": "إيقاف",
|
||||||
|
"on": "تشغيل",
|
||||||
|
"shadow": "ظل",
|
||||||
|
"roundness": "الاستدارة",
|
||||||
|
"padding": "المسافة البادئة",
|
||||||
|
"cursorHighlight": {
|
||||||
|
"title": "تمييز المؤشر",
|
||||||
|
"style": "النمط",
|
||||||
|
"dot": "نقطة",
|
||||||
|
"ring": "حلقة",
|
||||||
|
"size": "الحجم",
|
||||||
|
"onlyOnClicks": "عند النقر فقط",
|
||||||
|
"color": "اللون",
|
||||||
|
"offsetX": "إزاحة X (لتسجيلات النوافذ)",
|
||||||
|
"offsetY": "إزاحة Y",
|
||||||
|
"accessibilityPermissionTitle": "مطلوب إذن الوصول",
|
||||||
|
"accessibilityPermissionDescription": "افتح إعدادات النظام ← الخصوصية والأمان ← إمكانية الوصول، وقم بتفعيل Openscreen، ثم أعد تشغيل التطبيق."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"background": {
|
||||||
|
"title": "الخلفية",
|
||||||
|
"image": "صورة",
|
||||||
|
"color": "لون",
|
||||||
|
"gradient": "تدرج لوني",
|
||||||
|
"uploadCustom": "رفع صورة مخصصة",
|
||||||
|
"gradientLabel": "تدرج لوني {{index}}",
|
||||||
|
"colorWheel": "عجلة الألوان",
|
||||||
|
"colorPalette": "لوحة الألوان"
|
||||||
|
},
|
||||||
|
"crop": {
|
||||||
|
"title": "اقتصاص",
|
||||||
|
"cropVideo": "اقتصاص الفيديو",
|
||||||
|
"dragInstruction": "اسحب من كل جانب لضبط منطقة الاقتصاص",
|
||||||
|
"ratio": "النسبة",
|
||||||
|
"free": "حر",
|
||||||
|
"done": "تم",
|
||||||
|
"lockAspectRatio": "قفل نسبة العرض إلى الارتفاع",
|
||||||
|
"unlockAspectRatio": "إلغاء قفل نسبة العرض إلى الارتفاع"
|
||||||
|
},
|
||||||
|
"exportFormat": {
|
||||||
|
"mp4": "MP4",
|
||||||
|
"gif": "GIF",
|
||||||
|
"mp4Video": "فيديو MP4",
|
||||||
|
"mp4Description": "ملف فيديو عالي الجودة",
|
||||||
|
"gifAnimation": "صورة GIF متحركة",
|
||||||
|
"gifDescription": "صورة متحركة للمشاركة"
|
||||||
|
},
|
||||||
|
"exportQuality": {
|
||||||
|
"title": "جودة التصدير",
|
||||||
|
"low": "منخفضة",
|
||||||
|
"medium": "متوسطة",
|
||||||
|
"high": "عالية"
|
||||||
|
},
|
||||||
|
"gifSettings": {
|
||||||
|
"frameRate": "معدل إطارات GIF",
|
||||||
|
"size": "حجم GIF",
|
||||||
|
"loop": "تكرار GIF"
|
||||||
|
},
|
||||||
|
"project": {
|
||||||
|
"save": "حفظ المشروع",
|
||||||
|
"load": "تحميل المشروع"
|
||||||
|
},
|
||||||
|
"export": {
|
||||||
|
"videoButton": "تصدير الفيديو",
|
||||||
|
"gifButton": "تصدير GIF",
|
||||||
|
"chooseSaveLocation": "اختيار موقع الحفظ"
|
||||||
|
},
|
||||||
|
"links": {
|
||||||
|
"reportBug": "الإبلاغ عن خطأ",
|
||||||
|
"starOnGithub": "إعطاء نجمة على GitHub"
|
||||||
|
},
|
||||||
|
"imageUpload": {
|
||||||
|
"invalidFileType": "نوع ملف غير صالح",
|
||||||
|
"jpgOnly": "يرجى رفع ملف صورة JPG أو JPEG.",
|
||||||
|
"uploadSuccess": "تم رفع الصورة المخصصة بنجاح!",
|
||||||
|
"failedToUpload": "فشل رفع الصورة",
|
||||||
|
"errorReading": "حدث خطأ أثناء قراءة الملف."
|
||||||
|
},
|
||||||
|
"annotation": {
|
||||||
|
"title": "إعدادات الشروح",
|
||||||
|
"active": "نشط",
|
||||||
|
"typeText": "نص",
|
||||||
|
"typeImage": "صورة",
|
||||||
|
"typeArrow": "سهم",
|
||||||
|
"typeBlur": "تمويه",
|
||||||
|
"textContent": "محتوى النص",
|
||||||
|
"textPlaceholder": "أدخل النص هنا...",
|
||||||
|
"fontStyle": "نمط الخط",
|
||||||
|
"selectStyle": "حدد النمط",
|
||||||
|
"size": "الحجم",
|
||||||
|
"customFonts": "خطوط مخصصة",
|
||||||
|
"textColor": "لون النص",
|
||||||
|
"background": "الخلفية",
|
||||||
|
"none": "بدون",
|
||||||
|
"color": "لون",
|
||||||
|
"colorWheel": "عجلة الألوان",
|
||||||
|
"colorPalette": "لوحة الألوان",
|
||||||
|
"clearBackground": "مسح الخلفية",
|
||||||
|
"uploadImage": "رفع صورة",
|
||||||
|
"supportedFormats": "الصيغ المدعومة: JPG, PNG, GIF, WebP",
|
||||||
|
"arrowDirection": "اتجاه السهم",
|
||||||
|
"strokeWidth": "عرض الخط: {{width}}px",
|
||||||
|
"arrowColor": "لون السهم",
|
||||||
|
"blurType": "نوع التمويه",
|
||||||
|
"blurTypeBlur": "تمويه",
|
||||||
|
"blurTypeMosaic": "فسيفساء",
|
||||||
|
"blurColor": "لون التمويه",
|
||||||
|
"blurColorWhite": "أبيض",
|
||||||
|
"blurColorBlack": "أسود",
|
||||||
|
"blurShape": "شكل التمويه",
|
||||||
|
"blurIntensity": "كثافة التمويه",
|
||||||
|
"mosaicBlockSize": "حجم كتلة الفسيفساء",
|
||||||
|
"blurShapeRectangle": "مستطيل",
|
||||||
|
"blurShapeOval": "بيضاوي",
|
||||||
|
"blurShapeFreehand": "رسم حر",
|
||||||
|
"deleteAnnotation": "حذف الشرح",
|
||||||
|
"shortcutsAndTips": "اختصارات ونصائح",
|
||||||
|
"tipMovePlayhead": "انقل رأس التشغيل إلى قسم الشروح المتداخلة وحدد عنصرًا.",
|
||||||
|
"tipTabCycle": "استخدم Tab للتنقل بين العناصر المتداخلة.",
|
||||||
|
"tipShiftTabCycle": "استخدم Shift+Tab للتنقل للخلف.",
|
||||||
|
"invalidImageType": "نوع ملف غير صالح",
|
||||||
|
"imageFormatsOnly": "يرجى رفع ملف صورة JPG أو PNG أو GIF أو WebP.",
|
||||||
|
"imageUploadSuccess": "تم رفع الصورة بنجاح!",
|
||||||
|
"failedImageUpload": "فشل في رفع الصورة"
|
||||||
|
},
|
||||||
|
"fontStyles": {
|
||||||
|
"classic": "كلاسيكي",
|
||||||
|
"editor": "محرر",
|
||||||
|
"strong": "قوي",
|
||||||
|
"typewriter": "آلة كاتبة",
|
||||||
|
"deco": "ديكو",
|
||||||
|
"simple": "بسيط",
|
||||||
|
"modern": "حديث",
|
||||||
|
"clean": "نظيف"
|
||||||
|
},
|
||||||
|
"customFont": {
|
||||||
|
"dialogTitle": "إضافة خط Google",
|
||||||
|
"urlLabel": "رابط استيراد خطوط Google",
|
||||||
|
"urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
|
||||||
|
"urlHelp": "احصل على هذا من خطوط Google: حدد خطًا → انقر على \"احصل على الخط\" → انسخ رابط `@import`",
|
||||||
|
"nameLabel": "اسم العرض",
|
||||||
|
"namePlaceholder": "خطي المخصص",
|
||||||
|
"nameHelp": "هكذا سيظهر الخط في محدد الخطوط",
|
||||||
|
"addButton": "إضافة خط",
|
||||||
|
"addingButton": "جاري الإضافة...",
|
||||||
|
"errorEmptyUrl": "يرجى إدخال رابط استيراد لخطوط Google",
|
||||||
|
"errorInvalidUrl": "يرجى إدخال رابط صحيح لخطوط Google",
|
||||||
|
"errorEmptyName": "يرجى إدخال اسم الخط",
|
||||||
|
"errorExtractFailed": "تعذر استخراج عائلة الخط من الرابط",
|
||||||
|
"successMessage": "تم إضافة الخط \"{{fontName}}\" بنجاح",
|
||||||
|
"failedToAdd": "فشل في إضافة الخط",
|
||||||
|
"errorTimeout": "استغرق تحميل الخط وقتًا طويلاً. يرجى التحقق من الرابط والمحاولة مرة أخرى.",
|
||||||
|
"errorLoadFailed": "تعذر تحميل الخط. يرجى التحقق من صحة رابط خطوط Google."
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"title": "اللغة"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"title": "اختصارات لوحة المفاتيح",
|
||||||
|
"customize": "تخصيص",
|
||||||
|
"configurable": "قابل للتكوين",
|
||||||
|
"fixed": "ثابت",
|
||||||
|
"pressKey": "اضغط على مفتاح...",
|
||||||
|
"clickToChange": "انقر للتغيير",
|
||||||
|
"pressEscToCancel": "اضغط على Esc للإلغاء",
|
||||||
|
"helpText": "انقر على اختصار ثم اضغط على مجموعة المفاتيح الجديدة. اضغط على Esc للإلغاء.",
|
||||||
|
"resetToDefaults": "إعادة تعيين إلى الافتراضيات",
|
||||||
|
"alreadyUsedBy": "مستخدم بالفعل بواسطة {{action}}",
|
||||||
|
"swap": "تبديل",
|
||||||
|
"reservedShortcut": "هذا الاختصار محجوز لـ \"{{label}}\" ولا يمكن إعادة تعيينه.",
|
||||||
|
"savedToast": "تم حفظ اختصارات لوحة المفاتيح",
|
||||||
|
"resetToast": "إعادة تعيين إلى الاختصارات الافتراضية — انقر فوق حفظ للتطبيق",
|
||||||
|
"actions": {
|
||||||
|
"addZoom": "إضافة تكبير",
|
||||||
|
"addTrim": "إضافة قص",
|
||||||
|
"addSpeed": "إضافة سرعة",
|
||||||
|
"addAnnotation": "إضافة شرح",
|
||||||
|
"addBlur": "إضافة تمويه",
|
||||||
|
"addKeyframe": "إضافة إطار رئيسي",
|
||||||
|
"deleteSelected": "حذف المحدد",
|
||||||
|
"playPause": "تشغيل / إيقاف مؤقت"
|
||||||
|
},
|
||||||
|
"fixedActions": {
|
||||||
|
"undo": "تراجع",
|
||||||
|
"redo": "إعادة",
|
||||||
|
"cycleAnnotationsForward": "التنقل بين الشروح للأمام",
|
||||||
|
"cycleAnnotationsBackward": "التنقل بين الشروح للخلف",
|
||||||
|
"deleteSelectedAlt": "حذف المحدد (alt)",
|
||||||
|
"panTimeline": "تحريك المخطط الزمني",
|
||||||
|
"zoomTimeline": "تكبير المخطط الزمني",
|
||||||
|
"frameBack": "إطار للخلف",
|
||||||
|
"frameForward": "إطار للأمام"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
{
|
||||||
|
"buttons": {
|
||||||
|
"addZoom": "إضافة تكبير (Z)",
|
||||||
|
"suggestZooms": "اقتراح تكبير من المؤشر",
|
||||||
|
"addTrim": "إضافة قص (T)",
|
||||||
|
"addAnnotation": "إضافة شرح (A)",
|
||||||
|
"addBlur": "إضافة تمويه (B)",
|
||||||
|
"addSpeed": "إضافة سرعة (S)"
|
||||||
|
},
|
||||||
|
"hints": {
|
||||||
|
"pressZoom": "اضغط Z لإضافة تكبير",
|
||||||
|
"pressTrim": "اضغط T لإضافة قص",
|
||||||
|
"pressAnnotation": "اضغط A لإضافة شرح",
|
||||||
|
"pressBlur": "اضغط B لإضافة منطقة تمويه",
|
||||||
|
"pressSpeed": "اضغط S لإضافة سرعة"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"pan": "تحريك",
|
||||||
|
"zoom": "تكبير",
|
||||||
|
"trim": "قص",
|
||||||
|
"speed": "سرعة",
|
||||||
|
"zoomItem": "تكبير {{index}}",
|
||||||
|
"trimItem": "قص {{index}}",
|
||||||
|
"speedItem": "سرعة {{index}}",
|
||||||
|
"annotationItem": "شرح",
|
||||||
|
"blurItem": "تمويه {{index}}",
|
||||||
|
"imageItem": "صورة",
|
||||||
|
"emptyText": "نص فارغ"
|
||||||
|
},
|
||||||
|
"emptyState": {
|
||||||
|
"noVideo": "لم يتم تحميل أي فيديو",
|
||||||
|
"dragAndDrop": "اسحب وأفلت مقطع فيديو لبدء التعديل"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"cannotPlaceZoom": "لا يمكن وضع التكبير هنا",
|
||||||
|
"zoomExistsAtLocation": "يوجد تكبير بالفعل في هذا الموقع أو لا توجد مساحة كافية متاحة.",
|
||||||
|
"zoomSuggestionUnavailable": "معالج اقتراح التكبير غير متوفر",
|
||||||
|
"noCursorTelemetry": "لا تتوفر بيانات قياس المؤشر",
|
||||||
|
"noCursorTelemetryDescription": "قم بتسجيل الشاشة أولاً لإنشاء اقتراحات بناءً على المؤشر.",
|
||||||
|
"noUsableTelemetry": "لا توجد بيانات قياس مؤشر قابلة للاستخدام",
|
||||||
|
"noUsableTelemetryDescription": "التسجيل لا يتضمن بيانات حركة مؤشر كافية.",
|
||||||
|
"noDwellMoments": "لم يتم العثور على لحظات توقف واضحة للمؤشر",
|
||||||
|
"noDwellMomentsDescription": "جرب تسجيلاً مع توقفات مؤشر أبطأ عند الإجراءات المهمة.",
|
||||||
|
"noAutoZoomSlots": "لا تتوفر خانات تكبير تلقائي",
|
||||||
|
"noAutoZoomSlotsDescription": "نقاط التوقف المكتشفة تتداخل مع مناطق التكبير الحالية.",
|
||||||
|
"cannotPlaceTrim": "لا يمكن وضع القص هنا",
|
||||||
|
"trimExistsAtLocation": "يوجد قص بالفعل في هذا الموقع أو لا توجد مساحة كافية متاحة.",
|
||||||
|
"cannotPlaceSpeed": "لا يمكن وضع السرعة هنا",
|
||||||
|
"speedExistsAtLocation": "توجد منطقة سرعة بالفعل في هذا الموقع أو لا توجد مساحة كافية متاحة."
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"addedZoomSuggestions": "تمت إضافة {{count}} اقتراح تكبير بناءً على المؤشر",
|
||||||
|
"addedZoomSuggestionsPlural": "تمت إضافة {{count}} اقتراحات تكبير بناءً على المؤشر"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"actions": {
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"save": "Save",
|
||||||
|
"delete": "Delete",
|
||||||
|
"close": "Close",
|
||||||
|
"share": "Share",
|
||||||
|
"done": "Done",
|
||||||
|
"open": "Open",
|
||||||
|
"upload": "Upload",
|
||||||
|
"export": "Export",
|
||||||
|
"showInFolder": "Show in Folder",
|
||||||
|
"file": "File",
|
||||||
|
"edit": "Edit",
|
||||||
|
"view": "View",
|
||||||
|
"window": "Window",
|
||||||
|
"quit": "Quit",
|
||||||
|
"stopRecording": "Stop Recording",
|
||||||
|
"undo": "Undo",
|
||||||
|
"redo": "Redo",
|
||||||
|
"cut": "Cut",
|
||||||
|
"copy": "Copy",
|
||||||
|
"paste": "Paste",
|
||||||
|
"selectAll": "Select All",
|
||||||
|
"minimize": "Minimize",
|
||||||
|
"reload": "Reload",
|
||||||
|
"forceReload": "Force Reload",
|
||||||
|
"toggleDevTools": "Toggle Developer Tools",
|
||||||
|
"actualSize": "Actual Size",
|
||||||
|
"zoomIn": "Zoom In",
|
||||||
|
"zoomOut": "Zoom Out",
|
||||||
|
"toggleFullScreen": "Toggle Full Screen",
|
||||||
|
"recordingStatus": "Recording: {{source}}",
|
||||||
|
"about": "About OpenScreen",
|
||||||
|
"services": "Services",
|
||||||
|
"hide": "Hide OpenScreen",
|
||||||
|
"hideOthers": "Hide Others",
|
||||||
|
"unhide": "Show All"
|
||||||
|
},
|
||||||
|
"playback": {
|
||||||
|
"play": "Play",
|
||||||
|
"pause": "Pause",
|
||||||
|
"fullscreen": "Fullscreen",
|
||||||
|
"exitFullscreen": "Exit Fullscreen"
|
||||||
|
},
|
||||||
|
"locale": {
|
||||||
|
"name": "English",
|
||||||
|
"short": "EN"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
{
|
||||||
|
"export": {
|
||||||
|
"complete": "Export Complete",
|
||||||
|
"yourFormatReady": "Your {{format}} is ready",
|
||||||
|
"showInFolder": "Show in Folder",
|
||||||
|
"finalizingVideo": "Finalizing video export...",
|
||||||
|
"compilingGifProgress": "Compiling GIF... {{progress}}%",
|
||||||
|
"compilingGifWait": "Compiling GIF... This may take a while",
|
||||||
|
"takeMoment": "This may take a moment...",
|
||||||
|
"failed": "Export Failed",
|
||||||
|
"tryAgain": "Please try again",
|
||||||
|
"finalizingVideoTitle": "Finalizing Video",
|
||||||
|
"compilingGif": "Compiling GIF",
|
||||||
|
"exportingFormat": "Exporting {{format}}",
|
||||||
|
"compiling": "Compiling",
|
||||||
|
"renderingFrames": "Rendering Frames",
|
||||||
|
"processing": "Processing...",
|
||||||
|
"finalizing": "Finalizing...",
|
||||||
|
"compilingStatus": "Compiling...",
|
||||||
|
"status": "Status",
|
||||||
|
"format": "Format",
|
||||||
|
"frames": "Frames",
|
||||||
|
"cancelExport": "Cancel Export",
|
||||||
|
"savedSuccessfully": "{{format}} saved successfully!"
|
||||||
|
},
|
||||||
|
"tutorial": {
|
||||||
|
"triggerLabel": "How trimming works",
|
||||||
|
"title": "How Trimming Works",
|
||||||
|
"description": "Understanding how to cut out unwanted parts of your video.",
|
||||||
|
"explanationBefore": "The Trim tool works by defining the segments you want to",
|
||||||
|
"remove": "remove",
|
||||||
|
"explanationMiddle": " — anything",
|
||||||
|
"covered": "covered",
|
||||||
|
"explanationAfter": "by a red trim segment will be cut out when you export.",
|
||||||
|
"visualExample": "Visual Example",
|
||||||
|
"removed": "REMOVED",
|
||||||
|
"kept": "Kept",
|
||||||
|
"part1": "Part 1",
|
||||||
|
"part2": "Part 2",
|
||||||
|
"part3": "Part 3",
|
||||||
|
"finalVideo": "Final Video",
|
||||||
|
"step1Title": "1. Add Trim",
|
||||||
|
"step1DescriptionBefore": "Press ",
|
||||||
|
"step1DescriptionAfter": " or click the scissors icon to mark a section for removal.",
|
||||||
|
|
||||||
|
"step2Title": "2. Adjust",
|
||||||
|
"step2Description": "Drag the edges of the red region to cover exactly what you want to cut out."
|
||||||
|
},
|
||||||
|
"unsavedChanges": {
|
||||||
|
"title": "Unsaved Changes",
|
||||||
|
"message": "You have unsaved changes.",
|
||||||
|
"detail": "Do you want to save your project before closing?",
|
||||||
|
"saveAndClose": "Save & Close",
|
||||||
|
"discardAndClose": "Discard & Close",
|
||||||
|
"loadProject": "Load Project…",
|
||||||
|
"saveProject": "Save Project…",
|
||||||
|
"saveProjectAs": "Save Project As…"
|
||||||
|
},
|
||||||
|
"fileDialogs": {
|
||||||
|
"saveGif": "Save Exported GIF",
|
||||||
|
"saveVideo": "Save Exported Video",
|
||||||
|
"selectVideo": "Select Video File",
|
||||||
|
"saveProject": "Save OpenScreen Project",
|
||||||
|
"openProject": "Open OpenScreen Project",
|
||||||
|
"gifImage": "GIF Image",
|
||||||
|
"mp4Video": "MP4 Video",
|
||||||
|
"videoFiles": "Video Files",
|
||||||
|
"openscreenProject": "OpenScreen Project",
|
||||||
|
"allFiles": "All Files"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"newRecording": {
|
||||||
|
"title": "Return to Recorder",
|
||||||
|
"description": "Your current session has been saved.",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"confirm": "Confirm"
|
||||||
|
},
|
||||||
|
"loadingVideo": "Loading video...",
|
||||||
|
"errors": {
|
||||||
|
"noVideoLoaded": "No video loaded",
|
||||||
|
"videoNotReady": "Video not ready",
|
||||||
|
"unableToDetermineSourcePath": "Unable to determine source video path",
|
||||||
|
"failedToSaveGif": "Failed to save GIF",
|
||||||
|
"gifExportFailed": "GIF export failed",
|
||||||
|
"failedToSaveVideo": "Failed to save video",
|
||||||
|
"exportFailed": "Export failed",
|
||||||
|
"exportFailedWithError": "Export failed: {{error}}",
|
||||||
|
"exportBackgroundLoadFailed": "Export failed: could not load background image ({{url}})",
|
||||||
|
"failedToSaveExport": "Failed to save export",
|
||||||
|
"failedToSaveExportedVideo": "Failed to save exported video",
|
||||||
|
"failedToRevealInFolder": "Error revealing in folder: {{error}}"
|
||||||
|
},
|
||||||
|
"export": {
|
||||||
|
"canceled": "Export canceled",
|
||||||
|
"exportedSuccessfully": "{{format}} exported successfully"
|
||||||
|
},
|
||||||
|
"project": {
|
||||||
|
"saveCanceled": "Project save canceled",
|
||||||
|
"failedToSave": "Failed to save project",
|
||||||
|
"savedTo": "Project saved to {{path}}",
|
||||||
|
"failedToLoad": "Failed to load project",
|
||||||
|
"invalidFormat": "Invalid project file format",
|
||||||
|
"loadedFrom": "Project loaded from {{path}}"
|
||||||
|
},
|
||||||
|
"recording": {
|
||||||
|
"failedCameraAccess": "Failed to request camera access.",
|
||||||
|
"cameraBlocked": "Camera access is blocked. Enable it in system settings to use the webcam.",
|
||||||
|
"systemAudioUnavailable": "System audio not available. Recording without system audio.",
|
||||||
|
"microphoneDenied": "Microphone access denied. Recording will continue without audio.",
|
||||||
|
"cameraDenied": "Camera access denied. Recording will continue without webcam.",
|
||||||
|
"cameraDisconnected": "Webcam disconnected.",
|
||||||
|
"cameraNotFound": "Camera not found.",
|
||||||
|
"permissionDenied": "Recording permission denied. Please allow screen recording."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"tooltips": {
|
||||||
|
"hideHUD": "Hide HUD",
|
||||||
|
"closeApp": "Close App",
|
||||||
|
"restartRecording": "Restart recording",
|
||||||
|
"cancelRecording": "Cancel recording",
|
||||||
|
"pauseRecording": "Pause recording",
|
||||||
|
"resumeRecording": "Resume recording",
|
||||||
|
"openVideoFile": "Open video file",
|
||||||
|
"openProject": "Open project"
|
||||||
|
},
|
||||||
|
"audio": {
|
||||||
|
"enableSystemAudio": "Enable system audio",
|
||||||
|
"disableSystemAudio": "Disable system audio",
|
||||||
|
"enableMicrophone": "Enable microphone",
|
||||||
|
"disableMicrophone": "Disable microphone",
|
||||||
|
"defaultMicrophone": "Default Microphone"
|
||||||
|
},
|
||||||
|
"webcam": {
|
||||||
|
"enableWebcam": "Enable webcam",
|
||||||
|
"disableWebcam": "Disable webcam",
|
||||||
|
"defaultCamera": "Default Camera",
|
||||||
|
"searching": "Searching...",
|
||||||
|
"noneFound": "No camera found",
|
||||||
|
"unavailable": "Camera unavailable"
|
||||||
|
},
|
||||||
|
"sourceSelector": {
|
||||||
|
"loading": "Loading sources...",
|
||||||
|
"screens": "Screens ({{count}})",
|
||||||
|
"windows": "Windows ({{count}})",
|
||||||
|
"defaultSourceName": "Screen"
|
||||||
|
},
|
||||||
|
"recording": {
|
||||||
|
"selectSource": "Please select a source to record"
|
||||||
|
},
|
||||||
|
"language": "Language",
|
||||||
|
"systemLanguagePrompt": {
|
||||||
|
"title": "Use your system language?",
|
||||||
|
"description": "We detected {{language}} as your system language. Do you want to switch OpenScreen to {{language}}?",
|
||||||
|
"switch": "Switch to {{language}}",
|
||||||
|
"keepDefault": "Keep current language"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
{
|
||||||
|
"zoom": {
|
||||||
|
"level": "Zoom Level",
|
||||||
|
"selectRegion": "Select a zoom region to adjust",
|
||||||
|
"deleteZoom": "Delete Zoom",
|
||||||
|
"focusMode": {
|
||||||
|
"title": "Focus Mode",
|
||||||
|
"manual": "Manual",
|
||||||
|
"auto": "Auto",
|
||||||
|
"autoDescription": "Camera follows the recorded cursor position"
|
||||||
|
},
|
||||||
|
"threeD": {
|
||||||
|
"title": "3D Rotation",
|
||||||
|
"preset": {
|
||||||
|
"iso": "Iso",
|
||||||
|
"left": "Left",
|
||||||
|
"right": "Right"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"speed": {
|
||||||
|
"playbackSpeed": "Playback Speed",
|
||||||
|
"selectRegion": "Select a speed region to adjust",
|
||||||
|
"deleteRegion": "Delete Speed Region",
|
||||||
|
"customPlaybackSpeed": "Custom Playback Speed",
|
||||||
|
"maxSpeedError": "Speed can't go higher than 16×"
|
||||||
|
},
|
||||||
|
"trim": {
|
||||||
|
"deleteRegion": "Delete Trim Region"
|
||||||
|
},
|
||||||
|
"layout": {
|
||||||
|
"title": "Layout",
|
||||||
|
"preset": "Preset",
|
||||||
|
"selectPreset": "Select preset",
|
||||||
|
"pictureInPicture": "Picture in Picture",
|
||||||
|
"verticalStack": "Vertical Stack",
|
||||||
|
"dualFrame": "Dual Frame",
|
||||||
|
"webcamShape": "Camera Shape",
|
||||||
|
"webcamSize": "Webcam Size"
|
||||||
|
},
|
||||||
|
"effects": {
|
||||||
|
"title": "Video Effects",
|
||||||
|
"blurBg": "Blur BG",
|
||||||
|
"motionBlur": "Motion Blur",
|
||||||
|
"off": "off",
|
||||||
|
"on": "on",
|
||||||
|
"shadow": "Shadow",
|
||||||
|
"roundness": "Roundness",
|
||||||
|
"padding": "Padding",
|
||||||
|
"cursorHighlight": {
|
||||||
|
"title": "Cursor highlight",
|
||||||
|
"style": "Style",
|
||||||
|
"dot": "Dot",
|
||||||
|
"ring": "Ring",
|
||||||
|
"size": "Size",
|
||||||
|
"onlyOnClicks": "Only on clicks",
|
||||||
|
"color": "Color",
|
||||||
|
"offsetX": "Offset X (window recordings)",
|
||||||
|
"offsetY": "Offset Y",
|
||||||
|
"accessibilityPermissionTitle": "Accessibility permission needed",
|
||||||
|
"accessibilityPermissionDescription": "Open System Settings → Privacy & Security → Accessibility, enable Openscreen, then restart the app."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"background": {
|
||||||
|
"title": "Background",
|
||||||
|
"image": "Image",
|
||||||
|
"color": "Color",
|
||||||
|
"gradient": "Gradient",
|
||||||
|
"uploadCustom": "Upload Custom",
|
||||||
|
"gradientLabel": "Gradient {{index}}",
|
||||||
|
"colorWheel": "Color Wheel",
|
||||||
|
"colorPalette": "Color Palette"
|
||||||
|
},
|
||||||
|
"crop": {
|
||||||
|
"title": "Crop",
|
||||||
|
"cropVideo": "Crop Video",
|
||||||
|
"dragInstruction": "Drag on each side to adjust the crop area",
|
||||||
|
"ratio": "Ratio",
|
||||||
|
"free": "Free",
|
||||||
|
"done": "Done",
|
||||||
|
"lockAspectRatio": "Lock aspect ratio",
|
||||||
|
"unlockAspectRatio": "Unlock aspect ratio"
|
||||||
|
},
|
||||||
|
"exportFormat": {
|
||||||
|
"mp4": "MP4",
|
||||||
|
"gif": "GIF",
|
||||||
|
"mp4Video": "MP4 Video",
|
||||||
|
"mp4Description": "High quality video file",
|
||||||
|
"gifAnimation": "GIF Animation",
|
||||||
|
"gifDescription": "Animated image for sharing"
|
||||||
|
},
|
||||||
|
"exportQuality": {
|
||||||
|
"title": "Export Quality",
|
||||||
|
"low": "Low",
|
||||||
|
"medium": "Medium",
|
||||||
|
"high": "High"
|
||||||
|
},
|
||||||
|
"gifSettings": {
|
||||||
|
"frameRate": "GIF Frame Rate",
|
||||||
|
"size": "GIF Size",
|
||||||
|
"loop": "Loop GIF"
|
||||||
|
},
|
||||||
|
"project": {
|
||||||
|
"save": "Save Project",
|
||||||
|
"load": "Load Project"
|
||||||
|
},
|
||||||
|
"export": {
|
||||||
|
"videoButton": "Export Video",
|
||||||
|
"gifButton": "Export GIF",
|
||||||
|
"chooseSaveLocation": "Choose Save Location"
|
||||||
|
},
|
||||||
|
"links": {
|
||||||
|
"reportBug": "Report Bug",
|
||||||
|
"starOnGithub": "Star on GitHub"
|
||||||
|
},
|
||||||
|
"imageUpload": {
|
||||||
|
"invalidFileType": "Invalid file type",
|
||||||
|
"jpgOnly": "Please upload a JPG or JPEG image file.",
|
||||||
|
"uploadSuccess": "Custom image uploaded successfully!",
|
||||||
|
"failedToUpload": "Failed to upload image",
|
||||||
|
"errorReading": "There was an error reading the file."
|
||||||
|
},
|
||||||
|
"annotation": {
|
||||||
|
"title": "Annotation Settings",
|
||||||
|
"active": "Active",
|
||||||
|
"typeText": "Text",
|
||||||
|
"typeImage": "Image",
|
||||||
|
"typeArrow": "Arrow",
|
||||||
|
"typeBlur": "Blur",
|
||||||
|
"textContent": "Text Content",
|
||||||
|
"textPlaceholder": "Enter your text...",
|
||||||
|
"fontStyle": "Font Style",
|
||||||
|
"selectStyle": "Select style",
|
||||||
|
"size": "Size",
|
||||||
|
"customFonts": "Custom Fonts",
|
||||||
|
"textColor": "Text Color",
|
||||||
|
"background": "Background",
|
||||||
|
"none": "None",
|
||||||
|
"color": "Color",
|
||||||
|
"colorWheel": "Color Wheel",
|
||||||
|
"colorPalette": "Color Palette",
|
||||||
|
"clearBackground": "Clear Background",
|
||||||
|
"uploadImage": "Upload Image",
|
||||||
|
"supportedFormats": "Supported formats: JPG, PNG, GIF, WebP",
|
||||||
|
"arrowDirection": "Arrow Direction",
|
||||||
|
"strokeWidth": "Stroke Width: {{width}}px",
|
||||||
|
"arrowColor": "Arrow Color",
|
||||||
|
"blurType": "Blur Type",
|
||||||
|
"blurTypeBlur": "Blur",
|
||||||
|
"blurTypeMosaic": "Mosaic Blur",
|
||||||
|
"blurColor": "Blur Color",
|
||||||
|
"blurColorWhite": "White",
|
||||||
|
"blurColorBlack": "Black",
|
||||||
|
"blurShape": "Blur Shape",
|
||||||
|
"blurIntensity": "Blur Intensity",
|
||||||
|
"mosaicBlockSize": "Mosaic Block Size",
|
||||||
|
"blurShapeRectangle": "Rectangle",
|
||||||
|
"blurShapeOval": "Oval",
|
||||||
|
"blurShapeFreehand": "Freehand",
|
||||||
|
"deleteAnnotation": "Delete Annotation",
|
||||||
|
"shortcutsAndTips": "Shortcuts & Tips",
|
||||||
|
"tipMovePlayhead": "Move playhead to overlapping annotation section and select an item.",
|
||||||
|
"tipTabCycle": "Use Tab to cycle through overlapping items.",
|
||||||
|
"tipShiftTabCycle": "Use Shift+Tab to cycle backwards.",
|
||||||
|
"invalidImageType": "Invalid file type",
|
||||||
|
"imageFormatsOnly": "Please upload a JPG, PNG, GIF, or WebP image file.",
|
||||||
|
"imageUploadSuccess": "Image uploaded successfully!",
|
||||||
|
"failedImageUpload": "Failed to upload image"
|
||||||
|
},
|
||||||
|
"fontStyles": {
|
||||||
|
"classic": "Classic",
|
||||||
|
"editor": "Editor",
|
||||||
|
"strong": "Strong",
|
||||||
|
"typewriter": "Typewriter",
|
||||||
|
"deco": "Deco",
|
||||||
|
"simple": "Simple",
|
||||||
|
"modern": "Modern",
|
||||||
|
"clean": "Clean"
|
||||||
|
},
|
||||||
|
"customFont": {
|
||||||
|
"dialogTitle": "Add Google Font",
|
||||||
|
"urlLabel": "Google Fonts Import URL",
|
||||||
|
"urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
|
||||||
|
"urlHelp": "Get this from Google Fonts: Select a font → Click \"Get font\" → Copy the @import URL",
|
||||||
|
"nameLabel": "Display Name",
|
||||||
|
"namePlaceholder": "My Custom Font",
|
||||||
|
"nameHelp": "This is how the font will appear in the font selector",
|
||||||
|
"addButton": "Add Font",
|
||||||
|
"addingButton": "Adding...",
|
||||||
|
"errorEmptyUrl": "Please enter a Google Fonts import URL",
|
||||||
|
"errorInvalidUrl": "Please enter a valid Google Fonts URL",
|
||||||
|
"errorEmptyName": "Please enter a font name",
|
||||||
|
"errorExtractFailed": "Could not extract font family from URL",
|
||||||
|
"successMessage": "Font \"{{fontName}}\" added successfully",
|
||||||
|
"failedToAdd": "Failed to add font",
|
||||||
|
"errorTimeout": "Font took too long to load. Please check the URL and try again.",
|
||||||
|
"errorLoadFailed": "The font could not be loaded. Please verify the Google Fonts URL is correct."
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"title": "Language"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"title": "Keyboard Shortcuts",
|
||||||
|
"customize": "Customize",
|
||||||
|
"configurable": "Configurable",
|
||||||
|
"fixed": "Fixed",
|
||||||
|
"pressKey": "Press a key…",
|
||||||
|
"clickToChange": "Click to change",
|
||||||
|
"pressEscToCancel": "Press Esc to cancel",
|
||||||
|
"helpText": "Click a shortcut then press the new key combination. Press Esc to cancel.",
|
||||||
|
"resetToDefaults": "Reset to defaults",
|
||||||
|
"alreadyUsedBy": "Already used by {{action}}",
|
||||||
|
"swap": "Swap",
|
||||||
|
"reservedShortcut": "This shortcut is reserved for \"{{label}}\" and cannot be reassigned.",
|
||||||
|
"savedToast": "Keyboard shortcuts saved",
|
||||||
|
"resetToast": "Reset to default shortcuts — click Save to apply",
|
||||||
|
"actions": {
|
||||||
|
"addZoom": "Add Zoom",
|
||||||
|
"addTrim": "Add Trim",
|
||||||
|
"addSpeed": "Add Speed",
|
||||||
|
"addAnnotation": "Add Annotation",
|
||||||
|
"addBlur": "Add Blur",
|
||||||
|
"addKeyframe": "Add Keyframe",
|
||||||
|
"deleteSelected": "Delete Selected",
|
||||||
|
"playPause": "Play / Pause"
|
||||||
|
},
|
||||||
|
"fixedActions": {
|
||||||
|
"undo": "Undo",
|
||||||
|
"redo": "Redo",
|
||||||
|
"cycleAnnotationsForward": "Cycle Annotations Forward",
|
||||||
|
"cycleAnnotationsBackward": "Cycle Annotations Backward",
|
||||||
|
"deleteSelectedAlt": "Delete Selected (alt)",
|
||||||
|
"panTimeline": "Pan Timeline",
|
||||||
|
"zoomTimeline": "Zoom Timeline",
|
||||||
|
"frameBack": "Frame Back",
|
||||||
|
"frameForward": "Frame Forward"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
{
|
||||||
|
"buttons": {
|
||||||
|
"addZoom": "Add Zoom (Z)",
|
||||||
|
"suggestZooms": "Suggest Zooms from Cursor",
|
||||||
|
"addTrim": "Add Trim (T)",
|
||||||
|
"addAnnotation": "Add Annotation (A)",
|
||||||
|
"addBlur": "Add Blur (B)",
|
||||||
|
"addSpeed": "Add Speed (S)"
|
||||||
|
},
|
||||||
|
"hints": {
|
||||||
|
"pressZoom": "Press Z to add zoom",
|
||||||
|
"pressTrim": "Press T to add trim",
|
||||||
|
"pressAnnotation": "Press A to add annotation",
|
||||||
|
"pressBlur": "Press B to add blur region",
|
||||||
|
"pressSpeed": "Press S to add speed"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"pan": "Pan",
|
||||||
|
"zoom": "Zoom",
|
||||||
|
"trim": "Trim",
|
||||||
|
"speed": "Speed",
|
||||||
|
"zoomItem": "Zoom {{index}}",
|
||||||
|
"trimItem": "Trim {{index}}",
|
||||||
|
"speedItem": "Speed {{index}}",
|
||||||
|
"annotationItem": "Annotation",
|
||||||
|
"blurItem": "Blur {{index}}",
|
||||||
|
"imageItem": "Image",
|
||||||
|
"emptyText": "Empty text"
|
||||||
|
},
|
||||||
|
"emptyState": {
|
||||||
|
"noVideo": "No Video Loaded",
|
||||||
|
"dragAndDrop": "Drag and drop a video to start editing"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"cannotPlaceZoom": "Cannot place zoom here",
|
||||||
|
"zoomExistsAtLocation": "Zoom already exists at this location or not enough space available.",
|
||||||
|
"zoomSuggestionUnavailable": "Zoom suggestion handler unavailable",
|
||||||
|
"noCursorTelemetry": "No cursor telemetry available",
|
||||||
|
"noCursorTelemetryDescription": "Record a screencast first to generate cursor-based suggestions.",
|
||||||
|
"noUsableTelemetry": "No usable cursor telemetry",
|
||||||
|
"noUsableTelemetryDescription": "The recording does not include enough cursor movement data.",
|
||||||
|
"noDwellMoments": "No clear cursor dwell moments found",
|
||||||
|
"noDwellMomentsDescription": "Try a recording with slower cursor pauses on important actions.",
|
||||||
|
"noAutoZoomSlots": "No auto-zoom slots available",
|
||||||
|
"noAutoZoomSlotsDescription": "Detected dwell points overlap existing zoom regions.",
|
||||||
|
"cannotPlaceTrim": "Cannot place trim here",
|
||||||
|
"trimExistsAtLocation": "Trim already exists at this location or not enough space available.",
|
||||||
|
"cannotPlaceSpeed": "Cannot place speed here",
|
||||||
|
"speedExistsAtLocation": "Speed region already exists at this location or not enough space available."
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"addedZoomSuggestions": "Added {{count}} cursor-based zoom suggestion",
|
||||||
|
"addedZoomSuggestionsPlural": "Added {{count}} cursor-based zoom suggestions"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"actions": {
|
||||||
|
"cancel": "Cancelar",
|
||||||
|
"save": "Guardar",
|
||||||
|
"delete": "Eliminar",
|
||||||
|
"close": "Cerrar",
|
||||||
|
"share": "Compartir",
|
||||||
|
"done": "Listo",
|
||||||
|
"open": "Abrir",
|
||||||
|
"upload": "Subir",
|
||||||
|
"export": "Exportar",
|
||||||
|
"showInFolder": "Mostrar en carpeta",
|
||||||
|
"file": "Archivo",
|
||||||
|
"edit": "Editar",
|
||||||
|
"view": "Vista",
|
||||||
|
"window": "Ventana",
|
||||||
|
"quit": "Salir",
|
||||||
|
"stopRecording": "Detener grabación"
|
||||||
|
},
|
||||||
|
"playback": {
|
||||||
|
"play": "Reproducir",
|
||||||
|
"pause": "Pausar",
|
||||||
|
"fullscreen": "Pantalla completa",
|
||||||
|
"exitFullscreen": "Salir de pantalla completa"
|
||||||
|
},
|
||||||
|
"locale": {
|
||||||
|
"name": "Español",
|
||||||
|
"short": "ES"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
{
|
||||||
|
"export": {
|
||||||
|
"complete": "Exportación completada",
|
||||||
|
"yourFormatReady": "Tu {{format}} está listo",
|
||||||
|
"showInFolder": "Mostrar en carpeta",
|
||||||
|
"finalizingVideo": "Finalizando exportación de video...",
|
||||||
|
"compilingGifProgress": "Compilando GIF... {{progress}}%",
|
||||||
|
"compilingGifWait": "Compilando GIF... Esto puede tardar un rato",
|
||||||
|
"takeMoment": "Esto puede tardar un momento...",
|
||||||
|
"failed": "La exportación falló",
|
||||||
|
"tryAgain": "Por favor intenta de nuevo",
|
||||||
|
"finalizingVideoTitle": "Finalizando video",
|
||||||
|
"compilingGif": "Compilando GIF",
|
||||||
|
"exportingFormat": "Exportando {{format}}",
|
||||||
|
"compiling": "Compilando",
|
||||||
|
"renderingFrames": "Renderizando cuadros",
|
||||||
|
"processing": "Procesando...",
|
||||||
|
"finalizing": "Finalizando...",
|
||||||
|
"compilingStatus": "Compilando...",
|
||||||
|
"status": "Estado",
|
||||||
|
"format": "Formato",
|
||||||
|
"frames": "Cuadros",
|
||||||
|
"cancelExport": "Cancelar exportación",
|
||||||
|
"savedSuccessfully": "¡{{format}} guardado exitosamente!"
|
||||||
|
},
|
||||||
|
"tutorial": {
|
||||||
|
"triggerLabel": "Cómo funciona el recorte",
|
||||||
|
"title": "Cómo funciona el recorte",
|
||||||
|
"description": "Aprende a eliminar las partes no deseadas de tu video.",
|
||||||
|
"explanationBefore": "La herramienta de recorte funciona definiendo los segmentos que deseas",
|
||||||
|
"remove": "eliminar",
|
||||||
|
"explanationMiddle": " — cualquier parte",
|
||||||
|
"covered": "cubierta",
|
||||||
|
"explanationAfter": "por un segmento rojo será eliminada al exportar.",
|
||||||
|
"visualExample": "Ejemplo visual",
|
||||||
|
"removed": "ELIMINADO",
|
||||||
|
"kept": "Conservado",
|
||||||
|
"part1": "Parte 1",
|
||||||
|
"part2": "Parte 2",
|
||||||
|
"part3": "Parte 3",
|
||||||
|
"finalVideo": "Video final",
|
||||||
|
"step1Title": "1. Agregar recorte",
|
||||||
|
"step1DescriptionBefore": "Presiona ",
|
||||||
|
"step1DescriptionAfter": " o haz clic en el ícono de tijeras para marcar una sección a eliminar.",
|
||||||
|
"step2Title": "2. Ajustar",
|
||||||
|
"step2Description": "Arrastra los bordes de la región roja para cubrir exactamente lo que deseas eliminar."
|
||||||
|
},
|
||||||
|
"unsavedChanges": {
|
||||||
|
"title": "Cambios sin guardar",
|
||||||
|
"message": "Tienes cambios sin guardar.",
|
||||||
|
"detail": "¿Deseas guardar tu proyecto antes de cerrar?",
|
||||||
|
"saveAndClose": "Guardar y cerrar",
|
||||||
|
"discardAndClose": "Descartar y cerrar",
|
||||||
|
"loadProject": "Cargar proyecto…",
|
||||||
|
"saveProject": "Guardar proyecto…",
|
||||||
|
"saveProjectAs": "Guardar proyecto como…"
|
||||||
|
},
|
||||||
|
"fileDialogs": {
|
||||||
|
"saveGif": "Guardar GIF exportado",
|
||||||
|
"saveVideo": "Guardar video exportado",
|
||||||
|
"selectVideo": "Seleccionar archivo de video",
|
||||||
|
"saveProject": "Guardar proyecto OpenScreen",
|
||||||
|
"openProject": "Abrir proyecto OpenScreen",
|
||||||
|
"gifImage": "Imagen GIF",
|
||||||
|
"mp4Video": "Video MP4",
|
||||||
|
"videoFiles": "Archivos de video",
|
||||||
|
"openscreenProject": "Proyecto OpenScreen",
|
||||||
|
"allFiles": "Todos los archivos"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"errors": {
|
||||||
|
"noVideoLoaded": "No hay video cargado",
|
||||||
|
"videoNotReady": "El video no está listo",
|
||||||
|
"unableToDetermineSourcePath": "No se pudo determinar la ruta del video de origen",
|
||||||
|
"failedToSaveGif": "Error al guardar el GIF",
|
||||||
|
"gifExportFailed": "La exportación de GIF falló",
|
||||||
|
"failedToSaveVideo": "Error al guardar el video",
|
||||||
|
"exportFailed": "La exportación falló",
|
||||||
|
"exportFailedWithError": "La exportación falló: {{error}}",
|
||||||
|
"exportBackgroundLoadFailed": "La exportación falló: no se pudo cargar la imagen de fondo ({{url}})",
|
||||||
|
"failedToSaveExport": "Error al guardar la exportación",
|
||||||
|
"failedToSaveExportedVideo": "Error al guardar el video exportado",
|
||||||
|
"failedToRevealInFolder": "Error al mostrar en la carpeta: {{error}}"
|
||||||
|
},
|
||||||
|
"export": {
|
||||||
|
"canceled": "Exportación cancelada",
|
||||||
|
"exportedSuccessfully": "{{format}} exportado exitosamente"
|
||||||
|
},
|
||||||
|
"project": {
|
||||||
|
"saveCanceled": "Guardado de proyecto cancelado",
|
||||||
|
"failedToSave": "Error al guardar el proyecto",
|
||||||
|
"savedTo": "Proyecto guardado en {{path}}",
|
||||||
|
"failedToLoad": "Error al cargar el proyecto",
|
||||||
|
"invalidFormat": "Formato de archivo de proyecto no válido",
|
||||||
|
"loadedFrom": "Proyecto cargado desde {{path}}"
|
||||||
|
},
|
||||||
|
"recording": {
|
||||||
|
"failedCameraAccess": "Error al solicitar acceso a la cámara.",
|
||||||
|
"cameraBlocked": "El acceso a la cámara está bloqueado. Actívalo en la configuración del sistema para usar la cámara web.",
|
||||||
|
"systemAudioUnavailable": "Audio del sistema no disponible. Grabando sin audio del sistema.",
|
||||||
|
"microphoneDenied": "Acceso al micrófono denegado. La grabación continuará sin audio.",
|
||||||
|
"cameraDenied": "Acceso a la cámara denegado. La grabación continuará sin cámara web.",
|
||||||
|
"cameraDisconnected": "Cámara web desconectada.",
|
||||||
|
"cameraNotFound": "Cámara no encontrada.",
|
||||||
|
"permissionDenied": "Permiso de grabación denegado. Por favor permite la grabación de pantalla."
|
||||||
|
},
|
||||||
|
"loadingVideo": "Cargando video...",
|
||||||
|
"newRecording": {
|
||||||
|
"title": "Volver a la grabadora",
|
||||||
|
"description": "Tu sesión actual ha sido guardada.",
|
||||||
|
"cancel": "Cancelar",
|
||||||
|
"confirm": "Confirmar"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"tooltips": {
|
||||||
|
"hideHUD": "Ocultar HUD",
|
||||||
|
"closeApp": "Cerrar aplicación",
|
||||||
|
"restartRecording": "Reiniciar grabación",
|
||||||
|
"cancelRecording": "Cancelar grabación",
|
||||||
|
"pauseRecording": "Pausar grabación",
|
||||||
|
"resumeRecording": "Reanudar grabación",
|
||||||
|
"openVideoFile": "Abrir archivo de video",
|
||||||
|
"openProject": "Abrir proyecto"
|
||||||
|
},
|
||||||
|
"audio": {
|
||||||
|
"enableSystemAudio": "Activar audio del sistema",
|
||||||
|
"disableSystemAudio": "Desactivar audio del sistema",
|
||||||
|
"enableMicrophone": "Activar micrófono",
|
||||||
|
"disableMicrophone": "Desactivar micrófono",
|
||||||
|
"defaultMicrophone": "Micrófono predeterminado"
|
||||||
|
},
|
||||||
|
"webcam": {
|
||||||
|
"enableWebcam": "Activar cámara web",
|
||||||
|
"disableWebcam": "Desactivar cámara web",
|
||||||
|
"defaultCamera": "Cámara predeterminada",
|
||||||
|
"searching": "Buscando...",
|
||||||
|
"noneFound": "No se encontró cámara",
|
||||||
|
"unavailable": "Cámara no disponible"
|
||||||
|
},
|
||||||
|
"sourceSelector": {
|
||||||
|
"loading": "Cargando fuentes...",
|
||||||
|
"screens": "Pantallas ({{count}})",
|
||||||
|
"windows": "Ventanas ({{count}})",
|
||||||
|
"defaultSourceName": "Pantalla"
|
||||||
|
},
|
||||||
|
"recording": {
|
||||||
|
"selectSource": "Por favor selecciona una fuente para grabar"
|
||||||
|
},
|
||||||
|
"language": "Idioma",
|
||||||
|
"systemLanguagePrompt": {
|
||||||
|
"title": "¿Usar el idioma del sistema?",
|
||||||
|
"description": "Detectamos {{language}} como idioma de tu sistema. ¿Quieres cambiar OpenScreen a {{language}}?",
|
||||||
|
"switch": "Cambiar a {{language}}",
|
||||||
|
"keepDefault": "Mantener idioma actual"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
{
|
||||||
|
"zoom": {
|
||||||
|
"level": "Nivel de zoom",
|
||||||
|
"selectRegion": "Selecciona una región de zoom para ajustar",
|
||||||
|
"deleteZoom": "Eliminar zoom",
|
||||||
|
"focusMode": {
|
||||||
|
"title": "Modo de enfoque",
|
||||||
|
"manual": "Manual",
|
||||||
|
"auto": "Auto",
|
||||||
|
"autoDescription": "La cámara sigue la posición del cursor grabado"
|
||||||
|
},
|
||||||
|
"threeD": {
|
||||||
|
"title": "Rotación 3D",
|
||||||
|
"preset": {
|
||||||
|
"iso": "Iso",
|
||||||
|
"left": "Izquierda",
|
||||||
|
"right": "Derecha"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"speed": {
|
||||||
|
"playbackSpeed": "Velocidad de reproducción",
|
||||||
|
"selectRegion": "Selecciona una región de velocidad para ajustar",
|
||||||
|
"deleteRegion": "Eliminar región de velocidad",
|
||||||
|
"customPlaybackSpeed": "Velocidad personalizada",
|
||||||
|
"maxSpeedError": "La velocidad no puede superar 16×"
|
||||||
|
},
|
||||||
|
"trim": {
|
||||||
|
"deleteRegion": "Eliminar región de recorte"
|
||||||
|
},
|
||||||
|
"layout": {
|
||||||
|
"title": "Diseño",
|
||||||
|
"preset": "Predefinido",
|
||||||
|
"selectPreset": "Seleccionar predefinido",
|
||||||
|
"pictureInPicture": "Imagen en imagen",
|
||||||
|
"verticalStack": "Apilado vertical",
|
||||||
|
"dualFrame": "Marco dual",
|
||||||
|
"webcamShape": "Forma de cámara",
|
||||||
|
"webcamSize": "Tamaño de cámara"
|
||||||
|
},
|
||||||
|
"effects": {
|
||||||
|
"title": "Efectos de video",
|
||||||
|
"blurBg": "Desenfocar fondo",
|
||||||
|
"motionBlur": "Desenfoque de movimiento",
|
||||||
|
"off": "desactivado",
|
||||||
|
"shadow": "Sombra",
|
||||||
|
"roundness": "Redondez",
|
||||||
|
"padding": "Relleno"
|
||||||
|
},
|
||||||
|
"background": {
|
||||||
|
"title": "Fondo",
|
||||||
|
"image": "Imagen",
|
||||||
|
"color": "Color",
|
||||||
|
"gradient": "Degradado",
|
||||||
|
"uploadCustom": "Subir personalizado",
|
||||||
|
"gradientLabel": "Degradado {{index}}",
|
||||||
|
"colorWheel": "Rueda de colores",
|
||||||
|
"colorPalette": "Paleta de colores"
|
||||||
|
},
|
||||||
|
"crop": {
|
||||||
|
"title": "Recortar",
|
||||||
|
"cropVideo": "Recortar video",
|
||||||
|
"dragInstruction": "Arrastra cada lado para ajustar el área de recorte",
|
||||||
|
"ratio": "Proporción",
|
||||||
|
"free": "Libre",
|
||||||
|
"done": "Listo",
|
||||||
|
"lockAspectRatio": "Bloquear relación de aspecto",
|
||||||
|
"unlockAspectRatio": "Desbloquear relación de aspecto"
|
||||||
|
},
|
||||||
|
"exportFormat": {
|
||||||
|
"mp4": "MP4",
|
||||||
|
"gif": "GIF",
|
||||||
|
"mp4Video": "Video MP4",
|
||||||
|
"mp4Description": "Archivo de video de alta calidad",
|
||||||
|
"gifAnimation": "Animación GIF",
|
||||||
|
"gifDescription": "Imagen animada para compartir"
|
||||||
|
},
|
||||||
|
"exportQuality": {
|
||||||
|
"title": "Calidad de exportación",
|
||||||
|
"low": "Baja",
|
||||||
|
"medium": "Media",
|
||||||
|
"high": "Alta"
|
||||||
|
},
|
||||||
|
"gifSettings": {
|
||||||
|
"frameRate": "Velocidad de cuadros del GIF",
|
||||||
|
"size": "Tamaño del GIF",
|
||||||
|
"loop": "Repetir GIF"
|
||||||
|
},
|
||||||
|
"project": {
|
||||||
|
"save": "Guardar proyecto",
|
||||||
|
"load": "Cargar proyecto"
|
||||||
|
},
|
||||||
|
"export": {
|
||||||
|
"videoButton": "Exportar video",
|
||||||
|
"gifButton": "Exportar GIF",
|
||||||
|
"chooseSaveLocation": "Elegir ubicación de guardado"
|
||||||
|
},
|
||||||
|
"links": {
|
||||||
|
"reportBug": "Reportar error",
|
||||||
|
"starOnGithub": "Dar estrella en GitHub"
|
||||||
|
},
|
||||||
|
"imageUpload": {
|
||||||
|
"invalidFileType": "Tipo de archivo no válido",
|
||||||
|
"jpgOnly": "Por favor sube un archivo de imagen JPG o JPEG.",
|
||||||
|
"uploadSuccess": "¡Imagen personalizada subida exitosamente!",
|
||||||
|
"failedToUpload": "Error al subir la imagen",
|
||||||
|
"errorReading": "Hubo un error al leer el archivo."
|
||||||
|
},
|
||||||
|
"annotation": {
|
||||||
|
"title": "Configuración de anotaciones",
|
||||||
|
"active": "Activo",
|
||||||
|
"typeText": "Texto",
|
||||||
|
"typeImage": "Imagen",
|
||||||
|
"typeArrow": "Flecha",
|
||||||
|
"typeBlur": "Desenfoque",
|
||||||
|
"textContent": "Contenido de texto",
|
||||||
|
"textPlaceholder": "Escribe tu texto...",
|
||||||
|
"fontStyle": "Estilo de fuente",
|
||||||
|
"selectStyle": "Seleccionar estilo",
|
||||||
|
"size": "Tamaño",
|
||||||
|
"customFonts": "Fuentes personalizadas",
|
||||||
|
"textColor": "Color de texto",
|
||||||
|
"background": "Fondo",
|
||||||
|
"none": "Ninguno",
|
||||||
|
"color": "Color",
|
||||||
|
"colorWheel": "Rueda de colores",
|
||||||
|
"colorPalette": "Paleta de colores",
|
||||||
|
"clearBackground": "Quitar fondo",
|
||||||
|
"uploadImage": "Subir imagen",
|
||||||
|
"supportedFormats": "Formatos compatibles: JPG, PNG, GIF, WebP",
|
||||||
|
"arrowDirection": "Dirección de la flecha",
|
||||||
|
"strokeWidth": "Grosor del trazo: {{width}}px",
|
||||||
|
"arrowColor": "Color de la flecha",
|
||||||
|
"blurType": "Tipo de desenfoque",
|
||||||
|
"blurTypeBlur": "Desenfoque",
|
||||||
|
"blurTypeMosaic": "Desenfoque mosaico",
|
||||||
|
"blurColor": "Color del desenfoque",
|
||||||
|
"blurColorWhite": "Blanco",
|
||||||
|
"blurColorBlack": "Negro",
|
||||||
|
"blurShape": "Forma del desenfoque",
|
||||||
|
"blurIntensity": "Intensidad del desenfoque",
|
||||||
|
"mosaicBlockSize": "Tamano del bloque mosaico",
|
||||||
|
"blurShapeRectangle": "Rectángulo",
|
||||||
|
"blurShapeOval": "Óvalo",
|
||||||
|
"blurShapeFreehand": "Mano alzada",
|
||||||
|
"deleteAnnotation": "Eliminar anotación",
|
||||||
|
"shortcutsAndTips": "Atajos y consejos",
|
||||||
|
"tipMovePlayhead": "Mueve el cabezal de reproducción a la sección de anotación superpuesta y selecciona un elemento.",
|
||||||
|
"tipTabCycle": "Usa Tab para recorrer los elementos superpuestos.",
|
||||||
|
"tipShiftTabCycle": "Usa Shift+Tab para recorrer hacia atrás.",
|
||||||
|
"invalidImageType": "Tipo de archivo no válido",
|
||||||
|
"imageFormatsOnly": "Por favor sube un archivo de imagen JPG, PNG, GIF o WebP.",
|
||||||
|
"imageUploadSuccess": "¡Imagen subida exitosamente!",
|
||||||
|
"failedImageUpload": "Error al subir la imagen"
|
||||||
|
},
|
||||||
|
"fontStyles": {
|
||||||
|
"classic": "Clásico",
|
||||||
|
"editor": "Editor",
|
||||||
|
"strong": "Fuerte",
|
||||||
|
"typewriter": "Máquina de escribir",
|
||||||
|
"deco": "Deco",
|
||||||
|
"simple": "Simple",
|
||||||
|
"modern": "Moderno",
|
||||||
|
"clean": "Limpio"
|
||||||
|
},
|
||||||
|
"customFont": {
|
||||||
|
"dialogTitle": "Agregar fuente de Google",
|
||||||
|
"urlLabel": "URL de importación de Google Fonts",
|
||||||
|
"urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
|
||||||
|
"urlHelp": "Obtén esto de Google Fonts: Selecciona una fuente → Haz clic en \"Get font\" → Copia la URL de @import",
|
||||||
|
"nameLabel": "Nombre para mostrar",
|
||||||
|
"namePlaceholder": "Mi fuente personalizada",
|
||||||
|
"nameHelp": "Así aparecerá la fuente en el selector de fuentes",
|
||||||
|
"addButton": "Agregar fuente",
|
||||||
|
"addingButton": "Agregando...",
|
||||||
|
"errorEmptyUrl": "Por favor ingresa una URL de importación de Google Fonts",
|
||||||
|
"errorInvalidUrl": "Por favor ingresa una URL válida de Google Fonts",
|
||||||
|
"errorEmptyName": "Por favor ingresa un nombre de fuente",
|
||||||
|
"errorExtractFailed": "No se pudo extraer la familia de fuentes de la URL",
|
||||||
|
"successMessage": "Fuente \"{{fontName}}\" agregada exitosamente",
|
||||||
|
"failedToAdd": "Error al agregar la fuente",
|
||||||
|
"errorTimeout": "La fuente tardó demasiado en cargarse. Por favor verifica la URL e intenta de nuevo.",
|
||||||
|
"errorLoadFailed": "No se pudo cargar la fuente. Por favor verifica que la URL de Google Fonts sea correcta."
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"title": "Idioma"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"title": "Atajos de teclado",
|
||||||
|
"customize": "Personalizar",
|
||||||
|
"configurable": "Configurables",
|
||||||
|
"fixed": "Fijos",
|
||||||
|
"pressKey": "Presiona una tecla…",
|
||||||
|
"clickToChange": "Haz clic para cambiar",
|
||||||
|
"pressEscToCancel": "Presiona Esc para cancelar",
|
||||||
|
"helpText": "Haz clic en un atajo y luego presiona la nueva combinación de teclas. Presiona Esc para cancelar.",
|
||||||
|
"resetToDefaults": "Restablecer valores predeterminados",
|
||||||
|
"alreadyUsedBy": "Ya está en uso por {{action}}",
|
||||||
|
"swap": "Intercambiar",
|
||||||
|
"reservedShortcut": "Este atajo está reservado para \"{{label}}\" y no se puede reasignar.",
|
||||||
|
"savedToast": "Atajos de teclado guardados",
|
||||||
|
"resetToast": "Restablecido a los atajos predeterminados — haz clic en Guardar para aplicar",
|
||||||
|
"actions": {
|
||||||
|
"addZoom": "Agregar zoom",
|
||||||
|
"addTrim": "Agregar recorte",
|
||||||
|
"addSpeed": "Agregar velocidad",
|
||||||
|
"addAnnotation": "Agregar anotación",
|
||||||
|
"addBlur": "Agregar desenfoque",
|
||||||
|
"addKeyframe": "Agregar fotograma clave",
|
||||||
|
"deleteSelected": "Eliminar seleccionado",
|
||||||
|
"playPause": "Reproducir / Pausar"
|
||||||
|
},
|
||||||
|
"fixedActions": {
|
||||||
|
"undo": "Deshacer",
|
||||||
|
"redo": "Rehacer",
|
||||||
|
"cycleAnnotationsForward": "Recorrer anotaciones hacia adelante",
|
||||||
|
"cycleAnnotationsBackward": "Recorrer anotaciones hacia atrás",
|
||||||
|
"deleteSelectedAlt": "Eliminar seleccionado (alt)",
|
||||||
|
"panTimeline": "Desplazar línea de tiempo",
|
||||||
|
"zoomTimeline": "Zoom en línea de tiempo",
|
||||||
|
"frameBack": "Fotograma anterior",
|
||||||
|
"frameForward": "Fotograma siguiente"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
{
|
||||||
|
"buttons": {
|
||||||
|
"addZoom": "Agregar zoom (Z)",
|
||||||
|
"suggestZooms": "Sugerir zooms desde el cursor",
|
||||||
|
"addTrim": "Agregar recorte (T)",
|
||||||
|
"addAnnotation": "Agregar anotación (A)",
|
||||||
|
"addSpeed": "Agregar velocidad (S)",
|
||||||
|
"addBlur": "Agregar desenfoque (B)"
|
||||||
|
},
|
||||||
|
"hints": {
|
||||||
|
"pressZoom": "Presiona Z para agregar zoom",
|
||||||
|
"pressTrim": "Presiona T para agregar recorte",
|
||||||
|
"pressAnnotation": "Presiona A para agregar anotación",
|
||||||
|
"pressSpeed": "Presiona S para agregar velocidad",
|
||||||
|
"pressBlur": "Presiona B para agregar una región de desenfoque"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"pan": "Desplazar",
|
||||||
|
"zoom": "Zoom",
|
||||||
|
"trim": "Recortar",
|
||||||
|
"speed": "Velocidad",
|
||||||
|
"zoomItem": "Zoom {{index}}",
|
||||||
|
"trimItem": "Recorte {{index}}",
|
||||||
|
"speedItem": "Velocidad {{index}}",
|
||||||
|
"annotationItem": "Anotación",
|
||||||
|
"imageItem": "Imagen",
|
||||||
|
"emptyText": "Texto vacío",
|
||||||
|
"blurItem": "Desenfoque {{index}}"
|
||||||
|
},
|
||||||
|
"emptyState": {
|
||||||
|
"noVideo": "No hay video cargado",
|
||||||
|
"dragAndDrop": "Arrastra y suelta un video para comenzar a editar"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"cannotPlaceZoom": "No se puede colocar el zoom aquí",
|
||||||
|
"zoomExistsAtLocation": "Ya existe un zoom en esta ubicación o no hay suficiente espacio disponible.",
|
||||||
|
"zoomSuggestionUnavailable": "El controlador de sugerencias de zoom no está disponible",
|
||||||
|
"noCursorTelemetry": "No hay telemetría de cursor disponible",
|
||||||
|
"noCursorTelemetryDescription": "Graba una captura de pantalla primero para generar sugerencias basadas en el cursor.",
|
||||||
|
"noUsableTelemetry": "No hay telemetría de cursor utilizable",
|
||||||
|
"noUsableTelemetryDescription": "La grabación no incluye suficientes datos de movimiento del cursor.",
|
||||||
|
"noDwellMoments": "No se encontraron momentos claros de pausa del cursor",
|
||||||
|
"noDwellMomentsDescription": "Intenta una grabación con pausas más lentas del cursor en acciones importantes.",
|
||||||
|
"noAutoZoomSlots": "No hay espacios de auto-zoom disponibles",
|
||||||
|
"noAutoZoomSlotsDescription": "Los puntos de pausa detectados se superponen con regiones de zoom existentes.",
|
||||||
|
"cannotPlaceTrim": "No se puede colocar el recorte aquí",
|
||||||
|
"trimExistsAtLocation": "Ya existe un recorte en esta ubicación o no hay suficiente espacio disponible.",
|
||||||
|
"cannotPlaceSpeed": "No se puede colocar la velocidad aquí",
|
||||||
|
"speedExistsAtLocation": "Ya existe una región de velocidad en esta ubicación o no hay suficiente espacio disponible."
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"addedZoomSuggestions": "Se agregó {{count}} sugerencia de zoom basada en el cursor",
|
||||||
|
"addedZoomSuggestionsPlural": "Se agregaron {{count}} sugerencias de zoom basadas en el cursor"
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user