Fix L8/SR9/M20: list keys, jargon hints, ProgressBar a11y
L8: TerminalView lines now carry a stable auto-increment id (keyed by that
instead of array index) so front-trimming at MAX_LOG_LINES doesn't cause
React to reuse wrong DOM nodes. SettingsView error list keyed by id alone
(not id+occurredAt, which was redundant since IDs are already unique).
SR9: DownloadOptionsForm -- rewrote 'Container for merged video' to a plain
description; rewrote format-sorting hint to remove raw '-S string' /
'yt-dlp syntax' jargon.
M20: All ProgressBars now carry aria-label -- QueueItem (downloading +
paused rows, keyed on title/URL), DownloadsView overall summary bar,
SettingsView app-update download bar.
typecheck + 242 tests + eslint green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+3
-3
@@ -279,7 +279,7 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
|
|||||||
label contradicts the actual output format; `--audio-quality` is also meaningless for lossless.
|
label contradicts the actual output format; `--audio-quality` is also meaningless for lossless.
|
||||||
- [x] **M19 — Sidebar-collapsed pref uses `localStorage`** while every other preference uses
|
- [x] **M19 — Sidebar-collapsed pref uses `localStorage`** while every other preference uses
|
||||||
electron-store — won't back up/restore and is lost in the portable build across machines.
|
electron-store — won't back up/restore and is lost in the portable build across machines.
|
||||||
- [ ] **M20 — ProgressBar / native `<select>` accessibility.** QueueItem/DownloadsView
|
- [x] **M20 — ProgressBar / native `<select>` accessibility.** QueueItem/DownloadsView
|
||||||
`ProgressBar`s have no accessible name; several `Select`s carry both a `<Field label>` and an
|
`ProgressBar`s have no accessible name; several `Select`s carry both a `<Field label>` and an
|
||||||
`aria-label` (double announcement).
|
`aria-label` (double announcement).
|
||||||
- [x] **M21 — `--audio-quality` always emitted.** `buildArgs` passes `--audio-quality` even for
|
- [x] **M21 — `--audio-quality` always emitted.** `buildArgs` passes `--audio-quality` even for
|
||||||
@@ -390,7 +390,7 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
|
|||||||
(~40px) Input/Buttons in DownloadBar.
|
(~40px) Input/Buttons in DownloadBar.
|
||||||
- [x] **L7 — `canceled` and `paused` share the 'warning' badge color** (QueueItem) — visually
|
- [x] **L7 — `canceled` and `paused` share the 'warning' badge color** (QueueItem) — visually
|
||||||
ambiguous.
|
ambiguous.
|
||||||
- [ ] **L8 — Index/compound list keys.** TerminalView keys log lines by array index; Settings
|
- [x] **L8 — Index/compound list keys.** TerminalView keys log lines by array index; Settings
|
||||||
Diagnostics keys by `id + occurredAt` while every other list keys by `id` alone.
|
Diagnostics keys by `id + occurredAt` while every other list keys by `id` alone.
|
||||||
- [ ] **L9 — Thumbnail box sizes not shared.** Four hand-tuned 16:9 boxes (120×68, 108×64, 72×44,
|
- [ ] **L9 — Thumbnail box sizes not shared.** Four hand-tuned 16:9 boxes (120×68, 108×64, 72×44,
|
||||||
60×34) with no shared aspect/size constant.
|
60×34) with no shared aspect/size constant.
|
||||||
@@ -1393,7 +1393,7 @@ the end so the picture is complete. **Verified-clean first:** card/label/button
|
|||||||
|
|
||||||
### Wording / jargon polish
|
### Wording / jargon polish
|
||||||
|
|
||||||
- [ ] **SR9 — Dev jargon in user-facing option hints** (extends M37): "mux them into the video," "Tiebreaker,
|
- [x] **SR9 — Dev jargon in user-facing option hints** (extends M37): "mux them into the video," "Tiebreaker,
|
||||||
not a hard filter," "Endcards / credits" ("End cards"), "Proof-of-Origin token," "`--extractor-args`."
|
not a hard filter," "Endcards / credits" ("End cards"), "Proof-of-Origin token," "`--extractor-args`."
|
||||||
Reads like engineer notes. **Fix:** plain-language hints.
|
Reads like engineer notes. **Fix:** plain-language hints.
|
||||||
- [ ] **SR10 — Inconsistent product self-description / casing** surfaced to users: "yt-dlp frontend" caption
|
- [ ] **SR10 — Inconsistent product self-description / casing** surfaced to users: "yt-dlp frontend" caption
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
Field,
|
Field,
|
||||||
Input,
|
Input,
|
||||||
Switch,
|
Switch,
|
||||||
@@ -115,7 +115,7 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
|
|||||||
onChange={(v) => setOpt('audioFormat', v as AudioFormat)}
|
onChange={(v) => setOpt('audioFormat', v as AudioFormat)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Video container" hint="Container for merged video.">
|
<Field label="Video container" hint="The file format used when video and audio are combined into one file.">
|
||||||
<Select
|
<Select
|
||||||
aria-label="Video container"
|
aria-label="Video container"
|
||||||
value={value.videoContainer}
|
value={value.videoContainer}
|
||||||
@@ -125,7 +125,7 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
|
|||||||
</Field>
|
</Field>
|
||||||
<Field
|
<Field
|
||||||
label="Preferred codec"
|
label="Preferred codec"
|
||||||
hint="A preference when several formats match — not a strict filter."
|
hint="A preference when several formats match -- not a strict filter."
|
||||||
>
|
>
|
||||||
<Select
|
<Select
|
||||||
aria-label="Preferred video codec"
|
aria-label="Preferred video codec"
|
||||||
@@ -138,7 +138,7 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
|
|||||||
|
|
||||||
<Field
|
<Field
|
||||||
label="Format sorting (advanced)"
|
label="Format sorting (advanced)"
|
||||||
hint="Raw yt-dlp -S string to rank formats by priority, e.g. res:1080,vcodec:av01,size. Overrides the preferred-codec tiebreaker. Leave empty unless you know yt-dlp's -S syntax."
|
hint="Custom expression to rank available formats, e.g. res:1080,vcodec:av01,size. Leave blank to use the Preferred codec setting above. See yt-dlp docs for the full syntax."
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
value={value.formatSort}
|
value={value.formatSort}
|
||||||
@@ -201,7 +201,7 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
|
|||||||
validationState={value.sponsorBlockCategories.length === 0 ? 'warning' : 'none'}
|
validationState={value.sponsorBlockCategories.length === 0 ? 'warning' : 'none'}
|
||||||
validationMessage={
|
validationMessage={
|
||||||
value.sponsorBlockCategories.length === 0
|
value.sponsorBlockCategories.length === 0
|
||||||
? 'No categories selected — no segments will be skipped.'
|
? 'No categories selected -- no segments will be skipped.'
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -290,7 +290,7 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
|
|||||||
|
|
||||||
<Field
|
<Field
|
||||||
label="Sidecar files"
|
label="Sidecar files"
|
||||||
hint="Write separate metadata/poster/description files next to each download — handy for Jellyfin, Plex or Kodi libraries."
|
hint="Write separate metadata/poster/description files next to each download -- handy for Jellyfin, Plex or Kodi libraries."
|
||||||
>
|
>
|
||||||
<div className={styles.subGroup}>
|
<div className={styles.subGroup}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
Subtitle2,
|
Subtitle2,
|
||||||
Body1,
|
Body1,
|
||||||
Caption1,
|
Caption1,
|
||||||
@@ -82,7 +82,7 @@ export function DownloadsView(): React.JSX.Element {
|
|||||||
(i) => i.status === 'completed' || i.status === 'error' || i.status === 'canceled'
|
(i) => i.status === 'completed' || i.status === 'error' || i.status === 'canceled'
|
||||||
)
|
)
|
||||||
// The header count is the live queue (work not yet finished), not the whole
|
// The header count is the live queue (work not yet finished), not the whole
|
||||||
// list — completed/canceled/error rows linger until "Clear finished" (L11).
|
// list -- completed/canceled/error rows linger until "Clear finished" (L11).
|
||||||
const queueCount = items.filter(
|
const queueCount = items.filter(
|
||||||
(i) =>
|
(i) =>
|
||||||
i.status === 'downloading' ||
|
i.status === 'downloading' ||
|
||||||
@@ -99,7 +99,12 @@ export function DownloadsView(): React.JSX.Element {
|
|||||||
|
|
||||||
{summary.active && (
|
{summary.active && (
|
||||||
<div className={styles.summary}>
|
<div className={styles.summary}>
|
||||||
<ProgressBar className={styles.summaryBar} value={summary.progress} thickness="large" />
|
<ProgressBar
|
||||||
|
className={styles.summaryBar}
|
||||||
|
value={summary.progress}
|
||||||
|
thickness="large"
|
||||||
|
aria-label="Overall download progress"
|
||||||
|
/>
|
||||||
<Caption1 className={styles.summaryText}>
|
<Caption1 className={styles.summaryText}>
|
||||||
{summary.downloading} downloading
|
{summary.downloading} downloading
|
||||||
{summary.queued ? `, ${summary.queued} queued` : ''}
|
{summary.queued ? `, ${summary.queued} queued` : ''}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo } from 'react'
|
import { memo } from 'react'
|
||||||
import {
|
import {
|
||||||
Text,
|
Text,
|
||||||
Caption1,
|
Caption1,
|
||||||
@@ -120,7 +120,7 @@ export const QueueItem = memo(function QueueItem({
|
|||||||
|
|
||||||
const active = item.status === 'downloading' || item.status === 'queued'
|
const active = item.status === 'downloading' || item.status === 'queued'
|
||||||
|
|
||||||
// W7: Delete on a focused row removes it — cancelling first if it's still running,
|
// W7: Delete on a focused row removes it -- cancelling first if it's still running,
|
||||||
// since an in-flight download can't just be dropped from the list. Only when the
|
// since an in-flight download can't just be dropped from the list. Only when the
|
||||||
// row itself holds focus, so Delete on one of its action buttons is unaffected.
|
// row itself holds focus, so Delete on one of its action buttons is unaffected.
|
||||||
function onKeyDown(e: React.KeyboardEvent): void {
|
function onKeyDown(e: React.KeyboardEvent): void {
|
||||||
@@ -168,7 +168,7 @@ export const QueueItem = memo(function QueueItem({
|
|||||||
<Text className={styles.title}>{item.title}</Text>
|
<Text className={styles.title}>{item.title}</Text>
|
||||||
<StatusChip status={item.status} />
|
<StatusChip status={item.status} />
|
||||||
{item.incognito && (
|
{item.incognito && (
|
||||||
<Hint label="Private — not saved to history" placement="top" align="start">
|
<Hint label="Private -- not saved to history" placement="top" align="start">
|
||||||
<EyeOffRegular fontSize={14} style={{ color: tokens.colorNeutralForeground3 }} />
|
<EyeOffRegular fontSize={14} style={{ color: tokens.colorNeutralForeground3 }} />
|
||||||
</Hint>
|
</Hint>
|
||||||
)}
|
)}
|
||||||
@@ -185,6 +185,7 @@ export const QueueItem = memo(function QueueItem({
|
|||||||
className={styles.progressBar}
|
className={styles.progressBar}
|
||||||
value={item.finishing || item.sizeUnknown ? undefined : item.progress}
|
value={item.finishing || item.sizeUnknown ? undefined : item.progress}
|
||||||
thickness="large"
|
thickness="large"
|
||||||
|
aria-label={`Download progress for ${item.title ?? item.url}`}
|
||||||
/>
|
/>
|
||||||
<Caption1 className={styles.stats}>
|
<Caption1 className={styles.stats}>
|
||||||
{item.finishing ? (
|
{item.finishing ? (
|
||||||
@@ -209,7 +210,12 @@ export const QueueItem = memo(function QueueItem({
|
|||||||
|
|
||||||
{item.status === 'paused' && (
|
{item.status === 'paused' && (
|
||||||
<div className={styles.progressRow}>
|
<div className={styles.progressRow}>
|
||||||
<ProgressBar className={styles.progressBar} value={item.progress} thickness="large" />
|
<ProgressBar
|
||||||
|
className={styles.progressBar}
|
||||||
|
value={item.progress}
|
||||||
|
thickness="large"
|
||||||
|
aria-label={`Download progress for ${item.title ?? item.url}`}
|
||||||
|
/>
|
||||||
<Caption1 className={styles.stats}>Paused • {pct(item.progress)}</Caption1>
|
<Caption1 className={styles.stats}>Paused • {pct(item.progress)}</Caption1>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1000,7 +1000,7 @@ export function SettingsView(): React.JSX.Element {
|
|||||||
) : (
|
) : (
|
||||||
<div className={styles.errorList}>
|
<div className={styles.errorList}>
|
||||||
{errorEntries.slice(0, 20).map((e) => (
|
{errorEntries.slice(0, 20).map((e) => (
|
||||||
<div key={e.id + String(e.occurredAt)} className={styles.errorRow}>
|
<div key={e.id} className={styles.errorRow}>
|
||||||
<div className={styles.errorRowHeader}>
|
<div className={styles.errorRowHeader}>
|
||||||
<Text className={styles.errorRowTitle}>{e.title ?? e.url}</Text>
|
<Text className={styles.errorRowTitle}>{e.title ?? e.url}</Text>
|
||||||
<Caption1 className={styles.hint}>
|
<Caption1 className={styles.hint}>
|
||||||
@@ -1086,7 +1086,7 @@ export function SettingsView(): React.JSX.Element {
|
|||||||
manually.
|
manually.
|
||||||
</Caption1>
|
</Caption1>
|
||||||
)}
|
)}
|
||||||
{appDownloading && <ProgressBar value={appFraction} />}
|
{appDownloading && <ProgressBar value={appFraction} aria-label="App update download progress" />}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Textarea,
|
Textarea,
|
||||||
@@ -16,9 +16,12 @@ import { ScreenHeader, useScreenStyles } from './ui/Screen'
|
|||||||
|
|
||||||
type LineKind = 'stdout' | 'stderr' | 'cmd' | 'sys'
|
type LineKind = 'stdout' | 'stderr' | 'cmd' | 'sys'
|
||||||
interface Line {
|
interface Line {
|
||||||
|
id: number
|
||||||
text: string
|
text: string
|
||||||
kind: LineKind
|
kind: LineKind
|
||||||
}
|
}
|
||||||
|
let lineSeq = 0
|
||||||
|
const mkLine = (text: string, kind: LineKind): Line => ({ id: ++lineSeq, text, kind })
|
||||||
|
|
||||||
// Verbose yt-dlp runs (--verbose, -F on a large channel) can emit thousands of
|
// Verbose yt-dlp runs (--verbose, -F on a large channel) can emit thousands of
|
||||||
// lines in seconds. Cap the visible log to prevent unbounded React state growth
|
// lines in seconds. Cap the visible log to prevent unbounded React state growth
|
||||||
@@ -62,7 +65,7 @@ const useStyles = makeStyles({
|
|||||||
/**
|
/**
|
||||||
* Built-in yt-dlp terminal (Phase N): type raw yt-dlp args, run the bundled
|
* Built-in yt-dlp terminal (Phase N): type raw yt-dlp args, run the bundled
|
||||||
* binary, and watch its output stream. Gated on the customCommandEnabled consent
|
* binary, and watch its output stream. Gated on the customCommandEnabled consent
|
||||||
* flag (enforced again in main — see src/main/terminal.ts).
|
* flag (enforced again in main -- see src/main/terminal.ts).
|
||||||
*/
|
*/
|
||||||
export function TerminalView(): React.JSX.Element {
|
export function TerminalView(): React.JSX.Element {
|
||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
@@ -81,20 +84,14 @@ export function TerminalView(): React.JSX.Element {
|
|||||||
window.api.onTerminalOutput((ev) => {
|
window.api.onTerminalOutput((ev) => {
|
||||||
if (ev.id !== runId.current) return
|
if (ev.id !== runId.current) return
|
||||||
if (ev.type === 'output') {
|
if (ev.type === 'output') {
|
||||||
setLines((ls) =>
|
setLines((ls) => [...ls, mkLine(ev.line, ev.stream)].slice(-MAX_LOG_LINES))
|
||||||
[...ls, { text: ev.line, kind: ev.stream } as Line].slice(-MAX_LOG_LINES)
|
|
||||||
)
|
|
||||||
} else if (ev.type === 'error') {
|
} else if (ev.type === 'error') {
|
||||||
setLines((ls) =>
|
setLines((ls) => [...ls, mkLine(ev.error, 'stderr')].slice(-MAX_LOG_LINES))
|
||||||
[...ls, { text: ev.error, kind: 'stderr' as const }].slice(-MAX_LOG_LINES)
|
|
||||||
)
|
|
||||||
setRunning(false)
|
setRunning(false)
|
||||||
runId.current = null
|
runId.current = null
|
||||||
} else {
|
} else {
|
||||||
setLines((ls) =>
|
setLines((ls) =>
|
||||||
[...ls, { text: `— exited (code ${ev.code ?? '?'})`, kind: 'sys' as const }].slice(
|
[...ls, mkLine(`-- exited (code ${ev.code ?? '?'})`, 'sys')].slice(-MAX_LOG_LINES)
|
||||||
-MAX_LOG_LINES
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
setRunning(false)
|
setRunning(false)
|
||||||
runId.current = null
|
runId.current = null
|
||||||
@@ -113,19 +110,19 @@ export function TerminalView(): React.JSX.Element {
|
|||||||
if (!a || running) return
|
if (!a || running) return
|
||||||
const id = newId('t')
|
const id = newId('t')
|
||||||
runId.current = id
|
runId.current = id
|
||||||
setLines((ls) => [...ls, { text: `> yt-dlp ${a}`, kind: 'cmd' }])
|
setLines((ls) => [...ls, mkLine(`> yt-dlp ${a}`, 'cmd')])
|
||||||
setRunning(true)
|
setRunning(true)
|
||||||
window.api
|
window.api
|
||||||
.runTerminal(id, a)
|
.runTerminal(id, a)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
setLines((ls) => [...ls, { text: res.error ?? 'Failed to start.', kind: 'stderr' }])
|
setLines((ls) => [...ls, mkLine(res.error ?? 'Failed to start.', 'stderr')])
|
||||||
setRunning(false)
|
setRunning(false)
|
||||||
runId.current = null
|
runId.current = null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((e: unknown) => {
|
.catch((e: unknown) => {
|
||||||
setLines((ls) => [...ls, { text: String(e), kind: 'stderr' }])
|
setLines((ls) => [...ls, mkLine(String(e), 'stderr')])
|
||||||
setRunning(false)
|
setRunning(false)
|
||||||
runId.current = null
|
runId.current = null
|
||||||
})
|
})
|
||||||
@@ -158,7 +155,7 @@ export function TerminalView(): React.JSX.Element {
|
|||||||
|
|
||||||
{!customCommandEnabled && (
|
{!customCommandEnabled && (
|
||||||
<Body1 className={styles.gate}>
|
<Body1 className={styles.gate}>
|
||||||
The terminal is part of custom commands. Turn on “Run custom commands” in Settings →
|
The terminal is part of custom commands. Turn on "Run custom commands" in Settings →
|
||||||
Custom commands to use it.
|
Custom commands to use it.
|
||||||
</Body1>
|
</Body1>
|
||||||
)}
|
)}
|
||||||
@@ -210,8 +207,8 @@ export function TerminalView(): React.JSX.Element {
|
|||||||
{lines.length === 0 ? (
|
{lines.length === 0 ? (
|
||||||
<span className={styles.empty}>Output will appear here.</span>
|
<span className={styles.empty}>Output will appear here.</span>
|
||||||
) : (
|
) : (
|
||||||
lines.map((l, i) => (
|
lines.map((l) => (
|
||||||
<div key={i} className={lineClass(l.kind)}>
|
<div key={l.id} className={lineClass(l.kind)}>
|
||||||
{l.text}
|
{l.text}
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
|
|||||||
Reference in New Issue
Block a user