|
| 1 | +import { useCycleList } from '@siberiacancode/reactuse'; |
| 2 | +import { PauseIcon, RepeatIcon, ShuffleIcon, SkipBackIcon, SkipForwardIcon } from 'lucide-react'; |
| 3 | + |
| 4 | +import { cn } from '@/utils/lib'; |
| 5 | + |
| 6 | +const TRACKS = [ |
| 7 | + { |
| 8 | + title: 'Resistance', |
| 9 | + artist: 'Muse', |
| 10 | + accent: 'bg-cyan-400', |
| 11 | + cover: 'from-blue-950 via-indigo-700 to-rose-500', |
| 12 | + bars: [4, 8, 5, 9, 6, 12, 7, 10, 5, 8, 11, 6, 9, 4, 7, 12], |
| 13 | + current: 250, |
| 14 | + duration: 346 |
| 15 | + }, |
| 16 | + { |
| 17 | + title: 'Cold Start', |
| 18 | + artist: 'Deep Work', |
| 19 | + accent: 'bg-emerald-400', |
| 20 | + cover: 'from-emerald-950 via-teal-700 to-lime-400', |
| 21 | + bars: [8, 4, 10, 6, 12, 7, 5, 9, 11, 6, 4, 8, 12, 7, 9, 5], |
| 22 | + current: 68, |
| 23 | + duration: 252 |
| 24 | + }, |
| 25 | + { |
| 26 | + title: 'Diff Lights', |
| 27 | + artist: 'Review Flow', |
| 28 | + accent: 'bg-amber-400', |
| 29 | + cover: 'from-zinc-950 via-amber-700 to-orange-400', |
| 30 | + bars: [5, 11, 7, 4, 8, 12, 6, 10, 7, 5, 9, 12, 4, 8, 6, 10], |
| 31 | + current: 136, |
| 32 | + duration: 238 |
| 33 | + }, |
| 34 | + { |
| 35 | + title: 'Tag Cut', |
| 36 | + artist: 'Release Run', |
| 37 | + accent: 'bg-rose-400', |
| 38 | + cover: 'from-rose-950 via-fuchsia-800 to-sky-500', |
| 39 | + bars: [10, 6, 8, 12, 5, 9, 4, 7, 11, 8, 6, 12, 5, 10, 7, 4], |
| 40 | + current: 47, |
| 41 | + duration: 275 |
| 42 | + } |
| 43 | +]; |
| 44 | + |
| 45 | +const formatTime = (seconds: number) => { |
| 46 | + const minutes = Math.floor(seconds / 60); |
| 47 | + const remaining = seconds % 60; |
| 48 | + return `${minutes}:${String(remaining).padStart(2, '0')}`; |
| 49 | +}; |
| 50 | + |
| 51 | +const Demo = () => { |
| 52 | + const { value: track, index, next, prev, go } = useCycleList(TRACKS); |
| 53 | + |
| 54 | + const progress = track.current / track.duration; |
| 55 | + const activeBars = Math.round(track.bars.length * progress); |
| 56 | + |
| 57 | + const goToRandomTrack = () => { |
| 58 | + let nextIndex: number; |
| 59 | + do { |
| 60 | + nextIndex = Math.floor(Math.random() * TRACKS.length); |
| 61 | + } while (nextIndex === index); |
| 62 | + |
| 63 | + go(nextIndex); |
| 64 | + }; |
| 65 | + |
| 66 | + return ( |
| 67 | + <section className='flex w-full max-w-sm flex-col items-center p-4'> |
| 68 | + <div className='relative w-full pb-16'> |
| 69 | + <div className='border-border bg-card relative z-10 flex min-h-[400px] flex-col items-center rounded-[2rem] border p-6 shadow-sm'> |
| 70 | + <div |
| 71 | + className={cn( |
| 72 | + 'relative flex size-46 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-gradient-to-br shadow-lg', |
| 73 | + track.cover |
| 74 | + )} |
| 75 | + > |
| 76 | + <div className='absolute inset-0 bg-[radial-gradient(circle_at_center,transparent_28%,rgb(0_0_0/0.28)_29%,transparent_55%)]' /> |
| 77 | + <div className='absolute inset-6 rounded-full border-[18px] border-white/15' /> |
| 78 | + <div className='absolute inset-14 rounded-full border-[14px] border-white/20' /> |
| 79 | + <div className='absolute bottom-5 h-20 w-11 rounded-t-full bg-white/85' /> |
| 80 | + <span className='absolute top-3 left-3 border-b-2 border-current font-mono text-xs font-bold tracking-tight text-white'> |
| 81 | + MIX |
| 82 | + </span> |
| 83 | + </div> |
| 84 | + |
| 85 | + <div className='mt-10 flex w-full min-w-0 flex-col items-center gap-1 text-center'> |
| 86 | + <h3 className='text-foreground max-w-full truncate text-3xl font-bold'> |
| 87 | + {track.title} |
| 88 | + </h3> |
| 89 | + <span className='text-muted-foreground max-w-full truncate text-base'> |
| 90 | + {track.artist} |
| 91 | + </span> |
| 92 | + </div> |
| 93 | + |
| 94 | + <div className='mt-auto mb-auto flex items-center justify-center gap-2'> |
| 95 | + {TRACKS.map((item, trackIndex) => ( |
| 96 | + <button |
| 97 | + key={item.title} |
| 98 | + className={cn( |
| 99 | + 'size-2.5! rounded-full! p-0!', |
| 100 | + trackIndex === index ? item.accent : 'bg-muted-foreground/40' |
| 101 | + )} |
| 102 | + aria-label={`Select ${item.title}`} |
| 103 | + data-variant='unstyled' |
| 104 | + type='button' |
| 105 | + onClick={() => go(trackIndex)} |
| 106 | + /> |
| 107 | + ))} |
| 108 | + </div> |
| 109 | + |
| 110 | + <div className='flex w-full items-center justify-between gap-3'> |
| 111 | + <button |
| 112 | + aria-label='Repeat' |
| 113 | + data-size='icon-sm' |
| 114 | + data-variant='ghost' |
| 115 | + type='button' |
| 116 | + onClick={() => go(0)} |
| 117 | + > |
| 118 | + <RepeatIcon className='size-4' /> |
| 119 | + </button> |
| 120 | + <button |
| 121 | + aria-label='Previous track' |
| 122 | + data-size='icon' |
| 123 | + data-variant='ghost' |
| 124 | + type='button' |
| 125 | + onClick={() => prev()} |
| 126 | + > |
| 127 | + <SkipBackIcon className='size-5' /> |
| 128 | + </button> |
| 129 | + <button aria-label='Pause' className='size-16! rounded-full!' type='button'> |
| 130 | + <PauseIcon className='size-6' /> |
| 131 | + </button> |
| 132 | + <button |
| 133 | + aria-label='Next track' |
| 134 | + data-size='icon' |
| 135 | + data-variant='ghost' |
| 136 | + type='button' |
| 137 | + onClick={() => next()} |
| 138 | + > |
| 139 | + <SkipForwardIcon className='size-5' /> |
| 140 | + </button> |
| 141 | + <button |
| 142 | + aria-label='Shuffle' |
| 143 | + data-size='icon-sm' |
| 144 | + data-variant='ghost' |
| 145 | + type='button' |
| 146 | + onClick={goToRandomTrack} |
| 147 | + > |
| 148 | + <ShuffleIcon className='size-4' /> |
| 149 | + </button> |
| 150 | + </div> |
| 151 | + </div> |
| 152 | + |
| 153 | + <div className='border-border bg-card absolute right-10 bottom-4 left-10 flex items-center gap-3 rounded-b-3xl border px-4 py-3 shadow-sm'> |
| 154 | + <span className='text-muted-foreground font-mono text-[10px] tabular-nums'> |
| 155 | + {formatTime(track.current)} |
| 156 | + </span> |
| 157 | + |
| 158 | + <div className='flex h-6 flex-1 items-center justify-center gap-1'> |
| 159 | + {track.bars.map((height, barIndex) => ( |
| 160 | + <span |
| 161 | + key={barIndex} |
| 162 | + className={cn( |
| 163 | + 'w-0.5 rounded-full', |
| 164 | + barIndex < activeBars ? track.accent : 'bg-muted-foreground/40' |
| 165 | + )} |
| 166 | + style={{ height: `${height * 2}px` }} |
| 167 | + /> |
| 168 | + ))} |
| 169 | + </div> |
| 170 | + |
| 171 | + <span className='text-muted-foreground font-mono text-[10px] tabular-nums'> |
| 172 | + {formatTime(track.duration)} |
| 173 | + </span> |
| 174 | + </div> |
| 175 | + </div> |
| 176 | + </section> |
| 177 | + ); |
| 178 | +}; |
| 179 | + |
| 180 | +export default Demo; |
0 commit comments