Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
d89cc4a
chore(deps): add `@opennbs/nbsvis`
Bentroen Apr 8, 2026
57af2bd
feat: add song player widget on song page
Bentroen Apr 8, 2026
d215032
chore(player): enable cross-origin isolation via COOP/COEP headers
Bentroen Apr 8, 2026
5831fc7
chore(player): set up static asset routing/serving from `nbsvis` package
Bentroen Apr 8, 2026
d13c517
refactor(player): move player management to dedicated `SongPlayerCont…
Bentroen Apr 8, 2026
6746b09
feat: add fixed control bar to bottom of page for cross-page playback
Bentroen Sep 5, 2026
53f4f84
feat(player): add seek, looping, volume, expand and stop playback con…
Bentroen Sep 5, 2026
0b32aab
fix(player): adjust a lot of styling in the bottom bar
Bentroen Sep 6, 2026
1d94f4b
refactor(player): remove leftover player bar height tracking on styles
Bentroen Sep 6, 2026
205be71
fix(player): subtly adjust size of buttons and add hover scale change
Bentroen Sep 6, 2026
f5dea12
fix(player): add intermediate volume icon
Bentroen Sep 6, 2026
72d43fe
feat(player): implement marquee text on song title display when too long
Bentroen Sep 6, 2026
df2242b
fix(ui): make sliders slightly thicker
Bentroen Sep 6, 2026
93aec01
fix(player): remove loading state dump in document
Bentroen Sep 6, 2026
9c1d074
fix(player): add toast on song load error
Bentroen Sep 6, 2026
11d968a
fix(player): show spinning indicator on song card during load
Bentroen Sep 6, 2026
5b3ad7b
fix(player): implement popup slider for volume control
Bentroen Sep 16, 2026
7a0e539
fix(player): add more volume icon gradations
Bentroen Sep 16, 2026
de56dfd
fix(player): adjust size of control icons in bottom bar
Bentroen Sep 16, 2026
220a7d5
fix(player): make bottom bar responsive with title layout change
Bentroen Sep 16, 2026
01aee95
fix(player): incorrect nesting of player bar buttons
Bentroen Sep 17, 2026
42469ae
refactor(player): extract `VolumeButton` component from player bar
Bentroen Sep 17, 2026
757b904
fix(player): restore last audible volume before button click or drag …
Bentroen Sep 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions apps/frontend/next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,23 @@ const nextConfig = {
NEXT_PUBLIC_APP_ENV: appEnv,
},
pageExtensions: ['js', 'jsx', 'md', 'mdx', 'ts', 'tsx'],
async headers() {
return [
{
source: '/:path*',
headers: [
{
key: 'Cross-Origin-Opener-Policy',
value: 'same-origin',
},
{
key: 'Cross-Origin-Embedder-Policy',
value: 'require-corp',
},
],
},
];
},
// Externalize packages that use Node.js built-in modules for server components
serverExternalPackages: ['@nbw/database', '@nbw/config'],
// See: https://github.com/Automattic/node-canvas/issues/867#issuecomment-1925284985
Expand All @@ -24,6 +41,13 @@ const nextConfig = {
config.externals.push('@nbw/thumbnail', '@nbw/database');
}

// Handle audio assets from @opennbs/nbsvis as static files
config.module.rules.push({
test: /\.(ogg|mp3|wav)$/i,
include: /node_modules[\\/]@opennbs[\\/]nbsvis[\\/]/,
type: 'asset/resource',
});

return config;
},
images: {
Expand Down
5 changes: 3 additions & 2 deletions apps/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@
"test": "jest"
},
"dependencies": {
"@fortawesome/free-brands-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/free-brands-svg-icons": "^7.3.1",
"@fortawesome/free-solid-svg-icons": "^7.3.1",
"@fortawesome/react-fontawesome": "^0.2.6",
"@headlessui/react": "^1.7.19",
"@hookform/resolvers": "^5.2.2",
Expand All @@ -23,6 +23,7 @@
"@nbw/thumbnail": "workspace:*",
"@next/mdx": "^16.0.8",
"@next/third-parties": "^16.0.8",
"@opennbs/nbsvis": "^0.4.0",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
Expand Down
53 changes: 53 additions & 0 deletions apps/frontend/src/app/assets/[...path]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { readFile } from 'node:fs/promises';
import path from 'node:path';

import { NextResponse } from 'next/server';

export const runtime = 'nodejs';

const nbsvisAssetsRoot = path.resolve(
process.cwd(),
'../../node_modules/@opennbs/nbsvis/dist/assets',
);

const contentTypeByExt: Record<string, string> = {
'.js': 'text/javascript; charset=utf-8',
'.ogg': 'audio/ogg',
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.png': 'image/png',
};

export async function GET(
_: Request,
context: { params: Promise<{ path: string[] }> },
) {
const { path: parts } = await context.params;

if (!parts?.length) {
return NextResponse.json({ error: 'Asset not found' }, { status: 404 });
}

const relativePath = parts.join('/');
const fullPath = path.resolve(nbsvisAssetsRoot, relativePath);

// Prevent path traversal outside the package asset directory.
if (!fullPath.startsWith(nbsvisAssetsRoot + path.sep)) {
return NextResponse.json({ error: 'Invalid asset path' }, { status: 400 });
}

try {
const file = await readFile(fullPath);
const ext = path.extname(fullPath).toLowerCase();

return new NextResponse(file, {
status: 200,
headers: {
'Content-Type': contentTypeByExt[ext] ?? 'application/octet-stream',
'Cache-Control': 'public, max-age=31536000, immutable',
},
});
} catch {
return NextResponse.json({ error: 'Asset not found' }, { status: 404 });
}
}
25 changes: 25 additions & 0 deletions apps/frontend/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,31 @@ body {

/************** Animations **************/

@keyframes marquee {
0%,
18% {
transform: translateX(0);
}
100% {
transform: translateX(-50%);
}
}

@utility animate-marquee {
animation: marquee var(--marquee-duration, 8s) linear infinite;
}

@utility marquee-fade {
--marquee-mask-left-alpha: 1;
mask-image: linear-gradient(
to right,
rgb(0 0 0 / var(--marquee-mask-left-alpha)) 0,
black 8px,
black calc(100% - 16px),
transparent
);
}

@keyframes bounce2 {
25% {
transform-origin: center bottom;
Expand Down
9 changes: 6 additions & 3 deletions apps/frontend/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import DetectAdBlock from '../modules/shared/components/client/ads/DetectAdBlock
import GoogleAdSense from '../modules/shared/components/GoogleAdSense';
import { isProductionAppEnv } from '@web/lib/appEnv';
import { TooltipProvider } from '../modules/shared/components/tooltip';
import { SongPlayerProvider } from '../modules/song/components/client/context/SongPlayer.context';

// Pre-import FontAwesome CSS to avoid FOUC
// See: https://fontawesome.com/docs/web/use-with/react/use-with#nextjs
Expand Down Expand Up @@ -121,9 +122,11 @@ export default function RootLayout({
baseColor='rgb(39 39 42)'
highlightColor='rgb(63 63 70)'
>
<TooltipProvider delayDuration={0} skipDelayDuration={0}>
<NuqsAdapter>{children}</NuqsAdapter>
</TooltipProvider>
<SongPlayerProvider>
<TooltipProvider delayDuration={0} skipDelayDuration={0}>
<NuqsAdapter>{children}</NuqsAdapter>
</TooltipProvider>
</SongPlayerProvider>
</SkeletonTheme>
<DetectAdBlock />
</body>
Expand Down
78 changes: 68 additions & 10 deletions apps/frontend/src/modules/browse/components/SongCard.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
'use client';

import { faPlay } from '@fortawesome/free-solid-svg-icons';
import {
faCircleNotch,
faCirclePause,
faCirclePlay,
faPlay,
} from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import Link from 'next/link';
import Skeleton from 'react-loading-skeleton';

import type { SongPreviewDtoType } from '@nbw/database';
import { cn } from '@web/lib/utils';
import { formatDuration, formatTimeAgo } from '@web/modules/shared/util/format';
import {
previewToPlayingSong,
useSongPlayer,
} from '@web/modules/song/components/client/context/SongPlayer.context';

import SongThumbnail from '../../shared/components/layout/SongThumbnail';

Expand All @@ -23,7 +33,7 @@ const SongDataDisplay = ({ song }: { song: SongPreviewDtoType | null }) => {
) : (
<>
<SongThumbnail src={song.thumbnailUrl} />
<div className='absolute bottom-0 right-0 m-1 px-1 py-0.5 bg-zinc-800 rounded-md'>
<div className='absolute bottom-0 right-0 m-1 px-1 py-0.5 bg-zinc-900/50 rounded-md backdrop-blur-md'>
<span className='text-white font-semibold'>
{formatDuration(song.duration)}
</span>
Expand Down Expand Up @@ -67,17 +77,65 @@ const SongDataDisplay = ({ song }: { song: SongPreviewDtoType | null }) => {
};

const SongCard = ({ song }: { song: SongPreviewDtoType | null }) => {
return !song ? (
<SongDataDisplay song={song} />
) : (
<Link href={`/song/${song.publicId}`} className='h-full max-h-fit'>
const { playSong, currentSong, isPlaying, isLoading } = useSongPlayer();

if (!song) {
return <SongDataDisplay song={song} />;
}

const isCurrent = currentSong?.publicId === song.publicId;
const showPause = isCurrent && isPlaying;
const showLoading = isCurrent && isLoading;

const handlePlay = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
void playSong(previewToPlayingSong(song));
};

return (
<div
className='group relative bg-zinc-800 hover:scale-105 hover:bg-zinc-700 rounded-lg cursor-pointer w-full h-full max-h-fit transition-all duration-200'
style={{ backfaceVisibility: 'hidden' }}
>
<Link href={`/song/${song.publicId}`} className='block h-full max-h-fit'>
<SongDataDisplay song={song} />
</Link>
<div
className='bg-zinc-800 hover:scale-105 hover:bg-zinc-700 rounded-lg cursor-pointer w-full h-full transition-all duration-200'
style={{ backfaceVisibility: 'hidden' }}
className={cn(
'absolute top-0 left-0 w-full aspect-5/3 rounded-lg bg-black flex items-center justify-center pointer-events-none transition-all duration-200',
isCurrent ? 'opacity-40' : 'opacity-0 group-hover:opacity-40',
)}
>
<SongDataDisplay song={song} />
<button
type='button'
disabled={showLoading}
onClick={handlePlay}
className={cn(
'pointer-events-none group-hover:pointer-events-auto text-white text-6xl transition-transform duration-200',
isCurrent && 'pointer-events-auto',
!showLoading && 'hover:scale-110',
)}
aria-label={
showLoading
? 'Loading song'
: showPause
? 'Pause song'
: 'Play song'
}
title={showLoading ? 'Loading' : showPause ? 'Pause' : 'Play'}
>
{showLoading ? (
<FontAwesomeIcon
icon={faCircleNotch}
className='animate-spin text-zinc-400'
/>
) : (
<FontAwesomeIcon icon={showPause ? faCirclePause : faCirclePlay} />
)}
</button>
</div>
</Link>
</div>
);
};

Expand Down
Loading
Loading