Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import PlayerManager from '@/components/PlayerManager';
import SongsColumn from '@/components/SongsColumn';
import RelistenAPI from '@/lib/RelistenAPI';
import { createShowDate } from '@/lib/utils';
import { RawParams } from '@/types/params';
import { notFound } from 'next/navigation';
import { playImmediatelySearchParamsLoader } from '@/lib/searchParams/playImmediatelySearchParam';

interface EmbedSongPageProps {
params: Promise<RawParams>;
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}

export default async function EmbedSongPage({ params, searchParams }: EmbedSongPageProps) {
const resolvedParams = await params;
const { artistSlug, year, month, day } = resolvedParams;

if (!year || !month || !day) return notFound();

const show = await RelistenAPI.fetchShow(artistSlug, year, createShowDate(year, month, day));

if (!show) {
notFound();
}

// Parse search params on server
const parsedSearchParams = await playImmediatelySearchParamsLoader.parseAndValidate(searchParams);
const playImmediately = parsedSearchParams.playImmediately ?? true;

return (
<div className="flex h-full">
<div className="w-full flex-shrink-0 overflow-y-auto border-r px-2">
<SongsColumn
artistSlug={artistSlug}
year={year}
month={month}
day={day}
show={show}
routePrefix="/embed"
/>
</div>
<PlayerManager
{...resolvedParams}
show={show}
routePrefix="/embed"
playImmediately={playImmediately}
/>
</div>
);
}

export async function generateMetadata(props) {
const [params, artists] = await Promise.all([props.params, RelistenAPI.fetchArtists()]);
const { artistSlug, year, month, day, songSlug } = params;

const name = artists.find((a) => a.slug === artistSlug)?.name;

if (!name) return notFound();
if (!year || !month || !day) return notFound();

const show = await RelistenAPI.fetchShow(artistSlug, year, createShowDate(year, month, day));

const songs = show?.sources
?.map((source) => source?.sets?.map((set) => set?.tracks).flat())
.flat();

const song = songs?.find((song) => song?.slug === songSlug);

return {
title: [song?.title, createShowDate(year, month, day), name].filter((x) => x).join(' | '),
description: [show?.venue?.name, show?.venue?.location].filter((x) => x).join(' '),
openGraph: {
audio: [
{
url: song?.mp3_url,
},
],
},
};
}
51 changes: 51 additions & 0 deletions src/app/(embed)/embed/[artistSlug]/[year]/[month]/[day]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import RelistenAPI from '@/lib/RelistenAPI';
import type { RawParams } from '@/types/params';
import { notFound, redirect } from 'next/navigation';

interface EmbedShowPageProps {
params: Promise<RawParams>;
}

export default async function EmbedShowPage({ params }: EmbedShowPageProps) {
const { artistSlug, year, month, day } = await params;

if (!artistSlug || !year || !month || !day) {
return (
<div className="flex h-screen items-center justify-center text-gray-500">
Invalid show parameters
</div>
);
}

const displayDate = [year, month, day].join('-');
const show = await RelistenAPI.fetchShow(artistSlug, year, displayDate);

if (!show) {
notFound();
}

// Find the first song from the first source and redirect to it
const firstSource = show.sources?.[0];
const firstSet = firstSource?.sets?.[0];
const firstTrack = firstSet?.tracks?.[0];

if (!firstTrack) {
return (
<div className="flex h-screen items-center justify-center text-gray-500">
No songs available for this show
</div>
);
}

redirect(`/embed/${artistSlug}/${year}/${month}/${day}/${firstTrack.slug}?playImmediately=false`);
}

export async function generateMetadata(props: EmbedShowPageProps) {
const params = await props.params;
const { artistSlug, year, month, day } = params;

return {
title: `${artistSlug} - ${year}/${month}/${day}`,
description: `Embedded view of ${artistSlug} show from ${year}/${month}/${day}`,
};
}
30 changes: 30 additions & 0 deletions src/app/(embed)/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import Flex from '@/components/Flex';
import Player from '@/components/Player';
import RelistenAPI from '@/lib/RelistenAPI';
import { ReactNode } from 'react';

export default async function EmbedLayout({ children }: { children: ReactNode }) {
const artists = await RelistenAPI.fetchArtists();

const artistSlugsToName = artists.reduce(
(memo, next) => {
memo[String(next.slug)] = next.name;
return memo;
},
{} as Record<string, string | undefined>
);

return (
<Flex column className="h-screen bg-white">
{/* Minimal header with just the player */}
<div className="flex h-[50px] min-h-[50px] items-center justify-center border-b border-gray-300 bg-white">
<div className="w-full max-w-2xl">
<Player artistSlugsToName={artistSlugsToName} />
</div>
</div>

{/* Content area */}
<div className="flex-1 overflow-y-auto">{children}</div>
</Flex>
);
}
24 changes: 15 additions & 9 deletions src/components/Player.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
PauseIcon,
PlayIcon,
RewindIcon,
Volume2Icon,
} from 'lucide-react';

interface Props {
Expand Down Expand Up @@ -59,18 +60,19 @@ const Player = ({ artistSlugsToName }: Props) => {
};

const updateVolume = (e: React.MouseEvent<HTMLElement>) => {
const height = e.currentTarget.offsetHeight;
const nextVolume = (height - e.pageY) / height;
const rect = e.currentTarget.getBoundingClientRect();
const height = rect.height;
const nextVolume = (height - (e.pageY - rect.top)) / height;

setVolume(nextVolume);
setVolume(Math.max(0, Math.min(1, nextVolume)));

player.setVolume(nextVolume);
player.setVolume(Math.max(0, Math.min(1, nextVolume)));

localStorage.volume = nextVolume;
localStorage.volume = Math.max(0, Math.min(1, nextVolume));
};

return (
<Flex className="content relative h-[50px] flex-1">
<Flex className="content relative h-[50px] flex-1 px-2">
{false && activeTrack && (
<Head>
<title>
Expand Down Expand Up @@ -158,14 +160,18 @@ const Player = ({ artistSlugsToName }: Props) => {
style={{ transform: `translate(${notchPosition}px, 0)` }}
/>
</div>
</div>
)}
{activeTrack && (
<div className="volume-control">
<div
className="absolute top-0 right-[-6px] h-full w-[6px] cursor-pointer bg-[#0000001a]"
className="relative h-full w-[6px] cursor-pointer bg-[#0000001a]"
onClick={updateVolume}
>
<div
className="pointer-events-none absolute top-0 right-0 bottom-0 left-0 bg-[#707070]"
className="pointer-events-none absolute right-0 bottom-0 left-0 bg-[#707070]"
style={{
top: `${(1 - volume) * 100}%`,
height: `${volume * 100}%`,
}}
/>
</div>
Expand Down
19 changes: 15 additions & 4 deletions src/components/PlayerManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,22 @@ import { updatePlayback } from '@/redux/modules/playback';
import { usePathname, useRouter } from 'next/navigation';
import { useEffect } from 'react';

export default function PlayerManager(props: Props) {
interface PlayerManagerProps extends Props {
playImmediately?: boolean;
}

export default function PlayerManager(props: PlayerManagerProps) {
const router = useRouter();
const pathname = usePathname();
const [{ source: sourceId }] = sourceSearchParamsLoader.useQueryStates();

const [artistSlug, year, month, day, songSlug] = String(pathname).replace(/^\//, '').split('/');
// Remove leading slash and handle embed routes
const pathParts = String(pathname)
.replace(/^\/embed/, '')
.replace(/^\//, '')
.split('/');

const [artistSlug, year, month, day, songSlug] = pathParts;

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

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

store.dispatch(
updatePlayback({
Expand All @@ -45,13 +55,14 @@ export default function PlayerManager(props: Props) {
if (!isPlayerMounted()) {
initGaplessPlayer(store, (url: string) => {
if (window.location.pathname !== url) {
router.replace(url);
router.replace((props.routePrefix ?? '') + url);
}
});
} else {
// check if track is already in queue, and re-use
if (player.currentTrack?.metadata?.trackId === activeTrack?.id) {
console.log('track is already playing');
player.play();
return;
}

Expand Down
3 changes: 2 additions & 1 deletion src/components/SongsColumn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const getSetTime = (set: Set): string =>

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

interface SourceData {
Expand Down Expand Up @@ -103,7 +104,7 @@ const SongsColumn = (props: Props) => {
)}
<Row
key={track.id}
href={`/${props.artistSlug}/${props.year}/${props.month}/${props.day}/${track.slug}?source=${activeSourceObj.id}`}
href={`${props.routePrefix || ''}/${props.artistSlug}/${props.year}/${props.month}/${props.day}/${track.slug}?source=${activeSourceObj.id}`}
isActiveOverride={trackIsActive}
>
<div>
Expand Down
16 changes: 16 additions & 0 deletions src/lib/searchParams/playImmediatelySearchParam.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { createSearchParams } from '@/lib/searchParams/createSearchParams';
import { parseAsBoolean } from 'nuqs/server';
import { z } from 'zod/v4';

export const playImmediatelySchema = z.object({
playImmediately: z.boolean().nullable(),
});

export const playImmediatelyParser = {
playImmediately: parseAsBoolean.withDefault(true),
};

export const playImmediatelySearchParamsLoader = createSearchParams(
playImmediatelySchema,
playImmediatelyParser
);