From 04fe1f02c79ea1e7e5ad34b88575cbd7621a8ef9 Mon Sep 17 00:00:00 2001 From: DV Date: Thu, 7 May 2026 12:16:28 +0530 Subject: [PATCH 01/41] =?UTF-8?q?release:=20v3.0.3=20=E2=80=94=20Audio=20M?= =?UTF-8?q?ode,=20extension=20UI=20and=20website=20revamp,=20monorepo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clean up wait wait feat: Segments — multi-segment playback sequencer (#102) Replaces the single loop-segment system with a fully extensible Segments engine. Users can define multiple time-range markers on any video and configure exactly how they play back. Core capabilities - Multiple segments per video, each shown as a colored pentagon marker pair on the YouTube progress bar - Per-segment loop count (0 = infinite) and playback rate override - Per-iteration rate overrides: [1.0, 1.5] plays 1× first, 1.5× second - Advanced Sequence editor: compose steps with any custom segment order; duplicates allowed — [seg3, seg3, seg1] plays seg3 twice then seg1 - Segment merge: hold adjacent markers within 1.5% for 500 ms → merge with pulse animation Save tiers - Last-used: auto-updated volatile slot, never requires explicit save - Default slot: one-click quick save - Named configs: unlimited, each with an optional keyboard shortcut for instant activation (e.g. Ctrl+1) Keyboard shortcuts - Z — load last-used config / toggle off (replaces toggleLoopSegment) - Shift+Z — always start a fresh slate (full video, infinite loop) - Q / E — set start / end of the active segment (one containing playhead) - Shift+Q / Shift+E — nudge with acceleration (unchanged) - Named config combos — user-assignable per saved config New modules (SOLID boundaries) - utils/segments/types.ts — data types only - utils/segments/factories.ts — pure config factory functions - utils/segments/engine.ts — playback state machine, no DOM - utils/segments/markers.tsx — DOM marker controller, N colored pairs - utils/storage/segmentStore.ts — IndexedDB CRUD + transparent migration - components/SegmentButton.tsx — control bar button - components/SegmentPanel.tsx — below-video panel (simple + advanced) - components/Segments.tsx — coordinator; public API for watch.tsx Migration Existing SavedLoopSegment records are transparently converted to a single-segment SegmentConfig on first access. IndexedDB bumped to v4 with a new `segment_data` object store. Backward compat All existing preference keys (toggleLoopSegment, setLoopSegmentBegin, setLoopSegmentEnd, nudgeLoopSegment.*) are preserved unchanged. Co-Authored-By: Claude Sonnet 4.6 segment fixes marker update changes updates --- .github/ISSUE_TEMPLATE/bug_report.yml | 67 - .github/ISSUE_TEMPLATE/config.yml | 4 + .github/ISSUE_TEMPLATE/docs.yml | 30 - .github/ISSUE_TEMPLATE/feature_request.yml | 44 - .github/ISSUE_TEMPLATE/issue.md | 33 + .github/pull_request_template.md | 27 - .github/workflows/deploy-website.yml | 65 +- .gitignore | 31 +- AGENTS.md | 49 + CLAUDE.md | 1 + backend/.gitignore | 172 - backend/package.json | 1 - backend/src/constants/errors.ts | 7 + backend/src/constants/googleapis.ts | 5 + backend/src/constants/http.ts | 15 + backend/src/constants/index.ts | 4 + backend/src/constants/pattern.ts | 1 + backend/src/index.ts | 14 +- backend/src/middlewares/cors.ts | 5 - backend/src/routers/playlist.ts | 15 +- backend/src/services/playlist.ts | 72 + backend/src/services/playlists.ts | 64 - backend/src/types.ts | 20 - backend/src/utils/duration.ts | 12 + backend/src/utils/index.ts | 2 + backend/src/utils/parseDuration.ts | 12 - backend/src/utils/responseEntity.ts | 11 +- backend/worker-configuration.d.ts | 28 +- web-ext/{src => }/_locales/en/messages.json | 0 web-ext/app/background/context.ts | 160 + web-ext/app/background/index.ts | 108 + web-ext/app/background/store.ts | 23 + .../app/background/utils/dispatchContext.ts | 18 + .../components/AudioMode/AudioModeButton.tsx | 26 + .../components/AudioMode/AudioModeOverlay.tsx | 9 + .../components/AudioMode/AudioModeUI.tsx | 379 ++ .../AudioMode/AudioModeVisualizer.tsx | 268 ++ .../components/AudioMode/index.ts | 390 +++ .../components/GearMenuPanel/index.ts | 340 ++ .../components/SegmentButton.tsx | 87 + .../components/SegmentPanel.tsx | 1688 +++++++++ .../content_scripts/components/Segments.tsx | 485 +++ web-ext/app/content_scripts/index.css | 24 + web-ext/app/content_scripts/index.ts | 67 + .../content_scripts/pages/nativeSettings.ts | 538 +++ .../content_scripts/pages/playlist.tsx | 85 +- .../content_scripts/pages/shorts.tsx | 38 +- web-ext/app/content_scripts/pages/watch.tsx | 638 ++++ web-ext/app/content_scripts/pages/youtube.ts | 32 + .../primitives/applyProfile.ts | 119 + .../content_scripts/primitives/home/feed.ts | 28 + .../content_scripts/primitives/home/index.ts | 7 + .../content_scripts/primitives/home/shorts.ts | 29 + .../primitives/home/thumbnails.ts | 56 + .../primitives/search/index.ts | 11 + .../primitives/search/metadata.ts | 38 + .../primitives/search/shorts.ts | 25 + .../primitives/search/thumbnails.ts | 42 + .../primitives/shorts/index.ts | 5 + .../primitives/shorts/shelf.ts | 39 + .../primitives/watch/comments.ts | 49 + .../primitives/watch/endCards.ts | 57 + .../content_scripts/primitives/watch/index.ts | 3 + .../primitives/watch/sidebar.ts | 50 + .../app/content_scripts/utils/pageToast.ts | 59 + web-ext/{src => app}/manifest.json | 27 +- web-ext/app/options/App.tsx | 30 + .../options/components/layout/AppLayout.tsx | 25 + .../app/options/components/layout/Sidebar.tsx | 123 + .../components/layout/SidebarNavLink.tsx | 50 + .../components/layout/SidebarThemeToggle.tsx | 45 + web-ext/app/options/data.ts | 49 + web-ext/{src/popup => app/options}/index.css | 26 +- web-ext/{src => app}/options/index.html | 0 web-ext/app/options/index.tsx | 11 + web-ext/app/options/router.tsx | 34 + web-ext/app/options/routes/AudioMode.tsx | 228 ++ web-ext/app/options/routes/General.tsx | 205 ++ web-ext/app/options/routes/Keybindings.tsx | 296 ++ web-ext/app/options/routes/Playlist.tsx | 36 + web-ext/app/options/routes/Profiles.tsx | 643 ++++ web-ext/app/options/routes/Shorts.tsx | 86 + web-ext/app/options/routes/Watch.tsx | 249 ++ web-ext/app/options/routes/error-page.tsx | 47 + web-ext/app/options/routing.tsx | 41 + web-ext/app/options/search/OptionsSearch.tsx | 276 ++ web-ext/app/options/search/searchIndex.ts | 195 ++ web-ext/app/options/utils/validators.ts | 19 + web-ext/app/popup/App.tsx | 338 ++ web-ext/app/popup/components/NavBtn.tsx | 46 + web-ext/app/popup/components/PopupRow.tsx | 47 + web-ext/app/popup/components/TinySwitch.tsx | 34 + web-ext/app/popup/data.ts | 17 + web-ext/{src/options => app/popup}/index.css | 2 + web-ext/{src => app}/popup/index.html | 0 web-ext/{src => app}/popup/index.tsx | 0 web-ext/app/popup/utils/formatTabUrl.ts | 11 + web-ext/app/popup/utils/openOptions.ts | 5 + .../assets/icons/disabled_icon128.png | Bin .../assets/icons/disabled_icon16.png | Bin .../assets/icons/disabled_icon32.png | Bin .../assets/icons/disabled_icon48.png | Bin web-ext/{src => }/assets/icons/icon128.png | Bin web-ext/{src => }/assets/icons/icon16.png | Bin web-ext/{src => }/assets/icons/icon32.png | Bin web-ext/{src => }/assets/icons/icon48.png | Bin web-ext/components/ThemeApplier.tsx | 11 + web-ext/components/feedback/ActionPopup.tsx | 77 + .../feedback/CapabilityStatusRow.tsx | 114 + web-ext/components/feedback/ConfirmPopup.tsx | 42 + .../components/feedback/ConfirmProvider.tsx | 67 + web-ext/components/feedback/Popup.tsx | 98 + web-ext/components/feedback/Toast.tsx | 72 + web-ext/components/feedback/ToastProvider.tsx | 102 + web-ext/components/form/Field.tsx | 75 + web-ext/components/form/FilePicker.tsx | 80 + web-ext/components/form/Input.tsx | 127 + web-ext/components/form/Keybinding.tsx | 67 + web-ext/components/form/Select.tsx | 149 + web-ext/components/form/Slider.tsx | 69 + web-ext/components/form/Switch.tsx | 51 + web-ext/components/layout/Card.tsx | 18 + web-ext/components/layout/PageHeader.tsx | 31 + web-ext/components/layout/Section.tsx | 32 + web-ext/components/layout/SectionNav.tsx | 74 + web-ext/components/primitives/Badge.tsx | 40 + web-ext/components/primitives/Button.tsx | 67 + web-ext/components/primitives/Icon.tsx | 115 + web-ext/components/primitives/IconButton.tsx | 59 + web-ext/components/primitives/Tooltip.tsx | 137 + web-ext/{src/options => context}/store.tsx | 2 +- web-ext/data/api.data.ts | 9 + web-ext/data/extension.data.ts | 28 + web-ext/data/profiles.data.ts | 119 + web-ext/data/store.data.ts | 121 + web-ext/hooks/useActiveTab.ts | 23 + web-ext/hooks/useCapabilityCache.ts | 48 + web-ext/hooks/useChromeStorageLocal.ts | 36 + web-ext/hooks/useStoreUpdater.ts | 21 + web-ext/hooks/useTheme.ts | 22 + web-ext/hooks/useVideoPreference.ts | 29 + web-ext/index.d.ts | 1 - web-ext/package.json | 3 +- web-ext/scripts/build.js | 61 +- web-ext/scripts/release.js | 14 +- web-ext/src/assets/svg/apps.svg | 1 - web-ext/src/assets/svg/arrowhead-left.svg | 1 - web-ext/src/assets/svg/bell.svg | 1 - web-ext/src/assets/svg/brackets.svg | 1 - web-ext/src/assets/svg/bug_report.svg | 1 - web-ext/src/assets/svg/burger-menu.svg | 6 - web-ext/src/assets/svg/gear.svg | 1 - web-ext/src/assets/svg/heart.svg | 1 - web-ext/src/assets/svg/help.svg | 1 - web-ext/src/assets/svg/home.svg | 1 - web-ext/src/background/context.ts | 138 - web-ext/src/background/index.ts | 69 - web-ext/src/background/store.ts | 85 - .../src/background/utils/dispatchContext.ts | 37 - web-ext/src/constants/global.constants.ts | 59 - .../components/LoopSegment.tsx | 210 -- web-ext/src/content_scripts/index.css | 6 - web-ext/src/content_scripts/index.ts | 49 - web-ext/src/content_scripts/pages/watch.tsx | 343 -- web-ext/src/options/App.tsx | 23 - web-ext/src/options/components/Card.tsx | 27 - web-ext/src/options/components/Input.tsx | 149 - web-ext/src/options/components/Keybinding.tsx | 79 - web-ext/src/options/components/Navbar.tsx | 16 - web-ext/src/options/components/Switch.tsx | 71 - web-ext/src/options/components/Tooltip.tsx | 29 - web-ext/src/options/index.tsx | 29 - web-ext/src/options/routes/General.tsx | 428 --- web-ext/src/options/routes/error-page.tsx | 41 - web-ext/src/popup/App.tsx | 24 - web-ext/src/popup/components/Navbar.tsx | 55 - web-ext/theme.css | 102 + web-ext/utils/api.ts | 30 + web-ext/utils/browser.ts | 30 + web-ext/utils/keybinding.ts | 110 + web-ext/utils/primitive.ts | 145 + web-ext/utils/scope.ts | 28 + web-ext/utils/segments/engine.ts | 299 ++ web-ext/utils/segments/factories.ts | 187 + web-ext/utils/segments/markers.tsx | 465 +++ web-ext/utils/segments/types.ts | 95 + web-ext/utils/storage/capabilityCache.ts | 138 + web-ext/utils/storage/featureReports.ts | 115 + web-ext/utils/storage/indexedDb.ts | 88 + web-ext/utils/storage/loopSegmentStore.ts | 86 + web-ext/utils/storage/playlistCache.ts | 95 + web-ext/utils/storage/profileImportExport.ts | 211 ++ web-ext/utils/storage/profileStore.ts | 147 + web-ext/utils/storage/segmentStore.ts | 269 ++ web-ext/utils/storage/videoPreference.ts | 69 + web-ext/utils/theme.ts | 21 + web-ext/utils/version.ts | 11 + website/app/_fonts/inter-400.woff2 | Bin 0 -> 21564 bytes website/app/_fonts/inter-500.woff2 | Bin 0 -> 22760 bytes website/app/_fonts/inter-600.woff2 | Bin 0 -> 22820 bytes website/app/_fonts/inter-700.woff2 | Bin 0 -> 22904 bytes website/app/_fonts/inter-900.woff2 | Bin 0 -> 21960 bytes website/app/_fonts/jetbrains-mono-400.woff2 | Bin 0 -> 21088 bytes website/app/_fonts/jetbrains-mono-500.woff2 | Bin 0 -> 21820 bytes website/app/docs/changelog/kind-tone.data.ts | 15 + website/app/docs/changelog/page.tsx | 109 + website/app/docs/docs.css | 850 +++++ website/app/docs/faq/data.ts | 288 ++ website/app/docs/faq/page.tsx | 113 + website/app/docs/install/data.ts | 158 + website/app/docs/keybindings/data.ts | 45 + website/app/docs/keybindings/page.tsx | 110 + website/app/docs/layout.tsx | 26 + website/app/docs/nav.data.ts | 38 + website/app/docs/page.tsx | 131 + website/app/docs/pages.data.ts | 55 + website/app/farewell/data.ts | 9 + website/app/farewell/page.tsx | 54 +- website/app/globals.css | 180 +- website/app/greetings/data.ts | 8 + website/app/greetings/page.tsx | 60 +- website/app/layout.tsx | 64 +- website/app/page.tsx | 40 +- .../toppings-logo-512.png} | Bin website/assets/icons/chrome.svg | 7 - website/assets/icons/firefox.svg | 7 - website/assets/icons/form.png | Bin 20238 -> 0 bytes website/assets/icons/form1.png | Bin 26149 -> 0 bytes website/assets/icons/github.svg | 1 - website/assets/icons/question-mark.svg | 9 - website/assets/icons/toppings.svg | 3078 ----------------- website/assets/illustrations/farewell.webp | Bin 101754 -> 0 bytes website/assets/illustrations/greetings.webp | Bin 154982 -> 0 bytes website/assets/illustrations/hero.webp | Bin 167700 -> 0 bytes website/assets/illustrations/how-to.webp | Bin 88200 -> 0 bytes website/bun.lockb | Bin 155108 -> 156395 bytes website/components/CallToAction.tsx | 60 - website/components/Footer.tsx | 142 - website/components/Navbar.tsx | 77 - website/components/docs/DocsNav.tsx | 32 + website/components/docs/DocsTop.tsx | 52 + website/components/docs/DocsVersionBanner.tsx | 40 + website/components/docs/Pager.tsx | 27 + website/components/reclaim/Cursor.tsx | 61 + website/components/reclaim/DevToggle.tsx | 15 + website/components/reclaim/InstallCTA.tsx | 35 + website/components/reclaim/Nav.tsx | 20 + website/components/reclaim/NoiseCanvas.tsx | 259 ++ website/components/reclaim/ReclaimHome.tsx | 138 + website/components/reclaim/ReclaimMini.tsx | 95 + website/components/reclaim/SceneClose.tsx | 71 + website/components/reclaim/SceneControl.tsx | 89 + website/components/reclaim/SceneFeatures.tsx | 40 + website/components/reclaim/SceneHero.tsx | 57 + website/components/reclaim/SceneHow.tsx | 36 + website/components/reclaim/SceneQuiet.tsx | 37 + website/components/reclaim/SceneTrust.tsx | 73 + website/components/reclaim/reclaim.css | 858 +++++ website/components/reclaim/reclaim.data.ts | 135 + website/lib/docs-nav.ts | 14 + website/lib/docs-part.ts | 14 + website/lib/docs-rich-text.tsx | 51 + website/lib/features.data.ts | 43 + website/lib/feedback-mail.data.ts | 8 + website/lib/keybindings.ts | 15 + website/lib/mailto.ts | 9 + website/lib/releases.data.ts | 122 + website/lib/releases.ts | 30 + website/lib/site.data.ts | 71 + website/next.config.mjs | 1 - website/package.json | 13 +- website/public/.gitkeep | 0 website/tailwind.config.ts | 30 + website/tsconfig.json | 1 + 274 files changed, 19267 insertions(+), 6165 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml delete mode 100644 .github/ISSUE_TEMPLATE/docs.yml delete mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/ISSUE_TEMPLATE/issue.md delete mode 100644 .github/pull_request_template.md create mode 100644 AGENTS.md create mode 100644 CLAUDE.md delete mode 100644 backend/.gitignore create mode 100644 backend/src/constants/errors.ts create mode 100644 backend/src/constants/googleapis.ts create mode 100644 backend/src/constants/http.ts create mode 100644 backend/src/constants/index.ts create mode 100644 backend/src/constants/pattern.ts delete mode 100644 backend/src/middlewares/cors.ts create mode 100644 backend/src/services/playlist.ts delete mode 100644 backend/src/services/playlists.ts delete mode 100644 backend/src/types.ts create mode 100644 backend/src/utils/duration.ts create mode 100644 backend/src/utils/index.ts delete mode 100644 backend/src/utils/parseDuration.ts rename web-ext/{src => }/_locales/en/messages.json (100%) create mode 100644 web-ext/app/background/context.ts create mode 100644 web-ext/app/background/index.ts create mode 100644 web-ext/app/background/store.ts create mode 100644 web-ext/app/background/utils/dispatchContext.ts create mode 100644 web-ext/app/content_scripts/components/AudioMode/AudioModeButton.tsx create mode 100644 web-ext/app/content_scripts/components/AudioMode/AudioModeOverlay.tsx create mode 100644 web-ext/app/content_scripts/components/AudioMode/AudioModeUI.tsx create mode 100644 web-ext/app/content_scripts/components/AudioMode/AudioModeVisualizer.tsx create mode 100644 web-ext/app/content_scripts/components/AudioMode/index.ts create mode 100644 web-ext/app/content_scripts/components/GearMenuPanel/index.ts create mode 100644 web-ext/app/content_scripts/components/SegmentButton.tsx create mode 100644 web-ext/app/content_scripts/components/SegmentPanel.tsx create mode 100644 web-ext/app/content_scripts/components/Segments.tsx create mode 100644 web-ext/app/content_scripts/index.css create mode 100644 web-ext/app/content_scripts/index.ts create mode 100644 web-ext/app/content_scripts/pages/nativeSettings.ts rename web-ext/{src => app}/content_scripts/pages/playlist.tsx (64%) rename web-ext/{src => app}/content_scripts/pages/shorts.tsx (82%) create mode 100644 web-ext/app/content_scripts/pages/watch.tsx create mode 100644 web-ext/app/content_scripts/pages/youtube.ts create mode 100644 web-ext/app/content_scripts/primitives/applyProfile.ts create mode 100644 web-ext/app/content_scripts/primitives/home/feed.ts create mode 100644 web-ext/app/content_scripts/primitives/home/index.ts create mode 100644 web-ext/app/content_scripts/primitives/home/shorts.ts create mode 100644 web-ext/app/content_scripts/primitives/home/thumbnails.ts create mode 100644 web-ext/app/content_scripts/primitives/search/index.ts create mode 100644 web-ext/app/content_scripts/primitives/search/metadata.ts create mode 100644 web-ext/app/content_scripts/primitives/search/shorts.ts create mode 100644 web-ext/app/content_scripts/primitives/search/thumbnails.ts create mode 100644 web-ext/app/content_scripts/primitives/shorts/index.ts create mode 100644 web-ext/app/content_scripts/primitives/shorts/shelf.ts create mode 100644 web-ext/app/content_scripts/primitives/watch/comments.ts create mode 100644 web-ext/app/content_scripts/primitives/watch/endCards.ts create mode 100644 web-ext/app/content_scripts/primitives/watch/index.ts create mode 100644 web-ext/app/content_scripts/primitives/watch/sidebar.ts create mode 100644 web-ext/app/content_scripts/utils/pageToast.ts rename web-ext/{src => app}/manifest.json (75%) create mode 100644 web-ext/app/options/App.tsx create mode 100644 web-ext/app/options/components/layout/AppLayout.tsx create mode 100644 web-ext/app/options/components/layout/Sidebar.tsx create mode 100644 web-ext/app/options/components/layout/SidebarNavLink.tsx create mode 100644 web-ext/app/options/components/layout/SidebarThemeToggle.tsx create mode 100644 web-ext/app/options/data.ts rename web-ext/{src/popup => app/options}/index.css (93%) rename web-ext/{src => app}/options/index.html (100%) create mode 100644 web-ext/app/options/index.tsx create mode 100644 web-ext/app/options/router.tsx create mode 100644 web-ext/app/options/routes/AudioMode.tsx create mode 100644 web-ext/app/options/routes/General.tsx create mode 100644 web-ext/app/options/routes/Keybindings.tsx create mode 100644 web-ext/app/options/routes/Playlist.tsx create mode 100644 web-ext/app/options/routes/Profiles.tsx create mode 100644 web-ext/app/options/routes/Shorts.tsx create mode 100644 web-ext/app/options/routes/Watch.tsx create mode 100644 web-ext/app/options/routes/error-page.tsx create mode 100644 web-ext/app/options/routing.tsx create mode 100644 web-ext/app/options/search/OptionsSearch.tsx create mode 100644 web-ext/app/options/search/searchIndex.ts create mode 100644 web-ext/app/options/utils/validators.ts create mode 100644 web-ext/app/popup/App.tsx create mode 100644 web-ext/app/popup/components/NavBtn.tsx create mode 100644 web-ext/app/popup/components/PopupRow.tsx create mode 100644 web-ext/app/popup/components/TinySwitch.tsx create mode 100644 web-ext/app/popup/data.ts rename web-ext/{src/options => app/popup}/index.css (99%) rename web-ext/{src => app}/popup/index.html (100%) rename web-ext/{src => app}/popup/index.tsx (100%) create mode 100644 web-ext/app/popup/utils/formatTabUrl.ts create mode 100644 web-ext/app/popup/utils/openOptions.ts rename web-ext/{src => }/assets/icons/disabled_icon128.png (100%) rename web-ext/{src => }/assets/icons/disabled_icon16.png (100%) rename web-ext/{src => }/assets/icons/disabled_icon32.png (100%) rename web-ext/{src => }/assets/icons/disabled_icon48.png (100%) rename web-ext/{src => }/assets/icons/icon128.png (100%) rename web-ext/{src => }/assets/icons/icon16.png (100%) rename web-ext/{src => }/assets/icons/icon32.png (100%) rename web-ext/{src => }/assets/icons/icon48.png (100%) create mode 100644 web-ext/components/ThemeApplier.tsx create mode 100644 web-ext/components/feedback/ActionPopup.tsx create mode 100644 web-ext/components/feedback/CapabilityStatusRow.tsx create mode 100644 web-ext/components/feedback/ConfirmPopup.tsx create mode 100644 web-ext/components/feedback/ConfirmProvider.tsx create mode 100644 web-ext/components/feedback/Popup.tsx create mode 100644 web-ext/components/feedback/Toast.tsx create mode 100644 web-ext/components/feedback/ToastProvider.tsx create mode 100644 web-ext/components/form/Field.tsx create mode 100644 web-ext/components/form/FilePicker.tsx create mode 100644 web-ext/components/form/Input.tsx create mode 100644 web-ext/components/form/Keybinding.tsx create mode 100644 web-ext/components/form/Select.tsx create mode 100644 web-ext/components/form/Slider.tsx create mode 100644 web-ext/components/form/Switch.tsx create mode 100644 web-ext/components/layout/Card.tsx create mode 100644 web-ext/components/layout/PageHeader.tsx create mode 100644 web-ext/components/layout/Section.tsx create mode 100644 web-ext/components/layout/SectionNav.tsx create mode 100644 web-ext/components/primitives/Badge.tsx create mode 100644 web-ext/components/primitives/Button.tsx create mode 100644 web-ext/components/primitives/Icon.tsx create mode 100644 web-ext/components/primitives/IconButton.tsx create mode 100644 web-ext/components/primitives/Tooltip.tsx rename web-ext/{src/options => context}/store.tsx (82%) create mode 100644 web-ext/data/api.data.ts create mode 100644 web-ext/data/extension.data.ts create mode 100644 web-ext/data/profiles.data.ts create mode 100644 web-ext/data/store.data.ts create mode 100644 web-ext/hooks/useActiveTab.ts create mode 100644 web-ext/hooks/useCapabilityCache.ts create mode 100644 web-ext/hooks/useChromeStorageLocal.ts create mode 100644 web-ext/hooks/useStoreUpdater.ts create mode 100644 web-ext/hooks/useTheme.ts create mode 100644 web-ext/hooks/useVideoPreference.ts delete mode 100644 web-ext/src/assets/svg/apps.svg delete mode 100644 web-ext/src/assets/svg/arrowhead-left.svg delete mode 100644 web-ext/src/assets/svg/bell.svg delete mode 100644 web-ext/src/assets/svg/brackets.svg delete mode 100644 web-ext/src/assets/svg/bug_report.svg delete mode 100644 web-ext/src/assets/svg/burger-menu.svg delete mode 100644 web-ext/src/assets/svg/gear.svg delete mode 100644 web-ext/src/assets/svg/heart.svg delete mode 100644 web-ext/src/assets/svg/help.svg delete mode 100644 web-ext/src/assets/svg/home.svg delete mode 100644 web-ext/src/background/context.ts delete mode 100644 web-ext/src/background/index.ts delete mode 100644 web-ext/src/background/store.ts delete mode 100644 web-ext/src/background/utils/dispatchContext.ts delete mode 100644 web-ext/src/constants/global.constants.ts delete mode 100644 web-ext/src/content_scripts/components/LoopSegment.tsx delete mode 100644 web-ext/src/content_scripts/index.css delete mode 100644 web-ext/src/content_scripts/index.ts delete mode 100644 web-ext/src/content_scripts/pages/watch.tsx delete mode 100644 web-ext/src/options/App.tsx delete mode 100644 web-ext/src/options/components/Card.tsx delete mode 100644 web-ext/src/options/components/Input.tsx delete mode 100644 web-ext/src/options/components/Keybinding.tsx delete mode 100644 web-ext/src/options/components/Navbar.tsx delete mode 100644 web-ext/src/options/components/Switch.tsx delete mode 100644 web-ext/src/options/components/Tooltip.tsx delete mode 100644 web-ext/src/options/index.tsx delete mode 100644 web-ext/src/options/routes/General.tsx delete mode 100644 web-ext/src/options/routes/error-page.tsx delete mode 100644 web-ext/src/popup/App.tsx delete mode 100644 web-ext/src/popup/components/Navbar.tsx create mode 100644 web-ext/theme.css create mode 100644 web-ext/utils/api.ts create mode 100644 web-ext/utils/browser.ts create mode 100644 web-ext/utils/keybinding.ts create mode 100644 web-ext/utils/primitive.ts create mode 100644 web-ext/utils/scope.ts create mode 100644 web-ext/utils/segments/engine.ts create mode 100644 web-ext/utils/segments/factories.ts create mode 100644 web-ext/utils/segments/markers.tsx create mode 100644 web-ext/utils/segments/types.ts create mode 100644 web-ext/utils/storage/capabilityCache.ts create mode 100644 web-ext/utils/storage/featureReports.ts create mode 100644 web-ext/utils/storage/indexedDb.ts create mode 100644 web-ext/utils/storage/loopSegmentStore.ts create mode 100644 web-ext/utils/storage/playlistCache.ts create mode 100644 web-ext/utils/storage/profileImportExport.ts create mode 100644 web-ext/utils/storage/profileStore.ts create mode 100644 web-ext/utils/storage/segmentStore.ts create mode 100644 web-ext/utils/storage/videoPreference.ts create mode 100644 web-ext/utils/theme.ts create mode 100644 web-ext/utils/version.ts create mode 100644 website/app/_fonts/inter-400.woff2 create mode 100644 website/app/_fonts/inter-500.woff2 create mode 100644 website/app/_fonts/inter-600.woff2 create mode 100644 website/app/_fonts/inter-700.woff2 create mode 100644 website/app/_fonts/inter-900.woff2 create mode 100644 website/app/_fonts/jetbrains-mono-400.woff2 create mode 100644 website/app/_fonts/jetbrains-mono-500.woff2 create mode 100644 website/app/docs/changelog/kind-tone.data.ts create mode 100644 website/app/docs/changelog/page.tsx create mode 100644 website/app/docs/docs.css create mode 100644 website/app/docs/faq/data.ts create mode 100644 website/app/docs/faq/page.tsx create mode 100644 website/app/docs/install/data.ts create mode 100644 website/app/docs/keybindings/data.ts create mode 100644 website/app/docs/keybindings/page.tsx create mode 100644 website/app/docs/layout.tsx create mode 100644 website/app/docs/nav.data.ts create mode 100644 website/app/docs/page.tsx create mode 100644 website/app/docs/pages.data.ts create mode 100644 website/app/farewell/data.ts create mode 100644 website/app/greetings/data.ts rename website/assets/{icons/icon512.png => brand/toppings-logo-512.png} (100%) delete mode 100644 website/assets/icons/chrome.svg delete mode 100644 website/assets/icons/firefox.svg delete mode 100644 website/assets/icons/form.png delete mode 100644 website/assets/icons/form1.png delete mode 100644 website/assets/icons/github.svg delete mode 100644 website/assets/icons/question-mark.svg delete mode 100644 website/assets/icons/toppings.svg delete mode 100644 website/assets/illustrations/farewell.webp delete mode 100644 website/assets/illustrations/greetings.webp delete mode 100644 website/assets/illustrations/hero.webp delete mode 100644 website/assets/illustrations/how-to.webp delete mode 100644 website/components/CallToAction.tsx delete mode 100644 website/components/Footer.tsx delete mode 100644 website/components/Navbar.tsx create mode 100644 website/components/docs/DocsNav.tsx create mode 100644 website/components/docs/DocsTop.tsx create mode 100644 website/components/docs/DocsVersionBanner.tsx create mode 100644 website/components/docs/Pager.tsx create mode 100644 website/components/reclaim/Cursor.tsx create mode 100644 website/components/reclaim/DevToggle.tsx create mode 100644 website/components/reclaim/InstallCTA.tsx create mode 100644 website/components/reclaim/Nav.tsx create mode 100644 website/components/reclaim/NoiseCanvas.tsx create mode 100644 website/components/reclaim/ReclaimHome.tsx create mode 100644 website/components/reclaim/ReclaimMini.tsx create mode 100644 website/components/reclaim/SceneClose.tsx create mode 100644 website/components/reclaim/SceneControl.tsx create mode 100644 website/components/reclaim/SceneFeatures.tsx create mode 100644 website/components/reclaim/SceneHero.tsx create mode 100644 website/components/reclaim/SceneHow.tsx create mode 100644 website/components/reclaim/SceneQuiet.tsx create mode 100644 website/components/reclaim/SceneTrust.tsx create mode 100644 website/components/reclaim/reclaim.css create mode 100644 website/components/reclaim/reclaim.data.ts create mode 100644 website/lib/docs-nav.ts create mode 100644 website/lib/docs-part.ts create mode 100644 website/lib/docs-rich-text.tsx create mode 100644 website/lib/features.data.ts create mode 100644 website/lib/feedback-mail.data.ts create mode 100644 website/lib/keybindings.ts create mode 100644 website/lib/mailto.ts create mode 100644 website/lib/releases.data.ts create mode 100644 website/lib/releases.ts create mode 100644 website/lib/site.data.ts create mode 100644 website/public/.gitkeep diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml deleted file mode 100644 index 4d808b32..00000000 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ /dev/null @@ -1,67 +0,0 @@ -name: Bug Report -description: Report a bug or unexpected behavior. -title: "Bug: [Brief description of the issue]" -labels: ["Status: Needs Investigation", "Type: Bug 🐛"] -body: - - type: markdown - attributes: - value: | - Thank you for reporting a bug! Please fill out the details below so we can help resolve the issue quickly. - - - type: textarea - id: description - attributes: - label: Description - description: "Provide a clear and concise description of the bug." - placeholder: "Describe the bug in detail." - - - type: textarea - id: steps_to_reproduce - attributes: - label: Steps to Reproduce - description: "Please provide step-by-step instructions on how to reproduce the bug." - placeholder: | - 1. Go to '...' - 2. Click on '...' - 3. Scroll down to '...' - 4. See error - - - type: textarea - id: expected_behavior - attributes: - label: Expected Behavior - description: "Describe what you expected to happen." - placeholder: "Expected outcome of the action." - - - type: textarea - id: actual_behavior - attributes: - label: Actual Behavior - description: "Describe what actually happened." - placeholder: "Actual outcome that you observed." - - - type: dropdown - id: browser - attributes: - label: Browser - description: "Select the browser where the issue occurs." - options: - - "Chrome" - - "Firefox" - - "Edge" - - "Safari" - - "Other" - - - type: input - id: browser_version - attributes: - label: Browser Version - description: "Provide the browser version where the bug occurred." - placeholder: "e.g., 91.0.4472.124" - - - type: textarea - id: additional_context - attributes: - label: Additional Context - description: "Add any other context, logs, or screenshots to help us understand the issue." - placeholder: "Include logs, screenshots, or other details here." diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 0086358d..ee7cf774 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1 +1,5 @@ blank_issues_enabled: true +contact_links: + - name: Security + url: https://github.com/enrych/toppings/security + about: Report sensitive issues through GitHub; do not file public issues for vulnerabilities. diff --git a/.github/ISSUE_TEMPLATE/docs.yml b/.github/ISSUE_TEMPLATE/docs.yml deleted file mode 100644 index f94a684f..00000000 --- a/.github/ISSUE_TEMPLATE/docs.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Documentation -description: Request updates or additions to the documentation. -title: "Docs: [Brief description of the request]" -labels: ["Status: Needs Investigation", "Type: Documentation"] -body: - - type: markdown - attributes: - value: | - Thanks for suggesting an update or addition to the documentation! Please fill out the details below. - - - type: textarea - id: description - attributes: - label: Description - description: "Describe the missing or unclear documentation." - placeholder: "Explain the specific area or topic that requires documentation." - - - type: textarea - id: reason_for_update - attributes: - label: Reason for Update - description: "Why is this documentation update necessary?" - placeholder: "Explain why the current documentation is insufficient." - - - type: textarea - id: additional_context - attributes: - label: Additional Context - description: "Add any other relevant information, like links or examples." - placeholder: "Include any additional context or related issues here." diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml deleted file mode 100644 index aaed6aeb..00000000 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Feature Request -description: Suggest a new feature or improvement. -title: "Feature: [Brief description of the feature]" -labels: ["Status: Needs Investigation", "Type: Enhancement"] -body: - - type: markdown - attributes: - value: | - Thanks for suggesting a feature! Please provide the necessary details below to help us evaluate the request. - - - type: textarea - id: description - attributes: - label: Feature Description - description: "Provide a clear description of the feature." - placeholder: "Describe the feature you'd like to see implemented." - - - type: textarea - id: use_case - attributes: - label: Use Case - description: "Describe a scenario where this feature would be useful." - placeholder: "Explain how this feature would be valuable to users." - - - type: textarea - id: possible_implementation - attributes: - label: Possible Implementation (Optional) - description: "If you have ideas on how this feature could be implemented, describe them here." - placeholder: "Provide ideas or code snippets (if any)." - - - type: textarea - id: alternatives_considered - attributes: - label: Alternatives Considered - description: "Describe any alternative solutions you've considered." - placeholder: "Explain why the alternatives might not be sufficient." - - - type: textarea - id: additional_context - attributes: - label: Additional Context - description: "Add any other relevant information, references, or links." - placeholder: "Include any related issues, links, or additional information." diff --git a/.github/ISSUE_TEMPLATE/issue.md b/.github/ISSUE_TEMPLATE/issue.md new file mode 100644 index 00000000..2a0abc96 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/issue.md @@ -0,0 +1,33 @@ +--- +name: Issue +about: Bug, feature, or docs +title: "" +labels: [] +assignees: [] +--- + + + +## Summary + +One sentence: what is broken, missing, or should change? + +## Context + +- **Surface:** extension | website | backend | packages | docs +- **Version:** (if known) +- **Environment:** browser, OS (if relevant) + +## Details + +## Expected vs actual + +N/A + +## Acceptance criteria + +## Agent brief diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md deleted file mode 100644 index abc0dbc2..00000000 --- a/.github/pull_request_template.md +++ /dev/null @@ -1,27 +0,0 @@ - - -**What does this PR do?** - - - -**Why is this change necessary?** - - - -**How was this change implemented?** - - - -**Screenshots / Visual Changes** - - - -**Related Issues** - - - -**Additional Considerations** - - diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml index cc71c60a..8a0f8a3b 100644 --- a/.github/workflows/deploy-website.yml +++ b/.github/workflows/deploy-website.yml @@ -5,22 +5,17 @@ on: branches: ["main"] paths: - "website/**" - workflow_dispatch: permissions: contents: read - pages: write - id-token: write -# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. -# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. concurrency: - group: "pages" + group: "cf-pages-website" cancel-in-progress: false jobs: - build: + deploy: runs-on: ubuntu-latest steps: - name: Checkout @@ -29,42 +24,24 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v1 - - name: Setup Pages - uses: actions/configure-pages@v4 - - - name: Restore cache - uses: actions/cache@v4 - with: - path: | - website/.next/cache - # Generate a new cache whenever packages or source files change. - key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json', '**/yarn.lock', '**/bun.lockb') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }} - # If source files changed but packages didn't, rebuild from a prior cache. - restore-keys: | - ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json', '**/yarn.lock', '**/bun.lockb') }}- - - name: Install dependencies - run: | - source $GITHUB_ENV - cd website && bun install - - - name: Build with Next.js - run: | - source $GITHUB_ENV - cd website && bun run build - - - name: Upload artifact - uses: actions/upload-pages-artifact@v3 + run: cd website && bun install + + - name: Build (static export → website/out) + run: cd website && bun run build + + # Deploys the static export to Cloudflare Pages. + # Required repo secrets: + # CLOUDFLARE_API_TOKEN — token with "Cloudflare Pages: Edit" + # CLOUDFLARE_ACCOUNT_ID — your Cloudflare account id + # The --project-name MUST match the Pages project bound to + # toppings.enry.ch in the Cloudflare dashboard. + - name: Deploy to Cloudflare Pages + uses: cloudflare/wrangler-action@v3 with: - path: website/out - - deploy: - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - runs-on: ubuntu-latest - needs: build - steps: - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v4 + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + command: >- + pages deploy website/out + --project-name=toppings-website + --branch=main diff --git a/.gitignore b/.gitignore index 8b30607c..b86f6892 100644 --- a/.gitignore +++ b/.gitignore @@ -2,28 +2,29 @@ **/.DS_Store *.pem -# Logs -logs - -# nyc test coverage -.nyc_output -coverage - -# Dependency directories +# Dependencies node_modules/ -# Build +# Build output dist -*.zip build +*.zip +**/.next/ +**/out/ -# Environment variables & secrets +# Env & secrets .env .env.local .env.*.local +.dev.vars + +# Cloudflare Workers +**/.wrangler/ -# next.js +# Caches +**/tsconfig.tsbuildinfo +*.tsbuildinfo .vercel -**/.next/ -**/out/ -next-env.d.ts + +# Local agent tooling +.claude/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..0ccda9e0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,49 @@ +# AGENTS.md + +This file is the single source of truth for any coding agent working in this repo: Claude Code, OpenAI Codex CLI / Codex Cloud, Google Gemini CLI, Google Antigravity, Cursor, Windsurf, opencode, Aider, goose, Factory, RooCode, JetBrains Junie, GitHub Copilot, Devin, or any other AGENTS.md-aware tool. + +Read this file in full before taking any action. Obey it exactly. + +--- + +## 0. TLDR FOR THE AGENT + +On every session start, do this in order: + +1. Read this file completely. + +--- + +## 1. WHAT THIS REPO IS + +The extension lives in `web-ext/`, the marketing and docs site in `website/`, and the Cloudflare Workers API in `backend/`. + +## 2. PROJECT CONTRACT + +### 2.1 Repo layout + +```text +. +├── AGENTS.md # You are here +├── README.md # Readme file for the repo +├── assets/ # Brand assets +├── web-ext/ # Browser extension +├── website/ # Marketing and docs site +└── backend/ # Cloudflare Workers backend +``` + +--- + +## 3. CROSS-PLATFORM AND AGENT-COMPATIBILITY NOTES + +- **Nested AGENTS.md.** If a sub-project adds its own AGENTS.md, the closest one wins for files inside that sub-project. + +--- + +## 4. QUICK CHECKLIST FOR THE AGENT + +Before you respond to any user message, confirm: + +- [ ] I have read this file in this session. + +If any box is unchecked, fix that first. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..43c994c2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/backend/.gitignore b/backend/.gitignore deleted file mode 100644 index 3b0fe33c..00000000 --- a/backend/.gitignore +++ /dev/null @@ -1,172 +0,0 @@ -# Logs - -logs -_.log -npm-debug.log_ -yarn-debug.log* -yarn-error.log* -lerna-debug.log* -.pnpm-debug.log* - -# Diagnostic reports (https://nodejs.org/api/report.html) - -report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json - -# Runtime data - -pids -_.pid -_.seed -\*.pid.lock - -# Directory for instrumented libs generated by jscoverage/JSCover - -lib-cov - -# Coverage directory used by tools like istanbul - -coverage -\*.lcov - -# nyc test coverage - -.nyc_output - -# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) - -.grunt - -# Bower dependency directory (https://bower.io/) - -bower_components - -# node-waf configuration - -.lock-wscript - -# Compiled binary addons (https://nodejs.org/api/addons.html) - -build/Release - -# Dependency directories - -node_modules/ -jspm_packages/ - -# Snowpack dependency directory (https://snowpack.dev/) - -web_modules/ - -# TypeScript cache - -\*.tsbuildinfo - -# Optional npm cache directory - -.npm - -# Optional eslint cache - -.eslintcache - -# Optional stylelint cache - -.stylelintcache - -# Microbundle cache - -.rpt2_cache/ -.rts2_cache_cjs/ -.rts2_cache_es/ -.rts2_cache_umd/ - -# Optional REPL history - -.node_repl_history - -# Output of 'npm pack' - -\*.tgz - -# Yarn Integrity file - -.yarn-integrity - -# dotenv environment variable files - -.env -.env.development.local -.env.test.local -.env.production.local -.env.local - -# parcel-bundler cache (https://parceljs.org/) - -.cache -.parcel-cache - -# Next.js build output - -.next -out - -# Nuxt.js build / generate output - -.nuxt -dist - -# Gatsby files - -.cache/ - -# Comment in the public line in if your project uses Gatsby and not Next.js - -# https://nextjs.org/blog/next-9-1#public-directory-support - -# public - -# vuepress build output - -.vuepress/dist - -# vuepress v2.x temp and cache directory - -.temp -.cache - -# Docusaurus cache and generated files - -.docusaurus - -# Serverless directories - -.serverless/ - -# FuseBox cache - -.fusebox/ - -# DynamoDB Local files - -.dynamodb/ - -# TernJS port file - -.tern-port - -# Stores VSCode versions used for testing VSCode extensions - -.vscode-test - -# yarn v2 - -.yarn/cache -.yarn/unplugged -.yarn/build-state.yml -.yarn/install-state.gz -.pnp.\* - -# wrangler project - -.dev.vars -.wrangler/ diff --git a/backend/package.json b/backend/package.json index 89e6d9be..f310cdfc 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,5 @@ { "name": "toppings", - "version": "0.0.0", "private": true, "scripts": { "deploy": "wrangler deploy", diff --git a/backend/src/constants/errors.ts b/backend/src/constants/errors.ts new file mode 100644 index 00000000..258bdc76 --- /dev/null +++ b/backend/src/constants/errors.ts @@ -0,0 +1,7 @@ +export const ERROR = { + PLAYLIST_FETCH_FAILED: "Failed fetching playlist data", + MISSING_PLAYLIST_ID: "Missing playlistId", + PLAYLIST_UNAVAILABLE: "Playlist not found or private", + YOUTUBE_PLAYLIST_FETCH_FAILED: "Failed to fetch playlist items", + YOUTUBE_VIDEO_FETCH_FAILED: "Failed to fetch video details", +} as const; diff --git a/backend/src/constants/googleapis.ts b/backend/src/constants/googleapis.ts new file mode 100644 index 00000000..09bca87d --- /dev/null +++ b/backend/src/constants/googleapis.ts @@ -0,0 +1,5 @@ +export const PLAYLIST_VIDEO_IDS_ENDPOINT = + "https://www.googleapis.com/youtube/v3/playlistItems?part=contentDetails&fields=items/contentDetails/videoId,nextPageToken&maxResults=50"; + +export const VIDEO_DURATIONS_ENDPOINT = + "https://www.googleapis.com/youtube/v3/videos?part=contentDetails&fields=items/contentDetails/duration"; diff --git a/backend/src/constants/http.ts b/backend/src/constants/http.ts new file mode 100644 index 00000000..f75f9aa4 --- /dev/null +++ b/backend/src/constants/http.ts @@ -0,0 +1,15 @@ +export const HTTP_HEADER = { + CONTENT_TYPE: "Content-Type", + ACCEPT: "Accept", +} as const; + +export const MIME_TYPE = { + JSON: "application/json", +} as const; + +export const HTTP_STATUS = { + OK: 200, + CREATED: 201, + BAD_REQUEST: 400, + NOT_FOUND: 404, +} as const; diff --git a/backend/src/constants/index.ts b/backend/src/constants/index.ts new file mode 100644 index 00000000..c112fad1 --- /dev/null +++ b/backend/src/constants/index.ts @@ -0,0 +1,4 @@ +export * from "./errors"; +export * from "./googleapis"; +export * from "./http"; +export * from "./pattern"; diff --git a/backend/src/constants/pattern.ts b/backend/src/constants/pattern.ts new file mode 100644 index 00000000..c63d3a48 --- /dev/null +++ b/backend/src/constants/pattern.ts @@ -0,0 +1 @@ +export const ISO8601_DURATION = /PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?/; diff --git a/backend/src/index.ts b/backend/src/index.ts index 35c1728b..d0e11b6f 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,18 +1,18 @@ -import { AutoRouter, Router, error } from "itty-router"; -import { preflight, corsify } from "./middlewares/cors"; -import ResponseEntity from "./utils/responseEntity"; +import { AutoRouter, cors, error } from "itty-router"; +import { ResponseEntity } from "./utils"; import { playlistRouter } from "./routers/playlist"; +const { preflight, corsify } = cors(); + const router = AutoRouter({ + base: "/api", before: [preflight], finally: [corsify], catch: error, }); -router.get("/ping", () => { - return ResponseEntity.ok("pong"); -}); +router.get("/ping", () => ResponseEntity.ok("pong")); -router.all("/playlist/*", playlistRouter); +router.all("/v1/playlist/*", playlistRouter); export default router; diff --git a/backend/src/middlewares/cors.ts b/backend/src/middlewares/cors.ts deleted file mode 100644 index d6357982..00000000 --- a/backend/src/middlewares/cors.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { cors } from "itty-router"; - -const { preflight, corsify } = cors(); - -export { preflight, corsify }; diff --git a/backend/src/routers/playlist.ts b/backend/src/routers/playlist.ts index 1f42762b..9ef64816 100644 --- a/backend/src/routers/playlist.ts +++ b/backend/src/routers/playlist.ts @@ -1,19 +1,20 @@ import { AutoRouter, IRequest } from "itty-router"; -import PlaylistService from "../services/playlists"; -import ResponseEntity from "../utils/responseEntity"; +import { ERROR } from "../constants"; +import { getPlaylistMetadata } from "../services/playlist"; +import { ResponseEntity } from "../utils"; const router = AutoRouter({ - base: "/playlist", + base: "/api/v1/playlist", }); router.get("/:playlistId", async (request: IRequest, env: Env) => { const { playlistId } = request.params; - if (!playlistId) return ResponseEntity.badRequest("Missing playlistId"); + if (!playlistId) { + return ResponseEntity.badRequest(ERROR.MISSING_PLAYLIST_ID); + } - return ResponseEntity.ok( - await PlaylistService.getPlaylistMetadata(playlistId, env), - ); + return ResponseEntity.ok(await getPlaylistMetadata(playlistId, env)); }); export const playlistRouter = router.fetch; diff --git a/backend/src/services/playlist.ts b/backend/src/services/playlist.ts new file mode 100644 index 00000000..7bbe52db --- /dev/null +++ b/backend/src/services/playlist.ts @@ -0,0 +1,72 @@ +import { StatusError } from "itty-router"; +import { + ERROR, + HTTP_STATUS, + PLAYLIST_VIDEO_IDS_ENDPOINT, + VIDEO_DURATIONS_ENDPOINT, +} from "../constants"; +import { parseDuration } from "../utils"; + +export async function getPlaylistMetadata(playlistId: string, env: Env) { + const apiKey = env.YOUTUBE_DATA_API_V3_KEY; + let totalVideos = 0; + let totalRuntime = 0; + let pageToken = ""; + + do { + const itemsRes = await fetch( + `${PLAYLIST_VIDEO_IDS_ENDPOINT}&key=${apiKey}&playlistId=${playlistId}&pageToken=${pageToken}`, + ); + + if (itemsRes.status === HTTP_STATUS.NOT_FOUND) { + throw new StatusError(HTTP_STATUS.NOT_FOUND, ERROR.PLAYLIST_UNAVAILABLE); + } + if (!itemsRes.ok) { + throw new StatusError( + itemsRes.status, + `${ERROR.YOUTUBE_PLAYLIST_FETCH_FAILED}: ${itemsRes.statusText}`, + ); + } + + const page = (await itemsRes.json()) as { + items?: { contentDetails: { videoId: string } }[]; + nextPageToken?: string; + }; + const videoIds = (page.items ?? []).map( + (item) => item.contentDetails.videoId, + ); + totalVideos += videoIds.length; + if (videoIds.length === 0) break; + + const videosRes = await fetch( + `${VIDEO_DURATIONS_ENDPOINT}&key=${apiKey}&id=${videoIds.join(",")}`, + ); + + if (!videosRes.ok) { + throw new StatusError( + videosRes.status, + `${ERROR.YOUTUBE_VIDEO_FETCH_FAILED}: ${videosRes.statusText}`, + ); + } + + const videos = (await videosRes.json()) as { + items: { contentDetails: { duration: string } }[]; + }; + for (const item of videos.items) { + totalRuntime += parseDuration(item.contentDetails.duration); + } + + pageToken = page.nextPageToken ?? ""; + } while (pageToken); + + return { + scope: "playlist", + payload: { + playlistId, + totalVideos, + totalRuntime, + averageRuntime: + totalVideos === 0 ? 0 : Math.round(totalRuntime / totalVideos), + }, + }; +} diff --git a/backend/src/services/playlists.ts b/backend/src/services/playlists.ts deleted file mode 100644 index 3ddc5c0c..00000000 --- a/backend/src/services/playlists.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { StatusError } from "itty-router"; -import parseDuration from "../utils/parseDuration"; - -class PlaylistService { - static async getPlaylistMetadata(playlistId: string, env: Env) { - const { YOUTUBE_API_KEY } = env; - let videoCount = 0; - let totalRuntime = 0; - let nextPageToken = ""; - - do { - const playlistUrl = `https://www.googleapis.com/youtube/v3/playlistItems?part=contentDetails&maxResults=50&fields=items/contentDetails/videoId,nextPageToken&key=${YOUTUBE_API_KEY}&playlistId=${playlistId}&pageToken=${nextPageToken}`; - const playlistResponse = await fetch(playlistUrl); - - if (playlistResponse.status === 404) { - throw new StatusError(404, "Playlist not found or private"); - } - if (!playlistResponse.ok) { - throw new StatusError( - playlistResponse.status, - `Failed to fetch playlist items: ${playlistResponse.statusText}`, - ); - } - - const playlistData: PlaylistResponse = await playlistResponse.json(); - const videoIDs = playlistData.items.map( - (item) => item.contentDetails.videoId, - ); - videoCount += videoIDs.length; - - if (videoIDs.length === 0) break; - - const videoUrl = `https://www.googleapis.com/youtube/v3/videos?part=contentDetails&id=${videoIDs.join(",")}&key=${YOUTUBE_API_KEY}&fields=items/contentDetails/duration`; - const videoResponse = await fetch(videoUrl); - - if (!videoResponse.ok) { - throw new StatusError( - videoResponse.status, - `Failed to fetch video details: ${videoResponse.statusText}`, - ); - } - - const videoData: VideoResponse = await videoResponse.json(); - totalRuntime += videoData.items.reduce( - (sum, item) => sum + parseDuration(item.contentDetails.duration), - 0, - ); - - nextPageToken = playlistData.nextPageToken || ""; - } while (nextPageToken); - - return { - pathname: "playlist", - payload: { - playlistId: playlistId, - totalVideos: videoCount, - totalRuntime: totalRuntime, - averageRuntime: Math.round(totalRuntime / videoCount), - }, - }; - } -} - -export default PlaylistService; diff --git a/backend/src/types.ts b/backend/src/types.ts deleted file mode 100644 index d6726853..00000000 --- a/backend/src/types.ts +++ /dev/null @@ -1,20 +0,0 @@ -interface PlaylistItem { - contentDetails: { - videoId: string; - }; -} - -interface PlaylistResponse { - items: PlaylistItem[]; - nextPageToken?: string; -} - -interface VideoItem { - contentDetails: { - duration: string; - }; -} - -interface VideoResponse { - items: VideoItem[]; -} diff --git a/backend/src/utils/duration.ts b/backend/src/utils/duration.ts new file mode 100644 index 00000000..8fbceb12 --- /dev/null +++ b/backend/src/utils/duration.ts @@ -0,0 +1,12 @@ +import { ISO8601_DURATION } from "../constants"; + +export function parseDuration(duration: string): number { + const match = ISO8601_DURATION.exec(duration); + if (!match) return 0; + + const hours = Number.parseInt(match[1] ?? "0"); + const minutes = Number.parseInt(match[2] ?? "0"); + const seconds = Number.parseFloat(match[3] ?? "0"); + + return hours * 3600 + minutes * 60 + seconds; +} diff --git a/backend/src/utils/index.ts b/backend/src/utils/index.ts new file mode 100644 index 00000000..43b3d3e7 --- /dev/null +++ b/backend/src/utils/index.ts @@ -0,0 +1,2 @@ +export { parseDuration } from "./duration"; +export { default as ResponseEntity } from "./responseEntity"; diff --git a/backend/src/utils/parseDuration.ts b/backend/src/utils/parseDuration.ts deleted file mode 100644 index 26fb2cae..00000000 --- a/backend/src/utils/parseDuration.ts +++ /dev/null @@ -1,12 +0,0 @@ -const parseDuration = (duration: string): number => { - const match = /PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/.exec(duration); - if (!match) return 0; - - const hours = parseInt(match[1] || '0', 10); - const minutes = parseInt(match[2] || '0', 10); - const seconds = parseInt(match[3] || '0', 10); - - return hours * 3600 + minutes * 60 + seconds; -}; - -export default parseDuration; diff --git a/backend/src/utils/responseEntity.ts b/backend/src/utils/responseEntity.ts index b7300b9e..8d607565 100644 --- a/backend/src/utils/responseEntity.ts +++ b/backend/src/utils/responseEntity.ts @@ -1,23 +1,24 @@ import { StatusError } from "itty-router"; +import { HTTP_HEADER, HTTP_STATUS, MIME_TYPE } from "../constants"; export default class ResponseEntity { static ok(body: T): Response { return new Response(JSON.stringify(body), { - status: 200, - headers: { "Content-Type": "application/json" }, + status: HTTP_STATUS.OK, + headers: { [HTTP_HEADER.CONTENT_TYPE]: MIME_TYPE.JSON }, }); } static created(body: T): Response { return new Response(JSON.stringify(body), { - status: 201, - headers: { "Content-Type": "application/json" }, + status: HTTP_STATUS.CREATED, + headers: { [HTTP_HEADER.CONTENT_TYPE]: MIME_TYPE.JSON }, }); } static badRequest( message: ConstructorParameters[1], ): Response { - throw new StatusError(400, message); + throw new StatusError(HTTP_STATUS.BAD_REQUEST, message); } } diff --git a/backend/worker-configuration.d.ts b/backend/worker-configuration.d.ts index 361d5992..48d54ab2 100644 --- a/backend/worker-configuration.d.ts +++ b/backend/worker-configuration.d.ts @@ -2,12 +2,12 @@ // Generated by Wrangler by running `wrangler types` (hash: 940eda2ab6cf443a8894670a69d0a76f) // Runtime types generated with workerd@1.20251008.0 2024-11-12 nodejs_compat declare namespace Cloudflare { - interface GlobalProps { - mainModule: typeof import("./src/index"); - } - interface Env { - YOUTUBE_API_KEY: string; - } + interface GlobalProps { + mainModule: typeof import("./src/index"); + } + interface Env { + YOUTUBE_DATA_API_V3_KEY: string; + } } interface Env extends Cloudflare.Env {} @@ -1703,8 +1703,10 @@ declare var Request: { * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request) */ -interface Request> - extends Body { +interface Request< + CfHostMetadata = unknown, + Cf = CfProperties, +> extends Body { /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/clone) */ clone(): Request; /** @@ -2516,9 +2518,7 @@ interface TextDecoderStreamTextDecoderStreamInit { * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ByteLengthQueuingStrategy) */ -declare class ByteLengthQueuingStrategy - implements QueuingStrategy -{ +declare class ByteLengthQueuingStrategy implements QueuingStrategy { constructor(init: QueuingStrategyInit); /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ByteLengthQueuingStrategy/highWaterMark) */ get highWaterMark(): number; @@ -7479,8 +7479,7 @@ interface IncomingRequestCfPropertiesBotManagement { */ clientTrustScore: number; } -interface IncomingRequestCfPropertiesBotManagementEnterprise - extends IncomingRequestCfPropertiesBotManagement { +interface IncomingRequestCfPropertiesBotManagementEnterprise extends IncomingRequestCfPropertiesBotManagement { /** * Results of Cloudflare's Bot Management analysis */ @@ -8946,7 +8945,8 @@ declare namespace CloudflareWorkersModule { export abstract class WorkflowEntrypoint< Env = unknown, T extends Rpc.Serializable | unknown = unknown, - > implements Rpc.WorkflowEntrypointBranded + > + implements Rpc.WorkflowEntrypointBranded { [Rpc.__WORKFLOW_ENTRYPOINT_BRAND]: never; protected ctx: ExecutionContext; diff --git a/web-ext/src/_locales/en/messages.json b/web-ext/_locales/en/messages.json similarity index 100% rename from web-ext/src/_locales/en/messages.json rename to web-ext/_locales/en/messages.json diff --git a/web-ext/app/background/context.ts b/web-ext/app/background/context.ts new file mode 100644 index 00000000..9752c079 --- /dev/null +++ b/web-ext/app/background/context.ts @@ -0,0 +1,160 @@ +import { + ERROR, + EXTENSION_CONTEXT_SCOPE, + HTTP_METHOD, + YOUTUBE_QUERY_PARAM, + YOUTUBE_SYSTEM_PLAYLIST_ID, + YOUTUBE_URL_PATH, +} from "@toppings/constants"; +import { EXTENSION_API_ENDPOINT } from "../../data/api.data"; +import { api } from "../../utils/api"; +import { getStorage, Storage } from "./store"; +import { + getCachedPlaylist, + setCachedPlaylist, +} from "../../utils/storage/playlistCache"; + +export type YoutubeContext = BaseContext & { + scope: typeof EXTENSION_CONTEXT_SCOPE.YOUTUBE; + payload: Record; +}; + +export type Context = WatchContext | PlaylistContext | ShortsContext | YoutubeContext | null; + +export interface BaseContext { + scope: string; + payload: Record; + store: Storage; +} + +export type WatchContext = BaseContext & { + scope: typeof EXTENSION_CONTEXT_SCOPE.WATCH; + payload: WatchPayload; +}; + +export type WatchPayload = { + videoId: string | null; +}; + +export type PlaylistContext = BaseContext & { + scope: typeof EXTENSION_CONTEXT_SCOPE.PLAYLIST; + payload: ValidPlaylistPayload | InvalidPlaylistPayload; +}; + +export type ValidPlaylistPayload = { + playlistId: string; + averageRuntime: number; + totalRuntime: number; + totalVideos: number; +}; + +export type InvalidPlaylistPayload = { + playlistId: + | typeof YOUTUBE_SYSTEM_PLAYLIST_ID.WATCH_LATER + | typeof YOUTUBE_SYSTEM_PLAYLIST_ID.LIKED + | string + | null; +}; + +export type PlaylistResponse = PlaylistContext; + +export type ShortsContext = BaseContext & { + scope: typeof EXTENSION_CONTEXT_SCOPE.SHORTS; + payload: ShortsPayload; +}; + +export type ShortsPayload = { + shortId: string | null; +}; + +export const getContext = async (rawURL: string): Promise => { + const url = new URL(rawURL); + const store = await getStorage(); + + if (!store) { + throw new Error(ERROR.STORE_NOT_FOUND); + } + + if (url.pathname.startsWith(YOUTUBE_URL_PATH.WATCH)) { + const videoId = url.searchParams.get(YOUTUBE_QUERY_PARAM.VIDEO_ID); + if (!videoId) return null; + + return { + scope: EXTENSION_CONTEXT_SCOPE.WATCH, + payload: { videoId }, + store, + } as const; + } else if (url.pathname.startsWith(YOUTUBE_URL_PATH.PLAYLIST)) { + const playlistId = url.searchParams.get(YOUTUBE_QUERY_PARAM.PLAYLIST_ID); + + if ( + !playlistId || + playlistId === YOUTUBE_SYSTEM_PLAYLIST_ID.WATCH_LATER || + playlistId === YOUTUBE_SYSTEM_PLAYLIST_ID.LIKED + ) { + return { + scope: EXTENSION_CONTEXT_SCOPE.PLAYLIST, + payload: { playlistId }, + store, + } as const; + } + + try { + // Check cache before hitting the API. + const cached = await getCachedPlaylist(playlistId); + if (cached) { + return { + scope: EXTENSION_CONTEXT_SCOPE.PLAYLIST, + payload: cached, + store, + } as const; + } + + const response = await api.fetch( + EXTENSION_API_ENDPOINT.PLAYLIST_V1, + { playlistId }, + { method: HTTP_METHOD.GET }, + ); + + if (!response.ok) { + throw new Error(ERROR.PLAYLIST_FETCH_FAILED); + } + + const body = (await response.json()) as PlaylistResponse; + + // Persist to cache for subsequent visits. + if (body.scope === EXTENSION_CONTEXT_SCOPE.PLAYLIST && "totalRuntime" in body.payload) { + void setCachedPlaylist(playlistId, body.payload as ValidPlaylistPayload); + } + + return { + ...body, + store, + } as const; + } catch (error) { + console.error(ERROR.PLAYLIST_FETCH_FAILED, error); + return { + scope: EXTENSION_CONTEXT_SCOPE.PLAYLIST, + payload: { playlistId }, + store, + } as const; + } + } else if (url.pathname.startsWith(YOUTUBE_URL_PATH.SHORTS)) { + const shortId = + url.pathname.split("/")[2] || null; + return { + scope: EXTENSION_CONTEXT_SCOPE.SHORTS, + payload: { shortId }, + store, + } as const; + } + + // Generic YouTube page (home, search, channel, etc.) — return a YouTube + // scope context so the content script can apply profile primitives that + // target these pages (home feed, search results, Shorts shelf). + return { + scope: EXTENSION_CONTEXT_SCOPE.YOUTUBE, + payload: {}, + store, + } as const; +}; diff --git a/web-ext/app/background/index.ts b/web-ext/app/background/index.ts new file mode 100644 index 00000000..ea815958 --- /dev/null +++ b/web-ext/app/background/index.ts @@ -0,0 +1,108 @@ +import { syncStorageWithDefaults } from "./store"; +import { getActiveProfile } from "../../utils/storage/profileStore"; +import { + getFeatureReports, + markRecovered, + removeFeatureReport, +} from "../../utils/storage/featureReports"; +import { getCapabilityStatus } from "../../utils/storage/capabilityCache"; +import { getContext } from "./context"; +import { dispatchContext } from "./utils/dispatchContext"; +import { URLS } from "@toppings/constants"; +import { + EXTENSION_INSTALL_REASON, + EXTENSION_MESSAGE_BODY, + EXTENSION_MESSAGE_EVENT, + EXTENSION_MESSAGE_TYPE, + NODE_ENV, +} from "../../data/extension.data"; + +chrome.runtime.onInstalled.addListener(onInitialize); +chrome.runtime.onMessage.addListener(onConnected); +chrome.webNavigation.onHistoryStateUpdated.addListener(onWebNavigation); + +type InitializeDetails = Parameters< + Parameters[0] +>[0]; +function onInitialize({ reason }: InitializeDetails): void { + if ( + reason === EXTENSION_INSTALL_REASON.INSTALL || + reason === EXTENSION_INSTALL_REASON.UPDATE + ) { + if (process.env.NODE_ENV === NODE_ENV.PRODUCTION) { + void chrome.tabs.create({ url: URLS.GREETINGS }); + void chrome.runtime.setUninstallURL(URLS.FAREWELL); + } + void syncStorageWithDefaults(); + // Touch the profile store so it initialises with defaults for new + // installs and returns gracefully for existing users (no-op on update). + void getActiveProfile(); + // On update: check whether any reported-unsupported primitives are now + // resolved in the capability cache (user may have re-scanned on prior run). + if (reason === EXTENSION_INSTALL_REASON.UPDATE) { + void checkRecoveredFeatures(); + } + } +} + +/** + * On extension update: compare reported-unsupported features against the + * capability cache. If any were "unsupported" when reported but are now + * "supported", mark them as recovered so the options page can show a banner. + */ +async function checkRecoveredFeatures(): Promise { + const reports = await getFeatureReports(); + for (const report of reports) { + const status = await getCapabilityStatus(report.primitiveId); + if (status === "supported") { + await markRecovered(report.primitiveId); + // Remove the report — the user has been notified. + await removeFeatureReport(report.primitiveId); + } + } +} + +type WebNavigationDetails = Parameters< + Parameters[0] +>[0]; +async function onWebNavigation(details: WebNavigationDetails) { + const tabId = details.tabId; + + const ctx = await getContext(details.url); + if (!ctx?.store.isExtensionEnabled) return; + await dispatchContext(tabId, ctx); +} + +function onConnected( + message: any, + sender: chrome.runtime.MessageSender, + sendResponse: (response: any) => void, +) { + (async () => { + const parsed = JSON.parse(message) as Record; + const type = parsed[EXTENSION_MESSAGE_BODY.TYPE]; + const payload = parsed[EXTENSION_MESSAGE_BODY.PAYLOAD]; + if (type !== EXTENSION_MESSAGE_TYPE.EVENT) return; + + const event = payload; + if (event !== EXTENSION_MESSAGE_EVENT.CONNECTED) return; + + const tabId = sender.tab?.id; + if (!tabId) return; + + const url = sender.url; + if (!url) return; + + const ctx = await getContext(url); + if (!ctx?.store.isExtensionEnabled) return; + + sendResponse( + JSON.stringify({ + [EXTENSION_MESSAGE_BODY.TYPE]: EXTENSION_MESSAGE_TYPE.CONTEXT, + [EXTENSION_MESSAGE_BODY.PAYLOAD]: ctx, + }), + ); + })(); + + return true; +} diff --git a/web-ext/app/background/store.ts b/web-ext/app/background/store.ts new file mode 100644 index 00000000..5fea1319 --- /dev/null +++ b/web-ext/app/background/store.ts @@ -0,0 +1,23 @@ +import { mergeDefaults } from "@toppings/utils"; +import { DEFAULT_STORE } from "../../data/store.data"; + +export { DEFAULT_STORE }; + +export const getStorage = async (): Promise => { + return new Promise((resolve) => { + chrome.storage.sync.get(undefined, (storage) => { + resolve(mergeDefaults(DEFAULT_STORE, storage)); + }); + }); +}; + +export const syncStorageWithDefaults = async (): Promise => { + return new Promise((resolve) => { + chrome.storage.sync.get(undefined, (storage) => { + const merged = mergeDefaults(DEFAULT_STORE, storage); + void chrome.storage.sync.set(merged, () => resolve()); + }); + }); +}; + +export type Storage = typeof DEFAULT_STORE; diff --git a/web-ext/app/background/utils/dispatchContext.ts b/web-ext/app/background/utils/dispatchContext.ts new file mode 100644 index 00000000..d07eecf1 --- /dev/null +++ b/web-ext/app/background/utils/dispatchContext.ts @@ -0,0 +1,18 @@ +import { + EXTENSION_MESSAGE_BODY, + EXTENSION_MESSAGE_TYPE, +} from "../../../data/extension.data"; +import type { Context } from "../context"; + +export async function dispatchContext( + tabId: number, + ctx: Exclude, +): Promise { + await chrome.tabs.sendMessage( + tabId, + JSON.stringify({ + [EXTENSION_MESSAGE_BODY.TYPE]: EXTENSION_MESSAGE_TYPE.CONTEXT, + [EXTENSION_MESSAGE_BODY.PAYLOAD]: ctx, + }), + ); +} diff --git a/web-ext/app/content_scripts/components/AudioMode/AudioModeButton.tsx b/web-ext/app/content_scripts/components/AudioMode/AudioModeButton.tsx new file mode 100644 index 00000000..505b64bb --- /dev/null +++ b/web-ext/app/content_scripts/components/AudioMode/AudioModeButton.tsx @@ -0,0 +1,26 @@ +import React from "dom-chef"; + +export const AudioModeButton = ( + +); diff --git a/web-ext/app/content_scripts/components/AudioMode/AudioModeOverlay.tsx b/web-ext/app/content_scripts/components/AudioMode/AudioModeOverlay.tsx new file mode 100644 index 00000000..e4a81335 --- /dev/null +++ b/web-ext/app/content_scripts/components/AudioMode/AudioModeOverlay.tsx @@ -0,0 +1,9 @@ +import React from "dom-chef"; + +export const AudioModeOverlay = ( +
+); diff --git a/web-ext/app/content_scripts/components/AudioMode/AudioModeUI.tsx b/web-ext/app/content_scripts/components/AudioMode/AudioModeUI.tsx new file mode 100644 index 00000000..168f4eaa --- /dev/null +++ b/web-ext/app/content_scripts/components/AudioMode/AudioModeUI.tsx @@ -0,0 +1,379 @@ +import { defaultTo, isNull } from "@toppings/utils"; +import React from "dom-chef"; + +type ScreenMode = "black" | "visualizer" | "custom"; +type OnModeAction = { + onModeChange: (mode: ScreenMode) => void; + onSetDefault: (mode: ScreenMode) => void; + onPinToVideo: (mode: ScreenMode) => void; + onUnpinVideo: () => void; + onExitAudioMode: () => void; + onPickCustomImage: (file: File) => void; +}; + +let video: HTMLVideoElement | null = null; +let progressInterval: ReturnType | null = null; +let modeActions: OnModeAction | null = null; +let activeScreenMode: ScreenMode = "black"; +let isPinnedForVideo = false; + +const PlayIcon = ``; +const PauseIcon = ``; + +const playPauseBtn = ( + + ) as HTMLButtonElement; +} + +const setDefaultBtn = ( + +) as HTMLButtonElement; + +const pinBtn = ( + +) as HTMLButtonElement; + +const customImageInput = ( + { + const file = e.target?.files?.[0]; + if (file && modeActions) { + modeActions.onPickCustomImage(file); + flashChooseImageBtn(`Loaded: ${file.name}`); + } + if (e.target) e.target.value = ""; + }} + /> +) as HTMLInputElement; + +const chooseImageBtn = ( + +) as HTMLButtonElement; + +let chooseImageBtnFlashTimeout: ReturnType | null = null; +function flashChooseImageBtn(text: string) { + if (chooseImageBtnFlashTimeout !== null) { + clearTimeout(chooseImageBtnFlashTimeout); + } + const original = "Choose Image"; + const truncated = text.length > 28 ? text.slice(0, 25) + "..." : text; + chooseImageBtn.textContent = truncated; + chooseImageBtn.classList.remove("tw-text-white/70"); + chooseImageBtn.classList.add("tw-text-white", "tw-border-white/60"); + chooseImageBtnFlashTimeout = setTimeout(() => { + chooseImageBtn.textContent = original; + chooseImageBtn.classList.remove("tw-text-white", "tw-border-white/60"); + chooseImageBtn.classList.add("tw-text-white/70"); + chooseImageBtnFlashTimeout = null; + }, 2000); +} + +const modeSwitcher = ( +
+ {modeButtons.map((b) => b.el)} + {chooseImageBtn} + {customImageInput} +
+ {setDefaultBtn} + {pinBtn} +
+); + +export const AudioModeUIContainer = ( +
+ {closeBtn} +
+ {titleEl} + {channelEl} +
+
+
+ {modeSwitcher} +
+
+ {currentTimeEl} + {progressBar} + {durationEl} +
+
+ {playPauseBtn} +
+ {volumeBtn} + {volumeSlider} +
+
+
+
+) as HTMLDivElement; + +export function initAudioModeUI(videoElement: HTMLVideoElement) { + video = videoElement; + + playPauseBtn.onclick = () => { + if (!video) return; + if (video.paused) { + video.play(); + } else { + video.pause(); + } + }; + + progressBar.onclick = (e: MouseEvent) => { + if (!video) return; + const rect = progressBar.getBoundingClientRect(); + const percent = (e.clientX - rect.left) / rect.width; + video.currentTime = percent * video.duration; + }; + + volumeSlider.value = String(videoElement.volume); + volumeSlider.oninput = () => { + if (!video) return; + video.volume = parseFloat(volumeSlider.value); + video.muted = false; + }; + + volumeBtn.onclick = () => { + if (!video) return; + video.muted = !video.muted; + }; + + video.addEventListener("play", updatePlayPauseIcon); + video.addEventListener("pause", updatePlayPauseIcon); + updatePlayPauseIcon(); +} + +export function setModeActions(actions: OnModeAction) { + modeActions = actions; +} + +export function updateActiveMode(mode: ScreenMode, pinned: boolean) { + activeScreenMode = mode; + isPinnedForVideo = pinned; + + for (const btn of modeButtons) { + if (btn.mode === mode) { + btn.el.classList.remove("tw-border-white/20", "tw-text-white/70", "tw-bg-transparent"); + btn.el.classList.add("tw-border-white/60", "tw-text-white", "tw-bg-white/10"); + } else { + btn.el.classList.remove("tw-border-white/60", "tw-text-white", "tw-bg-white/10"); + btn.el.classList.add("tw-border-white/20", "tw-text-white/70", "tw-bg-transparent"); + } + } + + if (mode === "custom") { + chooseImageBtn.classList.remove("tw-hidden"); + } else { + chooseImageBtn.classList.add("tw-hidden"); + } + + updatePinBtn(); + updateSetDefaultBtn(false); +} + +function updatePinBtn() { + pinBtn.textContent = isPinnedForVideo ? "Unpin Video" : "Pin to Video"; + if (isPinnedForVideo) { + pinBtn.classList.remove("tw-text-white/40", "tw-border-white/10"); + pinBtn.classList.add("tw-text-white/70", "tw-border-white/30"); + } else { + pinBtn.classList.remove("tw-text-white/70", "tw-border-white/30"); + pinBtn.classList.add("tw-text-white/40", "tw-border-white/10"); + } +} + +function updateSetDefaultBtn(justSet: boolean) { + if (justSet) { + setDefaultBtn.textContent = "Default Set!"; + setTimeout(() => { + setDefaultBtn.textContent = "Set as Default"; + }, 1500); + } +} + +export function showAudioModeUI() { + AudioModeUIContainer.classList.remove("tw-hidden"); + updateMetadata(); + startProgressUpdates(); +} + +export function hideAudioModeUI() { + AudioModeUIContainer.classList.add("tw-hidden"); + stopProgressUpdates(); +} + +function updatePlayPauseIcon() { + if (!video) return; + playPauseBtn.innerHTML = video.paused ? PlayIcon : PauseIcon; +} + +// Ordered by likelihood. Append new selectors when YouTube ships UI variants. +const TITLE_STRATEGIES = [ + "yt-formatted-string.style-scope.ytd-watch-metadata", + "#title yt-formatted-string", + "ytd-watch-metadata h1 yt-formatted-string", +] as const; + +const CHANNEL_STRATEGIES = [ + "ytd-channel-name yt-formatted-string a", + "#channel-name yt-formatted-string a", + "ytd-video-owner-renderer #channel-name a", +] as const; + +function updateMetadata() { + const titleElement = TITLE_STRATEGIES + .map((s) => document.querySelector(s)) + .find(Boolean) ?? null; + + const channelElement = CHANNEL_STRATEGIES + .map((s) => document.querySelector(s)) + .find(Boolean) ?? null; + + titleEl.textContent = defaultTo(titleElement?.textContent, "Unknown Title"); + channelEl.textContent = defaultTo( + channelElement?.textContent, + "Unknown Channel", + ); +} + +function startProgressUpdates() { + stopProgressUpdates(); + progressInterval = setInterval(() => { + if (!video || isNaN(video.duration)) return; + const percent = (video.currentTime / video.duration) * 100; + (progressBarFill as HTMLElement).style.width = `${percent}%`; + (currentTimeEl as HTMLElement).textContent = formatTime(video.currentTime); + (durationEl as HTMLElement).textContent = formatTime(video.duration); + }, 250); +} + +function stopProgressUpdates() { + if (!isNull(progressInterval)) { + clearInterval(progressInterval); + progressInterval = null; + } +} + +function formatTime(seconds: number): string { + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, "0")}`; +} diff --git a/web-ext/app/content_scripts/components/AudioMode/AudioModeVisualizer.tsx b/web-ext/app/content_scripts/components/AudioMode/AudioModeVisualizer.tsx new file mode 100644 index 00000000..05fa1b73 --- /dev/null +++ b/web-ext/app/content_scripts/components/AudioMode/AudioModeVisualizer.tsx @@ -0,0 +1,268 @@ +import React from "dom-chef"; + +let audioContext: AudioContext | null = null; +let analyser: AnalyserNode | null = null; +let source: MediaElementAudioSourceNode | null = null; +let animationFrameId: number | null = null; +let connectedVideo: HTMLVideoElement | null = null; + +const SMOOTHING = 0.82; +const FFT_SIZE = 512; + +// Multiplier on the rendered wave amplitude. 1.0 = default. Tweak from the +// options page; passed in via setVisualizerSensitivity. +let sensitivity = 1.0; + +export function setVisualizerSensitivity(value: number) { + if (Number.isFinite(value) && value > 0) { + sensitivity = value; + } +} + +export const AudioModeCanvas = ( + +) as unknown as HTMLCanvasElement; + +/** + * Ensures the video's audio is routed through the AudioContext to the + * destination (speakers). Once createMediaElementSource is called, the video + * no longer outputs audio directly — it must go through the graph. We keep + * `source -> destination` connected at all times so audio plays in every + * screen mode, and only attach/detach the analyser side-branch. + */ +export function ensureAudioRouting(video: HTMLVideoElement) { + try { + if (!audioContext) { + audioContext = new AudioContext(); + } + + if (connectedVideo !== video) { + if (source) { + try { + source.disconnect(); + } catch (_e) {} + } + source = audioContext.createMediaElementSource(video); + source.connect(audioContext.destination); + connectedVideo = video; + } + + if (audioContext.state === "suspended") { + audioContext.resume(); + } + } catch (_e) { + // If we can't create the source (e.g. it was created elsewhere), the + // video keeps its native audio output and the visualizer falls back. + } +} + +export function startVisualizer(video: HTMLVideoElement) { + try { + ensureAudioRouting(video); + + if (!audioContext || !source) { + renderFallbackWave(); + return; + } + + if (!analyser) { + analyser = audioContext.createAnalyser(); + analyser.fftSize = FFT_SIZE; + analyser.smoothingTimeConstant = SMOOTHING; + } + + source.connect(analyser); + + renderWaveform(); + } catch (_e) { + renderFallbackWave(); + } +} + +export function stopVisualizer() { + if (animationFrameId !== null) { + cancelAnimationFrame(animationFrameId); + animationFrameId = null; + } + + if (analyser && source) { + try { + source.disconnect(analyser); + } catch (_e) {} + } +} + +function renderWaveform() { + if (!analyser) return; + + const canvas = AudioModeCanvas; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const freqLength = analyser.frequencyBinCount; + const freqData = new Uint8Array(freqLength); + const timeData = new Uint8Array(analyser.fftSize); + + const draw = () => { + animationFrameId = requestAnimationFrame(draw); + + const dpr = window.devicePixelRatio || 1; + canvas.width = canvas.offsetWidth * dpr; + canvas.height = canvas.offsetHeight * dpr; + + const w = canvas.width; + const h = canvas.height; + const centerY = h / 2; + + ctx.fillStyle = "#000000"; + ctx.fillRect(0, 0, w, h); + + analyser!.getByteFrequencyData(freqData); + analyser!.getByteTimeDomainData(timeData); + + // Use peak loudness across the lower spectrum (where most music energy + // lives) instead of the flat average, which dilutes amplitude across the + // many quiet high-frequency bins. Then apply a sqrt curve so quieter + // sounds still produce a visible wave. + let peak = 0; + const halfLen = Math.floor(freqLength * 0.6); + let sumLow = 0; + for (let i = 0; i < halfLen; i++) { + const v = freqData[i]; + if (v > peak) peak = v; + sumLow += v; + } + const peakNorm = peak / 255; + const avgLowNorm = sumLow / halfLen / 255; + // Blend peak and avg, then apply perceptual sqrt curve. + const loudness = Math.sqrt(peakNorm * 0.7 + avgLowNorm * 0.3); + + const amplitude = Math.max(0.05, loudness) * h * 0.45 * sensitivity; + + const points: { x: number; y: number }[] = []; + const totalPoints = 200; + + for (let i = 0; i <= totalPoints; i++) { + const t = i / totalPoints; + const x = t * w; + + const timeIndex = Math.floor(t * (timeData.length - 1)); + const timeSample = (timeData[timeIndex] - 128) / 128; + + const envelope = + Math.sin(t * Math.PI) * + Math.sin(t * Math.PI) * + (0.6 + 0.4 * Math.sin(t * Math.PI * 2)); + + const freqIndex = Math.floor(t * (freqLength - 1)); + const freqInfluence = freqData[freqIndex] / 255; + + const y = + centerY + + timeSample * amplitude * envelope + + Math.sin(t * Math.PI * 8 + loudness * 20) * + amplitude * + 0.35 * + envelope * + freqInfluence; + + points.push({ x, y }); + } + + ctx.beginPath(); + ctx.moveTo(0, centerY); + ctx.lineTo(points[0].x, points[0].y); + + for (let i = 1; i < points.length - 1; i++) { + const cpx = (points[i].x + points[i + 1].x) / 2; + const cpy = (points[i].y + points[i + 1].y) / 2; + ctx.quadraticCurveTo(points[i].x, points[i].y, cpx, cpy); + } + + const last = points[points.length - 1]; + ctx.lineTo(last.x, last.y); + ctx.lineTo(w, centerY); + + ctx.strokeStyle = "#ffffff"; + ctx.lineWidth = 2.5 * dpr; + ctx.lineJoin = "round"; + ctx.lineCap = "round"; + ctx.stroke(); + + ctx.beginPath(); + ctx.moveTo(0, centerY); + ctx.lineTo(w, centerY); + ctx.strokeStyle = "rgba(255, 255, 255, 0.08)"; + ctx.lineWidth = 1 * dpr; + ctx.stroke(); + }; + + draw(); +} + +function renderFallbackWave() { + const canvas = AudioModeCanvas; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + let phase = 0; + + const draw = () => { + animationFrameId = requestAnimationFrame(draw); + + const dpr = window.devicePixelRatio || 1; + canvas.width = canvas.offsetWidth * dpr; + canvas.height = canvas.offsetHeight * dpr; + + const w = canvas.width; + const h = canvas.height; + const centerY = h / 2; + + ctx.fillStyle = "#000000"; + ctx.fillRect(0, 0, w, h); + + phase += 0.015; + + ctx.beginPath(); + ctx.moveTo(0, centerY); + + for (let x = 0; x <= w; x += 2) { + const t = x / w; + const envelope = Math.sin(t * Math.PI) * Math.sin(t * Math.PI); + const amplitude = h * 0.15 * envelope; + const y = + centerY + + Math.sin(t * Math.PI * 8 + phase) * amplitude + + Math.sin(t * Math.PI * 3 + phase * 0.7) * amplitude * 0.4; + ctx.lineTo(x, y); + } + + ctx.strokeStyle = "rgba(255, 255, 255, 0.6)"; + ctx.lineWidth = 2 * dpr; + ctx.lineJoin = "round"; + ctx.stroke(); + + ctx.beginPath(); + ctx.moveTo(0, centerY); + ctx.lineTo(w, centerY); + ctx.strokeStyle = "rgba(255, 255, 255, 0.08)"; + ctx.lineWidth = 1 * dpr; + ctx.stroke(); + }; + + draw(); +} + +export function destroyVisualizer() { + stopVisualizer(); + if (audioContext) { + audioContext.close(); + audioContext = null; + analyser = null; + source = null; + connectedVideo = null; + } +} diff --git a/web-ext/app/content_scripts/components/AudioMode/index.ts b/web-ext/app/content_scripts/components/AudioMode/index.ts new file mode 100644 index 00000000..0aa3792a --- /dev/null +++ b/web-ext/app/content_scripts/components/AudioMode/index.ts @@ -0,0 +1,390 @@ +import { AudioModeButton } from "./AudioModeButton"; +import { AudioModeOverlay } from "./AudioModeOverlay"; +import { + AudioModeCanvas, + startVisualizer, + stopVisualizer, + setVisualizerSensitivity, +} from "./AudioModeVisualizer"; +import { + AudioModeUIContainer, + initAudioModeUI, + setModeActions, + updateActiveMode, + showAudioModeUI, + hideAudioModeUI, +} from "./AudioModeUI"; +import { EXTENSION_LOCAL_STORAGE_KEY } from "../../../../data/extension.data"; +import { + getAudioModePin, + removeAudioModePin, + setAudioModePin, +} from "../../../../utils/storage/videoPreference"; +import { Storage } from "../../../background/store"; +import type { PlayerLayout, PlayerVisuals } from "../../../../utils/primitive"; + +let isAudioModeActive = false; +let currentScreenMode: "black" | "visualizer" | "custom" = "black"; +let currentCustomImageUrl = ""; +let currentVideo: HTMLVideoElement | null = null; +let currentPrefs: AudioModePrefs | null = null; +let currentVideoId = ""; +let currentMoviePlayer: HTMLElement | null = null; +let adObserver: MutationObserver | null = null; +let pausedForAd = false; +let videoPinned = false; +// Persists across SPA navigations within the same tab. If the user toggled +// audio mode on, we keep it on as they move between videos until they +// explicitly toggle it off. +let userWantsAudioMode = false; +// True when the current Audio Mode activation was triggered by a profile +// (not by user intent). Used to cleanly deactivate when a profile is removed. +let profileActivatedAudioMode = false; + +type AudioModePrefs = Storage["preferences"]["watch"]["audioMode"]; + +const GLOBAL_CUSTOM_IMAGE_KEY = + EXTENSION_LOCAL_STORAGE_KEY.AUDIO_MODE_GLOBAL_CUSTOM_IMAGE; + +function loadGlobalCustomImage(): Promise { + return new Promise((resolve) => { + chrome.storage.local.get(GLOBAL_CUSTOM_IMAGE_KEY, (result) => { + resolve((result[GLOBAL_CUSTOM_IMAGE_KEY] as string) ?? null); + }); + }); +} + +function saveGlobalCustomImage(dataUrl: string) { + chrome.storage.local.set({ [GLOBAL_CUSTOM_IMAGE_KEY]: dataUrl }); +} + + +export async function setupAudioMode( + moviePlayer: HTMLElement, + videoId: string, + prefs: AudioModePrefs, +) { + // Reset transient per-video state but DO NOT reset userWantsAudioMode — + // we persist the user's intent across SPA navigations. + isAudioModeActive = false; + currentPrefs = prefs; + currentVideoId = videoId; + // Cache the resolved reference — avoids re-querying the DOM with a + // hardcoded selector later (e.g. in setYouTubeChromeHidden). + currentMoviePlayer = moviePlayer; + currentScreenMode = prefs.screenMode; + currentCustomImageUrl = prefs.customBackground.globalImageUrl; + videoPinned = false; + AudioModeButton.setAttribute("aria-pressed", "false"); + AudioModeOverlay.classList.add("tw-hidden"); + hideAudioModeUI(); + stopVisualizer(); + + const sensitivity = parseFloat(prefs.visualizerSensitivity ?? "1.5"); + setVisualizerSensitivity(isFinite(sensitivity) ? sensitivity : 1.5); + + if (!prefs.isEnabled) { + AudioModeButton.style.display = "none"; + return; + } + + AudioModeButton.style.display = ""; + AudioModeButton.onclick = () => toggleAudioMode(); + + if (!moviePlayer.querySelector("#tppng-audio-mode-overlay")) { + moviePlayer.appendChild(AudioModeOverlay); + } + + if (!moviePlayer.querySelector("#tppng-audio-mode-ui")) { + moviePlayer.appendChild(AudioModeUIContainer); + } + + currentVideo = + (moviePlayer.querySelector("video") as HTMLVideoElement) ?? null; + + if (currentVideo) { + initAudioModeUI(currentVideo); + } + + setModeActions({ + onModeChange: handleModeChange, + onSetDefault: handleSetDefault, + onPinToVideo: handlePinToVideo, + onUnpinVideo: handleUnpinVideo, + onExitAudioMode: () => { + if (isAudioModeActive) toggleAudioMode(); + }, + onPickCustomImage: handlePickCustomImage, + }); + + // If user uploaded a local image (stored in chrome.storage.local), it + // overrides the URL preference for the global custom background. + const localImage = await loadGlobalCustomImage(); + if (localImage) { + currentCustomImageUrl = localImage; + } + + let pinAppliedAudio = false; + if (videoId) { + const pin = await getAudioModePin(videoId); + if (pin?.enabled) { + currentScreenMode = pin.screenMode; + if (pin.imageUrl) currentCustomImageUrl = pin.imageUrl; + videoPinned = true; + toggleAudioMode(); + pinAppliedAudio = true; + } + } + + // If a pin didn't already enable audio mode for this video, but the user + // had audio mode active on the previous video, keep it on. This makes + // audio mode "sticky" across SPA navigation, matching user expectations. + if (!pinAppliedAudio && userWantsAudioMode) { + toggleAudioMode(); + } + + setupAdObserver(moviePlayer); +} + +function setupAdObserver(moviePlayer: HTMLElement) { + if (adObserver) { + adObserver.disconnect(); + } + + adObserver = new MutationObserver(() => { + const isAdPlaying = moviePlayer.classList.contains("ad-showing"); + + if (isAdPlaying && isAudioModeActive && !pausedForAd) { + pausedForAd = true; + AudioModeOverlay.classList.add("tw-hidden"); + hideAudioModeUI(); + stopVisualizer(); + } else if (!isAdPlaying && pausedForAd) { + pausedForAd = false; + if (isAudioModeActive) { + AudioModeOverlay.classList.remove("tw-hidden"); + applyScreenMode(); + showAudioModeUI(); + } + } + }); + + adObserver.observe(moviePlayer, { + attributes: true, + attributeFilter: ["class"], + }); +} + +function handleModeChange(mode: "black" | "visualizer" | "custom") { + currentScreenMode = mode; + updateActiveMode(mode, videoPinned); + + if (videoPinned && currentVideoId) { + setAudioModePin(currentVideoId, { + enabled: true, + screenMode: mode, + imageUrl: mode === "custom" ? currentCustomImageUrl : undefined, + }); + } + + if (isAudioModeActive) { + applyScreenMode(); + } +} + +function handleSetDefault(mode: "black" | "visualizer" | "custom") { + chrome.storage.sync.get(undefined, (storage) => { + if (storage?.preferences?.watch?.audioMode) { + storage.preferences.watch.audioMode.screenMode = mode; + chrome.storage.sync.set(storage); + } + }); +} + +function handlePinToVideo(mode: "black" | "visualizer" | "custom") { + if (!currentVideoId) return; + videoPinned = true; + setAudioModePin(currentVideoId, { + enabled: true, + screenMode: mode, + imageUrl: mode === "custom" ? currentCustomImageUrl : undefined, + }); + updateActiveMode(mode, true); +} + +function handleUnpinVideo() { + if (!currentVideoId) return; + videoPinned = false; + removeAudioModePin(currentVideoId); + updateActiveMode(currentScreenMode, false); +} + +function handlePickCustomImage(file: File) { + const reader = new FileReader(); + reader.onload = () => { + const dataUrl = reader.result; + if (typeof dataUrl !== "string") return; + currentCustomImageUrl = dataUrl; + saveGlobalCustomImage(dataUrl); + if (videoPinned && currentVideoId) { + setAudioModePin(currentVideoId, { + enabled: true, + screenMode: currentScreenMode, + imageUrl: dataUrl, + }); + } + if (isAudioModeActive && currentScreenMode === "custom") { + applyScreenMode(); + } + }; + reader.readAsDataURL(file); +} + +function applyScreenMode() { + stopVisualizer(); + AudioModeOverlay.innerHTML = ""; + AudioModeOverlay.style.backgroundImage = ""; + + switch (currentScreenMode) { + case "black": + break; + + case "visualizer": + AudioModeOverlay.appendChild(AudioModeCanvas); + if (currentVideo) { + startVisualizer(currentVideo); + } + break; + + case "custom": + if (currentCustomImageUrl) { + // Wrap in double quotes; CSS.escape() is for identifiers (class + // names/selectors) and would mangle the URL's : ; / + = characters. + const escapedUrl = currentCustomImageUrl.replace(/"/g, '\\"'); + AudioModeOverlay.style.backgroundImage = `url("${escapedUrl}")`; + AudioModeOverlay.style.backgroundSize = "cover"; + AudioModeOverlay.style.backgroundPosition = "center"; + } + break; + } +} + +function setYouTubeChromeHidden(hidden: boolean) { + // Use the cached reference from setupAudioMode — no hardcoded DOM lookup. + if (!currentMoviePlayer) return; + if (hidden) { + currentMoviePlayer.classList.add("tppng-audio-mode-on"); + } else { + currentMoviePlayer.classList.remove("tppng-audio-mode-on"); + } +} + +export function toggleAudioMode() { + isAudioModeActive = !isAudioModeActive; + // Persist the user's intent across SPA navigations. After this toggle + // (whether triggered by user click, keyboard shortcut, or pin restore), + // userWantsAudioMode reflects the new state — so future page navigations + // will keep audio mode in the same state without user action. + userWantsAudioMode = isAudioModeActive; + AudioModeButton.setAttribute( + "aria-pressed", + isAudioModeActive ? "true" : "false", + ); + + if (isAudioModeActive) { + AudioModeOverlay.classList.remove("tw-hidden"); + requestAnimationFrame(() => { + AudioModeOverlay.classList.remove("tw-opacity-0"); + AudioModeOverlay.classList.add("tw-opacity-100"); + }); + applyScreenMode(); + updateActiveMode(currentScreenMode, videoPinned); + showAudioModeUI(); + setYouTubeChromeHidden(true); + } else { + AudioModeOverlay.classList.remove("tw-opacity-100"); + AudioModeOverlay.classList.add("tw-opacity-0"); + hideAudioModeUI(); + stopVisualizer(); + setYouTubeChromeHidden(false); + setTimeout(() => { + if (!isAudioModeActive) { + AudioModeOverlay.classList.add("tw-hidden"); + AudioModeOverlay.innerHTML = ""; + AudioModeOverlay.style.backgroundImage = ""; + } + }, 300); + } +} + +/** + * Apply layout/visuals settings from an active profile. + * + * - `layout === "no-video"` → activates Audio Mode (if not already active). + * - `visuals` → sets the screen mode overlay (black / visualizer / custom). + * - Called with no arguments (or absent layout) → deactivates Audio Mode + * only if it was activated by a previous profile call; user-toggled state + * is preserved. + * + * Call this after `setupAudioMode` has run so the movie player and video + * references are already resolved. + */ +export function applyAudioModeFromProfile( + layout?: PlayerLayout, + visuals?: PlayerVisuals, +): void { + const wantsAudioMode = layout === "no-video"; + + if (wantsAudioMode) { + // Map PlayerVisuals → AudioMode screen mode. + // "video" means "show the video" — not applicable inside Audio Mode, + // so we default to "black" in that case. + const screenMode: "black" | "visualizer" | "custom" = + visuals === "visualizer" ? "visualizer" + : visuals === "custom" ? "custom" + : "black"; + + // Update currentScreenMode so applyScreenMode picks it up. + currentScreenMode = screenMode; + + if (!isAudioModeActive) { + profileActivatedAudioMode = true; + // toggleAudioMode sets userWantsAudioMode — we don't want a profile + // activation to lock the sticky flag on, so we'll restore it after. + const prevUserWants = userWantsAudioMode; + toggleAudioMode(); + userWantsAudioMode = prevUserWants; + } else { + // Already active — just update the overlay. + applyScreenMode(); + updateActiveMode(currentScreenMode, videoPinned); + } + } else { + // No Audio Mode requested by this profile. + if (isAudioModeActive && profileActivatedAudioMode) { + // Deactivate — but restore userWantsAudioMode to its pre-profile value + // so the sticky behaviour isn't affected. + const prevUserWants = userWantsAudioMode; + toggleAudioMode(); + userWantsAudioMode = prevUserWants; + } + profileActivatedAudioMode = false; + } +} + +export function teardownAudioMode() { + isAudioModeActive = false; + pausedForAd = false; + videoPinned = false; + AudioModeButton.setAttribute("aria-pressed", "false"); + AudioModeOverlay.classList.add("tw-hidden"); + hideAudioModeUI(); + stopVisualizer(); + setYouTubeChromeHidden(false); + if (adObserver) { + adObserver.disconnect(); + adObserver = null; + } +} + +export { AudioModeButton }; diff --git a/web-ext/app/content_scripts/components/GearMenuPanel/index.ts b/web-ext/app/content_scripts/components/GearMenuPanel/index.ts new file mode 100644 index 00000000..21079b5b --- /dev/null +++ b/web-ext/app/content_scripts/components/GearMenuPanel/index.ts @@ -0,0 +1,340 @@ +/** + * GearMenuPanel — injects a "Toppings" entry into YouTube's player gear menu. + * + * When the user opens the gear menu (⚙), a "Toppings" item appears at the top + * of the panel menu. Clicking it navigates into a Toppings sub-panel that lets + * users control watch-scope primitives (sidebar, comments, end cards) and + * switch profiles — all without leaving the video. + * + * YouTube's settings panel uses a fixed structure: + * .ytp-settings-menu > .ytp-panel > .ytp-panel-menu > .ytp-menuitem* + * Sub-panels are created by injecting additional .ytp-panel siblings inside + * .ytp-settings-menu. The back button at the top navigates back. + * + * All DOM mutations are idempotent — this file can be called on every gear + * menu open and will reuse existing injected nodes. + */ + +import { isNull } from "@toppings/utils"; +import { + getAllProfiles, + getActiveProfile, + setActiveProfileId, +} from "../../../../utils/storage/profileStore"; +import { + setSidebarVisible, + setCommentsVisible, + setEndCardsVisible, + resetSidebar, + resetComments, + resetEndCards, +} from "../../primitives/watch"; +import { applyWatchProfile } from "../../primitives/applyProfile"; +import type { Profile } from "../../../../data/profiles.data"; + +// --------------------------------------------------------------------------- +// IDs for injected elements +// --------------------------------------------------------------------------- + +const TPPNG_MENU_ITEM_ID = "tppng-gear-menu-item"; +const TPPNG_PANEL_ID = "tppng-gear-panel"; + +// --------------------------------------------------------------------------- +// Primitive state cache (updated when panel opens) +// --------------------------------------------------------------------------- + +interface PanelState { + sidebarVisible: boolean; + commentsVisible: boolean; + endCardsVisible: boolean; + profiles: Profile[]; + activeProfileId: string | null; +} + +// --------------------------------------------------------------------------- +// Helper: create a YouTube-styled toggle menu item +// --------------------------------------------------------------------------- + +function makeToggleItem( + label: string, + checked: boolean, + onChange: (next: boolean) => void, +): HTMLElement { + const item = document.createElement("div"); + item.className = "ytp-menuitem"; + item.setAttribute("role", "menuitemcheckbox"); + item.setAttribute("aria-checked", String(checked)); + item.setAttribute("tabindex", "0"); + + const labelEl = document.createElement("div"); + labelEl.className = "ytp-menuitem-label"; + labelEl.textContent = label; + + const content = document.createElement("div"); + content.className = "ytp-menuitem-content"; + + const toggle = document.createElement("div"); + toggle.className = "ytp-menuitem-toggle-checkbox"; + + content.appendChild(toggle); + item.appendChild(labelEl); + item.appendChild(content); + + const updateChecked = (val: boolean) => { + item.setAttribute("aria-checked", String(val)); + }; + + const handle = () => { + const next = item.getAttribute("aria-checked") !== "true"; + updateChecked(next); + onChange(next); + }; + item.addEventListener("click", handle); + item.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handle(); + } + }); + + return item; +} + +// --------------------------------------------------------------------------- +// Helper: create a YouTube-styled label item (non-interactive section header) +// --------------------------------------------------------------------------- + +function makeSectionHeader(text: string): HTMLElement { + const el = document.createElement("div"); + el.className = "ytp-menuitem"; + el.style.cssText = + "opacity:0.5;pointer-events:none;font-size:11px;padding-top:8px;"; + const label = document.createElement("div"); + label.className = "ytp-menuitem-label"; + label.textContent = text; + el.appendChild(label); + return el; +} + +// --------------------------------------------------------------------------- +// Helper: create a profile radio item +// --------------------------------------------------------------------------- + +function makeProfileItem( + profile: Profile | null, + isActive: boolean, + onSelect: () => void, +): HTMLElement { + const item = document.createElement("div"); + item.className = "ytp-menuitem"; + item.setAttribute("role", "menuitemradio"); + item.setAttribute("aria-checked", String(isActive)); + item.setAttribute("tabindex", "0"); + + const label = document.createElement("div"); + label.className = "ytp-menuitem-label"; + label.textContent = profile ? profile.name : "Default (no profile)"; + item.appendChild(label); + + const content = document.createElement("div"); + content.className = "ytp-menuitem-content"; + item.appendChild(content); + + item.addEventListener("click", onSelect); + item.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onSelect(); + } + }); + + return item; +} + +// --------------------------------------------------------------------------- +// Build / rebuild the Toppings sub-panel content +// --------------------------------------------------------------------------- + +function buildToppingsPanel( + panelMenu: HTMLElement, + state: PanelState, + onClose: () => void, +): void { + panelMenu.innerHTML = ""; + + // --- Primitives section --- + panelMenu.appendChild(makeSectionHeader("Watch page")); + + panelMenu.appendChild( + makeToggleItem("Recommendations sidebar", state.sidebarVisible, (next) => { + if (next) void setSidebarVisible(true); + else void setSidebarVisible(false); + }), + ); + + panelMenu.appendChild( + makeToggleItem("Comments", state.commentsVisible, (next) => { + if (next) void setCommentsVisible(true); + else void setCommentsVisible(false); + }), + ); + + panelMenu.appendChild( + makeToggleItem("End cards", state.endCardsVisible, (next) => { + if (next) void setEndCardsVisible(true); + else void setEndCardsVisible(false); + }), + ); + + // --- Profile section --- + panelMenu.appendChild(makeSectionHeader("Profile")); + + // "Default" (no profile) option + panelMenu.appendChild( + makeProfileItem(null, state.activeProfileId === null, async () => { + await setActiveProfileId(null); + void applyWatchProfile(); + onClose(); + }), + ); + + for (const profile of state.profiles) { + const isActive = profile.id === state.activeProfileId; + panelMenu.appendChild( + makeProfileItem(profile, isActive, async () => { + const next = isActive ? null : profile.id; + await setActiveProfileId(next); + void applyWatchProfile(); + onClose(); + }), + ); + } +} + +// --------------------------------------------------------------------------- +// Main injection function — call on every gear menu open +// --------------------------------------------------------------------------- + +export async function injectGearMenuEntry( + settingsMenu: HTMLElement, +): Promise { + // Find the main panel and its menu list. + const mainPanel = settingsMenu.querySelector(".ytp-panel"); + if (isNull(mainPanel)) return; + const mainPanelMenu = mainPanel.querySelector(".ytp-panel-menu"); + if (isNull(mainPanelMenu)) return; + + // --- Inject the "Toppings" entry in the main menu (idempotent) --- + let tppngMenuItem = settingsMenu.querySelector( + `#${TPPNG_MENU_ITEM_ID}`, + ) as HTMLElement | null; + + if (!tppngMenuItem) { + tppngMenuItem = document.createElement("div"); + tppngMenuItem.id = TPPNG_MENU_ITEM_ID; + tppngMenuItem.className = "ytp-menuitem"; + tppngMenuItem.setAttribute("role", "menuitem"); + tppngMenuItem.setAttribute("tabindex", "0"); + tppngMenuItem.setAttribute("aria-haspopup", "true"); + + const labelEl = document.createElement("div"); + labelEl.className = "ytp-menuitem-label"; + labelEl.textContent = "Toppings"; + tppngMenuItem.appendChild(labelEl); + + const content = document.createElement("div"); + content.className = "ytp-menuitem-content"; + // Right-arrow indicator like native sub-menu items. + content.innerHTML = + ''; + tppngMenuItem.appendChild(content); + + // Prepend so it appears first in the menu. + mainPanelMenu.prepend(tppngMenuItem); + } + + // --- Create or reuse the Toppings sub-panel --- + let tppngPanel = settingsMenu.querySelector( + `#${TPPNG_PANEL_ID}`, + ) as HTMLElement | null; + + if (!tppngPanel) { + tppngPanel = document.createElement("div"); + tppngPanel.id = TPPNG_PANEL_ID; + tppngPanel.className = "ytp-panel"; + tppngPanel.style.display = "none"; + + // Back button header + const header = document.createElement("div"); + header.className = "ytp-panel-header-back"; + + const backBtn = document.createElement("button"); + backBtn.className = "ytp-panel-back-button"; + backBtn.setAttribute("aria-label", "Back"); + backBtn.innerHTML = + ''; + + const title = document.createElement("span"); + title.className = "ytp-panel-title"; + title.textContent = "Toppings"; + + header.appendChild(backBtn); + header.appendChild(title); + tppngPanel.appendChild(header); + + const panelMenu = document.createElement("div"); + panelMenu.className = "ytp-panel-menu"; + tppngPanel.appendChild(panelMenu); + + settingsMenu.appendChild(tppngPanel); + + // Back button → return to main panel + backBtn.addEventListener("click", () => { + tppngPanel!.style.display = "none"; + (mainPanel as HTMLElement).style.display = ""; + }); + } + + const panelMenu = tppngPanel.querySelector( + ".ytp-panel-menu", + ) as HTMLElement | null; + if (isNull(panelMenu)) return; + + // --- Wire the entry click to show Toppings panel --- + tppngMenuItem.onclick = async () => { + // Read current state. + const [profiles, activeProfile] = await Promise.all([ + getAllProfiles(), + getActiveProfile(), + ]); + + // Snapshot visibility state from DOM (simplest source of truth). + const sidebarEl = document.querySelector( + "#secondary, ytd-watch-next-secondary-results-renderer", + ) as HTMLElement | null; + const commentsEl = document.querySelector( + "ytd-comments#comments, #comments", + ) as HTMLElement | null; + const endCardEl = document.querySelector( + ".ytp-ce-element, .ytp-endscreen-element", + ) as HTMLElement | null; + + const state: PanelState = { + sidebarVisible: !sidebarEl || sidebarEl.style.display !== "none", + commentsVisible: !commentsEl || commentsEl.style.display !== "none", + endCardsVisible: !endCardEl || endCardEl.style.display !== "none", + profiles, + activeProfileId: activeProfile?.id ?? null, + }; + + buildToppingsPanel(panelMenu, state, () => { + // Close: hide sub-panel, show main panel + tppngPanel!.style.display = "none"; + (mainPanel as HTMLElement).style.display = ""; + }); + + // Show sub-panel. + (mainPanel as HTMLElement).style.display = "none"; + tppngPanel!.style.display = ""; + }; +} diff --git a/web-ext/app/content_scripts/components/SegmentButton.tsx b/web-ext/app/content_scripts/components/SegmentButton.tsx new file mode 100644 index 00000000..9f127fd7 --- /dev/null +++ b/web-ext/app/content_scripts/components/SegmentButton.tsx @@ -0,0 +1,87 @@ +import React from "dom-chef"; + +// --------------------------------------------------------------------------- +// SegmentButton — control bar button for the Segments feature +// +// Icon states communicate the feature state clearly without relying on colour: +// Inactive → segment bars are outlined (hollow) — "idle / off" +// Active → segment bars are filled (solid) — "playing segments" +// +// This mirrors how YouTube's own buttons work (CC, subtitles, etc.). +// --------------------------------------------------------------------------- + +export const SegmentButton = ( + +) as HTMLButtonElement; + +/** + * Toggle the visual active/inactive state. + * + * Active → filled segment bars, full opacity, subtle background (YouTube press style) + * Inactive → outlined segment bars, dimmed opacity + */ +export function setSegmentButtonActive(active: boolean): void { + SegmentButton.setAttribute("aria-pressed", active ? "true" : "false"); + + const bars = SegmentButton.querySelectorAll(".tppng-sb-bar"); + if (active) { + SegmentButton.style.opacity = "1"; + SegmentButton.style.background = "rgba(255,255,255,0.15)"; + SegmentButton.style.borderRadius = "4px"; + bars.forEach((r) => { + r.setAttribute("fill", "white"); + r.setAttribute("stroke", "none"); + }); + } else { + SegmentButton.style.opacity = "0.7"; + SegmentButton.style.background = ""; + SegmentButton.style.borderRadius = ""; + bars.forEach((r) => { + r.setAttribute("fill", "none"); + r.setAttribute("stroke", "white"); + }); + } +} + +/** + * Update the button tooltip to show the loaded config name. + * No colour change — state is communicated by fill/outline only. + * Pass null to reset to the default tooltip. + */ +export function setSegmentButtonSaved(configLabel: string | null): void { + SegmentButton.title = configLabel ? `Segments — ${configLabel}` : "Segments"; +} diff --git a/web-ext/app/content_scripts/components/SegmentPanel.tsx b/web-ext/app/content_scripts/components/SegmentPanel.tsx new file mode 100644 index 00000000..188af0a5 --- /dev/null +++ b/web-ext/app/content_scripts/components/SegmentPanel.tsx @@ -0,0 +1,1688 @@ +import React from "dom-chef"; +import type { + SegmentConfig, + Segment, + PlayStep, + SegmentId, + StepId, +} from "../../../utils/segments/types"; +import { + addSegmentToConfig, + removeSegmentFromConfig, + generateConfigLabel, + splitSegmentAtTime, +} from "../../../utils/segments/factories"; +import type { SegmentAutoLoadPin } from "../../../utils/segments/types"; +import { + saveNamedConfig, + deleteNamedConfig, + setDefaultConfig, + setLastUsed, + getSavedConfigs, + getVideoSegmentData, + setAutoLoadPin, + getAutoLoadPin, +} from "../../../utils/storage/segmentStore"; +import { setSegmentButtonSaved } from "./SegmentButton"; + +// --------------------------------------------------------------------------- +// SegmentPanel — below-video control panel +// +// Uses dom-chef (same as rest of content scripts). The panel element is +// created once; re-renders happen imperatively via renderPanel(). +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// Module-level state +// --------------------------------------------------------------------------- + +/** Currently loaded config. watch.tsx reads this to know what's active. */ +export let activeConfig: SegmentConfig | null = null; +let currentVideoId: string | null = null; +let onConfigChangeCallback: ((config: SegmentConfig | null) => void) | null = null; + +/** Cache of saved named configs for this video — updated on save/delete. */ +let savedConfigsCache: SegmentConfig[] = []; + +/** Current per-video auto-load pin. Loaded in setupSegmentPanel. */ +let currentPin: SegmentAutoLoadPin = null; + +/** Which panel view is currently displayed. */ +type ViewMode = "main" | "manage"; +let viewMode: ViewMode = "main"; + +/** Whether the panel body is collapsed (header stays visible). */ +let panelCollapsed = false; + +/** Whether the Advanced Sequence section is expanded. Persists across re-renders. */ +let advSectionOpen = false; + +// --------------------------------------------------------------------------- +// Single global click-away handler — registered once (no accumulation) +// --------------------------------------------------------------------------- + +let openMenuEl: HTMLElement | null = null; + +function closeOpenMenu(): void { + if (openMenuEl) { + openMenuEl.style.display = "none"; + openMenuEl = null; + } +} + +document.addEventListener("click", closeOpenMenu); + +// --------------------------------------------------------------------------- +// Drag-to-reorder state for sequence chips +// --------------------------------------------------------------------------- + +let dragSrcStepId: StepId | null = null; +let dragSrcIdx = -1; + +// --------------------------------------------------------------------------- +// Public callbacks +// --------------------------------------------------------------------------- + +export function setOnConfigChange( + cb: (config: SegmentConfig | null) => void, +): void { + onConfigChangeCallback = cb; +} + +export function getCachedNamedConfigs(): SegmentConfig[] { + return savedConfigsCache; +} + +export async function refreshNamedConfigsCache(): Promise { + if (!currentVideoId) return; + savedConfigsCache = await getSavedConfigs(currentVideoId); +} + +function emitConfigChange(config: SegmentConfig | null): void { + activeConfig = config; + onConfigChangeCallback?.(config); +} + +// --------------------------------------------------------------------------- +// Panel DOM element (singleton, created once) +// --------------------------------------------------------------------------- + +const panelId = "tppng-segment-panel"; + +export const SegmentPanel: HTMLElement = ( +
+
+
+) as HTMLElement; + +// --------------------------------------------------------------------------- +// Public lifecycle +// --------------------------------------------------------------------------- + +export async function setupSegmentPanel(videoId: string): Promise { + currentVideoId = videoId; + viewMode = "main"; + [savedConfigsCache, currentPin] = await Promise.all([ + getSavedConfigs(videoId), + getAutoLoadPin(videoId), + ]); +} + +export function showSegmentPanel(): void { + SegmentPanel.style.display = "block"; + if (activeConfig) renderPanel(activeConfig); +} + +export function hideSegmentPanel(): void { + SegmentPanel.style.display = "none"; +} + +export function renderSegmentPanel(config: SegmentConfig): void { + activeConfig = config; + if (SegmentPanel.style.display !== "none") { + renderPanel(config); + } +} + +// --------------------------------------------------------------------------- +// Core render dispatcher +// --------------------------------------------------------------------------- + +function renderPanel(config: SegmentConfig): void { + const inner = document.getElementById("tppng-sp-inner"); + if (!inner) return; + inner.innerHTML = ""; + if (viewMode === "manage") { + inner.appendChild(buildConfigManager(config)); + } else { + inner.appendChild(buildMainView(config)); + } +} + +// --------------------------------------------------------------------------- +// Color helpers +// --------------------------------------------------------------------------- + +// First color is YouTube's red so the primary segment feels native. +const SEGMENT_COLORS = [ + "#ff3333", "#3ea6ff", "#4caf50", "#ff9800", + "#9c27b0", "#00bcd4", "#f5c518", +]; + +function colorForIndex(i: number): string { + return SEGMENT_COLORS[i % SEGMENT_COLORS.length]; +} + +function fmtTime(seconds: number): string { + const s = Math.floor(seconds); + const m = Math.floor(s / 60); + const h = Math.floor(m / 60); + const ss = String(s % 60).padStart(2, "0"); + const mm = String(m % 60).padStart(2, "0"); + if (h > 0) return `${h}:${mm}:${ss}`; + return `${m}:${ss}`; +} + +// --------------------------------------------------------------------------- +// Main view +// --------------------------------------------------------------------------- + +function buildMainView(config: SegmentConfig): HTMLElement { + return ( +
+ {buildHeader(config)} +
+ {buildSegmentList(config)} + {buildAdvancedSection(config)} +
+
+ ) as HTMLElement; +} + +// --------------------------------------------------------------------------- +// Header — config switcher + save menu + close +// --------------------------------------------------------------------------- + +function buildHeader(config: SegmentConfig): HTMLElement { + return ( +
+ {/* Title */} + + Segments + + + {/* Config switcher dropdown */} + {buildConfigSwitcher(config)} + +
+ + {/* Per-video auto-load pin */} + {buildPinButton(config)} + + {/* Save menu */} + {buildSaveMenu(config)} + + {/* Manage configs button */} + + + {/* Collapse button */} + +
+ ) as HTMLElement; +} + +function buildConfigSwitcher(config: SegmentConfig): HTMLElement { + const container = (
) as HTMLElement; + + const trigger = ( + + ) as HTMLButtonElement; + + const menu = ( +
+ ) as HTMLElement; + + trigger.addEventListener("click", (e) => { + e.stopPropagation(); + if (openMenuEl === menu) { + closeOpenMenu(); + return; + } + closeOpenMenu(); + + // Rebuild menu contents from cache (sync) + menu.innerHTML = ""; + + if (savedConfigsCache.length === 0) { + const empty = ( +
+ No saved configs +
+ ) as HTMLElement; + menu.appendChild(empty); + } else { + for (const saved of savedConfigsCache) { + const isActive = saved.id === config.id; + const item = ( + + ) as HTMLButtonElement; + menu.appendChild(item); + } + } + + menu.style.display = "block"; + openMenuEl = menu; + }); + + container.appendChild(trigger); + container.appendChild(menu); + return container; +} + +async function loadSavedConfig(config: SegmentConfig): Promise { + if (!currentVideoId) return; + emitConfigChange(config); + renderPanel(config); + setSegmentButtonSaved(config.label); + void setLastUsed(currentVideoId, config); +} + +// --------------------------------------------------------------------------- +// Per-video auto-load pin button +// --------------------------------------------------------------------------- + +/** Label + title for each pin state. */ +const PIN_LABELS: Record = { + null: { icon: "○", title: "Auto-load: use global setting — click to pin" }, + off: { icon: "⊗", title: "Auto-load: always OFF for this video — click to change" }, + "last-used":{ icon: "↺", title: "Auto-load: restore last-used — click to change" }, + default: { icon: "★", title: "Auto-load: restore default saved config — click to change" }, +}; + +function pinKey(pin: SegmentAutoLoadPin): string { + if (pin === null) return "null"; + if (typeof pin === "string") return pin; + return "specific"; +} + +function buildPinButton(config: SegmentConfig): HTMLElement { + const container = (
) as HTMLElement; + + const key = pinKey(currentPin); + const info = PIN_LABELS[key] ?? { icon: "📌", title: "Auto-load pin — click to change" }; + + const trigger = ( + + ) as HTMLButtonElement; + + const menu = ( +
+ {/* Header */} +
+ Auto-load on page open +
+ + {([ + [null, "○", "Use global setting"], + ["off", "⊗", "Always off for this video"], + ["last-used", "↺", "Restore last-used"], + ["default", "★", "Restore default saved config"], + ] as const).map(([pinVal, icon, label]) => { + const isActive = JSON.stringify(currentPin) === JSON.stringify(pinVal); + return ( + + ) as HTMLButtonElement; + })} + + {/* Specific config pins */} + {savedConfigsCache.length > 0 && ( +
+ {savedConfigsCache.map((saved) => { + const isActive = + typeof currentPin === "object" && + currentPin !== null && + currentPin.configId === saved.id; + return ( + + ) as HTMLButtonElement; + })} +
+ )} +
+ ) as HTMLElement; + + trigger.addEventListener("click", (e) => { + e.stopPropagation(); + if (openMenuEl === menu) { + closeOpenMenu(); + return; + } + closeOpenMenu(); + menu.style.display = "block"; + openMenuEl = menu; + }); + + container.appendChild(trigger); + container.appendChild(menu); + return container; +} + +// --------------------------------------------------------------------------- +// Save menu +// --------------------------------------------------------------------------- + +function buildSaveMenu(config: SegmentConfig): HTMLElement { + const container = (
) as HTMLElement; + + const trigger = ( + + ) as HTMLButtonElement; + + const menu = ( +
+ + +
+ ) as HTMLElement; + + trigger.addEventListener("click", (e) => { + e.stopPropagation(); + if (openMenuEl === menu) { + closeOpenMenu(); + return; + } + closeOpenMenu(); + menu.style.display = "block"; + openMenuEl = menu; + }); + + container.appendChild(trigger); + container.appendChild(menu); + return container; +} + +// --------------------------------------------------------------------------- +// Inline save-name form (replaces window.prompt) +// --------------------------------------------------------------------------- + +function showSaveNameForm(config: SegmentConfig): void { + const header = document.getElementById("tppng-sp-header"); + if (!header) return; + + // Remove any existing form. + document.getElementById("tppng-sp-save-form")?.remove(); + + const defaultLabel = generateConfigLabel(savedConfigsCache.length); + + const form = ( +
+ { + if (e.key === "Enter") { + e.preventDefault(); + void doSaveNamed(config, (e.target as HTMLInputElement).value); + } + if (e.key === "Escape") { + document.getElementById("tppng-sp-save-form")?.remove(); + } + }} + /> + + +
+ ) as HTMLElement; + + header.after(form); + + // Auto-select the text for immediate renaming. + requestAnimationFrame(() => { + const inp = document.getElementById("tppng-sp-save-input") as HTMLInputElement | null; + inp?.select(); + }); +} + +async function doSaveNamed(config: SegmentConfig, rawLabel: string): Promise { + const label = rawLabel.trim(); + if (!label || !currentVideoId) return; + const named: SegmentConfig = { ...config, label, updatedAt: Date.now() }; + await saveNamedConfig(currentVideoId, named); + savedConfigsCache = await getSavedConfigs(currentVideoId); + document.getElementById("tppng-sp-save-form")?.remove(); + emitConfigChange(named); + renderPanel(named); + setSegmentButtonSaved(label); + showToast(`Saved as "${label}" ✓`); +} + +// --------------------------------------------------------------------------- +// Segment list +// --------------------------------------------------------------------------- + +function buildSegmentList(config: SegmentConfig): HTMLElement { + const list = (
) as HTMLElement; + + const sorted = [...config.segments].sort((a, b) => a.startTime - b.startTime); + sorted.forEach((seg, i) => { + list.appendChild(buildSegmentRow(config, seg, i)); + }); + + const addBtn = ( + + ) as HTMLButtonElement; + list.appendChild(addBtn); + + return list; +} + +function buildSegmentRow( + config: SegmentConfig, + seg: Segment, + index: number, +): HTMLElement { + const color = colorForIndex(index); + + // Loop count and rate are step-level properties, not per-segment. + // Only expose them here in simple single-step mode where the mapping is + // unambiguous. In multi-step configs the step editor in Advanced Sequence + // is the correct place to configure them. + const isSimpleMode = config.sequence.length === 1; + const step = isSimpleMode ? config.sequence[0] : null; + const loopCount = step?.count ?? 0; + const rate = step?.playbackRate ?? null; + + return ( +
+ {/* Segment number */} + + {index + 1} + + + {/* Time range */} + + {fmtTime(seg.startTime)} → {fmtTime(seg.endTime)} + + + {/* Optional user label */} + {seg.label && ( + + {seg.label} + + )} + + {/* Loop count — simple mode only (single step) */} + {isSimpleMode && ( + + )} + + {/* Playback rate — simple mode only (single step) */} + {isSimpleMode && ( + + )} + + {/* Delete */} + +
+ ) as HTMLElement; +} + +// --------------------------------------------------------------------------- +// Advanced sequence section +// --------------------------------------------------------------------------- + +function buildAdvancedSection(config: SegmentConfig): HTMLElement { + const wrapper = (
) as HTMLElement; + + const toggle = ( + + ) as HTMLButtonElement; + + const body = ( +
+ {buildSequenceEditor(config)} +
+ ) as HTMLElement; + + toggle.addEventListener("click", () => { + advSectionOpen = !advSectionOpen; + body.style.display = advSectionOpen ? "block" : "none"; + toggle.textContent = (advSectionOpen ? "▼" : "▶") + " Advanced Sequence"; + }); + + wrapper.appendChild(toggle); + wrapper.appendChild(body); + return wrapper; +} + +function buildSequenceEditor(config: SegmentConfig): HTMLElement { + const list = (
) as HTMLElement; + + config.sequence.forEach((step, i) => { + list.appendChild(buildStepRow(config, step, i)); + }); + + const addBtn = ( + + ) as HTMLButtonElement; + list.appendChild(addBtn); + + return list; +} + +function buildStepRow( + config: SegmentConfig, + step: PlayStep, + stepIndex: number, +): HTMLElement { + const sortedSegs = [...config.segments].sort((a, b) => a.startTime - b.startTime); + + // Chips for each segment reference in this step (with drag-to-reorder) + const chipsContainer = (
) as HTMLElement; + + step.segmentIds.forEach((segId, chipIdx) => { + const seg = config.segments.find((s) => s.id === segId); + const sortedIdx = seg ? sortedSegs.indexOf(seg) : -1; + const label = sortedIdx >= 0 ? `Seg ${sortedIdx + 1}` : "?"; + const color = sortedIdx >= 0 ? colorForIndex(sortedIdx) : "#666"; + + const chip = ( + + ⠿ + {label} + + + ) as HTMLElement; + + // HTML5 drag-to-reorder handlers + chip.addEventListener("dragstart", (e: DragEvent) => { + dragSrcStepId = step.id; + dragSrcIdx = chipIdx; + e.dataTransfer?.setData("text/plain", String(chipIdx)); + (e.currentTarget as HTMLElement).style.opacity = "0.5"; + }); + chip.addEventListener("dragend", (e: DragEvent) => { + (e.currentTarget as HTMLElement).style.opacity = "1"; + }); + chip.addEventListener("dragover", (e: DragEvent) => { + e.preventDefault(); + (e.currentTarget as HTMLElement).style.outline = `2px solid ${color}`; + }); + chip.addEventListener("dragleave", (e: DragEvent) => { + (e.currentTarget as HTMLElement).style.outline = ""; + }); + chip.addEventListener("drop", (e: DragEvent) => { + e.preventDefault(); + (e.currentTarget as HTMLElement).style.outline = ""; + if (dragSrcStepId !== step.id || dragSrcIdx === chipIdx || dragSrcIdx < 0) return; + reorderChip(config, step.id, dragSrcIdx, chipIdx); + }); + + chipsContainer.appendChild(chip); + }); + + // Segment picker to add refs to this step + const addSegSelect = ( + + ) as HTMLSelectElement; + + return ( +
+ {/* Step header: number, chips, add-seg picker, delete */} +
+ + Step {stepIndex + 1} + + {chipsContainer} + {addSegSelect} + +
+ + {/* Step controls: count, rate, per-iteration rates */} +
+ + + + + +
+
+ ) as HTMLElement; +} + +// --------------------------------------------------------------------------- +// Config Manager view +// --------------------------------------------------------------------------- + +function buildConfigManager(currentCfg: SegmentConfig): HTMLElement { + const wrapper = (
) as HTMLElement; + + // Header + const header = ( +
+ + Saved Configs +
+ ) as HTMLElement; + wrapper.appendChild(header); + + if (savedConfigsCache.length === 0) { + const empty = ( +
+ No saved configs yet. Use Save ▾ → Save as Named Config to create one. +
+ ) as HTMLElement; + wrapper.appendChild(empty); + return wrapper; + } + + const list = (
) as HTMLElement; + + void (async () => { + const data = await getVideoSegmentData(currentVideoId ?? ""); + const defaultId = data?.defaultConfigId ?? null; + + for (const saved of savedConfigsCache) { + const isDefault = saved.id === defaultId; + const isLoaded = saved.id === currentCfg.id; + + // Rename input state + let isRenaming = false; + + const row = (
) as HTMLElement; + + const renderRow = () => { + row.innerHTML = ""; + row.appendChild(buildManagerRow(saved, isDefault, isLoaded, isRenaming, { + onLoad: () => { viewMode = "main"; void loadSavedConfig(saved); }, + onSetDefault: async () => { + if (!currentVideoId) return; + await setDefaultConfig(currentVideoId, saved.id); + savedConfigsCache = await getSavedConfigs(currentVideoId); + renderPanel(currentCfg); + }, + onDelete: async () => { + if (!currentVideoId) return; + await deleteNamedConfig(currentVideoId, saved.id); + savedConfigsCache = await getSavedConfigs(currentVideoId); + renderPanel(currentCfg); + }, + onStartRename: () => { isRenaming = true; renderRow(); }, + onRename: async (label: string) => { + if (!label.trim() || !currentVideoId) return; + const updated = { ...saved, label: label.trim(), updatedAt: Date.now() }; + await saveNamedConfig(currentVideoId, updated); + savedConfigsCache = await getSavedConfigs(currentVideoId); + isRenaming = false; + renderPanel(currentCfg); + }, + onCancelRename: () => { isRenaming = false; renderRow(); }, + onShortcutChange: async (key: string) => { + if (!currentVideoId) return; + const updated = { ...saved, shortcutKey: key, updatedAt: Date.now() }; + await saveNamedConfig(currentVideoId, updated); + savedConfigsCache = await getSavedConfigs(currentVideoId); + }, + })); + }; + + renderRow(); + list.appendChild(row); + } + + wrapper.appendChild(list); + })(); + + return wrapper; +} + +interface ManagerRowCallbacks { + onLoad: () => void; + onSetDefault: () => Promise; + onDelete: () => Promise; + onStartRename: () => void; + onRename: (label: string) => Promise; + onCancelRename: () => void; + onShortcutChange: (key: string) => Promise; +} + +function buildManagerRow( + config: SegmentConfig, + isDefault: boolean, + isLoaded: boolean, + isRenaming: boolean, + cbs: ManagerRowCallbacks, +): HTMLElement { + if (isRenaming) { + const inp = ( + { + if (e.key === "Enter") void cbs.onRename((e.target as HTMLInputElement).value); + if (e.key === "Escape") cbs.onCancelRename(); + }} + /> + ) as HTMLInputElement; + + const row = ( +
+ {inp} + + +
+ ) as HTMLElement; + + requestAnimationFrame(() => inp.select()); + return row; + } + + return ( +
+ {/* Config label (clickable to load) */} + + + {/* Default badge */} + {isDefault && ( + + default + + )} + + {/* Shortcut key input */} + { + void cbs.onShortcutChange((e.target as HTMLInputElement).value.trim()); + }} + onKeyDown={(e: KeyboardEvent) => e.stopPropagation()} + /> + + {/* Set as default */} + {!isDefault && ( + + )} + + {/* Rename */} + + + {/* Delete */} + +
+ ) as HTMLElement; +} + +// --------------------------------------------------------------------------- +// Mutation helpers — each produces an updated config and re-renders +// --------------------------------------------------------------------------- + +function mutateConfig(updater: (c: SegmentConfig) => SegmentConfig): void { + if (!activeConfig) return; + const next = updater(activeConfig); + emitConfigChange(next); + renderPanel(next); + if (currentVideoId) void setLastUsed(currentVideoId, next); +} + +function addNewSegment(config: SegmentConfig): void { + const video = document.querySelector("video") as HTMLVideoElement | null; + if (!video) return; + const dur = video.duration || 100; + const currentTime = video.currentTime; + + // Primary: split the segment at the current playhead. + // Left half → [original start, playhead] + // Right half → [playhead, original end] + const sorted = [...config.segments].sort((a, b) => a.startTime - b.startTime); + const segAtPlayhead = sorted.find( + (s) => currentTime > s.startTime + 0.1 && currentTime < s.endTime - 0.1, + ); + if (segAtPlayhead) { + const split = splitSegmentAtTime(config, segAtPlayhead.id, currentTime); + if (split) { + mutateConfig(() => split); + return; + } + } + + // Fallback: no segment at playhead — append after the last segment. + const lastSeg = sorted[sorted.length - 1]; + const start = lastSeg ? Math.min(lastSeg.endTime, dur - 2) : 0; + const end = Math.min(start + Math.min(30, (dur - start) * 0.5), dur); + mutateConfig((c) => addSegmentToConfig(c, Math.max(0, start), Math.max(start + 1, end))); +} + +function removeSegment(config: SegmentConfig, segId: SegmentId): void { + if (config.segments.length <= 1) return; // keep at least one + mutateConfig((c) => removeSegmentFromConfig(c, segId)); +} + +function addStep(config: SegmentConfig): void { + const firstSegId = config.segments[0]?.id; + if (!firstSegId) return; + const newStep: PlayStep = { + id: crypto.randomUUID(), + segmentIds: [firstSegId], + count: 1, + playbackRate: null, + perIterationRates: [], + }; + mutateConfig((_c) => ({ + ...config, + sequence: [...config.sequence, newStep], + updatedAt: Date.now(), + })); +} + +function removeStep(_config: SegmentConfig, stepId: StepId): void { + mutateConfig((c) => { + if (c.sequence.length <= 1) return c; // keep at least one step + return { ...c, sequence: c.sequence.filter((s) => s.id !== stepId), updatedAt: Date.now() }; + }); +} + +function addSegRefToStep( + _config: SegmentConfig, + stepId: StepId, + segId: SegmentId, +): void { + mutateConfig((c) => ({ + ...c, + sequence: c.sequence.map((s) => + s.id === stepId ? { ...s, segmentIds: [...s.segmentIds, segId] } : s, + ), + updatedAt: Date.now(), + })); +} + +function removeSegRefFromStep( + _config: SegmentConfig, + stepId: StepId, + chipIndex: number, +): void { + mutateConfig((c) => ({ + ...c, + sequence: c.sequence.map((s) => { + if (s.id !== stepId) return s; + const newIds = s.segmentIds.filter((_, i) => i !== chipIndex); + return newIds.length === 0 ? s : { ...s, segmentIds: newIds }; + }), + updatedAt: Date.now(), + })); +} + +function reorderChip( + _config: SegmentConfig, + stepId: StepId, + fromIdx: number, + toIdx: number, +): void { + mutateConfig((c) => ({ + ...c, + sequence: c.sequence.map((s) => { + if (s.id !== stepId) return s; + const ids = [...s.segmentIds]; + const [removed] = ids.splice(fromIdx, 1); + ids.splice(toIdx, 0, removed); + return { ...s, segmentIds: ids }; + }), + updatedAt: Date.now(), + })); + dragSrcStepId = null; + dragSrcIdx = -1; +} + +function updateStepCount( + _config: SegmentConfig, + stepId: StepId, + count: number, +): void { + mutateConfig((c) => ({ + ...c, + sequence: c.sequence.map((s) => s.id === stepId ? { ...s, count } : s), + updatedAt: Date.now(), + })); +} + +function updateStepRate( + _config: SegmentConfig, + stepId: StepId, + rate: number | null, +): void { + mutateConfig((c) => ({ + ...c, + sequence: c.sequence.map((s) => + s.id === stepId ? { ...s, playbackRate: rate } : s, + ), + updatedAt: Date.now(), + })); +} + +function updateStepPerIterationRates( + _config: SegmentConfig, + stepId: StepId, + rates: number[], +): void { + mutateConfig((c) => ({ + ...c, + sequence: c.sequence.map((s) => + s.id === stepId ? { ...s, perIterationRates: rates } : s, + ), + updatedAt: Date.now(), + })); +} + +// --------------------------------------------------------------------------- +// Save actions +// --------------------------------------------------------------------------- + +async function saveAsDefault(config: SegmentConfig): Promise { + if (!currentVideoId) return; + const named = { ...config, updatedAt: Date.now() }; + await saveNamedConfig(currentVideoId, named); + await setDefaultConfig(currentVideoId, named.id); + savedConfigsCache = await getSavedConfigs(currentVideoId); + setSegmentButtonSaved(named.label); + showToast("Saved to default slot ✓"); +} + +// --------------------------------------------------------------------------- +// Toast — uses YouTube's own paper-toast when available +// --------------------------------------------------------------------------- + +function showToast(msg: string): void { + // Try to use YouTube's own notification toast first + const ytToast = document.querySelector("yt-notification-action-renderer") as HTMLElement | null; + if (ytToast) { + // Fall through to our own; YT's is not easily programmable + } + + const existing = document.getElementById("tppng-panel-toast"); + if (existing) existing.remove(); + + const toast = ( +
+ {msg} +
+ ) as HTMLElement; + + document.body.appendChild(toast); + setTimeout(() => toast.remove(), 2500); +} + +// --------------------------------------------------------------------------- +// Shared style helpers — YouTube-native variables with dark-mode fallbacks +// --------------------------------------------------------------------------- + +type BtnVariant = "accent" | "ghost"; + +function btnStyle(variant: BtnVariant): Record { + const base: Record = { + cursor: "pointer", + border: "none", + borderRadius: "4px", + padding: "3px 8px", + fontSize: "12px", + fontFamily: "inherit", + lineHeight: "1.4", + transition: "opacity 0.1s", + }; + if (variant === "accent") { + return { + ...base, + background: "var(--yt-spec-call-to-action, #3ea6ff)", + color: "var(--yt-spec-brand-button-text, #fff)", + }; + } + return { + ...base, + background: "transparent", + color: "var(--yt-spec-text-secondary, rgba(255,255,255,0.6))", + }; +} + +function menuItemStyle(): Record { + return { + display: "block", + width: "100%", + textAlign: "left", + cursor: "pointer", + border: "none", + borderRadius: "4px", + padding: "7px 10px", + fontSize: "12px", + fontFamily: "inherit", + background: "transparent", + color: "var(--yt-spec-text-primary, #fff)", + }; +} + +function numInputStyle(): Record { + return { + width: "42px", + background: "var(--yt-spec-10-percent-layer, rgba(255,255,255,0.08))", + border: "1px solid var(--yt-spec-10-percent-layer, rgba(255,255,255,0.15))", + borderRadius: "4px", + color: "var(--yt-spec-text-primary, #fff)", + fontSize: "11px", + padding: "2px 4px", + textAlign: "right", + fontFamily: "inherit", + }; +} + +// Export mutation helper needed by watch.tsx +export { mutateConfig }; diff --git a/web-ext/app/content_scripts/components/Segments.tsx b/web-ext/app/content_scripts/components/Segments.tsx new file mode 100644 index 00000000..212869c9 --- /dev/null +++ b/web-ext/app/content_scripts/components/Segments.tsx @@ -0,0 +1,485 @@ +import elementReady from "element-ready"; +import { SegmentEngine } from "../../../utils/segments/engine"; +import { SegmentMarkersController } from "../../../utils/segments/markers"; +import { createFreshConfig, removeSegmentFromConfig, updateSegmentTimes } from "../../../utils/segments/factories"; +import { + getLastUsed, + setLastUsed, + getAutoloadConfig, +} from "../../../utils/storage/segmentStore"; +import type { SegmentConfig, SegmentId } from "../../../utils/segments/types"; +import { + SegmentPanel, + setupSegmentPanel, + showSegmentPanel, + hideSegmentPanel, + renderSegmentPanel, + setOnConfigChange, + activeConfig, + refreshNamedConfigsCache, + getCachedNamedConfigs, +} from "./SegmentPanel"; +import { + SegmentButton, + setSegmentButtonActive, + setSegmentButtonSaved, +} from "./SegmentButton"; +import { showPageToast } from "../utils/pageToast"; + +// --------------------------------------------------------------------------- +// Module-level state +// --------------------------------------------------------------------------- + +let engine: SegmentEngine | null = null; +let markersCtrl: SegmentMarkersController | null = null; +let currentVideoId: string | null = null; +let video: HTMLVideoElement | null = null; +let isActive = false; + +// Track the current button click handler so SPA navigation doesn't stack listeners. +let segButtonClickHandler: (() => void) | null = null; + +/** + * Holds the latest config produced by dragging a marker. + * We do NOT call applyConfig during drag (that would destroy marker DOM), + * so we buffer here and flush in the onDragEnd handler. + */ +let latestDragConfig: SegmentConfig | null = null; + +// Nudge acceleration state +let lastNudgeMarker: "start" | "end" | null = null; +let lastNudgeDirection: "forward" | "backward" | null = null; +let currentNudgeStep = 1; +let lastNudgeTime = 0; +const NUDGE_RESET_GAP_MS = 600; + +// --------------------------------------------------------------------------- +// Public re-exports +// --------------------------------------------------------------------------- + +export { SegmentButton, SegmentPanel, getCachedNamedConfigs, refreshNamedConfigsCache }; + +// --------------------------------------------------------------------------- +// Setup +// --------------------------------------------------------------------------- + +/** + * Initialise the segments system for a new watch page. + * + * @param videoId The YouTube video ID. + * @param autoLoad Global auto-load preference. Per-video pins override this. + * Defaults to "off" so segments never auto-enable unless + * the user has configured otherwise. + */ +export async function setupSegments( + videoId?: string, + autoLoad: "off" | "last-used" | "default" = "off", +): Promise { + currentVideoId = videoId ?? null; + video = (await elementReady("video")) as HTMLVideoElement | null; + + // Tear down any previous session. + teardown(); + + if (!currentVideoId || !video) return; + + await setupSegmentPanel(currentVideoId); + + // Wire panel config-change → engine + markers update. + // This fires when the user changes something via the panel UI. + setOnConfigChange((config) => { + if (config) { + // Do NOT rebuild markers here — applyConfig triggers a full marker rebuild + // which would interfere if the panel change happens during drag. + // The panel itself is already re-rendered by the mutation helpers. + applyConfig(config, /* skipMarkerRebuild */ false); + } + }); + + // Button click → toggle. Remove previous handler first so SPA navigation + // doesn't accumulate duplicate listeners (the button element is a singleton). + if (segButtonClickHandler) { + SegmentButton.removeEventListener("click", segButtonClickHandler); + } + segButtonClickHandler = () => void toggleSegmentsLastUsed(); + SegmentButton.addEventListener("click", segButtonClickHandler); + + // Restore saved state once video metadata is available. + const restore = async () => { + if (!video || !currentVideoId) return; + const dur = video.duration || 0; + + // getAutoloadConfig returns null when the effective setting is "off" + // or no config exists for the chosen mode. + const config = await getAutoloadConfig(currentVideoId, dur, autoLoad); + if (!config) return; // auto-load is disabled; wait for user to press Z + + const clamped = clampConfigToDuration(config, dur); + enableSegments(clamped, /* showToast */ true); + }; + + if (video.readyState >= 1 && video.duration) { + await restore(); + } else { + video.addEventListener("loadedmetadata", () => void restore(), { once: true }); + } +} + +// --------------------------------------------------------------------------- +// Enable / disable +// --------------------------------------------------------------------------- + +function enableSegments(config: SegmentConfig, toast = false): void { + if (!video) return; + + if (engine) engine.stop(); + engine = new SegmentEngine(video, config); + engine.start(); + + if (markersCtrl) { + markersCtrl.setSegments(config.segments); + markersCtrl.showAll(); + } + + renderSegmentPanel(config); + showSegmentPanel(); + setSegmentButtonActive(true); + setSegmentButtonSaved(config.label !== "Default" ? config.label : null); + + isActive = true; + + if (toast) { + showPageToast("↺ Segments restored"); + } +} + +function disableSegments(): void { + if (engine) { + engine.stop(); + engine = null; + } + if (markersCtrl) { + markersCtrl.hideAll(); + } + hideSegmentPanel(); + setSegmentButtonActive(false); + setSegmentButtonSaved(null); + isActive = false; + + if (currentVideoId && activeConfig) { + void setLastUsed(currentVideoId, activeConfig); + } +} + +/** + * Apply a config update. + * + * @param skipMarkerRebuild When true, the engine is updated but markers are + * NOT rebuilt. Pass true during drag operations so + * the marker DOM elements aren't destroyed mid-drag. + */ +function applyConfig(config: SegmentConfig, skipMarkerRebuild = false): void { + if (!video) return; + if (engine) { + engine.setConfig(config); + } else { + engine = new SegmentEngine(video, config); + if (isActive) engine.start(); + } + if (markersCtrl && !skipMarkerRebuild) { + markersCtrl.setSegments(config.segments); + if (isActive) markersCtrl.showAll(); + } +} + +function teardown(): void { + if (engine) { + engine.stop(); + engine = null; + } + if (markersCtrl) { + markersCtrl.destroy(); + markersCtrl = null; + } + latestDragConfig = null; + isActive = false; + setSegmentButtonActive(false); + setSegmentButtonSaved(null); + hideSegmentPanel(); +} + +// --------------------------------------------------------------------------- +// Marker controller wiring (called from watch.tsx after DOM is ready) +// --------------------------------------------------------------------------- + +export function initMarkers( + progressBarContainer: HTMLElement, + videoEl: HTMLVideoElement, +): void { + if (markersCtrl) markersCtrl.destroy(); + markersCtrl = new SegmentMarkersController(progressBarContainer, videoEl); + + // During drag: update only the engine — no marker rebuild, no panel re-render. + // Buffering the latest config in `latestDragConfig` for the onDragEnd flush. + markersCtrl.onSegmentsChanged((segs) => { + if (!activeConfig) return; + const updated: SegmentConfig = { + ...activeConfig, + segments: segs, + updatedAt: Date.now(), + }; + // Engine update only — skipMarkerRebuild = true prevents destroying markers + // while the user is still dragging them. + applyConfig(updated, /* skipMarkerRebuild */ true); + latestDragConfig = updated; + }); + + // After drag ends: flush — rebuild markers with final positions, update panel, + // and persist the last-used snapshot. + markersCtrl.onDragEnd(() => { + const cfg = latestDragConfig; + latestDragConfig = null; + if (!cfg) return; + // Full applyConfig: marker rebuild now safe (drag is done). + applyConfig(cfg, /* skipMarkerRebuild */ false); + renderSegmentPanel(cfg); + if (currentVideoId) void setLastUsed(currentVideoId, cfg); + }); + + markersCtrl.onMergeRequest((keepId, removeId) => { + if (!activeConfig) return; + const keepSeg = activeConfig.segments.find((s) => s.id === keepId); + const removeSeg = activeConfig.segments.find((s) => s.id === removeId); + if (!keepSeg || !removeSeg) return; + + let merged = updateSegmentTimes( + activeConfig, + keepId, + keepSeg.startTime, + removeSeg.endTime, + ); + merged = removeSegmentFromConfig(merged, removeId); + + applyConfig(merged); + renderSegmentPanel(merged); + if (currentVideoId) void setLastUsed(currentVideoId, merged); + showPageToast("Segments merged"); + }); + + if (activeConfig && isActive) { + markersCtrl.setSegments(activeConfig.segments); + markersCtrl.showAll(); + } +} + +// --------------------------------------------------------------------------- +// Keyboard shortcut handlers (called from watch.tsx useShortcuts) +// --------------------------------------------------------------------------- + +/** + * Z key: if active → toggle off; if inactive → load last-used (or fresh). + */ +export async function toggleSegmentsLastUsed(): Promise { + if (isActive) { + disableSegments(); + return; + } + + if (!video) return; + const dur = video.duration || 0; + + let config: SegmentConfig | null = null; + if (currentVideoId) { + config = await getLastUsed(currentVideoId); + } + if (!config) { + config = createFreshConfig(dur); + } + + enableSegments(config); +} + +/** + * Shift+Z: always start a fresh slate (full video, 1 segment, infinite loop). + */ +export function activateFreshSegments(): void { + if (!video) return; + const dur = video.duration || 0; + const fresh = createFreshConfig(dur); + enableSegments(fresh); + showPageToast("Fresh segments slate"); +} + +/** + * Q key: set the start boundary of the active segment to the current playhead. + */ +export function setActiveSegmentStart(): void { + if (!isActive || !video || !engine || !activeConfig) return; + const seg = engine.getSegmentAt(video.currentTime); + if (!seg) return; + + const newStart = Math.max(0, Math.min(video.currentTime, seg.endTime - 0.1)); + const updated = updateSegmentTimes(activeConfig, seg.id, newStart, seg.endTime); + applyConfig(updated); + renderSegmentPanel(updated); + if (markersCtrl) markersCtrl.updateSegmentPosition(seg.id, "start", newStart); + if (currentVideoId) void setLastUsed(currentVideoId, updated); +} + +/** + * E key: set the end boundary of the active segment to the current playhead. + */ +export function setActiveSegmentEnd(): void { + if (!isActive || !video || !engine || !activeConfig) return; + const seg = engine.getSegmentAt(video.currentTime); + if (!seg) return; + + const dur = video.duration || 0; + const newEnd = Math.max(seg.startTime + 0.1, Math.min(video.currentTime, dur)); + const updated = updateSegmentTimes(activeConfig, seg.id, seg.startTime, newEnd); + applyConfig(updated); + renderSegmentPanel(updated); + if (markersCtrl) markersCtrl.updateSegmentPosition(seg.id, "end", newEnd); + if (currentVideoId) void setLastUsed(currentVideoId, updated); +} + +// --------------------------------------------------------------------------- +// Nudge — accelerating marker movement +// --------------------------------------------------------------------------- + +function computeNudgeStep( + marker: "start" | "end", + direction: "forward" | "backward", + baseStep: number, + multiplier: number, + maxStep: number, +): number { + const now = Date.now(); + const isSame = + lastNudgeMarker === marker && + lastNudgeDirection === direction && + now - lastNudgeTime < NUDGE_RESET_GAP_MS; + + const step = isSame + ? Math.min(currentNudgeStep * multiplier, maxStep) + : baseStep; + + lastNudgeMarker = marker; + lastNudgeDirection = direction; + currentNudgeStep = step; + lastNudgeTime = now; + return step; +} + +export function nudgeActiveSegmentStart( + direction: "forward" | "backward", + baseStep: number, + multiplier: number, + maxStep: number, +): void { + if (!isActive || !video || !engine || !activeConfig) return; + const seg = engine.getSegmentAt(video.currentTime); + if (!seg) return; + + const step = computeNudgeStep("start", direction, baseStep, multiplier, maxStep); + if (!video.duration) return; + + const newStart = + direction === "forward" + ? Math.min(seg.startTime + step, seg.endTime - 0.1) + : Math.max(0, seg.startTime - step); + + const updated = updateSegmentTimes(activeConfig, seg.id, newStart, seg.endTime); + applyConfig(updated); + if (markersCtrl) markersCtrl.updateSegmentPosition(seg.id, "start", newStart); + video.currentTime = newStart; + if (currentVideoId) void setLastUsed(currentVideoId, updated); +} + +export function nudgeActiveSegmentEnd( + direction: "forward" | "backward", + baseStep: number, + multiplier: number, + maxStep: number, +): void { + if (!isActive || !video || !engine || !activeConfig) return; + const seg = engine.getSegmentAt(video.currentTime); + if (!seg) return; + + const step = computeNudgeStep("end", direction, baseStep, multiplier, maxStep); + const dur = video.duration; + if (!dur) return; + + const newEnd = + direction === "forward" + ? Math.min(seg.endTime + step, dur) + : Math.max(seg.startTime + 0.1, seg.endTime - step); + + const updated = updateSegmentTimes(activeConfig, seg.id, seg.startTime, newEnd); + applyConfig(updated); + if (markersCtrl) markersCtrl.updateSegmentPosition(seg.id, "end", newEnd); + if (currentVideoId) void setLastUsed(currentVideoId, updated); +} + +// --------------------------------------------------------------------------- +// Save / clear shortcut +// --------------------------------------------------------------------------- + +export async function saveSegmentsShortcut(): Promise { + if (!currentVideoId) return; + + if (isActive && activeConfig) { + const { saveNamedConfig, setDefaultConfig } = await import( + "../../../utils/storage/segmentStore" + ); + const named = { ...activeConfig, updatedAt: Date.now() }; + await saveNamedConfig(currentVideoId, named); + await setDefaultConfig(currentVideoId, named.id); + await refreshNamedConfigsCache(); + showPageToast("Segments saved ✓"); + setSegmentButtonSaved(named.label); + } else { + await setLastUsed(currentVideoId, null); + showPageToast("Last-used segments cleared"); + } +} + +// --------------------------------------------------------------------------- +// Named config shortcut +// --------------------------------------------------------------------------- + +export async function activateNamedConfig(config: SegmentConfig): Promise { + if (!video) return; + const dur = video.duration || 0; + const clamped = clampConfigToDuration(config, dur); + enableSegments(clamped); + showPageToast(`Segments: ${clamped.label}`); + setSegmentButtonSaved(clamped.label); +} + +// --------------------------------------------------------------------------- +// Utility +// --------------------------------------------------------------------------- + +function clampConfigToDuration( + config: SegmentConfig, + duration: number, +): SegmentConfig { + if (!duration || duration <= 0) return config; + return { + ...config, + segments: config.segments.map((s) => { + const start = Math.max(0, Math.min(s.startTime, duration - 0.1)); + const end = Math.max(start + 0.1, Math.min(s.endTime, duration)); + return { ...s, startTime: start, endTime: end }; + }), + }; +} + +export function isSegmentsActive(): boolean { + return isActive; +} + +export function getActiveConfig(): SegmentConfig | null { + return activeConfig; +} diff --git a/web-ext/app/content_scripts/index.css b/web-ext/app/content_scripts/index.css new file mode 100644 index 00000000..9d30c30f --- /dev/null +++ b/web-ext/app/content_scripts/index.css @@ -0,0 +1,24 @@ +@tailwind base; +@tailwind utilities; + +#contentContainer.tp-yt-app-drawer[swipe-open].tp-yt-app-drawer::after { + width: 10px !important; +} + +#movie_player.tppng-audio-mode-on .ytp-chrome-bottom, +#movie_player.tppng-audio-mode-on .ytp-chrome-top, +#movie_player.tppng-audio-mode-on .ytp-gradient-bottom, +#movie_player.tppng-audio-mode-on .ytp-gradient-top, +#movie_player.tppng-audio-mode-on .ytp-ce-element, +#movie_player.tppng-audio-mode-on .ytp-iv-player-content, +#movie_player.tppng-audio-mode-on .ytp-pause-overlay, +#movie_player.tppng-audio-mode-on .ytp-cards-teaser { + opacity: 0 !important; + pointer-events: none !important; +} + +/* Segment marker merge-ready pulse animation */ +@keyframes tppng-merge-pulse { + from { transform: translateX(-50%) scale(1); filter: brightness(1); } + to { transform: translateX(-50%) scale(1.25); filter: brightness(1.6) drop-shadow(0 0 6px #fff); } +} diff --git a/web-ext/app/content_scripts/index.ts b/web-ext/app/content_scripts/index.ts new file mode 100644 index 00000000..ee11f232 --- /dev/null +++ b/web-ext/app/content_scripts/index.ts @@ -0,0 +1,67 @@ +import type { Context } from "../background/context"; +import type { Storage } from "../background/store"; + +import onPlaylistPage from "./pages/playlist"; +import onShortsPage from "./pages/shorts"; +import onWatchPage from "./pages/watch"; +import onYoutubePage from "./pages/youtube"; +import { setupNativeSettings } from "./pages/nativeSettings"; +import "./index.css"; +import { ERROR, EXTENSION_CONTEXT_SCOPE } from "@toppings/constants"; +import { + EXTENSION_MESSAGE_BODY, + EXTENSION_MESSAGE_EVENT, + EXTENSION_MESSAGE_TYPE, +} from "../../data/extension.data"; + +const scopeHandlers: Record = { + [EXTENSION_CONTEXT_SCOPE.PLAYLIST]: onPlaylistPage, + [EXTENSION_CONTEXT_SCOPE.SHORTS]: onShortsPage, + [EXTENSION_CONTEXT_SCOPE.WATCH]: onWatchPage, + [EXTENSION_CONTEXT_SCOPE.YOUTUBE]: onYoutubePage, +}; + +function runApp(message: any): undefined { + (async () => { + const parsed = JSON.parse(message) as Record; + const type = parsed[EXTENSION_MESSAGE_BODY.TYPE]; + const payload = parsed[EXTENSION_MESSAGE_BODY.PAYLOAD]; + + if (type !== EXTENSION_MESSAGE_TYPE.CONTEXT) return; + const ctx = payload as Exclude; + + const { scope, store } = ctx; + + // Native settings sidebar — runs on every YouTube navigation, independent + // of scope. Feature-gated by store.ui.nativeSettingsEnabled. + setupNativeSettings( + !!(store.isExtensionEnabled && store.ui?.nativeSettingsEnabled), + ); + + const handler = scopeHandlers[scope]; + + if (!handler) { + console.warn(ERROR.NO_CONTENT_HANDLER, scope); + return; + } + + // The YOUTUBE scope has no preferences entry — it's always enabled when the + // extension is enabled (checked above via store.isExtensionEnabled). + const prefs = store.preferences[scope as keyof typeof store.preferences]; + const isEnabled = prefs ? prefs.isEnabled : true; + if (!isEnabled) return; + + await handler(ctx); + })(); +} + +chrome.runtime.sendMessage( + chrome.runtime.id, + JSON.stringify({ + [EXTENSION_MESSAGE_BODY.TYPE]: EXTENSION_MESSAGE_TYPE.EVENT, + [EXTENSION_MESSAGE_BODY.PAYLOAD]: EXTENSION_MESSAGE_EVENT.CONNECTED, + }), + {}, + runApp, +); +chrome.runtime.onMessage.addListener(runApp); diff --git a/web-ext/app/content_scripts/pages/nativeSettings.ts b/web-ext/app/content_scripts/pages/nativeSettings.ts new file mode 100644 index 00000000..c2ec28c0 --- /dev/null +++ b/web-ext/app/content_scripts/pages/nativeSettings.ts @@ -0,0 +1,538 @@ +/** + * Native settings page — injects a "Toppings" entry into YouTube's left + * sidebar and manages a full-page overlay that mirrors the extension options. + * + * Entry point: `setupNativeSettings()` called on every YouTube navigation. + * The sidebar link is injected once (idempotent). The overlay is a lazily- + * created singleton. Calling `setupNativeSettings(false)` removes the link + * and hides the overlay (feature disabled flow). + */ + +import { + getAllProfiles, + getActiveProfile, + setActiveProfileId, +} from "../../../utils/storage/profileStore"; +import { applyWatchProfile } from "../primitives/applyProfile"; +import type { Profile } from "../../../data/profiles.data"; + +// --------------------------------------------------------------------------- +// IDs +// --------------------------------------------------------------------------- + +const SIDEBAR_LINK_ID = "tppng-native-sidebar-link"; +const OVERLAY_ID = "tppng-native-settings-overlay"; + +// --------------------------------------------------------------------------- +// Sidebar strategies — ordered by stability/likelihood +// --------------------------------------------------------------------------- + +const SIDEBAR_STRATEGIES = [ + "ytd-guide-renderer #sections", + "ytd-guide-renderer", + "#guide-inner-content", + "tp-yt-app-drawer #guide-inner-content", +] as const; + +// --------------------------------------------------------------------------- +// CSS vars that mirror YouTube's design tokens +// --------------------------------------------------------------------------- + +const YT_STYLES = ` +#${OVERLAY_ID} { + position: fixed; + inset: 0; + z-index: 100000; + display: none; + overflow-y: auto; + background: var(--yt-spec-base-background, #0f0f0f); + color: var(--yt-spec-text-primary, #fff); + font-family: "YouTube Sans", "Roboto", sans-serif; + font-size: 14px; + padding: 0; +} +#${OVERLAY_ID}.tppng-visible { + display: block; +} +#${OVERLAY_ID} .tppng-ns-header { + display: flex; + align-items: center; + gap: 16px; + padding: 16px 24px; + border-bottom: 1px solid var(--yt-spec-10-percent-layer, rgba(255,255,255,0.1)); + position: sticky; + top: 0; + background: var(--yt-spec-base-background, #0f0f0f); + z-index: 1; +} +#${OVERLAY_ID} .tppng-ns-back { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 50%; + cursor: pointer; + background: none; + border: none; + color: inherit; + padding: 0; +} +#${OVERLAY_ID} .tppng-ns-back:hover { + background: var(--yt-spec-10-percent-layer, rgba(255,255,255,0.1)); +} +#${OVERLAY_ID} .tppng-ns-title { + font-size: 20px; + font-weight: 600; +} +#${OVERLAY_ID} .tppng-ns-body { + max-width: 900px; + margin: 0 auto; + padding: 24px 24px 64px; +} +#${OVERLAY_ID} .tppng-ns-section { + margin-bottom: 32px; +} +#${OVERLAY_ID} .tppng-ns-section-title { + font-size: 13px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--yt-spec-text-secondary, rgba(255,255,255,0.7)); + margin-bottom: 12px; +} +#${OVERLAY_ID} .tppng-ns-card { + background: var(--yt-spec-raised-background, #212121); + border-radius: 12px; + overflow: hidden; +} +#${OVERLAY_ID} .tppng-ns-row { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 16px; + border-bottom: 1px solid var(--yt-spec-10-percent-layer, rgba(255,255,255,0.08)); + cursor: pointer; + transition: background 0.1s; +} +#${OVERLAY_ID} .tppng-ns-row:last-child { + border-bottom: none; +} +#${OVERLAY_ID} .tppng-ns-row:hover { + background: var(--yt-spec-10-percent-layer, rgba(255,255,255,0.08)); +} +#${OVERLAY_ID} .tppng-ns-row-label { + flex: 1; + font-size: 14px; + font-weight: 500; +} +#${OVERLAY_ID} .tppng-ns-row-desc { + font-size: 12px; + color: var(--yt-spec-text-secondary, rgba(255,255,255,0.6)); + margin-top: 2px; +} +#${OVERLAY_ID} .tppng-ns-toggle { + width: 36px; + height: 20px; + border-radius: 10px; + background: var(--yt-spec-10-percent-layer, rgba(255,255,255,0.2)); + position: relative; + flex-shrink: 0; + transition: background 0.15s; +} +#${OVERLAY_ID} .tppng-ns-toggle.on { + background: var(--yt-spec-call-to-action, #3ea6ff); +} +#${OVERLAY_ID} .tppng-ns-toggle::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 16px; + height: 16px; + border-radius: 50%; + background: #fff; + transition: transform 0.15s; +} +#${OVERLAY_ID} .tppng-ns-toggle.on::after { + transform: translateX(16px); +} +#${OVERLAY_ID} .tppng-ns-profile-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 14px; + border-radius: 20px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + border: none; + transition: background 0.1s; + background: var(--yt-spec-10-percent-layer, rgba(255,255,255,0.1)); + color: var(--yt-spec-text-primary, #fff); +} +#${OVERLAY_ID} .tppng-ns-profile-chip.active { + background: var(--yt-spec-call-to-action, #3ea6ff); + color: #fff; +} +#${OVERLAY_ID} .tppng-ns-chips { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 16px; +} +#${OVERLAY_ID} .tppng-ns-open-options { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 20px; + border-radius: 20px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + border: 1px solid var(--yt-spec-10-percent-layer, rgba(255,255,255,0.2)); + background: none; + color: inherit; + transition: background 0.1s; +} +#${OVERLAY_ID} .tppng-ns-open-options:hover { + background: var(--yt-spec-10-percent-layer, rgba(255,255,255,0.08)); +} +`; + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +let overlayEl: HTMLElement | null = null; +let stylesInjected = false; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function injectStyles() { + if (stylesInjected) return; + const style = document.createElement("style"); + style.id = "tppng-native-settings-styles"; + style.textContent = YT_STYLES; + document.head.appendChild(style); + stylesInjected = true; +} + +function makeToggle(on: boolean): HTMLElement { + const el = document.createElement("div"); + el.className = `tppng-ns-toggle${on ? " on" : ""}`; + return el; +} + +// --------------------------------------------------------------------------- +// Build overlay content +// --------------------------------------------------------------------------- + +async function renderOverlay(body: HTMLElement): Promise { + body.innerHTML = ""; + + const [profiles, activeProfile] = await Promise.all([ + getAllProfiles(), + getActiveProfile(), + ]); + const activeId = activeProfile?.id ?? null; + + // --- Profiles section --- + const profileSection = document.createElement("div"); + profileSection.className = "tppng-ns-section"; + + const profileTitle = document.createElement("div"); + profileTitle.className = "tppng-ns-section-title"; + profileTitle.textContent = "Active Profile"; + profileSection.appendChild(profileTitle); + + const chips = document.createElement("div"); + chips.className = "tppng-ns-card tppng-ns-chips"; + + // "Default" chip + const defaultChip = makeProfileChip(null, activeId === null, async () => { + await setActiveProfileId(null); + void applyWatchProfile(); + void renderOverlay(body); + }); + chips.appendChild(defaultChip); + + for (const p of profiles) { + const isActive = p.id === activeId; + chips.appendChild( + makeProfileChip(p, isActive, async () => { + const next = isActive ? null : p.id; + await setActiveProfileId(next); + void applyWatchProfile(); + void renderOverlay(body); + }), + ); + } + profileSection.appendChild(chips); + body.appendChild(profileSection); + + // --- Watch primitives section --- + const primSection = document.createElement("div"); + primSection.className = "tppng-ns-section"; + + const primTitle = document.createElement("div"); + primTitle.className = "tppng-ns-section-title"; + primTitle.textContent = "Watch Page"; + primSection.appendChild(primTitle); + + const primCard = document.createElement("div"); + primCard.className = "tppng-ns-card"; + + const sidebarEl = document.querySelector( + "#secondary, ytd-watch-next-secondary-results-renderer", + ) as HTMLElement | null; + const commentsEl = document.querySelector( + "ytd-comments#comments, #comments", + ) as HTMLElement | null; + const endCardEl = document.querySelector( + ".ytp-ce-element, .ytp-endscreen-element", + ) as HTMLElement | null; + + primCard.appendChild( + makePrimRow( + "Recommendations sidebar", + "Show or hide the right-side recommendations", + !sidebarEl || sidebarEl.style.display !== "none", + async (next) => { + const { setSidebarVisible, resetSidebar } = await import( + "../primitives/watch" + ); + if (next) void setSidebarVisible(true); + else void setSidebarVisible(false); + }, + ), + ); + + primCard.appendChild( + makePrimRow( + "Comments", + "Show or hide the comments section", + !commentsEl || commentsEl.style.display !== "none", + async (next) => { + const { setCommentsVisible } = await import("../primitives/watch"); + void setCommentsVisible(next); + }, + ), + ); + + primCard.appendChild( + makePrimRow( + "End cards", + "Show or hide video end card overlays", + !endCardEl || endCardEl.style.display !== "none", + async (next) => { + const { setEndCardsVisible } = await import("../primitives/watch"); + void setEndCardsVisible(next); + }, + ), + ); + + primSection.appendChild(primCard); + body.appendChild(primSection); + + // --- Open full settings link --- + const linkSection = document.createElement("div"); + linkSection.className = "tppng-ns-section"; + + const openBtn = document.createElement("button"); + openBtn.className = "tppng-ns-open-options"; + openBtn.textContent = "Open full settings ↗"; + openBtn.addEventListener("click", () => { + chrome.runtime.sendMessage({ type: "OPEN_OPTIONS" }); + }); + linkSection.appendChild(openBtn); + body.appendChild(linkSection); +} + +function makeProfileChip( + profile: Profile | null, + isActive: boolean, + onClick: () => void, +): HTMLElement { + const chip = document.createElement("button"); + chip.className = `tppng-ns-profile-chip${isActive ? " active" : ""}`; + chip.textContent = profile ? profile.name : "Default"; + chip.addEventListener("click", onClick); + return chip; +} + +function makePrimRow( + label: string, + desc: string, + on: boolean, + onChange: (next: boolean) => void, +): HTMLElement { + const row = document.createElement("div"); + row.className = "tppng-ns-row"; + + const text = document.createElement("div"); + text.style.flex = "1"; + + const labelEl = document.createElement("div"); + labelEl.className = "tppng-ns-row-label"; + labelEl.textContent = label; + + const descEl = document.createElement("div"); + descEl.className = "tppng-ns-row-desc"; + descEl.textContent = desc; + + text.appendChild(labelEl); + text.appendChild(descEl); + + const toggle = makeToggle(on); + + row.appendChild(text); + row.appendChild(toggle); + + let current = on; + row.addEventListener("click", () => { + current = !current; + toggle.classList.toggle("on", current); + onChange(current); + }); + + return row; +} + +// --------------------------------------------------------------------------- +// Overlay lifecycle +// --------------------------------------------------------------------------- + +function getOrCreateOverlay(): HTMLElement { + let el = document.getElementById(OVERLAY_ID); + if (el) return el as HTMLElement; + + el = document.createElement("div"); + el.id = OVERLAY_ID; + + // Header + const header = document.createElement("div"); + header.className = "tppng-ns-header"; + + const backBtn = document.createElement("button"); + backBtn.className = "tppng-ns-back"; + backBtn.setAttribute("aria-label", "Back to YouTube"); + backBtn.innerHTML = + ''; + backBtn.addEventListener("click", hideOverlay); + + const titleEl = document.createElement("div"); + titleEl.className = "tppng-ns-title"; + titleEl.textContent = "Toppings Settings"; + + header.appendChild(backBtn); + header.appendChild(titleEl); + el.appendChild(header); + + const body = document.createElement("div"); + body.className = "tppng-ns-body"; + el.appendChild(body); + + document.body.appendChild(el); + overlayEl = el as HTMLElement; + return overlayEl; +} + +function showOverlay(): void { + const el = getOrCreateOverlay(); + el.classList.add("tppng-visible"); + const body = el.querySelector(".tppng-ns-body") as HTMLElement; + void renderOverlay(body); +} + +function hideOverlay(): void { + if (overlayEl) { + overlayEl.classList.remove("tppng-visible"); + } +} + +// --------------------------------------------------------------------------- +// Sidebar link injection +// --------------------------------------------------------------------------- + +function injectSidebarLink(): void { + if (document.getElementById(SIDEBAR_LINK_ID)) return; + + // Try each strategy for the sidebar container. + let container: HTMLElement | null = null; + for (const sel of SIDEBAR_STRATEGIES) { + container = document.querySelector(sel) as HTMLElement | null; + if (container) break; + } + if (!container) return; + + const link = document.createElement("div"); + link.id = SIDEBAR_LINK_ID; + link.style.cssText = ` + display: flex; + align-items: center; + gap: 16px; + padding: 0 24px; + height: 40px; + cursor: pointer; + border-radius: 10px; + font-family: "YouTube Sans","Roboto",sans-serif; + font-size: 14px; + font-weight: 400; + color: var(--yt-spec-text-primary, #fff); + margin: 4px 12px; + transition: background 0.1s; + `; + + link.innerHTML = ` + + + + Toppings + `; + + link.addEventListener("mouseenter", () => { + link.style.background = + "var(--yt-spec-10-percent-layer, rgba(255,255,255,0.1))"; + }); + link.addEventListener("mouseleave", () => { + link.style.background = ""; + }); + link.addEventListener("click", showOverlay); + + container.appendChild(link); +} + +function removeSidebarLink(): void { + document.getElementById(SIDEBAR_LINK_ID)?.remove(); +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Call on every YouTube navigation when the native settings feature is enabled. + * Safe to call multiple times — idempotent. + * + * @param enabled Whether the feature is currently enabled. Pass false to + * tear down all injections. + */ +export function setupNativeSettings(enabled: boolean): void { + if (!enabled) { + removeSidebarLink(); + hideOverlay(); + return; + } + + injectStyles(); + + // The sidebar may not be ready on the first call — retry once after a short + // delay to handle YouTube's SPA navigation latency. + injectSidebarLink(); + if (!document.getElementById(SIDEBAR_LINK_ID)) { + setTimeout(injectSidebarLink, 1500); + } +} diff --git a/web-ext/src/content_scripts/pages/playlist.tsx b/web-ext/app/content_scripts/pages/playlist.tsx similarity index 64% rename from web-ext/src/content_scripts/pages/playlist.tsx rename to web-ext/app/content_scripts/pages/playlist.tsx index 6d2b140c..f1f7955b 100644 --- a/web-ext/src/content_scripts/pages/playlist.tsx +++ b/web-ext/app/content_scripts/pages/playlist.tsx @@ -1,3 +1,4 @@ +import { formatDurationSeconds } from "@toppings/utils"; import React from "dom-chef"; import elementReady from "element-ready"; import { @@ -5,6 +6,7 @@ import { PlaylistContext, ValidPlaylistPayload, } from "../../background/context"; +import { invalidateCachedPlaylist } from "../../../utils/storage/playlistCache"; const onPlaylistPage = async (ctx: PlaylistContext): Promise => { const { payload } = ctx; @@ -25,6 +27,40 @@ const onPlaylistPage = async (ctx: PlaylistContext): Promise => { const { averageRuntime, totalRuntime } = payload; + const playlistIdForRefresh = (payload as ValidPlaylistPayload & { playlistId?: string }).playlistId + ?? new URL(window.location.href).searchParams.get("list") + ?? ""; + + const handleRefresh = async (btn: HTMLElement) => { + if (!playlistIdForRefresh) return; + btn.textContent = "↻"; + btn.setAttribute("disabled", "true"); + await invalidateCachedPlaylist(playlistIdForRefresh); + // Reload the page so the background re-fetches fresh data. + window.location.reload(); + }; + + const refreshBtn = ( + + ) as HTMLButtonElement; + runtimeSection = (
=> {

Toppings

+ {refreshBtn}
Average Runtime: - {formatRuntime(averageRuntime)} + {formatDurationSeconds(averageRuntime)}
Total Runtime: - {formatRuntime(totalRuntime)} + {formatDurationSeconds(totalRuntime)}
@@ -73,49 +110,11 @@ const onPlaylistPage = async (ctx: PlaylistContext): Promise => { ); if (averageRuntimeElement && totalRuntimeElement) { - averageRuntimeElement.textContent = formatRuntime(averageRuntime); - totalRuntimeElement.textContent = formatRuntime(totalRuntime); + averageRuntimeElement.textContent = + formatDurationSeconds(averageRuntime); + totalRuntimeElement.textContent = formatDurationSeconds(totalRuntime); } } }; -/** - * Formats the runtime seconds into a human-readable string. - * - * @param {number} seconds - The seconds in the runtime. - * @returns {string} - A formatted string representing the runtime. - */ -function formatRuntime(seconds: number): string { - const days = Math.floor(seconds / 86400); // 1 day = 24 hours * 60 minutes * 60 seconds - const hours = Math.floor((seconds % 86400) / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - const remainingSeconds = seconds % 60; - - const parts = []; - - if (days > 0) { - parts.push(`${days} day${days !== 1 ? "s" : ""}`); - } - - if (hours > 0) { - parts.push(`${hours} hour${hours !== 1 ? "s" : ""}`); - } - - if (minutes > 0) { - parts.push(`${minutes} minute${minutes !== 1 ? "s" : ""}`); - } - - if (remainingSeconds > 0) { - parts.push( - `${remainingSeconds} second${remainingSeconds !== 1 ? "s" : ""}`, - ); - } - - if (parts.length === 0) { - parts.push("0 seconds"); - } - - return parts.join(", "); -} - export default onPlaylistPage; diff --git a/web-ext/src/content_scripts/pages/shorts.tsx b/web-ext/app/content_scripts/pages/shorts.tsx similarity index 82% rename from web-ext/src/content_scripts/pages/shorts.tsx rename to web-ext/app/content_scripts/pages/shorts.tsx index 6f56e69d..4dd70a6e 100644 --- a/web-ext/src/content_scripts/pages/shorts.tsx +++ b/web-ext/app/content_scripts/pages/shorts.tsx @@ -1,7 +1,9 @@ +import { isNull } from "@toppings/utils"; import React from "dom-chef"; import elementReady from "element-ready"; import type { Storage } from "../../background/store"; import type { ShortsContext } from "../../background/context"; +import { matchesBinding } from "../../../utils/keybinding"; let player: HTMLVideoElement | undefined; let preferences: Storage["preferences"]["shorts"] | undefined; @@ -9,12 +11,12 @@ let preferences: Storage["preferences"]["shorts"] | undefined; const onShortsPage = async (ctx: ShortsContext) => { const { store } = ctx; preferences = store.preferences.shorts; - if (preferences === undefined) return; + if (isNull(preferences)) return; player = await elementReady("ytd-reel-video-renderer[is-active] video", { stopOnDomReady: false, }); - if (player === undefined) return; + if (isNull(player)) return; const playerActions = player .closest("ytd-reel-video-renderer[is-active]") @@ -61,21 +63,21 @@ const onShortsPage = async (ctx: ShortsContext) => { }; function useShortcuts(event: KeyboardEvent) { - if (player === null || player === undefined) return; - if (preferences === undefined) return; + if (isNull(player)) return; + if (isNull(preferences)) return; + + const target = event.target as HTMLElement; if ( - event.target !== null && - (event.target as HTMLElement).tagName !== "INPUT" && - (event.target as HTMLElement).tagName !== "TEXTAREA" && - !(event.target as HTMLElement).matches( - "#contenteditable-root.yt-formatted-string", - ) + target !== null && + target.tagName !== "INPUT" && + target.tagName !== "TEXTAREA" && + !target.matches("#contenteditable-root.yt-formatted-string") ) { - if (event.key === `${preferences.togglePlaybackRate.key.toLowerCase()}`) { + if (matchesBinding(event, preferences.togglePlaybackRate.key)) { togglePlaybackRate(); - } else if (event.key === `${preferences.seekBackward.key.toLowerCase()}`) { + } else if (matchesBinding(event, preferences.seekBackward.key)) { player.currentTime -= +preferences.seekBackward.value; - } else if (event.key === `${preferences.seekForward.key.toLowerCase()}`) { + } else if (matchesBinding(event, preferences.seekForward.key)) { player.currentTime += +preferences.seekForward.value; } } @@ -83,7 +85,7 @@ function useShortcuts(event: KeyboardEvent) { let setupTimeoutId: ReturnType; function setupAutoScroll() { - if (player === null || player === undefined) return; + if (isNull(player)) return; if (!preferences?.reelAutoScroll.value) return; clearTimeout(setupTimeoutId); setupTimeoutId = setTimeout(() => { @@ -92,8 +94,8 @@ function setupAutoScroll() { } function scrollToNextReel() { - if (player === null || player === undefined) return; - if (preferences === undefined) return; + if (isNull(player)) return; + if (isNull(preferences)) return; if (!preferences.reelAutoScroll.value) { player.play(); return; @@ -134,14 +136,14 @@ const TogglePlaybackRateButton = ( ); function togglePlaybackRate() { - if (player === null || player === undefined) return; + if (isNull(player)) return; player.playbackRate = player.playbackRate === 1 ? 2 : 1; TogglePlaybackRateButton.classList.toggle("tw-bg-white/10"); TogglePlaybackRateButton.classList.toggle("tw-bg-white/20"); } function enableAutoScroll() { - if (preferences === undefined) return; + if (isNull(preferences)) return; preferences.reelAutoScroll.value = !preferences.reelAutoScroll.value; AutoScrollButton.classList.toggle("tw-bg-white/10"); AutoScrollButton.classList.toggle("tw-bg-white/20"); diff --git a/web-ext/app/content_scripts/pages/watch.tsx b/web-ext/app/content_scripts/pages/watch.tsx new file mode 100644 index 00000000..61b87ae1 --- /dev/null +++ b/web-ext/app/content_scripts/pages/watch.tsx @@ -0,0 +1,638 @@ +import React from "dom-chef"; +import { + setupSegments, + SegmentButton, + SegmentPanel, + initMarkers, + toggleSegmentsLastUsed, + activateFreshSegments, + setActiveSegmentStart, + setActiveSegmentEnd, + nudgeActiveSegmentStart, + nudgeActiveSegmentEnd, + saveSegmentsShortcut, + activateNamedConfig, + getCachedNamedConfigs, +} from "../components/Segments"; +import { matchesBinding } from "../../../utils/keybinding"; +import { coalesce, defaultTo, isNull } from "@toppings/utils"; +import { WatchContext } from "../../background/context"; +import { Storage } from "../../background/store"; +import { resolveTarget } from "../../../utils/primitive"; +import { setCapabilityStatus } from "../../../utils/storage/capabilityCache"; +import { applyWatchProfile } from "../primitives/applyProfile"; +import { + getAllProfiles, + getActiveProfile, + setActiveProfileId, +} from "../../../utils/storage/profileStore"; +import { BUILT_IN_PRESETS } from "../../../data/profiles.data"; +import { showPageToast } from "../utils/pageToast"; +import { injectGearMenuEntry } from "../components/GearMenuPanel"; +import { getCachedPlaylist } from "../../../utils/storage/playlistCache"; +import { formatDurationSeconds } from "@toppings/utils"; + +// --------------------------------------------------------------------------- +// Selector strategy registries +// +// Each array is ordered by likelihood (current / most common variant first). +// To support a new YouTube UI variant, append a selector to the relevant +// array — no other code needs to change. +// --------------------------------------------------------------------------- + +const STRATEGIES = { + /** Main video element. */ + player: ["video"] as const, + + /** Right-hand player control bar — where we inject our buttons. */ + rightControls: [ + "div.ytp-right-controls", + ] as const, + + /** Progress bar container — where we inject loop segment markers. */ + progressBar: [ + "div.ytp-progress-bar-container", + ] as const, + + /** Below-video area — where the segment panel is injected. */ + panelHost: [ + "#above-the-fold", + "ytd-watch-flexy #below", + "#secondary-inner", + "#columns", + ] as const, + + /** Gear/settings button that opens the player settings panel. */ + settingsButton: [ + "button.ytp-settings-button", + ] as const, + + /** Playback speed panel when opened from settings menu. */ + playbackRatePanel: [ + ".ytp-panel-animate-forward", + ".ytp-panel.ytp-panel-animate-forward", + ] as const, + + /** Double-tap seek overlay shown after A/D seek shortcuts. */ + doubleTapSeek: [ + ".ytp-doubletap-ui-legacy", + ".ytp-doubletap-ui", + ] as const, +} as const; + +let player: HTMLVideoElement | undefined; +let playbackMenuButton: HTMLElement | undefined; +let preferences: Storage["preferences"]["watch"] | undefined; +let gearMenuEnabled = false; + +const onWatchPage = async (ctx: WatchContext) => { + const { store } = ctx; + preferences = store.preferences.watch; + gearMenuEnabled = !!(store.ui?.gearMenuEnabled); + if (isNull(preferences)) return; + + // Resolve video element — fundamental; bail if missing. + const playerResolution = await resolveTarget(STRATEGIES.player, { + stopOnDomReady: false, + }); + void setCapabilityStatus("watch.player", "watch", playerResolution); + if (!playerResolution.resolved) return; + player = playerResolution.element as HTMLVideoElement; + + // Reset Player + player.playbackRate = parseFloat(preferences.defaultPlaybackRate.value); + const labels = document.querySelectorAll(".ytp-menuitem-label"); + if (labels.length !== 0) { + for (const label of labels) { + if (label.textContent === "Playback speed") { + const playbackMenuButton = label.parentNode as HTMLElement; + playbackMenuButton.children[2].textContent = + player.playbackRate === 1 + ? "Normal" + : `${Number(player.playbackRate.toFixed(2))}`; + break; + } + } + } + + // Keyboard Shortcuts + document.removeEventListener("keydown", useShortcuts); + document.addEventListener("keydown", useShortcuts); + + // Segment button — inject into right controls bar. + const rightControlsResolution = await resolveTarget(STRATEGIES.rightControls); + void setCapabilityStatus("watch.rightControls", "watch", rightControlsResolution); + if (rightControlsResolution.resolved) { + rightControlsResolution.element.prepend(SegmentButton); + } + + // Inject segment panel below the video player FIRST so that renderPanel() + // can find #tppng-sp-inner when setupSegments auto-loads a saved config. + // resolveTarget polls until the element exists, avoiding the race where + // YouTube's SPA content hasn't rendered yet on a fresh page load. + const panelHostResolution = await resolveTarget(STRATEGIES.panelHost, { + stopOnDomReady: false, + }); + if (panelHostResolution.resolved) { + document.getElementById("tppng-segment-panel")?.remove(); + (panelHostResolution.element as HTMLElement).prepend(SegmentPanel); + } + + // Segments — set up engine + markers + panel. + const progressBarResolution = await resolveTarget(STRATEGIES.progressBar); + void setCapabilityStatus("watch.progressBar", "watch", progressBarResolution); + if (progressBarResolution.resolved) { + await setupSegments( + ctx.payload.videoId ?? undefined, + preferences.segments?.autoLoad ?? "off", + ); + // Wire marker controller to the progress bar container. + if (player) { + initMarkers( + progressBarResolution.element as HTMLElement, + player, + ); + } + } + + // Settings button — attach listener for playback speed menu. + const settingsResolution = await resolveTarget(STRATEGIES.settingsButton); + void setCapabilityStatus("watch.settingsButton", "watch", settingsResolution); + if (!settingsResolution.resolved) return; + const playerSettingsButton = settingsResolution.element as HTMLElement; + playerSettingsButton.removeEventListener("click", onSettingsMenu); + playerSettingsButton.addEventListener("click", onSettingsMenu); + + // Apply the active profile's watch-scope primitives (or restore defaults + // if no profile is active). This runs after all setup above so Audio Mode + // and other components are fully initialised before profile overrides land. + void applyWatchProfile(); + + // React to profile changes made from the popup or options page without a + // full page reload. The listener is idempotent — re-adding via + // addListener with the same function reference is a no-op in MV3. + chrome.storage.onChanged.removeListener(onProfileStoreChanged); + chrome.storage.onChanged.addListener(onProfileStoreChanged); + + // If watching as part of a playlist, inject runtime info into the + // watch page's playlist panel. + const listId = new URL(window.location.href).searchParams.get("list"); + if (listId) { + void injectPlaylistRuntimeInWatchPanel(listId); + } +}; + +// --------------------------------------------------------------------------- +// Playlist runtime in watch panel (#4) +// --------------------------------------------------------------------------- + +const WATCH_PANEL_RUNTIME_ID = "tppng-watch-playlist-runtime"; + +/** + * Strategies for the playlist panel header inside the watch page. + * Ordered by most common YouTube layout variant first. + */ +const WATCH_PLAYLIST_PANEL_STRATEGIES = [ + "#playlist-container .ytd-playlist-panel-renderer #header", + "ytd-playlist-panel-renderer #header", + "#secondary ytd-playlist-panel-renderer #header-title", + "#secondary ytd-playlist-panel-renderer", +] as const; + +async function injectPlaylistRuntimeInWatchPanel(playlistId: string): Promise { + const data = await getCachedPlaylist(playlistId); + if (!data) return; // No cached data — skip (playlist page will cache on visit) + + const panelResolution = await resolveTarget(WATCH_PLAYLIST_PANEL_STRATEGIES); + if (!panelResolution.resolved) return; + + const header = panelResolution.element as HTMLElement; + + // Remove existing badge if already injected (SPA navigation). + header.querySelector(`#${WATCH_PANEL_RUNTIME_ID}`)?.remove(); + + const badge = document.createElement("div"); + badge.id = WATCH_PANEL_RUNTIME_ID; + badge.style.cssText = ` + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 11px; + color: var(--yt-spec-text-secondary, rgba(255,255,255,0.7)); + margin-top: 4px; + font-family: "YouTube Sans","Roboto",sans-serif; + `; + + badge.innerHTML = ` + ⏱ ${formatDurationSeconds(data.totalRuntime)} + · + avg ${formatDurationSeconds(data.averageRuntime)} + `; + + header.appendChild(badge); +} + +/** + * Fired whenever chrome.storage.local changes. Re-applies the watch profile + * when the active profile store is updated from the popup or options page. + */ +const onProfileStoreChanged = ( + changes: Record, + area: string, +): void => { + if (area !== "local") return; + if (!("toppings:profile_store" in changes)) return; + void applyWatchProfile(); +}; + +const onSettingsMenu = async (): Promise => { + if (isNull(player)) return; + + const menuItemLabelResolution = await resolveTarget([".ytp-menuitem-label"], { + stopOnDomReady: false, + }); + if (!menuItemLabelResolution.resolved) return; + const labels = document.querySelectorAll(".ytp-menuitem-label"); + if (labels.length === 0) return; + + for (const label of labels) { + if (label.textContent === "Playback speed") { + playbackMenuButton = label.parentNode as HTMLElement; + playbackMenuButton.children[2].textContent = + player.playbackRate === 1 + ? "Normal" + : `${Number(player.playbackRate.toFixed(2))}`; + + playbackMenuButton.removeEventListener("click", onPlaybackRateMenu); + playbackMenuButton.addEventListener("click", onPlaybackRateMenu); + break; + } + } + + // Inject the Toppings entry into the gear menu settings panel (opt-in). + if (gearMenuEnabled) { + const settingsMenu = document.querySelector( + ".ytp-settings-menu", + ) as HTMLElement | null; + if (settingsMenu) { + void injectGearMenuEntry(settingsMenu); + } + } +}; + +const onPlaybackRateMenu = async (): Promise => { + if (isNull(player) || isNull(preferences)) return; + if (preferences.customPlaybackRates.length === 0) return; + + const panelResolution = await resolveTarget(STRATEGIES.playbackRatePanel, { + stopOnDomReady: false, + }); + if (!panelResolution.resolved) return; + const playbackRatePanel = panelResolution.element as HTMLElement; + + const menuPanelOptions = playbackRatePanel.querySelector( + ".ytp-panel-options", + ) as HTMLElement | null; + if (menuPanelOptions) { + menuPanelOptions.style.display = "none"; + } + + replacePlaybackItems(playbackRatePanel); +}; + +const replacePlaybackItems = (playbackRatePanel: HTMLElement) => { + if (isNull(player)) return; + if (isNull(preferences)) return; + + // Replace Native PlaybackRate Items + const panelMenu = playbackRatePanel.querySelector(".ytp-panel-menu"); + if (isNull(panelMenu)) return; + + const currentRate = player.playbackRate; + const isPresetRate = preferences.customPlaybackRates.some( + (rate) => parseFloat(rate) === currentRate, + ); + + const playbackRateItems = preferences.customPlaybackRates.map( + (playbackRate) => { + const label = parseFloat(playbackRate) === 1 ? "Normal" : Number(playbackRate); + const isAriaChecked = + parseFloat(playbackRate) === currentRate ? "true" : "false"; + + return ( +
{ + const panelBackButton = document.querySelector( + ".ytp-panel-back-button", + ) as HTMLElement | null; + if (!isNull(panelBackButton)) { + panelBackButton.click(); + } + setPlaybackRate(Number(playbackRate)); + }} + > +
{label}
+
+ ); + }, + ); + + const customPlaybackRateItem = ( +
{ + const panelBackButton = document.querySelector( + ".ytp-panel-back-button", + ) as HTMLElement | null; + if (!isNull(panelBackButton)) { + panelBackButton.click(); + } + setPlaybackRate( + Number( + defaultTo(player!.getAttribute("data-tppng-playback-rate"), "1"), + ), + ); + }} + > +
+ Custom ( + {Number( + coalesce( + player!.getAttribute("data-tppng-playback-rate"), + String(player!.playbackRate), + ), + )} + ) +
+
+ ); + + panelMenu.replaceChildren(customPlaybackRateItem, ...playbackRateItems); +}; + +const useShortcuts = (event: KeyboardEvent): void => { + if (isNull(player) || isNull(preferences)) return; + + const target = event.target as HTMLElement; + const tagName = target?.tagName; + const isNotEditable = + tagName !== "INPUT" && + tagName !== "TEXTAREA" && + !target.matches("#contenteditable-root.yt-formatted-string"); + + if (!isNotEditable) return; + + // --------------------------------------------------------------------------- + // Segment nudge (accelerating, modifier-aware) — targets active segment + // --------------------------------------------------------------------------- + if (preferences.nudgeLoopSegment) { + const cfg = preferences.nudgeLoopSegment; + const baseStep = Math.max(0.1, parseFloat(cfg.baseStep) || 1); + const multiplier = Math.max(1, parseFloat(cfg.multiplier) || 2); + const maxStep = Math.max(baseStep, parseFloat(cfg.maxStep) || 16); + + if (matchesBinding(event, cfg.startBackwardKey)) { + event.preventDefault(); + nudgeActiveSegmentStart("backward", baseStep, multiplier, maxStep); + return; + } + if (matchesBinding(event, cfg.startForwardKey)) { + event.preventDefault(); + nudgeActiveSegmentStart("forward", baseStep, multiplier, maxStep); + return; + } + if (matchesBinding(event, cfg.endForwardKey)) { + event.preventDefault(); + nudgeActiveSegmentEnd("forward", baseStep, multiplier, maxStep); + return; + } + if (matchesBinding(event, cfg.endBackwardKey)) { + event.preventDefault(); + nudgeActiveSegmentEnd("backward", baseStep, multiplier, maxStep); + return; + } + } + + // --------------------------------------------------------------------------- + // All other watch shortcuts — checked with exact modifier matching. + // --------------------------------------------------------------------------- + if (matchesBinding(event, preferences.togglePlaybackRate.key)) { + setPlaybackRate( + player.playbackRate !== 1 + ? 1 + : Number(preferences.togglePlaybackRate.value), + ); + return; + } + + if (matchesBinding(event, preferences.seekBackward.key)) { + const value = Number(preferences.seekBackward.value); + player.currentTime -= value; + onDoubleTapSeek("back", value); + return; + } + + if (matchesBinding(event, preferences.seekForward.key)) { + const value = Number(preferences.seekForward.value); + player.currentTime += value; + onDoubleTapSeek("forward", value); + return; + } + + if (matchesBinding(event, preferences.increasePlaybackRate.key)) { + const value = Number(preferences.increasePlaybackRate.value); + const increasedPlaybackRate = Number((player.playbackRate + value).toFixed(2)); + if (increasedPlaybackRate <= 16) { + setPlaybackRate(increasedPlaybackRate); + } + return; + } + + if (matchesBinding(event, preferences.decreasePlaybackRate.key)) { + const value = Number(preferences.decreasePlaybackRate.value); + const decreasedPlaybackRate = Number((player.playbackRate - value).toFixed(2)); + if (decreasedPlaybackRate >= 0.0625) { + setPlaybackRate(decreasedPlaybackRate); + } + return; + } + + // Z — load last-used segments / toggle off + if (matchesBinding(event, preferences.toggleLoopSegment.key)) { + void toggleSegmentsLastUsed(); + return; + } + + // Shift+Z (or configurable) — fresh slate + if (matchesBinding(event, preferences.segments?.freshSlateKey ?? "Shift+Z")) { + activateFreshSegments(); + return; + } + + // Q / E — set active segment's start / end to playhead + if (matchesBinding(event, preferences.setLoopSegmentBegin.key)) { + setActiveSegmentStart(); + return; + } + + if (matchesBinding(event, preferences.setLoopSegmentEnd.key)) { + setActiveSegmentEnd(); + return; + } + + // Save shortcut — save active config to default slot (or clear last-used) + if (preferences.saveLoopSegment?.key && matchesBinding(event, preferences.saveLoopSegment.key)) { + void saveSegmentsShortcut(); + return; + } + + // Named config shortcuts — sync cache lookup (no DB hit on every keydown) + const namedConfigs = getCachedNamedConfigs(); + for (const config of namedConfigs) { + if (config.shortcutKey && matchesBinding(event, config.shortcutKey)) { + void activateNamedConfig(config); + return; + } + } + + if (preferences.cycleProfiles?.key && matchesBinding(event, preferences.cycleProfiles.key)) { + void cycleProfilesShortcut(); + return; + } +}; + +/** + * Cycle through all available profiles (presets first, then custom) plus the + * "no profile" state. On each invocation, advance one step and apply the next + * profile, then show a brief on-page toast with the profile name. + */ +async function cycleProfilesShortcut(): Promise { + // Build the ordered cycle: [null (no profile), ...presets, ...custom] + const customProfiles = await getAllProfiles(); + // getAllProfiles returns only custom (non-preset) profiles. + const cycle: Array<{ id: string | null; name: string }> = [ + { id: null, name: "Default" }, + ...BUILT_IN_PRESETS.map((p) => ({ id: p.id, name: p.name })), + ...customProfiles.map((p) => ({ id: p.id, name: p.name })), + ]; + + const activeProfile = await getActiveProfile(); + const currentId = activeProfile?.id ?? null; + + const currentIdx = cycle.findIndex((c) => c.id === currentId); + const nextIdx = currentIdx === -1 ? 0 : (currentIdx + 1) % cycle.length; + const next = cycle[nextIdx]; + + await setActiveProfileId(next.id); + void applyWatchProfile(); // reads storage; next.id is already set + showPageToast(`Profile: ${next.name}`); +} + + +let doubleTapSeekTimeout: ReturnType; +const onDoubleTapSeek = (dataSide: "back" | "forward", time: number): void => { + // Use synchronous querySelector here — the double-tap element is always + // present in the DOM once the player is loaded, so resolveTarget's async + // path is not needed. + const selector = STRATEGIES.doubleTapSeek.find((s) => + document.querySelector(s), + ); + const doubleTapSeekElement = selector + ? (document.querySelector(selector) as HTMLElement | null) + : null; + if (!isNull(doubleTapSeekElement)) { + doubleTapSeekElement.setAttribute("data-side", dataSide); + doubleTapSeekElement.style.display = ""; + const doubleTapSeekLabel = doubleTapSeekElement.querySelector( + ".ytp-doubletap-tooltip-label", + ) as HTMLElement; + if (!isNull(doubleTapSeekLabel)) { + doubleTapSeekLabel.textContent = `${time} seconds`; + } + const staticCircle = document.querySelector( + ".ytp-doubletap-static-circle", + ) as HTMLElement; + if (!isNull(staticCircle) && dataSide === "back") { + staticCircle.style.top = "50%"; + staticCircle.style.left = "10%"; + staticCircle.style.width = "110px"; + staticCircle.style.height = "110px"; + staticCircle.style.transform = "translate(-14px, -40px)"; + } else if (!isNull(staticCircle) && dataSide === "forward") { + staticCircle.style.top = "50%"; + staticCircle.style.left = "80%"; + staticCircle.style.width = "110px"; + staticCircle.style.height = "110px"; + staticCircle.style.transform = "translate(-28px, -40px)"; + } + clearTimeout(doubleTapSeekTimeout); + doubleTapSeekTimeout = setTimeout(() => { + (doubleTapSeekElement as HTMLElement).setAttribute("data-side", "null"); + (doubleTapSeekElement as HTMLElement).style.display = "none"; + const doubleTapLabel = ( + doubleTapSeekElement as HTMLElement + ).querySelector(".ytp-doubletap-tooltip-label"); + if (!isNull(doubleTapLabel)) { + doubleTapLabel.textContent = "5 seconds"; + } + staticCircle.style.cssText = "null"; + }, 500); + } +}; + +const setPlaybackRate = (rate: number): void => { + if (isNull(player)) return; + + const prevPlaybackRate = player.playbackRate.toFixed(2); + const prevPlaybackMenuItem = + document.querySelector( + `.tppng-playback-item[data-tppng-playback-rate="${prevPlaybackRate}"]`, + ) || document.querySelector("#tppng-playback-custom-item"); + if (prevPlaybackMenuItem) { + prevPlaybackMenuItem.ariaChecked = "false"; + } + + player.playbackRate = rate; + + const nextPlaybackRate = player.playbackRate.toFixed(2); + const nextPlaybackMenuItem: HTMLElement | null = + document.querySelector( + `.tppng-playback-item[data-tppng-playback-rate="${nextPlaybackRate}"]`, + ) || document.querySelector("#tppng-playback-custom-item"); + if (nextPlaybackMenuItem) { + nextPlaybackMenuItem.ariaChecked = "true"; + if (nextPlaybackMenuItem.id === "tppng-playback-custom-item") { + player.setAttribute( + "data-tppng-playback-rate", + player.playbackRate.toFixed(2), + ); + nextPlaybackMenuItem.style.display = ""; + const customPlaybackItemLabel = document.querySelector( + "#tppng-playback-custom-item > .ytp-menuitem-label", + ); + if (customPlaybackItemLabel) { + customPlaybackItemLabel.textContent = `Custom (${player.playbackRate})`; + } + } + } + + if (!isNull(playbackMenuButton)) { + playbackMenuButton.children[2].textContent = + player.playbackRate === 1 + ? "Normal" + : `${Number(player.playbackRate.toFixed(2))}`; + } +}; + +export default onWatchPage; diff --git a/web-ext/app/content_scripts/pages/youtube.ts b/web-ext/app/content_scripts/pages/youtube.ts new file mode 100644 index 00000000..8cd50c6b --- /dev/null +++ b/web-ext/app/content_scripts/pages/youtube.ts @@ -0,0 +1,32 @@ +/** + * Generic YouTube page handler (home, search, channel, etc.) + * + * Runs `applyWatchProfile` so that home/search/shorts profile primitives + * take effect on every YouTube navigation — not just the watch page. + * + * Also re-applies primitives when the profile store changes (popup or options + * page interaction while browsing YouTube). + */ + +import type { YoutubeContext } from "../../background/context"; +import { applyWatchProfile } from "../primitives/applyProfile"; + +const onProfileStoreChangedYoutube = ( + changes: Record, + area: string, +): void => { + if (area !== "local") return; + if (!("toppings:profile_store" in changes)) return; + void applyWatchProfile(); +}; + +const onYoutubePage = async (_ctx: YoutubeContext): Promise => { + // Apply active profile's home/search/shorts primitives. + void applyWatchProfile(); + + // Keep in sync with profile store changes from other surfaces. + chrome.storage.onChanged.removeListener(onProfileStoreChangedYoutube); + chrome.storage.onChanged.addListener(onProfileStoreChangedYoutube); +}; + +export default onYoutubePage; diff --git a/web-ext/app/content_scripts/primitives/applyProfile.ts b/web-ext/app/content_scripts/primitives/applyProfile.ts new file mode 100644 index 00000000..5f9541e8 --- /dev/null +++ b/web-ext/app/content_scripts/primitives/applyProfile.ts @@ -0,0 +1,119 @@ +import { getActiveProfile } from "../../../utils/storage/profileStore"; +import type { ProfilePrimitiveConfig } from "../../../data/profiles.data"; +import { + setSidebarVisible, + setCommentsVisible, + setEndCardsVisible, + resetSidebar, + resetComments, + resetEndCards, +} from "./watch"; +import { + setHomeThumbnailMode, + resetHomeThumbnails, + setHomeFeedVisible, + resetHomeFeed, + setHomeShortsVisible, + resetHomeShorts, +} from "./home"; +import { + setSearchThumbnailMode, + resetSearchThumbnails, + setSearchMetadataVisible, + resetSearchMetadata, + setSearchShortsVisible, + resetSearchShorts, +} from "./search"; +import { + setShortsShelfVisible, + resetShortsShelf, +} from "./shorts"; + +// --------------------------------------------------------------------------- +// Watch profile application +// --------------------------------------------------------------------------- + +/** + * Apply the active profile's watch-scope primitive states. + * + * Call this after the watch page handler has completed its own setup so that + * Audio Mode and other components are already initialised before we apply + * profile overrides on top. + * + * If no profile is active all primitives are reset to their defaults + * (visible / unmodified), which is the same as calling resetAll() — + * this ensures switching from an active profile back to "no profile" + * cleanly restores the page. + * + * @param overrideConfig Optional config to apply directly instead of reading + * from storage. Used when profile switching mid-session. + */ +export async function applyWatchProfile( + overrideConfig?: ProfilePrimitiveConfig, +): Promise { + const config = + overrideConfig ?? (await getActiveProfile())?.primitives ?? null; + + // --- Reset all to defaults first, then apply profile on top. --- + // This guarantees a clean state whether we are entering, switching, or + // leaving a profile. + resetSidebar(); + resetComments(); + resetEndCards(); + resetHomeThumbnails(); + resetHomeFeed(); + resetHomeShorts(); + resetSearchThumbnails(); + resetSearchMetadata(); + resetSearchShorts(); + resetShortsShelf(); + + if (!config) return; + + // --- Watch primitives --- + if (config["watch.sidebar"] !== undefined) { + void setSidebarVisible(config["watch.sidebar"].visible); + } + if (config["watch.comments"] !== undefined) { + void setCommentsVisible(config["watch.comments"].visible); + } + if (config["watch.endCards"] !== undefined) { + void setEndCardsVisible(config["watch.endCards"].visible); + } + + // --- Home primitives --- + if (config["home.thumbnails"] !== undefined) { + void setHomeThumbnailMode(config["home.thumbnails"].mode); + } + if (config["home.feed"] !== undefined) { + void setHomeFeedVisible(config["home.feed"].visible); + } + if (config["home.shorts"] !== undefined) { + void setHomeShortsVisible(config["home.shorts"].visible); + } + + // --- Search primitives --- + if (config["search.thumbnails"] !== undefined) { + void setSearchThumbnailMode(config["search.thumbnails"].mode); + } + if (config["search.metadata"] !== undefined) { + void setSearchMetadataVisible(config["search.metadata"].visible); + } + if (config["search.shorts"] !== undefined) { + void setSearchShortsVisible(config["search.shorts"].visible); + } + + // --- Shorts shelf (home + search) --- + if (config["shorts.shelf"] !== undefined) { + void setShortsShelfVisible(config["shorts.shelf"].visible); + } +} + +/** + * Read the current active profile and return its primitives, or null if + * no profile is active. Lightweight — used for on-demand checks. + */ +export async function getActivePrimitives(): Promise { + const profile = await getActiveProfile(); + return profile?.primitives ?? null; +} diff --git a/web-ext/app/content_scripts/primitives/home/feed.ts b/web-ext/app/content_scripts/primitives/home/feed.ts new file mode 100644 index 00000000..746300e6 --- /dev/null +++ b/web-ext/app/content_scripts/primitives/home/feed.ts @@ -0,0 +1,28 @@ +/** + * Home scope — entire feed visibility primitive. + * + * Hides/shows the main content area of the YouTube home page. + */ + +import { resolveTarget } from "../../../../utils/primitive"; +import { setCapabilityStatus } from "../../../../utils/storage/capabilityCache"; + +const STRATEGIES = [ + "ytd-rich-grid-renderer", + "ytd-browse[page-subtype='home'] ytd-rich-grid-renderer", + "#contents.ytd-rich-grid-renderer", +] as const; + +export async function setHomeFeedVisible(visible: boolean): Promise { + const resolution = await resolveTarget(STRATEGIES); + void setCapabilityStatus("home.feed", "home", resolution); + if (!resolution.resolved) return; + + (resolution.element as HTMLElement).style.display = visible ? "" : "none"; +} + +export function resetHomeFeed(): void { + const selectors = STRATEGIES.join(", "); + const el = document.querySelector(selectors) as HTMLElement | null; + if (el) el.style.display = ""; +} diff --git a/web-ext/app/content_scripts/primitives/home/index.ts b/web-ext/app/content_scripts/primitives/home/index.ts new file mode 100644 index 00000000..eb18670d --- /dev/null +++ b/web-ext/app/content_scripts/primitives/home/index.ts @@ -0,0 +1,7 @@ +export { + setHomeThumbnailMode, + applyHomeThumbnailMode, + resetHomeThumbnails, +} from "./thumbnails"; +export { setHomeFeedVisible, resetHomeFeed } from "./feed"; +export { setHomeShortsVisible, resetHomeShorts } from "./shorts"; diff --git a/web-ext/app/content_scripts/primitives/home/shorts.ts b/web-ext/app/content_scripts/primitives/home/shorts.ts new file mode 100644 index 00000000..f3079f65 --- /dev/null +++ b/web-ext/app/content_scripts/primitives/home/shorts.ts @@ -0,0 +1,29 @@ +/** + * Home scope — Shorts shelf visibility primitive. + * + * Hides the Shorts row that appears in the YouTube home feed. + */ + +import { resolveTarget } from "../../../../utils/primitive"; +import { setCapabilityStatus } from "../../../../utils/storage/capabilityCache"; + +const STRATEGIES = [ + "ytd-rich-section-renderer:has(ytd-rich-shelf-renderer[is-shorts])", + "ytd-rich-shelf-renderer[is-shorts]", + "#shorts-container", + "ytd-reel-shelf-renderer", +] as const; + +export async function setHomeShortsVisible(visible: boolean): Promise { + const resolution = await resolveTarget(STRATEGIES); + void setCapabilityStatus("home.shorts", "home", resolution); + if (!resolution.resolved) return; + + (resolution.element as HTMLElement).style.display = visible ? "" : "none"; +} + +export function resetHomeShorts(): void { + const selectors = STRATEGIES.join(", "); + const el = document.querySelector(selectors) as HTMLElement | null; + if (el) el.style.display = ""; +} diff --git a/web-ext/app/content_scripts/primitives/home/thumbnails.ts b/web-ext/app/content_scripts/primitives/home/thumbnails.ts new file mode 100644 index 00000000..61daa5eb --- /dev/null +++ b/web-ext/app/content_scripts/primitives/home/thumbnails.ts @@ -0,0 +1,56 @@ +/** + * Home scope — feed thumbnail visibility/blur primitive. + * + * Strategy arrays cover the main thumbnail image elements inside the home + * feed, ordered by the most common YouTube DOM variant first. + */ + +import { resolveTarget } from "../../../../utils/primitive"; +import { setCapabilityStatus } from "../../../../utils/storage/capabilityCache"; +import type { ThumbnailMode } from "../../../../data/profiles.data"; + +const STRATEGIES = [ + "ytd-rich-grid-renderer ytd-rich-item-renderer img#img", + "ytd-rich-grid-renderer ytd-thumbnail img", + "#contents ytd-rich-item-renderer yt-image img", +] as const; + +/** CSS filter applied for blur mode. */ +const BLUR_FILTER = "blur(12px)"; + +let lastMode: ThumbnailMode = "show"; + +export async function setHomeThumbnailMode(mode: ThumbnailMode): Promise { + lastMode = mode; + + // Capability probe — just resolve the container once. + const resolution = await resolveTarget(STRATEGIES); + void setCapabilityStatus("home.thumbnails", "home", resolution); + + applyHomeThumbnailMode(mode); +} + +/** + * Apply the thumbnail mode synchronously to all current thumbnails in the DOM. + * Safe to call at any time — operates on the live DOM without async. + */ +export function applyHomeThumbnailMode(mode: ThumbnailMode): void { + const selector = [ + "ytd-rich-grid-renderer ytd-rich-item-renderer img#img", + "ytd-rich-grid-renderer ytd-thumbnail img", + "#contents ytd-rich-item-renderer yt-image img", + ].join(", "); + + const images = document.querySelectorAll(selector) as NodeListOf; + for (const img of images) { + img.style.filter = mode === "blur" ? BLUR_FILTER : ""; + img.style.visibility = mode === "hide" ? "hidden" : ""; + } +} + +export function resetHomeThumbnails(): void { + lastMode = "show"; + applyHomeThumbnailMode("show"); +} + +export { lastMode as currentHomeThumbnailMode }; diff --git a/web-ext/app/content_scripts/primitives/search/index.ts b/web-ext/app/content_scripts/primitives/search/index.ts new file mode 100644 index 00000000..b290d7bb --- /dev/null +++ b/web-ext/app/content_scripts/primitives/search/index.ts @@ -0,0 +1,11 @@ +export { + setSearchThumbnailMode, + applySearchThumbnailMode, + resetSearchThumbnails, +} from "./thumbnails"; +export { + setSearchMetadataVisible, + applySearchMetadataVisible, + resetSearchMetadata, +} from "./metadata"; +export { setSearchShortsVisible, resetSearchShorts } from "./shorts"; diff --git a/web-ext/app/content_scripts/primitives/search/metadata.ts b/web-ext/app/content_scripts/primitives/search/metadata.ts new file mode 100644 index 00000000..2d03d4eb --- /dev/null +++ b/web-ext/app/content_scripts/primitives/search/metadata.ts @@ -0,0 +1,38 @@ +/** + * Search scope — video metadata (view count, date) visibility primitive. + * + * Hides metadata lines below the title in search results. + */ + +import { resolveTarget } from "../../../../utils/primitive"; +import { setCapabilityStatus } from "../../../../utils/storage/capabilityCache"; + +const STRATEGIES = [ + "ytd-search ytd-video-renderer #metadata-line", + "ytd-search ytd-video-renderer .ytd-video-meta-block", + "#contents.ytd-section-list-renderer ytd-video-renderer #metadata-line", +] as const; + +export async function setSearchMetadataVisible(visible: boolean): Promise { + const resolution = await resolveTarget(STRATEGIES); + void setCapabilityStatus("search.metadata", "search", resolution); + + applySearchMetadataVisible(visible); +} + +export function applySearchMetadataVisible(visible: boolean): void { + const selector = [ + "ytd-search ytd-video-renderer #metadata-line", + "ytd-search ytd-video-renderer .ytd-video-meta-block", + "#contents.ytd-section-list-renderer ytd-video-renderer #metadata-line", + ].join(", "); + + const els = document.querySelectorAll(selector) as NodeListOf; + for (const el of els) { + el.style.display = visible ? "" : "none"; + } +} + +export function resetSearchMetadata(): void { + applySearchMetadataVisible(true); +} diff --git a/web-ext/app/content_scripts/primitives/search/shorts.ts b/web-ext/app/content_scripts/primitives/search/shorts.ts new file mode 100644 index 00000000..d900b9ce --- /dev/null +++ b/web-ext/app/content_scripts/primitives/search/shorts.ts @@ -0,0 +1,25 @@ +/** + * Search scope — Shorts shelf visibility in search results. + */ + +import { resolveTarget } from "../../../../utils/primitive"; +import { setCapabilityStatus } from "../../../../utils/storage/capabilityCache"; + +const STRATEGIES = [ + "ytd-search ytd-reel-shelf-renderer", + "ytd-search ytd-shelf-renderer:has(ytd-reel-item-renderer)", + "#contents.ytd-section-list-renderer ytd-reel-shelf-renderer", +] as const; + +export async function setSearchShortsVisible(visible: boolean): Promise { + const resolution = await resolveTarget(STRATEGIES); + void setCapabilityStatus("search.shorts", "search", resolution); + if (!resolution.resolved) return; + (resolution.element as HTMLElement).style.display = visible ? "" : "none"; +} + +export function resetSearchShorts(): void { + const selector = STRATEGIES.join(", "); + const el = document.querySelector(selector) as HTMLElement | null; + if (el) el.style.display = ""; +} diff --git a/web-ext/app/content_scripts/primitives/search/thumbnails.ts b/web-ext/app/content_scripts/primitives/search/thumbnails.ts new file mode 100644 index 00000000..2e6a6f6e --- /dev/null +++ b/web-ext/app/content_scripts/primitives/search/thumbnails.ts @@ -0,0 +1,42 @@ +/** + * Search scope — search result thumbnail visibility/blur primitive. + * + * Hiding thumbnails turns search results into a plain text list, + * ideal for distraction-free / SFW profiles. + */ + +import { resolveTarget } from "../../../../utils/primitive"; +import { setCapabilityStatus } from "../../../../utils/storage/capabilityCache"; +import type { ThumbnailMode } from "../../../../data/profiles.data"; + +const STRATEGIES = [ + "ytd-search ytd-video-renderer ytd-thumbnail img#img", + "ytd-search ytd-video-renderer a.ytd-thumbnail img", + "#contents.ytd-section-list-renderer ytd-video-renderer ytd-thumbnail img", +] as const; + +const BLUR_FILTER = "blur(12px)"; + +export async function setSearchThumbnailMode(mode: ThumbnailMode): Promise { + const resolution = await resolveTarget(STRATEGIES); + void setCapabilityStatus("search.thumbnails", "search", resolution); + applySearchThumbnailMode(mode); +} + +export function applySearchThumbnailMode(mode: ThumbnailMode): void { + const selector = [ + "ytd-search ytd-video-renderer ytd-thumbnail img#img", + "ytd-search ytd-video-renderer a.ytd-thumbnail img", + "#contents.ytd-section-list-renderer ytd-video-renderer ytd-thumbnail img", + ].join(", "); + + const images = document.querySelectorAll(selector) as NodeListOf; + for (const img of images) { + img.style.filter = mode === "blur" ? BLUR_FILTER : ""; + img.style.visibility = mode === "hide" ? "hidden" : ""; + } +} + +export function resetSearchThumbnails(): void { + applySearchThumbnailMode("show"); +} diff --git a/web-ext/app/content_scripts/primitives/shorts/index.ts b/web-ext/app/content_scripts/primitives/shorts/index.ts new file mode 100644 index 00000000..34da74cf --- /dev/null +++ b/web-ext/app/content_scripts/primitives/shorts/index.ts @@ -0,0 +1,5 @@ +export { + setShortsShelfVisible, + applyShortsShelfVisible, + resetShortsShelf, +} from "./shelf"; diff --git a/web-ext/app/content_scripts/primitives/shorts/shelf.ts b/web-ext/app/content_scripts/primitives/shorts/shelf.ts new file mode 100644 index 00000000..66dda9db --- /dev/null +++ b/web-ext/app/content_scripts/primitives/shorts/shelf.ts @@ -0,0 +1,39 @@ +/** + * Shorts scope — Shorts shelf visibility primitive. + * + * Hides the Shorts shelf from both the home page and search results. + * Uses a broad strategy list since the Shorts shelf appears in multiple + * YouTube page contexts. + */ + +import { resolveTarget } from "../../../../utils/primitive"; +import { setCapabilityStatus } from "../../../../utils/storage/capabilityCache"; + +/** Selectors that match the Shorts shelf in home + search. */ +const STRATEGIES = [ + "ytd-rich-section-renderer:has(ytd-rich-shelf-renderer[is-shorts])", + "ytd-reel-shelf-renderer", + "ytd-rich-shelf-renderer[is-shorts]", + "#shorts-container", + "ytd-shelf-renderer:has(ytd-reel-item-renderer)", +] as const; + +export async function setShortsShelfVisible(visible: boolean): Promise { + const resolution = await resolveTarget(STRATEGIES); + void setCapabilityStatus("shorts.shelf", "shorts", resolution); + + // Apply to all matching elements (shelf can appear multiple times on page). + applyShortsShelfVisible(visible); +} + +export function applyShortsShelfVisible(visible: boolean): void { + const selector = STRATEGIES.join(", "); + const els = document.querySelectorAll(selector) as NodeListOf; + for (const el of els) { + el.style.display = visible ? "" : "none"; + } +} + +export function resetShortsShelf(): void { + applyShortsShelfVisible(true); +} diff --git a/web-ext/app/content_scripts/primitives/watch/comments.ts b/web-ext/app/content_scripts/primitives/watch/comments.ts new file mode 100644 index 00000000..8ab93a33 --- /dev/null +++ b/web-ext/app/content_scripts/primitives/watch/comments.ts @@ -0,0 +1,49 @@ +import { resolveTarget } from "../../../../utils/primitive"; +import { setCapabilityStatus } from "../../../../utils/storage/capabilityCache"; + +// --------------------------------------------------------------------------- +// Comments section primitive +// +// Controls the comments section below the video on the watch page. +// --------------------------------------------------------------------------- + +const STRATEGIES = [ + "ytd-comments#comments", + "#comments", + "ytd-item-section-renderer #contents ytd-comments", + "ytd-watch-flexy #comments", +] as const; + +let hiddenByToppings = false; + +/** + * Hide or show the comments section. + * Writes the resolution result to the capability cache. + */ +export async function setCommentsVisible(visible: boolean): Promise { + const resolution = await resolveTarget(STRATEGIES); + void setCapabilityStatus("watch.comments", "watch", resolution); + + if (!resolution.resolved) return; + + const comments = resolution.element as HTMLElement; + if (visible) { + if (hiddenByToppings) { + comments.style.display = ""; + hiddenByToppings = false; + } + } else { + comments.style.display = "none"; + hiddenByToppings = true; + } +} + +/** Restore comments to their original state (called on navigation / teardown). */ +export function resetComments(): void { + if (!hiddenByToppings) return; + const el = STRATEGIES + .map((s) => document.querySelector(s)) + .find(Boolean) ?? null; + if (el) el.style.display = ""; + hiddenByToppings = false; +} diff --git a/web-ext/app/content_scripts/primitives/watch/endCards.ts b/web-ext/app/content_scripts/primitives/watch/endCards.ts new file mode 100644 index 00000000..4ea3d141 --- /dev/null +++ b/web-ext/app/content_scripts/primitives/watch/endCards.ts @@ -0,0 +1,57 @@ +import { resolveTarget } from "../../../../utils/primitive"; +import { setCapabilityStatus } from "../../../../utils/storage/capabilityCache"; + +// --------------------------------------------------------------------------- +// End screen cards primitive +// +// Controls the clickable overlay cards that appear in the final seconds +// of a YouTube video (subscribe prompts, related video cards, etc.). +// --------------------------------------------------------------------------- + +const STRATEGIES = [ + ".ytp-ce-element", + ".ytp-endscreen-element", + ".html5-endscreen", + ".ytp-player-content .ytp-ce-element", +] as const; + +/** + * Whether end cards are currently suppressed by Toppings. + * We use CSS visibility rather than display:none to preserve layout flow. + */ +let suppressedByToppings = false; + +/** + * Hide or show end screen cards. + * Writes the resolution result to the capability cache. + * + * Note: we target the container rather than individual cards so that + * cards injected dynamically (e.g. mid-video) are also covered. + */ +export async function setEndCardsVisible(visible: boolean): Promise { + const resolution = await resolveTarget(STRATEGIES); + void setCapabilityStatus("watch.endCards", "watch", resolution); + + if (!resolution.resolved) return; + + const container = resolution.element as HTMLElement; + if (visible) { + if (suppressedByToppings) { + container.style.display = ""; + suppressedByToppings = false; + } + } else { + container.style.display = "none"; + suppressedByToppings = true; + } +} + +/** Restore end cards to their original state (called on navigation / teardown). */ +export function resetEndCards(): void { + if (!suppressedByToppings) return; + const el = STRATEGIES + .map((s) => document.querySelector(s)) + .find(Boolean) ?? null; + if (el) el.style.display = ""; + suppressedByToppings = false; +} diff --git a/web-ext/app/content_scripts/primitives/watch/index.ts b/web-ext/app/content_scripts/primitives/watch/index.ts new file mode 100644 index 00000000..9a8b2889 --- /dev/null +++ b/web-ext/app/content_scripts/primitives/watch/index.ts @@ -0,0 +1,3 @@ +export { setSidebarVisible, resetSidebar } from "./sidebar"; +export { setCommentsVisible, resetComments } from "./comments"; +export { setEndCardsVisible, resetEndCards } from "./endCards"; diff --git a/web-ext/app/content_scripts/primitives/watch/sidebar.ts b/web-ext/app/content_scripts/primitives/watch/sidebar.ts new file mode 100644 index 00000000..1c458132 --- /dev/null +++ b/web-ext/app/content_scripts/primitives/watch/sidebar.ts @@ -0,0 +1,50 @@ +import { resolveTarget } from "../../../../utils/primitive"; +import { setCapabilityStatus } from "../../../../utils/storage/capabilityCache"; + +// --------------------------------------------------------------------------- +// Recommendations sidebar primitive +// +// Controls the "Up next" / related videos panel on the watch page. +// --------------------------------------------------------------------------- + +const STRATEGIES = [ + "#secondary", + "ytd-watch-next-secondary-results-renderer", + "#related", + "ytd-watch-flexy #secondary-inner", +] as const; + +let hiddenByToppings = false; + +/** + * Hide or show the recommendations sidebar. + * Writes the resolution result to the capability cache so the options UI + * can surface this primitive as supported or unsupported. + */ +export async function setSidebarVisible(visible: boolean): Promise { + const resolution = await resolveTarget(STRATEGIES); + void setCapabilityStatus("watch.sidebar", "watch", resolution); + + if (!resolution.resolved) return; + + const sidebar = resolution.element as HTMLElement; + if (visible) { + if (hiddenByToppings) { + sidebar.style.display = ""; + hiddenByToppings = false; + } + } else { + sidebar.style.display = "none"; + hiddenByToppings = true; + } +} + +/** Restore sidebar to its original state (called on navigation / teardown). */ +export function resetSidebar(): void { + if (!hiddenByToppings) return; + const el = STRATEGIES + .map((s) => document.querySelector(s)) + .find(Boolean) ?? null; + if (el) el.style.display = ""; + hiddenByToppings = false; +} diff --git a/web-ext/app/content_scripts/utils/pageToast.ts b/web-ext/app/content_scripts/utils/pageToast.ts new file mode 100644 index 00000000..8f787d14 --- /dev/null +++ b/web-ext/app/content_scripts/utils/pageToast.ts @@ -0,0 +1,59 @@ +/** + * Lightweight DOM toast for on-page feedback in content scripts. + * + * Creates a single shared toast element (TPPNG_TOAST_ID) and reuses it + * across calls, so rapid successive calls replace the current message rather + * than stacking. Auto-dismisses after `duration` ms. + */ + +const TPPNG_TOAST_ID = "tppng-page-toast"; +let dismissTimer: ReturnType | null = null; + +export function showPageToast(message: string, duration = 2000): void { + let el = document.getElementById(TPPNG_TOAST_ID); + + if (!el) { + el = document.createElement("div"); + el.id = TPPNG_TOAST_ID; + + // Inline styles — no class dependency on YouTube's stylesheet. + Object.assign(el.style, { + position: "fixed", + bottom: "80px", + left: "50%", + transform: "translateX(-50%)", + zIndex: "2147483647", + padding: "8px 16px", + borderRadius: "8px", + background: "rgba(15,15,15,0.88)", + backdropFilter: "blur(8px)", + color: "#fff", + fontFamily: "system-ui, sans-serif", + fontSize: "13px", + fontWeight: "500", + lineHeight: "1.4", + letterSpacing: "0.01em", + boxShadow: "0 4px 16px rgba(0,0,0,0.3)", + pointerEvents: "none", + userSelect: "none", + opacity: "0", + transition: "opacity 0.15s ease", + whiteSpace: "nowrap", + }); + + document.body.appendChild(el); + } + + // Update text and reset timer. + el.textContent = message; + + // Force reflow so the transition fires even if opacity was already 1. + void (el as HTMLElement).offsetHeight; + el.style.opacity = "1"; + + if (dismissTimer !== null) clearTimeout(dismissTimer); + dismissTimer = setTimeout(() => { + if (el) el.style.opacity = "0"; + dismissTimer = null; + }, duration); +} diff --git a/web-ext/src/manifest.json b/web-ext/app/manifest.json similarity index 75% rename from web-ext/src/manifest.json rename to web-ext/app/manifest.json index e27a0b6f..4b4e7405 100644 --- a/web-ext/src/manifest.json +++ b/web-ext/app/manifest.json @@ -18,15 +18,20 @@ "content_scripts": [ { "all_frames": true, - "js": ["/content.js"], - "matches": ["https://www.youtube.com/*"], + "js": [ + "/content.js" + ], + "matches": [ + "https://www.youtube.com/*" + ], "run_at": "document_end" } ], "default_locale": "en", "description": "__MSG_description__", - "homepage_url": "https://enrych.github.io/toppings", - "host_permissions": ["*://*.youtube.com/*"], + "host_permissions": [ + "*://*.youtube.com/*" + ], "icons": { "16": "assets/icons/icon16.png", "32": "assets/icons/icon32.png", @@ -39,13 +44,19 @@ "open_in_tab": true, "page": "/options/index.html" }, - "permissions": ["webNavigation", "storage"], + "permissions": [ + "webNavigation", + "storage" + ], "short_name": "Toppings", - "version": "0.0.0", "web_accessible_resources": [ { - "matches": ["https://*/*"], - "resources": ["assets/*"] + "matches": [ + "https://*/*" + ], + "resources": [ + "assets/*" + ] } ] } diff --git a/web-ext/app/options/App.tsx b/web-ext/app/options/App.tsx new file mode 100644 index 00000000..04574b8a --- /dev/null +++ b/web-ext/app/options/App.tsx @@ -0,0 +1,30 @@ +import React, { useState } from "react"; +import { Outlet, useLoaderData, useLocation } from "react-router-dom"; +import StoreContext from "../../context/store"; +import { Storage } from "../background/store"; +import ThemeApplier from "../../components/ThemeApplier"; +import AppLayout from "./components/layout/AppLayout"; +import { ToastProvider } from "../../components/feedback/ToastProvider"; +import { ConfirmProvider } from "../../components/feedback/ConfirmProvider"; +import { SECTION_RAILS } from "./routing"; + +export default function App() { + const loadedStore = useLoaderData() as Storage; + const [store, setStore] = useState(loadedStore); + const location = useLocation(); + const Rail = SECTION_RAILS[location.pathname]; + + return ( + + + + + : undefined}> + + + + + + + ); +} diff --git a/web-ext/app/options/components/layout/AppLayout.tsx b/web-ext/app/options/components/layout/AppLayout.tsx new file mode 100644 index 00000000..839bd8e9 --- /dev/null +++ b/web-ext/app/options/components/layout/AppLayout.tsx @@ -0,0 +1,25 @@ +import React, { ReactNode } from "react"; +import Sidebar from "./Sidebar"; + +interface AppLayoutProps { + children: ReactNode; + rightRail?: ReactNode; +} + +export default function AppLayout({ children, rightRail }: AppLayoutProps) { + return ( +
+ +
+
+
{children}
+ {rightRail && ( +
+ {rightRail} +
+ )} +
+
+
+ ); +} diff --git a/web-ext/app/options/components/layout/Sidebar.tsx b/web-ext/app/options/components/layout/Sidebar.tsx new file mode 100644 index 00000000..4085f506 --- /dev/null +++ b/web-ext/app/options/components/layout/Sidebar.tsx @@ -0,0 +1,123 @@ +import React from "react"; +import Icon, { IconName } from "../../../../components/primitives/Icon"; +import IconButton from "../../../../components/primitives/IconButton"; +import Tooltip from "../../../../components/primitives/Tooltip"; +import { useChromeStorageLocal } from "../../../../hooks/useChromeStorageLocal"; +import { useStoreUpdater } from "../../../../hooks/useStoreUpdater"; +import { BRAND_METADATA } from "@toppings/constants"; +import { EXTENSION_LOCAL_STORAGE_KEY } from "../../../../data/extension.data"; +import { OPTIONS_ICON_SRC, OPTIONS_PAGES } from "../../data"; +import { ThemePreference } from "../../../../utils/theme"; +import SidebarNavLink, { SidebarNavItem } from "./SidebarNavLink"; +import SidebarThemeToggle from "./SidebarThemeToggle"; +import OptionsSearch from "../../search/OptionsSearch"; + +const NAV_ITEMS: SidebarNavItem[] = OPTIONS_PAGES.map((page) => ({ + to: page.path, + label: page.label, + icon: page.icon as IconName, +})); + +const COLLAPSE_LABEL_EXPAND = "Expand sidebar"; +const COLLAPSE_LABEL_COLLAPSE = "Collapse sidebar"; + +export default function Sidebar() { + const version = chrome.runtime.getManifest().version; + const [collapsed, setCollapsed] = useChromeStorageLocal( + EXTENSION_LOCAL_STORAGE_KEY.OPTIONS_SIDEBAR_COLLAPSED, + false, + ); + const { store, update } = useStoreUpdater(); + const themePref: ThemePreference = store.ui?.theme ?? "system"; + + const setTheme = (next: ThemePreference) => { + update((d) => { + if (!d.ui) d.ui = { theme: "system" }; + d.ui.theme = next; + }); + }; + + return ( + + ); +} diff --git a/web-ext/app/options/components/layout/SidebarNavLink.tsx b/web-ext/app/options/components/layout/SidebarNavLink.tsx new file mode 100644 index 00000000..035fc92d --- /dev/null +++ b/web-ext/app/options/components/layout/SidebarNavLink.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import { NavLink } from "react-router-dom"; +import Icon, { IconName } from "../../../../components/primitives/Icon"; +import { OPTIONS_PAGES } from "../../data"; + +export interface SidebarNavItem { + to: string; + label: string; + icon: IconName; +} + +export default function SidebarNavLink({ + item, + collapsed, +}: { + item: SidebarNavItem; + collapsed: boolean; +}) { + return ( + + `tw-flex tw-items-center tw-rounded-md tw-transition-colors tw-text-sm ${ + collapsed + ? "tw-justify-center tw-w-10 tw-h-10 tw-mx-auto" + : "tw-gap-2.5 tw-px-2.5 tw-py-2" + } ${ + isActive + ? "tw-bg-surface-hover tw-text-fg" + : "tw-text-fg-muted hover:tw-bg-surface-hover hover:tw-text-fg" + }` + } + > + {({ isActive }) => ( + <> + + + + {!collapsed && {item.label}} + + )} + + ); +} diff --git a/web-ext/app/options/components/layout/SidebarThemeToggle.tsx b/web-ext/app/options/components/layout/SidebarThemeToggle.tsx new file mode 100644 index 00000000..3c710f7f --- /dev/null +++ b/web-ext/app/options/components/layout/SidebarThemeToggle.tsx @@ -0,0 +1,45 @@ +import React from "react"; +import { ThemePreference } from "../../../../utils/theme"; + +const THEME_OPTIONS: { id: ThemePreference; label: string }[] = [ + { id: "system", label: "System" }, + { id: "dark", label: "Dark" }, + { id: "light", label: "Light" }, +]; + +export default function SidebarThemeToggle({ + value, + onChange, +}: { + value: ThemePreference; + onChange: (next: ThemePreference) => void; +}) { + return ( +
+ {THEME_OPTIONS.map((opt) => { + const active = value === opt.id; + return ( + + ); + })} +
+ ); +} diff --git a/web-ext/app/options/data.ts b/web-ext/app/options/data.ts new file mode 100644 index 00000000..203b6729 --- /dev/null +++ b/web-ext/app/options/data.ts @@ -0,0 +1,49 @@ +import { EXTENSION_CONTEXT_SCOPE } from "@toppings/constants"; + +export const OPTIONS_HTML = "options/index.html"; + +export const OPTIONS_ICON_SRC = "/assets/icons/icon48.png"; + +export type ExtensionOptionsPage = { + readonly segment: string; + readonly path: string; + readonly label: string; + readonly icon: string; + readonly sectionNav?: true; +}; + +export const OPTIONS_PAGES = [ + { segment: "", path: "/", label: "General", icon: "general" }, + { + segment: EXTENSION_CONTEXT_SCOPE.WATCH, + path: "/watch", + label: "Watch", + icon: "watch", + sectionNav: true, + }, + { + segment: EXTENSION_CONTEXT_SCOPE.SHORTS, + path: "/shorts", + label: "Shorts", + icon: "shorts", + }, + { + segment: EXTENSION_CONTEXT_SCOPE.PLAYLIST, + path: "/playlist", + label: "Playlist", + icon: "playlist", + }, + { + segment: "keybindings", + path: "/keybindings", + label: "Shortcuts", + icon: "keyboard", + sectionNav: true, + }, + { + segment: "profiles", + path: "/profiles", + label: "Profiles", + icon: "profiles", + }, +] as const satisfies readonly ExtensionOptionsPage[]; diff --git a/web-ext/src/popup/index.css b/web-ext/app/options/index.css similarity index 93% rename from web-ext/src/popup/index.css rename to web-ext/app/options/index.css index 135c24b0..6c5e089d 100644 --- a/web-ext/src/popup/index.css +++ b/web-ext/app/options/index.css @@ -2,6 +2,8 @@ @tailwind components; @tailwind utilities; +@import "../../theme.css"; + /* 1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) 2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116) @@ -420,6 +422,25 @@ video { display: none; } +/* Search highlight in dropdown results */ +.tppng-search-highlight { + background: #fbbf24; + color: #000; + border-radius: 2px; + padding: 0 1px; +} + +/* Flash animation for section headers after search navigation */ +@keyframes tppng-section-flash { + 0% { background: transparent; } + 20% { background: color-mix(in srgb, #fbbf24 30%, transparent); border-radius: 8px; } + 100% { background: transparent; } +} + +.tppng-section-flash { + animation: tppng-section-flash 900ms ease-out forwards; +} + :root { font-synthesis: none; text-rendering: optimizeLegibility; @@ -434,10 +455,11 @@ video { } ::-webkit-scrollbar-thumb { - background: #18181b; + background: var(--color-border-default); } ::-webkit-scrollbar-thumb:hover { - background: #555; + background: var(--color-border-strong); } } + diff --git a/web-ext/src/options/index.html b/web-ext/app/options/index.html similarity index 100% rename from web-ext/src/options/index.html rename to web-ext/app/options/index.html diff --git a/web-ext/app/options/index.tsx b/web-ext/app/options/index.tsx new file mode 100644 index 00000000..95fc5e70 --- /dev/null +++ b/web-ext/app/options/index.tsx @@ -0,0 +1,11 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { RouterProvider } from "react-router-dom"; +import { optionsRouter } from "./router"; +import "./index.css"; + +ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( + + + , +); diff --git a/web-ext/app/options/router.tsx b/web-ext/app/options/router.tsx new file mode 100644 index 00000000..a08a9701 --- /dev/null +++ b/web-ext/app/options/router.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import { createMemoryRouter } from "react-router-dom"; +import { OPTIONS_PAGES } from "./data"; +import { getStorage } from "../background/store"; +import App from "./App"; +import General from "./routes/General"; +import Watch from "./routes/Watch"; +import Shorts from "./routes/Shorts"; +import Playlist from "./routes/Playlist"; +import Keybindings from "./routes/Keybindings"; +import Profiles from "./routes/Profiles"; +import ErrorPage from "./routes/error-page"; + +const ROUTE_BY_SEGMENT: Record = { + "": , + watch: , + shorts: , + playlist: , + keybindings: , + profiles: , +}; + +export const optionsRouter = createMemoryRouter([ + { + path: OPTIONS_PAGES[0].path, + element: , + loader: getStorage, + errorElement: , + children: OPTIONS_PAGES.map((page) => ({ + path: page.segment, + element: ROUTE_BY_SEGMENT[page.segment], + })), + }, +]); diff --git a/web-ext/app/options/routes/AudioMode.tsx b/web-ext/app/options/routes/AudioMode.tsx new file mode 100644 index 00000000..dec8b393 --- /dev/null +++ b/web-ext/app/options/routes/AudioMode.tsx @@ -0,0 +1,228 @@ +import React from "react"; +import PageHeader from "../../../components/layout/PageHeader"; +import Section from "../../../components/layout/Section"; +import Card from "../../../components/layout/Card"; +import Switch from "../../../components/form/Switch"; +import Keybinding from "../../../components/form/Keybinding"; +import Input from "../../../components/form/Input"; +import Select from "../../../components/form/Select"; +import Slider from "../../../components/form/Slider"; +import FilePicker from "../../../components/form/FilePicker"; +import Field from "../../../components/form/Field"; +import Button from "../../../components/primitives/Button"; +import Badge from "../../../components/primitives/Badge"; +import Icon from "../../../components/primitives/Icon"; +import { useStoreUpdater } from "../../../hooks/useStoreUpdater"; +import { useToast } from "../../../components/feedback/ToastProvider"; +import { useConfirm } from "../../../components/feedback/ConfirmProvider"; +import { useChromeStorageLocal } from "../../../hooks/useChromeStorageLocal"; +import { useVideoPreference } from "../../../hooks/useVideoPreference"; +import { clearAllAudioModePins } from "../../../utils/storage/videoPreference"; +import { EXTENSION_LOCAL_STORAGE_KEY } from "../../../data/extension.data"; + +export default function AudioMode() { + const { store, update } = useStoreUpdater(); + const toast = useToast(); + const confirm = useConfirm(); + const am = store.preferences.watch.audioMode; + const [localImage, setLocalImage] = useChromeStorageLocal( + EXTENSION_LOCAL_STORAGE_KEY.AUDIO_MODE_GLOBAL_CUSTOM_IMAGE, + null, + ); + const { preferences: videoPreferences, refresh: refreshVideoPreferences } = + useVideoPreference(); + const pinnedCount = videoPreferences.audioMode.pinCount; + + const handlePickImage = (file: File) => { + const reader = new FileReader(); + reader.onload = () => { + if (typeof reader.result === "string") { + setLocalImage(reader.result); + toast.success("Background updated", file.name); + } + }; + reader.onerror = () => toast.error("Failed to read image"); + reader.readAsDataURL(file); + }; + + const handleClearImage = () => { + setLocalImage(null); + toast.info("Background cleared"); + }; + + const handleResetPins = async () => { + if (pinnedCount === 0) return; + const ok = await confirm({ + title: "Reset all pinned videos?", + message: `This will clear ${pinnedCount} video-specific audio mode override${pinnedCount === 1 ? "" : "s"}. This action cannot be undone.`, + confirmLabel: "Reset all", + danger: true, + }); + if (!ok) return; + const cleared = await clearAllAudioModePins(); + refreshVideoPreferences(); + toast.success( + "Pinned videos reset", + `${cleared} pin${cleared === 1 ? "" : "s"} cleared`, + ); + }; + + return ( + <> + + +
+
+ + { + update((draft) => { + draft.preferences.watch.audioMode.isEnabled = isEnabled; + }); + }} + /> + { + update((draft) => { + draft.preferences.watch.audioMode.toggleAudioMode.key = key; + }); + }} + /> + +
+ +
+ + + label="Default Screen Mode" + description="Black is fastest. Visualizer reacts to audio. Custom shows your background image." + value={am.screenMode} + options={[ + { + value: "black", + label: "Black Screen", + description: "Solid black background.", + }, + { + value: "visualizer", + label: "Visualizer", + description: "Animated waveform that reacts to audio.", + }, + { + value: "custom", + label: "Custom Image", + description: "Your own background image.", + }, + ]} + onChange={(mode) => { + update((draft) => { + draft.preferences.watch.audioMode.screenMode = mode; + }); + }} + /> + +
+ +
+ + `${v.toFixed(2)}×`} + onChange={(value) => { + update((draft) => { + draft.preferences.watch.audioMode.visualizerSensitivity = + value.toFixed(2); + }); + }} + /> + +
+ +
+ + + { + update((draft) => { + draft.preferences.watch.audioMode.customBackground.globalImageUrl = + value.trim(); + }); + }} + /> + +
+ +
+ + +
+ 0 ? "info" : "neutral"}> + {pinnedCount === 0 + ? "No pinned videos" + : `${pinnedCount} pinned`} + + +
+
+
+
+
+ + ); +} diff --git a/web-ext/app/options/routes/General.tsx b/web-ext/app/options/routes/General.tsx new file mode 100644 index 00000000..566d667a --- /dev/null +++ b/web-ext/app/options/routes/General.tsx @@ -0,0 +1,205 @@ +import React, { useState } from "react"; +import PageHeader from "../../../components/layout/PageHeader"; +import Section from "../../../components/layout/Section"; +import Card from "../../../components/layout/Card"; +import Switch from "../../../components/form/Switch"; +import Select from "../../../components/form/Select"; +import { useStoreUpdater } from "../../../hooks/useStoreUpdater"; +import { useToast } from "../../../components/feedback/ToastProvider"; +import { useCapabilityCache } from "../../../hooks/useCapabilityCache"; +import { setExtensionIcon } from "../../../utils/browser"; +import { ThemePreference } from "../../../utils/theme"; + +export default function General() { + const { store, update } = useStoreUpdater(); + const toast = useToast(); + const { rescan } = useCapabilityCache(); + const [isRescanning, setIsRescanning] = useState(false); + + return ( + <> + + +
+
+ + { + update((draft) => { + draft.isExtensionEnabled = isEnabled; + }); + setExtensionIcon(!isEnabled); + toast.success( + isEnabled ? "Extension enabled" : "Extension disabled", + ); + }} + /> + +
+ +
+ + + label="Theme" + description="System follows your OS appearance. Affects the popup and options UI — YouTube itself is unaffected." + value={store.ui?.theme ?? "system"} + options={[ + { + value: "system", + label: "System", + description: "Match your operating system", + }, + { + value: "dark", + label: "Dark", + description: "Always use dark theme", + }, + { + value: "light", + label: "Light", + description: "Always use light theme", + }, + ]} + onChange={(theme) => { + update((draft) => { + if (!draft.ui) draft.ui = { theme: "system" }; + draft.ui.theme = theme; + }); + }} + /> + +
+ +
+ + { + toast.info("The popup switcher is always available."); + }} + /> + { + update((draft) => { + if (!draft.ui) draft.ui = { theme: "system", gearMenuEnabled: false, nativeSettingsEnabled: false }; + draft.ui.gearMenuEnabled = isEnabled; + }); + toast.success( + isEnabled + ? "Gear menu injection enabled — open the ⚙ menu on a video to try it" + : "Gear menu injection disabled", + ); + }} + /> + { + update((draft) => { + if (!draft.ui) draft.ui = { theme: "system", gearMenuEnabled: false, nativeSettingsEnabled: false }; + draft.ui.nativeSettingsEnabled = isEnabled; + }); + toast.success( + isEnabled + ? "Sidebar entry enabled — visit YouTube to see it" + : "Sidebar entry disabled", + ); + }} + /> + +
+ +
+ +
+
+ + Re-scan Capabilities + + + Clears the cached feature compatibility check. Features will + be re-tested next time you visit a YouTube page. + +
+ +
+
+
+ +
+ + { + update((draft) => { + draft.preferences.watch.isEnabled = isEnabled; + }); + }} + /> + { + update((draft) => { + draft.preferences.shorts.isEnabled = isEnabled; + }); + }} + /> + { + update((draft) => { + draft.preferences.playlist.isEnabled = isEnabled; + }); + }} + /> + +
+
+ + ); +} diff --git a/web-ext/app/options/routes/Keybindings.tsx b/web-ext/app/options/routes/Keybindings.tsx new file mode 100644 index 00000000..43b6a099 --- /dev/null +++ b/web-ext/app/options/routes/Keybindings.tsx @@ -0,0 +1,296 @@ +import React from "react"; +import PageHeader from "../../../components/layout/PageHeader"; +import Section from "../../../components/layout/Section"; +import Card from "../../../components/layout/Card"; +import Keybinding from "../../../components/form/Keybinding"; +import Input from "../../../components/form/Input"; +import { useStoreUpdater } from "../../../hooks/useStoreUpdater"; + +export default function Keybindings() { + const { store, update } = useStoreUpdater(); + const w = store.preferences.watch; + const s = store.preferences.shorts; + const nudge = w.nudgeLoopSegment; + const seg = w.segments; + + return ( + <> + + +
+
+ + { + update((draft) => { + draft.preferences.watch.togglePlaybackRate.key = key; + }); + }} + /> + { + update((draft) => { + draft.preferences.watch.increasePlaybackRate.key = key; + }); + }} + /> + { + update((draft) => { + draft.preferences.watch.decreasePlaybackRate.key = key; + }); + }} + /> + { + update((draft) => { + draft.preferences.watch.seekBackward.key = key; + }); + }} + /> + { + update((draft) => { + draft.preferences.watch.seekForward.key = key; + }); + }} + /> + { + update((draft) => { + draft.preferences.watch.toggleLoopSegment.key = key; + }); + }} + /> + { + update((draft) => { + if (!draft.preferences.watch.segments) { + draft.preferences.watch.segments = { freshSlateKey: key }; + } else { + draft.preferences.watch.segments.freshSlateKey = key; + } + }); + }} + /> + { + update((draft) => { + draft.preferences.watch.setLoopSegmentBegin.key = key; + }); + }} + /> + { + update((draft) => { + draft.preferences.watch.setLoopSegmentEnd.key = key; + }); + }} + /> + { + update((draft) => { + if (!draft.preferences.watch.saveLoopSegment) { + draft.preferences.watch.saveLoopSegment = { key }; + } else { + draft.preferences.watch.saveLoopSegment.key = key; + } + }); + }} + /> + +
+ +
+ + { + update((draft) => { + draft.preferences.shorts.togglePlaybackRate.key = key; + }); + }} + /> + { + update((draft) => { + draft.preferences.shorts.seekBackward.key = key; + }); + }} + /> + { + update((draft) => { + draft.preferences.shorts.seekForward.key = key; + }); + }} + /> + +
+ +
+ + { + update((draft) => { + if (!draft.preferences.watch.cycleProfiles) { + draft.preferences.watch.cycleProfiles = { key }; + } else { + draft.preferences.watch.cycleProfiles.key = key; + } + }); + }} + /> + +
+ +
+ + { + update((draft) => { + if (!draft.preferences.watch.nudgeLoopSegment) return; + draft.preferences.watch.nudgeLoopSegment.startBackwardKey = key; + }); + }} + /> + { + update((draft) => { + if (!draft.preferences.watch.nudgeLoopSegment) return; + draft.preferences.watch.nudgeLoopSegment.startForwardKey = key; + }); + }} + /> + { + update((draft) => { + if (!draft.preferences.watch.nudgeLoopSegment) return; + draft.preferences.watch.nudgeLoopSegment.endForwardKey = key; + }); + }} + /> + { + update((draft) => { + if (!draft.preferences.watch.nudgeLoopSegment) return; + draft.preferences.watch.nudgeLoopSegment.endBackwardKey = key; + }); + }} + /> + + + !isNaN(parseFloat(v)) && parseFloat(v) > 0} + errorMessage="Must be a positive number." + onChange={(val) => { + update((draft) => { + if (!draft.preferences.watch.nudgeLoopSegment) return; + draft.preferences.watch.nudgeLoopSegment.baseStep = val; + }); + }} + /> + !isNaN(parseFloat(v)) && parseFloat(v) >= 1} + errorMessage="Must be ≥ 1." + onChange={(val) => { + update((draft) => { + if (!draft.preferences.watch.nudgeLoopSegment) return; + draft.preferences.watch.nudgeLoopSegment.multiplier = val; + }); + }} + /> + !isNaN(parseFloat(v)) && parseFloat(v) > 0} + errorMessage="Must be a positive number." + onChange={(val) => { + update((draft) => { + if (!draft.preferences.watch.nudgeLoopSegment) return; + draft.preferences.watch.nudgeLoopSegment.maxStep = val; + }); + }} + /> + +
+
+ + ); +} diff --git a/web-ext/app/options/routes/Playlist.tsx b/web-ext/app/options/routes/Playlist.tsx new file mode 100644 index 00000000..e8d98753 --- /dev/null +++ b/web-ext/app/options/routes/Playlist.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import { Link } from "react-router-dom"; +import PageHeader from "../../../components/layout/PageHeader"; +import Section from "../../../components/layout/Section"; +import Card from "../../../components/layout/Card"; + +export default function Playlist() { + return ( + <> + + +
+
+ +

+ The Playlist feature toggle lives on the{" "} + + General + {" "} + page. There are no other playlist-specific settings yet. +

+
+
+
+ + ); +} diff --git a/web-ext/app/options/routes/Profiles.tsx b/web-ext/app/options/routes/Profiles.tsx new file mode 100644 index 00000000..198bfde1 --- /dev/null +++ b/web-ext/app/options/routes/Profiles.tsx @@ -0,0 +1,643 @@ +import React, { useEffect, useState } from "react"; +import PageHeader from "../../../components/layout/PageHeader"; +import Section from "../../../components/layout/Section"; +import Card from "../../../components/layout/Card"; +import Switch from "../../../components/form/Switch"; +import Select from "../../../components/form/Select"; +import { useToast } from "../../../components/feedback/ToastProvider"; +import { useCapabilityCache } from "../../../hooks/useCapabilityCache"; +import { + getAllProfiles, + getActiveProfile, + setActiveProfileId, + createProfile, + updateProfile, + deleteProfile, +} from "../../../utils/storage/profileStore"; +import { + exportProfile, + importProfileFromFile, +} from "../../../utils/storage/profileImportExport"; +import { + BUILT_IN_PRESETS, + type Profile, + type ProfilePrimitiveConfig, + type ThumbnailMode, +} from "../../../data/profiles.data"; +import type { PlayerLayout, PlayerVisuals } from "../../../utils/primitive"; + +// --------------------------------------------------------------------------- +// Blank profile template +// --------------------------------------------------------------------------- + +function blankPrimitives(): ProfilePrimitiveConfig { + return { + "watch.sidebar": { visible: true }, + "watch.comments": { visible: true }, + "watch.endCards": { visible: true }, + "watch.layout": { value: "default" }, + "watch.visuals": { value: "video" }, + }; +} + +// --------------------------------------------------------------------------- +// Profile editor (inline) +// --------------------------------------------------------------------------- + +interface ProfileEditorProps { + initial: Partial; + onSave: (name: string, primitives: ProfilePrimitiveConfig) => Promise; + onCancel: () => void; + getStatus: (id: string) => "supported" | "unsupported" | "untested"; +} + +function ProfileEditor({ + initial, + onSave, + onCancel, + getStatus, +}: ProfileEditorProps) { + const [name, setName] = useState(initial.name ?? ""); + const [primitives, setPrimitives] = useState( + initial.primitives ?? blankPrimitives(), + ); + const [isSaving, setIsSaving] = useState(false); + + const layout = primitives["watch.layout"]?.value ?? "default"; + const noVideo = layout === "no-video"; + + const set = ( + key: K, + value: ProfilePrimitiveConfig[K], + ) => setPrimitives((prev) => ({ ...prev, [key]: value })); + + const handleSave = async () => { + if (!name.trim()) return; + setIsSaving(true); + await onSave(name.trim(), primitives); + setIsSaving(false); + }; + + const isUnsupported = (id: string) => getStatus(id) === "unsupported"; + + return ( +
+ {/* Name */} +
+ + setName(e.currentTarget.value)} + placeholder="e.g. Study session" + maxLength={40} + className="tw-w-full tw-rounded-lg tw-border tw-border-border-default tw-bg-surface tw-px-3 tw-py-2 tw-text-sm tw-text-fg placeholder:tw-text-fg-subtle focus:tw-outline-none focus:tw-ring-2 focus:tw-ring-accent/50" + /> +
+ + {/* Watch scope */} +
+ + Watch page + + +
+ {/* Layout */} +
+ + label="Player Layout" + description={ + isUnsupported("watch.layout") + ? "Not available on your YouTube" + : "How the video player is arranged on the page." + } + value={layout} + options={[ + { value: "default", label: "Default", description: "YouTube's standard layout" }, + { value: "theatre", label: "Theatre", description: "Wider video, narrower sidebar" }, + { value: "no-video", label: "No Video", description: "Hide the player area entirely" }, + ]} + onChange={(v) => set("watch.layout", { value: v })} + /> +
+ + {/* Visuals — disabled when no-video */} +
+ + label="Player Visuals" + description={ + noVideo + ? "Not applicable when layout is No Video" + : isUnsupported("watch.visuals") + ? "Not available on your YouTube" + : "What fills the video slot when the player is visible." + } + value={primitives["watch.visuals"]?.value ?? "video"} + options={[ + { value: "video", label: "Real Video", description: "Normal video playback" }, + { value: "black", label: "Black Screen", description: "Audio only with black overlay" }, + { value: "visualizer", label: "Visualizer", description: "Audio visualizer animation" }, + { value: "custom", label: "Custom Image", description: "Your uploaded background image" }, + ]} + onChange={(v) => set("watch.visuals", { value: v })} + /> +
+ + {/* Sidebar */} +
+ set("watch.sidebar", { visible: v })} + /> +
+ + {/* Comments */} +
+ set("watch.comments", { visible: v })} + /> +
+ + {/* End cards */} +
+ set("watch.endCards", { visible: v })} + /> +
+
+
+ + {/* Home scope */} +
+ + Home page + +
+
+ + label="Feed Thumbnails" + description="Show, hide, or blur thumbnail images in the home feed." + value={primitives["home.thumbnails"]?.mode ?? "show"} + options={[ + { value: "show", label: "Show", description: "Normal thumbnails" }, + { value: "blur", label: "Blur", description: "Blurred — layout preserved" }, + { value: "hide", label: "Hide", description: "Invisible thumbnails" }, + ]} + onChange={(v) => set("home.thumbnails", { mode: v })} + /> +
+
+ set("home.feed", { visible: v })} + /> +
+
+ set("home.shorts", { visible: v })} + /> +
+
+
+ + {/* Search scope */} +
+ + Search + +
+
+ + label="Result Thumbnails" + description="Show, hide, or blur thumbnails in search results." + value={primitives["search.thumbnails"]?.mode ?? "show"} + options={[ + { value: "show", label: "Show", description: "Normal thumbnails" }, + { value: "blur", label: "Blur", description: "Blurred — layout preserved" }, + { value: "hide", label: "Hide", description: "Plain text list" }, + ]} + onChange={(v) => set("search.thumbnails", { mode: v })} + /> +
+
+ set("search.metadata", { visible: v })} + /> +
+
+ set("search.shorts", { visible: v })} + /> +
+
+
+ + {/* Shorts shelf (cross-page) */} +
+ + Shorts + +
+
+ set("shorts.shelf", { visible: v })} + /> +
+
+
+ + {/* Actions */} +
+ + +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Preset card (read-only) +// --------------------------------------------------------------------------- + +interface PresetCardProps { + profile: Profile; + isActive: boolean; + onActivate: () => void; +} + +function PresetCard({ profile, isActive, onActivate }: PresetCardProps) { + const primitiveLabels: Record = { + "watch.layout": "Layout", + "watch.visuals": "Visuals", + "watch.sidebar": "Sidebar", + "watch.comments": "Comments", + "watch.endCards": "End Cards", + "home.thumbnails": "Home Thumbnails", + "home.feed": "Home Feed", + "home.shorts": "Home Shorts", + "search.thumbnails": "Search Thumbnails", + "search.metadata": "Search Metadata", + "search.shorts": "Search Shorts", + "shorts.shelf": "Shorts Shelf", + }; + + const summaryParts = Object.entries(profile.primitives) + .map(([key, val]) => { + const label = primitiveLabels[key] ?? key; + if ("visible" in val) return `${label}: ${val.visible ? "on" : "off"}`; + if ("mode" in val) return `${label}: ${val.mode}`; + if ("value" in val) return `${label}: ${val.value}`; + return label; + }) + .join(" · "); + + return ( +
+
+
+ + {profile.name} + + + Built-in + + {isActive && ( + + Active + + )} +
+ {summaryParts} +
+ +
+ ); +} + +// --------------------------------------------------------------------------- +// Custom profile card +// --------------------------------------------------------------------------- + +interface CustomProfileCardProps { + profile: Profile; + isActive: boolean; + onActivate: () => void; + onEdit: () => void; + onDelete: () => void; +} + +function CustomProfileCard({ + profile, + isActive, + onActivate, + onEdit, + onDelete, +}: CustomProfileCardProps) { + return ( +
+
+
+ + {profile.name} + + {isActive && ( + + Active + + )} +
+ + {Object.keys(profile.primitives).length} primitive + {Object.keys(profile.primitives).length !== 1 ? "s" : ""} configured + +
+
+ + + + +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Main Profiles page +// --------------------------------------------------------------------------- + +type EditorMode = + | { type: "closed" } + | { type: "create" } + | { type: "edit"; profile: Profile }; + +export default function Profiles() { + const toast = useToast(); + const { getStatus } = useCapabilityCache(); + + const [profiles, setProfiles] = useState([]); + const [activeId, setActiveId] = useState(null); + const [editor, setEditor] = useState({ type: "closed" }); + + const load = async () => { + const [all, active] = await Promise.all([ + getAllProfiles(), + getActiveProfile(), + ]); + setProfiles(all); + setActiveId(active?.id ?? null); + }; + + useEffect(() => { + void load(); + }, []); + + const handleActivate = async (id: string) => { + const next = activeId === id ? null : id; + await setActiveProfileId(next); + setActiveId(next); + const name = profiles.find((p) => p.id === id)?.name ?? "Profile"; + toast.success(next ? `${name} activated` : "Profile deactivated"); + }; + + const handleCreate = async ( + name: string, + primitives: ProfilePrimitiveConfig, + ) => { + await createProfile({ name, primitives }); + await load(); + setEditor({ type: "closed" }); + toast.success(`"${name}" created`); + }; + + const handleUpdate = async ( + id: string, + name: string, + primitives: ProfilePrimitiveConfig, + ) => { + await updateProfile(id, { name, primitives }); + await load(); + setEditor({ type: "closed" }); + toast.success(`"${name}" saved`); + }; + + const handleDelete = async (profile: Profile) => { + await deleteProfile(profile.id); + await load(); + toast.success(`"${profile.name}" deleted`); + }; + + const presets = profiles.filter((p) => p.isPreset); + const custom = profiles.filter((p) => !p.isPreset); + + return ( + <> + + +
+ {/* Built-in presets */} +
+ + {presets.map((p) => ( + handleActivate(p.id)} + /> + ))} + +
+ + {/* Custom profiles */} +
+ + +
+ ) : null + } + > + + {/* Inline create editor */} + {editor.type === "create" && ( + setEditor({ type: "closed" })} + getStatus={getStatus} + /> + )} + + {custom.length === 0 && editor.type !== "create" ? ( +
+ No custom profiles yet. Create one with the button above. +
+ ) : ( + custom.map((p) => + editor.type === "edit" && editor.profile.id === p.id ? ( + + handleUpdate(p.id, name, primitives) + } + onCancel={() => setEditor({ type: "closed" })} + getStatus={getStatus} + /> + ) : ( + handleActivate(p.id)} + onEdit={() => setEditor({ type: "edit", profile: p })} + onDelete={() => handleDelete(p)} + /> + ), + ) + )} +
+ +
+ + ); +} diff --git a/web-ext/app/options/routes/Shorts.tsx b/web-ext/app/options/routes/Shorts.tsx new file mode 100644 index 00000000..85f063ce --- /dev/null +++ b/web-ext/app/options/routes/Shorts.tsx @@ -0,0 +1,86 @@ +import React from "react"; +import PageHeader from "../../../components/layout/PageHeader"; +import Section from "../../../components/layout/Section"; +import Card from "../../../components/layout/Card"; +import Switch from "../../../components/form/Switch"; +import Input from "../../../components/form/Input"; +import { useStoreUpdater } from "../../../hooks/useStoreUpdater"; +import { isNumericInRange } from "@toppings/utils"; + +export default function Shorts() { + const { store, update } = useStoreUpdater(); + const s = store.preferences.shorts; + + return ( + <> + + +
+
+ + { + update((draft) => { + draft.preferences.shorts.reelAutoScroll.value = isEnabled; + }); + }} + /> + +
+ +
+ + isNumericInRange(v, 0.0625, 16)} + errorMessage="Must be between 0.0625 and 16" + onChange={(value) => { + update((draft) => { + draft.preferences.shorts.togglePlaybackRate.value = value; + }); + }} + /> + +
+ +
+ + isNumericInRange(v, 0, Infinity)} + onChange={(value) => { + update((draft) => { + draft.preferences.shorts.seekBackward.value = value; + }); + }} + /> + isNumericInRange(v, 0, Infinity)} + onChange={(value) => { + update((draft) => { + draft.preferences.shorts.seekForward.value = value; + }); + }} + /> + +
+
+ + ); +} diff --git a/web-ext/app/options/routes/Watch.tsx b/web-ext/app/options/routes/Watch.tsx new file mode 100644 index 00000000..61c755f0 --- /dev/null +++ b/web-ext/app/options/routes/Watch.tsx @@ -0,0 +1,249 @@ +import React, { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import PageHeader from "../../../components/layout/PageHeader"; +import Section from "../../../components/layout/Section"; +import Card from "../../../components/layout/Card"; +import Input from "../../../components/form/Input"; +import Select from "../../../components/form/Select"; +import CapabilityStatusRow from "../../../components/feedback/CapabilityStatusRow"; +import { useStoreUpdater } from "../../../hooks/useStoreUpdater"; +import { useCapabilityCache } from "../../../hooks/useCapabilityCache"; +import { isNumericInRange } from "@toppings/utils"; +import { isCustomPlaybackRatesList } from "../utils/validators"; +import { + getUndismissedRecovered, + dismissRecovered, + type RecoveredFeature, +} from "../../../utils/storage/featureReports"; + +// All watch-page primitives the extension currently supports. +// When a new primitive is added, append an entry here. +const WATCH_PRIMITIVES: { id: string; label: string }[] = [ + { id: "watch.player", label: "Video Player" }, + { id: "watch.rightControls", label: "Player Controls Bar" }, + { id: "watch.progressBar", label: "Progress Bar" }, + { id: "watch.moviePlayer", label: "Movie Player Container" }, + { id: "watch.settingsButton",label: "Settings Button" }, + { id: "watch.sidebar", label: "Recommendations Sidebar" }, + { id: "watch.comments", label: "Comments Section" }, + { id: "watch.endCards", label: "End Screen Cards" }, +]; + +function useRecoveredFeatures() { + const [recovered, setRecovered] = useState([]); + + useEffect(() => { + getUndismissedRecovered().then(setRecovered); + }, []); + + const dismiss = async (primitiveId: string) => { + await dismissRecovered(primitiveId); + setRecovered((prev) => prev.filter((r) => r.primitiveId !== primitiveId)); + }; + + return { recovered, dismiss }; +} + +export default function Watch() { + const { store, update } = useStoreUpdater(); + const { getStatus, isLoading } = useCapabilityCache(); + const { recovered, dismiss } = useRecoveredFeatures(); + const w = store.preferences.watch; + + return ( + <> + + + {/* Recovery banners — shown when a previously-reported feature is now working */} + {recovered.map((r) => ( +
+
+ + A feature you reported is now working 🎉 + + + {r.primitiveId} is now + supported on your YouTube. You can re-scan to confirm. + +
+ +
+ ))} + +
+
+ + isNumericInRange(v, 0.0625, 16)} + errorMessage="Must be between 0.0625 and 16" + onChange={(value) => { + update((draft) => { + draft.preferences.watch.defaultPlaybackRate.value = value; + }); + }} + /> + { + update((draft) => { + const trimmed = value.trim(); + draft.preferences.watch.customPlaybackRates = + trimmed === "" + ? [] + : trimmed + .split(",") + .map((r) => Number(r.trim()).toFixed(2)); + }); + }} + /> + isNumericInRange(v, 0.0625, 16)} + errorMessage="Must be between 0.0625 and 16" + onChange={(value) => { + update((draft) => { + draft.preferences.watch.togglePlaybackRate.value = value; + }); + }} + /> + isNumericInRange(v, 0, 16)} + onChange={(value) => { + update((draft) => { + draft.preferences.watch.increasePlaybackRate.value = value; + }); + }} + /> + isNumericInRange(v, 0, 16)} + onChange={(value) => { + update((draft) => { + draft.preferences.watch.decreasePlaybackRate.value = value; + }); + }} + /> + +
+ +
+ + isNumericInRange(v, 0, Infinity)} + onChange={(value) => { + update((draft) => { + draft.preferences.watch.seekBackward.value = value; + }); + }} + /> + isNumericInRange(v, 0, Infinity)} + onChange={(value) => { + update((draft) => { + draft.preferences.watch.seekForward.value = value; + }); + }} + /> + +
+ +
+ + { + setQuery(e.target.value); + setOpen(true); + }} + onFocus={() => setOpen(true)} + onKeyDown={handleKeyDown} + className="tw-w-full tw-bg-surface-hover tw-border tw-border-border-default tw-rounded-lg tw-pl-8 tw-pr-7 tw-py-1.5 tw-text-xs tw-text-fg tw-placeholder-fg-subtle focus:tw-outline-none focus:tw-border-accent tw-transition-colors" + /> + {query && ( + + )} +
+ + {open && results.length > 0 && ( +
+ {results.map((result, i) => ( + + ))} +
+ )} + + {open && query.length >= 1 && results.length === 0 && ( +
+

+ No results for "{query}" +

+
+ )} +
+ ); +} diff --git a/web-ext/app/options/search/searchIndex.ts b/web-ext/app/options/search/searchIndex.ts new file mode 100644 index 00000000..64493724 --- /dev/null +++ b/web-ext/app/options/search/searchIndex.ts @@ -0,0 +1,195 @@ +export interface SearchEntry { + /** Exact label text as it appears in the UI — used for search and DOM lookup. */ + label: string; + description?: string; + path: string; + /** ID of the nearest
to scroll to when no individual row is found. */ + sectionId?: string; + page: string; + section?: string; +} + +export interface SearchResult { + entry: SearchEntry; + score: number; + /** Character indices in `entry.label` that matched the query — for highlighting. */ + matchedIndices: number[]; +} + +// --------------------------------------------------------------------------- +// Static index — labels must exactly match the text shown in the UI. +// --------------------------------------------------------------------------- + +export const SEARCH_INDEX: SearchEntry[] = [ + // ------------------------------------------------------------------------- + // General + // ------------------------------------------------------------------------- + { label: "Enable Extension", description: "When off, no Toppings features run on YouTube.", path: "/", page: "General", section: "Extension" }, + { label: "Theme", description: "System, Dark, or Light. Affects the popup and options UI.", path: "/", page: "General", section: "Appearance" }, + { label: "Popup profile switcher", description: "Show the profile list in the extension popup.", path: "/", page: "General", section: "Profile Surfaces" }, + { label: "Player gear menu", description: "Adds a Toppings section to the video player's settings menu.", path: "/", page: "General", section: "Profile Surfaces" }, + { label: "YouTube sidebar entry", description: "Adds a Toppings entry to YouTube's left navigation sidebar.", path: "/", page: "General", section: "Profile Surfaces" }, + { label: "Re-scan Capabilities", description: "Clears the cached feature compatibility check.", path: "/", page: "General", section: "Feature Diagnostics" }, + { label: "Watch Page", description: "Enable or disable all Toppings features on the watch page.", path: "/", page: "General", section: "YouTube Pages" }, + { label: "Shorts", description: "Enable or disable Toppings on Shorts.", path: "/", page: "General", section: "YouTube Pages" }, + { label: "Playlist", description: "Enable or disable runtime statistics on playlist pages.", path: "/", page: "General", section: "YouTube Pages" }, + + // ------------------------------------------------------------------------- + // Watch + // ------------------------------------------------------------------------- + { label: "Default Playback Rate", description: "Rate applied to every video on load. 1 = Normal.", path: "/watch", sectionId: "playback-rate", page: "Watch", section: "Playback Rate" }, + { label: "Custom Playback Rates", description: "Comma-separated rates for the player speed menu.", path: "/watch", sectionId: "playback-rate", page: "Watch", section: "Playback Rate" }, + { label: "Toggle Playback Rate", description: "Rate to switch to when pressing the toggle shortcut.", path: "/watch", sectionId: "playback-rate", page: "Watch", section: "Playback Rate" }, + { label: "Increase Playback Rate Step", description: "Amount the rate goes up when pressing the increase shortcut.", path: "/watch", sectionId: "playback-rate", page: "Watch", section: "Playback Rate" }, + { label: "Decrease Playback Rate Step", description: "Amount the rate goes down when pressing the decrease shortcut.", path: "/watch", sectionId: "playback-rate", page: "Watch", section: "Playback Rate" }, + { label: "Seek Backward", description: "Seconds to seek backward.", path: "/watch", sectionId: "seek", page: "Watch", section: "Seek" }, + { label: "Seek Forward", description: "Seconds to seek forward.", path: "/watch", sectionId: "seek", page: "Watch", section: "Seek" }, + { label: "Auto-load on page open", description: "Automatically restore segments when you open a video.", path: "/watch", sectionId: "loop", page: "Watch", section: "Segments" }, + { label: "Feature Availability", description: "Which features are active on your YouTube.", path: "/watch", sectionId: "feature-availability", page: "Watch", section: "Feature Availability" }, + + // ------------------------------------------------------------------------- + // Shorts + // ------------------------------------------------------------------------- + { label: "Auto-Scroll", description: "Automatically scroll to the next reel when one ends.", path: "/shorts", page: "Shorts", section: "Behavior" }, + { label: "Toggle Playback Rate", description: "Rate to switch to when pressing the toggle shortcut.", path: "/shorts", page: "Shorts", section: "Playback Rate" }, + { label: "Seek Backward", description: "Seconds to seek backward.", path: "/shorts", page: "Shorts", section: "Seek" }, + { label: "Seek Forward", description: "Seconds to seek forward.", path: "/shorts", page: "Shorts", section: "Seek" }, + + // ------------------------------------------------------------------------- + // Playlist + // ------------------------------------------------------------------------- + { label: "Runtime Statistics", description: "Total and average runtime shown at the top of playlist pages.", path: "/playlist", page: "Playlist", section: "Runtime Statistics" }, + + // ------------------------------------------------------------------------- + // Keybindings — Watch Page section + // ------------------------------------------------------------------------- + { label: "Toggle Playback Rate", path: "/keybindings", sectionId: "watch", page: "Shortcuts", section: "Watch Page" }, + { label: "Increase Playback Rate", path: "/keybindings", sectionId: "watch", page: "Shortcuts", section: "Watch Page" }, + { label: "Decrease Playback Rate", path: "/keybindings", sectionId: "watch", page: "Shortcuts", section: "Watch Page" }, + { label: "Seek Backward", path: "/keybindings", sectionId: "watch", page: "Shortcuts", section: "Watch Page" }, + { label: "Seek Forward", path: "/keybindings", sectionId: "watch", page: "Shortcuts", section: "Watch Page" }, + { label: "Segments: Load Last Used / Toggle", description: "Load the last-used segment config and enable it. If segments are already active, disables them.", path: "/keybindings", sectionId: "watch", page: "Shortcuts", section: "Watch Page" }, + { label: "Segments: Fresh Slate", description: "Always start a brand-new segment config.", path: "/keybindings", sectionId: "watch", page: "Shortcuts", section: "Watch Page" }, + { label: "Segments: Set Start of Active", description: "Pin the start marker of the active segment to the current time.", path: "/keybindings", sectionId: "watch", page: "Shortcuts", section: "Watch Page" }, + { label: "Segments: Set End of Active", description: "Pin the end marker of the active segment to the current time.", path: "/keybindings", sectionId: "watch", page: "Shortcuts", section: "Watch Page" }, + { label: "Segments: Save to Default Slot", description: "While segments active: saves to default slot. While segments off: clears the last-used record.", path: "/keybindings", sectionId: "watch", page: "Shortcuts", section: "Watch Page" }, + + // Keybindings — Shorts section + { label: "Toggle Playback Rate", path: "/keybindings", sectionId: "shorts", page: "Shortcuts", section: "Shorts" }, + { label: "Seek Backward", path: "/keybindings", sectionId: "shorts", page: "Shortcuts", section: "Shorts" }, + { label: "Seek Forward", path: "/keybindings", sectionId: "shorts", page: "Shortcuts", section: "Shorts" }, + + // Keybindings — Profiles section + { label: "Cycle Profiles", description: "Cycle through all profiles without leaving the video.", path: "/keybindings", sectionId: "profiles", page: "Shortcuts", section: "Profiles" }, + + // Keybindings — Nudge section + { label: "Nudge Active Segment Start Backward", description: "Move the start marker of the active segment back.", path: "/keybindings", sectionId: "nudge", page: "Shortcuts", section: "Segments Nudge" }, + { label: "Nudge Active Segment Start Forward", description: "Move the start marker of the active segment forward.", path: "/keybindings", sectionId: "nudge", page: "Shortcuts", section: "Segments Nudge" }, + { label: "Nudge Active Segment End Forward", description: "Move the end marker of the active segment forward.", path: "/keybindings", sectionId: "nudge", page: "Shortcuts", section: "Segments Nudge" }, + { label: "Nudge Active Segment End Backward", description: "Move the end marker of the active segment back.", path: "/keybindings", sectionId: "nudge", page: "Shortcuts", section: "Segments Nudge" }, + { label: "Base Step (seconds)", description: "How many seconds the first press nudges the marker.", path: "/keybindings", sectionId: "nudge", page: "Shortcuts", section: "Segments Nudge" }, + { label: "Multiplier", description: "Step multiplier applied on rapid consecutive presses.", path: "/keybindings", sectionId: "nudge", page: "Shortcuts", section: "Segments Nudge" }, + { label: "Max Step (seconds)", description: "The nudge step will not exceed this value.", path: "/keybindings", sectionId: "nudge", page: "Shortcuts", section: "Segments Nudge" }, + + // ------------------------------------------------------------------------- + // Profiles + // ------------------------------------------------------------------------- + { label: "Built-in Presets", description: "Curated by Toppings — activate in one tap, no configuration needed.", path: "/profiles", page: "Profiles", section: "Built-in Presets" }, + { label: "Audio", description: "Built-in preset: hides video player, shows only audio.", path: "/profiles", page: "Profiles", section: "Built-in Presets" }, + { label: "Focus", description: "Built-in preset: hides sidebar, comments, and end cards.", path: "/profiles", page: "Profiles", section: "Built-in Presets" }, + { label: "My Profiles", description: "Create your own mix of YouTube experience settings.", path: "/profiles", page: "Profiles", section: "My Profiles" }, + { label: "Player Layout", description: "Default, Theatre, or No Video layout for the watch page.", path: "/profiles", page: "Profiles", section: "Profile Editor" }, + { label: "Player Visuals", description: "What fills the video slot: real video, black screen, visualizer, or custom image.", path: "/profiles", page: "Profiles", section: "Profile Editor" }, + { label: "Recommendations Sidebar", description: "Show or hide the Up Next recommendations panel.", path: "/profiles", page: "Profiles", section: "Profile Editor" }, + { label: "Comments Section", description: "Show or hide the comments below the video.", path: "/profiles", page: "Profiles", section: "Profile Editor" }, + { label: "End Screen Cards", description: "Show or hide overlay cards at the end of videos.", path: "/profiles", page: "Profiles", section: "Profile Editor" }, + { label: "Feed Thumbnails", description: "Show, hide, or blur thumbnail images in the home feed.", path: "/profiles", page: "Profiles", section: "Profile Editor" }, + { label: "Home Feed", description: "Show or hide the entire home page feed.", path: "/profiles", page: "Profiles", section: "Profile Editor" }, + { label: "Shorts Shelf", description: "Show or hide the Shorts row in the home feed.", path: "/profiles", page: "Profiles", section: "Profile Editor" }, + { label: "Result Thumbnails", description: "Show, hide, or blur thumbnails in search results.", path: "/profiles", page: "Profiles", section: "Profile Editor" }, + { label: "Video Metadata", description: "Show or hide view count and date below search results.", path: "/profiles", page: "Profiles", section: "Profile Editor" }, + { label: "Shorts in Search", description: "Show or hide the Shorts shelf in search results.", path: "/profiles", page: "Profiles", section: "Profile Editor" }, + { label: "Shorts Shelf (everywhere)", description: "Hide the Shorts shelf across home, search, and other pages.", path: "/profiles", page: "Profiles", section: "Profile Editor" }, +]; + +// --------------------------------------------------------------------------- +// Fuzzy search +// +// Characters in the query must appear in order in the target text, but +// need not be contiguous. Scoring rewards: +// - consecutive character matches +// - matches at word boundaries +// - substring (contiguous) matches +// - exact matches +// --------------------------------------------------------------------------- + +function fuzzyMatch( + text: string, + query: string, +): { score: number; indices: number[] } { + const tl = text.toLowerCase(); + const ql = query.toLowerCase(); + + let ti = 0; + let qi = 0; + let score = 0; + let consecutive = 0; + let lastMatchIdx = -1; + const indices: number[] = []; + + while (ti < tl.length && qi < ql.length) { + if (tl[ti] === ql[qi]) { + indices.push(ti); + const isConsecutive = lastMatchIdx === ti - 1; + consecutive = isConsecutive ? consecutive + 1 : 0; + score += 1 + consecutive * 2; + // Word-boundary bonus + if (ti === 0 || tl[ti - 1] === " " || tl[ti - 1] === ":") score += 3; + lastMatchIdx = ti; + qi++; + } + ti++; + } + + if (qi < ql.length) return { score: 0, indices: [] }; // didn't consume all query chars + + // Substring bonus + if (tl.includes(ql)) score += 15; + // Starts-with bonus + if (tl.startsWith(ql)) score += 25; + // Exact match bonus + if (tl === ql) score += 50; + + return { score, indices }; +} + +export function fuzzySearch(query: string): SearchResult[] { + const q = query.trim(); + if (q.length < 1) return []; + + const results: SearchResult[] = []; + + for (const entry of SEARCH_INDEX) { + // Try label, description, section, and page as match targets. + // Use the best score across all fields; indices always correspond to label. + const labelMatch = fuzzyMatch(entry.label, q); + const descMatch = entry.description ? fuzzyMatch(entry.description, q) : { score: 0, indices: [] }; + const sectionMatch = entry.section ? fuzzyMatch(entry.section, q) : { score: 0, indices: [] }; + const pageMatch = fuzzyMatch(entry.page, q); + + // Weight label matches most heavily. + const totalScore = + labelMatch.score * 3 + + sectionMatch.score * 1.5 + + pageMatch.score + + descMatch.score * 0.5; + + if (totalScore > 0) { + results.push({ entry, score: totalScore, matchedIndices: labelMatch.indices }); + } + } + + return results + .sort((a, b) => b.score - a.score) + .slice(0, 10); +} diff --git a/web-ext/app/options/utils/validators.ts b/web-ext/app/options/utils/validators.ts new file mode 100644 index 00000000..4cb48234 --- /dev/null +++ b/web-ext/app/options/utils/validators.ts @@ -0,0 +1,19 @@ +const CUSTOM_PLAYBACK_RATES_PATTERN = + /^(\d+(\.\d+)?)(\s*,\s*\d+(\.\d+)?)*$/; + +const PLAYBACK_RATE_MIN = 0.0625; +const PLAYBACK_RATE_MAX = 16; + +export function isCustomPlaybackRatesList(value: string): boolean { + if (value.trim() === "") return true; + const playbackRates = value.split(",").map((r) => r.trim()); + if (!CUSTOM_PLAYBACK_RATES_PATTERN.test(value) || playbackRates.length <= 1) { + return false; + } + const rates = playbackRates.map(parseFloat); + return ( + !rates.includes(NaN) && + rates.includes(1) && + !rates.some((r) => r < PLAYBACK_RATE_MIN || r > PLAYBACK_RATE_MAX) + ); +} diff --git a/web-ext/app/popup/App.tsx b/web-ext/app/popup/App.tsx new file mode 100644 index 00000000..ca6d7b2b --- /dev/null +++ b/web-ext/app/popup/App.tsx @@ -0,0 +1,338 @@ +import React, { useEffect, useState } from "react"; +import { Storage, DEFAULT_STORE, getStorage } from "../background/store"; +import StoreContext from "../../context/store"; +import ThemeApplier from "../../components/ThemeApplier"; +import { ToastProvider } from "../../components/feedback/ToastProvider"; +import { useStoreUpdater } from "../../hooks/useStoreUpdater"; +import { useActiveTab } from "../../hooks/useActiveTab"; +import { EXTENSION_CONTEXT_SCOPE, URLS } from "@toppings/constants"; +import { setExtensionIcon } from "../../utils/browser"; +import { POPUP_TAB_SCOPE_LABEL, POPUP_SIZE } from "./data"; +import { formatTabUrlShort } from "./utils/formatTabUrl"; +import TinySwitch from "./components/TinySwitch"; +import PopupRow from "./components/PopupRow"; +import NavBtn from "./components/NavBtn"; +import { openOptionsPage } from "./utils/openOptions"; +import type { Profile } from "../../data/profiles.data"; +import { BUILT_IN_PRESETS } from "../../data/profiles.data"; +import { + getCustomProfiles, + setActiveProfileId, +} from "../../utils/storage/profileStore"; + +function usePopupProfiles() { + const [profiles, setProfiles] = useState([]); + const [activeProfileId, setActiveId] = useState(null); + + const load = () => { + chrome.storage.local.get("toppings:profile_store", (result) => { + const stored = result["toppings:profile_store"] as + | { activeProfileId: string | null; profiles: Profile[] } + | undefined; + setActiveId(stored?.activeProfileId ?? null); + const custom: Profile[] = Array.isArray(stored?.profiles) + ? stored!.profiles.sort((a, b) => a.createdAt - b.createdAt) + : []; + setProfiles([...BUILT_IN_PRESETS, ...custom]); + }); + }; + + useEffect(() => { + load(); + const listener = ( + changes: Record, + ) => { + if ("toppings:profile_store" in changes) load(); + }; + chrome.storage.onChanged.addListener(listener); + return () => chrome.storage.onChanged.removeListener(listener); + }, []); + + const activate = async (id: string | null) => { + await setActiveProfileId(id); + setActiveId(id); + }; + + return { profiles, activeProfileId, activate }; +} + +function PopupShell() { + const { store, update } = useStoreUpdater(); + const version = chrome.runtime.getManifest().version; + const tab = useActiveTab(); + const { profiles, activeProfileId, activate } = usePopupProfiles(); + + const masterOn = store.isExtensionEnabled; + const audioOn = store.preferences.watch.audioMode?.isEnabled ?? false; + const loopOn = store.preferences.watch.isEnabled; + const shortsAuto = store.preferences.shorts.reelAutoScroll.value; + const defaultRate = store.preferences.watch.defaultPlaybackRate.value; + + const toggleMaster = () => { + const next = !masterOn; + update((d) => { + d.isExtensionEnabled = next; + }); + setExtensionIcon(!next); + }; + + return ( +
+
+ +
+ Toppings +
+ + v{version} + +
+ +
+ +
+
+ {POPUP_TAB_SCOPE_LABEL[tab.scope]} +
+
+ {formatTabUrlShort(tab.url)} +
+
+
+ +
+ } + onActivate={toggleMaster} + /> + + update((d) => { + if (!d.preferences.watch.audioMode) return; + d.preferences.watch.audioMode.isEnabled = + !d.preferences.watch.audioMode.isEnabled; + }) + } + /> + } + onActivate={() => + update((d) => { + if (!d.preferences.watch.audioMode) return; + d.preferences.watch.audioMode.isEnabled = + !d.preferences.watch.audioMode.isEnabled; + }) + } + disabled={!masterOn} + /> + + update((d) => { + d.preferences.watch.isEnabled = !d.preferences.watch.isEnabled; + }) + } + /> + } + onActivate={() => + update((d) => { + d.preferences.watch.isEnabled = !d.preferences.watch.isEnabled; + }) + } + disabled={!masterOn} + /> + + update((d) => { + d.preferences.shorts.reelAutoScroll.value = + !d.preferences.shorts.reelAutoScroll.value; + }) + } + /> + } + onActivate={() => + update((d) => { + d.preferences.shorts.reelAutoScroll.value = + !d.preferences.shorts.reelAutoScroll.value; + }) + } + disabled={!masterOn} + /> + + {defaultRate}x + + } + /> + + {/* Profile switcher */} +
+
+ + Profile + + {activeProfileId !== null && ( + + )} +
+
+ {profiles.map((p) => { + const isActive = p.id === activeProfileId; + return ( + + ); + })} +
+
+
+ + +
+ ); +} + +export default function App() { + const [store, setStore] = useState(DEFAULT_STORE); + const [loaded, setLoaded] = useState(false); + + useEffect(() => { + getStorage().then((s) => { + setStore(s); + setLoaded(true); + }); + }, []); + + if (!loaded) { + return ( +
+ ); + } + + return ( + + + + + + + + ); +} diff --git a/web-ext/app/popup/components/NavBtn.tsx b/web-ext/app/popup/components/NavBtn.tsx new file mode 100644 index 00000000..47a5fa8e --- /dev/null +++ b/web-ext/app/popup/components/NavBtn.tsx @@ -0,0 +1,46 @@ +import React from "react"; + +export default function NavBtn({ + label, + active, + onClick, + path, +}: { + label: string; + active?: boolean; + onClick: () => void; + path: string; +}) { + return ( + + ); +} diff --git a/web-ext/app/popup/components/PopupRow.tsx b/web-ext/app/popup/components/PopupRow.tsx new file mode 100644 index 00000000..8dadf1f7 --- /dev/null +++ b/web-ext/app/popup/components/PopupRow.tsx @@ -0,0 +1,47 @@ +import React from "react"; + +export default function PopupRow({ + title, + subtitle, + control, + onActivate, + disabled, +}: { + title: string; + subtitle: string; + control: React.ReactNode; + onActivate?: () => void; + disabled?: boolean; +}) { + const clickable = !!onActivate && !disabled; + return ( +
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onActivate?.(); + } + } + : undefined + } + className={`tw-grid tw-grid-cols-[1fr_auto] tw-items-center tw-gap-3 tw-px-4 tw-py-3 tw-border-b tw-border-border-default tw-transition-colors tw-duration-150 ${ + clickable ? "tw-cursor-pointer hover:tw-bg-surface-hover" : "" + } ${disabled ? "tw-opacity-50 tw-pointer-events-none" : ""}`} + > +
+
+ {title} +
+
+ {subtitle} +
+
+
e.stopPropagation()}>{control}
+
+ ); +} diff --git a/web-ext/app/popup/components/TinySwitch.tsx b/web-ext/app/popup/components/TinySwitch.tsx new file mode 100644 index 00000000..a05c9f14 --- /dev/null +++ b/web-ext/app/popup/components/TinySwitch.tsx @@ -0,0 +1,34 @@ +import React from "react"; + +export default function TinySwitch({ + on, + onClick, +}: { + on: boolean; + onClick: () => void; +}) { + return ( + + ); +} diff --git a/web-ext/app/popup/data.ts b/web-ext/app/popup/data.ts new file mode 100644 index 00000000..99231e43 --- /dev/null +++ b/web-ext/app/popup/data.ts @@ -0,0 +1,17 @@ +import { + EXTENSION_CONTEXT_SCOPE, + type ExtensionContextScope, +} from "@toppings/constants"; + +export const POPUP_SIZE = { + WIDTH_PX: 340, + HEIGHT_PX: 520, +} as const; + +export const POPUP_TAB_SCOPE_LABEL: Record = { + [EXTENSION_CONTEXT_SCOPE.WATCH]: "Watch page · active", + [EXTENSION_CONTEXT_SCOPE.SHORTS]: "Shorts · active", + [EXTENSION_CONTEXT_SCOPE.PLAYLIST]: "Playlist · active", + [EXTENSION_CONTEXT_SCOPE.YOUTUBE]: "YouTube · active", + [EXTENSION_CONTEXT_SCOPE.UNSUPPORTED]: "Open a YouTube video", +}; diff --git a/web-ext/src/options/index.css b/web-ext/app/popup/index.css similarity index 99% rename from web-ext/src/options/index.css rename to web-ext/app/popup/index.css index 135c24b0..dcbbc3b7 100644 --- a/web-ext/src/options/index.css +++ b/web-ext/app/popup/index.css @@ -2,6 +2,8 @@ @tailwind components; @tailwind utilities; +@import "../../theme.css"; + /* 1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) 2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116) diff --git a/web-ext/src/popup/index.html b/web-ext/app/popup/index.html similarity index 100% rename from web-ext/src/popup/index.html rename to web-ext/app/popup/index.html diff --git a/web-ext/src/popup/index.tsx b/web-ext/app/popup/index.tsx similarity index 100% rename from web-ext/src/popup/index.tsx rename to web-ext/app/popup/index.tsx diff --git a/web-ext/app/popup/utils/formatTabUrl.ts b/web-ext/app/popup/utils/formatTabUrl.ts new file mode 100644 index 00000000..4e644ec4 --- /dev/null +++ b/web-ext/app/popup/utils/formatTabUrl.ts @@ -0,0 +1,11 @@ +export function formatTabUrlShort(url: string | null): string { + if (!url) return "—"; + try { + const u = new URL(url); + const path = + u.pathname.length > 28 ? `${u.pathname.slice(0, 28)}…` : u.pathname; + return `${u.hostname}${path}`; + } catch { + return url; + } +} diff --git a/web-ext/app/popup/utils/openOptions.ts b/web-ext/app/popup/utils/openOptions.ts new file mode 100644 index 00000000..d2ec4a84 --- /dev/null +++ b/web-ext/app/popup/utils/openOptions.ts @@ -0,0 +1,5 @@ +import { OPTIONS_HTML } from "../../options/data"; + +export function openOptionsPage() { + window.open(chrome.runtime.getURL(OPTIONS_HTML), "_blank"); +} diff --git a/web-ext/src/assets/icons/disabled_icon128.png b/web-ext/assets/icons/disabled_icon128.png similarity index 100% rename from web-ext/src/assets/icons/disabled_icon128.png rename to web-ext/assets/icons/disabled_icon128.png diff --git a/web-ext/src/assets/icons/disabled_icon16.png b/web-ext/assets/icons/disabled_icon16.png similarity index 100% rename from web-ext/src/assets/icons/disabled_icon16.png rename to web-ext/assets/icons/disabled_icon16.png diff --git a/web-ext/src/assets/icons/disabled_icon32.png b/web-ext/assets/icons/disabled_icon32.png similarity index 100% rename from web-ext/src/assets/icons/disabled_icon32.png rename to web-ext/assets/icons/disabled_icon32.png diff --git a/web-ext/src/assets/icons/disabled_icon48.png b/web-ext/assets/icons/disabled_icon48.png similarity index 100% rename from web-ext/src/assets/icons/disabled_icon48.png rename to web-ext/assets/icons/disabled_icon48.png diff --git a/web-ext/src/assets/icons/icon128.png b/web-ext/assets/icons/icon128.png similarity index 100% rename from web-ext/src/assets/icons/icon128.png rename to web-ext/assets/icons/icon128.png diff --git a/web-ext/src/assets/icons/icon16.png b/web-ext/assets/icons/icon16.png similarity index 100% rename from web-ext/src/assets/icons/icon16.png rename to web-ext/assets/icons/icon16.png diff --git a/web-ext/src/assets/icons/icon32.png b/web-ext/assets/icons/icon32.png similarity index 100% rename from web-ext/src/assets/icons/icon32.png rename to web-ext/assets/icons/icon32.png diff --git a/web-ext/src/assets/icons/icon48.png b/web-ext/assets/icons/icon48.png similarity index 100% rename from web-ext/src/assets/icons/icon48.png rename to web-ext/assets/icons/icon48.png diff --git a/web-ext/components/ThemeApplier.tsx b/web-ext/components/ThemeApplier.tsx new file mode 100644 index 00000000..e365ef19 --- /dev/null +++ b/web-ext/components/ThemeApplier.tsx @@ -0,0 +1,11 @@ +import React from "react"; +import { useTheme } from "../hooks/useTheme"; + +export default function ThemeApplier({ + children, +}: { + children: React.ReactNode; +}) { + useTheme(); + return <>{children}; +} diff --git a/web-ext/components/feedback/ActionPopup.tsx b/web-ext/components/feedback/ActionPopup.tsx new file mode 100644 index 00000000..12aa84f1 --- /dev/null +++ b/web-ext/components/feedback/ActionPopup.tsx @@ -0,0 +1,77 @@ +import React, { ReactNode, useId } from "react"; +import Popup from "./Popup"; +import Button, { ButtonVariant } from "../primitives/Button"; + +interface PopupAction { + label: string; + onClick: () => void; + variant?: ButtonVariant; + autoFocus?: boolean; + disabled?: boolean; +} + +interface ActionPopupProps { + open: boolean; + onClose: () => void; + title: string; + description?: ReactNode; + children?: ReactNode; + actions: PopupAction[]; + dismissable?: boolean; + widthClass?: string; +} + +export default function ActionPopup({ + open, + onClose, + title, + description, + children, + actions, + dismissable = true, + widthClass, +}: ActionPopupProps) { + const titleId = useId(); + + return ( + +
+

+ {title} +

+ {description && ( +

+ {description} +

+ )} +
+ {children &&
{children}
} +
+ {actions.map((action, i) => ( + + ))} +
+
+ ); +} + +export type { PopupAction }; diff --git a/web-ext/components/feedback/CapabilityStatusRow.tsx b/web-ext/components/feedback/CapabilityStatusRow.tsx new file mode 100644 index 00000000..3d0c0652 --- /dev/null +++ b/web-ext/components/feedback/CapabilityStatusRow.tsx @@ -0,0 +1,114 @@ +import React, { useState } from "react"; +import { EXTENSION_VERSION } from "@toppings/constants"; +import type { CapabilityStatus } from "../../utils/storage/capabilityCache"; +import { addFeatureReport } from "../../utils/storage/featureReports"; + +interface CapabilityStatusRowProps { + /** Human-readable feature name, e.g. "Recommendations Sidebar" */ + label: string; + /** Internal primitive ID, e.g. "watch.sidebar" */ + primitiveId: string; + /** Current cached status */ + status: CapabilityStatus; +} + +const STATUS_CONFIG: Record< + CapabilityStatus, + { dot: string; text: string; hint: string } +> = { + supported: { + dot: "tw-bg-green-500", + text: "Working", + hint: "This feature is active on your YouTube.", + }, + unsupported: { + dot: "tw-bg-amber-500", + text: "Unavailable on your YouTube", + hint: "YouTube's UI on your account doesn't match any known layout for this feature.", + }, + untested: { + dot: "tw-bg-fg-subtle", + text: "Not yet checked", + hint: "Visit a YouTube watch page to check this feature.", + }, +}; + +/** + * Renders a single row showing the capability status of a watch-page + * primitive. Unsupported primitives include a Report button that opens a + * pre-filled GitHub issue so the developer can add a new selector strategy. + */ +export default function CapabilityStatusRow({ + label, + primitiveId, + status, +}: CapabilityStatusRowProps) { + const config = STATUS_CONFIG[status]; + const [reported, setReported] = useState(false); + + const handleReport = async () => { + // 1. Store the report locally so we can notify the user when it's fixed. + await addFeatureReport(primitiveId); + setReported(true); + + // 2. Open a pre-filled GitHub issue for the developer. + const title = encodeURIComponent( + `[Report] Feature unavailable: ${label} (${primitiveId})`, + ); + const body = encodeURIComponent( + [ + `**Feature:** ${label}`, + `**Primitive ID:** \`${primitiveId}\``, + `**Extension version:** ${EXTENSION_VERSION}`, + `**Browser:** ${navigator.userAgent}`, + "", + "### What happened", + "This feature shows as unavailable in my Toppings options page.", + "", + "### Steps to reproduce", + "1. Install Toppings", + "2. Open YouTube watch page", + "3. Open Options → Watch → Feature Availability", + `4. "${label}" shows as unavailable`, + ].join("\n"), + ); + window.open( + `https://github.com/enrych/toppings/issues/new?title=${title}&body=${body}&labels=Type%3A+Bug+%F0%9F%90%9B%2CStatus%3A+Needs+Investigation`, + "_blank", + ); + }; + + return ( +
+ {/* Label + status badge */} +
+ + {label} + +
+ + {config.text} +
+
+ + {/* Report button — only shown when unsupported */} + {status === "unsupported" && ( + + )} +
+ ); +} diff --git a/web-ext/components/feedback/ConfirmPopup.tsx b/web-ext/components/feedback/ConfirmPopup.tsx new file mode 100644 index 00000000..40cd1b55 --- /dev/null +++ b/web-ext/components/feedback/ConfirmPopup.tsx @@ -0,0 +1,42 @@ +import React, { ReactNode } from "react"; +import ActionPopup from "./ActionPopup"; + +interface ConfirmPopupProps { + open: boolean; + title: string; + message: ReactNode; + confirmLabel?: string; + cancelLabel?: string; + danger?: boolean; + onConfirm: () => void; + onCancel: () => void; +} + +export default function ConfirmPopup({ + open, + title, + message, + confirmLabel = "Confirm", + cancelLabel = "Cancel", + danger = false, + onConfirm, + onCancel, +}: ConfirmPopupProps) { + return ( + + ); +} diff --git a/web-ext/components/feedback/ConfirmProvider.tsx b/web-ext/components/feedback/ConfirmProvider.tsx new file mode 100644 index 00000000..29ad07fd --- /dev/null +++ b/web-ext/components/feedback/ConfirmProvider.tsx @@ -0,0 +1,67 @@ +import React, { + ReactNode, + createContext, + useCallback, + useContext, + useRef, + useState, +} from "react"; +import ConfirmPopup from "./ConfirmPopup"; + +interface ConfirmOptions { + title: string; + message: ReactNode; + confirmLabel?: string; + cancelLabel?: string; + danger?: boolean; +} + +type ConfirmFn = (options: ConfirmOptions) => Promise; + +const ConfirmContext = createContext(null); + +export function ConfirmProvider({ children }: { children: ReactNode }) { + const [open, setOpen] = useState(false); + const [opts, setOpts] = useState(null); + const resolverRef = useRef<((value: boolean) => void) | null>(null); + + const confirm = useCallback((options) => { + return new Promise((resolve) => { + resolverRef.current = resolve; + setOpts(options); + setOpen(true); + }); + }, []); + + const close = (value: boolean) => { + setOpen(false); + resolverRef.current?.(value); + resolverRef.current = null; + }; + + return ( + + {children} + {opts && ( + close(true)} + onCancel={() => close(false)} + /> + )} + + ); +} + +export function useConfirm(): ConfirmFn { + const ctx = useContext(ConfirmContext); + if (!ctx) { + throw new Error("useConfirm must be used within a ConfirmProvider"); + } + return ctx; +} diff --git a/web-ext/components/feedback/Popup.tsx b/web-ext/components/feedback/Popup.tsx new file mode 100644 index 00000000..006e1fd6 --- /dev/null +++ b/web-ext/components/feedback/Popup.tsx @@ -0,0 +1,98 @@ +import React, { ReactNode, useEffect, useRef } from "react"; +import { createPortal } from "react-dom"; + +interface PopupProps { + open: boolean; + onClose: () => void; + children: ReactNode; + closeOnBackdrop?: boolean; + closeOnEscape?: boolean; + ariaLabel?: string; + ariaLabelledBy?: string; + widthClass?: string; +} + +export default function Popup({ + open, + onClose, + children, + closeOnBackdrop = true, + closeOnEscape = true, + ariaLabel, + ariaLabelledBy, + widthClass = "tw-max-w-md", +}: PopupProps) { + const dialogRef = useRef(null); + const previouslyFocused = useRef(null); + + useEffect(() => { + if (!open) return; + + previouslyFocused.current = document.activeElement as HTMLElement | null; + dialogRef.current?.focus(); + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape" && closeOnEscape) { + e.stopPropagation(); + onClose(); + return; + } + if (e.key === "Tab" && dialogRef.current) { + const focusables = dialogRef.current.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ); + if (focusables.length === 0) return; + const first = focusables[0]; + const last = focusables[focusables.length - 1]; + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + }; + + document.addEventListener("keydown", onKeyDown); + + const prevOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + + return () => { + document.removeEventListener("keydown", onKeyDown); + document.body.style.overflow = prevOverflow; + previouslyFocused.current?.focus?.(); + }; + }, [open, closeOnEscape, onClose]); + + if (!open) return null; + + return createPortal( +
{ + if (closeOnBackdrop && e.target === e.currentTarget) { + onClose(); + } + }} + > + , + document.body, + ); +} diff --git a/web-ext/components/feedback/Toast.tsx b/web-ext/components/feedback/Toast.tsx new file mode 100644 index 00000000..48a6bd79 --- /dev/null +++ b/web-ext/components/feedback/Toast.tsx @@ -0,0 +1,72 @@ +import React from "react"; +import Icon, { IconName } from "../primitives/Icon"; +import IconButton from "../primitives/IconButton"; + +export type ToastTone = "success" | "error" | "info"; + +export interface ToastData { + id: string; + tone: ToastTone; + title: string; + description?: string; + duration?: number; +} + +const TONE_STYLES: Record< + ToastTone, + { border: string; iconColor: string; icon: IconName } +> = { + success: { + border: "tw-border-success-fg/40", + iconColor: "tw-text-success-fg", + icon: "check", + }, + error: { + border: "tw-border-danger-fg/40", + iconColor: "tw-text-danger-fg", + icon: "alert", + }, + info: { + border: "tw-border-info-fg/40", + iconColor: "tw-text-info-fg", + icon: "info", + }, +}; + +interface ToastProps { + data: ToastData; + onDismiss: (id: string) => void; +} + +export default function Toast({ data, onDismiss }: ToastProps) { + const style = TONE_STYLES[data.tone]; + + return ( +
+
+ +
+
+
+ {data.title} +
+ {data.description && ( +
+ {data.description} +
+ )} +
+ onDismiss(data.id)} + > + + +
+ ); +} diff --git a/web-ext/components/feedback/ToastProvider.tsx b/web-ext/components/feedback/ToastProvider.tsx new file mode 100644 index 00000000..551a2cf4 --- /dev/null +++ b/web-ext/components/feedback/ToastProvider.tsx @@ -0,0 +1,102 @@ +import React, { + ReactNode, + createContext, + useCallback, + useContext, + useEffect, + useRef, + useState, +} from "react"; +import { createPortal } from "react-dom"; +import Toast, { ToastData, ToastTone } from "./Toast"; + +interface ToastApi { + show: (toast: Omit) => string; + success: (title: string, description?: string) => string; + error: (title: string, description?: string) => string; + info: (title: string, description?: string) => string; + dismiss: (id: string) => void; +} + +const ToastContext = createContext(null); + +let nextId = 0; +const genId = () => `t${++nextId}`; + +interface ToastProviderProps { + children: ReactNode; + /** Default auto-dismiss duration in ms. 0 = manual only. */ + defaultDuration?: number; +} + +export function ToastProvider({ + children, + defaultDuration = 3500, +}: ToastProviderProps) { + const [toasts, setToasts] = useState([]); + const timers = useRef>>(new Map()); + + const dismiss = useCallback((id: string) => { + setToasts((list) => list.filter((t) => t.id !== id)); + const timer = timers.current.get(id); + if (timer) { + clearTimeout(timer); + timers.current.delete(id); + } + }, []); + + const show = useCallback( + (data: Omit) => { + const id = genId(); + const duration = data.duration ?? defaultDuration; + setToasts((list) => [...list, { ...data, id }]); + if (duration > 0) { + const timer = setTimeout(() => dismiss(id), duration); + timers.current.set(id, timer); + } + return id; + }, + [defaultDuration, dismiss], + ); + + const helper = (tone: ToastTone) => (title: string, description?: string) => + show({ tone, title, description }); + + const api: ToastApi = { + show, + success: helper("success"), + error: helper("error"), + info: helper("info"), + dismiss, + }; + + useEffect( + () => () => { + timers.current.forEach((t) => clearTimeout(t)); + timers.current.clear(); + }, + [], + ); + + return ( + + {children} + {createPortal( +
+ {toasts.map((t) => ( + + ))} +
, + document.body, + )} +
+ ); +} + +export function useToast(): ToastApi { + const ctx = useContext(ToastContext); + if (!ctx) { + throw new Error("useToast must be used within a ToastProvider"); + } + return ctx; +} diff --git a/web-ext/components/form/Field.tsx b/web-ext/components/form/Field.tsx new file mode 100644 index 00000000..e53736bc --- /dev/null +++ b/web-ext/components/form/Field.tsx @@ -0,0 +1,75 @@ +import React, { ReactNode } from "react"; +import Icon from "../primitives/Icon"; +import Tooltip from "../primitives/Tooltip"; + +interface FieldProps { + label: string; + description?: string; + hint?: string; + error?: string; + htmlFor?: string; + children: ReactNode; + stacked?: boolean; +} + +export default function Field({ + label, + description, + hint, + error, + htmlFor, + children, + stacked = false, +}: FieldProps) { + const labelEl = ( +
+ + {description && ( + + + + )} +
+ ); + + if (stacked) { + return ( +
+ {labelEl} +
{children}
+ {(hint || error) && ( +
+ {error || hint} +
+ )} +
+ ); + } + + return ( +
+
+ {labelEl} +
{children}
+
+ {(hint || error) && ( +
+ {error || hint} +
+ )} +
+ ); +} diff --git a/web-ext/components/form/FilePicker.tsx b/web-ext/components/form/FilePicker.tsx new file mode 100644 index 00000000..402bf6e3 --- /dev/null +++ b/web-ext/components/form/FilePicker.tsx @@ -0,0 +1,80 @@ +import React, { ChangeEvent, useId, useRef } from "react"; +import Field from "./Field"; +import Button from "../primitives/Button"; +import Icon from "../primitives/Icon"; + +interface FilePickerProps { + label: string; + description?: string; + hint?: string; + /** Data URL of the currently selected image, if any. */ + value: string | null; + /** Called with the picked File. Consumer is responsible for converting/saving. */ + onPick: (file: File) => void; + onClear?: () => void; + accept?: string; +} + +/** + * Image-aware file picker with thumbnail preview, choose/replace/clear + * buttons. Renders nothing visually for the file input itself — clicks + * are forwarded from the styled buttons. + */ +export default function FilePicker({ + label, + description, + hint, + value, + onPick, + onClear, + accept = "image/*", +}: FilePickerProps) { + const id = useId(); + const inputRef = useRef(null); + + const handleChange = (e: ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) onPick(file); + // Reset so picking the same file again still triggers onChange + e.target.value = ""; + }; + + return ( + +
+ {value ? ( + Selected + ) : ( +
+ +
+ )} + + + {value && onClear && ( + + )} +
+
+ ); +} diff --git a/web-ext/components/form/Input.tsx b/web-ext/components/form/Input.tsx new file mode 100644 index 00000000..1defe7eb --- /dev/null +++ b/web-ext/components/form/Input.tsx @@ -0,0 +1,127 @@ +import React, { + ChangeEvent, + useEffect, + useId, + useRef, + useState, +} from "react"; +import Field from "./Field"; +import Icon from "../primitives/Icon"; + +interface InputProps { + label: string; + description?: string; + hint?: string; + initialValue: string; + placeholder?: string; + validator?: (value: string) => boolean; + errorMessage?: string; + onChange: (value: string) => void; + inputWidthClass?: string; +} + +export default function Input({ + label, + description, + hint, + initialValue, + placeholder = "Enter value", + validator, + errorMessage, + onChange, + inputWidthClass = "tw-w-44", +}: InputProps) { + const id = useId(); + const [value, setValue] = useState(initialValue); + const [status, setStatus] = useState<"idle" | "loading" | "valid" | "invalid">( + "idle", + ); + const debounceTimer = useRef | null>(null); + + useEffect(() => { + return () => { + if (debounceTimer.current) clearTimeout(debounceTimer.current); + }; + }, []); + + // Keep input value in sync if the source store updates externally. + useEffect(() => { + setValue(initialValue); + }, [initialValue]); + + const handleChange = (e: ChangeEvent) => { + const next = e.target.value; + setValue(next); + if (debounceTimer.current) clearTimeout(debounceTimer.current); + + if (!validator) { + onChange(next); + return; + } + + setStatus("loading"); + debounceTimer.current = setTimeout(() => { + const ok = validator(next); + if (ok) { + setStatus("valid"); + onChange(next); + setTimeout(() => setStatus("idle"), 1200); + } else { + setStatus("invalid"); + } + }, 500); + }; + + return ( + +
+ + {status !== "idle" && ( + + {status === "loading" && ( + + + + + )} + {status === "valid" && ( + + )} + {status === "invalid" && ( + + )} + + )} +
+
+ ); +} diff --git a/web-ext/components/form/Keybinding.tsx b/web-ext/components/form/Keybinding.tsx new file mode 100644 index 00000000..45c410f0 --- /dev/null +++ b/web-ext/components/form/Keybinding.tsx @@ -0,0 +1,67 @@ +import React, { KeyboardEvent, useEffect, useId, useState } from "react"; +import Field from "./Field"; +import { formatBindingDisplay, recordBinding } from "../../utils/keybinding"; + +interface KeybindingProps { + label: string; + description?: string; + hint?: string; + value: string; + onChange: (key: string) => void; +} + +export default function Keybinding({ + label, + description, + hint, + value, + onChange, +}: KeybindingProps) { + const id = useId(); + const [recording, setRecording] = useState(false); + + useEffect(() => { + // value is the source of truth — no internal shadow needed. + }, [value]); + + const onKeyDown = (e: KeyboardEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const isClear = e.keyCode === 8 || e.keyCode === 27; // Backspace / Escape + if (isClear) { + onChange(""); + setRecording(false); + return; + } + + const combo = recordBinding(e.nativeEvent); + if (combo !== null) { + onChange(combo); + setRecording(false); + } + // If combo is null (modifier-only press), stay in recording mode. + }; + + const displayValue = formatBindingDisplay(value); + + return ( + + setRecording(true)} + onBlur={() => setRecording(false)} + onKeyDown={onKeyDown} + title={value || undefined} + className={`tw-w-32 tw-px-3 tw-py-2 tw-bg-bg tw-text-fg tw-text-sm tw-text-center tw-font-mono tw-rounded-md tw-border tw-cursor-pointer focus:tw-outline-none focus:tw-ring-2 focus:tw-ring-accent/30 tw-transition-colors ${ + recording + ? "tw-border-accent tw-text-accent" + : "tw-border-border-strong hover:tw-border-border-strong" + }`} + /> + + ); +} diff --git a/web-ext/components/form/Select.tsx b/web-ext/components/form/Select.tsx new file mode 100644 index 00000000..feee19b9 --- /dev/null +++ b/web-ext/components/form/Select.tsx @@ -0,0 +1,149 @@ +import React, { + useEffect, + useId, + useRef, + useState, + KeyboardEvent, +} from "react"; +import Field from "./Field"; +import Icon from "../primitives/Icon"; + +interface SelectOption { + value: T; + label: string; + description?: string; +} + +interface SelectProps { + label: string; + description?: string; + hint?: string; + value: T; + options: SelectOption[]; + onChange: (value: T) => void; +} + +// Native + + {format(internal)} + +
+ + ); +} diff --git a/web-ext/components/form/Switch.tsx b/web-ext/components/form/Switch.tsx new file mode 100644 index 00000000..692a45d8 --- /dev/null +++ b/web-ext/components/form/Switch.tsx @@ -0,0 +1,51 @@ +import React, { useId, useState } from "react"; +import Field from "./Field"; + +interface SwitchProps { + label: string; + description?: string; + hint?: string; + isEnabled: boolean; + onToggle: (isEnabled: boolean) => void; +} + +export default function Switch({ + label, + description, + hint, + isEnabled, + onToggle, +}: SwitchProps) { + const [checked, setChecked] = useState(isEnabled); + const id = useId(); + + const handleChange = () => { + const next = !checked; + setChecked(next); + onToggle(next); + }; + + return ( + + + + ); +} diff --git a/web-ext/components/layout/Card.tsx b/web-ext/components/layout/Card.tsx new file mode 100644 index 00000000..1d8ba439 --- /dev/null +++ b/web-ext/components/layout/Card.tsx @@ -0,0 +1,18 @@ +import React, { ReactNode } from "react"; + +interface CardProps { + children: ReactNode; + className?: string; +} + +export default function Card({ children, className = "" }: CardProps) { + return ( +
+
+ {children} +
+
+ ); +} diff --git a/web-ext/components/layout/PageHeader.tsx b/web-ext/components/layout/PageHeader.tsx new file mode 100644 index 00000000..fd312655 --- /dev/null +++ b/web-ext/components/layout/PageHeader.tsx @@ -0,0 +1,31 @@ +import React, { ReactNode } from "react"; + +interface PageHeaderProps { + title: string; + description?: string; + actions?: ReactNode; +} + +export default function PageHeader({ + title, + description, + actions, +}: PageHeaderProps) { + return ( +
+
+
+

+ {title} +

+ {description && ( +

+ {description} +

+ )} +
+ {actions &&
{actions}
} +
+
+ ); +} diff --git a/web-ext/components/layout/Section.tsx b/web-ext/components/layout/Section.tsx new file mode 100644 index 00000000..bcb14667 --- /dev/null +++ b/web-ext/components/layout/Section.tsx @@ -0,0 +1,32 @@ +import React, { ReactNode } from "react"; + +interface SectionProps { + id?: string; + title: string; + description?: string; + headerActions?: ReactNode; + children: ReactNode; +} + +export default function Section({ + id, + title, + description, + headerActions, + children, +}: SectionProps) { + return ( +
+
+
+

{title}

+ {description && ( +

{description}

+ )} +
+ {headerActions &&
{headerActions}
} +
+
{children}
+
+ ); +} diff --git a/web-ext/components/layout/SectionNav.tsx b/web-ext/components/layout/SectionNav.tsx new file mode 100644 index 00000000..5d8a1326 --- /dev/null +++ b/web-ext/components/layout/SectionNav.tsx @@ -0,0 +1,74 @@ +import React, { useEffect, useState } from "react"; + +interface SectionNavItem { + id: string; + label: string; +} + +interface SectionNavProps { + items: SectionNavItem[]; +} + +export default function SectionNav({ items }: SectionNavProps) { + const [activeId, setActiveId] = useState(items[0]?.id ?? null); + + useEffect(() => { + if (items.length === 0) return; + + const observer = new IntersectionObserver( + (entries) => { + const visible = entries + .filter((e) => e.isIntersecting) + .sort( + (a, b) => + Math.abs(a.boundingClientRect.top) - + Math.abs(b.boundingClientRect.top), + ); + if (visible.length > 0) { + setActiveId(visible[0].target.id); + } + }, + { rootMargin: "-20% 0px -60% 0px", threshold: [0, 0.25, 0.5, 0.75, 1] }, + ); + + items.forEach((item) => { + const el = document.getElementById(item.id); + if (el) observer.observe(el); + }); + + return () => observer.disconnect(); + }, [items]); + + const onClick = (e: React.MouseEvent, id: string) => { + e.preventDefault(); + document.getElementById(id)?.scrollIntoView({ behavior: "smooth" }); + setActiveId(id); + }; + + return ( + + ); +} + +export type { SectionNavItem }; diff --git a/web-ext/components/primitives/Badge.tsx b/web-ext/components/primitives/Badge.tsx new file mode 100644 index 00000000..8d3c11a9 --- /dev/null +++ b/web-ext/components/primitives/Badge.tsx @@ -0,0 +1,40 @@ +import { cn } from "@toppings/utils"; +import React from "react"; + +type BadgeTone = "neutral" | "success" | "warning" | "danger" | "info"; + +const TONE_CLASSES: Record = { + neutral: + "tw-bg-surface-hover tw-text-fg-muted tw-border tw-border-border-default", + success: + "tw-bg-success-bg tw-text-success-fg tw-border tw-border-success-fg/30", + warning: + "tw-bg-warning-bg tw-text-warning-fg tw-border tw-border-warning-fg/30", + danger: + "tw-bg-danger-bg tw-text-danger-fg tw-border tw-border-danger-fg/30", + info: "tw-bg-info-bg tw-text-info-fg tw-border tw-border-info-fg/30", +}; + +interface BadgeProps extends React.HTMLAttributes { + tone?: BadgeTone; +} + +export default function Badge({ + tone = "neutral", + className = "", + children, + ...rest +}: BadgeProps) { + return ( + + {children} + + ); +} diff --git a/web-ext/components/primitives/Button.tsx b/web-ext/components/primitives/Button.tsx new file mode 100644 index 00000000..d66de179 --- /dev/null +++ b/web-ext/components/primitives/Button.tsx @@ -0,0 +1,67 @@ +import { cn } from "@toppings/utils"; +import React, { forwardRef } from "react"; + +type ButtonVariant = "primary" | "secondary" | "ghost" | "danger"; +type ButtonSize = "sm" | "md" | "lg"; + +const VARIANT_CLASSES: Record = { + primary: + "tw-bg-accent tw-text-accent-fg tw-border tw-border-accent hover:tw-bg-accent-hover hover:tw-border-accent-hover disabled:tw-opacity-50 disabled:tw-cursor-not-allowed", + secondary: + "tw-bg-transparent tw-text-fg tw-border tw-border-border-strong hover:tw-bg-surface-hover disabled:tw-opacity-40 disabled:tw-cursor-not-allowed", + ghost: + "tw-bg-transparent tw-text-fg-muted tw-border tw-border-transparent hover:tw-bg-surface-hover hover:tw-text-fg disabled:tw-opacity-40 disabled:tw-cursor-not-allowed", + danger: + "tw-bg-danger-bg tw-text-danger-fg tw-border tw-border-danger-fg/30 hover:tw-bg-danger-bg hover:tw-border-danger-fg/60 disabled:tw-opacity-40 disabled:tw-cursor-not-allowed", +}; + +const SIZE_CLASSES: Record = { + sm: "tw-px-2.5 tw-py-1 tw-text-xs", + md: "tw-px-3.5 tw-py-2 tw-text-sm", + lg: "tw-px-5 tw-py-2.5 tw-text-base", +}; + +interface ButtonProps extends React.ButtonHTMLAttributes { + variant?: ButtonVariant; + size?: ButtonSize; + fullWidth?: boolean; + leadingIcon?: React.ReactNode; + trailingIcon?: React.ReactNode; +} + +const Button = forwardRef(function Button( + { + variant = "secondary", + size = "md", + fullWidth = false, + leadingIcon, + trailingIcon, + className = "", + children, + type = "button", + ...rest + }, + ref, +) { + return ( + + ); +}); + +export default Button; +export type { ButtonVariant, ButtonSize }; diff --git a/web-ext/components/primitives/Icon.tsx b/web-ext/components/primitives/Icon.tsx new file mode 100644 index 00000000..f675dfa1 --- /dev/null +++ b/web-ext/components/primitives/Icon.tsx @@ -0,0 +1,115 @@ +import React from "react"; + +type IconName = + | "info" + | "check" + | "x" + | "settings" + | "watch" + | "shorts" + | "playlist" + | "audio" + | "keyboard" + | "general" + | "trash" + | "upload" + | "image" + | "search" + | "alert" + | "external" + | "chevron-down" + | "chevron-right" + | "chevron-left" + | "panel-left" + | "profiles"; + +const PATHS: Record = { + info: "M13 16h-1v-4h-1m1-4h.01M12 18.5A6.5 6.5 0 1118.5 12A6.508 6.508 0 0112 18.5z", + check: "M5 13l4 4L19 7", + x: "M6 18L18 6M6 6l12 12", + settings: + "M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065zM15 12a3 3 0 11-6 0 3 3 0 016 0z", + watch: + "M15.5 14h-.79l-.28-.27A6.471 6.471 0 0016 9.5 6.5 6.5 0 109.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z", + shorts: + "M10 16.5l6-4.5-6-4.5v9zM12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z", + playlist: + "M4 6h16M4 10h16M4 14h10M4 18h10M18 14l4 2-4 2v-4z", + audio: + "M12 1C8.96 1 6.5 3.46 6.5 6.5V12.5C6.5 15.54 8.96 18 12 18C15.04 18 17.5 15.54 17.5 12.5V6.5C17.5 3.46 15.04 1 12 1ZM4 12.5C4 16.64 7.09 20.06 11 20.45V23H13V20.45C16.91 20.06 20 16.64 20 12.5H18C18 15.81 15.31 18.5 12 18.5C8.69 18.5 6 15.81 6 12.5H4Z", + keyboard: + "M20 5H4c-1.1 0-1.99.9-1.99 2L2 17c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm-9 3h2v2h-2V8zm0 3h2v2h-2v-2zM8 8h2v2H8V8zm0 3h2v2H8v-2zm-1 2H5v-2h2v2zm0-3H5V8h2v2zm9 7H8v-2h8v2zm0-4h-2v-2h2v2zm0-3h-2V8h2v2zm3 3h-2v-2h2v2zm0-3h-2V8h2v2z", + general: + "M3 5a2 2 0 012-2h14a2 2 0 012 2v14a2 2 0 01-2 2H5a2 2 0 01-2-2V5zm4 0v14h10V5H7zm2 3h6v2H9V8zm0 4h6v2H9v-2z", + trash: + "M6 19a2 2 0 002 2h8a2 2 0 002-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z", + upload: + "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z", + image: + "M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z", + search: + "M15.5 14h-.79l-.28-.27a6.5 6.5 0 10-.7.7l.27.28v.79l5 4.99L20.49 19zM10 14a4 4 0 110-8 4 4 0 010 8z", + alert: + "M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z", + external: + "M14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7zm5 16H5V5h7V3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2v-7h-2v7z", + "chevron-down": "M6 9l6 6 6-6", + "chevron-right": "M9 6l6 6-6 6", + "chevron-left": "M15 18l-6-6 6-6", + "panel-left": + "M3 4a1 1 0 011-1h16a1 1 0 011 1v16a1 1 0 01-1 1H4a1 1 0 01-1-1V4zm6 1H5v14h4V5zm2 14h8V5h-8v14z", + profiles: + "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 14H9V8h2v8zm4 0h-2V8h2v8z", +}; + +const FILLED: Set = new Set([ + "audio", + "shorts", + "watch", + "general", + "trash", + "upload", + "image", + "settings", + "keyboard", + "search", + "alert", + "external", + "panel-left", + "profiles", +]); + +interface IconProps extends React.SVGAttributes { + name: IconName; + size?: number | string; +} + +export default function Icon({ + name, + size = 20, + className = "", + ...rest +}: IconProps) { + const isFilled = FILLED.has(name); + return ( + + ); +} + +export type { IconName }; diff --git a/web-ext/components/primitives/IconButton.tsx b/web-ext/components/primitives/IconButton.tsx new file mode 100644 index 00000000..6e71d492 --- /dev/null +++ b/web-ext/components/primitives/IconButton.tsx @@ -0,0 +1,59 @@ +import { cn } from "@toppings/utils"; +import React, { forwardRef } from "react"; + +type IconButtonVariant = "default" | "ghost" | "danger"; +type IconButtonSize = "sm" | "md" | "lg"; + +const VARIANT_CLASSES: Record = { + default: + "tw-bg-transparent tw-text-fg-muted tw-border tw-border-border-default hover:tw-bg-surface-hover hover:tw-text-fg", + ghost: + "tw-bg-transparent tw-text-fg-muted tw-border tw-border-transparent hover:tw-bg-surface-hover hover:tw-text-fg", + danger: + "tw-bg-transparent tw-text-danger-fg tw-border tw-border-danger-fg/30 hover:tw-bg-danger-bg hover:tw-border-danger-fg/60", +}; + +const SIZE_CLASSES: Record = { + sm: "tw-w-7 tw-h-7", + md: "tw-w-9 tw-h-9", + lg: "tw-w-11 tw-h-11", +}; + +interface IconButtonProps + extends React.ButtonHTMLAttributes { + variant?: IconButtonVariant; + size?: IconButtonSize; + "aria-label": string; +} + +const IconButton = forwardRef( + function IconButton( + { + variant = "ghost", + size = "md", + className = "", + children, + type = "button", + ...rest + }, + ref, + ) { + return ( + + ); + }, +); + +export default IconButton; diff --git a/web-ext/components/primitives/Tooltip.tsx b/web-ext/components/primitives/Tooltip.tsx new file mode 100644 index 00000000..d18f293c --- /dev/null +++ b/web-ext/components/primitives/Tooltip.tsx @@ -0,0 +1,137 @@ +import React, { + ReactNode, + useEffect, + useId, + useLayoutEffect, + useRef, + useState, +} from "react"; +import { createPortal } from "react-dom"; + +interface TooltipProps { + children: ReactNode; + text: string; + side?: "top" | "right" | "bottom" | "left"; +} + +interface Position { + top: number; + left: number; +} + +export default function Tooltip({ children, text, side = "right" }: TooltipProps) { + const [visible, setVisible] = useState(false); + const [position, setPosition] = useState(null); + const triggerRef = useRef(null); + const tooltipId = useId(); + + useLayoutEffect(() => { + if (!visible || !triggerRef.current) return; + const rect = triggerRef.current.getBoundingClientRect(); + const margin = 8; + let top = 0; + let left = 0; + + switch (side) { + case "right": + top = rect.top + rect.height / 2; + left = rect.right + margin; + break; + case "left": + top = rect.top + rect.height / 2; + left = rect.left - margin; + break; + case "top": + top = rect.top - margin; + left = rect.left + rect.width / 2; + break; + case "bottom": + top = rect.bottom + margin; + left = rect.left + rect.width / 2; + break; + } + + setPosition({ top, left }); + }, [visible, side, text]); + + useEffect(() => { + if (!visible) return; + const reposition = () => { + if (!triggerRef.current) return; + const rect = triggerRef.current.getBoundingClientRect(); + const margin = 8; + let top = 0; + let left = 0; + switch (side) { + case "right": + top = rect.top + rect.height / 2; + left = rect.right + margin; + break; + case "left": + top = rect.top + rect.height / 2; + left = rect.left - margin; + break; + case "top": + top = rect.top - margin; + left = rect.left + rect.width / 2; + break; + case "bottom": + top = rect.bottom + margin; + left = rect.left + rect.width / 2; + break; + } + setPosition({ top, left }); + }; + window.addEventListener("scroll", reposition, true); + window.addEventListener("resize", reposition); + return () => { + window.removeEventListener("scroll", reposition, true); + window.removeEventListener("resize", reposition); + }; + }, [visible, side]); + + const transformBySide: Record = { + right: "translateY(-50%)", + left: "translate(-100%, -50%)", + top: "translate(-50%, -100%)", + bottom: "translateX(-50%)", + }; + + return ( + setVisible(true)} + onMouseLeave={() => setVisible(false)} + onFocus={() => setVisible(true)} + onBlur={() => setVisible(false)} + // Hide on click. Without this, a hovered button stays "hovered" after + // a click that mutates the surrounding DOM (e.g. collapsing the + // sidebar) — the mouseleave event never fires because the cursor + // doesn't move out of the wrapper, and React may reuse the Tooltip + // instance across re-renders, keeping `visible=true` stale. + onClick={() => setVisible(false)} + aria-describedby={visible ? tooltipId : undefined} + > + {children} + {visible && + position && + createPortal( + + {text} + , + document.body, + )} + + ); +} diff --git a/web-ext/src/options/store.tsx b/web-ext/context/store.tsx similarity index 82% rename from web-ext/src/options/store.tsx rename to web-ext/context/store.tsx index e1d79fd8..0c46a318 100644 --- a/web-ext/src/options/store.tsx +++ b/web-ext/context/store.tsx @@ -1,6 +1,6 @@ import React from "react"; import { Dispatch, SetStateAction } from "react"; -import { Storage } from "../background/store"; +import { Storage } from "../app/background/store"; const StoreContext = React.createContext<{ store: Storage; diff --git a/web-ext/data/api.data.ts b/web-ext/data/api.data.ts new file mode 100644 index 00000000..0eadacbb --- /dev/null +++ b/web-ext/data/api.data.ts @@ -0,0 +1,9 @@ +export const EXTENSION_API_BASE_URL = { + LOCAL: "http://127.0.0.1:8787/api{{endpoint}}", + PRODUCTION: "https://toppings.enry.ch/api{{endpoint}}", +} as const; + +export const EXTENSION_API_ENDPOINT = { + PING: "/ping", + PLAYLIST_V1: "/v1/playlist/{{playlistId}}", +} as const; diff --git a/web-ext/data/extension.data.ts b/web-ext/data/extension.data.ts new file mode 100644 index 00000000..057bbf6e --- /dev/null +++ b/web-ext/data/extension.data.ts @@ -0,0 +1,28 @@ +export const NODE_ENV = { + DEVELOPMENT: "development", + PRODUCTION: "production", +} as const; + +export const EXTENSION_INSTALL_REASON = { + INSTALL: "install", + UPDATE: "update", +} as const; + +export const EXTENSION_MESSAGE_BODY = { + TYPE: "type", + PAYLOAD: "payload", +} as const; + +export const EXTENSION_MESSAGE_TYPE = { + CONTEXT: "context", + EVENT: "event", +} as const; + +export const EXTENSION_MESSAGE_EVENT = { + CONNECTED: "connected", +} as const; + +export const EXTENSION_LOCAL_STORAGE_KEY = { + OPTIONS_SIDEBAR_COLLAPSED: "toppings:options_sidebar_collapsed", + AUDIO_MODE_GLOBAL_CUSTOM_IMAGE: "toppings:audio_mode_global_custom_image", +} as const; diff --git a/web-ext/data/profiles.data.ts b/web-ext/data/profiles.data.ts new file mode 100644 index 00000000..5e2b4288 --- /dev/null +++ b/web-ext/data/profiles.data.ts @@ -0,0 +1,119 @@ +import type { PlayerLayout, PlayerVisuals } from "../utils/primitive"; + +// --------------------------------------------------------------------------- +// Profile primitive config +// +// Each key maps to a primitiveId. The value is that primitive's desired +// state when the profile is active. Absent keys mean "use default / no +// change from YouTube's own behaviour". +// --------------------------------------------------------------------------- + +export interface WatchPrimitiveConfig { + "watch.layout"?: { value: PlayerLayout }; + "watch.visuals"?: { value: PlayerVisuals }; + "watch.sidebar"?: { visible: boolean }; + "watch.comments"?: { visible: boolean }; + "watch.endCards"?: { visible: boolean }; +} + +/** Feed thumbnail display mode. blur uses CSS filter; hide removes from view. */ +export type ThumbnailMode = "show" | "hide" | "blur"; + +export interface HomePrimitiveConfig { + "home.thumbnails"?: { mode: ThumbnailMode }; + "home.feed"?: { visible: boolean }; + "home.shorts"?: { visible: boolean }; +} + +export interface SearchPrimitiveConfig { + "search.thumbnails"?: { mode: ThumbnailMode }; + "search.metadata"?: { visible: boolean }; + "search.shorts"?: { visible: boolean }; +} + +export interface ShortsPrimitiveConfig { + "shorts.shelf"?: { visible: boolean }; +} + +// Union of all scope configs — extends naturally as new scopes are added. +export type ProfilePrimitiveConfig = WatchPrimitiveConfig & + HomePrimitiveConfig & + SearchPrimitiveConfig & + ShortsPrimitiveConfig; + +// --------------------------------------------------------------------------- +// Profile type +// --------------------------------------------------------------------------- + +export interface Profile { + /** Stable unique identifier. Presets use "preset:", custom use uuid. */ + id: string; + /** Display name shown in UI. */ + name: string; + /** + * True for Toppings-shipped presets. Preset profiles are not editable or + * deletable by the user — they are derived from constants, not stored. + */ + isPreset: boolean; + /** Unix timestamp (ms) of creation. 0 for presets. */ + createdAt: number; + /** Primitive states to apply when this profile is active. */ + primitives: ProfilePrimitiveConfig; +} + +// --------------------------------------------------------------------------- +// Profile storage shape (chrome.storage.local) +// --------------------------------------------------------------------------- + +export interface ProfileStore { + /** + * ID of the currently active profile, or null if no profile is active + * (extension runs using individual preferences from chrome.storage.sync). + */ + activeProfileId: string | null; + /** User-created custom profiles. Presets are not stored here. */ + profiles: Profile[]; +} + +export const DEFAULT_PROFILE_STORE: ProfileStore = { + activeProfileId: null, + profiles: [], +}; + +// --------------------------------------------------------------------------- +// Built-in presets +// +// Presets are constants — never written to storage, always derived at +// runtime. This means preset definitions update automatically when the +// extension updates, without any migration. +// --------------------------------------------------------------------------- + +export const PRESET_AUDIO: Profile = { + id: "preset:audio", + name: "Audio", + isPreset: true, + createdAt: 0, + primitives: { + "watch.layout": { value: "no-video" }, + "watch.visuals": { value: "black" }, + "watch.sidebar": { visible: false }, + }, +}; + +export const PRESET_FOCUS: Profile = { + id: "preset:focus", + name: "Focus", + isPreset: true, + createdAt: 0, + primitives: { + "watch.sidebar": { visible: false }, + "watch.comments": { visible: false }, + "watch.endCards": { visible: false }, + }, +}; + +/** All built-in presets in display order. */ +export const BUILT_IN_PRESETS: readonly Profile[] = [ + PRESET_AUDIO, + PRESET_FOCUS, +]; diff --git a/web-ext/data/store.data.ts b/web-ext/data/store.data.ts new file mode 100644 index 00000000..912d3c1b --- /dev/null +++ b/web-ext/data/store.data.ts @@ -0,0 +1,121 @@ +import { EXTENSION_CONTEXT_SCOPE, KEYBOARD_KEY, NUMBER } from "@toppings/constants"; + +export const DEFAULT_STORE = { + isExtensionEnabled: true as boolean, + ui: { + theme: "system" as "system" | "dark" | "light", + /** Inject a Toppings section into YouTube's player gear (⚙) menu. */ + gearMenuEnabled: false as boolean, + /** Inject a Toppings entry into YouTube's left sidebar + native settings page. */ + nativeSettingsEnabled: false as boolean, + }, + preferences: { + [EXTENSION_CONTEXT_SCOPE.WATCH]: { + isEnabled: true as boolean, + defaultPlaybackRate: { + value: NUMBER.S1 as string, + }, + togglePlaybackRate: { + key: KEYBOARD_KEY.X as string, + value: NUMBER.S1_5 as string, + }, + seekBackward: { + key: KEYBOARD_KEY.A as string, + value: NUMBER.S15 as string, + }, + seekForward: { + key: KEYBOARD_KEY.D as string, + value: NUMBER.S15 as string, + }, + increasePlaybackRate: { + key: KEYBOARD_KEY.W as string, + value: NUMBER.S0_25 as string, + }, + decreasePlaybackRate: { + key: KEYBOARD_KEY.S as string, + value: NUMBER.S0_25 as string, + }, + toggleLoopSegment: { + key: KEYBOARD_KEY.Z as string, + }, + setLoopSegmentBegin: { + key: KEYBOARD_KEY.Q as string, + }, + setLoopSegmentEnd: { + key: KEYBOARD_KEY.E as string, + }, + saveLoopSegment: { + /** Save current loop (loop active) or clear saved loop (loop inactive). */ + key: "" as string, // unset by default — user assigns e.g. "Shift+Z" + }, + audioMode: { + isEnabled: true as boolean, + toggleAudioMode: { + key: KEYBOARD_KEY.B as string, + }, + screenMode: "black" as "black" | "visualizer" | "custom", + customBackground: { + globalImageUrl: "" as string, + }, + visualizerSensitivity: NUMBER.S1_5 as string, + }, + customPlaybackRates: [] as Array, + cycleProfiles: { + // Empty string = unset; user must configure. + key: "" as string, + }, + nudgeLoopSegment: { + /** Combo that nudges the start marker backward (default: Shift+Q). */ + startBackwardKey: `Shift+${KEYBOARD_KEY.Q}` as string, + /** Combo that nudges the start marker forward. Empty = unset. */ + startForwardKey: "" as string, + /** Combo that nudges the end marker forward (default: Shift+E). */ + endForwardKey: `Shift+${KEYBOARD_KEY.E}` as string, + /** Combo that nudges the end marker backward. Empty = unset. */ + endBackwardKey: "" as string, + /** First-press nudge size in seconds. */ + baseStep: "1" as string, + /** Step multiplier for rapid consecutive presses (1 = no acceleration). */ + multiplier: "2" as string, + /** Maximum nudge step ceiling in seconds. */ + maxStep: "16" as string, + }, + segments: { + /** + * Key combo that always starts a fresh-slate segment config + * (1 segment, full video, infinite loop). Defaults to Shift+Z. + */ + freshSlateKey: `Shift+${KEYBOARD_KEY.Z}` as string, + /** + * Default auto-load behaviour on each video page open. + * "off" → manual only; segments never auto-enable + * "last-used" → restore the volatile last-used snapshot if present + * "default" → restore the default saved config if one is set + * Per-video pins (stored in VideoSegmentData.autoLoadPin) override this. + */ + autoLoad: "off" as "off" | "last-used" | "default", + }, + }, + [EXTENSION_CONTEXT_SCOPE.PLAYLIST]: { + isEnabled: true as boolean, + }, + [EXTENSION_CONTEXT_SCOPE.SHORTS]: { + isEnabled: true as boolean, + togglePlaybackRate: { + key: KEYBOARD_KEY.X as string, + value: NUMBER.S1_5 as string, + }, + seekBackward: { + key: KEYBOARD_KEY.A as string, + value: NUMBER.S5 as string, + }, + seekForward: { + key: KEYBOARD_KEY.D as string, + value: NUMBER.S5 as string, + }, + reelAutoScroll: { + value: true as boolean, + }, + }, + }, +}; diff --git a/web-ext/hooks/useActiveTab.ts b/web-ext/hooks/useActiveTab.ts new file mode 100644 index 00000000..95358568 --- /dev/null +++ b/web-ext/hooks/useActiveTab.ts @@ -0,0 +1,23 @@ +import { useEffect, useState } from "react"; +import { + EXTENSION_CONTEXT_SCOPE, + type ExtensionContextScope, +} from "@toppings/constants"; +import { resolveScope } from "../utils/scope"; + +export function useActiveTab() { + const [url, setUrl] = useState(null); + const [scope, setScope] = useState( + EXTENSION_CONTEXT_SCOPE.UNSUPPORTED, + ); + + useEffect(() => { + chrome.tabs?.query?.({ active: true, currentWindow: true }, (tabs) => { + const tabUrl = tabs?.[0]?.url ?? null; + setUrl(tabUrl); + setScope(resolveScope(tabUrl)); + }); + }, []); + + return { url, scope }; +} diff --git a/web-ext/hooks/useCapabilityCache.ts b/web-ext/hooks/useCapabilityCache.ts new file mode 100644 index 00000000..2f031e43 --- /dev/null +++ b/web-ext/hooks/useCapabilityCache.ts @@ -0,0 +1,48 @@ +import { useEffect, useState } from "react"; +import { + getAllCapabilityEntries, + clearCapabilityCache, + type CapabilityCacheEntry, + type CapabilityStatus, +} from "../utils/storage/capabilityCache"; + +export type CapabilityMap = Map; + +interface UseCapabilityCacheResult { + capabilities: CapabilityMap; + isLoading: boolean; + getStatus: (primitiveId: string) => CapabilityStatus; + rescan: () => Promise; +} + +/** + * Read the capability cache and expose per-primitive support status to the + * options UI. Provides a `rescan` function that clears the cache and triggers + * a re-read (the actual re-probe happens on the next YouTube page navigation). + */ +export function useCapabilityCache(): UseCapabilityCacheResult { + const [capabilities, setCapabilities] = useState(new Map()); + const [isLoading, setIsLoading] = useState(true); + + const load = async () => { + setIsLoading(true); + const entries = await getAllCapabilityEntries(); + setCapabilities(new Map(entries.map((e) => [e.primitiveId, e]))); + setIsLoading(false); + }; + + useEffect(() => { + void load(); + }, []); + + const getStatus = (primitiveId: string): CapabilityStatus => { + return capabilities.get(primitiveId)?.status ?? "untested"; + }; + + const rescan = async () => { + await clearCapabilityCache(); + await load(); + }; + + return { capabilities, isLoading, getStatus, rescan }; +} diff --git a/web-ext/hooks/useChromeStorageLocal.ts b/web-ext/hooks/useChromeStorageLocal.ts new file mode 100644 index 00000000..10e419e6 --- /dev/null +++ b/web-ext/hooks/useChromeStorageLocal.ts @@ -0,0 +1,36 @@ +import { defaultTo, isNull } from "@toppings/utils"; +import { useEffect, useState } from "react"; + +export function useChromeStorageLocal( + key: string, + fallback: T, +): [T, (value: T) => void] { + const [value, setValue] = useState(fallback); + + useEffect(() => { + chrome.storage.local.get(key, (result) => { + if (!isNull(result[key])) setValue(result[key] as T); + }); + + const onChange = ( + changes: Record, + areaName: string, + ) => { + if (areaName !== "local") return; + if (key in changes) { + const next = changes[key].newValue as T | undefined; + setValue(defaultTo(next, fallback)); + } + }; + chrome.storage.onChanged.addListener(onChange); + return () => chrome.storage.onChanged.removeListener(onChange); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [key]); + + const update = (next: T) => { + setValue(next); + chrome.storage.local.set({ [key]: next }); + }; + + return [value, update]; +} diff --git a/web-ext/hooks/useStoreUpdater.ts b/web-ext/hooks/useStoreUpdater.ts new file mode 100644 index 00000000..f8c1ca81 --- /dev/null +++ b/web-ext/hooks/useStoreUpdater.ts @@ -0,0 +1,21 @@ +import { useContext } from "react"; +import { produce } from "immer"; +import StoreContext from "../context/store"; +import { Storage } from "../app/background/store"; + +type Recipe = (draft: Storage) => void; + +export function useStoreUpdater(): { + store: Storage; + update: (recipe: Recipe) => void; +} { + const { store, setStore } = useContext(StoreContext)!; + + const update = (recipe: Recipe) => { + const next = produce(store, recipe); + setStore(next); + chrome.storage.sync.set(next); + }; + + return { store, update }; +} diff --git a/web-ext/hooks/useTheme.ts b/web-ext/hooks/useTheme.ts new file mode 100644 index 00000000..1292b244 --- /dev/null +++ b/web-ext/hooks/useTheme.ts @@ -0,0 +1,22 @@ +import { useContext, useEffect } from "react"; +import StoreContext from "../context/store"; +import { + ThemePreference, + resolveTheme, + applyTheme, +} from "../utils/theme"; + +export function useTheme() { + const ctx = useContext(StoreContext)!; + const pref: ThemePreference = ctx.store.ui?.theme ?? "system"; + + useEffect(() => { + applyTheme(resolveTheme(pref)); + + if (pref !== "system" || typeof window === "undefined") return; + const mql = window.matchMedia("(prefers-color-scheme: light)"); + const onChange = () => applyTheme(resolveTheme("system")); + mql.addEventListener?.("change", onChange); + return () => mql.removeEventListener?.("change", onChange); + }, [pref]); +} diff --git a/web-ext/hooks/useVideoPreference.ts b/web-ext/hooks/useVideoPreference.ts new file mode 100644 index 00000000..f174be19 --- /dev/null +++ b/web-ext/hooks/useVideoPreference.ts @@ -0,0 +1,29 @@ +import { useCallback, useEffect, useState } from "react"; +import { + loadVideoPreferenceSnapshot, + type VideoPreferenceSnapshot, +} from "../utils/storage/videoPreference"; + +const EMPTY_SNAPSHOT: VideoPreferenceSnapshot = { + audioMode: { pinCount: 0 }, +}; + +export function useVideoPreference(): { + preferences: VideoPreferenceSnapshot; + refresh: () => void; +} { + const [preferences, setPreferences] = + useState(EMPTY_SNAPSHOT); + + const refresh = useCallback(() => { + loadVideoPreferenceSnapshot().then(setPreferences); + }, []); + + useEffect(() => { + refresh(); + window.addEventListener("focus", refresh); + return () => window.removeEventListener("focus", refresh); + }, [refresh]); + + return { preferences, refresh }; +} diff --git a/web-ext/index.d.ts b/web-ext/index.d.ts index 512c0a71..4763b7ab 100644 --- a/web-ext/index.d.ts +++ b/web-ext/index.d.ts @@ -2,4 +2,3 @@ declare module "*.png"; declare module "*.jpg"; declare module "*.jpeg"; declare module "*.gif"; -declare module "*.svg"; diff --git a/web-ext/package.json b/web-ext/package.json index 3f076628..8124c50d 100644 --- a/web-ext/package.json +++ b/web-ext/package.json @@ -1,6 +1,5 @@ { "name": "Toppings", - "version": "3.0.3", "description": "Your YouTube, Your Way.", "private": "true", "type": "module", @@ -54,6 +53,8 @@ "typescript": "^5.2.2" }, "dependencies": { + "@toppings/constants": "workspace:*", + "@toppings/utils": "workspace:*", "element-ready": "^7.0.0", "immer": "^10.1.1", "react": "^18.3.1", diff --git a/web-ext/scripts/build.js b/web-ext/scripts/build.js index 6cdbad1d..52a86897 100644 --- a/web-ext/scripts/build.js +++ b/web-ext/scripts/build.js @@ -1,5 +1,8 @@ import fs from "node:fs"; import path from "node:path"; +import { URLS } from "../../packages/constants/src/urls.ts"; +import { EXTENSION_VERSION } from "../../packages/constants/src/version.ts"; +import { BRAND_METADATA } from "../../packages/constants/src/brand.ts"; import tailwindcss from "tailwindcss"; import autoprefixer from "autoprefixer"; import CopyWebpackPlugin from "copy-webpack-plugin"; @@ -11,15 +14,15 @@ export default (env) => { mode: env.production ? "production" : "development", devtool: env.production ? false : "cheap-module-source-map", entry: { - background: "./src/background/index.ts", - content: ["./src/content_scripts/index.ts"], + background: "./app/background/index.ts", + content: ["./app/content_scripts/index.ts"], popup: { filename: "./popup/index.js", - import: "./src/popup/index.tsx", + import: "./app/popup/index.tsx", }, options: { filename: "./options/index.js", - import: "./src/options/index.tsx", + import: "./app/options/index.tsx", }, }, output: { @@ -47,8 +50,36 @@ export default (env) => { corePlugins: { preflight: false, }, - content: ["./src/**/*.tsx"], + content: ["./app/**/*.tsx", "./components/**/*.tsx"], prefix: "tw-", + darkMode: ["class", '[data-theme="dark"]'], + theme: { + extend: { + colors: { + bg: "var(--color-bg)", + surface: "var(--color-surface)", + "surface-2": "var(--color-surface-2)", + "surface-hover": "var(--color-surface-hover)", + fg: "var(--color-fg)", + "fg-muted": "var(--color-fg-muted)", + "fg-subtle": "var(--color-fg-subtle)", + "border-subtle": "var(--color-border-subtle)", + "border-default": "var(--color-border-default)", + "border-strong": "var(--color-border-strong)", + accent: "var(--color-accent)", + "accent-hover": "var(--color-accent-hover)", + "accent-fg": "var(--color-accent-fg)", + "danger-bg": "var(--color-danger-bg)", + "danger-fg": "var(--color-danger-fg)", + "success-bg": "var(--color-success-bg)", + "success-fg": "var(--color-success-fg)", + "info-bg": "var(--color-info-bg)", + "info-fg": "var(--color-info-fg)", + "warning-bg": "var(--color-warning-bg)", + "warning-fg": "var(--color-warning-fg)", + }, + }, + }, }), autoprefixer, ], @@ -59,7 +90,7 @@ export default (env) => { }, { test: /\.(ts|tsx)$/, - exclude: /node_modules/, + exclude: /node_modules\/(?!@toppings\/(constants|utils)\/)/, use: { loader: "babel-loader", options: { @@ -81,19 +112,17 @@ export default (env) => { new CleanWebpackPlugin(), new CopyWebpackPlugin({ patterns: [ - { from: "src/assets", to: "assets" }, - { from: "src/options/index.html", to: "options" }, - { from: "src/popup/index.html", to: "popup" }, - { from: "src/_locales", to: "_locales" }, + { from: "assets", to: "assets" }, + { from: "app/options/index.html", to: "options" }, + { from: "app/popup/index.html", to: "popup" }, + { from: "_locales", to: "_locales" }, { - from: "src/manifest.json", + from: "app/manifest.json", to: "manifest.json", transform(content) { - const packageJson = JSON.parse( - fs.readFileSync("package.json", "utf-8"), - ); const manifest = JSON.parse(content.toString()); - manifest.version = packageJson.version; + manifest.version = EXTENSION_VERSION; + manifest.homepage_url = URLS.HOMEPAGE; const isFirefox = env.firefox ? "firefox" : false; if (isFirefox) { @@ -119,7 +148,7 @@ export default (env) => { ); manifest.browser_specific_settings = { - gecko: { id: "toppings@enry.ch" }, + gecko: { id: BRAND_METADATA.ID }, }; } return JSON.stringify(manifest, null, 2); diff --git a/web-ext/scripts/release.js b/web-ext/scripts/release.js index ca2823b6..4de89731 100644 --- a/web-ext/scripts/release.js +++ b/web-ext/scripts/release.js @@ -2,9 +2,9 @@ import path from "node:path"; import archiver from "archiver"; import chalk from "chalk"; import fs from "fs-extra"; +import { EXTENSION_VERSION } from "../../packages/constants/src/version.ts"; const DIST_DIR = path.resolve("dist"); -const PACKAGE_FILE = path.resolve("package.json"); const isFirefox = process.argv[2] === "firefox" ? "firefox" : "chrome"; async function createArchive() { @@ -14,14 +14,7 @@ async function createArchive() { `The 'dist' directory was not found at ${DIST_DIR}. Please ensure it exists before running this script.`, ); } - if (!fs.existsSync(PACKAGE_FILE)) { - throw new Error( - `The 'package.json' file was not found at ${PACKAGE_FILE}. Please ensure it exists before running this script.`, - ); - } - - const packageJson = JSON.parse(fs.readFileSync(PACKAGE_FILE, "utf-8")); - const version = packageJson.version; + const version = EXTENSION_VERSION; const filename = `toppings_v${version}_${isFirefox}.zip`; const store = isFirefox ? "Mozilla Add-ons" : "Chrome Web Store"; @@ -59,10 +52,7 @@ async function createArchive() { throw err; }); - // Add directory to archive await archive.directory(DIST_DIR, false); - - // Finalize archive and remove build folder await archive.finalize(); } catch (err) { console.error("Error creating archive:", err); diff --git a/web-ext/src/assets/svg/apps.svg b/web-ext/src/assets/svg/apps.svg deleted file mode 100644 index a6022d46..00000000 --- a/web-ext/src/assets/svg/apps.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/web-ext/src/assets/svg/arrowhead-left.svg b/web-ext/src/assets/svg/arrowhead-left.svg deleted file mode 100644 index 710d493e..00000000 --- a/web-ext/src/assets/svg/arrowhead-left.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/web-ext/src/assets/svg/bell.svg b/web-ext/src/assets/svg/bell.svg deleted file mode 100644 index 76622335..00000000 --- a/web-ext/src/assets/svg/bell.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/web-ext/src/assets/svg/brackets.svg b/web-ext/src/assets/svg/brackets.svg deleted file mode 100644 index b4ffff6d..00000000 --- a/web-ext/src/assets/svg/brackets.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/web-ext/src/assets/svg/bug_report.svg b/web-ext/src/assets/svg/bug_report.svg deleted file mode 100644 index 494c4fef..00000000 --- a/web-ext/src/assets/svg/bug_report.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/web-ext/src/assets/svg/burger-menu.svg b/web-ext/src/assets/svg/burger-menu.svg deleted file mode 100644 index 52655f82..00000000 --- a/web-ext/src/assets/svg/burger-menu.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/web-ext/src/assets/svg/gear.svg b/web-ext/src/assets/svg/gear.svg deleted file mode 100644 index f21a2b25..00000000 --- a/web-ext/src/assets/svg/gear.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/web-ext/src/assets/svg/heart.svg b/web-ext/src/assets/svg/heart.svg deleted file mode 100644 index 0002ddab..00000000 --- a/web-ext/src/assets/svg/heart.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/web-ext/src/assets/svg/help.svg b/web-ext/src/assets/svg/help.svg deleted file mode 100644 index c4d04ba8..00000000 --- a/web-ext/src/assets/svg/help.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/web-ext/src/assets/svg/home.svg b/web-ext/src/assets/svg/home.svg deleted file mode 100644 index 9c659f8e..00000000 --- a/web-ext/src/assets/svg/home.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/web-ext/src/background/context.ts b/web-ext/src/background/context.ts deleted file mode 100644 index ca33fbcb..00000000 --- a/web-ext/src/background/context.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { - LOCAL_SERVER_URL, - NODE_ENV, - PROD_SERVER_URL, - PATHNAME as YOUTUBE_PATHNAME, - WATCH_LATER_PLAYLIST_ID, - LIKED_VIDEOS_PLAYLIST_ID, - ERROR_MESSAGE, - YOUTUBE_SEARCH_PARAM, - HTTP_METHOD, - HTTP_ACCEPT, - HTTP_STATUS, -} from "../constants/global.constants"; -import { getStorage, Storage } from "./store"; - -const SERVER_BASE_URL = - process.env.NODE_ENV === NODE_ENV.DEVELOPMENT - ? LOCAL_SERVER_URL - : PROD_SERVER_URL; - -export type Context = WatchContext | PlaylistContext | ShortsContext | null; - -export interface BaseContext { - pathname: string; - payload: Record; - store: Storage; -} - -export type WatchContext = BaseContext & { - pathname: typeof YOUTUBE_PATHNAME.WATCH; - payload: WatchPayload; -}; - -export type WatchPayload = { - videoId: string | null; -}; - -export type PlaylistContext = BaseContext & { - pathname: typeof YOUTUBE_PATHNAME.PLAYLIST; - payload: ValidPlaylistPayload | InvalidPlaylistPayload; -}; - -export type ValidPlaylistPayload = { - playlistId: string; - averageRuntime: number; - totalRuntime: number; - totalVideos: number; -}; - -export type InvalidPlaylistPayload = { - playlistId: - | typeof WATCH_LATER_PLAYLIST_ID - | typeof LIKED_VIDEOS_PLAYLIST_ID - | string - | null; -}; - -export type PlaylistResponse = PlaylistContext; - -export type ShortsContext = BaseContext & { - pathname: typeof YOUTUBE_PATHNAME.SHORTS; - payload: ShortsPayload; -}; - -export type ShortsPayload = { - shortId: string | null; -}; - -export const getContext = async (rawURL: string): Promise => { - const url = new URL(rawURL); - const store = await getStorage(); - - if (!store) { - throw new Error(ERROR_MESSAGE.STORE_NOT_FOUND); - } - - if (url.pathname.startsWith("/watch")) { - const videoId = url.searchParams.get(YOUTUBE_SEARCH_PARAM.VIDEO_ID); - if (!videoId) return null; - - return { - pathname: YOUTUBE_PATHNAME.WATCH, - payload: { videoId }, - store, - } as const; - } else if (url.pathname.startsWith("/playlist")) { - const playlistId = url.searchParams.get(YOUTUBE_SEARCH_PARAM.PLAYLIST_ID); - - if ( - !playlistId || - playlistId === WATCH_LATER_PLAYLIST_ID || - playlistId === LIKED_VIDEOS_PLAYLIST_ID - ) { - return { - pathname: YOUTUBE_PATHNAME.PLAYLIST, - payload: { playlistId }, - store, - } as const; - } - - try { - const response = await fetch( - `${SERVER_BASE_URL}/playlist/${playlistId}`, - { - method: HTTP_METHOD.GET, - headers: { Accept: HTTP_ACCEPT.JSON }, - }, - ); - - if (!response.ok) { - throw new Error(ERROR_MESSAGE.FAILED_FETCHING_PLAYLIST_DATA); - } - - const body = (await response.json()) as PlaylistResponse; - - return { - ...body, - store, - } as const; - } catch (error) { - console.error(ERROR_MESSAGE.FAILED_FETCHING_PLAYLIST_DATA, error); - return { - pathname: YOUTUBE_PATHNAME.PLAYLIST, - payload: { playlistId }, - store, - } as const; - } - } else if (url.pathname.startsWith("/shorts")) { - const shortId = url.pathname.split("/")[2] || null; - return { - pathname: YOUTUBE_PATHNAME.SHORTS, - payload: { shortId }, - store, - } as const; - } - - return null; -}; diff --git a/web-ext/src/background/index.ts b/web-ext/src/background/index.ts deleted file mode 100644 index 80e14c52..00000000 --- a/web-ext/src/background/index.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { DEFAULT_STORE } from "./store"; -import { getContext } from "./context"; -import { dispatchContext } from "./utils/dispatchContext"; -import { - EVENT_TYPE, - INSTALL_REASON, - INSTALL_URL, - MESSAGE_TYPE, - NODE_ENV, - UNINSTALL_URL, -} from "../constants/global.constants"; - -// Handle Events -chrome.runtime.onInstalled.addListener(onInitialize); -chrome.runtime.onMessage.addListener(onConnected); -chrome.webNavigation.onHistoryStateUpdated.addListener(onWebNavigation); - -type InitializeDetails = Parameters< - Parameters[0] ->[0]; -function onInitialize({ reason }: InitializeDetails): void { - if (reason === INSTALL_REASON.INSTALL || reason === INSTALL_REASON.UPDATE) { - if (process.env.NODE_ENV === NODE_ENV.PRODUCTION) { - void chrome.tabs.create({ url: INSTALL_URL }); - void chrome.runtime.setUninstallURL(UNINSTALL_URL); - } - void chrome.storage.sync.set(DEFAULT_STORE); - } -} - -type WebNavigationDetails = Parameters< - Parameters[0] ->[0]; -async function onWebNavigation(details: WebNavigationDetails) { - const tabId = details.tabId; - - const ctx = await getContext(details.url); - if (!ctx?.store.isExtensionEnabled) return; - await dispatchContext(tabId, ctx); -} - -function onConnected( - message: any, - sender: chrome.runtime.MessageSender, - sendResponse: (response: any) => void, -) { - (async () => { - // This function handles the initial handshake between the content script and the background script. - const { type, payload } = JSON.parse(message); - if (type !== MESSAGE_TYPE.EVENT) return; - - const event = payload; - if (event !== EVENT_TYPE.CONNECTED) return; - - const tabId = sender.tab?.id; - if (!tabId) return; - - const url = sender.url; - if (!url) return; - - const ctx = await getContext(url); - if (!ctx?.store.isExtensionEnabled) return; - - sendResponse(JSON.stringify({ type: "context", payload: ctx })); - })(); - - // Return true to indicate that sendResponse will be called asynchronously - return true; -} diff --git a/web-ext/src/background/store.ts b/web-ext/src/background/store.ts deleted file mode 100644 index 08e829a0..00000000 --- a/web-ext/src/background/store.ts +++ /dev/null @@ -1,85 +0,0 @@ -export const DEFAULT_STORE = { - isExtensionEnabled: true as boolean, - preferences: { - watch: { - isEnabled: true as boolean, - defaultPlaybackRate: { - value: "1.00" as string, - }, - togglePlaybackRate: { - key: "X" as string, - value: "1.50" as string, - }, - seekBackward: { - key: "A" as string, - value: "15.00" as string, - }, - seekForward: { - key: "D" as string, - value: "15.00" as string, - }, - increasePlaybackRate: { - key: "W" as string, - value: "0.25" as string, - }, - decreasePlaybackRate: { - key: "S" as string, - value: "0.25" as string, - }, - toggleLoopSegment: { - key: "Z" as string, - }, - setLoopSegmentBegin: { - key: "Q" as string, - }, - setLoopSegmentEnd: { - key: "E" as string, - }, - // TODO: The Options page should save the values with toFixed(2) as part of saving - // validation so that content script doesn't have to bother about it. - customPlaybackRates: [ - "0.25", - "0.50", - "0.75", - "1.00", - "1.25", - "1.50", - "1.75", - "2.00", - "2.25", - "2.50", - ] as Array, - }, - playlist: { - isEnabled: true as boolean, - }, - shorts: { - isEnabled: true as boolean, - togglePlaybackRate: { - key: "X" as string, - value: "1.5" as string, - }, - seekBackward: { - key: "A" as string, - value: "5.00" as string, - }, - seekForward: { - key: "D" as string, - value: "5.00" as string, - }, - reelAutoScroll: { - value: true as boolean, - }, - }, - }, -}; - -export const getStorage = async (): Promise => { - return new Promise((resolve) => { - chrome.storage.sync.get(undefined, (storage) => { - resolve(storage as Storage); - }); - }); -}; - -export type Storage = typeof DEFAULT_STORE; diff --git a/web-ext/src/background/utils/dispatchContext.ts b/web-ext/src/background/utils/dispatchContext.ts deleted file mode 100644 index 3bb3d792..00000000 --- a/web-ext/src/background/utils/dispatchContext.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Context } from "../context"; - -/** - * Dispatches the provided context to the specified tab using `chrome.tabs.sendMessage`. - * - * @param {number} tabId - The ID of the tab to send the message to. - * @param {Context} ctx - The context to be sent to the tab. - * @returns {Promise} A promise that resolves when the message is sent. - * - * @remarks - * - **Serialization in different browsers:** - * - **Firefox**: Uses the Structured Clone Algorithm. - * - **Chrome**: Currently uses JSON serialization, but may adopt the Structured Clone Algorithm in the future (Chrome issue 248548). - * - * - **Structured Clone Algorithm** (used by Firefox): - * - Supports a broader range of object types compared to JSON serialization. - * - * - **JSON Serialization** (used by Chrome): - * - Does not handle certain object types like DOM objects natively. - * - Objects with a `toJSON()` method (e.g., `URL`, `PerformanceEntry`) can be serialized with JSON, but they are still not structured cloneable. - * - * @note For extensions relying on `toJSON()`, use `JSON.stringify()` followed by `JSON.parse()` to ensure the message is structurally cloneable across browsers. - */ -export const dispatchContext = async ( - tabId: number, - ctx: Exclude, -): Promise => { - // For URL, To ensure the message is structurally cloneable across browsers. - const message = { type: "context", payload: ctx }; - const serializedContext = JSON.stringify(message); - // sendMessage will throw "Error: Could not establish connection. Receiving end does not exist." - // if there is no content script loaded in the given tab. This error is - // noisy and mysterious (it usually doesn't have a valid line number), so we silence it. - return await chrome.tabs - .sendMessage(tabId, serializedContext) - .catch(() => {}); -}; diff --git a/web-ext/src/constants/global.constants.ts b/web-ext/src/constants/global.constants.ts deleted file mode 100644 index 6a19a590..00000000 --- a/web-ext/src/constants/global.constants.ts +++ /dev/null @@ -1,59 +0,0 @@ -export const NODE_ENV = { - DEVELOPMENT: "development", - PRODUCTION: "production", -} as const; - -export const PROD_SERVER_URL = "https://toppings.enry.ch"; -export const LOCAL_SERVER_URL = "http://localhost:8787"; - -export const INSTALL_URL = "https://enrych.github.io/toppings/greetings"; -export const UNINSTALL_URL = "https://enrych.github.io/toppings/farewell"; - -export const INSTALL_REASON = { - INSTALL: "install", - UPDATE: "update", -} as const; - -export const WATCH_LATER_PLAYLIST_ID = "WL" as const; -export const LIKED_VIDEOS_PLAYLIST_ID = "LL" as const; - -export const PATHNAME = { - WATCH: "watch", - PLAYLIST: "playlist", - SHORTS: "shorts", -} as const; - -export const ERROR_MESSAGE = { - STORE_NOT_FOUND: "Extension store not found.", - FAILED_FETCHING_PLAYLIST_DATA: "Failed to fetch playlist data.", -} as const; - -export const WARNING_MESSAGE = { - NO_HANDLER_FOUND: "No handler found", -}; - -export const YOUTUBE_SEARCH_PARAM = { - VIDEO_ID: "v", - PLAYLIST_ID: "list", -} as const; - -export const HTTP_METHOD = { - GET: "GET", -} as const; - -export const HTTP_ACCEPT = { - JSON: "application/json", -} as const; - -export const HTTP_STATUS = { - NOT_FOUND: 404, -} as const; - -export const MESSAGE_TYPE = { - EVENT: "event", - CONTEXT: "context", -} as const; - -export const EVENT_TYPE = { - CONNECTED: "connected", -} as const; diff --git a/web-ext/src/content_scripts/components/LoopSegment.tsx b/web-ext/src/content_scripts/components/LoopSegment.tsx deleted file mode 100644 index 1f6db6c2..00000000 --- a/web-ext/src/content_scripts/components/LoopSegment.tsx +++ /dev/null @@ -1,210 +0,0 @@ -import React from "dom-chef"; -import elementReady from "element-ready"; - -let isMarkerDragging: "start" | "end" | null = null; -let video: HTMLVideoElement | null = null; -let timeUpdateListener: ((event: Event) => void) | null = null; - -export const LoopSegmentButton = ( - -); - -export const LoopSegmentStartMarker = ( -
handleMarkerDrag(e, "start")} - /> -); - -export const LoopSegmentEndMarker = ( -
handleMarkerDrag(e, "end")} - /> -); - -export async function setupLoopSegment() { - video = (await elementReady("video")) as HTMLVideoElement | null; - disableLoopSegment(); - LoopSegmentButton.setAttribute("aria-pressed", "false"); - LoopSegmentStartMarker.classList.add("tw-hidden"); - LoopSegmentEndMarker.classList.add("tw-hidden"); -} - -export function toggleLoopSegment(_event?: React.MouseEvent) { - const isPressed = LoopSegmentButton.getAttribute("aria-pressed") ?? "false"; - if (isPressed === "true") { - disableLoopSegment(); - LoopSegmentButton.setAttribute("aria-pressed", "false"); - } else { - enableLoopSegment(); - LoopSegmentButton.setAttribute("aria-pressed", "true"); - } - LoopSegmentStartMarker.classList.toggle("tw-hidden"); - LoopSegmentEndMarker.classList.toggle("tw-hidden"); -} - -async function enableLoopSegment() { - if (!video) return; - - timeUpdateListener = (_event: Event) => { - if (!video) return; - const currentTime = video.currentTime; - const loopStartTime = getTimeFromMarker("#start-marker") || 0; - let loopEndTime = getTimeFromMarker("#end-marker") || video.duration; - - if (video.duration - loopEndTime <= 0.3) { - loopEndTime = video.duration - 0.3; - } - - const isLoopSegmentEnabled = - LoopSegmentButton.getAttribute("aria-pressed") ?? "false"; - if (currentTime > loopEndTime && isLoopSegmentEnabled === "true") { - video.currentTime = loopStartTime; - } - }; - - video.addEventListener("timeupdate", timeUpdateListener); - LoopSegmentStartMarker.style.left = "0%"; - LoopSegmentEndMarker.style.left = "100%"; -} - -function disableLoopSegment() { - if (video && timeUpdateListener) { - video.removeEventListener("timeupdate", timeUpdateListener); - timeUpdateListener = null; - } -} - -function getTimeFromMarker(markerSelector: "#start-marker" | "#end-marker") { - if (!video) return; - - const duration = video.duration; - const marker = document.querySelector(markerSelector); - if (!marker) return; - const markerPositionPercentage = parseFloat( - (marker as HTMLDivElement).style.left, - ); - - return (markerPositionPercentage / 100) * duration; -} - -function handleMarkerDrag( - e: React.MouseEvent, - markerType: "start" | "end" | null, -) { - e.preventDefault(); - isMarkerDragging = markerType; - - document.addEventListener("mousemove", handleMouseMove); - document.addEventListener("mouseup", handleMouseUp); -} - -function handleMouseMove(e: MouseEvent) { - if (!isMarkerDragging) return; - - const progressBarContainer = document.querySelector( - ".ytp-progress-bar-container", - ); - if (!progressBarContainer) return; - const progressBarRect = progressBarContainer.getBoundingClientRect(); - const progressBarWidth = progressBarRect.width; - - const xPosition = e.clientX - progressBarRect.left; - const newPosition = Math.max(0, Math.min(xPosition, progressBarWidth)); - let newPercentage = (newPosition / progressBarWidth) * 100; - - const startPercentage = parseFloat(LoopSegmentStartMarker.style.left); - const endPercentage = parseFloat(LoopSegmentEndMarker.style.left); - - // Prevent markers from crossing each other - if (isMarkerDragging === "start" && newPercentage >= endPercentage) { - newPercentage = endPercentage - 0.1; - } else if (isMarkerDragging === "end" && newPercentage <= startPercentage) { - newPercentage = startPercentage + 0.1; - } - - const marker = document.getElementById(`${isMarkerDragging}-marker`); - if (marker) { - marker.style.left = `${newPercentage}%`; - } - if (video && isMarkerDragging === "start") { - const loopStartTime = (newPercentage / 100) * video.duration; - video.currentTime = loopStartTime; - } -} - -function handleMouseUp() { - if (isMarkerDragging) { - isMarkerDragging = null; - document.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener("mouseup", handleMouseUp); - } -} - -export function setLoopSegmentStart() { - if (!video) return; - - const isLoopSegmentEnabled = - LoopSegmentButton.getAttribute("aria-pressed") === "true"; - - if (!isLoopSegmentEnabled) { - return; - } - - const currentTime = video.currentTime; - const duration = video.duration; - const percentage = (currentTime / duration) * 100; - - LoopSegmentStartMarker.style.left = `${percentage}%`; - - // Ensure start marker does not cross the end marker - const endPercentage = parseFloat(LoopSegmentEndMarker.style.left); - if (percentage >= endPercentage) { - LoopSegmentStartMarker.style.left = `${endPercentage - 0.1}%`; - } -} - -export function setLoopSegmentEnd() { - if (!video) return; - - const isLoopSegmentEnabled = - LoopSegmentButton.getAttribute("aria-pressed") === "true"; - - if (!isLoopSegmentEnabled) { - return; - } - - const currentTime = video.currentTime; - const duration = video.duration; - const percentage = (currentTime / duration) * 100; - - LoopSegmentEndMarker.style.left = `${percentage}%`; - - // Ensure end marker does not cross the start marker - const startPercentage = parseFloat(LoopSegmentStartMarker.style.left); - if (percentage <= startPercentage) { - LoopSegmentEndMarker.style.left = `${startPercentage + 0.1}%`; - } -} diff --git a/web-ext/src/content_scripts/index.css b/web-ext/src/content_scripts/index.css deleted file mode 100644 index 0ae1ed21..00000000 --- a/web-ext/src/content_scripts/index.css +++ /dev/null @@ -1,6 +0,0 @@ -@tailwind base; -@tailwind utilities; - -#contentContainer.tp-yt-app-drawer[swipe-open].tp-yt-app-drawer::after { - width: 10px !important; -} diff --git a/web-ext/src/content_scripts/index.ts b/web-ext/src/content_scripts/index.ts deleted file mode 100644 index 398b3981..00000000 --- a/web-ext/src/content_scripts/index.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { Context } from "../background/context"; -import type { Storage } from "../background/store"; - -import onPlaylistPage from "./pages/playlist"; -import onShortsPage from "./pages/shorts"; -import onWatchPage from "./pages/watch"; -import "./index.css"; -import { - EVENT_TYPE, - MESSAGE_TYPE, - WARNING_MESSAGE, -} from "../constants/global.constants"; - -const pageHandlers: Record = { - playlist: onPlaylistPage, - shorts: onShortsPage, - watch: onWatchPage, -}; - -function runApp(message: any): undefined { - (async () => { - const { type, payload } = JSON.parse(message); - - if (type !== MESSAGE_TYPE.CONTEXT) return; - const ctx = payload as Exclude; - - const { pathname, store } = ctx; - const handler = pageHandlers[pathname]; - - if (!handler) { - console.warn(WARNING_MESSAGE.NO_HANDLER_FOUND, pathname); - return; - } - - const isEnabled = store.preferences[pathname].isEnabled; - if (!isEnabled) return; - - await handler(ctx); - })(); -} - -// Handle Events -chrome.runtime.sendMessage( - chrome.runtime.id, - JSON.stringify({ type: MESSAGE_TYPE.EVENT, payload: EVENT_TYPE.CONNECTED }), - {}, - runApp, -); -chrome.runtime.onMessage.addListener(runApp); diff --git a/web-ext/src/content_scripts/pages/watch.tsx b/web-ext/src/content_scripts/pages/watch.tsx deleted file mode 100644 index 35d9b8eb..00000000 --- a/web-ext/src/content_scripts/pages/watch.tsx +++ /dev/null @@ -1,343 +0,0 @@ -import React from "dom-chef"; -import elementReady from "element-ready"; -import { - setupLoopSegment, - LoopSegmentButton, - LoopSegmentEndMarker, - LoopSegmentStartMarker, - toggleLoopSegment, - setLoopSegmentStart, - setLoopSegmentEnd, -} from "../components/LoopSegment"; -import { WatchContext } from "../../background/context"; -import { Storage } from "../../background/store"; - -let player: HTMLVideoElement | undefined; -let playbackMenuButton: HTMLElement | undefined; -let preferences: Storage["preferences"]["watch"] | undefined; - -const onWatchPage = async (ctx: WatchContext) => { - const { store } = ctx; - preferences = store.preferences.watch; - if (preferences === undefined) return; - - player = await elementReady("video", { - stopOnDomReady: false, - }); - if (player === null || player === undefined) return; - - // Reset Player - player.playbackRate = parseFloat(preferences.defaultPlaybackRate.value); - const labels = document.querySelectorAll(".ytp-menuitem-label"); - if (labels.length !== 0) { - for (const label of labels) { - if (label.textContent === "Playback speed") { - const playbackMenuButton = label.parentNode as HTMLElement; - playbackMenuButton.children[2].textContent = - player.playbackRate === 1 - ? "Normal" - : `${Number(player.playbackRate.toFixed(2))}`; - break; - } - } - } - - // Keyboard Shortcuts - document.removeEventListener("keydown", useShortcuts); - document.addEventListener("keydown", useShortcuts); - - // Loop Segment - const rightControls = await elementReady("div.ytp-right-controls"); - if (rightControls) { - rightControls.prepend(LoopSegmentButton); - } - const progressBar = await elementReady("div.ytp-progress-bar-container"); - if (progressBar) { - await setupLoopSegment(); - progressBar.append(LoopSegmentStartMarker, LoopSegmentEndMarker); - } - - const playerSettingsButton = await elementReady("button.ytp-settings-button"); - if (playerSettingsButton === undefined) return; - playerSettingsButton.removeEventListener("click", onSettingsMenu); - playerSettingsButton.addEventListener("click", onSettingsMenu); -}; - -const onSettingsMenu = async (): Promise => { - if (player === null || player === undefined) return; - - const menuItemLabels = await elementReady(".ytp-menuitem-label"); - if (menuItemLabels === undefined) return; - const labels = document.querySelectorAll(".ytp-menuitem-label"); - if (labels.length === 0) return; - - for (const label of labels) { - if (label.textContent === "Playback speed") { - playbackMenuButton = label.parentNode as HTMLElement; - playbackMenuButton.children[2].textContent = - player.playbackRate === 1 - ? "Normal" - : `${Number(player.playbackRate.toFixed(2))}`; - - playbackMenuButton.removeEventListener("click", onPlaybackRateMenu); - playbackMenuButton.addEventListener("click", onPlaybackRateMenu); - break; - } - } -}; - -const onPlaybackRateMenu = async (): Promise => { - if (player === undefined || player === null) return; - - const playbackRatePanel = await elementReady(".ytp-panel-animate-forward"); - if (playbackRatePanel === undefined) return; - - // Disable Native Custom PlaybackRate Slider Option - const menuPanelOptions = playbackRatePanel.querySelector( - ".ytp-panel-options", - ) as HTMLElement | null; - if (menuPanelOptions) { - menuPanelOptions.style.display = "none"; - } - - replacePlaybackItems(playbackRatePanel); -}; - -const replacePlaybackItems = (playbackRatePanel: HTMLElement) => { - if (player === undefined || player === null) return; - if (preferences === undefined) return; - - // Replace Native PlaybackRate Items - const panelMenu = playbackRatePanel.querySelector(".ytp-panel-menu"); - if (panelMenu === null) return; - - const currentRate = player.playbackRate.toFixed(2); - const isPresetRate = preferences.customPlaybackRates.some( - (rate) => rate === currentRate, - ); - - const playbackRateItems = preferences.customPlaybackRates.map( - (playbackRate) => { - const label = playbackRate === "1.00" ? "Normal" : Number(playbackRate); - const isAriaChecked = playbackRate === currentRate ? "true" : "false"; - - return ( -
{ - const panelBackButton = document.querySelector( - ".ytp-panel-back-button", - ) as HTMLElement | null; - if (panelBackButton !== null) { - panelBackButton.click(); - } - setPlaybackRate(Number(playbackRate)); - }} - > -
{label}
-
- ); - }, - ); - - const customPlaybackRateItem = ( -
{ - const panelBackButton = document.querySelector( - ".ytp-panel-back-button", - ) as HTMLElement | null; - if (panelBackButton !== null) { - panelBackButton.click(); - } - setPlaybackRate( - Number(player!.getAttribute("data-tppng-playback-rate") ?? "1"), - ); - }} - > -
- Custom ( - {Number( - player!.getAttribute("data-tppng-playback-rate") ?? - player!.playbackRate, - )} - ) -
-
- ); - - panelMenu.replaceChildren(customPlaybackRateItem, ...playbackRateItems); -}; - -const useShortcuts = (event: KeyboardEvent): void => { - if (!player || !preferences) return; - - const target = event.target as HTMLElement; - const tagName = target?.tagName; - const isNotEditable = - tagName !== "INPUT" && - tagName !== "TEXTAREA" && - !target.matches("#contenteditable-root.yt-formatted-string"); - - if (!isNotEditable) return; - - const key = event.key.toLowerCase(); - - switch (key) { - case preferences.togglePlaybackRate.key.toLowerCase(): { - setPlaybackRate( - player.playbackRate !== 1 - ? 1 - : Number(preferences.togglePlaybackRate.value), - ); - break; - } - case preferences.seekBackward.key.toLowerCase(): { - const value = Number(preferences.seekBackward.value); - player.currentTime -= value; - onDoubleTapSeek("back", value); - break; - } - case preferences.seekForward.key.toLowerCase(): { - const value = Number(preferences.seekForward.value); - player.currentTime += value; - onDoubleTapSeek("forward", value); - break; - } - case preferences.increasePlaybackRate.key.toLowerCase(): { - const value = Number(preferences.increasePlaybackRate.value); - const increasedPlaybackRate = Number( - (player.playbackRate + value).toFixed(2), - ); - if (increasedPlaybackRate <= 16) { - setPlaybackRate(increasedPlaybackRate); - } - break; - } - case preferences.decreasePlaybackRate.key.toLowerCase(): { - const value = Number(preferences.decreasePlaybackRate.value); - const decreasedPlaybackRate = Number( - (player.playbackRate - value).toFixed(2), - ); - if (decreasedPlaybackRate >= 0.0625) { - setPlaybackRate(decreasedPlaybackRate); - } - break; - } - case preferences.toggleLoopSegment.key.toLowerCase(): { - toggleLoopSegment(); - break; - } - case preferences.setLoopSegmentBegin.key.toLowerCase(): { - setLoopSegmentStart(); - break; - } - case preferences.setLoopSegmentEnd.key.toLowerCase(): { - setLoopSegmentEnd(); - break; - } - default: - break; - } -}; - -let doubleTapSeekTimeout: ReturnType; -const onDoubleTapSeek = (dataSide: "back" | "forward", time: number): void => { - const doubleTapSeekElement = document.querySelector( - ".ytp-doubletap-ui-legacy", - ) as HTMLElement | null; - if (doubleTapSeekElement !== null) { - doubleTapSeekElement.setAttribute("data-side", dataSide); - doubleTapSeekElement.style.display = ""; - const doubleTapSeekLabel = doubleTapSeekElement.querySelector( - ".ytp-doubletap-tooltip-label", - ) as HTMLElement; - if (doubleTapSeekLabel !== null) { - doubleTapSeekLabel.textContent = `${time} seconds`; - } - const staticCircle = document.querySelector( - ".ytp-doubletap-static-circle", - ) as HTMLElement; - if (staticCircle !== null && dataSide === "back") { - staticCircle.style.top = "50%"; - staticCircle.style.left = "10%"; - staticCircle.style.width = "110px"; - staticCircle.style.height = "110px"; - staticCircle.style.transform = "translate(-14px, -40px)"; - } else if (staticCircle !== null && dataSide === "forward") { - staticCircle.style.top = "50%"; - staticCircle.style.left = "80%"; - staticCircle.style.width = "110px"; - staticCircle.style.height = "110px"; - staticCircle.style.transform = "translate(-28px, -40px)"; - } - clearTimeout(doubleTapSeekTimeout); - doubleTapSeekTimeout = setTimeout(() => { - (doubleTapSeekElement as HTMLElement).setAttribute("data-side", "null"); - (doubleTapSeekElement as HTMLElement).style.display = "none"; - const doubleTapLabel = ( - doubleTapSeekElement as HTMLElement - ).querySelector(".ytp-doubletap-tooltip-label"); - if (doubleTapLabel !== null) { - doubleTapLabel.textContent = "5 seconds"; - } - staticCircle.style.cssText = "null"; - }, 500); - } -}; - -const setPlaybackRate = (rate: number): void => { - if (player === null || player === undefined) return; - - const prevPlaybackRate = player.playbackRate.toFixed(2); - const prevPlaybackMenuItem = - document.querySelector( - `.tppng-playback-item[data-tppng-playback-rate="${prevPlaybackRate}"]`, - ) || document.querySelector("#tppng-playback-custom-item"); - if (prevPlaybackMenuItem) { - prevPlaybackMenuItem.ariaChecked = "false"; - } - - player.playbackRate = rate; - - const nextPlaybackRate = player.playbackRate.toFixed(2); - const nextPlaybackMenuItem: HTMLElement | null = - document.querySelector( - `.tppng-playback-item[data-tppng-playback-rate="${nextPlaybackRate}"]`, - ) || document.querySelector("#tppng-playback-custom-item"); - if (nextPlaybackMenuItem) { - nextPlaybackMenuItem.ariaChecked = "true"; - if (nextPlaybackMenuItem.id === "tppng-playback-custom-item") { - player.setAttribute( - "data-tppng-playback-rate", - player.playbackRate.toFixed(2), - ); - nextPlaybackMenuItem.style.display = ""; - const customPlaybackItemLabel = document.querySelector( - "#tppng-playback-custom-item > .ytp-menuitem-label", - ); - if (customPlaybackItemLabel) { - customPlaybackItemLabel.textContent = `Custom (${player.playbackRate})`; - } - } - } - - if (playbackMenuButton !== null && playbackMenuButton !== undefined) { - playbackMenuButton.children[2].textContent = - player.playbackRate === 1 - ? "Normal" - : `${Number(player.playbackRate.toFixed(2))}`; - } -}; - -export default onWatchPage; diff --git a/web-ext/src/options/App.tsx b/web-ext/src/options/App.tsx deleted file mode 100644 index 84c04eb2..00000000 --- a/web-ext/src/options/App.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import React, { useState } from "react"; -import { Outlet, useLoaderData } from "react-router-dom"; -import Navbar from "./components/Navbar"; -import StoreContext from "./store"; -import { Storage } from "../background/store"; - -function App() { - const loadedStore = useLoaderData() as Storage; - const [store, setStore] = useState(loadedStore); - - return ( - -
- -
- -
-
-
- ); -} - -export default App; diff --git a/web-ext/src/options/components/Card.tsx b/web-ext/src/options/components/Card.tsx deleted file mode 100644 index d166791d..00000000 --- a/web-ext/src/options/components/Card.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import React, { ReactNode } from "react"; - -function Card({ - title, - description, - children, -}: { - title?: string; - description?: string; - children: ReactNode | ReactNode[]; -}) { - return ( -
- {(title || description) && ( -
-

{title}

-

{description}

-
- )} -
- {children} -
-
- ); -} - -export default Card; diff --git a/web-ext/src/options/components/Input.tsx b/web-ext/src/options/components/Input.tsx deleted file mode 100644 index d1a655d6..00000000 --- a/web-ext/src/options/components/Input.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import React from "react"; -import { ChangeEvent, useState, useEffect, useRef } from "react"; -import Tooltip from "./Tooltip"; - -export default function InputBlock({ - title, - description, - initialValue, - validator, - onChange, -}: { - title: string; - description?: string; - initialValue: string; - validator: (value: string) => boolean; - onChange: (value: string) => void; -}) { - const [value, setValue] = useState(initialValue); - const [isLoading, setIsLoading] = useState(false); - const [isValid, setIsValid] = useState(null); - const debounceTimer = useRef(null); - - const onChangeHandler = (e: ChangeEvent) => { - e.preventDefault(); - e.stopPropagation(); - setValue(e.target.value); - setIsValid(null); - setIsLoading(true); - - if (debounceTimer.current) { - clearTimeout(debounceTimer.current); - } - - debounceTimer.current = setTimeout(() => { - const isValid = validator(e.target.value); - setIsLoading(false); - setIsValid(isValid); - - if (isValid) { - onChange(e.target.value); - // Hide the validation SVG if valid - setTimeout(() => { - setIsValid(null); - }, 1500); - } - }, 750); - }; - - useEffect(() => { - return () => { - if (debounceTimer.current) { - clearTimeout(debounceTimer.current); - } - }; - }, []); - - return ( -
-
-
- - {description && ( - - - - - - )} -
-
- - {isLoading && ( - - - - - )} - {isValid === true && ( - - - - )} - {isValid === false && ( - - - - )} -
-
-
- ); -} diff --git a/web-ext/src/options/components/Keybinding.tsx b/web-ext/src/options/components/Keybinding.tsx deleted file mode 100644 index deb7146d..00000000 --- a/web-ext/src/options/components/Keybinding.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import React from "react"; -import { KeyboardEvent, useState } from "react"; -import Tooltip from "./Tooltip"; - -export default function KeybindingBlock({ - title, - description, - keybinding, - onChange, -}: { - title: string; - description?: string; - keybinding: string; - onChange: (key: string) => void; -}) { - const [key, setKey] = useState(keybinding); - const recordKeyHandler = (e: KeyboardEvent) => { - if ( - (e.keyCode >= 48 && e.keyCode <= 57) || // Numbers 0-9 - (e.keyCode >= 65 && e.keyCode <= 90) // Letters A-Z - ) { - e.preventDefault(); - e.stopPropagation(); - const key = String.fromCharCode(e.keyCode); - setKey(key); - onChange(key); - } else if (e.keyCode === 8 || e.keyCode === 27) { - const key = ""; - setKey(key); - onChange(key); - } - }; - - const focusHandler = () => { - setKey(""); - }; - - const blurHandler = () => { - setKey(keybinding); - }; - - return ( -
-
- - {description && ( - - - - - - )} -
- -
- ); -} diff --git a/web-ext/src/options/components/Navbar.tsx b/web-ext/src/options/components/Navbar.tsx deleted file mode 100644 index 4bc530f7..00000000 --- a/web-ext/src/options/components/Navbar.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import React from "react"; - -export default function Navbar() { - const version = chrome.runtime.getManifest().version; - - return ( -
-
-

Toppings

-
- v{version} -
-
-
- ); -} diff --git a/web-ext/src/options/components/Switch.tsx b/web-ext/src/options/components/Switch.tsx deleted file mode 100644 index 2f0657fc..00000000 --- a/web-ext/src/options/components/Switch.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import React, { ChangeEvent, useState } from "react"; -import Tooltip from "./Tooltip"; - -export default function SwitchBlock({ - title, - description, - isEnabled, - onToggle, -}: { - title: string; - description?: string; - isEnabled: boolean; - onToggle: (isEnabled: boolean) => void; -}) { - const [checked, setChecked] = useState(isEnabled); - - const handleChange = (e: ChangeEvent) => { - const isChecked = e.target.checked; - setChecked(isChecked); - onToggle(isChecked); - }; - return ( -
-
- - {description && ( - - - - - - )} -
- -
- ); -} diff --git a/web-ext/src/options/components/Tooltip.tsx b/web-ext/src/options/components/Tooltip.tsx deleted file mode 100644 index 9e2c4efd..00000000 --- a/web-ext/src/options/components/Tooltip.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import React, { ReactNode, useState } from "react"; - -interface TooltipProps { - children: ReactNode; - text: string; -} - -const Tooltip = ({ children, text }: TooltipProps) => { - const [visible, setVisible] = useState(false); - - return ( -
-
setVisible(true)} - onMouseLeave={() => setVisible(false)} - className="tw-cursor-pointer" - > - {children} -
- {visible && ( -
- {text} -
- )} -
- ); -}; - -export default Tooltip; diff --git a/web-ext/src/options/index.tsx b/web-ext/src/options/index.tsx deleted file mode 100644 index 2ed88af8..00000000 --- a/web-ext/src/options/index.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import React from "react"; -import ReactDOM from "react-dom/client"; -import { createMemoryRouter, RouterProvider } from "react-router-dom"; -import App from "./App"; -import General from "./routes/General"; -import ErrorPage from "./routes/error-page"; -import "./index.css"; -import { getStorage } from "../background/store"; - -const router = createMemoryRouter([ - { - path: "/", - element: , - loader: getStorage, - errorElement: , - children: [ - { - path: "", - element: , - }, - ], - }, -]); - -ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - - - , -); diff --git a/web-ext/src/options/routes/General.tsx b/web-ext/src/options/routes/General.tsx deleted file mode 100644 index 004ed4ee..00000000 --- a/web-ext/src/options/routes/General.tsx +++ /dev/null @@ -1,428 +0,0 @@ -import React, { ChangeEvent } from "react"; -import { useContext } from "react"; -import { produce } from "immer"; -import StoreContext from "../store"; -import Card from "../components/Card"; -import Switch from "../components/Switch"; -import Keybinding from "../components/Keybinding"; -import Input from "../components/Input"; - -export default function General() { - const { store, setStore } = useContext(StoreContext)!; - - const setIsExtensionEnabled = (isEnabled: boolean) => { - const newConfig = produce(store, (draft) => { - draft.isExtensionEnabled = isEnabled; - }); - setStore(newConfig); - - const prefix = `${isEnabled ? "" : "disabled_"}`; - const browser = getBrowserEngine(); - - if (browser === "firefox") { - // @ts-ignore: Firefox uses `browserAction` instead of `action` in Manifest V2, - // but the TypeScript definitions for `chrome` do not account for this discrepancy. - chrome.browserAction.setIcon({ - path: { - 16: `/assets/icons/${prefix}icon16.png`, - 32: `/assets/icons/${prefix}icon32.png`, - 48: `/assets/icons/${prefix}icon48.png`, - 128: `/assets/icons/${prefix}icon128.png`, - }, - }); - chrome.storage.sync.set(newConfig); - } else { - chrome.action.setIcon({ - path: { - 16: `/assets/icons/${prefix}icon16.png`, - 32: `/assets/icons/${prefix}icon32.png`, - 48: `/assets/icons/${prefix}icon48.png`, - 128: `/assets/icons/${prefix}icon128.png`, - }, - }); - chrome.storage.sync.set(newConfig); - } - }; - - const getBrowserEngine = (): "firefox" | "chromium" => { - if (typeof navigator !== "undefined" && navigator.userAgent) { - if (navigator.userAgent.includes("Firefox")) { - return "firefox"; - } else if ( - navigator.userAgent.includes("Chrome") || - navigator.userAgent.includes("Edg") || - navigator.userAgent.includes("OPR") // Opera - ) { - return "chromium"; - } - } - return "chromium"; // Default to Chromium for unknown cases - }; - - const setIsPageEnabled = ( - pathname: keyof (typeof store)["preferences"], - isEnabled: boolean, - ) => { - const newConfig = produce(store, (draft) => { - draft.preferences[pathname].isEnabled = isEnabled; - }); - setStore(newConfig); - chrome.storage.sync.set(newConfig); - }; - - const customPlaybackRatesValidator = (inValue: string) => { - const regex = /^(\d+(\.\d+)?)(\s*,\s*\d+(\.\d+)?)*$/; - const playbackRates = inValue.split(",").map((rate) => rate.trim()); - - if (!regex.test(inValue) || playbackRates.length <= 1) { - return false; - } - - const rates = playbackRates.map(parseFloat); - if ( - rates.includes(NaN) || - !rates.includes(1) || - rates.some((rate) => rate < 0.0625 || rate > 16) - ) { - return false; - } - - return true; - }; - - const isValidNumeric = (inValue: string, min: number, max: number) => { - const value = +inValue; - if (isNaN(value) || value < min || value > max) { - return false; - } - return true; - }; - - return ( - <> -
-

- General -

-

- Manage your general settings and preferences -

-
- - - -
-
-

- Pages -

-

- Manage your page settings and preferences -

-
-
- - - - - - { - const newConfig = produce(store, (draft) => { - draft.preferences.shorts.reelAutoScroll.value = isEnabled; - }); - setStore(newConfig); - chrome.storage.sync.set(newConfig); - }} - /> - { - return isValidNumeric(value, 0.0625, 16); - }} - onChange={(value) => { - const newConfig = produce(store, (draft) => { - draft.preferences.shorts.togglePlaybackRate.value = value; - }); - setStore(newConfig); - chrome.storage.sync.set(newConfig); - }} - /> - { - return isValidNumeric(value, 0, Infinity); - }} - onChange={(value) => { - const newConfig = produce(store, (draft) => { - draft.preferences.shorts.seekBackward.value = value; - }); - setStore(newConfig); - chrome.storage.sync.set(newConfig); - }} - /> - { - return isValidNumeric(value, 0, Infinity); - }} - onChange={(value) => { - const newConfig = produce(store, (draft) => { - draft.preferences.shorts.seekForward.value = value; - }); - setStore(newConfig); - chrome.storage.sync.set(newConfig); - }} - /> - { - const newConfig = produce(store, (draft) => { - draft.preferences.shorts.togglePlaybackRate.key = key; - }); - setStore(newConfig); - chrome.storage.sync.set(newConfig); - }} - /> - { - const newConfig = produce(store, (draft) => { - draft.preferences.shorts.seekBackward.key = key; - }); - setStore(newConfig); - chrome.storage.sync.set(newConfig); - }} - /> - { - const newConfig = produce(store, (draft) => { - draft.preferences.shorts.seekForward.key = key; - }); - setStore(newConfig); - chrome.storage.sync.set(newConfig); - }} - /> - - - - { - const newConfig = produce(store, (draft) => { - draft.preferences.watch.customPlaybackRates = value - .split(",") - .map((playbackRate) => - Number(playbackRate.trim()).toFixed(2), - ); - }); - setStore(newConfig); - chrome.storage.sync.set(newConfig); - }} - /> - { - return isValidNumeric(value, 0.0625, 16); - }} - onChange={(value) => { - const newConfig = produce(store, (draft) => { - draft.preferences.watch.togglePlaybackRate.value = value; - }); - setStore(newConfig); - chrome.storage.sync.set(newConfig); - }} - /> - { - return isValidNumeric(value, 0.0625, 16); - }} - onChange={(value) => { - const newConfig = produce(store, (draft) => { - draft.preferences.watch.defaultPlaybackRate.value = value; - }); - setStore(newConfig); - chrome.storage.sync.set(newConfig); - }} - /> - { - return isValidNumeric(value, 0, 16); - }} - onChange={(value) => { - const newConfig = produce(store, (draft) => { - draft.preferences.watch.decreasePlaybackRate.value = value; - }); - setStore(newConfig); - chrome.storage.sync.set(newConfig); - }} - /> - { - return isValidNumeric(value, 0, 16); - }} - onChange={(value) => { - const newConfig = produce(store, (draft) => { - draft.preferences.watch.increasePlaybackRate.value = value; - }); - setStore(newConfig); - chrome.storage.sync.set(newConfig); - }} - /> - { - return isValidNumeric(value, 0, Infinity); - }} - onChange={(value) => { - const newConfig = produce(store, (draft) => { - draft.preferences.watch.seekBackward.value = value; - }); - setStore(newConfig); - chrome.storage.sync.set(newConfig); - }} - /> - { - return isValidNumeric(value, 0, Infinity); - }} - onChange={(value) => { - const newConfig = produce(store, (draft) => { - draft.preferences.watch.seekForward.value = value; - }); - setStore(newConfig); - chrome.storage.sync.set(newConfig); - }} - /> - { - const newConfig = produce(store, (draft) => { - draft.preferences.watch.decreasePlaybackRate.key = key; - }); - setStore(newConfig); - chrome.storage.sync.set(newConfig); - }} - /> - { - const newConfig = produce(store, (draft) => { - draft.preferences.watch.increasePlaybackRate.key = key; - }); - setStore(newConfig); - chrome.storage.sync.set(newConfig); - }} - /> - { - const newConfig = produce(store, (draft) => { - draft.preferences.watch.seekBackward.key = key; - }); - setStore(newConfig); - chrome.storage.sync.set(newConfig); - }} - /> - { - const newConfig = produce(store, (draft) => { - draft.preferences.watch.seekForward.key = key; - }); - setStore(newConfig); - chrome.storage.sync.set(newConfig); - }} - /> - { - const newConfig = produce(store, (draft) => { - draft.preferences.watch.toggleLoopSegment.key = key; - }); - setStore(newConfig); - chrome.storage.sync.set(newConfig); - }} - /> - { - const newConfig = produce(store, (draft) => { - draft.preferences.watch.setLoopSegmentBegin.key = key; - }); - setStore(newConfig); - chrome.storage.sync.set(newConfig); - }} - /> - { - const newConfig = produce(store, (draft) => { - draft.preferences.watch.setLoopSegmentEnd.key = key; - }); - setStore(newConfig); - chrome.storage.sync.set(newConfig); - }} - /> - { - const newConfig = produce(store, (draft) => { - draft.preferences.watch.togglePlaybackRate.key = key; - }); - setStore(newConfig); - chrome.storage.sync.set(newConfig); - }} - /> - -
-
- - ); -} diff --git a/web-ext/src/options/routes/error-page.tsx b/web-ext/src/options/routes/error-page.tsx deleted file mode 100644 index fe9e4d15..00000000 --- a/web-ext/src/options/routes/error-page.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import React from "react"; -import { useRouteError } from "react-router-dom"; - -type RouteError = { - statusText?: string; - message?: string; -}; - -export default function ErrorPage() { - const error = useRouteError() as RouteError; - console.error(error); - - const handleFileIssue = () => { - window.open("https://github.com/enrych/toppings/issues", "_blank"); - }; - - return ( -
-
-

Oops!

-

Sorry, an unexpected error has occurred.

-

- {error.statusText || error.message} -

-

- If you keep seeing this error, please{" "} - - . -

-
-
- ); -} diff --git a/web-ext/src/popup/App.tsx b/web-ext/src/popup/App.tsx deleted file mode 100644 index f12edde0..00000000 --- a/web-ext/src/popup/App.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import React from "react"; -import Navbar from "./components/Navbar"; - -function App() { - return ( -
-
-
-

- Welcome To Toppings! -

-

- Your YouTube! Your Way! -

- -
-
-
- -
- ); -} - -export default App; diff --git a/web-ext/src/popup/components/Navbar.tsx b/web-ext/src/popup/components/Navbar.tsx deleted file mode 100644 index bbf56172..00000000 --- a/web-ext/src/popup/components/Navbar.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import React from "react"; - -export default function Navbar() { - return ( -
- -
- ); -} diff --git a/web-ext/theme.css b/web-ext/theme.css new file mode 100644 index 00000000..a76002a9 --- /dev/null +++ b/web-ext/theme.css @@ -0,0 +1,102 @@ +:root, +[data-theme="dark"] { + + --color-bg: #0a0a0a; + --color-surface: #161616; + --color-surface-2: #050505; + --color-surface-hover: rgba(242, 239, 231, 0.06); + + + --color-fg: #f2efe7; + --color-fg-muted: rgba(242, 239, 231, 0.62); + --color-fg-subtle: rgba(242, 239, 231, 0.38); + + + --color-border-subtle: rgba(242, 239, 231, 0.06); + --color-border-default: rgba(242, 239, 231, 0.1); + --color-border-strong: rgba(242, 239, 231, 0.2); + + + --color-accent: #fca929; + --color-accent-hover: #f57c20; + --color-accent-fg: #0a0a0a; + + + --color-danger-bg: rgba(239, 68, 68, 0.1); + --color-danger-fg: #fca5a5; + --color-success-bg: rgba(34, 197, 94, 0.1); + --color-success-fg: #86efac; + --color-info-bg: rgba(252, 169, 41, 0.1); + --color-info-fg: #fca929; + --color-warning-bg: rgba(234, 179, 8, 0.1); + --color-warning-fg: #fde047; +} + +[data-theme="light"] { + --color-bg: #fff9ef; + --color-surface: #fffcf6; + --color-surface-2: #f8f1e1; + --color-surface-hover: rgba(10, 10, 10, 0.05); + + --color-fg: #0a0a0a; + --color-fg-muted: rgba(10, 10, 10, 0.72); + --color-fg-subtle: rgba(10, 10, 10, 0.5); + + --color-border-subtle: rgba(10, 10, 10, 0.06); + --color-border-default: rgba(10, 10, 10, 0.1); + --color-border-strong: rgba(10, 10, 10, 0.18); + + --color-accent: #fca929; + --color-accent-hover: #f57c20; + --color-accent-fg: #0a0a0a; + + --color-danger-bg: rgba(220, 38, 38, 0.08); + --color-danger-fg: #b91c1c; + --color-success-bg: rgba(22, 163, 74, 0.08); + --color-success-fg: #15803d; + --color-info-bg: rgba(252, 124, 32, 0.08); + --color-info-fg: #b34a0e; + --color-warning-bg: rgba(202, 138, 4, 0.1); + --color-warning-fg: #a16207; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes popIn { + 0% { + opacity: 0; + transform: scale(0.96) translateY(4px); + } + 100% { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +@keyframes slideInRight { + from { + opacity: 0; + transform: translateX(20px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes pulseAmber { + 0%, + 100% { + box-shadow: 0 0 0 0 rgba(252, 169, 41, 0.45); + } + 50% { + box-shadow: 0 0 0 6px rgba(252, 169, 41, 0); + } +} diff --git a/web-ext/utils/api.ts b/web-ext/utils/api.ts new file mode 100644 index 00000000..7ddfe9b6 --- /dev/null +++ b/web-ext/utils/api.ts @@ -0,0 +1,30 @@ +import { HTTP_ACCEPT, HTTP_HEADER } from "@toppings/constants"; +import { EXTENSION_API_BASE_URL } from "../data/api.data"; +import { NODE_ENV } from "../data/extension.data"; +import { interpolateTemplate } from "@toppings/utils"; + +function resolveBaseUrl(): string { + return process.env.NODE_ENV === NODE_ENV.DEVELOPMENT + ? EXTENSION_API_BASE_URL.LOCAL + : EXTENSION_API_BASE_URL.PRODUCTION; +} + +export const api = { + url( + endpointTemplate: string, + params: Record = {}, + ): string { + return interpolateTemplate(resolveBaseUrl(), { endpoint: endpointTemplate, ...params }); + }, + + fetch( + endpointTemplate: string, + params: Record = {}, + init?: RequestInit, + ): Promise { + return fetch(api.url(endpointTemplate, params), { + headers: { [HTTP_HEADER.ACCEPT]: HTTP_ACCEPT.JSON }, + ...init, + }); + }, +}; diff --git a/web-ext/utils/browser.ts b/web-ext/utils/browser.ts new file mode 100644 index 00000000..0f9b8c52 --- /dev/null +++ b/web-ext/utils/browser.ts @@ -0,0 +1,30 @@ +export function getBrowserEngine(): "firefox" | "chromium" { + if (typeof navigator !== "undefined" && navigator.userAgent) { + if (navigator.userAgent.includes("Firefox")) return "firefox"; + if ( + navigator.userAgent.includes("Chrome") || + navigator.userAgent.includes("Edg") || + navigator.userAgent.includes("OPR") + ) { + return "chromium"; + } + } + return "chromium"; +} + +export function setExtensionIcon(disabled: boolean) { + const prefix = disabled ? "disabled_" : ""; + const path = { + 16: `/assets/icons/${prefix}icon16.png`, + 32: `/assets/icons/${prefix}icon32.png`, + 48: `/assets/icons/${prefix}icon48.png`, + 128: `/assets/icons/${prefix}icon128.png`, + }; + const browser = getBrowserEngine(); + if (browser === "firefox") { + // @ts-ignore: Firefox MV2 uses browserAction + chrome.browserAction.setIcon({ path }); + } else { + chrome.action.setIcon({ path }); + } +} diff --git a/web-ext/utils/keybinding.ts b/web-ext/utils/keybinding.ts new file mode 100644 index 00000000..4e911c2a --- /dev/null +++ b/web-ext/utils/keybinding.ts @@ -0,0 +1,110 @@ +/** + * Extension-wide keybinding utilities. + * + * Keybindings are stored as human-readable combo strings, e.g.: + * "Q" — plain key, no modifier + * "Shift+Q" — Shift held + * "Ctrl+Shift+A" — multiple modifiers + * + * Modifier order (canonical): Ctrl → Alt → Shift → Meta + */ + +/** All recognised modifier names, in canonical order. */ +export const MODIFIER_KEYS = ["Ctrl", "Alt", "Shift", "Meta"] as const; +export type ModifierKey = (typeof MODIFIER_KEYS)[number]; + +/** Keys whose `event.key` value IS the modifier itself — skip these during capture. */ +const MODIFIER_KEY_VALUES = new Set([ + "Control", "Alt", "Shift", "Meta", + "CapsLock", "NumLock", "ScrollLock", +]); + +// --------------------------------------------------------------------------- +// Formatting +// --------------------------------------------------------------------------- + +const MODIFIER_SYMBOLS: Record = { + Ctrl: "⌃", + Alt: "⌥", + Shift: "⇧", + Meta: "⌘", +}; + +/** + * Convert a stored combo string to a compact display string. + * + * "Shift+Q" → "⇧Q" + * "Ctrl+Shift+A" → "⌃⇧A" + * "Q" → "Q" + * "" → "" + */ +export function formatBindingDisplay(combo: string): string { + if (!combo) return ""; + return combo + .split("+") + .map((part) => MODIFIER_SYMBOLS[part as ModifierKey] ?? part) + .join(""); +} + +// --------------------------------------------------------------------------- +// Recording +// --------------------------------------------------------------------------- + +/** + * Given a raw keydown event, return the canonical combo string to store, or + * `null` if the event should be ignored (modifier-only press, etc.). + * + * Only letter (A-Z) and digit (0-9) base keys are accepted; everything else + * is ignored so accidental function-key or arrow-key presses don't overwrite + * bindings. + */ +export function recordBinding(e: KeyboardEvent): string | null { + // Skip pure modifier key presses — wait for an actual key. + if (MODIFIER_KEY_VALUES.has(e.key)) return null; + + // Only accept letter or digit base keys. + const baseKey = e.key.toUpperCase(); + if (!/^[A-Z0-9]$/.test(baseKey)) return null; + + const parts: string[] = []; + if (e.ctrlKey) parts.push("Ctrl"); + if (e.altKey) parts.push("Alt"); + if (e.shiftKey) parts.push("Shift"); + if (e.metaKey) parts.push("Meta"); + parts.push(baseKey); + + return parts.join("+"); +} + +// --------------------------------------------------------------------------- +// Matching +// --------------------------------------------------------------------------- + +/** + * Returns true when a KeyboardEvent matches a stored combo string. + * + * Matching rules: + * - Base key comparison is case-insensitive (stored "Q" matches event.key + * "q" or "Q"). + * - Modifier keys are compared EXACTLY: "Q" (no modifier) does NOT fire + * when Shift is held; "Shift+Q" only fires when Shift is held. + * - An empty or falsy binding never matches. + */ +export function matchesBinding(event: KeyboardEvent, binding: string): boolean { + if (!binding) return false; + + const parts = binding.split("+"); + const storedKey = parts[parts.length - 1].toUpperCase(); + const mods = new Set(parts.slice(0, -1).map((m) => m.toLowerCase())); + + // Base key check (case-insensitive). + if (event.key.toUpperCase() !== storedKey) return false; + + // Exact modifier check. + return ( + event.shiftKey === mods.has("shift") && + event.ctrlKey === mods.has("ctrl") && + event.altKey === mods.has("alt") && + event.metaKey === mods.has("meta") + ); +} diff --git a/web-ext/utils/primitive.ts b/web-ext/utils/primitive.ts new file mode 100644 index 00000000..9b12a734 --- /dev/null +++ b/web-ext/utils/primitive.ts @@ -0,0 +1,145 @@ +import elementReady from "element-ready"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * The outcome of attempting to resolve a primitive's DOM target. + * + * `resolved: true` — an element matched; `strategyIndex` tells you which + * selector in the strategies array was the winning one. + * `resolved: false` — no strategy matched within the allowed time. + */ +export type PrimitiveResolution = + | { resolved: true; element: Element; strategyIndex: number } + | { resolved: false; element: null; strategyIndex: null }; + +/** + * A single YouTube UI variant selector string. + * Ordered by likelihood (most current / most common first). + */ +export type PrimitiveStrategy = string; + +/** + * Options forwarded to the underlying element-ready watchers. + * + * @property stopOnDomReady - Stop watching once `DOMContentLoaded` fires. + * Default: false (keep watching — YouTube is a SPA). + * @property timeout - Give up after this many milliseconds. + * Default: 10 000 ms. + */ +export interface ResolveOptions { + stopOnDomReady?: boolean; + timeout?: number; +} + +// --------------------------------------------------------------------------- +// Watch-page primitive value types +// +// These represent the possible states for each composable watch-page +// primitive. Profiles (Phase 2) will store these values per-primitive and +// apply them on navigation. +// --------------------------------------------------------------------------- + +/** + * How the player area is laid out on the watch page. + * + * - `"default"` — YouTube's standard layout (no change) + * - `"theatre"` — YouTube's theatre mode (wider video, narrower sidebar) + * - `"no-video"` — Toppings feature: player area collapsed/hidden entirely + */ +export type PlayerLayout = "default" | "theatre" | "no-video"; + +/** + * What fills the video slot when the player is visible. + * + * - `"video"` — the real video stream (no change) + * - `"black"` — solid black overlay (current Audio Mode default) + * - `"visualizer"` — audio visualizer canvas overlay + * - `"custom"` — user-supplied image or video overlay + */ +export type PlayerVisuals = "video" | "black" | "visualizer" | "custom"; + +// --------------------------------------------------------------------------- +// Core resolver +// --------------------------------------------------------------------------- + +/** + * Try each CSS selector strategy in order, returning the first element found. + * + * Strategy: + * 1. Synchronous pass — if any selector already matches in the DOM, return + * immediately (zero cost on the happy path). + * 2. Async race — fire an `elementReady` watcher for every strategy + * simultaneously. The first watcher to resolve a non-null element wins. + * A timeout governs the overall wait so we never hang indefinitely. + * + * Callers should write their result to the capability cache so the options UI + * can surface unsupported primitives and avoid redundant future attempts. + * + * @example + * const result = await resolveTarget([ + * "div.ytp-right-controls", // current variant + * "div.ytp-right-controls-v2", // A/B variant B + * ]); + * if (result.resolved) { + * result.element.prepend(myButton); + * } + */ +export async function resolveTarget( + strategies: readonly PrimitiveStrategy[], + options: ResolveOptions = {}, +): Promise { + const { stopOnDomReady = false, timeout = 10_000 } = options; + + if (strategies.length === 0) { + return { resolved: false, element: null, strategyIndex: null }; + } + + // 1. Synchronous pass — no async overhead when element is already present. + for (let i = 0; i < strategies.length; i++) { + const el = document.querySelector(strategies[i]); + if (el) return { resolved: true, element: el, strategyIndex: i }; + } + + // 2. Async race — all strategies watch in parallel; first match wins. + return new Promise((resolve) => { + let settled = false; + + const settle = (resolution: PrimitiveResolution): void => { + if (!settled) { + settled = true; + clearTimeout(timer); + resolve(resolution); + } + }; + + // Hard timeout — never leave watchers hanging on unsupported pages. + const timer = setTimeout( + () => settle({ resolved: false, element: null, strategyIndex: null }), + timeout, + ); + + // Launch a watcher per strategy — all run in parallel; first match wins. + // element-ready v7 does not support AbortSignal, so we rely on the + // `settled` flag to ensure only the first resolution is acted on. + // Remaining watchers become inert once `settled` is true. + const watchers = strategies.map((selector, i) => + elementReady(selector, { stopOnDomReady }) + .then((el) => { + if (el) { + settle({ resolved: true, element: el, strategyIndex: i }); + } + }) + .catch(() => { + // Individual watcher failed — not a failure unless ALL fail. + }), + ); + + // If every watcher finishes without resolving a match, mark unsupported. + Promise.allSettled(watchers).then(() => { + settle({ resolved: false, element: null, strategyIndex: null }); + }); + }); +} diff --git a/web-ext/utils/scope.ts b/web-ext/utils/scope.ts new file mode 100644 index 00000000..b0da7582 --- /dev/null +++ b/web-ext/utils/scope.ts @@ -0,0 +1,28 @@ +import { + EXTENSION_CONTEXT_SCOPE, + YOUTUBE_HOSTNAME_SUFFIX, + YOUTUBE_URL_PATH, + type ExtensionContextScope, +} from "@toppings/constants"; + +export function resolveScope(url: string | null): ExtensionContextScope { + if (!url) return EXTENSION_CONTEXT_SCOPE.UNSUPPORTED; + try { + const u = new URL(url); + if (!u.hostname.endsWith(YOUTUBE_HOSTNAME_SUFFIX)) { + return EXTENSION_CONTEXT_SCOPE.UNSUPPORTED; + } + if (u.pathname.startsWith(YOUTUBE_URL_PATH.WATCH)) { + return EXTENSION_CONTEXT_SCOPE.WATCH; + } + if (u.pathname.startsWith(YOUTUBE_URL_PATH.SHORTS)) { + return EXTENSION_CONTEXT_SCOPE.SHORTS; + } + if (u.pathname.startsWith(YOUTUBE_URL_PATH.PLAYLIST)) { + return EXTENSION_CONTEXT_SCOPE.PLAYLIST; + } + return EXTENSION_CONTEXT_SCOPE.YOUTUBE; + } catch { + return EXTENSION_CONTEXT_SCOPE.UNSUPPORTED; + } +} diff --git a/web-ext/utils/segments/engine.ts b/web-ext/utils/segments/engine.ts new file mode 100644 index 00000000..bc70042e --- /dev/null +++ b/web-ext/utils/segments/engine.ts @@ -0,0 +1,299 @@ +import type { Segment, SegmentConfig, PlayStep, SegmentId } from "./types"; + +// --------------------------------------------------------------------------- +// SegmentEngine — playback state machine +// +// Single responsibility: decide when to seek and at what rate. +// No DOM access except HTMLVideoElement. No storage access. No UI. +// --------------------------------------------------------------------------- + +interface EngineState { + stepIndex: number; + iterationInStep: number; // how many full segmentIds cycles completed in this step + segmentIndexInStep: number; // index into step.segmentIds (user-defined order, may have dupes) +} + +function initialState(): EngineState { + return { stepIndex: 0, iterationInStep: 0, segmentIndexInStep: 0 }; +} + +export class SegmentEngine { + private video: HTMLVideoElement; + private config: SegmentConfig; + private state: EngineState = initialState(); + private listener: (() => void) | null = null; + private originalRate: number | null = null; + private _active = false; + + constructor(video: HTMLVideoElement, config: SegmentConfig) { + this.video = video; + this.config = config; + } + + // --------------------------------------------------------------------------- + // Public API + // --------------------------------------------------------------------------- + + start(): void { + if (this._active) this.stop(); + this.state = initialState(); + this._active = true; + this.originalRate = this.video.playbackRate; + + // If the playhead is already inside a segment, start tracking from here + // without seeking — no disruptive jump mid-playback. + // Only seek to a segment's start when we're outside all of them. + const ct = this.video.currentTime; + const alreadyInside = this.config.segments.find( + (s) => ct >= s.startTime && ct <= s.endTime, + ); + if (alreadyInside) { + this.reconcileStateToCurrentTime(); + } else { + this.seekToCurrentSegment(); + } + + this.listener = () => this.onTimeUpdate(); + this.video.addEventListener("timeupdate", this.listener); + } + + stop(): void { + if (this.listener) { + this.video.removeEventListener("timeupdate", this.listener); + this.listener = null; + } + // Restore original playback rate if we changed it. + if ( + this.originalRate !== null && + this.video.playbackRate !== this.originalRate + ) { + this.video.playbackRate = this.originalRate; + } + this.originalRate = null; + this._active = false; + } + + isActive(): boolean { + return this._active; + } + + /** + * Hot-swap the config without stopping playback. If the playhead is already + * inside a segment, update state in place and don't seek — this prevents + * disruptive restarts while the user is dragging markers. Only seeks when the + * playhead is outside all segment ranges. + */ + setConfig(config: SegmentConfig): void { + this.config = config; + if (this._active) { + this.reconcileStateToCurrentTime(); + } else { + this.state = initialState(); + } + } + + /** + * Return the segment that the engine is currently enforcing, or null if the + * config/sequence is empty. + */ + getCurrentSegment(): Segment | null { + return this.resolveCurrentSegment(); + } + + /** + * Find the segment whose range contains `currentTime`. Used by Q/E shortcuts + * to target the "active" segment for boundary editing. + * + * Tie-breaking: if the playhead is between segments, return the one whose + * end time is closest to `currentTime`. If no segments exist, returns null. + */ + getSegmentAt(currentTime: number): Segment | null { + const segs = this.config.segments; + if (segs.length === 0) return null; + + // 1. Prefer a segment that contains the playhead. + const containing = segs.find( + (s) => currentTime >= s.startTime && currentTime <= s.endTime, + ); + if (containing) return containing; + + // 2. Fall back to nearest by absolute distance to midpoint. + return segs.reduce((best, seg) => { + const mid = (seg.startTime + seg.endTime) / 2; + const bestMid = (best.startTime + best.endTime) / 2; + return Math.abs(currentTime - mid) < Math.abs(currentTime - bestMid) + ? seg + : best; + }); + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + private onTimeUpdate(): void { + const seg = this.resolveCurrentSegment(); + if (!seg) return; + + // Apply rate if the step/iteration specifies one. + const rate = this.getEffectiveRate(); + if (rate !== null && Math.abs(this.video.playbackRate - rate) > 0.001) { + this.video.playbackRate = rate; + } + + const ct = this.video.currentTime; + + // Use a small 0.3 s safety margin at the very end of the video so we + // never seek into an unmuxed tail. + const safeEnd = Math.min( + seg.endTime, + this.video.duration > 0 ? this.video.duration - 0.3 : seg.endTime, + ); + + // Still within the current segment — nothing to do. + if (ct >= seg.startTime && ct < safeEnd) return; + + // Playhead is outside the current segment's valid range. + // Before advancing or snapping, check whether the user seeked directly + // into a different segment — if so, start tracking it without seeking. + const seekedInto = this.config.segments.find( + (s) => s.id !== seg.id && ct >= s.startTime && ct <= s.endTime, + ); + if (seekedInto) { + this.updateStateToSegment(seekedInto.id); + return; + } + + // Not inside any segment: advance on natural end, or snap forward from a gap. + if (ct >= safeEnd) { + this.advance(); + } else { + // ct < seg.startTime — seeked backward into a gap before the current segment. + this.snapToNearestSegmentForward(ct); + } + } + + /** + * Snap to the start of the nearest segment whose startTime is ≥ currentTime. + * Falls back to the last segment if we are past all of them. + */ + private snapToNearestSegmentForward(currentTime: number): void { + const sorted = [...this.config.segments].sort( + (a, b) => a.startTime - b.startTime, + ); + const next = sorted.find((s) => s.startTime >= currentTime); + if (next) { + this.video.currentTime = next.startTime; + } else { + // Past all segments — go to last segment's start. + const last = sorted[sorted.length - 1]; + if (last) this.video.currentTime = last.startTime; + } + } + + private advance(): void { + const step = this.currentStep(); + if (!step) return; + + const nextSegIdx = this.state.segmentIndexInStep + 1; + + // More segments left in this step's list → move to the next one. + if (nextSegIdx < step.segmentIds.length) { + this.state.segmentIndexInStep = nextSegIdx; + this.seekToCurrentSegment(); + return; + } + + // Completed one full cycle through segmentIds. + const nextIteration = this.state.iterationInStep + 1; + this.state.segmentIndexInStep = 0; + + // step.count === 0 means infinite; otherwise keep cycling until count. + if (step.count === 0 || nextIteration < step.count) { + this.state.iterationInStep = nextIteration; + this.seekToCurrentSegment(); + return; + } + + // This step is exhausted → move to the next step (wrap for global loop). + const nextStepIdx = + (this.state.stepIndex + 1) % this.config.sequence.length; + this.state.stepIndex = nextStepIdx; + this.state.iterationInStep = 0; + this.state.segmentIndexInStep = 0; + this.seekToCurrentSegment(); + } + + /** + * Update this.state to reflect whichever segment contains the current + * playhead, without seeking. If the playhead is outside all segments, + * reset to the beginning and seek there instead. + */ + private reconcileStateToCurrentTime(): void { + const ct = this.video.currentTime; + const containingSeg = this.config.segments.find( + (s) => ct >= s.startTime && ct <= s.endTime, + ); + if (containingSeg) { + // Playhead is inside a valid segment — locate it in the sequence. + for (let si = 0; si < this.config.sequence.length; si++) { + const step = this.config.sequence[si]; + const segIdx = step.segmentIds.indexOf(containingSeg.id); + if (segIdx !== -1) { + this.state = { stepIndex: si, iterationInStep: 0, segmentIndexInStep: segIdx }; + return; + } + } + } + // Playhead is outside all segments (or segment not in sequence) — reset. + this.state = initialState(); + this.seekToCurrentSegment(); + } + + private seekToCurrentSegment(): void { + const seg = this.resolveCurrentSegment(); + if (seg && this.video.duration > 0) { + const seekTo = Math.max(0, Math.min(seg.startTime, this.video.duration)); + this.video.currentTime = seekTo; + } + } + + private updateStateToSegment(segId: SegmentId): void { + for (let si = 0; si < this.config.sequence.length; si++) { + const step = this.config.sequence[si]; + const segIdx = step.segmentIds.indexOf(segId); + if (segIdx !== -1) { + this.state = { stepIndex: si, iterationInStep: 0, segmentIndexInStep: segIdx }; + return; + } + } + } + + private resolveCurrentSegment(): Segment | null { + const step = this.currentStep(); + if (!step || step.segmentIds.length === 0) return null; + const segId = step.segmentIds[this.state.segmentIndexInStep]; + return this.config.segments.find((s) => s.id === segId) ?? null; + } + + private currentStep(): PlayStep | null { + return this.config.sequence[this.state.stepIndex] ?? null; + } + + /** + * Effective playback rate for the current step + iteration. + * Returns null when no override is in effect (leave rate unchanged). + */ + private getEffectiveRate(): number | null { + const step = this.currentStep(); + if (!step) return null; + if (step.perIterationRates.length > 0) { + const idx = Math.min( + this.state.iterationInStep, + step.perIterationRates.length - 1, + ); + return step.perIterationRates[idx]; + } + return step.playbackRate; + } +} diff --git a/web-ext/utils/segments/factories.ts b/web-ext/utils/segments/factories.ts new file mode 100644 index 00000000..5b12325e --- /dev/null +++ b/web-ext/utils/segments/factories.ts @@ -0,0 +1,187 @@ +import type { Segment, SegmentConfig, PlayStep } from "./types"; + +// --------------------------------------------------------------------------- +// Segment config factories +// --------------------------------------------------------------------------- + +function randomId(): string { + return crypto.randomUUID(); +} + +/** + * Build the default "fresh slate" config for a video. + * + * Produces a single segment spanning the full video duration with one step + * that loops it infinitely — identical to the previous Loop Segment default. + * + * Pass `videoDuration = 0` when the duration isn't known yet; callers should + * update the endTime once the video's metadata loads. + */ +export function createFreshConfig(videoDuration: number): SegmentConfig { + const segId = randomId(); + const stepId = randomId(); + + const segment: Segment = { + id: segId, + startTime: 0, + endTime: videoDuration > 0 ? videoDuration : 0, + }; + + const step: PlayStep = { + id: stepId, + segmentIds: [segId], + count: 0, // 0 = infinite loop + playbackRate: null, + perIterationRates: [], + }; + + const now = Date.now(); + return { + id: randomId(), + label: "Default", + segments: [segment], + sequence: [step], + shortcutKey: "", + createdAt: now, + updatedAt: now, + }; +} + +/** + * Create a new empty config placeholder (no segments, no steps). + * Used when adding a brand-new named config before the user populates it. + */ +export function createEmptyConfig(label: string): SegmentConfig { + const now = Date.now(); + return { + id: randomId(), + label, + segments: [], + sequence: [], + shortcutKey: "", + createdAt: now, + updatedAt: now, + }; +} + +/** + * Add a new segment to an existing config, appending it to both the segments + * list and the first (or only) step's segmentIds. Returns a new config object + * (immutable update pattern). + */ +export function addSegmentToConfig( + config: SegmentConfig, + startTime: number, + endTime: number, + label?: string, +): SegmentConfig { + const newSeg: Segment = { id: randomId(), startTime, endTime, label }; + + // If there are no steps yet, create a default infinite step. + let newSequence: PlayStep[]; + if (config.sequence.length === 0) { + const step: PlayStep = { + id: randomId(), + segmentIds: [newSeg.id], + count: 0, + playbackRate: null, + perIterationRates: [], + }; + newSequence = [step]; + } else { + // Append to the first step's segmentIds by default; user can rearrange. + newSequence = config.sequence.map((step, i) => + i === 0 ? { ...step, segmentIds: [...step.segmentIds, newSeg.id] } : step, + ); + } + + return { + ...config, + segments: [...config.segments, newSeg], + sequence: newSequence, + updatedAt: Date.now(), + }; +} + +/** + * Remove a segment from a config, also purging its ID from all steps. + * Steps that become empty of segmentIds are also removed. + */ +export function removeSegmentFromConfig( + config: SegmentConfig, + segmentId: string, +): SegmentConfig { + const newSegments = config.segments.filter((s) => s.id !== segmentId); + const newSequence = config.sequence + .map((step) => ({ + ...step, + segmentIds: step.segmentIds.filter((id) => id !== segmentId), + })) + .filter((step) => step.segmentIds.length > 0); + + return { + ...config, + segments: newSegments, + sequence: newSequence, + updatedAt: Date.now(), + }; +} + +/** + * Update a single segment's time boundaries within a config. + */ +export function updateSegmentTimes( + config: SegmentConfig, + segmentId: string, + startTime: number, + endTime: number, +): SegmentConfig { + return { + ...config, + segments: config.segments.map((s) => + s.id === segmentId ? { ...s, startTime, endTime } : s, + ), + updatedAt: Date.now(), + }; +} + +/** Generate a human-readable auto-label for new named configs. */ +export function generateConfigLabel(existingCount: number): string { + return `Segment Config ${existingCount + 1}`; +} + +/** + * Split a segment at `splitTime`, replacing it with two adjacent segments. + * + * The original segment [start → end] becomes: + * Segment A: [start → splitTime] (replaces original in-place) + * Segment B: [splitTime → end] (new, inserted right after A in all steps) + * + * Returns null if the split point is too close to either boundary (< 0.1 s gap). + */ +export function splitSegmentAtTime( + config: SegmentConfig, + segmentId: string, + splitTime: number, +): SegmentConfig | null { + const seg = config.segments.find((s) => s.id === segmentId); + if (!seg) return null; + if (splitTime <= seg.startTime + 0.1 || splitTime >= seg.endTime - 0.1) return null; + + const newSegId = randomId(); + const segA: Segment = { ...seg, endTime: splitTime }; + const segB: Segment = { id: newSegId, startTime: splitTime, endTime: seg.endTime }; + + const newSegments = config.segments.map((s) => (s.id === segmentId ? segA : s)).concat(segB); + + // Insert segB after segA in every step that references the original. + const newSequence = config.sequence.map((step) => { + const idx = step.segmentIds.indexOf(segmentId); + if (idx < 0) return step; + const ids = [...step.segmentIds]; + ids.splice(idx + 1, 0, newSegId); + return { ...step, segmentIds: ids }; + }); + + return { ...config, segments: newSegments, sequence: newSequence, updatedAt: Date.now() }; +} diff --git a/web-ext/utils/segments/markers.tsx b/web-ext/utils/segments/markers.tsx new file mode 100644 index 00000000..7d900cbe --- /dev/null +++ b/web-ext/utils/segments/markers.tsx @@ -0,0 +1,465 @@ +import React from "dom-chef"; +import type { Segment, SegmentId } from "./types"; + +// --------------------------------------------------------------------------- +// SegmentMarkersController +// +// Single responsibility: manage the N pairs of DOM marker elements that sit +// on the YouTube progress bar. No playback logic, no storage. +// --------------------------------------------------------------------------- + +// Per-segment colors — YouTube palette. +const SEGMENT_COLORS = [ + "#ff3333", // YouTube red + "#3ea6ff", // YouTube blue + "#4caf50", // green + "#ff9800", // orange + "#9c27b0", // purple + "#00bcd4", // cyan + "#f5c518", // amber +]; + +function colorForSegment(index: number): string { + return SEGMENT_COLORS[index % SEGMENT_COLORS.length]; +} + +// --------------------------------------------------------------------------- +// Marker SVG — NLE-style right-trapezoid trim handles. +// +// Shape: right trapezoid — one tall flat vertical edge (the inward face, +// toward the segment) and one diagonal edge (the outward face, at the cut). +// The bottom-corner tip marks the exact cut position on the bar. +// +// Dimensions: 14 wide × 26 tall, viewBox "0 0 14 26". +// +// START (in-point): +// Long flat RIGHT edge (x=14, 26 px tall) faces inward toward segment. +// Diagonal left edge runs from top-left (5,0) to tip (0,26). +// Tip at (0,26) — bottom-left corner sits on the cut line. +// translateX(0): left edge of element (x=0) lands at left: X%. +// Path: M 0,26 L 5,0 L 14,0 L 14,26 Z +// +// END (out-point): +// Long flat LEFT edge (x=0, 26 px tall) faces inward toward segment. +// Diagonal right edge runs from top-right (9,0) to tip (14,26). +// Tip at (14,26) — bottom-right corner sits on the cut line. +// translateX(-100%): right edge of element (x=14) lands at left: X%. +// Path: M 14,26 L 9,0 L 0,0 L 0,26 Z +// --------------------------------------------------------------------------- + +function makeMarkerSVG(color: string, segNumber: number, role: "start" | "end"): SVGElement { + const d = role === "start" + ? "M 0,26 L 5,0 L 14,0 L 14,26 Z" // tip bottom-left, long right edge (inward) + : "M 14,26 L 9,0 L 0,0 L 0,26 Z"; // tip bottom-right, long left edge (inward) + + // Number centered in the body — right-of-center for start, left-of-center for end. + const tx = role === "start" ? "10" : "4"; + + return ( + + {/* Body fill */} + + {/* Segment number centered in the rectangular grip area */} + + {segNumber} + + + ) as unknown as SVGElement; +} + +// --------------------------------------------------------------------------- +// Internal marker record +// --------------------------------------------------------------------------- + +interface MarkerRecord { + segmentId: SegmentId; + role: "start" | "end"; + element: HTMLElement; +} + +// --------------------------------------------------------------------------- +// Controller class +// --------------------------------------------------------------------------- + +export class SegmentMarkersController { + private container: HTMLElement; + private video: HTMLVideoElement; + private records: MarkerRecord[] = []; + private segments: Segment[] = []; + + private dragging: MarkerRecord | null = null; + private mergeTimer: ReturnType | null = null; + private mergeCandidate: { keepId: SegmentId; removeId: SegmentId } | null = null; + + private onSegmentsChangedCb: ((segs: Segment[]) => void) | null = null; + private onMergeRequestCb: + | ((keepId: SegmentId, removeId: SegmentId) => void) + | null = null; + private onDragEndCb: (() => void) | null = null; + + private boundMouseMove = (e: MouseEvent) => this.handleMouseMove(e); + private boundMouseUp = () => this.handleMouseUp(); + + constructor(container: HTMLElement, video: HTMLVideoElement) { + this.container = container; + this.video = video; + // Allow markers to overflow upward without affecting container layout. + // We intentionally do NOT set position here — YouTube's progress bar + // container is already positioned; overriding it breaks its internal layout. + container.style.overflow = "visible"; + } + + // --------------------------------------------------------------------------- + // Public API + // --------------------------------------------------------------------------- + + setSegments(segments: Segment[]): void { + this.segments = segments; + this.rebuild(); + } + + getSegments(): Segment[] { + return [...this.segments]; + } + + updateSegmentPosition( + segmentId: SegmentId, + role: "start" | "end", + timeSeconds: number, + ): void { + if (!this.video.duration) return; + const pct = (timeSeconds / this.video.duration) * 100; + const record = this.records.find( + (r) => r.segmentId === segmentId && r.role === role, + ); + if (record) { + record.element.style.left = `${pct}%`; + } + this.segments = this.segments.map((s) => + s.id === segmentId + ? { ...s, [role === "start" ? "startTime" : "endTime"]: timeSeconds } + : s, + ); + } + + setActiveSegment(segmentId: SegmentId | null): void { + this.records.forEach((r) => { + r.element.style.filter = + r.segmentId === segmentId + ? "drop-shadow(0 0 4px rgba(255,255,255,0.8))" + : ""; + }); + } + + onSegmentsChanged(cb: (segs: Segment[]) => void): void { + this.onSegmentsChangedCb = cb; + } + + onMergeRequest(cb: (keepId: SegmentId, removeId: SegmentId) => void): void { + this.onMergeRequestCb = cb; + } + + /** Called once after a drag gesture completes. Use to persist and re-render. */ + onDragEnd(cb: () => void): void { + this.onDragEndCb = cb; + } + + showAll(): void { + this.records.forEach((r) => { + r.element.style.display = "block"; + }); + } + + hideAll(): void { + this.records.forEach((r) => { + r.element.style.display = "none"; + }); + } + + destroy(): void { + for (const r of this.records) { + r.element.remove(); + } + this.records = []; + document.removeEventListener("mousemove", this.boundMouseMove); + document.removeEventListener("mouseup", this.boundMouseUp); + if (this.mergeTimer !== null) clearTimeout(this.mergeTimer); + } + + // --------------------------------------------------------------------------- + // DOM construction + // --------------------------------------------------------------------------- + + private rebuild(): void { + for (const r of this.records) r.element.remove(); + this.records = []; + + const sorted = [...this.segments].sort((a, b) => a.startTime - b.startTime); + + sorted.forEach((seg, i) => { + const color = colorForSegment(i); + const segNumber = i + 1; + + const startEl = this.buildMarkerElement(seg, "start", color, segNumber); + const endEl = this.buildMarkerElement(seg, "end", color, segNumber); + + if (this.video.duration > 0) { + startEl.style.left = `${(seg.startTime / this.video.duration) * 100}%`; + endEl.style.left = `${(seg.endTime / this.video.duration) * 100}%`; + } else { + startEl.style.left = "0%"; + endEl.style.left = "100%"; + } + + this.container.appendChild(startEl); + this.container.appendChild(endEl); + + this.records.push({ segmentId: seg.id, role: "start", element: startEl }); + this.records.push({ segmentId: seg.id, role: "end", element: endEl }); + }); + } + + private buildMarkerElement( + seg: Segment, + role: "start" | "end", + color: string, + segNumber: number, + ): HTMLElement { + // Positioning: + // The flat base (y=24) rests directly on the bar's top surface. + // bottom: 100% → element's bottom edge = container's top edge (bar top). + // + // Horizontal transform — the apex AND flat leg are on the same vertical + // edge, so that edge must land at `left: X%` (the cut line): + // START — apex + flat right leg at x=14 → translateX(-100%) shifts + // the element left so its right edge sits at the cut line. + // END — apex + flat left leg at x=0 → translateX(0), left edge + // already at the cut line. + const transform = role === "start" ? "translateX(0)" : "translateX(-100%)"; + + const el = ( +
this.handleMarkerMouseDown(e, seg.id, role)} + > + {makeMarkerSVG(color, segNumber, role)} +
+ ) as HTMLElement; + + return el; + } + + // --------------------------------------------------------------------------- + // Drag handling + // --------------------------------------------------------------------------- + + private handleMarkerMouseDown( + e: MouseEvent, + segmentId: SegmentId, + role: "start" | "end", + ): void { + e.preventDefault(); + e.stopPropagation(); + const record = this.records.find( + (r) => r.segmentId === segmentId && r.role === role, + ); + if (!record) return; + this.dragging = record; + (record.element as HTMLElement).style.cursor = "grabbing"; + document.addEventListener("mousemove", this.boundMouseMove); + document.addEventListener("mouseup", this.boundMouseUp); + } + + private handleMouseMove(e: MouseEvent): void { + if (!this.dragging) return; + + const rect = this.container.getBoundingClientRect(); + const rawPct = ((e.clientX - rect.left) / rect.width) * 100; + + const { segmentId, role } = this.dragging; + const clampedPct = this.clampMarkerPct(rawPct, segmentId, role); + + // Update DOM position of the dragged marker. + this.dragging.element.style.left = `${clampedPct}%`; + + // Update internal segment data. + const duration = this.video.duration || 1; + const newTime = (clampedPct / 100) * duration; + this.segments = this.segments.map((s) => { + if (s.id !== segmentId) return s; + return role === "start" + ? { ...s, startTime: newTime } + : { ...s, endTime: newTime }; + }); + + // Seek video when dragging the in-point. + if (role === "start") { + this.video.currentTime = newTime; + } + + // Notify consumer (engine update, but no marker rebuild during drag). + this.onSegmentsChangedCb?.(this.segments); + + // Check for merge opportunity. + this.checkMerge(segmentId, role, clampedPct); + } + + private handleMouseUp(): void { + if (this.dragging) { + this.dragging.element.style.cursor = "grab"; + } + this.dragging = null; + document.removeEventListener("mousemove", this.boundMouseMove); + document.removeEventListener("mouseup", this.boundMouseUp); + if (this.mergeCandidate === null && this.mergeTimer !== null) { + clearTimeout(this.mergeTimer); + this.mergeTimer = null; + } + // Notify that drag is done — caller can now persist + re-render panel. + this.onDragEndCb?.(); + } + + // --------------------------------------------------------------------------- + // Clamping + // --------------------------------------------------------------------------- + + private clampMarkerPct( + rawPct: number, + segmentId: SegmentId, + role: "start" | "end", + ): number { + const duration = this.video.duration || 1; + const sorted = [...this.segments].sort((a, b) => a.startTime - b.startTime); + const idx = sorted.findIndex((s) => s.id === segmentId); + const seg = sorted[idx]; + const EPSILON_PCT = 0.2; + + if (role === "start") { + const ownEndPct = (seg.endTime / duration) * 100; + const prev = sorted[idx - 1]; + const prevEndPct = prev ? (prev.endTime / duration) * 100 : 0; + return Math.max(prevEndPct + EPSILON_PCT, Math.min(rawPct, ownEndPct - EPSILON_PCT)); + } else { + const ownStartPct = (seg.startTime / duration) * 100; + const next = sorted[idx + 1]; + const nextStartPct = next ? (next.startTime / duration) * 100 : 100; + return Math.min(nextStartPct - EPSILON_PCT, Math.max(rawPct, ownStartPct + EPSILON_PCT)); + } + } + + // --------------------------------------------------------------------------- + // Merge detection + // --------------------------------------------------------------------------- + + private static readonly MERGE_THRESHOLD_PCT = 1.5; + private static readonly MERGE_HOLD_MS = 500; + + private checkMerge( + draggedSegId: SegmentId, + role: "start" | "end", + pct: number, + ): void { + const sorted = [...this.segments].sort((a, b) => a.startTime - b.startTime); + const duration = this.video.duration || 1; + const threshold = SegmentMarkersController.MERGE_THRESHOLD_PCT; + + let candidate: { keepId: SegmentId; removeId: SegmentId } | null = null; + + if (role === "end") { + const idx = sorted.findIndex((s) => s.id === draggedSegId); + const next = sorted[idx + 1]; + if (next) { + const nextStartPct = (next.startTime / duration) * 100; + if (Math.abs(pct - nextStartPct) < threshold) { + candidate = { keepId: draggedSegId, removeId: next.id }; + } + } + } else { + const idx = sorted.findIndex((s) => s.id === draggedSegId); + const prev = sorted[idx - 1]; + if (prev) { + const prevEndPct = (prev.endTime / duration) * 100; + if (Math.abs(pct - prevEndPct) < threshold) { + candidate = { keepId: prev.id, removeId: draggedSegId }; + } + } + } + + if (candidate) { + this.applyMergePulse(candidate.keepId, candidate.removeId, true); + if ( + this.mergeCandidate?.keepId !== candidate.keepId || + this.mergeCandidate?.removeId !== candidate.removeId + ) { + if (this.mergeTimer !== null) clearTimeout(this.mergeTimer); + this.mergeCandidate = candidate; + this.mergeTimer = setTimeout(() => { + if (this.mergeCandidate) { + const { keepId, removeId } = this.mergeCandidate; + this.onMergeRequestCb?.(keepId, removeId); + this.mergeCandidate = null; + this.mergeTimer = null; + } + }, SegmentMarkersController.MERGE_HOLD_MS); + } + } else { + if (this.mergeCandidate) { + this.applyMergePulse(this.mergeCandidate.keepId, this.mergeCandidate.removeId, false); + this.mergeCandidate = null; + } + if (this.mergeTimer !== null) { + clearTimeout(this.mergeTimer); + this.mergeTimer = null; + } + } + } + + private applyMergePulse( + keepId: SegmentId, + removeId: SegmentId, + active: boolean, + ): void { + [keepId, removeId].forEach((id) => { + this.records + .filter((r) => r.segmentId === id) + .forEach((r) => { + r.element.style.animation = active + ? "tppng-merge-pulse 0.4s ease-in-out infinite alternate" + : ""; + }); + }); + } +} diff --git a/web-ext/utils/segments/types.ts b/web-ext/utils/segments/types.ts new file mode 100644 index 00000000..d2d57fa4 --- /dev/null +++ b/web-ext/utils/segments/types.ts @@ -0,0 +1,95 @@ +// --------------------------------------------------------------------------- +// Segments — core data types +// All segment-related logic depends on these; keep this file logic-free. +// --------------------------------------------------------------------------- + +export type SegmentId = string; +export type StepId = string; +export type ConfigId = string; + +/** A single named time-range within a video. */ +export interface Segment { + id: SegmentId; + startTime: number; // seconds ≥ 0 + endTime: number; // seconds > startTime + label?: string; +} + +/** + * One unit in a playback sequence. + * + * `segmentIds` is an ordered list of segment references — the playback engine + * plays them in exactly that order. Duplicates are intentional: [seg3, seg3, + * seg1] plays seg3 twice then seg1 once. This is the primary way to express + * custom playback arrangements without needing a separate count-per-reference + * field. + * + * `count` governs how many times the whole `segmentIds` list is played through: + * 0 = loop forever + * 1+ = play that many full cycles, then advance to the next step + * + * `perIterationRates` overrides `playbackRate` on a per-cycle basis. + * Index 0 = first cycle, index 1 = second cycle, etc. The last entry repeats + * for any cycle beyond the array length. An empty array means `playbackRate` + * is used for every cycle. + */ +export interface PlayStep { + id: StepId; + segmentIds: SegmentId[]; + count: number; + playbackRate: number | null; // null = keep current video rate + perIterationRates: number[]; +} + +/** + * A complete, saveable playback configuration for a video. + * + * `sequence` is an ordered list of steps. When the last step finishes + * (count ≠ 0), the engine wraps back to step 0 for a global infinite loop. + * Set the last step's count = 0 if you want that step to loop forever. + */ +export interface SegmentConfig { + id: ConfigId; + label: string; // required; auto-generated if user leaves blank + segments: Segment[]; + sequence: PlayStep[]; + shortcutKey: string; // "" = no shortcut; e.g. "Ctrl+1" + createdAt: number; // unix ms + updatedAt: number; +} + +/** + * Per-video auto-load override. + * + * null → no pin; fall back to the global preference + * "off" → always skip auto-load for this video + * "last-used" → always restore the volatile last-used snapshot + * "default" → always restore the default saved config + * { configId } → always restore a specific named config + */ +export type SegmentAutoLoadPin = + | null + | "off" + | "last-used" + | "default" + | { configId: string }; + +/** + * Per-video persistence record stored in IndexedDB. + * + * Three tiers: + * lastUsed — auto-updated volatile snapshot; never requires explicit save + * configs — explicitly saved named configurations + * defaultConfigId — which of configs[] is the "default" quick-save slot + * + * autoLoadPin overrides the global "segments.autoLoad" preference for this + * specific video. Undefined / null = honour the global preference. + */ +export interface VideoSegmentData { + videoId: string; + lastUsed: SegmentConfig | null; + configs: SegmentConfig[]; + defaultConfigId: string | null; + autoLoadPin?: SegmentAutoLoadPin; + savedAt: number; +} diff --git a/web-ext/utils/storage/capabilityCache.ts b/web-ext/utils/storage/capabilityCache.ts new file mode 100644 index 00000000..a9d55c46 --- /dev/null +++ b/web-ext/utils/storage/capabilityCache.ts @@ -0,0 +1,138 @@ +import { EXTENSION_VERSION } from "@toppings/constants"; +import { CAPABILITY_CACHE_STORE, withStore } from "./indexedDb"; +import type { PrimitiveResolution } from "../primitive"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type CapabilityStatus = "supported" | "unsupported" | "untested"; + +export type PrimitiveScope = + | "watch" + | "home" + | "search" + | "shorts" + | "playlist"; + +export interface CapabilityCacheEntry { + /** Unique identifier for the primitive, e.g. "watch.sidebar". */ + primitiveId: string; + /** YouTube page scope the primitive operates on. */ + scope: PrimitiveScope; + /** Whether the primitive resolved successfully on this user's YouTube. */ + status: CapabilityStatus; + /** + * Index into the primitive's strategies array that resolved, or null if + * the primitive is unsupported / untested. + */ + resolvedStrategyIndex: number | null; + /** Unix timestamp (ms) of the last resolution attempt. */ + lastCheckedAt: number; + /** + * Extension version at time of last check. + * Used to invalidate stale cache entries after updates that add new + * selector strategies — a previously-unsupported primitive may now work. + */ + extensionVersion: string; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Read the cached capability status for a primitive. + * + * Returns `"untested"` when: + * - No entry exists for this primitive yet. + * - The cached entry was written by an older extension version (stale). + * + * Staleness check: if the stored `extensionVersion` differs from the current + * version, we treat the entry as untested so the primitive retries resolution + * with any newly added selector strategies. + */ +export async function getCapabilityStatus( + primitiveId: string, +): Promise { + try { + const entry = await withStore( + CAPABILITY_CACHE_STORE, + "readonly", + (store) => store.get(primitiveId), + ); + if (!entry) return "untested"; + if (entry.extensionVersion !== EXTENSION_VERSION) return "untested"; + return entry.status; + } catch { + return "untested"; + } +} + +/** + * Persist the result of a primitive resolution attempt to the cache. + * + * @param primitiveId - Unique primitive identifier, e.g. "watch.sidebar". + * @param scope - YouTube page scope for this primitive. + * @param resolution - The result returned by `resolveTarget`. + */ +export async function setCapabilityStatus( + primitiveId: string, + scope: PrimitiveScope, + resolution: PrimitiveResolution, +): Promise { + const entry: CapabilityCacheEntry = { + primitiveId, + scope, + status: resolution.resolved ? "supported" : "unsupported", + resolvedStrategyIndex: resolution.resolved + ? resolution.strategyIndex + : null, + lastCheckedAt: Date.now(), + extensionVersion: EXTENSION_VERSION, + }; + try { + await withStore( + CAPABILITY_CACHE_STORE, + "readwrite", + (store) => store.put(entry), + ); + } catch { + // Cache write failure is non-fatal — the extension continues to work; + // the primitive will simply re-attempt resolution on the next page load. + } +} + +/** + * Read all cached entries — used by the options UI to render supported / + * unsupported states for every primitive. + */ +export async function getAllCapabilityEntries(): Promise< + CapabilityCacheEntry[] +> { + try { + return await withStore( + CAPABILITY_CACHE_STORE, + "readonly", + (store) => store.getAll(), + ); + } catch { + return []; + } +} + +/** + * Clear all capability cache entries, forcing every primitive to re-attempt + * DOM resolution on the next page load. + * + * Exposed to the options UI as "Re-scan / Refresh capabilities". + */ +export async function clearCapabilityCache(): Promise { + try { + await withStore(CAPABILITY_CACHE_STORE, "readwrite", (store) => + store.clear(), + ); + } catch { + // Non-fatal. + } +} diff --git a/web-ext/utils/storage/featureReports.ts b/web-ext/utils/storage/featureReports.ts new file mode 100644 index 00000000..bebb741a --- /dev/null +++ b/web-ext/utils/storage/featureReports.ts @@ -0,0 +1,115 @@ +/** + * Feature report storage. + * + * When a user taps "Report" on an unsupported primitive, we store a record + * locally. On each extension update, the background re-scans capabilities + * and checks whether any reported primitives are now supported. If so, a + * "recovered" flag is set that the options page reads to show a banner. + */ + +import { getCurrentVersion } from "../../utils/version"; + +const REPORT_STORE_KEY = "toppings:feature_reports"; +const RECOVERED_KEY = "toppings:feature_recovered"; + +export interface FeatureReport { + primitiveId: string; + reportedAt: number; // Unix timestamp (ms) + reportedVersion: string; // Extension version at time of report +} + +export interface RecoveredFeature { + primitiveId: string; + recoveredAt: number; + dismissedAt: number | null; +} + +// --------------------------------------------------------------------------- +// Reports +// --------------------------------------------------------------------------- + +export async function getFeatureReports(): Promise { + return new Promise((resolve) => { + chrome.storage.local.get(REPORT_STORE_KEY, (result) => { + const stored = result[REPORT_STORE_KEY]; + resolve(Array.isArray(stored) ? (stored as FeatureReport[]) : []); + }); + }); +} + +export async function addFeatureReport(primitiveId: string): Promise { + const reports = await getFeatureReports(); + // Idempotent — don't add duplicate reports for the same primitive. + if (reports.some((r) => r.primitiveId === primitiveId)) return; + + const version = await getCurrentVersion(); + const newReport: FeatureReport = { + primitiveId, + reportedAt: Date.now(), + reportedVersion: version, + }; + + await new Promise((resolve) => { + chrome.storage.local.set( + { [REPORT_STORE_KEY]: [...reports, newReport] }, + resolve, + ); + }); +} + +export async function removeFeatureReport(primitiveId: string): Promise { + const reports = await getFeatureReports(); + const filtered = reports.filter((r) => r.primitiveId !== primitiveId); + await new Promise((resolve) => { + chrome.storage.local.set({ [REPORT_STORE_KEY]: filtered }, resolve); + }); +} + +// --------------------------------------------------------------------------- +// Recovered features (set by background on update) +// --------------------------------------------------------------------------- + +export async function getRecoveredFeatures(): Promise { + return new Promise((resolve) => { + chrome.storage.local.get(RECOVERED_KEY, (result) => { + const stored = result[RECOVERED_KEY]; + resolve(Array.isArray(stored) ? (stored as RecoveredFeature[]) : []); + }); + }); +} + +export async function markRecovered(primitiveId: string): Promise { + const existing = await getRecoveredFeatures(); + if (existing.some((r) => r.primitiveId === primitiveId && r.dismissedAt === null)) { + return; // Already pending dismissal — don't duplicate + } + const entry: RecoveredFeature = { + primitiveId, + recoveredAt: Date.now(), + dismissedAt: null, + }; + const filtered = existing.filter((r) => r.primitiveId !== primitiveId); + await new Promise((resolve) => { + chrome.storage.local.set( + { [RECOVERED_KEY]: [...filtered, entry] }, + resolve, + ); + }); +} + +export async function dismissRecovered(primitiveId: string): Promise { + const existing = await getRecoveredFeatures(); + const updated = existing.map((r) => + r.primitiveId === primitiveId + ? { ...r, dismissedAt: Date.now() } + : r, + ); + await new Promise((resolve) => { + chrome.storage.local.set({ [RECOVERED_KEY]: updated }, resolve); + }); +} + +export async function getUndismissedRecovered(): Promise { + const all = await getRecoveredFeatures(); + return all.filter((r) => r.dismissedAt === null); +} diff --git a/web-ext/utils/storage/indexedDb.ts b/web-ext/utils/storage/indexedDb.ts new file mode 100644 index 00000000..287a6108 --- /dev/null +++ b/web-ext/utils/storage/indexedDb.ts @@ -0,0 +1,88 @@ +const DB_NAME = "toppings"; +const DB_VERSION = 4; + +export const VIDEO_PREFERENCE_STORE = "video_preference"; +const VIDEO_PREFERENCE_KEY_PATH = "videoId"; + +export const CAPABILITY_CACHE_STORE = "capability_cache"; +const CAPABILITY_CACHE_KEY_PATH = "primitiveId"; + +export const LOOP_SEGMENT_STORE = "loop_segment"; +const LOOP_SEGMENT_KEY_PATH = "videoId"; + +export const SEGMENT_DATA_STORE = "segment_data"; +const SEGMENT_DATA_KEY_PATH = "videoId"; + +let dbPromise: Promise | null = null; + +function upgradeDatabase(db: IDBDatabase): void { + if (!db.objectStoreNames.contains(VIDEO_PREFERENCE_STORE)) { + db.createObjectStore(VIDEO_PREFERENCE_STORE, { + keyPath: VIDEO_PREFERENCE_KEY_PATH, + }); + } + if (!db.objectStoreNames.contains(CAPABILITY_CACHE_STORE)) { + db.createObjectStore(CAPABILITY_CACHE_STORE, { + keyPath: CAPABILITY_CACHE_KEY_PATH, + }); + } + if (!db.objectStoreNames.contains(LOOP_SEGMENT_STORE)) { + db.createObjectStore(LOOP_SEGMENT_STORE, { + keyPath: LOOP_SEGMENT_KEY_PATH, + }); + } + if (!db.objectStoreNames.contains(SEGMENT_DATA_STORE)) { + db.createObjectStore(SEGMENT_DATA_STORE, { + keyPath: SEGMENT_DATA_KEY_PATH, + }); + } +} + +export function openExtensionDatabase(): Promise { + if (!dbPromise) { + dbPromise = new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onerror = () => { + dbPromise = null; + reject(request.error ?? new Error("IndexedDB open failed")); + }; + + request.onupgradeneeded = () => { + upgradeDatabase(request.result); + }; + + request.onsuccess = () => { + resolve(request.result); + }; + }); + } + + return dbPromise; +} + +export async function withStore( + storeName: string, + mode: IDBTransactionMode, + run: (store: IDBObjectStore) => IDBRequest | Promise, +): Promise { + const db = await openExtensionDatabase(); + return new Promise((resolve, reject) => { + const tx = db.transaction(storeName, mode); + const store = tx.objectStore(storeName); + const outcome = run(store); + + const finish = (value: T) => { + tx.oncomplete = () => resolve(value); + tx.onerror = () => reject(tx.error ?? new Error("IndexedDB transaction failed")); + }; + + if (outcome instanceof IDBRequest) { + outcome.onsuccess = () => finish(outcome.result as T); + outcome.onerror = () => + reject(outcome.error ?? new Error("IndexedDB request failed")); + } else { + outcome.then(finish).catch(reject); + } + }); +} diff --git a/web-ext/utils/storage/loopSegmentStore.ts b/web-ext/utils/storage/loopSegmentStore.ts new file mode 100644 index 00000000..6519f8f2 --- /dev/null +++ b/web-ext/utils/storage/loopSegmentStore.ts @@ -0,0 +1,86 @@ +/** + * Per-video loop segment persistence backed by IndexedDB. + * + * Records are stored in the `loop_segment` object store, keyed by `videoId`. + * This is the right storage tier for structured per-entity data — no quota + * worries, no key-prefix scanning, and it sits naturally alongside + * `video_preference` which follows the same pattern. + * + * Save — user presses the save shortcut while loop is active. + * Clear — user presses the save shortcut while loop is inactive. + */ + +import { LOOP_SEGMENT_STORE, withStore } from "./indexedDb"; + +export interface SavedLoopSegment { + /** IDB key path — matches the videoId query param. */ + videoId: string; + /** Loop start in seconds from the beginning of the video. */ + startTime: number; + /** Loop end in seconds from the beginning of the video. */ + endTime: number; + /** Unix timestamp (ms) when the segment was last saved. */ + savedAt: number; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** Retrieve the saved loop segment for a video, or null if none exists. */ +export async function getSavedLoopSegment( + videoId: string, +): Promise { + const result = await withStore( + LOOP_SEGMENT_STORE, + "readonly", + (store) => store.get(videoId), + ); + return result ?? null; +} + +/** Persist the current loop segment positions for a video. */ +export async function saveLoopSegment( + videoId: string, + startTime: number, + endTime: number, +): Promise { + const entry: SavedLoopSegment = { + videoId, + startTime, + endTime, + savedAt: Date.now(), + }; + await withStore( + LOOP_SEGMENT_STORE, + "readwrite", + (store) => store.put(entry), + ); +} + +/** Remove the saved loop segment for a video. */ +export async function deleteSavedLoopSegment(videoId: string): Promise { + await withStore( + LOOP_SEGMENT_STORE, + "readwrite", + (store) => store.delete(videoId), + ); +} + +/** Return all saved loop segments (e.g. for a management UI). */ +export async function getAllSavedLoopSegments(): Promise { + return withStore( + LOOP_SEGMENT_STORE, + "readonly", + (store) => store.getAll(), + ); +} + +/** Remove every saved loop segment. */ +export async function clearAllSavedLoopSegments(): Promise { + await withStore( + LOOP_SEGMENT_STORE, + "readwrite", + (store) => store.clear(), + ); +} diff --git a/web-ext/utils/storage/playlistCache.ts b/web-ext/utils/storage/playlistCache.ts new file mode 100644 index 00000000..88b4d39e --- /dev/null +++ b/web-ext/utils/storage/playlistCache.ts @@ -0,0 +1,95 @@ +/** + * Playlist data cache stored in chrome.storage.local. + * + * The background service worker calls the Toppings API for every playlist + * page visit. This cache stores the response keyed by playlistId and + * returns stale data within the TTL window, cutting unnecessary API calls. + * + * TTL: 30 minutes by default. Users can manually refresh from the + * playlist UI, which clears the entry and forces a fresh call. + */ + +import type { ValidPlaylistPayload } from "../../app/background/context"; + +const CACHE_KEY_PREFIX = "toppings:playlist_cache:"; +/** Cache TTL in milliseconds. */ +const CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes + +interface PlaylistCacheEntry { + playlistId: string; + data: ValidPlaylistPayload; + cachedAt: number; // Unix timestamp (ms) +} + +function storageKey(playlistId: string): string { + return `${CACHE_KEY_PREFIX}${playlistId}`; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Retrieve cached playlist data. Returns null if absent or expired. + */ +export async function getCachedPlaylist( + playlistId: string, +): Promise { + const key = storageKey(playlistId); + return new Promise((resolve) => { + chrome.storage.local.get(key, (result) => { + const entry = result[key] as PlaylistCacheEntry | undefined; + if (!entry) { resolve(null); return; } + if (Date.now() - entry.cachedAt > CACHE_TTL_MS) { + // Expired — purge and return null. + chrome.storage.local.remove(key); + resolve(null); + return; + } + resolve(entry.data); + }); + }); +} + +/** + * Write playlist data to the cache. + */ +export async function setCachedPlaylist( + playlistId: string, + data: ValidPlaylistPayload, +): Promise { + const entry: PlaylistCacheEntry = { + playlistId, + data, + cachedAt: Date.now(), + }; + return new Promise((resolve) => { + chrome.storage.local.set({ [storageKey(playlistId)]: entry }, resolve); + }); +} + +/** + * Evict the cached entry for a specific playlist (manual refresh). + */ +export async function invalidateCachedPlaylist( + playlistId: string, +): Promise { + return new Promise((resolve) => { + chrome.storage.local.remove(storageKey(playlistId), resolve); + }); +} + +/** + * Evict all cached playlist entries. + */ +export async function clearPlaylistCache(): Promise { + return new Promise((resolve) => { + chrome.storage.local.get(null, (all) => { + const keys = Object.keys(all).filter((k) => + k.startsWith(CACHE_KEY_PREFIX), + ); + if (keys.length === 0) { resolve(); return; } + chrome.storage.local.remove(keys, resolve); + }); + }); +} diff --git a/web-ext/utils/storage/profileImportExport.ts b/web-ext/utils/storage/profileImportExport.ts new file mode 100644 index 00000000..57ed525b --- /dev/null +++ b/web-ext/utils/storage/profileImportExport.ts @@ -0,0 +1,211 @@ +/** + * Profile import/export utilities. + * + * Export: serialises a Profile to JSON and triggers a browser download. + * Import: reads a JSON file, validates the primitive schema, and returns + * a Profile object ready to be persisted via `createProfile`. + */ + +import type { Profile, ProfilePrimitiveConfig } from "../../data/profiles.data"; + +// --------------------------------------------------------------------------- +// Export +// --------------------------------------------------------------------------- + +/** + * Trigger a browser download of the profile as a JSON file. + * Filename: `toppings-profile-.json` + */ +export function exportProfile(profile: Profile): void { + const exportData = { + $schema: + "https://toppings.greenstitch.studio/profile-schema/v1.json", + name: profile.name, + primitives: profile.primitives, + exportedAt: new Date().toISOString(), + }; + + const blob = new Blob([JSON.stringify(exportData, null, 2)], { + type: "application/json", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `toppings-profile-${slugify(profile.name)}.json`; + a.click(); + URL.revokeObjectURL(url); +} + +// --------------------------------------------------------------------------- +// Import +// --------------------------------------------------------------------------- + +export interface ImportResult { + ok: true; + name: string; + primitives: ProfilePrimitiveConfig; +} + +export interface ImportError { + ok: false; + message: string; +} + +/** + * Read and validate a JSON file from a file input. + * Returns a discriminated union — check `.ok` before using the result. + */ +export async function importProfileFromFile( + file: File, +): Promise { + if (!file.name.endsWith(".json") && file.type !== "application/json") { + return { ok: false, message: "File must be a .json file." }; + } + + let raw: string; + try { + raw = await file.text(); + } catch { + return { ok: false, message: "Could not read the file." }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return { ok: false, message: "File is not valid JSON." }; + } + + return validateProfileJson(parsed); +} + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +/** + * Validate an unknown JSON value against the ProfilePrimitiveConfig schema. + * Accepts both the full export format (with `$schema`, `exportedAt`) and a + * bare `{ name, primitives }` object for simple hand-crafted configs. + */ +function validateProfileJson(data: unknown): ImportResult | ImportError { + if (typeof data !== "object" || data === null || Array.isArray(data)) { + return { ok: false, message: "Expected a JSON object at the top level." }; + } + + const obj = data as Record; + + // Name + const name = + typeof obj.name === "string" && obj.name.trim() + ? obj.name.trim() + : null; + if (!name) { + return { + ok: false, + message: 'Missing or empty "name" field.', + }; + } + if (name.length > 40) { + return { ok: false, message: "Profile name must be 40 characters or fewer." }; + } + + // Primitives + if ( + typeof obj.primitives !== "object" || + obj.primitives === null || + Array.isArray(obj.primitives) + ) { + return { + ok: false, + message: '"primitives" must be an object.', + }; + } + + const primitives = obj.primitives as Record; + const validated: ProfilePrimitiveConfig = {}; + const errors: string[] = []; + + for (const [key, value] of Object.entries(primitives)) { + const error = validatePrimitiveEntry(key, value, validated); + if (error) errors.push(error); + } + + if (errors.length > 0) { + return { + ok: false, + message: `Invalid primitive values:\n${errors.slice(0, 5).join("\n")}`, + }; + } + + return { ok: true, name, primitives: validated }; +} + +const VALID_PLAYER_LAYOUTS = new Set(["default", "theatre", "no-video"]); +const VALID_PLAYER_VISUALS = new Set(["video", "black", "visualizer", "custom"]); +const VALID_THUMBNAIL_MODES = new Set(["show", "hide", "blur"]); + +function validatePrimitiveEntry( + key: string, + value: unknown, + out: ProfilePrimitiveConfig, +): string | null { + if (typeof value !== "object" || value === null) { + return `"${key}": value must be an object`; + } + const v = value as Record; + + switch (key) { + case "watch.layout": + if (!VALID_PLAYER_LAYOUTS.has(v.value as string)) { + return `"${key}.value" must be one of: ${[...VALID_PLAYER_LAYOUTS].join(", ")}`; + } + out["watch.layout"] = { value: v.value as "default" | "theatre" | "no-video" }; + return null; + + case "watch.visuals": + if (!VALID_PLAYER_VISUALS.has(v.value as string)) { + return `"${key}.value" must be one of: ${[...VALID_PLAYER_VISUALS].join(", ")}`; + } + out["watch.visuals"] = { value: v.value as "video" | "black" | "visualizer" | "custom" }; + return null; + + case "watch.sidebar": + case "watch.comments": + case "watch.endCards": + case "home.feed": + case "home.shorts": + case "search.metadata": + case "search.shorts": + case "shorts.shelf": + if (typeof v.visible !== "boolean") { + return `"${key}.visible" must be a boolean`; + } + (out as Record)[key] = { visible: v.visible }; + return null; + + case "home.thumbnails": + case "search.thumbnails": + if (!VALID_THUMBNAIL_MODES.has(v.mode as string)) { + return `"${key}.mode" must be one of: ${[...VALID_THUMBNAIL_MODES].join(", ")}`; + } + (out as Record)[key] = { mode: v.mode as "show" | "hide" | "blur" }; + return null; + + default: + // Unknown key — silently skip rather than reject the whole file. + return null; + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function slugify(str: string): string { + return str + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 40); +} diff --git a/web-ext/utils/storage/profileStore.ts b/web-ext/utils/storage/profileStore.ts new file mode 100644 index 00000000..f11b2e18 --- /dev/null +++ b/web-ext/utils/storage/profileStore.ts @@ -0,0 +1,147 @@ +import { + BUILT_IN_PRESETS, + DEFAULT_PROFILE_STORE, + type Profile, + type ProfileStore, +} from "../../data/profiles.data"; + +// --------------------------------------------------------------------------- +// Storage key +// --------------------------------------------------------------------------- + +const PROFILE_STORE_KEY = "toppings:profile_store"; + +// --------------------------------------------------------------------------- +// Low-level read / write +// --------------------------------------------------------------------------- + +async function readProfileStore(): Promise { + return new Promise((resolve) => { + chrome.storage.local.get(PROFILE_STORE_KEY, (result) => { + const stored = result[PROFILE_STORE_KEY] as ProfileStore | undefined; + if (!stored) { + resolve({ ...DEFAULT_PROFILE_STORE }); + return; + } + // Ensure shape is complete — defensive merge with defaults. + resolve({ + activeProfileId: stored.activeProfileId ?? null, + profiles: Array.isArray(stored.profiles) ? stored.profiles : [], + }); + }); + }); +} + +async function writeProfileStore(store: ProfileStore): Promise { + return new Promise((resolve) => { + chrome.storage.local.set({ [PROFILE_STORE_KEY]: store }, resolve); + }); +} + +// --------------------------------------------------------------------------- +// Public helpers +// --------------------------------------------------------------------------- + +/** + * Return all profiles: built-in presets first, then user-created profiles + * sorted by creation date (newest last). + */ +export async function getAllProfiles(): Promise { + const { profiles } = await readProfileStore(); + return [ + ...BUILT_IN_PRESETS, + ...profiles.sort((a, b) => a.createdAt - b.createdAt), + ]; +} + +/** + * Return only user-created (non-preset) profiles. + */ +export async function getCustomProfiles(): Promise { + const { profiles } = await readProfileStore(); + return profiles.sort((a, b) => a.createdAt - b.createdAt); +} + +/** + * Look up a profile by ID. Checks presets first, then custom profiles. + * Returns undefined if no match. + */ +export async function getProfileById( + id: string, +): Promise { + const preset = BUILT_IN_PRESETS.find((p) => p.id === id); + if (preset) return preset; + const { profiles } = await readProfileStore(); + return profiles.find((p) => p.id === id); +} + +/** + * Get the currently active profile, or null if no profile is active. + */ +export async function getActiveProfile(): Promise { + const { activeProfileId } = await readProfileStore(); + if (!activeProfileId) return null; + return (await getProfileById(activeProfileId)) ?? null; +} + +/** + * Set the active profile by ID. Pass null to deactivate all profiles + * (extension falls back to individual preferences). + */ +export async function setActiveProfileId(id: string | null): Promise { + const store = await readProfileStore(); + await writeProfileStore({ ...store, activeProfileId: id }); +} + +/** + * Create a new custom profile. Returns the created profile. + */ +export async function createProfile( + data: Omit, +): Promise { + const store = await readProfileStore(); + const profile: Profile = { + ...data, + id: crypto.randomUUID(), + isPreset: false, + createdAt: Date.now(), + }; + await writeProfileStore({ + ...store, + profiles: [...store.profiles, profile], + }); + return profile; +} + +/** + * Update an existing custom profile. Silently ignores attempts to update + * built-in presets. + */ +export async function updateProfile( + id: string, + patch: Partial>, +): Promise { + if (BUILT_IN_PRESETS.some((p) => p.id === id)) return; + const store = await readProfileStore(); + await writeProfileStore({ + ...store, + profiles: store.profiles.map((p) => + p.id === id ? { ...p, ...patch } : p, + ), + }); +} + +/** + * Delete a custom profile. If the deleted profile was active, deactivates it. + * Silently ignores attempts to delete built-in presets. + */ +export async function deleteProfile(id: string): Promise { + if (BUILT_IN_PRESETS.some((p) => p.id === id)) return; + const store = await readProfileStore(); + const activeProfileId = + store.activeProfileId === id ? null : store.activeProfileId; + await writeProfileStore({ + activeProfileId, + profiles: store.profiles.filter((p) => p.id !== id), + }); +} diff --git a/web-ext/utils/storage/segmentStore.ts b/web-ext/utils/storage/segmentStore.ts new file mode 100644 index 00000000..11371386 --- /dev/null +++ b/web-ext/utils/storage/segmentStore.ts @@ -0,0 +1,269 @@ +import { + SEGMENT_DATA_STORE, + LOOP_SEGMENT_STORE, + withStore, +} from "./indexedDb"; +import type { VideoSegmentData, SegmentConfig, SegmentAutoLoadPin } from "../segments/types"; +import { createFreshConfig } from "../segments/factories"; + +// --------------------------------------------------------------------------- +// Legacy loop segment type (for migration only) +// --------------------------------------------------------------------------- +interface LegacyLoopSegment { + videoId: string; + startTime: number; + endTime: number; + savedAt: number; +} + +// --------------------------------------------------------------------------- +// Migration helper +// --------------------------------------------------------------------------- + +/** + * Attempt to migrate a legacy `SavedLoopSegment` record into `VideoSegmentData`. + * Returns migrated data or null if no legacy record exists. + */ +async function migrateLegacyLoopSegment( + videoId: string, +): Promise { + let legacy: LegacyLoopSegment | null = null; + try { + legacy = await withStore( + LOOP_SEGMENT_STORE, + "readonly", + (store) => store.get(videoId), + ).then((r) => r ?? null); + } catch { + return null; + } + if (!legacy) return null; + + // Convert to a single-segment config with infinite loop. + const segId = crypto.randomUUID(); + const stepId = crypto.randomUUID(); + const config: SegmentConfig = { + id: crypto.randomUUID(), + label: "Restored Loop", + segments: [ + { id: segId, startTime: legacy.startTime, endTime: legacy.endTime }, + ], + sequence: [ + { + id: stepId, + segmentIds: [segId], + count: 0, + playbackRate: null, + perIterationRates: [], + }, + ], + shortcutKey: "", + createdAt: legacy.savedAt, + updatedAt: legacy.savedAt, + }; + + const data: VideoSegmentData = { + videoId, + lastUsed: config, + configs: [], + defaultConfigId: null, + savedAt: legacy.savedAt, + }; + + // Persist migrated data so we don't re-migrate on next load. + await withStore(SEGMENT_DATA_STORE, "readwrite", (store) => + store.put(data), + ); + return data; +} + +// --------------------------------------------------------------------------- +// Public CRUD API +// --------------------------------------------------------------------------- + +/** + * Retrieve all segment data for a video. + * On first access, transparently migrates any legacy loop segment record. + */ +export async function getVideoSegmentData( + videoId: string, +): Promise { + const existing = await withStore( + SEGMENT_DATA_STORE, + "readonly", + (store) => store.get(videoId), + ).then((r) => r ?? null); + + if (existing) return existing; + + // No new-format record — try to migrate from old loop segment store. + return migrateLegacyLoopSegment(videoId); +} + +/** Persist (upsert) the full data record for a video. */ +export async function saveVideoSegmentData( + data: VideoSegmentData, +): Promise { + await withStore(SEGMENT_DATA_STORE, "readwrite", (store) => + store.put({ ...data, savedAt: Date.now() }), + ); +} + +// --------------------------------------------------------------------------- +// Convenience helpers +// --------------------------------------------------------------------------- + +/** Get the last-used (volatile) config for a video. */ +export async function getLastUsed( + videoId: string, +): Promise { + const data = await getVideoSegmentData(videoId); + return data?.lastUsed ?? null; +} + +/** + * Update the last-used slot. Creates a new VideoSegmentData record if needed. + * Pass `null` to clear the last-used slot. + */ +export async function setLastUsed( + videoId: string, + config: SegmentConfig | null, +): Promise { + const data = (await getVideoSegmentData(videoId)) ?? { + videoId, + lastUsed: null, + configs: [], + defaultConfigId: null, + savedAt: 0, + }; + await saveVideoSegmentData({ ...data, lastUsed: config }); +} + +/** Get all explicitly saved named configs for a video. */ +export async function getSavedConfigs( + videoId: string, +): Promise { + const data = await getVideoSegmentData(videoId); + return data?.configs ?? []; +} + +/** + * Save (upsert) a named config. If a config with the same ID already exists, + * it is replaced. Otherwise it is appended. + */ +export async function saveNamedConfig( + videoId: string, + config: SegmentConfig, +): Promise { + const data = (await getVideoSegmentData(videoId)) ?? { + videoId, + lastUsed: null, + configs: [], + defaultConfigId: null, + savedAt: 0, + }; + const idx = data.configs.findIndex((c) => c.id === config.id); + const newConfigs = + idx >= 0 + ? data.configs.map((c) => (c.id === config.id ? config : c)) + : [...data.configs, config]; + await saveVideoSegmentData({ ...data, configs: newConfigs }); +} + +/** Delete a named config by ID. Clears defaultConfigId if it pointed to it. */ +export async function deleteNamedConfig( + videoId: string, + configId: string, +): Promise { + const data = await getVideoSegmentData(videoId); + if (!data) return; + const newConfigs = data.configs.filter((c) => c.id !== configId); + const newDefaultId = + data.defaultConfigId === configId ? null : data.defaultConfigId; + await saveVideoSegmentData({ + ...data, + configs: newConfigs, + defaultConfigId: newDefaultId, + }); +} + +/** Mark one of the saved configs as the "default" slot. Pass null to unset. */ +export async function setDefaultConfig( + videoId: string, + configId: string | null, +): Promise { + const data = await getVideoSegmentData(videoId); + if (!data) return; + await saveVideoSegmentData({ ...data, defaultConfigId: configId }); +} + +/** Get the config designated as the default slot, or null. */ +export async function getDefaultConfig( + videoId: string, +): Promise { + const data = await getVideoSegmentData(videoId); + if (!data || !data.defaultConfigId) return null; + return data.configs.find((c) => c.id === data.defaultConfigId) ?? null; +} + +/** + * Determine which config (if any) to auto-load on page open. + * + * Resolution order: + * 1. Per-video `autoLoadPin` (stored in VideoSegmentData) — overrides global + * 2. `globalAutoLoad` argument — from the user's watch-page preference + * + * Returns `null` when the effective setting is "off" or no matching config + * exists (caller should keep segments disabled rather than creating a fresh one). + */ +export async function getAutoloadConfig( + videoId: string, + _videoDuration: number, + globalAutoLoad: "off" | "last-used" | "default" = "off", +): Promise { + const data = await getVideoSegmentData(videoId); + + // Effective setting: per-video pin wins over global. + const pin: SegmentAutoLoadPin = data?.autoLoadPin ?? null; + const effective: "off" | "last-used" | "default" | { configId: string } = + pin !== null ? pin : globalAutoLoad; + + if (effective === "off") return null; + + if (effective === "last-used") return data?.lastUsed ?? null; + + if (effective === "default") { + return ( + data?.configs.find((c) => c.id === data.defaultConfigId) ?? null + ); + } + + if (typeof effective === "object" && "configId" in effective) { + return data?.configs.find((c) => c.id === effective.configId) ?? null; + } + + return null; +} + +/** Set or clear the per-video auto-load pin. */ +export async function setAutoLoadPin( + videoId: string, + pin: SegmentAutoLoadPin, +): Promise { + const data = (await getVideoSegmentData(videoId)) ?? { + videoId, + lastUsed: null, + configs: [], + defaultConfigId: null, + savedAt: 0, + }; + await saveVideoSegmentData({ ...data, autoLoadPin: pin }); +} + +/** Get the current per-video auto-load pin. */ +export async function getAutoLoadPin( + videoId: string, +): Promise { + const data = await getVideoSegmentData(videoId); + return data?.autoLoadPin ?? null; +} diff --git a/web-ext/utils/storage/videoPreference.ts b/web-ext/utils/storage/videoPreference.ts new file mode 100644 index 00000000..d1f75af0 --- /dev/null +++ b/web-ext/utils/storage/videoPreference.ts @@ -0,0 +1,69 @@ +import { + VIDEO_PREFERENCE_STORE, + withStore, +} from "./indexedDb"; + +const STORE = VIDEO_PREFERENCE_STORE; + +export type AudioModePin = { + enabled: boolean; + screenMode: "black" | "visualizer" | "custom"; + imageUrl?: string; +}; + +export type VideoPreferenceRecord = { + videoId: string; + audioModePin: AudioModePin; +}; + +export type VideoPreferenceSnapshot = { + audioMode: { + pinCount: number; + }; +}; + +async function putAudioModePinRecord( + videoId: string, + pin: AudioModePin, +): Promise { + await withStore(STORE, "readwrite", (store) => + store.put({ videoId, audioModePin: pin } satisfies VideoPreferenceRecord), + ); +} + +export async function getAudioModePin( + videoId: string, +): Promise { + const record = await withStore( + STORE, + "readonly", + (store) => store.get(videoId), + ); + return record?.audioModePin ?? null; +} + +export async function setAudioModePin( + videoId: string, + pin: AudioModePin, +): Promise { + await putAudioModePinRecord(videoId, pin); +} + +export async function removeAudioModePin(videoId: string): Promise { + await withStore(STORE, "readwrite", (store) => store.delete(videoId)); +} + +export async function countAudioModePins(): Promise { + return withStore(STORE, "readonly", (store) => store.count()); +} + +export async function loadVideoPreferenceSnapshot(): Promise { + const pinCount = await countAudioModePins(); + return { audioMode: { pinCount } }; +} + +export async function clearAllAudioModePins(): Promise { + const count = await countAudioModePins(); + await withStore(STORE, "readwrite", (store) => store.clear()); + return count; +} diff --git a/web-ext/utils/theme.ts b/web-ext/utils/theme.ts new file mode 100644 index 00000000..2a434ae1 --- /dev/null +++ b/web-ext/utils/theme.ts @@ -0,0 +1,21 @@ +export type ThemePreference = "system" | "dark" | "light"; +export type ResolvedTheme = "dark" | "light"; + +export function resolveTheme(pref: ThemePreference): ResolvedTheme { + if (pref === "system") { + if ( + typeof window !== "undefined" && + window.matchMedia && + window.matchMedia("(prefers-color-scheme: light)").matches + ) { + return "light"; + } + return "dark"; + } + return pref; +} + +export function applyTheme(theme: ResolvedTheme) { + if (typeof document === "undefined") return; + document.documentElement.setAttribute("data-theme", theme); +} diff --git a/web-ext/utils/version.ts b/web-ext/utils/version.ts new file mode 100644 index 00000000..a6448dff --- /dev/null +++ b/web-ext/utils/version.ts @@ -0,0 +1,11 @@ +/** + * Returns the extension's current version from the manifest. + * Works in both background and content script contexts. + */ +export async function getCurrentVersion(): Promise { + try { + return chrome.runtime.getManifest().version; + } catch { + return "unknown"; + } +} diff --git a/website/app/_fonts/inter-400.woff2 b/website/app/_fonts/inter-400.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..d228a4af2fcc51e12c8a744e6c35d2363bd4b98a GIT binary patch literal 21564 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 literal 0 HcmV?d00001 diff --git a/website/app/_fonts/inter-500.woff2 b/website/app/_fonts/inter-500.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..21db79416d8d50282a37c2a564aef73d4f80fe64 GIT binary patch literal 22760 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� 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 R7(LY+?A`bNhw0%N006DJ9@+o^ literal 0 HcmV?d00001 diff --git a/website/app/_fonts/inter-700.woff2 b/website/app/_fonts/inter-700.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..775f7575e653614760dba82fd9dffaddb939770f GIT binary patch literal 22904 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

p9mvRq3FeHt~X*bZJ9} zBd!ciZ%|lEL|j7O9j54!d4Qg&>n^((D%slKa4+7Goxp|*`Dj2=&;SY@aM)qIMSR&6 zhqCt2venm;(gCB5rKMAc`H11LOTJ5Bz3uR?>6WTuF4`^{EAZafz5%IewVUZqf;{nA z)`R_0*%|xLVUPY&lM9kOK|5FNa;k@L8)gES?9A)WdiY+vIyc=32s|M>HRAeg+Trrq zRMz#h2@bzLr_ZFx_19ixJ2|8W2HK^%uw87@sR54ZKChm!(;{29ms(OWlNwHVu!w83 zDP?7|X{@U{w2g~HDkW$rn!kMpMsnr^*tt0E&ap#xra=q!2iZfU zh3;)n?q3|6{q~%;zM_hg`zue%fyTBo9}(v!uH*qeRG+ZVMSo|h70Gc4g%hW*=7QUL za5qaQ0|R4gBB>Z8-NKldJ5o9`X_ zzsXCgS8!22mnEN>3=vS_*8i6}BPh1or4?cmE(2!2;@mpP`JJGa#OV;ZC(i#ZYkG`E z=dDzRA|?C{QAqw)GG&%vc>9W+ zzY%!T0Q9|e(72E%uG(T?_c=pa|4o~^%b>mc9w*0sF5tiOmBCxTd_?@7bJ>$F>0-d@ zLhZ+*#cyRpo$efu>ySkDYN}AMO`G}`??^`zzU#;L^EEP3vTQ^Qn)d^A<{OJtiYTzJD2i=&2mADY%n7_bgquita5^$+yjvYTH8()SOF zp?JgGuzU8=cM{;hU4hw?K!0<-5?w%}5B!;SA=ZPC7~}23k0E&QV}DIQKMy9v`1r(q zXA~C;`}nLf^VP86`9pHaZm^q0^QA=5z#hlBfn;(-5W~SSI*9BSP6a#crqe0p=wNX9 z4)zJ;?H>Smr7NqB?v4p$)O?JhXrdpT(y?@B2|SN1TDrF+wUPZy8kx+df&=!mgDHOD z!3;acNGgRKK?8dnW(D|CkOZ9{QpsbpREO}eKHq1H7TzHGPo2rK16s2Vk*PsJ)~Jr+ zCjoo^oyl|pdb8BXq@WP9Jri`RT3b@6H8;76O2PBa+ByKUVJ3V(EijCuzl&1t)cWl z51NF}*~{6rf9rHRhOG%R^p+S@G;>!rl)9`@ie(VgQeXGVl+P9i=u#Sht zy)?E@f_*=y(lJ1h_WKX*VvoOKk0!B)x!7Zm*t=6D^}l_+JLspNfSb>GN8f%7+1dMO zMBbwKTUSNXs|KR%u{k;-@wAbkiJ6ndQmJH%%l6h%7C zVX>FyC`-_{n+xol*rJO&+n6^$g&f?6j`qdW#;o1bk)Vs5WEWgWbu$3opMDe4Vf$2E z2hDk!lc^jB6%zeb{xONpqof_>h3%*FU}_^EYY}ad1S<+Bf;ADweE_rkdX@MgjM7lY z@{h;h`gs9YcR8Oz1e8#$EC#>E3U`u$iLLQ1G^g1KK=#5I&} z?GUDZ6r;3Uyz}Pqjn(a!M6oijI*N?z&vcEC<`d!?$KdIT=WQ6zm?i{PQ|s(II-H$9FX`KO#OR{N*vc zGz&rvQylW1~#9P}hfwoUphf}*dd~STDOIT)uJoKYzPVxO;L8nrq z-soBXl(0X%y#8yH@{1tfdC?UA2}eRq$CyYLnyAfh{F2~D8~CL*`7!B!=?z24XhI0Y zv;emQtw$TsMzjfSZl+y}Z9~8G8-YD=R@)0O-rj?bkCw}6ELJVwjZ4 z1_0rovp_=4Mb&u#&%DKRTEe^p*djTYfGuM1_gJ34KxHx4nRD=>C zK^`EP8PX}}9TM&CvG;MV3aup|DXddRs~v9vA&4axX0PJnO2S(lY)CDk=2;<`cat&^ z2~Z>=<^g&(<9gTAIx8f5A7^GHXp(!XqVrx7IvrUF(wvC}7^&X5F-o_%BVUdtp)&M;^ol-S(QBwMX7U( zNgHw~Wo+|>9Pq%E3T)3HNFabznY}%fXB?6(iFn0kzDHFd5gaeGpr8#3QqIT{0o}w7 z>NGGp$xfij__EC4;Yd)m(aMrm!2Ed#dTvEzCTJiJuhI8BMKy(BFPy5h+oOgiiq#Th zStl;Z2nj2cOKQwX1QIDVv^_mA(n*m}A|z0HMkJBMniMF2XP(I!jgP5h*T!J8ggO%S zp^AmEuB-x!s34SCm(tH&RWIwql^jdxtnXovk&1|+L`a~FwAvs*QVvNl0X_kPQ3iJH zy8y8|BXxHoaw13s2LoP6s=^dOm0YkwJBq49lG`Ub3fvwd@8g`r_1jtx;$N!T5a5y% z;B^Onv$Zf-@8gzO9>Jdp@QR(mJzVeNUIt4r6%Mflu?`LXFxNNq+6%cizw!vLNHUsU zU<0-Sc{6?l&>f-V>BcS}D$dbt!w0QF^On$_fbW9I-^F=4t}%R_ZdXo#ZF6N@NPzu? z!F|$?TdFI?!~jO)LvBM4&c%5)0=QHkb5px$)sqZKY--}|jS3pvPc0cc&FDk@uqn5( z+C0|otApYI-1Pk~rjGQDg!`P26VA%(erkCK4k4|k?fV)p?IQ{OKY3wLkJi#odW2T& zYgB`F%*skJs8wUXsYGLlLyV8=)UqPX1U59ETN(SZ`49}Q2&G-$jX z0Xv2!=+d-L0zV7Xvk=@0=;XxBr^{2BB%VxW%nRnTk}7nhVv%&JCnVhXbjro@*8U2^ zVouFjyHm7ZS3?-l!fK>4YulzX0vOG_U>mX^35YLHKyP8Zo^(5H@PofGUH4LytKy$Y zyBT6rg)K$j%kbCaR0~D1K0vWms#911o_2qEeZ*tfwQ0Gkz+Lta@C(4zC$yOV?7!Hp zO~e3D`pJKV{G18$R{x{DE&$-o9UVDo6CWA&c0On!LGsc}6AoSrL zq2OTpEY=yu1jXU8&iDNEULy9a|MBjpa@emf~VCke=i)S9g5xe$8M_BGs+`qp}>31v1 zS$O`3&TV)$L+7@2VTg50!PDTxvpzt*%&OeJ%+9%dMh`X)U{>S_@alFKG1t}6VRwk$ zsDbUz(z*ZSIZfWpj=*%z6yY7p5G{t1_-xU()npRn?R9##z1%1yIylOoNzoRPjaPRkqbL*8 z)fTJO9LANlT^ijE=j6jF1WEwllDKU*L$x6_7ss-m{V z&^CNE4!ohf?6gwK7lSV92A(2er^RcS1Z~*G@iM&eanVlkKa7R}euJ3!TJigRYkN!` z;*GMdRb)@W-qdR;Z9L?jSZ~cwuW+3ltXV}eX6@d zgTt0X&|OH@ke_Anw)wbOPfQ!dO;=c38c!R=OA)I-V~sYJ2s`Af?H8^2qt}(`iPCv{ zaW|x-DJ72gQ6+U%?Wl>|OAQ-c>v^Gsj4V$LVcLv`iQ#E!T5f2TpvVWgF@RhbY|OFf zUR}Rp!?|Gm0$Abfa=>gh4MD5(Y{_+Cf`jy zCv>NVoiULAMk7}UkSitTsu>o{_q;iy;6KGR;(Jz6T7KWnw~!_@Q1pbni|1++C1KeQ zgO=)Zl-Mp!+aRIO+YpS`+fek5ZbOsXxD8LFevF@?{F{kbXxgC|SRU9$l8}hGn+D+C zIpcLvuF^356dNpC-2sNm%UJL3^fL4_X~8Nsq3#f+2Is8G$izlNSifKc6IOX@+fj;4 z=@nS{9uJX_H}z8{*U~6qG6QhJ8JUEM(q5(Bun^p05yIm1Bb75Lsglp76}eqx>)^e& z&r`Lt&l20RDx(bUt%+l1`W)xvY*3z-81H1TptC*;FC9AZh8@BOK2pcgoudtDrQBYI zDG@6zIsJtWNw6WkqW5xL5aJ7mI4|OpKi4Y}Z6!O+a>4FAcvA9WRWfoaMjdl7bEF*mvv*&zw;;7a~c+5fFLvLE=jZ`cT#vAW&3s!mvb4 zh+H`uTMUla99|P&ocP$lSNegAqK&2Po!z~NX0m%&H`Z_2IX#@^FR8Mso3`tRalTlt z)|>5ae>lPraACJL^~dw|{(OH^wN?ij2Eq=}6FT>hK8o*#r!+-zOn z+}_f?MTAK0SVggE&n{YwSaE0!7KbMgNn{F@MrSZtYz~*l z7YIdSiBu+6C{=2WR;M=@O=gRgwT-Qvy@R8Zvx}>nyN9Qjw~w!%KN^F@;R!?%nL?$} z8B7+N!{zY>LXlV^mB|%Km0F|K=?zA63rj0&8(TYj2S+Do7gslT4^J;|A74L{s+u}L z0|){`pfETBi9%zrI6Q$!B2%a|I)lk#bGSUdKqwMxYPojf)}4C~{%F|$D*1y*xlLMI z5eNpw)=YHs=9Z@@BIeT|x3d;Ngi)73B}bP=p7dl<9VMydLYy&hqj|lDik2c%(W;ju zh>i{qk8CY$YPX)w_`x{v?Cb*-0?Wl94T9&R7^DH$vp$QiQE%nLV^$ThiKMvJ=|`>VGs0(+abQ(P zf`KTB+FU0yOj~)Ays6x%AxEq@W;{A(F?d_K=(X&Ik!s;-giAL2JgeGf7PVCx zD-WCCsnmjMi-^@{P4ID1(gJ5$mSxBF%-*W4?T|%#tg;y~2BwPuH*RvYTd~rfnHgTR z94qXz=~t9W=|(&w#~8I@b$O8;z+l933_S_5dPES62nMJaiH0I|J3+GmY$X?18Yu)A zASi|toK-&0Y#RWAVmQGeRXxaIOii-fiep*r#^~J=Ka{?eyn1A0{T(?MpMclip16cp zZ1AgLGL=@W`i;5FCLWsJf>=xwXplD_aPr%>z5u*jM_2iiaPt%pKu11P%1@0;bky)j z2eZEE@wFkj`O6fdm@{u2K}prMqPyj+(u}=l;3vQjTP(Ef_vS})1xwqvv-xfNRouDm z4FBuLVEs5Jr%9ReG5-6~R&9i#fmt-1hp}cLFL^54{mbuY*RQX?;n#nE{QdJUFE&)GWSm&f2OA-VP!4HgSQDt! zBmOzaa$j=Neyn|1<~{aC=TE?V!DZu99)c>iP)e+md=o9{eB6fRYxc>d<@n({*!JHM z*nMYNx}#?iKXp%T1p0dj=>H&JwjT^u)8sSd8Q%mc{t)l3Qp9f#@77Wq<|*b6_5QZA J`~QD_kKOs6|ET}~ literal 0 HcmV?d00001 diff --git a/website/app/_fonts/inter-900.woff2 b/website/app/_fonts/inter-900.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..566eb1bd187840390edba2eee4f340172ea3b9b1 GIT binary patch literal 21960 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 literal 0 HcmV?d00001 diff --git a/website/app/_fonts/jetbrains-mono-500.woff2 b/website/app/_fonts/jetbrains-mono-500.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..f2ca96c3ae970071d6b9eca0869fa5b9fa692209 GIT binary patch literal 21820 zcmV)SK(fDgPew8T0RR91098By5C8xG0OA+`094rk0RR9100000000000000000000 z0000QffO5tP#l4pat2^OQ&d4zOaO%{5eN!_++cyLa0`YQ00A}vBm;~d1Rw>49tWcq z3bmnJMd48HBmRz}dJSWkt1QB{M9f)vl4e8GH(HzSyBUFEDl z@fV4)*E}C@rsA^A_^?5^QPCjr$E&8}zdVU;Cw9s!2q)tLpD-&XJpz9pfR?QcA70)KJ4X)Yv~pjl)vI zc&uYs4{<%zAL1K_dklagBiwqh`tTBitO|*Zp^qYD5S@gYkF8@#$dw&m(=)|Ic14|QF z01m@uk|4OocW7O)spJp!UHaF&=E*A@xEc)U85l@=TCZ>H7hPOw-rld*S#?W>s(z9^ z1la*lbeCPW^g-i4Alb{{l@d9%Q}ZkkUrLo@{{w(FHkr&Ur2#HM>y#I_waf5VI-`0- zmJBOGlZY)@ve2-2{7gaq;YTq>(FfrN@z)0-aQIV9?oTpIp?Uwirax~kY26(UkX1=j zG6rEt*#hwx6b;+9Hyf;bqZ_N{UsZGcpPxK|(j_6>s&usu3w2d_xhbWs$uPE$k58d| zzyynIP*f%5GblGwa-pmgf)M=(ioVFY6xk`gftIgyI<2&+l(OgMv|(6VAJ#E@{3lh* zcIM;5`@q?8k~17)mCCCVoO7Ft?hiOvQUY-NtC`i(adp8d)@ixCEIN={XE|TiHKp5Y z3;U(%FOYRUVwK9m$IuoYY2}5JggkDEu}o>-?ho>PiUzu0wNRG8|J&YX-$|I{4aH+u zIhqw)+U)-~u-k4ki-90-%064yBj2#t#4q=cSyi_uuN6G7`c|8+f!ps{L@a2kli!%N-RlGwT|F#DH z9kZ03;*sw|D7FF+=4zZSmm*Rs5aB?{1&nOk@VSRZ9G2{6(zXM9~`JR=#(06ao%}xQy(GwvpkPzC->|k=}Hxt3-^b-K^mB!f3 z!l21f2%;f6%0LRFL0XiJa!?T}K`3&e3x#%50azQBK~sx06~JIvbi5V-6$Oc>1H$sk zir@Wt2_S~VJ~2TCE4Gs)g!x9ChakQ_7fo+|c|^FlqLF~+y7C0>*k%^M-+o>Y2tQZ8 zd~}4g0FlssEKLv{o_6@4qZZ`%^KZC7_+I+|u{alS9<%cfNMTghtQzQb=$wpGl?wn+ zN&nGJlYu1*R-|aL;^7gI(IiWehJ#N;BAt?&j!~v;R=M&Ov2#MXRr2zy)u72N?dF)L zkpG$`KYL9^#{HbQ}qg5Q&f&NziF@2FZ~EDN#NuKq{n08l**qNQa7$ z9u*@4DnUkMLS|Ho%1}A7AS1p0bF%>dv`V*T6E; zvNPdHvN$F7y1isq+UD>e!tuO{PdJX&Evz?`DcC=39a;|c2Z@07$k@JrZ`$kj`MLAE zk)3|0T6nz?#}nJxt!L}Zx2=6^<0WKytm?Vd+%*$6%S_!EHYSV+XV+xsJ+-Kh>HQ(q z4XDQWeMeyk5nxG%38JDY;R0$sqmaR;fX9@CQZQ6IVp1XcS_? zq+k-1S%ZAH(!{H>1nfaBpfqthPLUbAv)7YZnaN;nJ$b)M7W;5=6FSt%X6~)~cl*{b zMTdr0VCfD?8B23~mN4WQD`RTZWRzvl=o>xQy2o0)$cz^?sGICTyAP}(it-LEZkDb*5aiydI z0m-}>O%UGc?SKF@e}YwOBlcXHslw>=WGLys=DOo!3n?7V4Ito^0`x9P{R)Unv3rmQ zup--*4d8dOXePlTRt5!J+%)O!7T|Xo-kp5E2)^xX z&jy6W*Cu|8m5_t;0LiiZdO*Bw2aY=d+?id4kB=d60LZ%GLyT*s@lTHGrJ*|sN`OV4Q zG~hs2e8*@#DEK>gj=mBE4*B^W6R`2u5pRI|E z*?(?lc|ljJt@UO6$dF^yMfv$pmjW?uRx|46UZ>~bDO%ksaPi*_Ix)?<%40r_mAz6` zgFnU8uT@z^t4IW@Da3hBvxQ_b~_<@nD!=^i%ft1sR6)8&^KENUElP z7-K)VM=v_==JS9$A3aB(9_uD@pNv&mf7An00XlbK(FK#$w|jg62((?Vhzx8^?K1v4 zLTNRniqDD&!~o57lav~a8-C(*gGE9oT1Di=l5UMjRLEjqU6&qUlU-N{5`3qJ{@(BXylGtsxJcud0QGweaRH2g+jh(AFGR zyKz1pY=x=#Y*iu_38N8>f^$t>gsqoRu zoB85w!w~AVI$mlzileb3o^nk?ah#hSD69F1I(!^=HKjzPI-pO%G1chsa_b)84W2N^ zsk5#t>q^?8jpw?A0orRjpJ9wD@(w2pJYI`QkvJ8riEFNGYz;>)0b^XWU{oQ>9|3`+Wftj^l3q2d*NOpF z`=IaU0ag`MG^v8%e$IzM5>^%8GMS&lJ>6Z=o5KDJQ5Av)1WtEql zwNYglBbTh=)Ow7RCqZ3JtjBmh6$!nMjwQgj@U5j3 zKxiYv5D}EAL+D1t5wv36AV%dkg;38&8F64H0cWL$LKaX`uLk+Cw^i0(i&I%GGrS8T zCOgwjNLdqjp*j#h79tMyEd(6rwG*p#Z-p^>$Sgd&`s-U9nY>b6Vt>gi1*o=jXL=G* zm4GL3elKlME4|;&8@PKf@EeLQv>S7$XRy&dt8bMU?&-E);=$^zYJCyl21Uuw0j*FC zz-@9qot9d4W}w{RC0wgmy_h=d-av!5yxF42V;m+g z;6$Z0?Qvvs?$F@vv=zOk5gHW03Djx16Ce_&Fw!FDG!mgFe}yqR$poBoO>?)nGq-x9 z*OaLhIBl$f9LbV8Tu)ITBd#1MpupxF1aEIJLeh0;FCii&lrFJ7tyLDPjbx(`60^9s zTmKMzAj)1O+KKI%fcILqRv3ew7r}cZd}$S$-P!jv8k~iND`ZK;JHoatZ_IxE3A-?z zkz0D$nQ4TYHlguYrDN*A!FZyoq}#sKckE>qc)P^Kw!_m8+5sK!-HP#fRAF4Z1u(S< zzbgkKwi(CX=q{x>y2N9X9)4^bCmS1>k;FC5*)1mEKNQu7>62uB0{+#a)L~p32bhA9 z^lKE`Va1Z!PSdH;tX9n3QQhRnrb_KHrl2>-6P2wij5MbCLIp~7Ic6kk=UCLy@(fRH zhF0K$L<0pPzQ*-TQM@uX#5Na^#ml*=1RrkkON3F4H5Nst@QyBVoagI#F3Jnwb%J=k1qam_`g_+o=AFZ$uxC!u@-o*?rg%#EuEcsdQ46_Guq z{(i8BXMj*894;b|p+*K|pudsjWK_Ta{iPW_1&>7hClFiQGBUwFZ9Q_&a`A&J{06h1 z&49^Cpqqf-k@^uR;wl2r{pvuFYXP)sz^;f44Z0OT-v0_Vmx42uVZK5bz_@s5c+Y{w z1H?gK6*JuG5DrT>0Si}d9J42me**5MWT58{u!_VXsgA26@*%FLITfu_{8s}%4iV>V z(H$gO@2&7194Phfxw9GSWy>1_7QD;wm$klXIOv_>r(Y2-k482g{AxtC;qq*4Xu0!> z>I?@`w+v&|K4rfF$-xa@l=l7lVy zaUrb8#UfXl*lut1Xk)<-IVX>e&+P8rIfLbvRMJQ`67zcw=p zXS$24hT%8MVDk6KEy4zHq*2Re)cwa8|@P@^Qwi**~836^aXJShx z;F1P)8OAj(z!VJ6EiG}y1`oZBhS1}tR(w=Ko_*v#*jxFYJiD&AU}+}D8I^Qi{cVLY zmXOcG-H{e&L&fB-(hzD$w89FJ8$8w_;YOTRLucVPd98xa!lhIzQD-sK>$av5+jAx% zqE0p3B2m&9pwsYk;3}M&q*LnCGmLSSdIpaqw*-3sRYkI1VpmlmFP$4c2yymJdZPkL636sBth?mR zXD;1secXrs0nzJpCGCeZ?R6pVhiIvF_R-chw1y+ltp~MkYg--b3?dlvhR700Oi6p; zn6j|T0PRsn<1ofJc^JaHbSsfbn4rO;8;w!7-FU^Pv67NKnAPgENSr zFLoAK8lYXU&7R-mo#5AnBP#~*%YsDY5r2m(wRU{jiC<<3KInGkv;)VO-|2Y=_&VL? zSC@XU>DEpRNB7?GtjTwxz4+32yjWU(X*eqC++o17B`V>-yTP9e2X4-p)BWeO;dyhI zKLSQTQj#;y3Xp@TUq&5`7oDHhddjcZXoEm=BIT`{n#174>@g< z`gEtRFvewaAGB%ZsV$C7+ORG$@Ahgd)9y|1wK;fQt$8&st-*G-(j0|@*6Mb&0%aMm z0Z%{Fbcje?QkSJEbUkJMc@$E?-<*ZB9V2t28lctK7I`krJ>UXDSkY?H#5GU4V*X?3 zZ~)HW)Mn~}wdB=0?gA%KwJM`grJ8&G5{(DCZmYVh&tDmaRUb8MhT14RI>YkSMy=)R zJ7-g@Mg`7t(MM|KR~Vy({1Gg@8Utd|2nkQX#>HFOIiRhgDv$DQFqYf-Em;fR?K% zav0+zc^Oo<$`O5Qy$;aY4!SU_VyjCmQ(AFU0cFc#Nj}V?7^*;=j+TO!zz9KQI=Qf4 zyxwoW|CtP6N=jS}K9Y0XKv@t}X2gw~cJZ@|)<(JDLJ2tO9xa5C$Yqzs#aj(k@NkQ`L=Ccm%(zz(E*wZE$V3>&&4vNbWKI%rC)c` zl~_Dot46Rab7Bs4Ky!GJ!5vsCYat~~wxJchG_e#}?tG~_F9WH+FU;T>AZ7#gc?Pp6 zl9-ZaLrr96k^yQ{b-`hb5ppY-pmZydiD{<6qE+KFJK>q;$c#w>eT%uZjanQuuPV`e zAk#?jtBJ|Q2*;N!z#K8!L(u!eReln7bAR(_aShQcveI{K`I#yH!zY#!NB2=9lu^+B z>sDDDQ>4USaS_%N;_tU%Qr$-AP)U&o2-6(X$ z1-)1=%<1MktIxh&NKZ*MDw{HlF^^mgI(WF?Ga}K=y2J~b)^Qwhu>lvL4Njzy57LSk zcGw46qQa`X7BnKz{7cu&Ow)B+m~1JcT~U$Yhv`g%TXjNvQDyCA16Nht_=_BrJaZB5 z7A!A@J|;@fgR8Fv2}eQ|5cjjdMSwgiu4fn{kSoAxl;6;|$U%TM?_e37pFUfc7(b(R z*Co>5BtkESz(x;wic^paq7{6tv}j5yS5Z%R>aE%el^u!-2_E%i7g%5B6^ux%OTS9+ zxm@>J=Aqi(TGjx8mEA)L$sP~_VF(n<5iNwM&~3pJZbtC5eN4u)iiq?S)AKO7kR~VP zOAQb3)Kjk)lT$d12PhB^AO6FM#;QPi#C@nE1 zm4fffPb>zgMEMR2ezVKP*u=#xBhzMq28&i$Z$|m~vju~jT^YRsJ}JS_BX5dY4Tzo=YTU4`7lp&b9U^Q<2Rh0FM|n_i z-qt$y>L{=0WQIp-GvlAX4+yaCa2Q$G4Bw;e(q^2mLNUb#D^EjntydcMt_iI_xmf4E z8Cykg2oB|d?Ezb&*j~*dOUzQ-1H$6k(pW?jwl?G?^D$>6{P}4}mWfBDt5QFt-zA<> zSf5rqtTk_>`2_&1Jc_wA1)0G@#w;43EPdLr;4wX$vCOEQ8OMx|-0@7fFS6IS9Fhgj0KN(a|Uoq+_Gcurop0B{<#$% z24^rr{uq{Vl?F(LmC550mICJq-%1Yyp{31f8RZB|%DN$Noc>KJcjyN;?m$OkEEhc} zSiFS=`*p@Bnc;%}86awjGo=PKDUz6ygjhN<6=8rNU6xt!RFDOrn#3(56IDxtMJxPQ zsvO#aziEKHY%^7$0pfvK^OTsmptwR<(dtf;EBAjT7ugQZFoQO0kV9gLDTxiHCX?L^ z5K9@W!x$+t6J(uqE0Kw;qQRooDi@&*mq!MWd5#211-T--7;Wq}5Zu4h})hi(O5AMm1&cHmjbyh@-5 z(D7=juk>+Fm6}CO3-bYDkq#?*SQvQ2;xCTBh&++o-O!}2i6|RWv2UD*Wi-uSL4LTlz^{+l5cvng4JPc*OMrl;`6=Up#O)iR|gNb?2!^#aJq12*E2 zZf{f4VI+){ibPWqbJ|tEhN6|0bm%aiyWk^b2Er9bMy3D_(Q@^AaE3MKx*yC2_p=~6 z!pueyp!AnXNqK!tgASp+MJ8Ta{7By4K!WZ3;=<^BWLLfmPt>~B)B)b2a2VoPBO%C$ zihU@E6DVM)$~v{nEw+Tba2^ZKuL%RlD$kBIfUj2>052?`MQ9&RS^$F#007_x7XWx2 z06G9Dz5;*=hx z5%2{$w_722=J)Ui03!h4gJB!IzY+Ou(7s;@@ZJzx4FI@vF6NEhiMeCySVZje`%mw` zd%t<*ua&=|o$7}GXaTTz007WkpLlf3-HmQ0;A*#Y_`PeANk@lZ82{J$u}%-P3b`Vv zO?wa=1o*=MgyE3GGI2$U5)CH-9zi0aWGPamVPfGEfC!0g^;_4I2I16@fT?KcWHQT= zEnlG`#Y)(@c~q+6<5#aiqb9QqglkI(_`BcOk@64U=V%V2x59$=d>s=0Et%-uWCcT$ z%XvFA#DqZH7OOYp*C;Li6%9tBOOq}G(-GmOn;~9|SaGI0fr^BTj^dq@c-T0&L>Rm> zNXaOqlT%VN(K9g0V_{{ZRSMv{kUAEx}4sIXFVwO28W!+Y>YNs0P z8D}||2DkJNuoLa2Pjpx2u5RhXsf6QkMK3&_?3_B8cwFR241bENe#}p9pZ!;hrrFWGzb=&D)F_IJ7o)f52UqwmFi=IbLoB5fG+MPfHH zzh85w7>{rC*n9ejIk!yNTiyYx6KH(jAF!5NY=R4Yg#3iX3n#wtin%t3h*U`!eUs~G zAoB4rXHC8}YC;fA>g4IWyonSF%>v=eqATWC#D>)ff&wQeWx&B+V`4Sqr)3)bubIC6 zr`K&7eunt!kQ3>E5^CyOejwxptY^MCI}Zfdz?v9Q4(19Hx}XsoAqn9S4>;5CCH8RW zK*+%l=D*;FY^-*SS_oqR9cYV_Hu^(;3;)+bSdnf_y9O_B@)X2#HU|CT6Snx``tb@7 zpOttbPOPF{YpU-*b;lt^P!7dpKnE$T+aqcV3xlwDdGJvXUp%leK{zBSfbuXTa)?~8 zlZCugo^hHf~*LW9uWZ(?ZENX{XZ? zO;1l>hNjK0vgJLwmyp5oGKYh5A}l}-Q$6D$*$#;3OHFX-bdM#Zhzy^inQapocb`eu zIE)(zqOdt;Vge1Q21wi*Zsp^~pa^su(?{}bOw-ZLVRDx)q96?M17pK=tel7PCT?(8 zeay2q5*@H9yVQ+4zx*n%%KBJx3SGf2t-{ryM?-!vR;^r2Gaj+lKvM9fyb@0UaEJE5 z2LcXhZ%DdLR4)PV(ichSYYd*Se4GQ?!H78qOx95$Fj^jhw8kD2GB(E_r7aT5qeBgJ z72_dJNkxW(SK3MH8Us)JqIIeBz@!^aAb{%qbTwg$ggQc_}6km|SCv9ytw{RIjz1uHeV%&IY- zEPP?GOeURjfHu`5Jd+@{t-CQzSPGWAws#Q@^?0K6WLQEU?nQ26H} z&`d-?&1A69>bBZfAGA3zsvZDR#sQnXr$koe{HML^0QTB^sz3GkI&)i8T+)0^8T9ff zh-#`RVurQoAetP8$Dct~znu6FxZIB53#ggGoM^Ad=t|_u*5jd#h&lGL0%5=dIKmFH zjT$D6{e^vslq-Y`DGY<6B=cvxoAhC%$p^Iw+-TZ1SA_>qp)PsztV(UHDMLkytO3_! zkLE?L&pj!_DQ)Y>du!hwursuWTKp!{a5Y67jAq{Q~Z8PM6kWtoGi)~}}w*uL1 z{}e5&Pg{10QueBD%FW&S$FgV79gH0E%re98H1L2+-W;=z06NFWOezqARg4ZL36Ku+ z!VrV;`z&mso-G`}-cT5K$1U(jHXv6U0BJrA)&z9C*uq_h+lotxV%;uPwbD=<1trgH zHvzx{!Fl-viJC|f6foOtbBOk63zckiA*66^L+aF*ahB0T3iJ&Z)c%ep)|ou}?HYq3 z6+C;*4lA^tc3EePN^}+-Rp~bJ8;uMt=UPCZHNF$Y-hITGv75<_ICo?B+-|JC?p65t zvF1e#$`!XXI|wd1tX9{`$JUu%bR}zL#w+^;Q3y}5!XmBwM6z6>yTV52%0S?lz%IfUAU zH3%etRZ^E7J54)zM#+(naz0l{@1u!B6v(n17~e1!N(XnRZde1Y0t#a`wDuSVfw)1A zAk`<3D=9&|z8hGH#13dN<*ZK$+OKfcM=6V+;7QedEtukI!MO+Q-M-i#b%(KrNuFS# zwrah_B+{=mYVO^#ZPXux==V$f=4P@XzBtI95u3})S1U8;Lm8#G98ypS!)|8jDf5)j zCI=Pvh=8exT0dFmJA8{9H*K-MYOixbO|7d@Q_~7HxxdEM>U3MZ6e>2i9?%1;!~kL| zQ&9&k$1o))?VhAD@)K&(GJLbI;?r5@keZkuQ1yYaE#bz^H3!_4Q9{WSNb;sRJ31VS z)1FCSijv*w{N!_#{_rcqtDh2a^&YlXFNFJYGQwllF=Hat9;us53k{{=z8o%JvyxZ+ zs{@hL(Mm7kV!DG`?qkBK6`*jyMK_W)5(p+Wr}!bgJJyW`KtjQXHjqG_DXPj*I-_>l z5X%DNisX@qUFe*js#2;iX!CNW->9NkVYJSZba*rtW@x6I5s9H3!zOi%Vr^Gf3oT4} zQmJ6$CRfWSRp}s4Q*zdC7Be5=ZF8-}?OnUtZpSm!nybd@n|spvu-nu~g$HpSN#TyNQyfPpp(vb+rGy-GwDk^(0#TI1JAw|5k% z!2yABhxId%fUv(V&cI2*Ns4q>hpgfL*c{;)?MN&IIqg3z?!YYi%`LWMQM%uX8wr^^ zJwbXR6p}jY+b)m3wEln21P+5gORn^8^0s`O*Q*w*fh8D-q7xDag4|p@vT`?#GY_!j zjnK4?KZOAR5)t_3Ir= zv+jD3I|K4k-Poid&)OP`#E6@NyU0+gVmwPqOCBtjk__!sm&`ZH*H2R4dF#VMXA9)s znQ;D@7IKEGdEFEFfh<~h!Y$b)4i!#`>1&GXQ5`^g=6~cA65V21y*vw=sLLohAKohM zb1a^g{Ek0K$Nh&ZF0Sl~z2FUJ6j!G;(l4VDh1I$|FGTlRXfTLRfbok zikB8aE@RZpAs-y1+}`4#jFRH87*MQKxx1Sq05ruks5U%CN4aq`5c_`nZa*aP&|Rd? z&?AIt)4TjB;_RJ=2|lH`|J^h$?qrh>xDKbjDGv7L)NYhfM zXj!^(H4LTbLJ9;A*SX7;w3ks1;dJSu%sS4~)C4;5MJB+x)}{=IbH)|bx}M!>;UeG! zW2bmpfU?eh+?othpp2W(D(-BFD+?#1hwG?1S(Qdz(;AbWQgtrGx$TcVL`V4urRPk# zMK?oPYM6!+#7b~rpBM$xhE{f9O0CnA>JnVS)*i|0bh99%l_WT_LYD_oskun$)}J<9dWhxKmPgW@8%a2--AFMXU(pk#_W zuWdYWugzXgS4lc#^()>pK8E=iHQhSx)Ln(2rRG1;^x+l{d=B;Vn)8E}@qLJ^x}T4} z+i>{CE#{3Vq32&nfzEh(&S`UP+;z4)WIFLN+c?Zeo?_HdvS(~`{!nj$Z3mD1Bt*&ISvCf# zUyt$X|EJ9XaZ@Mbxn=f-AB_e(!%%OX*)i6uL@FJ#GnVbLHiCKD;;+oT$G)jk(J3{h zQ0>rUW4BvILki6J^md=566bwpbYSwT)`Xpo3k~#!g`(|Rj5;v$qi4107EA2f;75B6 zx|kP{jHk1xUgwZDc5Dh!#pJ6s_x9EODvoI4`MOP6N3ZBA1!9*f9u}8Z3Um&{DHWz9Uc7vrOPv5? z%!$dlX3YOZV;|k=gs&$$5+B1(Q1H{ivg}bo8Bat%xVqy}p=Ful^==ySb4Pt?Kc@NC z`E)<(0P0*Fx7ykJ>J6q~e6w3ByfTGSBB3uK>9#>v^rVXRW?&ojv5;P7^N!Nt*{vIN z4n6RX`dTtvKsmZt_vgS{S^DEH^`DDJ-EWu^>oJX(D#(j^{HO-w4sHl)mrB{r^rn6aY@^S#9=;HTl_FOKU%QaFaG_AC6pEAz;X+^=_4RzL zLfQP{bbE`x{Ntf1%Y;91d4EC6z@}mEH~)}B!K!jTCU2Ij^EH5bXvCcvKZ_<)3Zzze+ip;Gs%S0s= z9Pp;)blJ&g^hsT53MySA@? zycI6+8k2_>WSTt%CH>sgI&SK|tf~-wbAB;ehvv?zET4gu7Hb$5u|b@sTbDeAp4ACs@^c0fhvH)A9Zvz+sNPEapdSu;GD1`? zF5jW(RPyaWj4R+9oeB%%YYf|=Bce|6VMuUU+$p*O-tG0G`@COa4OFKtfA<8t@F~{PnF+GLc_=!?Q9fnIu? z1s6;NY%C#qv!eHpd(2WzNXMbufQW+$A35bC2oy zj)Cki?^WIF-K@&idQ=5sI^`f9SaFSho{@3DS5ECLPpm3BjpEx{;!H%ApcxJCAG=GXkr!%1zgqr6z zPg3}xt28QeD-mDXqzfyVoM+N!x%1t?GCf1!K)6b{ECsSkISf;F^1NvS`Kc_MLN_2CG=sjD@DSpVyH^A3LCLX z$gL{Kr_}S^l1(#+of60m7$@TQVA`GQyrV+;sMoo!eNX7|xb~{;-d}{mC$qiV!H!|` zjVnPd^?cV&1^cGU%Wnv~4Qy_@BhmSZJH;V%g6eb7gjEs$#K*i!ljQ!z7JP^w)=f;c z(`g$y4u&IoO+>`+vE=!(1i&~7DP$}yTOolUg@mnTDP&IE^muLjblme6z`&@> zCL$={)EX!-6_8U{suH0Ru4QP^g&u~&pKzoE91*Agq$iL-zVj6BR9Dkyt;^arIzg`Y z?jL+s9z%GYFIFnWz5rN+R`4~ji_0&SE0nX1#_gYFpn!-`mvaU73?KAFDEf^r5dJJO zyT9C$=;(A`I1=Y+t#d*dkloD%U47cE@tbEc4$^+x;oT0BFpehYq#Uny16MQ%X@#GN z$gCsYi9MYtE#MyRB2&*#FGPJVEz(p6QZPENMB>v55`A}f<7eH-Qc9KhBuPHREK_SF zm6evw%#_PXm%jhL2VVs>e`vhde(=1l0}PC&oCy_3l-g1Q(Zi}gMonGK;cQQ(9Lw=A z&18*TQPfuHDHSTjEE+?JdO3QcwlD+YSsiIgr6Y}Nh2)xi1KG#PNmfwV3JKVaeUEaL zR^q7=YpLt->iCKNSp%y``SZ4}SF;G;mY<7g-L47^*wS0{X^XinioA5@%oegaKxxTT zYO*pF-UHzMMu#>G#IQw3_$g(9_tJ@exJoYc@Qjo2ztLa(Ul zR}J-x8)mK8KG`k$ySI``fE}Zye0d}5HE@Uqdf~ymjveMD2g56J)!$w&;U3+;M;3i7 z(TbSrs5l%hEGjxI9xpYD*9(3Td2+AQTww@M7qC~b(NaJAZ@rj8n(h(Bz3NeC&vmy8s6Z2af9QRvH z?Dca0>uX}2KP>_ie~X+oziob-<3GT0aQZ`!$IpnzadF`3@Y2Wzb7aF(EkaNx;1q-? zf;=HXNdOnW2S?>%rHsdeW#VP!i}fC!$f1<5it~6|Q2EUS1pb6CssxbzrLK}IDwT^_ zh54Lrv@GDLLZlRNxk{0EdHLcz0*p(@#z*6`6L3~MU_2M7)dEPZ5B*u#(g8J;)*+vl^Ymv#7Cl=CK^&v9*Om(qv-Rm=o3j*dH}vuvWyu)=ytu z(+^sD$Xl-T?m-M2S5(`hJ--&3SxtsLx+cHS^ed=u@qGXjuiInDH~;E!L@%#SGLb#K z=^rd`#V@kaQ%pR}ri2nkaWP_UzR?mg;#>YQ&LlKN+s3<0!c z0C4+x;I*x~AMJ1O!nt60zI;+GT0`>iz+z&IUyH6Whwjhq%?H#yMPA7<7(UryiiI11 z7UHq7Ptl{t$KIJ40+=(vy^n=a^yeUxuO6x+<-Bo)BTNmEbsC)#Hj|qW@0T1IOkrur zG-Zn1A;nwQ-C-OY1Zuz7MrWAFm`$lZu_U}{A_lf&=AccKP(f!N3omHsT<#Y zGCp?q@a!9@v+j;3T_MgUDT1glWQf@Hqz#F3L{hxt==dZ<85}elQw$4x0OonF%UCCgP_uF2ZTZZ&=V9e-)jdw7na~yE?b{MyoHoKGri*lJ z?6!mwp#`;=5^g=O#WwGH@`1X#pxtt#0JHs{y+`MIn#_dEWPC2hnbaf!Kf?GY<`~ob zE5C7O#msA)f0?q+LLcBx8I9k>;|W_0#oKVdPDC-4F5Ijf4~Jz)Eicnl%a@{XI?J9X z*lI5eI3&zaWr#%vxl(0Fhor&8vo*$!#_qFz1fQLdV8KUEZkG@KCZzgN=zPqMZs^q6 zhr~HK-C9y{#YN@&90rhf*`RW8VL%{Nkj_7>+SfM=gp z5Xc(>SqIQ5w*SBC5vB+~F7=GCS;#*+_44DXH{aOBb@O)*zbL%L-=nxyY$Nz(#|PzN zzdhy(j%%3>1?Fpx;@ya@bTdLmZoEWBko3TSnL#7bodKj^9zd+~UQq@hWm~g-b?lkT z@CM0uil)@<9q}Z8B&_P*QboW1)4R{Xz-FWc;nc=oN<);yJDvCb%L99Ta<{BtAkj3+gDd8MUzyx(uLefY6H3Ly}J|H;jj%k%U7nfUZ~R_jwo z`|o)CzmL2FH6-UhWb!{w5~(&AUya~eFm2^RN3g#NBZ2N|&zz85oV5FGQ3C!^qhUZv+7r zIxF~UrHd^pGW$6H@`Vbyh%jInu+t|IK_r)pcyb}fXBHQsY+!7jOCZN47e9YzsX{@n zD@_ffX@Y!Q&E%V0{@yga;nr^v3KoN^n)_8fdM&xcTLL7ZwmY`p`qbr7>dvW6dQ*D% zAbRj{)oU|gYr8_Zc*)|W3dPbT%a$vZ%a<1htTe9;xN!C^`}fxf@&p`Ah?%@CC72@a5@6WyqC>KyUqev3uuY zSNW^PJxNJt7FE0gmf=yaU1$`FiiLKs9re{9%>Ojtzg3_b0q+zL>&~C2Q7%;t1^i#2%@SyU;f zCdELZ7*m?k^xzRD&rxdiOQq)azCRO8RCcP~N*7A<7|cw_6Q9?Bi$yuHF_n__H`5z@ z{tI=RY6V7ZZgDYTG5e!4YnI^s^ef>1;lFY{rKapmPf2b;XKrUfC&2YfN+=@L+}e)S zbUc40dFG#gmdwyi&Ci#JbeY?OcOUzhWAYWa@VT{F7-eli76s3Si1>?NmST<5a`R+j zo#{^ScQ1}}&Gp37wkVvxb8=K;%c8*Rr=?GdfpYtvm_HzbxuqF}VF+-G|9K ziXN?w+jB4wCFV-ewP<+6a9dP_ck zZ}B0+>yh<6sEzevq~m*Mdk66TGxdGPuhvAc?sPN^i+((04F^DH!&V>pyYp>HUv%FP z%_(5j?HKVSek2N;-yIwK{M{s|yPG_X!eZaP)4W;y%Lm)gx}cR~?0Q9nD)#%N` z!XJ{KGLnO1mxUt=fL9A>0)ENLk^yn^mj~wo?yUK$ff?)|I@|NQ08-M$QKMwu6XZo0 zF6ItQN9rC2YLV$sxOfSKtmD@rkn;*(u_jqr$V5mtnG=!hyL(;V`O~Y(bZ_oOMoPOm zNL}T2-(0i~xmnW9LmIg(m_Xdq9Q@96-qEo;=%r(0pn6Vvr~4rC+>e8JxCOf2e8=%I zV6E}|*dLkO6%v{o`Qs;UU>1#jm94IZO&{N96abOv63OJrWfUyS{X&g`X?Mn@Qa6j-mS# z#?a~LTJ$Jd^jxGXm&Ka3d23ky-KwJG7pNXHdS8gTUTk->kBGSc&aV1#?F?!sgIRy7 zaQv?A7n`ZP-c;p#2vYsrMYuH2NHDKHW%ZIpjoP* zUX)W|a>rmU6vS}7K9=M!0{xwBzBce~<-#ph(f8?pQ}$!%kV~P`RP#)^eiCqS|ZrHZjH%=9~ zt27UOT9M%5tTkg(y**mnRRh6cYHOn&8Fg7y%_HVt^Gyd=wd0qZZ$VGa9U#yiqlWCh zi?ab=E6^9qjhNI?EE2u}qb5a26NSy;K%^DxHy8g$Red3~{;n;%OAz+fwC?h=>D8{gxIns9d?_dAVyxnG;r5unaYk`y-on{sxxcDd=lGn^don zQt6a6*)P`wg*0MZjBtyfU9XkW=D<#d)6Mh4q%KE^Aq}6upVBsL$f}d(?bQW%Kcj&hTDNb+!}usBZ2r8kn1erJ?=0jk ztUtMd!h^lOMxy*?{py{7U7&D;F}TCj5ud=tOC9nQno>(WvY5+eTW^zoxe3j_PaNS%a(=6Idl<=l_PHtsZlFoIA}4g z5$5JKK`~^?#+Y$X9F|U@zL|D3aeLX+XRL2utKcprBq%Tm8?hpW>PVIf(<5rlfe7pu zizkIpT70V)I8OxKuG7k>!r;M(s~Mw%9mGE zjVnLm{I&@pWGSXNspQ40H4?jj_3L9~YBUcS`^CJ$qg_TYS%Olh$-!!yxNU^<#lW}z zn!MC=sm#85!cOF2!|nMyqoR&>x7?0>c|?&b&6tgg2r5G27di1?U?PD97>h`~H=~!d zG0pY@!lH(iv%D+8rA3u18*5jBnnlZ3mf5T}8>0v?N<=C-pWnJ@v6jb!c$`D=0C(M> zZZW~y-xxk6V0yfkz|=5;?KSCI^qw z@2_^oX|l06OKBrtrqC{vZOZGR0mgqBaPa}bfcS?wn#6eP4RI;ozI)T|6MnuC7J<*y zoS|ur9C3NwYBYy9&I0i zWEmbE9$lSiQ%yV;P7Ei86T^w&tZ-I1E1Xp=1d`0CBt5!R1%YV?J2Vrg!xaYNWJ zCj}qa!JM76|e}QReu744*mAm*hPq)ItG4SvU2R(a>cB^x?69{Aw z?XRFf^5O3ljeiZ?gZpB{|4bcyZ?!>o^ct#0uOkO~1G&(9s2*KJ9@Ky?0q?IhXXnIR^ZaKQUIp`yu96-YTXQ@@ffti*>;u! zRL+k6M)W4Yi!nU?-eFwHqT5MnCwvj6+gZwv;{e_O)d!;yS*XQA;2h6phKw?ZvOqk_ z%kESp*kkssoNXEM70^sm`~PWQYO&bIX7N^zQ5LtStyM|!21O1pgpn^pLoD&&wzWD) zM_Mxe_g*gAG&s{>o%_w@vz89m@>Skz^!Qm$^YLiLZ9mE9r0NEUBi$Htl30VM1fnFI4ztOKahL2fc z&;30wj{j`i{e4OI-P=cl?e|kehVAnIUd8u|fb42~yU?oQ`*sTKN_=}Jm+y^lshHFb zHrPpj_pgr+ti11{)GR*UAK#s3d@JYeVQ2iK6MAXg99+@wN5I-ypubZtr~jXna2{ zZMwM5H)$6(>Tfr6|39%58Na&CekNOjkH77`8_mC{Mv1S~vPvxp({6#VSl7{_E>&&6 zt^C5vDeK|y7qMRq^F2h$F3Z(l!Ji8azBp2p$SdSSOP)O$}| z_ufybd+XgJ6^YfxeW~dB?NlGaS!`lXhH|$%HWk1bEeHF_;-(yz|&X0hrYALsZjigz%~4lCNB)3LcFz zQOZ`XiJt*HI+W@mxoQm(-%>TIo9x3&shv47Cgz!QsaKgwqm;BxRq|P>d3b3mFlEs& zC6jLMR8yFz)W4ugGZDu^&t+qO%}E$2kG+x(T9gVs?LLV575n8*tfRnVd#zUW{2{b3 z)*+S1W>{VUs#LamD^BA1_FYeMfCk~< z=w&nPKR=G>1^|(ZVfaF&ozB~(-D9ZA(ELfF+y!^sbxJaZ6j%J&tS>KE@cOg4aeV75 z7u;~wCk8#~3FV~>8jbi1yB|G69EDPUdzMPj`|4Gx<~QIu8a1^3qF2igz3wsrH*9iK zt!6C|@Q%#L3n08dbIfAVYPNQ5ws_xeFL}x=SDxiwR$z{Ke(BPs60Yj8QLosj+mQ<~ z(2E%4;^9j3f0pTp4r6xwLsFJ$j>Fr5be+&ve{|VlG#nXaAO;-5WaX4jF1$l9y5$dU=r`^&X&7Om!-$145j$DoE9?4KSi4o@JG$P_A#&S0|G94?P95Q@YSsZ6d=s?-{-u1>HkZu!M+qkgS7 z7)`L*Vzt>FPM6!`_4x# zmPU9_`>PWViFrXldhs9eDy^z@)n?FNv&qek`x^@9)<-`1djr?^*9+)C>2Ouzp~OJo zb~p5(fGZ$h+X_b%M0Z9D;c>KugVIy5LLgLy5g76<19?3RE4 z16%nC``f62s;`qlV3)Yj&@MbLFprUja69qg%Il9E?q_TKOB|CGxghmfjne0fo7T@4*KYquHeNUTNRB7-`_0%F);9|kTM}ANBro(PFlkyxj@)*s|y{;qo;~dY{XHZh$5~Ks*jRM1A-`>WCim) z3&$~7#F5fH<*1<-Ht|YT$E)+$DLn&mPgzIQijI;~R3pU|3nl8HdI8eBuDu&7eo-ah za5vk+jsFoyl0mfAxpZ@TuNRKrS0g0v(|8Nh*(5>Y = { + feat: { label: "New", bg: "rgba(252,169,41,0.12)", fg: "var(--ink)" }, + fix: { label: "Fix", bg: "rgba(10,10,10,0.06)", fg: "var(--fg-2)" }, + polish: { label: "Polish", bg: "rgba(10,10,10,0.06)", fg: "var(--fg-2)" }, + internal: { + label: "Internal", + bg: "rgba(10,10,10,0.04)", + fg: "var(--fg-3)", + }, +}; diff --git a/website/app/docs/changelog/page.tsx b/website/app/docs/changelog/page.tsx new file mode 100644 index 00000000..304fd5e0 --- /dev/null +++ b/website/app/docs/changelog/page.tsx @@ -0,0 +1,109 @@ +import Link from "next/link"; +import Pager from "@/components/docs/Pager"; +import { EXTENSION_VERSION, URLS } from "@toppings/constants"; +import { RELEASES } from "@/lib/releases.data"; +import { userFacingItems } from "@/lib/releases"; +import { ROUTE } from "@/lib/site.data"; +import { DOCS_PAGE } from "../pages.data"; +import { DOCS_CHANGELOG_KIND_TONE } from "./kind-tone.data"; +import { formatReleaseDate } from "@toppings/utils"; + +export default function DocsChangelogPage() { + const page = DOCS_PAGE.CHANGELOG; + + return ( +

+
+ Docs + / + {page.CRUMB_GROUP} + / + {page.CRUMB_CURRENT} +
+ +
+ + {page.EYEBROW} +
+ +

+ {page.TITLE_BEFORE} + {page.TITLE_HIGHLIGHT} + {page.TITLE_AFTER} +

+ +

+ {page.LEDE_BEFORE} + + v{EXTENSION_VERSION} + + . For commits and PRs see the{" "} + + {page.LEDE_LINK_LABEL} + + . +

+ +
+ {RELEASES.map((release) => { + const isCurrent = release.version === EXTENSION_VERSION; + const items = userFacingItems(release.items); + return ( +
+
+
+ + v{release.version} + + + {formatReleaseDate(release.date)} + + {isCurrent && ( + Current + )} +
+ {release.title && ( +

{release.title}

+ )} +
+
    + {items.map((item, i) => ( +
  • + + {DOCS_CHANGELOG_KIND_TONE[item.kind].label} + + {item.text} +
  • + ))} +
+
+ ); + })} +
+ + +
+ ); +} diff --git a/website/app/docs/docs.css b/website/app/docs/docs.css new file mode 100644 index 00000000..f21b7581 --- /dev/null +++ b/website/app/docs/docs.css @@ -0,0 +1,850 @@ +.docs-top { + position: sticky; + top: 0; + z-index: 50; + background: var(--cream); + border-bottom: 1px solid var(--border-1); +} +.docs-top__inner { + height: 64px; + max-width: 1320px; + margin: 0 auto; + padding: 0 32px; + display: flex; + align-items: center; + gap: 24px; +} +.docs-top__brand { + display: flex; + align-items: center; + gap: 10px; +} +.docs-top__brand img { + width: 28px; + height: 28px; +} +.docs-top__brand .name { + font: 900 18px/1 "Inter", var(--font-sans); + letter-spacing: -0.04em; +} +.docs-top__brand .tag { + font: 500 11px/1 "Inter", var(--font-sans); + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--fg-3); + padding-left: 12px; + margin-left: 12px; + border-left: 1px solid var(--border-1); +} +.docs-top__spacer { + flex: 1; +} + +.docs-search { + display: flex; + align-items: center; + gap: 8px; + height: 36px; + padding: 0 12px; + background: var(--surface-sunken); + border-radius: 6px; + color: var(--fg-3); + font-size: 13px; + min-width: 280px; + cursor: pointer; +} +.docs-search svg { + width: 14px; + height: 14px; + stroke: currentColor; + fill: none; + stroke-width: 2; +} +.docs-search .placeholder { + flex: 1; +} +.docs-search kbd { + background: rgba(10, 10, 10, 0.06); + color: var(--fg-2); + font: 600 11px/1 "Inter", var(--font-sans); + padding: 4px 7px; + border-radius: 4px; +} + +.docs-top a.lnk { + font-size: 13.5px; + color: var(--fg-2); + transition: color 140ms var(--ease-expo-out); +} +.docs-top a.lnk:hover { + color: var(--fg-1); +} + +.docs-shell { + max-width: 1320px; + margin: 0 auto; + padding: 32px; + display: grid; + grid-template-columns: 220px 1fr 220px; + gap: 48px; + align-items: start; +} +@media (max-width: 1024px) { + .docs-shell { + grid-template-columns: 220px 1fr; + } + .docs-toc { + display: none; + } +} +@media (max-width: 768px) { + .docs-shell { + grid-template-columns: 1fr; + padding: 24px 20px; + gap: 32px; + } + .docs-nav { + position: static !important; + } +} + +.docs-nav { + position: sticky; + top: 96px; +} +.docs-nav__group + .docs-nav__group { + margin-top: 28px; +} +.docs-nav__title { + font: 600 11px/1 "Inter", var(--font-sans); + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--fg-3); + padding: 0 10px 10px; +} +.docs-nav__items { + display: flex; + flex-direction: column; + gap: 1px; +} +.docs-nav__item { + display: flex; + align-items: center; + gap: 10px; + padding: 7px 10px; + border-radius: 6px; + font-size: 13.5px; + font-weight: 500; + color: var(--fg-2); + cursor: pointer; + border: 0; + background: transparent; + text-align: left; + text-decoration: none; + transition: + background 140ms var(--ease-expo-out), + color 140ms var(--ease-expo-out); +} +.docs-nav__item:hover { + color: var(--fg-1); + background: rgba(10, 10, 10, 0.04); +} +.docs-nav__item.active { + color: var(--fg-1); + background: rgba(10, 10, 10, 0.06); +} +.docs-nav__item .num { + font: 500 11px/1 "JetBrains Mono", var(--font-mono); + color: var(--fg-3); + margin-left: auto; +} + +.docs-main { + min-width: 0; +} +.docs-crumbs { + display: flex; + align-items: center; + gap: 8px; + font: 500 12px/1 "Inter", var(--font-sans); + color: var(--fg-3); + margin-bottom: 16px; +} +.docs-crumbs .sep { + color: rgba(10, 10, 10, 0.18); +} +.docs-crumbs .current { + color: var(--fg-1); +} + +.docs-eyebrow { + font: 600 11px/1 "Inter", var(--font-sans); + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--fg-3); + margin-bottom: 16px; + display: inline-flex; + align-items: center; + gap: 10px; +} +.docs-eyebrow .dot { + width: 6px; + height: 6px; + border-radius: 999px; + background: var(--amber); +} + +.docs-title { + margin: 0 0 12px; + font-weight: 900; + font-size: clamp(40px, 5.5vw, 64px); + line-height: 0.96; + letter-spacing: -0.045em; + color: var(--fg-1); + text-wrap: balance; +} +.docs-lede { + margin: 0 0 40px; + font-size: 19px; + line-height: 1.55; + color: var(--fg-2); + max-width: 60ch; + letter-spacing: -0.011em; + text-wrap: pretty; +} + +.docs-prose h2 { + margin: 56px 0 16px; + font-weight: 900; + font-size: 32px; + line-height: 1.08; + letter-spacing: -0.04em; + color: var(--fg-1); +} +.docs-prose h3 { + margin: 36px 0 12px; + font-weight: 600; + font-size: 20px; + line-height: 1.1; + letter-spacing: -0.022em; + color: var(--fg-1); +} +.docs-prose p { + margin: 0 0 16px; + font-size: 16px; + line-height: 1.65; + color: var(--fg-1); + letter-spacing: -0.005em; + text-wrap: pretty; + max-width: 64ch; +} +.docs-prose a { + color: var(--fg-1); + text-decoration: underline; + text-decoration-color: rgba(10, 10, 10, 0.2); + text-decoration-thickness: 1px; + text-underline-offset: 4px; + transition: text-decoration-color 200ms var(--ease-expo-out); +} +.docs-prose a:hover { + text-decoration-color: var(--amber); + text-decoration-thickness: 2px; +} +.docs-prose ul, +.docs-prose ol { + margin: 0 0 16px; + padding-left: 20px; + max-width: 64ch; +} +.docs-prose li { + font-size: 16px; + line-height: 1.65; + color: var(--fg-1); + padding-left: 4px; +} +.docs-prose li + li { + margin-top: 8px; +} +.docs-prose code { + font: 500 13.5px/1 "JetBrains Mono", var(--font-mono); + background: rgba(10, 10, 10, 0.06); + color: var(--fg-1); + padding: 2px 6px; + border-radius: 4px; +} +.docs-prose pre { + margin: 24px 0; + padding: 18px 22px; + background: var(--ink); + color: var(--fg-on-ink-1); + border-radius: 10px; + font: 500 13px/1.55 "JetBrains Mono", var(--font-mono); + overflow-x: auto; +} +.docs-prose pre code { + background: transparent; + color: inherit; + padding: 0; +} +.docs-prose .callout { + margin: 24px 0; + padding: 16px 20px; + background: var(--surface-raised); + border: 1px solid var(--border-1); + border-radius: 10px; + display: grid; + grid-template-columns: auto 1fr; + gap: 12px; +} +.docs-prose .callout .ico { + width: 18px; + height: 18px; + margin-top: 2px; +} +.docs-prose .callout .ico svg { + width: 100%; + height: 100%; + stroke: currentColor; + fill: none; + stroke-width: 1.75; +} +.docs-prose .callout p { + margin: 0; + font-size: 14.5px; + color: var(--fg-2); +} +.docs-prose .callout strong { + color: var(--fg-1); + font-weight: 600; +} + +kbd.k { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 22px; + height: 22px; + padding: 0 7px; + background: var(--ink); + color: var(--fg-on-ink-1); + border-radius: 4px; + font: 700 11.5px/1 "Inter", var(--font-sans); + vertical-align: 1px; +} + +.docs-steps { + counter-reset: step; + margin: 24px 0; +} +.docs-step { + display: grid; + grid-template-columns: 48px 1fr; + gap: 16px; + padding: 18px 0; + border-top: 1px solid var(--border-1); +} +.docs-step:last-child { + border-bottom: 1px solid var(--border-1); +} +.docs-step::before { + counter-increment: step; + content: counter(step, decimal-leading-zero); + font: 500 12px/1 "JetBrains Mono", var(--font-mono); + color: var(--fg-3); + padding-top: 4px; +} +.docs-step h4 { + margin: 0 0 4px; + font-weight: 600; + font-size: 16px; + letter-spacing: -0.012em; +} +.docs-step p { + margin: 0; + font-size: 14.5px; + color: var(--fg-2); + line-height: 1.55; +} + +.docs-foot { + margin-top: 72px; + padding-top: 24px; + border-top: 1px solid var(--border-1); + display: flex; + justify-content: space-between; + align-items: center; + font-size: 13px; + color: var(--fg-3); +} +.docs-foot a { + color: var(--fg-3); + text-decoration: none; + transition: color 140ms var(--ease-expo-out); +} +.docs-foot a:hover { + color: var(--amber); +} + +.docs-pager { + margin-top: 48px; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} +.docs-pager a { + padding: 18px 20px; + border: 1px solid var(--border-1); + border-radius: 10px; + display: flex; + flex-direction: column; + gap: 4px; + text-decoration: none; + transition: + border-color 200ms var(--ease-expo-out), + background 200ms var(--ease-expo-out); +} +.docs-pager a:hover { + border-color: var(--ink); + background: var(--surface-raised); +} +.docs-pager .dir { + font: 600 11px/1 "Inter", var(--font-sans); + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--fg-3); +} +.docs-pager .nxt { + text-align: right; +} +.docs-pager .ttl { + font-weight: 600; + font-size: 16px; + letter-spacing: -0.012em; + color: var(--fg-1); +} + +.docs-toc { + position: sticky; + top: 96px; +} +.docs-toc__title { + font: 600 11px/1 "Inter", var(--font-sans); + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--fg-3); + padding: 0 0 12px; +} +.docs-toc__list { + display: flex; + flex-direction: column; + gap: 6px; +} +.docs-toc__link { + font-size: 13px; + color: var(--fg-2); + padding: 4px 0; + line-height: 1.4; + border-left: 2px solid transparent; + padding-left: 12px; + margin-left: -2px; + cursor: pointer; + text-decoration: none; + transition: + color 140ms var(--ease-expo-out), + border-color 140ms var(--ease-expo-out); +} +.docs-toc__link:hover { + color: var(--fg-1); +} +.docs-toc__link.active { + color: var(--fg-1); + border-left-color: var(--ink); +} +.docs-toc__link.sub { + padding-left: 24px; + font-size: 12.5px; + color: var(--fg-3); +} + +.kb-section { + margin-top: 40px; +} +.kb-section__head { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-bottom: 14px; +} +.kb-section__title { + font-weight: 900; + font-size: 24px; + letter-spacing: -0.04em; + line-height: 1; +} +.kb-section__count { + font: 500 12px/1 "JetBrains Mono", var(--font-mono); + color: var(--fg-3); +} + +.kb-table { + width: 100%; + border-top: 1px solid var(--border-1); +} +.kb-row { + display: grid; + grid-template-columns: 1fr 1.4fr 180px auto; + align-items: center; + gap: 20px; + padding: 14px 0; + border-bottom: 1px solid var(--border-1); +} +@media (max-width: 768px) { + .kb-row { + grid-template-columns: 1fr auto; + gap: 6px 20px; + } + .kb-row__desc { + grid-column: 1 / -1; + order: 3; + } + .kb-row__edit { + display: none; + } +} +.kb-row__name { + font-size: 15px; + font-weight: 500; + color: var(--fg-1); + letter-spacing: -0.011em; +} +.kb-row__desc { + font-size: 13.5px; + color: var(--fg-2); + line-height: 1.45; +} +.kb-row__combo { + display: inline-flex; + gap: 6px; + align-items: center; +} +.kb-row__combo kbd { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 32px; + height: 30px; + padding: 0 10px; + background: var(--ink); + color: var(--fg-on-ink-1); + border-radius: 6px; + font: 700 13px/1 "Inter", var(--font-sans); +} +.kb-row__combo .sep { + color: var(--fg-3); + font: 500 12px/1 "Inter", var(--font-sans); +} +.kb-row__edit { + font: 500 12px/1 "Inter", var(--font-sans); + color: var(--fg-3); + text-decoration: underline; + text-decoration-color: transparent; + text-decoration-thickness: 2px; + text-underline-offset: 3px; + transition: + color 200ms var(--ease-expo-out), + text-decoration-color 200ms var(--ease-expo-out); + cursor: pointer; +} +.kb-row__edit:hover { + color: var(--fg-1); + text-decoration-color: var(--amber); +} + +.kb-intro { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 24px; + padding: 24px; + background: var(--surface-sunken); + border-radius: 12px; + margin-bottom: 40px; +} +@media (max-width: 768px) { + .kb-intro { + grid-template-columns: 1fr; + } +} +.kb-intro .stat { + font-weight: 900; + font-size: 36px; + line-height: 1; + letter-spacing: -0.04em; +} +.kb-intro .stat .amber { + color: var(--amber); +} +.kb-intro .lbl { + font: 600 11px/1 "Inter", var(--font-sans); + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--fg-3); + margin-top: 10px; +} + +.faq-cat-tabs { + display: flex; + gap: 6px; + margin-bottom: 32px; + border-bottom: 1px solid var(--border-1); + flex-wrap: wrap; +} +.faq-cat-tab { + background: transparent; + border: 0; + cursor: pointer; + padding: 12px 14px; + font: 600 13px/1 "Inter", var(--font-sans); + color: var(--fg-3); + border-bottom: 2px solid transparent; + margin-bottom: -1px; + transition: + color 200ms var(--ease-expo-out), + border-color 200ms var(--ease-expo-out); +} +.faq-cat-tab:hover { + color: var(--fg-1); +} +.faq-cat-tab.active { + color: var(--fg-1); + border-bottom-color: var(--ink); +} +.faq-cat-tab .count { + color: var(--fg-3); + font-weight: 500; + margin-left: 6px; +} + +.faq-list { + display: flex; + flex-direction: column; +} +.faq-item { + border-top: 1px solid var(--border-1); +} +.faq-item:last-child { + border-bottom: 1px solid var(--border-1); +} +.faq-item__btn { + width: 100%; + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + gap: 16px; + padding: 20px 4px; + background: transparent; + border: 0; + cursor: pointer; + text-align: left; + font-family: inherit; +} +.faq-item__q { + font-size: 17px; + font-weight: 600; + letter-spacing: -0.018em; + color: var(--fg-1); + line-height: 1.3; +} +.faq-item__toggle { + width: 28px; + height: 28px; + border-radius: 999px; + display: grid; + place-items: center; + background: rgba(10, 10, 10, 0.06); + color: var(--fg-1); + transition: + transform 240ms var(--ease-expo-out), + background 200ms var(--ease-expo-out); + flex-shrink: 0; +} +.faq-item__toggle svg { + width: 12px; + height: 12px; + stroke: currentColor; + fill: none; + stroke-width: 2.2; +} +.faq-item.open .faq-item__toggle { + transform: rotate(45deg); + background: var(--ink); + color: var(--fg-on-ink-1); +} +.faq-item__a { + max-height: 0; + overflow: hidden; + transition: max-height 380ms var(--ease-expo-out); +} +.faq-item.open .faq-item__a { + max-height: 1200px; +} +.faq-item__a-inner { + padding: 0 4px 24px; + font-size: 15px; + line-height: 1.65; + color: var(--fg-2); + max-width: 64ch; +} +.faq-item__a-inner a { + color: var(--fg-1); + text-decoration: underline; + text-decoration-color: rgba(10, 10, 10, 0.2); + text-underline-offset: 4px; +} +.faq-item__a-inner a:hover { + text-decoration-color: var(--amber); +} +.faq-item__a-inner p { + margin: 0 0 12px; +} +.faq-item__a-inner p:last-child { + margin-bottom: 0; +} + +.changelog-list { + display: flex; + flex-direction: column; + gap: 0; +} +.changelog-entry { + border-top: 1px solid var(--border-1); + padding: 28px 0; +} +.changelog-list .changelog-entry:last-child { + border-bottom: 1px solid var(--border-1); +} +.changelog-entry__head { + margin-bottom: 18px; +} +.changelog-entry__meta { + display: flex; + align-items: baseline; + gap: 16px; + flex-wrap: wrap; +} +.changelog-entry__version { + font: 900 28px/1 "Inter", var(--font-sans); + letter-spacing: -0.04em; + color: var(--fg-1); +} +.changelog-entry__version--current { + color: var(--fg-1); + position: relative; +} +.changelog-entry__date { + font: 500 12px/1 "JetBrains Mono", var(--font-mono); + color: var(--fg-3); +} +.changelog-entry__chip { + display: inline-flex; + align-items: center; + padding: 4px 10px; + background: var(--amber); + color: var(--ink); + border-radius: 999px; + font: 600 11px/1 "Inter", var(--font-sans); + letter-spacing: 0.04em; + text-transform: uppercase; +} +.changelog-entry__title { + margin: 10px 0 0; + font-weight: 600; + font-size: 18px; + line-height: 1.25; + letter-spacing: -0.018em; + color: var(--fg-2); +} +.changelog-entry__items { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 10px; +} +.changelog-entry__item { + display: grid; + grid-template-columns: 64px 1fr; + gap: 14px; + align-items: baseline; +} +.changelog-entry__kind { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 3px 8px; + border-radius: 999px; + font: 600 10px/1 "Inter", var(--font-sans); + letter-spacing: 0.08em; + text-transform: uppercase; + text-align: center; +} +.changelog-entry__text { + font-size: 15px; + line-height: 1.5; + color: var(--fg-1); +} + +.docs-version-banner { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + margin-bottom: 24px; + background: rgba(252, 169, 41, 0.1); + border: 1px solid rgba(252, 169, 41, 0.3); + border-radius: 10px; + font-size: 14px; + color: var(--fg-1); +} +.docs-version-banner .ico { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 999px; + background: var(--amber); + color: var(--ink); + flex-shrink: 0; +} +.docs-version-banner .ico svg { + width: 14px; + height: 14px; + stroke: currentColor; + stroke-width: 2.2; + fill: none; + stroke-linecap: round; + stroke-linejoin: round; +} +.docs-version-banner__body { + flex: 1; +} +.docs-version-banner__head { + font-weight: 600; + letter-spacing: -0.011em; +} +.docs-version-banner__cta { + margin-left: auto; + font-weight: 600; + font-size: 13.5px; + color: var(--fg-1); + white-space: nowrap; + text-decoration: underline; + text-decoration-color: var(--amber); + text-decoration-thickness: 2px; + text-underline-offset: 3px; +} +.docs-version-banner__cta:hover { + text-decoration-color: var(--flame); +} + diff --git a/website/app/docs/faq/data.ts b/website/app/docs/faq/data.ts new file mode 100644 index 00000000..a34664a9 --- /dev/null +++ b/website/app/docs/faq/data.ts @@ -0,0 +1,288 @@ +import type { DocsPart } from "@/lib/docs-part"; + +export const FAQ_CATEGORIES = [ + "All", + "Privacy", + "Features", + "Install", + "Troubleshooting", +] as const; + +export type FaqCategoryFilter = (typeof FAQ_CATEGORIES)[number]; +export type FaqCategory = Exclude; + +export interface FaqEntry { + category: FaqCategory; + question: string; + paragraphs: readonly (readonly DocsPart[])[]; +} + +export const FAQ_ENTRIES: readonly FaqEntry[] = [ + { + category: "Privacy", + question: "Does Toppings collect any data?", + paragraphs: [ + [ + { + kind: "text", + value: + "No. Toppings has zero analytics, zero telemetry, and zero remote calls. The extension requests two permissions — access to ", + }, + { kind: "code", value: "youtube.com" }, + { + kind: "text", + value: + " (so it can inject buttons into the player) and ", + }, + { kind: "code", value: "storage" }, + { + kind: "text", + value: " (so your settings survive restarts). That is the complete list.", + }, + ], + [ + { kind: "text", value: "Don't trust us? Read the source — it's " }, + { kind: "link", target: "GITHUB_REPO", label: "on GitHub" }, + { + kind: "text", + value: + " under GPL-3.0, and your network panel will be empty while it runs.", + }, + ], + ], + }, + { + category: "Privacy", + question: "Where are my settings stored?", + paragraphs: [ + [ + { + kind: "text", + value: + "In your browser's local extension storage. If you have browser profile sync enabled, they sync across your signed-in browsers via your browser vendor's sync system. Toppings does not run its own sync — we never see your settings.", + }, + ], + ], + }, + { + category: "Features", + question: "How does Audio mode work?", + paragraphs: [ + [ + { + kind: "text", + value: + "When you toggle Audio mode, Toppings hides the video frame and keeps the audio playing. You can replace the visible canvas with a solid black screen, an animated waveform, or your own background image.", + }, + ], + [ + { + kind: "text", + value: + "The full timeline, chapters, playback controls, and speed keep working. Perfect for podcasts, talks, and live sets you don't need to see.", + }, + ], + ], + }, + { + category: "Features", + question: "Can I loop more than one segment?", + paragraphs: [ + [ + { + kind: "text", + value: + "Currently no — Loop supports a single in/out pair per video. Multi-segment loops are on the roadmap but we're being cautious about the UI complexity.", + }, + ], + ], + }, + { + category: "Features", + question: "What's the maximum playback speed?", + paragraphs: [ + [ + { + kind: "text", + value: + "16.0× technically, but audio decoders give up well below that. We recommend staying under 4× in the custom playback rate list, and Toppings will let you set increments as small as 0.0625× if you want.", + }, + ], + ], + }, + { + category: "Install", + question: "Is Toppings available on mobile?", + paragraphs: [ + [ + { + kind: "text", + value: + "Firefox for Android, yes — install from addons.mozilla.org directly. Chrome for Android does not support extensions; there's nothing we can do until Google ships extension support. Safari is not on our roadmap (different extension format, separate audit process).", + }, + ], + ], + }, + { + category: "Install", + question: "Can I install Toppings without the store?", + paragraphs: [ + [ + { kind: "text", value: "Yes. Clone the GitHub repo, run " }, + { kind: "code", value: "bun install" }, + { kind: "text", value: " then " }, + { kind: "code", value: "bun run build" }, + { kind: "text", value: " in " }, + { kind: "code", value: "web-ext/" }, + { kind: "text", value: ", and load the unpacked " }, + { kind: "code", value: "dist/" }, + { kind: "text", value: " directory via " }, + { kind: "code", value: "chrome://extensions" }, + { kind: "text", value: " with Developer mode on. Same flow for Firefox via " }, + { kind: "code", value: "about:debugging" }, + { kind: "text", value: "." }, + ], + ], + }, + { + category: "Features", + question: "What are Profiles?", + paragraphs: [ + [ + { + kind: "text", + value: + "Profiles are named bundles of YouTube experience settings. Instead of toggling features one at a time, you activate a profile and everything changes at once — sidebar visibility, comments, end cards, playback layout, Shorts shelf, and more.", + }, + ], + [ + { + kind: "text", + value: + "Toppings ships two built-in presets: ", + }, + { kind: "code", value: "Audio" }, + { + kind: "text", + value: " (hides the player, perfect for music/podcasts) and ", + }, + { kind: "code", value: "Focus" }, + { + kind: "text", + value: + " (hides sidebar, comments, and end cards). You can create as many custom profiles as you like and switch between them from the popup, a keyboard shortcut, or the player gear menu.", + }, + ], + ], + }, + { + category: "Features", + question: "How do I switch profiles quickly while watching a video?", + paragraphs: [ + [ + { + kind: "text", + value: + "Three ways: (1) click the extension popup — the profile list is at the bottom; (2) use a keyboard shortcut — configure one in Options → Keybindings → Profiles → Cycle Profiles; (3) open the YouTube player's gear (⚙) menu and enable the Toppings section in Options → General → Profile Surfaces.", + }, + ], + ], + }, + { + category: "Features", + question: "Can Profiles control the home page and search results too?", + paragraphs: [ + [ + { + kind: "text", + value: + "Yes. Each profile can also configure home-page settings (feed thumbnails: show/hide/blur, Shorts shelf, entire feed) and search-result settings (thumbnails, video metadata, Shorts shelf). Open Options → Profiles, edit or create a profile, and scroll to the Home and Search sections.", + }, + ], + ], + }, + { + category: "Features", + question: "Can I share or back up my custom profiles?", + paragraphs: [ + [ + { + kind: "text", + value: + "Yes. Each custom profile card in Options → Profiles has an Export button that downloads a ", + }, + { kind: "code", value: "toppings-profile-*.json" }, + { + kind: "text", + value: + " file. To import on another browser, use the Import button at the top of the My Profiles section. Imported profiles are validated — a malformed file can never corrupt your storage.", + }, + ], + ], + }, + { + category: "Features", + question: "What is the YouTube sidebar entry in Options → General?", + paragraphs: [ + [ + { + kind: "text", + value: + "When you enable 'YouTube sidebar entry' in Options → General → Profile Surfaces, a Toppings link appears in YouTube's left navigation. Clicking it opens a native-styled settings page inside YouTube — no need to leave the site. From there you can switch profiles and toggle watch-page primitives.", + }, + ], + ], + }, + { + category: "Troubleshooting", + question: "A feature shows as 'Unavailable on your YouTube' — what does that mean?", + paragraphs: [ + [ + { + kind: "text", + value: + "YouTube runs A/B tests that change the DOM structure of the page. If Toppings can't find the element it needs, it marks that feature as unavailable rather than crashing. Hit 'Report' on the row — we'll get a GitHub issue, and we try to add a new selector strategy in the next patch release. You'll see a banner in the options page once it's fixed.", + }, + ], + ], + }, + { + category: "Troubleshooting", + question: "Toppings buttons don't appear on YouTube.", + paragraphs: [ + [ + { + kind: "text", + value: + "Three things to check: (1) Is the extension enabled? (2) Did you reload the YouTube tab after installing? (3) Are you on a ", + }, + { kind: "code", value: "/watch" }, + { + kind: "text", + value: + " URL? Playback controls (loop, speed, seek) only appear on video pages. Profile primitives for the home page and search run on those pages automatically.", + }, + ], + [ + { + kind: "text", + value: + "If all three are yes, open the popup — if the status dot is grey, Toppings hasn't detected an active YouTube tab. If it's amber but the buttons still don't appear, another YouTube extension may be conflicting. Disable other YouTube extensions one at a time to find the culprit.", + }, + ], + ], + }, + { + category: "Troubleshooting", + question: "My keybindings stopped working.", + paragraphs: [ + [ + { + kind: "text", + value: + "Keybindings only fire when the YouTube page is focused — if you're typing in a comment or the search box, they pause automatically (this is intentional). Click the video to refocus.", + }, + ], + ], + }, +]; diff --git a/website/app/docs/faq/page.tsx b/website/app/docs/faq/page.tsx new file mode 100644 index 00000000..ac8c9c7f --- /dev/null +++ b/website/app/docs/faq/page.tsx @@ -0,0 +1,113 @@ +"use client"; +import { useState } from "react"; +import Link from "next/link"; +import Pager from "@/components/docs/Pager"; +import { ROUTE } from "@/lib/site.data"; +import { DOCS_PAGE } from "../pages.data"; +import { + FAQ_CATEGORIES, + FAQ_ENTRIES, + type FaqCategoryFilter, +} from "./data"; +import { DocsInlineParts, DocsRichText } from "@/lib/docs-rich-text"; + +export default function DocsFaqPage() { + const page = DOCS_PAGE.FAQ; + const [cat, setCat] = useState("All"); + const [open, setOpen] = useState>(new Set([0])); + + const filtered = FAQ_ENTRIES.filter( + (entry) => cat === "All" || entry.category === cat, + ); + + const toggle = (i: number) => { + const next = new Set(open); + next.has(i) ? next.delete(i) : next.add(i); + setOpen(next); + }; + + const counts = FAQ_CATEGORIES.reduce( + (acc, c) => ({ + ...acc, + [c]: + c === "All" + ? FAQ_ENTRIES.length + : FAQ_ENTRIES.filter((entry) => entry.category === c).length, + }), + {} as Record, + ); + + return ( +
+
+ Docs + / + {page.CRUMB_GROUP} + / + {page.CRUMB_CURRENT} +
+ +
+ + {page.EYEBROW} +
+ +

+ {page.TITLE_BEFORE} + {page.TITLE_HIGHLIGHT} + {page.TITLE_AFTER} +

+ +

+ +

+ +
+ {FAQ_CATEGORIES.map((c) => ( + + ))} +
+ +
+ {filtered.map((entry, i) => ( +
+ +
+
+ +
+
+
+ ))} +
+ + +
+ ); +} diff --git a/website/app/docs/install/data.ts b/website/app/docs/install/data.ts new file mode 100644 index 00000000..931853c0 --- /dev/null +++ b/website/app/docs/install/data.ts @@ -0,0 +1,158 @@ +import type { DocsPart } from "@/lib/docs-part"; + +export const DOCS_INSTALL_TOC = [ + { id: "what", label: "What it does" }, + { id: "install", label: "Install" }, + { id: "first", label: "First run" }, + { id: "privacy", label: "Privacy model" }, + { id: "next", label: "What to do next" }, +] as const; + +export const DOCS_INSTALL_EDIT_PATH = + "website/app/docs/page.tsx" as const; + +export const DOCS_INSTALL = { + WHAT: { + HEADING: "What it does, before you commit", + INTRO_PREFIX: "Toppings adds small, sharp tools to YouTube:", + INTRO_SUFFIX: ". It adds nothing else. It removes nothing. It collects ", + PRIVACY_LINK: { kind: "anchor", id: "privacy", label: "zero data" }, + SUFFIX_AFTER_LINK: ".", + CALLOUT: [ + { kind: "strong", value: "Heads up:" }, + { kind: "text", value: " Toppings only runs on " }, + { kind: "code", value: "youtube.com" }, + { + kind: "text", + value: + ". No data is collected, sent, or stored outside your browser. Settings sync via your browser profile only if you have profile sync turned on.", + }, + ] as const satisfies readonly DocsPart[], + }, + INSTALL: { + HEADING: "Install", + STEPS: [ + { + title: "Open your browser's extension store", + body: [ + { + kind: "link", + target: "CHROME_WEBSTORE_TOPPINGS", + label: "Chrome Web Store", + }, + { kind: "text", value: " for Chrome, Edge, Opera, Brave, Arc. " }, + { + kind: "link", + target: "FIREFOX_AMO_TOPPINGS", + label: "Firefox Add-ons", + }, + { kind: "text", value: " for Firefox and its forks." }, + ], + }, + { + title: 'Click "Add to Chrome" or "Add to Firefox"', + body: [ + { kind: "text", value: "The browser will ask for two permissions: " }, + { kind: "code", value: "youtube.com" }, + { kind: "text", value: " access (to inject the buttons) and " }, + { kind: "code", value: "storage" }, + { + kind: "text", + value: + " (to remember your settings). That's the complete list of what Toppings ever asks for.", + }, + ], + }, + { + title: "Pin the toolbar icon", + body: [ + { + kind: "text", + value: + "Click the puzzle-piece in your toolbar, find Toppings, and pin it. You'll want quick access to the popup for the Audio Mode toggle and per-tab status.", + }, + ], + }, + { + title: "Open YouTube", + body: [ + { + kind: "text", + value: + "Any video page. You'll see two new amber buttons next to YouTube's native controls — that's Toppings. Press ", + }, + { kind: "code", value: "B" }, + { kind: "text", value: " to try Audio mode immediately." }, + ], + }, + ] as const satisfies readonly { + title: string; + body: readonly DocsPart[]; + }[], + }, + FIRST_RUN: { + HEADING: "First run", + PARAGRAPHS: [ + [ + { + kind: "text", + value: + "On first run Toppings opens a welcome tab with sensible defaults: Audio Mode ", + }, + { kind: "em", value: "available but off" }, + { kind: "text", value: ", Loop " }, + { kind: "em", value: "off" }, + { kind: "text", value: ", Shorts auto-scroll " }, + { kind: "em", value: "on" }, + { kind: "text", value: ", default playback speed " }, + { kind: "em", value: "1.0×" }, + { + kind: "text", + value: + ". Override any of them from the options page — right-click the toolbar icon → ", + }, + { kind: "strong", value: "Options" }, + { kind: "text", value: ", or click the gear in the popup." }, + ], + [ + { + kind: "text", + value: + "The options page is its own tab. You can change settings any time; changes are saved automatically.", + }, + ], + ] as const satisfies readonly (readonly DocsPart[])[], + }, + PRIVACY: { + HEADING: "Privacy model", + BODY: [ + { + kind: "text", + value: + "Toppings has no analytics, no telemetry, and no remote calls. Everything runs inside your browser and dies there. Read the source on ", + }, + { kind: "link", target: "GITHUB_REPO", label: "GitHub" }, + { kind: "text", value: "; it's GPL-3.0 in roughly 4,200 lines." }, + ] as const satisfies readonly DocsPart[], + }, + NEXT: { + HEADING: "What to do next", + ITEMS: [ + [ + { kind: "text", value: "See every shortcut Toppings ships with on the " }, + { kind: "route", target: "DOCS_KEYBINDINGS", label: "Keybindings" }, + { kind: "text", value: " page — all of them are rebindable." }, + ], + [ + { kind: "text", value: "The most-asked questions, with answers, on the " }, + { kind: "route", target: "DOCS_FAQ", label: "FAQ" }, + { kind: "text", value: "." }, + ], + [ + { kind: "text", value: "Found a bug or want a feature? " }, + { kind: "link", target: "GITHUB_ISSUES", label: "Open an issue on GitHub" }, + { kind: "text", value: " — we read all of them." }, + ], + ] as const satisfies readonly (readonly DocsPart[])[], + }, +} as const; diff --git a/website/app/docs/keybindings/data.ts b/website/app/docs/keybindings/data.ts new file mode 100644 index 00000000..9388eecf --- /dev/null +++ b/website/app/docs/keybindings/data.ts @@ -0,0 +1,45 @@ +export interface DocsKeybindingRow { + name: string; + desc: string; + combo: readonly string[]; + single?: boolean; +} + +export interface DocsKeybindingGroup { + title: string; + rows: readonly DocsKeybindingRow[]; +} + +/** Default keys — keep in sync with web-ext/data/store.data.ts */ +export const KEYBINDING_GROUPS: readonly DocsKeybindingGroup[] = [ + { + title: "Watch page", + rows: [ + { name: "Toggle Audio mode", desc: "Replace the video stream with audio-only", combo: ["B"], single: true }, + { name: "Toggle Segments (last used)", desc: "Load the last-used segment config and enable it; if already active, disables segments", combo: ["Z"], single: true }, + { name: "Fresh Slate Segments", desc: "Start a brand-new segment config: one segment spanning the full video, infinite loop", combo: ["Shift", "Z"] }, + { name: "Set active segment in", desc: "Pin the start of the active segment (the one containing the playhead) to the current time", combo: ["Q"], single: true }, + { name: "Set active segment out", desc: "Pin the end of the active segment to the current time", combo: ["E"], single: true }, + { name: "Nudge segment start back", desc: "Move the active segment's start marker back with acceleration (1→2→4→8→16 s)", combo: ["Shift", "Q"] }, + { name: "Nudge segment end forward", desc: "Move the active segment's end marker forward with acceleration (1→2→4→8→16 s)", combo: ["Shift", "E"] }, + { name: "Toggle playback speed", desc: "Snap between 1× and your favorite fast rate", combo: ["X"], single: true }, + { name: "Speed up", desc: "Increase playback rate by 0.25×", combo: ["W"], single: true }, + { name: "Speed down", desc: "Reduce playback rate by 0.25×", combo: ["S"], single: true }, + ], + }, + { + title: "Seek", + rows: [ + { name: "Seek back", desc: "Custom duration (default 15s)", combo: ["A"], single: true }, + { name: "Seek forward", desc: "Custom duration (default 15s)", combo: ["D"], single: true }, + ], + }, + { + title: "Shorts", + rows: [ + { name: "Toggle playback speed", desc: "Snap between 1× and your favorite fast rate", combo: ["X"], single: true }, + { name: "Seek back", desc: "Custom duration (default 5s)", combo: ["A"], single: true }, + { name: "Seek forward", desc: "Custom duration (default 5s)", combo: ["D"], single: true }, + ], + }, +]; diff --git a/website/app/docs/keybindings/page.tsx b/website/app/docs/keybindings/page.tsx new file mode 100644 index 00000000..dd3d5923 --- /dev/null +++ b/website/app/docs/keybindings/page.tsx @@ -0,0 +1,110 @@ +import Link from "next/link"; +import Pager from "@/components/docs/Pager"; +import { ROUTE } from "@/lib/site.data"; +import { DOCS_PAGE } from "../pages.data"; +import { + KEYBINDING_GROUPS, + type DocsKeybindingRow, +} from "./data"; + +function Combo({ row }: { row: DocsKeybindingRow }) { + if (row.single) { + return ( +
+ {row.combo[0]} +
+ ); + } + return ( +
+ {row.combo[0]} + + + {row.combo[1]} +
+ ); +} + +export default function DocsKeybindingsPage() { + const page = DOCS_PAGE.KEYBINDINGS; + const total = KEYBINDING_GROUPS.reduce((n, g) => n + g.rows.length, 0); + + return ( +
+
+ Docs + / + {page.CRUMB_GROUP} + / + {page.CRUMB_CURRENT} +
+ +
+ + {page.EYEBROW} +
+ +

+ {page.TITLE_BEFORE} + {page.TITLE_HIGHLIGHT} + {page.TITLE_AFTER} +

+ +

+ Toppings ships with {total} default shortcuts. All of them are + rebindable from the extension’s options page. Bindings only fire + on{" "} + + youtube.com + {" "} + — never globally, never when you’re typing in a text field. +

+ +
+
+
{total}
+
Default shortcuts
+
+
+
+ 100% +
+
Rebindable
+
+
+
0
+
Global hotkeys
+
+
+ + {KEYBINDING_GROUPS.map((group) => ( +
+
+
{group.title}
+
+ {group.rows.length} shortcut{group.rows.length === 1 ? "" : "s"} +
+
+
+ {group.rows.map((row, i) => ( +
+
{row.name}
+
{row.desc}
+ + Rebind +
+ ))} +
+
+ ))} + + +
+ ); +} diff --git a/website/app/docs/layout.tsx b/website/app/docs/layout.tsx new file mode 100644 index 00000000..8c63f18d --- /dev/null +++ b/website/app/docs/layout.tsx @@ -0,0 +1,26 @@ +import type { Metadata } from "next"; +import DocsTop from "@/components/docs/DocsTop"; +import DocsNav from "@/components/docs/DocsNav"; +import "./docs.css"; + +export const metadata: Metadata = { + title: "Toppings — Docs", + description: + "Documentation, keybindings, and frequently asked questions for Toppings — a free, open-source YouTube extension.", +}; + +export default function DocsLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + <> + +
+ + {children} +
+ + ); +} diff --git a/website/app/docs/nav.data.ts b/website/app/docs/nav.data.ts new file mode 100644 index 00000000..4e85939a --- /dev/null +++ b/website/app/docs/nav.data.ts @@ -0,0 +1,38 @@ +import { LABEL, ROUTE } from "@/lib/site.data"; + +export interface DocsNavItem { + id: string; + label: string; + href: string; + num?: string; +} + +export interface DocsNavGroup { + title: string; + items: DocsNavItem[]; +} + +export const DOCS_NAV: DocsNavGroup[] = [ + { + title: "Getting started", + items: [ + { id: "install", label: "Install Toppings", href: ROUTE.DOCS, num: "01" }, + ], + }, + { + title: "Reference", + items: [ + { + id: "keybindings", + label: LABEL.KEYBINDINGS, + href: ROUTE.DOCS_KEYBINDINGS, + }, + { id: "faq", label: LABEL.FAQ, href: ROUTE.DOCS_FAQ }, + { + id: "changelog", + label: LABEL.CHANGELOG, + href: ROUTE.DOCS_CHANGELOG, + }, + ], + }, +]; diff --git a/website/app/docs/page.tsx b/website/app/docs/page.tsx new file mode 100644 index 00000000..134d9a21 --- /dev/null +++ b/website/app/docs/page.tsx @@ -0,0 +1,131 @@ +import Link from "next/link"; +import Pager from "@/components/docs/Pager"; +import { DocsInlineParts, DocsRichText } from "@/lib/docs-rich-text"; +import { URLS } from "@toppings/constants"; +import { FEATURES } from "@/lib/features.data"; +import { ROUTE } from "@/lib/site.data"; +import { DOCS_PAGE } from "./pages.data"; +import { + DOCS_INSTALL, + DOCS_INSTALL_EDIT_PATH, + DOCS_INSTALL_TOC, +} from "./install/data"; +import { formatTodayReviewDate } from "@toppings/utils"; + +export default function DocsInstallPage() { + const page = DOCS_PAGE.INSTALL; + const { WHAT, INSTALL, FIRST_RUN, PRIVACY, NEXT } = DOCS_INSTALL; + + return ( + <> +
+
+ Docs + / + {page.CRUMB_GROUP} + / + {page.CRUMB_CURRENT} +
+ +
+ + {page.EYEBROW} +
+ +

+ {page.TITLE_BEFORE} + {page.TITLE_HIGHLIGHT} + {page.TITLE_AFTER} +

+ +

{page.LEDE}

+ +
+

{WHAT.HEADING}

+

+ {WHAT.INTRO_PREFIX}{" "} + {FEATURES.map((feature, i) => ( + + {i > 0 && (i === FEATURES.length - 1 ? ", and " : ", ")} + {feature.name} + + ))} + {WHAT.INTRO_SUFFIX} + + {WHAT.SUFFIX_AFTER_LINK} +

+ +
+ + + + + + +

+ +

+
+ +

{INSTALL.HEADING}

+
+ {INSTALL.STEPS.map((step) => ( +
+
+

{step.title}

+

+ +

+
+
+ ))} +
+ +

{FIRST_RUN.HEADING}

+ + +

{PRIVACY.HEADING}

+

+ +

+ +

{NEXT.HEADING}

+
    + {NEXT.ITEMS.map((parts, i) => ( +
  • + +
  • + ))} +
+
+ + + +
+ Last updated {formatTodayReviewDate()} + + Edit this page on GitHub → + +
+
+ + + + ); +} diff --git a/website/app/docs/pages.data.ts b/website/app/docs/pages.data.ts new file mode 100644 index 00000000..8c7638bd --- /dev/null +++ b/website/app/docs/pages.data.ts @@ -0,0 +1,55 @@ +import type { DocsPart } from "@/lib/docs-part"; + +export const DOCS_PAGE = { + INSTALL: { + EYEBROW: "Getting started · 01", + TITLE_HIGHLIGHT: "Toppings", + TITLE_BEFORE: "Install ", + TITLE_AFTER: " in 60 seconds.", + LEDE: + "Toppings ships on the official Chrome and Firefox stores. Install it the normal way — no sideloading, no developer mode, no account.", + CRUMB_GROUP: "Getting started", + CRUMB_CURRENT: "Install Toppings", + }, + KEYBINDINGS: { + EYEBROW: "Reference", + TITLE_HIGHLIGHT: "yours", + TITLE_BEFORE: "Every key, ", + TITLE_AFTER: " to remap.", + CRUMB_GROUP: "Reference", + CRUMB_CURRENT: "Keybindings", + }, + FAQ: { + EYEBROW: "Frequently asked", + TITLE_HIGHLIGHT: "answered", + TITLE_BEFORE: "Questions, ", + TITLE_AFTER: ".", + CRUMB_GROUP: "Reference", + CRUMB_CURRENT: "FAQ", + LEDE: [ + [ + { + kind: "text", + value: "The short version of what people ask us most. If yours isn't here, ", + }, + { + kind: "link", + target: "GITHUB_ISSUES", + label: "open an issue", + }, + { kind: "text", value: " — we read all of them." }, + ], + ] as const satisfies readonly (readonly DocsPart[])[], + }, + CHANGELOG: { + EYEBROW: "What changed, and when", + TITLE_HIGHLIGHT: "changelog", + TITLE_BEFORE: "The ", + TITLE_AFTER: ".", + CRUMB_GROUP: "Reference", + CRUMB_CURRENT: "Changelog", + LEDE_BEFORE: + "Every Toppings release in user-facing terms. The currently published version is ", + LEDE_LINK_LABEL: "GitHub history", + }, +} as const; diff --git a/website/app/farewell/data.ts b/website/app/farewell/data.ts new file mode 100644 index 00000000..11475576 --- /dev/null +++ b/website/app/farewell/data.ts @@ -0,0 +1,9 @@ +export const FAREWELL = { + KICKER: "Sorry to see you go", + TITLE_BEFORE: "We'll miss ", + TITLE_EM: "you", + TITLE_AFTER: ".", + BODY: + "We're sorry to see you go. We'd love to hear about any feedback you may have.", + FEEDBACK_BUTTON: "Help Us Improve!", +} as const; diff --git a/website/app/farewell/page.tsx b/website/app/farewell/page.tsx index a5c7126a..fa6d5a13 100644 --- a/website/app/farewell/page.tsx +++ b/website/app/farewell/page.tsx @@ -1,45 +1,19 @@ -import Image from "next/image"; -import Link from "next/link"; -import formIcon from "@/assets/icons/form.png"; -import farewellIllustration from "@/assets/illustrations/farewell.webp"; +import { FAREWELL } from "./data"; +import ReclaimMini from "@/components/reclaim/ReclaimMini"; +import { feedbackMailtoHref } from "@/lib/mailto"; export default function Farewell() { return ( -
-
-
-

- Goodbye! -
- We'll Miss You! -

-

- We're sorry to see you go. We'd love to hear about any - feedback you may have. -

-
- - Form - Help Us Improve! - -
-
-
- hero -
-
-
+ ); } diff --git a/website/app/globals.css b/website/app/globals.css index f26d7b23..3cfc2a28 100644 --- a/website/app/globals.css +++ b/website/app/globals.css @@ -4,45 +4,175 @@ @layer base { :root { - --background: 36 100% 98%; - --foreground: 0 100% 2%; + --cream: #fff9ef; + --ink: #0a0a0a; + --amber: #fca929; + --flame: #f57c20; - --card: 36 100% 98%; - --card-foreground: 0 100% 2%; + + --background: 36 100% 97%; + --foreground: 0 0% 4%; + --primary: 36 97% 57%; + --primary-foreground: 0 0% 4%; + --secondary: 36 30% 94%; + --secondary-foreground: 0 0% 4%; + --muted: 36 30% 94%; + --muted-foreground: 0 0% 32%; + --accent: 36 97% 57%; + --accent-foreground: 0 0% 4%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --border: 0 0% 90%; + --input: 0 0% 90%; + --ring: 36 97% 57%; + --popover: 36 100% 97%; + --popover-foreground: 0 0% 4%; + --card: 36 100% 97%; + --card-foreground: 0 0% 4%; + --radius: 0.5rem; - --popover: 0 0% 100%; - --popover-foreground: 222.2 84% 4.9%; + + --surface-page: var(--cream); + --surface-raised: #fffcf6; + --surface-sunken: #f8f1e1; + --surface-inverse: var(--ink); - --primary: 36 94.1% 58.1%; - --primary-foreground: 210 40% 98%; + --fg-1: var(--ink); + --fg-2: rgba(10, 10, 10, 0.72); + --fg-3: rgba(10, 10, 10, 0.52); + --fg-4: rgba(10, 10, 10, 0.32); - --secondary: 210 40% 96.1%; - --secondary-foreground: 222.2 47.4% 11.2%; + --border-1: rgba(10, 10, 10, 0.1); + --border-2: rgba(10, 10, 10, 0.18); + --border-strong: var(--ink); - --muted: 210 40% 96.1%; - --muted-foreground: 215.4 16.3% 46.9%; + --fg-on-ink-1: #f2efe7; + --fg-on-ink-2: rgba(242, 239, 231, 0.66); + --fg-on-ink-3: rgba(242, 239, 231, 0.4); + --border-on-ink-1: rgba(242, 239, 231, 0.12); - --accent: 210 40% 96.1%; - --accent-foreground: 222.2 47.4% 11.2%; + + --accent-color: var(--amber); + --accent-hover: var(--flame); + --accent-pressed: #e26a12; + --accent-on: var(--ink); - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 210 40% 98%; + + --highlighter: var(--amber); - --border: 214.3 31.8% 91.4%; - --input: 214.3 31.8% 91.4%; - --ring: 222.2 84% 4.9%; + + + --font-sans: + var(--font-inter), -apple-system, BlinkMacSystemFont, + "Helvetica Neue", Arial, sans-serif; + --font-mono: + var(--font-jetbrains-mono), "SF Mono", ui-monospace, Menlo, Consolas, + monospace; - --radius: 0.5rem; + --fw-regular: 400; + --fw-medium: 500; + --fw-semibold: 600; + --fw-display: 900; + + --tracking-display: -0.04em; + --tracking-heading: -0.022em; + --tracking-body: -0.011em; + --tracking-eyebrow: 0.14em; + + --lh-display: 0.94; + --lh-heading: 1.04; + --lh-body: 1.5; + --lh-tight: 1.2; + + --fs-eyebrow: 12px; + --fs-small: 13px; + --fs-body: 16px; + --fs-lead: 19px; + --fs-h4: 24px; + --fs-h3: 32px; + --fs-h2: 48px; + --fs-h1: 72px; + --fs-display: clamp(56px, 8.4vw, 128px); + + + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 24px; + --space-6: 32px; + --space-7: 48px; + --space-8: 64px; + --space-9: 96px; + --space-10: 128px; + + --radius-xs: 4px; + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 14px; + --radius-pill: 999px; + + + --ease-expo-out: cubic-bezier(0.22, 1, 0.36, 1); + --ease-standard: cubic-bezier(0.4, 0, 0.2, 1); + --dur-fast: 140ms; + --dur-base: 240ms; + --dur-slow: 480ms; + + + --shadow-1: + 0 1px 2px rgba(10, 10, 10, 0.04), 0 1px 1px rgba(10, 10, 10, 0.03); + --shadow-2: + 0 4px 12px rgba(10, 10, 10, 0.05), 0 1px 2px rgba(10, 10, 10, 0.04); + --shadow-3: + 0 12px 32px rgba(10, 10, 10, 0.08), 0 2px 6px rgba(10, 10, 10, 0.04); } -} -@layer base { * { @apply border-border; } + html, + body { + margin: 0; + padding: 0; + } body { - @apply bg-background text-foreground; - -webkit-font-smoothing: subpixel-antialiased!; - -webkit-text-stroke: 1px transparent; + font-family: var(--font-sans); + font-feature-settings: "ss01", "cv11"; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + background: var(--surface-page); + color: var(--fg-1); + } +} + +@layer components { + .amber-underline { + position: relative; + display: inline-block; + isolation: isolate; + color: var(--fg-1); + white-space: nowrap; + } + .amber-underline::before { + content: ""; + position: absolute; + inset: 18% -0.08em 12% -0.08em; + background: var(--highlighter); + z-index: -1; + transform: skew(-3deg); + border-radius: 2px; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; } } + diff --git a/website/app/greetings/data.ts b/website/app/greetings/data.ts new file mode 100644 index 00000000..af2234bd --- /dev/null +++ b/website/app/greetings/data.ts @@ -0,0 +1,8 @@ +export const GREETINGS = { + KICKER: "Toppings installed", + TITLE_BEFORE: "You're all ", + TITLE_EM: "set", + TITLE_AFTER: ".", + BODY: "Thank you for installing our extension.", + BODY_LINE_2: "Happy browsing!", +} as const; diff --git a/website/app/greetings/page.tsx b/website/app/greetings/page.tsx index 3cb2e631..77227a4e 100644 --- a/website/app/greetings/page.tsx +++ b/website/app/greetings/page.tsx @@ -1,50 +1,20 @@ -import Image from "next/image"; -import Link from "next/link"; -import questionMarkIcon from "@/assets/icons/question-mark.svg"; -import greetingsIllustration from "@/assets/illustrations/greetings.webp"; +import { GREETINGS } from "./data"; +import { LABEL, ROUTE } from "@/lib/site.data"; +import ReclaimMini from "@/components/reclaim/ReclaimMini"; export default function Greetings() { return ( -
-
-
-

- Success! -
- You're All Set! -

-

- Thank you for installing our extension.
- Happy browsing! -

-
- - Question Mark - Read Wiki - -
-
-
- hero -
-
-
+ ); } diff --git a/website/app/layout.tsx b/website/app/layout.tsx index 1141fa2e..73b4d325 100644 --- a/website/app/layout.tsx +++ b/website/app/layout.tsx @@ -1,15 +1,54 @@ import type { Metadata } from "next"; -import { Inter } from "next/font/google"; -import Navbar from "@/components/Navbar"; -import Footer from "@/components/Footer"; +import localFont from "next/font/local"; +import { Instrument_Serif, Geist } from "next/font/google"; import "./globals.css"; +import { HTML_LANG, METADATA } from "@/lib/site.data"; -const inter = Inter({ subsets: ["latin"] }); +const inter = localFont({ + variable: "--font-inter", + display: "swap", + src: [ + { path: "./_fonts/inter-400.woff2", weight: "400", style: "normal" }, + { path: "./_fonts/inter-500.woff2", weight: "500", style: "normal" }, + { path: "./_fonts/inter-600.woff2", weight: "600", style: "normal" }, + { path: "./_fonts/inter-700.woff2", weight: "700", style: "normal" }, + { path: "./_fonts/inter-900.woff2", weight: "900", style: "normal" }, + ], +}); + +const jetbrainsMono = localFont({ + variable: "--font-jetbrains-mono", + display: "swap", + src: [ + { + path: "./_fonts/jetbrains-mono-400.woff2", + weight: "400", + style: "normal", + }, + { + path: "./_fonts/jetbrains-mono-500.woff2", + weight: "500", + style: "normal", + }, + ], +}); + +const display = Instrument_Serif({ + weight: "400", + subsets: ["latin"], + variable: "--font-display", + display: "swap", +}); + +const ui = Geist({ + subsets: ["latin"], + variable: "--font-ui", + display: "swap", +}); export const metadata: Metadata = { - title: "Toppings: Your YouTube, Your Way", - description: - "A customizable browser extension that gives you total control over YouTube—track playlist runtimes, fine-tune playback speed, auto-scroll Shorts, set custom seek durations, and more. Take control of your YouTube like never before.", + title: METADATA.TITLE, + description: METADATA.DESCRIPTION, }; export default function RootLayout({ @@ -18,13 +57,12 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - - - + + {children} -