├── app ├── helpers │ ├── dl.server.ts │ ├── time.ts │ ├── IconButton.tsx │ ├── useSafeContext.ts │ ├── useInterval.ts │ ├── IconButton.css │ ├── Button.css │ ├── Button.tsx │ ├── Slider.css │ ├── useAudio.ts │ ├── search.server.ts │ └── Slider.tsx ├── entry.client.tsx ├── routes │ ├── $id.css │ ├── resource │ │ ├── $id.ts │ │ └── $id.$clip.ts │ ├── $id │ │ ├── $clip.css │ │ ├── index.css │ │ ├── index.tsx │ │ └── $clip.tsx │ ├── $id.tsx │ ├── index.css │ └── index.tsx ├── entry.server.tsx ├── root.tsx └── root.css ├── public ├── favicon.ico ├── Unica-One.woff2 └── apple-touch-icon.png ├── remix.env.d.ts ├── .gitignore ├── vercel.json ├── server.js ├── overrides.d.ts ├── tsconfig.json ├── remix.config.js ├── README.md ├── LICENSE └── package.json /app/helpers/dl.server.ts: -------------------------------------------------------------------------------- 1 | export { default as dl } from 'ytdl-core'; 2 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayank99/riffs/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/Unica-One.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayank99/riffs/HEAD/public/Unica-One.woff2 -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayank99/riffs/HEAD/public/apple-touch-icon.png -------------------------------------------------------------------------------- /remix.env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | import { hydrate } from 'react-dom'; 2 | import { RemixBrowser } from 'remix'; 3 | 4 | hydrate(, document); 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | .cache 4 | .env 5 | .vercel 6 | .output 7 | 8 | /build/ 9 | /public/build 10 | /api/index.js 11 | /api/index.js.map 12 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "build": { 3 | "env": { 4 | "ENABLE_FILE_SYSTEM_API": "1" 5 | } 6 | }, 7 | "github": { 8 | "silent": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | import { createRequestHandler } from '@remix-run/vercel'; 2 | import * as build from '@remix-run/dev/server-build'; 3 | 4 | export default createRequestHandler({ build, mode: process.env.NODE_ENV }); 5 | -------------------------------------------------------------------------------- /app/routes/$id.css: -------------------------------------------------------------------------------- 1 | h2 { 2 | font-weight: 400; 3 | font-size: 1.2rem; 4 | max-width: 90vw; 5 | white-space: pre-line; 6 | text-align: center; 7 | } 8 | 9 | h2::first-line { 10 | font-size: 1.4rem; 11 | } 12 | -------------------------------------------------------------------------------- /overrides.d.ts: -------------------------------------------------------------------------------- 1 | import 'react'; // eslint-disable-line react/no-typos 2 | 3 | // add inline CSS variable support 4 | declare module 'react' { 5 | interface CSSProperties { 6 | [key: `--${string}`]: string | number; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /app/helpers/time.ts: -------------------------------------------------------------------------------- 1 | export const formatToMinutesAndSeconds = (value: number) => { 2 | const minutes = Math.floor(value / 60); 3 | const seconds = Math.floor(value % 60); 4 | return `${minutes}:${seconds.toString().padStart(2, '0')}`; 5 | }; 6 | -------------------------------------------------------------------------------- /app/helpers/IconButton.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import styles from './IconButton.css'; 3 | 4 | /** 5 | * Icon button that supports switching between one or more icons. 6 | * Pass svg icons as children, with `data-active='true'` for the one to show. 7 | */ 8 | export const IconButton = ({ className, ...rest }: React.ComponentProps<'button'>) => { 9 | return 120 | {transition.state !== 'idle' &&

Creating clip

} 121 | 122 | 123 | 124 | {transition.state === 'idle' && actionData?.error ? `Error: ${actionData.error}` : ' '} 125 | 126 | 127 | ); 128 | } 129 | -------------------------------------------------------------------------------- /app/routes/$id/$clip.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useHref, useMatches, useParams, useTransition } from 'remix'; 3 | import type { HeadersFunction, LinksFunction, MetaFunction } from 'remix'; 4 | import { useAudio } from '~/helpers/useAudio'; 5 | import { Button } from '~/helpers/Button'; 6 | import { Slider } from '~/helpers/Slider'; 7 | import { IconButton } from '~/helpers/IconButton'; 8 | import { formatToMinutesAndSeconds } from '~/helpers/time'; 9 | import { Share2, Youtube, Play, Pause, Volume, Volume1, Volume2, VolumeX } from 'react-feather'; 10 | import { useSliderState } from '@react-stately/slider'; 11 | import styles from './$clip.css'; 12 | 13 | export const links: LinksFunction = () => [ 14 | { rel: 'stylesheet', href: Slider.styles }, 15 | { rel: 'stylesheet', href: Button.styles }, 16 | { rel: 'stylesheet', href: IconButton.styles }, 17 | { rel: 'stylesheet', href: styles }, 18 | ]; 19 | 20 | export const headers: HeadersFunction = () => ({ 21 | 'Cache-Control': 's-maxage=31536000, max-age=31536000', 22 | }); 23 | 24 | export const meta: MetaFunction = ({ params, parentsData }) => { 25 | const { songName, artist } = parentsData['routes/$id']; 26 | const [start, end] = (params.clip as string).split(',').map(Number).map(formatToMinutesAndSeconds); 27 | 28 | return { 29 | title: `riffs | ${songName} - ${artist}`, 30 | 'og:title': `riffs | ${songName} - ${artist}`, 31 | 'twitter:title': `riffs | ${songName} - ${artist}`, 32 | description: `Listen to ${start}-${end} from "${songName} - ${artist}"`, 33 | 'og:description': `Listen to ${start}-${end} from "${songName} - ${artist}"`, 34 | 'twitter:description': `Listen to ${start}-${end} from "${songName} - ${artist}"`, 35 | }; 36 | }; 37 | 38 | export default function $clip() { 39 | const { id, clip } = useParams(); 40 | const transition = useTransition(); 41 | 42 | if (!id || !clip) { 43 | throw new Error('Invalid route'); 44 | } 45 | 46 | const { songName, artist } = useMatches().find(({ pathname }) => pathname == `/${id}`)?.data!; 47 | const currentHref = useHref(''); 48 | const fileRef = React.useRef(); 49 | const [start, end] = clip.split(',').map(Number); 50 | const [clipboardMessage, setClipboardMessage] = React.useState(''); 51 | 52 | const { 53 | isPlaying, 54 | setIsPlaying, 55 | currentTime, 56 | setCurrentTime, 57 | volume, 58 | setVolume, 59 | playbackRate, 60 | setPlaybackRate, 61 | audioRef, 62 | } = useAudio(`/resource/${id}/${clip}`); 63 | 64 | // stop audio when navigating away 65 | if (transition.state === 'loading') { 66 | audioRef.current?.pause(); 67 | } 68 | 69 | const duration = audioRef.current?.duration ?? Math.round(end - start); 70 | 71 | React.useEffect(() => { 72 | (async () => { 73 | const blob = await fetch(`/resource/${id}/${clip}`).then((res) => res.blob()); 74 | fileRef.current = new File([blob], `${songName}-${clip}.mp3`, { type: 'audio/mpeg' }); 75 | })(); 76 | }, [clip, id, songName]); 77 | 78 | const currentTimeSliderState = useSliderState({ 79 | numberFormatter: React.useMemo(() => new Intl.NumberFormat(), []), 80 | minValue: start, 81 | maxValue: end, 82 | defaultValue: [start], 83 | value: [start + currentTime], 84 | onChange: React.useCallback(([value]) => setCurrentTime(value - start), [setCurrentTime, start]), 85 | }); 86 | 87 | const handleShare = () => { 88 | if (fileRef.current && navigator.canShare?.({ files: [fileRef.current] })) { 89 | navigator.share({ 90 | files: [fileRef.current], 91 | text: `Check out this riff from ${artist}`, 92 | url: `${window.origin}${currentHref}`, 93 | }); 94 | } else if (navigator.share) { 95 | navigator.share({ 96 | title: `Check out this riff from ${artist}`, 97 | text: `${window.origin}${currentHref}`, 98 | }); 99 | } else { 100 | navigator.clipboard.writeText(`${window.origin}${currentHref}`); 101 | setClipboardMessage('Copied shareable url to clipboard.'); 102 | setTimeout(() => setClipboardMessage(''), 3000); // clear message after 3 seconds 103 | } 104 | }; 105 | 106 | return ( 107 | <> 108 |
109 | { 112 | switch (volume) { 113 | case 0: 114 | return setVolume(0.3); 115 | case 0.3: 116 | return setVolume(0.6); 117 | case 0.6: 118 | return setVolume(0.9); 119 | case 0.9: 120 | return setVolume(0); 121 | } 122 | }} 123 | > 124 | 125 | 126 | 127 | 128 | 129 | 130 | setIsPlaying((p) => !p)} aria-label='Play/pause the song'> 131 | 132 | 133 | 134 | 135 | { 137 | switch (playbackRate) { 138 | case 0.5: 139 | return setPlaybackRate(1); 140 | case 1: 141 | return setPlaybackRate(1.5); 142 | case 1.5: 143 | return setPlaybackRate(2); 144 | case 2: 145 | return setPlaybackRate(0.5); 146 | } 147 | }} 148 | > 149 | 150 | 0.5x 151 | 152 | 153 | 1x 154 | 155 | 156 | 1.5x 157 | 158 | 159 | 2x 160 | 161 | 162 |
163 | 164 |
165 |
{formatToMinutesAndSeconds(start)}
166 | 167 | 177 | 178 | 179 | 180 |
{formatToMinutesAndSeconds(end)}
181 |
182 | 183 |
184 | 188 | 192 |
193 | 194 | {clipboardMessage} 195 | 196 | ); 197 | } 198 | --------------------------------------------------------------------------------