diff --git a/src/App.tsx b/src/App.tsx
index b32bb37..ad9a559 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -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}
diff --git a/src/components/FfmpegdSetup.tsx b/src/components/FfmpegdSetup.tsx
new file mode 100644
index 0000000..55bcd21
--- /dev/null
+++ b/src/components/FfmpegdSetup.tsx
@@ -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 (
+
+ Connecting to ffmpegd at {displayHost()}…
+
+ )
+ }
+
+ return (
+
+
+ Can't reach ffmpegd at {displayHost()}
+
+
+ {explain ? 'ffmpegd is a small companion app that runs these commands on your computer. ' : ''}
+ To set it up:
+
+
+ -
+ Install it with Homebrew:
brew install alfg/tap/ffmpegd.
+ For Windows, Go or Docker, see{' '}
+
+ the install guide
+
+ . If it's already installed, make sure it's up to date.
+
+ -
+ Run
ffmpegd in the folder with your videos. Input and output
+ paths are relative to that folder.
+
+ - This page connects on its own once ffmpegd is running.
+
+
+ )
+}
diff --git a/src/components/Queue.tsx b/src/components/Queue.tsx
index 129b1f9..66216f2 100644
--- a/src/components/Queue.tsx
+++ b/src/components/Queue.tsx
@@ -1,3 +1,4 @@
+import FfmpegdSetup from '@/components/FfmpegdSetup'
import { Status, type Job, type Progress } from '@/lib/ffmpegd'
interface Props {
@@ -65,6 +66,8 @@ export default function Queue({
+ {!connected ? : null}
+
{ordered.length === 0 ? (
Nothing queued. Build a command and press Encode.
diff --git a/src/components/sections/OptionsSection.tsx b/src/components/sections/OptionsSection.tsx
index e3ab969..bd7df15 100644
--- a/src/components/sections/OptionsSection.tsx
+++ b/src/components/sections/OptionsSection.tsx
@@ -3,7 +3,8 @@ 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'
@@ -11,6 +12,7 @@ interface Props {
value: IFFMpegOptionsForm['options']
onChange: (patch: Partial) => void
ffmpegdEnabled: boolean
+ ffmpegdConnected: boolean
onFfmpegdChange: (enabled: boolean) => void
/** Saved daemon origin, or '' when the default is in use. */
ffmpegdAddress: string
@@ -70,6 +72,7 @@ export default function OptionsSection({
value,
onChange,
ffmpegdEnabled,
+ ffmpegdConnected,
onFfmpegdChange,
ffmpegdAddress,
onFfmpegdAddressChange,
@@ -110,8 +113,32 @@ export default function OptionsSection({
label="Send encode jobs to a local ffmpegd daemon (experimental)."
onChange={onFfmpegdChange}
/>
+
+ ffmpegd is a small companion app that runs these commands on your computer and reports
+ progress here.{' '}
+
+ Install ffmpegd
+
+
{ffmpegdEnabled ? (
-
+ <>
+
+
+ {ffmpegdConnected ? (
+
+ ● Connected to ffmpegd at{' '}
+ {displayHost()}
+
+ ) : (
+
+ )}
+
+ >
) : null}
diff --git a/src/lib/__tests__/ffmpegd.spec.tsx b/src/lib/__tests__/ffmpegd.spec.tsx
index 65999f4..6f22dc8 100644
--- a/src/lib/__tests__/ffmpegd.spec.tsx
+++ b/src/lib/__tests__/ffmpegd.spec.tsx
@@ -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() ?? ''
@@ -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()
+ 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()
+ 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()
+ 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()
+ 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()
+ 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')
+ })
+})
diff --git a/src/lib/ffmpegd.ts b/src/lib/ffmpegd.ts
index 88ecbde..2db4047 100644
--- a/src/lib/ffmpegd.ts
+++ b/src/lib/ffmpegd.ts
@@ -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 {
const res = await fetch(`${host()}/files?prefix=${encodeURIComponent(prefix)}`)
if (!res.ok) throw new Error(`ffmpegd responded ${res.status}`)