Skip to content

Commit 39ee8de

Browse files
committed
changed format of replay videos.
1 parent 74042f0 commit 39ee8de

4 files changed

Lines changed: 313 additions & 10 deletions

File tree

.claude/settings.local.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
"Bash(git add:*)",
1010
"Bash(git commit:*)",
1111
"Bash(git push:*)",
12-
"Bash(git pull:*)"
12+
"Bash(git pull:*)",
13+
"WebSearch"
1314
],
1415
"deny": [],
1516
"ask": []

index.html

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
1818
<!-- Iconoir Icons -->
1919
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/iconoir-icons/iconoir@main/css/iconoir.css">
20-
<link rel="stylesheet" href="styles/game.css?v=12">
20+
<link rel="stylesheet" href="styles/game.css?v=17">
21+
<!-- FFmpeg.wasm for video conversion (loaded on-demand) -->
22+
<script src="https://unpkg.com/@ffmpeg/ffmpeg@0.12.10/dist/umd/ffmpeg.js"></script>
2123
</head>
2224
<body>
2325
<!-- Professional Navigation Bar -->
@@ -88,9 +90,12 @@
8890
<button class="btn-replay" onclick="watchReplay()">
8991
<i class="iconoir-play"></i> REPLAY
9092
</button>
91-
<button class="btn-save" onclick="saveReplay()">
93+
<button class="btn-save desktop-only" onclick="saveReplay()">
9294
<i class="iconoir-download"></i> SAVE
9395
</button>
96+
<button class="btn-share mobile-only" onclick="shareReplay()">
97+
<i class="iconoir-share-ios"></i> <span class="share-text">SHARE</span>
98+
</button>
9499
<button class="btn-primary" onclick="closeGameOver()">RETRY</button>
95100
</div>
96101
</div>
@@ -110,9 +115,12 @@
110115
<button class="btn-replay" onclick="watchReplay()">
111116
<i class="iconoir-play"></i> REPLAY
112117
</button>
113-
<button class="btn-save" onclick="saveReplay()">
118+
<button class="btn-save desktop-only" onclick="saveReplay()">
114119
<i class="iconoir-download"></i> SAVE
115120
</button>
121+
<button class="btn-share mobile-only" onclick="shareReplay()">
122+
<i class="iconoir-share-ios"></i> <span class="share-text">SHARE</span>
123+
</button>
116124
<button class="btn-primary" onclick="closeVictory()">RETRY</button>
117125
</div>
118126
</div>
@@ -134,9 +142,12 @@
134142
<button class="btn-replay-control" onclick="toggleReplayPlayback()">
135143
<i id="replayPlayIcon" class="iconoir-play"></i>
136144
</button>
137-
<button class="btn-save" onclick="saveReplay()">
145+
<button class="btn-save desktop-only" onclick="saveReplay()">
138146
<i class="iconoir-download"></i> SAVE
139147
</button>
148+
<button class="btn-share mobile-only" onclick="shareReplay()">
149+
<i class="iconoir-share-ios"></i> <span class="share-text">SHARE</span>
150+
</button>
140151
<button class="btn-secondary" onclick="closeReplay()">CLOSE</button>
141152
</div>
142153
</div>
@@ -314,6 +325,50 @@ <h2 class="mobile-menu-title">REFLECTIONS</h2>
314325
window.game.replayRecorder.downloadVideo(`reflections-${time}.webm`);
315326
}
316327

328+
async function shareReplay() {
329+
if (!window.game || !window.game.replayRecorder.hasReplay()) {
330+
console.warn('No replay available');
331+
alert('No replay available to share.');
332+
return;
333+
}
334+
335+
// Get all share buttons and update their state
336+
const shareButtons = document.querySelectorAll('.btn-share');
337+
const originalContents = [];
338+
339+
// Show loading state
340+
shareButtons.forEach((btn, i) => {
341+
originalContents[i] = btn.innerHTML;
342+
btn.innerHTML = '<i class="iconoir-hourglass"></i> <span class="share-text">Converting...</span>';
343+
btn.disabled = true;
344+
btn.classList.add('converting');
345+
});
346+
347+
try {
348+
const time = document.getElementById('finalTime').textContent.replace(/:/g, '-').replace(/\./g, '-');
349+
const filename = `reflections-${time}.mp4`;
350+
351+
// Try to share using native share API
352+
const shared = await window.game.replayRecorder.shareVideo(filename);
353+
354+
if (!shared) {
355+
// Fallback: try to download MP4 instead
356+
console.log('Share not supported, falling back to MP4 download');
357+
await window.game.replayRecorder.downloadMP4(filename);
358+
}
359+
} catch (error) {
360+
console.error('Share failed:', error);
361+
alert('Unable to share video. Try using the download option on desktop.');
362+
} finally {
363+
// Restore button state
364+
shareButtons.forEach((btn, i) => {
365+
btn.innerHTML = originalContents[i];
366+
btn.disabled = false;
367+
btn.classList.remove('converting');
368+
});
369+
}
370+
}
371+
317372
function closeReplay() {
318373
const video = document.getElementById('replayVideo');
319374
video.pause();

js/classes/ReplayRecorder.js

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/**
22
* ReplayRecorder - Records canvas gameplay as video for replay and download
33
* Uses MediaRecorder API to capture canvas stream
4+
* Supports MP4 conversion and native sharing on mobile
45
*/
56
export class ReplayRecorder {
67
constructor(canvas) {
@@ -11,6 +12,26 @@ export class ReplayRecorder {
1112
this.videoURL = null;
1213
this.isRecording = false;
1314
this.stream = null;
15+
16+
// MP4 conversion state
17+
this.mp4Blob = null;
18+
this.mp4URL = null;
19+
this.isConverting = false;
20+
this.ffmpegLoaded = false;
21+
}
22+
23+
/**
24+
* Check if running on mobile device
25+
*/
26+
static isMobile() {
27+
return /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
28+
}
29+
30+
/**
31+
* Check if Web Share API is supported with files
32+
*/
33+
static canShare() {
34+
return navigator.share && navigator.canShare;
1435
}
1536

1637
/**
@@ -165,6 +186,174 @@ export class ReplayRecorder {
165186
document.body.removeChild(a);
166187
}
167188

189+
/**
190+
* Load FFmpeg for MP4 conversion
191+
*/
192+
async loadFFmpeg() {
193+
if (this.ffmpegLoaded) return true;
194+
195+
try {
196+
// FFmpeg is loaded via script tag, access via window
197+
if (typeof FFmpeg === 'undefined') {
198+
console.error('FFmpeg not loaded. Make sure the script is included.');
199+
return false;
200+
}
201+
202+
this.ffmpeg = new FFmpeg.FFmpeg();
203+
204+
// Set up logging
205+
this.ffmpeg.on('log', ({ message }) => {
206+
console.log('FFmpeg:', message);
207+
});
208+
209+
this.ffmpeg.on('progress', ({ progress }) => {
210+
console.log('FFmpeg progress:', Math.round(progress * 100) + '%');
211+
});
212+
213+
console.log('Loading FFmpeg core...');
214+
await this.ffmpeg.load({
215+
coreURL: 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/umd/ffmpeg-core.js',
216+
wasmURL: 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/umd/ffmpeg-core.wasm'
217+
});
218+
219+
this.ffmpegLoaded = true;
220+
console.log('FFmpeg loaded successfully');
221+
return true;
222+
} catch (error) {
223+
console.error('Failed to load FFmpeg:', error);
224+
return false;
225+
}
226+
}
227+
228+
/**
229+
* Convert WebM to MP4 for mobile compatibility
230+
* @returns {Promise<Blob>} MP4 video blob
231+
*/
232+
async getMP4Blob() {
233+
// Return cached version if available
234+
if (this.mp4Blob) {
235+
return this.mp4Blob;
236+
}
237+
238+
if (!this.videoBlob) {
239+
console.warn('No WebM video to convert');
240+
return null;
241+
}
242+
243+
if (this.isConverting) {
244+
console.warn('Conversion already in progress');
245+
return null;
246+
}
247+
248+
this.isConverting = true;
249+
250+
try {
251+
// Load FFmpeg if not already loaded
252+
const loaded = await this.loadFFmpeg();
253+
if (!loaded) {
254+
throw new Error('Failed to load FFmpeg');
255+
}
256+
257+
console.log('Converting WebM to MP4...');
258+
259+
// Write WebM to virtual filesystem
260+
const webmData = new Uint8Array(await this.videoBlob.arrayBuffer());
261+
await this.ffmpeg.writeFile('input.webm', webmData);
262+
263+
// Convert to MP4 with H.264 codec
264+
await this.ffmpeg.exec([
265+
'-i', 'input.webm',
266+
'-c:v', 'libx264',
267+
'-preset', 'fast',
268+
'-crf', '23',
269+
'-c:a', 'aac',
270+
'-b:a', '128k',
271+
'-movflags', '+faststart',
272+
'output.mp4'
273+
]);
274+
275+
// Read the output
276+
const mp4Data = await this.ffmpeg.readFile('output.mp4');
277+
this.mp4Blob = new Blob([mp4Data], { type: 'video/mp4' });
278+
this.mp4URL = URL.createObjectURL(this.mp4Blob);
279+
280+
// Clean up virtual filesystem
281+
await this.ffmpeg.deleteFile('input.webm');
282+
await this.ffmpeg.deleteFile('output.mp4');
283+
284+
console.log('MP4 conversion complete:', this.mp4Blob.size, 'bytes');
285+
return this.mp4Blob;
286+
} catch (error) {
287+
console.error('MP4 conversion failed:', error);
288+
return null;
289+
} finally {
290+
this.isConverting = false;
291+
}
292+
}
293+
294+
/**
295+
* Share video using native share API (mobile)
296+
* @param {string} filename - Name for the shared file
297+
* @returns {Promise<boolean>} Whether share was successful
298+
*/
299+
async shareVideo(filename = 'reflections-replay.mp4') {
300+
if (!ReplayRecorder.canShare()) {
301+
console.warn('Web Share API not supported');
302+
return false;
303+
}
304+
305+
try {
306+
// Convert to MP4 for mobile compatibility
307+
const mp4Blob = await this.getMP4Blob();
308+
if (!mp4Blob) {
309+
throw new Error('Failed to get MP4 video');
310+
}
311+
312+
// Create file for sharing
313+
const file = new File([mp4Blob], filename, { type: 'video/mp4' });
314+
315+
// Check if we can share this file type
316+
if (!navigator.canShare({ files: [file] })) {
317+
console.warn('Cannot share MP4 files on this device');
318+
return false;
319+
}
320+
321+
// Share with only files property for iOS compatibility
322+
await navigator.share({ files: [file] });
323+
324+
console.log('Video shared successfully');
325+
return true;
326+
} catch (error) {
327+
if (error.name === 'AbortError') {
328+
console.log('Share cancelled by user');
329+
} else {
330+
console.error('Share failed:', error);
331+
}
332+
return false;
333+
}
334+
}
335+
336+
/**
337+
* Download MP4 version (for mobile fallback)
338+
* @param {string} filename - Name for the downloaded file
339+
*/
340+
async downloadMP4(filename = 'reflections-replay.mp4') {
341+
const mp4Blob = await this.getMP4Blob();
342+
if (!mp4Blob) {
343+
console.warn('No MP4 video to download');
344+
return;
345+
}
346+
347+
const url = this.mp4URL;
348+
const a = document.createElement('a');
349+
a.style.display = 'none';
350+
a.href = url;
351+
a.download = filename;
352+
document.body.appendChild(a);
353+
a.click();
354+
document.body.removeChild(a);
355+
}
356+
168357
/**
169358
* Clean up resources
170359
*/
@@ -178,9 +367,17 @@ export class ReplayRecorder {
178367
URL.revokeObjectURL(this.videoURL);
179368
this.videoURL = null;
180369
}
370+
371+
if (this.mp4URL) {
372+
URL.revokeObjectURL(this.mp4URL);
373+
this.mp4URL = null;
374+
}
375+
181376
this.videoBlob = null;
377+
this.mp4Blob = null;
182378
this.recordedChunks = [];
183379
this.mediaRecorder = null;
184380
this.isRecording = false;
381+
this.isConverting = false;
185382
}
186383
}

0 commit comments

Comments
 (0)