From d2e799929216d7f46b643adbe8c070d26a7f633f Mon Sep 17 00:00:00 2001 From: adelnizamutdinov Date: Wed, 30 Sep 2026 15:05:36 +0300 Subject: [PATCH] Unify podcast episodes and import states with shared desktop design --- design/README.md | 24 + design/tokens.json | 286 +++++++ moon.yml | 1 + packages/desktop/lib/design_tokens.dart | 172 +++++ packages/desktop/lib/main.dart | 722 +++++++++--------- packages/desktop/lib/theme.dart | 170 +++++ packages/desktop/test/desktop_e2e_test.dart | 279 ++++++- packages/sync-engine/lib/src/client.dart | 3 + packages/sync-engine/lib/src/database.dart | 87 ++- packages/sync-engine/lib/src/downloads.dart | 13 +- .../sync-engine/lib/src/publicapi/models.dart | 17 + packages/sync-engine/lib/src/sync.dart | 70 +- pubspec.lock | 2 +- pubspec.yaml | 2 + tool/check.dart | 20 + tool/design.dart | 162 ++++ 16 files changed, 1628 insertions(+), 402 deletions(-) create mode 100644 design/README.md create mode 100644 design/tokens.json create mode 100644 packages/desktop/lib/design_tokens.dart create mode 100644 packages/desktop/lib/theme.dart create mode 100644 tool/design.dart diff --git a/design/README.md b/design/README.md new file mode 100644 index 0000000..bcff450 --- /dev/null +++ b/design/README.md @@ -0,0 +1,24 @@ +# Desktop design bridge + +The parent Listenbox repository's `DESIGN.md` is the visual authority. This +directory holds its generated token snapshot so the client repository can build +and check independently. Do not edit the snapshot or generated Dart values. + +From the parent workspace, regenerate with +`dart apps/client2/tool/design.dart --source DESIGN.md`. `moon run +root:desktop-design-check` compares the snapshot with that canonical contract. +The check also participates in the parent lint workflow and affected CI tasks. + +`moon run client:lint` verifies generated Dart against the snapshot and rejects +raw colors, type sizes/weights, corner radii, and seeded palettes in desktop +widgets. UI consumes +`DesignTokens` through `ListenboxTheme`; native controls receive explicit light +and dark color roles, system fonts, capsule actions, field geometry, and focus +states. Native action and field dimensions come from the component definitions. +Typography uses Flutter's nearest supported font-weight step. Layout +dimensions remain specific to this desktop surface. + +The recording-studio identity is expressed through the quiet work area, +achromatic navigation, readable rows, and clear states. Product UI uses no +homepage imagery. Design changes belong in the parent contract and its linked +system files, followed by regeneration here. diff --git a/design/tokens.json b/design/tokens.json new file mode 100644 index 0000000..867f652 --- /dev/null +++ b/design/tokens.json @@ -0,0 +1,286 @@ +{ + "colors": { + "bg": "oklch(0.9851 0 0)", + "sheet": "oklch(1 0 0)", + "surface": "oklch(0.9702 0 0)", + "surface-hover": "oklch(0.9401 0 0)", + "navigation-active": "oklch(0.9219 0 0)", + "navigation-active-end": "oklch(0.9219 0 0)", + "surface-muted": "oklch(0.9612 0 0)", + "ink": "oklch(0.2435 0 0)", + "muted": "oklch(0.4532 0 0)", + "placeholder": "oklch(0.4962 0 0)", + "border": "oklch(0.8761 0 0)", + "divider": "oklch(0.9219 0 0)", + "border-strong": "oklch(0.6167 0 0)", + "primary": "oklch(0.3092 0 0)", + "primary-end": "oklch(0.3523 0 0)", + "primary-hover": "oklch(0.2264 0 0)", + "primary-hover-end": "oklch(0.2686 0 0)", + "primary-soft": "oklch(0.9642 0 0)", + "accent-soft": "oklch(0.931 0 0)", + "ocean-deep": "oklch(0.2603 0 0)", + "focus": "oklch(0.3092 0 0)", + "focus-inverse": "oklch(1 0 0)", + "rail": "oklch(0.9612 0 0)", + "rail-mobile": "oklch(0.9612 0 0)", + "rail-end": "oklch(0.9612 0 0)", + "rail-control": "oklch(1 0 0)", + "rail-control-end": "oklch(1 0 0)", + "rail-plan": "oklch(0.3092 0 0)", + "dark-bg": "oklch(0.2134 0 0)", + "dark-sheet": "oklch(0.252 0 0)", + "dark-surface": "oklch(0.2768 0 0)", + "dark-surface-hover": "oklch(0.329 0 0)", + "dark-ink": "oklch(0.9702 0 0)", + "dark-muted": "oklch(0.8015 0 0)", + "dark-border": "oklch(0.4349 0 0)", + "dark-divider": "oklch(0.3446 0 0)", + "dark-primary": "oklch(0.4532 0 0)", + "dark-primary-end": "oklch(0.4891 0 0)", + "dark-rail": "oklch(0.2478 0 0)", + "dark-rail-mobile": "oklch(0.2478 0 0)", + "dark-rail-end": "oklch(0.2478 0 0)", + "dark-rail-control": "oklch(0.2931 0 0)", + "dark-navigation-active": "oklch(0.3368 0 0)", + "dark-navigation-active-end": "oklch(0.3368 0 0)", + "success": "oklch(0.5368 0.0853 140.3)", + "warning": "oklch(0.726 0.1421 78.7)", + "danger": "oklch(0.5861 0.1563 27.2)", + "danger-soft": "oklch(0.9492 0.0198 37)", + "danger-strong": "oklch(0.4482 0.1273 26.6)", + "youtube": "#ff1612", + "spotify": "#1db954", + "apple-podcasts": "#8f32d2", + "rss": "#ff741f", + "dark-primary-hover": "oklch(0.5173 0 0)", + "dark-primary-hover-end": "oklch(0.5173 0 0)", + "dark-primary-soft": "oklch(0.3092 0 0)", + "dark-focus": "oklch(0.8545 0 0)", + "dark-accent-strong": "oklch(0.8545 0 0)", + "dark-placeholder": "oklch(0.7316 0 0)" + }, + "typography": { + "display": { + "fontFamily": "ui-sans-serif, system-ui, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\"", + "fontSize": "clamp(3rem, 7vw, 5.5rem)", + "fontWeight": 700, + "lineHeight": 1.02, + "letterSpacing": "-0.025em" + }, + "headline": { + "fontFamily": "ui-sans-serif, system-ui, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\"", + "fontSize": "clamp(2rem, 4vw, 3.5rem)", + "fontWeight": 700, + "lineHeight": 1.08, + "letterSpacing": "-0.02em" + }, + "page-title": { + "fontFamily": "ui-sans-serif, system-ui, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\"", + "fontSize": "1.875rem", + "fontWeight": 700, + "lineHeight": 1, + "letterSpacing": "-0.025em" + }, + "title": { + "fontFamily": "ui-sans-serif, system-ui, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\"", + "fontSize": "1.125rem", + "fontWeight": 650, + "lineHeight": 1.35, + "letterSpacing": "0em" + }, + "body": { + "fontFamily": "ui-sans-serif, system-ui, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\"", + "fontSize": "1rem", + "fontWeight": 400, + "lineHeight": 1.6, + "letterSpacing": "0em" + }, + "field": { + "fontFamily": "ui-sans-serif, system-ui, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\"", + "fontSize": "0.875rem", + "fontWeight": 400, + "lineHeight": 1.5, + "letterSpacing": "0em" + }, + "label": { + "fontFamily": "ui-sans-serif, system-ui, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\"", + "fontSize": "0.875rem", + "fontWeight": 600, + "lineHeight": 1.25, + "letterSpacing": "0em" + }, + "button": { + "fontFamily": "ui-sans-serif, system-ui, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\"", + "fontSize": "0.875rem", + "fontWeight": 700, + "lineHeight": 1.25, + "letterSpacing": "0em" + }, + "homepage-display": { + "fontFamily": "ui-sans-serif, system-ui, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\"", + "fontSize": "clamp(28px, 6.4vw, 88px)", + "fontWeight": 680, + "lineHeight": 1.04, + "letterSpacing": "-0.035em" + }, + "campaign-headline": { + "fontFamily": "ui-sans-serif, system-ui, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\"", + "fontSize": "clamp(36px, 4.2vw, 60px)", + "fontWeight": 720, + "lineHeight": 1.08, + "letterSpacing": "-0.035em" + }, + "support": { + "fontFamily": "ui-sans-serif, system-ui, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\"", + "fontSize": "15px", + "fontWeight": 400, + "lineHeight": 1.6, + "letterSpacing": "0em" + }, + "feature-title": { + "fontFamily": "ui-sans-serif, system-ui, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\"", + "fontSize": "17px", + "fontWeight": 700, + "lineHeight": 1.35, + "letterSpacing": "0em" + }, + "code-label": { + "fontFamily": "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace", + "fontSize": "13px", + "fontWeight": 400, + "lineHeight": 1.5, + "letterSpacing": "0em" + }, + "badge-detail": { + "fontFamily": "ui-sans-serif, system-ui, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\"", + "fontSize": "0.6875rem", + "fontWeight": 900, + "lineHeight": 1.25, + "letterSpacing": "0.04em" + }, + "eyebrow-compact": { + "fontFamily": "ui-sans-serif, system-ui, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\"", + "fontSize": "0.65rem", + "fontWeight": 700, + "lineHeight": 1.25, + "letterSpacing": "0.04em" + } + }, + "rounded": { + "xs": "6px", + "sm": "10px", + "md": "14px", + "lg": "16px", + "xl": "20px", + "2xl": "24px", + "3xl": "32px", + "4xl": "40px", + "button": "999px", + "surface": "16px", + "media": "20px", + "panel": "24px", + "icon": "14px", + "pill": "999px" + }, + "spacing": { + "xs": "4px", + "sm": "8px", + "md": "12px", + "lg": "16px", + "xl": "24px", + "2xl": "32px", + "3xl": "48px", + "4xl": "64px" + }, + "components": { + "button-primary": { + "backgroundColor": "{colors.primary}", + "textColor": "{colors.sheet}", + "typography": "{typography.button}", + "rounded": "{rounded.button}", + "padding": "0 16px", + "height": "36px" + }, + "button-primary-hover": { + "backgroundColor": "{colors.primary-hover}", + "textColor": "{colors.sheet}", + "typography": "{typography.button}", + "rounded": "{rounded.button}", + "padding": "0 16px", + "height": "36px" + }, + "button-marketing-primary": { + "backgroundColor": "{colors.primary}", + "textColor": "{colors.sheet}", + "typography": "{typography.button}", + "rounded": "{rounded.button}", + "padding": "0 16px", + "height": "40px" + }, + "button-secondary": { + "backgroundColor": "{colors.sheet}", + "textColor": "{colors.ink}", + "typography": "{typography.button}", + "rounded": "{rounded.button}", + "padding": "0 16px", + "height": "36px" + }, + "input-default": { + "backgroundColor": "{colors.sheet}", + "textColor": "{colors.ink}", + "typography": "{typography.field}", + "rounded": "{rounded.md}", + "padding": "0 12px", + "height": "36px" + }, + "chip-active": { + "backgroundColor": "{colors.primary-soft}", + "textColor": "{colors.primary}", + "typography": "{typography.label}", + "rounded": "{rounded.pill}", + "padding": "4px 10px" + }, + "card-default": { + "backgroundColor": "{colors.sheet}", + "textColor": "{colors.ink}", + "rounded": "{rounded.surface}", + "padding": "16px" + }, + "card-media": { + "backgroundColor": "{colors.sheet}", + "textColor": "{colors.ink}", + "rounded": "{rounded.media}", + "padding": "24px" + }, + "panel-default": { + "backgroundColor": "{colors.sheet}", + "textColor": "{colors.ink}", + "rounded": "{rounded.panel}", + "padding": "24px" + }, + "navigation-active": { + "backgroundColor": "{colors.navigation-active}", + "textColor": "{colors.ink}", + "typography": "{typography.label}", + "rounded": "{rounded.md}", + "padding": "8px 10px" + }, + "team-picker": { + "backgroundColor": "{colors.rail-control}", + "textColor": "{colors.ink}", + "typography": "{typography.label}", + "rounded": "{rounded.md}", + "padding": "0 12px", + "height": "44px" + }, + "button-hero": { + "backgroundColor": "{colors.sheet}", + "textColor": "{colors.ink}", + "typography": "{typography.button}", + "rounded": "{rounded.button}", + "padding": "0 22px", + "height": "52px" + } + } +} diff --git a/moon.yml b/moon.yml index 348729a..6cf6edc 100644 --- a/moon.yml +++ b/moon.yml @@ -19,6 +19,7 @@ fileGroups: - .moon/**/*.yml - moon.yml - tool/**/*.dart + - design/**/* - packages/**/pubspec.yaml - packages/**/lib/**/*.dart - packages/**/bin/**/*.dart diff --git a/packages/desktop/lib/design_tokens.dart b/packages/desktop/lib/design_tokens.dart new file mode 100644 index 0000000..cca2768 --- /dev/null +++ b/packages/desktop/lib/design_tokens.dart @@ -0,0 +1,172 @@ +// Generated from DESIGN.md by tool/design.dart. Do not edit. +import 'package:flutter/material.dart'; + +abstract final class DesignTokens { + static const bg = Color(0xfffafafa); + static const sheet = Color(0xffffffff); + static const surface = Color(0xfff5f5f5); + static const surfaceHover = Color(0xffebebeb); + static const navigationActive = Color(0xffe5e5e5); + static const navigationActiveEnd = Color(0xffe5e5e5); + static const surfaceMuted = Color(0xfff2f2f2); + static const ink = Color(0xff202020); + static const muted = Color(0xff565656); + static const placeholder = Color(0xff626262); + static const border = Color(0xffd6d6d6); + static const divider = Color(0xffe5e5e5); + static const borderStrong = Color(0xff858585); + static const primary = Color(0xff303030); + static const primaryEnd = Color(0xff3b3b3b); + static const primaryHover = Color(0xff1c1c1c); + static const primaryHoverEnd = Color(0xff262626); + static const primarySoft = Color(0xfff3f3f3); + static const accentSoft = Color(0xffe8e8e8); + static const oceanDeep = Color(0xff242424); + static const focus = Color(0xff303030); + static const focusInverse = Color(0xffffffff); + static const rail = Color(0xfff2f2f2); + static const railMobile = Color(0xfff2f2f2); + static const railEnd = Color(0xfff2f2f2); + static const railControl = Color(0xffffffff); + static const railControlEnd = Color(0xffffffff); + static const railPlan = Color(0xff303030); + static const darkBg = Color(0xff191919); + static const darkSheet = Color(0xff222222); + static const darkSurface = Color(0xff282828); + static const darkSurfaceHover = Color(0xff353535); + static const darkInk = Color(0xfff5f5f5); + static const darkMuted = Color(0xffbebebe); + static const darkBorder = Color(0xff515151); + static const darkDivider = Color(0xff393939); + static const darkPrimary = Color(0xff565656); + static const darkPrimaryEnd = Color(0xff606060); + static const darkRail = Color(0xff212121); + static const darkRailMobile = Color(0xff212121); + static const darkRailEnd = Color(0xff212121); + static const darkRailControl = Color(0xff2c2c2c); + static const darkNavigationActive = Color(0xff373737); + static const darkNavigationActiveEnd = Color(0xff373737); + static const success = Color(0xff527a4a); + static const warning = Color(0xffd69a22); + static const danger = Color(0xffc84f46); + static const dangerSoft = Color(0xfffbeae5); + static const dangerStrong = Color(0xff8e312c); + static const youtube = Color(0xffff1612); + static const spotify = Color(0xff1db954); + static const applePodcasts = Color(0xff8f32d2); + static const rss = Color(0xffff741f); + static const darkPrimaryHover = Color(0xff686868); + static const darkPrimaryHoverEnd = Color(0xff686868); + static const darkPrimarySoft = Color(0xff303030); + static const darkFocus = Color(0xffcfcfcf); + static const darkAccentStrong = Color(0xffcfcfcf); + static const darkPlaceholder = Color(0xffa8a8a8); + static const radiusXs = 6.0; + static const radiusSm = 10.0; + static const radiusMd = 14.0; + static const radiusLg = 16.0; + static const radiusXl = 20.0; + static const radius2xl = 24.0; + static const radius3xl = 32.0; + static const radius4xl = 40.0; + static const radiusButton = 999.0; + static const radiusSurface = 16.0; + static const radiusMedia = 20.0; + static const radiusPanel = 24.0; + static const radiusIcon = 14.0; + static const radiusPill = 999.0; + static const spaceXs = 4.0; + static const spaceSm = 8.0; + static const spaceMd = 12.0; + static const spaceLg = 16.0; + static const spaceXl = 24.0; + static const space2xl = 32.0; + static const space3xl = 48.0; + static const space4xl = 64.0; + static const pageTitleType = TextStyle( + fontSize: 30.0, + fontWeight: FontWeight.w700, + height: 1, + letterSpacing: -0.75, + ); + static const titleType = TextStyle( + fontSize: 18.0, + fontWeight: FontWeight.w700, + height: 1.35, + letterSpacing: 0.0, + ); + static const bodyType = TextStyle( + fontSize: 16.0, + fontWeight: FontWeight.w400, + height: 1.6, + letterSpacing: 0.0, + ); + static const fieldType = TextStyle( + fontSize: 14.0, + fontWeight: FontWeight.w400, + height: 1.5, + letterSpacing: 0.0, + ); + static const labelType = TextStyle( + fontSize: 14.0, + fontWeight: FontWeight.w600, + height: 1.25, + letterSpacing: 0.0, + ); + static const buttonType = TextStyle( + fontSize: 14.0, + fontWeight: FontWeight.w700, + height: 1.25, + letterSpacing: 0.0, + ); + static const supportType = TextStyle( + fontSize: 15.0, + fontWeight: FontWeight.w400, + height: 1.6, + letterSpacing: 0.0, + ); + static const buttonPrimaryHeight = 36.0; + static const buttonPrimaryPadding = EdgeInsets.fromLTRB(16.0, 0.0, 16.0, 0.0); + static const buttonPrimaryHoverHeight = 36.0; + static const buttonPrimaryHoverPadding = EdgeInsets.fromLTRB( + 16.0, + 0.0, + 16.0, + 0.0, + ); + static const buttonMarketingPrimaryHeight = 40.0; + static const buttonMarketingPrimaryPadding = EdgeInsets.fromLTRB( + 16.0, + 0.0, + 16.0, + 0.0, + ); + static const buttonSecondaryHeight = 36.0; + static const buttonSecondaryPadding = EdgeInsets.fromLTRB( + 16.0, + 0.0, + 16.0, + 0.0, + ); + static const inputDefaultHeight = 36.0; + static const inputDefaultPadding = EdgeInsets.fromLTRB(12.0, 0.0, 12.0, 0.0); + static const chipActivePadding = EdgeInsets.fromLTRB(10.0, 4.0, 10.0, 4.0); + static const cardDefaultPadding = EdgeInsets.fromLTRB(16.0, 16.0, 16.0, 16.0); + static const cardMediaPadding = EdgeInsets.fromLTRB(24.0, 24.0, 24.0, 24.0); + static const panelDefaultPadding = EdgeInsets.fromLTRB( + 24.0, + 24.0, + 24.0, + 24.0, + ); + static const navigationActivePadding = EdgeInsets.fromLTRB( + 10.0, + 8.0, + 10.0, + 8.0, + ); + static const teamPickerHeight = 44.0; + static const teamPickerPadding = EdgeInsets.fromLTRB(12.0, 0.0, 12.0, 0.0); + static const buttonHeroHeight = 52.0; + static const buttonHeroPadding = EdgeInsets.fromLTRB(22.0, 0.0, 22.0, 0.0); +} diff --git a/packages/desktop/lib/main.dart b/packages/desktop/lib/main.dart index 828a84b..7460e48 100644 --- a/packages/desktop/lib/main.dart +++ b/packages/desktop/lib/main.dart @@ -9,6 +9,8 @@ import 'package:url_launcher/url_launcher.dart'; import 'package:window_manager/window_manager.dart'; import 'desktop_host.dart'; +import 'design_tokens.dart'; +import 'theme.dart'; /// The client belongs to the running app, so a Flutter hot reload rebuilds the /// interface without reopening SQLite or cancelling admitted transfers. @@ -72,23 +74,6 @@ class ListenboxDesktop extends StatefulWidget { State createState() => DesktopWorkspaceState(); } -class _Palette { - const _Palette(this.dark); - final bool dark; - Color get background => - dark ? const Color(0xff242424) : const Color(0xfffcfcfc); - Color get rail => dark ? const Color(0xff292929) : const Color(0xfff1f1f1); - Color get sheet => dark ? const Color(0xff2b2b2b) : Colors.white; - Color get ink => dark ? const Color(0xfff8f8f8) : const Color(0xff303030); - Color get muted => dark ? const Color(0xffcccccc) : const Color(0xff656565); - Color get border => dark ? const Color(0xff777777) : const Color(0xff999999); - Color get divider => dark ? const Color(0xff444444) : const Color(0xffe7e7e7); - Color get selected => - dark ? const Color(0xff484848) : const Color(0xffe6e6e6); - Color get action => dark ? const Color(0xff727272) : const Color(0xff303030); - Color get danger => dark ? const Color(0xffef9a8d) : const Color(0xffb44d42); -} - class _Notice { const _Notice(this.message, {this.upgradeUrl, this.signIn = false}); final String message; @@ -119,6 +104,8 @@ class DesktopWorkspaceState extends State String? _cookieError; _Notice? notice; List> episodes = []; + List _sourceItems = []; + bool _showIssues = false; final Map _jobs = {}; final Map _reports = {}; final TextEditingController source = TextEditingController(); @@ -394,7 +381,6 @@ class DesktopWorkspaceState extends State onReport: (report) { if (!mounted || stopping) return; setState(() => _reports[slug] = _reportText(report)); - if (selectedSlug == slug) unawaited(loadEpisodes()); }, ); } on OperationCancelled { @@ -419,6 +405,8 @@ class DesktopWorkspaceState extends State } finally { if (mounted) setState(() => _jobs.remove(slug)); settled.complete(); + if (mounted && !stopping && selectedSlug == slug) + unawaited(loadEpisodes()); } } @@ -432,6 +420,8 @@ class DesktopWorkspaceState extends State importOpen = false; settingsOpen = false; episodes = []; + _sourceItems = []; + _showIssues = false; _episodeCursor = null; _episodeError = null; }); @@ -463,11 +453,14 @@ class DesktopWorkspaceState extends State cancel: token, ); if (!mounted || request != _episodeRequest || stopping) return; + final sourceItems = await client.syncItems(slug, cancel: token); + if (!mounted || request != _episodeRequest || stopping) return; final incoming = (page['episodes'] as List).cast>(); final next = page['next_cursor'] as String?; setState(() { final previous = append ? episodes : >[]; final ids = previous.map((row) => row['id']).toSet(); + _sourceItems = sourceItems; episodes = [ ...previous, ...incoming.where((row) => ids.add(row['id'])), @@ -656,64 +649,14 @@ class DesktopWorkspaceState extends State @override Widget build(BuildContext context) { - final t = _Palette( + final t = ListenboxTheme( MediaQuery.platformBrightnessOf(context) == Brightness.dark, ); - final theme = ThemeData( - brightness: t.dark ? Brightness.dark : Brightness.light, - useMaterial3: true, - fontFamily: Platform.isMacOS ? '.SF NS Text' : null, - scaffoldBackgroundColor: t.background, - colorScheme: ColorScheme.fromSeed( - seedColor: t.action, - brightness: t.dark ? Brightness.dark : Brightness.light, - surface: t.sheet, - onSurface: t.ink, - primary: t.action, - onPrimary: Colors.white, - ), - textTheme: Theme.of(context).textTheme - .apply(bodyColor: t.ink, displayColor: t.ink), - textButtonTheme: TextButtonThemeData( - style: TextButton.styleFrom( - foregroundColor: t.ink, - textStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600), - ), - ), - filledButtonTheme: FilledButtonThemeData( - style: FilledButton.styleFrom( - backgroundColor: t.action, - foregroundColor: Colors.white, - textStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600), - minimumSize: const Size(0, 36), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - ), - ), - inputDecorationTheme: InputDecorationTheme( - isDense: true, - filled: true, - fillColor: t.sheet, - hintStyle: TextStyle(color: t.muted), - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 12, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: BorderSide(color: t.border), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: BorderSide(color: t.border), - ), - ), - ); return MaterialApp( title: 'Listenbox', debugShowCheckedModeBanner: false, - theme: theme, + theme: const ListenboxTheme(false).data, + darkTheme: const ListenboxTheme(true).data, home: Scaffold( body: Stack( children: [ @@ -730,7 +673,7 @@ class DesktopWorkspaceState extends State ); } - Widget _sidebar(_Palette t) { + Widget _sidebar(ListenboxTheme t) { final scoped = shows .where((show) => teamId == null || show['team_id'] == teamId) .toList(); @@ -748,10 +691,7 @@ class DesktopWorkspaceState extends State decoration: BoxDecoration( border: Border(bottom: BorderSide(color: t.divider)), ), - child: const Text( - 'Listenbox', - style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600), - ), + child: Text('Listenbox', style: t.label), ), Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 12), @@ -764,7 +704,9 @@ class DesktopWorkspaceState extends State : null, style: TextButton.styleFrom( padding: EdgeInsets.zero, - minimumSize: const Size.fromHeight(36), + minimumSize: const Size.fromHeight( + DesignTokens.buttonPrimaryHeight, + ), ), child: Row( children: [ @@ -772,11 +714,7 @@ class DesktopWorkspaceState extends State child: Text( teamId == null ? 'All teams' : _teamName(teamId), overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: t.ink, - ), + style: t.title, ), ), Icon( @@ -820,7 +758,9 @@ class DesktopWorkspaceState extends State : null, style: FilledButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 12), - minimumSize: const Size.fromHeight(36), + minimumSize: const Size.fromHeight( + DesignTokens.buttonPrimaryHeight, + ), ), child: const Row( children: [ @@ -848,7 +788,7 @@ class DesktopWorkspaceState extends State padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), child: Text( 'Your imported playlists will appear here.', - style: TextStyle(fontSize: 12, color: t.muted), + style: t.meta, ), ); final show = scoped[index], slug = _string(show, 'slug'); @@ -862,11 +802,13 @@ class DesktopWorkspaceState extends State child: Material( color: !importOpen && selectedSlug == slug ? t.selected - : Colors.transparent, - borderRadius: BorderRadius.circular(10), + : t.sheet.withValues(alpha: 0), + borderRadius: BorderRadius.circular(DesignTokens.radiusMd), child: InkWell( key: Key('show-$slug'), - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular( + DesignTokens.radiusMd, + ), onTap: () => selectShow(slug), child: Padding( padding: const EdgeInsets.all(8), @@ -882,19 +824,10 @@ class DesktopWorkspaceState extends State _string(show, 'title'), maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - ), + style: t.label, ), const SizedBox(height: 3), - Text( - status, - style: TextStyle( - fontSize: 12, - color: t.muted, - ), - ), + Text(status, style: t.meta), ], ), ), @@ -911,10 +844,7 @@ class DesktopWorkspaceState extends State padding: const EdgeInsets.all(16), child: Align( alignment: Alignment.centerLeft, - child: Text( - 'YouTube → Listenbox', - style: TextStyle(fontSize: 12, color: t.muted), - ), + child: Text('YouTube → Listenbox', style: t.meta), ), ), ], @@ -922,7 +852,7 @@ class DesktopWorkspaceState extends State ); } - Widget _teamChoice(_Palette t, String label, String? id) => TextButton( + Widget _teamChoice(ListenboxTheme t, String label, String? id) => TextButton( key: Key(id == null ? 'all-teams' : 'team-$id'), onPressed: () { setState(() { @@ -935,7 +865,7 @@ class DesktopWorkspaceState extends State selectShow(first?['slug'] as String?); }, style: TextButton.styleFrom( - minimumSize: const Size.fromHeight(36), + minimumSize: const Size.fromHeight(DesignTokens.buttonPrimaryHeight), padding: const EdgeInsets.symmetric(horizontal: 8), ), child: Align( @@ -949,7 +879,7 @@ class DesktopWorkspaceState extends State ), ); - Widget _artwork(String? url, double size, _Palette t) { + Widget _artwork(String? url, double size, ListenboxTheme t) { final imageUrl = url?.trim(); final uri = imageUrl == null ? null : Uri.tryParse(imageUrl); final validUrl = @@ -960,7 +890,9 @@ class DesktopWorkspaceState extends State .ceil() .clamp(1, 512); return ClipRRect( - borderRadius: BorderRadius.circular(size == 44 ? 8 : 14), + borderRadius: BorderRadius.circular( + size == 44 ? DesignTokens.radiusIcon : DesignTokens.radiusMedia, + ), child: SizedBox( width: size, height: size, @@ -985,85 +917,117 @@ class DesktopWorkspaceState extends State ); } - Widget _artworkFallback(_Palette t) => Container( + Widget _artworkFallback(ListenboxTheme t) => Container( color: t.selected, child: Icon(Icons.headphones, color: t.muted, size: 22), ); - Widget _mainPane(_Palette t) => Column( - children: [ - Container( - height: 56, - padding: const EdgeInsets.symmetric(horizontal: 24), - decoration: BoxDecoration( - border: Border(bottom: BorderSide(color: t.divider)), - ), - child: Row( - children: [ - Expanded( - child: Text( - settingsOpen ? 'Settings' : 'YouTube imports', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: t.muted, + Widget _mainPane(ListenboxTheme t) { + final rows = _rows; + final visible = rows.where((row) => row.issue == _showIssues).toList(); + return Column( + children: [ + Container( + height: 56, + padding: const EdgeInsets.symmetric(horizontal: 24), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: t.divider)), + ), + child: Row( + children: [ + Expanded( + child: Text( + settingsOpen ? 'Settings' : 'YouTube imports', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: t.label.copyWith(color: t.muted), ), ), - ), - if (loaded) - TextButton( - key: const Key('reload'), - onPressed: loading || stopping ? null : reload, - child: const Text('Reload'), - ), - if (loaded) const SizedBox(width: 12), - if (loaded) + if (loaded) + TextButton( + key: const Key('reload'), + onPressed: loading || stopping ? null : reload, + child: const Text('Reload'), + ), + if (loaded) const SizedBox(width: 12), + if (loaded) + TextButton( + key: const Key('settings'), + onPressed: stopping ? null : openSettings, + child: const Text('Settings'), + ), + const SizedBox(width: 12), TextButton( - key: const Key('settings'), - onPressed: stopping ? null : openSettings, - child: const Text('Settings'), - ), - const SizedBox(width: 12), - TextButton( - key: const Key('account'), - onPressed: authenticating || stopping - ? null - : (loaded ? logout : login), - child: Text( - authenticating - ? 'Finish in your browser…' - : loaded - ? 'Log out' - : 'Sign in', + key: const Key('account'), + onPressed: authenticating || stopping + ? null + : (loaded ? logout : login), + child: Text( + authenticating + ? 'Finish in your browser…' + : loaded + ? 'Log out' + : 'Sign in', + ), ), - ), - ], + ], + ), ), - ), - Expanded( - child: SingleChildScrollView( - key: const Key('workspace-content'), - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (notice != null && !settingsOpen) _errorNotice(t), - if (settingsOpen) - _settings(t) - else if (importOpen) - _importForm(t) - else - _detail(t), - if (loaded && !settingsOpen && !importOpen) _episodeList(t), - if (loaded && !settingsOpen) _transfers(t), + Expanded( + child: CustomScrollView( + key: const Key('workspace-content'), + slivers: [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(DesignTokens.spaceXl), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (notice != null && !settingsOpen) _errorNotice(t), + if (settingsOpen) + _settings(t) + else if (importOpen) + _importForm(t) + else + _detail(t), + if (loaded && + !settingsOpen && + !importOpen && + selectedShow != null) + _episodeHeader(t, rows), + ], + ), + ), + ), + if (loaded && + !settingsOpen && + !importOpen && + selectedShow != null) ...[ + SliverPadding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.spaceXl, + ), + sliver: SliverList.builder( + itemCount: visible.length, + itemBuilder: (context, index) => + _episodeRow(visible[index], t), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(DesignTokens.spaceXl), + child: _episodeFooter(t, visible), + ), + ), + ], ], ), ), - ), - ], - ); + ], + ); + } - Widget _errorNotice(_Palette t) => Padding( + Widget _errorNotice(ListenboxTheme t) => Padding( padding: const EdgeInsets.only(bottom: 16), child: Column( key: const Key('error-notice'), @@ -1086,7 +1050,7 @@ class DesktopWorkspaceState extends State ), ); - Widget _importForm(_Palette t) { + Widget _importForm(ListenboxTheme t) { final team = _teamName(catalog?.importTeam); return ConstrainedBox( constraints: const BoxConstraints(maxWidth: 620), @@ -1095,22 +1059,17 @@ class DesktopWorkspaceState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - 'Import a YouTube playlist', - style: TextStyle(fontSize: 30, fontWeight: FontWeight.bold), - ), + Text('Import a YouTube playlist', style: t.pageTitle), const SizedBox(height: 12), Text( 'Give your playlist a podcast home. Import it once, then keep new episodes coming with Listenbox.', - style: TextStyle(fontSize: 14, color: t.muted), + style: t.meta, ), const SizedBox(height: 24), - const Text( - 'Playlist URL', - style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600), - ), + Text('Playlist URL', style: t.label), const SizedBox(height: 8), TextField( + style: t.field, key: const Key('playlist-url'), controller: source, enabled: !importing && !stopping, @@ -1121,13 +1080,10 @@ class DesktopWorkspaceState extends State const SizedBox(height: 8), Text( 'Use a public playlist. Its title becomes your podcast’s name.', - style: TextStyle(fontSize: 12, color: t.muted), + style: t.meta, ), const SizedBox(height: 24), - const Text( - 'Podcast format', - style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600), - ), + Text('Podcast format', style: t.label), const SizedBox(height: 8), Row( children: [ @@ -1145,7 +1101,7 @@ class DesktopWorkspaceState extends State importVideo ? 'A video plan with enough storage for the playlist is required. Playlist order is preserved. On later syncs, videos removed from the playlist are removed from the podcast.' : 'A paid podcast plan is required. Playlist order is preserved. On later syncs, videos removed from the playlist are removed from the podcast.', - style: TextStyle(fontSize: 12, color: t.muted), + style: t.meta, ), const SizedBox(height: 16), Wrap( @@ -1184,7 +1140,12 @@ class DesktopWorkspaceState extends State ); } - Widget _formatChoice(_Palette t, String label, IconData icon, bool video) { + Widget _formatChoice( + ListenboxTheme t, + String label, + IconData icon, + bool video, + ) { final selected = importVideo == video; return selected ? FilledButton( @@ -1210,7 +1171,7 @@ class DesktopWorkspaceState extends State foregroundColor: t.ink, side: BorderSide(color: t.border), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(DesignTokens.radiusMd), ), ), child: Row( @@ -1224,7 +1185,7 @@ class DesktopWorkspaceState extends State ); } - Widget _detail(_Palette t) { + Widget _detail(ListenboxTheme t) { if (!loaded) return ConstrainedBox( constraints: const BoxConstraints(maxWidth: 600), @@ -1233,10 +1194,7 @@ class DesktopWorkspaceState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - 'Your playlists. Your podcast.', - style: TextStyle(fontSize: 30, fontWeight: FontWeight.bold), - ), + Text('Your playlists. Your podcast.', style: t.pageTitle), const SizedBox(height: 24), Text( 'Bring a public YouTube playlist to Listenbox, then keep your podcast in sync from this desktop.', @@ -1277,13 +1235,7 @@ class DesktopWorkspaceState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - _string(show, 'title'), - style: const TextStyle( - fontSize: 30, - fontWeight: FontWeight.bold, - ), - ), + Text(_string(show, 'title'), style: t.pageTitle), const SizedBox(height: 16), Text( show['source_kind'] == 'video' @@ -1312,10 +1264,7 @@ class DesktopWorkspaceState extends State const SizedBox(height: 24), Divider(color: t.divider), const SizedBox(height: 24), - const Text( - 'YouTube source', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600), - ), + Text('YouTube source', style: t.title), const SizedBox(height: 12), TextButton( key: const Key('open-playlist'), @@ -1334,7 +1283,7 @@ class DesktopWorkspaceState extends State isPlaylist ? 'Episodes follow the playlist’s order. Removed videos leave this podcast on the next sync.' : 'This podcast imports a YouTube video. Sync again to resume unfinished transfers.', - style: TextStyle(fontSize: 12, color: t.muted), + style: t.meta, ), if (!active) Padding( @@ -1392,194 +1341,217 @@ class DesktopWorkspaceState extends State ); } - Widget _episodeList(_Palette t) { - if (selectedShow == null) return const SizedBox.shrink(); + List<_EpisodeRow> get _rows { + final slug = selectedSlug; + final sources = {for (final item in _sourceItems) item.sourceUrl: item}; + final live = {}; + for (final item in progress?.items ?? const []) { + if (item.sourceId == slug) { + sources[item.sourceUrl] = item; + live.add(item.sourceUrl); + } + } + final published = { + for (final episode in episodes) + if (episode['source_url'] is String) episode['source_url'] as String, + }; + final result = [ + for (final (index, episode) in episodes.indexed) + _EpisodeRow( + order: index, + episode: episode, + item: sources[episode['source_url']], + ), + for (final item in sources.values) + if (!published.contains(item.sourceUrl) && + (item.phase != Phase.complete || live.contains(item.sourceUrl))) + _EpisodeRow(item: item, order: episodes.length), + ]; + result.sort((a, b) { + final position = a.position.compareTo(b.position); + return position != 0 ? position : a.order.compareTo(b.order); + }); + return result; + } + + Widget _episodeHeader(ListenboxTheme t, List<_EpisodeRow> rows) { + final missing = rows.where((row) => row.issue).length; + final published = episodes + .where((episode) => episode['status'] == 'published') + .length; return Padding( - padding: const EdgeInsets.only(top: 24), + padding: const EdgeInsets.only(top: DesignTokens.spaceXl), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Divider(color: t.divider), - const SizedBox(height: 24), - const Text( - 'Episodes', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600), + const SizedBox(height: DesignTokens.spaceXl), + Text( + '${_episodeCursor == null ? '$published in RSS' : '${episodes.length} episodes loaded'} · $missing not imported', + key: const Key('episode-summary'), + style: t.supporting, + ), + const SizedBox(height: DesignTokens.spaceMd), + Wrap( + spacing: DesignTokens.spaceSm, + runSpacing: DesignTokens.spaceSm, + children: [ + _episodeFilter(t, false, 'Episodes'), + _episodeFilter(t, true, 'Not imported ($missing)'), + ], ), - for (final episode in episodes) - Container( - padding: const EdgeInsets.symmetric(vertical: 12), - decoration: BoxDecoration( - border: Border(bottom: BorderSide(color: t.divider)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(_string(episode, 'title')), - const SizedBox(height: 4), - Text( - [ - if (episode['duration_seconds'] case final int duration - when duration > 0) - _duration(duration), - episode['status'] == 'published' ? 'Published' : 'Draft', - ].join(' · '), - style: TextStyle(fontSize: 12, color: t.muted), - ), - ], - ), - ), - if (_episodeLoading) - Padding( - padding: const EdgeInsets.symmetric(vertical: 12), - child: Text( - 'Loading episodes…', - style: TextStyle(color: t.muted), - ), - ), - if (!_episodeLoading && _episodeError != null) ...[ - Text(_episodeError!, style: TextStyle(color: t.danger)), - TextButton( - key: const Key('retry-episodes'), - onPressed: loadEpisodes, - child: const Text('Reload episodes'), - ), - ], - if (!_episodeLoading && _episodeError == null && episodes.isEmpty) - Padding( - padding: const EdgeInsets.symmetric(vertical: 12), - child: Text( - 'No episodes synced yet.', - style: TextStyle(color: t.muted), - ), - ), - if (_episodeCursor != null) - TextButton( - key: const Key('more-episodes'), - onPressed: _episodeLoading - ? null - : () => loadEpisodes(append: true), - child: const Text('Load more episodes'), - ), ], ), ); } - String _duration(int seconds) => seconds >= 3600 - ? '${seconds ~/ 3600}:${((seconds ~/ 60) % 60).toString().padLeft(2, '0')}:${(seconds % 60).toString().padLeft(2, '0')}' - : '${seconds ~/ 60}:${(seconds % 60).toString().padLeft(2, '0')}'; + Widget _episodeFilter(ListenboxTheme t, bool issues, String label) => + Semantics( + selected: _showIssues == issues, + child: TextButton( + key: Key(issues ? 'filter-not-imported' : 'filter-episodes'), + onPressed: () => setState(() => _showIssues = issues), + style: TextButton.styleFrom( + backgroundColor: _showIssues == issues ? t.selected : null, + ), + child: Text(label), + ), + ); - Widget _transfers(_Palette t) { - final transfers = - progress?.items.reversed.take(100).toList() ?? const []; - return Padding( - padding: const EdgeInsets.only(top: 24), + Widget _episodeRow(_EpisodeRow row, ListenboxTheme t) { + final item = row.item; + final episode = row.episode; + final status = episode != null + ? (episode['status'] == 'published' ? 'Published' : 'Draft') + : item!.phase == Phase.queued && !_jobs.containsKey(selectedSlug) + ? 'Waiting for sync' + : _phase(item.phase); + final duration = + episode?['duration_seconds'] as int? ?? item?.durationSeconds; + return Container( + key: Key('episode-row-${episode?['id'] ?? item!.id}'), + padding: const EdgeInsets.symmetric(vertical: DesignTokens.spaceLg), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: t.divider)), + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Divider(color: t.divider), - const SizedBox(height: 24), - const Text( - 'Transfers', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600), - ), - if (transfers.isEmpty) - Padding( - padding: const EdgeInsets.symmetric(vertical: 24), - child: Text( - 'New episodes will appear here as they sync.', - style: TextStyle(color: t.muted), - ), - ), - for (final item in transfers) - Container( - padding: const EdgeInsets.symmetric(vertical: 12), - decoration: BoxDecoration( - border: Border(bottom: BorderSide(color: t.divider)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(item.title), - const SizedBox(height: 4), - Text( - item.durationSeconds == null - ? item.sourceTitle - : '${item.sourceTitle} · ${_duration(item.durationSeconds!)}', - style: TextStyle(fontSize: 12, color: t.muted), - ), - ], - ), - ), - const SizedBox(width: 16), - Text( - _phase(item.phase), - style: TextStyle( - color: item.phase == Phase.failed - ? t.danger - : t.muted, - ), - ), - ], - ), - if (item.phase == Phase.downloading && item.total > 0) ...[ - const SizedBox(height: 8), - LinearProgressIndicator( - value: (item.received / item.total).clamp(0, 1), - backgroundColor: t.selected, - color: t.action, - minHeight: 5, - borderRadius: BorderRadius.circular(5), - ), - const SizedBox(height: 6), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ Text( - '${(item.received / 1000000).toStringAsFixed(1)} / ${(item.total / 1000000).toStringAsFixed(1)} MB', - style: TextStyle(fontSize: 12, color: t.muted), + episode?['title'] as String? ?? item!.title, + style: t.body, ), + if (duration != null && duration > 0) ...[ + const SizedBox(height: DesignTokens.spaceXs), + Text(_duration(duration), style: t.meta), + ], ], - if (item.reason != null) - Text(item.reason!, style: TextStyle(color: t.muted)), - if (item.error != null) - Text(item.error!, style: TextStyle(color: t.danger)), - ], + ), + ), + const SizedBox(width: DesignTokens.spaceLg), + Text( + status, + style: t.meta.copyWith(color: row.issue ? t.danger : t.muted), ), + ], + ), + if (episode == null && + item!.phase == Phase.downloading && + item.total > 0) ...[ + const SizedBox(height: DesignTokens.spaceSm), + LinearProgressIndicator( + value: (item.received / item.total).clamp(0, 1), + semanticsLabel: 'Downloading ${item.title}', + semanticsValue: + '${(item.received / item.total * 100).round().clamp(0, 100)}', + minHeight: DesignTokens.spaceXs, ), + const SizedBox(height: DesignTokens.spaceXs), + Text( + '${(item.received / 1000000).toStringAsFixed(1)} / ${(item.total / 1000000).toStringAsFixed(1)} MB', + style: t.meta, + ), + ], + if (row.issue) ...[ + const SizedBox(height: DesignTokens.spaceSm), + Text( + item!.reason ?? item.error ?? 'Import did not finish.', + style: t.body, + ), + const SizedBox(height: DesignTokens.spaceXs), + TextButton.icon( + key: Key('open-source-${item.id}'), + onPressed: () => _open(item.sourceUrl), + icon: const Icon(Icons.open_in_new, size: 16), + label: const Text('Open on YouTube'), + ), + ], ], ), ); } + Widget _episodeFooter(ListenboxTheme t, List<_EpisodeRow> visible) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_episodeLoading) Text('Loading episodes…', style: t.supporting), + if (!_episodeLoading && _episodeError != null) ...[ + Text(_episodeError!, style: t.body.copyWith(color: t.danger)), + TextButton( + key: const Key('retry-episodes'), + onPressed: loadEpisodes, + child: const Text('Reload episodes'), + ), + ], + if (!_episodeLoading && _episodeError == null && visible.isEmpty) + Text( + _showIssues ? 'No import issues.' : 'No episodes synced yet.', + style: t.supporting, + ), + if (_showIssues && visible.isNotEmpty) + Text( + 'Sync again after resolving the source issues.', + style: t.supporting, + ), + if (!_showIssues && _episodeCursor != null) + TextButton( + key: const Key('more-episodes'), + onPressed: _episodeLoading ? null : () => loadEpisodes(append: true), + child: const Text('Load more episodes'), + ), + ], + ); + + String _duration(int seconds) => seconds >= 3600 + ? '${seconds ~/ 3600}:${((seconds ~/ 60) % 60).toString().padLeft(2, '0')}:${(seconds % 60).toString().padLeft(2, '0')}' + : '${seconds ~/ 60}:${(seconds % 60).toString().padLeft(2, '0')}'; + String _phase(Phase phase) => switch (phase) { Phase.queued => 'Queued', Phase.resolving => 'Reading source', Phase.downloading => 'Downloading', Phase.preparing => 'Preparing media', Phase.uploading => 'Uploading', - Phase.complete => 'Complete', - Phase.skipped => 'Skipped', - Phase.failed => 'Failed', + Phase.complete => 'Published', + Phase.skipped => 'Unavailable', + Phase.failed => 'Import failed', }; - Widget _settings(_Palette t) => ConstrainedBox( + Widget _settings(ListenboxTheme t) => ConstrainedBox( constraints: const BoxConstraints(maxWidth: 720), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ - const Expanded( - child: Text( - 'Settings', - style: TextStyle(fontSize: 30, fontWeight: FontWeight.bold), - ), - ), + Expanded(child: Text('Settings', style: t.pageTitle)), TextButton( key: const Key('close-settings'), onPressed: cookieBusy @@ -1593,10 +1565,7 @@ class DesktopWorkspaceState extends State ], ), const SizedBox(height: 24), - const Text( - 'YouTube', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600), - ), + Text('YouTube', style: t.title), const SizedBox(height: 8), const Text( 'If YouTube asks you to sign in, add cookies from a private browser session to continue importing.', @@ -1612,10 +1581,7 @@ class DesktopWorkspaceState extends State child: const Text('How to export YouTube cookies'), ), const SizedBox(height: 24), - const Text( - 'YouTube cookies', - style: TextStyle(fontWeight: FontWeight.w600), - ), + Text('YouTube cookies', style: t.label), const SizedBox(height: 8), Text( cookieSaved @@ -1625,6 +1591,7 @@ class DesktopWorkspaceState extends State ), const SizedBox(height: 8), TextField( + style: t.field, key: const Key('cookie-export'), controller: cookieText, enabled: !cookieBusy && !stopping, @@ -1659,26 +1626,23 @@ class DesktopWorkspaceState extends State ), ); - Widget _quitNotice(_Palette t) => IgnorePointer( + Widget _quitNotice(ListenboxTheme t) => IgnorePointer( child: Center( child: Container( width: 280, padding: const EdgeInsets.all(24), decoration: BoxDecoration( color: t.action.withValues(alpha: 0.96), - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(DesignTokens.radiusPanel), ), - child: const Column( + child: Column( mainAxisSize: MainAxisSize.min, children: [ - Text( - 'Saving progress', - style: TextStyle(color: Colors.white, fontSize: 18), - ), + Text('Saving progress', style: t.title.copyWith(color: t.onAction)), SizedBox(height: 12), Text( 'Finishing current work…', - style: TextStyle(color: Colors.white), + style: t.body.copyWith(color: t.onAction), ), ], ), @@ -1686,3 +1650,17 @@ class DesktopWorkspaceState extends State ), ); } + +class _EpisodeRow { + const _EpisodeRow({this.episode, this.item, required this.order}); + final int order; + final Map? episode; + final Download? item; + bool get issue => + episode == null && + (item?.phase == Phase.skipped || item?.phase == Phase.failed); + int get position => + item?.position ?? + episode?['source_position'] as int? ?? + 0x7fffffffffffffff; +} diff --git a/packages/desktop/lib/theme.dart b/packages/desktop/lib/theme.dart new file mode 100644 index 0000000..4cb07b1 --- /dev/null +++ b/packages/desktop/lib/theme.dart @@ -0,0 +1,170 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import 'design_tokens.dart'; + +/// Native controls consume the same semantic roles as the web workspace. +/// No seeded Material palette is allowed to invent additional brand colors. +class ListenboxTheme { + const ListenboxTheme(this.dark); + final bool dark; + String get fontFamily => Platform.isMacOS + ? '.SF NS Text' + : Platform.isWindows + ? 'Segoe UI' + : 'sans-serif'; + Color get background => dark ? DesignTokens.darkBg : DesignTokens.bg; + Color get rail => dark ? DesignTokens.darkRail : DesignTokens.rail; + Color get sheet => dark ? DesignTokens.darkSheet : DesignTokens.sheet; + Color get ink => dark ? DesignTokens.darkInk : DesignTokens.ink; + Color get muted => dark ? DesignTokens.darkMuted : DesignTokens.muted; + Color get border => + dark ? DesignTokens.darkBorder : DesignTokens.borderStrong; + Color get divider => dark ? DesignTokens.darkDivider : DesignTokens.divider; + Color get selected => + dark ? DesignTokens.darkNavigationActive : DesignTokens.navigationActive; + Color get action => dark ? DesignTokens.darkPrimary : DesignTokens.primary; + Color get onAction => DesignTokens.sheet; + Color get hover => + dark ? DesignTokens.darkPrimaryHover : DesignTokens.primaryHover; + Color get focus => dark ? DesignTokens.darkFocus : DesignTokens.focus; + Color get danger => + dark ? DesignTokens.dangerSoft : DesignTokens.dangerStrong; + TextStyle get pageTitle => DesignTokens.pageTitleType.copyWith(color: ink); + TextStyle get title => DesignTokens.titleType.copyWith(color: ink); + TextStyle get label => DesignTokens.labelType.copyWith(color: ink); + TextStyle get meta => DesignTokens.fieldType.copyWith(color: muted); + TextStyle get field => DesignTokens.fieldType.copyWith(color: ink); + TextStyle get body => DesignTokens.bodyType.copyWith(color: ink); + TextStyle get supporting => DesignTokens.supportType.copyWith(color: muted); + + ThemeData get data { + final buttons = ButtonStyle( + minimumSize: const WidgetStatePropertyAll( + Size(0, DesignTokens.buttonPrimaryHeight), + ), + padding: const WidgetStatePropertyAll(DesignTokens.buttonPrimaryPadding), + shape: WidgetStatePropertyAll( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(DesignTokens.radiusButton), + ), + ), + textStyle: WidgetStatePropertyAll( + DesignTokens.buttonType.copyWith(fontFamily: fontFamily), + ), + side: WidgetStateProperty.resolveWith( + (states) => states.contains(WidgetState.focused) + ? BorderSide(color: focus, width: 3) + : BorderSide.none, + ), + ); + final fill = WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) + return action.withValues(alpha: 0.12); + if (states.contains(WidgetState.hovered)) return hover; + return action; + }); + return ThemeData( + brightness: dark ? Brightness.dark : Brightness.light, + useMaterial3: true, + fontFamily: fontFamily, + scaffoldBackgroundColor: background, + canvasColor: sheet, + focusColor: focus.withValues(alpha: 0.15), + hoverColor: selected, + colorScheme: ColorScheme( + brightness: dark ? Brightness.dark : Brightness.light, + primary: action, + onPrimary: onAction, + secondary: action, + onSecondary: onAction, + error: danger, + onError: background, + surface: sheet, + onSurface: ink, + onSurfaceVariant: muted, + outline: border, + outlineVariant: divider, + surfaceContainerHighest: selected, + surfaceTint: sheet.withValues(alpha: 0), + ), + textTheme: TextTheme( + bodyLarge: body, + bodyMedium: body, + bodySmall: meta, + titleLarge: title, + titleMedium: label, + labelLarge: DesignTokens.buttonType.copyWith(color: ink), + headlineSmall: pageTitle, + ), + textButtonTheme: TextButtonThemeData( + style: buttons.copyWith(foregroundColor: WidgetStatePropertyAll(ink)), + ), + filledButtonTheme: FilledButtonThemeData( + style: buttons.copyWith( + backgroundColor: fill, + foregroundColor: WidgetStateProperty.resolveWith( + (states) => + states.contains(WidgetState.disabled) ? muted : onAction, + ), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: buttons.copyWith( + foregroundColor: WidgetStatePropertyAll(ink), + side: WidgetStateProperty.resolveWith( + (states) => BorderSide( + color: states.contains(WidgetState.focused) ? focus : border, + width: states.contains(WidgetState.focused) ? 3 : 1, + ), + ), + ), + ), + inputDecorationTheme: InputDecorationTheme( + isDense: true, + filled: true, + fillColor: sheet, + hintStyle: DesignTokens.fieldType.copyWith( + color: dark ? DesignTokens.darkPlaceholder : DesignTokens.placeholder, + ), + contentPadding: DesignTokens.inputDefaultPadding, + constraints: const BoxConstraints( + minHeight: DesignTokens.inputDefaultHeight, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(DesignTokens.radiusMd), + borderSide: BorderSide(color: border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(DesignTokens.radiusMd), + borderSide: BorderSide(color: border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(DesignTokens.radiusMd), + borderSide: BorderSide(color: focus, width: 3), + ), + ), + dividerTheme: DividerThemeData(color: divider, thickness: 1), + textSelectionTheme: TextSelectionThemeData( + cursorColor: ink, + selectionColor: selected, + selectionHandleColor: focus, + ), + progressIndicatorTheme: ProgressIndicatorThemeData( + color: action, + linearTrackColor: selected, + ), + scrollbarTheme: ScrollbarThemeData( + thumbColor: WidgetStatePropertyAll(muted.withValues(alpha: 0.5)), + ), + tooltipTheme: TooltipThemeData( + textStyle: DesignTokens.fieldType.copyWith(color: onAction), + decoration: BoxDecoration( + color: action, + borderRadius: BorderRadius.circular(DesignTokens.radiusSm), + ), + ), + ); + } +} diff --git a/packages/desktop/test/desktop_e2e_test.dart b/packages/desktop/test/desktop_e2e_test.dart index 5115c7b..52862a3 100644 --- a/packages/desktop/test/desktop_e2e_test.dart +++ b/packages/desktop/test/desktop_e2e_test.dart @@ -2,8 +2,11 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:math'; +import 'dart:ui' as ui; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:listenbox_desktop/desktop_host.dart'; import 'package:listenbox_desktop/main.dart'; @@ -12,12 +15,33 @@ import 'package:tray_manager/tray_manager.dart'; import 'package:window_manager/window_manager.dart'; void main() { - _LoopbackTestBinding(); + final binding = _LoopbackTestBinding(); + setUpAll(() async { + if (Platform.environment['LISTENBOX_DESIGN_CAPTURE'] == null) return; + final family = Platform.isMacOS + ? '.SF NS Text' + : Platform.isWindows + ? 'Segoe UI' + : 'sans-serif'; + final file = Platform.isMacOS + ? '/System/Library/Fonts/SFNS.ttf' + : Platform.isWindows + ? '${Platform.environment['WINDIR']}/Fonts/segoeui.ttf' + : '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf'; + await (FontLoader( + family, + )..addFont(File(file).readAsBytes().then(ByteData.sublistView))).load(); + await (FontLoader( + 'MaterialIcons', + )..addFont(rootBundle.load('fonts/MaterialIcons-Regular.otf'))).load(); + }); testWidgets('workspace uses its real client and isolated profile', ( tester, ) async { tester.view.physicalSize = const Size(1080, 900); tester.view.devicePixelRatio = 1; + await binding.setSurfaceSize(const Size(1080, 900)); + addTearDown(() => binding.setSurfaceSize(null)); addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); final service = (await tester.runAsync(_DesktopFixture.start))!; @@ -173,6 +197,253 @@ void main() { await tester.runAsync(service.close); } }, timeout: const Timeout(Duration(seconds: 30))); + + for (final dark in [false, true]) { + testWidgets( + 'episode rows and durable issues in ${dark ? 'dark' : 'light'} appearance', + (tester) async { + tester.view.physicalSize = const Size(1080, 1040); + tester.view.devicePixelRatio = 1; + await binding.setSurfaceSize(const Size(1080, 1040)); + addTearDown(() => binding.setSurfaceSize(null)); + tester.platformDispatcher.platformBrightnessTestValue = dark + ? Brightness.dark + : Brightness.light; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.platformDispatcher.clearPlatformBrightnessTestValue); + final service = (await tester.runAsync(_DesktopFixture.start))!; + const publishedVideo = 'abcdefghij1', + pendingVideo = 'abcdefghij2', + unavailableVideo = 'abcdefghij3'; + const publishedSource = + 'https://www.youtube.com/watch?v=$publishedVideo'; + const unavailableSource = + 'https://www.youtube.com/watch?v=$unavailableVideo'; + const publishedTitle = 'Pendulum - Blood Sugar (Guitar Cover)'; + const pendingTitle = + 'Nine Inch Nails - The Hand That Feeds (Guitar Cover)'; + const unavailableTitle = 'Pendulum - Self vs Self (Guitar Cover)'; + service.episodeRows[service.firstSlug] = [ + { + 'id': 'ep_${service.firstTeam.substring(5)}', + 'show_id': 'shw_${service.firstTeam.substring(5)}', + 'title': publishedTitle, + 'status': 'published', + 'duration_seconds': 318, + 'source_url': publishedSource, + 'source_position': 0, + }, + ]; + await tester.runAsync(() async { + final journal = await Database.open(service.profile); + try { + await journal.snapshot( + service.client.config.apiOrigin, + service.firstSlug, + service.sourceUrl, + [ + (url: publishedSource, title: publishedTitle, duration: 318), + ( + url: 'https://www.youtube.com/watch?v=$pendingVideo', + title: pendingTitle, + duration: 218, + ), + ( + url: unavailableSource, + title: unavailableTitle, + duration: 286, + ), + ], + canRemove: true, + ); + await journal.published( + service.client.config.apiOrigin, + service.firstSlug, + publishedSource, + ); + final unavailable = + Download( + id: '${service.firstSlug}/$unavailableVideo', + sourceId: service.firstSlug, + sourceTitle: 'First playlist', + title: unavailableTitle, + sourceUrl: unavailableSource, + position: 2, + ) + ..phase = Phase.skipped + ..reason = 'Video unavailable' + ..durationSeconds = 286; + await journal.outcome( + service.client.config.apiOrigin, + service.firstSlug, + unavailable, + ); + } finally { + await journal.close(); + } + }); + // The HTTP publication and production transfer projection intentionally + // overlap while the app refreshes: they must render as one identity. + final transfers = service.client.downloads.enqueue( + service.firstSlug, + 'First playlist', + [ + (id: publishedVideo, title: publishedTitle, position: 0), + (id: pendingVideo, title: pendingTitle, position: 1), + ], + ); + transfers.first.phase(Phase.complete); + transfers.last.startDownload(3000000); + service.client.downloads + .enqueue(service.secondSlug, 'Second playlist', [ + (id: 'abcdefghij4', title: 'Other podcast failure', position: 0), + ]) + .single + .error(StateError('Other podcast failure')); + final boundary = GlobalKey(); + var key = GlobalKey(); + final host = _HeadlessDesktopHost(); + try { + await tester.pumpWidget( + RepaintBoundary( + key: boundary, + child: ListenboxDesktop( + key: key, + client: service.client, + host: host, + ), + ), + ); + await tester.runAsync(() => key.currentState!.initialLoad!); + await tester.runAsync( + () => key.currentState!.episodesSettled!.future, + ); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.byKey(const Key('filter-episodes'))); + await tester.pump(); + expect(find.text(publishedTitle), findsOneWidget); + expect(find.text(pendingTitle), findsOneWidget); + expect(find.text('Published'), findsOneWidget); + expect(find.text('Downloading'), findsOneWidget); + expect(find.text(unavailableTitle), findsNothing); + expect(find.text('Other podcast failure'), findsNothing); + expect(find.text('Transfers'), findsNothing); + expect( + tester.getTopLeft(find.text(publishedTitle)).dy, + lessThan(tester.getTopLeft(find.text(pendingTitle)).dy), + ); + await _capture( + tester, + boundary, + 'episodes-${dark ? 'dark' : 'light'}', + ); + await tester.tap(find.byKey(const Key('filter-not-imported'))); + await tester.pumpAndSettle(); + expect(find.text(unavailableTitle), findsOneWidget); + expect(find.text('Video unavailable'), findsOneWidget); + expect(find.text(publishedTitle), findsNothing); + String? opened; + key.currentState!.browserOpener = (url) async { + opened = url; + }; + await tester.tap(find.text('Open on YouTube')); + expect(opened, unavailableSource); + await _capture( + tester, + boundary, + 'not-imported-${dark ? 'dark' : 'light'}', + ); + expect(tester.takeException(), isNull); + + await binding.setSurfaceSize(const Size(840, 600)); + tester.platformDispatcher.textScaleFactorTestValue = 1.25; + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible( + find.text('Open on YouTube'), + 220, + scrollable: find.descendant( + of: find.byKey(const Key('workspace-content')), + matching: find.byType(Scrollable), + ), + ); + await tester.pump(); + expect(tester.takeException(), isNull); + await _capture( + tester, + boundary, + 'compact-not-imported-${dark ? 'dark' : 'light'}', + ); + await tester.ensureVisible(find.byKey(const Key('filter-episodes'))); + await tester.tap(find.byKey(const Key('filter-episodes'))); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible( + find.text(pendingTitle), + 220, + scrollable: find.descendant( + of: find.byKey(const Key('workspace-content')), + matching: find.byType(Scrollable), + ), + ); + await tester.pump(); + expect(tester.takeException(), isNull); + await _capture( + tester, + boundary, + 'compact-episodes-${dark ? 'dark' : 'light'}', + ); + + tester.platformDispatcher.clearTextScaleFactorTestValue(); + await binding.setSurfaceSize(const Size(1080, 1040)); + + await tester.pumpWidget(const SizedBox.shrink()); + final config = service.client.config; + await tester.runAsync(service.client.close); + service.client = Client(config); + key = GlobalKey(); + await tester.pumpWidget( + ListenboxDesktop(key: key, client: service.client, host: host), + ); + await tester.runAsync(() => key.currentState!.initialLoad!); + await tester.runAsync( + () => key.currentState!.episodesSettled!.future, + ); + await tester.pumpAndSettle(); + await tester.ensureVisible( + find.byKey(const Key('filter-not-imported')), + ); + await tester.tap(find.byKey(const Key('filter-not-imported'))); + await tester.pumpAndSettle(); + expect(find.text(unavailableTitle), findsOneWidget); + expect(find.text('Video unavailable'), findsOneWidget); + expect(service.unexpected, isEmpty); + } finally { + await tester.pumpWidget(const SizedBox.shrink()); + await tester.runAsync(service.close); + } + }, + timeout: const Timeout(Duration(seconds: 30)), + ); + } +} + +Future _capture(WidgetTester tester, GlobalKey key, String name) async { + final directory = Platform.environment['LISTENBOX_DESIGN_CAPTURE']; + if (directory == null) return; + final boundary = + key.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final image = await boundary.toImage(pixelRatio: 1); + try { + final bytes = await image.toByteData(format: ui.ImageByteFormat.png); + await tester.runAsync(() async { + await Directory(directory).create(recursive: true); + await File('$directory/$name.png') + .writeAsBytes(bytes!.buffer.asUint8List()); + }); + } finally { + image.dispose(); + } } class _LoopbackTestBinding extends LiveTestWidgetsFlutterBinding { @@ -260,7 +531,8 @@ class _DesktopFixture { final requests = []; final unexpected = []; final artworkServed = Completer(); - late final Client client; + late Client client; + final episodeRows = >>{}; final sourceUrl = 'https://www.youtube.com/playlist?list=PLdesktopFixture'; String get artworkUrl => 'http://localhost:${server.port}/artwork.png'; @@ -353,7 +625,8 @@ class _DesktopFixture { }; } else if (route == 'GET /s/shows/$firstSlug/episodes' || route == 'GET /s/shows/$secondSlug/episodes') { - response = {'episodes': []}; + final slug = request.uri.path.split('/')[3]; + response = {'episodes': episodeRows[slug] ?? []}; } else { unexpected.add(route); request.response.statusCode = HttpStatus.notFound; diff --git a/packages/sync-engine/lib/src/client.dart b/packages/sync-engine/lib/src/client.dart index 9777d8f..928c925 100644 --- a/packages/sync-engine/lib/src/client.dart +++ b/packages/sync-engine/lib/src/client.dart @@ -105,6 +105,9 @@ class Client { return value.toJson().cast(); }); + Future> syncItems(String slug, {CancellationToken? cancel}) => + _run(cancel, (api) => _engine.items(api, slug)); + Future login( FutureOr Function(Uri) openBrowser, { CancellationToken? cancel, diff --git a/packages/sync-engine/lib/src/database.dart b/packages/sync-engine/lib/src/database.dart index f8200f3..1965a2e 100644 --- a/packages/sync-engine/lib/src/database.dart +++ b/packages/sync-engine/lib/src/database.dart @@ -6,6 +6,8 @@ import 'package:sqlite_async/native.dart'; import 'package:sqlite_async/sqlite_async.dart'; import 'package:uuid/uuid.dart'; +import 'downloads.dart'; + /// The local journal owns resumable effects; the API owns published state. /// sqlite_async keeps its writer and reader pool off the application isolate. class Database { @@ -28,7 +30,7 @@ class Database { await connection.writeTransaction((tx) async { final version = (await tx.get('PRAGMA user_version')).columnAt(0) as int; - if (version != 0 && version != 1) { + if (version != 0 && version != 2) { throw StateError('Unsupported sync journal version $version'); } if (version == 0) { @@ -59,9 +61,12 @@ CREATE TABLE download_ranges ( CREATE TABLE source_items ( origin TEXT NOT NULL, show_slug TEXT NOT NULL, collection_url TEXT NOT NULL, source_url TEXT NOT NULL, position INTEGER NOT NULL CHECK(position >= 0), + title TEXT NOT NULL, duration_seconds INTEGER, + phase TEXT NOT NULL CHECK(phase IN ('queued','complete','skipped','failed')), + reason TEXT, error TEXT, PRIMARY KEY(origin, show_slug, source_url) ); -PRAGMA user_version=1; +PRAGMA user_version=2; '''); } }); @@ -84,20 +89,80 @@ PRAGMA user_version=1; String origin, String show, String collection, - List urls, - ) => _connection.writeTransaction((tx) async { - await tx.execute( - 'DELETE FROM source_items WHERE origin IS ? AND show_slug IS ?', - [origin, show], - ); - for (var i = 0; i < urls.length; i++) { + List<({String url, String title, int? duration})> items, { + required bool canRemove, + }) => _connection.writeTransaction((tx) async { + if (canRemove) { + final present = items.map((item) => item.url).toSet(); + final previous = await tx.getAll( + 'SELECT source_url FROM source_items WHERE origin IS ? AND show_slug IS ?', + [origin, show], + ); + for (final row in previous) { + if (!present.contains(row['source_url'])) { + await tx.execute( + 'DELETE FROM source_items WHERE origin IS ? AND show_slug IS ? AND source_url IS ?', + [origin, show, row['source_url']], + ); + } + } + } + for (var i = 0; i < items.length; i++) { + final item = items[i]; await tx.execute( - 'INSERT INTO source_items VALUES (?, ?, ?, ?, ?) ON CONFLICT DO NOTHING', - [origin, show, collection, urls[i], i], + '''INSERT INTO source_items (origin,show_slug,collection_url,source_url,position,title,duration_seconds,phase) + VALUES (?, ?, ?, ?, ?, ?, ?, 'queued') + ON CONFLICT(origin,show_slug,source_url) DO UPDATE SET + position=excluded.position, title=excluded.title, duration_seconds=excluded.duration_seconds''', + [origin, show, collection, item.url, i, item.title, item.duration], ); } }); + Future outcome(String origin, String show, Download item) => + _connection.execute( + '''UPDATE source_items SET title=?, duration_seconds=?, phase=?, reason=?, error=? + WHERE origin IS ? AND show_slug IS ? AND source_url IS ?''', + [ + item.title, + item.durationSeconds, + item.phase.name, + item.reason, + item.error, + origin, + show, + item.sourceUrl, + ], + ); + + Future published(String origin, String show, String source) => + _connection.execute( + "UPDATE source_items SET phase='complete', reason=NULL, error=NULL WHERE origin IS ? AND show_slug IS ? AND source_url IS ?", + [origin, show, source], + ); + + Future> items(String origin, String show) async { + final rows = await _connection.getAll( + "SELECT * FROM source_items WHERE origin IS ? AND show_slug IS ? ORDER BY position", + [origin, show], + ); + return [ + for (final row in rows) + Download( + id: '$show/${Uri.parse(row['source_url'] as String).queryParameters['v']}', + sourceId: show, + sourceTitle: show, + title: row['title'] as String, + sourceUrl: row['source_url'] as String, + position: row['position'] as int, + ) + ..durationSeconds = row['duration_seconds'] as int? + ..phase = Phase.values.byName(row['phase'] as String) + ..reason = row['reason'] as String? + ..error = row['error'] as String?, + ]; + } + Future operation( String origin, String show, diff --git a/packages/sync-engine/lib/src/downloads.dart b/packages/sync-engine/lib/src/downloads.dart index ed1d763..d6609ba 100644 --- a/packages/sync-engine/lib/src/downloads.dart +++ b/packages/sync-engine/lib/src/downloads.dart @@ -34,10 +34,14 @@ class Download { required this.sourceId, required this.sourceTitle, required this.title, + required this.sourceUrl, + this.position, }); final String id; final String sourceId; final String sourceTitle; + final String sourceUrl; + final int? position; String title; int? durationSeconds; String? reason; @@ -83,7 +87,7 @@ class DownloadManager { List enqueue( String sourceId, String sourceTitle, - List<({String id, String title})> episodes, + List<({String id, String title, int position})> episodes, ) { removeSource(sourceId); final result = []; @@ -93,6 +97,8 @@ class DownloadManager { sourceId: sourceId, sourceTitle: sourceTitle, title: episode.title, + sourceUrl: 'https://www.youtube.com/watch?v=${episode.id}', + position: episode.position, ); _items.add(item); result.add(Transfer._(this, item)); @@ -196,7 +202,10 @@ class ActiveTransfer { void finish([Object? error]) { if (_finished) return; _finished = true; - if (error != null) { + if (error is OperationCancelled) { + transfer.item.error = null; + transfer.phase(Phase.queued); + } else if (error != null) { transfer.error(error); } else if (transfer.item.phase != Phase.skipped) { transfer.phase(Phase.complete); diff --git a/packages/sync-engine/lib/src/publicapi/models.dart b/packages/sync-engine/lib/src/publicapi/models.dart index 1ba2667..daa6dbd 100644 --- a/packages/sync-engine/lib/src/publicapi/models.dart +++ b/packages/sync-engine/lib/src/publicapi/models.dart @@ -1693,6 +1693,8 @@ class EpisodeListItem implements PublicJsonValue { final UnixMillis? republishedAt; final ShowID showId; final NonEmptyString? slug; + final int? sourcePosition; + final String? sourceUrl; final EpisodeStatus status; final String? thumbnailUrl; final NonEmptyString title; @@ -1706,6 +1708,8 @@ class EpisodeListItem implements PublicJsonValue { this.republishedAt, required this.showId, this.slug, + this.sourcePosition, + this.sourceUrl, required this.status, this.thumbnailUrl, required this.title, @@ -1723,6 +1727,8 @@ class EpisodeListItem implements PublicJsonValue { "republished_at", "show_id", "slug", + "source_position", + "source_url", "status", "thumbnail_url", "title", @@ -1750,6 +1756,12 @@ class EpisodeListItem implements PublicJsonValue { final slug = (map["slug"] == null ? null : decodeNonEmptyString(map["slug"])); + final sourcePosition = (map["source_position"] == null + ? null + : expectPublicInt(map["source_position"])); + final sourceUrl = (map["source_url"] == null + ? null + : expectPublicString(map["source_url"])); final status = EpisodeStatus.fromJson(requirePublicField(map, "status")); final thumbnailUrl = (map["thumbnail_url"] == null ? null @@ -1767,6 +1779,8 @@ class EpisodeListItem implements PublicJsonValue { republishedAt: republishedAt, showId: showId, slug: slug, + sourcePosition: sourcePosition, + sourceUrl: sourceUrl, status: status, thumbnailUrl: thumbnailUrl, title: title, @@ -1785,6 +1799,9 @@ class EpisodeListItem implements PublicJsonValue { "republished_at": encodePublicJson(republishedAt), "show_id": encodePublicJson(showId), if (slug != null) "slug": encodePublicJson(slug), + if (sourcePosition != null) + "source_position": encodePublicJson(sourcePosition), + if (sourceUrl != null) "source_url": encodePublicJson(sourceUrl), "status": encodePublicJson(status), if (thumbnailUrl != null) "thumbnail_url": encodePublicJson(thumbnailUrl), "title": encodePublicJson(title), diff --git a/packages/sync-engine/lib/src/sync.dart b/packages/sync-engine/lib/src/sync.dart index 23a43bf..97b5e78 100644 --- a/packages/sync-engine/lib/src/sync.dart +++ b/packages/sync-engine/lib/src/sync.dart @@ -71,8 +71,24 @@ class Engine { final DownloadManager downloads; final CookieJar cookies; Database? _journal; + Future? _openingJournal; final Stopwatch _scanClock = Stopwatch()..start(); + Future _database(Api api) async { + if (_journal case final value?) return value; + final opening = _openingJournal ??= Database.open( + Directory(api.config.directory), + ); + try { + return _journal = await opening; + } finally { + _openingJournal = null; + } + } + + Future> items(Api api, String slug) async => + (await _database(api)).items(api.config.apiOrigin, slug); + Future close() async { await downloads.drain(); await _journal?.close(); @@ -122,9 +138,7 @@ class Engine { 'Another client is already syncing this show on this computer', ); } - final journal = _journal ??= await Database.open( - Directory(api.config.directory), - ); + final journal = await _database(api); final before = await inventory(api, slug); final collection = _source(before.show); if (collection == null) @@ -154,16 +168,22 @@ class Engine { final orderedUrls = snapshot.present .map((video) => 'https://www.youtube.com/watch?v=${video.id}') .toList(); - if (snapshot.canRemove) - await journal.snapshot( - api.config.apiOrigin, - slug, - collection, - orderedUrls, - ); + await journal.snapshot(api.config.apiOrigin, slug, collection, [ + for (final video in snapshot.present) + ( + url: 'https://www.youtube.com/watch?v=${video.id}', + title: video.title, + duration: video.durationSeconds, + ), + ], canRemove: snapshot.canRemove); final remote = orderedUrls.toSet(); // Published inventory is the authority after a lost acknowledgement. for (final episode in before.episodes) { + await journal.published( + api.config.apiOrigin, + slug, + episode.sourceUrl, + ); await journal.forget(api.config.apiOrigin, slug, episode.sourceUrl); } final existing = { @@ -184,8 +204,13 @@ class Engine { collection, ); } + final positions = { + for (final (index, video) in snapshot.present.indexed) + video.id: index, + }; final transfers = downloads.enqueue(slug, before.show.title, [ - for (final video in additions) (id: video.id, title: video.title), + for (final video in additions) + (id: video.id, title: video.title, position: positions[video.id]!), ]); for (var i = 0; i < additions.length; i++) transfers[i].duration(additions[i].durationSeconds); @@ -210,15 +235,34 @@ class Engine { audioOnly: before.show.sourceKind == p.ShowSourceKind.audio, transfer: transfer, ); - if (result is Published) added++; + if (result is Published) { + added++; + await journal.published( + api.config.apiOrigin, + slug, + 'https://www.youtube.com/watch?v=${video.id}', + ); + } if (result is Skipped) { skipped++; transfer.skipped(result.reason); + await journal.outcome( + api.config.apiOrigin, + slug, + transfer.item, + ); } } catch (error) { taskError = error; failures.add(error); - transfer.error(error); + if (error is! OperationCancelled) { + transfer.error(error); + await journal.outcome( + api.config.apiOrigin, + slug, + transfer.item, + ); + } } finally { active?.finish(taskError); } diff --git a/pubspec.lock b/pubspec.lock index 77675b8..ff4dcbc 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -582,7 +582,7 @@ packages: source: hosted version: "0.5.2" yaml: - dependency: transitive + dependency: "direct dev" description: name: yaml sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea diff --git a/pubspec.yaml b/pubspec.yaml index c795afe..68a4f6a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,6 +2,8 @@ name: listenbox_client_workspace publish_to: none environment: sdk: ^3.13.0 +dev_dependencies: + yaml: ^3.1.3 workspace: - packages/youtubei - packages/sync-engine diff --git a/tool/check.dart b/tool/check.dart index 456c2a0..6ac6d53 100644 --- a/tool/check.dart +++ b/tool/check.dart @@ -1,7 +1,27 @@ import 'dart:io'; +import 'design.dart' as design; + Future main() async { final root = File.fromUri(Platform.script).parent.parent.path; + await design.main(['--check']); + if (exitCode != 0) exit(exitCode); + for (final file in Directory( + '$root/packages/desktop/lib', + ).listSync(recursive: true).whereType()) { + if (!file.path.endsWith('.dart') || + file.path.endsWith('/design_tokens.dart')) + continue; + final source = file.readAsStringSync(); + if (RegExp( + r'\b(?:Color|ColorSwatch|MaterialColor|MaterialAccentColor)\s*(?:\(|\.from)|\bColors\.|ColorScheme\.fromSeed|fontSize:\s*[\d.]|FontWeight\.|(?:BorderRadius|Radius)\.circular\(\s*\d', + ).hasMatch(source)) { + stderr.writeln( + 'Raw design value in ${file.path}. Use design_tokens.dart and ListenboxTheme.', + ); + exit(1); + } + } for (final arguments in [ ['format', '--output=none', '--set-exit-if-changed', 'tool', 'packages'], ['analyze', '--fatal-infos'], diff --git a/tool/design.dart b/tool/design.dart new file mode 100644 index 0000000..9fef1c3 --- /dev/null +++ b/tool/design.dart @@ -0,0 +1,162 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:math' as math; + +import 'package:yaml/yaml.dart'; + +/// DESIGN.md owns the values; the committed snapshot makes standalone client +/// builds independent of the parent checkout. Both workspaces check the bridge. +Future main(List args) async { + final root = File.fromUri(Platform.script).parent.parent; + final snapshot = File('${root.path}/design/tokens.json'); + final generated = File( + '${root.path}/packages/desktop/lib/design_tokens.dart', + ); + final check = args.contains('--check'); + final sourceIndex = args.indexOf('--source'); + Map tokens; + if (sourceIndex >= 0) { + final source = File(args[sourceIndex + 1]).readAsStringSync(); + final frontmatter = source.split('---')[1]; + final document = jsonDecode(jsonEncode(loadYaml(frontmatter))) as Map; + tokens = { + for (final name in [ + 'colors', + 'typography', + 'rounded', + 'spacing', + 'components', + ]) + name: document[name], + }; + await _write( + snapshot, + '${const JsonEncoder.withIndent(' ').convert(tokens)}\n', + check, + ); + } else { + tokens = jsonDecode(snapshot.readAsStringSync()) as Map; + } + final code = StringBuffer( + '''// Generated from DESIGN.md by tool/design.dart. Do not edit. +import 'package:flutter/material.dart'; + +abstract final class DesignTokens { +''', + ); + final colors = tokens['colors'] as Map; + for (final entry in colors.entries) { + code.writeln( + ' static const ${_name(entry.key as String)} = Color(0xff${_rgb(entry.value as String)});', + ); + } + for (final section in ['rounded', 'spacing']) { + for (final entry in (tokens[section] as Map).entries) { + final prefix = section == 'rounded' ? 'radius' : 'space'; + code.writeln( + ' static const ${_name('$prefix-${entry.key}')} = ${_pixels(entry.value as String)};', + ); + } + } + for (final name in [ + 'page-title', + 'title', + 'body', + 'field', + 'label', + 'button', + 'support', + ]) { + final style = (tokens['typography'] as Map)[name] as Map; + final size = _pixels(style['fontSize'] as String); + final weight = ((style['fontWeight'] as int) / 100).round() * 100; + final tracking = + double.parse((style['letterSpacing'] as String).replaceAll('em', '')) * + size; + code.writeln(' static const ${_name('$name-type')} = TextStyle('); + code.writeln(' fontSize: $size,'); + code.writeln(' fontWeight: FontWeight.w$weight,'); + code.writeln(' height: ${style['lineHeight']},'); + code.writeln(' letterSpacing: $tracking,'); + code.writeln(' );'); + } + for (final entry in (tokens['components'] as Map).entries) { + final component = entry.value as Map; + final name = _name(entry.key as String); + if (component['height'] case final String height) { + code.writeln(' static const ${name}Height = ${_pixels(height)};'); + } + if (component['padding'] case final String padding) { + final values = padding.split(' ').map(_pixels).toList(); + final top = values[0], right = values.length == 1 ? top : values[1]; + final bottom = values.length < 3 ? top : values[2]; + final left = values.length < 4 ? right : values[3]; + code.writeln(' static const ${name}Padding = EdgeInsets.fromLTRB('); + code.writeln(' $left, $top, $right, $bottom,'); + code.writeln(' );'); + } + } + code.writeln('}'); + final formatter = await Process.start(Platform.resolvedExecutable, [ + 'format', + '--output=show', + '--summary=none', + ]); + formatter.stdin.write(code); + await formatter.stdin.close(); + final output = formatter.stdout.transform(utf8.decoder).join(); + final errors = formatter.stderr.transform(utf8.decoder).join(); + final formatted = await output; + if (await formatter.exitCode != 0) throw FormatException(await errors); + await errors; + await _write(generated, formatted, check); +} + +Future _write(File file, String expected, bool check) async { + if (check) { + if (!file.existsSync() || file.readAsStringSync() != expected) { + stderr.writeln( + 'Design tokens drifted: ${file.path}. Regenerate with tool/design.dart.', + ); + exitCode = 1; + } + } else { + await file.parent.create(recursive: true); + await file.writeAsString(expected); + } +} + +String _name(String value) { + final parts = value.split('-'); + return '${parts.first}${parts.skip(1).map((part) => '${part[0].toUpperCase()}${part.substring(1)}').join()}'; +} + +double _pixels(String value) => value.endsWith('rem') + ? double.parse(value.replaceAll('rem', '')) * 16 + : double.parse(value.replaceAll('px', '')); + +String _rgb(String value) { + if (value.startsWith('#')) return value.substring(1); + final values = value + .replaceAll('oklch(', '') + .replaceAll(')', '') + .split(' ') + .map(double.parse) + .toList(); + final l = values[0], + a = values[1] * math.cos(values[2] * math.pi / 180), + b = values[1] * math.sin(values[2] * math.pi / 180); + final lm = math.pow(l + 0.3963377774 * a + 0.2158037573 * b, 3).toDouble(); + final mm = math.pow(l - 0.1055613458 * a - 0.0638541728 * b, 3).toDouble(); + final sm = math.pow(l - 0.0894841775 * a - 1.2914855480 * b, 3).toDouble(); + return [ + 4.0767416621 * lm - 3.3077115913 * mm + 0.2309699292 * sm, + -1.2684380046 * lm + 2.6097574011 * mm - 0.3413193965 * sm, + -0.0041960863 * lm - 0.7034186147 * mm + 1.7076147010 * sm, + ].map((linear) { + final srgb = linear <= 0.0031308 + ? 12.92 * linear + : 1.055 * math.pow(linear, 1 / 2.4) - 0.055; + return (srgb.clamp(0, 1) * 255).round().toRadixString(16).padLeft(2, '0'); + }).join(); +}