Browse Source

bug-fix

imwald
Silberengel 4 weeks ago
parent
commit
dba9d22a0f
  1. 7
      src/components/Content/index.tsx
  2. 12
      src/components/MediaGridItem/index.tsx
  3. 9
      src/components/MediaPlayer/index.tsx
  4. 13
      src/components/Note/VideoNote.tsx
  5. 2
      src/components/Note/index.tsx
  6. 27
      src/lib/long-video-load-policy.test.ts
  7. 23
      src/lib/long-video-load-policy.ts

7
src/components/Content/index.tsx

@ -39,6 +39,7 @@ import { toNote } from '@/lib/link'
import { YOUTUBE_URL_REGEX } from '@/constants' import { YOUTUBE_URL_REGEX } from '@/constants'
import { isSpotifyOpenUrl } from '@/lib/spotify-url' import { isSpotifyOpenUrl } from '@/lib/spotify-url'
import { canonicalZapStreamWatchUrl, isZapStreamWatchUrl } from '@/lib/zap-stream-url' import { canonicalZapStreamWatchUrl, isZapStreamWatchUrl } from '@/lib/zap-stream-url'
import { shouldDeferLongVideoAutoload } from '@/lib/long-video-load-policy'
// Helper function to check if a URL is a YouTube URL // Helper function to check if a URL is a YouTube URL
function isYouTubeUrl(url: string): boolean { function isYouTubeUrl(url: string): boolean {
@ -92,6 +93,9 @@ export default function Content({
() => (iArticleUrl ? cleanUrl(iArticleUrl) || iArticleUrl : ''), () => (iArticleUrl ? cleanUrl(iArticleUrl) || iArticleUrl : ''),
[iArticleUrl] [iArticleUrl]
) )
const deferLongVideoLoad = shouldDeferLongVideoAutoload(event, {
forceLoadMedia: mustLoadMedia
})
// Use unified media extraction service // Use unified media extraction service
const extractedMedia = useMediaExtraction(event, _content) const extractedMedia = useMediaExtraction(event, _content)
@ -464,6 +468,7 @@ export default function Content({
src={video.url} src={video.url}
className="w-full max-w-full" className="w-full max-w-full"
mustLoad={mustLoadMedia} mustLoad={mustLoadMedia}
deferLoadUntilClick={deferLongVideoLoad}
poster={video.image || video.thumb} poster={video.image || video.thumb}
blurHash={video.blurHash} blurHash={video.blurHash}
/> />
@ -538,6 +543,7 @@ export default function Content({
key={index} key={index}
src={cleanedUrl} src={cleanedUrl}
mustLoad={mustLoadMedia} mustLoad={mustLoadMedia}
deferLoadUntilClick={deferLongVideoLoad}
poster={tagMediaInfo?.image || tagMediaInfo?.thumb} poster={tagMediaInfo?.image || tagMediaInfo?.thumb}
blurHash={tagMediaInfo?.blurHash} blurHash={tagMediaInfo?.blurHash}
/> />
@ -566,6 +572,7 @@ export default function Content({
key={`url-media-${index}`} key={`url-media-${index}`}
src={cleanedUrl} src={cleanedUrl}
mustLoad={mustLoadMedia} mustLoad={mustLoadMedia}
deferLoadUntilClick={deferLongVideoLoad}
poster={poster} poster={poster}
blurHash={mediaInfo?.blurHash} blurHash={mediaInfo?.blurHash}
/> />

12
src/components/MediaGridItem/index.tsx

@ -1,4 +1,5 @@
import { ExtendedKind, isNip71StyleVideoKind } from '@/constants' import { ExtendedKind, isNip71StyleVideoKind } from '@/constants'
import { isLongFormNip71VideoEventKind } from '@/lib/long-video-load-policy'
import { getCachedThreadContextEvents } from '@/lib/navigation-related-events' import { getCachedThreadContextEvents } from '@/lib/navigation-related-events'
import { toNote } from '@/lib/link' import { toNote } from '@/lib/link'
import client from '@/services/client.service' import client from '@/services/client.service'
@ -16,15 +17,18 @@ export default function MediaGridItem({ event }: { event: Event }) {
/** Kind 20 is always treated as image unless imeta explicitly says video (rare mis-tag). */ /** Kind 20 is always treated as image unless imeta explicitly says video (rare mis-tag). */
const isPictureKind = event.kind === ExtendedKind.PICTURE const isPictureKind = event.kind === ExtendedKind.PICTURE
const isLongFormVideo = isLongFormNip71VideoEventKind(event.kind)
const isVideo = const isVideo =
(!isPictureKind && first?.m?.startsWith('video/')) || (!isPictureKind && first?.m?.startsWith('video/')) ||
(!isPictureKind && isNip71StyleVideoKind(event.kind)) (!isPictureKind && isNip71StyleVideoKind(event.kind))
const isAudio = first?.m?.startsWith('audio/') || event.kind === ExtendedKind.VOICE const isAudio = first?.m?.startsWith('audio/') || event.kind === ExtendedKind.VOICE
const hasMultiple = media.all.length > 1 const hasMultiple = media.all.length > 1
// For videos prefer the poster image; fall back to video URL (browser extracts frame) // For videos prefer the poster image; long-form feed tiles never prefetch the .mp4 (open note to play).
const displayUrl = isVideo const displayUrl = isVideo
? (first?.image ?? first?.url) ? isLongFormVideo
? (first?.image ?? first?.thumb)
: (first?.image ?? first?.url)
: (first?.thumb ?? first?.url) : (first?.thumb ?? first?.url)
const handleClick = () => { const handleClick = () => {
@ -38,9 +42,9 @@ export default function MediaGridItem({ event }: { event: Event }) {
onClick={handleClick} onClick={handleClick}
> >
{displayUrl ? ( {displayUrl ? (
isVideo && !first?.image ? ( isVideo && !isLongFormVideo && !(first?.image ?? first?.thumb) && first?.url ? (
<video <video
src={displayUrl} src={first.url}
className="h-full w-full object-cover" className="h-full w-full object-cover"
muted muted
preload="metadata" preload="metadata"

9
src/components/MediaPlayer/index.tsx

@ -47,6 +47,7 @@ export default function MediaPlayer({
src, src,
className, className,
mustLoad = false, mustLoad = false,
deferLoadUntilClick = false,
poster, poster,
blurHash, blurHash,
fallbackPageUrl fallbackPageUrl
@ -54,6 +55,11 @@ export default function MediaPlayer({
src: string src: string
className?: string className?: string
mustLoad?: boolean mustLoad?: boolean
/**
* When true, never autoload/embed (even if global auto-load media is on) until the user taps the
* placeholder. Used for NIP-71 long-form video events in feeds.
*/
deferLoadUntilClick?: boolean
poster?: string poster?: string
/** NIP-94 / imeta blurhash for lazy placeholder when poster is missing */ /** NIP-94 / imeta blurhash for lazy placeholder when poster is missing */
blurHash?: string blurHash?: string
@ -89,7 +95,8 @@ export default function MediaPlayer({
/** Probe result wins when set (e.g. audio-only mp4); URL hint avoids a blank frame before useEffect runs. */ /** Probe result wins when set (e.g. audio-only mp4); URL hint avoids a blank frame before useEffect runs. */
const effectiveMediaType = mediaType ?? urlEmbedSurfaceHint const effectiveMediaType = mediaType ?? urlEmbedSurfaceHint
const showEmbed = mustLoad || autoLoadMedia || userClickedLoad const showEmbed =
mustLoad || (!deferLoadUntilClick && autoLoadMedia) || userClickedLoad
useLayoutEffect(() => { useLayoutEffect(() => {
if (!autoLoadMedia) setUserClickedLoad(false) if (!autoLoadMedia) setUserClickedLoad(false)

13
src/components/Note/VideoNote.tsx

@ -1,12 +1,21 @@
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import Content from '../Content' import Content from '../Content'
export default function VideoNote({ event, className }: { event: Event; className?: string }) { export default function VideoNote({
event,
className,
loadMedia = false
}: {
event: Event
className?: string
/** When true (note detail / expanded view), long-form video may autoload. */
loadMedia?: boolean
}) {
// Content component already handles all media rendering (from content and tags) // Content component already handles all media rendering (from content and tags)
// with proper deduplication, so we don't need to add anything extra // with proper deduplication, so we don't need to add anything extra
return ( return (
<div className={className}> <div className={className}>
<Content event={event} /> <Content event={event} mustLoadMedia={loadMedia} />
</div> </div>
) )
} }

2
src/components/Note/index.tsx

@ -558,7 +558,7 @@ export default function Note({
} else if (event.kind === ExtendedKind.PICTURE) { } else if (event.kind === ExtendedKind.PICTURE) {
content = <PictureNote className="mt-2" event={event} /> content = <PictureNote className="mt-2" event={event} />
} else if (isNip71StyleVideoKind(event.kind)) { } else if (isNip71StyleVideoKind(event.kind)) {
content = <VideoNote className="mt-2" event={event} /> content = <VideoNote className="mt-2" event={event} loadMedia={showFull} />
} else if (event.kind === ExtendedKind.RELAY_REVIEW) { } else if (event.kind === ExtendedKind.RELAY_REVIEW) {
content = <RelayReview className="mt-2" event={displayEvent} /> content = <RelayReview className="mt-2" event={displayEvent} />
} else if (isCalendarEventKind(event.kind)) { } else if (isCalendarEventKind(event.kind)) {

27
src/lib/long-video-load-policy.test.ts

@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { ExtendedKind } from '@/constants'
import type { Event } from 'nostr-tools'
import {
isLongFormNip71VideoEventKind,
shouldDeferLongVideoAutoload
} from './long-video-load-policy'
function fakeEvent(kind: number): Event {
return { kind, id: 'ab'.repeat(32), pubkey: 'cd'.repeat(32), created_at: 0, tags: [], content: '' }
}
describe('long-video-load-policy', () => {
it('treats kind 21 and 34235 as long-form', () => {
expect(isLongFormNip71VideoEventKind(ExtendedKind.VIDEO)).toBe(true)
expect(isLongFormNip71VideoEventKind(ExtendedKind.VIDEO_ADDRESSABLE)).toBe(true)
expect(isLongFormNip71VideoEventKind(ExtendedKind.SHORT_VIDEO)).toBe(false)
})
it('defers autoload for long-form events unless forced', () => {
expect(shouldDeferLongVideoAutoload(fakeEvent(ExtendedKind.VIDEO))).toBe(true)
expect(
shouldDeferLongVideoAutoload(fakeEvent(ExtendedKind.VIDEO), { forceLoadMedia: true })
).toBe(false)
expect(shouldDeferLongVideoAutoload(fakeEvent(ExtendedKind.SHORT_VIDEO))).toBe(false)
})
})

23
src/lib/long-video-load-policy.ts

@ -0,0 +1,23 @@
import { ExtendedKind } from '@/constants'
import type { Event } from 'nostr-tools'
/** Long-form video threshold (matches publish-time kind 21 vs 22 split). */
export const LONG_VIDEO_MIN_DURATION_SEC = 600
/** NIP-71 kinds published for videos longer than {@link LONG_VIDEO_MIN_DURATION_SEC}. */
export function isLongFormNip71VideoEventKind(kind: number): boolean {
return kind === ExtendedKind.VIDEO || kind === ExtendedKind.VIDEO_ADDRESSABLE
}
/**
* Feed / timeline: do not fetch long-form video bytes until the user opts in.
* Detail views pass `forceLoadMedia` / `mustLoadMedia` to bypass this.
*/
export function shouldDeferLongVideoAutoload(
event: Event | null | undefined,
options?: { forceLoadMedia?: boolean }
): boolean {
if (options?.forceLoadMedia) return false
if (!event) return false
return isLongFormNip71VideoEventKind(event.kind)
}
Loading…
Cancel
Save