From d42d6a536a7a9debac0dfde86c1cc91b78362710 Mon Sep 17 00:00:00 2001 From: Matheus Fillipe Date: Sun, 30 Aug 2026 20:28:07 +0200 Subject: [PATCH 01/21] fix(media): stop off-screen media from loading itself --- src/components/message/MediaPreview.tsx | 108 +++++++++++++++++++----- src/hooks/useNearViewport.ts | 30 +++++++ src/locales/cs/messages.po | 2 + src/locales/de/messages.po | 2 + src/locales/en/messages.po | 2 + src/locales/es/messages.po | 2 + src/locales/fi/messages.po | 2 + src/locales/fr/messages.po | 2 + src/locales/it/messages.po | 2 + src/locales/ja/messages.po | 2 + src/locales/ko/messages.po | 2 + src/locales/nl/messages.po | 2 + src/locales/pl/messages.po | 2 + src/locales/pt/messages.po | 2 + src/locales/ro/messages.po | 2 + src/locales/ru/messages.po | 2 + src/locales/sv/messages.po | 2 + src/locales/tr/messages.po | 2 + src/locales/uk/messages.po | 2 + src/locales/zh-TW/messages.po | 2 + src/locales/zh/messages.po | 2 + tests/components/MediaPreview.test.tsx | 24 ++++-- 22 files changed, 170 insertions(+), 30 deletions(-) create mode 100644 src/hooks/useNearViewport.ts diff --git a/src/components/message/MediaPreview.tsx b/src/components/message/MediaPreview.tsx index c4a8d52a8..e73c772b4 100644 --- a/src/components/message/MediaPreview.tsx +++ b/src/components/message/MediaPreview.tsx @@ -2,7 +2,7 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import exifr from "exifr"; import type * as React from "react"; -import { lazy, Suspense, useEffect, useRef, useState } from "react"; +import { lazy, memo, Suspense, useEffect, useRef, useState } from "react"; import { FaFileAlt, FaPause, @@ -11,6 +11,7 @@ import { FaVolumeMute, FaVolumeUp, } from "react-icons/fa"; +import { useNearViewport } from "../../hooks/useNearViewport"; import { getAudio } from "../../lib/audioManager"; import ircClient from "../../lib/ircClient"; import { probeMediaUrl } from "../../lib/mediaProbe"; @@ -293,6 +294,8 @@ const ImagePreview: React.FC<{ )} {isFilehostImage openMedia(displayUrl, msgid, serverId, channelId)} @@ -371,6 +374,9 @@ const VideoPlayer: React.FC<{ const [loadError, setLoadError] = useState(false); const [thumbnail, setThumbnail] = useState(null); const thumbnailCapturedRef = useRef(false); + // Grabbing a poster frame costs a second download of the video. Off-screen + // messages must not pay it, or a media-heavy channel opens dozens at once. + const nearViewport = useNearViewport(containerRef); const { isActive, isPlaying, play, pause } = useMediaController({ url, @@ -449,18 +455,21 @@ const VideoPlayer: React.FC<{ // is never affected by crossOrigin restrictions. Falls back silently if the // server doesn't support CORS. thumbnailCapturedRef.current = false; - let thumbVideo: HTMLVideoElement | null = document.createElement("video"); - thumbVideo.crossOrigin = "anonymous"; - thumbVideo.preload = "auto"; - thumbVideo.muted = true; - thumbVideo.src = url; + let thumbVideo: HTMLVideoElement | null = null; const releaseThumbVideo = () => { if (thumbVideo) { thumbVideo.src = ""; thumbVideo = null; } }; - thumbVideo.addEventListener( + if (nearViewport) { + thumbVideo = document.createElement("video"); + thumbVideo.crossOrigin = "anonymous"; + thumbVideo.preload = "auto"; + thumbVideo.muted = true; + thumbVideo.src = url; + } + thumbVideo?.addEventListener( "loadeddata", () => { if (!thumbVideo || thumbVideo.videoWidth === 0) { @@ -488,7 +497,7 @@ const VideoPlayer: React.FC<{ }, { once: true }, ); - thumbVideo.addEventListener("error", releaseThumbVideo, { once: true }); + thumbVideo?.addEventListener("error", releaseThumbVideo, { once: true }); video.addEventListener("timeupdate", onTimeUpdate); video.addEventListener("loadedmetadata", onLoadedMetadata); @@ -504,7 +513,7 @@ const VideoPlayer: React.FC<{ video.removeEventListener("error", onError); setThumbnail(null); }; - }, [url]); + }, [url, nearViewport]); function handlePlayPause() { if (isPlaying) { @@ -798,9 +807,12 @@ const SoundCloudEmbed: React.FC<{ }> = ({ url, msgid, serverId, channelId }) => { const iframeRef = useRef(null); const [embedError, setEmbedError] = useState(false); + // Same reasoning as the other embeds: no widget until playback is asked for. + const [activated, setActivated] = useState(false); const height = /\/sets\//.test(url) ? 350 : 166; - const src = `https://w.soundcloud.com/player/?url=${encodeURIComponent(url)}&color=%23ff5500&auto_play=false&hide_related=false&show_comments=false&show_user=true&show_reposts=false&show_teaser=false`; + // The widget only mounts after the user asks to play, so it starts itself. + const src = `https://w.soundcloud.com/player/?url=${encodeURIComponent(url)}&color=%23ff5500&auto_play=true&hide_related=false&show_comments=false&show_user=true&show_reposts=false&show_teaser=false`; const pauseWidget = () => { iframeRef.current?.contentWindow?.postMessage( @@ -859,6 +871,22 @@ const SoundCloudEmbed: React.FC<{ if (embedError) return null; + if (!activated) { + return ( +
+ +
+ ); + } + return (