diff --git a/src/app/(browse)/[artistSlug]/[year]/[month]/[day]/[songSlug]/PlayerManager.tsx b/src/app/(browse)/[artistSlug]/[year]/[month]/[day]/[songSlug]/PlayerManager.tsx deleted file mode 100644 index fb3eb4d..0000000 --- a/src/app/(browse)/[artistSlug]/[year]/[month]/[day]/[songSlug]/PlayerManager.tsx +++ /dev/null @@ -1,93 +0,0 @@ -'use client'; - -import { Props, useSourceData } from '@/components/SongsColumn'; -import player, { initGaplessPlayer, isPlayerMounted } from '@/lib/player'; -import { sourceSearchParamsLoader } from '@/lib/searchParams/sourceSearchParam'; -import { createShowDate } from '@/lib/utils'; -import { store } from '@/redux'; -import { updatePlayback } from '@/redux/modules/playback'; -import { usePathname, useRouter } from 'next/navigation'; -import { useEffect } from 'react'; - -export default function PlayerManager(props: Props) { - const router = useRouter(); - const pathname = usePathname(); - const [{ source: sourceId }] = sourceSearchParamsLoader.useQueryStates(); - - const [artistSlug, year, month, day, songSlug] = String(pathname).replace(/^\//, '').split('/'); - - const { activeSourceObj } = useSourceData({ ...props, source: sourceId }); - - useEffect(() => { - if (activeSourceObj) { - const tracks = activeSourceObj.sets?.map((set) => set.tracks).flat() ?? []; - const activeTrackIndex = tracks.findIndex((track) => track?.slug === songSlug); - const activeTrack = tracks[activeTrackIndex]; - const playImmediately = true; - - store.dispatch( - updatePlayback({ - artistSlug, - year, - showDate: createShowDate(year, month, day), - songSlug, - source: sourceId, - paused: false, - }) - ); - - if (tracks.length && typeof window.Notification !== 'undefined') { - if (Notification.permission !== 'granted' && Notification.permission !== 'denied') { - Notification.requestPermission(); - } - } - - if (!isPlayerMounted()) { - initGaplessPlayer(store, (url: string) => { - if (window.location.pathname !== url) { - router.replace(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'); - return; - } - - const prevFirstTrack = player.tracks[0]; - const nextFirstTrack = tracks[0]; - if ( - prevFirstTrack && - nextFirstTrack && - prevFirstTrack.metadata.trackId === nextFirstTrack.id - ) { - player.gotoTrack(activeTrackIndex, playImmediately); - return; - } else { - player.pauseAll(); - // player.cleanUp(); - player.tracks = []; - } - } - - tracks.map((track) => { - const url = window.FLAC ? track?.flac_url || track?.mp3_url : track?.mp3_url; - - player.addTrack({ - trackUrl: url, - skipHEAD: /phish\.in/.test(String(url)), // skip phish from loading head due to cloudflare - metadata: { - trackId: track?.id, - }, - }); - }); - - store.dispatch(updatePlayback({ tracks })); - - player.gotoTrack(activeTrackIndex, playImmediately); - } - }, [pathname, sourceId, activeSourceObj]); - - return null; -} diff --git a/src/app/(embed)/embed/[artistSlug]/[year]/[month]/[day]/[songSlug]/page.tsx b/src/app/(embed)/embed/[artistSlug]/[year]/[month]/[day]/[songSlug]/page.tsx new file mode 100644 index 0000000..bd1417f --- /dev/null +++ b/src/app/(embed)/embed/[artistSlug]/[year]/[month]/[day]/[songSlug]/page.tsx @@ -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; + 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 ( +
+
+ +
+ +
+ ); +} + +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, + }, + ], + }, + }; +} diff --git a/src/app/(embed)/embed/[artistSlug]/[year]/[month]/[day]/page.tsx b/src/app/(embed)/embed/[artistSlug]/[year]/[month]/[day]/page.tsx new file mode 100644 index 0000000..4f2c803 --- /dev/null +++ b/src/app/(embed)/embed/[artistSlug]/[year]/[month]/[day]/page.tsx @@ -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; +} + +export default async function EmbedShowPage({ params }: EmbedShowPageProps) { + const { artistSlug, year, month, day } = await params; + + if (!artistSlug || !year || !month || !day) { + return ( +
+ Invalid show parameters +
+ ); + } + + 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 ( +
+ No songs available for this show +
+ ); + } + + 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}`, + }; +} diff --git a/src/app/(embed)/layout.tsx b/src/app/(embed)/layout.tsx new file mode 100644 index 0000000..df62080 --- /dev/null +++ b/src/app/(embed)/layout.tsx @@ -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 + ); + + return ( + + {/* Minimal header with just the player */} +
+
+ +
+
+ + {/* Content area */} +
{children}
+
+ ); +} diff --git a/src/components/Player.tsx b/src/components/Player.tsx index dddbef8..1db6683 100644 --- a/src/components/Player.tsx +++ b/src/components/Player.tsx @@ -15,6 +15,7 @@ import { PauseIcon, PlayIcon, RewindIcon, + Volume2Icon, } from 'lucide-react'; interface Props { @@ -59,18 +60,19 @@ const Player = ({ artistSlugsToName }: Props) => { }; const updateVolume = (e: React.MouseEvent) => { - 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 ( - + {false && activeTrack && ( @@ -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> diff --git a/src/components/PlayerManager.tsx b/src/components/PlayerManager.tsx index fb3eb4d..27f1e06 100644 --- a/src/components/PlayerManager.tsx +++ b/src/components/PlayerManager.tsx @@ -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 }); @@ -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({ @@ -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; } diff --git a/src/components/SongsColumn.tsx b/src/components/SongsColumn.tsx index fc0af94..cfbe626 100644 --- a/src/components/SongsColumn.tsx +++ b/src/components/SongsColumn.tsx @@ -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 { @@ -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> diff --git a/src/lib/searchParams/playImmediatelySearchParam.ts b/src/lib/searchParams/playImmediatelySearchParam.ts new file mode 100644 index 0000000..803b597 --- /dev/null +++ b/src/lib/searchParams/playImmediatelySearchParam.ts @@ -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 +);