Skip to content

Commit eb01817

Browse files
feat(portfolio): add custom TIDAL player for ModernOrange Band
- Custom TidalPlayer component with HLS.js playback, Web Audio API gain nodes, crossfading, and autoplay on scroll-into-view - ModernOrangeMusic component with Persona5-themed streaming links (modal = monochrome icons, card = native colors) - Server-side TIDAL API routes: tidal-token, tidal-search, tidal-tracks (lazy priority loading, cover art, release type) - tidal-hls-proxy route proxies TIDAL CDN URLs through the server to resolve browser CORS restrictions on HLS manifests/segments - Handles both TIDAL response formats: attrs.uri (CDN URL) wrapped with proxy, and attrs.manifest (base64 HLS) decoded and rewritten - AudioContext unlocked via capture-phase gesture listener; HLS.js fatal errors surfaced to console - ModernOrange project description and title updated - Discography streaming links shown on project card (id=5) - Inject Vercel env vars via flake.nix dev shell before next dev Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 06f6c63 commit eb01817

14 files changed

Lines changed: 1962 additions & 7 deletions

File tree

portfolio/flake.nix

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,17 @@
7070

7171
dev = pkgs.writeShellApplication {
7272
name = "dev";
73-
runtimeInputs = [ pkgs.nodejs_22 ];
73+
runtimeInputs = [ pkgs.nodejs_22 pkgs.nodePackages_latest.vercel ];
7474
text = ''
7575
echo "Starting development server..."
76+
ENV_FILE="../.env.vercel.development.local"
77+
vercel env pull "$ENV_FILE" --environment development --yes --cwd ..
78+
if [ -f "$ENV_FILE" ]; then
79+
set -a
80+
# shellcheck source=/dev/null
81+
. "$ENV_FILE"
82+
set +a
83+
fi
7684
npm run dev
7785
'';
7886
};

portfolio/package-lock.json

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

portfolio/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"private": true,
55
"scripts": {
66
"dev": "next dev --turbopack",
7+
"dev:vercel": "vercel dev --yes",
78
"build": "next build",
89
"start": "next start",
910
"lint": "eslint .",
@@ -17,6 +18,7 @@
1718
"extract-colors": "^4.2.1",
1819
"framer-motion": "^12.27.5",
1920
"glob": "^13.0.6",
21+
"hls.js": "^1.6.16",
2022
"lru-cache": "^11.0.0",
2123
"next": "^16.1.6",
2224
"pdfjs-dist": "^5.7.284",
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
3+
const ALLOWED_TIDAL_HOSTNAMES = [
4+
'sp-pr-cf.audio.tidal.com',
5+
'sp-ad-cf.audio.tidal.com',
6+
'sp-pr-ad.audio.tidal.com',
7+
'cdn.tidal.com',
8+
'audio.tidal.com',
9+
];
10+
11+
function isAllowedTidalUrl(url: URL): boolean {
12+
return ALLOWED_TIDAL_HOSTNAMES.some(
13+
(h) => url.hostname === h || url.hostname.endsWith(`.${h}`)
14+
);
15+
}
16+
17+
function rewriteManifestSegments(content: string, manifestUrl: string): string {
18+
let baseUrl: URL | null = null;
19+
try {
20+
baseUrl = new URL(manifestUrl);
21+
} catch {
22+
/* leave baseUrl null – only absolute segment URLs can be rewritten */
23+
}
24+
25+
return content
26+
.split('\n')
27+
.map((line) => {
28+
const trimmed = line.trim();
29+
if (!trimmed || trimmed.startsWith('#')) return line;
30+
31+
// Resolve relative URLs against the manifest URL when possible.
32+
let segUrl = trimmed;
33+
if (baseUrl && !trimmed.startsWith('http')) {
34+
try {
35+
segUrl = new URL(trimmed, baseUrl).toString();
36+
} catch {
37+
return line;
38+
}
39+
}
40+
41+
try {
42+
const parsed = new URL(segUrl);
43+
if (isAllowedTidalUrl(parsed)) {
44+
return `/api/tidal-hls-proxy?url=${encodeURIComponent(segUrl)}`;
45+
}
46+
} catch {
47+
/* not a valid URL, leave as-is */
48+
}
49+
return line;
50+
})
51+
.join('\n');
52+
}
53+
54+
export async function GET(req: NextRequest) {
55+
const rawUrl = req.nextUrl.searchParams.get('url');
56+
if (!rawUrl) {
57+
return new NextResponse('Missing url parameter', { status: 400 });
58+
}
59+
60+
let targetUrl: URL;
61+
try {
62+
targetUrl = new URL(rawUrl);
63+
} catch {
64+
return new NextResponse('Invalid URL', { status: 400 });
65+
}
66+
67+
if (!isAllowedTidalUrl(targetUrl)) {
68+
return new NextResponse('Domain not allowed', { status: 403 });
69+
}
70+
71+
const upstream = await fetch(targetUrl.toString(), {
72+
headers: { 'User-Agent': 'Mozilla/5.0 TidalPlayer/1.0' },
73+
cache: 'no-store',
74+
}).catch(() => null);
75+
76+
if (!upstream) {
77+
return new NextResponse('Upstream fetch failed', { status: 502 });
78+
}
79+
if (!upstream.ok) {
80+
return new NextResponse(`Upstream error ${upstream.status}`, {
81+
status: upstream.status,
82+
});
83+
}
84+
85+
const contentType = upstream.headers.get('content-type') ?? '';
86+
const looksLikeManifest =
87+
contentType.includes('mpegurl') ||
88+
rawUrl.includes('.m3u8') ||
89+
rawUrl.includes('/manifest') ||
90+
contentType.includes('tidal.bts');
91+
92+
const corsHeaders = {
93+
'Access-Control-Allow-Origin': '*',
94+
'Access-Control-Allow-Methods': 'GET, OPTIONS',
95+
};
96+
97+
if (looksLikeManifest) {
98+
const text = await upstream.text();
99+
const rewritten = rewriteManifestSegments(text, rawUrl);
100+
return new NextResponse(rewritten, {
101+
headers: {
102+
...corsHeaders,
103+
'Content-Type': 'application/vnd.apple.mpegurl',
104+
'Cache-Control': 'no-cache',
105+
},
106+
});
107+
}
108+
109+
// Audio segment – stream through.
110+
const buffer = await upstream.arrayBuffer();
111+
return new NextResponse(buffer, {
112+
headers: {
113+
...corsHeaders,
114+
'Content-Type': contentType || 'audio/aac',
115+
'Cache-Control': 'max-age=3600',
116+
},
117+
});
118+
}
119+
120+
export async function OPTIONS() {
121+
return new NextResponse(null, {
122+
status: 204,
123+
headers: {
124+
'Access-Control-Allow-Origin': '*',
125+
'Access-Control-Allow-Methods': 'GET, OPTIONS',
126+
},
127+
});
128+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
3+
type TidalSearchPayload = {
4+
data?: unknown;
5+
included: unknown[];
6+
};
7+
8+
type CacheEntry = {
9+
expiresAt: number;
10+
payload: TidalSearchPayload;
11+
};
12+
13+
const SEARCH_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
14+
const searchCache = new Map<string, CacheEntry>();
15+
16+
// GET /api/tidal-search?q=BladeWalker+ModernOrange&countryCode=US
17+
export async function GET(req: NextRequest) {
18+
const { searchParams } = new URL(req.url);
19+
const q = searchParams.get('q') ?? '';
20+
const countryCode = searchParams.get('countryCode') ?? 'US';
21+
const query = q.trim();
22+
const cacheKey = `${countryCode}::${query.toLowerCase()}`;
23+
24+
if (!query) {
25+
return NextResponse.json({ included: [] }, { status: 200 });
26+
}
27+
28+
const now = Date.now();
29+
const cached = searchCache.get(cacheKey);
30+
if (cached && now < cached.expiresAt) {
31+
return NextResponse.json(cached.payload, { status: 200 });
32+
}
33+
34+
// Get a fresh access token from our own secure endpoint
35+
const tokenRes = await fetch(`${req.nextUrl.origin}/api/tidal-token`);
36+
if (!tokenRes.ok) return NextResponse.json({ error: 'token fetch failed' }, { status: 500 });
37+
const { token } = await tokenRes.json();
38+
39+
// TIDAL path is case-sensitive: /searchResults/{id}
40+
const url = `https://openapi.tidal.com/v2/searchResults/${encodeURIComponent(query)}?countryCode=${countryCode}&include=tracks`;
41+
42+
const res = await fetch(url, {
43+
headers: {
44+
'Authorization': `Bearer ${token}`,
45+
'Accept': 'application/vnd.api+json',
46+
},
47+
});
48+
49+
if (res.status === 429) {
50+
// In dev React strict mode, repeated mounts can trigger bursts.
51+
// Serve stale cache if available; otherwise provide a soft-empty payload.
52+
if (cached) {
53+
return NextResponse.json(cached.payload, { status: 200 });
54+
}
55+
return NextResponse.json({ included: [] }, { status: 200 });
56+
}
57+
58+
if (res.status === 404) {
59+
// No search result id for this query; return empty tracks payload for client fallback.
60+
const payload: TidalSearchPayload = { included: [] };
61+
searchCache.set(cacheKey, {
62+
payload,
63+
expiresAt: now + SEARCH_CACHE_TTL_MS,
64+
});
65+
return NextResponse.json(payload, { status: 200 });
66+
}
67+
68+
if (!res.ok) {
69+
const text = await res.text();
70+
return NextResponse.json({ error: text }, { status: res.status });
71+
}
72+
73+
const data = await res.json();
74+
const payload: TidalSearchPayload = {
75+
data: data.data,
76+
included: Array.isArray(data.included) ? data.included : [],
77+
};
78+
searchCache.set(cacheKey, {
79+
payload,
80+
expiresAt: now + SEARCH_CACHE_TTL_MS,
81+
});
82+
return NextResponse.json(payload);
83+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { NextResponse } from 'next/server';
2+
3+
let cachedToken: string | null = null;
4+
let tokenExpiry = 0;
5+
6+
export async function GET() {
7+
// Serve cached token if still valid (with 60s buffer)
8+
if (cachedToken && Date.now() < tokenExpiry - 60_000) {
9+
return NextResponse.json({ token: cachedToken });
10+
}
11+
12+
const clientId = process.env.TIDAL_CLIENT_ID;
13+
const clientSecret = process.env.TIDAL_CLIENT_SECRET;
14+
15+
if (!clientId || !clientSecret) {
16+
return NextResponse.json({ error: 'TIDAL credentials not configured' }, { status: 500 });
17+
}
18+
19+
const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
20+
21+
const res = await fetch('https://auth.tidal.com/v1/oauth2/token', {
22+
method: 'POST',
23+
headers: {
24+
'Authorization': `Basic ${credentials}`,
25+
'Content-Type': 'application/x-www-form-urlencoded',
26+
},
27+
body: 'grant_type=client_credentials',
28+
});
29+
30+
if (!res.ok) {
31+
const text = await res.text();
32+
return NextResponse.json({ error: `TIDAL auth failed: ${text}` }, { status: res.status });
33+
}
34+
35+
const data = await res.json();
36+
cachedToken = data.access_token;
37+
tokenExpiry = Date.now() + data.expires_in * 1000;
38+
39+
return NextResponse.json({ token: cachedToken });
40+
}

0 commit comments

Comments
 (0)