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:
Chad Curtis
2026-04-24 10:02:49 -05:00
parent 5a80df05f5
commit 23e845ebc1
+44 -25
View File
@@ -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>
)}