Skip to content

Commit f0a984b

Browse files
authored
Merge pull request #94 from RelistenNet/embedded-player
Embedded player
2 parents d93cf51 + 2ed628d commit f0a984b

File tree

8 files changed

+209
-107
lines changed

8 files changed

+209
-107
lines changed

src/app/(browse)/[artistSlug]/[year]/[month]/[day]/[songSlug]/PlayerManager.tsx

Lines changed: 0 additions & 93 deletions
This file was deleted.
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import PlayerManager from '@/components/PlayerManager';
2+
import SongsColumn from '@/components/SongsColumn';
3+
import RelistenAPI from '@/lib/RelistenAPI';
4+
import { createShowDate } from '@/lib/utils';
5+
import { RawParams } from '@/types/params';
6+
import { notFound } from 'next/navigation';
7+
import { playImmediatelySearchParamsLoader } from '@/lib/searchParams/playImmediatelySearchParam';
8+
9+
interface EmbedSongPageProps {
10+
params: Promise<RawParams>;
11+
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
12+
}
13+
14+
export default async function EmbedSongPage({ params, searchParams }: EmbedSongPageProps) {
15+
const resolvedParams = await params;
16+
const { artistSlug, year, month, day } = resolvedParams;
17+
18+
if (!year || !month || !day) return notFound();
19+
20+
const show = await RelistenAPI.fetchShow(artistSlug, year, createShowDate(year, month, day));
21+
22+
if (!show) {
23+
notFound();
24+
}
25+
26+
// Parse search params on server
27+
const parsedSearchParams = await playImmediatelySearchParamsLoader.parseAndValidate(searchParams);
28+
const playImmediately = parsedSearchParams.playImmediately ?? true;
29+
30+
return (
31+
<div className="flex h-full">
32+
<div className="w-full flex-shrink-0 overflow-y-auto border-r px-2">
33+
<SongsColumn
34+
artistSlug={artistSlug}
35+
year={year}
36+
month={month}
37+
day={day}
38+
show={show}
39+
routePrefix="/embed"
40+
/>
41+
</div>
42+
<PlayerManager
43+
{...resolvedParams}
44+
show={show}
45+
routePrefix="/embed"
46+
playImmediately={playImmediately}
47+
/>
48+
</div>
49+
);
50+
}
51+
52+
export async function generateMetadata(props) {
53+
const [params, artists] = await Promise.all([props.params, RelistenAPI.fetchArtists()]);
54+
const { artistSlug, year, month, day, songSlug } = params;
55+
56+
const name = artists.find((a) => a.slug === artistSlug)?.name;
57+
58+
if (!name) return notFound();
59+
if (!year || !month || !day) return notFound();
60+
61+
const show = await RelistenAPI.fetchShow(artistSlug, year, createShowDate(year, month, day));
62+
63+
const songs = show?.sources
64+
?.map((source) => source?.sets?.map((set) => set?.tracks).flat())
65+
.flat();
66+
67+
const song = songs?.find((song) => song?.slug === songSlug);
68+
69+
return {
70+
title: [song?.title, createShowDate(year, month, day), name].filter((x) => x).join(' | '),
71+
description: [show?.venue?.name, show?.venue?.location].filter((x) => x).join(' '),
72+
openGraph: {
73+
audio: [
74+
{
75+
url: song?.mp3_url,
76+
},
77+
],
78+
},
79+
};
80+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import RelistenAPI from '@/lib/RelistenAPI';
2+
import type { RawParams } from '@/types/params';
3+
import { notFound, redirect } from 'next/navigation';
4+
5+
interface EmbedShowPageProps {
6+
params: Promise<RawParams>;
7+
}
8+
9+
export default async function EmbedShowPage({ params }: EmbedShowPageProps) {
10+
const { artistSlug, year, month, day } = await params;
11+
12+
if (!artistSlug || !year || !month || !day) {
13+
return (
14+
<div className="flex h-screen items-center justify-center text-gray-500">
15+
Invalid show parameters
16+
</div>
17+
);
18+
}
19+
20+
const displayDate = [year, month, day].join('-');
21+
const show = await RelistenAPI.fetchShow(artistSlug, year, displayDate);
22+
23+
if (!show) {
24+
notFound();
25+
}
26+
27+
// Find the first song from the first source and redirect to it
28+
const firstSource = show.sources?.[0];
29+
const firstSet = firstSource?.sets?.[0];
30+
const firstTrack = firstSet?.tracks?.[0];
31+
32+
if (!firstTrack) {
33+
return (
34+
<div className="flex h-screen items-center justify-center text-gray-500">
35+
No songs available for this show
36+
</div>
37+
);
38+
}
39+
40+
redirect(`/embed/${artistSlug}/${year}/${month}/${day}/${firstTrack.slug}?playImmediately=false`);
41+
}
42+
43+
export async function generateMetadata(props: EmbedShowPageProps) {
44+
const params = await props.params;
45+
const { artistSlug, year, month, day } = params;
46+
47+
return {
48+
title: `${artistSlug} - ${year}/${month}/${day}`,
49+
description: `Embedded view of ${artistSlug} show from ${year}/${month}/${day}`,
50+
};
51+
}

src/app/(embed)/layout.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import Flex from '@/components/Flex';
2+
import Player from '@/components/Player';
3+
import RelistenAPI from '@/lib/RelistenAPI';
4+
import { ReactNode } from 'react';
5+
6+
export default async function EmbedLayout({ children }: { children: ReactNode }) {
7+
const artists = await RelistenAPI.fetchArtists();
8+
9+
const artistSlugsToName = artists.reduce(
10+
(memo, next) => {
11+
memo[String(next.slug)] = next.name;
12+
return memo;
13+
},
14+
{} as Record<string, string | undefined>
15+
);
16+
17+
return (
18+
<Flex column className="h-screen bg-white">
19+
{/* Minimal header with just the player */}
20+
<div className="flex h-[50px] min-h-[50px] items-center justify-center border-b border-gray-300 bg-white">
21+
<div className="w-full max-w-2xl">
22+
<Player artistSlugsToName={artistSlugsToName} />
23+
</div>
24+
</div>
25+
26+
{/* Content area */}
27+
<div className="flex-1 overflow-y-auto">{children}</div>
28+
</Flex>
29+
);
30+
}

src/components/Player.tsx

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
PauseIcon,
1616
PlayIcon,
1717
RewindIcon,
18+
Volume2Icon,
1819
} from 'lucide-react';
1920

2021
interface Props {
@@ -59,18 +60,19 @@ const Player = ({ artistSlugsToName }: Props) => {
5960
};
6061

6162
const updateVolume = (e: React.MouseEvent<HTMLElement>) => {
62-
const height = e.currentTarget.offsetHeight;
63-
const nextVolume = (height - e.pageY) / height;
63+
const rect = e.currentTarget.getBoundingClientRect();
64+
const height = rect.height;
65+
const nextVolume = (height - (e.pageY - rect.top)) / height;
6466

65-
setVolume(nextVolume);
67+
setVolume(Math.max(0, Math.min(1, nextVolume)));
6668

67-
player.setVolume(nextVolume);
69+
player.setVolume(Math.max(0, Math.min(1, nextVolume)));
6870

69-
localStorage.volume = nextVolume;
71+
localStorage.volume = Math.max(0, Math.min(1, nextVolume));
7072
};
7173

7274
return (
73-
<Flex className="content relative h-[50px] flex-1">
75+
<Flex className="content relative h-[50px] flex-1 px-2">
7476
{false && activeTrack && (
7577
<Head>
7678
<title>
@@ -158,14 +160,18 @@ const Player = ({ artistSlugsToName }: Props) => {
158160
style={{ transform: `translate(${notchPosition}px, 0)` }}
159161
/>
160162
</div>
163+
</div>
164+
)}
165+
{activeTrack && (
166+
<div className="volume-control">
161167
<div
162-
className="absolute top-0 right-[-6px] h-full w-[6px] cursor-pointer bg-[#0000001a]"
168+
className="relative h-full w-[6px] cursor-pointer bg-[#0000001a]"
163169
onClick={updateVolume}
164170
>
165171
<div
166-
className="pointer-events-none absolute top-0 right-0 bottom-0 left-0 bg-[#707070]"
172+
className="pointer-events-none absolute right-0 bottom-0 left-0 bg-[#707070]"
167173
style={{
168-
top: `${(1 - volume) * 100}%`,
174+
height: `${volume * 100}%`,
169175
}}
170176
/>
171177
</div>

src/components/PlayerManager.tsx

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,22 @@ import { updatePlayback } from '@/redux/modules/playback';
99
import { usePathname, useRouter } from 'next/navigation';
1010
import { useEffect } from 'react';
1111

12-
export default function PlayerManager(props: Props) {
12+
interface PlayerManagerProps extends Props {
13+
playImmediately?: boolean;
14+
}
15+
16+
export default function PlayerManager(props: PlayerManagerProps) {
1317
const router = useRouter();
1418
const pathname = usePathname();
1519
const [{ source: sourceId }] = sourceSearchParamsLoader.useQueryStates();
1620

17-
const [artistSlug, year, month, day, songSlug] = String(pathname).replace(/^\//, '').split('/');
21+
// Remove leading slash and handle embed routes
22+
const pathParts = String(pathname)
23+
.replace(/^\/embed/, '')
24+
.replace(/^\//, '')
25+
.split('/');
26+
27+
const [artistSlug, year, month, day, songSlug] = pathParts;
1828

1929
const { activeSourceObj } = useSourceData({ ...props, source: sourceId });
2030

@@ -23,7 +33,7 @@ export default function PlayerManager(props: Props) {
2333
const tracks = activeSourceObj.sets?.map((set) => set.tracks).flat() ?? [];
2434
const activeTrackIndex = tracks.findIndex((track) => track?.slug === songSlug);
2535
const activeTrack = tracks[activeTrackIndex];
26-
const playImmediately = true;
36+
const playImmediately = props.playImmediately ?? true;
2737

2838
store.dispatch(
2939
updatePlayback({
@@ -45,13 +55,14 @@ export default function PlayerManager(props: Props) {
4555
if (!isPlayerMounted()) {
4656
initGaplessPlayer(store, (url: string) => {
4757
if (window.location.pathname !== url) {
48-
router.replace(url);
58+
router.replace((props.routePrefix ?? '') + url);
4959
}
5060
});
5161
} else {
5262
// check if track is already in queue, and re-use
5363
if (player.currentTrack?.metadata?.trackId === activeTrack?.id) {
5464
console.log('track is already playing');
65+
player.play();
5566
return;
5667
}
5768

src/components/SongsColumn.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const getSetTime = (set: Set): string =>
2020

2121
export type Props = Pick<RawParams, 'artistSlug' | 'year' | 'month' | 'day'> & {
2222
show?: Partial<Tape>;
23+
routePrefix?: string;
2324
};
2425

2526
interface SourceData {
@@ -103,7 +104,7 @@ const SongsColumn = (props: Props) => {
103104
)}
104105
<Row
105106
key={track.id}
106-
href={`/${props.artistSlug}/${props.year}/${props.month}/${props.day}/${track.slug}?source=${activeSourceObj.id}`}
107+
href={`${props.routePrefix || ''}/${props.artistSlug}/${props.year}/${props.month}/${props.day}/${track.slug}?source=${activeSourceObj.id}`}
107108
isActiveOverride={trackIsActive}
108109
>
109110
<div>
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { createSearchParams } from '@/lib/searchParams/createSearchParams';
2+
import { parseAsBoolean } from 'nuqs/server';
3+
import { z } from 'zod/v4';
4+
5+
export const playImmediatelySchema = z.object({
6+
playImmediately: z.boolean().nullable(),
7+
});
8+
9+
export const playImmediatelyParser = {
10+
playImmediately: parseAsBoolean.withDefault(true),
11+
};
12+
13+
export const playImmediatelySearchParamsLoader = createSearchParams(
14+
playImmediatelySchema,
15+
playImmediatelyParser
16+
);

0 commit comments

Comments
 (0)