(status);
+
+ // Show toast notification when download completes
+ useEffect(() => {
+ logger.debug('[RuntimeControls] Status changed:', {
+ prev: prevStatusRef.current,
+ current: status,
+ });
+ if (prevStatusRef.current === 'downloading' && status === 'downloaded') {
+ logger.debug('[RuntimeControls] Showing toast - download complete!');
+ toast.success('Presentation ready for offline mode!', {
+ position: 'bottom-center',
+ autoClose: 5000,
+ });
+ }
+ prevStatusRef.current = status;
+ }, [status]);
+
+ // Don't render if offline not supported
+ if (!isOfflineCapable) {
+ return null;
+ }
+
+ const handleClick = () => {
+ if (isDownloaded) {
+ if (confirm('Remove offline data for this project?')) {
+ deleteOfflineData();
+ }
+ } else if (isDownloading) {
+ pauseDownload();
+ } else if (status === 'error') {
+ startDownload();
+ } else {
+ if (isCritical) {
+ alert(
+ 'Storage space is critically low. Please free up some space first.',
+ );
+ return;
+ }
+ if (isWarning && estimatedSize > 0) {
+ if (
+ !confirm(
+ `Storage space is running low. Download ${formatSize(estimatedSize)} anyway?`,
+ )
+ ) {
+ return;
+ }
+ }
+ startDownload();
+ }
+ };
+
+ // Determine icon and color
+ let icon = mdiCloudDownload;
+ let color: 'info' | 'success' | 'danger' | 'warning' = 'info';
+ let title = 'Download for offline';
+
+ if (isDownloaded) {
+ icon = mdiCloudCheck;
+ color = 'success';
+ title = 'Available offline';
+ } else if (isDownloading) {
+ icon = mdiCloudDownload;
+ color = 'info';
+ title = `Downloading ${progress}%`;
+ } else if (status === 'error') {
+ icon = mdiCloudOff;
+ color = 'danger';
+ title = 'Retry download';
+ } else if (status === 'outdated') {
+ icon = mdiCloudDownload;
+ color = 'warning';
+ title = 'Update available';
+ }
+
+ const containerStyle: CSSProperties = {
+ display: 'flex',
+ alignItems: 'center',
+ gap: 4,
+ };
+
+ return (
+
+
+ {isDownloaded && (
+ {
+ if (confirm('Remove offline data for this project?')) {
+ deleteOfflineData();
+ }
+ }}
+ />
+ )}
+
+ );
+}
+
+/**
+ * Hook to counter pinch-zoom scaling using visualViewport API
+ * Returns a scale factor to apply as inverse transform
+ */
+function useCounterZoom() {
+ const [scale, setScale] = useState(1);
+
+ useEffect(() => {
+ // visualViewport API is supported in all modern browsers
+ const vv = window.visualViewport;
+ if (!vv) return;
+
+ const handleResize = () => {
+ // visualViewport.scale gives the pinch-zoom level
+ // We apply 1/scale to counter it
+ setScale(1 / vv.scale);
+ };
+
+ // Set initial value
+ handleResize();
+
+ vv.addEventListener('resize', handleResize);
+ vv.addEventListener('scroll', handleResize);
+
+ return () => {
+ vv.removeEventListener('resize', handleResize);
+ vv.removeEventListener('scroll', handleResize);
+ };
+ }, []);
+
+ return scale;
+}
+
+/**
+ * RuntimeControls - Main component for presentation controls
+ *
+ * Renders offline toggle and fullscreen button using fixed pixel values
+ * to maintain usable size regardless of canvas scaling.
+ * Uses visualViewport API to counter pinch-zoom scaling on mobile.
+ */
+export default function RuntimeControls({
+ projectId,
+ projectSlug,
+ projectName,
+ pages,
+ isFullscreen,
+ toggleFullscreen,
+}: RuntimeControlsProps) {
+ // Counter-scale to resist pinch-zoom
+ const counterScale = useCounterZoom();
+
+ const containerStyle: CSSProperties = {
+ position: 'fixed',
+ top: 16,
+ right: 16,
+ display: 'flex',
+ alignItems: 'center',
+ gap: 8,
+ zIndex: 9999, // Above everything including modals
+ // Counter pinch-zoom scaling
+ transform: `scale(${counterScale})`,
+ transformOrigin: 'top right',
+ };
+
+ return (
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/RuntimePresentation.tsx b/frontend/src/components/RuntimePresentation.tsx
index e440560..f2b2ba1 100644
--- a/frontend/src/components/RuntimePresentation.tsx
+++ b/frontend/src/components/RuntimePresentation.tsx
@@ -5,7 +5,6 @@
* Used by /p/[projectSlug] and /p/[projectSlug]/stage routes.
*/
-import { mdiFullscreen, mdiFullscreenExit } from '@mdi/js';
import Head from 'next/head';
import React, {
ReactElement,
@@ -17,9 +16,8 @@ import React, {
} from 'react';
import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
-import BaseButton from './BaseButton';
import CardBox from './CardBox';
-import { OfflineToggle } from './Offline/OfflineToggle';
+import RuntimeControls from './Runtime/RuntimeControls';
import RuntimeElement from './RuntimeElement';
import TransitionPreviewOverlay from './Constructor/TransitionPreviewOverlay';
import GalleryCarouselOverlay from './UiElements/GalleryCarouselOverlay';
@@ -693,24 +691,6 @@ export default function RuntimePresentation({
{/* End page elements wrapper */}
- {/* Controls: Offline toggle and Fullscreen button */}
-
-
-
-
-
{/* Transition overlay - uses useTransitionPlayback hook for smooth transitions */}
{/* Opacity is 0 during 'preparing' phase to show old page while video loads */}
{/* NO fade-out: video itself IS the transition (last frame = new page) */}
@@ -785,6 +765,17 @@ export default function RuntimePresentation({
{/* End inner canvas container */}
+ {/* Controls: Offline toggle and Fullscreen button */}
+ {/* Positioned outside canvas to avoid scaling with canvas transform */}
+
+
{/* Toast notifications for offline download status */}