Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ automatically.
$ yoinks https://youtu.be/dQw4w9WgXcQ # straight to the format picker
$ yoinks # prompts for a url
$ yoinks --theme light # force the light palette
$ yoinks --embed-chapters <url> # preserve chapters when available
```

yoinks takes over the terminal (full-screen, centered — and restores your
Expand All @@ -50,6 +51,11 @@ click the theme control in the footer to cycle through `auto`, `light`, and
`dark` for the current session. Use `--theme auto`, `--theme light`, or
`--theme dark` to choose the starting theme for one launch.

From the format picker, press `^o` to open the optional **Download Options**
menu. Use space to toggle **Embed chapters** (when the source provides them),
then press enter to apply or `esc` to cancel. This never adds a step to the
default flow.

<img src="assets/download-options.png" alt="yoinks format picker — resolutions with estimated file sizes, plus audio-only mp3" width="100%">

## How it works
Expand Down
143 changes: 122 additions & 21 deletions src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ import {TextInput} from './components/text-input.js'
import {clickTargetAt, findFrameRow, frameRowSpan, type ClickTarget} from './lib/click-map.js'
import {formatBytes, formatDuration, formatEta, formatSpeed, shortenPath, truncate, wrapText} from './lib/format.js'
import {addToHistory, loadHistory} from './lib/history.js'
import {
DEFAULT_DOWNLOAD_OPTIONS,
DOWNLOAD_OPTION_DEFINITIONS,
setDownloadOption,
type DownloadOptionId,
type DownloadOptions,
} from './lib/download-options.js'
import {detectPlatform, isProbablyUrl, type Platform} from './lib/platforms.js'
import {useMouseClick} from './lib/use-mouse-click.js'
import {nextThemeMode, ThemeProvider, type ThemeMode, useTheme} from './theme.js'
Expand All @@ -32,6 +39,7 @@ const OUT_DIR = path.join(os.homedir(), 'Downloads')
const YOINK_BUTTON = 'yoink'
const DONE_LABEL = '↵ yoink another'
const TAGLINE = 'yoink any video. paste. yoink. done.'
const OPTION_LIST_LIMIT = 8

const choiceLabel = (choice: DownloadChoice) => `${choice.kind === 'audio' ? '♪ ' : '▶ '}${choice.label}`

Expand Down Expand Up @@ -111,6 +119,7 @@ const HINTS: Record<Phase['name'], Array<[string, string]>> = {
picking: [
['↑↓', 'choose'],
['↵', 'yoink'],
['^o', 'options'],
['esc', 'back'],
['^c', 'quit'],
],
Expand All @@ -129,18 +138,19 @@ type AppProps = {
initialUrl?: string
clipboardUrl?: string
initialThemeMode?: ThemeMode
initialDownloadOptions?: DownloadOptions
onOutcome: (outcome: Outcome) => void
}

export function App({initialThemeMode = 'auto', ...props}: AppProps) {
export function App({initialThemeMode = 'auto', initialDownloadOptions = DEFAULT_DOWNLOAD_OPTIONS, ...props}: AppProps) {
const [themeMode, setThemeMode] = useState(initialThemeMode)
const cycleTheme = useCallback(() => {
setThemeMode(nextThemeMode)
}, [])

return (
<ThemeProvider mode={themeMode}>
<AppContent {...props} cycleTheme={cycleTheme} />
<AppContent {...props} cycleTheme={cycleTheme} initialDownloadOptions={initialDownloadOptions} />
</ThemeProvider>
)
}
Expand All @@ -150,11 +160,13 @@ function AppContent({
clipboardUrl,
onOutcome,
cycleTheme,
initialDownloadOptions,
}: {
initialUrl?: string
clipboardUrl?: string
onOutcome: (outcome: Outcome) => void
cycleTheme: () => void
initialDownloadOptions: DownloadOptions
}) {
const theme = useTheme()
const {exit} = useApp()
Expand All @@ -165,8 +177,12 @@ function AppContent({
const [platform, setPlatform] = useState<Platform>()
const [info, setInfo] = useState<VideoInfo>()
const [choices, setChoices] = useState<DownloadChoice[]>([])
const [downloadOptions, setDownloadOptions] = useState(initialDownloadOptions)
const [downloadOptionsDraft, setDownloadOptionsDraft] = useState<DownloadOptions>()
const [pickerView, setPickerView] = useState<'formats' | 'options'>('formats')
const ytdlpRef = useRef('')
const highlightRef = useRef(0) // choice under the cursor, for the ↵ hint click
const optionHighlightRef = useRef<DownloadOptionId>(DOWNLOAD_OPTION_DEFINITIONS[0].id)
const infoJsonRef = useRef<string | undefined>(undefined)
const abortRef = useRef<AbortController | undefined>(undefined)
const [phase, setPhase] = useState<Phase>(initialUrl ? {name: 'probing', status: 'warming up…'} : {name: 'input'})
Expand All @@ -193,6 +209,8 @@ function AppContent({
setInfo(videoInfo)
setChoices(buildChoices(videoInfo))
highlightRef.current = 0
setDownloadOptionsDraft(undefined)
setPickerView('formats')
setPhase({name: 'picking'})
} catch (error) {
if (controller.signal.aborted) return
Expand All @@ -210,6 +228,8 @@ function AppContent({
setPlatform(undefined)
setInfo(undefined)
setChoices([])
setDownloadOptionsDraft(undefined)
setPickerView('formats')
setPhase({name: 'input'})
}, [])

Expand All @@ -219,13 +239,50 @@ function AppContent({
setUrlInput(url) // keep the link around so a cancel isn't destructive
}, [resetToInput, url])

const openDownloadOptions = () => {
setDownloadOptionsDraft(downloadOptions)
setPickerView('options')
}

const cancelDownloadOptions = () => {
setDownloadOptionsDraft(undefined)
setPickerView('formats')
}

const saveDownloadOptions = () => {
if (downloadOptionsDraft) setDownloadOptions(downloadOptionsDraft)
setDownloadOptionsDraft(undefined)
setPickerView('formats')
}

const toggleDownloadOption = (option: DownloadOptionId) => {
setDownloadOptionsDraft(current => {
const options = current ?? downloadOptions
return setDownloadOption(options, option, !options[option])
})
}

const toggleHighlightedDownloadOption = () => toggleDownloadOption(optionHighlightRef.current)

useInput(
(input, key) => {
if (key.ctrl && input === 't') {
cycleTheme()
return
}
if (key.escape && (phase.name === 'picking' || phase.name === 'error' || phase.name === 'done')) resetToInput()
if (key.ctrl && input === 'o' && phase.name === 'picking' && pickerView === 'formats') {
openDownloadOptions()
return
}
if (phase.name === 'picking' && pickerView === 'options' && input === ' ') {
toggleHighlightedDownloadOption()
return
}
if (key.escape && phase.name === 'picking') {
if (pickerView === 'options') cancelDownloadOptions()
else resetToInput()
}
if (key.escape && (phase.name === 'error' || phase.name === 'done')) resetToInput()
if (key.escape && (phase.name === 'probing' || phase.name === 'downloading')) cancelRun()
if (key.return && (phase.name === 'error' || phase.name === 'done')) resetToInput()
},
Expand Down Expand Up @@ -259,7 +316,7 @@ function AppContent({
}
try {
const ffmpegLocation = await findFfmpeg()
const base = {ytdlp: ytdlpRef.current, ffmpegLocation, url, choice, outDir: OUT_DIR}
const base = {ytdlp: ytdlpRef.current, ffmpegLocation, url, choice, downloadOptions, outDir: OUT_DIR}
let filepath: string
try {
// reuse the probe's metadata — starts immediately instead of re-extracting
Expand All @@ -282,7 +339,17 @@ function AppContent({
})()
}

let hints: Array<[string, string]> = [...HINTS[phase.name], ['^t', `theme:${theme.mode}`]]
const phaseHints: Array<[string, string]> =
phase.name === 'picking' && pickerView === 'options'
? [
['↑↓', 'choose'],
['space', 'toggle'],
['↵', 'done'],
['esc', 'cancel'],
['^c', 'quit'],
]
: HINTS[phase.name]
let hints: Array<[string, string]> = [...phaseHints, ['^t', `theme:${theme.mode}`]]
if (phase.name === 'input' && history.length > 0) {
hints = [hints[0]!, ['↑', 'history'], ...hints.slice(1)]
}
Expand All @@ -293,10 +360,18 @@ function AppContent({
const hintAction = (key: string): (() => void) | undefined => {
if (key === '^c') return () => exit()
if (key === '^t') return cycleTheme
if (key === 'esc') return phase.name === 'probing' || phase.name === 'downloading' ? cancelRun : resetToInput
if (key === '^o' && phase.name === 'picking' && pickerView === 'formats') return openDownloadOptions
if (key === 'space' && phase.name === 'picking' && pickerView === 'options') return toggleHighlightedDownloadOption
if (key === 'esc') {
if (phase.name === 'probing' || phase.name === 'downloading') return cancelRun
if (phase.name === 'picking' && pickerView === 'options') return cancelDownloadOptions
return resetToInput
}
if (key === '↵') {
if (phase.name === 'input') return () => handleUrlSubmit(urlInput)
if (phase.name === 'picking') return () => handlePick({value: highlightRef.current})
if (phase.name === 'picking') {
return pickerView === 'options' ? saveDownloadOptions : () => handlePick({value: highlightRef.current})
}
if (phase.name === 'error' || phase.name === 'done') return resetToInput
}
return undefined // ↑↓ / ↑ stay keyboard-only
Expand All @@ -307,8 +382,17 @@ function AppContent({
clickTargets.push({match: ` ${YOINK_BUTTON} `, padY: 1, action: () => handleUrlSubmit(urlInput)})
}
if (phase.name === 'picking') {
for (const [index, choice] of choices.entries()) {
clickTargets.push({match: choiceLabel(choice), action: () => handlePick({value: index})})
if (pickerView === 'formats') {
for (const [index, choice] of choices.entries()) {
clickTargets.push({match: choiceLabel(choice), action: () => handlePick({value: index})})
}
} else {
for (const option of DOWNLOAD_OPTION_DEFINITIONS) {
clickTargets.push({
match: option.label,
action: () => toggleDownloadOption(option.id),
})
}
}
}
if (phase.name === 'done') {
Expand Down Expand Up @@ -395,18 +479,35 @@ function AppContent({
{info?.uploader ? ` · ${info.uploader}` : ''}
</Text>
</Box>
<Panel title="Download" width={38}>
<SelectInput
indicatorComponent={ChoiceIndicator}
itemComponent={ChoiceItem}
items={choices.map((choice, index) => ({
key: String(index),
label: choiceLabel(choice),
value: index,
}))}
onSelect={handlePick}
onHighlight={item => (highlightRef.current = item.value)}
/>
<Panel title={pickerView === 'formats' ? 'Download' : 'Download Options'} width={38}>
{pickerView === 'formats' ? (
<SelectInput
indicatorComponent={ChoiceIndicator}
itemComponent={ChoiceItem}
items={choices.map((choice, index) => ({
key: String(index),
label: choiceLabel(choice),
value: index,
}))}
onSelect={handlePick}
onHighlight={item => (highlightRef.current = item.value)}
/>
) : (
<Box flexDirection="column">
<SelectInput<DownloadOptionId>
indicatorComponent={ChoiceIndicator}
itemComponent={ChoiceItem}
items={DOWNLOAD_OPTION_DEFINITIONS.map(option => ({
key: option.id,
label: `${(downloadOptionsDraft ?? downloadOptions)[option.id] ? '●' : '○'} ${option.label}`,
value: option.id,
}))}
limit={OPTION_LIST_LIMIT}
onSelect={saveDownloadOptions}
onHighlight={item => (optionHighlightRef.current = item.value)}
/>
</Box>
)}
</Panel>
</Box>
)}
Expand Down
2 changes: 2 additions & 0 deletions src/cli.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const HELP = `

Options
--theme <mode> use auto, light, or dark for this run
--embed-chapters embed video chapters when available
-h, --help show this help
-v, --version show version

Expand Down Expand Up @@ -85,6 +86,7 @@ const {waitUntilExit} = render(
initialUrl={initialUrl}
clipboardUrl={clipboardUrl}
initialThemeMode={initialThemeMode}
initialDownloadOptions={args.downloadOptions}
onOutcome={result => (outcome = result)}
/>,
// keep a copy of every frame so clicks can be hit-tested against it
Expand Down
11 changes: 11 additions & 0 deletions src/lib/args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ test('parses a url and a spaced theme option without confusing the value for the
assert.deepEqual(parseArgs(['--theme', 'light', 'https://example.com/video']), {
help: false,
version: false,
downloadOptions: {embedChapters: false},
themeMode: 'light',
initialUrl: 'https://example.com/video',
})
Expand All @@ -16,11 +17,21 @@ test('parses an equals-style theme option after the url', () => {
assert.deepEqual(parseArgs(['https://example.com/video', '--theme=dark']), {
help: false,
version: false,
downloadOptions: {embedChapters: false},
themeMode: 'dark',
initialUrl: 'https://example.com/video',
})
})

test('parses download options alongside a url without treating them as positional arguments', () => {
assert.deepEqual(parseArgs(['https://example.com/video', '--embed-chapters']), {
help: false,
version: false,
initialUrl: 'https://example.com/video',
downloadOptions: {embedChapters: true},
})
})

test('rejects missing, invalid, and unknown options', () => {
assert.match(parseArgs(['--theme']).error ?? '', /needs a value/)
assert.match(parseArgs(['--theme', 'sepia']).error ?? '', /unknown theme/)
Expand Down
13 changes: 12 additions & 1 deletion src/lib/args.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,28 @@
import {isThemeMode, type ThemeMode} from '../theme.js'
import {
DEFAULT_DOWNLOAD_OPTIONS,
downloadOptionForCliFlag,
setDownloadOption,
type DownloadOptions,
} from './download-options.js'

export type CliArgs = {
help: boolean
version: boolean
initialUrl?: string
themeMode?: ThemeMode
/** Complete global download preferences, shared with the TUI. */
downloadOptions: DownloadOptions
error?: string
}

export function parseArgs(args: string[]): CliArgs {
const result: CliArgs = {help: false, version: false}
const result: CliArgs = {help: false, version: false, downloadOptions: DEFAULT_DOWNLOAD_OPTIONS}
const positional: string[] = []

for (let index = 0; index < args.length; index++) {
const arg = args[index]!
const downloadOption = downloadOptionForCliFlag(arg)
if (arg === '-h' || arg === '--help') {
result.help = true
} else if (arg === '-v' || arg === '--version') {
Expand All @@ -27,6 +36,8 @@ export function parseArgs(args: string[]): CliArgs {
const value = arg.slice('--theme='.length)
if (!isThemeMode(value)) return {...result, error: `unknown theme “${value}” — use auto, light, or dark`}
result.themeMode = value
} else if (downloadOption) {
result.downloadOptions = setDownloadOption(result.downloadOptions, downloadOption, true)
} else if (arg.startsWith('-')) {
return {...result, error: `unknown option “${arg}”`}
} else {
Expand Down
11 changes: 11 additions & 0 deletions src/lib/download-options.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {DEFAULT_DOWNLOAD_OPTIONS, setDownloadOption} from './download-options.js'

test('returns a new options value without mutating the prior value', () => {
const enabled = setDownloadOption(DEFAULT_DOWNLOAD_OPTIONS, 'embedChapters', true)

assert.notEqual(enabled, DEFAULT_DOWNLOAD_OPTIONS)
assert.deepEqual(DEFAULT_DOWNLOAD_OPTIONS, {embedChapters: false})
assert.deepEqual(enabled, {embedChapters: true})
})
Loading