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:
@@ -1,4 +1,4 @@
|
||||
import {
|
||||
import {
|
||||
Field,
|
||||
Input,
|
||||
Switch,
|
||||
@@ -115,7 +115,7 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
|
||||
onChange={(v) => setOpt('audioFormat', v as AudioFormat)}
|
||||
/>
|
||||
</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
|
||||
aria-label="Video container"
|
||||
value={value.videoContainer}
|
||||
@@ -125,7 +125,7 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
|
||||
</Field>
|
||||
<Field
|
||||
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
|
||||
aria-label="Preferred video codec"
|
||||
@@ -138,7 +138,7 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
|
||||
|
||||
<Field
|
||||
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
|
||||
value={value.formatSort}
|
||||
@@ -201,7 +201,7 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
|
||||
validationState={value.sponsorBlockCategories.length === 0 ? 'warning' : 'none'}
|
||||
validationMessage={
|
||||
value.sponsorBlockCategories.length === 0
|
||||
? 'No categories selected — no segments will be skipped.'
|
||||
? 'No categories selected -- no segments will be skipped.'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
@@ -290,7 +290,7 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
|
||||
|
||||
<Field
|
||||
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}>
|
||||
<Checkbox
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {
|
||||
import {
|
||||
Subtitle2,
|
||||
Body1,
|
||||
Caption1,
|
||||
@@ -82,7 +82,7 @@ export function DownloadsView(): React.JSX.Element {
|
||||
(i) => i.status === 'completed' || i.status === 'error' || i.status === 'canceled'
|
||||
)
|
||||
// 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(
|
||||
(i) =>
|
||||
i.status === 'downloading' ||
|
||||
@@ -99,7 +99,12 @@ export function DownloadsView(): React.JSX.Element {
|
||||
|
||||
{summary.active && (
|
||||
<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}>
|
||||
{summary.downloading} downloading
|
||||
{summary.queued ? `, ${summary.queued} queued` : ''}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo } from 'react'
|
||||
import { memo } from 'react'
|
||||
import {
|
||||
Text,
|
||||
Caption1,
|
||||
@@ -120,7 +120,7 @@ export const QueueItem = memo(function QueueItem({
|
||||
|
||||
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
|
||||
// row itself holds focus, so Delete on one of its action buttons is unaffected.
|
||||
function onKeyDown(e: React.KeyboardEvent): void {
|
||||
@@ -168,7 +168,7 @@ export const QueueItem = memo(function QueueItem({
|
||||
<Text className={styles.title}>{item.title}</Text>
|
||||
<StatusChip status={item.status} />
|
||||
{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 }} />
|
||||
</Hint>
|
||||
)}
|
||||
@@ -185,6 +185,7 @@ export const QueueItem = memo(function QueueItem({
|
||||
className={styles.progressBar}
|
||||
value={item.finishing || item.sizeUnknown ? undefined : item.progress}
|
||||
thickness="large"
|
||||
aria-label={`Download progress for ${item.title ?? item.url}`}
|
||||
/>
|
||||
<Caption1 className={styles.stats}>
|
||||
{item.finishing ? (
|
||||
@@ -209,7 +210,12 @@ export const QueueItem = memo(function QueueItem({
|
||||
|
||||
{item.status === 'paused' && (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1000,7 +1000,7 @@ export function SettingsView(): React.JSX.Element {
|
||||
) : (
|
||||
<div className={styles.errorList}>
|
||||
{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}>
|
||||
<Text className={styles.errorRowTitle}>{e.title ?? e.url}</Text>
|
||||
<Caption1 className={styles.hint}>
|
||||
@@ -1086,7 +1086,7 @@ export function SettingsView(): React.JSX.Element {
|
||||
manually.
|
||||
</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 {
|
||||
Button,
|
||||
Textarea,
|
||||
@@ -16,9 +16,12 @@ import { ScreenHeader, useScreenStyles } from './ui/Screen'
|
||||
|
||||
type LineKind = 'stdout' | 'stderr' | 'cmd' | 'sys'
|
||||
interface Line {
|
||||
id: number
|
||||
text: string
|
||||
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
|
||||
// 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
|
||||
* 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 {
|
||||
const styles = useStyles()
|
||||
@@ -81,20 +84,14 @@ export function TerminalView(): React.JSX.Element {
|
||||
window.api.onTerminalOutput((ev) => {
|
||||
if (ev.id !== runId.current) return
|
||||
if (ev.type === 'output') {
|
||||
setLines((ls) =>
|
||||
[...ls, { text: ev.line, kind: ev.stream } as Line].slice(-MAX_LOG_LINES)
|
||||
)
|
||||
setLines((ls) => [...ls, mkLine(ev.line, ev.stream)].slice(-MAX_LOG_LINES))
|
||||
} else if (ev.type === 'error') {
|
||||
setLines((ls) =>
|
||||
[...ls, { text: ev.error, kind: 'stderr' as const }].slice(-MAX_LOG_LINES)
|
||||
)
|
||||
setLines((ls) => [...ls, mkLine(ev.error, 'stderr')].slice(-MAX_LOG_LINES))
|
||||
setRunning(false)
|
||||
runId.current = null
|
||||
} else {
|
||||
setLines((ls) =>
|
||||
[...ls, { text: `— exited (code ${ev.code ?? '?'})`, kind: 'sys' as const }].slice(
|
||||
-MAX_LOG_LINES
|
||||
)
|
||||
[...ls, mkLine(`-- exited (code ${ev.code ?? '?'})`, 'sys')].slice(-MAX_LOG_LINES)
|
||||
)
|
||||
setRunning(false)
|
||||
runId.current = null
|
||||
@@ -113,19 +110,19 @@ export function TerminalView(): React.JSX.Element {
|
||||
if (!a || running) return
|
||||
const id = newId('t')
|
||||
runId.current = id
|
||||
setLines((ls) => [...ls, { text: `> yt-dlp ${a}`, kind: 'cmd' }])
|
||||
setLines((ls) => [...ls, mkLine(`> yt-dlp ${a}`, 'cmd')])
|
||||
setRunning(true)
|
||||
window.api
|
||||
.runTerminal(id, a)
|
||||
.then((res) => {
|
||||
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)
|
||||
runId.current = null
|
||||
}
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
setLines((ls) => [...ls, { text: String(e), kind: 'stderr' }])
|
||||
setLines((ls) => [...ls, mkLine(String(e), 'stderr')])
|
||||
setRunning(false)
|
||||
runId.current = null
|
||||
})
|
||||
@@ -158,7 +155,7 @@ export function TerminalView(): React.JSX.Element {
|
||||
|
||||
{!customCommandEnabled && (
|
||||
<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.
|
||||
</Body1>
|
||||
)}
|
||||
@@ -210,8 +207,8 @@ export function TerminalView(): React.JSX.Element {
|
||||
{lines.length === 0 ? (
|
||||
<span className={styles.empty}>Output will appear here.</span>
|
||||
) : (
|
||||
lines.map((l, i) => (
|
||||
<div key={i} className={lineClass(l.kind)}>
|
||||
lines.map((l) => (
|
||||
<div key={l.id} className={lineClass(l.kind)}>
|
||||
{l.text}
|
||||
</div>
|
||||
))
|
||||
|
||||
Reference in New Issue
Block a user