Docker Build and Publish / build-and-push (push) Failing after 10s
Tapping a post on a phone now opens a real feed page — header, media, actions, caption, next post peeking in below — scrolled with the browser's own vertical scrolling rather than swipe gestures. Desktop keeps the modal, where a centred sheet with side arrows suits a pointer. Only a window of posts is mounted: a profile here holds up to 1129 posts and mounting them all would mean as many full-size images. The window grows in both directions as you scroll. Growing upwards shifts everything below it, so the scroll offset is corrected in the same frame, before paint — measured against the real archive, an anchored post moves exactly one screen per scroll with no jump. Only the post crossing the viewport centre plays its video; the rest stay paused, so a feed of reels doesn't play ten at once. The URL tracks that same post, so scrolling updates /<archive>/p/<shortcode>/ the way Instagram does, and the back button returns to the grid with its scroll position intact. Feed video sizes to the container width rather than its intrinsic size: a <video> reports 300x150 until metadata loads, which made it render narrow and then jump to full width. It also gets a taller height ceiling than the modal so ordinary portrait media fills the width instead of sitting in side bars. The carousel is extracted into a shared MediaCarousel used by both surfaces, so horizontal paging behaves identically; touch-action keeps vertical scrolling passing through to the feed. PostModal loses its now-dead mobile swipe branch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011uBWhwV3wFQ5MBCcMHHem7
85 lines
3.8 KiB
TypeScript
85 lines
3.8 KiB
TypeScript
import React, { useState, useEffect, useRef } from 'react';
|
|
import { Play, Volume2, VolumeX } from 'lucide-react';
|
|
import { MediaFile } from '../types';
|
|
import { cn } from '../lib/utils';
|
|
|
|
interface MediaRendererProps {
|
|
file: MediaFile;
|
|
className?: string;
|
|
isFullView?: boolean;
|
|
/** Hold playback: the feed keeps every off-screen video paused. */
|
|
paused?: boolean;
|
|
/** Cap media height to this instead of the default full-view ceiling. */
|
|
heightCap?: string;
|
|
/**
|
|
* Size video to the container width rather than its own intrinsic size.
|
|
*
|
|
* A <video> reports 300x150 until metadata loads, so `w-auto` makes it render
|
|
* narrow and then jump to full width. The feed needs a stable width more than
|
|
* it needs a snug fit.
|
|
*/
|
|
fillWidth?: boolean;
|
|
}
|
|
|
|
export const MediaRenderer = ({ file, className, isFullView, paused, heightCap, fillWidth }: MediaRendererProps) => {
|
|
// Try to play with sound: opening the modal is a user gesture, so browsers
|
|
// generally allow it. If this particular browser still refuses, the effect
|
|
// 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;
|
|
|
|
if (paused) {
|
|
video.pause();
|
|
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, paused]);
|
|
/**
|
|
* 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 = `${heightCap ?? 'max-h-[70vh] md:max-h-[calc(100vh-5rem)]'} object-contain`;
|
|
const videoFullView = fillWidth
|
|
? `block w-full h-auto ${fullViewCap}`
|
|
: `block w-auto max-w-full ${fullViewCap}`;
|
|
const videoSizing = isFullView ? videoFullView : "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)' };
|
|
|
|
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.type === 'video') {
|
|
return (
|
|
<div className="relative w-full h-full flex items-center justify-center group/video text-black">
|
|
<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">
|
|
{isMuted ? <VolumeX size={20} /> : <Volume2 size={20} />}
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
return <img src={file.url} alt="" className={cn("transition-all duration-300", sizingClass, className)} style={mediaStyle} referrerPolicy="no-referrer" decoding="async" loading="eager" />;
|
|
};
|