@orochibraru/ui 0.0.0 → 1.0.0-canary.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +97 -21
  2. package/dist/components/blocks/accent-toggle.svelte +85 -0
  3. package/dist/components/blocks/accent-toggle.svelte.d.ts +18 -0
  4. package/dist/components/blocks/bottom-nav.svelte +67 -0
  5. package/dist/components/blocks/bottom-nav.svelte.d.ts +18 -0
  6. package/dist/components/blocks/music-player/format-time.d.ts +2 -0
  7. package/dist/components/blocks/music-player/format-time.js +13 -0
  8. package/dist/components/blocks/music-player/labelled-slider.svelte +36 -0
  9. package/dist/components/blocks/music-player/labelled-slider.svelte.d.ts +15 -0
  10. package/dist/components/blocks/music-player/volume-control.svelte +48 -0
  11. package/dist/components/blocks/music-player/volume-control.svelte.d.ts +7 -0
  12. package/dist/components/blocks/music-player.svelte +263 -0
  13. package/dist/components/blocks/music-player.svelte.d.ts +26 -0
  14. package/dist/components/blocks/nav-cards.svelte +42 -0
  15. package/dist/components/blocks/nav-cards.svelte.d.ts +15 -0
  16. package/dist/components/blocks/new-password-fields.svelte +127 -0
  17. package/dist/components/blocks/new-password-fields.svelte.d.ts +19 -0
  18. package/dist/components/blocks/option-group.svelte +71 -0
  19. package/dist/components/blocks/option-group.svelte.d.ts +20 -0
  20. package/dist/components/blocks/otp-field.svelte +59 -0
  21. package/dist/components/blocks/otp-field.svelte.d.ts +12 -0
  22. package/dist/components/blocks/password-input.svelte +51 -0
  23. package/dist/components/blocks/password-input.svelte.d.ts +11 -0
  24. package/dist/components/blocks/stat-card.svelte +1 -1
  25. package/dist/components/blocks/tab-nav.svelte +38 -0
  26. package/dist/components/blocks/tab-nav.svelte.d.ts +14 -0
  27. package/dist/components/blocks/tab-section.svelte +48 -0
  28. package/dist/components/blocks/tab-section.svelte.d.ts +12 -0
  29. package/dist/components/blocks/theme-picker.svelte +69 -0
  30. package/dist/components/blocks/theme-picker.svelte.d.ts +16 -0
  31. package/dist/components/blocks/theme-toggle.svelte +68 -0
  32. package/dist/components/blocks/theme-toggle.svelte.d.ts +12 -0
  33. package/dist/components/blocks/video-player/center-controls.svelte +54 -0
  34. package/dist/components/blocks/video-player/center-controls.svelte.d.ts +10 -0
  35. package/dist/components/blocks/video-player/control-bar.svelte +184 -0
  36. package/dist/components/blocks/video-player/control-bar.svelte.d.ts +18 -0
  37. package/dist/components/blocks/video-player/control-button.svelte +54 -0
  38. package/dist/components/blocks/video-player/control-button.svelte.d.ts +13 -0
  39. package/dist/components/blocks/video-player/format.d.ts +9 -0
  40. package/dist/components/blocks/video-player/format.js +24 -0
  41. package/dist/components/blocks/video-player/gesture-feedback.svelte +32 -0
  42. package/dist/components/blocks/video-player/gesture-feedback.svelte.d.ts +7 -0
  43. package/dist/components/blocks/video-player/gestures.d.ts +13 -0
  44. package/dist/components/blocks/video-player/gestures.js +25 -0
  45. package/dist/components/blocks/video-player/keymap.d.ts +14 -0
  46. package/dist/components/blocks/video-player/keymap.js +55 -0
  47. package/dist/components/blocks/video-player/messages.d.ts +22 -0
  48. package/dist/components/blocks/video-player/messages.js +21 -0
  49. package/dist/components/blocks/video-player/option-menu.svelte +85 -0
  50. package/dist/components/blocks/video-player/option-menu.svelte.d.ts +18 -0
  51. package/dist/components/blocks/video-player/player.svelte.d.ts +37 -0
  52. package/dist/components/blocks/video-player/player.svelte.js +115 -0
  53. package/dist/components/blocks/video-player/scrub-bar.svelte +95 -0
  54. package/dist/components/blocks/video-player/scrub-bar.svelte.d.ts +12 -0
  55. package/dist/components/blocks/video-player/tap-gestures.svelte.d.ts +19 -0
  56. package/dist/components/blocks/video-player/tap-gestures.svelte.js +56 -0
  57. package/dist/components/blocks/video-player.svelte +288 -0
  58. package/dist/components/blocks/video-player.svelte.d.ts +29 -0
  59. package/dist/components/ui/accordion/accordion.svelte.d.ts +1 -1
  60. package/dist/components/ui/calendar/calendar.svelte.d.ts +1 -1
  61. package/dist/components/ui/command/command-dialog.svelte.d.ts +1 -1
  62. package/dist/components/ui/command/command-input.svelte.d.ts +1 -1
  63. package/dist/components/ui/command/command.svelte.d.ts +1 -1
  64. package/dist/components/ui/context-menu/context-menu-radio-group.svelte.d.ts +1 -1
  65. package/dist/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte.d.ts +1 -1
  66. package/dist/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte.d.ts +1 -1
  67. package/dist/components/ui/input/input.svelte +2 -2
  68. package/dist/components/ui/input/input.svelte.d.ts +1 -1
  69. package/dist/components/ui/input-group/input-group-input.svelte.d.ts +2 -2
  70. package/dist/components/ui/input-group/input-group-textarea.svelte.d.ts +1 -1
  71. package/dist/components/ui/input-group/input-group.svelte +1 -1
  72. package/dist/components/ui/input-otp/input-otp-slot.svelte +1 -1
  73. package/dist/components/ui/input-otp/input-otp.svelte.d.ts +1 -1
  74. package/dist/components/ui/menubar/menubar-radio-group.svelte.d.ts +1 -1
  75. package/dist/components/ui/radio-group/radio-group.svelte.d.ts +1 -1
  76. package/dist/components/ui/range-calendar/range-calendar.svelte.d.ts +1 -1
  77. package/dist/components/ui/select/select-trigger.svelte +1 -1
  78. package/dist/components/ui/sidebar/sidebar-input.svelte.d.ts +2 -2
  79. package/dist/components/ui/slider/slider.svelte.d.ts +1 -1
  80. package/dist/components/ui/tabs/tabs.svelte.d.ts +1 -1
  81. package/dist/components/ui/textarea/textarea.svelte +1 -1
  82. package/dist/components/ui/textarea/textarea.svelte.d.ts +1 -1
  83. package/dist/components/ui/toggle/toggle.svelte.d.ts +1 -1
  84. package/dist/components/ui/toggle-group/toggle-group-item.svelte.d.ts +1 -1
  85. package/dist/components/ui/toggle-group/toggle-group.svelte.d.ts +1 -1
  86. package/dist/mcp-content.json +1 -0
  87. package/dist/mcp.js +83 -0
  88. package/dist/password.d.ts +11 -0
  89. package/dist/password.js +38 -0
  90. package/package.json +15 -3
@@ -0,0 +1,51 @@
1
+ <script lang="ts">
2
+ import EyeIcon from "@lucide/svelte/icons/eye";
3
+ import EyeOffIcon from "@lucide/svelte/icons/eye-off";
4
+ import type { HTMLInputAttributes } from "svelte/elements";
5
+
6
+ import IconButton from "./icon-button.svelte";
7
+ import { Input } from "../ui/input/index.js";
8
+ import { cn } from "../../utils.js";
9
+
10
+ const defaultMessages = {
11
+ show: "Show password",
12
+ hide: "Hide password",
13
+ };
14
+
15
+ type Props = Omit<HTMLInputAttributes, "type" | "value" | "files"> & {
16
+ value?: string;
17
+ visible?: boolean;
18
+ messages?: Partial<typeof defaultMessages>;
19
+ };
20
+
21
+ let {
22
+ value = $bindable(""),
23
+ visible = $bindable(false),
24
+ name = "password",
25
+ autocomplete = "current-password",
26
+ messages = {},
27
+ class: className,
28
+ ...rest
29
+ }: Props = $props();
30
+
31
+ const text = $derived({ ...defaultMessages, ...messages });
32
+ </script>
33
+
34
+ <div class="relative">
35
+ <Input
36
+ {...rest}
37
+ {name}
38
+ {autocomplete}
39
+ type={visible ? "text" : "password"}
40
+ bind:value
41
+ class={cn("h-11 pr-12", className)}
42
+ />
43
+ <div class="absolute inset-y-0 right-0 flex items-center">
44
+ <IconButton
45
+ label={visible ? text.hide : text.show}
46
+ icon={visible ? EyeOffIcon : EyeIcon}
47
+ onclick={() => (visible = !visible)}
48
+ class="size-11 md:size-11"
49
+ />
50
+ </div>
51
+ </div>
@@ -0,0 +1,11 @@
1
+ import type { HTMLInputAttributes } from "svelte/elements";
2
+ declare const PasswordInput: import("svelte").Component<Omit<HTMLInputAttributes, "type" | "value" | "files"> & {
3
+ value?: string;
4
+ visible?: boolean;
5
+ messages?: Partial<{
6
+ show: string;
7
+ hide: string;
8
+ }>;
9
+ }, {}, "value" | "visible">;
10
+ type PasswordInput = ReturnType<typeof PasswordInput>;
11
+ export default PasswordInput;
@@ -31,7 +31,7 @@
31
31
  <svelte:element
32
32
  this={href ? "a" : "div"}
33
33
  {href}
34
- class="glass flex items-start gap-3 rounded-2xl p-4 transition-colors {href
34
+ class="glass flex min-w-0 items-start gap-3 rounded-2xl p-4 transition-colors {href
35
35
  ? 'hover:border-primary/40 active:bg-muted'
36
36
  : ''}"
37
37
  >
@@ -0,0 +1,38 @@
1
+ <script lang="ts" module>
2
+ import type { Component } from "svelte";
3
+
4
+ export interface Tab {
5
+ href: string;
6
+ label: string;
7
+ icon?: Component<{ class?: string }>;
8
+ }
9
+ </script>
10
+
11
+ <script lang="ts">
12
+ import { page } from "$app/state";
13
+
14
+ const { tabs }: { tabs: Tab[] } = $props();
15
+
16
+ function scrollIntoViewWhenActive(node: HTMLElement, active: boolean) {
17
+ if (active) {
18
+ node.scrollIntoView({ block: "nearest", inline: "center" });
19
+ }
20
+ }
21
+ </script>
22
+
23
+ <nav class="no-scrollbar flex overflow-x-auto py-1" aria-label="Sections">
24
+ <div class="glass flex w-full gap-1 rounded-full p-1 md:w-auto md:shrink-0">
25
+ {#each tabs as tab (tab.href)}
26
+ {@const active = page.url.pathname === tab.href}
27
+ <a
28
+ href={tab.href}
29
+ aria-current={active ? "page" : undefined}
30
+ use:scrollIntoViewWhenActive={active}
31
+ class="text-muted-foreground hover:bg-accent/70 hover:text-foreground aria-[current=page]:bg-primary aria-[current=page]:text-primary-foreground aria-[current=page]:glow focus-visible:ring-ring/50 flex h-9 min-w-0 flex-1 items-center justify-center gap-2 rounded-full px-2 md:flex-none md:shrink-0 md:px-4 text-sm font-medium transition-[color,background-color,box-shadow] outline-none focus-visible:ring-3"
32
+ >
33
+ {#if tab.icon}<tab.icon class="hidden size-4 sm:block" />{/if}
34
+ {tab.label}
35
+ </a>
36
+ {/each}
37
+ </div>
38
+ </nav>
@@ -0,0 +1,14 @@
1
+ import type { Component } from "svelte";
2
+ export interface Tab {
3
+ href: string;
4
+ label: string;
5
+ icon?: Component<{
6
+ class?: string;
7
+ }>;
8
+ }
9
+ type $$ComponentProps = {
10
+ tabs: Tab[];
11
+ };
12
+ declare const TabNav: Component<$$ComponentProps, {}, "">;
13
+ type TabNav = ReturnType<typeof TabNav>;
14
+ export default TabNav;
@@ -0,0 +1,48 @@
1
+ <script lang="ts">
2
+ import { goto } from "$app/navigation";
3
+ import { page } from "$app/state";
4
+ import type { Snippet } from "svelte";
5
+ import { MediaQuery } from "svelte/reactivity";
6
+
7
+ import type { SectionTab } from "./nav-cards.svelte";
8
+ import TabNav from "./tab-nav.svelte";
9
+ import { setPageTitle } from "../../page-title.svelte.js";
10
+
11
+ interface Props {
12
+ title: string;
13
+ indexHref: string;
14
+ backHref?: string;
15
+ tabs: SectionTab[];
16
+ children: Snippet;
17
+ }
18
+
19
+ const { title, indexHref, backHref, tabs, children }: Props = $props();
20
+
21
+ const onIndex = $derived(page.url.pathname === indexHref);
22
+ const active = $derived(
23
+ tabs.find(
24
+ (tab) =>
25
+ page.url.pathname === tab.href ||
26
+ page.url.pathname.startsWith(`${tab.href}/`),
27
+ ),
28
+ );
29
+
30
+ const desktop = new MediaQuery("(min-width: 768px)");
31
+
32
+ $effect(() => {
33
+ const first = tabs[0];
34
+ if (desktop.current && onIndex && first) {
35
+ goto(first.href, { replace: true });
36
+ }
37
+ });
38
+
39
+ setPageTitle(
40
+ () => (desktop.current || onIndex || !active ? title : active.label),
41
+ () => (desktop.current || onIndex ? backHref : indexHref),
42
+ );
43
+ </script>
44
+
45
+ <div class="mb-6 hidden md:block">
46
+ <TabNav {tabs} />
47
+ </div>
48
+ {@render children()}
@@ -0,0 +1,12 @@
1
+ import type { Snippet } from "svelte";
2
+ import type { SectionTab } from "./nav-cards.svelte";
3
+ interface Props {
4
+ title: string;
5
+ indexHref: string;
6
+ backHref?: string;
7
+ tabs: SectionTab[];
8
+ children: Snippet;
9
+ }
10
+ declare const TabSection: import("svelte").Component<Props, {}, "">;
11
+ type TabSection = ReturnType<typeof TabSection>;
12
+ export default TabSection;
@@ -0,0 +1,69 @@
1
+ <script lang="ts">
2
+ import MonitorSmartphoneIcon from "@lucide/svelte/icons/monitor-smartphone";
3
+ import MoonIcon from "@lucide/svelte/icons/moon";
4
+ import SunIcon from "@lucide/svelte/icons/sun";
5
+ import { setMode, userPrefersMode } from "mode-watcher";
6
+
7
+ import OptionGroup from "./option-group.svelte";
8
+
9
+ type Theme = "system" | "light" | "dark";
10
+
11
+ const defaultMessages = {
12
+ legend: "Theme",
13
+ system: "System",
14
+ systemDescription: "Follows your device: light or dark as it switches.",
15
+ light: "Light",
16
+ lightDescription: "Always light, whatever the device says.",
17
+ dark: "Dark",
18
+ darkDescription: "Always dark, easier on the eyes at night.",
19
+ };
20
+
21
+ interface Props {
22
+ value?: Theme;
23
+ name?: string;
24
+ onchange?: (theme: Theme) => void;
25
+ messages?: Partial<typeof defaultMessages>;
26
+ }
27
+
28
+ let {
29
+ value = $bindable(userPrefersMode.current),
30
+ name = "theme",
31
+ onchange,
32
+ messages = {},
33
+ }: Props = $props();
34
+
35
+ const text = $derived({ ...defaultMessages, ...messages });
36
+ const themes = $derived([
37
+ {
38
+ value: "system",
39
+ label: text.system,
40
+ description: text.systemDescription,
41
+ icon: MonitorSmartphoneIcon,
42
+ },
43
+ {
44
+ value: "light",
45
+ label: text.light,
46
+ description: text.lightDescription,
47
+ icon: SunIcon,
48
+ },
49
+ {
50
+ value: "dark",
51
+ label: text.dark,
52
+ description: text.darkDescription,
53
+ icon: MoonIcon,
54
+ },
55
+ ]);
56
+
57
+ function change() {
58
+ setMode(value);
59
+ onchange?.(value);
60
+ }
61
+ </script>
62
+
63
+ <OptionGroup
64
+ {name}
65
+ legend={text.legend}
66
+ options={themes}
67
+ bind:value
68
+ onchange={change}
69
+ />
@@ -0,0 +1,16 @@
1
+ declare const ThemePicker: import("svelte").Component<{
2
+ value?: "system" | "light" | "dark";
3
+ name?: string;
4
+ onchange?: (theme: "system" | "light" | "dark") => void;
5
+ messages?: Partial<{
6
+ legend: string;
7
+ system: string;
8
+ systemDescription: string;
9
+ light: string;
10
+ lightDescription: string;
11
+ dark: string;
12
+ darkDescription: string;
13
+ }>;
14
+ }, {}, "value">;
15
+ type ThemePicker = ReturnType<typeof ThemePicker>;
16
+ export default ThemePicker;
@@ -0,0 +1,68 @@
1
+ <script lang="ts">
2
+ import MonitorSmartphoneIcon from "@lucide/svelte/icons/monitor-smartphone";
3
+ import MoonIcon from "@lucide/svelte/icons/moon";
4
+ import SunIcon from "@lucide/svelte/icons/sun";
5
+ import { setMode, userPrefersMode } from "mode-watcher";
6
+ import { MediaQuery } from "svelte/reactivity";
7
+
8
+ import * as ToggleGroup from "../ui/toggle-group/index.js";
9
+
10
+ type Theme = "system" | "light" | "dark";
11
+
12
+ const defaultMessages = {
13
+ label: "Theme",
14
+ system: "System",
15
+ light: "Light",
16
+ dark: "Dark",
17
+ };
18
+
19
+ interface Props {
20
+ value?: Theme;
21
+ onchange?: (theme: Theme) => void;
22
+ messages?: Partial<typeof defaultMessages>;
23
+ }
24
+
25
+ let {
26
+ value = $bindable(userPrefersMode.current),
27
+ onchange,
28
+ messages = {},
29
+ }: Props = $props();
30
+
31
+ const desktop = new MediaQuery("(min-width: 768px)");
32
+ const text = $derived({ ...defaultMessages, ...messages });
33
+ const themes = $derived([
34
+ { value: "system", label: text.system, icon: MonitorSmartphoneIcon },
35
+ { value: "light", label: text.light, icon: SunIcon },
36
+ { value: "dark", label: text.dark, icon: MoonIcon },
37
+ ] as const);
38
+
39
+ function pick(next: string) {
40
+ if (!next) {
41
+ return;
42
+ }
43
+ value = next as Theme;
44
+ setMode(value);
45
+ onchange?.(value);
46
+ }
47
+ </script>
48
+
49
+ <ToggleGroup.Root
50
+ type="single"
51
+ variant="outline"
52
+ orientation={desktop.current ? "horizontal" : "vertical"}
53
+ aria-label={text.label}
54
+ class="max-md:w-full"
55
+ bind:value={() => value, pick}
56
+ >
57
+ {#each themes as theme (theme.value)}
58
+ <ToggleGroup.Item
59
+ value={theme.value}
60
+ aria-label={theme.label}
61
+ title={theme.label}
62
+ class="max-md:h-12 max-md:w-full max-md:justify-start max-md:gap-3 max-md:px-4 md:size-9"
63
+ >
64
+ <theme.icon class="size-4" />
65
+ <span class="md:hidden">{theme.label}</span>
66
+ </ToggleGroup.Item>
67
+ {/each}
68
+ </ToggleGroup.Root>
@@ -0,0 +1,12 @@
1
+ declare const ThemeToggle: import("svelte").Component<{
2
+ value?: "system" | "light" | "dark";
3
+ onchange?: (theme: "system" | "light" | "dark") => void;
4
+ messages?: Partial<{
5
+ label: string;
6
+ system: string;
7
+ light: string;
8
+ dark: string;
9
+ }>;
10
+ }, {}, "value">;
11
+ type ThemeToggle = ReturnType<typeof ThemeToggle>;
12
+ export default ThemeToggle;
@@ -0,0 +1,54 @@
1
+ <script lang="ts">
2
+ import PauseIcon from "@lucide/svelte/icons/pause";
3
+ import PlayIcon from "@lucide/svelte/icons/play";
4
+ import RotateCcwIcon from "@lucide/svelte/icons/rotate-ccw";
5
+ import RotateCwIcon from "@lucide/svelte/icons/rotate-cw";
6
+
7
+ import { SEEK_SECONDS } from "./keymap.js";
8
+ import type { VideoPlayerMessages } from "./messages.js";
9
+ import type { VideoPlayerState } from "./player.svelte.js";
10
+
11
+ interface Props {
12
+ player: VideoPlayerState;
13
+ paused: boolean;
14
+ text: VideoPlayerMessages;
15
+ }
16
+
17
+ const { player, paused, text }: Props = $props();
18
+
19
+ const side =
20
+ "bg-chrome text-foreground ring-border pointer-events-auto flex size-11 items-center justify-center rounded-full ring-1 backdrop-blur-xl transition-transform active:scale-90 sm:size-14";
21
+ </script>
22
+
23
+ <div class="flex items-center justify-center gap-8 sm:gap-12">
24
+ <button
25
+ type="button"
26
+ aria-label={text.seekBackward}
27
+ class={side}
28
+ onclick={() => player.seek(-SEEK_SECONDS)}
29
+ >
30
+ <RotateCcwIcon class="size-5 sm:size-6" />
31
+ </button>
32
+ <button
33
+ type="button"
34
+ aria-label={player.ended ? text.replay : paused ? text.play : text.pause}
35
+ class="bg-primary text-primary-foreground glow pointer-events-auto flex size-14 items-center justify-center rounded-full transition-transform active:scale-90 sm:size-18"
36
+ onclick={player.togglePlay}
37
+ >
38
+ {#if player.ended}
39
+ <RotateCcwIcon class="size-7 sm:size-8" />
40
+ {:else if paused}
41
+ <PlayIcon class="size-7 fill-current sm:size-8" />
42
+ {:else}
43
+ <PauseIcon class="size-7 fill-current sm:size-8" />
44
+ {/if}
45
+ </button>
46
+ <button
47
+ type="button"
48
+ aria-label={text.seekForward}
49
+ class={side}
50
+ onclick={() => player.seek(SEEK_SECONDS)}
51
+ >
52
+ <RotateCwIcon class="size-5 sm:size-6" />
53
+ </button>
54
+ </div>
@@ -0,0 +1,10 @@
1
+ import type { VideoPlayerMessages } from "./messages.js";
2
+ import type { VideoPlayerState } from "./player.svelte.js";
3
+ interface Props {
4
+ player: VideoPlayerState;
5
+ paused: boolean;
6
+ text: VideoPlayerMessages;
7
+ }
8
+ declare const CenterControls: import("svelte").Component<Props, {}, "">;
9
+ type CenterControls = ReturnType<typeof CenterControls>;
10
+ export default CenterControls;
@@ -0,0 +1,184 @@
1
+ <script lang="ts">
2
+ import CaptionsIcon from "@lucide/svelte/icons/captions";
3
+ import CaptionsOffIcon from "@lucide/svelte/icons/captions-off";
4
+ import GaugeIcon from "@lucide/svelte/icons/gauge";
5
+ import MaximizeIcon from "@lucide/svelte/icons/maximize";
6
+ import MinimizeIcon from "@lucide/svelte/icons/minimize";
7
+ import PauseIcon from "@lucide/svelte/icons/pause";
8
+ import PictureInPictureIcon from "@lucide/svelte/icons/picture-in-picture-2";
9
+ import PlayIcon from "@lucide/svelte/icons/play";
10
+ import RotateCcwIcon from "@lucide/svelte/icons/rotate-ccw";
11
+ import Volume1Icon from "@lucide/svelte/icons/volume-1";
12
+ import Volume2Icon from "@lucide/svelte/icons/volume-2";
13
+ import VolumeXIcon from "@lucide/svelte/icons/volume-x";
14
+
15
+ import ControlButton from "./control-button.svelte";
16
+ import {
17
+ type Chapter,
18
+ formatTime,
19
+ } from "./format.js";
20
+ import type { VideoPlayerMessages } from "./messages.js";
21
+ import OptionMenu from "./option-menu.svelte";
22
+ import type { VideoPlayerState } from "./player.svelte.js";
23
+ import ScrubBar from "./scrub-bar.svelte";
24
+
25
+ const RATES = [0.5, 0.75, 1, 1.25, 1.5, 2];
26
+
27
+ interface Props {
28
+ player: VideoPlayerState;
29
+ paused: boolean;
30
+ currentTime: number;
31
+ chapters: Chapter[];
32
+ subtitles: string[];
33
+ activeSubtitle: number;
34
+ onsubtitlechange: (index: number) => void;
35
+ pictureInPicture: boolean;
36
+ portalTarget: HTMLElement | string;
37
+ text: VideoPlayerMessages;
38
+ }
39
+
40
+ const {
41
+ player,
42
+ paused,
43
+ currentTime,
44
+ chapters,
45
+ subtitles,
46
+ activeSubtitle,
47
+ onsubtitlechange,
48
+ pictureInPicture,
49
+ portalTarget,
50
+ text,
51
+ }: Props = $props();
52
+
53
+ const bufferedEnd = $derived(player.buffered.at(-1)?.end ?? 0);
54
+ const silent = $derived(player.muted || player.volume === 0);
55
+ const playLabel = $derived(
56
+ player.ended ? text.replay : paused ? text.play : text.pause,
57
+ );
58
+ </script>
59
+
60
+ <div
61
+ class="glass pointer-events-auto mx-2 mb-2 rounded-2xl px-3 pt-1.5 pb-1 sm:mx-3 sm:mb-3"
62
+ >
63
+ <div class="flex items-center gap-3 text-xs font-medium tabular">
64
+ <span class="w-11 shrink-0">{formatTime(currentTime)}</span>
65
+ <div class="min-w-0 flex-1">
66
+ <ScrubBar
67
+ {currentTime}
68
+ duration={player.duration}
69
+ {bufferedEnd}
70
+ {chapters}
71
+ label={text.seek}
72
+ onseek={player.seekTo}
73
+ />
74
+ </div>
75
+ <span class="text-muted-foreground w-11 shrink-0 text-right"
76
+ >-{formatTime(Math.max(0, player.duration - currentTime))}</span
77
+ >
78
+ </div>
79
+ <div class="flex items-center gap-0.5">
80
+ <ControlButton
81
+ label={playLabel}
82
+ shortcut="K"
83
+ {portalTarget}
84
+ onclick={player.togglePlay}
85
+ class="bg-primary text-primary-foreground hover:bg-primary/85 hover:text-primary-foreground dark:hover:bg-primary/85 glow"
86
+ >
87
+ {#if player.ended}
88
+ <RotateCcwIcon />
89
+ {:else if paused}
90
+ <PlayIcon class="size-5 fill-current" />
91
+ {:else}
92
+ <PauseIcon class="size-5 fill-current" />
93
+ {/if}
94
+ </ControlButton>
95
+ <ControlButton
96
+ label={silent ? text.unmute : text.mute}
97
+ shortcut="M"
98
+ {portalTarget}
99
+ onclick={player.toggleMute}
100
+ >
101
+ {#if silent}
102
+ <VolumeXIcon />
103
+ {:else if player.volume < 0.5}
104
+ <Volume1Icon />
105
+ {:else}
106
+ <Volume2Icon />
107
+ {/if}
108
+ </ControlButton>
109
+ <input
110
+ type="range"
111
+ min="0"
112
+ max="1"
113
+ step="0.05"
114
+ value={player.muted ? 0 : player.volume}
115
+ aria-label={text.volume}
116
+ oninput={(event) => {
117
+ player.volume = Number(event.currentTarget.value);
118
+ player.muted = player.volume === 0;
119
+ }}
120
+ class="accent-primary pointer-fine:block hidden h-1 w-20 cursor-pointer"
121
+ />
122
+ <div class="ml-auto flex items-center gap-0.5">
123
+ {#if subtitles.length > 0}
124
+ <OptionMenu
125
+ label={text.subtitles}
126
+ shortcut="C"
127
+ options={[
128
+ { value: "-1", label: text.subtitlesOff },
129
+ ...subtitles.map((label, index) => ({
130
+ value: String(index),
131
+ label,
132
+ })),
133
+ ]}
134
+ value={String(activeSubtitle)}
135
+ active={activeSubtitle >= 0}
136
+ {portalTarget}
137
+ onselect={(value) => onsubtitlechange(Number(value))}
138
+ onopenchange={(open) => (player.menuOpen = open)}
139
+ >
140
+ {#if activeSubtitle >= 0}
141
+ <CaptionsIcon />
142
+ {:else}
143
+ <CaptionsOffIcon />
144
+ {/if}
145
+ </OptionMenu>
146
+ {/if}
147
+ <OptionMenu
148
+ label={text.speed}
149
+ options={RATES.map((rate) => ({
150
+ value: String(rate),
151
+ label: rate === 1 ? text.speedNormal : `${rate}×`,
152
+ }))}
153
+ value={String(player.rate)}
154
+ active={player.rate !== 1}
155
+ {portalTarget}
156
+ onselect={(value) => (player.rate = Number(value))}
157
+ onopenchange={(open) => (player.menuOpen = open)}
158
+ >
159
+ <GaugeIcon />
160
+ </OptionMenu>
161
+ {#if pictureInPicture}
162
+ <ControlButton
163
+ label={text.pictureInPicture}
164
+ {portalTarget}
165
+ onclick={() => void player.togglePictureInPicture()}
166
+ >
167
+ <PictureInPictureIcon />
168
+ </ControlButton>
169
+ {/if}
170
+ <ControlButton
171
+ label={player.fullscreen ? text.exitFullscreen : text.fullscreen}
172
+ shortcut="F"
173
+ {portalTarget}
174
+ onclick={() => void player.toggleFullscreen()}
175
+ >
176
+ {#if player.fullscreen}
177
+ <MinimizeIcon />
178
+ {:else}
179
+ <MaximizeIcon />
180
+ {/if}
181
+ </ControlButton>
182
+ </div>
183
+ </div>
184
+ </div>
@@ -0,0 +1,18 @@
1
+ import { type Chapter } from "./format.js";
2
+ import type { VideoPlayerMessages } from "./messages.js";
3
+ import type { VideoPlayerState } from "./player.svelte.js";
4
+ interface Props {
5
+ player: VideoPlayerState;
6
+ paused: boolean;
7
+ currentTime: number;
8
+ chapters: Chapter[];
9
+ subtitles: string[];
10
+ activeSubtitle: number;
11
+ onsubtitlechange: (index: number) => void;
12
+ pictureInPicture: boolean;
13
+ portalTarget: HTMLElement | string;
14
+ text: VideoPlayerMessages;
15
+ }
16
+ declare const ControlBar: import("svelte").Component<Props, {}, "">;
17
+ type ControlBar = ReturnType<typeof ControlBar>;
18
+ export default ControlBar;
@@ -0,0 +1,54 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from "svelte";
3
+
4
+ import { Button } from "../../ui/button/index.js";
5
+ import * as Tooltip from "../../ui/tooltip/index.js";
6
+ import { cn } from "../../../utils.js";
7
+
8
+ interface Props {
9
+ label: string;
10
+ shortcut?: string;
11
+ pressed?: boolean;
12
+ portalTarget: HTMLElement | string;
13
+ onclick: () => void;
14
+ class?: string;
15
+ children: Snippet;
16
+ }
17
+
18
+ const {
19
+ label,
20
+ shortcut,
21
+ pressed,
22
+ portalTarget,
23
+ onclick,
24
+ class: className,
25
+ children,
26
+ }: Props = $props();
27
+ </script>
28
+
29
+ <Tooltip.Root>
30
+ <Tooltip.Trigger>
31
+ {#snippet child({ props })}
32
+ <Button
33
+ {...props}
34
+ variant="ghost"
35
+ size="icon"
36
+ aria-label={label}
37
+ aria-pressed={pressed}
38
+ {onclick}
39
+ class={cn(
40
+ "size-11 rounded-full md:size-9 [&_svg:not([class*='size-'])]:size-5",
41
+ className,
42
+ )}
43
+ >
44
+ {@render children()}
45
+ </Button>
46
+ {/snippet}
47
+ </Tooltip.Trigger>
48
+ <Tooltip.Content portalProps={{ to: portalTarget }} sideOffset={6}>
49
+ {label}
50
+ {#if shortcut}
51
+ <kbd class="font-sans opacity-60">{shortcut}</kbd>
52
+ {/if}
53
+ </Tooltip.Content>
54
+ </Tooltip.Root>