├── 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 ; 10 | }; 11 | IconButton.styles = styles; 12 | -------------------------------------------------------------------------------- /app/helpers/useSafeContext.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | /** Wrapper around useContext that throws an error if the context is not provided */ 4 | export const useSafeContext = (context: React.Context) => { 5 | const value = React.useContext(context); 6 | if (value == undefined) { 7 | throw new Error(`${context.displayName} must be used inside ${context.displayName}.Provider`); 8 | } 9 | return value!; // this cannot be undefined, so we can destructure from it 10 | }; 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"], 3 | "compilerOptions": { 4 | "lib": ["DOM", "DOM.Iterable", "ES2019"], 5 | "isolatedModules": true, 6 | "esModuleInterop": true, 7 | "jsx": "react-jsx", 8 | "moduleResolution": "node", 9 | "resolveJsonModule": true, 10 | "target": "ES2019", 11 | "strict": true, 12 | "baseUrl": ".", 13 | "paths": { 14 | "~/*": ["./app/*"] 15 | }, 16 | "noEmit": true, 17 | "forceConsistentCasingInFileNames": true, 18 | "allowJs": true 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /remix.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('@remix-run/dev').AppConfig} 3 | */ 4 | module.exports = { 5 | serverBuildTarget: 'vercel', 6 | // When running locally in development mode, we use the built in remix 7 | // server. This does not understand the vercel lambda module format, 8 | // so we default back to the standard build output. 9 | server: process.env.NODE_ENV === 'development' ? undefined : './server.js', 10 | ignoredRouteFiles: ['.*', '**/*.css'], 11 | // appDirectory: "app", 12 | // assetsBuildDirectory: "public/build", 13 | // serverBuildPath: "api/index.js", 14 | // publicPath: "/build/", 15 | }; 16 | -------------------------------------------------------------------------------- /app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import { renderToString } from 'react-dom/server'; 2 | import { RemixServer } from 'remix'; 3 | import type { EntryContext } from 'remix'; 4 | 5 | export default function handleRequest( 6 | request: Request, 7 | responseStatusCode: number, 8 | responseHeaders: Headers, 9 | remixContext: EntryContext 10 | ) { 11 | let markup = renderToString(); 12 | 13 | responseHeaders.set('Content-Type', 'text/html'); 14 | 15 | return new Response('' + markup, { 16 | status: responseStatusCode, 17 | headers: responseHeaders, 18 | }); 19 | } 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # riffs 2 | 3 | Riffs lets you easily clip sections of a song ("riffs"). 4 | 5 | Give it a try at [riffs.run](http://riffs.run). Search for a song, drag the slider around to select start/end times, then create a clip! 6 | 7 | ## Development 8 | 9 | This app is built using [`remix`](https://remix.run). Refer to their documentation for more details. 10 | 11 | To run this app locally, clone the repo, create a `.env` file with `YOUTUBE_MUSIC_KEY`, then run the following commands: 12 | 13 | ```sh 14 | npm install 15 | ``` 16 | 17 | ```sh 18 | npm run dev 19 | ``` 20 | 21 | Open up [http://localhost:3000](http://localhost:3000) and you should be ready to go! 22 | -------------------------------------------------------------------------------- /app/helpers/useInterval.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | /** 4 | * Dan Abramov's declarative hook around `setInterval` 5 | * @see https://overreacted.io/making-setinterval-declarative-with-react-hooks/ 6 | */ 7 | export const useInterval = (callback: Function, delay: number | null) => { 8 | const savedCallback = React.useRef(); 9 | 10 | React.useEffect(() => { 11 | savedCallback.current = callback; 12 | }, [callback]); 13 | 14 | React.useEffect(() => { 15 | const tick = () => void savedCallback.current?.(); 16 | if (delay !== null) { 17 | const id = setInterval(tick, delay); 18 | return () => clearInterval(id); 19 | } 20 | }, [delay]); 21 | }; 22 | -------------------------------------------------------------------------------- /app/routes/resource/$id.ts: -------------------------------------------------------------------------------- 1 | import type { LoaderFunction } from 'remix'; 2 | import { dl } from '~/helpers/dl.server'; 3 | 4 | export const loader: LoaderFunction = async ({ params }) => { 5 | const { id: idOrUrl = '' } = params; 6 | const stream = dl(idOrUrl, { 7 | filter: (format) => format.hasAudio && !format.hasVideo, 8 | quality: 'lowestaudio', 9 | }); 10 | 11 | let buffer = Buffer.from([]); 12 | for await (const chunk of stream) { 13 | buffer = Buffer.concat([buffer, chunk]); 14 | } 15 | 16 | return new Response(buffer, { 17 | headers: { 18 | 'Content-Length': `${buffer.length}`, 19 | 'Cache-Control': 's-maxage=31536000, max-age=31536000', 20 | 'Content-Type': 'audio/webm', 21 | }, 22 | }); 23 | }; 24 | -------------------------------------------------------------------------------- /app/routes/$id/$clip.css: -------------------------------------------------------------------------------- 1 | .media-controls { 2 | display: flex; 3 | gap: 0.25rem; 4 | } 5 | 6 | .playback-rate { 7 | display: inline-grid; 8 | place-items: center; 9 | font-size: 0.9rem; 10 | } 11 | 12 | .media-slider-wrapper { 13 | display: flex; 14 | align-items: center; 15 | gap: 1rem; 16 | margin: 1rem auto; 17 | } 18 | 19 | .media-slider-wrapper > :first-child, 20 | .media-slider-wrapper > :last-child { 21 | color: hsl(var(--fg-hsl) / 0.7); 22 | } 23 | 24 | .media-slider-wrapper > .slider { 25 | width: min(400px, calc(90vw - 15ch)); 26 | } 27 | 28 | .progress-thumb { 29 | border-radius: 2px; 30 | width: 0.25rem; 31 | height: 2rem; 32 | background: var(--accent-color); 33 | } 34 | 35 | .button-bar { 36 | margin-top: 1rem; 37 | display: flex; 38 | flex-wrap: wrap; 39 | gap: 0.75rem; 40 | justify-content: center; 41 | max-width: 75vw; 42 | } 43 | 44 | .clipboard-status { 45 | font-style: italic; 46 | } 47 | -------------------------------------------------------------------------------- /app/routes/$id.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import type { LinksFunction, LoaderFunction } from 'remix'; 3 | import { useLoaderData, Outlet } from 'remix'; 4 | import { dl } from '~/helpers/dl.server'; 5 | import styles from './$id.css'; 6 | 7 | export const links: LinksFunction = () => [{ rel: 'stylesheet', href: styles }]; 8 | 9 | export const loader: LoaderFunction = async ({ params }) => { 10 | const { id = '' } = params; 11 | const { videoDetails } = await dl.getBasicInfo(id, { requestOptions: {} }); 12 | 13 | return { 14 | songName: videoDetails.media.song ?? videoDetails.title, 15 | artist: videoDetails.media.artist ?? videoDetails.ownerChannelName.replace(' - Topic', ''), 16 | duration: videoDetails.lengthSeconds, 17 | }; 18 | }; 19 | 20 | export default function $id() { 21 | const { songName, artist } = useLoaderData(); 22 | 23 | return ( 24 | <> 25 | 26 | {songName} 27 | {'\n'} 28 | {artist} 29 | 30 | 31 | > 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /app/helpers/IconButton.css: -------------------------------------------------------------------------------- 1 | .icon-button { 2 | all: unset; 3 | cursor: pointer; 4 | position: relative; 5 | display: flex; 6 | align-items: center; 7 | justify-content: center; 8 | width: 2.5rem; 9 | height: 2.5rem; 10 | border-radius: 50%; 11 | transition: transform 0.2s ease-out; 12 | border-radius: 50%; 13 | margin: 0.5rem; 14 | -webkit-tap-highlight-color: transparent; 15 | } 16 | 17 | .icon-button:hover, 18 | .icon-button:focus { 19 | transform: scale(1.1); 20 | transition: transform 0.2s ease-in; 21 | background: hsl(0 0% 50% / 0.1); 22 | } 23 | 24 | .icon-button:focus-visible { 25 | outline: 1px solid; 26 | } 27 | 28 | .icon-button:disabled { 29 | opacity: 0.5; 30 | transform: none; 31 | background: transparent; 32 | cursor: not-allowed; 33 | } 34 | 35 | .icon-button > * { 36 | width: 1.5rem; 37 | height: 1.5rem; 38 | opacity: 0; 39 | position: absolute; 40 | transition: opacity 0.2s ease-in; 41 | } 42 | 43 | .icon-button > [data-active='true'] { 44 | opacity: 1; 45 | position: static; 46 | transition: opacity 0.2s ease-out; 47 | } 48 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Mayank 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /app/routes/$id/index.css: -------------------------------------------------------------------------------- 1 | .media-controls { 2 | display: flex; 3 | gap: 0.25rem; 4 | } 5 | 6 | .slider-container { 7 | display: grid; 8 | margin: 1rem auto; 9 | } 10 | 11 | /** 12 | * sliders need to overlap to fake the impression that it's a single slider. 13 | * this is a workaround needed because react aria does not allow thumbs to "cross" each other. 14 | */ 15 | .slider-container > * { 16 | grid-area: 1 / -1; 17 | } 18 | 19 | .clip-thumb { 20 | z-index: 2; 21 | border-radius: 2px; 22 | background: var(--accent-color); 23 | } 24 | 25 | .progress-thumb { 26 | z-index: 1; 27 | width: 2px; 28 | height: 2rem; 29 | background: hsl(var(--fg-hsl) / 0.7); 30 | } 31 | 32 | /* always show the output "tooltip" and move it above the thumb to avoid collision */ 33 | .progress-thumb > .output { 34 | opacity: 1; 35 | background: transparent; 36 | transform: translateY(-100%); 37 | } 38 | 39 | @keyframes loading { 40 | to { 41 | transform: rotate(1turn); 42 | } 43 | } 44 | 45 | form { 46 | margin-top: 1rem; 47 | } 48 | 49 | .error { 50 | font-style: italic; 51 | color: hsl(var(--fg-hsl) / 0.7); 52 | } 53 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "riffs", 3 | "private": true, 4 | "description": "", 5 | "license": "", 6 | "sideEffects": false, 7 | "scripts": { 8 | "build": "remix build", 9 | "dev": "remix dev", 10 | "postinstall": "remix setup node" 11 | }, 12 | "dependencies": { 13 | "@ffmpeg-installer/ffmpeg": "^1.1.0", 14 | "@reach/combobox": "^0.16.5", 15 | "@react-aria/slider": "^3.0.7", 16 | "@react-aria/ssr": "^3.1.2", 17 | "@react-stately/slider": "^3.0.7", 18 | "@remix-run/react": "~1.4.3", 19 | "@remix-run/vercel": "~1.4.3", 20 | "react": "^17.0.2", 21 | "react-dom": "^17.0.2", 22 | "react-feather": "^2.0.9", 23 | "remix": "~1.4.3", 24 | "ytdl-core": "^4.11.2" 25 | }, 26 | "devDependencies": { 27 | "@remix-run/dev": "~1.4.3", 28 | "@remix-run/eslint-config": "~1.4.3", 29 | "@remix-run/serve": "~1.4.3", 30 | "@types/react": "^17.0.24", 31 | "@types/react-dom": "^17.0.9", 32 | "eslint": "^8.11.0", 33 | "patch-package": "^6.4.7", 34 | "typescript": "^4.5.5" 35 | }, 36 | "engines": { 37 | "node": ">=14" 38 | }, 39 | "prettier": { 40 | "printWidth": 120, 41 | "singleQuote": true, 42 | "jsxSingleQuote": true, 43 | "endOfLine": "lf" 44 | }, 45 | "eslintConfig": { 46 | "extends": [ 47 | "@remix-run/eslint-config", 48 | "@remix-run/eslint-config/node" 49 | ] 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /app/root.tsx: -------------------------------------------------------------------------------- 1 | import { Link, Links, LiveReload, Meta, Outlet, Scripts, ScrollRestoration } from 'remix'; 2 | import type { MetaFunction, LinksFunction } from 'remix'; 3 | import styles from './root.css'; 4 | import { SSRProvider } from '@react-aria/ssr'; 5 | 6 | export const links: LinksFunction = () => [ 7 | { rel: 'stylesheet', href: styles }, 8 | { rel: 'icon', href: '/favicon.ico', sizes: 'any' }, 9 | { rel: 'apple-touch-icon', href: '/apple-touch-icon.png' }, 10 | ]; 11 | 12 | export const meta: MetaFunction = () => ({ 13 | charset: 'utf-8', 14 | viewport: 'width=device-width,initial-scale=1', 15 | title: 'riffs', 16 | 'og:title': 'riffs', 17 | 'twitter:title': 'riffs', 18 | description: 'Create and share riffs by clipping parts of songs', 19 | 'og:description': 'Create and share riffs by clipping parts of songs', 20 | 'twitter:description': 'Create and share riffs by clipping parts of songs', 21 | 'og:type': 'website', 22 | }); 23 | 24 | export default function App() { 25 | return ( 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | Riffs 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /app/helpers/Button.css: -------------------------------------------------------------------------------- 1 | .button { 2 | all: unset; 3 | cursor: pointer; 4 | position: relative; 5 | -webkit-tap-highlight-color: transparent; 6 | } 7 | 8 | .button-content { 9 | border-radius: 0.75rem; 10 | text-transform: uppercase; 11 | border: 1px solid transparent; 12 | padding: 0.75rem 1.75rem; 13 | --font-size: clamp(1.2rem, 1.2rem + 0.25vw, 1.5rem); 14 | font-size: var(--font-size); 15 | line-height: var(--font-size); 16 | background: var(--accent-color); 17 | color: hsl(0 0% 100% / 0.9); 18 | transition: transform 0.2s; 19 | will-change: transform; 20 | display: flex; 21 | gap: 0.5rem; 22 | align-items: center; 23 | } 24 | 25 | .button-content svg { 26 | width: 1.2rem; 27 | height: 1.2rem; 28 | } 29 | 30 | .button:hover .button-content { 31 | transform: translateY(-2px); 32 | } 33 | 34 | .button:focus-visible .button-content { 35 | outline: 2px solid var(--accent-color); 36 | outline-offset: 2px; 37 | } 38 | 39 | .button[data-state='loading'] .button-content { 40 | background: hsl(0 0% 50% / 0.25); 41 | opacity: 0.7; 42 | cursor: wait; 43 | transform: none; 44 | } 45 | 46 | .button-loader { 47 | position: absolute; 48 | width: 1.5rem; 49 | height: 1.5rem; 50 | right: 0; 51 | top: 50%; 52 | transform: translate(125%, -50%); 53 | } 54 | 55 | .button-loader::after { 56 | content: ''; 57 | position: absolute; 58 | width: 1.5rem; 59 | height: 1.5rem; 60 | border-radius: 50%; 61 | border: 2px solid; 62 | animation: loading 1s ease-in-out infinite; 63 | --loader-color: hsl(0 0% 50%); 64 | border-color: var(--loader-color) var(--loader-color) transparent transparent; 65 | } 66 | 67 | .button.outline .button-content { 68 | background: transparent; 69 | border: 1px solid hsl(var(--fg-hsl) / 0.9); 70 | } 71 | -------------------------------------------------------------------------------- /app/helpers/Button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import styles from './Button.css'; 3 | 4 | /** 5 | * A button that supports primary (cta), secondary (outline), and loading styles. 6 | * Can be rendered as a button or as a link if `href` is passed 7 | */ 8 | export const Button: Overloaded = ({ loading, variant = 'cta', children, className, ...rest }) => { 9 | const props: (ButtonProps | AnchorProps) & { [k: string]: unknown } = { 10 | className: `button ${variant === 'outline' ? 'outline' : ''} ${className ?? ''}`, 11 | 'data-state': loading ? 'loading' : undefined, 12 | ...rest, 13 | children: ( 14 | 15 | {children} 16 | {loading && } 17 | 18 | ), 19 | }; 20 | 21 | if (hasHref(props)) { 22 | return ; // eslint-disable-line jsx-a11y/anchor-has-content -- content comes from children 23 | } else { 24 | return ; 25 | } 26 | }; 27 | 28 | Button.styles = styles; 29 | 30 | // ---------------------------------------------------------------------------------------- 31 | // all TS stuff lives below 32 | // lesson to future self: just create multiple components, polymorphism is never worth it 33 | // ---------------------------------------------------------------------------------------- 34 | 35 | const hasHref = (props: ButtonProps | AnchorProps): props is AnchorProps => 'href' in props; 36 | type ExtraProps = { variant?: 'cta' | 'outline'; loading?: boolean }; 37 | type ButtonProps = React.ComponentProps<'button'> & { href?: undefined } & ExtraProps; 38 | type AnchorProps = React.ComponentProps<'a'> & { href?: string } & ExtraProps; 39 | type Overloaded = { 40 | (props: ButtonProps): JSX.Element; 41 | (props: AnchorProps): JSX.Element; 42 | styles: string; 43 | }; 44 | -------------------------------------------------------------------------------- /app/routes/resource/$id.$clip.ts: -------------------------------------------------------------------------------- 1 | import type { LoaderFunction } from 'remix'; 2 | import { dl } from '~/helpers/dl.server'; 3 | import fs from 'fs'; 4 | import os from 'os'; 5 | import path from 'path'; 6 | 7 | const spawn = require('child_process').spawn; 8 | const ffmpegPath = require('@ffmpeg-installer/ffmpeg').path; 9 | 10 | export const loader: LoaderFunction = async ({ params }) => { 11 | const { id: idOrUrl = '', clip = '' } = params; 12 | const originalStream = dl(idOrUrl, { 13 | filter: (format) => format.hasAudio && !format.hasVideo && format.container === 'mp4', 14 | quality: 'highestaudio', 15 | }); 16 | 17 | const [start, end] = clip.split(',').map((x) => parseInt(x, 10)); 18 | if (end - start > 100) { 19 | return new Response('clip too long', { status: 418 }); 20 | } 21 | 22 | const inputPath = path.join(os.tmpdir(), 'input.mp4'); 23 | if (fs.existsSync(inputPath)) { 24 | fs.promises.unlink(inputPath); 25 | } 26 | 27 | const outputPath = path.join(os.tmpdir(), 'output.mp4'); 28 | if (fs.existsSync(outputPath)) { 29 | fs.promises.unlink(outputPath); 30 | } 31 | 32 | await fs.promises.writeFile(inputPath, originalStream); 33 | 34 | const runFfmpeg = new Promise((resolve) => { 35 | spawn(ffmpegPath, ['-i', inputPath, '-ss', `${start}`, '-t', `${end - start}`, '-acodec', 'copy', outputPath], { 36 | stdio: ['inherit'], 37 | }).on('close', () => { 38 | fs.promises.rm(inputPath); 39 | resolve(null); 40 | }); 41 | }); 42 | 43 | const getTruncatedSongName = dl.getInfo(idOrUrl).then((info) => info?.videoDetails.media.song?.substring(0, 20)); 44 | 45 | // run ffmpeg and fetch metadata at the same time 46 | const [songName] = await Promise.all([getTruncatedSongName, runFfmpeg]); 47 | 48 | const output = await fs.promises.readFile(outputPath); 49 | 50 | fs.promises.rm(outputPath); 51 | 52 | return new Response(output.buffer, { 53 | status: 200, 54 | headers: { 55 | 'Content-Type': `audio/mp4`, 56 | 'Content-Disposition': `filename="${songName}.${start}-${end}.mp4"`, 57 | 'Cache-Control': 's-maxage=31536000, max-age=31536000', 58 | // 'Content-Length': `${output.length}`, 59 | }, 60 | }); 61 | }; 62 | -------------------------------------------------------------------------------- /app/helpers/Slider.css: -------------------------------------------------------------------------------- 1 | .slider { 2 | position: relative; 3 | display: flex; 4 | flex-direction: column; 5 | gap: 0.5rem; 6 | align-items: center; 7 | width: min(500px, 85vw); 8 | touch-action: none; 9 | } 10 | 11 | .track { 12 | position: relative; 13 | height: 40px; 14 | width: 100%; 15 | display: flex; 16 | align-items: center; 17 | cursor: pointer; 18 | -webkit-tap-highlight-color: transparent; 19 | } 20 | 21 | .track::before { 22 | content: ''; 23 | background-color: hsl(0 0% 0% / 0.25); 24 | height: 4px; 25 | width: 100%; 26 | } 27 | 28 | .track::after { 29 | content: ''; 30 | position: absolute; 31 | background: var(--accent-color); 32 | height: 4px; 33 | inset: var(--highlight-inset); 34 | transition: inset var(--inset-transition-duration) linear; 35 | will-change: left; 36 | } 37 | 38 | .thumb { 39 | position: absolute; 40 | transform: translateX(-50%); 41 | left: var(--left); 42 | transition: left var(--transition-duration) linear; 43 | will-change: left; 44 | width: 0.5rem; 45 | height: 1.5rem; 46 | background-color: hsl(0 0% 50% / 0.75); 47 | cursor: pointer; 48 | z-index: 1; 49 | display: flex; 50 | justify-content: center; 51 | } 52 | 53 | .clip-thumb::before { 54 | content: ''; 55 | border-left: 0.5px solid hsl(0 0% 100% / 0.7); 56 | margin: 0.25rem auto; 57 | } 58 | 59 | /* increase tap target size to be bigger than the visible thumb */ 60 | .thumb::after { 61 | content: ''; 62 | position: absolute; 63 | width: 1.5rem; 64 | height: 100%; 65 | top: 50%; 66 | transform: translateY(-50%); 67 | } 68 | 69 | /* increase tap target size even more on touch devices */ 70 | @media (pointer: coarse) { 71 | .thumb::after { 72 | height: 3rem; 73 | width: 3rem; 74 | } 75 | } 76 | 77 | .thumb:focus-within { 78 | outline: 1px solid var(--accent-color); 79 | outline-offset: 2px; 80 | } 81 | 82 | /* output is shown as a "tooltip" below the thumb */ 83 | .thumb > .output { 84 | position: absolute; 85 | transform: translateY(100%); 86 | background: hsl(0 0% 50% / 0.25); 87 | border-radius: 0.25rem; 88 | padding: 0.25rem 0.5rem; 89 | opacity: 0; 90 | transition: opacity 0.25s; 91 | } 92 | 93 | .thumb:focus-within > .output { 94 | opacity: 1; 95 | } 96 | -------------------------------------------------------------------------------- /app/helpers/useAudio.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useInterval } from './useInterval'; 3 | 4 | export const useAudio = (url: string | undefined) => { 5 | const audioRef = React.useRef(); 6 | 7 | if (url && !audioRef.current && typeof Audio !== 'undefined') { 8 | audioRef.current = new Audio(url); 9 | } 10 | 11 | React.useEffect(() => { 12 | if (!audioRef.current || typeof url !== 'string') { 13 | return; 14 | } 15 | audioRef.current.load(); // load again if url changes 16 | audioRef.current.volume = 0.6; // nobody wants music to start blasting into their ears 17 | audioRef.current.onended = () => setIsPlaying(false); 18 | 19 | return () => audioRef.current?.pause(); 20 | }, [url]); 21 | 22 | const [isPlaying, setIsPlaying] = React.useState(false); 23 | React.useEffect(() => { 24 | if (!audioRef.current || typeof url !== 'string') { 25 | return; 26 | } 27 | if (isPlaying) { 28 | audioRef.current.play(); 29 | } else if (!audioRef.current.ended) { 30 | audioRef.current.pause(); 31 | } 32 | }, [isPlaying, url]); 33 | 34 | const [currentTime, _setCurrentTime] = React.useState(0); 35 | const [volume, _setVolume] = React.useState(0.6); 36 | const [playbackRate, _setPlaybackRate] = React.useState(1); 37 | 38 | // keep react state in sync with the audio element's currentTime 39 | useInterval( 40 | () => _setCurrentTime(parseFloat((audioRef.current?.currentTime ?? 0).toFixed(2))), 41 | isPlaying ? 500 : null 42 | ); 43 | 44 | // wrapper around _setCurrentTime to update the actual currentTime of the audio element 45 | const setCurrentTime = React.useCallback((value: number) => { 46 | _setCurrentTime(value); 47 | if (audioRef.current != null && audioRef.current.currentTime !== value) { 48 | audioRef.current.currentTime = value; 49 | } 50 | }, []); 51 | 52 | // wrapper around _setVolume to update the actual volume of the audio element 53 | const setVolume = React.useCallback((volume: number) => { 54 | _setVolume(volume); 55 | if (audioRef.current != null) { 56 | audioRef.current.volume = volume; 57 | } 58 | }, []); 59 | 60 | // wrapper around _setPlaybackRate to update the actual playbackRate of the audio element 61 | const setPlaybackRate = React.useCallback((playbackRate: number) => { 62 | _setPlaybackRate(playbackRate); 63 | if (audioRef.current != null) { 64 | audioRef.current.playbackRate = playbackRate; 65 | } 66 | }, []); 67 | 68 | return { 69 | isPlaying, 70 | setIsPlaying, 71 | currentTime, 72 | setCurrentTime, 73 | volume, 74 | setVolume, 75 | playbackRate, 76 | setPlaybackRate, 77 | audioRef, 78 | }; 79 | }; 80 | -------------------------------------------------------------------------------- /app/routes/index.css: -------------------------------------------------------------------------------- 1 | p { 2 | color: hsl(var(--fg-hsl) / 0.6); 3 | max-width: min(90vw, 50ch); 4 | margin: 0.5rem; 5 | text-align: center; 6 | } 7 | 8 | .search-input-wrapper { 9 | display: grid; 10 | } 11 | 12 | @keyframes loading { 13 | to { 14 | transform: rotate(1turn); 15 | } 16 | } 17 | 18 | .search-input-wrapper.loading::after { 19 | grid-area: 1 / -1; 20 | content: ''; 21 | justify-self: end; 22 | align-self: center; 23 | margin-right: 0.75rem; 24 | width: 1.5rem; 25 | height: 1.5rem; 26 | border-radius: 50%; 27 | box-sizing: border-box; 28 | border: 2px solid; 29 | animation: loading 1s ease-in-out infinite; 30 | --loader-color: hsl(0 0% 50%); 31 | border-color: var(--loader-color) var(--loader-color) transparent transparent; 32 | } 33 | 34 | .search-input { 35 | grid-area: 1 / -1; 36 | font: inherit; 37 | width: min(80vw, 400px); 38 | padding: 0.75rem; 39 | border: 1px solid hsl(0deg 0% 40% / 45%); 40 | border-radius: 0.5rem; 41 | background: var(--bg-color); 42 | padding-inline-end: 2.5rem; 43 | box-shadow: var(--shadow); 44 | } 45 | 46 | .search-input::placeholder { 47 | opacity: 0.9; 48 | } 49 | 50 | .search-input:focus { 51 | outline: 2px solid var(--accent-color); 52 | outline-offset: 2px; 53 | } 54 | 55 | .search-list { 56 | background: var(--bg-color); 57 | color: currentColor; 58 | list-style: none; 59 | padding: 0; 60 | user-select: none; 61 | max-height: 300px; 62 | width: 100%; 63 | overflow-y: auto; 64 | overflow-y: overlay; 65 | border-radius: 0.5rem; 66 | margin-top: 4px; 67 | box-shadow: var(--shadow); 68 | } 69 | 70 | .search-option { 71 | all: unset; 72 | display: grid; 73 | padding: 0.5rem 0.75rem; 74 | grid-template: 'thumbnail title' 'thumbnail subtitle' / auto 1fr; 75 | column-gap: 0.75rem; 76 | cursor: pointer; 77 | } 78 | 79 | .search-option:hover { 80 | background: var(--highlight-color); 81 | } 82 | 83 | .search-list > [data-highlighted] { 84 | background: var(--highlight-color); 85 | outline: 1px solid var(--focus-color); 86 | outline-offset: -1px; 87 | border-radius: 0.5rem; 88 | } 89 | 90 | .search-option-thumbnail { 91 | grid-area: thumbnail; 92 | width: 2rem; 93 | height: 2rem; 94 | border-radius: 2px; 95 | background: var(--thumbnail); 96 | background-size: cover; 97 | align-self: center; 98 | } 99 | 100 | .search-option-title { 101 | grid-area: title; 102 | text-overflow: ellipsis; 103 | overflow: hidden; 104 | white-space: nowrap; 105 | } 106 | 107 | .search-option-subtitle { 108 | grid-area: subtitle; 109 | display: flex; 110 | } 111 | 112 | .search-option-subtitle > * { 113 | overflow: hidden; 114 | text-overflow: ellipsis; 115 | white-space: nowrap; 116 | max-width: 25ch; 117 | } 118 | 119 | .search-option-subtitle > * + *::before { 120 | content: '•'; 121 | margin: 0 0.25rem; 122 | } 123 | -------------------------------------------------------------------------------- /app/helpers/search.server.ts: -------------------------------------------------------------------------------- 1 | export const search = async (query: string) => { 2 | const response = await fetch( 3 | `https://music.youtube.com/youtubei/v1/search?alt=json&key=${process.env.YOUTUBE_MUSIC_KEY}`, 4 | { 5 | method: 'POST', 6 | headers: { 7 | 'Content-Type': 'application/json', 8 | 'User-Agent': 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)', 9 | origin: 'https://music.youtube.com', 10 | }, 11 | body: JSON.stringify({ 12 | query, 13 | context: { client: { clientName: 'WEB_REMIX', clientVersion: '0.1' } }, 14 | params: 'EgWKAQIIAWoKEAoQCRADEAQQBQ%3D%3D', // this tells YTM to only search for songs 15 | }), 16 | } 17 | ).then((r) => r.json()); 18 | 19 | let items = []; 20 | try { 21 | items = 22 | response.contents.tabbedSearchResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents 23 | .pop() 24 | .musicShelfRenderer.contents.map(({ musicResponsiveListItemRenderer }: any) => ({ 25 | title: getTitle(musicResponsiveListItemRenderer), 26 | id: getId(musicResponsiveListItemRenderer), 27 | artists: getArtists(musicResponsiveListItemRenderer), 28 | thumbnail: getThumbnail(musicResponsiveListItemRenderer), 29 | duration: getDuration(musicResponsiveListItemRenderer), 30 | })) ?? []; 31 | } catch (err) { 32 | console.error('Oopsie', err); 33 | } 34 | 35 | return items as Array>; 36 | }; 37 | 38 | const getTitle = (item: any) => { 39 | let title = ''; 40 | try { 41 | title = item.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text; 42 | } catch (err) { 43 | console.log('Failed to parse title', err); 44 | } 45 | return title; 46 | }; 47 | 48 | const getId = (item: any) => { 49 | let id = ''; 50 | try { 51 | id = 52 | item.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint.watchEndpoint 53 | .videoId; 54 | } catch (err) { 55 | console.log('Failed to parse id', err); 56 | } 57 | return id; 58 | }; 59 | 60 | const getArtists = (item: any) => { 61 | let artists = []; 62 | try { 63 | artists = item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs 64 | .filter( 65 | (r: any) => 66 | r.navigationEndpoint?.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig 67 | .pageType === 'MUSIC_PAGE_TYPE_ARTIST' 68 | ) 69 | .map((r: any) => r.text as string); 70 | } catch (err) { 71 | console.log('Failed to parse artists', err); 72 | } 73 | return artists; 74 | }; 75 | 76 | const getThumbnail = (item: any) => { 77 | let thumbnailUrl = ''; 78 | try { 79 | thumbnailUrl = item.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails.pop()?.url; 80 | } catch (err) { 81 | console.log('Failed to parse thumbnailUrl', err); 82 | } 83 | return thumbnailUrl; 84 | }; 85 | 86 | const getDuration = (item: any) => { 87 | let duration; 88 | try { 89 | duration = item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs.pop().text; 90 | } catch (err) { 91 | console.log('Failed to parse duration', err); 92 | } 93 | return duration; 94 | }; 95 | -------------------------------------------------------------------------------- /app/root.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --reach-combobox: 1; 3 | 4 | --fg-hsl: 0 0% 0%; 5 | --lightness: 75%; 6 | --bg-color: hsl(0 0% 80% / 0.4); 7 | --highlight-color: hsl(0 0% 70% / 0.5); 8 | --focus-color: hsl(0 0% 0% / 0.8); 9 | --accent-color: hsl(260deg 58% 57% / 85%); 10 | --shadow: 0 1px 4px hsl(0 0% 0% / 0.25); 11 | } 12 | 13 | @media (prefers-color-scheme: dark) { 14 | :root { 15 | --fg-hsl: 0 0% 100%; 16 | --lightness: 25%; 17 | --bg-color: hsl(0 0% 20% / 0.4); 18 | --highlight-color: hsl(0 0% 30% / 0.5); 19 | --focus-color: hsl(0 0% 100% / 0.8); 20 | } 21 | } 22 | 23 | *, 24 | *::before, 25 | *::after { 26 | margin: 0; 27 | box-sizing: border-box; 28 | } 29 | 30 | @font-face { 31 | font-family: 'Unica One'; 32 | font-style: normal; 33 | font-weight: 400; 34 | src: url('/Unica-One.woff2') format('woff2'); 35 | } 36 | 37 | html { 38 | color-scheme: dark light; 39 | font-family: system-ui, sans-serif; 40 | color: hsl(0 0% 0% / 0.9); 41 | background: linear-gradient( 42 | 0deg, 43 | hsl(265deg 37% 41%) 0%, 44 | hsl(265deg 29% 49%) 9%, 45 | hsl(265deg 31% 58%) 20%, 46 | hsl(264deg 34% 68%) 34%, 47 | hsl(264deg 39% 79%) 56%, 48 | hsl(264deg 47% 90%) 100% 49 | ); 50 | } 51 | 52 | @media (prefers-color-scheme: dark) { 53 | html { 54 | color: hsl(0 0% 100% / 0.8); 55 | background: linear-gradient( 56 | 0deg, 57 | hsl(250deg 70% 10%) 0%, 58 | hsl(283deg 65% 14%) 24%, 59 | hsl(292deg 64% 17%) 44%, 60 | hsl(289deg 65% 18%) 62%, 61 | hsl(278deg 69% 19%) 80%, 62 | hsl(266deg 73% 19%) 100% 63 | ); 64 | } 65 | } 66 | 67 | html, 68 | body { 69 | height: 100%; 70 | } 71 | 72 | img { 73 | max-width: 100%; 74 | } 75 | 76 | @media (pointer: fine) { 77 | * { 78 | scrollbar-width: thin; 79 | } 80 | 81 | ::-webkit-scrollbar { 82 | width: 0.5em; 83 | background: hsl(0 0% var(--lightness) / 0.1); 84 | } 85 | 86 | ::-webkit-scrollbar-thumb { 87 | background: hsl(0 0% 30% / 0.3); 88 | border-radius: 0.5em; 89 | } 90 | 91 | ::-webkit-scrollbar-thumb:hover { 92 | background: hsl(0 0% 30% / 0.6); 93 | } 94 | } 95 | 96 | body { 97 | display: grid; 98 | place-items: center; 99 | grid-template-rows: 1fr auto 2fr 1fr; 100 | } 101 | 102 | body::before, 103 | body::after { 104 | content: ''; 105 | } 106 | 107 | header { 108 | padding: 1rem; 109 | } 110 | 111 | main { 112 | align-self: start; 113 | display: grid; 114 | gap: 0.75rem; 115 | place-content: center; 116 | place-items: center; 117 | } 118 | 119 | .logo-wrapper { 120 | color: inherit; 121 | text-decoration: none; 122 | -webkit-tap-highlight-color: transparent; 123 | border-radius: 0.5rem; 124 | } 125 | 126 | .logo { 127 | font-weight: 400; 128 | font-size: 2.75rem; 129 | letter-spacing: -2px; 130 | font-family: 'Unica One', system-ui, sans-serif; 131 | border-radius: 0.5rem; 132 | padding: 0.25rem 0.5rem; 133 | transition: background-color 0.2s ease-in-out; 134 | } 135 | 136 | .logo-wrapper:hover > .logo, 137 | .logo-wrapper:focus > .logo { 138 | background-color: hsl(0 0% 50% / 0.1); 139 | } 140 | 141 | .visually-hidden { 142 | position: absolute; 143 | width: 1px; 144 | height: 1px; 145 | padding: 0; 146 | margin: -1px; 147 | overflow: hidden; 148 | clip: rect(0, 0, 0, 0); 149 | white-space: nowrap; 150 | border-width: 0; 151 | } 152 | -------------------------------------------------------------------------------- /app/helpers/Slider.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useSlider, useSliderThumb } from '@react-aria/slider'; 3 | import type { SliderState } from '@react-stately/slider'; 4 | import styles from './Slider.css'; 5 | import { useSafeContext } from './useSafeContext'; 6 | import { formatToMinutesAndSeconds } from './time'; 7 | 8 | /** 9 | * Slider component build using react-aria. Needs state from `useSliderState` 10 | * and requires one or more `Slider.Thumb`s to be passed as children. 11 | */ 12 | export const Slider = ({ 13 | state, 14 | className, 15 | children, 16 | style, 17 | ...props 18 | }: { 19 | maxValue: number; 20 | defaultValue: number[]; 21 | step: number; 22 | state: SliderState; 23 | children: React.ReactNode; 24 | className?: string; 25 | style?: React.CSSProperties; 26 | }) => { 27 | const trackRef = React.useRef(null); 28 | const { groupProps, trackProps, outputProps } = useSlider( 29 | { 30 | ...props, 31 | // react-aria produces thousands of warnings which are not useful because we already have aria-label on every thumb 32 | 'aria-label': 'shut up', 33 | }, 34 | state, 35 | trackRef 36 | ); 37 | 38 | return ( 39 | 40 | 41 | 42 | {React.Children.map(children, (child, index) => { 43 | return React.isValidElement(child) ? React.cloneElement(child, { index }) : child; 44 | })} 45 | 46 | 47 | 48 | ); 49 | }; 50 | 51 | export const Thumb = (props: { 52 | index?: number; 53 | 'aria-label': string; 54 | className?: string; 55 | /** 56 | * This will render the tooltip in a `` instead of ``. 57 | * Handy when the slider updates too frequently to be useful in screen reader announcements. 58 | */ 59 | supressOutput?: boolean; 60 | }) => { 61 | const { index, 'aria-label': ariaLabel, supressOutput, className, ...rest } = props; 62 | const inputRef = React.useRef(null); 63 | const { state, outputProps, trackRef } = useSafeContext(SliderContext); 64 | 65 | if (index === undefined) { 66 | throw new Error('Thumb must have an index'); 67 | } 68 | 69 | const { thumbProps, inputProps } = useSliderThumb({ index, trackRef, inputRef }, state); 70 | 71 | const inputId = React.useMemo(() => outputProps.htmlFor?.split(' ')[index], [index, outputProps.htmlFor]); 72 | 73 | return ( 74 | 83 | 84 | 92 | 93 | {supressOutput ? ( 94 | {formatToMinutesAndSeconds(state.getThumbValue(index))} 95 | ) : ( 96 | 97 | {formatToMinutesAndSeconds(state.getThumbValue(index))} 98 | 99 | )} 100 | 101 | ); 102 | }; 103 | 104 | const SliderContext = React.createContext<{ 105 | outputProps: React.ComponentPropsWithRef<'output'>; 106 | trackRef: React.RefObject; 107 | state: SliderState; 108 | } | null>(null); 109 | SliderContext.displayName = 'SliderContext'; 110 | 111 | Slider.Thumb = Thumb; 112 | Slider.styles = styles; 113 | 114 | export default Slider; 115 | -------------------------------------------------------------------------------- /app/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Combobox, ComboboxInput, ComboboxPopover, ComboboxList, ComboboxOption } from '@reach/combobox'; 3 | import type { ActionFunction, LinksFunction } from 'remix'; 4 | import { useTransition, Link, json, useFetcher, useNavigate } from 'remix'; 5 | import styles from './index.css'; 6 | import { search } from '~/helpers/search.server'; 7 | 8 | export const links: LinksFunction = () => [{ rel: 'stylesheet', href: styles }]; 9 | 10 | export const action: ActionFunction = async ({ request }) => { 11 | const { searchTerm } = Object.fromEntries(await request.formData()); 12 | if (!searchTerm || typeof searchTerm !== 'string') return []; 13 | 14 | const searchResults = await search(searchTerm); 15 | 16 | return json( 17 | searchResults 18 | .map((item) => ({ 19 | title: item.title, 20 | id: item.id, 21 | thumbnail: item.thumbnail, 22 | artists: item.artists, 23 | duration: item.duration, 24 | })) 25 | .filter(Boolean) 26 | ); 27 | }; 28 | 29 | export default function Index() { 30 | const fetcher = useFetcher>>(); 31 | const navigate = useNavigate(); 32 | const transition = useTransition(); 33 | 34 | const [inputValue, setInputValue] = React.useState(''); 35 | const listRef = React.useRef(null); 36 | 37 | const doSearch = (searchTerm: string) => { 38 | if (searchTerm.length >= 3) { 39 | fetcher.submit({ searchTerm }, { method: 'post' }); 40 | } 41 | }; 42 | 43 | return ( 44 | <> 45 | 46 | Search for a song and select start/end times to create a riff 47 | 48 | 49 | { 53 | const item = fetcher.data?.find((item) => item.id === value); 54 | setInputValue(item?.title || ''); 55 | navigate(`/${value}`); 56 | }} 57 | > 58 | { 67 | doSearch(value); 68 | setInputValue(value); 69 | }} 70 | onKeyDown={(e) => { 71 | if (!e.isDefaultPrevented() && e.key.startsWith('Arrow')) { 72 | window.requestAnimationFrame(() => { 73 | listRef.current?.querySelector('[data-highlighted]')?.scrollIntoView({ block: 'nearest' }); 74 | }); 75 | } 76 | }} 77 | /> 78 | {fetcher.data && transition.state !== 'loading' && ( 79 | 80 | 81 | {fetcher.data.flatMap((item, index) => 82 | item.id ? ( 83 | 84 | 85 | 90 | {item.title} 91 | 92 | {item.artists?.[0]} 93 | {item.duration} 94 | 95 | 96 | 97 | ) : ( 98 | [] 99 | ) 100 | )} 101 | 102 | 103 | )} 104 | 105 | 106 | > 107 | ); 108 | } 109 | -------------------------------------------------------------------------------- /app/routes/$id/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useParams, useTransition, useMatches, Form, redirect, useActionData, json } from 'remix'; 3 | import type { LinksFunction, ActionFunction } from 'remix'; 4 | import { useAudio } from '~/helpers/useAudio'; 5 | import { Slider } from '~/helpers/Slider'; 6 | import { Button } from '~/helpers/Button'; 7 | import { IconButton } from '~/helpers/IconButton'; 8 | import { useSliderState } from '@react-stately/slider'; 9 | import { Play, Pause, FastForward, Rewind } from 'react-feather'; 10 | import styles from './index.css'; 11 | 12 | export const links: LinksFunction = () => [ 13 | { rel: 'stylesheet', href: Slider.styles }, 14 | { rel: 'stylesheet', href: Button.styles }, 15 | { rel: 'stylesheet', href: IconButton.styles }, 16 | { rel: 'stylesheet', href: styles }, 17 | ]; 18 | 19 | export const action: ActionFunction = async ({ params, request }) => { 20 | const { id } = params; 21 | const { clip } = Object.fromEntries(await request.formData()); 22 | 23 | const response = await fetch(`${new URL(request.url).origin}/resource/${id}/${clip}`); 24 | 25 | if (!response.ok) { 26 | return json({ error: await response.text() }, { status: response.status }); 27 | } 28 | 29 | return redirect(`/${id}/${clip}`); 30 | }; 31 | 32 | export default function Index() { 33 | const { id = '' } = useParams(); 34 | const transition = useTransition(); 35 | const actionData = useActionData(); 36 | 37 | // get duration from parent loader 38 | const duration = useMatches().find(({ pathname }) => pathname == `/${id}`)?.data.duration ?? 0; 39 | 40 | const center = Math.floor(duration / 2); 41 | 42 | const clipSliderState = useSliderState({ 43 | numberFormatter: React.useMemo(() => new Intl.NumberFormat(), []), 44 | maxValue: duration, 45 | defaultValue: [center - 15, center + 15], 46 | }); 47 | 48 | const { isPlaying, setIsPlaying, currentTime, setCurrentTime, audioRef } = useAudio(`/resource/${id}`); 49 | 50 | // stop audio when navigating away 51 | if (transition.state === 'loading') { 52 | audioRef.current?.pause(); 53 | } 54 | 55 | const currentTimeSliderState = useSliderState({ 56 | numberFormatter: React.useMemo(() => new Intl.NumberFormat(), []), 57 | maxValue: duration, 58 | defaultValue: [0], 59 | value: [currentTime], 60 | onChange: React.useCallback(([value]) => setCurrentTime(value), [setCurrentTime]), 61 | }); 62 | 63 | return ( 64 | <> 65 | 66 | setCurrentTime(Math.max(currentTime - 10, 0))} 68 | aria-label='Rewind 10 seconds' 69 | disabled={currentTime === 0} 70 | > 71 | 72 | 73 | 74 | setIsPlaying((p) => !p)} aria-label='Play/pause the song'> 75 | 76 | 77 | 78 | 79 | setCurrentTime(Math.round(Math.min(currentTime + 10, duration)))} 81 | aria-label='Fast forward 10 seconds' 82 | disabled={currentTime === Math.round(duration)} 83 | > 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 118 | Clip 119 | 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 | handleShare()}> 185 | Share 186 | 187 | 188 | 189 | YouTube 190 | 191 | 192 | 193 | 194 | {clipboardMessage} 195 | > 196 | ); 197 | } 198 | --------------------------------------------------------------------------------
46 | Search for a song and select start/end times to create a riff 47 |
Creating clip