From 34a6804bbf2e9b1b1d0a8d1f5bae25f99afaa863 Mon Sep 17 00:00:00 2001 From: Greg Trihus Date: Thu, 28 May 2026 09:07:28 -0500 Subject: [PATCH 1/3] TT-7228b partial fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What changed in MediaRecord.tsx handleLoadAudio:532-555 now wraps the whole load sequence in a single try/catch: - If getGoodUrl() throws (waitForIt timeout — the main root cause), the catch calls blobError(ts.mediaError) which sets loading=false and shows the localized "Error loading audio" message. - Same for any throw from loadBlobAsync. - The thrown error is logged via logError/bugsnag so future failures aren't silent. - Hardcoded English error strings replaced with ts.mediaError. What this should fix on iOS for the tester - Symptom 3 (infinite loading on Record-step revisit) — addressed directly. - Symptom 1 (Play disabled after upload) — same root cause; should clear once load completes or fails. - Symptom 2 (Audio tab toggles silent) — not addressed by this fix. That's a separate iOS Safari Web Audio gesture issue in MediaPlayer/HiddenPlayer that needs its own ticket. --- src/renderer/src/components/MediaRecord.tsx | 33 ++++++++++++--------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/src/renderer/src/components/MediaRecord.tsx b/src/renderer/src/components/MediaRecord.tsx index bec69181..3ba08a94 100644 --- a/src/renderer/src/components/MediaRecord.tsx +++ b/src/renderer/src/components/MediaRecord.tsx @@ -10,7 +10,14 @@ import { useGlobal } from '../context/useGlobal'; import { IPassageRecordStrings, ISharedStrings } from '../model'; import { Stack, Paper, Typography } from '@mui/material'; import WSAudioPlayer, { WSAudioPlayerControls } from './WSAudioPlayer'; -import { loadBlobAsync, useMobile, waitForIt } from '../utils'; +import { + infoMsg, + loadBlobAsync, + logError, + Severity, + useMobile, + waitForIt, +} from '../utils'; import { IMediaState, MediaSt, @@ -535,20 +542,18 @@ function MediaRecord(props: IProps) { setLoading(true); reset(); - const url = await getGoodUrl(); - - if (url) { - try { - const blob = await loadBlobAsync(url); - if (blob) gotTheBlob(blob); - else blobError('Failed to load blob'); - } catch (error) { - blobError( - error instanceof Error ? error.message : 'Failed to load blob' - ); + try { + const url = await getGoodUrl(); + if (!url) { + blobError(mediaStateRef.current.error || ts.mediaError); + return; } - } else { - blobError(mediaStateRef.current.error || 'Failed to fetch media URL'); + const blob = await loadBlobAsync(url); + if (blob) gotTheBlob(blob); + else blobError(ts.mediaError); + } catch (error) { + logError(Severity.error, reporter, infoMsg(error as Error, 'media load failed')); + blobError(ts.mediaError); } }; From 8888cb4f7ed31321ee2585b548795c13a863fbc9 Mon Sep 17 00:00:00 2001 From: Greg Trihus Date: Thu, 28 May 2026 09:36:24 -0500 Subject: [PATCH 2/3] TT-7228c fix start not in click handler for iPhone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What changed - WSAudioPlayer.tsx:handlePlayStatus — added an idempotent early-return when play === playingRef.current (except when a region replay is intended). This lets the same handler be hit twice — once synchronously from the click, once via the state chain — without double-toggling. - HiddenPlayer.tsx — accepts and forwards a controlsRef to its inner WSAudioPlayer. - MediaPlayer.tsx:handlePlayPause — owns a wsControlsRef, passes it down to HiddenPlayer, and calls wsControlsRef.current?.togglePlay() synchronously inside the click handler before the React state updates run. Why this fixes Symptom 2 togglePlay → togglePlayStatus → handlePlayStatus → wsTogglePlay → setPlayingx → wavesurferRef.current.play() are all synchronous. Calling it inside the click keeps wavesurfer.play() inside the user-gesture context iOS Safari requires for AudioContext resume. The pre-existing state-driven chain (with its 100ms setTimeout in usePlayerLogic) still runs but now hits the idempotent guard and no-ops. Pause works the same way (Safari doesn't gate pause on user gesture, so the existing chain is fine, but the synchronous call simplifies it). Caveats - This only fixes the click-driven play in MediaPlayer (the path the bug report describes — the Audio tab). The prop-driven path (parent sets requestPlay=true programmatically) was never going to play on iOS Safari anyway, since Safari blocks autoplay without a gesture. - I left the 100ms setTimeout in usePlayerLogic alone — it predates this work and the comment hints at a race; removing it is a separate decision. --- src/renderer/src/components/HiddenPlayer.tsx | 5 ++++- src/renderer/src/components/MediaPlayer.tsx | 10 ++++++++++ src/renderer/src/components/WSAudioPlayer.tsx | 5 +++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/HiddenPlayer.tsx b/src/renderer/src/components/HiddenPlayer.tsx index 61fa3e67..4fb9d73b 100644 --- a/src/renderer/src/components/HiddenPlayer.tsx +++ b/src/renderer/src/components/HiddenPlayer.tsx @@ -1,7 +1,7 @@ import { useState, useRef } from 'react'; import { IRegion } from '../crud/useWavesurferRegions'; -import WSAudioPlayer from './WSAudioPlayer'; +import WSAudioPlayer, { WSAudioPlayerControls } from './WSAudioPlayer'; import { MediaFile } from '../model'; import { NamedRegions } from '../utils/namedSegments'; import { RequestPlay, usePlayerLogic } from '../business/player/usePlayerLogic'; @@ -20,6 +20,7 @@ export interface HiddenPlayerProps { currentSegment?: IRegion; setCurrentSegment?: (segment: IRegion | undefined, index: number) => void; playerMediafile?: MediaFile; + controlsRef?: React.RefObject; } export function HiddenPlayer(props: HiddenPlayerProps) { @@ -36,6 +37,7 @@ export function HiddenPlayer(props: HiddenPlayerProps) { currentSegmentIndex, setCurrentSegment, playerMediafile, + controlsRef, } = props; const [requestPlay, setRequestPlay] = useState({ @@ -88,6 +90,7 @@ export function HiddenPlayer(props: HiddenPlayerProps) { onCurrentSegment={onCurrentSegment} onProgress={onProgress} onDuration={onDuration} + controlsRef={controlsRef} /> ); diff --git a/src/renderer/src/components/MediaPlayer.tsx b/src/renderer/src/components/MediaPlayer.tsx index 0ede3689..c4d2836a 100644 --- a/src/renderer/src/components/MediaPlayer.tsx +++ b/src/renderer/src/components/MediaPlayer.tsx @@ -25,6 +25,7 @@ import SkipPrevious from '@mui/icons-material/SkipPrevious'; import Pause from '@mui/icons-material/Pause'; import PlayArrow from '@mui/icons-material/PlayArrow'; import HiddenPlayer from './HiddenPlayer'; +import { WSAudioPlayerControls } from './WSAudioPlayer'; import { RecordKeyMap } from '@orbit/records'; const StyledTip = styled(LightTooltip)(() => ({ @@ -74,6 +75,7 @@ export function MediaPlayer(props: IProps) { const { fetchMediaUrl, mediaState } = useFetchMediaUrl(reporter); const [blobState, fetchBlob] = useFetchMediaBlob(); const audioRef = useRef(null); + const wsControlsRef = useRef(null); const playSuccess = useRef(false); const [playing, setPlayingx] = useState(false); const playingRef = useRef(false); @@ -318,6 +320,13 @@ export function MediaPlayer(props: IProps) { const handlePlayPause = () => { if (onTogglePlay) onTogglePlay(); + // iOS Safari requires AudioContext resume to happen synchronously inside + // the user gesture. Driving playback through React state introduces async + // hops (incl. a 100ms setTimeout in usePlayerLogic) that lose that context, + // so audio toggles silently. Calling wavesurfer directly here keeps us in + // the gesture; handlePlayStatus is idempotent so the state chain catching + // up later is a no-op. + wsControlsRef.current?.togglePlay(); if (playingRef.current) stopPlay(); else startPlayWaveSurfer(); }; @@ -428,6 +437,7 @@ export function MediaPlayer(props: IProps) { audioBlob={blobState.blob} playing={playing} setPlaying={setPlaying} + controlsRef={wsControlsRef} /> )} diff --git a/src/renderer/src/components/WSAudioPlayer.tsx b/src/renderer/src/components/WSAudioPlayer.tsx index f8466513..4632b719 100644 --- a/src/renderer/src/components/WSAudioPlayer.tsx +++ b/src/renderer/src/components/WSAudioPlayer.tsx @@ -968,6 +968,11 @@ function WSAudioPlayer(props: IProps) { const handlePlayStatus = useCallback( (play: boolean) => { if (durationRef.current === 0 || recordingRef.current) return false; + const wouldReplayRegion = + play && (regionOnly || forceRegionOnly) && !!currentSegmentRef.current; + if (play === playingRef.current && !wouldReplayRegion) { + return playingRef.current; + } let nowplaying = play; if ( From 4f4ba8d8fee0e3f52e02245b003d55036d336e8d Mon Sep 17 00:00:00 2001 From: gtryus Date: Thu, 28 May 2026 10:49:52 -0500 Subject: [PATCH 3/3] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/renderer/src/components/MediaPlayer.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/MediaPlayer.tsx b/src/renderer/src/components/MediaPlayer.tsx index c4d2836a..032db8f9 100644 --- a/src/renderer/src/components/MediaPlayer.tsx +++ b/src/renderer/src/components/MediaPlayer.tsx @@ -326,8 +326,9 @@ export function MediaPlayer(props: IProps) { // so audio toggles silently. Calling wavesurfer directly here keeps us in // the gesture; handlePlayStatus is idempotent so the state chain catching // up later is a no-op. + const wasPlaying = playingRef.current; wsControlsRef.current?.togglePlay(); - if (playingRef.current) stopPlay(); + if (wasPlaying) stopPlay(); else startPlayWaveSurfer(); };