mirror of
https://github.com/siddharthvaddem/openscreen.git
synced 2026-11-04 07:02:07 +08:00
Compare commits
537
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fc85270bc | ||
|
|
e3d4a330df | ||
|
|
7bbb855e8e | ||
|
|
52cb709a88 | ||
|
|
68a95c642a | ||
|
|
2ae7aca185 | ||
|
|
8afca89520 | ||
|
|
7feb05cca7 | ||
|
|
9ac4e778f8 | ||
|
|
ed825d8b37 | ||
|
|
b48370e3d0 | ||
|
|
8c0c555640 | ||
|
|
24be97bae7 | ||
|
|
c820b416f9 | ||
|
|
f42c478725 | ||
|
|
64c2a43006 | ||
|
|
4a0878c3d0 | ||
|
|
d8da26a41a | ||
|
|
f0699cc8b0 | ||
|
|
3ad3e22a16 | ||
|
|
2381e48a46 | ||
|
|
68c35ff01c | ||
|
|
3dd7b85ebb | ||
|
|
be4e2d0c94 | ||
|
|
c9b6074626 | ||
|
|
c1f6cf67b2 | ||
|
|
5bd17f4346 | ||
|
|
d3e397e249 | ||
|
|
c771bf8bb9 | ||
|
|
38f2044967 | ||
|
|
b4f7b4c182 | ||
|
|
e880f05866 | ||
|
|
b7c85a9b4e | ||
|
|
bc7c51ecdf | ||
|
|
42127e647f | ||
|
|
f3dcbf2867 | ||
|
|
f30090bf88 | ||
|
|
37215531c2 | ||
|
|
770a872861 | ||
|
|
e170fe1a83 | ||
|
|
9af318561f | ||
|
|
b525571f71 | ||
|
|
fe93050089 | ||
|
|
f47fa6bdca | ||
|
|
a0c423de67 | ||
|
|
c9980b0dca | ||
|
|
1aac6eddb0 | ||
|
|
25cfd2777f | ||
|
|
ada1f434f7 | ||
|
|
9336e3d3c6 | ||
|
|
6130c66be6 | ||
|
|
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 | ||
|
|
e2bdfee653 | ||
|
|
e7e493294b | ||
|
|
b2cc722613 | ||
|
|
36076aaf2a | ||
|
|
b3469c469b | ||
|
|
b801c1ccea | ||
|
|
c40727672f | ||
|
|
a38454a7fb | ||
|
|
8e8b194454 | ||
|
|
916d649037 | ||
|
|
37c1ea5984 | ||
|
|
884021c7d6 | ||
|
|
93466fdda1 | ||
|
|
786165208f | ||
|
|
0768c449d7 | ||
|
|
dc7259ba09 | ||
|
|
a6fe33a0f6 | ||
|
|
608e0abe87 | ||
|
|
f9401f051c | ||
|
|
cae71ed49c | ||
|
|
6577a54418 | ||
|
|
3b9b4192bf | ||
|
|
1fefde8881 | ||
|
|
5e994d214e | ||
|
|
49213960e2 | ||
|
|
8458cbb40e | ||
|
|
e1c67c4e92 | ||
|
|
92f0ed8efe | ||
|
|
67e7048636 | ||
|
|
13c982618a | ||
|
|
657d55bd72 | ||
|
|
c53dd2df93 | ||
|
|
e06e40dbc2 | ||
|
|
373319808e | ||
|
|
af159e8a2b | ||
|
|
f2ff7fb21c | ||
|
|
702b733074 | ||
|
|
86c1c483d4 | ||
|
|
adf3855ac8 | ||
|
|
d145f80041 | ||
|
|
42468c13b6 | ||
|
|
dca91b4f86 | ||
|
|
466fad399a | ||
|
|
1673daabe4 | ||
|
|
cffca5f2ff | ||
|
|
80a3b1e4d3 | ||
|
|
d1087af63c | ||
|
|
8e1c7e035a | ||
|
|
a26eb3cbab | ||
|
|
f4e10b28cc | ||
|
|
9a361a9f2e | ||
|
|
67ec57751f | ||
|
|
0264d8cb9e | ||
|
|
2704c280bd | ||
|
|
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 | ||
|
|
69f1b4d20f | ||
|
|
d968689975 | ||
|
|
7e65d52847 | ||
|
|
1680ef9b77 | ||
|
|
b7070f3ac8 | ||
|
|
de18a2f46f | ||
|
|
7a6efc5df9 | ||
|
|
4b8c95f04f | ||
|
|
0f123283b3 | ||
|
|
b33ec5e2d7 | ||
|
|
0a0dd088c3 | ||
|
|
2669b380a3 | ||
|
|
0935dac70a | ||
|
|
e2147bec63 | ||
|
|
3d2d0a4dbc | ||
|
|
1591f7dfcb | ||
|
|
c3e4c86b33 | ||
|
|
942a7e599a | ||
|
|
776ed954f2 | ||
|
|
f1a453b9b2 | ||
|
|
e4263d4597 | ||
|
|
2fb5b3b574 | ||
|
|
9a5d94a1c8 | ||
|
|
119c3acb18 | ||
|
|
881acdb26f | ||
|
|
4a308fde12 | ||
|
|
fc8a4db8f1 | ||
|
|
ac4f82484b | ||
|
|
e9f0fda397 | ||
|
|
4655e71ca5 | ||
|
|
e82332647a | ||
|
|
9fb91dd17b | ||
|
|
c8cf052fc9 | ||
|
|
61d89831bb | ||
|
|
9f6ef0f582 | ||
|
|
ea68300634 | ||
|
|
446e3a35fc | ||
|
|
c35a33203b | ||
|
|
dd84edaf41 | ||
|
|
9d71f509b8 | ||
|
|
9687157aba | ||
|
|
e2075f15e9 | ||
|
|
d182854270 | ||
|
|
7a8d0f449a | ||
|
|
0727b61de7 | ||
|
|
56988e86e2 | ||
|
|
965d3e5f4c | ||
|
|
5f6576768c | ||
|
|
6c086be1b6 | ||
|
|
b52d27bf56 | ||
|
|
e3c922d032 | ||
|
|
16dea49fa8 | ||
|
|
575a339550 | ||
|
|
e5fa783b59 | ||
|
|
b5cc7777d7 | ||
|
|
e72fb8252c | ||
|
|
5e8bb99e96 | ||
|
|
1b08618831 | ||
|
|
144e34318e | ||
|
|
6035719252 | ||
|
|
2af33894e9 | ||
|
|
63fd87612e | ||
|
|
4b79909116 | ||
|
|
0a6895e89f | ||
|
|
36a0a304d5 | ||
|
|
4f68df1db8 | ||
|
|
7422e16b1e | ||
|
|
151a3b2902 | ||
|
|
066832a3bd | ||
|
|
118158b8ee | ||
|
|
948e2b1e4a | ||
|
|
c48243360b | ||
|
|
7833dee014 | ||
|
|
dcf35a6ede | ||
|
|
c8ebef026b | ||
|
|
7226632fc4 | ||
|
|
2ccead5fb9 | ||
|
|
6d44dafd96 | ||
|
|
e6e3abb88c | ||
|
|
0e85679b14 |
@@ -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:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
arch:
|
||||
description: 'Architecture to build'
|
||||
required: true
|
||||
default: 'both'
|
||||
type: choice
|
||||
options:
|
||||
- arm64
|
||||
- x64
|
||||
- both
|
||||
|
||||
jobs:
|
||||
build-windows:
|
||||
@@ -10,18 +20,15 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install app dependencies
|
||||
run: npx electron-builder install-app-deps
|
||||
|
||||
|
||||
- name: Build Windows app
|
||||
run: npm run build:win
|
||||
env:
|
||||
@@ -36,38 +43,180 @@ jobs:
|
||||
|
||||
build-macos:
|
||||
runs-on: macos-latest
|
||||
strategy:
|
||||
matrix:
|
||||
arch: ${{ github.event.inputs.arch == 'both' && fromJSON('["arm64", "x64"]') || fromJSON(format('["{0}"]', github.event.inputs.arch)) }}
|
||||
|
||||
steps:
|
||||
# ─── Checkout ─────────────────────────────────────────────
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# ─── Setup Node.js ────────────────────────────────────────
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
# ─── Setup Python (needed by some native deps) ────────────
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
# ─── Install Dependencies ─────────────────────────────────
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install app dependencies
|
||||
run: npx electron-builder install-app-deps
|
||||
|
||||
- name: Build macOS app
|
||||
run: npm run build:mac
|
||||
# ─── Import Code Signing Certificate ──────────────────────
|
||||
# This is the KEY step that makes CI signing work.
|
||||
# We create a temporary keychain, import the .p12 cert into it,
|
||||
# and set it as the default so codesign can find it.
|
||||
- name: Import code signing certificate
|
||||
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 }}
|
||||
|
||||
- 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
|
||||
with:
|
||||
name: macos-installer
|
||||
path: release/**/*.dmg
|
||||
name: openscreen-mac-${{ matrix.arch }}
|
||||
path: ${{ steps.dmg.outputs.dmg_path }}
|
||||
retention-days: 30
|
||||
|
||||
# ─── Cleanup Keychain ─────────────────────────────────────
|
||||
- name: Cleanup keychain
|
||||
if: always()
|
||||
run: security delete-keychain $RUNNER_TEMP/build.keychain-db || true
|
||||
|
||||
build-linux:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -82,8 +231,10 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install app dependencies
|
||||
run: npx electron-builder install-app-deps
|
||||
# bsdtar (from libarchive-tools) is required by fpm to build pacman
|
||||
# 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
|
||||
run: npm run build:linux
|
||||
@@ -94,6 +245,9 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-installer
|
||||
path: release/**/*.AppImage
|
||||
path: |
|
||||
release/**/*.AppImage
|
||||
release/**/*.zsync
|
||||
release/**/*.deb
|
||||
release/**/*.pacman
|
||||
retention-days: 30
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
name: Bump Nix package on release
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Release tag to bump (e.g. v1.5.0)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
bump:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'workflow_dispatch' || !github.event.release.prerelease
|
||||
steps:
|
||||
- name: Resolve tag and version
|
||||
id: meta
|
||||
env:
|
||||
GH_EVENT_TAG: ${{ github.event.release.tag_name }}
|
||||
INPUT_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${GH_EVENT_TAG:-$INPUT_TAG}"
|
||||
if [[ -z "$TAG" ]]; then
|
||||
echo "::error::No tag resolved from release event or workflow input"
|
||||
exit 1
|
||||
fi
|
||||
VERSION="${TAG#v}"
|
||||
BRANCH="chore/bump-nix-${VERSION}"
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Nix
|
||||
uses: cachix/install-nix-action@v27
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
extra_nix_config: |
|
||||
experimental-features = nix-command flakes
|
||||
|
||||
- name: Compute npmDepsHash
|
||||
id: hash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
HASH=$(nix run nixpkgs#prefetch-npm-deps -- package-lock.json)
|
||||
if [[ -z "$HASH" ]]; then
|
||||
echo "::error::prefetch-npm-deps returned an empty hash"
|
||||
exit 1
|
||||
fi
|
||||
echo "hash=$HASH" >> "$GITHUB_OUTPUT"
|
||||
echo "Computed npmDepsHash: $HASH"
|
||||
|
||||
- name: Update nix/package.nix
|
||||
env:
|
||||
VERSION: ${{ steps.meta.outputs.version }}
|
||||
HASH: ${{ steps.hash.outputs.hash }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Update version line: ` version = "<anything>";`
|
||||
sed -i -E "s|^([[:space:]]*version[[:space:]]*=[[:space:]]*)\"[^\"]*\";|\1\"${VERSION}\";|" nix/package.nix
|
||||
# Update npmDepsHash line: ` npmDepsHash = "<anything>";`
|
||||
sed -i -E "s|^([[:space:]]*npmDepsHash[[:space:]]*=[[:space:]]*)\"[^\"]*\";|\1\"${HASH}\";|" nix/package.nix
|
||||
|
||||
echo "=== diff ==="
|
||||
git --no-pager diff nix/package.nix || true
|
||||
|
||||
- name: Create PR
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ steps.meta.outputs.version }}
|
||||
HASH: ${{ steps.hash.outputs.hash }}
|
||||
BRANCH: ${{ steps.meta.outputs.branch }}
|
||||
TAG: ${{ steps.meta.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if git diff --quiet -- nix/package.nix; then
|
||||
echo "nix/package.nix already at v${VERSION} with this hash — nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
# Replace any prior bump branch to keep the workflow idempotent.
|
||||
git push origin --delete "$BRANCH" 2>/dev/null || true
|
||||
git checkout -b "$BRANCH"
|
||||
git add nix/package.nix
|
||||
git commit -m "chore: bump nix package to v${VERSION}"
|
||||
git push -u origin "$BRANCH"
|
||||
|
||||
gh pr create \
|
||||
--title "chore: bump nix package to v${VERSION}" \
|
||||
--base main \
|
||||
--head "$BRANCH" \
|
||||
--body "$(cat <<EOF
|
||||
Automated bump triggered by release \`${TAG}\`.
|
||||
|
||||
- \`version\` → \`${VERSION}\`
|
||||
- \`npmDepsHash\` → \`${HASH}\` (computed via \`prefetch-npm-deps package-lock.json\`)
|
||||
|
||||
Merge this so Nix users (NixOS, Home Manager, \`nix run github:siddharthvaddem/openscreen\`) pick up the new release.
|
||||
|
||||
> Note: PRs opened by \`GITHUB_TOKEN\` don't auto-trigger CI. The diff is two lines — review the change here, then merge. If you want CI to run, push an empty commit to this branch or close-and-reopen the PR.
|
||||
EOF
|
||||
)"
|
||||
@@ -31,6 +31,20 @@ jobs:
|
||||
- run: npm ci
|
||||
- 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
|
||||
- run: npm run test:browser:install
|
||||
- run: npm run test:browser
|
||||
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
name: Publish release to WinGet
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [released]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Release tag to publish to winget (e.g. v1.4.0)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: windows-latest
|
||||
if: github.event_name == 'workflow_dispatch' || !github.event.release.prerelease
|
||||
steps:
|
||||
- uses: vedantmgoyal9/winget-releaser@v2
|
||||
with:
|
||||
identifier: SiddharthVaddem.OpenScreen
|
||||
# Match the Windows installer asset attached to each release.
|
||||
# Today: "Openscreen.Setup.latest.exe". Adjust this regex if you
|
||||
# ever rename the installer to include a version (e.g. "Setup\.\d+\.\d+\.\d+\.exe").
|
||||
installers-regex: 'Setup\..*\.exe$'
|
||||
release-tag: ${{ inputs.tag || github.event.release.tag_name }}
|
||||
token: ${{ secrets.WINGET_ACC_TOKEN }}
|
||||
@@ -0,0 +1,168 @@
|
||||
name: Update Homebrew Cask
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Release tag to publish to the tap (e.g. v1.4.0)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
update-cask:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'workflow_dispatch' || !github.event.release.prerelease
|
||||
env:
|
||||
TAP_OWNER: siddharthvaddem
|
||||
TAP_REPO: homebrew-openscreen
|
||||
CASK_NAME: openscreen
|
||||
steps:
|
||||
- name: Resolve tag and version
|
||||
id: meta
|
||||
env:
|
||||
GH_EVENT_TAG: ${{ github.event.release.tag_name }}
|
||||
INPUT_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${GH_EVENT_TAG:-$INPUT_TAG}"
|
||||
if [[ -z "$TAG" ]]; then
|
||||
echo "::error::No tag resolved from release event or workflow input"
|
||||
exit 1
|
||||
fi
|
||||
VERSION="${TAG#v}"
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Find macOS DMG assets
|
||||
id: assets
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ steps.meta.outputs.tag }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
NAMES=$(gh release view "$TAG" --repo "$REPO" --json assets --jq '.assets[].name')
|
||||
|
||||
# arm64 DMG: explicit "arm64" / "apple silicon" / fallback to any .dmg
|
||||
# whose name does NOT contain "x64" or non-mac platform markers.
|
||||
ARM_NAME=$(echo "$NAMES" | grep -iE '\.dmg$' \
|
||||
| grep -iE '(arm64|apple[-_. ]?silicon)' | head -n1 || true)
|
||||
if [[ -z "$ARM_NAME" ]]; then
|
||||
ARM_NAME=$(echo "$NAMES" | grep -iE '\.dmg$' \
|
||||
| grep -iv 'x64' | grep -iv 'linux' | grep -iv 'win' | head -n1 || true)
|
||||
fi
|
||||
|
||||
# x64 DMG
|
||||
X64_NAME=$(echo "$NAMES" | grep -iE '\.dmg$' \
|
||||
| grep -iE '(x64|x86[-_]?64|intel)' | head -n1 || true)
|
||||
|
||||
if [[ -z "$ARM_NAME" || -z "$X64_NAME" ]]; then
|
||||
echo "::error::Could not locate both arm64 and x64 DMGs in release assets"
|
||||
echo "Available assets:"
|
||||
echo "$NAMES"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "arm_name=$ARM_NAME" >> "$GITHUB_OUTPUT"
|
||||
echo "x64_name=$X64_NAME" >> "$GITHUB_OUTPUT"
|
||||
echo "Found arm64 asset: $ARM_NAME"
|
||||
echo "Found x64 asset: $X64_NAME"
|
||||
|
||||
- name: Download DMGs and compute sha256
|
||||
id: shas
|
||||
env:
|
||||
REPO: ${{ github.repository }}
|
||||
TAG: ${{ steps.meta.outputs.tag }}
|
||||
ARM_NAME: ${{ steps.assets.outputs.arm_name }}
|
||||
X64_NAME: ${{ steps.assets.outputs.x64_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BASE="https://github.com/${REPO}/releases/download/${TAG}"
|
||||
curl -fsSL --retry 3 -o /tmp/arm.dmg "${BASE}/${ARM_NAME}"
|
||||
curl -fsSL --retry 3 -o /tmp/x64.dmg "${BASE}/${X64_NAME}"
|
||||
ARM_SHA=$(sha256sum /tmp/arm.dmg | awk '{print $1}')
|
||||
X64_SHA=$(sha256sum /tmp/x64.dmg | awk '{print $1}')
|
||||
echo "arm_sha=$ARM_SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "x64_sha=$X64_SHA" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout tap
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ env.TAP_OWNER }}/${{ env.TAP_REPO }}
|
||||
token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||
path: tap
|
||||
|
||||
- name: Write cask file
|
||||
env:
|
||||
REPO: ${{ github.repository }}
|
||||
TAG: ${{ steps.meta.outputs.tag }}
|
||||
VERSION: ${{ steps.meta.outputs.version }}
|
||||
ARM_NAME: ${{ steps.assets.outputs.arm_name }}
|
||||
X64_NAME: ${{ steps.assets.outputs.x64_name }}
|
||||
ARM_SHA: ${{ steps.shas.outputs.arm_sha }}
|
||||
X64_SHA: ${{ steps.shas.outputs.x64_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p tap/Casks
|
||||
BASE="https://github.com/${REPO}/releases/download/${TAG}"
|
||||
|
||||
# #{version} is Ruby interpolation written literally to the cask
|
||||
# file (bash heredoc leaves "#{...}" alone). \${VERSION}, \${ARM_SHA},
|
||||
# etc. are bash variables expanded by the heredoc. The literal
|
||||
# #{version} fixes Homebrew's "URL is unversioned" audit warning by
|
||||
# making the version string statically detectable.
|
||||
cat > "tap/Casks/${CASK_NAME}.rb" <<EOF
|
||||
cask "${CASK_NAME}" do
|
||||
version "${VERSION}"
|
||||
|
||||
on_arm do
|
||||
sha256 "${ARM_SHA}"
|
||||
|
||||
url "https://github.com/${REPO}/releases/download/v#{version}/${ARM_NAME}"
|
||||
end
|
||||
on_intel do
|
||||
sha256 "${X64_SHA}"
|
||||
|
||||
url "https://github.com/${REPO}/releases/download/v#{version}/${X64_NAME}"
|
||||
end
|
||||
|
||||
name "Openscreen"
|
||||
desc "Screen recorder and video editor"
|
||||
homepage "https://github.com/${REPO}"
|
||||
|
||||
auto_updates false
|
||||
depends_on macos: ">= :big_sur"
|
||||
|
||||
app "Openscreen.app"
|
||||
|
||||
zap trash: [
|
||||
"~/Library/Application Support/Openscreen",
|
||||
"~/Library/Caches/com.siddharthvaddem.openscreen",
|
||||
"~/Library/Logs/Openscreen",
|
||||
"~/Library/Preferences/com.siddharthvaddem.openscreen.plist",
|
||||
"~/Library/Saved Application State/com.siddharthvaddem.openscreen.savedState",
|
||||
]
|
||||
end
|
||||
EOF
|
||||
|
||||
- name: Commit and push to tap
|
||||
working-directory: tap
|
||||
env:
|
||||
VERSION: ${{ steps.meta.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add "Casks/${CASK_NAME}.rb"
|
||||
if git diff --cached --quiet; then
|
||||
echo "Cask already up to date for ${VERSION} — nothing to commit."
|
||||
exit 0
|
||||
fi
|
||||
git commit -m "Bump ${CASK_NAME} to ${VERSION}"
|
||||
git push
|
||||
+18
-1
@@ -12,9 +12,11 @@ dist
|
||||
dist-electron
|
||||
dist-ssr
|
||||
*.local
|
||||
.env
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
.zed/
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
@@ -25,4 +27,19 @@ dist-ssr
|
||||
*.sw?
|
||||
release/**
|
||||
*.kiro/
|
||||
# npx electron-builder --mac --win
|
||||
.claude/
|
||||
# npx electron-builder --mac --win
|
||||
|
||||
# Playwright
|
||||
test-results
|
||||
playwright-report/
|
||||
|
||||
# Vitest browser mode screenshots
|
||||
__screenshots__/
|
||||
|
||||
# shell files
|
||||
/shell.sh
|
||||
# Nix
|
||||
result
|
||||
result-*
|
||||
.direnv/
|
||||
|
||||
@@ -1,42 +1,51 @@
|
||||
> [!WARNING]
|
||||
> This is very much in beta and might be buggy here and there (but hope you have a good experience!).
|
||||
> This started as a side project that took off — it's not production grade and you'll hit bugs, but hopefully it covers what you need.
|
||||
|
||||
<p align="center">
|
||||
<img src="public/openscreen.png" alt="OpenScreen Logo" width="64" />
|
||||
<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">
|
||||
<img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki" />
|
||||
</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 align="center">OpenScreen</p>
|
||||
|
||||
<p align="center"><strong>OpenScreen is your free, open-source alternative to Screen Studio (sort of).</strong></p>
|
||||
|
||||
If you don't want to pay $29/month for Screen Studio but want a much simpler version that does what most people seem to need, making beautiful product demos and walkthroughs, here's a free-to-use app for you. OpenScreen does not offer all Screen Studio features, but covers the basics well!
|
||||
If you don't want to pay $29/month for Screen Studio but want a much simpler version that does what most people seem to need - quick, polished product demos and walkthroughs you'd post on X, Reddit. OpenScreen does not offer all Screen Studio features, but covers the basics well!
|
||||
|
||||
Screen Studio is an awesome product and this is definitely not a 1:1 clone. OpenScreen is a much simpler take, just the basics for folks who want control and don't want to pay. If you need all the fancy features, your best bet is to support Screen Studio (they really do a great job, haha). But if you just want something free (no gotchas) and open, this project does the job!
|
||||
|
||||
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 !)
|
||||
**100% free** for both **personal** and **commercial** use. Use it, modify it, distribute it — just be cool 😁 and shout out the project if you feel like it.
|
||||
|
||||
<p align="center">
|
||||
<img src="public/preview3.png" alt="OpenScreen App Preview 3" style="height: 320px; margin-right: 12px;" />
|
||||
<img src="public/preview4.png" alt="OpenScreen App Preview 4" 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: 0.1678; margin-right: 12px;" />
|
||||
</p>
|
||||
|
||||
## Core Features
|
||||
- Record your whole screen or specific windows.
|
||||
- Add Automatic zooms or manual zooms (customizable depth levels).
|
||||
- Record microphone audio and system audio capture.
|
||||
- Customize the duration and position of zooms however you please.
|
||||
- Crop video recordings to hide parts.
|
||||
- Choose between wallpapers, solid colors, gradients or a custom background.
|
||||
- Motion blur for smoother pan and zoom effects.
|
||||
- Add annotations (text, arrows, images).
|
||||
- Trim sections of the clip.
|
||||
- Customize speed at different segments.
|
||||
- Export in different aspect ratios and resolutions.
|
||||
- Record a specific window, region, or your whole screen.
|
||||
- Record microphone and system audio.
|
||||
- Webcam overlay with picture-in-picture, drag-to-position, and shape options.
|
||||
- Auto or manual zooms with adjustable depth, duration, easing, and pixel-precise position.
|
||||
- Wallpapers, solid colors, gradients, or a custom background.
|
||||
- Motion blur for smoother pan and zoom transitions.
|
||||
- Crop, trim, and per-segment speed control on the timeline.
|
||||
- Blur effects to hide sensitive parts of the screen.
|
||||
- Cursor and click highlighting.
|
||||
- Text, arrow, and image annotations.
|
||||
- Save and reopen projects without re-recording.
|
||||
- Export to MP4 or GIF in multiple aspect ratios and resolutions.
|
||||
- Translated into Arabic, English, Spanish, French, Japanese, Korean, Russian, Turkish, Vietnamese, Simplified Chinese, and Traditional Chinese.
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -44,7 +53,20 @@ Download the latest installer for your platform from the [GitHub Releases](https
|
||||
|
||||
### macOS
|
||||
|
||||
If you encounter issues with macOS Gatekeeper blocking the app (since it does not come with a developer certificate), you can bypass this by running the following command in your terminal after installation:
|
||||
The easiest way to install on macOS is via [Homebrew](https://brew.sh):
|
||||
|
||||
```bash
|
||||
brew install --cask siddharthvaddem/openscreen/openscreen
|
||||
```
|
||||
|
||||
Brew automatically picks the right build for Apple Silicon or Intel, and verifies the download against a notarized signature so Gatekeeper won't block it.
|
||||
|
||||
To update later: `brew upgrade --cask openscreen`
|
||||
To uninstall: `brew uninstall --cask openscreen` (add `--zap` to also remove app data)
|
||||
|
||||
#### Manual install (if you prefer)
|
||||
|
||||
If you'd rather grab the `.dmg` directly from the [Releases page](https://github.com/siddharthvaddem/openscreen/releases) and encounter Gatekeeper blocking the app, you can bypass it by running the following command in your terminal after installation:
|
||||
|
||||
```bash
|
||||
xattr -rd com.apple.quarantine /Applications/Openscreen.app
|
||||
@@ -54,18 +76,72 @@ Note: Give your terminal Full Disk Access in **System Settings > Privacy & Secur
|
||||
|
||||
After running this command, proceed to **System Preferences > Security & Privacy** to grant the necessary permissions for "screen recording" and "accessibility". Once permissions are granted, you can launch the app.
|
||||
|
||||
### Windows
|
||||
|
||||
Install via [winget](https://learn.microsoft.com/en-us/windows/package-manager/winget/):
|
||||
|
||||
```bash
|
||||
winget install SiddharthVaddem.OpenScreen
|
||||
```
|
||||
|
||||
To update later: `winget upgrade SiddharthVaddem.OpenScreen`
|
||||
To uninstall: `winget uninstall SiddharthVaddem.OpenScreen`
|
||||
|
||||
If you'd rather grab the `.exe` installer directly, download it from the [Releases page](https://github.com/siddharthvaddem/openscreen/releases).
|
||||
|
||||
### Linux
|
||||
|
||||
Download the `.AppImage` file from the releases page. Make it executable and run:
|
||||
Three packages are published to the [Releases page](https://github.com/siddharthvaddem/openscreen/releases) for each version. Pick the one that matches your distro:
|
||||
|
||||
**Debian / Ubuntu / Pop!_OS (`.deb`)**
|
||||
```bash
|
||||
sudo apt install ./Openscreen-Linux-latest.deb
|
||||
```
|
||||
|
||||
**Arch / Manjaro (`.pacman`)**
|
||||
```bash
|
||||
sudo pacman -U Openscreen-Linux-latest.pacman
|
||||
```
|
||||
|
||||
**Any distro (`.AppImage`)**
|
||||
```bash
|
||||
chmod +x Openscreen-Linux-*.AppImage
|
||||
./Openscreen-Linux-*.AppImage
|
||||
```
|
||||
|
||||
**NixOS / Nix (flake)**
|
||||
|
||||
Try without installing:
|
||||
```bash
|
||||
nix run github:siddharthvaddem/openscreen
|
||||
```
|
||||
|
||||
Install into your user profile:
|
||||
```bash
|
||||
nix profile install github:siddharthvaddem/openscreen
|
||||
```
|
||||
|
||||
For a NixOS system config (flake):
|
||||
```nix
|
||||
{
|
||||
inputs.openscreen.url = "github:siddharthvaddem/openscreen";
|
||||
|
||||
outputs = { nixpkgs, openscreen, ... }: {
|
||||
nixosConfigurations.<host> = nixpkgs.lib.nixosSystem {
|
||||
modules = [
|
||||
openscreen.nixosModules.default
|
||||
{ programs.openscreen.enable = true; }
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
For Home Manager, use `openscreen.homeManagerModules.default` with the same `programs.openscreen.enable = true;`.
|
||||
|
||||
You may need to grant screen recording permissions depending on your desktop environment.
|
||||
|
||||
**Note:** If the app fails to launch due to a "sandbox" error, run it with --no-sandbox:
|
||||
**Sandbox error:** If the AppImage fails to launch with a "sandbox" error, run it with `--no-sandbox`:
|
||||
```bash
|
||||
./Openscreen-Linux-*.AppImage --no-sandbox
|
||||
```
|
||||
@@ -74,9 +150,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:
|
||||
|
||||
- **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.
|
||||
- **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
|
||||
- Electron
|
||||
@@ -88,11 +164,26 @@ 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 🙏_
|
||||
|
||||
## Documentation
|
||||
|
||||
See the documentation here:
|
||||
[OpenScreen Docs](https://deepwiki.com/siddharthvaddem/openscreen)
|
||||
Refresh if outdated.
|
||||
|
||||
## 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 - please **include screenshots or a short video** for any UI change or new user-facing feature. If it touches what users see or do, show it. Skip only when it genuinely doesn't apply. PRs that don't follow this will be closed.
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
+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 },
|
||||
"files": { "ignoreUnknown": false, "includes": ["**", "!**/*.css"] },
|
||||
"formatter": {
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
# Writing Tests
|
||||
|
||||
This project uses [Vitest](https://vitest.dev/) for both unit/integration tests and browser tests. There are two separate configs — each targets a different set of files.
|
||||
|
||||
## Unit tests
|
||||
|
||||
**Config:** `vitest.config.ts`
|
||||
**Runs in:** jsdom (simulated DOM, no real browser)
|
||||
**File pattern:** `src/**/*.test.ts` — anything that does **not** end in `.browser.test.ts`
|
||||
**CI command:** `npm run test`
|
||||
|
||||
Use unit tests for pure logic, utility functions, data transformations, and anything that doesn't need real browser APIs (Canvas, WebCodecs, MediaRecorder, etc.).
|
||||
|
||||
### File placement
|
||||
|
||||
Co-locate the test file next to the source file, or put it in a `__tests__/` folder in the same directory.
|
||||
|
||||
```
|
||||
src/lib/compositeLayout.ts
|
||||
src/lib/compositeLayout.test.ts # co-located
|
||||
|
||||
src/i18n/__tests__/tutorialHelpTranslations.test.ts # grouped
|
||||
```
|
||||
|
||||
### Example
|
||||
|
||||
```ts
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeCompositeLayout } from "./compositeLayout";
|
||||
|
||||
describe("computeCompositeLayout", () => {
|
||||
it("anchors the overlay in the lower-right corner", () => {
|
||||
const layout = computeCompositeLayout({
|
||||
canvasSize: { width: 1920, height: 1080 },
|
||||
screenSize: { width: 1920, height: 1080 },
|
||||
webcamSize: { width: 1280, height: 720 },
|
||||
});
|
||||
|
||||
expect(layout).not.toBeNull();
|
||||
expect(layout!.webcamRect!.x).toBeGreaterThan(1920 / 2);
|
||||
expect(layout!.webcamRect!.y).toBeGreaterThan(1080 / 2);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Path aliases
|
||||
|
||||
The `@/` alias resolves to `src/`. Use it for imports that would otherwise need long relative paths.
|
||||
|
||||
```ts
|
||||
import { SUPPORTED_LOCALES } from "@/i18n/config";
|
||||
```
|
||||
|
||||
### Running locally
|
||||
|
||||
```bash
|
||||
npm run test # run once
|
||||
npm run test:watch # watch mode
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Browser tests
|
||||
|
||||
**Config:** `vitest.browser.config.ts`
|
||||
**Runs in:** real Chromium via Playwright (headless)
|
||||
**File pattern:** `src/**/*.browser.test.ts`
|
||||
**CI commands:** `npm run test:browser:install` then `npm run test:browser`
|
||||
|
||||
Use browser tests when the code under test depends on real browser APIs that jsdom doesn't implement: `VideoDecoder`, `VideoEncoder`, `MediaRecorder`, `OffscreenCanvas`, `WebGL`, etc.
|
||||
|
||||
### File placement
|
||||
|
||||
Name the file `<subject>.browser.test.ts` and place it next to the source file.
|
||||
|
||||
```
|
||||
src/lib/exporter/videoExporter.ts
|
||||
src/lib/exporter/videoExporter.browser.test.ts
|
||||
```
|
||||
|
||||
### Loading fixture assets
|
||||
|
||||
Static assets (video files, images) live in `tests/fixtures/`. Import them with Vite's `?url` suffix so Vite serves them through the dev server.
|
||||
|
||||
```ts
|
||||
import sampleVideoUrl from "../../../tests/fixtures/sample.webm?url";
|
||||
```
|
||||
|
||||
### Example
|
||||
|
||||
```ts
|
||||
import { describe, expect, it } from "vitest";
|
||||
import sampleVideoUrl from "../../../tests/fixtures/sample.webm?url";
|
||||
import { VideoExporter } from "./videoExporter";
|
||||
|
||||
describe("VideoExporter (real browser)", () => {
|
||||
it("exports a valid MP4 blob from a real video", async () => {
|
||||
const exporter = new VideoExporter({
|
||||
videoUrl: sampleVideoUrl,
|
||||
width: 320,
|
||||
height: 180,
|
||||
frameRate: 15,
|
||||
bitrate: 1_000_000,
|
||||
wallpaper: "#1a1a2e",
|
||||
zoomRegions: [],
|
||||
showShadow: false,
|
||||
shadowIntensity: 0,
|
||||
showBlur: false,
|
||||
cropRegion: { x: 0, y: 0, width: 1, height: 1 },
|
||||
});
|
||||
|
||||
const result = await exporter.export();
|
||||
|
||||
expect(result.success, result.error).toBe(true);
|
||||
expect(result.blob).toBeInstanceOf(Blob);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Timeouts
|
||||
|
||||
Browser tests have a default timeout of 120 seconds per test and 30 seconds per hook (set in `vitest.browser.config.ts`). Export operations are slow — prefer small fixture dimensions (320×180) and low bitrates to keep tests fast.
|
||||
|
||||
### Running locally
|
||||
|
||||
First install the browser (one-time):
|
||||
|
||||
```bash
|
||||
npm run test:browser:install
|
||||
```
|
||||
|
||||
Then run the tests:
|
||||
|
||||
```bash
|
||||
npm run test:browser
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Choosing the right type
|
||||
|
||||
| Situation | Use |
|
||||
|---|---|
|
||||
| Pure function / data transformation | Unit test |
|
||||
| i18n key coverage | Unit test |
|
||||
| React hook logic (no real browser APIs) | Unit test |
|
||||
| `VideoDecoder` / `VideoEncoder` / `MediaRecorder` | Browser test |
|
||||
| `OffscreenCanvas` / WebGL / Pixi.js rendering | Browser test |
|
||||
| File export producing a real `Blob` | Browser test |
|
||||
+22
-2
@@ -3,6 +3,11 @@
|
||||
"$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json",
|
||||
"appId": "com.siddharthvaddem.openscreen",
|
||||
"asar": true,
|
||||
// .node binaries can't be dlopen'd from inside an asar — must live unpacked.
|
||||
"asarUnpack": [
|
||||
"node_modules/uiohook-napi/**/*",
|
||||
"**/*.node"
|
||||
],
|
||||
"productName": "Openscreen",
|
||||
"npmRebuild": true,
|
||||
"buildDependenciesFromSource": true,
|
||||
@@ -20,14 +25,20 @@
|
||||
"!CONTRIBUTING.md",
|
||||
"!LICENSE"
|
||||
],
|
||||
// Asset layout contract: "wallpapers/" under resourcesPath must align with
|
||||
// assetBaseDir in electron/preload.ts (packaged branch).
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "public/wallpapers",
|
||||
"to": "assets/wallpapers"
|
||||
"to": "wallpapers"
|
||||
}
|
||||
],
|
||||
|
||||
"mac": {
|
||||
"notarize": false,
|
||||
"hardenedRuntime": true,
|
||||
"entitlements": "macos.entitlements",
|
||||
"entitlementsInherit": "macos.entitlements",
|
||||
"target": [
|
||||
{
|
||||
"target": "dmg",
|
||||
@@ -38,12 +49,17 @@
|
||||
"artifactName": "${productName}-Mac-${arch}-${version}-Installer.${ext}",
|
||||
"extendInfo": {
|
||||
"NSAudioCaptureUsageDescription": "OpenScreen needs audio capture permission to record system audio.",
|
||||
"NSMicrophoneUsageDescription": "OpenScreen needs microphone access to record voice audio.",
|
||||
"NSCameraUsageDescription": "OpenScreen needs camera access to record webcam video.",
|
||||
"NSScreenCaptureUsageDescription": "OpenScreen needs screen recording permission to detect and capture windows.",
|
||||
"NSCameraUseContinuityCameraDeviceType": true
|
||||
}
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
"AppImage"
|
||||
"AppImage",
|
||||
"deb",
|
||||
"pacman"
|
||||
],
|
||||
"icon": "icons/icons/png",
|
||||
"artifactName": "${productName}-Linux-${version}.${ext}",
|
||||
@@ -54,5 +70,9 @@
|
||||
"nsis"
|
||||
],
|
||||
"icon": "icons/icons/win/icon.ico"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+91
-9
@@ -26,30 +26,98 @@ interface Window {
|
||||
electronAPI: {
|
||||
getSources: (opts: Electron.SourcesOptions) => Promise<ProcessedDesktopSource[]>;
|
||||
switchToEditor: () => Promise<void>;
|
||||
switchToHud: () => Promise<void>;
|
||||
startNewRecording: () => Promise<{ success: boolean; error?: string }>;
|
||||
openSourceSelector: () => Promise<void>;
|
||||
selectSource: (source: any) => Promise<any>;
|
||||
getSelectedSource: () => Promise<any>;
|
||||
selectSource: (source: ProcessedDesktopSource) => Promise<ProcessedDesktopSource | null>;
|
||||
getSelectedSource: () => Promise<ProcessedDesktopSource | null>;
|
||||
requestCameraAccess: () => Promise<{
|
||||
success: boolean;
|
||||
granted: boolean;
|
||||
status: string;
|
||||
error?: string;
|
||||
}>;
|
||||
requestAccessibilityAccess: () => Promise<{
|
||||
success: boolean;
|
||||
granted: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
assetBaseUrl: string;
|
||||
storeRecordedVideo: (
|
||||
videoData: ArrayBuffer,
|
||||
fileName: string,
|
||||
) => Promise<{ success: boolean; path?: string; message?: string }>;
|
||||
getRecordedVideoPath: () => Promise<{ success: boolean; path?: string; message?: string }>;
|
||||
setRecordingState: (recording: boolean) => Promise<void>;
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
path?: string;
|
||||
session?: import("../src/lib/recordingSession").RecordingSession;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
storeRecordedSession: (
|
||||
payload: import("../src/lib/recordingSession").StoreRecordedSessionInput,
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
path?: string;
|
||||
session?: import("../src/lib/recordingSession").RecordingSession;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
getRecordedVideoPath: () => Promise<{
|
||||
success: boolean;
|
||||
path?: string;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
setRecordingState: (recording: boolean, recordingId?: number) => Promise<void>;
|
||||
discardCursorTelemetry: (recordingId: number) => Promise<void>;
|
||||
getCursorTelemetry: (videoPath?: string) => Promise<{
|
||||
success: boolean;
|
||||
samples: CursorTelemetryPoint[];
|
||||
clicks: number[];
|
||||
message?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
onStopRecordingFromTray: (callback: () => void) => () => void;
|
||||
openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }>;
|
||||
saveExportedVideo: (
|
||||
videoData: ArrayBuffer,
|
||||
pickExportSavePath: (
|
||||
fileName: string,
|
||||
) => Promise<{ success: boolean; path?: string; message?: string; canceled?: boolean }>;
|
||||
exportFolder?: string,
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
path?: string;
|
||||
message?: string;
|
||||
canceled?: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
writeExportToPath: (
|
||||
videoData: ArrayBuffer,
|
||||
filePath: string,
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
path?: string;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>;
|
||||
setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>;
|
||||
setCurrentRecordingSession: (
|
||||
session: import("../src/lib/recordingSession").RecordingSession | null,
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
session?: import("../src/lib/recordingSession").RecordingSession;
|
||||
}>;
|
||||
getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }>;
|
||||
getCurrentRecordingSession: () => Promise<{
|
||||
success: boolean;
|
||||
session?: import("../src/lib/recordingSession").RecordingSession;
|
||||
}>;
|
||||
readBinaryFile: (filePath: string) => Promise<{
|
||||
success: boolean;
|
||||
data?: ArrayBuffer;
|
||||
path?: string;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
clearCurrentVideoPath: () => Promise<{ success: boolean }>;
|
||||
saveProjectFile: (
|
||||
projectData: unknown,
|
||||
@@ -89,9 +157,23 @@ interface Window {
|
||||
saveShortcuts: (shortcuts: unknown) => Promise<{ success: boolean; error?: string }>;
|
||||
hudOverlayHide: () => void;
|
||||
hudOverlayClose: () => void;
|
||||
setHudOverlayIgnoreMouseEvents: (ignore: boolean) => 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;
|
||||
setHasUnsavedChanges: (hasChanges: boolean) => void;
|
||||
onRequestSaveBeforeClose: (callback: () => Promise<void>) => () => void;
|
||||
onRequestSaveBeforeClose: (callback: () => Promise<boolean> | boolean) => () => void;
|
||||
onRequestCloseConfirm: (callback: () => void) => () => void;
|
||||
sendCloseConfirmResponse: (choice: "save" | "discard" | "cancel") => void;
|
||||
setLocale: (locale: string) => Promise<void>;
|
||||
saveDiagnostic: (payload: {
|
||||
error: string;
|
||||
stack?: string;
|
||||
projectState: unknown;
|
||||
logs: string[];
|
||||
}) => Promise<{ success: boolean; path?: string; canceled?: boolean; error?: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// 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 commonVi from "../src/i18n/locales/vi/common.json";
|
||||
import dialogsVi from "../src/i18n/locales/vi/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" | "vi";
|
||||
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 },
|
||||
vi: { common: commonVi, dialogs: dialogsVi },
|
||||
};
|
||||
|
||||
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" ||
|
||||
locale === "vi"
|
||||
) {
|
||||
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);
|
||||
}
|
||||
+1009
-130
File diff suppressed because it is too large
Load Diff
+298
-76
@@ -1,9 +1,25 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, Tray } from "electron";
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
desktopCapturer,
|
||||
ipcMain,
|
||||
Menu,
|
||||
nativeImage,
|
||||
session,
|
||||
systemPreferences,
|
||||
Tray,
|
||||
} from "electron";
|
||||
import { mainT, setMainLocale } from "./i18n";
|
||||
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));
|
||||
|
||||
@@ -14,6 +30,18 @@ if (process.platform === "darwin") {
|
||||
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");
|
||||
|
||||
async function ensureRecordingsDir() {
|
||||
@@ -49,17 +77,33 @@ process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL
|
||||
// Window references
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let sourceSelectorWindow: BrowserWindow | null = null;
|
||||
let countdownOverlayWindow: BrowserWindow | null = null;
|
||||
let tray: Tray | null = null;
|
||||
let selectedSourceName = "";
|
||||
const isMac = process.platform === "darwin";
|
||||
const trayIconSize = isMac ? 16 : 24;
|
||||
|
||||
// Tray Icons
|
||||
const defaultTrayIcon = getTrayIcon("openscreen.png");
|
||||
const recordingTrayIcon = getTrayIcon("rec-button.png");
|
||||
const defaultTrayIcon = getTrayIcon("openscreen.png", trayIconSize);
|
||||
const recordingTrayIcon = getTrayIcon("rec-button.png", trayIconSize);
|
||||
|
||||
function createWindow() {
|
||||
mainWindow = createHudOverlayWindow();
|
||||
}
|
||||
|
||||
function showMainWindow() {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
if (mainWindow.isMinimized()) {
|
||||
mainWindow.restore();
|
||||
}
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
createWindow();
|
||||
}
|
||||
|
||||
function isEditorWindow(window: BrowserWindow) {
|
||||
return window.webContents.getURL().includes("windowType=editor");
|
||||
}
|
||||
@@ -92,72 +136,136 @@ function setupApplicationMenu() {
|
||||
template.push({
|
||||
label: app.name,
|
||||
submenu: [
|
||||
{ role: "about" },
|
||||
{
|
||||
role: "about",
|
||||
label: mainT("common", "actions.about") || "About OpenScreen",
|
||||
},
|
||||
{ type: "separator" },
|
||||
{ role: "services" },
|
||||
{
|
||||
role: "services",
|
||||
label: mainT("common", "actions.services") || "Services",
|
||||
},
|
||||
{ type: "separator" },
|
||||
{ role: "hide" },
|
||||
{ role: "hideOthers" },
|
||||
{ role: "unhide" },
|
||||
{
|
||||
role: "hide",
|
||||
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" },
|
||||
{ role: "quit" },
|
||||
{ role: "quit", label: mainT("common", "actions.quit") || "Quit" },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
template.push(
|
||||
{
|
||||
label: "File",
|
||||
label: mainT("common", "actions.file") || "File",
|
||||
submenu: [
|
||||
{
|
||||
label: "Load Project…",
|
||||
label: mainT("dialogs", "unsavedChanges.loadProject") || "Load Project…",
|
||||
accelerator: "CmdOrCtrl+O",
|
||||
click: () => sendEditorMenuAction("menu-load-project"),
|
||||
},
|
||||
{
|
||||
label: "Save Project…",
|
||||
label: mainT("dialogs", "unsavedChanges.saveProject") || "Save Project…",
|
||||
accelerator: "CmdOrCtrl+S",
|
||||
click: () => sendEditorMenuAction("menu-save-project"),
|
||||
},
|
||||
{
|
||||
label: "Save Project As…",
|
||||
label: mainT("dialogs", "unsavedChanges.saveProjectAs") || "Save Project As…",
|
||||
accelerator: "CmdOrCtrl+Shift+S",
|
||||
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: [
|
||||
{ role: "undo" },
|
||||
{ role: "redo" },
|
||||
{ role: "undo", label: mainT("common", "actions.undo") || "Undo" },
|
||||
{ role: "redo", label: mainT("common", "actions.redo") || "Redo" },
|
||||
{ type: "separator" },
|
||||
{ role: "cut" },
|
||||
{ role: "copy" },
|
||||
{ role: "paste" },
|
||||
{ role: "selectAll" },
|
||||
{ role: "cut", label: mainT("common", "actions.cut") || "Cut" },
|
||||
{ role: "copy", label: mainT("common", "actions.copy") || "Copy" },
|
||||
{ role: "paste", label: mainT("common", "actions.paste") || "Paste" },
|
||||
{
|
||||
role: "selectAll",
|
||||
label: mainT("common", "actions.selectAll") || "Select All",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "View",
|
||||
label: mainT("common", "actions.view") || "View",
|
||||
submenu: [
|
||||
{ role: "reload" },
|
||||
{ role: "forceReload" },
|
||||
{ role: "toggleDevTools" },
|
||||
{
|
||||
role: "reload",
|
||||
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" },
|
||||
{ role: "resetZoom" },
|
||||
{ role: "zoomIn" },
|
||||
{ role: "zoomOut" },
|
||||
{
|
||||
role: "resetZoom",
|
||||
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" },
|
||||
{ role: "togglefullscreen" },
|
||||
{
|
||||
role: "togglefullscreen",
|
||||
label: mainT("common", "actions.toggleFullScreen") || "Toggle Full Screen",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Window",
|
||||
label: mainT("common", "actions.window") || "Window",
|
||||
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",
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -167,14 +275,20 @@ function setupApplicationMenu() {
|
||||
|
||||
function createTray() {
|
||||
tray = new Tray(defaultTrayIcon);
|
||||
tray.on("click", () => {
|
||||
showMainWindow();
|
||||
});
|
||||
tray.on("double-click", () => {
|
||||
showMainWindow();
|
||||
});
|
||||
}
|
||||
|
||||
function getTrayIcon(filename: string) {
|
||||
function getTrayIcon(filename: string, size: number) {
|
||||
return nativeImage
|
||||
.createFromPath(path.join(process.env.VITE_PUBLIC || RENDERER_DIST, filename))
|
||||
.resize({
|
||||
width: 24,
|
||||
height: 24,
|
||||
width: size,
|
||||
height: size,
|
||||
quality: "best",
|
||||
});
|
||||
}
|
||||
@@ -182,11 +296,15 @@ function getTrayIcon(filename: string) {
|
||||
function updateTrayMenu(recording: boolean = false) {
|
||||
if (!tray) return;
|
||||
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
|
||||
? [
|
||||
{
|
||||
label: "Stop Recording",
|
||||
label: mainT("common", "actions.stopRecording") || "Stop Recording",
|
||||
click: () => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send("stop-recording-from-tray");
|
||||
@@ -196,17 +314,13 @@ function updateTrayMenu(recording: boolean = false) {
|
||||
]
|
||||
: [
|
||||
{
|
||||
label: "Open",
|
||||
label: mainT("common", "actions.open") || "Open",
|
||||
click: () => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.isMinimized() && mainWindow.restore();
|
||||
} else {
|
||||
createWindow();
|
||||
}
|
||||
showMainWindow();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Quit",
|
||||
label: mainT("common", "actions.quit") || "Quit",
|
||||
click: () => {
|
||||
app.quit();
|
||||
},
|
||||
@@ -219,11 +333,27 @@ function updateTrayMenu(recording: boolean = false) {
|
||||
|
||||
let editorHasUnsavedChanges = false;
|
||||
let isForceClosing = false;
|
||||
let isCloseConfirmInFlight = false;
|
||||
|
||||
ipcMain.on("set-has-unsaved-changes", (_, hasChanges: boolean) => {
|
||||
editorHasUnsavedChanges = hasChanges;
|
||||
});
|
||||
|
||||
function forceCloseEditorWindow(windowToClose: BrowserWindow | null) {
|
||||
if (!windowToClose || windowToClose.isDestroyed()) return;
|
||||
|
||||
isForceClosing = true;
|
||||
setImmediate(() => {
|
||||
try {
|
||||
if (!windowToClose.isDestroyed()) {
|
||||
windowToClose.close();
|
||||
}
|
||||
} finally {
|
||||
isForceClosing = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function createEditorWindowWrapper() {
|
||||
if (mainWindow) {
|
||||
isForceClosing = true;
|
||||
@@ -235,35 +365,35 @@ function createEditorWindowWrapper() {
|
||||
editorHasUnsavedChanges = false;
|
||||
|
||||
mainWindow.on("close", (event) => {
|
||||
if (isForceClosing || !editorHasUnsavedChanges) return;
|
||||
if (isForceClosing || !editorHasUnsavedChanges || isCloseConfirmInFlight) return;
|
||||
|
||||
event.preventDefault();
|
||||
isCloseConfirmInFlight = true;
|
||||
|
||||
const choice = dialog.showMessageBoxSync(mainWindow!, {
|
||||
type: "warning",
|
||||
buttons: ["Save & Close", "Discard & Close", "Cancel"],
|
||||
defaultId: 0,
|
||||
cancelId: 2,
|
||||
title: "Unsaved Changes",
|
||||
message: "You have unsaved changes.",
|
||||
detail: "Do you want to save your project before closing?",
|
||||
const windowToClose = mainWindow;
|
||||
if (!windowToClose || windowToClose.isDestroyed()) return;
|
||||
|
||||
// Ask renderer to show the custom in-app dialog
|
||||
windowToClose.webContents.send("request-close-confirm");
|
||||
|
||||
ipcMain.once("close-confirm-response", (event, choice: "save" | "discard" | "cancel") => {
|
||||
if (event.sender.id !== windowToClose?.webContents.id) return;
|
||||
isCloseConfirmInFlight = false;
|
||||
if (!windowToClose || windowToClose.isDestroyed()) return;
|
||||
|
||||
if (choice === "save") {
|
||||
// Tell renderer to save the project, then close when done
|
||||
windowToClose.webContents.send("request-save-before-close");
|
||||
ipcMain.once("save-before-close-done", (event, shouldClose: boolean) => {
|
||||
if (event.sender.id !== windowToClose?.webContents.id) return;
|
||||
if (!shouldClose) return;
|
||||
forceCloseEditorWindow(windowToClose);
|
||||
});
|
||||
} else if (choice === "discard") {
|
||||
forceCloseEditorWindow(windowToClose);
|
||||
}
|
||||
// "cancel": flag reset, window stays open
|
||||
});
|
||||
|
||||
if (choice === 0) {
|
||||
// Save & Close — tell renderer to save, then close
|
||||
mainWindow!.webContents.send("request-save-before-close");
|
||||
ipcMain.once("save-before-close-done", () => {
|
||||
isForceClosing = true;
|
||||
mainWindow?.close();
|
||||
isForceClosing = false;
|
||||
});
|
||||
} else if (choice === 1) {
|
||||
// Discard & Close
|
||||
isForceClosing = true;
|
||||
mainWindow?.close();
|
||||
isForceClosing = false;
|
||||
}
|
||||
// choice === 2: Cancel — do nothing, window stays open
|
||||
});
|
||||
}
|
||||
|
||||
@@ -275,45 +405,137 @@ function createSourceSelectorWindowWrapper() {
|
||||
return sourceSelectorWindow;
|
||||
}
|
||||
|
||||
// On macOS, applications and their menu bar stay active until the user quits
|
||||
// explicitly with Cmd + Q.
|
||||
function createCountdownOverlayWindowWrapper() {
|
||||
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", () => {
|
||||
// Keep app running (macOS behavior)
|
||||
app.quit();
|
||||
});
|
||||
|
||||
app.on("activate", () => {
|
||||
// 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.
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
const hasVisibleWindow = BrowserWindow.getAllWindows().some((window) => {
|
||||
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
|
||||
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/screen permission checks
|
||||
session.defaultSession.setPermissionCheckHandler((_webContents, permission) => {
|
||||
const allowed = [
|
||||
"media",
|
||||
"audioCapture",
|
||||
"microphone",
|
||||
"videoCapture",
|
||||
"camera",
|
||||
"screen",
|
||||
"display-capture",
|
||||
];
|
||||
return allowed.includes(permission);
|
||||
});
|
||||
|
||||
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => {
|
||||
const allowed = [
|
||||
"media",
|
||||
"audioCapture",
|
||||
"microphone",
|
||||
"videoCapture",
|
||||
"camera",
|
||||
"screen",
|
||||
"display-capture",
|
||||
];
|
||||
callback(allowed.includes(permission));
|
||||
});
|
||||
|
||||
// Request microphone and screen recording permissions from macOS
|
||||
if (process.platform === "darwin") {
|
||||
const micStatus = systemPreferences.getMediaAccessStatus("microphone");
|
||||
if (micStatus !== "granted") {
|
||||
await systemPreferences.askForMediaAccess("microphone");
|
||||
}
|
||||
|
||||
// Screen recording has no askForMediaAccess equivalent — the TCC prompt is
|
||||
// triggered by the first desktopCapturer.getSources() call. Firing it here
|
||||
// at startup settles the permission state early and prevents repeated prompts
|
||||
// driven by later getSources() calls (fixes repeated permission dialog).
|
||||
const screenStatus = systemPreferences.getMediaAccessStatus("screen");
|
||||
if (screenStatus === "not-determined") {
|
||||
desktopCapturer.getSources({ types: ["screen"] }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for HUD overlay quit event (macOS only)
|
||||
ipcMain.on("hud-overlay-close", () => {
|
||||
app.quit();
|
||||
});
|
||||
ipcMain.handle("set-locale", (_, locale: string) => {
|
||||
setMainLocale(locale);
|
||||
setupApplicationMenu();
|
||||
updateTrayMenu();
|
||||
});
|
||||
|
||||
createTray();
|
||||
updateTrayMenu();
|
||||
setupApplicationMenu();
|
||||
// Ensure recordings directory exists
|
||||
await ensureRecordingsDir();
|
||||
|
||||
function switchToHudWrapper() {
|
||||
if (mainWindow) {
|
||||
isForceClosing = true;
|
||||
mainWindow.close();
|
||||
isForceClosing = false;
|
||||
mainWindow = null;
|
||||
}
|
||||
showMainWindow();
|
||||
}
|
||||
|
||||
registerIpcHandlers(
|
||||
createEditorWindowWrapper,
|
||||
createSourceSelectorWindowWrapper,
|
||||
createCountdownOverlayWindowWrapper,
|
||||
() => mainWindow,
|
||||
() => sourceSelectorWindow,
|
||||
() => countdownOverlayWindow,
|
||||
(recording: boolean, sourceName: string) => {
|
||||
selectedSourceName = sourceName;
|
||||
if (!tray) createTray();
|
||||
updateTrayMenu(recording);
|
||||
if (!recording) {
|
||||
if (mainWindow) mainWindow.restore();
|
||||
showMainWindow();
|
||||
}
|
||||
},
|
||||
switchToHudWrapper,
|
||||
);
|
||||
createWindow();
|
||||
});
|
||||
|
||||
+86
-11
@@ -1,15 +1,23 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
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", {
|
||||
assetBaseUrl,
|
||||
hudOverlayHide: () => {
|
||||
ipcRenderer.send("hud-overlay-hide");
|
||||
},
|
||||
hudOverlayClose: () => {
|
||||
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");
|
||||
setHudOverlayIgnoreMouseEvents: (ignore: boolean) => {
|
||||
ipcRenderer.send("hud-overlay-ignore-mouse-events", ignore);
|
||||
},
|
||||
getSources: async (opts: Electron.SourcesOptions) => {
|
||||
return await ipcRenderer.invoke("get-sources", opts);
|
||||
@@ -17,29 +25,47 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
switchToEditor: () => {
|
||||
return ipcRenderer.invoke("switch-to-editor");
|
||||
},
|
||||
switchToHud: () => {
|
||||
return ipcRenderer.invoke("switch-to-hud");
|
||||
},
|
||||
startNewRecording: () => {
|
||||
return ipcRenderer.invoke("start-new-recording");
|
||||
},
|
||||
openSourceSelector: () => {
|
||||
return ipcRenderer.invoke("open-source-selector");
|
||||
},
|
||||
selectSource: (source: any) => {
|
||||
selectSource: (source: ProcessedDesktopSource) => {
|
||||
return ipcRenderer.invoke("select-source", source);
|
||||
},
|
||||
getSelectedSource: () => {
|
||||
return ipcRenderer.invoke("get-selected-source");
|
||||
},
|
||||
requestCameraAccess: () => {
|
||||
return ipcRenderer.invoke("request-camera-access");
|
||||
},
|
||||
requestAccessibilityAccess: () => {
|
||||
return ipcRenderer.invoke("request-accessibility-access");
|
||||
},
|
||||
|
||||
storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => {
|
||||
return ipcRenderer.invoke("store-recorded-video", videoData, fileName);
|
||||
},
|
||||
storeRecordedSession: (payload: StoreRecordedSessionInput) => {
|
||||
return ipcRenderer.invoke("store-recorded-session", payload);
|
||||
},
|
||||
|
||||
getRecordedVideoPath: () => {
|
||||
return ipcRenderer.invoke("get-recorded-video-path");
|
||||
},
|
||||
setRecordingState: (recording: boolean) => {
|
||||
return ipcRenderer.invoke("set-recording-state", recording);
|
||||
setRecordingState: (recording: boolean, recordingId?: number) => {
|
||||
return ipcRenderer.invoke("set-recording-state", recording, recordingId);
|
||||
},
|
||||
getCursorTelemetry: (videoPath?: string) => {
|
||||
return ipcRenderer.invoke("get-cursor-telemetry", videoPath);
|
||||
},
|
||||
discardCursorTelemetry: (recordingId: number) => {
|
||||
return ipcRenderer.invoke("discard-cursor-telemetry", recordingId);
|
||||
},
|
||||
onStopRecordingFromTray: (callback: () => void) => {
|
||||
const listener = () => callback();
|
||||
ipcRenderer.on("stop-recording-from-tray", listener);
|
||||
@@ -48,8 +74,11 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
openExternalUrl: (url: string) => {
|
||||
return ipcRenderer.invoke("open-external-url", url);
|
||||
},
|
||||
saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => {
|
||||
return ipcRenderer.invoke("save-exported-video", videoData, fileName);
|
||||
pickExportSavePath: (fileName: string, exportFolder?: string) => {
|
||||
return ipcRenderer.invoke("pick-export-save-path", fileName, exportFolder);
|
||||
},
|
||||
writeExportToPath: (videoData: ArrayBuffer, filePath: string) => {
|
||||
return ipcRenderer.invoke("write-export-to-path", videoData, filePath);
|
||||
},
|
||||
openVideoFilePicker: () => {
|
||||
return ipcRenderer.invoke("open-video-file-picker");
|
||||
@@ -57,9 +86,18 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
setCurrentVideoPath: (path: string) => {
|
||||
return ipcRenderer.invoke("set-current-video-path", path);
|
||||
},
|
||||
setCurrentRecordingSession: (session: RecordingSession | null) => {
|
||||
return ipcRenderer.invoke("set-current-recording-session", session);
|
||||
},
|
||||
getCurrentVideoPath: () => {
|
||||
return ipcRenderer.invoke("get-current-video-path");
|
||||
},
|
||||
getCurrentRecordingSession: () => {
|
||||
return ipcRenderer.invoke("get-current-recording-session");
|
||||
},
|
||||
readBinaryFile: (filePath: string) => {
|
||||
return ipcRenderer.invoke("read-binary-file", filePath);
|
||||
},
|
||||
clearCurrentVideoPath: () => {
|
||||
return ipcRenderer.invoke("clear-current-video-path");
|
||||
},
|
||||
@@ -99,18 +137,55 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
saveShortcuts: (shortcuts: unknown) => {
|
||||
return ipcRenderer.invoke("save-shortcuts", shortcuts);
|
||||
},
|
||||
setLocale: (locale: string) => {
|
||||
return ipcRenderer.invoke("set-locale", locale);
|
||||
},
|
||||
saveDiagnostic: (payload: {
|
||||
error: string;
|
||||
stack?: string;
|
||||
projectState: unknown;
|
||||
logs: string[];
|
||||
}) => {
|
||||
return ipcRenderer.invoke("save-diagnostic", payload);
|
||||
},
|
||||
setMicrophoneExpanded: (expanded: boolean) => {
|
||||
ipcRenderer.send("hud:setMicrophoneExpanded", expanded);
|
||||
},
|
||||
setHasUnsavedChanges: (hasChanges: boolean) => {
|
||||
ipcRenderer.send("set-has-unsaved-changes", hasChanges);
|
||||
},
|
||||
onRequestSaveBeforeClose: (callback: () => Promise<void>) => {
|
||||
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) => {
|
||||
const listener = async () => {
|
||||
await callback();
|
||||
ipcRenderer.send("save-before-close-done");
|
||||
try {
|
||||
const shouldClose = await callback();
|
||||
ipcRenderer.send("save-before-close-done", shouldClose);
|
||||
} catch {
|
||||
ipcRenderer.send("save-before-close-done", false);
|
||||
}
|
||||
};
|
||||
ipcRenderer.on("request-save-before-close", listener);
|
||||
return () => ipcRenderer.removeListener("request-save-before-close", listener);
|
||||
},
|
||||
onRequestCloseConfirm: (callback: () => void) => {
|
||||
const listener = () => callback();
|
||||
ipcRenderer.on("request-close-confirm", listener);
|
||||
return () => ipcRenderer.removeListener("request-close-confirm", listener);
|
||||
},
|
||||
sendCloseConfirmResponse: (choice: "save" | "discard" | "cancel") => {
|
||||
ipcRenderer.send("close-confirm-response", choice);
|
||||
},
|
||||
});
|
||||
|
||||
+105
-7
@@ -1,5 +1,5 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { BrowserWindow, ipcMain, screen } from "electron";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
@@ -7,6 +7,14 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const APP_ROOT = path.join(__dirname, "..");
|
||||
const VITE_DEV_SERVER_URL = process.env["VITE_DEV_SERVER_URL"];
|
||||
const RENDERER_DIST = path.join(APP_ROOT, "dist");
|
||||
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;
|
||||
|
||||
@@ -16,12 +24,23 @@ ipcMain.on("hud-overlay-hide", () => {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on("hud-overlay-ignore-mouse-events", (_event, ignore: boolean) => {
|
||||
if (hudOverlayWindow && !hudOverlayWindow.isDestroyed()) {
|
||||
hudOverlayWindow.setIgnoreMouseEvents(ignore, { forward: true });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
const primaryDisplay = screen.getPrimaryDisplay();
|
||||
const { workArea } = primaryDisplay;
|
||||
|
||||
const windowWidth = 500;
|
||||
const windowHeight = 155;
|
||||
const windowWidth = 600;
|
||||
const windowHeight = 160;
|
||||
|
||||
const x = Math.floor(workArea.x + (workArea.width - windowWidth) / 2);
|
||||
const y = Math.floor(workArea.y + workArea.height - windowHeight - 5);
|
||||
@@ -29,10 +48,10 @@ export function createHudOverlayWindow(): BrowserWindow {
|
||||
const win = new BrowserWindow({
|
||||
width: windowWidth,
|
||||
height: windowHeight,
|
||||
minWidth: 500,
|
||||
maxWidth: 500,
|
||||
minHeight: 155,
|
||||
maxHeight: 155,
|
||||
minWidth: 600,
|
||||
maxWidth: 600,
|
||||
minHeight: 160,
|
||||
maxHeight: 160,
|
||||
x: x,
|
||||
y: y,
|
||||
frame: false,
|
||||
@@ -41,13 +60,22 @@ export function createHudOverlayWindow(): BrowserWindow {
|
||||
alwaysOnTop: true,
|
||||
skipTaskbar: true,
|
||||
hasShadow: false,
|
||||
show: !HEADLESS,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "preload.mjs"),
|
||||
additionalArguments: [ASSET_BASE_URL_ARG],
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
backgroundThrottling: false,
|
||||
},
|
||||
});
|
||||
win.setIgnoreMouseEvents(true, { forward: true });
|
||||
|
||||
// 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.send("main-process-message", new Date().toLocaleString());
|
||||
@@ -72,6 +100,10 @@ export function createHudOverlayWindow(): BrowserWindow {
|
||||
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 {
|
||||
const isMac = process.platform === "darwin";
|
||||
|
||||
@@ -90,8 +122,10 @@ export function createEditorWindow(): BrowserWindow {
|
||||
skipTaskbar: false,
|
||||
title: "OpenScreen",
|
||||
backgroundColor: "#000000",
|
||||
show: !HEADLESS,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "preload.mjs"),
|
||||
additionalArguments: [ASSET_BASE_URL_ARG],
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
webSecurity: false,
|
||||
@@ -117,6 +151,10 @@ export function createEditorWindow(): BrowserWindow {
|
||||
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 {
|
||||
const { width, height } = screen.getPrimaryDisplay().workAreaSize;
|
||||
|
||||
@@ -134,11 +172,18 @@ export function createSourceSelectorWindow(): BrowserWindow {
|
||||
backgroundColor: "#00000000",
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "preload.mjs"),
|
||||
additionalArguments: [ASSET_BASE_URL_ARG],
|
||||
nodeIntegration: false,
|
||||
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) {
|
||||
win.loadURL(VITE_DEV_SERVER_URL + "?windowType=source-selector");
|
||||
} else {
|
||||
@@ -149,3 +194,56 @@ export function createSourceSelectorWindow(): BrowserWindow {
|
||||
|
||||
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,29 @@
|
||||
<?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/>
|
||||
|
||||
<!-- Screen recording (required for desktopCapturer.getSources() on macOS 10.15+) -->
|
||||
<key>com.apple.security.device.screen-capture</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-tOpoJPzaZDK3HJijGHpZ0+jWsbrYyQUuw1pO0Uxcifg=";
|
||||
|
||||
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
+2746
-6200
File diff suppressed because it is too large
Load Diff
+66
-40
@@ -1,21 +1,37 @@
|
||||
{
|
||||
"name": "openscreen",
|
||||
"private": true,
|
||||
"version": "1.2.0",
|
||||
"version": "1.4.0",
|
||||
"type": "module",
|
||||
"packageManager": "npm@10.9.4",
|
||||
"engines": {
|
||||
"node": "22.22.1",
|
||||
"npm": "10.9.4"
|
||||
},
|
||||
"author": {
|
||||
"name": "Sid",
|
||||
"email": "svaddem@asu.edu"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build && electron-builder",
|
||||
"lint": "biome check .",
|
||||
"lint:fix": "biome check --write .",
|
||||
"format": "biome format --write .",
|
||||
"i18n:check": "node scripts/i18n-check.mjs",
|
||||
"preview": "vite preview",
|
||||
"build:mac": "tsc && vite build && electron-builder --mac",
|
||||
"build:win": "tsc && vite build && electron-builder --win",
|
||||
"build:linux": "tsc && vite build && electron-builder --linux",
|
||||
"build:win": "tsc && vite build && electron-builder --win --config.npmRebuild=false",
|
||||
"build:linux": "tsc && vite build && electron-builder --linux AppImage deb pacman --config.npmRebuild=false",
|
||||
"test": "vitest --run",
|
||||
"test:watch": "vitest",
|
||||
"prepare": "husky"
|
||||
"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",
|
||||
"prepare": "husky",
|
||||
"rebuild:native": "node ./scripts/rebuild-native.mjs",
|
||||
"postinstall": "npm run rebuild:native"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fix-webm-duration/fix": "^1.0.1",
|
||||
@@ -26,60 +42,70 @@
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.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-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@types/gif.js": "^0.2.5",
|
||||
"@uiw/color-convert": "^2.9.2",
|
||||
"@uiw/react-color-block": "^2.9.2",
|
||||
"@uiw/color-convert": "^2.10.1",
|
||||
"@uiw/react-color-block": "^2.10.1",
|
||||
"@uiw/react-color-colorful": "^2.9.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dnd-timeline": "^2.2.0",
|
||||
"emoji-picker-react": "^4.16.1",
|
||||
"dnd-timeline": "^2.4.0",
|
||||
"emoji-picker-react": "^4.18.0",
|
||||
"fix-webm-duration": "^1.0.6",
|
||||
"gif.js": "^0.2.0",
|
||||
"gsap": "^3.13.0",
|
||||
"gsap": "^3.15.0",
|
||||
"lucide-react": "^0.545.0",
|
||||
"mediabunny": "^1.25.1",
|
||||
"motion": "^12.23.24",
|
||||
"mp4box": "^2.2.0",
|
||||
"pixi.js": "^8.14.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"mediabunny": "^1.40.1",
|
||||
"motion": "^12.38.0",
|
||||
"mp4box": "^2.3.0",
|
||||
"pixi-filters": "^6.1.5",
|
||||
"pixi.js": "^8.18.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-icons": "^5.6.0",
|
||||
"react-resizable-panels": "^3.0.6",
|
||||
"react-rnd": "^10.5.2",
|
||||
"react-rnd": "^10.5.3",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"uiohook-napi": "^1.5.5",
|
||||
"uuid": "^13.0.0",
|
||||
"web-demuxer": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.3.13",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/react": "^18.2.64",
|
||||
"@types/react-dom": "^18.2.21",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"electron": "^39.2.7",
|
||||
"electron-builder": "^26.7.0",
|
||||
"electron-icon-builder": "^2.0.1",
|
||||
"electron-rebuild": "^3.2.9",
|
||||
"fast-check": "^4.5.2",
|
||||
"@biomejs/biome": "^2.4.12",
|
||||
"@electron/rebuild": "^4.0.4",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^22.19.17",
|
||||
"@types/react": "^18.3.28",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"@vitest/browser": "^4.1.4",
|
||||
"@vitest/browser-playwright": "^4.1.4",
|
||||
"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",
|
||||
"lint-staged": "^16.3.2",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.18",
|
||||
"terser": "^5.44.1",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.1.6",
|
||||
"vite-plugin-electron": "^0.28.6",
|
||||
"vite-plugin-electron-renderer": "^0.14.5",
|
||||
"vitest": "^4.0.16"
|
||||
"jsdom": "^29.0.2",
|
||||
"lint-staged": "^16.4.0",
|
||||
"postcss": "^8.5.10",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"terser": "^5.46.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.2",
|
||||
"vite-plugin-electron": "^0.29.1",
|
||||
"vite-plugin-electron-renderer": "^0.14.6",
|
||||
"vitest": "^4.1.4"
|
||||
},
|
||||
"main": "dist-electron/main.js",
|
||||
"lint-staged": {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e",
|
||||
timeout: 120_000, // GIF encoding is CPU-bound; give it room
|
||||
retries: 0,
|
||||
reporter: "list",
|
||||
});
|
||||
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,88 @@
|
||||
#!/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);
|
||||
+58
-24
@@ -1,47 +1,81 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { CountdownOverlay } from "./components/launch/CountdownOverlay.tsx";
|
||||
import { LaunchWindow } from "./components/launch/LaunchWindow";
|
||||
import { SourceSelector } from "./components/launch/SourceSelector";
|
||||
import { Toaster } from "./components/ui/sonner";
|
||||
import { TooltipProvider } from "./components/ui/tooltip";
|
||||
import { ShortcutsConfigDialog } from "./components/video-editor/ShortcutsConfigDialog";
|
||||
import VideoEditor from "./components/video-editor/VideoEditor";
|
||||
import { ShortcutsProvider } from "./contexts/ShortcutsContext";
|
||||
import { loadAllCustomFonts } from "./lib/customFonts";
|
||||
|
||||
export default function App() {
|
||||
const [windowType, setWindowType] = useState("");
|
||||
const [windowType, setWindowType] = useState(
|
||||
() => new URLSearchParams(window.location.search).get("windowType") || "",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const type = params.get("windowType") || "";
|
||||
setWindowType(type);
|
||||
if (type === "hud-overlay" || type === "source-selector") {
|
||||
const type = new URLSearchParams(window.location.search).get("windowType") || "";
|
||||
if (type !== windowType) {
|
||||
setWindowType(type);
|
||||
}
|
||||
|
||||
if (type === "hud-overlay" || type === "source-selector" || type === "countdown-overlay") {
|
||||
document.body.style.background = "transparent";
|
||||
document.documentElement.style.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
|
||||
loadAllCustomFonts().catch((error) => {
|
||||
console.error("Failed to load custom fonts:", error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
switch (windowType) {
|
||||
case "hud-overlay":
|
||||
return <LaunchWindow />;
|
||||
case "source-selector":
|
||||
return <SourceSelector />;
|
||||
case "editor":
|
||||
return (
|
||||
<ShortcutsProvider>
|
||||
<VideoEditor />
|
||||
<ShortcutsConfigDialog />
|
||||
</ShortcutsProvider>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className="w-full h-full bg-background text-foreground">
|
||||
<h1>Openscreen</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const content = (() => {
|
||||
switch (windowType) {
|
||||
case "hud-overlay":
|
||||
return <LaunchWindow />;
|
||||
case "source-selector":
|
||||
return <SourceSelector />;
|
||||
case "countdown-overlay":
|
||||
return <CountdownOverlay />;
|
||||
case "editor":
|
||||
return (
|
||||
<ShortcutsProvider>
|
||||
<VideoEditor />
|
||||
<ShortcutsConfigDialog />
|
||||
</ShortcutsProvider>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className="w-full h-full bg-background text-foreground">
|
||||
<h1>Openscreen</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
/* Electron-specific: no Tailwind equivalent for -webkit-app-region */
|
||||
.electronDrag {
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
@@ -6,128 +7,77 @@
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.hudBar {
|
||||
isolation: isolate;
|
||||
box-shadow:
|
||||
0 2px 16px rgba(0, 0, 0, 0.25),
|
||||
0 0 40px rgba(100, 80, 200, 0.08);
|
||||
.languageMenuScroll {
|
||||
max-height: 16rem;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
overscroll-behavior: contain;
|
||||
touch-action: pan-y;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Sub-pill group container */
|
||||
.hudGroup {
|
||||
.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: 11rem;
|
||||
padding: 0.25rem;
|
||||
border-radius: 0.625rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(8, 9, 12, 0.96);
|
||||
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.48), inset 0 1px 0 rgba(255, 255, 255, 0.045);
|
||||
backdrop-filter: blur(18px) saturate(140%);
|
||||
pointer-events: auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.languageMenuItem {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 9999px;
|
||||
padding: 4px 8px;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.hudGroup:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* Icon button within groups */
|
||||
.hudIconBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
border-radius: 9999px;
|
||||
transition: all 0.15s ease;
|
||||
cursor: pointer;
|
||||
justify-content: space-between;
|
||||
padding: 0.425rem 0.5rem;
|
||||
border-radius: 0.45rem;
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.hudIconBtn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.hudIconBtn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Active icon glow (green) for enabled audio toggles */
|
||||
.hudIconActive {
|
||||
filter: drop-shadow(0 0 4px rgba(74, 222, 128, 0.4));
|
||||
}
|
||||
|
||||
/* Recording pulse animation on the record group */
|
||||
@keyframes recordPulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 8px rgba(239, 68, 68, 0.15);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 16px rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
.recordingPulse {
|
||||
animation: recordPulse 1.5s ease-in-out infinite;
|
||||
background: rgba(239, 68, 68, 0.1) !important;
|
||||
}
|
||||
|
||||
/* Mic panel above the bar */
|
||||
.micPanel {
|
||||
background: linear-gradient(135deg, rgba(28, 28, 36, 0.97) 0%, rgba(18, 18, 26, 0.96) 100%);
|
||||
backdrop-filter: blur(16px) saturate(140%);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(140%);
|
||||
border: 1px solid rgba(80, 80, 120, 0.25);
|
||||
border-radius: 16px;
|
||||
box-shadow:
|
||||
0 2px 12px rgba(0, 0, 0, 0.2),
|
||||
0 0 30px rgba(100, 80, 200, 0.06);
|
||||
animation: micPanelIn 0.15s ease-out;
|
||||
}
|
||||
|
||||
@keyframes micPanelIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Window control buttons */
|
||||
.windowBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3px;
|
||||
border-radius: 9999px;
|
||||
transition: all 0.15s ease;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: none;
|
||||
opacity: 0.5;
|
||||
transition: background-color 120ms ease, color 120ms ease;
|
||||
}
|
||||
|
||||
.windowBtn:hover {
|
||||
opacity: 0.9;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
.languageMenuItem:hover,
|
||||
.languageMenuItem:focus-visible {
|
||||
background: rgba(255, 255, 255, 0.075);
|
||||
color: #ffffff;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Folder button */
|
||||
.folderButton {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.folderText {
|
||||
color: #cbd5e1;
|
||||
transition: text-decoration 0.15s;
|
||||
}
|
||||
|
||||
.folderButton:hover .folderText {
|
||||
text-decoration: underline;
|
||||
.languageMenuItemActive {
|
||||
background: rgba(52, 178, 123, 0.14);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
@@ -1,70 +1,261 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { BsRecordCircle } from "react-icons/bs";
|
||||
import { Check, ChevronDown, Languages } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { BsPauseCircle, BsPlayCircle, BsRecordCircle } from "react-icons/bs";
|
||||
import { FaRegStopCircle } from "react-icons/fa";
|
||||
import { FaFolderOpen } from "react-icons/fa6";
|
||||
import { FiMinus, FiX } from "react-icons/fi";
|
||||
import { MdMic, MdMicOff, MdMonitor, MdVideoFile, MdVolumeOff, MdVolumeUp } from "react-icons/md";
|
||||
import {
|
||||
MdCancel,
|
||||
MdMic,
|
||||
MdMicOff,
|
||||
MdMonitor,
|
||||
MdRestartAlt,
|
||||
MdVideocam,
|
||||
MdVideocamOff,
|
||||
MdVideoFile,
|
||||
MdVolumeOff,
|
||||
MdVolumeUp,
|
||||
} from "react-icons/md";
|
||||
import { RxDragHandleDots2 } from "react-icons/rx";
|
||||
import { useI18n, useScopedT } from "@/contexts/I18nContext";
|
||||
import { getAvailableLocales, getLocaleName } from "@/i18n/loader";
|
||||
import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter";
|
||||
import { useCameraDevices } from "../../hooks/useCameraDevices";
|
||||
import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices";
|
||||
import { useScreenRecorder } from "../../hooks/useScreenRecorder";
|
||||
import { requestCameraAccess } from "../../lib/requestCameraAccess";
|
||||
import { formatTimePadded } from "../../utils/timeUtils";
|
||||
import { AudioLevelMeter } from "../ui/audio-level-meter";
|
||||
import { Button } from "../ui/button";
|
||||
import { Tooltip } from "../ui/tooltip";
|
||||
import styles from "./LaunchWindow.module.css";
|
||||
|
||||
const ICON_SIZE = 20;
|
||||
|
||||
const ICON_CONFIG = {
|
||||
drag: { icon: RxDragHandleDots2, size: ICON_SIZE },
|
||||
monitor: { icon: MdMonitor, size: ICON_SIZE },
|
||||
volumeOn: { icon: MdVolumeUp, size: ICON_SIZE },
|
||||
volumeOff: { icon: MdVolumeOff, size: ICON_SIZE },
|
||||
micOn: { icon: MdMic, size: ICON_SIZE },
|
||||
micOff: { icon: MdMicOff, size: ICON_SIZE },
|
||||
webcamOn: { icon: MdVideocam, 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 },
|
||||
restart: { icon: MdRestartAlt, size: ICON_SIZE },
|
||||
cancel: { icon: MdCancel, size: ICON_SIZE },
|
||||
record: { icon: BsRecordCircle, size: ICON_SIZE },
|
||||
videoFile: { icon: MdVideoFile, size: ICON_SIZE },
|
||||
folder: { icon: FaFolderOpen, size: ICON_SIZE },
|
||||
minimize: { icon: FiMinus, size: ICON_SIZE },
|
||||
close: { icon: FiX, size: ICON_SIZE },
|
||||
} as const;
|
||||
|
||||
type IconName = keyof typeof ICON_CONFIG;
|
||||
|
||||
function getIcon(name: IconName, className?: string) {
|
||||
const { icon: Icon, size } = ICON_CONFIG[name];
|
||||
return <Icon size={size} className={className} />;
|
||||
}
|
||||
|
||||
const hudGroupClasses =
|
||||
"flex items-center gap-0.5 rounded-xl border border-white/[0.07] bg-white/[0.045] transition-colors duration-150 hover:bg-white/[0.075]";
|
||||
|
||||
const hudIconBtnClasses =
|
||||
"flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150 cursor-pointer text-white hover:bg-white/10 active:scale-95";
|
||||
|
||||
const hudAuxIconBtnClasses =
|
||||
"flex h-7 w-7 items-center justify-center rounded-lg transition-colors duration-150 text-white/55 hover:bg-white/10 disabled:opacity-30 disabled:cursor-not-allowed";
|
||||
|
||||
const windowBtnClasses =
|
||||
"flex h-8 w-8 items-center justify-center rounded-lg 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() {
|
||||
const t = useScopedT("launch");
|
||||
const availableLocales = getAvailableLocales();
|
||||
const {
|
||||
locale,
|
||||
setLocale,
|
||||
systemLocaleSuggestion,
|
||||
acceptSystemLocaleSuggestion,
|
||||
dismissSystemLocaleSuggestion,
|
||||
resolveSystemLocaleSuggestion,
|
||||
} = useI18n();
|
||||
const suggestedLanguageName = systemLocaleSuggestion ? getLocaleName(systemLocaleSuggestion) : "";
|
||||
const activeLanguageLabel = getLocaleName(locale).split(/\s+/)[0] || locale.toUpperCase();
|
||||
|
||||
const {
|
||||
recording,
|
||||
paused,
|
||||
elapsedSeconds,
|
||||
toggleRecording,
|
||||
togglePaused,
|
||||
restartRecording,
|
||||
cancelRecording,
|
||||
microphoneEnabled,
|
||||
setMicrophoneEnabled,
|
||||
microphoneDeviceId,
|
||||
setMicrophoneDeviceId,
|
||||
systemAudioEnabled,
|
||||
setSystemAudioEnabled,
|
||||
webcamEnabled,
|
||||
setWebcamEnabled,
|
||||
webcamDeviceId,
|
||||
setWebcamDeviceId,
|
||||
} = useScreenRecorder();
|
||||
const [recordingStart, setRecordingStart] = useState<number | null>(null);
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
|
||||
const showMicControls = microphoneEnabled && !recording;
|
||||
const { devices, selectedDeviceId, setSelectedDeviceId } =
|
||||
useMicrophoneDevices(microphoneEnabled);
|
||||
const showWebcamControls = webcamEnabled && !recording;
|
||||
|
||||
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({
|
||||
enabled: showMicControls,
|
||||
deviceId: microphoneDeviceId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedDeviceId && selectedDeviceId !== "default") {
|
||||
setMicrophoneDeviceId(selectedDeviceId);
|
||||
if (selectedMicId && selectedMicId !== "default") {
|
||||
setMicrophoneDeviceId(selectedMicId);
|
||||
}
|
||||
}, [selectedDeviceId, setMicrophoneDeviceId]);
|
||||
}, [selectedMicId, setMicrophoneDeviceId]);
|
||||
|
||||
useEffect(() => {
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
if (recording) {
|
||||
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);
|
||||
if (selectedCameraId) {
|
||||
setWebcamDeviceId(selectedCameraId);
|
||||
}
|
||||
}, [selectedCameraId, setWebcamDeviceId]);
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
window.electronAPI?.setHudOverlayIgnoreMouseEvents?.(true);
|
||||
return () => {
|
||||
if (timer) clearInterval(timer);
|
||||
window.electronAPI?.setHudOverlayIgnoreMouseEvents?.(false);
|
||||
};
|
||||
}, [recording, recordingStart]);
|
||||
}, []);
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const m = Math.floor(seconds / 60)
|
||||
.toString()
|
||||
.padStart(2, "0");
|
||||
const s = (seconds % 60).toString().padStart(2, "0");
|
||||
return `${m}:${s}`;
|
||||
};
|
||||
const [selectedSource, setSelectedSource] = useState("Screen");
|
||||
const [hasSelectedSource, setHasSelectedSource] = useState(false);
|
||||
|
||||
@@ -131,138 +322,389 @@ export function LaunchWindow() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex items-end justify-center bg-transparent">
|
||||
<div className={`flex flex-col items-center gap-2 mx-auto ${styles.electronDrag}`}>
|
||||
{/* Mic controls panel */}
|
||||
{showMicControls && (
|
||||
<div
|
||||
className={`flex items-center gap-2 px-4 py-2 ${styles.micPanel} ${styles.electronNoDrag}`}
|
||||
>
|
||||
<select
|
||||
value={microphoneDeviceId || selectedDeviceId}
|
||||
onChange={(e) => {
|
||||
setSelectedDeviceId(e.target.value);
|
||||
setMicrophoneDeviceId(e.target.value);
|
||||
}}
|
||||
className="flex-1 bg-white/10 text-white text-xs rounded-full px-3 py-1 border border-white/20 outline-none truncate"
|
||||
style={{ maxWidth: "70%" }}
|
||||
// Root fills the HUD window only. Avoid w-screen/h-screen (100vw/100vh):
|
||||
// 100vw can exceed the inner layout width when scrollbars affect the
|
||||
// viewport (notably on Windows), causing a horizontal scrollbar once the
|
||||
// recording toolbar widened (issue #305).
|
||||
<div
|
||||
className={`h-full w-full min-w-0 max-w-full overflow-x-hidden overflow-y-hidden bg-transparent ${styles.electronDrag}`}
|
||||
onPointerMove={(event) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
const shouldCapture = Boolean(target?.closest("[data-hud-interactive='true']"));
|
||||
window.electronAPI?.setHudOverlayIgnoreMouseEvents?.(!shouldCapture);
|
||||
}}
|
||||
onPointerLeave={() => window.electronAPI?.setHudOverlayIgnoreMouseEvents?.(true)}
|
||||
>
|
||||
{systemLocaleSuggestion && (
|
||||
<div
|
||||
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}`}
|
||||
>
|
||||
<div className="text-[13px] font-semibold text-white">
|
||||
{t("systemLanguagePrompt.title")}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] leading-relaxed text-white/75">
|
||||
{t("systemLanguagePrompt.description", {
|
||||
language: suggestedLanguageName,
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={dismissSystemLocaleSuggestion}
|
||||
className="h-7 text-xs text-white/80 hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
{devices.map((device) => (
|
||||
<option key={device.deviceId} value={device.deviceId}>
|
||||
{device.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<AudioLevelMeter level={level} className="w-24 h-4" />
|
||||
{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
|
||||
data-hud-interactive="true"
|
||||
className={`fixed bottom-[68px] left-1/2 -translate-x-1/2 flex items-center gap-2 animate-mic-panel-in ${styles.electronNoDrag}`}
|
||||
>
|
||||
{/* Mic selector */}
|
||||
{showMicControls && (
|
||||
<div
|
||||
className={`flex h-9 items-center gap-2 overflow-hidden rounded-xl border border-white/[0.08] bg-[#0b0c10]/90 px-3 py-1.5 shadow-[0_18px_42px_rgba(0,0,0,0.4)] backdrop-blur-2xl transition-all duration-300 ${!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>
|
||||
)}
|
||||
|
||||
{/* Webcam selector */}
|
||||
{showWebcamControls && (
|
||||
<div
|
||||
className={`flex h-9 items-center gap-2 overflow-hidden rounded-xl border border-white/[0.08] bg-[#0b0c10]/90 px-3 py-1.5 shadow-[0_18px_42px_rgba(0,0,0,0.4)] backdrop-blur-2xl transition-all duration-300 ${!webcamExpanded ? "opacity-60 grayscale-[0.5]" : "opacity-100"}`}
|
||||
onMouseEnter={() => setIsWebcamHovered(true)}
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* HUD bar — fixed at bottom center, viewport-relative, never moves */}
|
||||
<div
|
||||
data-hud-interactive="true"
|
||||
className={`fixed bottom-5 left-1/2 -translate-x-1/2 flex items-center gap-1.5 rounded-2xl border border-white/[0.10] bg-[#07080a]/90 px-2 py-1.5 shadow-[0_20px_60px_rgba(0,0,0,0.42),inset_0_1px_0_rgba(255,255,255,0.06)] backdrop-blur-2xl backdrop-saturate-[140%]`}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div className={`flex items-center px-1 ${styles.electronDrag}`}>
|
||||
{getIcon("drag", "text-white/30")}
|
||||
</div>
|
||||
|
||||
{/* Source selector */}
|
||||
<button
|
||||
className={`${hudGroupClasses} h-8 px-2.5 ${styles.electronNoDrag}`}
|
||||
onClick={openSourceSelector}
|
||||
disabled={recording}
|
||||
title={selectedSource}
|
||||
>
|
||||
{getIcon("monitor", "text-white/80")}
|
||||
<span className="max-w-[86px] truncate text-[11px] font-medium text-white/75">
|
||||
{selectedSource}
|
||||
</span>
|
||||
</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 ? t("audio.disableSystemAudio") : t("audio.enableSystemAudio")
|
||||
}
|
||||
>
|
||||
{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 ? t("audio.disableMicrophone") : t("audio.enableMicrophone")}
|
||||
>
|
||||
{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={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>
|
||||
|
||||
{/* Record/Stop group */}
|
||||
<button
|
||||
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} ${
|
||||
recording
|
||||
? paused
|
||||
? "bg-amber-500/10 hover:bg-amber-500/15"
|
||||
: "bg-red-500/12 hover:bg-red-500/16"
|
||||
: "bg-white/[0.06] hover:bg-white/[0.10]"
|
||||
}`}
|
||||
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")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip content={t("tooltips.cancelRecording")}>
|
||||
<button className={hudAuxIconBtnClasses} onClick={cancelRecording}>
|
||||
{getIcon("cancel", "text-white/60")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main pill bar */}
|
||||
<div
|
||||
className={`flex items-center gap-1.5 px-2 py-1.5 ${styles.hudBar}`}
|
||||
style={{
|
||||
borderRadius: 9999,
|
||||
background: "linear-gradient(135deg, rgba(28,28,36,0.97) 0%, rgba(18,18,26,0.96) 100%)",
|
||||
backdropFilter: "blur(16px) saturate(140%)",
|
||||
WebkitBackdropFilter: "blur(16px) saturate(140%)",
|
||||
border: "1px solid rgba(80,80,120,0.25)",
|
||||
}}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div className={`flex items-center px-1 ${styles.electronDrag}`}>
|
||||
<RxDragHandleDots2 size={16} className="text-white/30" />
|
||||
</div>
|
||||
{!recording && (
|
||||
<>
|
||||
{/* Open video file */}
|
||||
<Tooltip content={t("tooltips.openVideoFile")}>
|
||||
<button
|
||||
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
||||
onClick={openVideoFile}
|
||||
>
|
||||
{getIcon("videoFile", "text-white/60")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Source selector */}
|
||||
<button
|
||||
className={`${styles.hudGroup} ${styles.electronNoDrag}`}
|
||||
onClick={openSourceSelector}
|
||||
disabled={recording}
|
||||
title={selectedSource}
|
||||
>
|
||||
<MdMonitor size={14} className="text-white/80" />
|
||||
<span className="text-white/70 text-[11px] max-w-[72px] truncate">
|
||||
{selectedSource}
|
||||
</span>
|
||||
</button>
|
||||
{/* Open project */}
|
||||
<Tooltip content={t("tooltips.openProject")}>
|
||||
<button
|
||||
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
||||
onClick={openProjectFile}
|
||||
>
|
||||
{getIcon("folder", "text-white/60")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Audio controls group */}
|
||||
<div className={`${styles.hudGroup} ${styles.electronNoDrag}`}>
|
||||
{/* Right sidebar controls */}
|
||||
<div className={`${hudSidebarClasses} ${styles.electronNoDrag}`}>
|
||||
<div className={`${styles.languageMenuContainer} ${styles.electronNoDrag}`}>
|
||||
<button
|
||||
className={`${styles.hudIconBtn} ${systemAudioEnabled ? styles.hudIconActive : ""}`}
|
||||
onClick={() => !recording && setSystemAudioEnabled(!systemAudioEnabled)}
|
||||
disabled={recording}
|
||||
title={systemAudioEnabled ? "Disable system audio" : "Enable system audio"}
|
||||
ref={languageTriggerRef}
|
||||
type="button"
|
||||
aria-label={t("language")}
|
||||
aria-expanded={isLanguageMenuOpen}
|
||||
aria-haspopup="menu"
|
||||
onClick={() => setIsLanguageMenuOpen((open) => !open)}
|
||||
className={`flex h-8 items-center gap-1.5 rounded-lg border border-white/10 bg-white/[0.045] px-2 text-white/85 shadow-none transition-colors hover:bg-white/10 ${styles.electronNoDrag}`}
|
||||
>
|
||||
{systemAudioEnabled ? (
|
||||
<MdVolumeUp size={15} className="text-green-400" />
|
||||
) : (
|
||||
<MdVolumeOff size={15} className="text-white/40" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.hudIconBtn} ${microphoneEnabled ? styles.hudIconActive : ""}`}
|
||||
onClick={toggleMicrophone}
|
||||
disabled={recording}
|
||||
title={microphoneEnabled ? "Disable microphone" : "Enable microphone"}
|
||||
>
|
||||
{microphoneEnabled ? (
|
||||
<MdMic size={15} className="text-green-400" />
|
||||
) : (
|
||||
<MdMicOff size={15} className="text-white/40" />
|
||||
)}
|
||||
<Languages size={13} className="text-white/70" />
|
||||
<span className="max-w-[54px] truncate text-[10px] font-semibold text-white/75">
|
||||
{activeLanguageLabel}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Record/Stop group */}
|
||||
<button
|
||||
className={`${styles.hudGroup} ${styles.electronNoDrag} ${recording ? styles.recordingPulse : ""}`}
|
||||
onClick={hasSelectedSource ? toggleRecording : openSourceSelector}
|
||||
disabled={!hasSelectedSource && !recording}
|
||||
style={{ flex: "0 0 auto" }}
|
||||
>
|
||||
{recording ? (
|
||||
<>
|
||||
<FaRegStopCircle size={13} className="text-red-400" />
|
||||
<span className="text-red-400 text-xs font-semibold tabular-nums">
|
||||
{formatTime(elapsed)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<BsRecordCircle
|
||||
size={14}
|
||||
className={hasSelectedSource ? "text-white/80" : "text-white/30"}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Open video file */}
|
||||
<button
|
||||
className={`${styles.hudIconBtn} ${styles.electronNoDrag}`}
|
||||
onClick={openVideoFile}
|
||||
disabled={recording}
|
||||
title="Open video file"
|
||||
>
|
||||
<MdVideoFile size={14} className="text-white/60" />
|
||||
</button>
|
||||
|
||||
{/* Open project */}
|
||||
<button
|
||||
className={`${styles.hudIconBtn} ${styles.electronNoDrag}`}
|
||||
onClick={openProjectFile}
|
||||
disabled={recording}
|
||||
title="Open project"
|
||||
>
|
||||
<FaFolderOpen size={14} className="text-white/60" />
|
||||
</button>
|
||||
{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 */}
|
||||
<div className={`flex items-center gap-0.5 ${styles.electronNoDrag}`}>
|
||||
<button className={styles.windowBtn} title="Hide HUD" onClick={sendHudOverlayHide}>
|
||||
<FiMinus size={14} className="text-white" />
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
className={windowBtnClasses}
|
||||
title={t("tooltips.hideHUD")}
|
||||
onClick={sendHudOverlayHide}
|
||||
>
|
||||
{getIcon("minimize", "text-white")}
|
||||
</button>
|
||||
<button className={styles.windowBtn} title="Close App" onClick={sendHudOverlayClose}>
|
||||
<FiX size={14} className="text-white" />
|
||||
<button
|
||||
className={windowBtnClasses}
|
||||
title={t("tooltips.closeApp")}
|
||||
onClick={sendHudOverlayClose}
|
||||
>
|
||||
{getIcon("close", "text-white")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,38 +1,46 @@
|
||||
.glassContainer {
|
||||
background: linear-gradient(135deg, rgba(28, 28, 34, 0.92) 0%, rgba(18, 18, 22, 0.88) 100%);
|
||||
backdrop-filter: blur(20px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(160%);
|
||||
border-radius: 14px;
|
||||
box-shadow:
|
||||
0 4px 16px 0 rgba(0, 0, 0, 0.32),
|
||||
0 1px 3px 0 rgba(0, 0, 0, 0.18) inset;
|
||||
border: 1px solid rgba(60, 60, 80, 0.18);
|
||||
background: linear-gradient(145deg, rgba(13, 14, 17, 0.94) 0%, rgba(8, 9, 12, 0.9) 100%);
|
||||
backdrop-filter: blur(24px) saturate(150%);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(150%);
|
||||
border-radius: 24px;
|
||||
corner-shape: squircle;
|
||||
/*
|
||||
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.
|
||||
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: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.sourceCard {
|
||||
border-radius: 12px;
|
||||
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);
|
||||
box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.18);
|
||||
corner-shape: squircle;
|
||||
border-radius: 13px;
|
||||
background: rgba(255, 255, 255, 0.045);
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.035);
|
||||
transition:
|
||||
box-shadow 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
background-color 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sourceCard:hover {
|
||||
border-color: rgba(120, 120, 160, 0.35);
|
||||
background: rgba(255, 255, 255, 0.065);
|
||||
border-color: rgba(255, 255, 255, 0.14);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 0.25);
|
||||
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.22), inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.selected {
|
||||
border: 2px solid #34b27b;
|
||||
background: linear-gradient(120deg, rgba(52, 178, 123, 0.08) 0%, rgba(38, 38, 48, 0.98) 100%);
|
||||
border-color: rgba(52, 178, 123, 0.68);
|
||||
background: linear-gradient(145deg, rgba(52, 178, 123, 0.13), rgba(255, 255, 255, 0.045));
|
||||
box-shadow:
|
||||
0 0 12px rgba(52, 178, 123, 0.15),
|
||||
0 0 4px rgba(52, 178, 123, 0.1);
|
||||
0 0 0 1px rgba(52, 178, 123, 0.18) inset,
|
||||
0 12px 28px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
.selected:hover {
|
||||
@@ -40,16 +48,16 @@
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
color: #c7d2fe;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 0.8rem;
|
||||
font-size: 0.72rem;
|
||||
color: #e4e4e7;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.cardText {
|
||||
@@ -59,41 +67,38 @@
|
||||
|
||||
/* Checkmark badge */
|
||||
.checkBadge {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
background: #34b27b;
|
||||
border-radius: 9999px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 0 8px rgba(52, 178, 123, 0.4);
|
||||
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
/* scrollbar */
|
||||
.sourceGridScroll {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(52, 178, 123, 0.5) rgba(40, 40, 50, 0.6);
|
||||
}
|
||||
|
||||
.sourceGridScroll::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
width: 3px;
|
||||
}
|
||||
|
||||
.sourceGridScroll::-webkit-scrollbar-track {
|
||||
background: rgba(30, 30, 38, 0.5);
|
||||
background: rgba(30, 30, 38, 0.3);
|
||||
border-radius: 4px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.sourceGridScroll::-webkit-scrollbar-thumb {
|
||||
background: rgba(80, 80, 100, 0.6);
|
||||
border-radius: 4px;
|
||||
background: rgba(52, 178, 123, 0.5);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.sourceGridScroll::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(52, 178, 123, 0.6);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.sourceGridScroll::-webkit-scrollbar-thumb:active {
|
||||
background: rgba(52, 178, 123, 0.8);
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { MdCheck } from "react-icons/md";
|
||||
import { useScopedT } from "@/contexts/I18nContext";
|
||||
import { Button } from "../ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
|
||||
import styles from "./SourceSelector.module.css";
|
||||
@@ -13,6 +14,8 @@ interface DesktopSource {
|
||||
}
|
||||
|
||||
export function SourceSelector() {
|
||||
const t = useScopedT("launch");
|
||||
const tc = useScopedT("common");
|
||||
const [sources, setSources] = useState<DesktopSource[]>([]);
|
||||
const [selectedSource, setSelectedSource] = useState<DesktopSource | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -62,8 +65,8 @@ export function SourceSelector() {
|
||||
style={{ minHeight: "100vh" }}
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-[#34B27B] mx-auto mb-2" />
|
||||
<p className="text-xs text-zinc-400">Loading sources...</p>
|
||||
<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">{t("sourceSelector.loading")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -74,24 +77,24 @@ export function SourceSelector() {
|
||||
return (
|
||||
<div
|
||||
key={source.id}
|
||||
className={`${styles.sourceCard} ${isSelected ? styles.selected : ""} p-2`}
|
||||
className={`${styles.sourceCard} ${isSelected ? styles.selected : ""} p-1.5`}
|
||||
onClick={() => handleSourceSelect(source)}
|
||||
>
|
||||
<div className="relative mb-1.5">
|
||||
<div className="relative mb-1.5 overflow-hidden rounded-lg border border-white/[0.06] bg-black/30">
|
||||
<img
|
||||
src={source.thumbnail || ""}
|
||||
alt={source.name}
|
||||
className="w-full aspect-video object-cover rounded-lg"
|
||||
className="w-full aspect-video object-cover"
|
||||
/>
|
||||
{isSelected && (
|
||||
<div className="absolute -top-1.5 -right-1.5">
|
||||
<div className="absolute right-1.5 top-1.5">
|
||||
<div className={styles.checkBadge}>
|
||||
<MdCheck size={12} className="text-white" />
|
||||
<MdCheck size={11} className="text-white" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex items-center gap-1.5 px-1 pb-0.5">
|
||||
{source.appIcon && (
|
||||
<img src={source.appIcon} alt="" className={`${styles.icon} flex-shrink-0`} />
|
||||
)}
|
||||
@@ -103,33 +106,36 @@ export function SourceSelector() {
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen flex flex-col ${styles.glassContainer}`}>
|
||||
<div className="flex-1 flex flex-col w-full px-4 pt-4">
|
||||
<Tabs defaultValue="screens" className="flex-1 flex flex-col">
|
||||
<TabsList className="grid grid-cols-2 mb-3 bg-white/5 rounded-full">
|
||||
<div className="flex-1 flex flex-col w-full px-3.5 pt-3.5">
|
||||
<Tabs
|
||||
defaultValue={screenSources.length === 0 ? "windows" : "screens"}
|
||||
className="flex-1 flex flex-col"
|
||||
>
|
||||
<TabsList className="mb-3 grid h-8 grid-cols-2 rounded-xl border border-white/[0.06] bg-white/[0.04] p-0.5">
|
||||
<TabsTrigger
|
||||
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="rounded-lg py-1 text-[11px] text-zinc-400 transition-all data-[state=active]:bg-white/[0.12] data-[state=active]:text-white"
|
||||
>
|
||||
Screens
|
||||
{t("sourceSelector.screens", { count: String(screenSources.length) })}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
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="rounded-lg py-1 text-[11px] text-zinc-400 transition-all data-[state=active]:bg-white/[0.12] data-[state=active]:text-white"
|
||||
>
|
||||
Windows
|
||||
{t("sourceSelector.windows", { count: String(windowSources.length) })}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="flex-1 min-h-0">
|
||||
<TabsContent value="screens" className="h-full mt-0">
|
||||
<div
|
||||
className={`grid grid-cols-2 gap-3 h-[280px] overflow-y-auto pr-1 auto-rows-min ${styles.sourceGridScroll}`}
|
||||
className={`grid h-[282px] auto-rows-min grid-cols-2 gap-2.5 overflow-y-auto pr-1.5 pt-1 ${styles.sourceGridScroll}`}
|
||||
>
|
||||
{screenSources.map(renderSourceCard)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="windows" className="h-full mt-0">
|
||||
<div
|
||||
className={`grid grid-cols-2 gap-3 h-[280px] overflow-y-auto pr-1 auto-rows-min ${styles.sourceGridScroll}`}
|
||||
className={`grid h-[282px] auto-rows-min grid-cols-2 gap-2.5 overflow-y-auto pr-1.5 pt-1 ${styles.sourceGridScroll}`}
|
||||
>
|
||||
{windowSources.map(renderSourceCard)}
|
||||
</div>
|
||||
@@ -137,20 +143,20 @@ export function SourceSelector() {
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div className="p-3 flex justify-center gap-2">
|
||||
<div className="flex justify-center gap-2 border-t border-white/[0.06] p-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => window.close()}
|
||||
className="px-5 py-1 text-xs text-zinc-400 hover:text-white hover:bg-white/5 rounded-full"
|
||||
className="h-8 rounded-lg px-5 text-[11px] text-zinc-400 transition-transform duration-150 hover:bg-white/5 hover:text-white active:scale-95"
|
||||
>
|
||||
Cancel
|
||||
{tc("actions.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleShare}
|
||||
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="h-8 rounded-lg bg-[#34B27B] px-5 text-[11px] font-semibold text-white transition-transform duration-150 hover:bg-[#34B27B]/85 active:scale-95 disabled:bg-zinc-700 disabled:opacity-30"
|
||||
>
|
||||
Share
|
||||
{tc("actions.share")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -52,4 +52,4 @@ const AccordionContent = React.forwardRef<
|
||||
));
|
||||
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";
|
||||
|
||||
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 {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
||||
|
||||
@@ -54,9 +54,11 @@ DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayNam
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> & {
|
||||
portalled?: boolean;
|
||||
}
|
||||
>(({ className, sideOffset = 4, portalled = true, ...props }, ref) => {
|
||||
const content = (
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
@@ -67,8 +69,14 @@ const DropdownMenuContent = React.forwardRef<
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
);
|
||||
|
||||
if (!portalled) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return <DropdownMenuPrimitive.Portal>{content}</DropdownMenuPrimitive.Portal>;
|
||||
});
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
@@ -169,18 +177,18 @@ DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
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<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<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" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> & {
|
||||
showScrollButtons?: boolean;
|
||||
viewportClassName?: string;
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
showScrollButtons = true,
|
||||
viewportClassName,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
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" &&
|
||||
"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}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
{showScrollButtons ? <SelectScrollUpButton /> : null}
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
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;
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
@@ -141,13 +157,13 @@ SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
};
|
||||
|
||||
@@ -50,4 +50,4 @@ const TabsContent = React.forwardRef<
|
||||
));
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
export { Tabs, TabsContent, TabsList, TabsTrigger };
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 200,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TooltipRoot({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
|
||||
}
|
||||
|
||||
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 6,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"px-2 py-1 text-[11px] leading-none text-white/90 bg-black/85 border border-white/10 rounded-md z-50",
|
||||
"animate-in fade-in-0 zoom-in-95",
|
||||
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
children,
|
||||
content,
|
||||
side,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
content: React.ReactNode;
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<TooltipRoot>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
<TooltipContent side={side} className={className}>
|
||||
{content}
|
||||
</TooltipContent>
|
||||
</TooltipRoot>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger };
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useScopedT } from "@/contexts/I18nContext";
|
||||
import {
|
||||
addCustomFont,
|
||||
type CustomFont,
|
||||
@@ -25,6 +26,8 @@ interface AddCustomFontDialogProps {
|
||||
}
|
||||
|
||||
export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
|
||||
const t = useScopedT("settings");
|
||||
const tc = useScopedT("common");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [importUrl, setImportUrl] = useState("");
|
||||
const [fontName, setFontName] = useState("");
|
||||
@@ -45,17 +48,17 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
|
||||
const handleAdd = async () => {
|
||||
// Validate inputs
|
||||
if (!importUrl.trim()) {
|
||||
toast.error("Please enter a Google Fonts import URL");
|
||||
toast.error(t("customFont.errorEmptyUrl"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isValidGoogleFontsUrl(importUrl)) {
|
||||
toast.error("Please enter a valid Google Fonts URL");
|
||||
toast.error(t("customFont.errorInvalidUrl"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fontName.trim()) {
|
||||
toast.error("Please enter a font name");
|
||||
toast.error(t("customFont.errorEmptyName"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -65,7 +68,7 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
|
||||
// Extract font family from URL
|
||||
const fontFamily = parseFontFamilyFromImport(importUrl);
|
||||
if (!fontFamily) {
|
||||
toast.error("Could not extract font family from URL");
|
||||
toast.error(t("customFont.errorExtractFailed"));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -86,7 +89,7 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
|
||||
onFontAdded(newFont);
|
||||
}
|
||||
|
||||
toast.success(`Font "${fontName}" added successfully`);
|
||||
toast.success(t("customFont.successMessage", { fontName }));
|
||||
|
||||
// Reset and close
|
||||
setImportUrl("");
|
||||
@@ -95,10 +98,10 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
|
||||
} catch (error) {
|
||||
console.error("Failed to add custom font:", error);
|
||||
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")
|
||||
? "Font took too long to load. Please check the URL and try again."
|
||||
: "The font could not be loaded. Please verify the Google Fonts URL is correct.",
|
||||
? t("customFont.errorTimeout")
|
||||
: t("customFont.errorLoadFailed"),
|
||||
});
|
||||
} finally {
|
||||
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"
|
||||
>
|
||||
<Plus className="w-3 h-3 mr-1" />
|
||||
Add Google Font
|
||||
{t("customFont.dialogTitle")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="bg-[#1a1a1c] border-white/10 text-slate-200">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Google Font</DialogTitle>
|
||||
<DialogTitle>{t("customFont.dialogTitle")}</DialogTitle>
|
||||
<DialogDescription className="text-slate-400">
|
||||
Add a custom font from Google Fonts to use in your annotations.
|
||||
</DialogDescription>
|
||||
@@ -128,34 +131,30 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="import-url" className="text-slate-200">
|
||||
Google Fonts Import URL
|
||||
{t("customFont.urlLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="import-url"
|
||||
placeholder="https://fonts.googleapis.com/css2?family=Roboto&display=swap"
|
||||
placeholder={t("customFont.urlPlaceholder")}
|
||||
value={importUrl}
|
||||
onChange={(e) => handleImportUrlChange(e.target.value)}
|
||||
className="bg-white/5 border-white/10 text-slate-200"
|
||||
/>
|
||||
<p className="text-xs text-slate-400">
|
||||
Get this from Google Fonts: Select a font → Click "Get font" → Copy the @import URL
|
||||
</p>
|
||||
<p className="text-xs text-slate-400">{t("customFont.urlHelp")}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="font-name" className="text-slate-200">
|
||||
Display Name
|
||||
{t("customFont.nameLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="font-name"
|
||||
placeholder="My Custom Font"
|
||||
placeholder={t("customFont.namePlaceholder")}
|
||||
value={fontName}
|
||||
onChange={(e) => setFontName(e.target.value)}
|
||||
className="bg-white/5 border-white/10 text-slate-200"
|
||||
/>
|
||||
<p className="text-xs text-slate-400">
|
||||
This is how the font will appear in the font selector
|
||||
</p>
|
||||
<p className="text-xs text-slate-400">{t("customFont.nameHelp")}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 mt-6">
|
||||
@@ -164,14 +163,14 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
|
||||
onClick={() => setOpen(false)}
|
||||
className="bg-white/5 border-white/10 text-slate-200 hover:bg-white/10"
|
||||
>
|
||||
Cancel
|
||||
{tc("actions.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAdd}
|
||||
disabled={loading}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
{loading ? "Adding..." : "Add Font"}
|
||||
{loading ? t("customFont.addingButton") : t("customFont.addButton")}
|
||||
</Button>
|
||||
</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 {
|
||||
getBlurOverlayColor,
|
||||
getMosaicGridOverlayColor,
|
||||
getNormalizedMosaicBlockSize,
|
||||
} from "@/lib/blurEffects";
|
||||
import { cn } from "@/lib/utils";
|
||||
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 {
|
||||
annotation: AnnotationRegion;
|
||||
@@ -11,9 +43,13 @@ interface AnnotationOverlayProps {
|
||||
containerHeight: number;
|
||||
onPositionChange: (id: string, position: { x: number; y: number }) => void;
|
||||
onSizeChange: (id: string, size: { width: number; height: number }) => void;
|
||||
onBlurDataChange?: (id: string, blurData: BlurData) => void;
|
||||
onBlurDataCommit?: () => void;
|
||||
onClick: (id: string) => void;
|
||||
zIndex: number;
|
||||
isSelectedBoost: boolean; // Boost z-index when selected for easy editing
|
||||
previewSourceCanvas?: PreviewCanvasSource | null;
|
||||
previewFrameVersion?: number;
|
||||
}
|
||||
|
||||
export function AnnotationOverlay({
|
||||
@@ -23,16 +59,129 @@ export function AnnotationOverlay({
|
||||
containerHeight,
|
||||
onPositionChange,
|
||||
onSizeChange,
|
||||
onBlurDataChange,
|
||||
onBlurDataCommit,
|
||||
onClick,
|
||||
zIndex,
|
||||
isSelectedBoost,
|
||||
previewSourceCanvas,
|
||||
previewFrameVersion,
|
||||
}: AnnotationOverlayProps) {
|
||||
const x = (annotation.position.x / 100) * containerWidth;
|
||||
const y = (annotation.position.y / 100) * containerHeight;
|
||||
const width = (annotation.size.width / 100) * containerWidth;
|
||||
const height = (annotation.size.height / 100) * containerHeight;
|
||||
|
||||
const committedX = (annotation.position.x / 100) * containerWidth;
|
||||
const committedY = (annotation.position.y / 100) * containerHeight;
|
||||
const committedWidth = (annotation.size.width / 100) * containerWidth;
|
||||
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 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 = "mosaic";
|
||||
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") {
|
||||
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,
|
||||
containerHeight,
|
||||
containerWidth,
|
||||
height,
|
||||
previewFrameVersion,
|
||||
previewSourceCanvas,
|
||||
width,
|
||||
x,
|
||||
y,
|
||||
]);
|
||||
|
||||
const renderArrow = () => {
|
||||
const direction = annotation.figureData?.arrowDirection || "right";
|
||||
@@ -43,6 +192,95 @@ export function AnnotationOverlay({
|
||||
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 = () => {
|
||||
switch (annotation.type) {
|
||||
case "text":
|
||||
@@ -113,6 +351,149 @@ export function AnnotationOverlay({
|
||||
<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:
|
||||
return null;
|
||||
}
|
||||
@@ -125,7 +506,19 @@ export function AnnotationOverlay({
|
||||
onDragStart={() => {
|
||||
isDraggingRef.current = true;
|
||||
}}
|
||||
onDrag={(_e, d) => {
|
||||
setLiveRect((prev) => ({
|
||||
...prev,
|
||||
x: d.x,
|
||||
y: d.y,
|
||||
}));
|
||||
}}
|
||||
onDragStop={(_e, d) => {
|
||||
setLiveRect((prev) => ({
|
||||
...prev,
|
||||
x: d.x,
|
||||
y: d.y,
|
||||
}));
|
||||
const xPercent = (d.x / containerWidth) * 100;
|
||||
const yPercent = (d.y / containerHeight) * 100;
|
||||
onPositionChange(annotation.id, { x: xPercent, y: yPercent });
|
||||
@@ -135,7 +528,21 @@ export function AnnotationOverlay({
|
||||
isDraggingRef.current = false;
|
||||
}, 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) => {
|
||||
setLiveRect({
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
width: ref.offsetWidth,
|
||||
height: ref.offsetHeight,
|
||||
});
|
||||
const xPercent = (position.x / containerWidth) * 100;
|
||||
const yPercent = (position.y / containerHeight) * 100;
|
||||
const widthPercent = (ref.offsetWidth / containerWidth) * 100;
|
||||
@@ -149,18 +556,23 @@ export function AnnotationOverlay({
|
||||
}}
|
||||
bounds="parent"
|
||||
className={cn(
|
||||
"cursor-move transition-all",
|
||||
isSelected && "ring-2 ring-[#34B27B] ring-offset-2 ring-offset-transparent",
|
||||
"cursor-move",
|
||||
isSelected &&
|
||||
annotation.type !== "blur" &&
|
||||
"ring-2 ring-[#34B27B] ring-offset-2 ring-offset-transparent",
|
||||
)}
|
||||
style={{
|
||||
zIndex: isSelectedBoost ? zIndex + 1000 : zIndex, // Boost selected annotation to ensure it's on top
|
||||
pointerEvents: isSelected ? "auto" : "none",
|
||||
border: isSelected ? "2px solid rgba(52, 178, 123, 0.8)" : "none",
|
||||
backgroundColor: isSelected ? "rgba(52, 178, 123, 0.1)" : "transparent",
|
||||
boxShadow: isSelected ? "0 0 0 1px rgba(52, 178, 123, 0.35)" : "none",
|
||||
border:
|
||||
isSelected && annotation.type !== "blur" ? "2px solid rgba(52, 178, 123, 0.8)" : "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}
|
||||
disableDragging={!isSelected}
|
||||
enableResizing={isSelected && !isSelectedFreehandBlur}
|
||||
disableDragging={!isSelected || isSelectedFreehandBlur}
|
||||
resizeHandleStyles={{
|
||||
topLeft: {
|
||||
width: "12px",
|
||||
@@ -206,11 +618,13 @@ export function AnnotationOverlay({
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-full h-full rounded-lg",
|
||||
"w-full h-full",
|
||||
annotation.type !== "blur" && "rounded-lg",
|
||||
annotation.type === "text" && "bg-transparent",
|
||||
annotation.type === "image" && "bg-transparent",
|
||||
annotation.type === "figure" && "bg-transparent",
|
||||
isSelected && "shadow-lg",
|
||||
annotation.type === "blur" && "bg-transparent",
|
||||
isSelected && annotation.type !== "blur" && "shadow-lg",
|
||||
)}
|
||||
>
|
||||
{renderContent()}
|
||||
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
AlignRight,
|
||||
Bold,
|
||||
ChevronDown,
|
||||
Copy,
|
||||
Image as ImageIcon,
|
||||
Info,
|
||||
Italic,
|
||||
Trash2,
|
||||
Type,
|
||||
@@ -27,11 +27,18 @@ import {
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { useScopedT } from "@/contexts/I18nContext";
|
||||
import { type CustomFont, getCustomFonts } from "@/lib/customFonts";
|
||||
import { cn } from "@/lib/utils";
|
||||
import ColorPicker from "../ui/color-picker";
|
||||
import { AddCustomFontDialog } from "./AddCustomFontDialog";
|
||||
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 {
|
||||
annotation: AnnotationRegion;
|
||||
@@ -39,18 +46,19 @@ interface AnnotationSettingsPanelProps {
|
||||
onTypeChange: (type: AnnotationType) => void;
|
||||
onStyleChange: (style: Partial<AnnotationRegion["style"]>) => void;
|
||||
onFigureDataChange?: (figureData: FigureData) => void;
|
||||
onDuplicate?: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
const FONT_FAMILIES = [
|
||||
{ value: "system-ui, -apple-system, sans-serif", label: "Classic" },
|
||||
{ value: "Georgia, serif", label: "Editor" },
|
||||
{ value: "Impact, Arial Black, sans-serif", label: "Strong" },
|
||||
{ value: "Courier New, monospace", label: "Typewriter" },
|
||||
{ value: "Brush Script MT, cursive", label: "Deco" },
|
||||
{ value: "Arial, sans-serif", label: "Simple" },
|
||||
{ value: "Verdana, sans-serif", label: "Modern" },
|
||||
{ value: "Trebuchet MS, sans-serif", label: "Clean" },
|
||||
{ value: "system-ui, -apple-system, sans-serif", labelKey: "classic" },
|
||||
{ value: "Georgia, serif", labelKey: "editor" },
|
||||
{ value: "Impact, Arial Black, sans-serif", labelKey: "strong" },
|
||||
{ value: "Courier New, monospace", labelKey: "typewriter" },
|
||||
{ value: "Brush Script MT, cursive", labelKey: "deco" },
|
||||
{ value: "Arial, sans-serif", labelKey: "simple" },
|
||||
{ value: "Verdana, sans-serif", labelKey: "modern" },
|
||||
{ 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];
|
||||
@@ -61,10 +69,22 @@ export function AnnotationSettingsPanel({
|
||||
onTypeChange,
|
||||
onStyleChange,
|
||||
onFigureDataChange,
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
}: AnnotationSettingsPanelProps) {
|
||||
const t = useScopedT("settings");
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
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
|
||||
useEffect(() => {
|
||||
@@ -99,8 +119,8 @@ export function AnnotationSettingsPanel({
|
||||
// Validate file type
|
||||
const validTypes = ["image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp"];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
toast.error("Invalid file type", {
|
||||
description: "Please upload a JPG, PNG, GIF, or WebP image file.",
|
||||
toast.error(t("annotation.invalidImageType"), {
|
||||
description: t("annotation.imageFormatsOnly"),
|
||||
});
|
||||
event.target.value = "";
|
||||
return;
|
||||
@@ -112,12 +132,12 @@ export function AnnotationSettingsPanel({
|
||||
const dataUrl = e.target?.result as string;
|
||||
if (dataUrl) {
|
||||
onContentChange(dataUrl);
|
||||
toast.success("Image uploaded successfully!");
|
||||
toast.success(t("annotation.imageUploadSuccess"));
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
toast.error("Failed to upload image", {
|
||||
toast.error(t("annotation.failedImageUpload"), {
|
||||
description: "There was an error reading the file.",
|
||||
});
|
||||
};
|
||||
@@ -127,39 +147,39 @@ export function AnnotationSettingsPanel({
|
||||
};
|
||||
|
||||
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">Annotation Settings</span>
|
||||
<span className="text-[10px] uppercase tracking-wider font-medium text-[#34B27B] bg-[#34B27B]/10 px-2 py-1 rounded-full">
|
||||
Active
|
||||
<div className="min-w-0 p-4 flex flex-col h-full overflow-y-auto custom-scrollbar">
|
||||
<div className="mb-3">
|
||||
<div className="mb-4">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">
|
||||
{t("annotation.active")}
|
||||
</span>
|
||||
<div className="mt-1 text-xl font-semibold text-slate-100">{t("annotation.title")}</div>
|
||||
</div>
|
||||
|
||||
{/* Type Selector */}
|
||||
<Tabs
|
||||
value={annotation.type}
|
||||
onValueChange={(value) => onTypeChange(value as AnnotationType)}
|
||||
className="mb-6"
|
||||
className="mb-4"
|
||||
>
|
||||
<TabsList className="mb-4 bg-white/5 border border-white/5 p-1 w-full grid grid-cols-3 h-auto rounded-xl">
|
||||
<TabsList className="mb-4 bg-white/[0.035] border border-white/[0.06] p-0.5 w-full grid grid-cols-3 h-9 rounded-xl">
|
||||
<TabsTrigger
|
||||
value="text"
|
||||
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 rounded-lg transition-all gap-1.5 text-[11px]"
|
||||
>
|
||||
<Type className="w-4 h-4" />
|
||||
Text
|
||||
{t("annotation.typeText")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
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 rounded-lg transition-all gap-1.5 text-[11px]"
|
||||
>
|
||||
<ImageIcon className="w-4 h-4" />
|
||||
Image
|
||||
{t("annotation.typeImage")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="figure"
|
||||
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 rounded-lg transition-all gap-1.5 text-[11px]"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
@@ -170,18 +190,20 @@ export function AnnotationSettingsPanel({
|
||||
>
|
||||
<path d="M4 12h16m0 0l-6-6m6 6l-6 6" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
Arrow
|
||||
{t("annotation.typeArrow")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Text Content */}
|
||||
<TabsContent value="text" className="mt-0 space-y-4">
|
||||
<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
|
||||
value={annotation.textContent || annotation.content}
|
||||
onChange={(e) => onContentChange(e.target.value)}
|
||||
placeholder="Enter your text..."
|
||||
placeholder={t("annotation.textPlaceholder")}
|
||||
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"
|
||||
/>
|
||||
@@ -193,14 +215,14 @@ export function AnnotationSettingsPanel({
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-200 mb-2 block">
|
||||
Font Style
|
||||
{t("annotation.fontStyle")}
|
||||
</label>
|
||||
<Select
|
||||
value={annotation.style.fontFamily}
|
||||
onValueChange={(value) => onStyleChange({ fontFamily: value })}
|
||||
>
|
||||
<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>
|
||||
<SelectContent className="bg-[#1a1a1c] border-white/10 text-slate-200 max-h-[300px]">
|
||||
{FONT_FAMILIES.map((font) => (
|
||||
@@ -209,13 +231,13 @@ export function AnnotationSettingsPanel({
|
||||
value={font.value}
|
||||
style={{ fontFamily: font.value }}
|
||||
>
|
||||
{font.label}
|
||||
{fontStyleLabels[font.labelKey]}
|
||||
</SelectItem>
|
||||
))}
|
||||
{customFonts.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 py-1.5 text-[10px] font-medium text-slate-400 uppercase tracking-wider">
|
||||
Custom Fonts
|
||||
{t("annotation.customFonts")}
|
||||
</div>
|
||||
{customFonts.map((font) => (
|
||||
<SelectItem
|
||||
@@ -232,13 +254,15 @@ export function AnnotationSettingsPanel({
|
||||
</Select>
|
||||
</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
|
||||
value={annotation.style.fontSize.toString()}
|
||||
onValueChange={(value) => onStyleChange({ fontSize: parseInt(value) })}
|
||||
>
|
||||
<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>
|
||||
<SelectContent className="bg-[#1a1a1c] border-white/10 text-slate-200 max-h-[200px]">
|
||||
{FONT_SIZES.map((size) => (
|
||||
@@ -345,7 +369,7 @@ export function AnnotationSettingsPanel({
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-200 mb-2 block">
|
||||
Text Color
|
||||
{t("annotation.textColor")}
|
||||
</label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -363,15 +387,19 @@ export function AnnotationSettingsPanel({
|
||||
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[260px] p-3 bg-[#1a1a1c] border border-white/10 rounded-xl shadow-xl">
|
||||
<Block
|
||||
color={annotation.style.color}
|
||||
colors={colorPalette}
|
||||
onChange={(color) => {
|
||||
onStyleChange({ color: color.hex });
|
||||
<PopoverContent
|
||||
side="top"
|
||||
className="w-[260px] p-3 bg-[#1a1a1c] border border-white/10 rounded-xl shadow-xl"
|
||||
>
|
||||
<ColorPicker
|
||||
selectedColor={annotation.style.color}
|
||||
colorPalette={colorPalette}
|
||||
translations={{
|
||||
colorWheel: t("annotation.colorWheel"),
|
||||
colorPalette: t("annotation.colorPalette"),
|
||||
}}
|
||||
style={{
|
||||
borderRadius: "8px",
|
||||
onUpdateColor={(color) => {
|
||||
onStyleChange({ color: color });
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
@@ -379,7 +407,7 @@ export function AnnotationSettingsPanel({
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-200 mb-2 block">
|
||||
Background
|
||||
{t("annotation.background")}
|
||||
</label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -395,36 +423,30 @@ export function AnnotationSettingsPanel({
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[260px] p-3 bg-[#1a1a1c] border border-white/10 rounded-xl shadow-xl">
|
||||
<Block
|
||||
color={
|
||||
annotation.style.backgroundColor === "transparent"
|
||||
? "#000000"
|
||||
: annotation.style.backgroundColor
|
||||
}
|
||||
colors={colorPalette}
|
||||
onChange={(color) => {
|
||||
onStyleChange({ backgroundColor: color.hex });
|
||||
<PopoverContent
|
||||
side="top"
|
||||
className="w-[260px] p-3 bg-[#1a1a1c] border border-white/10 rounded-xl shadow-xl"
|
||||
>
|
||||
<ColorPicker
|
||||
selectedColor={annotation.style.backgroundColor}
|
||||
colorPalette={colorPalette}
|
||||
translations={{
|
||||
colorWheel: t("annotation.colorWheel"),
|
||||
colorPalette: t("annotation.colorPalette"),
|
||||
clearBackground: t("annotation.clearBackground"),
|
||||
}}
|
||||
style={{
|
||||
borderRadius: "8px",
|
||||
clearBackgroundOption={true}
|
||||
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>
|
||||
</Popover>
|
||||
</div>
|
||||
@@ -447,7 +469,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"
|
||||
>
|
||||
<Upload className="w-5 h-5" />
|
||||
Upload Image
|
||||
{t("annotation.uploadImage")}
|
||||
</Button>
|
||||
|
||||
{annotation.content && annotation.content.startsWith("data:image") && (
|
||||
@@ -461,14 +483,14 @@ export function AnnotationSettingsPanel({
|
||||
)}
|
||||
|
||||
<p className="text-xs text-slate-500 text-center leading-relaxed">
|
||||
Supported formats: JPG, PNG, GIF, WebP
|
||||
{t("annotation.supportedFormats")}
|
||||
</p>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="figure" className="mt-0 space-y-4">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-200 mb-3 block">
|
||||
Arrow Direction
|
||||
{t("annotation.arrowDirection")}
|
||||
</label>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{(
|
||||
@@ -517,7 +539,9 @@ export function AnnotationSettingsPanel({
|
||||
|
||||
<div>
|
||||
<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>
|
||||
<Slider
|
||||
value={[annotation.figureData?.strokeWidth || 4]}
|
||||
@@ -536,7 +560,9 @@ export function AnnotationSettingsPanel({
|
||||
</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>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
@@ -574,36 +600,27 @@ export function AnnotationSettingsPanel({
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<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" />
|
||||
Delete Annotation
|
||||
</Button>
|
||||
<div className="mt-4 grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
onClick={() => onDuplicate?.()}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!onDuplicate}
|
||||
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"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
Duplicate
|
||||
</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">Shortcuts & Tips</span>
|
||||
</div>
|
||||
<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>
|
||||
Use{" "}
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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,
|
||||
MIN_BLUR_BLOCK_SIZE,
|
||||
} 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="min-w-0 p-4 flex flex-col h-full overflow-y-auto custom-scrollbar">
|
||||
<div className="mb-3">
|
||||
<div className="mb-4">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">
|
||||
{t("annotation.blurTypeMosaic")}
|
||||
</span>
|
||||
<div className="mt-1 text-xl font-semibold text-slate-100">
|
||||
{t("annotation.typeBlur")}
|
||||
</div>
|
||||
</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,
|
||||
type: "mosaic",
|
||||
shape: shape.value,
|
||||
};
|
||||
onBlurDataChange(nextBlurData);
|
||||
requestAnimationFrame(() => {
|
||||
onBlurDataCommit?.();
|
||||
});
|
||||
}}
|
||||
className={cn(
|
||||
"h-12 rounded-lg border flex items-center justify-center transition-all p-2 gap-2",
|
||||
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 font-medium">
|
||||
{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.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,
|
||||
type: "mosaic",
|
||||
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 editor-control-surface">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-medium text-slate-300">
|
||||
{t("annotation.mosaicBlockSize")}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400 font-mono">
|
||||
{Math.round(blurRegion.blurData?.blockSize ?? DEFAULT_BLUR_BLOCK_SIZE)}
|
||||
px
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[blurRegion.blurData?.blockSize ?? DEFAULT_BLUR_BLOCK_SIZE]}
|
||||
onValueChange={(values) => {
|
||||
onBlurDataChange({
|
||||
...DEFAULT_BLUR_DATA,
|
||||
...blurRegion.blurData,
|
||||
type: "mosaic",
|
||||
blockSize: values[0],
|
||||
});
|
||||
}}
|
||||
onValueCommit={() => onBlurDataCommit?.()}
|
||||
min={MIN_BLUR_BLOCK_SIZE}
|
||||
max={MAX_BLUR_BLOCK_SIZE}
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,7 +16,7 @@ interface CropControlProps {
|
||||
aspectRatio: AspectRatio;
|
||||
}
|
||||
|
||||
type DragHandle = "top" | "right" | "bottom" | "left" | null;
|
||||
type DragHandle = "top" | "right" | "bottom" | "left" | "move" | null;
|
||||
|
||||
export function CropControl({ videoElement, cropRegion, onCropChange }: CropControlProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
@@ -99,6 +99,11 @@ export function CropControl({ videoElement, cropRegion, onCropChange }: CropCont
|
||||
case "right":
|
||||
newCrop.width = Math.max(0.1, Math.min(initialCrop.width + deltaX, 1 - initialCrop.x));
|
||||
break;
|
||||
case "move": {
|
||||
newCrop.x = Math.max(0, Math.min(initialCrop.x + deltaX, 1 - initialCrop.width));
|
||||
newCrop.y = Math.max(0, Math.min(initialCrop.y + deltaY, 1 - initialCrop.height));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
onCropChange(newCrop);
|
||||
@@ -108,7 +113,9 @@ export function CropControl({ videoElement, cropRegion, onCropChange }: CropCont
|
||||
if (isDragging) {
|
||||
try {
|
||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
} catch {}
|
||||
} catch {
|
||||
// Pointer may already be released; ignore.
|
||||
}
|
||||
}
|
||||
setIsDragging(null);
|
||||
};
|
||||
@@ -176,6 +183,18 @@ export function CropControl({ videoElement, cropRegion, onCropChange }: CropCont
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="absolute z-10 pointer-events-auto cursor-move"
|
||||
style={{
|
||||
left: `${cropPixelX}%`,
|
||||
top: `${cropPixelY}%`,
|
||||
width: `${cropPixelWidth}%`,
|
||||
height: `${cropPixelHeight}%`,
|
||||
transition: "none",
|
||||
}}
|
||||
onPointerDown={(e) => handlePointerDown(e, "move")}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn("absolute h-[3px] cursor-ns-resize z-20 pointer-events-auto bg-[#34B27B]")}
|
||||
style={{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Download, Loader2, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner"; // Add this import
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useScopedT } from "@/contexts/I18nContext";
|
||||
import type { ExportProgress } from "@/lib/exporter";
|
||||
|
||||
interface ExportDialogProps {
|
||||
@@ -13,6 +13,7 @@ interface ExportDialogProps {
|
||||
onCancel?: () => void;
|
||||
exportFormat?: "mp4" | "gif";
|
||||
exportedFilePath?: string;
|
||||
onShowInFolder?: () => void;
|
||||
}
|
||||
|
||||
export function ExportDialog({
|
||||
@@ -23,8 +24,10 @@ export function ExportDialog({
|
||||
error,
|
||||
onCancel,
|
||||
exportFormat = "mp4",
|
||||
exportedFilePath, // Add this line
|
||||
exportedFilePath,
|
||||
onShowInFolder,
|
||||
}: ExportDialogProps) {
|
||||
const t = useScopedT("dialogs");
|
||||
const [showSuccess, setShowSuccess] = useState(false);
|
||||
|
||||
// Reset showSuccess when a new export starts or dialog reopens
|
||||
@@ -64,38 +67,25 @@ export function ExportDialog({
|
||||
|
||||
// Get status message based on phase
|
||||
const getStatusMessage = () => {
|
||||
if (error) return "Please try again";
|
||||
if (error) return t("export.tryAgain");
|
||||
if (isCompiling || isFinalizing) {
|
||||
if (renderProgress !== undefined && renderProgress > 0) {
|
||||
return `Compiling GIF... ${renderProgress}%`;
|
||||
if (exportFormat === "mp4") {
|
||||
return t("export.finalizingVideo");
|
||||
}
|
||||
return "Compiling GIF... This may take a while";
|
||||
if (renderProgress !== undefined && renderProgress > 0) {
|
||||
return t("export.compilingGifProgress", { progress: String(renderProgress) });
|
||||
}
|
||||
return t("export.compilingGifWait");
|
||||
}
|
||||
return "This may take a moment...";
|
||||
return t("export.takeMoment");
|
||||
};
|
||||
|
||||
// Get title based on phase
|
||||
const getTitle = () => {
|
||||
if (error) return "Export Failed";
|
||||
if (isCompiling || isFinalizing) return "Compiling GIF";
|
||||
return `Exporting ${formatLabel}`;
|
||||
};
|
||||
|
||||
const handleClickShowInFolder = async () => {
|
||||
if (exportedFilePath) {
|
||||
try {
|
||||
const result = await window.electronAPI.revealInFolder(exportedFilePath);
|
||||
if (!result.success) {
|
||||
const errorMessage = result.error || result.message || "Failed to reveal item in folder.";
|
||||
console.error("Failed to reveal in folder:", errorMessage);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = String(err);
|
||||
console.error("Error calling revealInFolder IPC:", errorMessage);
|
||||
toast.error(`Error revealing in folder: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
if (error) return t("export.failed");
|
||||
if (isFinalizing && exportFormat === "mp4") return t("export.finalizingVideoTitle");
|
||||
if (isCompiling || isFinalizing) return t("export.compilingGif");
|
||||
return t("export.exportingFormat", { format: formatLabel });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -113,17 +103,19 @@ export function ExportDialog({
|
||||
<Download className="w-6 h-6 text-[#34B27B]" />
|
||||
</div>
|
||||
<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">
|
||||
Your {formatLabel.toLowerCase()} is ready
|
||||
{t("export.yourFormatReady", { format: formatLabel.toLowerCase() })}
|
||||
</span>
|
||||
{exportedFilePath && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleClickShowInFolder}
|
||||
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"
|
||||
>
|
||||
Show in Folder
|
||||
{t("export.showInFolder")}
|
||||
</Button>
|
||||
)}
|
||||
{exportedFilePath && (
|
||||
@@ -178,7 +170,11 @@ export function ExportDialog({
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<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">
|
||||
{isCompiling || isFinalizing ? (
|
||||
renderProgress !== undefined && renderProgress > 0 ? (
|
||||
@@ -186,7 +182,7 @@ export function ExportDialog({
|
||||
) : (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
Processing...
|
||||
{t("export.processing")}
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
@@ -230,15 +226,19 @@ export function ExportDialog({
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<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">
|
||||
{isCompiling || isFinalizing ? "Status" : "Format"}
|
||||
{isCompiling || isFinalizing ? t("export.status") : t("export.format")}
|
||||
</div>
|
||||
<div className="text-slate-200 font-medium text-sm">
|
||||
{isCompiling || isFinalizing ? "Compiling..." : formatLabel}
|
||||
{isFinalizing && exportFormat === "mp4"
|
||||
? t("export.finalizing")
|
||||
: isCompiling || isFinalizing
|
||||
? t("export.compilingStatus")
|
||||
: formatLabel}
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
Frames
|
||||
{t("export.frames")}
|
||||
</div>
|
||||
<div className="text-slate-200 font-medium text-sm">
|
||||
{progress.currentFrame} / {progress.totalFrames}
|
||||
@@ -253,7 +253,7 @@ export function ExportDialog({
|
||||
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"
|
||||
>
|
||||
Cancel Export
|
||||
{t("export.cancelExport")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -262,7 +262,9 @@ export function ExportDialog({
|
||||
|
||||
{showSuccess && (
|
||||
<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>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Film, Image } from "lucide-react";
|
||||
import { useScopedT } from "@/contexts/I18nContext";
|
||||
import type { ExportFormat } from "@/lib/exporter/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -8,26 +9,9 @@ interface FormatSelectorProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface FormatOption {
|
||||
value: ExportFormat;
|
||||
label: string;
|
||||
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" />,
|
||||
},
|
||||
const formatOptions: Array<{ value: ExportFormat; icon: React.ReactNode }> = [
|
||||
{ value: "mp4", icon: <Film className="w-5 h-5" /> },
|
||||
{ value: "gif", icon: <Image className="w-5 h-5" /> },
|
||||
];
|
||||
|
||||
export function FormatSelector({
|
||||
@@ -35,10 +19,18 @@ export function FormatSelector({
|
||||
onFormatChange,
|
||||
disabled = false,
|
||||
}: 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 (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{formatOptions.map((option) => {
|
||||
const isSelected = selectedFormat === option.value;
|
||||
const labels = formatLabels[option.value];
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
@@ -63,8 +55,8 @@ export function FormatSelector({
|
||||
{option.icon}
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="font-medium text-sm">{option.label}</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">{option.description}</div>
|
||||
<div className="font-medium text-sm">{labels.label}</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">{labels.description}</div>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div className="absolute top-2 right-2 w-2 h-2 rounded-full bg-[#34B27B]" />
|
||||
|
||||
@@ -1,23 +1,11 @@
|
||||
import { HelpCircle, Settings2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useScopedT } from "@/contexts/I18nContext";
|
||||
import { useShortcuts } from "@/contexts/ShortcutsContext";
|
||||
import { formatBinding, SHORTCUT_ACTIONS, SHORTCUT_LABELS } from "@/lib/shortcuts";
|
||||
import { formatShortcut } from "@/utils/platformUtils";
|
||||
import { FIXED_SHORTCUTS, formatBinding, SHORTCUT_ACTIONS } from "@/lib/shortcuts";
|
||||
|
||||
export function KeyboardShortcutsHelp() {
|
||||
const { shortcuts, isMac, openConfig } = useShortcuts();
|
||||
|
||||
const [scrollLabels, setScrollLabels] = useState({
|
||||
pan: "Shift + Ctrl + Scroll",
|
||||
zoom: "Ctrl + Scroll",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
formatShortcut(["shift", "mod", "Scroll"]),
|
||||
formatShortcut(["mod", "Scroll"]),
|
||||
]).then(([pan, zoom]) => setScrollLabels({ pan, zoom }));
|
||||
}, []);
|
||||
const t = useScopedT("shortcuts");
|
||||
|
||||
return (
|
||||
<div className="relative group">
|
||||
@@ -25,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="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
|
||||
type="button"
|
||||
onClick={openConfig}
|
||||
@@ -33,39 +21,36 @@ export function KeyboardShortcutsHelp() {
|
||||
className="flex items-center gap-1 text-[10px] text-slate-500 hover:text-[#34B27B] transition-colors"
|
||||
>
|
||||
<Settings2 className="w-3 h-3" />
|
||||
Customize
|
||||
{t("customize")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 text-[10px]">
|
||||
{SHORTCUT_ACTIONS.map((action) => (
|
||||
<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">
|
||||
{formatBinding(shortcuts[action], isMac)}
|
||||
</kbd>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="pt-1 border-t border-white/5 mt-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400">Pan Timeline</span>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">
|
||||
{scrollLabels.pan}
|
||||
</kbd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-1.5">
|
||||
<span className="text-slate-400">Zoom Timeline</span>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">
|
||||
{scrollLabels.zoom}
|
||||
</kbd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-1.5">
|
||||
<span className="text-slate-400">Cycle Annotations</span>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">
|
||||
Tab
|
||||
</kbd>
|
||||
</div>
|
||||
<div className="pt-1 border-t border-white/5 mt-1 space-y-1.5">
|
||||
{FIXED_SHORTCUTS.map((fixed) => (
|
||||
<div key={fixed.i18nKey} className="flex items-center justify-between">
|
||||
<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">
|
||||
{isMac
|
||||
? fixed.display
|
||||
.replace(/Ctrl/g, "⌘")
|
||||
.replace(/Shift/g, "⇧")
|
||||
.replace(/Alt/g, "⌥")
|
||||
: fixed.display}
|
||||
</kbd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 { Button } from "../ui/button";
|
||||
|
||||
@@ -6,6 +7,8 @@ interface PlaybackControlsProps {
|
||||
isPlaying: boolean;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
isFullscreen?: boolean;
|
||||
onToggleFullscreen?: () => void;
|
||||
onTogglePlayPause: () => void;
|
||||
onSeek: (time: number) => void;
|
||||
}
|
||||
@@ -14,9 +17,13 @@ export default function PlaybackControls({
|
||||
isPlaying,
|
||||
currentTime,
|
||||
duration,
|
||||
isFullscreen = false,
|
||||
onToggleFullscreen,
|
||||
onTogglePlayPause,
|
||||
onSeek,
|
||||
}: PlaybackControlsProps) {
|
||||
const t = useScopedT("common");
|
||||
|
||||
function formatTime(seconds: number) {
|
||||
if (!isFinite(seconds) || isNaN(seconds) || seconds < 0) return "0:00";
|
||||
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 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 ? (
|
||||
<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]">
|
||||
{formatTime(duration)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useScopedT } from "@/contexts/I18nContext";
|
||||
import { useShortcuts } from "@/contexts/ShortcutsContext";
|
||||
import {
|
||||
DEFAULT_SHORTCUTS,
|
||||
@@ -16,7 +17,6 @@ import {
|
||||
findConflict,
|
||||
formatBinding,
|
||||
SHORTCUT_ACTIONS,
|
||||
SHORTCUT_LABELS,
|
||||
type ShortcutAction,
|
||||
type ShortcutBinding,
|
||||
type ShortcutConflict,
|
||||
@@ -28,6 +28,8 @@ const MODIFIER_KEYS = new Set(["Control", "Shift", "Alt", "Meta"]);
|
||||
export function ShortcutsConfigDialog() {
|
||||
const { shortcuts, isMac, isConfigOpen, closeConfig, setShortcuts, persistShortcuts } =
|
||||
useShortcuts();
|
||||
const t = useScopedT("shortcuts");
|
||||
const tc = useScopedT("common");
|
||||
|
||||
const [draft, setDraft] = useState<ShortcutsConfig>(shortcuts);
|
||||
const [captureFor, setCaptureFor] = useState<ShortcutAction | null>(null);
|
||||
@@ -70,7 +72,7 @@ export function ShortcutsConfigDialog() {
|
||||
setCaptureFor(null);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -84,7 +86,7 @@ export function ShortcutsConfigDialog() {
|
||||
|
||||
window.addEventListener("keydown", handleCapture, { capture: true });
|
||||
return () => window.removeEventListener("keydown", handleCapture, { capture: true });
|
||||
}, [captureFor]);
|
||||
}, [captureFor, draft, t]);
|
||||
|
||||
const handleSwap = useCallback(() => {
|
||||
if (!conflict || conflict.conflictWith.type !== "configurable") return;
|
||||
@@ -102,14 +104,14 @@ export function ShortcutsConfigDialog() {
|
||||
const handleSave = useCallback(async () => {
|
||||
setShortcuts(draft);
|
||||
await persistShortcuts(draft);
|
||||
toast.success("Keyboard shortcuts saved");
|
||||
toast.success(t("savedToast"));
|
||||
closeConfig();
|
||||
}, [draft, setShortcuts, persistShortcuts, closeConfig]);
|
||||
}, [draft, setShortcuts, persistShortcuts, closeConfig, t]);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
setDraft({ ...DEFAULT_SHORTCUTS });
|
||||
toast.info("Reset to default shortcuts — click Save to apply");
|
||||
}, []);
|
||||
toast.info(t("resetToast"));
|
||||
}, [t]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setCaptureFor(null);
|
||||
@@ -124,96 +126,99 @@ export function ShortcutsConfigDialog() {
|
||||
if (!open) handleClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="bg-[#09090b] border-white/10 text-white max-w-[420px]">
|
||||
<DialogHeader>
|
||||
<DialogContent className="bg-[#09090b] border-white/10 text-white max-w-[420px] max-h-[85vh] flex flex-col">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle className="flex items-center gap-2 text-sm">
|
||||
<Keyboard className="w-4 h-4 text-[#34B27B]" />
|
||||
Keyboard Shortcuts
|
||||
{t("title")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[10px] text-slate-500 mb-2 uppercase tracking-wide font-semibold">
|
||||
Configurable
|
||||
</p>
|
||||
{SHORTCUT_ACTIONS.map((action) => {
|
||||
const isCapturing = captureFor === action;
|
||||
const hasConflict = conflict?.forAction === action;
|
||||
return (
|
||||
<div key={action}>
|
||||
<div className="flex items-center justify-between py-1.5 px-1 border-b border-white/5">
|
||||
<span className="text-sm text-slate-300">{SHORTCUT_LABELS[action]}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setConflict(null);
|
||||
setCaptureFor(isCapturing ? null : action);
|
||||
}}
|
||||
title={isCapturing ? "Press Esc to cancel" : "Click to change"}
|
||||
className={[
|
||||
"px-2 py-1 rounded text-xs font-mono border transition-all min-w-[90px] text-center select-none",
|
||||
isCapturing
|
||||
? "bg-[#34B27B]/20 border-[#34B27B] text-[#34B27B] animate-pulse"
|
||||
: hasConflict
|
||||
? "bg-amber-500/10 border-amber-500/50 text-amber-400"
|
||||
: "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>
|
||||
</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">
|
||||
⚠ 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 className="flex-1 min-h-0 overflow-y-auto pr-1 -mr-1">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[10px] text-slate-500 mb-2 uppercase tracking-wide font-semibold">
|
||||
{t("configurable")}
|
||||
</p>
|
||||
{SHORTCUT_ACTIONS.map((action) => {
|
||||
const isCapturing = captureFor === action;
|
||||
const hasConflict = conflict?.forAction === action;
|
||||
return (
|
||||
<div key={action}>
|
||||
<div className="flex items-center justify-between py-1.5 px-1 border-b border-white/5">
|
||||
<span className="text-sm text-slate-300">{t(`actions.${action}`)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setConflict(null);
|
||||
setCaptureFor(isCapturing ? null : action);
|
||||
}}
|
||||
title={isCapturing ? t("pressEscToCancel") : t("clickToChange")}
|
||||
className={[
|
||||
"px-2 py-1 rounded text-xs font-mono border transition-all min-w-[90px] text-center select-none",
|
||||
isCapturing
|
||||
? "bg-[#34B27B]/20 border-[#34B27B] text-[#34B27B] animate-pulse"
|
||||
: hasConflict
|
||||
? "bg-amber-500/10 border-amber-500/50 text-amber-400"
|
||||
: "bg-white/5 border-white/10 text-slate-200 hover:border-[#34B27B]/50 hover:text-[#34B27B] cursor-pointer",
|
||||
].join(" ")}
|
||||
>
|
||||
{isCapturing ? t("pressKey") : formatBinding(draft[action], isMac)}
|
||||
</button>
|
||||
</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>
|
||||
|
||||
<p className="text-[10px] text-slate-500 mt-1">{t("helpText")}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-0.5 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">
|
||||
<DialogFooter className="shrink-0 flex gap-2 sm:justify-between mt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -221,18 +226,18 @@ export function ShortcutsConfigDialog() {
|
||||
onClick={handleReset}
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
Reset to defaults
|
||||
{t("resetToDefaults")}
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={handleClose}>
|
||||
Cancel
|
||||
{tc("actions.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-[#34B27B] hover:bg-[#2d9e6c] text-white"
|
||||
onClick={handleSave}
|
||||
>
|
||||
Save
|
||||
{tc("actions.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -8,8 +8,10 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useScopedT } from "@/contexts/I18nContext";
|
||||
|
||||
export function TutorialHelp() {
|
||||
const t = useScopedT("dialogs");
|
||||
return (
|
||||
<Dialog>
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl bg-[#09090b] border-white/10 [&>button]:text-slate-400 [&>button:hover]:text-white">
|
||||
<DialogHeader>
|
||||
<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>
|
||||
<DialogDescription className="text-slate-400">
|
||||
Understanding how to cut out unwanted parts of your video.
|
||||
{t("tutorial.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="mt-4 space-y-8">
|
||||
{/* Explanation */}
|
||||
<div className="bg-white/5 rounded-lg p-4 border border-white/5">
|
||||
<p className="text-slate-300 leading-relaxed">
|
||||
The Trim tool works by defining the segments you want to
|
||||
<span className="text-[#ef4444] font-bold"> remove</span>. Any part of the timeline
|
||||
that is
|
||||
<span className="text-[#ef4444] font-bold"> covered</span> by a red trim segment will
|
||||
be cut out when you export.
|
||||
{t("tutorial.explanationBefore")}
|
||||
<span className="text-[#ef4444] font-bold"> {t("tutorial.remove")}</span>
|
||||
{t("tutorial.explanationMiddle")}
|
||||
<span className="text-[#ef4444] font-bold"> {t("tutorial.covered")}</span>
|
||||
{t("tutorial.explanationAfter")}
|
||||
</p>
|
||||
</div>
|
||||
{/* Visual Illustration */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium text-slate-400 uppercase tracking-wider">
|
||||
Visual Example
|
||||
{t("tutorial.visualExample")}
|
||||
</h3>
|
||||
<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) */}
|
||||
@@ -58,7 +60,7 @@ export function TutorialHelp() {
|
||||
style={{ width: "20%" }}
|
||||
>
|
||||
<span className="text-[10px] font-bold text-[#ef4444] bg-black/50 px-1 rounded">
|
||||
REMOVED
|
||||
{t("tutorial.removed")}
|
||||
</span>
|
||||
</div>
|
||||
{/* Removed Segment 2 */}
|
||||
@@ -67,13 +69,19 @@ export function TutorialHelp() {
|
||||
style={{ width: "15%" }}
|
||||
>
|
||||
<span className="text-[10px] font-bold text-[#ef4444] bg-black/50 px-1 rounded">
|
||||
REMOVED
|
||||
{t("tutorial.removed")}
|
||||
</span>
|
||||
</div>
|
||||
{/* Labels for kept parts */}
|
||||
<div className="absolute left-[5%] text-[10px] text-slate-400 font-medium">Kept</div>
|
||||
<div className="absolute left-[50%] text-[10px] text-slate-400 font-medium">Kept</div>
|
||||
<div className="absolute left-[90%] text-[10px] text-slate-400 font-medium">Kept</div>
|
||||
<div className="absolute left-[5%] text-[10px] text-slate-400 font-medium">
|
||||
{t("tutorial.kept")}
|
||||
</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 className="flex justify-center mt-2">
|
||||
<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"
|
||||
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
|
||||
className="h-8 bg-slate-700 rounded flex items-center justify-center opacity-80"
|
||||
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
|
||||
className="h-8 bg-slate-700 rounded flex items-center justify-center opacity-80"
|
||||
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>
|
||||
<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>
|
||||
{/* Steps */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<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">
|
||||
Press
|
||||
{t("tutorial.step1DescriptionBefore")}
|
||||
<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>
|
||||
</div>
|
||||
<div className="p-3 rounded bg-white/5 border border-white/5">
|
||||
<div className="text-[#ef4444] font-bold mb-1">2. Adjust</div>
|
||||
<p className="text-xs text-slate-400">
|
||||
Drag the edges of the red region to cover exactly what you want to cut out.
|
||||
</p>
|
||||
<div className="text-[#ef4444] font-bold mb-1">{t("tutorial.step2Title")}</div>
|
||||
<p className="text-xs text-slate-400">{t("tutorial.step2Description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Save, Trash2 } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useScopedT } from "@/contexts/I18nContext";
|
||||
|
||||
interface UnsavedChangesDialogProps {
|
||||
isOpen: boolean;
|
||||
onSaveAndClose: () => void;
|
||||
onDiscardAndClose: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function UnsavedChangesDialog({
|
||||
isOpen,
|
||||
onSaveAndClose,
|
||||
onDiscardAndClose,
|
||||
onCancel,
|
||||
}: UnsavedChangesDialogProps) {
|
||||
const td = useScopedT("dialogs");
|
||||
const tc = useScopedT("common");
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onCancel()}>
|
||||
<DialogContent className="bg-[#09090b] border-white/10 rounded-2xl max-w-sm p-6 gap-0">
|
||||
<DialogHeader className="mb-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<img
|
||||
src="./openscreen.png"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="w-9 h-9 rounded-xl flex-shrink-0"
|
||||
/>
|
||||
<DialogTitle className="text-base font-semibold text-slate-200 leading-tight">
|
||||
{td("unsavedChanges.title")}
|
||||
</DialogTitle>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<p className="text-sm text-slate-300 mb-1">{td("unsavedChanges.message")}</p>
|
||||
<DialogDescription className="text-sm text-slate-500 mb-6">
|
||||
{td("unsavedChanges.detail")}
|
||||
</DialogDescription>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSaveAndClose}
|
||||
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 rounded-lg bg-[#34B27B] hover:bg-[#2d9e6c] active:bg-[#27885c] text-white font-medium text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-[#34B27B] focus-visible:ring-offset-2 focus-visible:ring-offset-[#09090b]"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{td("unsavedChanges.saveAndClose")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDiscardAndClose}
|
||||
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 rounded-lg bg-white/5 hover:bg-red-500/15 border border-white/10 hover:border-red-500/30 text-slate-300 hover:text-red-400 font-medium text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-white/30 focus-visible:ring-offset-2 focus-visible:ring-offset-[#09090b]"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{td("unsavedChanges.discardAndClose")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 rounded-lg hover:bg-white/5 text-slate-500 hover:text-slate-300 font-medium text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-white/20 focus-visible:ring-offset-2 focus-visible:ring-offset-[#09090b]"
|
||||
>
|
||||
{tc("actions.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,258 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createProjectData,
|
||||
createProjectSnapshot,
|
||||
hasProjectUnsavedChanges,
|
||||
normalizeProjectEditor,
|
||||
PROJECT_VERSION,
|
||||
resolveProjectMedia,
|
||||
validateProjectData,
|
||||
} from "./projectPersistence";
|
||||
|
||||
describe("projectPersistence media compatibility", () => {
|
||||
it("accepts legacy projects with a single videoPath", () => {
|
||||
const project = {
|
||||
version: 1,
|
||||
videoPath: "/tmp/screen.webm",
|
||||
editor: {},
|
||||
};
|
||||
|
||||
expect(validateProjectData(project)).toBe(true);
|
||||
expect(resolveProjectMedia(project)).toEqual({
|
||||
screenVideoPath: "/tmp/screen.webm",
|
||||
});
|
||||
});
|
||||
|
||||
it("creates version 2 projects with explicit media", () => {
|
||||
const project = createProjectData(
|
||||
{
|
||||
screenVideoPath: "/tmp/screen.webm",
|
||||
webcamVideoPath: "/tmp/webcam.webm",
|
||||
},
|
||||
{
|
||||
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",
|
||||
webcamPosition: null,
|
||||
exportQuality: "good",
|
||||
exportFormat: "mp4",
|
||||
gifFrameRate: 15,
|
||||
gifLoop: true,
|
||||
gifSizePreset: "medium",
|
||||
},
|
||||
);
|
||||
|
||||
expect(project.version).toBe(PROJECT_VERSION);
|
||||
expect(project.media).toEqual({
|
||||
screenVideoPath: "/tmp/screen.webm",
|
||||
webcamVideoPath: "/tmp/webcam.webm",
|
||||
});
|
||||
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("mosaic");
|
||||
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,34 +1,68 @@
|
||||
import { normalizeBlurColor, normalizeBlurType } from "@/lib/blurEffects";
|
||||
import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter";
|
||||
import { ASPECT_RATIOS, type AspectRatio } from "@/utils/aspectRatioUtils";
|
||||
import type { ProjectMedia } from "@/lib/recordingSession";
|
||||
import { normalizeProjectMedia } from "@/lib/recordingSession";
|
||||
import { DEFAULT_WALLPAPER, WALLPAPER_PATHS } from "@/lib/wallpaper";
|
||||
import { ASPECT_RATIOS, type AspectRatio, isPortraitAspectRatio } from "@/utils/aspectRatioUtils";
|
||||
import {
|
||||
type AnnotationRegion,
|
||||
type CropRegion,
|
||||
clampPlaybackSpeed,
|
||||
DEFAULT_ANNOTATION_POSITION,
|
||||
DEFAULT_ANNOTATION_SIZE,
|
||||
DEFAULT_ANNOTATION_STYLE,
|
||||
DEFAULT_BLUR_BLOCK_SIZE,
|
||||
DEFAULT_BLUR_DATA,
|
||||
DEFAULT_BLUR_FREEHAND_POINTS,
|
||||
DEFAULT_BLUR_INTENSITY,
|
||||
DEFAULT_CROP_REGION,
|
||||
DEFAULT_FIGURE_DATA,
|
||||
DEFAULT_PLAYBACK_SPEED,
|
||||
DEFAULT_WEBCAM_LAYOUT_PRESET,
|
||||
DEFAULT_WEBCAM_MASK_SHAPE,
|
||||
DEFAULT_WEBCAM_POSITION,
|
||||
DEFAULT_WEBCAM_SIZE_PRESET,
|
||||
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 TrimRegion,
|
||||
type WebcamLayoutPreset,
|
||||
type WebcamMaskShape,
|
||||
type WebcamPosition,
|
||||
type WebcamSizePreset,
|
||||
type ZoomRegion,
|
||||
} from "./types";
|
||||
|
||||
const WALLPAPER_COUNT = 18;
|
||||
const VALID_BLUR_SHAPES = new Set(["rectangle", "oval", "freehand"] as const);
|
||||
|
||||
export const WALLPAPER_PATHS = Array.from(
|
||||
{ length: WALLPAPER_COUNT },
|
||||
(_, i) => `/wallpapers/wallpaper${i + 1}.jpg`,
|
||||
);
|
||||
// Pre-fix projects could persist resolved file:// URLs (machine-specific) for
|
||||
// bundled wallpapers. Rewrite only paths that match a known install layout
|
||||
// (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);
|
||||
|
||||
export const PROJECT_VERSION = 1;
|
||||
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 interface ProjectEditorState {
|
||||
wallpaper: string;
|
||||
shadowIntensity: number;
|
||||
showBlur: boolean;
|
||||
motionBlurEnabled: boolean;
|
||||
motionBlurAmount: number;
|
||||
borderRadius: number;
|
||||
padding: number;
|
||||
cropRegion: CropRegion;
|
||||
@@ -37,45 +71,118 @@ export interface ProjectEditorState {
|
||||
speedRegions: SpeedRegion[];
|
||||
annotationRegions: AnnotationRegion[];
|
||||
aspectRatio: AspectRatio;
|
||||
webcamLayoutPreset: WebcamLayoutPreset;
|
||||
webcamMaskShape: WebcamMaskShape;
|
||||
webcamSizePreset: WebcamSizePreset;
|
||||
webcamPosition: WebcamPosition | null;
|
||||
exportQuality: ExportQuality;
|
||||
exportFormat: ExportFormat;
|
||||
gifFrameRate: GifFrameRate;
|
||||
gifLoop: boolean;
|
||||
gifSizePreset: GifSizePreset;
|
||||
cursorHighlight: import("./videoPlayback/cursorHighlight").CursorHighlightConfig;
|
||||
}
|
||||
|
||||
export interface EditorProjectData {
|
||||
version: number;
|
||||
videoPath: string;
|
||||
media?: ProjectMedia;
|
||||
editor: ProjectEditorState;
|
||||
videoPath?: string;
|
||||
}
|
||||
|
||||
function isFiniteNumber(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value);
|
||||
}
|
||||
|
||||
function computeNormalizedWebcamLayoutPreset(
|
||||
webcamLayoutPreset: Partial<ProjectEditorState>["webcamLayoutPreset"],
|
||||
normalizedAspectRatio: AspectRatio,
|
||||
): WebcamLayoutPreset {
|
||||
switch (webcamLayoutPreset) {
|
||||
case "picture-in-picture":
|
||||
case "no-webcam":
|
||||
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) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function isFileUrl(value: string): boolean {
|
||||
return /^file:\/\//i.test(value);
|
||||
}
|
||||
|
||||
function encodePathSegments(pathname: string, keepWindowsDrive = false): string {
|
||||
return pathname
|
||||
.split("/")
|
||||
.map((segment, index) => {
|
||||
if (!segment) return "";
|
||||
if (keepWindowsDrive && index === 1 && /^[a-zA-Z]:$/.test(segment)) {
|
||||
return segment;
|
||||
}
|
||||
return encodeURIComponent(segment);
|
||||
})
|
||||
.join("/");
|
||||
}
|
||||
|
||||
export function toFileUrl(filePath: string): string {
|
||||
const normalized = filePath.replace(/\\/g, "/");
|
||||
if (normalized.match(/^[a-zA-Z]:/)) {
|
||||
return `file:///${normalized}`;
|
||||
|
||||
// Windows drive path: C:/Users/...
|
||||
if (/^[a-zA-Z]:\//.test(normalized)) {
|
||||
return `file://${encodePathSegments(`/${normalized}`, true)}`;
|
||||
}
|
||||
return `file://${normalized}`;
|
||||
|
||||
// UNC path: //server/share/...
|
||||
if (normalized.startsWith("//")) {
|
||||
const [host, ...pathParts] = normalized.replace(/^\/+/, "").split("/");
|
||||
const encodedPath = pathParts.map((part) => encodeURIComponent(part)).join("/");
|
||||
return encodedPath ? `file://${host}/${encodedPath}` : `file://${host}/`;
|
||||
}
|
||||
|
||||
const absolutePath = normalized.startsWith("/") ? normalized : `/${normalized}`;
|
||||
return `file://${encodePathSegments(absolutePath)}`;
|
||||
}
|
||||
|
||||
export function fromFileUrl(fileUrl: string): string {
|
||||
if (!fileUrl.startsWith("file://")) {
|
||||
const value = fileUrl.trim();
|
||||
if (!isFileUrl(value)) {
|
||||
return fileUrl;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(fileUrl);
|
||||
return decodeURIComponent(url.pathname);
|
||||
const url = new URL(value);
|
||||
const pathname = decodeURIComponent(url.pathname);
|
||||
|
||||
if (url.host && url.host !== "localhost") {
|
||||
return `//${url.host}${pathname}`;
|
||||
}
|
||||
|
||||
if (/^\/[a-zA-Z]:/.test(pathname)) {
|
||||
return pathname.slice(1);
|
||||
}
|
||||
|
||||
return pathname;
|
||||
} catch {
|
||||
return fileUrl.replace(/^file:\/\//, "");
|
||||
const rawFallbackPath = value.replace(/^file:\/\//i, "");
|
||||
let fallbackPath = rawFallbackPath;
|
||||
try {
|
||||
fallbackPath = decodeURIComponent(rawFallbackPath);
|
||||
} catch {
|
||||
// Keep raw best-effort path if percent decoding fails.
|
||||
}
|
||||
return fallbackPath.replace(/^\/([a-zA-Z]:)/, "$1");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,13 +200,48 @@ export function validateProjectData(candidate: unknown): candidate is EditorProj
|
||||
if (!candidate || typeof candidate !== "object") return false;
|
||||
const project = candidate as Partial<EditorProjectData>;
|
||||
if (typeof project.version !== "number") return false;
|
||||
if (typeof project.videoPath !== "string" || !project.videoPath) return false;
|
||||
if (!resolveProjectMedia(project)) return false;
|
||||
if (!project.editor || typeof project.editor !== "object") return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function resolveProjectMedia(
|
||||
candidate: Partial<EditorProjectData> | { media?: unknown; videoPath?: unknown },
|
||||
): ProjectMedia | null {
|
||||
const media = normalizeProjectMedia(candidate.media);
|
||||
if (media) {
|
||||
return media;
|
||||
}
|
||||
|
||||
if (typeof candidate.videoPath === "string" && candidate.videoPath.trim()) {
|
||||
return { screenVideoPath: candidate.videoPath };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): ProjectEditorState {
|
||||
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)
|
||||
? editor.zoomRegions
|
||||
@@ -110,6 +252,12 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
|
||||
const endMs = Math.max(startMs + 1, rawEnd);
|
||||
|
||||
const validPreset =
|
||||
region.rotationPreset === "iso" ||
|
||||
region.rotationPreset === "left" ||
|
||||
region.rotationPreset === "right"
|
||||
? region.rotationPreset
|
||||
: undefined;
|
||||
return {
|
||||
id: region.id,
|
||||
startMs,
|
||||
@@ -119,6 +267,8 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
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),
|
||||
},
|
||||
focusMode: region.focusMode === "auto" ? "auto" : "manual",
|
||||
...(validPreset ? { rotationPreset: validPreset } : {}),
|
||||
};
|
||||
})
|
||||
: [];
|
||||
@@ -149,14 +299,10 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
const endMs = Math.max(startMs + 1, rawEnd);
|
||||
|
||||
const speed =
|
||||
region.speed === 0.25 ||
|
||||
region.speed === 0.5 ||
|
||||
region.speed === 0.75 ||
|
||||
region.speed === 1.25 ||
|
||||
region.speed === 1.5 ||
|
||||
region.speed === 1.75 ||
|
||||
region.speed === 2
|
||||
? region.speed
|
||||
isFiniteNumber(region.speed) &&
|
||||
region.speed >= MIN_PLAYBACK_SPEED &&
|
||||
region.speed <= MAX_PLAYBACK_SPEED
|
||||
? clampPlaybackSpeed(region.speed)
|
||||
: DEFAULT_PLAYBACK_SPEED;
|
||||
|
||||
return {
|
||||
@@ -178,12 +324,22 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000;
|
||||
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
|
||||
const endMs = Math.max(startMs + 1, rawEnd);
|
||||
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 {
|
||||
id: region.id,
|
||||
startMs,
|
||||
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 : "",
|
||||
textContent: typeof region.textContent === "string" ? region.textContent : undefined,
|
||||
imageContent: typeof region.imageContent === "string" ? region.imageContent : undefined,
|
||||
@@ -230,6 +386,42 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
...region.figureData,
|
||||
}
|
||||
: 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,
|
||||
};
|
||||
})
|
||||
: [];
|
||||
@@ -253,11 +445,19 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
const cropHeight = clamp(rawCropHeight, 0.01, 1 - cropY);
|
||||
|
||||
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,
|
||||
showBlur: typeof editor.showBlur === "boolean" ? editor.showBlur : false,
|
||||
motionBlurEnabled:
|
||||
typeof editor.motionBlurEnabled === "boolean" ? editor.motionBlurEnabled : false,
|
||||
motionBlurAmount: isFiniteNumber(editor.motionBlurAmount)
|
||||
? clamp(editor.motionBlurAmount, 0, 1)
|
||||
: typeof (editor as { motionBlurEnabled?: unknown }).motionBlurEnabled === "boolean"
|
||||
? (editor as { motionBlurEnabled?: boolean }).motionBlurEnabled
|
||||
? 0.35
|
||||
: 0
|
||||
: 0,
|
||||
borderRadius: typeof editor.borderRadius === "number" ? editor.borderRadius : 0,
|
||||
padding: isFiniteNumber(editor.padding) ? clamp(editor.padding, 0, 100) : 50,
|
||||
cropRegion: {
|
||||
@@ -270,8 +470,20 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
trimRegions: normalizedTrimRegions,
|
||||
speedRegions: normalizedSpeedRegions,
|
||||
annotationRegions: normalizedAnnotationRegions,
|
||||
aspectRatio:
|
||||
editor.aspectRatio && validAspectRatios.has(editor.aspectRatio) ? editor.aspectRatio : "16:9",
|
||||
aspectRatio: normalizedAspectRatio,
|
||||
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:
|
||||
editor.exportQuality === "medium" || editor.exportQuality === "source"
|
||||
? editor.exportQuality
|
||||
@@ -291,16 +503,78 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
editor.gifSizePreset === "original"
|
||||
? editor.gifSizePreset
|
||||
: "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,
|
||||
};
|
||||
}
|
||||
|
||||
export function createProjectData(
|
||||
videoPath: string,
|
||||
media: ProjectMedia,
|
||||
editor: ProjectEditorState,
|
||||
): EditorProjectData {
|
||||
return {
|
||||
version: PROJECT_VERSION,
|
||||
videoPath,
|
||||
media,
|
||||
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 { 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 { useScopedT } from "@/contexts/I18nContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import glassStyles from "./ItemGlass.module.css";
|
||||
|
||||
@@ -13,8 +14,10 @@ interface ItemProps {
|
||||
isSelected?: boolean;
|
||||
onSelect?: () => void;
|
||||
zoomDepth?: number;
|
||||
zoomCustomScale?: number;
|
||||
speedValue?: number;
|
||||
variant?: "zoom" | "trim" | "annotation" | "speed";
|
||||
isAutoFocus?: boolean;
|
||||
variant?: "zoom" | "trim" | "annotation" | "speed" | "blur";
|
||||
}
|
||||
|
||||
// Map zoom depth to multiplier labels
|
||||
@@ -44,10 +47,13 @@ export default function Item({
|
||||
isSelected = false,
|
||||
onSelect,
|
||||
zoomDepth = 1,
|
||||
zoomCustomScale,
|
||||
speedValue,
|
||||
isAutoFocus = false,
|
||||
variant = "zoom",
|
||||
children,
|
||||
}: ItemProps) {
|
||||
const t = useScopedT("timeline");
|
||||
const { setNodeRef, attributes, listeners, itemStyle, itemContentStyle } = useItem({
|
||||
id,
|
||||
span,
|
||||
@@ -95,7 +101,7 @@ export default function Item({
|
||||
"w-full h-full overflow-hidden flex items-center justify-center gap-1.5 cursor-grab active:cursor-grabbing relative",
|
||||
isSelected && glassStyles.selected,
|
||||
)}
|
||||
style={{ height: 40, color: "#fff", minWidth: 24 }}
|
||||
style={{ height: 30, color: "#fff", minWidth: 24 }}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onSelect?.();
|
||||
@@ -124,33 +130,41 @@ export default function Item({
|
||||
title="Resize right"
|
||||
/>
|
||||
{/* Content */}
|
||||
<div className="relative z-10 flex flex-col items-center justify-center text-white/90 opacity-80 group-hover:opacity-100 transition-opacity select-none overflow-hidden">
|
||||
<div className="relative z-10 flex min-w-0 flex-col items-center justify-center text-white/90 opacity-85 group-hover:opacity-100 transition-opacity select-none overflow-hidden px-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isZoom ? (
|
||||
<>
|
||||
<ZoomIn className="w-3.5 h-3.5 shrink-0" />
|
||||
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
|
||||
{ZOOM_LABELS[zoomDepth] || `${zoomDepth}×`}
|
||||
<span className="text-[11px] font-semibold whitespace-nowrap">
|
||||
{zoomCustomScale != null
|
||||
? `${zoomCustomScale.toFixed(2)}×`
|
||||
: ZOOM_LABELS[zoomDepth] || `${zoomDepth}×`}
|
||||
</span>
|
||||
{isAutoFocus && (
|
||||
<MousePointer2
|
||||
className="w-3 h-3 shrink-0 opacity-90"
|
||||
aria-label="Cursor-follow"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : isTrim ? (
|
||||
<>
|
||||
<Scissors className="w-3.5 h-3.5 shrink-0" />
|
||||
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
|
||||
Trim
|
||||
<span className="text-[11px] font-semibold whitespace-nowrap">
|
||||
{t("labels.trim")}
|
||||
</span>
|
||||
</>
|
||||
) : isSpeed ? (
|
||||
<>
|
||||
<Gauge className="w-3.5 h-3.5 shrink-0" />
|
||||
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
|
||||
{speedValue !== undefined ? `${speedValue}×` : "Speed"}
|
||||
<span className="text-[11px] font-semibold whitespace-nowrap">
|
||||
{speedValue !== undefined ? `${speedValue}×` : t("labels.speed")}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MessageSquare 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 truncate whitespace-nowrap">
|
||||
{children}
|
||||
</span>
|
||||
</>
|
||||
|
||||
@@ -1,39 +1,39 @@
|
||||
.glassGreen {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
border-radius: 10px;
|
||||
-corner-smoothing: antialiased;
|
||||
background: rgba(52, 178, 123, 0.15);
|
||||
border: 1px solid rgba(52, 178, 123, 0.3);
|
||||
box-shadow: 0 2px 12px 0 rgba(52, 178, 123, 0.1) inset;
|
||||
margin: 2px 0;
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
background: linear-gradient(180deg, rgba(52, 178, 123, 0.28), rgba(31, 115, 82, 0.2));
|
||||
border: 1px solid rgba(77, 221, 157, 0.36);
|
||||
box-shadow:
|
||||
0 1px 0 rgba(255, 255, 255, 0.1) inset,
|
||||
0 8px 22px rgba(0, 0, 0, 0.22);
|
||||
margin: 3px 0;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.glassGreen:hover {
|
||||
background: rgba(52, 178, 123, 0.25);
|
||||
border-color: rgba(52, 178, 123, 0.5);
|
||||
box-shadow: 0 4px 20px 0 rgba(52, 178, 123, 0.2) inset;
|
||||
background: linear-gradient(180deg, rgba(52, 178, 123, 0.36), rgba(31, 115, 82, 0.25));
|
||||
border-color: rgba(77, 221, 157, 0.62);
|
||||
}
|
||||
|
||||
.glassGreen.selected {
|
||||
background: rgba(52, 178, 123, 0.35);
|
||||
background: linear-gradient(180deg, rgba(52, 178, 123, 0.48), rgba(31, 115, 82, 0.32));
|
||||
border-color: #34b27b;
|
||||
box-shadow:
|
||||
0 0 0 1px #34b27b,
|
||||
0 4px 20px 0 rgba(52, 178, 123, 0.3) inset;
|
||||
0 0 0 1px rgba(52, 178, 123, 0.95),
|
||||
0 0 0 4px rgba(52, 178, 123, 0.14),
|
||||
0 12px 26px rgba(0, 0, 0, 0.28);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.glassRed {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
border-radius: 10px;
|
||||
-corner-smoothing: antialiased;
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
box-shadow: 0 2px 12px 0 rgba(239, 68, 68, 0.1) inset;
|
||||
margin: 2px 0;
|
||||
margin: 3px 0;
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
@@ -56,12 +56,12 @@
|
||||
|
||||
.glassYellow {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
border-radius: 10px;
|
||||
-corner-smoothing: antialiased;
|
||||
background: rgba(180, 160, 70, 0.15);
|
||||
border: 1px solid rgba(180, 160, 70, 0.3);
|
||||
box-shadow: 0 2px 12px 0 rgba(180, 160, 70, 0.1) inset;
|
||||
margin: 2px 0;
|
||||
margin: 3px 0;
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
@@ -84,12 +84,12 @@
|
||||
|
||||
.glassAmber {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
border-radius: 10px;
|
||||
-corner-smoothing: antialiased;
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||
box-shadow: 0 2px 12px 0 rgba(245, 158, 11, 0.1) inset;
|
||||
margin: 2px 0;
|
||||
margin: 3px 0;
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
@@ -137,13 +137,13 @@
|
||||
.zoomEndCap.left {
|
||||
left: 0;
|
||||
cursor: ew-resize;
|
||||
border-top-left-radius: 7px;
|
||||
border-bottom-left-radius: 7px;
|
||||
border-top-left-radius: 9px;
|
||||
border-bottom-left-radius: 9px;
|
||||
}
|
||||
|
||||
.zoomEndCap.right {
|
||||
right: 0;
|
||||
cursor: ew-resize;
|
||||
border-top-right-radius: 7px;
|
||||
border-bottom-right-radius: 7px;
|
||||
border-top-right-radius: 9px;
|
||||
border-bottom-right-radius: 9px;
|
||||
}
|
||||
|
||||
@@ -3,31 +3,21 @@ import { useRow } from "dnd-timeline";
|
||||
|
||||
interface RowProps extends RowDefinition {
|
||||
children: React.ReactNode;
|
||||
label?: string;
|
||||
hint?: string;
|
||||
isEmpty?: boolean;
|
||||
labelColor?: string;
|
||||
}
|
||||
|
||||
export default function Row({ id, children, label, hint, isEmpty, labelColor = "#666" }: RowProps) {
|
||||
export default function Row({ id, children, hint, isEmpty }: RowProps) {
|
||||
const { setNodeRef, rowWrapperStyle, rowStyle } = useRow({ id });
|
||||
|
||||
return (
|
||||
<div
|
||||
className="border-b border-[#18181b] bg-[#18181b] relative"
|
||||
style={{ ...rowWrapperStyle, minHeight: 48, marginBottom: 4 }}
|
||||
className="border-b border-white/[0.055] bg-[#101116] relative overflow-hidden"
|
||||
style={{ ...rowWrapperStyle, minHeight: 36 }}
|
||||
>
|
||||
{label && (
|
||||
<div
|
||||
className="absolute left-1.5 top-1/2 -translate-y-1/2 text-[9px] font-semibold uppercase tracking-widest z-20 pointer-events-none select-none"
|
||||
style={{ color: labelColor, writingMode: "horizontal-tb" }}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
{isEmpty && hint && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none select-none z-10">
|
||||
<span className="text-[11px] text-white/15 font-medium">{hint}</span>
|
||||
<span className="text-[11px] text-white/[0.12] font-medium">{hint}</span>
|
||||
</div>
|
||||
)}
|
||||
<div ref={setNodeRef} style={rowStyle}>
|
||||
|
||||
@@ -20,12 +20,12 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useScopedT } from "@/contexts/I18nContext";
|
||||
import { useShortcuts } from "@/contexts/ShortcutsContext";
|
||||
import { matchesShortcut } from "@/lib/shortcuts";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ASPECT_RATIOS, type AspectRatio, getAspectRatioLabel } from "@/utils/aspectRatioUtils";
|
||||
import { formatShortcut } from "@/utils/platformUtils";
|
||||
import { TutorialHelp } from "../TutorialHelp";
|
||||
import type {
|
||||
AnnotationRegion,
|
||||
CursorTelemetryPoint,
|
||||
@@ -43,6 +43,7 @@ import { detectZoomDwellCandidates, normalizeCursorTelemetry } from "./zoomSugge
|
||||
const ZOOM_ROW_ID = "row-zoom";
|
||||
const TRIM_ROW_ID = "row-trim";
|
||||
const ANNOTATION_ROW_ID = "row-annotation";
|
||||
const BLUR_ROW_ID = "row-blur";
|
||||
const SPEED_ROW_ID = "row-speed";
|
||||
const FALLBACK_RANGE_MS = 1000;
|
||||
const TARGET_MARKER_COUNT = 12;
|
||||
@@ -72,12 +73,22 @@ interface TimelineEditorProps {
|
||||
onAnnotationDelete?: (id: string) => void;
|
||||
selectedAnnotationId?: string | null;
|
||||
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[];
|
||||
onSpeedAdded?: (span: Span) => void;
|
||||
onSpeedSpanChange?: (id: string, span: Span) => void;
|
||||
onSpeedDelete?: (id: string) => void;
|
||||
selectedSpeedId?: string | null;
|
||||
onSelectSpeed?: (id: string | null) => void;
|
||||
canCopySelectedItem?: boolean;
|
||||
canPasteTimelineItem?: boolean;
|
||||
onCopySelectedItem?: () => void;
|
||||
onPasteTimelineItem?: () => void;
|
||||
aspectRatio: AspectRatio;
|
||||
onAspectRatioChange: (aspectRatio: AspectRatio) => void;
|
||||
}
|
||||
@@ -94,8 +105,10 @@ interface TimelineRenderItem {
|
||||
span: Span;
|
||||
label: string;
|
||||
zoomDepth?: number;
|
||||
zoomCustomScale?: number;
|
||||
speedValue?: number;
|
||||
variant: "zoom" | "trim" | "annotation" | "speed";
|
||||
isAutoFocus?: boolean;
|
||||
variant: "zoom" | "trim" | "annotation" | "speed" | "blur";
|
||||
}
|
||||
|
||||
const SCALE_CANDIDATES = [
|
||||
@@ -169,6 +182,33 @@ function createInitialRange(totalMs: number): Range {
|
||||
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) {
|
||||
const totalSeconds = milliseconds / 1000;
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
@@ -204,18 +244,21 @@ function PlaybackCursor({
|
||||
currentTimeMs,
|
||||
videoDurationMs,
|
||||
onSeek,
|
||||
onRangeChange,
|
||||
timelineRef,
|
||||
keyframes = [],
|
||||
}: {
|
||||
currentTimeMs: number;
|
||||
videoDurationMs: number;
|
||||
onSeek?: (time: number) => void;
|
||||
onRangeChange?: (updater: (previous: Range) => Range) => void;
|
||||
timelineRef: React.RefObject<HTMLDivElement>;
|
||||
keyframes?: { id: string; time: number }[];
|
||||
}) {
|
||||
const { sidebarWidth, direction, range, valueToPixels, pixelsToValue } = useTimelineContext();
|
||||
const sideProperty = direction === "rtl" ? "right" : "left";
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragPreviewTimeMs, setDragPreviewTimeMs] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
@@ -225,6 +268,7 @@ function PlaybackCursor({
|
||||
|
||||
const rect = timelineRef.current.getBoundingClientRect();
|
||||
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
|
||||
const relativeMs = pixelsToValue(clickX);
|
||||
@@ -243,11 +287,51 @@ function PlaybackCursor({
|
||||
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);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
setDragPreviewTimeMs(null);
|
||||
document.body.style.cursor = "";
|
||||
};
|
||||
|
||||
@@ -263,6 +347,7 @@ function PlaybackCursor({
|
||||
}, [
|
||||
isDragging,
|
||||
onSeek,
|
||||
onRangeChange,
|
||||
timelineRef,
|
||||
sidebarWidth,
|
||||
range.start,
|
||||
@@ -272,11 +357,14 @@ function PlaybackCursor({
|
||||
keyframes,
|
||||
]);
|
||||
|
||||
if (videoDurationMs <= 0 || currentTimeMs < 0) {
|
||||
const displayTimeMs =
|
||||
isDragging && dragPreviewTimeMs !== null ? dragPreviewTimeMs : currentTimeMs;
|
||||
|
||||
if (videoDurationMs <= 0 || displayTimeMs < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const clampedTime = Math.min(currentTimeMs, videoDurationMs);
|
||||
const clampedTime = Math.min(displayTimeMs, videoDurationMs);
|
||||
|
||||
if (clampedTime < range.start || clampedTime > range.end) {
|
||||
return null;
|
||||
@@ -293,20 +381,21 @@ function PlaybackCursor({
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-[2px] bg-[#34B27B] shadow-[0_0_10px_rgba(52,178,123,0.5)] cursor-ew-resize pointer-events-auto hover:shadow-[0_0_15px_rgba(52,178,123,0.7)] transition-shadow"
|
||||
className="absolute top-0 bottom-0 w-[2px] bg-[#6C55FF] shadow-[0_0_18px_rgba(108,85,255,0.68)] cursor-ew-resize pointer-events-auto hover:shadow-[0_0_24px_rgba(108,85,255,0.85)] transition-shadow"
|
||||
style={{
|
||||
[sideProperty]: `${offset}px`,
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation(); // Prevent timeline click
|
||||
setDragPreviewTimeMs(currentTimeMs);
|
||||
setIsDragging(true);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute -top-1 left-1/2 -translate-x-1/2 hover:scale-125 transition-transform"
|
||||
style={{ width: "16px", height: "16px" }}
|
||||
className="absolute -top-2 left-1/2 -translate-x-1/2 hover:scale-110 transition-transform"
|
||||
style={{ width: "20px", height: "20px" }}
|
||||
>
|
||||
<div className="w-3 h-3 mx-auto mt-[2px] bg-[#34B27B] rotate-45 rounded-sm shadow-lg border border-white/20" />
|
||||
<div className="w-4 h-4 mx-auto mt-[2px] bg-[#6C55FF] rotate-45 rounded-[5px] shadow-lg shadow-[#6C55FF]/30 border border-white/30" />
|
||||
</div>
|
||||
{isDragging && (
|
||||
<div className="absolute -top-6 left-1/2 -translate-x-1/2 px-1.5 py-0.5 rounded bg-black/80 text-[10px] text-white/90 font-medium tabular-nums whitespace-nowrap border border-white/10 shadow-lg pointer-events-none">
|
||||
@@ -389,7 +478,7 @@ function TimelineAxis({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-8 bg-[#09090b] border-b border-white/5 relative overflow-hidden select-none"
|
||||
className="h-9 bg-[#0c0d10] border-b border-white/[0.07] relative overflow-hidden select-none"
|
||||
style={{
|
||||
[sideProperty === "right" ? "marginRight" : "marginLeft"]: `${sidebarWidth}px`,
|
||||
}}
|
||||
@@ -400,7 +489,7 @@ function TimelineAxis({
|
||||
return (
|
||||
<div
|
||||
key={`minor-${time}`}
|
||||
className="absolute bottom-0 h-1 w-[1px] bg-white/5"
|
||||
className="absolute bottom-0 h-1.5 w-[1px] bg-white/[0.07]"
|
||||
style={{ [sideProperty]: `${offset}px` }}
|
||||
/>
|
||||
);
|
||||
@@ -422,7 +511,7 @@ function TimelineAxis({
|
||||
return (
|
||||
<div key={marker.time} style={markerStyle}>
|
||||
<div className="flex flex-col items-center pb-1">
|
||||
<div className="h-2 w-[1px] bg-white/20 mb-1" />
|
||||
<div className="h-2.5 w-[1px] bg-white/20 mb-1" />
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] font-medium tabular-nums tracking-tight",
|
||||
@@ -444,13 +533,16 @@ function Timeline({
|
||||
videoDurationMs,
|
||||
currentTimeMs,
|
||||
onSeek,
|
||||
onRangeChange,
|
||||
onSelectZoom,
|
||||
onSelectTrim,
|
||||
onSelectAnnotation,
|
||||
onSelectBlur,
|
||||
onSelectSpeed,
|
||||
selectedZoomId,
|
||||
selectedTrimId,
|
||||
selectedAnnotationId,
|
||||
selectedBlurId,
|
||||
selectedSpeedId,
|
||||
keyframes = [],
|
||||
}: {
|
||||
@@ -458,16 +550,20 @@ function Timeline({
|
||||
videoDurationMs: number;
|
||||
currentTimeMs: number;
|
||||
onSeek?: (time: number) => void;
|
||||
onRangeChange?: (updater: (previous: Range) => Range) => void;
|
||||
onSelectZoom?: (id: string | null) => void;
|
||||
onSelectTrim?: (id: string | null) => void;
|
||||
onSelectAnnotation?: (id: string | null) => void;
|
||||
onSelectBlur?: (id: string | null) => void;
|
||||
onSelectSpeed?: (id: string | null) => void;
|
||||
selectedZoomId: string | null;
|
||||
selectedTrimId?: string | null;
|
||||
selectedAnnotationId?: string | null;
|
||||
selectedBlurId?: string | null;
|
||||
selectedSpeedId?: string | null;
|
||||
keyframes?: { id: string; time: number }[];
|
||||
}) {
|
||||
const t = useScopedT("timeline");
|
||||
const { setTimelineRef, style, sidebarWidth, range, pixelsToValue } = useTimelineContext();
|
||||
const localTimelineRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
@@ -488,6 +584,7 @@ function Timeline({
|
||||
onSelectZoom?.(null);
|
||||
onSelectTrim?.(null);
|
||||
onSelectAnnotation?.(null);
|
||||
onSelectBlur?.(null);
|
||||
onSelectSpeed?.(null);
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
@@ -506,6 +603,7 @@ function Timeline({
|
||||
onSelectZoom,
|
||||
onSelectTrim,
|
||||
onSelectAnnotation,
|
||||
onSelectBlur,
|
||||
onSelectSpeed,
|
||||
videoDurationMs,
|
||||
sidebarWidth,
|
||||
@@ -514,29 +612,72 @@ 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 trimItems = items.filter((item) => item.rowId === TRIM_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);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setRefs}
|
||||
style={style}
|
||||
className="select-none bg-[#09090b] min-h-[140px] relative cursor-pointer group"
|
||||
className="select-none bg-[#0b0c0f] min-h-[190px] relative cursor-pointer group"
|
||||
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,#ffffff05_1px,transparent_1px)] bg-[length:24px_100%] pointer-events-none" />
|
||||
<TimelineAxis videoDurationMs={videoDurationMs} currentTimeMs={currentTimeMs} />
|
||||
<PlaybackCursor
|
||||
currentTimeMs={currentTimeMs}
|
||||
videoDurationMs={videoDurationMs}
|
||||
onSeek={onSeek}
|
||||
onRangeChange={onRangeChange}
|
||||
timelineRef={localTimelineRef}
|
||||
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) => (
|
||||
<Item
|
||||
id={item.id}
|
||||
@@ -546,6 +687,8 @@ function Timeline({
|
||||
isSelected={item.id === selectedZoomId}
|
||||
onSelect={() => onSelectZoom?.(item.id)}
|
||||
zoomDepth={item.zoomDepth}
|
||||
zoomCustomScale={item.zoomCustomScale}
|
||||
isAutoFocus={item.isAutoFocus}
|
||||
variant="zoom"
|
||||
>
|
||||
{item.label}
|
||||
@@ -553,7 +696,7 @@ function Timeline({
|
||||
))}
|
||||
</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) => (
|
||||
<Item
|
||||
id={item.id}
|
||||
@@ -572,7 +715,7 @@ function Timeline({
|
||||
<Row
|
||||
id={ANNOTATION_ROW_ID}
|
||||
isEmpty={annotationItems.length === 0}
|
||||
hint="Press A to add annotation"
|
||||
hint={t("hints.pressAnnotation")}
|
||||
>
|
||||
{annotationItems.map((item) => (
|
||||
<Item
|
||||
@@ -589,7 +732,23 @@ function Timeline({
|
||||
))}
|
||||
</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) => (
|
||||
<Item
|
||||
id={item.id}
|
||||
@@ -633,15 +792,26 @@ export default function TimelineEditor({
|
||||
onAnnotationDelete,
|
||||
selectedAnnotationId,
|
||||
onSelectAnnotation,
|
||||
blurRegions = [],
|
||||
onBlurAdded,
|
||||
onBlurSpanChange,
|
||||
onBlurDelete,
|
||||
selectedBlurId,
|
||||
onSelectBlur,
|
||||
speedRegions = [],
|
||||
onSpeedAdded,
|
||||
onSpeedSpanChange,
|
||||
onSpeedDelete,
|
||||
selectedSpeedId,
|
||||
onSelectSpeed,
|
||||
canCopySelectedItem = false,
|
||||
canPasteTimelineItem = false,
|
||||
onCopySelectedItem,
|
||||
onPasteTimelineItem,
|
||||
aspectRatio,
|
||||
onAspectRatioChange,
|
||||
}: TimelineEditorProps) {
|
||||
const t = useScopedT("timeline");
|
||||
const totalMs = useMemo(() => Math.max(0, Math.round(videoDuration * 1000)), [videoDuration]);
|
||||
const currentTimeMs = useMemo(() => Math.round(currentTime * 1000), [currentTime]);
|
||||
const timelineScale = useMemo(() => calculateTimelineScale(videoDuration), [videoDuration]);
|
||||
@@ -657,17 +827,15 @@ export default function TimelineEditor({
|
||||
const [keyframes, setKeyframes] = useState<{ id: string; time: number }[]>([]);
|
||||
const [selectedKeyframeId, setSelectedKeyframeId] = useState<string | null>(null);
|
||||
const [scrollLabels, setScrollLabels] = useState({
|
||||
pan: "Shift + Ctrl + Scroll",
|
||||
pan: "Scroll",
|
||||
zoom: "Ctrl + Scroll",
|
||||
});
|
||||
const timelineContainerRef = useRef<HTMLDivElement>(null);
|
||||
const { shortcuts: keyShortcuts, isMac } = useShortcuts();
|
||||
|
||||
useEffect(() => {
|
||||
formatShortcut(["shift", "mod", "Scroll"]).then((pan) => {
|
||||
formatShortcut(["mod", "Scroll"]).then((zoom) => {
|
||||
setScrollLabels({ pan, zoom });
|
||||
});
|
||||
formatShortcut(["mod", "Scroll"]).then((zoom) => {
|
||||
setScrollLabels({ pan: "Scroll", zoom });
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -718,6 +886,12 @@ export default function TimelineEditor({
|
||||
onSelectAnnotation(null);
|
||||
}, [selectedAnnotationId, onAnnotationDelete, onSelectAnnotation]);
|
||||
|
||||
const deleteSelectedBlur = useCallback(() => {
|
||||
if (!selectedBlurId || !onBlurDelete || !onSelectBlur) return;
|
||||
onBlurDelete(selectedBlurId);
|
||||
onSelectBlur(null);
|
||||
}, [selectedBlurId, onBlurDelete, onSelectBlur]);
|
||||
|
||||
const deleteSelectedSpeed = useCallback(() => {
|
||||
if (!selectedSpeedId || !onSpeedDelete || !onSelectSpeed) return;
|
||||
onSpeedDelete(selectedSpeedId);
|
||||
@@ -787,9 +961,10 @@ export default function TimelineEditor({
|
||||
const isZoomItem = zoomRegions.some((r) => r.id === excludeId);
|
||||
const isTrimItem = trimRegions.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);
|
||||
|
||||
if (isAnnotationItem) {
|
||||
if (isAnnotationItem || isBlurItem) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -816,7 +991,7 @@ export default function TimelineEditor({
|
||||
|
||||
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
|
||||
@@ -848,15 +1023,15 @@ export default function TimelineEditor({
|
||||
(region) => startPos >= region.startMs && startPos < region.endMs,
|
||||
);
|
||||
if (isOverlapping || gapToNext <= 0) {
|
||||
toast.error("Cannot place zoom here", {
|
||||
description: "Zoom already exists at this location or not enough space available.",
|
||||
toast.error(t("errors.cannotPlaceZoom"), {
|
||||
description: t("errors.zoomExistsAtLocation"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const actualDuration = Math.min(defaultRegionDurationMs, gapToNext);
|
||||
onZoomAdded({ start: startPos, end: startPos + actualDuration });
|
||||
}, [videoDuration, totalMs, currentTimeMs, zoomRegions, onZoomAdded, defaultRegionDurationMs]);
|
||||
}, [videoDuration, totalMs, currentTimeMs, zoomRegions, onZoomAdded, defaultRegionDurationMs, t]);
|
||||
|
||||
const handleSuggestZooms = useCallback(() => {
|
||||
if (!videoDuration || videoDuration === 0 || totalMs === 0) {
|
||||
@@ -864,13 +1039,13 @@ export default function TimelineEditor({
|
||||
}
|
||||
|
||||
if (!onZoomSuggested) {
|
||||
toast.error("Zoom suggestion handler unavailable");
|
||||
toast.error(t("errors.zoomSuggestionUnavailable"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (cursorTelemetry.length < 2) {
|
||||
toast.info("No cursor telemetry available", {
|
||||
description: "Record a screencast first to generate cursor-based suggestions.",
|
||||
toast.info(t("errors.noCursorTelemetry"), {
|
||||
description: t("errors.noCursorTelemetryDescription"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -887,8 +1062,8 @@ export default function TimelineEditor({
|
||||
const normalizedSamples = normalizeCursorTelemetry(cursorTelemetry, totalMs);
|
||||
|
||||
if (normalizedSamples.length < 2) {
|
||||
toast.info("No usable cursor telemetry", {
|
||||
description: "The recording does not include enough cursor movement data.",
|
||||
toast.info(t("errors.noUsableTelemetry"), {
|
||||
description: t("errors.noUsableTelemetryDescription"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -896,8 +1071,8 @@ export default function TimelineEditor({
|
||||
const dwellCandidates = detectZoomDwellCandidates(normalizedSamples);
|
||||
|
||||
if (dwellCandidates.length === 0) {
|
||||
toast.info("No clear cursor dwell moments found", {
|
||||
description: "Try a recording with slower cursor pauses on important actions.",
|
||||
toast.info(t("errors.noDwellMoments"), {
|
||||
description: t("errors.noDwellMomentsDescription"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -934,13 +1109,17 @@ export default function TimelineEditor({
|
||||
});
|
||||
|
||||
if (addedCount === 0) {
|
||||
toast.info("No auto-zoom slots available", {
|
||||
description: "Detected dwell points overlap existing zoom regions.",
|
||||
toast.info(t("errors.noAutoZoomSlots"), {
|
||||
description: t("errors.noAutoZoomSlotsDescription"),
|
||||
});
|
||||
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,
|
||||
totalMs,
|
||||
@@ -948,6 +1127,7 @@ export default function TimelineEditor({
|
||||
zoomRegions,
|
||||
onZoomSuggested,
|
||||
cursorTelemetry,
|
||||
t,
|
||||
]);
|
||||
|
||||
const handleAddTrim = useCallback(() => {
|
||||
@@ -972,15 +1152,15 @@ export default function TimelineEditor({
|
||||
(region) => startPos >= region.startMs && startPos < region.endMs,
|
||||
);
|
||||
if (isOverlapping || gapToNext <= 0) {
|
||||
toast.error("Cannot place trim here", {
|
||||
description: "Trim already exists at this location or not enough space available.",
|
||||
toast.error(t("errors.cannotPlaceTrim"), {
|
||||
description: t("errors.trimExistsAtLocation"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const actualDuration = Math.min(defaultRegionDurationMs, gapToNext);
|
||||
onTrimAdded({ start: startPos, end: startPos + actualDuration });
|
||||
}, [videoDuration, totalMs, currentTimeMs, trimRegions, onTrimAdded, defaultRegionDurationMs]);
|
||||
}, [videoDuration, totalMs, currentTimeMs, trimRegions, onTrimAdded, defaultRegionDurationMs, t]);
|
||||
|
||||
const handleAddSpeed = useCallback(() => {
|
||||
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onSpeedAdded) {
|
||||
@@ -1004,15 +1184,23 @@ export default function TimelineEditor({
|
||||
(region) => startPos >= region.startMs && startPos < region.endMs,
|
||||
);
|
||||
if (isOverlapping || gapToNext <= 0) {
|
||||
toast.error("Cannot place speed here", {
|
||||
description: "Speed region already exists at this location or not enough space available.",
|
||||
toast.error(t("errors.cannotPlaceSpeed"), {
|
||||
description: t("errors.speedExistsAtLocation"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const actualDuration = Math.min(defaultRegionDurationMs, gapToNext);
|
||||
onSpeedAdded({ start: startPos, end: startPos + actualDuration });
|
||||
}, [videoDuration, totalMs, currentTimeMs, speedRegions, onSpeedAdded, defaultRegionDurationMs]);
|
||||
}, [
|
||||
videoDuration,
|
||||
totalMs,
|
||||
currentTimeMs,
|
||||
speedRegions,
|
||||
onSpeedAdded,
|
||||
defaultRegionDurationMs,
|
||||
t,
|
||||
]);
|
||||
|
||||
const handleAddAnnotation = useCallback(() => {
|
||||
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onAnnotationAdded) {
|
||||
@@ -1031,9 +1219,59 @@ export default function TimelineEditor({
|
||||
onAnnotationAdded({ start: startPos, end: endPos });
|
||||
}, [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(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
||||
const target = e.target;
|
||||
const isEditableTarget =
|
||||
target instanceof HTMLInputElement ||
|
||||
target instanceof HTMLTextAreaElement ||
|
||||
target instanceof HTMLSelectElement ||
|
||||
(target instanceof HTMLElement && target.isContentEditable);
|
||||
if (isEditableTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mod = isMac ? e.metaKey : e.ctrlKey;
|
||||
const key = e.key.toLowerCase();
|
||||
|
||||
if (
|
||||
mod &&
|
||||
!e.shiftKey &&
|
||||
!e.altKey &&
|
||||
key === "c" &&
|
||||
canCopySelectedItem &&
|
||||
onCopySelectedItem
|
||||
) {
|
||||
e.preventDefault();
|
||||
onCopySelectedItem();
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
mod &&
|
||||
!e.shiftKey &&
|
||||
!e.altKey &&
|
||||
key === "v" &&
|
||||
canPasteTimelineItem &&
|
||||
onPasteTimelineItem
|
||||
) {
|
||||
e.preventDefault();
|
||||
onPasteTimelineItem();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1049,6 +1287,9 @@ export default function TimelineEditor({
|
||||
if (matchesShortcut(e, keyShortcuts.addAnnotation, isMac)) {
|
||||
handleAddAnnotation();
|
||||
}
|
||||
if (matchesShortcut(e, keyShortcuts.addBlur, isMac)) {
|
||||
handleAddBlur();
|
||||
}
|
||||
if (matchesShortcut(e, keyShortcuts.addSpeed, isMac)) {
|
||||
handleAddSpeed();
|
||||
}
|
||||
@@ -1089,6 +1330,8 @@ export default function TimelineEditor({
|
||||
deleteSelectedTrim();
|
||||
} else if (selectedAnnotationId) {
|
||||
deleteSelectedAnnotation();
|
||||
} else if (selectedBlurId) {
|
||||
deleteSelectedBlur();
|
||||
} else if (selectedSpeedId) {
|
||||
deleteSelectedSpeed();
|
||||
}
|
||||
@@ -1101,22 +1344,29 @@ export default function TimelineEditor({
|
||||
handleAddZoom,
|
||||
handleAddTrim,
|
||||
handleAddAnnotation,
|
||||
handleAddBlur,
|
||||
handleAddSpeed,
|
||||
deleteSelectedKeyframe,
|
||||
deleteSelectedZoom,
|
||||
deleteSelectedTrim,
|
||||
deleteSelectedAnnotation,
|
||||
deleteSelectedBlur,
|
||||
deleteSelectedSpeed,
|
||||
selectedKeyframeId,
|
||||
selectedZoomId,
|
||||
selectedTrimId,
|
||||
selectedAnnotationId,
|
||||
selectedBlurId,
|
||||
selectedSpeedId,
|
||||
annotationRegions,
|
||||
currentTime,
|
||||
onSelectAnnotation,
|
||||
keyShortcuts,
|
||||
isMac,
|
||||
canCopySelectedItem,
|
||||
canPasteTimelineItem,
|
||||
onCopySelectedItem,
|
||||
onPasteTimelineItem,
|
||||
]);
|
||||
|
||||
const clampedRange = useMemo<Range>(() => {
|
||||
@@ -1135,8 +1385,10 @@ export default function TimelineEditor({
|
||||
id: region.id,
|
||||
rowId: ZOOM_ROW_ID,
|
||||
span: { start: region.startMs, end: region.endMs },
|
||||
label: `Zoom ${index + 1}`,
|
||||
label: t("labels.zoomItem", { index: String(index + 1) }),
|
||||
zoomDepth: region.depth,
|
||||
zoomCustomScale: region.customScale,
|
||||
isAutoFocus: region.focusMode === "auto",
|
||||
variant: "zoom",
|
||||
}));
|
||||
|
||||
@@ -1144,7 +1396,7 @@ export default function TimelineEditor({
|
||||
id: region.id,
|
||||
rowId: TRIM_ROW_ID,
|
||||
span: { start: region.startMs, end: region.endMs },
|
||||
label: `Trim ${index + 1}`,
|
||||
label: t("labels.trimItem", { index: String(index + 1) }),
|
||||
variant: "trim",
|
||||
}));
|
||||
|
||||
@@ -1153,12 +1405,12 @@ export default function TimelineEditor({
|
||||
|
||||
if (region.type === "text") {
|
||||
// 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;
|
||||
} else if (region.type === "image") {
|
||||
label = "Image";
|
||||
label = t("labels.imageItem");
|
||||
} else {
|
||||
label = "Annotation";
|
||||
label = t("labels.annotationItem");
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -1170,17 +1422,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) => ({
|
||||
id: region.id,
|
||||
rowId: SPEED_ROW_ID,
|
||||
span: { start: region.startMs, end: region.endMs },
|
||||
label: `Speed ${index + 1}`,
|
||||
label: t("labels.speedItem", { index: String(index + 1) }),
|
||||
speedValue: region.speed,
|
||||
variant: "speed",
|
||||
}));
|
||||
|
||||
return [...zooms, ...trims, ...annotations, ...speeds];
|
||||
}, [zoomRegions, trimRegions, annotationRegions, speedRegions]);
|
||||
return [...zooms, ...trims, ...annotations, ...blurs, ...speeds];
|
||||
}, [zoomRegions, trimRegions, annotationRegions, blurRegions, speedRegions, t]);
|
||||
|
||||
// Flat list of all non-annotation region spans for neighbour-clamping during drag/resize
|
||||
const allRegionSpans = useMemo(() => {
|
||||
@@ -1201,6 +1461,8 @@ export default function TimelineEditor({
|
||||
onSpeedSpanChange?.(id, span);
|
||||
} else if (annotationRegions.some((r) => r.id === id)) {
|
||||
onAnnotationSpanChange?.(id, span);
|
||||
} else if (blurRegions.some((r) => r.id === id)) {
|
||||
onBlurSpanChange?.(id, span);
|
||||
}
|
||||
},
|
||||
[
|
||||
@@ -1208,10 +1470,12 @@ export default function TimelineEditor({
|
||||
trimRegions,
|
||||
speedRegions,
|
||||
annotationRegions,
|
||||
blurRegions,
|
||||
onZoomSpanChange,
|
||||
onTrimSpanChange,
|
||||
onSpeedSpanChange,
|
||||
onAnnotationSpanChange,
|
||||
onBlurSpanChange,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1222,23 +1486,23 @@ export default function TimelineEditor({
|
||||
<Plus className="w-6 h-6 text-slate-600" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium text-slate-300">No Video Loaded</p>
|
||||
<p className="text-xs text-slate-500 mt-1">Drag and drop a video to start editing</p>
|
||||
<p className="text-sm font-medium text-slate-300">{t("emptyState.noVideo")}</p>
|
||||
<p className="text-xs text-slate-500 mt-1">{t("emptyState.dragAndDrop")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col bg-[#09090b] overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-white/5 bg-[#09090b]">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex-1 min-h-0 flex flex-col bg-[#09090b] overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-white/[0.06] bg-[#08090b]/95">
|
||||
<div className="flex items-center gap-0.5 rounded-xl border border-white/[0.06] bg-white/[0.025] p-0.5">
|
||||
<Button
|
||||
onClick={handleAddZoom}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-slate-400 hover:text-[#34B27B] hover:bg-[#34B27B]/10 transition-all"
|
||||
title="Add Zoom (Z)"
|
||||
className="h-7 w-7 rounded-lg text-slate-400 hover:text-[#34B27B] hover:bg-[#34B27B]/10 transition-all"
|
||||
title={t("buttons.addZoom")}
|
||||
>
|
||||
<ZoomIn className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -1246,8 +1510,8 @@ export default function TimelineEditor({
|
||||
onClick={handleSuggestZooms}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-slate-400 hover:text-[#34B27B] hover:bg-[#34B27B]/10 transition-all"
|
||||
title="Suggest Zooms from Cursor"
|
||||
className="h-7 w-7 rounded-lg text-slate-400 hover:text-[#34B27B] hover:bg-[#34B27B]/10 transition-all"
|
||||
title={t("buttons.suggestZooms")}
|
||||
>
|
||||
<WandSparkles className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -1255,8 +1519,8 @@ export default function TimelineEditor({
|
||||
onClick={handleAddTrim}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-slate-400 hover:text-[#ef4444] hover:bg-[#ef4444]/10 transition-all"
|
||||
title="Add Trim (T)"
|
||||
className="h-7 w-7 rounded-lg text-slate-400 hover:text-[#ef4444] hover:bg-[#ef4444]/10 transition-all"
|
||||
title={t("buttons.addTrim")}
|
||||
>
|
||||
<Scissors className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -1264,28 +1528,47 @@ export default function TimelineEditor({
|
||||
onClick={handleAddAnnotation}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-slate-400 hover:text-[#B4A046] hover:bg-[#B4A046]/10 transition-all"
|
||||
title="Add Annotation (A)"
|
||||
className="h-7 w-7 rounded-lg text-slate-400 hover:text-[#B4A046] hover:bg-[#B4A046]/10 transition-all"
|
||||
title={t("buttons.addAnnotation")}
|
||||
>
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAddBlur}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-lg 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
|
||||
onClick={handleAddSpeed}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-slate-400 hover:text-[#d97706] hover:bg-[#d97706]/10 transition-all"
|
||||
title="Add Speed (S)"
|
||||
className="h-7 w-7 rounded-lg text-slate-400 hover:text-[#d97706] hover:bg-[#d97706]/10 transition-all"
|
||||
title={t("buttons.addSpeed")}
|
||||
>
|
||||
<Gauge className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs text-slate-400 hover:text-slate-200 hover:bg-white/10 transition-all gap-1"
|
||||
className="h-7 px-2 rounded-lg text-[11px] text-slate-400 hover:text-slate-200 hover:bg-white/[0.07] transition-all gap-1"
|
||||
>
|
||||
<span className="font-medium">{getAspectRatioLabel(aspectRatio)}</span>
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
@@ -1304,28 +1587,26 @@ export default function TimelineEditor({
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="w-[1px] h-4 bg-white/10" />
|
||||
<TutorialHelp />
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center gap-4 text-[10px] text-slate-500 font-medium">
|
||||
<div className="hidden md:flex items-center gap-3 text-[10px] text-slate-500 font-medium">
|
||||
<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">
|
||||
{scrollLabels.pan}
|
||||
</kbd>
|
||||
<span>Pan</span>
|
||||
<span>{t("labels.pan")}</span>
|
||||
</span>
|
||||
<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">
|
||||
{scrollLabels.zoom}
|
||||
</kbd>
|
||||
<span>Zoom</span>
|
||||
<span>{t("labels.zoom")}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref={timelineContainerRef}
|
||||
className="flex-1 overflow-hidden bg-[#09090b] relative"
|
||||
className="flex-1 min-h-0 overflow-auto custom-scrollbar bg-[#09090b] relative"
|
||||
onClick={() => setSelectedKeyframeId(null)}
|
||||
>
|
||||
<TimelineWrapper
|
||||
@@ -1351,13 +1632,16 @@ export default function TimelineEditor({
|
||||
videoDurationMs={totalMs}
|
||||
currentTimeMs={currentTimeMs}
|
||||
onSeek={onSeek}
|
||||
onRangeChange={setRange}
|
||||
onSelectZoom={onSelectZoom}
|
||||
onSelectTrim={onSelectTrim}
|
||||
onSelectAnnotation={onSelectAnnotation}
|
||||
onSelectBlur={onSelectBlur}
|
||||
onSelectSpeed={onSelectSpeed}
|
||||
selectedZoomId={selectedZoomId}
|
||||
selectedTrimId={selectedTrimId}
|
||||
selectedAnnotationId={selectedAnnotationId}
|
||||
selectedBlurId={selectedBlurId}
|
||||
selectedSpeedId={selectedSpeedId}
|
||||
keyframes={keyframes}
|
||||
/>
|
||||
|
||||
@@ -57,7 +57,7 @@ export default function TimelineWrapper({
|
||||
const duration = Math.min(Math.max(rawDuration, minDuration), totalMs);
|
||||
|
||||
const start = Math.max(0, Math.min(normalizedStart, totalMs - duration));
|
||||
const end = start + duration;
|
||||
const end = Math.min(start + duration, totalMs);
|
||||
|
||||
return { start, end };
|
||||
},
|
||||
@@ -187,12 +187,12 @@ export default function TimelineWrapper({
|
||||
// Drag/resize tooltip (direct DOM updates, no re-renders)
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const formatTooltipMs = (ms: number) => {
|
||||
const formatTooltipMs = useCallback((ms: number) => {
|
||||
const s = ms / 1000;
|
||||
const min = Math.floor(s / 60);
|
||||
const sec = s % 60;
|
||||
return min > 0 ? `${min}:${sec.toFixed(1).padStart(4, "0")}` : `${sec.toFixed(1)}s`;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const showTooltip = useCallback(
|
||||
(span: { start: number; end: number } | null, screenX?: number) => {
|
||||
@@ -213,7 +213,7 @@ export default function TimelineWrapper({
|
||||
}
|
||||
}
|
||||
},
|
||||
[],
|
||||
[formatTooltipMs],
|
||||
);
|
||||
|
||||
const onDragStart = useCallback(
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
cloneAnnotationRegion,
|
||||
getPastedAnnotationPosition,
|
||||
spansOverlap,
|
||||
} from "./timelineClipboardUtils";
|
||||
import type { AnnotationRegion } from "./types";
|
||||
|
||||
function createAnnotationRegion(overrides: Partial<AnnotationRegion> = {}): AnnotationRegion {
|
||||
return {
|
||||
id: "annotation-1",
|
||||
startMs: 100,
|
||||
endMs: 600,
|
||||
type: "blur",
|
||||
content: "",
|
||||
position: { x: 10, y: 15 },
|
||||
size: { width: 30, 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: "freehand",
|
||||
color: "white",
|
||||
intensity: 12,
|
||||
blockSize: 8,
|
||||
freehandPoints: [
|
||||
{ x: 10, y: 20 },
|
||||
{ x: 30, y: 40 },
|
||||
],
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("timelineClipboardUtils", () => {
|
||||
it("deep clones nested annotation data", () => {
|
||||
const original = createAnnotationRegion();
|
||||
const cloned = cloneAnnotationRegion(original);
|
||||
|
||||
expect(cloned).toEqual(original);
|
||||
expect(cloned).not.toBe(original);
|
||||
expect(cloned.position).not.toBe(original.position);
|
||||
expect(cloned.size).not.toBe(original.size);
|
||||
expect(cloned.style).not.toBe(original.style);
|
||||
expect(cloned.blurData).not.toBe(original.blurData);
|
||||
expect(cloned.blurData?.freehandPoints).not.toBe(original.blurData?.freehandPoints);
|
||||
expect(cloned.blurData?.freehandPoints?.[0]).not.toBe(original.blurData?.freehandPoints?.[0]);
|
||||
});
|
||||
|
||||
it("detects true overlaps but not adjacent spans", () => {
|
||||
expect(spansOverlap(100, 200, 150, 250)).toBe(true);
|
||||
expect(spansOverlap(100, 200, 200, 300)).toBe(false);
|
||||
expect(spansOverlap(100, 200, 0, 100)).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves pasted annotation positions when they are already in bounds", () => {
|
||||
expect(getPastedAnnotationPosition({ x: 10, y: 15 }, { width: 30, height: 20 })).toEqual({
|
||||
x: 10,
|
||||
y: 15,
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps pasted annotation positions when the source would overflow its bounds", () => {
|
||||
expect(getPastedAnnotationPosition({ x: 94, y: 93 }, { width: 12, height: 9 })).toEqual({
|
||||
x: 88,
|
||||
y: 91,
|
||||
});
|
||||
});
|
||||
|
||||
it("pins oversized pasted annotations to the visible origin", () => {
|
||||
expect(getPastedAnnotationPosition({ x: 50, y: 50 }, { width: 140, height: 120 })).toEqual({
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { AnnotationPosition, AnnotationRegion, AnnotationSize } from "./types";
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
export function cloneAnnotationRegion(region: AnnotationRegion): AnnotationRegion {
|
||||
return {
|
||||
...region,
|
||||
position: { ...region.position },
|
||||
size: { ...region.size },
|
||||
style: { ...region.style },
|
||||
figureData: region.figureData ? { ...region.figureData } : undefined,
|
||||
blurData: region.blurData
|
||||
? {
|
||||
...region.blurData,
|
||||
freehandPoints: region.blurData.freehandPoints?.map((point) => ({ ...point })),
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function spansOverlap(startA: number, endA: number, startB: number, endB: number): boolean {
|
||||
return startA < endB && endA > startB;
|
||||
}
|
||||
|
||||
export function getPastedAnnotationPosition(
|
||||
position: AnnotationPosition,
|
||||
size: AnnotationSize,
|
||||
): AnnotationPosition {
|
||||
const maxX = Math.max(0, 100 - size.width);
|
||||
const maxY = Math.max(0, 100 - size.height);
|
||||
|
||||
return {
|
||||
x: clamp(position.x, 0, maxX),
|
||||
y: clamp(position.y, 0, maxY),
|
||||
};
|
||||
}
|
||||
@@ -1,16 +1,169 @@
|
||||
import type { WebcamLayoutPreset } from "@/lib/compositeLayout";
|
||||
|
||||
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 {
|
||||
cx: number; // normalized horizontal 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 {
|
||||
id: string;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
depth: ZoomDepth;
|
||||
focus: ZoomFocus;
|
||||
focusMode?: ZoomFocusMode;
|
||||
rotationPreset?: Rotation3DPreset;
|
||||
/** Custom scale overriding the preset depth (1.0–5.0, two decimal precision). */
|
||||
customScale?: number;
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -25,7 +178,7 @@ export interface TrimRegion {
|
||||
endMs: number;
|
||||
}
|
||||
|
||||
export type AnnotationType = "text" | "image" | "figure";
|
||||
export type AnnotationType = "text" | "image" | "figure" | "blur";
|
||||
|
||||
export type ArrowDirection =
|
||||
| "up"
|
||||
@@ -43,6 +196,27 @@ export interface FigureData {
|
||||
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 {
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -77,6 +251,7 @@ export interface AnnotationRegion {
|
||||
style: AnnotationTextStyle;
|
||||
zIndex: number;
|
||||
figureData?: FigureData;
|
||||
blurData?: BlurData;
|
||||
}
|
||||
|
||||
export const DEFAULT_ANNOTATION_POSITION: AnnotationPosition = {
|
||||
@@ -106,6 +281,27 @@ export const DEFAULT_FIGURE_DATA: FigureData = {
|
||||
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: "mosaic",
|
||||
shape: "rectangle",
|
||||
color: "white",
|
||||
intensity: DEFAULT_BLUR_INTENSITY,
|
||||
blockSize: DEFAULT_BLUR_BLOCK_SIZE,
|
||||
freehandPoints: DEFAULT_BLUR_FREEHAND_POINTS,
|
||||
};
|
||||
|
||||
export interface CropRegion {
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -120,7 +316,16 @@ export const DEFAULT_CROP_REGION: CropRegion = {
|
||||
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 {
|
||||
id: string;
|
||||
@@ -137,6 +342,9 @@ export const SPEED_OPTIONS: Array<{ speed: PlaybackSpeed; label: string }> = [
|
||||
{ speed: 1.5, label: "1.5×" },
|
||||
{ speed: 1.75, label: "1.75×" },
|
||||
{ speed: 2, label: "2×" },
|
||||
{ speed: 3, label: "3×" },
|
||||
{ speed: 4, label: "4×" },
|
||||
{ speed: 5, label: "5×" },
|
||||
];
|
||||
|
||||
export const DEFAULT_PLAYBACK_SPEED: PlaybackSpeed = 1.5;
|
||||
@@ -150,8 +358,20 @@ export const ZOOM_DEPTH_SCALES: Record<ZoomDepth, number> = {
|
||||
6: 5.0,
|
||||
};
|
||||
|
||||
export const MIN_ZOOM_SCALE = 1.0;
|
||||
export const MAX_ZOOM_SCALE = 5.0;
|
||||
|
||||
export const DEFAULT_ZOOM_DEPTH: ZoomDepth = 3;
|
||||
|
||||
/** Returns the effective zoom scale for a region, preferring customScale over the preset. */
|
||||
export function getZoomScale(region: ZoomRegion): number {
|
||||
if (region.customScale != null) {
|
||||
const clamped = Math.max(MIN_ZOOM_SCALE, Math.min(MAX_ZOOM_SCALE, region.customScale));
|
||||
if (Number.isFinite(clamped)) return clamped;
|
||||
}
|
||||
return ZOOM_DEPTH_SCALES[region.depth];
|
||||
}
|
||||
|
||||
export function clampFocusToDepth(focus: ZoomFocus, _depth: ZoomDepth): ZoomFocus {
|
||||
return {
|
||||
cx: clamp(focus.cx, 0, 1),
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import type { ZoomFocus } from "../types";
|
||||
|
||||
export const DEFAULT_FOCUS: ZoomFocus = { cx: 0.5, cy: 0.5 };
|
||||
export const TRANSITION_WINDOW_MS = 320;
|
||||
export const SMOOTHING_FACTOR = 0.12;
|
||||
export const TRANSITION_WINDOW_MS = 1015.05;
|
||||
export const ZOOM_IN_TRANSITION_WINDOW_MS = TRANSITION_WINDOW_MS * 1.5;
|
||||
export const MIN_DELTA = 0.0001;
|
||||
export const VIEWPORT_SCALE = 0.8;
|
||||
export const SMOOTHING_FACTOR = 0.12;
|
||||
export const ZOOM_TRANSLATION_DEADZONE_PX = 1.25;
|
||||
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();
|
||||
}
|
||||
@@ -5,28 +5,108 @@ interface StageSize {
|
||||
height: number;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function easeIntoBoundary(normalized: number) {
|
||||
const t = clamp(normalized, 0, 1);
|
||||
return -t * t * t + 2 * t * t;
|
||||
}
|
||||
|
||||
function softClampToRange(value: number, min: number, max: number, softness: number) {
|
||||
const clamped = clamp(value, min, max);
|
||||
|
||||
if (softness <= 0 || max <= min) {
|
||||
return clamped;
|
||||
}
|
||||
|
||||
if (clamped < min + softness) {
|
||||
const normalized = (clamped - min) / softness;
|
||||
return min + softness * easeIntoBoundary(normalized);
|
||||
}
|
||||
|
||||
if (clamped > max - softness) {
|
||||
const normalized = (max - clamped) / softness;
|
||||
return max - softness * easeIntoBoundary(normalized);
|
||||
}
|
||||
|
||||
return clamped;
|
||||
}
|
||||
|
||||
function getFocusBounds(depth: ZoomDepth) {
|
||||
const zoomScale = ZOOM_DEPTH_SCALES[depth];
|
||||
return getFocusBoundsForScale(zoomScale);
|
||||
}
|
||||
|
||||
interface ViewportRatio {
|
||||
widthRatio: number;
|
||||
heightRatio: number;
|
||||
}
|
||||
|
||||
export 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 {
|
||||
minX: marginX,
|
||||
maxX: 1 - marginX,
|
||||
minY: marginY,
|
||||
maxY: 1 - marginY,
|
||||
};
|
||||
}
|
||||
|
||||
export function clampFocusToStage(
|
||||
focus: ZoomFocus,
|
||||
depth: ZoomDepth,
|
||||
stageSize: StageSize,
|
||||
_stageSize: StageSize,
|
||||
): ZoomFocus {
|
||||
if (!stageSize.width || !stageSize.height) {
|
||||
return clampFocusToDepth(focus, depth);
|
||||
}
|
||||
|
||||
const zoomScale = ZOOM_DEPTH_SCALES[depth];
|
||||
|
||||
const windowWidth = stageSize.width / zoomScale;
|
||||
const windowHeight = stageSize.height / zoomScale;
|
||||
|
||||
const marginX = windowWidth / (2 * stageSize.width);
|
||||
const marginY = windowHeight / (2 * stageSize.height);
|
||||
|
||||
const baseFocus = clampFocusToDepth(focus, depth);
|
||||
const bounds = getFocusBounds(depth);
|
||||
|
||||
return {
|
||||
cx: Math.max(marginX, Math.min(1 - marginX, baseFocus.cx)),
|
||||
cy: Math.max(marginY, Math.min(1 - marginY, baseFocus.cy)),
|
||||
cx: clamp(baseFocus.cx, bounds.minX, bounds.maxX),
|
||||
cy: clamp(baseFocus.cy, bounds.minY, bounds.maxY),
|
||||
};
|
||||
}
|
||||
|
||||
export function clampFocusToScale(
|
||||
focus: ZoomFocus,
|
||||
zoomScale: number,
|
||||
viewportRatio?: ViewportRatio,
|
||||
): ZoomFocus {
|
||||
const baseFocus = {
|
||||
cx: clamp(focus.cx, 0, 1),
|
||||
cy: clamp(focus.cy, 0, 1),
|
||||
};
|
||||
const bounds = getFocusBoundsForScale(zoomScale, viewportRatio);
|
||||
|
||||
return {
|
||||
cx: clamp(baseFocus.cx, bounds.minX, bounds.maxX),
|
||||
cy: clamp(baseFocus.cy, bounds.minY, bounds.maxY),
|
||||
};
|
||||
}
|
||||
|
||||
export function softenFocusToScale(
|
||||
focus: ZoomFocus,
|
||||
zoomScale: number,
|
||||
viewportRatio?: ViewportRatio,
|
||||
): ZoomFocus {
|
||||
const baseFocus = {
|
||||
cx: clamp(focus.cx, 0, 1),
|
||||
cy: clamp(focus.cy, 0, 1),
|
||||
};
|
||||
const bounds = getFocusBoundsForScale(zoomScale, viewportRatio);
|
||||
const horizontalRange = bounds.maxX - bounds.minX;
|
||||
const verticalRange = bounds.maxY - bounds.minY;
|
||||
const horizontalSoftness = Math.min(0.12, horizontalRange * 0.35);
|
||||
const verticalSoftness = Math.min(0.12, verticalRange * 0.35);
|
||||
|
||||
return {
|
||||
cx: softClampToRange(baseFocus.cx, bounds.minX, bounds.maxX, horizontalSoftness),
|
||||
cy: softClampToRange(baseFocus.cy, bounds.minY, bounds.maxY, verticalSoftness),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
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 {
|
||||
container: HTMLDivElement;
|
||||
@@ -11,6 +19,11 @@ interface LayoutParams {
|
||||
lockedVideoDimensions?: { width: number; height: number } | null;
|
||||
borderRadius?: number;
|
||||
padding?: number;
|
||||
webcamDimensions?: Size | null;
|
||||
webcamLayoutPreset?: WebcamLayoutPreset;
|
||||
webcamSizePreset?: WebcamSizePreset;
|
||||
webcamPosition?: { cx: number; cy: number } | null;
|
||||
webcamMaskShape?: WebcamMaskShape;
|
||||
}
|
||||
|
||||
interface LayoutResult {
|
||||
@@ -18,7 +31,8 @@ interface LayoutResult {
|
||||
videoSize: { width: number; height: number };
|
||||
baseScale: 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 };
|
||||
}
|
||||
|
||||
@@ -33,6 +47,11 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
|
||||
lockedVideoDimensions,
|
||||
borderRadius = 0,
|
||||
padding = 0,
|
||||
webcamDimensions,
|
||||
webcamLayoutPreset,
|
||||
webcamSizePreset,
|
||||
webcamPosition,
|
||||
webcamMaskShape,
|
||||
} = params;
|
||||
|
||||
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
|
||||
// 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 maxDisplayHeight = height * paddingScale;
|
||||
|
||||
const scale = Math.min(
|
||||
maxDisplayWidth / croppedVideoWidth,
|
||||
maxDisplayHeight / croppedVideoHeight,
|
||||
1,
|
||||
);
|
||||
const compositeLayout = computeCompositeLayout({
|
||||
canvasSize: { width, height },
|
||||
maxContentSize: { width: maxDisplayWidth, height: maxDisplayHeight },
|
||||
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);
|
||||
|
||||
@@ -83,31 +123,25 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
|
||||
const fullVideoDisplayWidth = videoWidth * 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 croppedDisplayHeight = croppedVideoHeight * scale;
|
||||
|
||||
// Center the cropped region in the container
|
||||
const centerOffsetX = (width - croppedDisplayWidth) / 2;
|
||||
const centerOffsetY = (height - croppedDisplayHeight) / 2;
|
||||
|
||||
// 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;
|
||||
const offsetX = screenRect.x + (screenRect.width - croppedDisplayWidth) / 2;
|
||||
const offsetY = screenRect.y + (screenRect.height - croppedDisplayHeight) / 2;
|
||||
const spriteX = offsetX - crop.x * fullVideoDisplayWidth;
|
||||
const spriteY = offsetY - crop.y * fullVideoDisplayHeight;
|
||||
|
||||
videoSprite.position.set(spriteX, spriteY);
|
||||
|
||||
// Create a mask that only shows the cropped region (centered in container)
|
||||
const maskX = centerOffsetX;
|
||||
const maskY = centerOffsetY;
|
||||
|
||||
// Apply border radius
|
||||
// Apply border radius — mask clips the video to the screenRect
|
||||
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 });
|
||||
|
||||
return {
|
||||
@@ -115,7 +149,8 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
|
||||
videoSize: { width: croppedVideoWidth, height: croppedVideoHeight },
|
||||
baseScale: scale,
|
||||
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 },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,7 +2,85 @@ export function clamp01(value: number) {
|
||||
return Math.max(0, Math.min(1, value));
|
||||
}
|
||||
|
||||
function sampleCubicBezier(a1: number, a2: number, t: number) {
|
||||
const oneMinusT = 1 - t;
|
||||
return 3 * a1 * oneMinusT * oneMinusT * t + 3 * a2 * oneMinusT * t * t + t * t * t;
|
||||
}
|
||||
|
||||
function sampleCubicBezierDerivative(a1: number, a2: number, t: number) {
|
||||
const oneMinusT = 1 - t;
|
||||
return 3 * a1 * oneMinusT * oneMinusT + 6 * (a2 - a1) * oneMinusT * t + 3 * (1 - a2) * t * t;
|
||||
}
|
||||
|
||||
export function cubicBezier(x1: number, y1: number, x2: number, y2: number, t: number) {
|
||||
const targetX = clamp01(t);
|
||||
let solvedT = targetX;
|
||||
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
const currentX = sampleCubicBezier(x1, x2, solvedT) - targetX;
|
||||
const currentDerivative = sampleCubicBezierDerivative(x1, x2, solvedT);
|
||||
|
||||
if (Math.abs(currentX) < 1e-6 || Math.abs(currentDerivative) < 1e-6) {
|
||||
break;
|
||||
}
|
||||
|
||||
solvedT -= currentX / currentDerivative;
|
||||
}
|
||||
|
||||
let lower = 0;
|
||||
let upper = 1;
|
||||
solvedT = clamp01(solvedT);
|
||||
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
const currentX = sampleCubicBezier(x1, x2, solvedT);
|
||||
if (Math.abs(currentX - targetX) < 1e-6) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (currentX < targetX) {
|
||||
lower = solvedT;
|
||||
} else {
|
||||
upper = solvedT;
|
||||
}
|
||||
|
||||
solvedT = (lower + upper) / 2;
|
||||
}
|
||||
|
||||
return sampleCubicBezier(y1, y2, solvedT);
|
||||
}
|
||||
|
||||
export function easeOutExpo(t: number) {
|
||||
const clamped = clamp01(t);
|
||||
if (clamped === 1) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 1 - Math.pow(2, -7 * clamped);
|
||||
}
|
||||
|
||||
export function easeOutScreenStudio(t: number) {
|
||||
return cubicBezier(0.16, 1, 0.3, 1, t);
|
||||
}
|
||||
|
||||
export function smoothStep(t: number) {
|
||||
const clamped = clamp01(t);
|
||||
return clamped * clamped * (3 - 2 * clamped);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gentle ease-in-out cubic — slow start, smooth middle, gentle landing.
|
||||
* Used for zoom-in transitions.
|
||||
*/
|
||||
export function easeInOutCubic(t: number) {
|
||||
const x = clamp01(t);
|
||||
return x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ease-out cubic — starts at speed, then decelerates to a gentle stop.
|
||||
* Used for zoom-out transitions so strength eases smoothly to zero.
|
||||
*/
|
||||
export function easeOutCubic(t: number) {
|
||||
const x = clamp01(t);
|
||||
return 1 - Math.pow(1 - x, 3);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ZOOM_DEPTH_SCALES, type ZoomFocus, type ZoomRegion } from "../types";
|
||||
import { clampFocusToStage } from "./focusUtils";
|
||||
import { getZoomScale, type ZoomFocus, type ZoomRegion } from "../types";
|
||||
import { clampFocusToScale } from "./focusUtils";
|
||||
|
||||
interface OverlayUpdateParams {
|
||||
overlayEl: HTMLDivElement;
|
||||
@@ -14,7 +14,7 @@ interface OverlayUpdateParams {
|
||||
export function updateOverlayIndicator(params: OverlayUpdateParams) {
|
||||
const { overlayEl, indicatorEl, region, focusOverride, videoSize, baseScale, isPlaying } = params;
|
||||
|
||||
if (!region) {
|
||||
if (!region || region.focusMode === "auto") {
|
||||
indicatorEl.style.display = "none";
|
||||
overlayEl.style.pointerEvents = "none";
|
||||
return;
|
||||
@@ -35,11 +35,8 @@ export function updateOverlayIndicator(params: OverlayUpdateParams) {
|
||||
return;
|
||||
}
|
||||
|
||||
const zoomScale = ZOOM_DEPTH_SCALES[region.depth];
|
||||
const focus = clampFocusToStage(focusOverride ?? region.focus, region.depth, {
|
||||
width: stageWidth,
|
||||
height: stageHeight,
|
||||
});
|
||||
const zoomScale = getZoomScale(region);
|
||||
const focus = clampFocusToScale(focusOverride ?? region.focus, zoomScale);
|
||||
|
||||
// Zoom window shows the stage area that will be visible after zooming (1/zoomScale of stage dimensions)
|
||||
const indicatorWidth = stageWidth / zoomScale;
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type React from "react";
|
||||
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 {
|
||||
video: HTMLVideoElement;
|
||||
isSeekingRef: React.MutableRefObject<boolean>;
|
||||
@@ -12,6 +17,9 @@ interface VideoEventHandlersParams {
|
||||
onTimeUpdate: (time: number) => void;
|
||||
trimRegionsRef: React.MutableRefObject<TrimRegion[]>;
|
||||
speedRegionsRef: React.MutableRefObject<SpeedRegion[]>;
|
||||
isScrubbingRef?: React.MutableRefObject<boolean>;
|
||||
scrubEndTimerRef?: React.MutableRefObject<number | null>;
|
||||
onScrubChange?: (scrubbing: boolean) => void;
|
||||
}
|
||||
|
||||
export function createVideoEventHandlers(params: VideoEventHandlersParams) {
|
||||
@@ -26,8 +34,18 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
|
||||
onTimeUpdate,
|
||||
trimRegionsRef,
|
||||
speedRegionsRef,
|
||||
isScrubbingRef,
|
||||
scrubEndTimerRef,
|
||||
onScrubChange,
|
||||
} = params;
|
||||
|
||||
const clearScrubEndTimer = () => {
|
||||
if (scrubEndTimerRef && scrubEndTimerRef.current !== null) {
|
||||
window.clearTimeout(scrubEndTimerRef.current);
|
||||
scrubEndTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const emitTime = (timeValue: number) => {
|
||||
currentTimeRef.current = timeValue * 1000;
|
||||
onTimeUpdate(timeValue);
|
||||
@@ -113,6 +131,15 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
|
||||
const handleSeeked = () => {
|
||||
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 activeTrimRegion = findActiveTrimRegion(currentTimeMs);
|
||||
|
||||
@@ -137,6 +164,14 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
|
||||
const handleSeeking = () => {
|
||||
isSeekingRef.current = true;
|
||||
|
||||
if (isScrubbingRef) {
|
||||
clearScrubEndTimer();
|
||||
if (!isScrubbingRef.current) {
|
||||
isScrubbingRef.current = true;
|
||||
onScrubChange?.(true);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isPlayingRef.current && !video.paused) {
|
||||
video.pause();
|
||||
}
|
||||
|
||||
@@ -1,31 +1,353 @@
|
||||
import type { ZoomRegion } from "../types";
|
||||
import { TRANSITION_WINDOW_MS } from "./constants";
|
||||
import { smoothStep } from "./mathUtils";
|
||||
import type { CursorTelemetryPoint, Rotation3D, ZoomFocus, ZoomRegion } from "../types";
|
||||
import { DEFAULT_ROTATION_3D, getRotation3D, getZoomScale, lerpRotation3D } from "../types";
|
||||
import { TRANSITION_WINDOW_MS, ZOOM_IN_TRANSITION_WINDOW_MS } from "./constants";
|
||||
import { interpolateCursorAt } from "./cursorFollowUtils";
|
||||
import { clampFocusToScale } from "./focusUtils";
|
||||
import { clamp01, cubicBezier, easeOutScreenStudio } from "./mathUtils";
|
||||
|
||||
const CHAINED_ZOOM_PAN_GAP_MS = 1500;
|
||||
const CONNECTED_ZOOM_PAN_DURATION_MS = 1000;
|
||||
const ZOOM_IN_OVERLAP_MS = 500;
|
||||
|
||||
type DominantRegionOptions = {
|
||||
connectZooms?: boolean;
|
||||
cursorTelemetry?: CursorTelemetryPoint[];
|
||||
viewportRatio?: ViewportRatio;
|
||||
};
|
||||
|
||||
type ConnectedRegionPair = {
|
||||
currentRegion: ZoomRegion;
|
||||
nextRegion: ZoomRegion;
|
||||
transitionStart: number;
|
||||
transitionEnd: number;
|
||||
};
|
||||
|
||||
type ConnectedPanTransition = {
|
||||
progress: number;
|
||||
startFocus: ZoomFocus;
|
||||
endFocus: ZoomFocus;
|
||||
startScale: number;
|
||||
endScale: number;
|
||||
};
|
||||
|
||||
function lerp(start: number, end: number, amount: number) {
|
||||
return start + (end - start) * amount;
|
||||
}
|
||||
|
||||
function easeConnectedPan(value: number) {
|
||||
return cubicBezier(0.1, 0.0, 0.2, 1.0, value);
|
||||
}
|
||||
|
||||
export function computeRegionStrength(region: ZoomRegion, timeMs: number) {
|
||||
const leadInStart = region.startMs - TRANSITION_WINDOW_MS;
|
||||
const zoomInEnd = region.startMs + ZOOM_IN_OVERLAP_MS;
|
||||
const leadInStart = zoomInEnd - ZOOM_IN_TRANSITION_WINDOW_MS;
|
||||
const leadOutEnd = region.endMs + TRANSITION_WINDOW_MS;
|
||||
|
||||
if (timeMs < leadInStart || timeMs > leadOutEnd) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const fadeIn = smoothStep((timeMs - leadInStart) / TRANSITION_WINDOW_MS);
|
||||
const fadeOut = smoothStep((leadOutEnd - timeMs) / TRANSITION_WINDOW_MS);
|
||||
return Math.min(fadeIn, fadeOut);
|
||||
if (timeMs < zoomInEnd) {
|
||||
const progress = (timeMs - leadInStart) / ZOOM_IN_TRANSITION_WINDOW_MS;
|
||||
return easeOutScreenStudio(progress);
|
||||
}
|
||||
|
||||
if (timeMs <= region.endMs) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const progress = clamp01((timeMs - region.endMs) / TRANSITION_WINDOW_MS);
|
||||
return 1 - easeOutScreenStudio(progress);
|
||||
}
|
||||
|
||||
export function findDominantRegion(regions: ZoomRegion[], timeMs: number) {
|
||||
let bestRegion: ZoomRegion | null = null;
|
||||
let bestStrength = 0;
|
||||
function getLinearFocus(start: ZoomFocus, end: ZoomFocus, amount: number): ZoomFocus {
|
||||
return {
|
||||
cx: lerp(start.cx, end.cx, amount),
|
||||
cy: lerp(start.cy, end.cy, amount),
|
||||
};
|
||||
}
|
||||
|
||||
for (const region of regions) {
|
||||
const strength = computeRegionStrength(region, timeMs);
|
||||
if (strength > bestStrength) {
|
||||
bestStrength = strength;
|
||||
bestRegion = region;
|
||||
interface ViewportRatio {
|
||||
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 { region: bestRegion, strength: bestStrength };
|
||||
return clampFocusToScale(focus, zoomScale, viewportRatio);
|
||||
}
|
||||
|
||||
function getConnectedRegionPairs(regions: ZoomRegion[]) {
|
||||
const sortedRegions = [...regions].sort((a, b) => a.startMs - b.startMs);
|
||||
const pairs: ConnectedRegionPair[] = [];
|
||||
|
||||
for (let index = 0; index < sortedRegions.length - 1; index += 1) {
|
||||
const currentRegion = sortedRegions[index];
|
||||
const nextRegion = sortedRegions[index + 1];
|
||||
const gapMs = nextRegion.startMs - currentRegion.endMs;
|
||||
|
||||
if (gapMs > CHAINED_ZOOM_PAN_GAP_MS) {
|
||||
continue;
|
||||
}
|
||||
|
||||
pairs.push({
|
||||
currentRegion,
|
||||
nextRegion,
|
||||
transitionStart: currentRegion.endMs,
|
||||
transitionEnd: currentRegion.endMs + CONNECTED_ZOOM_PAN_DURATION_MS,
|
||||
});
|
||||
}
|
||||
|
||||
return pairs;
|
||||
}
|
||||
|
||||
function getActiveRegion(
|
||||
regions: ZoomRegion[],
|
||||
timeMs: number,
|
||||
connectedPairs: ConnectedRegionPair[],
|
||||
cursorTelemetry?: CursorTelemetryPoint[],
|
||||
viewportRatio?: ViewportRatio,
|
||||
) {
|
||||
const activeRegions = regions
|
||||
.map((region) => {
|
||||
const outgoingPair = connectedPairs.find((pair) => pair.currentRegion.id === region.id);
|
||||
if (outgoingPair && timeMs > outgoingPair.currentRegion.endMs) {
|
||||
return { region, strength: 0 };
|
||||
}
|
||||
|
||||
const incomingPair = connectedPairs.find((pair) => pair.nextRegion.id === region.id);
|
||||
if (incomingPair && timeMs < incomingPair.transitionEnd) {
|
||||
return { region, strength: 0 };
|
||||
}
|
||||
|
||||
return { region, strength: computeRegionStrength(region, timeMs) };
|
||||
})
|
||||
.filter((entry) => entry.strength > 0)
|
||||
.sort((left, right) => {
|
||||
if (right.strength !== left.strength) {
|
||||
return right.strength - left.strength;
|
||||
}
|
||||
|
||||
return right.region.startMs - left.region.startMs;
|
||||
});
|
||||
|
||||
if (activeRegions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeRegion = activeRegions[0].region;
|
||||
const activeScale = getZoomScale(activeRegion);
|
||||
|
||||
return {
|
||||
region: {
|
||||
...activeRegion,
|
||||
focus: getResolvedFocus(activeRegion, activeScale, timeMs, cursorTelemetry, viewportRatio),
|
||||
},
|
||||
strength: activeRegions[0].strength,
|
||||
blendedScale: null,
|
||||
rotation3D: getRotation3D(activeRegion),
|
||||
};
|
||||
}
|
||||
|
||||
function getConnectedRegionHold(
|
||||
timeMs: number,
|
||||
connectedPairs: ConnectedRegionPair[],
|
||||
cursorTelemetry?: CursorTelemetryPoint[],
|
||||
viewportRatio?: ViewportRatio,
|
||||
) {
|
||||
for (const pair of connectedPairs) {
|
||||
if (timeMs > pair.transitionEnd && timeMs < pair.nextRegion.startMs) {
|
||||
const nextScale = getZoomScale(pair.nextRegion);
|
||||
return {
|
||||
region: {
|
||||
...pair.nextRegion,
|
||||
focus: getResolvedFocus(
|
||||
pair.nextRegion,
|
||||
nextScale,
|
||||
timeMs,
|
||||
cursorTelemetry,
|
||||
viewportRatio,
|
||||
),
|
||||
},
|
||||
strength: 1,
|
||||
blendedScale: null,
|
||||
rotation3D: getRotation3D(pair.nextRegion),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getConnectedRegionTransition(
|
||||
connectedPairs: ConnectedRegionPair[],
|
||||
timeMs: number,
|
||||
cursorTelemetry?: CursorTelemetryPoint[],
|
||||
viewportRatio?: ViewportRatio,
|
||||
) {
|
||||
for (const pair of connectedPairs) {
|
||||
const { currentRegion, nextRegion, transitionStart, transitionEnd } = pair;
|
||||
|
||||
if (timeMs < transitionStart || timeMs > transitionEnd) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const transitionProgress = easeConnectedPan(
|
||||
clamp01((timeMs - transitionStart) / Math.max(1, transitionEnd - transitionStart)),
|
||||
);
|
||||
const currentScale = getZoomScale(currentRegion);
|
||||
const nextScale = getZoomScale(nextRegion);
|
||||
const transitionScale = lerp(currentScale, nextScale, transitionProgress);
|
||||
// Both regions share the same timeMs, so interpolate cursor once and reuse.
|
||||
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 transitionRotation = lerpRotation3D(
|
||||
getRotation3D(currentRegion),
|
||||
getRotation3D(nextRegion),
|
||||
transitionProgress,
|
||||
);
|
||||
|
||||
return {
|
||||
region: {
|
||||
...nextRegion,
|
||||
focus: transitionFocus,
|
||||
},
|
||||
strength: 1,
|
||||
blendedScale: transitionScale,
|
||||
rotation3D: transitionRotation,
|
||||
transition: {
|
||||
progress: transitionProgress,
|
||||
startFocus: currentFocus,
|
||||
endFocus: nextFocus,
|
||||
startScale: currentScale,
|
||||
endScale: nextScale,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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(
|
||||
regions: ZoomRegion[],
|
||||
timeMs: number,
|
||||
options: DominantRegionOptions = {},
|
||||
): DominantRegionResult {
|
||||
const connectZooms = !!options.connectZooms;
|
||||
const telemetry = options.cursorTelemetry;
|
||||
const vr = options.viewportRatio;
|
||||
const timeMsKey = Math.round(timeMs);
|
||||
|
||||
if (
|
||||
dominantRegionCache &&
|
||||
dominantRegionCache.regions === regions &&
|
||||
dominantRegionCache.timeMsKey === timeMsKey &&
|
||||
dominantRegionCache.telemetry === telemetry &&
|
||||
dominantRegionCache.connectZooms === connectZooms &&
|
||||
dominantRegionCache.viewportRatio === vr
|
||||
) {
|
||||
return dominantRegionCache.result;
|
||||
}
|
||||
|
||||
const connectedPairs = connectZooms ? getConnectedRegionPairs(regions) : [];
|
||||
|
||||
let result: DominantRegionResult;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,61 +1,251 @@
|
||||
import { BlurFilter, Container } from "pixi.js";
|
||||
import { MotionBlurFilter } from "pixi-filters/motion-blur";
|
||||
|
||||
const PEAK_VELOCITY_PPS = 1400;
|
||||
const MAX_BLUR_PX = 14;
|
||||
const VELOCITY_THRESHOLD_PPS = 12;
|
||||
const MAX_AMOUNT_BOOST = 2.2;
|
||||
|
||||
function getMotionBlurAmountResponse(motionBlurAmount: number) {
|
||||
const clampedAmount = Math.min(1, Math.max(0, motionBlurAmount));
|
||||
// Keep the low end usable while giving the top of the slider substantially more headroom.
|
||||
return clampedAmount * (1 + (MAX_AMOUNT_BOOST - 1) * clampedAmount);
|
||||
}
|
||||
|
||||
export interface MotionBlurState {
|
||||
lastFrameTimeMs: number;
|
||||
prevCamX: number;
|
||||
prevCamY: number;
|
||||
prevCamScale: number;
|
||||
initialized: boolean;
|
||||
}
|
||||
|
||||
export function createMotionBlurState(): MotionBlurState {
|
||||
return {
|
||||
lastFrameTimeMs: 0,
|
||||
prevCamX: 0,
|
||||
prevCamY: 0,
|
||||
prevCamScale: 1,
|
||||
initialized: false,
|
||||
};
|
||||
}
|
||||
|
||||
interface TransformParams {
|
||||
cameraContainer: Container;
|
||||
blurFilter: BlurFilter | null;
|
||||
motionBlurFilter?: MotionBlurFilter | null;
|
||||
stageSize: { width: number; height: number };
|
||||
baseMask: { x: number; y: number; width: number; height: number };
|
||||
zoomScale: number;
|
||||
zoomProgress?: number;
|
||||
focusX: number;
|
||||
focusY: number;
|
||||
motionIntensity: number;
|
||||
motionVector?: { x: number; y: number };
|
||||
isPlaying: boolean;
|
||||
motionBlurEnabled?: boolean;
|
||||
motionBlurAmount?: number;
|
||||
transformOverride?: AppliedTransform;
|
||||
motionBlurState?: MotionBlurState;
|
||||
frameTimeMs?: number;
|
||||
}
|
||||
|
||||
export function applyZoomTransform({
|
||||
cameraContainer,
|
||||
blurFilter,
|
||||
interface AppliedTransform {
|
||||
scale: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface FocusFromTransformGeometry {
|
||||
stageSize: { width: number; height: number };
|
||||
baseMask: { x: number; y: number; width: number; height: number };
|
||||
zoomScale: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface ZoomTransformGeometry {
|
||||
stageSize: { width: number; height: number };
|
||||
baseMask: { x: number; y: number; width: number; height: number };
|
||||
zoomScale: number;
|
||||
zoomProgress?: number;
|
||||
focusX: number;
|
||||
focusY: number;
|
||||
}
|
||||
|
||||
export function computeZoomTransform({
|
||||
stageSize,
|
||||
baseMask,
|
||||
zoomScale,
|
||||
zoomProgress = 1,
|
||||
focusX,
|
||||
focusY,
|
||||
motionIntensity,
|
||||
isPlaying,
|
||||
motionBlurEnabled = false,
|
||||
}: TransformParams) {
|
||||
}: ZoomTransformGeometry): AppliedTransform {
|
||||
if (
|
||||
stageSize.width <= 0 ||
|
||||
stageSize.height <= 0 ||
|
||||
baseMask.width <= 0 ||
|
||||
baseMask.height <= 0
|
||||
) {
|
||||
return;
|
||||
return { scale: 1, x: 0, y: 0 };
|
||||
}
|
||||
|
||||
// The focus point in stage coordinates (where the user clicked/selected)
|
||||
const progress = Math.min(1, Math.max(0, zoomProgress));
|
||||
// Focus coordinates are stage-normalized (0-1 of full canvas),
|
||||
// so map directly to stage pixels, not through baseMask.
|
||||
const focusStagePxX = focusX * stageSize.width;
|
||||
const focusStagePxY = focusY * stageSize.height;
|
||||
|
||||
// Stage center (where we want the focus to end up after zoom)
|
||||
const stageCenterX = stageSize.width / 2;
|
||||
const stageCenterY = stageSize.height / 2;
|
||||
const scale = 1 + (zoomScale - 1) * progress;
|
||||
const finalX = stageCenterX - focusStagePxX * zoomScale;
|
||||
const finalY = stageCenterY - focusStagePxY * zoomScale;
|
||||
|
||||
// Apply zoom scale to camera container
|
||||
cameraContainer.scale.set(zoomScale);
|
||||
return {
|
||||
scale,
|
||||
x: finalX * progress,
|
||||
y: finalY * progress,
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate camera position to keep focus point centered
|
||||
// After scaling, the focus point moves to (focusX * zoomScale, focusY * zoomScale)
|
||||
// We want it at stage center, so offset = center - (focus * scale)
|
||||
const cameraX = stageCenterX - focusStagePxX * zoomScale;
|
||||
const cameraY = stageCenterY - focusStagePxY * zoomScale;
|
||||
export function computeFocusFromTransform({
|
||||
stageSize,
|
||||
baseMask,
|
||||
zoomScale,
|
||||
x,
|
||||
y,
|
||||
}: FocusFromTransformGeometry) {
|
||||
if (
|
||||
stageSize.width <= 0 ||
|
||||
stageSize.height <= 0 ||
|
||||
baseMask.width <= 0 ||
|
||||
baseMask.height <= 0 ||
|
||||
zoomScale <= 0
|
||||
) {
|
||||
return { cx: 0.5, cy: 0.5 };
|
||||
}
|
||||
|
||||
const stageCenterX = stageSize.width / 2;
|
||||
const stageCenterY = stageSize.height / 2;
|
||||
const focusStagePxX = (stageCenterX - x) / zoomScale;
|
||||
const focusStagePxY = (stageCenterY - y) / zoomScale;
|
||||
|
||||
return {
|
||||
cx: focusStagePxX / stageSize.width,
|
||||
cy: focusStagePxY / stageSize.height,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyZoomTransform({
|
||||
cameraContainer,
|
||||
blurFilter,
|
||||
motionBlurFilter,
|
||||
stageSize,
|
||||
baseMask,
|
||||
zoomScale,
|
||||
zoomProgress = 1,
|
||||
focusX,
|
||||
focusY,
|
||||
motionIntensity: _motionIntensity,
|
||||
motionVector: _motionVector,
|
||||
isPlaying,
|
||||
motionBlurAmount = 0,
|
||||
transformOverride,
|
||||
motionBlurState,
|
||||
frameTimeMs,
|
||||
}: TransformParams): AppliedTransform {
|
||||
if (
|
||||
stageSize.width <= 0 ||
|
||||
stageSize.height <= 0 ||
|
||||
baseMask.width <= 0 ||
|
||||
baseMask.height <= 0
|
||||
) {
|
||||
return { scale: 1, x: 0, y: 0 };
|
||||
}
|
||||
|
||||
const transform =
|
||||
transformOverride ??
|
||||
computeZoomTransform({
|
||||
stageSize,
|
||||
baseMask,
|
||||
zoomScale,
|
||||
zoomProgress,
|
||||
focusX,
|
||||
focusY,
|
||||
});
|
||||
|
||||
// Apply position & scale to camera container
|
||||
cameraContainer.scale.set(transform.scale);
|
||||
cameraContainer.position.set(transform.x, transform.y);
|
||||
|
||||
cameraContainer.position.set(cameraX, cameraY);
|
||||
if (motionBlurState && motionBlurFilter && motionBlurAmount > 0 && isPlaying) {
|
||||
const now = frameTimeMs ?? performance.now();
|
||||
|
||||
if (blurFilter) {
|
||||
const shouldBlur = motionBlurEnabled && isPlaying && motionIntensity > 0.0005;
|
||||
const motionBlur = shouldBlur ? Math.min(6, motionIntensity * 120) : 0;
|
||||
blurFilter.blur = motionBlur;
|
||||
if (!motionBlurState.initialized) {
|
||||
motionBlurState.prevCamX = transform.x;
|
||||
motionBlurState.prevCamY = transform.y;
|
||||
motionBlurState.prevCamScale = transform.scale;
|
||||
motionBlurState.lastFrameTimeMs = now;
|
||||
motionBlurState.initialized = true;
|
||||
motionBlurFilter.velocity = { x: 0, y: 0 };
|
||||
motionBlurFilter.kernelSize = 5;
|
||||
motionBlurFilter.offset = 0;
|
||||
if (blurFilter) blurFilter.blur = 0;
|
||||
} else {
|
||||
const dtMs = Math.min(80, Math.max(1, now - motionBlurState.lastFrameTimeMs));
|
||||
const dtSeconds = dtMs / 1000;
|
||||
motionBlurState.lastFrameTimeMs = now;
|
||||
const amountResponse = getMotionBlurAmountResponse(motionBlurAmount);
|
||||
|
||||
// Camera displacement this frame (stage-px)
|
||||
const dx = transform.x - motionBlurState.prevCamX;
|
||||
const dy = transform.y - motionBlurState.prevCamY;
|
||||
const dScale = transform.scale - motionBlurState.prevCamScale;
|
||||
|
||||
motionBlurState.prevCamX = transform.x;
|
||||
motionBlurState.prevCamY = transform.y;
|
||||
motionBlurState.prevCamScale = transform.scale;
|
||||
|
||||
// Velocity in px/s (translation + scale-change contribution)
|
||||
const velocityX = dx / dtSeconds;
|
||||
const velocityY = dy / dtSeconds;
|
||||
const scaleVelocity =
|
||||
Math.abs(dScale / dtSeconds) * Math.max(stageSize.width, stageSize.height) * 0.5;
|
||||
const speed = Math.sqrt(velocityX * velocityX + velocityY * velocityY) + scaleVelocity;
|
||||
|
||||
const normalised = Math.min(1, speed / PEAK_VELOCITY_PPS);
|
||||
const targetBlur =
|
||||
speed < VELOCITY_THRESHOLD_PPS ? 0 : normalised * normalised * MAX_BLUR_PX * amountResponse;
|
||||
|
||||
const dirMag = Math.sqrt(velocityX * velocityX + velocityY * velocityY) || 1;
|
||||
const velocityScale = targetBlur * 2.4;
|
||||
motionBlurFilter.velocity =
|
||||
targetBlur > 0
|
||||
? { x: (velocityX / dirMag) * velocityScale, y: (velocityY / dirMag) * velocityScale }
|
||||
: { x: 0, y: 0 };
|
||||
motionBlurFilter.kernelSize = targetBlur > 8 ? 15 : targetBlur > 4 ? 11 : 7;
|
||||
motionBlurFilter.offset = targetBlur > 0.5 ? -0.2 : 0;
|
||||
|
||||
if (blurFilter) {
|
||||
blurFilter.blur = 0;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (motionBlurFilter) {
|
||||
motionBlurFilter.velocity = { x: 0, y: 0 };
|
||||
motionBlurFilter.kernelSize = 5;
|
||||
motionBlurFilter.offset = 0;
|
||||
}
|
||||
if (blurFilter) {
|
||||
blurFilter.blur = 0;
|
||||
}
|
||||
if (motionBlurState) {
|
||||
motionBlurState.initialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
scale: transform.scale,
|
||||
x: transform.x,
|
||||
y: transform.y,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -36,7 +36,9 @@ export function ShortcutsProvider({ children }: { children: ReactNode }) {
|
||||
useEffect(() => {
|
||||
getIsMac()
|
||||
.then(setIsMac)
|
||||
.catch(() => {});
|
||||
.catch(() => {
|
||||
// Keep default non-mac fallback if detection fails.
|
||||
});
|
||||
|
||||
window.electronAPI
|
||||
.getShortcuts?.()
|
||||
@@ -45,7 +47,9 @@ export function ShortcutsProvider({ children }: { children: ReactNode }) {
|
||||
setShortcuts(mergeWithDefaults(saved as Partial<ShortcutsConfig>));
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch(() => {
|
||||
// Keep default shortcuts if persisted settings can't be loaded.
|
||||
});
|
||||
}, []);
|
||||
|
||||
const persistShortcuts = useCallback(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
export interface AudioLevelMeterOptions {
|
||||
enabled: boolean;
|
||||
@@ -13,6 +13,22 @@ export function useAudioLevelMeter(options: AudioLevelMeterOptions) {
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const animationFrameRef = useRef<number | null>(null);
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
animationFrameRef.current = null;
|
||||
}
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach((track) => track.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
if (audioContextRef.current) {
|
||||
audioContextRef.current.close();
|
||||
audioContextRef.current = null;
|
||||
}
|
||||
analyserRef.current = null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!options.enabled) {
|
||||
cleanup();
|
||||
@@ -39,6 +55,9 @@ export function useAudioLevelMeter(options: AudioLevelMeterOptions) {
|
||||
streamRef.current = stream;
|
||||
|
||||
const audioContext = new AudioContext();
|
||||
if (audioContext.state === "suspended") {
|
||||
await audioContext.resume();
|
||||
}
|
||||
audioContextRef.current = audioContext;
|
||||
|
||||
const analyser = audioContext.createAnalyser();
|
||||
@@ -82,23 +101,7 @@ export function useAudioLevelMeter(options: AudioLevelMeterOptions) {
|
||||
mounted = false;
|
||||
cleanup();
|
||||
};
|
||||
}, [options.enabled, options.deviceId, options.smoothingFactor]);
|
||||
|
||||
const cleanup = () => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
animationFrameRef.current = null;
|
||||
}
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach((track) => track.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
if (audioContextRef.current) {
|
||||
audioContextRef.current.close();
|
||||
audioContextRef.current = null;
|
||||
}
|
||||
analyserRef.current = null;
|
||||
};
|
||||
}, [options.enabled, options.deviceId, options.smoothingFactor, cleanup]);
|
||||
|
||||
return { level };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useScopedT } from "@/contexts/I18nContext";
|
||||
|
||||
export type ClipboardAction = "copy" | "paste";
|
||||
|
||||
export function useClipboardFeedback() {
|
||||
const t = useScopedT("timeline");
|
||||
|
||||
const notifyClipboardAction = useCallback(
|
||||
(action: ClipboardAction) => {
|
||||
toast.success(action === "copy" ? t("feedback.copied") : t("feedback.pasted"));
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const notifyCopied = useCallback(() => {
|
||||
notifyClipboardAction("copy");
|
||||
}, [notifyClipboardAction]);
|
||||
|
||||
const notifyPasted = useCallback(() => {
|
||||
notifyClipboardAction("paste");
|
||||
}, [notifyClipboardAction]);
|
||||
|
||||
return {
|
||||
notifyClipboardAction,
|
||||
notifyCopied,
|
||||
notifyPasted,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import type {
|
||||
AnnotationRegion,
|
||||
CropRegion,
|
||||
SpeedRegion,
|
||||
TrimRegion,
|
||||
WebcamLayoutPreset,
|
||||
WebcamMaskShape,
|
||||
WebcamPosition,
|
||||
WebcamSizePreset,
|
||||
ZoomRegion,
|
||||
} 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";
|
||||
|
||||
// Undoable state — selection IDs are intentionally excluded (undoing a
|
||||
// selection change would feel surprising to the user).
|
||||
export interface EditorState {
|
||||
zoomRegions: ZoomRegion[];
|
||||
trimRegions: TrimRegion[];
|
||||
speedRegions: SpeedRegion[];
|
||||
annotationRegions: AnnotationRegion[];
|
||||
cropRegion: CropRegion;
|
||||
wallpaper: string;
|
||||
shadowIntensity: number;
|
||||
showBlur: boolean;
|
||||
motionBlurAmount: number;
|
||||
borderRadius: number;
|
||||
padding: number;
|
||||
aspectRatio: AspectRatio;
|
||||
webcamLayoutPreset: WebcamLayoutPreset;
|
||||
webcamMaskShape: WebcamMaskShape;
|
||||
webcamSizePreset: WebcamSizePreset;
|
||||
webcamPosition: WebcamPosition | null;
|
||||
cursorHighlight: CursorHighlightConfig;
|
||||
}
|
||||
|
||||
export const INITIAL_EDITOR_STATE: EditorState = {
|
||||
zoomRegions: [],
|
||||
trimRegions: [],
|
||||
speedRegions: [],
|
||||
annotationRegions: [],
|
||||
cropRegion: DEFAULT_CROP_REGION,
|
||||
wallpaper: DEFAULT_WALLPAPER,
|
||||
shadowIntensity: 0,
|
||||
showBlur: false,
|
||||
motionBlurAmount: 0,
|
||||
borderRadius: 0,
|
||||
padding: 50,
|
||||
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>);
|
||||
|
||||
interface History {
|
||||
past: EditorState[];
|
||||
present: EditorState;
|
||||
future: EditorState[];
|
||||
}
|
||||
|
||||
const MAX_HISTORY = 80;
|
||||
|
||||
function resolve(present: EditorState, update: StateUpdate): EditorState {
|
||||
const partial = typeof update === "function" ? update(present) : update;
|
||||
return { ...present, ...partial };
|
||||
}
|
||||
|
||||
function withCheckpoint(history: History, newPresent: EditorState): History {
|
||||
return {
|
||||
past: [...history.past.slice(-(MAX_HISTORY - 1)), history.present],
|
||||
present: newPresent,
|
||||
future: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function useEditorHistory(initial: EditorState = INITIAL_EDITOR_STATE) {
|
||||
const [history, setHistory] = useState<History>({ past: [], present: initial, future: [] });
|
||||
|
||||
// Tracks whether a live-update series (e.g. slider drag) is in progress.
|
||||
// The first updateState call saves the pre-interaction state as a checkpoint.
|
||||
const dirtyRef = useRef(false);
|
||||
|
||||
const pushState = useCallback((update: StateUpdate) => {
|
||||
setHistory((prev) => withCheckpoint(prev, resolve(prev.present, update)));
|
||||
dirtyRef.current = false;
|
||||
}, []);
|
||||
|
||||
const updateState = useCallback((update: StateUpdate) => {
|
||||
const isFirst = !dirtyRef.current;
|
||||
dirtyRef.current = true;
|
||||
setHistory((prev) => {
|
||||
const next = resolve(prev.present, update);
|
||||
return isFirst ? withCheckpoint(prev, next) : { ...prev, present: next };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const commitState = useCallback(() => {
|
||||
dirtyRef.current = false;
|
||||
}, []);
|
||||
|
||||
const undo = useCallback(() => {
|
||||
setHistory((prev) => {
|
||||
if (!prev.past.length) return prev;
|
||||
const previous = prev.past[prev.past.length - 1];
|
||||
return {
|
||||
past: prev.past.slice(0, -1),
|
||||
present: previous,
|
||||
future: [prev.present, ...prev.future],
|
||||
};
|
||||
});
|
||||
dirtyRef.current = false;
|
||||
}, []);
|
||||
|
||||
const redo = useCallback(() => {
|
||||
setHistory((prev) => {
|
||||
if (!prev.future.length) return prev;
|
||||
const [next, ...remainingFuture] = prev.future;
|
||||
return { past: [...prev.past, prev.present], present: next, future: remainingFuture };
|
||||
});
|
||||
dirtyRef.current = false;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
state: history.present,
|
||||
pushState,
|
||||
updateState,
|
||||
commitState,
|
||||
undo,
|
||||
redo,
|
||||
canUndo: history.past.length > 0,
|
||||
canRedo: history.future.length > 0,
|
||||
};
|
||||
}
|
||||
@@ -69,7 +69,7 @@ export function useMicrophoneDevices(enabled: boolean = true) {
|
||||
mounted = false;
|
||||
navigator.mediaDevices.removeEventListener("devicechange", handleDeviceChange);
|
||||
};
|
||||
}, [enabled]);
|
||||
}, [enabled, selectedDeviceId]);
|
||||
|
||||
return {
|
||||
devices,
|
||||
|
||||
+710
-118
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,61 @@
|
||||
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";
|
||||
import zhTWDialogs from "@/i18n/locales/zh-TW/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,
|
||||
"zh-TW": zhTWDialogs,
|
||||
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,16 @@
|
||||
export const DEFAULT_LOCALE = "en" as const;
|
||||
export const SUPPORTED_LOCALES = ["en", "zh-CN", "zh-TW", "es", "fr", "tr", "ko-KR"] 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": "الاحتفاظ باللغة الحالية"
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user