feat: fit full-view media to the viewport and play with sound
Media in the post modal used w-full/h-auto, so a portrait video or image grew taller than the screen (a 720x1280 reel rendered 768x1365 in a 786px viewport) and forced the modal to scroll. Full view now caps height to the viewport minus the modal's own padding. Video sizes to its own aspect within the cap so a portrait clip isn't letterboxed edge to edge; images keep filling the modal width and only gain a height ceiling. Opening the modal or a story reel is a user gesture, so playback now starts unmuted and only falls back to muted if the browser actually refuses the play() promise — previously it always started muted, and the earlier muted-by-default fix meant a blocked video could stall the story progress bar. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011uBWhwV3wFQ5MBCcMHHem7
This commit is contained in:
co-authored by
Claude Opus 5
parent
c0b6b6cf3e
commit
dec02d5bea
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "instaarchive-viewer",
|
"name": "instaarchive-viewer",
|
||||||
"version": "1.3.1",
|
"version": "1.3.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "instaarchive-viewer",
|
"name": "instaarchive-viewer",
|
||||||
"version": "1.3.1",
|
"version": "1.3.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.14",
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "instaarchive-viewer",
|
"name": "instaarchive-viewer",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.3.1",
|
"version": "1.3.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --port=3000 --host=0.0.0.0",
|
"dev": "vite --port=3000 --host=0.0.0.0",
|
||||||
|
|||||||
@@ -1,12 +1,45 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { Play, Volume2, VolumeX } from 'lucide-react';
|
import { Play, Volume2, VolumeX } from 'lucide-react';
|
||||||
import { MediaFile } from '../types';
|
import { MediaFile } from '../types';
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
|
|
||||||
export const MediaRenderer = ({ file, className, isFullView }: { file: MediaFile; className?: string; isFullView?: boolean }) => {
|
export const MediaRenderer = ({ file, className, isFullView }: { file: MediaFile; className?: string; isFullView?: boolean }) => {
|
||||||
// Start muted so autoplay is not blocked by Safari/Firefox policy.
|
// Try to play with sound: opening the modal is a user gesture, so browsers
|
||||||
const [isMuted, setIsMuted] = useState(true);
|
// generally allow it. If this particular browser still refuses, the effect
|
||||||
const sizingClass = isFullView ? "w-full h-auto block" : "w-full h-full object-cover";
|
// below falls back to muted playback rather than leaving a stalled video.
|
||||||
|
const [isMuted, setIsMuted] = useState(false);
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video || file.type !== 'video') return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
video.muted = false;
|
||||||
|
video.play().catch(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setIsMuted(true);
|
||||||
|
video.muted = true;
|
||||||
|
video.play().catch(() => { /* user can start it from the controls */ });
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [file.url, file.type]);
|
||||||
|
/**
|
||||||
|
* In full view the media must never outgrow the viewport.
|
||||||
|
*
|
||||||
|
* Video is sized to its own aspect within the cap (`w-auto`) so a portrait
|
||||||
|
* clip doesn't sit in a wide letterbox, while images keep filling the modal
|
||||||
|
* width and only gain a height ceiling — `object-contain` stops the cap from
|
||||||
|
* distorting anything that hits it.
|
||||||
|
*
|
||||||
|
* The desktop cap subtracts the modal's own padding (md:p-10 = 2.5rem each
|
||||||
|
* side); mobile leaves room for the caption panel stacked underneath.
|
||||||
|
*/
|
||||||
|
const fullViewCap = "max-h-[70vh] md:max-h-[calc(100vh-5rem)] object-contain";
|
||||||
|
const videoSizing = isFullView ? `block w-auto max-w-full ${fullViewCap}` : "w-full h-full object-cover";
|
||||||
|
const imageSizing = isFullView ? `block w-full h-auto ${fullViewCap}` : "w-full h-full object-cover";
|
||||||
|
const sizingClass = file.type === 'video' ? videoSizing : imageSizing;
|
||||||
const mediaStyle = { transform: 'translateZ(0)' };
|
const mediaStyle = { transform: 'translateZ(0)' };
|
||||||
|
|
||||||
if (!file.url) return <div className={cn("bg-gray-100 flex items-center justify-center text-black", sizingClass)}><Play size={24} className="text-gray-300" /></div>;
|
if (!file.url) return <div className={cn("bg-gray-100 flex items-center justify-center text-black", sizingClass)}><Play size={24} className="text-gray-300" /></div>;
|
||||||
@@ -14,7 +47,7 @@ export const MediaRenderer = ({ file, className, isFullView }: { file: MediaFile
|
|||||||
if (file.type === 'video') {
|
if (file.type === 'video') {
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full h-full flex items-center justify-center group/video text-black">
|
<div className="relative w-full h-full flex items-center justify-center group/video text-black">
|
||||||
<video src={file.url} className={cn("transition-all duration-300", sizingClass, className)} style={mediaStyle} playsInline autoPlay muted={isMuted} loop controls />
|
<video ref={videoRef} src={file.url} className={cn("transition-all duration-300", sizingClass, className)} style={mediaStyle} playsInline autoPlay muted={isMuted} loop controls />
|
||||||
<button onClick={(e) => { e.stopPropagation(); setIsMuted(!isMuted); }} className="absolute bottom-16 right-4 z-30 bg-black/40 hover:bg-black/60 text-white p-2 rounded-full backdrop-blur-md transition-all md:opacity-0 md:group-hover/video:opacity-100">
|
<button onClick={(e) => { e.stopPropagation(); setIsMuted(!isMuted); }} className="absolute bottom-16 right-4 z-30 bg-black/40 hover:bg-black/60 text-white p-2 rounded-full backdrop-blur-md transition-all md:opacity-0 md:group-hover/video:opacity-100">
|
||||||
{isMuted ? <VolumeX size={20} /> : <Volume2 size={20} />}
|
{isMuted ? <VolumeX size={20} /> : <Volume2 size={20} />}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -26,9 +26,10 @@ export const StoryViewer: React.FC<StoryViewerProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [currentStoryIndex, setCurrentStoryIndex] = useState(0);
|
const [currentStoryIndex, setCurrentStoryIndex] = useState(0);
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
// Start muted: Safari and Firefox refuse to autoplay audible media, which
|
// Opening the reel is a user gesture, so try for sound; the effect below
|
||||||
// would stall the reel on its first video.
|
// falls back to muted if the browser refuses, which would otherwise stall
|
||||||
const [isMuted, setIsMuted] = useState(true);
|
// the progress bar on the first video.
|
||||||
|
const [isMuted, setIsMuted] = useState(false);
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const story = stories[currentStoryIndex];
|
const story = stories[currentStoryIndex];
|
||||||
const primary = story?.media?.[0];
|
const primary = story?.media?.[0];
|
||||||
@@ -61,6 +62,22 @@ export const StoryViewer: React.FC<StoryViewerProps> = ({
|
|||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [currentStoryIndex, primary]);
|
}, [currentStoryIndex, primary]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video || primary?.type !== 'video') return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
video.muted = false;
|
||||||
|
video.play().catch(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setIsMuted(true);
|
||||||
|
video.muted = true;
|
||||||
|
video.play().catch(() => { /* leave it to the controls */ });
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [primary]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (progress >= 100) {
|
if (progress >= 100) {
|
||||||
if (currentStoryIndex < stories.length - 1) {
|
if (currentStoryIndex < stories.length - 1) {
|
||||||
|
|||||||
Reference in New Issue
Block a user