{"record":{"id":"73bafd33d5136a07","repo":"RocketChat/Rocket.Chat","slug":"failed-to-resume-playback-after-url-recovery","errorCode":null,"errorMessage":"Failed to resume playback after URL recovery:","messagePattern":"Failed to resume playback after URL recovery:","errorType":"console","errorClass":null,"httpStatus":null,"severity":"warning","filePath":"apps/meteor/client/components/message/content/attachments/file/hooks/useReloadOnError.ts","lineNumber":112,"sourceCode":"\t\t}\n\n\t\tconst wasPlaying = !node.paused;\n\t\tconst { currentTime } = node;\n\n\t\ttry {\n\t\t\tconst { redirectUrl: newUrl, expires: newExpiresAt } = await getRedirectURLInfo(url);\n\t\t\tsetExpiresAt(newExpiresAt);\n\t\t\tnode.src = newUrl || url;\n\n\t\t\tconst onCanPlay = async () => {\n\t\t\t\tnode.removeEventListener('canplay', onCanPlay);\n\n\t\t\t\tnode.currentTime = currentTime;\n\t\t\t\tif (wasPlaying) {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tawait node.play();\n\t\t\t\t\t} catch (playError) {\n\t\t\t\t\t\tconsole.warn('Failed to resume playback after URL recovery:', playError);\n\t\t\t\t\t} finally {\n\t\t\t\t\t\tisRecovering.current = false;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t};\n\n\t\t\tconst onMetaDataLoaded = () => {\n\t\t\t\tnode.removeEventListener('loadedmetadata', onMetaDataLoaded);\n\t\t\t\tisRecovering.current = false;\n\t\t\t\tcleanup?.();\n\t\t\t};\n\n\t\t\tnode.addEventListener('canplay', onCanPlay, { once: true });\n\t\t\tnode.addEventListener('loadedmetadata', onMetaDataLoaded, { once: true });\n\t\t\tnode.load();\n\t\t} catch (err) {\n\t\t\tconsole.error('Error during URL recovery:', err);\n\t\t\tisRecovering.current = false;","sourceCodeStart":94,"sourceCodeEnd":130,"githubUrl":"https://github.com/RocketChat/Rocket.Chat/blob/b2c16d5842cbe6b69b59bdf6fc5e5f1afcd1f0b0/apps/meteor/client/components/message/content/attachments/file/hooks/useReloadOnError.ts#L94-L130","documentation":"Raised inside the useReloadOnError media hook for audio/video file attachments: when the signed media URL expired, the hook fetched a fresh redirect URL, swapped node.src, and on 'canplay' called node.play() to resume at the previous position. The play() promise rejected (browser autoplay policy or a still-invalid URL), the rejection is caught and logged, and playback stays paused even though the stream itself recovered.","triggerScenarios":"Autoplay policy blocks programmatic play() without a user gesture after the source was reloaded (NotAllowedError in Chrome/Safari/Firefox), or the recovered URL is again invalid/expired (NotSupportedError). Only fires when the media was playing when the error/stall happened (wasPlaying true).","commonSituations":"Long-running tabs where S3 presigned/upload URLs expire; backgrounded browsers refusing unmuted autoplay; users who hit play, switched tabs, and the auto-recovery ran without a gesture; corporate browsers with strict autoplay settings.","solutions":["Catch NotAllowedError specifically and retry muted - muted autoplay is generally allowed - then surface an unmute control","Otherwise render a resume/play button so a single user gesture resumes at node.currentTime","Pre-emptively refresh the URL before expiresAt instead of waiting for the error/stalled event","Verify the redirect endpoint still returns a fresh redirectUrl for the file when recovery runs"],"exampleFix":"// before\ntry {\n  await node.play();\n} catch (playError) {\n  console.warn('Failed to resume playback after URL recovery:', playError);\n}\n\n// after\ntry {\n  await node.play();\n} catch (playError) {\n  if ((playError as DOMException).name === 'NotAllowedError') {\n    node.muted = true;\n    try {\n      await node.play(); // muted autoplay is permitted\n      showUnmuteIndicator(node);\n      return;\n    } catch { /* fall through */ }\n  }\n  showResumeButton(node, currentTime); // one user gesture resumes playback\n}","handlingStrategy":"try-catch","validationCode":null,"typeGuard":"const isAutoplayBlocked = (error: unknown): error is DOMException =>\n  error instanceof DOMException && error.name === 'NotAllowedError';","tryCatchPattern":"try {\n  await node.play();\n} catch (playError) {\n  if (isAutoplayBlocked(playError)) {\n    node.muted = true; // muted autoplay is permitted\n    await node.play().catch(() => showResumeButton(node));\n  } else {\n    showResumeButton(node); // NotSupportedError etc: URL still bad, let the user retry\n  }\n}","preventionTips":["Treat every play() as fallible: always await it inside try/catch after swapping media sources","Refresh expiring media URLs proactively using the expires value instead of waiting for error/stalled","Remember the muted-autoplay escape hatch and pair it with an unmute affordance"],"tags":["media","autoplay","presigned-url","file-attachments","client","hooks"],"backgroundTag":"autoplay-blocked","analyzedSha":"b2c16d5842cbe6b69b59bdf6fc5e5f1afcd1f0b0","analyzedAt":"2026-08-18T15:26:39.429Z","contentChangedAt":"2026-08-18T15:26:39.429Z","schemaVersion":2},"datasetVersion":"2026-09-14T05:17:10.506Z"}