diff --git a/frontend/src/components/Runtime/RuntimeControls.tsx b/frontend/src/components/Runtime/RuntimeControls.tsx
index a35fc2b..1bc17f5 100644
--- a/frontend/src/components/Runtime/RuntimeControls.tsx
+++ b/frontend/src/components/Runtime/RuntimeControls.tsx
@@ -46,6 +46,10 @@ interface RuntimeControlsProps {
isMuted?: boolean;
/** Callback to toggle all presentation sound on/off */
onSoundToggle?: () => void;
+ /** Whether to show the offline download button */
+ showOfflineButton?: boolean;
+ /** Whether to show the fullscreen button */
+ showFullscreenButton?: boolean;
}
/**
@@ -468,6 +472,8 @@ export default function RuntimeControls({
showSoundButton = false,
isMuted = true,
onSoundToggle,
+ showOfflineButton = true,
+ showFullscreenButton = true,
}: RuntimeControlsProps) {
// Counter-scale to resist pinch-zoom
const counterScale = useCounterZoom();
@@ -520,18 +526,22 @@ export default function RuntimeControls({
onTouchEnd={stopControlEvent}
onTouchEndCapture={stopControlEvent}
>
-
-
+ {showOfflineButton && (
+
+ )}
+ {showFullscreenButton && (
+
+ )}
{showSoundButton && onSoundToggle && (
{
});
// Canvas scale for responsive UI elements and letterbox mode
- const { cssVars: canvasCssVars, letterboxStyles } = useCanvasScale({
+ const {
+ cssVars: canvasCssVars,
+ letterboxStyles,
+ canvasWidth,
+ canvasHeight,
+ } = useCanvasScale({
designWidth: project?.design_width,
designHeight: project?.design_height,
});
@@ -241,13 +247,6 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
backgroundAudioEndTime,
} = usePageBackground();
- // Global sound control starts muted for browser autoplay compatibility.
- const soundControl = useVideoSoundControl({
- pageHasSound: backgroundVideoMuted === false,
- hasBackgroundVideo: Boolean(backgroundVideoUrl),
- hasBackgroundAudio: Boolean(backgroundAudioUrl),
- });
-
// Network-aware transitions: skip video on slow networks, use CSS fade instead
const { shouldUseVideoTransitions, networkInfo } = useNetworkAware();
@@ -367,6 +366,49 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
[elements],
);
+ const hasElementAudio = useMemo(
+ () =>
+ elements.some((element) => {
+ if (element.hoverAudioUrl || element.clickAudioUrl) return true;
+ if (
+ (element.type === 'audio_player' ||
+ element.type === 'video_player') &&
+ element.mediaUrl &&
+ !element.mediaMuted
+ ) {
+ return true;
+ }
+ if (
+ element.galleryCards?.some(
+ (card: GalleryCarouselMediaItem) =>
+ card.mediaType === 'video' || Boolean(card.videoUrl),
+ )
+ ) {
+ return true;
+ }
+ if (
+ element.infoPanelSections?.some((section) =>
+ section.images?.some(
+ (item: InfoPanelImage) =>
+ item.itemType === 'video' || Boolean(item.videoUrl),
+ ),
+ )
+ ) {
+ return true;
+ }
+ return false;
+ }),
+ [elements],
+ );
+
+ // Global sound control starts muted for browser autoplay compatibility.
+ const soundControl = useVideoSoundControl({
+ pageHasSound: backgroundVideoMuted === false,
+ hasBackgroundVideo: Boolean(backgroundVideoUrl),
+ hasBackgroundAudio: Boolean(backgroundAudioUrl),
+ hasElementAudio,
+ });
+
// Look up current element for gallery carousel (so it receives updates from element editor)
const activeGalleryCarouselElement = useMemo(() => {
if (!activeGalleryCarousel) return null;
@@ -2184,6 +2226,27 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
+ {!isConstructorEditMode &&
+ !activeGalleryCarousel &&
+ !activeInfoPanelGallery &&
+ soundControl.showSoundButton && (
+ undefined}
+ canvasWidth={canvasWidth}
+ canvasHeight={canvasHeight}
+ showOfflineButton={false}
+ showFullscreenButton={false}
+ showSoundButton={soundControl.showSoundButton}
+ isMuted={soundControl.isMuted}
+ onSoundToggle={soundControl.toggleSound}
+ />
+ )}
+
{/* ElementEditorPanel now uses ConstructorContext for all state */}
{pages.length > 0 && hasEditorSelection && (