Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { Outlet, useLocation, useParams } from "react-router";

import { useElementOnScreen } from "@/hooks/useElementOnScreen";
import { useI18nNavigate } from "@/hooks/useI18nNavigate";
import { useVideoWakeLockFallback } from "@/hooks/useVideoWakeLockFallback";
import { useWakeLock } from "@/hooks/useWakeLock";
import { finishConversation } from "@/lib/api";
import { I18nLink } from "../common/i18nLink";
Expand Down Expand Up @@ -100,7 +101,7 @@ export const ParticipantConversationAudio = () => {
const cooldown = useRefineSelectionCooldown(conversationId);

const audioRecorder = useChunkedAudioRecorder({ deviceId, onChunk });
useWakeLock({ obtainWakeLockOnMount: true });
const wakeLock = useWakeLock({ obtainWakeLockOnMount: true });

const {
startRecording,
Expand All @@ -114,6 +115,12 @@ export const ParticipantConversationAudio = () => {
permissionError,
} = audioRecorder;

// iOS low battery mode fallback: play silent 1-pixel video only when wakelock fails
useVideoWakeLockFallback({
isRecording,
isWakeLockActive: wakeLock.isActive,
});

const handleMicrophoneDeviceChanged = async () => {
try {
stopRecording();
Expand Down
126 changes: 126 additions & 0 deletions echo/frontend/src/hooks/useVideoWakeLockFallback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { useEffect, useRef } from "react";

const MINIMAL_VIDEO_BASE64 =
"data:video/mp4;base64,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";

export const useVideoWakeLockFallback = ({
isRecording,
isWakeLockActive,
}: {
isRecording: boolean;
isWakeLockActive: boolean;
}) => {
const videoRef = useRef<HTMLVideoElement | null>(null);
const checkIntervalRef = useRef<NodeJS.Timeout | null>(null);

useEffect(() => {
// Only activate fallback if recording AND wakelock is not active
const shouldActivateFallback = isRecording && !isWakeLockActive;

if (!shouldActivateFallback) {
// Clean up video element if it exists
if (videoRef.current) {
videoRef.current.pause();
videoRef.current.src = "";
videoRef.current.remove();
videoRef.current = null;
console.log(
"[VideoWakeLockFallback] Cleaned up video - wakelock is working",
);
}
if (checkIntervalRef.current) {
clearInterval(checkIntervalRef.current);
checkIntervalRef.current = null;
}
return;
Comment on lines +20 to +35
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider extracting cleanup logic to avoid duplication.

The cleanup code at lines 22-34 and lines 109-118 is nearly identical. Not a blocker, but could DRY this up with a helper:

+const cleanupVideo = (
+  videoRef: React.MutableRefObject<HTMLVideoElement | null>,
+  checkIntervalRef: React.MutableRefObject<NodeJS.Timeout | null>,
+) => {
+  if (videoRef.current) {
+    videoRef.current.pause();
+    videoRef.current.src = "";
+    videoRef.current.remove();
+    videoRef.current = null;
+  }
+  if (checkIntervalRef.current) {
+    clearInterval(checkIntervalRef.current);
+    checkIntervalRef.current = null;
+  }
+};

Then call cleanupVideo(videoRef, checkIntervalRef) in both places. Optional refactor.

Also applies to: 107-119

🤖 Prompt for AI Agents
In echo/frontend/src/hooks/useVideoWakeLockFallback.ts around lines 20-35 and
107-119, the video cleanup logic is duplicated; extract a small helper function
(e.g., cleanupVideo) that accepts the videoRef and checkIntervalRef, performs
null checks, pauses the video, clears src, removes the element, nulls the
videoRef, clears and nulls the interval ref, and emits the same console log;
replace both duplicated blocks with a single call to cleanupVideo(videoRef,
checkIntervalRef) to keep behavior identical and preserve types and null-safety.

}

// If video already exists, don't create a new one
// The cleanup function at the end of this effect will handle cleanup when needed
if (videoRef.current) {
console.log(
"[VideoWakeLockFallback] Video already active, skipping creation",
);
return;
}

// Create a minimal 1x1 pixel video element
try {
console.log(
"[VideoWakeLockFallback] Activating video fallback - wakelock not active",
);

// Create video element
const video = document.createElement("video");
video.style.position = "fixed";
video.style.top = "-9999px";
video.style.left = "-9999px";
video.style.width = "1px";
video.style.height = "1px";
video.style.opacity = "0";
video.style.pointerEvents = "none";
video.style.zIndex = "-9999";
video.setAttribute("playsinline", "true");
video.setAttribute("webkit-playsinline", "true");
video.muted = true;
video.loop = true;
video.src = MINIMAL_VIDEO_BASE64;
videoRef.current = video;

// Append to DOM (required for iOS)
document.body.appendChild(video);

// Play the video
const playVideo = async () => {
try {
await video.play();
console.log(
"[VideoWakeLockFallback] Silent 1-pixel video playing as fallback for wakelock",
);
} catch (error) {
console.warn(
"[VideoWakeLockFallback] Failed to play fallback video:",
error,
);
}
};

playVideo();

// Periodically check if video is still playing and restart if needed
// (iOS can sometimes pause background videos)
checkIntervalRef.current = setInterval(() => {
if (videoRef.current?.paused) {
console.log(
"[VideoWakeLockFallback] Video was paused, restarting...",
);
playVideo();
}
}, 5000); // Check every 5 seconds
} catch (error) {
console.error(
"[VideoWakeLockFallback] Failed to create fallback video:",
error,
);
}

// Cleanup on unmount or when conditions change
return () => {
if (videoRef.current) {
videoRef.current.pause();
videoRef.current.src = "";
videoRef.current.remove();
videoRef.current = null;
}
if (checkIntervalRef.current) {
clearInterval(checkIntervalRef.current);
checkIntervalRef.current = null;
}
};
}, [isRecording, isWakeLockActive]);

return {
isActive: isRecording && !isWakeLockActive,
videoElement: videoRef.current,
};
};
52 changes: 43 additions & 9 deletions echo/frontend/src/hooks/useWakeLock.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,66 @@
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";

export const useWakeLock = ({ obtainWakeLockOnMount = true }) => {
const wakeLock = useRef<null | WakeLockSentinel>(null);
const [isSupported, setIsSupported] = useState<boolean>(false);
const [isActive, setIsActive] = useState<boolean>(false);
const releaseHandlerRef = useRef<(() => void) | null>(null);

const releaseWakeLock = () => {
if (wakeLock.current) {
if (releaseHandlerRef.current) {
wakeLock.current.removeEventListener(
"release",
releaseHandlerRef.current,
);
releaseHandlerRef.current = null;
}
wakeLock.current.release();
} else {
// console.log("no active wakelock to release");
wakeLock.current = null;
setIsActive(false);
}
};

const obtainWakeLock = async () => {
if ("wakeLock" in navigator) {
setIsSupported(true);
try {
// Release old wakelock BEFORE requesting a new one
if (wakeLock.current) {
// Remove old listener first
if (releaseHandlerRef.current) {
wakeLock.current.removeEventListener(
"release",
releaseHandlerRef.current,
);
releaseHandlerRef.current = null;
}
await wakeLock.current.release();
wakeLock.current = null;
}

// Now request the new wakelock
const wakelock = await navigator.wakeLock.request("screen");
releaseWakeLock();
if (wakelock) {
// console.log("wakelock obtained")
wakeLock.current = wakelock;
setIsActive(true);

const handleRelease = () => {
setIsActive(false);
};
releaseHandlerRef.current = handleRelease;
wakelock.addEventListener("release", handleRelease);
}
} catch (_err) {
// console.error("obtaining wakelock failed:", err);
setIsActive(false);
}
} else {
// console.log("wakeLock not supported");
setIsSupported(false);
setIsActive(false);
}
};

// biome-ignore lint/correctness/useExhaustiveDependencies: needs to be fixed
// biome-ignore lint/correctness/useExhaustiveDependencies: no dependency needed
useEffect(() => {
if (obtainWakeLockOnMount) {
obtainWakeLock();
Expand All @@ -47,9 +79,11 @@ export const useWakeLock = ({ obtainWakeLockOnMount = true }) => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
releaseWakeLock();
};
}, [wakeLock]);
}, []);

return {
isActive,
isSupported,
obtainWakeLock,
releaseWakeLock,
wakeLock,
Expand Down
Loading