Skip to content

Commit 0fd4184

Browse files
author
Alex Patterson
committed
add player with hooks
1 parent 69249f0 commit 0fd4184

5 files changed

Lines changed: 217 additions & 25 deletions

File tree

‎app/(main)/(podcast)/podcast/[slug]/page.tsx‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { Metadata, ResolvingMetadata } from "next";
2-
import { groq, type PortableTextBlock } from "next-sanity";
2+
import { type PortableTextBlock } from "next-sanity";
33
import { notFound } from "next/navigation";
44
import { Suspense } from "react";
55

@@ -17,6 +17,7 @@ import { BreadcrumbLinks } from "@/components/breadrumb-links";
1717
import SponsorCard from "@/components/sponsor-card";
1818
import Avatar from "@/components/avatar";
1919
import Picks from "./picks";
20+
import PlayerPlayButton from "@/components/player-play-button";
2021

2122
type Props = {
2223
params: { slug: string };
@@ -59,6 +60,8 @@ export default async function PodcastPage({ params }: Props) {
5960
return notFound();
6061
}
6162

63+
const mp3 = podcast?.spotify?.enclosures?.at(0)?.url;
64+
6265
return (
6366
<div className="container px-5 mx-auto">
6467
<BreadcrumbLinks
@@ -96,6 +99,9 @@ export default async function PodcastPage({ params }: Props) {
9699
<div className="text-lg">
97100
<DateComponent dateString={podcast.date} />
98101
</div>
102+
{mp3 && (
103+
<PlayerPlayButton src={mp3} />
104+
)}
99105
</div>
100106
</div>
101107
<div className="mb-8 sm:mx-0 md:mb-16">
@@ -132,6 +138,9 @@ export default async function PodcastPage({ params }: Props) {
132138
<div className="mb-4 text-lg">
133139
<DateComponent dateString={podcast.date} />
134140
</div>
141+
{mp3 && (
142+
<PlayerPlayButton src={mp3} />
143+
)}
135144
</div>
136145
</div>
137146
{podcast?.sponsor?.length && (

‎app/(main)/layout.tsx‎

Lines changed: 5 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import Footer from "@/components/footer";
2424
import { Toaster } from "@/components/ui/toaster";
2525
import AvatarDropdown from "./avatar-dropdown";
2626
import AlgoliaDialog from "@/components/algolia-dialog";
27+
import { FaBars } from "react-icons/fa6";
28+
import PlayerFloating from "@/components/player-floating";
2729

2830
const nunito = Nunito({
2931
subsets: ["latin"],
@@ -111,7 +113,7 @@ export default async function RootLayout({
111113
<Sheet>
112114
<SheetTrigger asChild>
113115
<Button className="md:hidden" size="icon" variant="ghost">
114-
<MenuIcon className="w-6 h-6" />
116+
<FaBars className="w-6 h-6" />
115117
<span className="sr-only">Toggle navigation menu</span>
116118
</Button>
117119
</SheetTrigger>
@@ -131,31 +133,11 @@ export default async function RootLayout({
131133
<Footer />
132134
</Suspense>
133135
</section>
136+
<PlayerFloating />
134137
{draftMode().isEnabled && <VisualEditing />}
135138
<SpeedInsights />
136139
</ThemeProvider>
137140
</body>
138141
</html>
139142
);
140-
}
141-
142-
function MenuIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
143-
return (
144-
<svg
145-
{...props}
146-
xmlns="http://www.w3.org/2000/svg"
147-
width="24"
148-
height="24"
149-
viewBox="0 0 24 24"
150-
fill="none"
151-
stroke="currentColor"
152-
strokeWidth="2"
153-
strokeLinecap="round"
154-
strokeLinejoin="round"
155-
>
156-
<line x1="4" x2="20" y1="12" y2="12" />
157-
<line x1="4" x2="20" y1="6" y2="6" />
158-
<line x1="4" x2="20" y1="18" y2="18" />
159-
</svg>
160-
);
161-
}
143+
}

‎components/player-floating.tsx‎

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
"use client"
2+
import { Button } from "@/components/ui/button"
3+
import { Slider } from "@/components/ui/slider"
4+
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuItem } from "@/components/ui/dropdown-menu"
5+
import { usePlayer } from "@/lib/hooks"
6+
import { useEffect } from "react"
7+
import { FaCirclePause, FaPlay, FaForward, FaBackward, FaVolumeHigh, FaShare, FaChevronDown, FaX } from "react-icons/fa6";
8+
9+
export default function PlayerFloating() {
10+
const { audioRef, podcast, setPodcast } = usePlayer();
11+
12+
useEffect(() => {
13+
if (!podcast?.src) {
14+
setPodcast((pod) => {
15+
return {
16+
...pod,
17+
src: 'https://traffic.megaphone.fm/APO7656568543.mp3'
18+
}
19+
})
20+
}
21+
}, []);
22+
23+
return (
24+
<div className="fixed inset-x-0 bottom-0 z-50 bg-background shadow-lg md:rounded-t-xl">
25+
<div className="flex items-center justify-between px-4 py-3 md:px-6 md:py-4">
26+
<div className="flex items-center gap-4">
27+
<Button variant="ghost" size="icon" onClick={() => audioRef.current!.currentTime -= 30.0}>
28+
<FaBackward className="h-5 w-5" />
29+
</Button>
30+
{podcast?.isPlaying ? (
31+
<Button variant="ghost" size="icon" onClick={() => audioRef.current?.pause()}>
32+
<FaCirclePause className="h-5 w-5" />
33+
</Button>
34+
) :
35+
(
36+
<Button variant="ghost" size="icon" onClick={() => audioRef.current?.play()}>
37+
<FaPlay className="h-5 w-5" />
38+
</Button>
39+
)}
40+
<Button variant="ghost" size="icon" onClick={() => audioRef.current!.currentTime += 30.0}>
41+
<FaForward className="h-5 w-5" />
42+
</Button>
43+
</div>
44+
<div className="flex-1">
45+
<Slider
46+
className="[&>span:first-child]:h-1 [&>span:first-child]:bg-primary [&_[role=slider]]:bg-primary [&_[role=slider]]:w-3 [&_[role=slider]]:h-3 [&_[role=slider]]:border-0 [&>span:first-child_span]:bg-primary [&_[role=slider]:focus-visible]:ring-0 [&_[role=slider]:focus-visible]:ring-offset-0 [&_[role=slider]:focus-visible]:scale-105 [&_[role=slider]:focus-visible]:transition-transform"
47+
min={0}
48+
max={podcast.duration}
49+
value={[podcast.curTime]}
50+
onChange={(val) => {
51+
audioRef.current!.currentTime = +val;
52+
}}
53+
/>
54+
</div>
55+
<div className="flex items-center gap-4">
56+
<DropdownMenu>
57+
<DropdownMenuTrigger asChild>
58+
<Button variant="ghost" size="icon">
59+
1x
60+
</Button>
61+
</DropdownMenuTrigger>
62+
<DropdownMenuContent align="end">
63+
<DropdownMenuLabel>Playback Speed</DropdownMenuLabel>
64+
<DropdownMenuSeparator />
65+
<DropdownMenuItem>0.5x</DropdownMenuItem>
66+
<DropdownMenuItem>1x</DropdownMenuItem>
67+
<DropdownMenuItem>1.5x</DropdownMenuItem>
68+
<DropdownMenuItem>2x</DropdownMenuItem>
69+
</DropdownMenuContent>
70+
</DropdownMenu>
71+
<Slider
72+
className="w-20 [&>span:first-child]:h-1 [&>span:first-child]:bg-primary [&_[role=slider]]:bg-primary [&_[role=slider]]:w-3 [&_[role=slider]]:h-3 [&_[role=slider]]:border-0 [&>span:first-child_span]:bg-primary [&_[role=slider]:focus-visible]:ring-0 [&_[role=slider]:focus-visible]:ring-offset-0 [&_[role=slider]:focus-visible]:scale-105 [&_[role=slider]:focus-visible]:transition-transform"
73+
defaultValue={[80]}
74+
/>
75+
<Button variant="ghost" size="icon">
76+
<FaVolumeHigh className="h-5 w-5" />
77+
</Button>
78+
</div>
79+
<div className="ml-auto flex items-center gap-4">
80+
<Button variant="ghost" size="icon">
81+
<FaShare className="h-5 w-5" />
82+
</Button>
83+
<Button variant="ghost" size="icon">
84+
<FaChevronDown className="h-5 w-5" />
85+
</Button>
86+
<Button variant="ghost" size="icon">
87+
<FaX className="h-5 w-5" />
88+
</Button>
89+
</div>
90+
</div>
91+
</div>
92+
)
93+
}

‎components/player-play-button.tsx‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"use client"
2+
import { Button } from "@/components/ui/button"
3+
import { usePlayer } from "@/lib/hooks"
4+
import { useEffect } from "react";
5+
import { FaPlay } from "react-icons/fa6";
6+
7+
export default function PlayerPlayButton({ src }: { src: string }) {
8+
9+
const { audioRef, podcast, setPodcast } = usePlayer();
10+
11+
useEffect(() => {
12+
if (src) {
13+
setPodcast((pod) => {
14+
return {
15+
...pod,
16+
src
17+
}
18+
})
19+
}
20+
}, [src]);
21+
22+
useEffect(() => {
23+
if (src === audioRef.current?.src &&
24+
podcast?.duration === audioRef.current?.duration) {
25+
audioRef.current?.play();
26+
}
27+
}, [podcast]);
28+
29+
30+
return (
31+
<Button variant="ghost" size="icon" onClick={() => audioRef.current?.play()}>
32+
<FaPlay className="h-5 w-5" />
33+
</Button>
34+
)
35+
}

‎lib/hooks.ts‎

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { usePathname } from "next/navigation";
2-
import { useEffect } from "react";
2+
import { useEffect, useRef, useState } from "react";
3+
4+
/*
5+
* General hooks
6+
*
7+
*/
38

49
export function useActivePath(): (path?: string) => boolean {
510
const pathname = usePathname();
@@ -20,3 +25,71 @@ export function useKeyPress(callback: () => void, key: string): void {
2025
});
2126
});
2227
}
28+
29+
/*
30+
* Player hooks
31+
*
32+
*/
33+
34+
export function usePlayer() {
35+
const [isOpen, setIsOpen] = useState(true);
36+
const [isMinimized, setIsMinimized] = useState(false);
37+
//TODO: make this recent
38+
const [podcast, setPodcast] = useState({
39+
id: 1,
40+
src: "",
41+
title: "",
42+
artist: "",
43+
isPlaying: false,
44+
curTime: 0,
45+
duration: 100,
46+
});
47+
const audioRef = useRef<HTMLAudioElement>();
48+
const [volume, setVolume] = useState(100);
49+
50+
useEffect(() => {
51+
if (!podcast?.src || (audioRef.current?.src === podcast.src)) return;
52+
53+
audioRef.current = new Audio(podcast.src);
54+
55+
// volume changer
56+
audioRef.current.addEventListener("volumechange", (e: any) => {
57+
setVolume(+e.target.volume);
58+
});
59+
60+
// play and pause
61+
audioRef.current.addEventListener("play", () => {
62+
setPodcast((p) => { return { ...p, isPlaying: true } });
63+
});
64+
audioRef.current.addEventListener("pause", () => {
65+
setPodcast((p) => { return { ...p, isPlaying: false } });
66+
});
67+
68+
// time and duration
69+
audioRef.current.addEventListener("loadedmetadata", (e: any) => {
70+
setPodcast((p) => {
71+
if (!e.target) return p;
72+
return {
73+
...p, curTime: e.target.currentTime,
74+
duration: e.target.duration,
75+
}
76+
});
77+
78+
});
79+
audioRef.current.addEventListener("timeupdate", (e: any) => {
80+
setPodcast((p) => {
81+
if (!e.target) return p;
82+
return {
83+
...p, curTime: e.target.currentTime,
84+
}
85+
});
86+
});
87+
88+
return () => {
89+
audioRef.current?.pause();
90+
};
91+
}, [podcast.src]);
92+
93+
94+
return { isOpen, setIsOpen, isMinimized, setIsMinimized, podcast, setPodcast, audioRef, volume, setVolume };
95+
}

0 commit comments

Comments
 (0)