Skip to content

Commit bab1e27

Browse files
authored
fix(tv): resolve droposal video via resilient IPFS gateway chain (#278)
Droposal videos stopped playing on the homepage TV. Zora serves the media as raw ipfs:// URIs, and the client pointed them at ipfs.io / dweb.link — both now return 403/301 for Zora-hosted CIDs. Thumbnails still worked because Zora's feed pre-hosts images on its own CDN. Centralize IPFS resolution in @/lib/ipfs behind an ordered gateway chain — Zora content gateway (where droposal media is pinned) → Pinata → SkateHive — and add fallback-on-error in both the <video> feed player and the 3D TV WebGL texture, so a dead/blocked gateway advances to the next instead of failing. Route every ipfs:// conversion (TV, auctions, feed, swap token logos) through the helper. Both leading gateways send CORS + Accept-Ranges, required by the WebGL texture and range seeking.
1 parent 3a4bca1 commit bab1e27

13 files changed

Lines changed: 126 additions & 88 deletions

File tree

src/app/[locale]/swap/SwapWidget.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"use client";
22

33
import * as React from "react";
4+
import { ipfsToHttp } from "@/lib/ipfs";
45
import { useTranslations } from "next-intl";
56
import { useQuery } from "@tanstack/react-query";
67
import { getCoin, setApiKey } from "@zoralabs/coins-sdk";
@@ -60,7 +61,7 @@ function useZoraLogo(address: string, chainId: number, skip: boolean): string |
6061
: (preview as string | undefined);
6162
if (!raw) return null;
6263
// Convert IPFS URIs to an HTTP gateway URL.
63-
return raw.startsWith("ipfs://") ? raw.replace("ipfs://", "https://dweb.link/ipfs/") : raw;
64+
return ipfsToHttp(raw);
6465
},
6566
}).data ?? null
6667
);

src/app/[locale]/tv/[coinAddress]/page.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Suspense } from "react";
2+
import { ipfsToHttp } from "@/lib/ipfs";
23
import { Metadata } from "next";
34
import { getTranslations, setRequestLocale } from "next-intl/server";
45
import { getCoin, setApiKey } from "@zoralabs/coins-sdk";
@@ -71,10 +72,8 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
7172
};
7273
}
7374

74-
// Convert IPFS to HTTP
75-
const imageUrl = metadata.imageUrl?.startsWith("ipfs://")
76-
? metadata.imageUrl.replace("ipfs://", "https://ipfs.io/ipfs/")
77-
: metadata.imageUrl;
75+
// Convert IPFS to HTTP (resilient gateway chain — see @/lib/ipfs)
76+
const imageUrl = metadata.imageUrl ? ipfsToHttp(metadata.imageUrl) : undefined;
7877

7978
const miniappEmbed = {
8079
...TV_MINIAPP_EMBED_CONFIG,

src/app/api/wallet/token-lookup/route.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { NextResponse, type NextRequest } from "next/server";
2+
import { ipfsToHttp } from "@/lib/ipfs";
23
import { getCoin, setApiKey } from "@zoralabs/coins-sdk";
34
import { getAddress, isAddress } from "viem";
45

@@ -100,7 +101,7 @@ export async function GET(req: NextRequest) {
100101
(preview as Record<string, string>)?.small)
101102
: (preview as string | undefined);
102103
if (raw) {
103-
logoUrl = raw.startsWith("ipfs://") ? raw.replace("ipfs://", "https://dweb.link/ipfs/") : raw;
104+
logoUrl = ipfsToHttp(raw);
104105
}
105106
}
106107

src/components/hero/AuctionSpotlight.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"use client";
22

33
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4+
import { ipfsToHttp } from "@/lib/ipfs";
45
import { useTranslations } from "next-intl";
56
import { useDaoAuction } from "@buildeross/hooks";
67
import { toast } from "sonner";
@@ -89,11 +90,7 @@ export function AuctionSpotlight() {
8990

9091
// Derived state
9192
const tokenName = tokenUri?.name;
92-
const imageUrl = tokenUri?.image
93-
? tokenUri.image.startsWith("ipfs://")
94-
? tokenUri.image.replace("ipfs://", "https://ipfs.io/ipfs/")
95-
: tokenUri.image
96-
: undefined;
93+
const imageUrl = tokenUri?.image ? ipfsToHttp(tokenUri.image) : undefined;
9794

9895
const isWinner =
9996
address && displayBidder && address.toLowerCase() === displayBidder.toLowerCase();

src/components/newhome/AuctionPanel.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"use client";
22

33
import { useCallback, useEffect, useMemo, useState } from "react";
4+
import { ipfsToHttp } from "@/lib/ipfs";
45
import { useTranslations } from "next-intl";
56
import { useDaoAuction } from "@buildeross/hooks";
67
import { formatEther } from "viem";
@@ -109,9 +110,7 @@ export function AuctionPanel() {
109110
return () => clearInterval(id);
110111
}, [startTime, displayEndTime]);
111112

112-
const imageUrl = tokenUri?.image?.startsWith("ipfs://")
113-
? tokenUri.image.replace("ipfs://", "https://ipfs.io/ipfs/")
114-
: tokenUri?.image;
113+
const imageUrl = tokenUri?.image ? ipfsToHttp(tokenUri.image) : undefined;
115114

116115
const isWinner =
117116
address && displayBidder && address.toLowerCase() === displayBidder.toLowerCase();

src/components/tv/TV3DModel.tsx

Lines changed: 25 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { useTexture, useVideoTexture } from "@react-three/drei";
66
import { useFrame, useThree } from "@react-three/fiber";
77
import type { Group } from "three";
88
import * as THREE from "three";
9+
import { ipfsCandidates, ipfsToHttp } from "@/lib/ipfs";
910
import type { PlasticConfig, TVTextureConfig } from "./TVTextureControls";
1011

1112
// Creator coin image type
@@ -861,20 +862,10 @@ const sharedMaterials = {
861862
}),
862863
};
863864

864-
// Convert IPFS URIs and gateways to faster gateway
865+
// Convert IPFS URIs (and re-point any existing gateway URL) to the primary gateway.
865866
function toFastIPFS(url?: string): string | undefined {
866867
if (!url) return undefined;
867-
868-
// First convert ipfs:// protocol to HTTP gateway
869-
let httpUrl = url;
870-
if (url.startsWith("ipfs://")) {
871-
httpUrl = url.replace("ipfs://", "https://dweb.link/ipfs/");
872-
}
873-
874-
// Then convert other gateways to faster one
875-
return httpUrl
876-
.replace("https://ipfs.io/ipfs/", "https://dweb.link/ipfs/")
877-
.replace("https://cloudflare-ipfs.com/ipfs/", "https://dweb.link/ipfs/");
868+
return ipfsToHttp(url);
878869
}
879870

880871
// Vertex shader (shared by all screen effects)
@@ -969,7 +960,15 @@ const crtFragmentShader = `
969960

970961
// Video Screen component that uses VideoTexture with CRT effect
971962
function VideoScreen({ videoUrl, isVisible = true }: { videoUrl: string; isVisible?: boolean }) {
972-
const texture = useVideoTexture(videoUrl, {
963+
// Fallback chain: if the current gateway errors, advance to the next one.
964+
const candidates = useMemo(() => ipfsCandidates(videoUrl), [videoUrl]);
965+
const [gwIndex, setGwIndex] = useState(0);
966+
useEffect(() => {
967+
setGwIndex(0);
968+
}, [videoUrl]);
969+
const resolvedUrl = candidates[gwIndex] ?? videoUrl;
970+
971+
const texture = useVideoTexture(resolvedUrl, {
973972
muted: true,
974973
loop: true,
975974
start: isVisible, // Only auto-start if visible
@@ -989,6 +988,17 @@ function VideoScreen({ videoUrl, isVisible = true }: { videoUrl: string; isVisib
989988
[texture],
990989
);
991990

991+
// Advance to the next gateway if the current source fails to load.
992+
useEffect(() => {
993+
const video = texture.image as HTMLVideoElement;
994+
if (!video) return;
995+
const onError = () => {
996+
setGwIndex((i) => (i < candidates.length - 1 ? i + 1 : i));
997+
};
998+
video.addEventListener("error", onError);
999+
return () => video.removeEventListener("error", onError);
1000+
}, [texture, candidates.length]);
1001+
9921002
// Ensure video plays at 2x
9931003
useEffect(() => {
9941004
const video = texture.image as HTMLVideoElement;
@@ -1268,11 +1278,8 @@ function Sticker({
12681278
function proxyImageUrl(url?: string): string | undefined {
12691279
if (!url) return undefined;
12701280

1271-
// Convert IPFS URLs first
1272-
let httpUrl = url;
1273-
if (url.startsWith("ipfs://")) {
1274-
httpUrl = url.replace("ipfs://", "https://ipfs.io/ipfs/");
1275-
}
1281+
// Convert IPFS URLs first (ipfs:// or a dead gateway → primary gateway)
1282+
const httpUrl = ipfsToHttp(url);
12761283

12771284
// Use wsrv.nl as CORS proxy for external images
12781285
// This service adds proper CORS headers

src/components/tv/TVVideoPlayer.tsx

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,9 @@
33
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
44
import { useTranslations } from "next-intl";
55
import Image from "next/image";
6+
import { ipfsCandidates } from "@/lib/ipfs";
67
import { usePerformanceTracking } from "./useVideoPreloader";
78

8-
/**
9-
* Convert IPFS URIs to HTTP gateway URLs
10-
*/
11-
function toHttpUrl(url: string): string {
12-
if (url.startsWith("ipfs://")) {
13-
return url.replace("ipfs://", "https://dweb.link/ipfs/");
14-
}
15-
return url;
16-
}
17-
189
type LoadState = "idle" | "loading" | "canplay" | "playing" | "error" | "waiting";
1910

2011
const IMAGE_DISPLAY_DURATION_MS = 3000;
@@ -54,8 +45,15 @@ export function TVVideoPlayer({
5445

5546
const isImageOnly = !src && !!poster;
5647

57-
// Convert IPFS URLs to HTTP gateway
58-
const videoSrc = useMemo(() => (src ? toHttpUrl(src) : ""), [src]);
48+
// Ordered gateway candidates for this source, so a dead/blocked gateway falls
49+
// through to the next one instead of failing the video (see @/lib/ipfs).
50+
const candidates = useMemo(() => (src ? ipfsCandidates(src) : []), [src]);
51+
const [gwIndex, setGwIndex] = useState(0);
52+
// Reset to the primary gateway whenever the source changes.
53+
useEffect(() => {
54+
setGwIndex(0);
55+
}, [src]);
56+
const videoSrc = candidates[gwIndex] ?? "";
5957
const [loadProgress, setLoadProgress] = useState(0);
6058
const [hasFirstFrame, setHasFirstFrame] = useState(false);
6159
const mountedRef = useRef(true);
@@ -96,8 +94,14 @@ export function TVVideoPlayer({
9694

9795
const handleError = useCallback(() => {
9896
if (!mountedRef.current) return;
97+
// Try the next gateway in the chain before surfacing an error.
98+
if (gwIndex < candidates.length - 1) {
99+
setGwIndex((i) => i + 1);
100+
setLoadState("loading");
101+
return;
102+
}
99103
setLoadState("error");
100-
}, []);
104+
}, [gwIndex, candidates.length]);
101105

102106
const handleLoadStart = useCallback(() => {
103107
if (!mountedRef.current) return;

src/components/tv/useTVFeed.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { useCallback, useEffect, useRef, useState } from "react";
44
import { getCoin, setApiKey } from "@zoralabs/coins-sdk";
5+
import { ipfsToHttp } from "@/lib/ipfs";
56
import type { CoinNode, TVItem } from "./types";
67
import { FALLBACK_ITEMS, mapCoinToTVItem, PRELOAD_THRESHOLD } from "./utils";
78

@@ -10,10 +11,7 @@ import { FALLBACK_ITEMS, mapCoinToTVItem, PRELOAD_THRESHOLD } from "./utils";
1011
*/
1112
function toHttpUrl(url?: string | null): string | undefined {
1213
if (!url) return undefined;
13-
if (url.startsWith("ipfs://")) {
14-
return url.replace("ipfs://", "https://dweb.link/ipfs/");
15-
}
16-
return url;
14+
return ipfsToHttp(url);
1715
}
1816

1917
/**

src/components/tv/useVideoPreloader.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,14 @@
11
"use client";
22

33
import { useEffect, useRef, useState, type MutableRefObject } from "react";
4+
import { ipfsToHttp } from "@/lib/ipfs";
45
import type { TVItem } from "./types";
56

67
/**
78
* Convert IPFS URIs to HTTP gateway URLs
89
*/
910
function toHttpUrl(url: string): string {
10-
if (url.startsWith("ipfs://")) {
11-
return url.replace("ipfs://", "https://dweb.link/ipfs/");
12-
}
13-
return url;
11+
return ipfsToHttp(url);
1412
}
1513

1614
/**

src/components/tv/utils.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
*/
99

1010
import { DAO_ADDRESSES, GNARS_CREATOR_COIN } from "@/lib/config";
11+
import { ipfsToHttp } from "@/lib/ipfs";
1112
import type { CoinMedia, CoinNode, TVItem } from "./types";
1213

1314
const GNARS_TREASURY = DAO_ADDRESSES.treasury;
@@ -33,10 +34,7 @@ export const FALLBACK_ITEMS: TVItem[] = [
3334
*/
3435
function toHttpUrl(uri?: string | null): string | undefined {
3536
if (!uri) return undefined;
36-
if (uri.startsWith("ipfs://")) {
37-
return uri.replace("ipfs://", "https://ipfs.io/ipfs/");
38-
}
39-
return uri;
37+
return ipfsToHttp(uri);
4038
}
4139

4240
/**

0 commit comments

Comments
 (0)