diff --git a/app.py b/app.py index 2de18f3..8bf2df4 100644 --- a/app.py +++ b/app.py @@ -26,7 +26,10 @@ def get_base_path(): app = Flask("Beamer+", static_folder=os.path.join(BASE_PATH, 'static'), template_folder=BASE_PATH) -socketio = SocketIO(app, cors_allowed_origins='*', async_mode='threading') +# socketio allows different webpages in our app (viewer, index, etc) to share data +socketio = SocketIO(app, + cors_allowed_origins='*', # meaning of *: all webpages can communicate + async_mode='threading') # Store active surveys and responses surveys = {} @@ -34,7 +37,9 @@ def get_base_path(): # Store current presentation UPLOAD_FOLDER = 'uploads' -os.makedirs(UPLOAD_FOLDER, exist_ok=True) +os.makedirs(UPLOAD_FOLDER, exist_ok=True) +# exist_ok=True ensures that even if '/uploads' already exists, +# the program shouldn't crash current_presentation = { 'file': None, @@ -146,12 +151,28 @@ def upload_presentation(): 'models': available_models }) -@app.route('/api/presentation/current') +# Getter for upload.zip: +@app.route('/api/presentation/current') # by default: methods=['GET'] def get_current_presentation(): if current_presentation['file'] and os.path.exists(current_presentation['file']): return send_file(current_presentation['file'], as_attachment=True, download_name='presentation.zip') return jsonify({'error': 'No presentation loaded'}), 404 +# Getter for metadata about upload.zip +@app.route('/api/presentation/info') # by default: methods=['GET'] +def get_presentation_info(): + if current_presentation['file'] and os.path.exists(current_presentation['file']): + return jsonify({ + 'loaded': True, + 'models': current_presentation.get('available_models', []), + 'filename': os.path.basename(current_presentation['file']) + }) + + return jsonify({ + 'loaded': False + }) + + # Model endpoints @app.route('/api/models') def get_models(): @@ -384,4 +405,4 @@ def handle_screen_frame(data): emit("screen_frame", data, room='viewer') if __name__ == '__main__': - socketio.run(app, host='0.0.0.0', port=5000, debug=True) \ No newline at end of file + socketio.run(app, host='0.0.0.0', port=5000, debug=False) \ No newline at end of file diff --git a/index.html b/index.html index 6cdfc32..7d12b10 100644 --- a/index.html +++ b/index.html @@ -21,8 +21,7 @@ @@ -73,7 +72,9 @@ }, 2000); }); - + + + diff --git a/static/css/button.css b/static/css/button.css index 75c7054..7e3c8cd 100644 --- a/static/css/button.css +++ b/static/css/button.css @@ -29,7 +29,9 @@ background-color: #bbbbbb; } -.btn:disabled { + +.btn:disabled, .not-in-use { + /* .not-in-use applies to viewer-slide-navigator icons when switchToPresentation == false */ opacity: 0.5; cursor: not-allowed; pointer-events: none; diff --git a/static/css/canvas.css b/static/css/canvas.css index b5d32d9..7852f7b 100644 --- a/static/css/canvas.css +++ b/static/css/canvas.css @@ -1,7 +1,8 @@ /* Beamer+ Canvas and Display Styles */ #pdf-container, -#viewer-container { +#viewer-container, +#switch-presentation-container { width: calc(100% - 75px); height: calc(100vh - 60px); position: relative; @@ -13,13 +14,13 @@ background: #f0f0f0; } -#pdf-container video { +#pdf-container video, #switch-presentation-container video { position: absolute; object-fit: contain; pointer-events: auto; } -#pdf-canvas { +#pdf-canvas, #viewer-pdf-canvas { position: absolute; z-index: 1; background-color: #ffffff; @@ -29,7 +30,7 @@ aspect-ratio: 4 / 3; } -#pdf-canvas canvas { +#pdf-canvas canvas, #viewer-pdf-canvas canvas { width: 100% !important; height: 100% !important; display: block; diff --git a/static/css/shared.css b/static/css/shared.css index 68904f6..3ef73ef 100644 --- a/static/css/shared.css +++ b/static/css/shared.css @@ -22,7 +22,7 @@ input[type=file] { } /* Slide Navigator */ -#slide-navigator { +#slide-navigator, #viewer-slide-navigator { position: fixed; left: 0; top: 60px; @@ -40,7 +40,7 @@ input[type=file] { } /* Hide scrollbar for Chrome, Safari and Opera */ -#slide-navigator::-webkit-scrollbar { +#slide-navigator::-webkit-scrollbar, #viewer-slide-navigator::-webkit-scrollbar { display: none; } diff --git a/static/css/viewer-panel.css b/static/css/viewer-panel.css new file mode 100644 index 0000000..1e059c3 --- /dev/null +++ b/static/css/viewer-panel.css @@ -0,0 +1,36 @@ +#viewer-panel { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px; + background: #ccc; + color: white; + gap: 10px; + box-sizing: border-box; + flex-wrap: wrap; + position: relative; + position: relative; + z-index: 200; + border-bottom: 2px solid #666; +} + +#viewer-left { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + flex: 0 1 auto; +} + +#viewer-right { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + flex: 0 0 auto; + margin-left: auto; +} + +.hidden { + display: none !important; +} \ No newline at end of file diff --git a/static/css/viewer.css b/static/css/viewer.css new file mode 100644 index 0000000..5ee5bb8 --- /dev/null +++ b/static/css/viewer.css @@ -0,0 +1,110 @@ +@font-face { + font-family: "Computer Modern Sans"; + src: url("https://mirrors.ctan.org/fonts/cm-unicode/fonts/otf/cmunss.otf"); + font-display: swap; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, +body { + width: 100%; + height: 100%; + overflow: hidden; + background: #fcfcfc; + font-family: "Computer Modern Sans", sans-serif; +} + +/* Viewer container */ +#viewer-container { + width: 100vw; + height: 100vh; + display: flex; + align-items: center; + justify-content: center; + position: absolute; + inset: 0; + z-index: 1; + background: #fcfcfc; + padding: 0; +} + +#screen-display { + width: 100%; + height: 100%; + object-fit: contain; + display: none; +} + +/* Waiting message */ +#waiting-message { + text-align: center; + color: #666; +} + +#waiting-message h2 { + font-size: 28px; + margin-bottom: 20px; + margin-top: 20px; + font-weight: 700; + color: #333; +} + +#waiting-message p { + font-size: 16px; + color: #666; +} + +/* Scribble animation matching Beamer+ modal style */ +.viewer-squiggle { + width: 120px; + height: 40px; + display: block; + margin: 0 auto; +} + +.viewer-squiggle path { + fill: none; + stroke: #666; + stroke-width: 3; + stroke-linecap: round; + stroke-linejoin: round; + stroke-dasharray: 150; + stroke-dashoffset: 150; + animation: draw-squiggle 1.6s ease-in-out infinite; +} + +@keyframes draw-squiggle { + 0% { + stroke-dashoffset: 150; + opacity: 0.6; + } + 40% { + stroke-dashoffset: 0; + opacity: 1; + } + 100% { + stroke-dashoffset: -150; + opacity: 0.6; + } +} + +/* FPS counter in corner */ +#stream-info { + position: fixed; + top: 20px; + right: 20px; + font-size: 14px; + font-weight: 600; + color: #333; + background: #eee; + padding: 6px 12px; + border-radius: 4px; + border: 1px solid #999; + opacity: 0.9; + display: none; +} diff --git a/static/js/main.js b/static/js/main.js index b52d946..d2a7ecc 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -1,457 +1,528 @@ -import { Timer } from './timer.js'; -import { Label } from './label.js'; -import { Button } from './button.js'; -import { Selector } from './selector.js'; -import { Toggle } from './toggle.js'; -import { Canvas } from './canvas.js'; -import { renderWidgets, cleanupWidgets, updateWidgetPositions } from './iframe-widget-renderer.js'; -import { Modal } from './beamer_modal.js'; -import { setControlsEnabledAfterUpload, disableControlButtons } from './beamer_ui.js'; +import { Timer } from "./timer.js"; +import { Label } from "./label.js"; +import { Button } from "./button.js"; +import { Selector } from "./selector.js"; +import { Toggle } from "./toggle.js"; +import { Canvas } from "./canvas.js"; +import { + renderWidgets, + cleanupWidgets, + updateWidgetPositions, +} from "./iframe-widget-renderer.js"; +import { Modal } from "./beamer_modal.js"; +import { + setControlsEnabledAfterUpload, + disableControlButtons, +} from "./beamer_ui.js"; const socket = io(); -socket.emit('join_presenter'); +socket.emit("join_presenter"); // Available AI models (loaded from presentation ZIP) let availableModels = []; window.addEventListener("DOMContentLoaded", () => { + const timerContainer = document.getElementById("timer-container"); + const timer = new Timer(timerContainer); -const timerContainer = document.getElementById("timer-container"); -const timer = new Timer(timerContainer); + const toolContainer = document.getElementById("tool-container"); -const toolContainer = document.getElementById('tool-container'); + const hand = new Button(toolContainer, { + label: '', + className: "btn", + }); -const hand = new Button(toolContainer, { - label: '', - className: 'btn' -}); + const pen = new Button(toolContainer, { + label: '', + className: "btn", + }); -const pen = new Button(toolContainer, { - label: '', - className: 'btn' -}); + const highlighter = new Button(toolContainer, { + label: '', + className: "btn", + }); -const highlighter = new Button(toolContainer, { - label: '', - className: 'btn' -}); + const eraser = new Button(toolContainer, { + label: '', + className: "btn", + }); -const eraser = new Button(toolContainer, { - label: '', - className: 'btn' -}); + const toolSelector = new Selector( + [hand, pen, highlighter, eraser], + "btn_selected", + ); + toolSelector.select(hand); + + const colors = [ + "#eeeeee", + "#e74c3c", + "#f1c40f", + "#2ecc71", + "#3498db", + "#9b59b6", + "#333333", + ]; + const colorContainer = document.getElementById("color-picker"); + + const colorBtns = colors.map((color) => { + const btn = new Button(colorContainer, { + className: "color-swatch", + }); + btn.el.style.background = color; + return btn; + }); -const toolSelector = new Selector([hand, pen, highlighter, eraser], 'btn_selected'); -toolSelector.select(hand); + const colorSelector = new Selector(colorBtns, "color-selected"); + colorSelector.select(colorBtns[6]); -const colors = ['#eeeeee', '#e74c3c', '#f1c40f', '#2ecc71', '#3498db', '#9b59b6', '#333333']; -const colorContainer = document.getElementById('color-picker'); + const navContainer = document.getElementById("nav-container"); -const colorBtns = colors.map(color => { - const btn = new Button(colorContainer, { - className: 'color-swatch', + const prevBtn = new Button(navContainer, { + label: '', + className: "btn", }); - btn.el.style.background = color; - return btn; -}); -const colorSelector = new Selector(colorBtns, 'color-selected'); -colorSelector.select(colorBtns[6]); + const nextBtn = new Button(navContainer, { + label: '', + className: "btn", + }); -const navContainer = document.getElementById('nav-container'); + const brushContainer = document.getElementById("brush-controls"); -const prevBtn = new Button(navContainer, { - label: '', - className: 'btn' -}); + const brushMinusBtn = new Button(brushContainer, { + label: '', + className: "btn", + }); -const nextBtn = new Button(navContainer, { - label: '', - className: 'btn' -}); + const brushSizeLbl = new Label(brushContainer, { + id: "brush_size_scroll", + className: "brush_size_scroll", + initial: "2", + }); -const brushContainer = document.getElementById('brush-controls'); + const brushPlusBtn = new Button(brushContainer, { + label: '', + className: "btn", + }); -const brushMinusBtn = new Button(brushContainer, { - label: '', - className: 'btn' -}); + const otherControlsContainer = document.getElementById("other-controls"); -const brushSizeLbl = new Label(brushContainer, { - id: 'brush_size_scroll', - className: 'brush_size_scroll', - initial: '2' -}); + const undoBtn = new Button(otherControlsContainer, { + className: "btn", + label: '', + }); -const brushPlusBtn = new Button(brushContainer, { - label: '', - className: 'btn' -}); + undoBtn.el.id = "undo-btn"; + undoBtn.el.style.marginLeft = "15px"; + undoBtn.el.style.marginRight = "5px"; + + const redoBtn = new Button(otherControlsContainer, { + className: "btn", + label: '', + }); -const otherControlsContainer = document.getElementById('other-controls'); + redoBtn.el.id = "redo-btn"; + redoBtn.el.style.marginRight = "5px"; -const undoBtn = new Button(otherControlsContainer, { - className: 'btn', - label: '' -}); + const clearBtn = new Button(otherControlsContainer, { + className: "btn", + label: '', + }); -undoBtn.el.id = 'undo-btn'; -undoBtn.el.style.marginLeft = '15px'; -undoBtn.el.style.marginRight = '5px'; + clearBtn.el.id = "clear-btn"; + clearBtn.el.style.marginRight = "20px"; -const redoBtn = new Button(otherControlsContainer, { - className: 'btn', - label: '' -}); + const surveyBtn = new Button(otherControlsContainer, { + className: "btn", + label: '', + }); -redoBtn.el.id = 'redo-btn'; -redoBtn.el.style.marginRight = '5px'; + surveyBtn.el.style.marginRight = "10px"; -const clearBtn = new Button(otherControlsContainer, { - className: 'btn', - label: '' -}); + const surveyResultsBtn = new Button(otherControlsContainer, { + className: "btn", + label: '', + }); -clearBtn.el.id = 'clear-btn'; -clearBtn.el.style.marginRight = '20px'; + // Initially disable results button + surveyResultsBtn.el.disabled = true; + surveyResultsBtn.el.style.opacity = "0.5"; + surveyResultsBtn.el.style.cursor = "not-allowed"; -const surveyBtn = new Button(otherControlsContainer, { - className: 'btn', - label: '' -}); + const displayControls = document.getElementById("display-controls"); -surveyBtn.el.style.marginRight = '10px'; + const uploadBtn = new Button(displayControls, { + className: "btn", + label: '', + }); -const surveyResultsBtn = new Button(otherControlsContainer, { - className: 'btn', - label: '' -}); + const screenShareContainer = document.getElementById( + "screen-share-container", + ); -// Initially disable results button -surveyResultsBtn.el.disabled = true; -surveyResultsBtn.el.style.opacity = '0.5'; -surveyResultsBtn.el.style.cursor = 'not-allowed'; + const screenShareBtn = new Button(screenShareContainer, { + className: "btn", + label: '', + }); -const displayControls = document.getElementById('display-controls'); + // Initially disabled until presentation is loaded + screenShareBtn.el.disabled = true; + screenShareBtn.el.style.opacity = "0.5"; + screenShareBtn.el.style.cursor = "not-allowed"; -const uploadBtn = new Button(displayControls, { - className: 'btn', - label: '', -}); + // Create recording button + const recordContainer = document.getElementById("record-container"); -const screenShareContainer = document.getElementById('screen-share-container'); + const recordBtn = new Button(recordContainer, { + className: "btn", + label: '', + }); -const screenShareBtn = new Button(screenShareContainer, { - className: 'btn', - label: '', -}); + // Initially disabled until presentation is loaded + recordBtn.el.disabled = true; + recordBtn.el.style.opacity = "0.5"; + recordBtn.el.style.cursor = "not-allowed"; + + // Create floating annotation panel toggle button for mobile + const annotationToggleBtn = document.createElement("button"); + annotationToggleBtn.id = "annotation-panel-toggle"; + annotationToggleBtn.className = "btn"; + annotationToggleBtn.innerHTML = ''; + annotationToggleBtn.title = "Annotation Tools"; + + // Insert after nav-container instead of in display-controls + const navContainerElement = document.getElementById("nav-container"); + const controlsLeft = document.querySelector(".controls-left"); + if (navContainerElement && navContainerElement.nextSibling) { + controlsLeft.insertBefore( + annotationToggleBtn, + navContainerElement.nextSibling, + ); + } else if (navContainerElement) { + navContainerElement.parentNode.insertBefore( + annotationToggleBtn, + navContainerElement.nextSibling, + ); + } else { + controlsLeft.appendChild(annotationToggleBtn); + } -// Initially disabled until presentation is loaded -screenShareBtn.el.disabled = true; -screenShareBtn.el.style.opacity = '0.5'; -screenShareBtn.el.style.cursor = 'not-allowed'; + // Create floating annotation panel + const floatingPanel = document.createElement("div"); + floatingPanel.id = "floating-annotation-panel"; + document.body.appendChild(floatingPanel); + + // Function to populate floating panel on mobile + function updateFloatingPanel() { + const toolContainer = document.getElementById("tool-container"); + const colorPicker = document.getElementById("color-picker"); + const brushControls = document.getElementById("brush-controls"); + const otherControls = document.getElementById("other-controls"); + const controlsRight = document.querySelector(".controls-right"); + + if (window.innerWidth <= 1300) { + // Move elements to floating panel on mobile + floatingPanel.innerHTML = ""; + + // Tools section + const toolsSection = document.createElement("div"); + toolsSection.className = "panel-section"; + if (toolContainer) toolsSection.appendChild(toolContainer); + floatingPanel.appendChild(toolsSection); + + // Colors section + const colorsSection = document.createElement("div"); + colorsSection.className = "panel-section"; + if (colorPicker) colorsSection.appendChild(colorPicker); + floatingPanel.appendChild(colorsSection); + + // Brush controls section + const brushSection = document.createElement("div"); + brushSection.className = "panel-section"; + if (brushControls) brushSection.appendChild(brushControls); + floatingPanel.appendChild(brushSection); + + // Undo/Redo/Clear section - move individual buttons from other-controls + const actionSection = document.createElement("div"); + actionSection.className = "panel-section"; + const undoBtnEl = document.getElementById("undo-btn"); + const redoBtnEl = document.getElementById("redo-btn"); + const clearBtnEl = document.getElementById("clear-btn"); + if (undoBtnEl) actionSection.appendChild(undoBtnEl); + if (redoBtnEl) actionSection.appendChild(redoBtnEl); + if (clearBtnEl) actionSection.appendChild(clearBtnEl); + if (actionSection.children.length > 0) + floatingPanel.appendChild(actionSection); + + // Show toggle button on mobile + annotationToggleBtn.style.display = "inline-flex"; + } else { + // Move elements back to controls-right on desktop + + // Move undo/redo/clear back to other-controls in the correct order + const undoBtnEl = document.getElementById("undo-btn"); + const redoBtnEl = document.getElementById("redo-btn"); + const clearBtnEl = document.getElementById("clear-btn"); + + // Insert in correct order at the beginning of other-controls + if ( + undoBtnEl && + otherControls && + !otherControls.contains(undoBtnEl) + ) { + otherControls.insertBefore(undoBtnEl, otherControls.firstChild); + } + if ( + redoBtnEl && + otherControls && + !otherControls.contains(redoBtnEl) + ) { + otherControls.insertBefore( + redoBtnEl, + undoBtnEl + ? undoBtnEl.nextSibling + : otherControls.firstChild, + ); + } + if ( + clearBtnEl && + otherControls && + !otherControls.contains(clearBtnEl) + ) { + otherControls.insertBefore( + clearBtnEl, + redoBtnEl + ? redoBtnEl.nextSibling + : otherControls.firstChild, + ); + } -// Create recording button -const recordContainer = document.getElementById('record-container'); + // Insert elements back in correct order + if (toolContainer && !controlsRight.contains(toolContainer)) { + controlsRight.insertBefore( + toolContainer, + otherControls || controlsRight.firstChild, + ); + } + if (colorPicker && !controlsRight.contains(colorPicker)) { + controlsRight.insertBefore( + colorPicker, + otherControls || controlsRight.firstChild, + ); + } + if (brushControls && !controlsRight.contains(brushControls)) { + controlsRight.insertBefore( + brushControls, + otherControls || controlsRight.firstChild, + ); + } -const recordBtn = new Button(recordContainer, { - className: 'btn', - label: '', -}); + // Clear floating panel after moving elements + floatingPanel.innerHTML = ""; -// Initially disabled until presentation is loaded -recordBtn.el.disabled = true; -recordBtn.el.style.opacity = '0.5'; -recordBtn.el.style.cursor = 'not-allowed'; - -// Create floating annotation panel toggle button for mobile -const annotationToggleBtn = document.createElement('button'); -annotationToggleBtn.id = 'annotation-panel-toggle'; -annotationToggleBtn.className = 'btn'; -annotationToggleBtn.innerHTML = ''; -annotationToggleBtn.title = 'Annotation Tools'; - -// Insert after nav-container instead of in display-controls -const navContainerElement = document.getElementById('nav-container'); -const controlsLeft = document.querySelector('.controls-left'); -if (navContainerElement && navContainerElement.nextSibling) { - controlsLeft.insertBefore(annotationToggleBtn, navContainerElement.nextSibling); -} else if (navContainerElement) { - navContainerElement.parentNode.insertBefore(annotationToggleBtn, navContainerElement.nextSibling); -} else { - controlsLeft.appendChild(annotationToggleBtn); -} - -// Create floating annotation panel -const floatingPanel = document.createElement('div'); -floatingPanel.id = 'floating-annotation-panel'; -document.body.appendChild(floatingPanel); - -// Function to populate floating panel on mobile -function updateFloatingPanel() { - const toolContainer = document.getElementById('tool-container'); - const colorPicker = document.getElementById('color-picker'); - const brushControls = document.getElementById('brush-controls'); - const otherControls = document.getElementById('other-controls'); - const controlsRight = document.querySelector('.controls-right'); - - if (window.innerWidth <= 1300) { - // Move elements to floating panel on mobile - floatingPanel.innerHTML = ''; - - // Tools section - const toolsSection = document.createElement('div'); - toolsSection.className = 'panel-section'; - if (toolContainer) toolsSection.appendChild(toolContainer); - floatingPanel.appendChild(toolsSection); - - // Colors section - const colorsSection = document.createElement('div'); - colorsSection.className = 'panel-section'; - if (colorPicker) colorsSection.appendChild(colorPicker); - floatingPanel.appendChild(colorsSection); - - // Brush controls section - const brushSection = document.createElement('div'); - brushSection.className = 'panel-section'; - if (brushControls) brushSection.appendChild(brushControls); - floatingPanel.appendChild(brushSection); - - // Undo/Redo/Clear section - move individual buttons from other-controls - const actionSection = document.createElement('div'); - actionSection.className = 'panel-section'; - const undoBtnEl = document.getElementById('undo-btn'); - const redoBtnEl = document.getElementById('redo-btn'); - const clearBtnEl = document.getElementById('clear-btn'); - if (undoBtnEl) actionSection.appendChild(undoBtnEl); - if (redoBtnEl) actionSection.appendChild(redoBtnEl); - if (clearBtnEl) actionSection.appendChild(clearBtnEl); - if (actionSection.children.length > 0) floatingPanel.appendChild(actionSection); - - // Show toggle button on mobile - annotationToggleBtn.style.display = 'inline-flex'; - } else { - // Move elements back to controls-right on desktop - - // Move undo/redo/clear back to other-controls in the correct order - const undoBtnEl = document.getElementById('undo-btn'); - const redoBtnEl = document.getElementById('redo-btn'); - const clearBtnEl = document.getElementById('clear-btn'); - - // Insert in correct order at the beginning of other-controls - if (undoBtnEl && otherControls && !otherControls.contains(undoBtnEl)) { - otherControls.insertBefore(undoBtnEl, otherControls.firstChild); - } - if (redoBtnEl && otherControls && !otherControls.contains(redoBtnEl)) { - otherControls.insertBefore(redoBtnEl, undoBtnEl ? undoBtnEl.nextSibling : otherControls.firstChild); - } - if (clearBtnEl && otherControls && !otherControls.contains(clearBtnEl)) { - otherControls.insertBefore(clearBtnEl, redoBtnEl ? redoBtnEl.nextSibling : otherControls.firstChild); - } - - // Insert elements back in correct order - if (toolContainer && !controlsRight.contains(toolContainer)) { - controlsRight.insertBefore(toolContainer, otherControls || controlsRight.firstChild); - } - if (colorPicker && !controlsRight.contains(colorPicker)) { - controlsRight.insertBefore(colorPicker, otherControls || controlsRight.firstChild); + // Hide toggle button and floating panel on desktop + annotationToggleBtn.style.display = "none"; + floatingPanel.classList.remove("visible"); } - if (brushControls && !controlsRight.contains(brushControls)) { - controlsRight.insertBefore(brushControls, otherControls || controlsRight.firstChild); - } - - // Clear floating panel after moving elements - floatingPanel.innerHTML = ''; - - // Hide toggle button and floating panel on desktop - annotationToggleBtn.style.display = 'none'; - floatingPanel.classList.remove('visible'); } -} - -// Initialize and listen for resize -updateFloatingPanel(); -window.addEventListener('resize', updateFloatingPanel); - -// Make floating panel draggable -let isDragging = false; -let currentX; -let currentY; -let initialX; -let initialY; -let xOffset = 0; -let yOffset = 0; - -floatingPanel.addEventListener('mousedown', dragStart); -floatingPanel.addEventListener('touchstart', dragStart); -document.addEventListener('mousemove', drag); -document.addEventListener('touchmove', drag); -document.addEventListener('mouseup', dragEnd); -document.addEventListener('touchend', dragEnd); - -function dragStart(e) { - // Only start drag if clicking on the panel background, not on buttons - if (e.target === floatingPanel || e.target.classList.contains('panel-section')) { - if (e.type === 'touchstart') { - initialX = e.touches[0].clientX - xOffset; - initialY = e.touches[0].clientY - yOffset; - } else { - initialX = e.clientX - xOffset; - initialY = e.clientY - yOffset; + + // Initialize and listen for resize + updateFloatingPanel(); + window.addEventListener("resize", updateFloatingPanel); + + // Make floating panel draggable + let isDragging = false; + let currentX; + let currentY; + let initialX; + let initialY; + let xOffset = 0; + let yOffset = 0; + + floatingPanel.addEventListener("mousedown", dragStart); + floatingPanel.addEventListener("touchstart", dragStart); + document.addEventListener("mousemove", drag); + document.addEventListener("touchmove", drag); + document.addEventListener("mouseup", dragEnd); + document.addEventListener("touchend", dragEnd); + + function dragStart(e) { + // Only start drag if clicking on the panel background, not on buttons + if ( + e.target === floatingPanel || + e.target.classList.contains("panel-section") + ) { + if (e.type === "touchstart") { + initialX = e.touches[0].clientX - xOffset; + initialY = e.touches[0].clientY - yOffset; + } else { + initialX = e.clientX - xOffset; + initialY = e.clientY - yOffset; + } + isDragging = true; + floatingPanel.style.cursor = "grabbing"; } - isDragging = true; - floatingPanel.style.cursor = 'grabbing'; } -} - -function drag(e) { - if (isDragging) { - e.preventDefault(); - - if (e.type === 'touchmove') { - currentX = e.touches[0].clientX - initialX; - currentY = e.touches[0].clientY - initialY; - } else { - currentX = e.clientX - initialX; - currentY = e.clientY - initialY; - } - xOffset = currentX; - yOffset = currentY; + function drag(e) { + if (isDragging) { + e.preventDefault(); + + if (e.type === "touchmove") { + currentX = e.touches[0].clientX - initialX; + currentY = e.touches[0].clientY - initialY; + } else { + currentX = e.clientX - initialX; + currentY = e.clientY - initialY; + } + + xOffset = currentX; + yOffset = currentY; + + setTranslate(currentX, currentY, floatingPanel); + } + } - setTranslate(currentX, currentY, floatingPanel); + function dragEnd() { + if (isDragging) { + initialX = currentX; + initialY = currentY; + isDragging = false; + floatingPanel.style.cursor = "grab"; + } } -} - -function dragEnd() { - if (isDragging) { - initialX = currentX; - initialY = currentY; - isDragging = false; - floatingPanel.style.cursor = 'grab'; + + function setTranslate(xPos, yPos, el) { + el.style.transform = `translate(${xPos}px, ${yPos}px)`; } -} -function setTranslate(xPos, yPos, el) { - el.style.transform = `translate(${xPos}px, ${yPos}px)`; -} + // Set initial cursor + floatingPanel.style.cursor = "grab"; -// Set initial cursor -floatingPanel.style.cursor = 'grab'; + // Toggle panel visibility + annotationToggleBtn.addEventListener("click", (e) => { + e.stopPropagation(); + floatingPanel.classList.toggle("visible"); + }); -// Toggle panel visibility -annotationToggleBtn.addEventListener('click', (e) => { - e.stopPropagation(); - floatingPanel.classList.toggle('visible'); -}); + // Close panel when clicking outside + document.addEventListener("click", (e) => { + if ( + !floatingPanel.contains(e.target) && + e.target !== annotationToggleBtn && + !annotationToggleBtn.contains(e.target) + ) { + floatingPanel.classList.remove("visible"); + } + }); + + // Keep list of controls for enabling/disabling (upload button remains enabled) + const __beamer_controls = [ + hand, + pen, + highlighter, + eraser, + ...colorBtns, + brushMinusBtn, + brushPlusBtn, + prevBtn, + nextBtn, + undoBtn, + redoBtn, + clearBtn, + surveyBtn, + ]; + // QUESTION: Why aren't screenShareBtn and recordBtn in this array?^ + + // Disable at startup + setControlsEnabledAfterUpload(false, __beamer_controls); + + // Full set used for temporary disabling (includes upload button) + const __beamer_all_buttons = [...__beamer_controls, uploadBtn]; + + const ann_canvas_container = document.getElementById("ann-canvas"); + const annCvs = new Canvas(ann_canvas_container); + const slide_canvas_container = document.getElementById("pdf-canvas"); + const pdfCvs = new Canvas(slide_canvas_container, false); + + const updateHistoryButtons = () => { + undoBtn.el.disabled = !annCvs.canUndo(); + redoBtn.el.disabled = !annCvs.canRedo(); + }; + annCvs.setHistoryChangeHandler(updateHistoryButtons); + updateHistoryButtons(); + + hand.onClick(() => annCvs.setPointerMode("hand")); + pen.onClick(() => annCvs.setPointerMode("draw")); + highlighter.onClick(() => annCvs.setPointerMode("highlight")); + eraser.onClick(() => annCvs.setPointerMode("erase")); -// Close panel when clicking outside -document.addEventListener('click', (e) => { - if (!floatingPanel.contains(e.target) && e.target !== annotationToggleBtn && !annotationToggleBtn.contains(e.target)) { - floatingPanel.classList.remove('visible'); + function onToolSelected(selected) { + if (selected === pen) annCvs.setPointerMode("draw"); + else if (selected === highlighter) annCvs.setPointerMode("highlight"); + else if (selected === eraser) annCvs.setPointerMode("erase"); } -}); -// Keep list of controls for enabling/disabling (upload button remains enabled) -const __beamer_controls = [ - hand, pen, highlighter, eraser, - ...colorBtns, - brushMinusBtn, brushPlusBtn, - prevBtn, nextBtn, - undoBtn, redoBtn, - clearBtn, surveyBtn -]; -// QUESTION: Why aren't screenShareBtn and recordBtn in this array?^ - -// Disable at startup -setControlsEnabledAfterUpload(false, __beamer_controls); - -// Full set used for temporary disabling (includes upload button) -const __beamer_all_buttons = [...__beamer_controls, uploadBtn]; - -const ann_canvas_container = document.getElementById('ann-canvas'); -const annCvs = new Canvas(ann_canvas_container); -const slide_canvas_container = document.getElementById('pdf-canvas'); -const pdfCvs = new Canvas(slide_canvas_container, false); - -const updateHistoryButtons = () => { - undoBtn.el.disabled = !annCvs.canUndo(); - redoBtn.el.disabled = !annCvs.canRedo(); -}; -annCvs.setHistoryChangeHandler(updateHistoryButtons); -updateHistoryButtons(); - -hand.onClick(() => annCvs.setPointerMode('hand')); -pen.onClick(() => annCvs.setPointerMode('draw')); -highlighter.onClick(() => annCvs.setPointerMode('highlight')); -eraser.onClick(() => annCvs.setPointerMode('erase')); - -function onToolSelected(selected) { - if (selected === pen) annCvs.setPointerMode('draw'); - else if (selected === highlighter) annCvs.setPointerMode('highlight'); - else if (selected === eraser) annCvs.setPointerMode('erase'); -} - -toolSelector.buttons.forEach(item => { - item.el.addEventListener('click', () => onToolSelected(item)); -}); + toolSelector.buttons.forEach((item) => { + item.el.addEventListener("click", () => onToolSelected(item)); + }); -colorBtns.forEach(btn => { - btn.onClick(() => { - annCvs.setStrokeColor(getComputedStyle(btn.el).backgroundColor); - }); -}); + colorBtns.forEach((btn) => { + btn.onClick(() => { + annCvs.setStrokeColor(getComputedStyle(btn.el).backgroundColor); + }); + }); -brushMinusBtn.onClick(() => { - let val = parseInt(brushSizeLbl.get()); - if (val > 1) val--; - brushSizeLbl.set(String(val)); - annCvs.setStrokeWidth(val); -}); + brushMinusBtn.onClick(() => { + let val = parseInt(brushSizeLbl.get()); + if (val > 1) val--; + brushSizeLbl.set(String(val)); + annCvs.setStrokeWidth(val); + }); -brushPlusBtn.onClick(() => { - let val = parseInt(brushSizeLbl.get()); - if (val < 9) val++; - brushSizeLbl.set(String(val)); - annCvs.setStrokeWidth(val); -}); + brushPlusBtn.onClick(() => { + let val = parseInt(brushSizeLbl.get()); + if (val < 9) val++; + brushSizeLbl.set(String(val)); + annCvs.setStrokeWidth(val); + }); -clearBtn.onClick(() => { - annCvs.clearAndCommit(); - // Clear current slide annotations locally and notify server - annotations[currentSlide] = null; - socket.emit('clear_annotations'); -}); + clearBtn.onClick(() => { + annCvs.clearAndCommit(); + // Clear current slide annotations locally and notify server + annotations[currentSlide] = null; + socket.emit("clear_annotations"); + }); -undoBtn.onClick(async () => { - await annCvs.undo(); - syncAnnotations(); -}); + undoBtn.onClick(async () => { + await annCvs.undo(); + syncAnnotations(); + }); -redoBtn.onClick(async () => { - await annCvs.redo(); - syncAnnotations(); -}); + redoBtn.onClick(async () => { + await annCvs.redo(); + syncAnnotations(); + }); -const zipInput = document.getElementById("upload-zip"); -const folderInput = document.getElementById("upload-folder"); + const zipInput = document.getElementById("upload-zip"); + const folderInput = document.getElementById("upload-folder"); -uploadBtn.onClick(() => { - showUploadModal(); -}); + uploadBtn.onClick(() => { + showUploadModal(); + }); -function showUploadModal() { - const existingModal = document.querySelector('.upload-modal-overlay'); - if (existingModal) existingModal.remove(); - - const overlay = document.createElement('div'); - overlay.className = 'custom-modal-overlay'; - - const modal = document.createElement('div'); - modal.className = 'custom-modal-content'; - modal.style.maxWidth = '500px'; - - modal.innerHTML = ` + function showUploadModal() { + const existingModal = document.querySelector(".upload-modal-overlay"); + if (existingModal) existingModal.remove(); + + const overlay = document.createElement("div"); + overlay.className = "custom-modal-overlay"; + + const modal = document.createElement("div"); + modal.className = "custom-modal-content"; + modal.style.maxWidth = "500px"; + + modal.innerHTML = `
@@ -487,640 +558,644 @@ function showUploadModal() { `; - - overlay.appendChild(modal); - document.body.appendChild(overlay); - - // Handle button clicks - modal.querySelector('[data-type="zip"]').onclick = () => { - overlay.remove(); - zipInput.click(); - }; - - modal.querySelector('[data-type="folder"]').onclick = () => { - overlay.remove(); - folderInput.click(); - }; - - const cancelBtn = modal.querySelector('.custom-modal-btn-cancel'); - cancelBtn.onclick = () => { - overlay.remove(); - }; - - overlay.onclick = (e) => { - if (e.target === overlay) overlay.remove(); - }; - - // ESC key handler - const escHandler = (e) => { - if (e.key === 'Escape') { + + overlay.appendChild(modal); + document.body.appendChild(overlay); + + // Handle button clicks + modal.querySelector('[data-type="zip"]').onclick = () => { overlay.remove(); - document.removeEventListener('keydown', escHandler); - } - }; - document.addEventListener('keydown', escHandler); -} - -function populateSlideNavigator() { - const navigator = document.getElementById('slide-navigator'); - navigator.innerHTML = ''; - - for (let i = 0; i < totalSlides; i++) { - const item = document.createElement('div'); - item.className = 'slide-nav-item'; - item.textContent = i + 1; - item.dataset.slideIndex = i; - - item.onclick = () => { - goToSlide(i); + zipInput.click(); + }; + + modal.querySelector('[data-type="folder"]').onclick = () => { + overlay.remove(); + folderInput.click(); + }; + + const cancelBtn = modal.querySelector(".custom-modal-btn-cancel"); + cancelBtn.onclick = () => { + overlay.remove(); + }; + + overlay.onclick = (e) => { + if (e.target === overlay) overlay.remove(); + }; + + // ESC key handler + const escHandler = (e) => { + if (e.key === "Escape") { + overlay.remove(); + document.removeEventListener("keydown", escHandler); + } }; - - navigator.appendChild(item); + document.addEventListener("keydown", escHandler); } - - updateSlideNavigator(); -} - -function updateSlideNavigator() { - const items = document.querySelectorAll('.slide-nav-item'); - items.forEach((item, index) => { - if (index === currentSlide) { - item.classList.add('active'); - // Scroll into view - item.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); - } else { - item.classList.remove('active'); + + // SLIDE NAVIGATOR STARTS //////////////////////////////////////// + const slideNavigator = document.getElementById("slide-navigator"); + + function populateSlideNavigator() { + slideNavigator.innerHTML = ""; + + for (let i = 0; i < totalSlides; i++) { + const item = document.createElement("div"); + item.className = "slide-nav-item"; + item.textContent = i + 1; + item.dataset.slideIndex = i; + + item.onclick = () => { + goToSlide(i); + }; + + slideNavigator.appendChild(item); } - }); -} - -let zipFile = null; -let slideConfigs = {}; -let mediaCache = {}; -let annotations = {}; -let currentSlide = 0; -let totalSlides = 0; - -async function loadSlideConfig(slideIndex) { - if (slideConfigs[slideIndex]) { - return slideConfigs[slideIndex]; - } - - const configFileName = `config/s${slideIndex}.json`; - const configFile = zipFile.file(configFileName); - - if (!configFile) { - slideConfigs[slideIndex] = null; - return null; - } - - const configText = await configFile.async("string"); - const config = JSON.parse(configText); - slideConfigs[slideIndex] = config; - - console.log(`Loaded config for slide ${slideIndex}:`, config); - return config; -} - -async function loadMediaFromPath(path) { - if (mediaCache[path]) { - return mediaCache[path]; - } - - const file = zipFile.file(path); - if (!file) { - console.error(`Media file not found: ${path}`); - return null; + + updateSlideNavigator(); } - - const blob = await file.async("blob"); - const url = URL.createObjectURL(blob); - mediaCache[path] = url; - - console.log(`Loaded media: ${path}`); - return url; -} - -let annotationSyncTimeout = null; -annCvs.canvas.addEventListener('mouseup', () => syncAnnotations()); -annCvs.canvas.addEventListener('touchend', () => syncAnnotations()); - -function syncAnnotations() { - clearTimeout(annotationSyncTimeout); - annotationSyncTimeout = setTimeout(() => { - const annData = annCvs.canvas.toDataURL("image/png"); - // Save annotations locally per-slide and emit to server - annotations[currentSlide] = annData; - socket.emit('annotation_update', { - annotations: annData, - slideIndex: currentSlide + + function updateSlideNavigator() { + const items = document.querySelectorAll(".slide-nav-item"); + items.forEach((item, index) => { + if (index === currentSlide) { + item.classList.add("active"); + // Scroll into view + item.scrollIntoView({ behavior: "smooth", block: "nearest" }); + } else { + item.classList.remove("active"); + } }); - }, 100); -} - -// Helper function to update all media element positions after resize -function updateMediaPositions() { - // Get the container's actual size on screen - const rect = slide_canvas_container.getBoundingClientRect(); - - // Update videos using stored fractional positions - const videos = slide_canvas_container.querySelectorAll('video'); - videos.forEach(video => { - const x = parseFloat(video.dataset.videoX); - const y = parseFloat(video.dataset.videoY); - const width = parseFloat(video.dataset.videoWidth); - const height = parseFloat(video.dataset.videoHeight); - - if (!isNaN(x) && !isNaN(y) && !isNaN(width) && !isNaN(height)) { - video.style.left = `${x * rect.width}px`; - video.style.top = `${y * rect.height}px`; - video.style.width = `${width * rect.width}px`; - video.style.height = `${height * rect.height}px`; - } - }); - - // Update 3D models using stored fractional positions - const models = slide_canvas_container.querySelectorAll('model-viewer'); - models.forEach(mv => { - const x = parseFloat(mv.dataset.modelX); - const y = parseFloat(mv.dataset.modelY); - const width = parseFloat(mv.dataset.modelWidth); - const height = parseFloat(mv.dataset.modelHeight); - - if (!isNaN(x) && !isNaN(y) && !isNaN(width) && !isNaN(height)) { - mv.style.left = `${x * rect.width}px`; - mv.style.top = `${y * rect.height}px`; - mv.style.width = `${width * rect.width}px`; - mv.style.height = `${height * rect.height}px`; + } + // SLIDE NAVIGATOR ENDS //////////////////////////////////////// + + + let zipFile = null; + let slideConfigs = {}; + let mediaCache = {}; + let annotations = {}; + let currentSlide = 0; + let totalSlides = 0; + + async function loadSlideConfig(slideIndex) { + if (slideConfigs[slideIndex]) { + return slideConfigs[slideIndex]; } - }); - - // Update widgets - updateWidgetPositions(slide_canvas_container); -} + const configFileName = `config/s${slideIndex}.json`; + const configFile = zipFile.file(configFileName); -prevBtn.onClick(() => goToSlide(currentSlide - 1)); -nextBtn.onClick(() => goToSlide(currentSlide + 1)); + if (!configFile) { + slideConfigs[slideIndex] = null; + return null; + } -document.addEventListener('keydown', (e) => { - if (surveyOverlayVisible || resultsOverlayVisible) return; - if (e.key === 'ArrowLeft') goToSlide(currentSlide - 1); - if (e.key === 'ArrowRight') goToSlide(currentSlide + 1); -}); + const configText = await configFile.async("string"); + const config = JSON.parse(configText); + slideConfigs[slideIndex] = config; -async function goToSlide(slideIndex) { - if (slideIndex < 0 || slideIndex >= totalSlides) return; - - currentSlide = slideIndex; - await renderSlide(currentSlide); - updateSlideNavigator(); - - const annData = annCvs.canvas.toDataURL("image/png"); - socket.emit('slide_change', { - slideIndex: currentSlide, - annotations: annData - }); -} - -function populateSlideNavigator() { - const navigator = document.getElementById('slide-navigator'); - navigator.innerHTML = ''; - - for (let i = 0; i < totalSlides; i++) { - const item = document.createElement('div'); - item.className = 'slide-nav-item'; - item.textContent = i + 1; - item.dataset.slideIndex = i; - - item.onclick = () => { - goToSlide(i); - }; - - navigator.appendChild(item); + console.log(`Loaded config for slide ${slideIndex}:`, config); + return config; } - - updateSlideNavigator(); -} - -function updateSlideNavigator() { - const items = document.querySelectorAll('.slide-nav-item'); - items.forEach((item, index) => { - if (index === currentSlide) { - item.classList.add('active'); - // Scroll into view - item.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); - } else { - item.classList.remove('active'); + + async function loadMediaFromPath(path) { + if (mediaCache[path]) { + return mediaCache[path]; } - }); -} - -async function renderSlide(slideIndex) { - console.log('renderSlide called:', slideIndex); - - if (!zipFile) { - console.log('No ZIP file loaded'); - return; - } - - const pdfFile = zipFile.file("slides.pdf"); - if (!pdfFile) { - console.error("No slides.pdf found in ZIP"); - return; - } - - const pdfData = await pdfFile.async("arraybuffer"); - const pdfDoc = await pdfjsLib.getDocument({ data: pdfData }).promise; - const page = await pdfDoc.getPage(slideIndex + 1); - - await pdfCvs.renderPDFPage(page); - - // Load per-slide annotations (clear then draw saved image if present) - try { - annCvs.clear(); - if (annotations[slideIndex]) { - await annCvs.loadAnnotations(annotations[slideIndex]); - } else { - annCvs.resetHistory(); + + const file = zipFile.file(path); + if (!file) { + console.error(`Media file not found: ${path}`); + return null; } - } catch (e) { - console.warn('Error loading annotations for slide', slideIndex, e); + + const blob = await file.async("blob"); + const url = URL.createObjectURL(blob); + mediaCache[path] = url; + + console.log(`Loaded media: ${path}`); + return url; } - - const existingMedia = slide_canvas_container.querySelectorAll('video, audio, model-viewer'); - existingMedia.forEach(el => el.remove()); - - cleanupWidgets(slide_canvas_container); - - const slideConfig = await loadSlideConfig(slideIndex); - - if (!slideConfig) { - console.log('No config for this slide'); - return; + + let annotationSyncTimeout = null; + annCvs.canvas.addEventListener("mouseup", () => syncAnnotations()); + annCvs.canvas.addEventListener("touchend", () => syncAnnotations()); + + function syncAnnotations() { + clearTimeout(annotationSyncTimeout); + annotationSyncTimeout = setTimeout(() => { + const annData = annCvs.canvas.toDataURL("image/png"); + // Save annotations locally per-slide and emit to server + annotations[currentSlide] = annData; + socket.emit("annotation_update", { + annotations: annData, + slideIndex: currentSlide, + }); + }, 100); } - - console.log('Videos to render:', slideConfig.videos?.length || 0); - console.log('Models to render:', slideConfig.models?.length || 0); - console.log('Widgets to render:', slideConfig.widgets?.length || 0); - - // Get container's actual size for positioning all media elements - const containerRect = slide_canvas_container.getBoundingClientRect(); - - if (slideConfig.videos) { - for (const v of slideConfig.videos) { - const videoURL = await loadMediaFromPath(v.path); - if (!videoURL) continue; - - const video = document.createElement("video"); - video.src = videoURL; - video.volume = v.volume || 1.0; - video.dataset.videoId = v.id; - - // Store fractional positions for resize handling - video.dataset.videoX = v.x; - video.dataset.videoY = v.y; - video.dataset.videoWidth = v.width; - video.dataset.videoHeight = v.height; - - video.style.position = "absolute"; - video.style.left = `${v.x * containerRect.width}px`; - video.style.top = `${v.y * containerRect.height}px`; - video.style.width = `${v.width * containerRect.width}px`; - video.style.height = `${v.height * containerRect.height}px`; - video.style.objectFit = "contain"; - video.style.zIndex = v.zIndex || 5; - - if (v.playMode === "once") { - video.autoplay = true; - video.loop = false; + + // Helper function to update all media element positions after resize + function updateMediaPositions() { + // Get the container's actual size on screen + const rect = slide_canvas_container.getBoundingClientRect(); + + // Update videos using stored fractional positions + const videos = slide_canvas_container.querySelectorAll("video"); + videos.forEach((video) => { + const x = parseFloat(video.dataset.videoX); + const y = parseFloat(video.dataset.videoY); + const width = parseFloat(video.dataset.videoWidth); + const height = parseFloat(video.dataset.videoHeight); + + if (!isNaN(x) && !isNaN(y) && !isNaN(width) && !isNaN(height)) { + video.style.left = `${x * rect.width}px`; + video.style.top = `${y * rect.height}px`; + video.style.width = `${width * rect.width}px`; + video.style.height = `${height * rect.height}px`; } - if (v.playMode === "loop") { - video.autoplay = true; - video.loop = true; + }); + + // Update 3D models using stored fractional positions + const models = slide_canvas_container.querySelectorAll("model-viewer"); + models.forEach((mv) => { + const x = parseFloat(mv.dataset.modelX); + const y = parseFloat(mv.dataset.modelY); + const width = parseFloat(mv.dataset.modelWidth); + const height = parseFloat(mv.dataset.modelHeight); + + if (!isNaN(x) && !isNaN(y) && !isNaN(width) && !isNaN(height)) { + mv.style.left = `${x * rect.width}px`; + mv.style.top = `${y * rect.height}px`; + mv.style.width = `${width * rect.width}px`; + mv.style.height = `${height * rect.height}px`; } - if (v.playMode === "manual") { - video.controls = true; + }); + + // Update widgets + updateWidgetPositions(slide_canvas_container); + } + + prevBtn.onClick(() => goToSlide(currentSlide - 1)); + nextBtn.onClick(() => goToSlide(currentSlide + 1)); + + document.addEventListener("keydown", (e) => { + if (surveyOverlayVisible || resultsOverlayVisible) return; + if (e.key === "ArrowLeft") goToSlide(currentSlide - 1); + if (e.key === "ArrowRight") goToSlide(currentSlide + 1); + }); + + async function goToSlide(slideIndex) { + if (slideIndex < 0 || slideIndex >= totalSlides) return; + + currentSlide = slideIndex; + await renderSlide(currentSlide); + updateSlideNavigator(); + + const annData = annCvs.canvas.toDataURL("image/png"); + socket.emit("slide_change", { + slideIndex: currentSlide, + annotations: annData, + }); + } + + async function renderSlide(slideIndex) { + console.log("renderSlide called:", slideIndex); + + if (!zipFile) { + console.log("No ZIP file loaded"); + return; + } + + const pdfFile = zipFile.file("slides.pdf"); + if (!pdfFile) { + console.error("No slides.pdf found in ZIP"); + return; + } + + const pdfData = await pdfFile.async("arraybuffer"); + const pdfDoc = await pdfjsLib.getDocument({ data: pdfData }).promise; + const page = await pdfDoc.getPage(slideIndex + 1); + + await pdfCvs.renderPDFPage(page); + + // Load per-slide annotations (clear then draw saved image if present) + try { + annCvs.clear(); + if (annotations[slideIndex]) { + await annCvs.loadAnnotations(annotations[slideIndex]); + } else { + annCvs.resetHistory(); } - - video.addEventListener('play', () => { - socket.emit('video_action', { - videoId: v.id, - slideIndex: currentSlide, - action: 'play', - currentTime: video.currentTime + } catch (e) { + console.warn("Error loading annotations for slide", slideIndex, e); + } + + // Clear old media + const existingMedia = slide_canvas_container.querySelectorAll( + "video, audio, model-viewer", + ); + existingMedia.forEach((el) => el.remove()); + // Clear old widgets + cleanupWidgets(slide_canvas_container); + + const slideConfig = await loadSlideConfig(slideIndex); + + if (!slideConfig) { + console.log("No config for this slide"); + return; + } + + console.log("Videos to render:", slideConfig.videos?.length || 0); + console.log("Models to render:", slideConfig.models?.length || 0); + console.log("Widgets to render:", slideConfig.widgets?.length || 0); + + // Get container's actual size for positioning all media elements + const containerRect = slide_canvas_container.getBoundingClientRect(); + + if (slideConfig.videos) { + for (const v of slideConfig.videos) { + const videoURL = await loadMediaFromPath(v.path); + if (!videoURL) continue; + + const video = document.createElement("video"); + video.src = videoURL; + video.volume = v.volume || 1.0; + video.dataset.videoId = v.id; + + // Store fractional positions for resize handling + video.dataset.videoX = v.x; + video.dataset.videoY = v.y; + video.dataset.videoWidth = v.width; + video.dataset.videoHeight = v.height; + + video.style.position = "absolute"; + video.style.left = `${v.x * containerRect.width}px`; + video.style.top = `${v.y * containerRect.height}px`; + video.style.width = `${v.width * containerRect.width}px`; + video.style.height = `${v.height * containerRect.height}px`; + video.style.objectFit = "contain"; + video.style.zIndex = v.zIndex || 5; + + if (v.playMode === "once") { + video.autoplay = true; + video.loop = false; + } + if (v.playMode === "loop") { + video.autoplay = true; + video.loop = true; + } + if (v.playMode === "manual") { + video.controls = true; + } + + video.addEventListener("play", () => { + socket.emit("video_action", { + videoId: v.id, + slideIndex: currentSlide, + action: "play", + currentTime: video.currentTime, + }); }); - }); - - video.addEventListener('pause', () => { - socket.emit('video_action', { - videoId: v.id, - slideIndex: currentSlide, - action: 'pause', - currentTime: video.currentTime + + video.addEventListener("pause", () => { + socket.emit("video_action", { + videoId: v.id, + slideIndex: currentSlide, + action: "pause", + currentTime: video.currentTime, + }); }); - }); - // Allow clicking the video to toggle play/pause - video.addEventListener('click', (ev) => { - // Only toggle when in hand mode (clicks should pass through otherwise) - try { - if (video.paused) video.play(); - else video.pause(); - } catch (e) { - console.error('Error toggling video playback:', e); - } - ev.stopPropagation(); - }); - - slide_canvas_container.appendChild(video); - } - } - - if (slideConfig.models) { - for (const m of slideConfig.models) { - const modelURL = await loadMediaFromPath(m.path); - if (!modelURL) continue; - - const mv = document.createElement("model-viewer"); - mv.src = modelURL; - mv.alt = m.alt || "3D model"; - mv.dataset.modelId = m.id; - - // Store fractional positions for resize handling - mv.dataset.modelX = m.x; - mv.dataset.modelY = m.y; - mv.dataset.modelWidth = m.width; - mv.dataset.modelHeight = m.height; - - mv.setAttribute("camera-controls", ""); - mv.setAttribute("shadow-intensity", "1"); - mv.setAttribute("auto-rotate", m.autoRotate ? "true" : "false"); - - mv.style.position = "absolute"; - mv.style.left = `${m.x * containerRect.width}px`; - mv.style.top = `${m.y * containerRect.height}px`; - mv.style.width = `${m.width * containerRect.width}px`; - mv.style.height = `${m.height * containerRect.height}px`; - mv.style.zIndex = m.zIndex || 5; - - mv.addEventListener('camera-change', () => { - const camera = mv.getCameraOrbit(); - const target = mv.getCameraTarget(); - socket.emit('model_interaction', { - modelId: m.id, - slideIndex: currentSlide, - camera: { - theta: camera.theta, - phi: camera.phi, - radius: camera.radius - }, - target: { - x: target.x, - y: target.y, - z: target.z + // Allow clicking the video to toggle play/pause + video.addEventListener("click", (ev) => { + // Only toggle when in hand mode (clicks should pass through otherwise) + try { + if (video.paused) video.play(); + else video.pause(); + } catch (e) { + console.error("Error toggling video playback:", e); } + ev.stopPropagation(); }); - }); - - slide_canvas_container.appendChild(mv); - } - } - - if (slideConfig.audio) { - for (const a of slideConfig.audio) { - const audioURL = await loadMediaFromPath(a.path); - if (!audioURL) continue; - - const audio = document.createElement("audio"); - audio.src = audioURL; - audio.volume = a.volume || 1.0; - if (a.playMode === "auto") audio.play(); - if (a.playMode === "manual") audio.controls = true; - - slide_canvas_container.appendChild(audio); + + slide_canvas_container.appendChild(video); + } } - } - - if (slideConfig.widgets) { - renderWidgets(slideConfig, slide_canvas_container, zipFile); - } -} - -folderInput.addEventListener('change', async (e) => { - const files = Array.from(e.target.files); - if (!files || files.length === 0) return; - console.log('Folder selected with', files.length, 'files'); - - const uploadModal = Modal.loading('Uploading Presentation', 'Please wait while your presentation is uploaded...'); - - // Create a ZIP file from the selected folder - const zip = new JSZip(); - - // Add all files to the ZIP, preserving folder structure - for (const file of files) { - // Get the relative path from the file's webkitRelativePath - const relativePath = file.webkitRelativePath; - // Remove the first folder name to get the path relative to the selected folder - const pathParts = relativePath.split('/'); - const zipPath = pathParts.slice(1).join('/'); - - if (zipPath) { - const fileData = await file.arrayBuffer(); - zip.file(zipPath, fileData); + + if (slideConfig.models) { + for (const m of slideConfig.models) { + const modelURL = await loadMediaFromPath(m.path); + if (!modelURL) continue; + + const mv = document.createElement("model-viewer"); + mv.src = modelURL; + mv.alt = m.alt || "3D model"; + mv.dataset.modelId = m.id; + + // Store fractional positions for resize handling + mv.dataset.modelX = m.x; + mv.dataset.modelY = m.y; + mv.dataset.modelWidth = m.width; + mv.dataset.modelHeight = m.height; + + mv.setAttribute("camera-controls", ""); + mv.setAttribute("shadow-intensity", "1"); + mv.setAttribute("auto-rotate", m.autoRotate ? "true" : "false"); + + mv.style.position = "absolute"; + mv.style.left = `${m.x * containerRect.width}px`; + mv.style.top = `${m.y * containerRect.height}px`; + mv.style.width = `${m.width * containerRect.width}px`; + mv.style.height = `${m.height * containerRect.height}px`; + mv.style.zIndex = m.zIndex || 5; + + mv.addEventListener("camera-change", () => { + const camera = mv.getCameraOrbit(); + const target = mv.getCameraTarget(); + socket.emit("model_interaction", { + modelId: m.id, + slideIndex: currentSlide, + camera: { + theta: camera.theta, + phi: camera.phi, + radius: camera.radius, + }, + target: { + x: target.x, + y: target.y, + z: target.z, + }, + }); + }); + + slide_canvas_container.appendChild(mv); + } } - } - - console.log('Creating ZIP from folder...'); - const zipBlob = await zip.generateAsync({ type: 'blob' }); - - const formData = new FormData(); - formData.append('file', zipBlob, 'presentation.zip'); - - try { - const response = await fetch('/api/presentation/upload', { - method: 'POST', - body: formData - }); - const data = await response.json(); - console.log('Upload response:', data); + if (slideConfig.audio) { + for (const a of slideConfig.audio) { + const audioURL = await loadMediaFromPath(a.path); + if (!audioURL) continue; - if (data.success) { - await loadAvailableModels(); + const audio = document.createElement("audio"); + audio.src = audioURL; + audio.volume = a.volume || 1.0; + if (a.playMode === "auto") audio.play(); + if (a.playMode === "manual") audio.controls = true; - console.log(`Presentation uploaded with ${data.models_found} Summarizer Script`); - if (data.models && data.models.length > 0) { - console.log('Available AI models:', data.models); + slide_canvas_container.appendChild(audio); } } - } catch (error) { - console.error('Error uploading presentation:', error); - uploadModal.close(); - Modal.error('Upload Failed', 'Failed to upload presentation. Please try again.'); - return; - } - - // Load the ZIP we just created into memory for frontend use - zipFile = zip; - console.log('ZIP loaded into memory'); - - const pdfFile = zipFile.file("slides.pdf"); - if (!pdfFile) { - console.error("The uploaded package is not a valid Beamer+ presentation (no slides.pdf found)."); - uploadModal.close(); - Modal.error('Invalid Presentation', 'The uploaded package is not a valid Beamer+ presentation.'); - return; + + if (slideConfig.widgets) { + renderWidgets(slideConfig, slide_canvas_container, zipFile); + } } - - const pdfData = await pdfFile.async("arraybuffer"); - const pdfDoc = await pdfjsLib.getDocument({ data: pdfData }).promise; - totalSlides = pdfDoc.numPages; - - console.log(`Total slides: ${totalSlides}`); - - currentSlide = 0; - slideConfigs = {}; - mediaCache = {}; - - await renderSlide(0); - - // Populate slide navigator - populateSlideNavigator(); - - socket.emit('presentation_loaded', { - totalSlides: totalSlides - }); - // Enable controls now that a presentation is loaded - uploadModal.close(); - setControlsEnabledAfterUpload(true, __beamer_controls); - - // Enable screen share button - screenShareBtn.el.disabled = false; - screenShareBtn.el.style.opacity = '1'; - screenShareBtn.el.style.cursor = 'pointer'; - - // Enable record button - recordBtn.el.disabled = false; - recordBtn.el.style.opacity = '1'; - recordBtn.el.style.cursor = 'pointer'; - - updateHistoryButtons(); -}); -zipInput.addEventListener('change', async (e) => { - const file = e.target.files[0]; - if (!file) return; - console.log('ZIP file selected:', file.name); + folderInput.addEventListener("change", async (e) => { + const files = Array.from(e.target.files); + if (!files || files.length === 0) return; + console.log("Folder selected with", files.length, "files"); + + const uploadModal = Modal.loading( + "Uploading Presentation", + "Please wait while your presentation is uploaded...", + ); + + // Create a ZIP file from the selected folder + const zip = new JSZip(); + // we can use JSZip because we have an HTML script for it (Cloudflare) + + // Add all files to the ZIP, preserving folder structure + for (const file of files) { + // Get the relative path from the file's webkitRelativePath + const relativePath = file.webkitRelativePath; + // Remove the first folder name to get the path relative to the selected folder + const pathParts = relativePath.split("/"); + const zipPath = pathParts.slice(1).join("/"); + + if (zipPath) { + const fileData = await file.arrayBuffer(); + zip.file(zipPath, fileData); + } + } - const uploadModal = Modal.loading('Uploading Presentation', 'Please wait while your presentation is uploaded...'); + console.log("Creating ZIP from folder..."); + const zipBlob = await zip.generateAsync({ type: "blob" }); - const formData = new FormData(); - formData.append('file', file); + const formData = new FormData(); + formData.append("file", zipBlob, "presentation.zip"); - try { - const response = await fetch('/api/presentation/upload', { - method: 'POST', - body: formData - }); + try { + const response = await fetch("/api/presentation/upload", { + method: "POST", + body: formData, + }); - const data = await response.json(); - console.log('Upload response:', data); + const data = await response.json(); + console.log("Upload response:", data); - if (data.success) { - await loadAvailableModels(); + if (data.success) { + await loadAvailableModels(); - console.log(`Presentation uploaded with ${data.models_found} Summarizer Script`); - if (data.models && data.models.length > 0) { - console.log('Available AI models:', data.models); + console.log( + `Presentation uploaded with ${data.models_found} Summarizer Script`, + ); + if (data.models && data.models.length > 0) { + console.log("Available AI models:", data.models); + } } + } catch (error) { + console.error("Error uploading presentation:", error); + uploadModal.close(); + Modal.error( + "Upload Failed", + "Failed to upload presentation. Please try again.", + ); + return; } - } catch (error) { - console.error('Error uploading presentation:', error); - uploadModal.close(); - Modal.error('Upload Failed', 'Failed to upload presentation. Please try again.'); - return; - } - - // Load the ZIP into memory for frontend use - const reader = new FileReader(); - reader.onload = async (event) => { - zipFile = await JSZip.loadAsync(event.target.result); - console.log('ZIP loaded into memory'); - + + // Load the ZIP we just created into memory for frontend use + zipFile = zip; + console.log("ZIP loaded into memory"); + const pdfFile = zipFile.file("slides.pdf"); if (!pdfFile) { - console.error("The uploaded package is not a valid Beamer+ presentation (no slides.pdf found)."); + console.error( + "The uploaded package is not a valid Beamer+ presentation (no slides.pdf found).", + ); uploadModal.close(); - Modal.error('Invalid Presentation', 'The uploaded package is not a valid Beamer+ presentation.'); + Modal.error( + "Invalid Presentation", + "The uploaded package is not a valid Beamer+ presentation.", + ); return; } - + const pdfData = await pdfFile.async("arraybuffer"); const pdfDoc = await pdfjsLib.getDocument({ data: pdfData }).promise; totalSlides = pdfDoc.numPages; - + console.log(`Total slides: ${totalSlides}`); - + currentSlide = 0; slideConfigs = {}; mediaCache = {}; - + await renderSlide(0); - + // Populate slide navigator populateSlideNavigator(); - - socket.emit('presentation_loaded', { - totalSlides: totalSlides + + socket.emit("presentation_loaded", { + totalSlides: totalSlides, }); // Enable controls now that a presentation is loaded uploadModal.close(); setControlsEnabledAfterUpload(true, __beamer_controls); - + // Enable screen share button screenShareBtn.el.disabled = false; - screenShareBtn.el.style.opacity = '1'; - screenShareBtn.el.style.cursor = 'pointer'; - + screenShareBtn.el.style.opacity = "1"; + screenShareBtn.el.style.cursor = "pointer"; + // Enable record button recordBtn.el.disabled = false; - recordBtn.el.style.opacity = '1'; - recordBtn.el.style.cursor = 'pointer'; - + recordBtn.el.style.opacity = "1"; + recordBtn.el.style.cursor = "pointer"; + updateHistoryButtons(); - }; - reader.readAsArrayBuffer(file); -}); + }); -async function loadAvailableModels() { - try { - const response = await fetch('/api/models'); - const data = await response.json(); - availableModels = data.models || []; - console.log('Available AI models:', availableModels); - } catch (error) { - console.error('Error loading models:', error); - availableModels = []; - } -} - -// Survey functionality -let currentSurveyResults = null; -let currentSurveyData = null; -let resultsOverlayVisible = false; -let surveyOverlayVisible = false; - -// Survey Button - Opens creation modal -surveyBtn.onClick(() => { - if (availableModels.length === 0) { - Modal.warning('No Presentation Loaded', 'Please upload a presentation.'); - return; + zipInput.addEventListener("change", async (e) => { + const file = e.target.files[0]; + if (!file) return; + console.log("ZIP file selected:", file.name); + + const uploadModal = Modal.loading( + "Uploading Presentation", + "Please wait while your presentation is uploaded...", + ); + + const formData = new FormData(); + formData.append("file", file); + + try { + const response = await fetch("/api/presentation/upload", { + method: "POST", + body: formData, + }); + + const data = await response.json(); + console.log("Upload response:", data); + + if (data.success) { + await loadAvailableModels(); + + console.log( + `Presentation uploaded with ${data.models_found} Summarizer Script`, + ); + if (data.models && data.models.length > 0) { + console.log("Available AI models:", data.models); + } + } + } catch (error) { + console.error("Error uploading presentation:", error); + uploadModal.close(); + Modal.error( + "Upload Failed", + "Failed to upload presentation. Please try again.", + ); + return; + } + + // Load the ZIP into memory for frontend use + const reader = new FileReader(); + reader.onload = async (event) => { + zipFile = await JSZip.loadAsync(event.target.result); + console.log("ZIP loaded into memory"); + + const pdfFile = zipFile.file("slides.pdf"); + if (!pdfFile) { + console.error( + "The uploaded package is not a valid Beamer+ presentation (no slides.pdf found).", + ); + uploadModal.close(); + Modal.error( + "Invalid Presentation", + "The uploaded package is not a valid Beamer+ presentation.", + ); + return; + } + + const pdfData = await pdfFile.async("arraybuffer"); + const pdfDoc = await pdfjsLib.getDocument({ data: pdfData }) + .promise; + totalSlides = pdfDoc.numPages; + + console.log(`Total slides: ${totalSlides}`); + + currentSlide = 0; + slideConfigs = {}; + mediaCache = {}; + + await renderSlide(0); + + // Populate slide navigator + populateSlideNavigator(); + + socket.emit("presentation_loaded", { + totalSlides: totalSlides, + }); + // Enable controls now that a presentation is loaded + uploadModal.close(); + setControlsEnabledAfterUpload(true, __beamer_controls); + + // Enable screen share button + screenShareBtn.el.disabled = false; + screenShareBtn.el.style.opacity = "1"; + screenShareBtn.el.style.cursor = "pointer"; + + // Enable record button + recordBtn.el.disabled = false; + recordBtn.el.style.opacity = "1"; + recordBtn.el.style.cursor = "pointer"; + + updateHistoryButtons(); + }; + reader.readAsArrayBuffer(file); + }); + + async function loadAvailableModels() { + try { + const response = await fetch("/api/models"); + const data = await response.json(); + availableModels = data.models || []; + console.log("Available AI models:", availableModels); + } catch (error) { + console.error("Error loading models:", error); + availableModels = []; + } } - - const modal = document.createElement('div'); - modal.className = 'modal-overlay'; - modal.innerHTML = ` + + // Survey functionality + let currentSurveyResults = null; + let currentSurveyData = null; + let resultsOverlayVisible = false; + let surveyOverlayVisible = false; + + // Survey Button - Opens creation modal + surveyBtn.onClick(() => { + if (availableModels.length === 0) { + Modal.warning( + "No Presentation Loaded", + "Please upload a presentation.", + ); + return; + } + + const modal = document.createElement("div"); + modal.className = "modal-overlay"; + modal.innerHTML = ` `; - document.body.appendChild(modal); - - // Populate model dropdown - const modelSelect = document.getElementById('survey-model'); - availableModels.forEach(model => { - const option = document.createElement('option'); - option.value = model; - option.textContent = model.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); - modelSelect.appendChild(option); - }); - - if (availableModels.length > 0) { - modelSelect.value = availableModels[0]; - } - - document.getElementById('survey-question').focus(); - - document.getElementById('cancel-survey-modal').onclick = () => { - document.body.removeChild(modal); - }; - - document.getElementById('create-survey-btn').onclick = async () => { - const question = document.getElementById('survey-question').value.trim() || 'Survey'; - const model = document.getElementById('survey-model').value; - const numSummaries = parseInt(document.getElementById('survey-num-summaries').value); - - if (!model) { - Modal.warning('No Model Selected', 'Please select an AI model.'); - return; - } - - if (isNaN(numSummaries) || numSummaries < 1 || numSummaries > 10) { - Modal.warning('Invalid Number', 'Number of summaries must be between 1 and 10.'); - return; + document.body.appendChild(modal); + + // Populate model dropdown + const modelSelect = document.getElementById("survey-model"); + availableModels.forEach((model) => { + const option = document.createElement("option"); + option.value = model; + option.textContent = model + .replace(/_/g, " ") + .replace(/\b\w/g, (l) => l.toUpperCase()); + modelSelect.appendChild(option); + }); + + if (availableModels.length > 0) { + modelSelect.value = availableModels[0]; } - - try { - const response = await fetch('/api/survey/create', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - question, - model, - num_summaries: numSummaries - }) - }); - - const data = await response.json(); - - if (!response.ok) { - Modal.error('Survey Creation Failed', data.error || 'Failed to create survey'); + + document.getElementById("survey-question").focus(); + + document.getElementById("cancel-survey-modal").onclick = () => { + document.body.removeChild(modal); + }; + + document.getElementById("create-survey-btn").onclick = async () => { + const question = + document.getElementById("survey-question").value.trim() || + "Survey"; + const model = document.getElementById("survey-model").value; + const numSummaries = parseInt( + document.getElementById("survey-num-summaries").value, + ); + + if (!model) { + Modal.warning( + "No Model Selected", + "Please select an AI model.", + ); return; } - - document.body.removeChild(modal); - - currentSurveyData = { - ...data, - model, - num_summaries: numSummaries, - question - }; - - // Reset results when creating new survey - currentSurveyResults = null; - - // Disable results button until we have results - surveyResultsBtn.el.disabled = true; - surveyResultsBtn.el.style.opacity = '0.5'; - surveyResultsBtn.el.style.cursor = 'not-allowed'; - - socket.emit('survey_show', data); - showSurveyOverlay(); - - } catch (error) { - console.error('Error creating survey:', error); - Modal.error('Survey Creation Failed', 'Failed to create survey. Please try again.'); - } - }; - - modal.onclick = (e) => { - if (e.target === modal) { - document.body.removeChild(modal); - } - }; -}); -function updateSurveyOverlayPosition() { - const overlay = document.getElementById('survey-overlay'); - if (!overlay) return; - - const pdfContainer = document.getElementById('pdf-canvas'); - const containerRect = pdfContainer.getBoundingClientRect(); - - overlay.style.top = `${containerRect.top}px`; - overlay.style.left = `${containerRect.left}px`; - overlay.style.width = `${containerRect.width}px`; - overlay.style.height = `${containerRect.height}px`; -} - -function showSurveyOverlay() { - if (!currentSurveyData) return; - - surveyOverlayVisible = true; - disableControlButtons(true, __beamer_all_buttons, surveyResultsBtn); - - const pdfContainer = document.getElementById('pdf-canvas'); - const containerRect = pdfContainer.getBoundingClientRect(); - - const surveyUrl = `${window.location.origin}${currentSurveyData.url}`; - - const overlay = document.createElement('div'); - overlay.id = 'survey-overlay'; - overlay.style.position = 'fixed'; - overlay.style.top = `${containerRect.top}px`; - overlay.style.left = `${containerRect.left}px`; - overlay.style.width = `${containerRect.width}px`; - overlay.style.height = `${containerRect.height}px`; - overlay.style.backgroundColor = '#ffffff'; - overlay.style.zIndex = '1000'; - overlay.style.display = 'flex'; - overlay.style.flexDirection = 'column'; - overlay.style.alignItems = 'center'; - overlay.style.justifyContent = 'center'; - overlay.style.padding = '2rem'; - overlay.style.boxSizing = 'border-box'; - overlay.style.overflow = 'auto'; - overlay.style.fontFamily = "'Computer Modern Sans', sans-serif"; - - overlay.innerHTML = ` + if (isNaN(numSummaries) || numSummaries < 1 || numSummaries > 10) { + Modal.warning( + "Invalid Number", + "Number of summaries must be between 1 and 10.", + ); + return; + } + + try { + const response = await fetch("/api/survey/create", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + question, + model, + num_summaries: numSummaries, + }), + }); + + const data = await response.json(); + + if (!response.ok) { + Modal.error( + "Survey Creation Failed", + data.error || "Failed to create survey", + ); + return; + } + + document.body.removeChild(modal); + + currentSurveyData = { + ...data, + model, + num_summaries: numSummaries, + question, + }; + + // Reset results when creating new survey + currentSurveyResults = null; + + // Disable results button until we have results + surveyResultsBtn.el.disabled = true; + surveyResultsBtn.el.style.opacity = "0.5"; + surveyResultsBtn.el.style.cursor = "not-allowed"; + + socket.emit("survey_show", data); + showSurveyOverlay(); + } catch (error) { + console.error("Error creating survey:", error); + Modal.error( + "Survey Creation Failed", + "Failed to create survey. Please try again.", + ); + } + }; + + modal.onclick = (e) => { + if (e.target === modal) { + document.body.removeChild(modal); + } + }; + }); + + function updateSurveyOverlayPosition() { + const overlay = document.getElementById("survey-overlay"); + if (!overlay) return; + + const pdfContainer = document.getElementById("pdf-canvas"); + const containerRect = pdfContainer.getBoundingClientRect(); + + overlay.style.top = `${containerRect.top}px`; + overlay.style.left = `${containerRect.left}px`; + overlay.style.width = `${containerRect.width}px`; + overlay.style.height = `${containerRect.height}px`; + } + + function showSurveyOverlay() { + if (!currentSurveyData) return; + + surveyOverlayVisible = true; + disableControlButtons(true, __beamer_all_buttons, surveyResultsBtn); + + const pdfContainer = document.getElementById("pdf-canvas"); + const containerRect = pdfContainer.getBoundingClientRect(); + + const surveyUrl = `${window.location.origin}${currentSurveyData.url}`; + + const overlay = document.createElement("div"); + overlay.id = "survey-overlay"; + overlay.style.position = "fixed"; + overlay.style.top = `${containerRect.top}px`; + overlay.style.left = `${containerRect.left}px`; + overlay.style.width = `${containerRect.width}px`; + overlay.style.height = `${containerRect.height}px`; + overlay.style.backgroundColor = "#ffffff"; + overlay.style.zIndex = "1000"; + overlay.style.display = "flex"; + overlay.style.flexDirection = "column"; + overlay.style.alignItems = "center"; + overlay.style.justifyContent = "center"; + overlay.style.padding = "2rem"; + overlay.style.boxSizing = "border-box"; + overlay.style.overflow = "auto"; + overlay.style.fontFamily = "'Computer Modern Sans', sans-serif"; + + overlay.innerHTML = `

- ${currentSurveyData.question || 'Survey'} + ${currentSurveyData.question || "Survey"}
(scan the QR code below or navigate to the URL to respond)

@@ -1340,165 +1432,184 @@ function showSurveyOverlay() {
`; - - document.body.appendChild(overlay); - - new QRCode(document.getElementById("qrcode"), { - text: surveyUrl, - width: 200, - height: 200, - colorDark: "#333333", - colorLight: "#ffffff" - }); - - socket.on('survey_response', (data) => { - if (data.survey_id === currentSurveyData.survey_id) { - document.getElementById('response-count').textContent = data.total; - } - }); -} - -function hideSurveyOverlay() { - surveyOverlayVisible = false; - disableControlButtons(false, __beamer_all_buttons, surveyResultsBtn); - - const overlay = document.getElementById('survey-overlay'); - if (overlay) { - document.body.removeChild(overlay); - } -} - -// Results Button - Toggle between show/hide results -surveyResultsBtn.onClick(async () => { - // If results are already showing, just hide them - if (resultsOverlayVisible) { - hideSurveyResultsOverlay(); - return; - } - - // Check if we have a survey - if (!currentSurveyData) { - Modal.info('No Survey', 'Please create a survey first.'); - return; - } - - // If we already have results, just show them - if (currentSurveyResults) { - showSurveyResultsOverlay(); - return; + + document.body.appendChild(overlay); + + new QRCode(document.getElementById("qrcode"), { + text: surveyUrl, + width: 200, + height: 200, + colorDark: "#333333", + colorLight: "#ffffff", + }); + + socket.on("survey_response", (data) => { + if (data.survey_id === currentSurveyData.survey_id) { + document.getElementById("response-count").textContent = + data.total; + } + }); } - - // Close the survey if it's still open - if (surveyOverlayVisible) { - await fetch(`/api/survey/${currentSurveyData.survey_id}/close`, { method: 'POST' }); - socket.emit('survey_close', { survey_id: currentSurveyData.survey_id }); - hideSurveyOverlay(); + + function hideSurveyOverlay() { + surveyOverlayVisible = false; + disableControlButtons(false, __beamer_all_buttons, surveyResultsBtn); + + const overlay = document.getElementById("survey-overlay"); + if (overlay) { + document.body.removeChild(overlay); + } } - - // Show loading modal - const loadingModal = Modal.loading('Generating Summaries', 'Please wait while the responses are analyzed...'); - - try { - const response = await fetch(`/api/survey/${currentSurveyData.survey_id}/responses`); - const data = await response.json(); - - if (data.responses.length === 0) { - loadingModal.close(); - // No responses — survey already closed above; do nothing further. + + // Results Button - Toggle between show/hide results + surveyResultsBtn.onClick(async () => { + // If results are already showing, just hide them + if (resultsOverlayVisible) { + hideSurveyResultsOverlay(); return; } - - const analyzeResponse = await fetch(`/api/survey/${currentSurveyData.survey_id}/analyze`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' } - }); - - if (!analyzeResponse.ok) { - const errorData = await analyzeResponse.json(); - throw new Error(errorData.error || 'Analysis failed'); + + // Check if we have a survey + if (!currentSurveyData) { + Modal.info("No Survey", "Please create a survey first."); + return; } - - const analysisData = await analyzeResponse.json(); - - currentSurveyResults = { - summaries: analysisData.summaries, - model: analysisData.model, - num_responses: analysisData.num_responses - }; - - console.log('Analysis complete:', currentSurveyResults); - - // Close loading modal - loadingModal.close(); - - // Enable the results button for future clicks - surveyResultsBtn.el.disabled = false; - surveyResultsBtn.el.style.opacity = '1'; - surveyResultsBtn.el.style.cursor = 'pointer'; - - // Show results overlay - showSurveyResultsOverlay(); - - } catch (error) { - console.error('Error processing responses:', error); - loadingModal.close(); - Modal.error('Analysis Failed', "There was an error summarizing the responses."); - } -}); -let currentResultIndex = 0; - -// Use UI helper `disableControlButtons` from beamer_ui.js - -function updateSurveyResultsOverlayPosition() { - const overlay = document.getElementById('survey-results-overlay'); - if (!overlay) return; - - const pdfContainer = document.getElementById('pdf-canvas'); - const containerRect = pdfContainer.getBoundingClientRect(); - - overlay.style.top = `${containerRect.top}px`; - overlay.style.left = `${containerRect.left}px`; - overlay.style.width = `${containerRect.width}px`; - overlay.style.height = `${containerRect.height}px`; -} - -function showSurveyResultsOverlay() { - if (!currentSurveyResults || !currentSurveyResults.summaries || currentSurveyResults.summaries.length === 0) { - Modal.info('No Results', 'No survey results available.'); - return; + // If we already have results, just show them + if (currentSurveyResults) { + showSurveyResultsOverlay(); + return; + } + + // Close the survey if it's still open + if (surveyOverlayVisible) { + await fetch(`/api/survey/${currentSurveyData.survey_id}/close`, { + method: "POST", + }); + socket.emit("survey_close", { + survey_id: currentSurveyData.survey_id, + }); + hideSurveyOverlay(); + } + + // Show loading modal + const loadingModal = Modal.loading( + "Generating Summaries", + "Please wait while the responses are analyzed...", + ); + + try { + const response = await fetch( + `/api/survey/${currentSurveyData.survey_id}/responses`, + ); + const data = await response.json(); + + if (data.responses.length === 0) { + loadingModal.close(); + // No responses — survey already closed above; do nothing further. + return; + } + + const analyzeResponse = await fetch( + `/api/survey/${currentSurveyData.survey_id}/analyze`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + }, + ); + + if (!analyzeResponse.ok) { + const errorData = await analyzeResponse.json(); + throw new Error(errorData.error || "Analysis failed"); + } + + const analysisData = await analyzeResponse.json(); + + currentSurveyResults = { + summaries: analysisData.summaries, + model: analysisData.model, + num_responses: analysisData.num_responses, + }; + + console.log("Analysis complete:", currentSurveyResults); + + // Close loading modal + loadingModal.close(); + + // Enable the results button for future clicks + surveyResultsBtn.el.disabled = false; + surveyResultsBtn.el.style.opacity = "1"; + surveyResultsBtn.el.style.cursor = "pointer"; + + // Show results overlay + showSurveyResultsOverlay(); + } catch (error) { + console.error("Error processing responses:", error); + loadingModal.close(); + Modal.error( + "Analysis Failed", + "There was an error summarizing the responses.", + ); + } + }); + + let currentResultIndex = 0; + + // Use UI helper `disableControlButtons` from beamer_ui.js + + function updateSurveyResultsOverlayPosition() { + const overlay = document.getElementById("survey-results-overlay"); + if (!overlay) return; + + const pdfContainer = document.getElementById("pdf-canvas"); + const containerRect = pdfContainer.getBoundingClientRect(); + + overlay.style.top = `${containerRect.top}px`; + overlay.style.left = `${containerRect.left}px`; + overlay.style.width = `${containerRect.width}px`; + overlay.style.height = `${containerRect.height}px`; } - - // Set this BEFORE disabling buttons to prevent flash - resultsOverlayVisible = true; - currentResultIndex = 0; - - disableControlButtons(true, __beamer_all_buttons, surveyResultsBtn); - - const pdfContainer = document.getElementById('pdf-canvas'); - const containerRect = pdfContainer.getBoundingClientRect(); - - const overlay = document.createElement('div'); - overlay.id = 'survey-results-overlay'; - overlay.style.position = 'fixed'; - overlay.style.top = `${containerRect.top}px`; - overlay.style.left = `${containerRect.left}px`; - overlay.style.width = `${containerRect.width}px`; - overlay.style.height = `${containerRect.height}px`; - overlay.style.backgroundColor = '#ffffff'; - overlay.style.zIndex = '1000'; - overlay.style.display = 'flex'; - overlay.style.flexDirection = 'column'; - overlay.style.alignItems = 'center'; - overlay.style.justifyContent = 'center'; - overlay.style.padding = '2rem'; - overlay.style.boxSizing = 'border-box'; - overlay.style.overflow = 'auto'; - overlay.style.fontFamily = "'Computer Modern Sans', sans-serif"; - - const numSummaries = currentSurveyResults.summaries.length; - - overlay.innerHTML = ` + + function showSurveyResultsOverlay() { + if ( + !currentSurveyResults || + !currentSurveyResults.summaries || + currentSurveyResults.summaries.length === 0 + ) { + Modal.info("No Results", "No survey results available."); + return; + } + + // Set this BEFORE disabling buttons to prevent flash + resultsOverlayVisible = true; + currentResultIndex = 0; + + disableControlButtons(true, __beamer_all_buttons, surveyResultsBtn); + + const pdfContainer = document.getElementById("pdf-canvas"); + const containerRect = pdfContainer.getBoundingClientRect(); + + const overlay = document.createElement("div"); + overlay.id = "survey-results-overlay"; + overlay.style.position = "fixed"; + overlay.style.top = `${containerRect.top}px`; + overlay.style.left = `${containerRect.left}px`; + overlay.style.width = `${containerRect.width}px`; + overlay.style.height = `${containerRect.height}px`; + overlay.style.backgroundColor = "#ffffff"; + overlay.style.zIndex = "1000"; + overlay.style.display = "flex"; + overlay.style.flexDirection = "column"; + overlay.style.alignItems = "center"; + overlay.style.justifyContent = "center"; + overlay.style.padding = "2rem"; + overlay.style.boxSizing = "border-box"; + overlay.style.overflow = "auto"; + overlay.style.fontFamily = "'Computer Modern Sans', sans-serif"; + + const numSummaries = currentSurveyResults.summaries.length; + + overlay.innerHTML = `
@@ -1517,460 +1628,522 @@ function showSurveyResultsOverlay() {
`; - - document.body.appendChild(overlay); - - document.getElementById('prev-result').addEventListener('click', () => { - if (currentResultIndex > 0) { - currentResultIndex--; - updateResultDisplay(); - } - }); - - document.getElementById('next-result').addEventListener('click', () => { - if (currentResultIndex < currentSurveyResults.summaries.length - 1) { - currentResultIndex++; - updateResultDisplay(); + + document.body.appendChild(overlay); + + document.getElementById("prev-result").addEventListener("click", () => { + if (currentResultIndex > 0) { + currentResultIndex--; + updateResultDisplay(); + } + }); + + document.getElementById("next-result").addEventListener("click", () => { + if ( + currentResultIndex < + currentSurveyResults.summaries.length - 1 + ) { + currentResultIndex++; + updateResultDisplay(); + } + }); + + updateResultDisplay(); + } + + function hideSurveyResultsOverlay() { + resultsOverlayVisible = false; + + disableControlButtons(false, __beamer_all_buttons, surveyResultsBtn); + + const overlay = document.getElementById("survey-results-overlay"); + if (overlay) { + document.body.removeChild(overlay); } - }); - - updateResultDisplay(); -} - -function hideSurveyResultsOverlay() { - resultsOverlayVisible = false; - - disableControlButtons(false, __beamer_all_buttons, surveyResultsBtn); - - const overlay = document.getElementById('survey-results-overlay'); - if (overlay) { - document.body.removeChild(overlay); } -} - -function updateResultDisplay() { - const contentDiv = document.getElementById('result-content'); - const counterSpan = document.getElementById('result-counter'); - const prevBtn = document.getElementById('prev-result'); - const nextBtn = document.getElementById('next-result'); - - if (!contentDiv || !currentSurveyResults || !currentSurveyResults.summaries) return; - - const summaryData = currentSurveyResults.summaries[currentResultIndex]; - const totalSummaries = currentSurveyResults.summaries.length; - - contentDiv.innerHTML = ` + + function updateResultDisplay() { + const contentDiv = document.getElementById("result-content"); + const counterSpan = document.getElementById("result-counter"); + const prevBtn = document.getElementById("prev-result"); + const nextBtn = document.getElementById("next-result"); + + if ( + !contentDiv || + !currentSurveyResults || + !currentSurveyResults.summaries + ) + return; + + const summaryData = currentSurveyResults.summaries[currentResultIndex]; + const totalSummaries = currentSurveyResults.summaries.length; + + contentDiv.innerHTML = `

- ${currentSurveyData.question || 'Survey Response Summaries'} + ${currentSurveyData.question || "Survey Response Summaries"}
- (based on ${summaryData.num_respondents} response${summaryData.num_respondents !== 1 ? 's' : ''}) + (based on ${summaryData.num_respondents} response${summaryData.num_respondents !== 1 ? "s" : ""})

${summaryData.summary}
`; - - counterSpan.textContent = `${currentResultIndex + 1} / ${totalSummaries}`; - - prevBtn.disabled = currentResultIndex === 0; - nextBtn.disabled = currentResultIndex === totalSummaries - 1; - - prevBtn.style.opacity = prevBtn.disabled ? '0.5' : '1'; - nextBtn.style.opacity = nextBtn.disabled ? '0.5' : '1'; - prevBtn.style.cursor = prevBtn.disabled ? 'not-allowed' : 'pointer'; - nextBtn.style.cursor = nextBtn.disabled ? 'not-allowed' : 'pointer'; -} - -document.addEventListener('keydown', (e) => { - if (e.key === 'Escape' && resultsOverlayVisible) { - hideSurveyResultsOverlay(); - } -}); -// Add resize listener to update overlay positions -window.addEventListener('resize', () => { - if (surveyOverlayVisible) { - updateSurveyOverlayPosition(); - } - if (resultsOverlayVisible) { - updateSurveyResultsOverlayPosition(); - } - - // Resize annotation canvas to maintain proper mouse coordinate mapping - if (annCvs) { - annCvs.resize(); - } - - // Update positions of all media elements (videos, models, widgets) - if (zipFile && slideConfigs[currentSlide]) { - updateMediaPositions(); + counterSpan.textContent = `${currentResultIndex + 1} / ${totalSummaries}`; + + prevBtn.disabled = currentResultIndex === 0; + nextBtn.disabled = currentResultIndex === totalSummaries - 1; + + prevBtn.style.opacity = prevBtn.disabled ? "0.5" : "1"; + nextBtn.style.opacity = nextBtn.disabled ? "0.5" : "1"; + prevBtn.style.cursor = prevBtn.disabled ? "not-allowed" : "pointer"; + nextBtn.style.cursor = nextBtn.disabled ? "not-allowed" : "pointer"; } -}); -// Add fullscreen change listener to handle fullscreen transitions -document.addEventListener('fullscreenchange', () => { - // Need a small delay for the browser to finish the fullscreen transition - setTimeout(() => { + document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && resultsOverlayVisible) { + hideSurveyResultsOverlay(); + } + }); + + // Add resize listener to update overlay positions + window.addEventListener("resize", () => { if (surveyOverlayVisible) { updateSurveyOverlayPosition(); } if (resultsOverlayVisible) { updateSurveyResultsOverlayPosition(); } - + + // Resize annotation canvas to maintain proper mouse coordinate mapping if (annCvs) { annCvs.resize(); } - + + // Update positions of all media elements (videos, models, widgets) if (zipFile && slideConfigs[currentSlide]) { updateMediaPositions(); } - }, 100); -}); + }); -// ==================== SCREEN SHARING ==================== -let screenStream = null; -let isScreenSharing = false; - -// ==================== RECORDING ==================== -let isRecording = false; -let mediaRecorder = null; -let recordedChunks = []; -let recordingCanvas = null; -let recordingStream = null; -let audioStream = null; -let micStream = null; - -recordBtn.onClick(async () => { - if (isRecording) { - stopRecording(); - } else { - await startRecording(); - } -}); + // Add fullscreen change listener to handle fullscreen transitions + document.addEventListener("fullscreenchange", () => { + // Need a small delay for the browser to finish the fullscreen transition + setTimeout(() => { + if (surveyOverlayVisible) { + updateSurveyOverlayPosition(); + } + if (resultsOverlayVisible) { + updateSurveyResultsOverlayPosition(); + } -async function startRecording() { - if (!isScreenSharing) { - Modal.error('Recording Error', 'Please start screen sharing before recording.'); - return; - } - - try { - // Get the canvas that's being used for screen sharing - const pdfContainer = document.getElementById('pdf-canvas'); - - // Create a new canvas for recording - recordingCanvas = document.createElement('canvas'); - recordingCanvas.width = pdfContainer.getBoundingClientRect().width; - recordingCanvas.height = pdfContainer.getBoundingClientRect().height; - - // Create a stream from the canvas at 30 FPS - recordingStream = recordingCanvas.captureStream(30); - - // Request microphone audio - try { - micStream = await navigator.mediaDevices.getUserMedia({ audio: true }); - micStream.getAudioTracks().forEach(track => recordingStream.addTrack(track)); - } catch (error) { - console.warn('Microphone not captured:', error); + if (annCvs) { + annCvs.resize(); + } + + if (zipFile && slideConfigs[currentSlide]) { + updateMediaPositions(); + } + }, 100); + }); + + // ==================== SCREEN SHARING ==================== + let screenStream = null; + let isScreenSharing = false; + + // ==================== RECORDING ==================== + let isRecording = false; + let mediaRecorder = null; + let recordedChunks = []; + let recordingCanvas = null; + let recordingStream = null; + let audioStream = null; + let micStream = null; + + recordBtn.onClick(async () => { + if (isRecording) { + stopRecording(); + } else { + await startRecording(); } - - // Request system audio (will prompt user) - try { - audioStream = await navigator.mediaDevices.getDisplayMedia({ - video: false, - audio: true - }); - audioStream.getAudioTracks().forEach(track => recordingStream.addTrack(track)); - } catch (audioError) { - console.warn('System audio not captured:', audioError); + }); + + async function startRecording() { + if (!isScreenSharing) { + Modal.error( + "Recording Error", + "Please start screen sharing before recording.", + ); + return; } - - // Create MediaRecorder to record in real-time - recordedChunks = []; - mediaRecorder = new MediaRecorder(recordingStream, { - mimeType: 'video/webm;codecs=vp9', - videoBitsPerSecond: 2500000 - }); - - mediaRecorder.ondataavailable = (e) => { - if (e.data.size > 0) { - recordedChunks.push(e.data); - } - }; - - mediaRecorder.start(100); // Collect data every 100ms - - isRecording = true; - - // Update button appearance - recordBtn.el.innerHTML = ''; - recordBtn.el.style.backgroundColor = '#e74c3c'; - - console.log('Recording started'); - } catch (error) { - console.error('Error starting recording:', error); - Modal.error('Recording Error', 'Could not start recording: ' + error.message); - } -} - -async function stopRecording() { - if (!isRecording || !mediaRecorder) return; - - isRecording = false; - - // Reset button appearance - recordBtn.el.innerHTML = ''; - recordBtn.el.style.backgroundColor = ''; - - console.log('Recording stopped. Processing video...'); - - // Show processing modal with loading animation - const processingModal = Modal.loading('Processing Video', 'Please wait while your recording is being processed and downloaded...'); - - return new Promise((resolve, reject) => { - mediaRecorder.onstop = () => { + + try { + // Get the canvas that's being used for screen sharing + const pdfContainer = document.getElementById("pdf-canvas"); + + // Create a new canvas for recording + recordingCanvas = document.createElement("canvas"); + recordingCanvas.width = pdfContainer.getBoundingClientRect().width; + recordingCanvas.height = + pdfContainer.getBoundingClientRect().height; + + // Create a stream from the canvas at 30 FPS + recordingStream = recordingCanvas.captureStream(30); + + // Request microphone audio try { - const blob = new Blob(recordedChunks, { type: 'video/webm' }); - const url = URL.createObjectURL(blob); - - // Download the video - const a = document.createElement('a'); - a.href = url; - a.download = `beamer-recording-${new Date().toISOString().replace(/[:.]/g, '-')}.webm`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - - // Clean up - recordedChunks = []; - if (micStream) { - micStream.getTracks().forEach(track => track.stop()); - micStream = null; - } - if (audioStream) { - audioStream.getTracks().forEach(track => track.stop()); - audioStream = null; - } - if (recordingStream) { - recordingStream.getTracks().forEach(track => track.stop()); - recordingStream = null; - } - recordingCanvas = null; - - processingModal.close(); - Modal.success('Recording Complete', 'Your video has been downloaded successfully!'); - resolve(); + micStream = await navigator.mediaDevices.getUserMedia({ + audio: true, + }); + micStream + .getAudioTracks() + .forEach((track) => recordingStream.addTrack(track)); } catch (error) { - console.error('Error processing video:', error); - processingModal.close(); - Modal.error('Recording Error', 'Failed to process video: ' + error.message); - reject(error); + console.warn("Microphone not captured:", error); } - }; - - mediaRecorder.stop(); - }); -} -screenShareBtn.onClick(async () => { - if (isScreenSharing) { - stopScreenShare(); - } else { - await startScreenShare(); + // Request system audio (will prompt user) + try { + audioStream = await navigator.mediaDevices.getDisplayMedia({ + video: false, + audio: true, + }); + audioStream + .getAudioTracks() + .forEach((track) => recordingStream.addTrack(track)); + } catch (audioError) { + console.warn("System audio not captured:", audioError); + } + + // Create MediaRecorder to record in real-time + recordedChunks = []; + mediaRecorder = new MediaRecorder(recordingStream, { + mimeType: "video/webm;codecs=vp9", + videoBitsPerSecond: 2500000, + }); + + mediaRecorder.ondataavailable = (e) => { + if (e.data.size > 0) { + recordedChunks.push(e.data); + } + }; + + mediaRecorder.start(100); // Collect data every 100ms + + isRecording = true; + + // Update button appearance + recordBtn.el.innerHTML = ''; + recordBtn.el.style.backgroundColor = "#e74c3c"; + + console.log("Recording started"); + } catch (error) { + console.error("Error starting recording:", error); + Modal.error( + "Recording Error", + "Could not start recording: " + error.message, + ); + } } -}); -async function startScreenShare() { - try { - // Request screen capture - restrict to current tab only - const stream = await navigator.mediaDevices.getDisplayMedia({ - video: { - cursor: 'always', // always show cursor (other options: 'moving', 'never') - displaySurface: 'browser' // this determines which displaySurface - // (tab/window/monitor) the picker defaults to. 'browser' means 'tab'. - }, - audio: false, - selfBrowserSurface: 'include', - surfaceSwitching: 'exclude', - systemAudio: 'exclude' + async function stopRecording() { + if (!isRecording || !mediaRecorder) return; + + isRecording = false; + + // Reset button appearance + recordBtn.el.innerHTML = ''; + recordBtn.el.style.backgroundColor = ""; + + console.log("Recording stopped. Processing video..."); + + // Show processing modal with loading animation + const processingModal = Modal.loading( + "Processing Video", + "Please wait while your recording is being processed and downloaded...", + ); + + return new Promise((resolve, reject) => { + mediaRecorder.onstop = () => { + try { + const blob = new Blob(recordedChunks, { + type: "video/webm", + }); + const url = URL.createObjectURL(blob); + + // Download the video + const a = document.createElement("a"); + a.href = url; + a.download = `beamer-recording-${new Date().toISOString().replace(/[:.]/g, "-")}.webm`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + // Clean up + recordedChunks = []; + if (micStream) { + micStream.getTracks().forEach((track) => track.stop()); + micStream = null; + } + if (audioStream) { + audioStream + .getTracks() + .forEach((track) => track.stop()); + audioStream = null; + } + if (recordingStream) { + recordingStream + .getTracks() + .forEach((track) => track.stop()); + recordingStream = null; + } + recordingCanvas = null; + + processingModal.close(); + Modal.success( + "Recording Complete", + "Your video has been downloaded successfully!", + ); + resolve(); + } catch (error) { + console.error("Error processing video:", error); + processingModal.close(); + Modal.error( + "Recording Error", + "Failed to process video: " + error.message, + ); + reject(error); + } + }; + + mediaRecorder.stop(); }); - - // Check if the user selected the correct surface type - const videoTrack = stream.getVideoTracks()[0]; - const settings = videoTrack.getSettings(); - - // check by regex if the user is on Safari: - const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); - console.log(`isSafari: ${isSafari}`); - - if (!isSafari) { - // Verify that a browser tab was selected (not window or monitor) - if (settings.displaySurface && settings.displaySurface !== 'browser') { - // Stop the stream immediately - stream.getTracks().forEach(track => track.stop()); - - Modal.error( - 'Wrong Source Selected', - 'Please select the Beamer+ browser tab (not a window or entire screen). Click the screen share button again and choose "This Tab" or the Beamer+ tab from the list.' - ); - isScreenSharing = false; - return; - } + } + + screenShareBtn.onClick(async () => { + if (isScreenSharing) { + stopScreenShare(); + } else { + await startScreenShare(); } - // We don't do the above check for Safari, because 'browser' screensharing - // (i.e. tab screensharing) is not possible on Safari. - - screenStream = stream; - isScreenSharing = true; - - // Update button appearance - screenShareBtn.el.innerHTML = ''; - - // Create a hidden video element to capture the stream - const video = document.createElement('video'); - video.style.display = 'none'; - video.srcObject = stream; - video.play(); - - document.body.appendChild(video); - - // Create canvas for cropping - const cropCanvas = document.createElement('canvas'); - const cropCtx = cropCanvas.getContext('2d'); - - // Get the canvas container dimensions - const pdfContainer = document.getElementById('pdf-canvas'); - - let lastFrameTime = 0; - const frameInterval = 100; // Reduced to 10 FPS to prevent flashing - - // Start capturing and cropping frames - const captureFrame = (currentTime) => { - if (!isScreenSharing) return; - - // Throttle frame rate - if (currentTime - lastFrameTime < frameInterval) { - requestAnimationFrame(captureFrame); - return; - } - lastFrameTime = currentTime; - - const containerRect = pdfContainer.getBoundingClientRect(); - - // Set canvas size to match the container - cropCanvas.width = containerRect.width; - cropCanvas.height = containerRect.height; - - // Calculate the position and size to crop from the video - // This assumes the video is capturing the full screen - const scaleX = video.videoWidth / window.innerWidth; - const scaleY = video.videoHeight / window.innerHeight; - - const sourceX = containerRect.left * scaleX; - const sourceY = containerRect.top * scaleY; - const sourceWidth = containerRect.width * scaleX; - const sourceHeight = containerRect.height * scaleY; - - // Draw the cropped region onto the canvas - cropCtx.drawImage( - video, - sourceX, sourceY, sourceWidth, sourceHeight, - 0, 0, cropCanvas.width, cropCanvas.height + }); + + async function startScreenShare() { + try { + // Request screen capture - restrict to current tab only + const stream = await navigator.mediaDevices.getDisplayMedia({ + video: { + cursor: "always", // always show cursor (other options: 'moving', 'never') + displaySurface: "browser", // this determines which displaySurface + // (tab/window/monitor) the picker defaults to. 'browser' means 'tab'. + }, + audio: false, + selfBrowserSurface: "include", + surfaceSwitching: "exclude", + systemAudio: "exclude", + }); + + // Check if the user selected the correct surface type + const videoTrack = stream.getVideoTracks()[0]; + const settings = videoTrack.getSettings(); + + // check by regex if the user is on Safari: + const isSafari = /^((?!chrome|android).)*safari/i.test( + navigator.userAgent, ); - - // If recording, draw to recording canvas in real-time - if (isRecording && recordingCanvas) { - const recordingCtx = recordingCanvas.getContext('2d'); - // Update canvas size if needed - if (recordingCanvas.width !== cropCanvas.width || recordingCanvas.height !== cropCanvas.height) { - recordingCanvas.width = cropCanvas.width; - recordingCanvas.height = cropCanvas.height; + console.log(`isSafari: ${isSafari}`); + + if (!isSafari) { + // Verify that a browser tab was selected (not window or monitor) + if ( + settings.displaySurface && + settings.displaySurface !== "browser" + ) { + // Stop the stream immediately + stream.getTracks().forEach((track) => track.stop()); + + Modal.error( + "Wrong Source Selected", + 'Please select the Beamer+ browser tab (not a window or entire screen). Click the screen share button again and choose "This Tab" or the Beamer+ tab from the list.', + ); + isScreenSharing = false; + return; } - // Draw the current frame to the recording canvas - recordingCtx.drawImage(cropCanvas, 0, 0); } - - // Convert canvas to blob and emit to server - cropCanvas.toBlob((blob) => { - if (blob && isScreenSharing) { - // Convert blob to base64 - const reader = new FileReader(); - reader.onloadend = () => { - const base64data = reader.result; - socket.emit('screen_frame', { - frame: base64data, - width: cropCanvas.width, - height: cropCanvas.height - }); - }; - reader.readAsDataURL(blob); + // We don't do the above check for Safari, because 'browser' screensharing + // (i.e. tab screensharing) is not possible on Safari. + + screenStream = stream; // assign the global "state" variable + isScreenSharing = true; // assign the global "state" variable + + // Update button appearance + screenShareBtn.el.innerHTML = + ''; + + // Create a hidden video element to capture the stream + const video = document.createElement("video"); + video.style.display = "none"; // -> not displayed. invisible. + video.srcObject = stream; + video.play(); + + document.body.appendChild(video); + + // Create canvas for cropping + const cropCanvas = document.createElement("canvas"); + const cropCtx = cropCanvas.getContext("2d"); + + // the