@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.
- package/README.md +97 -21
- package/dist/components/blocks/accent-toggle.svelte +85 -0
- package/dist/components/blocks/accent-toggle.svelte.d.ts +18 -0
- package/dist/components/blocks/bottom-nav.svelte +67 -0
- package/dist/components/blocks/bottom-nav.svelte.d.ts +18 -0
- package/dist/components/blocks/music-player/format-time.d.ts +2 -0
- package/dist/components/blocks/music-player/format-time.js +13 -0
- package/dist/components/blocks/music-player/labelled-slider.svelte +36 -0
- package/dist/components/blocks/music-player/labelled-slider.svelte.d.ts +15 -0
- package/dist/components/blocks/music-player/volume-control.svelte +48 -0
- package/dist/components/blocks/music-player/volume-control.svelte.d.ts +7 -0
- package/dist/components/blocks/music-player.svelte +263 -0
- package/dist/components/blocks/music-player.svelte.d.ts +26 -0
- package/dist/components/blocks/nav-cards.svelte +42 -0
- package/dist/components/blocks/nav-cards.svelte.d.ts +15 -0
- package/dist/components/blocks/new-password-fields.svelte +127 -0
- package/dist/components/blocks/new-password-fields.svelte.d.ts +19 -0
- package/dist/components/blocks/option-group.svelte +71 -0
- package/dist/components/blocks/option-group.svelte.d.ts +20 -0
- package/dist/components/blocks/otp-field.svelte +59 -0
- package/dist/components/blocks/otp-field.svelte.d.ts +12 -0
- package/dist/components/blocks/password-input.svelte +51 -0
- package/dist/components/blocks/password-input.svelte.d.ts +11 -0
- package/dist/components/blocks/stat-card.svelte +1 -1
- package/dist/components/blocks/tab-nav.svelte +38 -0
- package/dist/components/blocks/tab-nav.svelte.d.ts +14 -0
- package/dist/components/blocks/tab-section.svelte +48 -0
- package/dist/components/blocks/tab-section.svelte.d.ts +12 -0
- package/dist/components/blocks/theme-picker.svelte +69 -0
- package/dist/components/blocks/theme-picker.svelte.d.ts +16 -0
- package/dist/components/blocks/theme-toggle.svelte +68 -0
- package/dist/components/blocks/theme-toggle.svelte.d.ts +12 -0
- package/dist/components/blocks/video-player/center-controls.svelte +54 -0
- package/dist/components/blocks/video-player/center-controls.svelte.d.ts +10 -0
- package/dist/components/blocks/video-player/control-bar.svelte +184 -0
- package/dist/components/blocks/video-player/control-bar.svelte.d.ts +18 -0
- package/dist/components/blocks/video-player/control-button.svelte +54 -0
- package/dist/components/blocks/video-player/control-button.svelte.d.ts +13 -0
- package/dist/components/blocks/video-player/format.d.ts +9 -0
- package/dist/components/blocks/video-player/format.js +24 -0
- package/dist/components/blocks/video-player/gesture-feedback.svelte +32 -0
- package/dist/components/blocks/video-player/gesture-feedback.svelte.d.ts +7 -0
- package/dist/components/blocks/video-player/gestures.d.ts +13 -0
- package/dist/components/blocks/video-player/gestures.js +25 -0
- package/dist/components/blocks/video-player/keymap.d.ts +14 -0
- package/dist/components/blocks/video-player/keymap.js +55 -0
- package/dist/components/blocks/video-player/messages.d.ts +22 -0
- package/dist/components/blocks/video-player/messages.js +21 -0
- package/dist/components/blocks/video-player/option-menu.svelte +85 -0
- package/dist/components/blocks/video-player/option-menu.svelte.d.ts +18 -0
- package/dist/components/blocks/video-player/player.svelte.d.ts +37 -0
- package/dist/components/blocks/video-player/player.svelte.js +115 -0
- package/dist/components/blocks/video-player/scrub-bar.svelte +95 -0
- package/dist/components/blocks/video-player/scrub-bar.svelte.d.ts +12 -0
- package/dist/components/blocks/video-player/tap-gestures.svelte.d.ts +19 -0
- package/dist/components/blocks/video-player/tap-gestures.svelte.js +56 -0
- package/dist/components/blocks/video-player.svelte +288 -0
- package/dist/components/blocks/video-player.svelte.d.ts +29 -0
- package/dist/components/ui/accordion/accordion.svelte.d.ts +1 -1
- package/dist/components/ui/calendar/calendar.svelte.d.ts +1 -1
- package/dist/components/ui/command/command-dialog.svelte.d.ts +1 -1
- package/dist/components/ui/command/command-input.svelte.d.ts +1 -1
- package/dist/components/ui/command/command.svelte.d.ts +1 -1
- package/dist/components/ui/context-menu/context-menu-radio-group.svelte.d.ts +1 -1
- package/dist/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte.d.ts +1 -1
- package/dist/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte.d.ts +1 -1
- package/dist/components/ui/input/input.svelte +2 -2
- package/dist/components/ui/input/input.svelte.d.ts +1 -1
- package/dist/components/ui/input-group/input-group-input.svelte.d.ts +2 -2
- package/dist/components/ui/input-group/input-group-textarea.svelte.d.ts +1 -1
- package/dist/components/ui/input-group/input-group.svelte +1 -1
- package/dist/components/ui/input-otp/input-otp-slot.svelte +1 -1
- package/dist/components/ui/input-otp/input-otp.svelte.d.ts +1 -1
- package/dist/components/ui/menubar/menubar-radio-group.svelte.d.ts +1 -1
- package/dist/components/ui/radio-group/radio-group.svelte.d.ts +1 -1
- package/dist/components/ui/range-calendar/range-calendar.svelte.d.ts +1 -1
- package/dist/components/ui/select/select-trigger.svelte +1 -1
- package/dist/components/ui/sidebar/sidebar-input.svelte.d.ts +2 -2
- package/dist/components/ui/slider/slider.svelte.d.ts +1 -1
- package/dist/components/ui/tabs/tabs.svelte.d.ts +1 -1
- package/dist/components/ui/textarea/textarea.svelte +1 -1
- package/dist/components/ui/textarea/textarea.svelte.d.ts +1 -1
- package/dist/components/ui/toggle/toggle.svelte.d.ts +1 -1
- package/dist/components/ui/toggle-group/toggle-group-item.svelte.d.ts +1 -1
- package/dist/components/ui/toggle-group/toggle-group.svelte.d.ts +1 -1
- package/dist/mcp-content.json +1 -0
- package/dist/mcp.js +83 -0
- package/dist/password.d.ts +11 -0
- package/dist/password.js +38 -0
- package/package.json +15 -3
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Snippet } from "svelte";
|
|
2
|
+
interface Props {
|
|
3
|
+
label: string;
|
|
4
|
+
shortcut?: string;
|
|
5
|
+
pressed?: boolean;
|
|
6
|
+
portalTarget: HTMLElement | string;
|
|
7
|
+
onclick: () => void;
|
|
8
|
+
class?: string;
|
|
9
|
+
children: Snippet;
|
|
10
|
+
}
|
|
11
|
+
declare const ControlButton: import("svelte").Component<Props, {}, "">;
|
|
12
|
+
type ControlButton = ReturnType<typeof ControlButton>;
|
|
13
|
+
export default ControlButton;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** A point on the timeline: a chapter start, or any marker worth a tick on the scrub bar. */
|
|
2
|
+
export interface Chapter {
|
|
3
|
+
start: number;
|
|
4
|
+
title?: string;
|
|
5
|
+
}
|
|
6
|
+
/** `h:mm:ss`, or `m:ss` under an hour; anything not finite reads `0:00`. */
|
|
7
|
+
export declare function formatTime(seconds: number): string;
|
|
8
|
+
/** The chapter playing at `time`, from a list sorted by start. */
|
|
9
|
+
export declare function chapterAt(chapters: Chapter[], time: number): Chapter | null;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** `h:mm:ss`, or `m:ss` under an hour; anything not finite reads `0:00`. */
|
|
2
|
+
export function formatTime(seconds) {
|
|
3
|
+
if (!Number.isFinite(seconds)) {
|
|
4
|
+
return "0:00";
|
|
5
|
+
}
|
|
6
|
+
const total = Math.max(0, Math.floor(seconds));
|
|
7
|
+
const hours = Math.floor(total / 3600);
|
|
8
|
+
const minutes = Math.floor((total % 3600) / 60);
|
|
9
|
+
const rest = String(total % 60).padStart(2, "0");
|
|
10
|
+
return hours > 0
|
|
11
|
+
? `${hours}:${String(minutes).padStart(2, "0")}:${rest}`
|
|
12
|
+
: `${minutes}:${rest}`;
|
|
13
|
+
}
|
|
14
|
+
/** The chapter playing at `time`, from a list sorted by start. */
|
|
15
|
+
export function chapterAt(chapters, time) {
|
|
16
|
+
let current = null;
|
|
17
|
+
for (const chapter of chapters) {
|
|
18
|
+
if (chapter.start > time) {
|
|
19
|
+
break;
|
|
20
|
+
}
|
|
21
|
+
current = chapter;
|
|
22
|
+
}
|
|
23
|
+
return current;
|
|
24
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import FastForwardIcon from "@lucide/svelte/icons/fast-forward";
|
|
3
|
+
import RewindIcon from "@lucide/svelte/icons/rewind";
|
|
4
|
+
|
|
5
|
+
import type { SeekFeedback } from "./tap-gestures.svelte.js";
|
|
6
|
+
import { cn } from "../../../utils.js";
|
|
7
|
+
|
|
8
|
+
const { feedback }: { feedback: SeekFeedback | null } = $props();
|
|
9
|
+
</script>
|
|
10
|
+
|
|
11
|
+
{#if feedback}
|
|
12
|
+
<div
|
|
13
|
+
class={cn(
|
|
14
|
+
"bg-foreground/10 pointer-events-none absolute inset-y-0 z-10 flex w-1/3 items-center justify-center",
|
|
15
|
+
feedback.side === "left"
|
|
16
|
+
? "left-0 rounded-r-[50%]"
|
|
17
|
+
: "right-0 rounded-l-[50%]",
|
|
18
|
+
)}
|
|
19
|
+
>
|
|
20
|
+
<span
|
|
21
|
+
class="glass flex items-center gap-1.5 rounded-full px-4 py-2 text-sm font-semibold tabular"
|
|
22
|
+
>
|
|
23
|
+
{#if feedback.side === "left"}
|
|
24
|
+
<RewindIcon class="size-4 fill-current" />
|
|
25
|
+
-{feedback.seconds}s
|
|
26
|
+
{:else}
|
|
27
|
+
+{feedback.seconds}s
|
|
28
|
+
<FastForwardIcon class="size-4 fill-current" />
|
|
29
|
+
{/if}
|
|
30
|
+
</span>
|
|
31
|
+
</div>
|
|
32
|
+
{/if}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { SeekFeedback } from "./tap-gestures.svelte.js";
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
feedback: SeekFeedback | null;
|
|
4
|
+
};
|
|
5
|
+
declare const GestureFeedback: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
6
|
+
type GestureFeedback = ReturnType<typeof GestureFeedback>;
|
|
7
|
+
export default GestureFeedback;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type GestureZone = "left" | "center" | "right";
|
|
2
|
+
export interface TapPoint {
|
|
3
|
+
x: number;
|
|
4
|
+
y: number;
|
|
5
|
+
time: number;
|
|
6
|
+
}
|
|
7
|
+
/** Two taps further apart than this, in milliseconds, are two single taps. */
|
|
8
|
+
export declare const DOUBLE_TAP_MS = 300;
|
|
9
|
+
/** ... and further apart than this, in pixels, too. */
|
|
10
|
+
export declare const DOUBLE_TAP_DISTANCE = 60;
|
|
11
|
+
/** The left and right thirds seek on a double tap; the middle one doesn't. */
|
|
12
|
+
export declare function gestureZone(x: number, width: number): GestureZone;
|
|
13
|
+
export declare function isDoubleTap(previous: TapPoint | null, next: TapPoint): boolean;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Two taps further apart than this, in milliseconds, are two single taps. */
|
|
2
|
+
export const DOUBLE_TAP_MS = 300;
|
|
3
|
+
/** ... and further apart than this, in pixels, too. */
|
|
4
|
+
export const DOUBLE_TAP_DISTANCE = 60;
|
|
5
|
+
/** The left and right thirds seek on a double tap; the middle one doesn't. */
|
|
6
|
+
export function gestureZone(x, width) {
|
|
7
|
+
if (width <= 0) {
|
|
8
|
+
return "center";
|
|
9
|
+
}
|
|
10
|
+
const ratio = x / width;
|
|
11
|
+
if (ratio < 1 / 3) {
|
|
12
|
+
return "left";
|
|
13
|
+
}
|
|
14
|
+
if (ratio > 2 / 3) {
|
|
15
|
+
return "right";
|
|
16
|
+
}
|
|
17
|
+
return "center";
|
|
18
|
+
}
|
|
19
|
+
export function isDoubleTap(previous, next) {
|
|
20
|
+
if (!previous) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
return (next.time - previous.time <= DOUBLE_TAP_MS &&
|
|
24
|
+
Math.hypot(next.x - previous.x, next.y - previous.y) <= DOUBLE_TAP_DISTANCE);
|
|
25
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface PlayerKeyActions {
|
|
2
|
+
togglePlay: () => void;
|
|
3
|
+
seek: (delta: number) => void;
|
|
4
|
+
adjustVolume: (delta: number) => void;
|
|
5
|
+
toggleMute: () => void;
|
|
6
|
+
toggleFullscreen: () => void;
|
|
7
|
+
cycleSubtitles: () => void;
|
|
8
|
+
}
|
|
9
|
+
export declare const SEEK_SECONDS = 10;
|
|
10
|
+
/**
|
|
11
|
+
* Runs the player shortcut for `event` and reports whether it took the key. Browser shortcuts (a modifier held), keys
|
|
12
|
+
* a focused field or menu handles itself, and Space on a focused button are left alone.
|
|
13
|
+
*/
|
|
14
|
+
export declare function handlePlayerKey(event: KeyboardEvent, actions: PlayerKeyActions): boolean;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export const SEEK_SECONDS = 10;
|
|
2
|
+
const VOLUME_STEP = 0.1;
|
|
3
|
+
const OWNS_KEYS = 'input, textarea, select, [contenteditable="true"], [role="slider"], [role^="menuitem"], [role="option"]';
|
|
4
|
+
const ACTIVATES_ON_SPACE = 'button, a[href], [role="button"]';
|
|
5
|
+
function focusedOn(event, selector) {
|
|
6
|
+
const target = event.target;
|
|
7
|
+
return Boolean(target?.closest?.(selector));
|
|
8
|
+
}
|
|
9
|
+
function action(key, actions) {
|
|
10
|
+
switch (key) {
|
|
11
|
+
case " ":
|
|
12
|
+
case "k":
|
|
13
|
+
return actions.togglePlay;
|
|
14
|
+
case "ArrowLeft":
|
|
15
|
+
case "j":
|
|
16
|
+
return () => actions.seek(-SEEK_SECONDS);
|
|
17
|
+
case "ArrowRight":
|
|
18
|
+
case "l":
|
|
19
|
+
return () => actions.seek(SEEK_SECONDS);
|
|
20
|
+
case "ArrowUp":
|
|
21
|
+
return () => actions.adjustVolume(VOLUME_STEP);
|
|
22
|
+
case "ArrowDown":
|
|
23
|
+
return () => actions.adjustVolume(-VOLUME_STEP);
|
|
24
|
+
case "m":
|
|
25
|
+
return actions.toggleMute;
|
|
26
|
+
case "f":
|
|
27
|
+
return actions.toggleFullscreen;
|
|
28
|
+
case "c":
|
|
29
|
+
return actions.cycleSubtitles;
|
|
30
|
+
default:
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Runs the player shortcut for `event` and reports whether it took the key. Browser shortcuts (a modifier held), keys
|
|
36
|
+
* a focused field or menu handles itself, and Space on a focused button are left alone.
|
|
37
|
+
*/
|
|
38
|
+
export function handlePlayerKey(event, actions) {
|
|
39
|
+
if (event.ctrlKey || event.metaKey || event.altKey) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
if (focusedOn(event, OWNS_KEYS)) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
if (event.key === " " && focusedOn(event, ACTIVATES_ON_SPACE)) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
const run = action(event.key, actions);
|
|
49
|
+
if (!run) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
event.preventDefault();
|
|
53
|
+
run();
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export declare const defaultMessages: {
|
|
2
|
+
region: string;
|
|
3
|
+
play: string;
|
|
4
|
+
pause: string;
|
|
5
|
+
replay: string;
|
|
6
|
+
seek: string;
|
|
7
|
+
seekBackward: string;
|
|
8
|
+
seekForward: string;
|
|
9
|
+
mute: string;
|
|
10
|
+
unmute: string;
|
|
11
|
+
volume: string;
|
|
12
|
+
speed: string;
|
|
13
|
+
speedNormal: string;
|
|
14
|
+
subtitles: string;
|
|
15
|
+
subtitlesOff: string;
|
|
16
|
+
pictureInPicture: string;
|
|
17
|
+
fullscreen: string;
|
|
18
|
+
exitFullscreen: string;
|
|
19
|
+
loading: string;
|
|
20
|
+
error: string;
|
|
21
|
+
};
|
|
22
|
+
export type VideoPlayerMessages = typeof defaultMessages;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export const defaultMessages = {
|
|
2
|
+
region: "Video player",
|
|
3
|
+
play: "Play",
|
|
4
|
+
pause: "Pause",
|
|
5
|
+
replay: "Replay",
|
|
6
|
+
seek: "Seek",
|
|
7
|
+
seekBackward: "Back 10 seconds",
|
|
8
|
+
seekForward: "Forward 10 seconds",
|
|
9
|
+
mute: "Mute",
|
|
10
|
+
unmute: "Unmute",
|
|
11
|
+
volume: "Volume",
|
|
12
|
+
speed: "Playback speed",
|
|
13
|
+
speedNormal: "Normal",
|
|
14
|
+
subtitles: "Subtitles",
|
|
15
|
+
subtitlesOff: "Off",
|
|
16
|
+
pictureInPicture: "Picture in picture",
|
|
17
|
+
fullscreen: "Fullscreen",
|
|
18
|
+
exitFullscreen: "Exit fullscreen",
|
|
19
|
+
loading: "Loading",
|
|
20
|
+
error: "This video can't be played.",
|
|
21
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from "svelte";
|
|
3
|
+
|
|
4
|
+
import { Button } from "../../ui/button/index.js";
|
|
5
|
+
import * as DropdownMenu from "../../ui/dropdown-menu/index.js";
|
|
6
|
+
import * as Tooltip from "../../ui/tooltip/index.js";
|
|
7
|
+
import { cn } from "../../../utils.js";
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
label: string;
|
|
11
|
+
shortcut?: string;
|
|
12
|
+
options: Array<{ value: string; label: string }>;
|
|
13
|
+
value: string;
|
|
14
|
+
active?: boolean;
|
|
15
|
+
portalTarget: HTMLElement | string;
|
|
16
|
+
onselect: (value: string) => void;
|
|
17
|
+
onopenchange: (open: boolean) => void;
|
|
18
|
+
children: Snippet;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const {
|
|
22
|
+
label,
|
|
23
|
+
shortcut,
|
|
24
|
+
options,
|
|
25
|
+
value,
|
|
26
|
+
active = false,
|
|
27
|
+
portalTarget,
|
|
28
|
+
onselect,
|
|
29
|
+
onopenchange,
|
|
30
|
+
children,
|
|
31
|
+
}: Props = $props();
|
|
32
|
+
</script>
|
|
33
|
+
|
|
34
|
+
<DropdownMenu.Root onOpenChange={onopenchange}>
|
|
35
|
+
<Tooltip.Root>
|
|
36
|
+
<Tooltip.Trigger>
|
|
37
|
+
{#snippet child({ props: tooltipProps })}
|
|
38
|
+
<DropdownMenu.Trigger {...tooltipProps}>
|
|
39
|
+
{#snippet child({ props })}
|
|
40
|
+
<Button
|
|
41
|
+
{...props}
|
|
42
|
+
variant="ghost"
|
|
43
|
+
size="icon"
|
|
44
|
+
aria-label={label}
|
|
45
|
+
class={cn(
|
|
46
|
+
"size-11 rounded-full md:size-9 [&_svg]:size-5",
|
|
47
|
+
active && "text-primary",
|
|
48
|
+
)}
|
|
49
|
+
>
|
|
50
|
+
{@render children()}
|
|
51
|
+
</Button>
|
|
52
|
+
{/snippet}
|
|
53
|
+
</DropdownMenu.Trigger>
|
|
54
|
+
{/snippet}
|
|
55
|
+
</Tooltip.Trigger>
|
|
56
|
+
<Tooltip.Content portalProps={{ to: portalTarget }} sideOffset={6}>
|
|
57
|
+
{label}
|
|
58
|
+
{#if shortcut}
|
|
59
|
+
<kbd class="font-sans opacity-60">{shortcut}</kbd>
|
|
60
|
+
{/if}
|
|
61
|
+
</Tooltip.Content>
|
|
62
|
+
</Tooltip.Root>
|
|
63
|
+
<DropdownMenu.Content
|
|
64
|
+
portalProps={{ to: portalTarget }}
|
|
65
|
+
side="top"
|
|
66
|
+
align="end"
|
|
67
|
+
sideOffset={12}
|
|
68
|
+
class="w-44 rounded-2xl p-1.5"
|
|
69
|
+
>
|
|
70
|
+
<DropdownMenu.Group>
|
|
71
|
+
<DropdownMenu.GroupHeading>{label}</DropdownMenu.GroupHeading>
|
|
72
|
+
<DropdownMenu.RadioGroup {value} onValueChange={onselect}>
|
|
73
|
+
{#each options as option (option.value)}
|
|
74
|
+
<DropdownMenu.RadioItem
|
|
75
|
+
value={option.value}
|
|
76
|
+
closeOnSelect
|
|
77
|
+
class="min-h-11 rounded-xl md:min-h-8"
|
|
78
|
+
>
|
|
79
|
+
<span class="truncate">{option.label}</span>
|
|
80
|
+
</DropdownMenu.RadioItem>
|
|
81
|
+
{/each}
|
|
82
|
+
</DropdownMenu.RadioGroup>
|
|
83
|
+
</DropdownMenu.Group>
|
|
84
|
+
</DropdownMenu.Content>
|
|
85
|
+
</DropdownMenu.Root>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Snippet } from "svelte";
|
|
2
|
+
interface Props {
|
|
3
|
+
label: string;
|
|
4
|
+
shortcut?: string;
|
|
5
|
+
options: Array<{
|
|
6
|
+
value: string;
|
|
7
|
+
label: string;
|
|
8
|
+
}>;
|
|
9
|
+
value: string;
|
|
10
|
+
active?: boolean;
|
|
11
|
+
portalTarget: HTMLElement | string;
|
|
12
|
+
onselect: (value: string) => void;
|
|
13
|
+
onopenchange: (open: boolean) => void;
|
|
14
|
+
children: Snippet;
|
|
15
|
+
}
|
|
16
|
+
declare const OptionMenu: import("svelte").Component<Props, {}, "">;
|
|
17
|
+
type OptionMenu = ReturnType<typeof OptionMenu>;
|
|
18
|
+
export default OptionMenu;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `<video>` element's state the controls show, bound off the element, and the actions they run on it. `currentTime`
|
|
3
|
+
* and `paused` stay with the component, which exposes them as bindable props.
|
|
4
|
+
*/
|
|
5
|
+
export declare class VideoPlayerState {
|
|
6
|
+
#private;
|
|
7
|
+
duration: number;
|
|
8
|
+
volume: number;
|
|
9
|
+
muted: boolean;
|
|
10
|
+
rate: number;
|
|
11
|
+
buffered: {
|
|
12
|
+
start: number;
|
|
13
|
+
end: number;
|
|
14
|
+
}[];
|
|
15
|
+
readyState: number;
|
|
16
|
+
seeking: boolean;
|
|
17
|
+
ended: boolean;
|
|
18
|
+
failed: boolean;
|
|
19
|
+
fullscreen: boolean;
|
|
20
|
+
controlsVisible: boolean;
|
|
21
|
+
menuOpen: boolean;
|
|
22
|
+
constructor(elements: {
|
|
23
|
+
video: () => HTMLVideoElement | null;
|
|
24
|
+
container: () => HTMLElement | null;
|
|
25
|
+
});
|
|
26
|
+
togglePlay: () => void;
|
|
27
|
+
seekTo: (time: number) => void;
|
|
28
|
+
seek: (delta: number) => void;
|
|
29
|
+
adjustVolume: (delta: number) => void;
|
|
30
|
+
toggleMute: () => void;
|
|
31
|
+
toggleFullscreen: () => Promise<void>;
|
|
32
|
+
syncFullscreen: () => void;
|
|
33
|
+
togglePictureInPicture: () => Promise<void>;
|
|
34
|
+
nudgeControls: () => void;
|
|
35
|
+
hideControls: () => void;
|
|
36
|
+
dispose: () => void;
|
|
37
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
const HIDE_CONTROLS_MS = 3000;
|
|
2
|
+
/**
|
|
3
|
+
* The `<video>` element's state the controls show, bound off the element, and the actions they run on it. `currentTime`
|
|
4
|
+
* and `paused` stay with the component, which exposes them as bindable props.
|
|
5
|
+
*/
|
|
6
|
+
export class VideoPlayerState {
|
|
7
|
+
duration = $state(0);
|
|
8
|
+
volume = $state(1);
|
|
9
|
+
muted = $state(false);
|
|
10
|
+
rate = $state(1);
|
|
11
|
+
buffered = $state([]);
|
|
12
|
+
readyState = $state(0);
|
|
13
|
+
seeking = $state(false);
|
|
14
|
+
ended = $state(false);
|
|
15
|
+
failed = $state(false);
|
|
16
|
+
fullscreen = $state(false);
|
|
17
|
+
controlsVisible = $state(true);
|
|
18
|
+
menuOpen = $state(false);
|
|
19
|
+
#video;
|
|
20
|
+
#container;
|
|
21
|
+
#hideTimer;
|
|
22
|
+
constructor(elements) {
|
|
23
|
+
this.#video = elements.video;
|
|
24
|
+
this.#container = elements.container;
|
|
25
|
+
}
|
|
26
|
+
togglePlay = () => {
|
|
27
|
+
const video = this.#video();
|
|
28
|
+
if (!video) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (video.ended) {
|
|
32
|
+
video.currentTime = 0;
|
|
33
|
+
}
|
|
34
|
+
if (video.paused || video.ended) {
|
|
35
|
+
video.play().catch(() => undefined);
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
video.pause();
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
seekTo = (time) => {
|
|
42
|
+
const video = this.#video();
|
|
43
|
+
if (!video) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const end = Number.isFinite(video.duration)
|
|
47
|
+
? video.duration
|
|
48
|
+
: Number.POSITIVE_INFINITY;
|
|
49
|
+
video.currentTime = Math.min(Math.max(0, time), end);
|
|
50
|
+
};
|
|
51
|
+
seek = (delta) => {
|
|
52
|
+
this.seekTo((this.#video()?.currentTime ?? 0) + delta);
|
|
53
|
+
};
|
|
54
|
+
adjustVolume = (delta) => {
|
|
55
|
+
const current = this.muted ? 0 : this.volume;
|
|
56
|
+
this.volume = Math.min(1, Math.max(0, Number((current + delta).toFixed(2))));
|
|
57
|
+
this.muted = this.volume === 0;
|
|
58
|
+
};
|
|
59
|
+
toggleMute = () => {
|
|
60
|
+
this.muted = !this.muted;
|
|
61
|
+
if (!this.muted && this.volume === 0) {
|
|
62
|
+
this.volume = 1;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
toggleFullscreen = async () => {
|
|
66
|
+
try {
|
|
67
|
+
if (document.fullscreenElement) {
|
|
68
|
+
await document.exitFullscreen();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const container = this.#container();
|
|
72
|
+
if (container?.requestFullscreen) {
|
|
73
|
+
await container.requestFullscreen();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
this.#video()?.webkitEnterFullscreen?.();
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
this.syncFullscreen();
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
syncFullscreen = () => {
|
|
83
|
+
const container = this.#container();
|
|
84
|
+
this.fullscreen =
|
|
85
|
+
container !== null && document.fullscreenElement === container;
|
|
86
|
+
};
|
|
87
|
+
togglePictureInPicture = async () => {
|
|
88
|
+
const video = this.#video();
|
|
89
|
+
try {
|
|
90
|
+
if (document.pictureInPictureElement) {
|
|
91
|
+
await document.exitPictureInPicture();
|
|
92
|
+
}
|
|
93
|
+
else if (video) {
|
|
94
|
+
await video.requestPictureInPicture();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
nudgeControls = () => {
|
|
102
|
+
this.controlsVisible = true;
|
|
103
|
+
clearTimeout(this.#hideTimer);
|
|
104
|
+
this.#hideTimer = setTimeout(() => {
|
|
105
|
+
if (this.#video()?.paused === false && !this.menuOpen) {
|
|
106
|
+
this.controlsVisible = false;
|
|
107
|
+
}
|
|
108
|
+
}, HIDE_CONTROLS_MS);
|
|
109
|
+
};
|
|
110
|
+
hideControls = () => {
|
|
111
|
+
clearTimeout(this.#hideTimer);
|
|
112
|
+
this.controlsVisible = false;
|
|
113
|
+
};
|
|
114
|
+
dispose = () => clearTimeout(this.#hideTimer);
|
|
115
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
type Chapter,
|
|
4
|
+
chapterAt,
|
|
5
|
+
formatTime,
|
|
6
|
+
} from "./format.js";
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
currentTime: number;
|
|
10
|
+
duration: number;
|
|
11
|
+
bufferedEnd: number;
|
|
12
|
+
chapters: Chapter[];
|
|
13
|
+
label: string;
|
|
14
|
+
onseek: (time: number) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const { currentTime, duration, bufferedEnd, chapters, label, onseek }: Props =
|
|
18
|
+
$props();
|
|
19
|
+
|
|
20
|
+
let track = $state<HTMLDivElement | null>(null);
|
|
21
|
+
let hoverRatio = $state<number | null>(null);
|
|
22
|
+
|
|
23
|
+
const ratio = (time: number) =>
|
|
24
|
+
duration > 0 ? Math.min(1, Math.max(0, time / duration)) : 0;
|
|
25
|
+
const hovered = $derived(
|
|
26
|
+
hoverRatio === null ? null : chapterAt(chapters, hoverRatio * duration),
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
function onpointermove(event: PointerEvent) {
|
|
30
|
+
if (!track || event.pointerType !== "mouse") {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const rect = track.getBoundingClientRect();
|
|
34
|
+
hoverRatio =
|
|
35
|
+
rect.width > 0
|
|
36
|
+
? Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width))
|
|
37
|
+
: null;
|
|
38
|
+
}
|
|
39
|
+
</script>
|
|
40
|
+
|
|
41
|
+
<div
|
|
42
|
+
bind:this={track}
|
|
43
|
+
role="presentation"
|
|
44
|
+
class="group/scrub relative flex h-6 items-center"
|
|
45
|
+
{onpointermove}
|
|
46
|
+
onpointerleave={() => (hoverRatio = null)}
|
|
47
|
+
>
|
|
48
|
+
<div
|
|
49
|
+
class="bg-foreground/15 pointer-events-none absolute inset-x-0 h-1.5 rounded-full transition-[height] group-hover/scrub:h-2"
|
|
50
|
+
></div>
|
|
51
|
+
<div
|
|
52
|
+
class="bg-foreground/20 pointer-events-none absolute h-1.5 rounded-full transition-[height] group-hover/scrub:h-2"
|
|
53
|
+
style:width="{ratio(bufferedEnd) * 100}%"
|
|
54
|
+
></div>
|
|
55
|
+
<div
|
|
56
|
+
class="bg-primary glow pointer-events-none absolute h-1.5 rounded-full transition-[height] group-hover/scrub:h-2"
|
|
57
|
+
style:width="{ratio(currentTime) * 100}%"
|
|
58
|
+
></div>
|
|
59
|
+
{#each chapters as chapter (chapter.start)}
|
|
60
|
+
{#if chapter.start > 0 && chapter.start < duration}
|
|
61
|
+
<div
|
|
62
|
+
class="bg-background pointer-events-none absolute h-2.5 w-0.5 -translate-x-1/2 rounded-full"
|
|
63
|
+
style:left="{ratio(chapter.start) * 100}%"
|
|
64
|
+
></div>
|
|
65
|
+
{/if}
|
|
66
|
+
{/each}
|
|
67
|
+
<div
|
|
68
|
+
class="bg-primary ring-background pointer-events-none absolute size-3.5 -translate-x-1/2 rounded-full shadow-md ring-2 transition-transform group-focus-within/scrub:scale-125 group-hover/scrub:scale-125"
|
|
69
|
+
style:left="{ratio(currentTime) * 100}%"
|
|
70
|
+
></div>
|
|
71
|
+
{#if hoverRatio !== null && duration > 0}
|
|
72
|
+
<div
|
|
73
|
+
class="bg-popover text-popover-foreground pointer-events-none absolute bottom-full mb-2 flex -translate-x-1/2 flex-col items-center rounded-lg px-2 py-1 text-xs font-medium tabular shadow-md"
|
|
74
|
+
style:left="{hoverRatio * 100}%"
|
|
75
|
+
>
|
|
76
|
+
{#if hovered?.title}
|
|
77
|
+
<span class="max-w-48 truncate font-semibold">{hovered.title}</span>
|
|
78
|
+
{/if}
|
|
79
|
+
<span class={hovered?.title ? "text-muted-foreground" : undefined}
|
|
80
|
+
>{formatTime(hoverRatio * duration)}</span
|
|
81
|
+
>
|
|
82
|
+
</div>
|
|
83
|
+
{/if}
|
|
84
|
+
<input
|
|
85
|
+
type="range"
|
|
86
|
+
min="0"
|
|
87
|
+
max={duration || 0}
|
|
88
|
+
step="0.1"
|
|
89
|
+
value={currentTime}
|
|
90
|
+
aria-label={label}
|
|
91
|
+
aria-valuetext="{formatTime(currentTime)} / {formatTime(duration)}"
|
|
92
|
+
oninput={(event) => onseek(Number(event.currentTarget.value))}
|
|
93
|
+
class="relative h-6 w-full cursor-pointer appearance-none bg-transparent focus-visible:outline-none [&::-moz-range-thumb]:size-5 [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-transparent [&::-webkit-slider-thumb]:size-5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:bg-transparent"
|
|
94
|
+
/>
|
|
95
|
+
</div>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type Chapter } from "./format.js";
|
|
2
|
+
interface Props {
|
|
3
|
+
currentTime: number;
|
|
4
|
+
duration: number;
|
|
5
|
+
bufferedEnd: number;
|
|
6
|
+
chapters: Chapter[];
|
|
7
|
+
label: string;
|
|
8
|
+
onseek: (time: number) => void;
|
|
9
|
+
}
|
|
10
|
+
declare const ScrubBar: import("svelte").Component<Props, {}, "">;
|
|
11
|
+
type ScrubBar = ReturnType<typeof ScrubBar>;
|
|
12
|
+
export default ScrubBar;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type GestureZone, type TapPoint } from "./gestures.js";
|
|
2
|
+
export interface SeekFeedback {
|
|
3
|
+
side: "left" | "right";
|
|
4
|
+
seconds: number;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Taps on the picture on a touch screen. A tap in the middle runs `onTap` at once; on a side it waits out the
|
|
8
|
+
* double-tap window, and a second tap there seeks instead, as does each further quick tap, adding up.
|
|
9
|
+
*/
|
|
10
|
+
export declare class TapGestures {
|
|
11
|
+
#private;
|
|
12
|
+
feedback: SeekFeedback | null;
|
|
13
|
+
constructor(actions: {
|
|
14
|
+
seek: (delta: number) => void;
|
|
15
|
+
onTap: () => void;
|
|
16
|
+
});
|
|
17
|
+
tap: (point: TapPoint, zone: GestureZone) => void;
|
|
18
|
+
dispose: () => void;
|
|
19
|
+
}
|