Skip to content
Merged
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
1 change: 1 addition & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export default function App() {
value={form.options}
onChange={updateOptions}
ffmpegdEnabled={ffmpegd.enabled}
ffmpegdConnected={ffmpegd.connected}
onFfmpegdChange={ffmpegd.setEnabled}
ffmpegdAddress={ffmpegd.address}
onFfmpegdAddressChange={ffmpegd.setAddress}
Expand Down
65 changes: 65 additions & 0 deletions src/components/FfmpegdSetup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { useEffect, useState } from 'react'
import { displayHost } from '@/lib/ffmpegd'

export const FFMPEGD_INSTALL_URL = 'https://github.com/alfg/ffmpegd#install'

// The socket takes a moment to open on page load, so wait before telling
// someone whose daemon is running fine how to install it.
export const SETUP_DELAY_MS = 1500

const link = 'text-accent underline underline-offset-2 hover:text-accent-hover'
// nowrap keeps a command in one piece rather than breaking it mid-line on a phone.
const code = 'rounded bg-sunken px-1 font-mono text-xs whitespace-nowrap'

/**
* How to get ffmpegd running, shown wherever ffmpegd is enabled but not
* connected. For the first SETUP_DELAY_MS it only says it is connecting, so a
* normal connect does not flash install steps; the parent unmounts it once the
* socket opens.
*
* `explain` adds a sentence on what ffmpegd is, for places that don't already
* say so (the Options tab does, right above it).
*/
export default function FfmpegdSetup({ explain = true }: { explain?: boolean }) {
const [visible, setVisible] = useState(false)

useEffect(() => {
const id = window.setTimeout(() => setVisible(true), SETUP_DELAY_MS)
return () => window.clearTimeout(id)
}, [])

if (!visible) {
return (
<p className="text-sm text-muted">
Connecting to ffmpegd at <code className={code}>{displayHost()}</code>…
</p>
)
}

return (
<div role="status" className="rounded-lg border border-line bg-panel p-4 text-sm text-fg">
<p className="font-medium">
Can&apos;t reach ffmpegd at <code className={code}>{displayHost()}</code>
</p>
<p className="mt-1 text-muted">
{explain ? 'ffmpegd is a small companion app that runs these commands on your computer. ' : ''}
To set it up:
</p>
<ol className="mt-2 list-decimal space-y-1.5 pl-5">
<li>
Install it with Homebrew: <code className={code}>brew install alfg/tap/ffmpegd</code>.
For Windows, Go or Docker, see{' '}
<a href={FFMPEGD_INSTALL_URL} target="_blank" rel="noopener noreferrer" className={link}>
the install guide
</a>
. If it&apos;s already installed, make sure it&apos;s up to date.
</li>
<li>
Run <code className={code}>ffmpegd</code> in the folder with your videos. Input and output
paths are relative to that folder.
</li>
<li>This page connects on its own once ffmpegd is running.</li>
</ol>
</div>
)
}
3 changes: 3 additions & 0 deletions src/components/Queue.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import FfmpegdSetup from '@/components/FfmpegdSetup'
import { Status, type Job, type Progress } from '@/lib/ffmpegd'

interface Props {
Expand Down Expand Up @@ -65,6 +66,8 @@ export default function Queue({
</button>
</div>

{!connected ? <FfmpegdSetup /> : null}

{ordered.length === 0 ? (
<p className="rounded-lg border border-line bg-panel p-6 text-center text-sm text-muted">
Nothing queued. Build a command and press Encode.
Expand Down
31 changes: 29 additions & 2 deletions src/components/sections/OptionsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,16 @@ import Field from '@/components/ui/Field'
import Input from '@/components/ui/Input'
import Select from '@/components/ui/Select'
import Toggle from '@/components/ui/Toggle'
import { defaultHost, parseAddress } from '@/lib/ffmpegd'
import FfmpegdSetup, { FFMPEGD_INSTALL_URL } from '@/components/FfmpegdSetup'
import { defaultHost, displayHost, parseAddress } from '@/lib/ffmpegd'
import form from '@/lib/form'
import type { IFFMpegOptionsForm } from '@/lib/types'

interface Props {
value: IFFMpegOptionsForm['options']
onChange: (patch: Partial<IFFMpegOptionsForm['options']>) => void
ffmpegdEnabled: boolean
ffmpegdConnected: boolean
onFfmpegdChange: (enabled: boolean) => void
/** Saved daemon origin, or '' when the default is in use. */
ffmpegdAddress: string
Expand Down Expand Up @@ -70,6 +72,7 @@ export default function OptionsSection({
value,
onChange,
ffmpegdEnabled,
ffmpegdConnected,
onFfmpegdChange,
ffmpegdAddress,
onFfmpegdAddressChange,
Expand Down Expand Up @@ -110,8 +113,32 @@ export default function OptionsSection({
label="Send encode jobs to a local ffmpegd daemon (experimental)."
onChange={onFfmpegdChange}
/>
<p className="mt-1 max-w-xl text-xs text-muted">
ffmpegd is a small companion app that runs these commands on your computer and reports
progress here.{' '}
<a
href={FFMPEGD_INSTALL_URL}
target="_blank"
rel="noopener noreferrer"
className="text-accent underline underline-offset-2 hover:text-accent-hover"
>
Install ffmpegd
</a>
</p>
{ffmpegdEnabled ? (
<DaemonAddress value={ffmpegdAddress} onChange={onFfmpegdAddressChange} />
<>
<DaemonAddress value={ffmpegdAddress} onChange={onFfmpegdAddressChange} />
<div className="mt-3 max-w-xl">
{ffmpegdConnected ? (
<p role="status" className="text-sm text-terminal">
● Connected to ffmpegd at{' '}
<code className="rounded bg-sunken px-1 font-mono text-xs">{displayHost()}</code>
</p>
) : (
<FfmpegdSetup explain={false} />
)}
</div>
</>
) : null}
</div>

Expand Down
75 changes: 74 additions & 1 deletion src/lib/__tests__/ffmpegd.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'
import { render, screen, cleanup, act, waitFor } from '@testing-library/react'
import { render, screen, cleanup, act, waitFor, fireEvent, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import App from '@/App'
import storage from '@/lib/storage'
import { FFMPEGD_KEY, POLL_MS, QUEUE_KEY, Status } from '@/lib/ffmpegd'
import { FFMPEGD_INSTALL_URL, SETUP_DELAY_MS } from '@/components/FfmpegdSetup'

const commandText = () =>
screen.getByTestId('command').textContent?.replace(/\s+/g, ' ').trim() ?? ''
Expand Down Expand Up @@ -293,3 +294,75 @@ describe('ffmpegd', () => {
expect(FakeSocket.instances).toHaveLength(1)
})
})

describe('ffmpegd setup hint and status', () => {
const text = () => document.body.textContent ?? ''
const wait = (ms: number) => act(async () => { vi.advanceTimersByTime(ms) })

it('links to the install guide from Options, even while ffmpegd is off', async () => {
const user = userEvent.setup()
render(<App />)
await user.click(screen.getByRole('tab', { name: 'Options' }))

const link = screen.getByRole('link', { name: 'Install ffmpegd' })
expect(link.getAttribute('href')).toBe(FFMPEGD_INSTALL_URL)
expect(text()).not.toContain('Connecting to ffmpegd')
expect(text()).not.toContain("Can't reach ffmpegd")
})

it('says it is connecting, then shows setup steps if nothing answers', async () => {
vi.useFakeTimers()
enableFfmpegd()
render(<App />)
fireEvent.click(screen.getByRole('tab', { name: 'Options' }))

// A normal connect takes a moment; don't flash install steps at it.
await wait(SETUP_DELAY_MS - 100)
expect(text()).toContain('Connecting to ffmpegd at localhost:3000')
expect(text()).not.toContain("Can't reach ffmpegd")

await wait(200)
const hint = screen.getByRole('status')
expect(hint.textContent).toContain("Can't reach ffmpegd at localhost:3000")
expect(hint.textContent).toContain('brew install alfg/tap/ffmpegd')
expect(hint.textContent).toContain("If it's already installed, make sure it's up to date.")
expect(within(hint).getByRole('link', { name: 'the install guide' }).getAttribute('href'))
.toBe(FFMPEGD_INSTALL_URL)
})

it('shows that it connected, in place of the steps', async () => {
vi.useFakeTimers()
enableFfmpegd()
render(<App />)
fireEvent.click(screen.getByRole('tab', { name: 'Options' }))
await wait(SETUP_DELAY_MS)
expect(text()).toContain("Can't reach ffmpegd")

await act(async () => socket().open())
expect(screen.getByRole('status').textContent).toContain('Connected to ffmpegd at localhost:3000')
expect(text()).not.toContain("Can't reach ffmpegd")
expect(text()).not.toContain('Connecting to ffmpegd')
})

it('names a saved daemon address', async () => {
vi.useFakeTimers()
enableFfmpegd()
localStorage.setItem('host', 'http://mybox:9000')
render(<App />)
fireEvent.click(screen.getByRole('tab', { name: 'Options' }))
await wait(SETUP_DELAY_MS)

expect(screen.getByRole('status').textContent).toContain("Can't reach ffmpegd at mybox:9000")
})

it('shows the same steps on the Queue while offline', async () => {
vi.useFakeTimers()
enableFfmpegd()
render(<App />)
fireEvent.click(screen.getByRole('tab', { name: 'Queue' }))
await wait(SETUP_DELAY_MS)

expect(screen.getByText('● ffmpegd offline')).toBeTruthy()
expect(screen.getByRole('status').textContent).toContain('Run ffmpegd in the folder with your videos')
})
})
9 changes: 9 additions & 0 deletions src/lib/ffmpegd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,15 @@ export const wsUri = () => {

export const host = () => read(HOST_KEY) || defaultHost()

/** host() as host:port without the scheme, for display. */
export function displayHost(): string {
try {
return new URL(host()).host
} catch {
return host()
}
}

export async function listFiles(prefix = ''): Promise<FileListing> {
const res = await fetch(`${host()}/files?prefix=${encodeURIComponent(prefix)}`)
if (!res.ok) throw new Error(`ffmpegd responded ${res.status}`)
Expand Down
Loading