"use client"
import {
BroadcastIcon,
FastForwardIcon,
PauseIcon,
PlayIcon,
RewindIcon,
StarIcon,
} from "@phosphor-icons/react"
import { AnimatePresence, MotionConfig, motion, type Variants } from "motion/react"
import Image from "next/image"
import { useEffect, useMemo, useRef, useState } from "react"
import { useOnClickOutside } from "usehooks-ts"
import { cn } from "@/src/utils"
import styles from "./DynamicIsland.module.css"
import { type IslandTransitionKey, useIslandTuning } from "./tuning"
const TRACK_SECONDS = 241
const START_SECONDS = 31
const BARS = [0.45, 0.8, 1, 0.6, 0.9]
const ARTWORK = "/images/artwork.jpg"
type View = "idle" | "compact" | "expanded"
const DEFAULT_TRANSITION = { duration: 0.45, bounce: 0.4 }
const EXIT: Record<string, { scale: number; scaleX?: number; y: number }> = {
"idle-compact": { scale: 1.15, y: 2 },
"compact-idle": { scale: 0.85, scaleX: 0.9, y: -2 },
"compact-expanded": { scale: 1.5, y: 12 },
"expanded-compact": { scale: 0.55, scaleX: 0.9, y: -12 },
}
const ENTER: Record<string, number> = {
"idle-compact": 4,
"compact-idle": -4,
"compact-expanded": -8,
"expanded-compact": 8,
}
const exitVariants: Variants = {
exit: (custom) => ({ ...custom, opacity: [1, 0], filter: "blur(4px)" }),
}
function formatTime(seconds: number) {
const minutes = Math.floor(seconds / 60)
const rest = Math.floor(seconds % 60)
return `${minutes}:${String(rest).padStart(2, "0")}`
}
function Waveform({ isPlaying, isVisible }: { isPlaying: boolean; isVisible: boolean }) {
return (
<motion.div
layout
className={styles.waveform}
initial={false}
animate={{ opacity: isVisible ? 1 : 0 }}
>
{BARS.map((height, index) => (
<motion.span
key={`${height}-${index.toString()}`}
className={styles.bar}
style={{ height: `${height * 100}%` }}
animate={isPlaying ? { scaleY: [1, 0.35, 1] } : { scaleY: 0.35 }}
transition={
isPlaying
? {
duration: 0.75 + index * 0.14,
repeat: Number.POSITIVE_INFINITY,
ease: "easeInOut",
}
: { duration: 0.2 }
}
/>
))}
</motion.div>
)
}
interface ExpandedViewProps {
elapsed: number
isPlaying: boolean
onTogglePlay: () => void
}
function ExpandedView({ elapsed, isPlaying, onTogglePlay }: ExpandedViewProps) {
return (
<div className={styles.expanded}>
<div className={styles.header}>
<div className={styles.meta}>
<p className={styles.title}>{"Paper Lanterns"}</p>
<p className={styles.artist}>{"Marlow Vane"}</p>
</div>
</div>
<div className={styles.progress}>
<span className={styles.time}>{formatTime(elapsed)}</span>
<div className={styles.track}>
<div className={styles.fill} style={{ width: `${(elapsed / TRACK_SECONDS) * 100}%` }} />
</div>
<span className={styles.time}>{`-${formatTime(TRACK_SECONDS - elapsed)}`}</span>
</div>
<div className={styles.controls}>
<button
type={"button"}
aria-label={"Favourite"}
className={cn(styles.control, styles.controlDimmed)}
>
<StarIcon size={22} />
</button>
<button type={"button"} aria-label={"Previous"} className={styles.control}>
<RewindIcon size={26} weight={"fill"} />
</button>
<button
type={"button"}
aria-label={isPlaying ? "Pause" : "Play"}
className={styles.controlMain}
onClick={onTogglePlay}
>
<AnimatePresence mode={"wait"} initial={false}>
<motion.span
key={isPlaying ? "pause" : "play"}
className={styles.controlIcon}
initial={{ opacity: 0, scale: 0.6 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.6 }}
transition={{ duration: 0.14 }}
>
{isPlaying ? (
<PauseIcon size={30} weight={"fill"} />
) : (
<PlayIcon size={30} weight={"fill"} />
)}
</motion.span>
</AnimatePresence>
</button>
<button type={"button"} aria-label={"Next"} className={styles.control}>
<FastForwardIcon size={26} weight={"fill"} />
</button>
<button
type={"button"}
aria-label={"AirPlay"}
className={cn(styles.control, styles.controlDimmed)}
>
<BroadcastIcon size={22} />
</button>
</div>
</div>
)
}
const TAP_TARGET: Record<View, View> = {
idle: "compact",
compact: "expanded",
expanded: "compact",
}
const DISMISS_TARGET: Record<View, View> = {
idle: "idle",
compact: "idle",
expanded: "compact",
}
const TAP_LABEL: Record<View, string> = {
idle: "Start playing",
compact: "Expand the island",
expanded: "Collapse the island",
}
export function DynamicIsland() {
const transitions = useIslandTuning()
const [view, setView] = useState<View>("idle")
const [variantKey, setVariantKey] = useState("idle")
const [elapsed, setElapsed] = useState(START_SECONDS)
const [isPlaying, setIsPlaying] = useState(true)
const islandRef = useRef<HTMLDivElement>(null)
function switchTo(next: View) {
if (next === view) return
setVariantKey(`${view}-${next}`)
setView(next)
if (next === "idle") {
setElapsed(START_SECONDS)
setIsPlaying(true)
}
}
useOnClickOutside(islandRef as React.RefObject<HTMLDivElement>, () =>
switchTo(DISMISS_TARGET[view]),
)
useEffect(() => {
if (view === "idle" || !isPlaying) return
const id = setInterval(() => setElapsed((prev) => (prev + 1) % TRACK_SECONDS), 1000)
return () => clearInterval(id)
}, [view, isPlaying])
useEffect(() => {
if (view === "idle") return
function onKeyDown(event: KeyboardEvent) {
if (event.key !== "Escape") return
const next = DISMISS_TARGET[view]
setVariantKey(`${view}-${next}`)
setView(next)
if (next === "idle") {
setElapsed(START_SECONDS)
setIsPlaying(true)
}
}
window.addEventListener("keydown", onKeyDown)
return () => window.removeEventListener("keydown", onKeyDown)
}, [view])
const content = useMemo(
() =>
view === "expanded" ? (
<ExpandedView
elapsed={elapsed}
isPlaying={isPlaying}
onTogglePlay={() => setIsPlaying((prev) => !prev)}
/>
) : null,
[view, elapsed, isPlaying],
)
return (
<div className={styles.demo}>
<MotionConfig
reducedMotion={"user"}
transition={{
type: "spring",
...(transitions[variantKey as IslandTransitionKey] ?? DEFAULT_TRANSITION),
}}
>
<div className={styles.stage}>
<motion.div
ref={islandRef}
layout
data-view={view}
className={styles.island}
style={{ borderRadius: 32 }}
>
<button
type={"button"}
aria-label={TAP_LABEL[view]}
className={styles.surface}
onClick={() => switchTo(TAP_TARGET[view])}
/>
{
}
<motion.div
layout
className={styles.artwork}
initial={false}
animate={{
borderRadius: view === "expanded" ? 12 : 7,
opacity: view === "idle" ? 0 : 1,
}}
>
<Image src={ARTWORK} alt={""} fill sizes={"56px"} />
</motion.div>
<Waveform isPlaying={isPlaying} isVisible={view !== "idle"} />
{
}
<motion.div layout className={styles.layer}>
<motion.div
key={view}
initial={{ y: ENTER[variantKey] ?? 0, scale: 0.9, opacity: 0, filter: "blur(4px)" }}
animate={{
y: 0,
scale: 1,
opacity: 1,
filter: "blur(0px)",
transition: { delay: 0.05 },
}}
>
{content}
</motion.div>
</motion.div>
{
}
<motion.div layout className={styles.ghost} aria-hidden={true} inert>
<AnimatePresence mode={"popLayout"} custom={EXIT[variantKey]}>
<motion.div
key={view}
initial={{ opacity: 0 }}
exit={"exit"}
variants={exitVariants}
>
{content}
</motion.div>
</AnimatePresence>
</motion.div>
</motion.div>
</div>
</MotionConfig>
</div>
)
}