electrobun 0.0.19-beta.11 → 0.0.19-beta.111
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/BUILD.md +90 -0
- package/bin/electrobun.cjs +39 -14
- package/debug.js +5 -0
- package/dist/api/browser/builtinrpcSchema.ts +19 -0
- package/dist/api/browser/index.ts +409 -0
- package/dist/api/browser/rpc/webview.ts +79 -0
- package/dist/api/browser/stylesAndElements.ts +3 -0
- package/dist/api/browser/webviewtag.ts +534 -0
- package/dist/api/bun/core/ApplicationMenu.ts +66 -0
- package/dist/api/bun/core/BrowserView.ts +349 -0
- package/dist/api/bun/core/BrowserWindow.ts +191 -0
- package/dist/api/bun/core/ContextMenu.ts +67 -0
- package/dist/api/bun/core/Paths.ts +5 -0
- package/dist/api/bun/core/Socket.ts +181 -0
- package/dist/api/bun/core/Tray.ts +107 -0
- package/dist/api/bun/core/Updater.ts +680 -0
- package/dist/api/bun/core/Utils.ts +48 -0
- package/dist/api/bun/events/ApplicationEvents.ts +14 -0
- package/dist/api/bun/events/event.ts +29 -0
- package/dist/api/bun/events/eventEmitter.ts +45 -0
- package/dist/api/bun/events/trayEvents.ts +9 -0
- package/dist/api/bun/events/webviewEvents.ts +16 -0
- package/dist/api/bun/events/windowEvents.ts +12 -0
- package/dist/api/bun/index.ts +45 -0
- package/dist/api/bun/proc/linux.md +43 -0
- package/dist/api/bun/proc/native.ts +1220 -0
- package/dist/api/shared/platform.ts +48 -0
- package/dist/main.js +54 -0
- package/package.json +9 -6
- package/src/cli/index.ts +1227 -223
- package/templates/hello-world/README.md +57 -0
- package/templates/hello-world/bun.lock +63 -0
- package/templates/hello-world/electrobun.config +18 -0
- package/templates/hello-world/package.json +16 -0
- package/templates/hello-world/src/bun/index.ts +15 -0
- package/templates/hello-world/src/mainview/index.css +124 -0
- package/templates/hello-world/src/mainview/index.html +50 -0
- package/templates/hello-world/src/mainview/index.ts +24 -0
- package/templates/photo-booth/README.md +108 -0
- package/templates/photo-booth/bun.lock +225 -0
- package/templates/photo-booth/electrobun.config +28 -0
- package/templates/photo-booth/package.json +16 -0
- package/templates/photo-booth/src/bun/index.ts +91 -0
- package/templates/photo-booth/src/mainview/index.css +353 -0
- package/templates/photo-booth/src/mainview/index.html +95 -0
- package/templates/photo-booth/src/mainview/index.ts +584 -0
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
import Electrobun, { Electroview } from "electrobun/view";
|
|
2
|
+
import type { PhotoBoothRPC } from "../bun/index";
|
|
3
|
+
|
|
4
|
+
// Create RPC client
|
|
5
|
+
const rpc = Electroview.defineRPC<PhotoBoothRPC>({
|
|
6
|
+
maxRequestTime: 5000,
|
|
7
|
+
handlers: {
|
|
8
|
+
requests: {},
|
|
9
|
+
messages: {}
|
|
10
|
+
}
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const electrobun = new Electrobun.Electroview({ rpc });
|
|
14
|
+
|
|
15
|
+
interface Photo {
|
|
16
|
+
id: string;
|
|
17
|
+
dataUrl: string;
|
|
18
|
+
timestamp: Date;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
class PhotoBooth {
|
|
22
|
+
private video: HTMLVideoElement;
|
|
23
|
+
private canvas: HTMLCanvasElement;
|
|
24
|
+
private captureBtn: HTMLButtonElement;
|
|
25
|
+
private gallery: HTMLElement;
|
|
26
|
+
private cameraSelect: HTMLSelectElement;
|
|
27
|
+
private timerToggle: HTMLInputElement;
|
|
28
|
+
private changeSourceBtn: HTMLButtonElement;
|
|
29
|
+
private status: HTMLElement;
|
|
30
|
+
private statusText: HTMLElement;
|
|
31
|
+
private countdown: HTMLElement;
|
|
32
|
+
private modal: HTMLElement;
|
|
33
|
+
private modalImage: HTMLImageElement;
|
|
34
|
+
|
|
35
|
+
private stream: MediaStream | null = null;
|
|
36
|
+
private photos: Photo[] = [];
|
|
37
|
+
private currentPhotoId: string | null = null;
|
|
38
|
+
|
|
39
|
+
constructor() {
|
|
40
|
+
// Get DOM elements
|
|
41
|
+
this.video = document.getElementById('video') as HTMLVideoElement;
|
|
42
|
+
this.canvas = document.getElementById('canvas') as HTMLCanvasElement;
|
|
43
|
+
this.captureBtn = document.getElementById('captureBtn') as HTMLButtonElement;
|
|
44
|
+
this.gallery = document.getElementById('gallery') as HTMLElement;
|
|
45
|
+
this.cameraSelect = document.getElementById('cameraSelect') as HTMLSelectElement;
|
|
46
|
+
this.timerToggle = document.getElementById('timerToggle') as HTMLInputElement;
|
|
47
|
+
this.changeSourceBtn = document.getElementById('changeSourceBtn') as HTMLButtonElement;
|
|
48
|
+
this.status = document.getElementById('status') as HTMLElement;
|
|
49
|
+
this.statusText = this.status.querySelector('.status-text') as HTMLElement;
|
|
50
|
+
this.countdown = document.getElementById('countdown') as HTMLElement;
|
|
51
|
+
this.modal = document.getElementById('photoModal') as HTMLElement;
|
|
52
|
+
this.modalImage = document.getElementById('modalImage') as HTMLImageElement;
|
|
53
|
+
|
|
54
|
+
this.initializeEventListeners();
|
|
55
|
+
this.initializeCamera();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private initializeEventListeners() {
|
|
59
|
+
// Capture button
|
|
60
|
+
this.captureBtn.addEventListener('click', (e) => {
|
|
61
|
+
console.log('Capture button clicked - event:', e);
|
|
62
|
+
this.capturePhoto();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Camera selector
|
|
66
|
+
this.cameraSelect.addEventListener('change', (e) => {
|
|
67
|
+
const deviceId = (e.target as HTMLSelectElement).value;
|
|
68
|
+
if (deviceId) {
|
|
69
|
+
this.switchCamera(deviceId);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Change source button for screen capture
|
|
74
|
+
this.changeSourceBtn.addEventListener('click', (e) => {
|
|
75
|
+
console.log('Change source button clicked - event:', e);
|
|
76
|
+
e.preventDefault();
|
|
77
|
+
this.changeScreenSource();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Modal controls
|
|
81
|
+
document.getElementById('modalClose')?.addEventListener('click', () => this.closeModal());
|
|
82
|
+
document.getElementById('downloadBtn')?.addEventListener('click', () => this.saveCurrentPhoto());
|
|
83
|
+
document.getElementById('deleteBtn')?.addEventListener('click', () => this.deleteCurrentPhoto());
|
|
84
|
+
|
|
85
|
+
// Close modal on background click
|
|
86
|
+
this.modal.addEventListener('click', (e) => {
|
|
87
|
+
if (e.target === this.modal) {
|
|
88
|
+
this.closeModal();
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// No need to listen for results - we'll handle them in saveCurrentPhoto
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private async initializeCamera() {
|
|
96
|
+
try {
|
|
97
|
+
// First, get available cameras without requesting permission
|
|
98
|
+
await this.getAvailableCameras();
|
|
99
|
+
|
|
100
|
+
// Check if cameras are available but don't request permission yet
|
|
101
|
+
const devices = await navigator.mediaDevices.enumerateDevices();
|
|
102
|
+
const videoDevices = devices.filter(device => device.kind === 'videoinput');
|
|
103
|
+
|
|
104
|
+
if (videoDevices.length > 0) {
|
|
105
|
+
// Camera available but don't access it until user clicks capture
|
|
106
|
+
this.setStatus('Camera available - click capture to start', false);
|
|
107
|
+
this.captureBtn.disabled = false;
|
|
108
|
+
} else {
|
|
109
|
+
console.log('No cameras found, enabling screen capture mode');
|
|
110
|
+
// No cameras found, enable screen capture mode
|
|
111
|
+
this.enableScreenCaptureMode();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
} catch (error) {
|
|
115
|
+
console.log('Camera enumeration failed, enabling screen capture mode:', error);
|
|
116
|
+
// If camera enumeration fails, enable screen capture mode
|
|
117
|
+
this.enableScreenCaptureMode();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private enableScreenCaptureMode() {
|
|
122
|
+
// Check what media APIs are available
|
|
123
|
+
console.log('MediaDevices available:', !!navigator.mediaDevices);
|
|
124
|
+
console.log('getUserMedia available:', !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia));
|
|
125
|
+
console.log('getDisplayMedia available:', !!(navigator.mediaDevices && (navigator.mediaDevices as any).getDisplayMedia));
|
|
126
|
+
console.log('WebRTC APIs available:', !!window.RTCPeerConnection);
|
|
127
|
+
|
|
128
|
+
this.setStatus('Screen capture mode', true);
|
|
129
|
+
this.captureBtn.disabled = false;
|
|
130
|
+
|
|
131
|
+
// Hide camera selector and show change source button
|
|
132
|
+
this.cameraSelect.style.display = 'none';
|
|
133
|
+
this.changeSourceBtn.style.display = 'flex';
|
|
134
|
+
|
|
135
|
+
// Update UI to indicate screen capture mode
|
|
136
|
+
const captureBtn = this.captureBtn;
|
|
137
|
+
captureBtn.innerHTML = `
|
|
138
|
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
139
|
+
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
|
|
140
|
+
<line x1="8" y1="21" x2="16" y2="21"/>
|
|
141
|
+
<line x1="12" y1="17" x2="12" y2="21"/>
|
|
142
|
+
</svg>
|
|
143
|
+
Take Screenshot
|
|
144
|
+
`;
|
|
145
|
+
|
|
146
|
+
if (navigator.mediaDevices && (navigator.mediaDevices as any).getDisplayMedia) {
|
|
147
|
+
this.showStatus('No camera found. Click "Take Screenshot" to capture your screen instead!', 'info');
|
|
148
|
+
|
|
149
|
+
console.log('getDisplayMedia is available - ready for screen capture');
|
|
150
|
+
} else {
|
|
151
|
+
this.showStatus('Screen capture not supported in this WebKit version.', 'error');
|
|
152
|
+
this.captureBtn.disabled = true;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private async tryCamera() {
|
|
157
|
+
// Request camera permission and start stream
|
|
158
|
+
const constraints: MediaStreamConstraints = {
|
|
159
|
+
video: {
|
|
160
|
+
width: { ideal: 1280 },
|
|
161
|
+
height: { ideal: 720 }
|
|
162
|
+
// Removed facingMode constraint as it's not supported on most desktop cameras
|
|
163
|
+
},
|
|
164
|
+
audio: false
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
this.stream = await navigator.mediaDevices.getUserMedia(constraints);
|
|
168
|
+
this.video.srcObject = this.stream;
|
|
169
|
+
|
|
170
|
+
// Update status
|
|
171
|
+
this.setStatus('Camera active', true);
|
|
172
|
+
this.captureBtn.disabled = false;
|
|
173
|
+
|
|
174
|
+
// Update camera list with active camera
|
|
175
|
+
const videoTrack = this.stream.getVideoTracks()[0];
|
|
176
|
+
const settings = videoTrack.getSettings();
|
|
177
|
+
if (settings.deviceId) {
|
|
178
|
+
this.cameraSelect.value = settings.deviceId;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private async tryScreenCapture() {
|
|
183
|
+
console.log('tryScreenCapture called');
|
|
184
|
+
|
|
185
|
+
// Stop existing stream if any
|
|
186
|
+
if (this.stream) {
|
|
187
|
+
console.log('Stopping existing stream');
|
|
188
|
+
this.stream.getTracks().forEach(track => track.stop());
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Check if getDisplayMedia is available
|
|
192
|
+
if (!navigator.mediaDevices || !(navigator.mediaDevices as any).getDisplayMedia) {
|
|
193
|
+
throw new Error('getDisplayMedia not supported in this browser');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
console.log('Requesting display media...');
|
|
197
|
+
// Request screen capture (requires user gesture)
|
|
198
|
+
this.stream = await (navigator.mediaDevices as any).getDisplayMedia({
|
|
199
|
+
video: true,
|
|
200
|
+
audio: false
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
console.log('Display media obtained:', this.stream);
|
|
204
|
+
|
|
205
|
+
this.video.srcObject = this.stream;
|
|
206
|
+
|
|
207
|
+
// Wait for video to be ready
|
|
208
|
+
await new Promise<void>((resolve) => {
|
|
209
|
+
const onLoadedData = () => {
|
|
210
|
+
this.video.removeEventListener('loadeddata', onLoadedData);
|
|
211
|
+
resolve();
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
if (this.video.readyState >= 2) {
|
|
215
|
+
// Video is already loaded
|
|
216
|
+
resolve();
|
|
217
|
+
} else {
|
|
218
|
+
this.video.addEventListener('loadeddata', onLoadedData);
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// Update status to indicate screen capture
|
|
223
|
+
this.setStatus('Screen capture active', true);
|
|
224
|
+
this.captureBtn.disabled = false;
|
|
225
|
+
|
|
226
|
+
// Hide camera selector and show change source button
|
|
227
|
+
this.cameraSelect.style.display = 'none';
|
|
228
|
+
this.changeSourceBtn.style.display = 'flex';
|
|
229
|
+
|
|
230
|
+
// Listen for when the user stops sharing (e.g., closes the share dialog)
|
|
231
|
+
this.stream.getVideoTracks()[0].addEventListener('ended', () => {
|
|
232
|
+
this.setStatus('Screen sharing stopped', false, true);
|
|
233
|
+
this.showStatus('Screen sharing was stopped. Click "Change Source" to select a new source.', 'info');
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private async changeScreenSource() {
|
|
238
|
+
console.log('Change source button clicked');
|
|
239
|
+
try {
|
|
240
|
+
console.log('Attempting screen capture...');
|
|
241
|
+
// Always try screen capture when user explicitly asks to change source
|
|
242
|
+
await this.tryScreenCapture();
|
|
243
|
+
console.log('Screen capture successful');
|
|
244
|
+
this.showStatus('Screen source changed successfully!', 'success');
|
|
245
|
+
} catch (error) {
|
|
246
|
+
console.error('Failed to change screen source:', error);
|
|
247
|
+
this.showStatus(`Screen capture failed: ${error.message}`, 'error');
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private async getAvailableCameras() {
|
|
252
|
+
try {
|
|
253
|
+
const devices = await navigator.mediaDevices.enumerateDevices();
|
|
254
|
+
const videoDevices = devices.filter(device => device.kind === 'videoinput');
|
|
255
|
+
|
|
256
|
+
// Clear existing options
|
|
257
|
+
this.cameraSelect.innerHTML = '<option value="">Select Camera</option>';
|
|
258
|
+
|
|
259
|
+
// Add camera options
|
|
260
|
+
videoDevices.forEach((device, index) => {
|
|
261
|
+
const option = document.createElement('option');
|
|
262
|
+
option.value = device.deviceId;
|
|
263
|
+
option.textContent = device.label || `Camera ${index + 1}`;
|
|
264
|
+
this.cameraSelect.appendChild(option);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// Show/hide camera selector based on available cameras
|
|
268
|
+
this.cameraSelect.style.display = videoDevices.length > 1 ? 'block' : 'none';
|
|
269
|
+
|
|
270
|
+
} catch (error) {
|
|
271
|
+
console.error('Error enumerating devices:', error);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private async switchCamera(deviceId: string) {
|
|
276
|
+
try {
|
|
277
|
+
// Stop current stream
|
|
278
|
+
if (this.stream) {
|
|
279
|
+
this.stream.getTracks().forEach(track => track.stop());
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Start new stream with selected camera
|
|
283
|
+
const constraints: MediaStreamConstraints = {
|
|
284
|
+
video: {
|
|
285
|
+
deviceId: { exact: deviceId },
|
|
286
|
+
width: { ideal: 1280 },
|
|
287
|
+
height: { ideal: 720 }
|
|
288
|
+
},
|
|
289
|
+
audio: false
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
this.stream = await navigator.mediaDevices.getUserMedia(constraints);
|
|
293
|
+
this.video.srcObject = this.stream;
|
|
294
|
+
|
|
295
|
+
// Wait for video to be ready after switching
|
|
296
|
+
await new Promise<void>((resolve) => {
|
|
297
|
+
const onLoadedData = () => {
|
|
298
|
+
this.video.removeEventListener('loadeddata', onLoadedData);
|
|
299
|
+
resolve();
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
if (this.video.readyState >= 2) {
|
|
303
|
+
// Video is already loaded
|
|
304
|
+
resolve();
|
|
305
|
+
} else {
|
|
306
|
+
this.video.addEventListener('loadeddata', onLoadedData);
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
this.setStatus('Camera switched', true);
|
|
311
|
+
this.showStatus('Camera switched successfully!', 'success');
|
|
312
|
+
|
|
313
|
+
} catch (error) {
|
|
314
|
+
console.error('Error switching camera:', error);
|
|
315
|
+
this.showStatus('Failed to switch camera', 'error');
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private async capturePhoto() {
|
|
320
|
+
console.log('capturePhoto called, stream exists:', !!this.stream);
|
|
321
|
+
|
|
322
|
+
// If no stream is available, try to get one (camera or screen capture)
|
|
323
|
+
if (!this.stream) {
|
|
324
|
+
console.log('No stream available, trying to get one...');
|
|
325
|
+
try {
|
|
326
|
+
// Check if cameras are available first
|
|
327
|
+
const devices = await navigator.mediaDevices.enumerateDevices();
|
|
328
|
+
const videoDevices = devices.filter(device => device.kind === 'videoinput');
|
|
329
|
+
|
|
330
|
+
if (videoDevices.length > 0) {
|
|
331
|
+
// Try camera first
|
|
332
|
+
console.log('Cameras available, trying camera...');
|
|
333
|
+
await this.tryCamera();
|
|
334
|
+
} else {
|
|
335
|
+
// No cameras, try screen capture
|
|
336
|
+
console.log('No cameras available, trying screen capture...');
|
|
337
|
+
await this.tryScreenCapture();
|
|
338
|
+
}
|
|
339
|
+
console.log('Stream obtained successfully');
|
|
340
|
+
} catch (error) {
|
|
341
|
+
console.error('Failed to get stream in capturePhoto:', error);
|
|
342
|
+
this.showStatus(`Failed to get camera/screen: ${error.message}`, 'error');
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (this.timerToggle.checked) {
|
|
348
|
+
// Use timer
|
|
349
|
+
this.captureBtn.disabled = true;
|
|
350
|
+
await this.runCountdown();
|
|
351
|
+
this.takePhoto();
|
|
352
|
+
this.captureBtn.disabled = false;
|
|
353
|
+
} else {
|
|
354
|
+
// Immediate capture
|
|
355
|
+
this.takePhoto();
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
private async runCountdown() {
|
|
360
|
+
for (let i = 3; i > 0; i--) {
|
|
361
|
+
this.countdown.textContent = i.toString();
|
|
362
|
+
this.countdown.classList.add('active');
|
|
363
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
364
|
+
}
|
|
365
|
+
this.countdown.classList.remove('active');
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
private takePhoto() {
|
|
369
|
+
console.log('Taking photo - video dimensions:', this.video.videoWidth, 'x', this.video.videoHeight);
|
|
370
|
+
console.log('Video readyState:', this.video.readyState);
|
|
371
|
+
console.log('Stream state:', this.stream ? 'exists' : 'null');
|
|
372
|
+
|
|
373
|
+
// Check if video is ready - give it more time if needed
|
|
374
|
+
if (!this.video.videoWidth || !this.video.videoHeight) {
|
|
375
|
+
// Try waiting a bit more for the video to load
|
|
376
|
+
setTimeout(() => {
|
|
377
|
+
if (this.video.videoWidth && this.video.videoHeight) {
|
|
378
|
+
console.log('Video ready after timeout, retrying...');
|
|
379
|
+
this.takePhoto();
|
|
380
|
+
} else {
|
|
381
|
+
this.showStatus(`Video not ready (${this.video.videoWidth}x${this.video.videoHeight}). Please try "Change Source".`, 'error');
|
|
382
|
+
}
|
|
383
|
+
}, 1000);
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Check if stream is still active
|
|
388
|
+
if (!this.stream || this.stream.getVideoTracks().length === 0) {
|
|
389
|
+
this.showStatus('Stream not active. Please change source and try again.', 'error');
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const videoTrack = this.stream.getVideoTracks()[0];
|
|
394
|
+
if (!videoTrack || videoTrack.readyState !== 'live') {
|
|
395
|
+
this.showStatus('Video track not live. Please change source and try again.', 'error');
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Set canvas size to match video
|
|
400
|
+
this.canvas.width = this.video.videoWidth;
|
|
401
|
+
this.canvas.height = this.video.videoHeight;
|
|
402
|
+
|
|
403
|
+
// Draw video frame to canvas
|
|
404
|
+
const ctx = this.canvas.getContext('2d');
|
|
405
|
+
if (!ctx) {
|
|
406
|
+
this.showStatus('Canvas not available', 'error');
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
ctx.drawImage(this.video, 0, 0);
|
|
411
|
+
|
|
412
|
+
// Convert to data URL
|
|
413
|
+
const dataUrl = this.canvas.toDataURL('image/png');
|
|
414
|
+
|
|
415
|
+
// Verify we got valid image data
|
|
416
|
+
if (dataUrl === 'data:,' || dataUrl.length < 100) {
|
|
417
|
+
this.showStatus('Failed to capture image data. Please try again.', 'error');
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Create photo object
|
|
422
|
+
const photo: Photo = {
|
|
423
|
+
id: Date.now().toString(),
|
|
424
|
+
dataUrl: dataUrl,
|
|
425
|
+
timestamp: new Date()
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
// Add to photos array
|
|
429
|
+
this.photos.unshift(photo);
|
|
430
|
+
|
|
431
|
+
// Update gallery
|
|
432
|
+
this.updateGallery();
|
|
433
|
+
|
|
434
|
+
// Flash effect
|
|
435
|
+
this.flashEffect();
|
|
436
|
+
|
|
437
|
+
// Show success message
|
|
438
|
+
this.showStatus('Screenshot captured!', 'success');
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
private flashEffect() {
|
|
442
|
+
const flash = document.createElement('div');
|
|
443
|
+
flash.style.cssText = `
|
|
444
|
+
position: fixed;
|
|
445
|
+
top: 0;
|
|
446
|
+
left: 0;
|
|
447
|
+
right: 0;
|
|
448
|
+
bottom: 0;
|
|
449
|
+
background: white;
|
|
450
|
+
opacity: 0.8;
|
|
451
|
+
z-index: 999;
|
|
452
|
+
pointer-events: none;
|
|
453
|
+
`;
|
|
454
|
+
document.body.appendChild(flash);
|
|
455
|
+
|
|
456
|
+
setTimeout(() => {
|
|
457
|
+
flash.style.transition = 'opacity 0.3s';
|
|
458
|
+
flash.style.opacity = '0';
|
|
459
|
+
setTimeout(() => flash.remove(), 300);
|
|
460
|
+
}, 100);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
private updateGallery() {
|
|
464
|
+
if (this.photos.length === 0) {
|
|
465
|
+
this.gallery.innerHTML = '<div class="empty-state">No photos/screenshots yet. Click the capture button to get started!</div>';
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
this.gallery.innerHTML = this.photos.map(photo => `
|
|
470
|
+
<div class="photo-item" data-id="${photo.id}">
|
|
471
|
+
<img src="${photo.dataUrl}" alt="Photo ${photo.id}">
|
|
472
|
+
<div class="photo-time">${this.formatTime(photo.timestamp)}</div>
|
|
473
|
+
</div>
|
|
474
|
+
`).join('');
|
|
475
|
+
|
|
476
|
+
// Add click listeners to photos
|
|
477
|
+
this.gallery.querySelectorAll('.photo-item').forEach(item => {
|
|
478
|
+
item.addEventListener('click', () => {
|
|
479
|
+
const id = item.getAttribute('data-id');
|
|
480
|
+
if (id) this.openModal(id);
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
private formatTime(date: Date): string {
|
|
486
|
+
return date.toLocaleTimeString('en-US', {
|
|
487
|
+
hour: '2-digit',
|
|
488
|
+
minute: '2-digit'
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
private openModal(photoId: string) {
|
|
493
|
+
const photo = this.photos.find(p => p.id === photoId);
|
|
494
|
+
if (!photo) return;
|
|
495
|
+
|
|
496
|
+
this.currentPhotoId = photoId;
|
|
497
|
+
this.modalImage.src = photo.dataUrl;
|
|
498
|
+
this.modal.classList.add('active');
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
private closeModal() {
|
|
502
|
+
this.modal.classList.remove('active');
|
|
503
|
+
this.currentPhotoId = null;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
private async saveCurrentPhoto() {
|
|
507
|
+
if (!this.currentPhotoId) return;
|
|
508
|
+
|
|
509
|
+
const photo = this.photos.find(p => p.id === this.currentPhotoId);
|
|
510
|
+
if (!photo) return;
|
|
511
|
+
|
|
512
|
+
// Generate filename with timestamp
|
|
513
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
514
|
+
const filename = `photo-booth-${timestamp}.png`;
|
|
515
|
+
|
|
516
|
+
try {
|
|
517
|
+
// Call RPC method to save photo
|
|
518
|
+
if (electrobun.rpc) {
|
|
519
|
+
const result = await electrobun.rpc.request.savePhoto({
|
|
520
|
+
dataUrl: photo.dataUrl,
|
|
521
|
+
filename: filename
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
if (result.success) {
|
|
525
|
+
this.showStatus(`Photo saved to ${result.path}`, 'success');
|
|
526
|
+
} else if (result.reason === 'canceled') {
|
|
527
|
+
this.showStatus('Save canceled', 'info');
|
|
528
|
+
} else {
|
|
529
|
+
this.showStatus(`Failed to save photo: ${result.error}`, 'error');
|
|
530
|
+
}
|
|
531
|
+
} else {
|
|
532
|
+
this.showStatus('RPC not available', 'error');
|
|
533
|
+
}
|
|
534
|
+
} catch (error) {
|
|
535
|
+
console.error('Error calling savePhoto RPC:', error);
|
|
536
|
+
this.showStatus('Failed to save photo', 'error');
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
private deleteCurrentPhoto() {
|
|
541
|
+
if (!this.currentPhotoId) return;
|
|
542
|
+
|
|
543
|
+
// Remove from photos array
|
|
544
|
+
this.photos = this.photos.filter(p => p.id !== this.currentPhotoId);
|
|
545
|
+
|
|
546
|
+
// Update gallery
|
|
547
|
+
this.updateGallery();
|
|
548
|
+
|
|
549
|
+
// Close modal
|
|
550
|
+
this.closeModal();
|
|
551
|
+
|
|
552
|
+
this.showStatus('Photo deleted', 'info');
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
private setStatus(text: string, active: boolean, error: boolean = false) {
|
|
556
|
+
this.statusText.textContent = text;
|
|
557
|
+
this.status.classList.toggle('active', active && !error);
|
|
558
|
+
this.status.classList.toggle('error', error);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
private showStatus(message: string, type: 'success' | 'error' | 'info') {
|
|
562
|
+
// You could implement a toast notification here
|
|
563
|
+
console.log(`[${type}] ${message}`);
|
|
564
|
+
|
|
565
|
+
// Update status bar temporarily
|
|
566
|
+
const originalText = this.statusText.textContent;
|
|
567
|
+
const originalClasses = this.status.className;
|
|
568
|
+
|
|
569
|
+
this.statusText.textContent = message;
|
|
570
|
+
this.status.className = 'status ' + type;
|
|
571
|
+
|
|
572
|
+
setTimeout(() => {
|
|
573
|
+
this.statusText.textContent = originalText;
|
|
574
|
+
this.status.className = originalClasses;
|
|
575
|
+
}, 3000);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Initialize app when DOM is ready
|
|
580
|
+
if (document.readyState === 'loading') {
|
|
581
|
+
document.addEventListener('DOMContentLoaded', () => new PhotoBooth());
|
|
582
|
+
} else {
|
|
583
|
+
new PhotoBooth();
|
|
584
|
+
}
|