Render kind 22 shorts as vines in feeds with volume control
Kind 22 (Short-form Portrait Video) was rendering through VideoContent with a shrunken max-width and a redundant "Short" badge, duplicating the vine experience poorly. Now kind 22 shares the VineMedia component with kind 34236, rendering full-width with play/pause and mute toggle. - Unified isVine to match both kind 22 and 34236 - Removed isShortVideo flag and the small/badged VideoContent path - Added mute/unmute button to VineMedia with shared module-level state - Mute preference persists across shorts as you scroll through a feed - Removed dead parseImeta function (replaced by parseVideoImeta)
This commit is contained in:
+44
-25
@@ -22,6 +22,8 @@ import {
|
||||
Sparkles,
|
||||
UserCheck,
|
||||
Users,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { nip19 } from "nostr-tools";
|
||||
@@ -256,22 +258,6 @@ function getTag(tags: string[][], name: string): string | undefined {
|
||||
}
|
||||
|
||||
/** Parse single imeta tag into structured object (legacy, for kind 34236 vines). */
|
||||
function parseImeta(tags: string[][]): { url?: string; thumbnail?: string } {
|
||||
const imetaTag = tags.find(([name]) => name === "imeta");
|
||||
if (!imetaTag) return {};
|
||||
const result: Record<string, string> = {};
|
||||
for (let i = 1; i < imetaTag.length; i++) {
|
||||
const part = imetaTag[i];
|
||||
const spaceIdx = part.indexOf(" ");
|
||||
if (spaceIdx === -1) continue;
|
||||
const key = part.slice(0, spaceIdx);
|
||||
const value = part.slice(spaceIdx + 1);
|
||||
if (key === "url") result.url = value;
|
||||
else if (key === "image") result.thumbnail = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Encodes the NIP-19 identifier for navigating to an event. */
|
||||
function encodeEventId(event: NostrEvent): string {
|
||||
// Addressable events (30000-39999) use naddr with their d-tag
|
||||
@@ -391,7 +377,7 @@ export const NoteCard = memo(function NoteCard({
|
||||
auxOpenPost(e);
|
||||
};
|
||||
|
||||
const isVine = event.kind === 34236;
|
||||
const isVine = event.kind === 34236 || event.kind === 22;
|
||||
const isPoll = event.kind === 1068;
|
||||
const isGeocache = event.kind === 37516;
|
||||
const isFoundLog = event.kind === 7516;
|
||||
@@ -415,9 +401,7 @@ export const NoteCard = memo(function NoteCard({
|
||||
const isPollVote = event.kind === 1018;
|
||||
const isRepost = event.kind === 6 || event.kind === 16;
|
||||
const isPhoto = event.kind === 20;
|
||||
const isNormalVideo = event.kind === 21;
|
||||
const isShortVideo = event.kind === 22;
|
||||
const isVideo = isNormalVideo || isShortVideo;
|
||||
const isVideo = event.kind === 21;
|
||||
const isMusicTrack = event.kind === 36787;
|
||||
const isMusicPlaylist = event.kind === 34139;
|
||||
const isPodcastEpisode = event.kind === 30054;
|
||||
@@ -521,9 +505,9 @@ export const NoteCard = memo(function NoteCard({
|
||||
}, [event, isReply]);
|
||||
const parentEventId = parentHints?.id;
|
||||
|
||||
// Kind 34236 specific
|
||||
// Kind 22 / 34236 specific
|
||||
const imeta = useMemo(
|
||||
() => (isVine ? parseImeta(event.tags) : undefined),
|
||||
() => (isVine ? parseVideoImeta(event.tags) : undefined),
|
||||
[event.tags, isVine],
|
||||
);
|
||||
const vineTitle = isVine ? getTag(event.tags, "title") : undefined;
|
||||
@@ -1389,7 +1373,10 @@ function VideoContent({ event }: { event: NostrEvent }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Media content for kind 34236 vine events — rendered at full card width. */
|
||||
/** Module-level mute state shared across all VineMedia instances in the feed. */
|
||||
let vineGlobalMuted = true;
|
||||
|
||||
/** Media content for kind 22 / 34236 short-form video events — rendered at full card width. */
|
||||
function VineMedia({
|
||||
imeta,
|
||||
hashtags,
|
||||
@@ -1400,6 +1387,7 @@ function VineMedia({
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isMuted, setIsMuted] = useState(vineGlobalMuted);
|
||||
|
||||
// Pause video when scrolled out of view
|
||||
useEffect(() => {
|
||||
@@ -1425,14 +1413,30 @@ function VineMedia({
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
if (video.paused) {
|
||||
video.play();
|
||||
setIsPlaying(true);
|
||||
// Start muted (required by browsers), then sync to shared state once playing
|
||||
video.muted = true;
|
||||
video.play().then(() => {
|
||||
video.muted = vineGlobalMuted;
|
||||
setIsMuted(vineGlobalMuted);
|
||||
}).catch(() => {
|
||||
// play blocked — leave paused
|
||||
});
|
||||
} else {
|
||||
video.pause();
|
||||
setIsPlaying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMuteToggle = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
const next = !video.muted;
|
||||
video.muted = next;
|
||||
vineGlobalMuted = next;
|
||||
setIsMuted(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{imeta?.url && (
|
||||
@@ -1448,6 +1452,7 @@ function VineMedia({
|
||||
className="w-full max-h-[70vh] object-cover"
|
||||
loop
|
||||
playsInline
|
||||
muted
|
||||
preload="none"
|
||||
onPlay={() => setIsPlaying(true)}
|
||||
onPause={() => setIsPlaying(false)}
|
||||
@@ -1459,6 +1464,20 @@ function VineMedia({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Mute/unmute toggle */}
|
||||
{isPlaying && (
|
||||
<button
|
||||
className="absolute bottom-2.5 right-2.5 z-10 size-8 rounded-full bg-black/50 backdrop-blur-sm flex items-center justify-center text-white hover:bg-black/70 transition-colors"
|
||||
onClick={handleMuteToggle}
|
||||
aria-label={isMuted ? "Unmute" : "Mute"}
|
||||
>
|
||||
{isMuted ? (
|
||||
<VolumeX className="size-4" />
|
||||
) : (
|
||||
<Volume2 className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user