ink-framework 0.0.0-stage → 0.1.0-alpha.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/src/native.ts ADDED
@@ -0,0 +1,96 @@
1
+ declare const __inkPost: (message: string) => void;
2
+ declare const __inkPostBytes: (message: string, bytes: Uint8Array) => void;
3
+
4
+ type Listener = (message: Record<string, unknown>) => void;
5
+ const listeners = new Map<string, Listener>();
6
+ const pending = new Map<number, {
7
+ resolve: (value: string, bytes?: Uint8Array) => void;
8
+ reject: (error: unknown) => void;
9
+ dispose: () => void;
10
+ }>();
11
+ declare const __inkNextId: () => number;
12
+
13
+ export class NativeError extends Error {
14
+ constructor(readonly kind: string, message: string, readonly retryable = false) {
15
+ super(message);
16
+ this.name = "NativeError";
17
+ }
18
+ }
19
+
20
+ export function onNativeMessage(type: string, listener: Listener) {
21
+ listeners.set(type, listener);
22
+ return () => {
23
+ if (listeners.get(type) === listener) listeners.delete(type);
24
+ };
25
+ }
26
+
27
+ Object.defineProperty(globalThis, "__inkReceive", {
28
+ value: (source: string, bytes?: Uint8Array) => {
29
+ const message: unknown = JSON.parse(source);
30
+ if (typeof message !== "object" || message === null || !("type" in message)
31
+ || typeof message.type !== "string") throw new Error("Invalid native message");
32
+ if (bytes !== undefined) Object.assign(message, { bytes });
33
+ listeners.get(message.type)?.(message);
34
+ },
35
+ });
36
+
37
+ onNativeMessage("result", message => {
38
+ if (typeof message.id !== "number") throw new Error("Invalid native result ID");
39
+ const request = pending.get(message.id);
40
+ if (!request) return;
41
+ pending.delete(message.id);
42
+ request.dispose();
43
+ if (typeof message.value === "string") request.resolve(message.value, message.bytes instanceof Uint8Array ? message.bytes : undefined);
44
+ else if (typeof message.kind === "string" && typeof message.message === "string") {
45
+ request.reject(new NativeError(message.kind, message.message, message.retryable === true));
46
+ } else request.reject(new NativeError("protocol", "Invalid native result"));
47
+ });
48
+
49
+ type RequestOptions = { signal?: AbortSignal; timeoutMs?: number; controller?: number };
50
+ export type BinaryResult = { value: string; bytes: Uint8Array };
51
+
52
+ export function callNative(module: string, operation: string, payload: unknown, options: RequestOptions = {}): Promise<string> {
53
+ return request(module, operation, payload, options, value => value);
54
+ }
55
+
56
+ /** @internal Binary data travels beside the JSON envelope, never inside it. */
57
+ export function callNativeBytes(module: string, operation: string, payload: unknown,
58
+ options: RequestOptions & { bytes?: Uint8Array } = {}): Promise<BinaryResult> {
59
+ return request(module, operation, payload, options, (value, bytes) => ({ value, bytes: bytes ?? new Uint8Array(0) }), options.bytes);
60
+ }
61
+
62
+ function request<T>(module: string, operation: string, payload: unknown, options: RequestOptions,
63
+ result: (value: string, bytes?: Uint8Array) => T, bytes?: Uint8Array): Promise<T> {
64
+ const { signal, timeoutMs = 30_000 } = options;
65
+ if (options.controller !== undefined && (!Number.isSafeInteger(options.controller) || options.controller <= 0)) {
66
+ return Promise.reject(new RangeError("Native controller ID must be a positive safe integer"));
67
+ }
68
+ if (signal?.aborted) return Promise.reject(signal.reason);
69
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647) {
70
+ return Promise.reject(new RangeError("Native request timeout must be a positive 32-bit integer"));
71
+ }
72
+ if (pending.size >= 256) return Promise.reject(new NativeError("busy", "Too many pending native requests", true));
73
+ const id = __inkNextId();
74
+ return new Promise((resolve, reject) => {
75
+ const abort = () => {
76
+ const request = pending.get(id);
77
+ if (!request) return;
78
+ pending.delete(id);
79
+ request.dispose();
80
+ reject(signal?.reason);
81
+ __inkPost(JSON.stringify({ type: "cancel", id }));
82
+ };
83
+ pending.set(id, { resolve: (value, bytes) => resolve(result(value, bytes)), reject, dispose: () => signal?.removeEventListener("abort", abort) });
84
+ signal?.addEventListener("abort", abort, { once: true });
85
+ try {
86
+ const message = JSON.stringify({ type: "call", id, module, operation, payload, timeoutMs, controller: options.controller, binary: bytes !== undefined });
87
+ if (bytes === undefined) __inkPost(message);
88
+ else __inkPostBytes(message, bytes);
89
+ } catch (error) {
90
+ pending.delete(id);
91
+ signal?.removeEventListener("abort", abort);
92
+ const message = error instanceof Error ? error.message : String(error);
93
+ reject(message.startsWith("busy:") ? new NativeError("busy", message.slice(6), true) : error);
94
+ }
95
+ });
96
+ }
@@ -0,0 +1,237 @@
1
+ import type { IconAsset } from "./assets";
2
+ import {
3
+ Activity, Children, Fragment, createContext, createElement, isValidElement, useContext,
4
+ useLayoutEffect, useMemo, useRef, useState, type ReactElement, type ReactNode,
5
+ } from "./react";
6
+ import { onNativeMessage } from "./native";
7
+ import { comparePaths, matchPath, parameterNames, type PathParams } from "./route-path";
8
+
9
+ type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
10
+ type Params = Record<string, Json>;
11
+ export type Destination = string | { path: string; params?: Params };
12
+ export type Entry = { key: number; path: string; params: Params; tabGroup?: object; unavailable?: boolean; page?: ReactElement };
13
+ type NavigationAction = { type: "back" } | { type: "push" | "replace" | "open"; destination: Destination } | { type: "present"; page: ReactElement };
14
+ let dispatch: ((action: NavigationAction) => void) | undefined;
15
+ let openNativeRoute: ((destination: Destination) => void) | undefined;
16
+ let pendingNativeRoute: Destination | undefined;
17
+ onNativeMessage("navigate", message => {
18
+ if (typeof message.path !== "string" || !message.path.startsWith("/")) return;
19
+ const params = message.params;
20
+ if (params !== undefined && (typeof params !== "object" || params === null || Array.isArray(params))) return;
21
+ const destination: Destination = { path: message.path, params: params as Params | undefined };
22
+ if (openNativeRoute) openNativeRoute(destination);
23
+ else pendingNativeRoute = destination;
24
+ });
25
+ export const RouteContext = /* @__PURE__ */ createContext<Params | undefined>(undefined);
26
+ const TabRoutesContext = /* @__PURE__ */ createContext<((group: object, paths: readonly string[], ownsStart: boolean) => void) | null>(null);
27
+
28
+ function send(action: NavigationAction) {
29
+ if (!dispatch) throw new Error("Navigation is unavailable before the app mounts");
30
+ dispatch(action);
31
+ }
32
+
33
+ export type CheckedDestination<D extends Destination> = D extends string
34
+ ? keyof PathParams<D> extends never ? D : never
35
+ : D extends { path: infer Path extends string }
36
+ ? keyof PathParams<Path> extends never ? D : D & { params: { [Key in keyof PathParams<Path>]: string | number } }
37
+ : never;
38
+
39
+ export function navigate<const D extends Destination>(destination: D & CheckedDestination<D>) { send({ type: "push", destination }); }
40
+ export function replace<const D extends Destination>(destination: D & CheckedDestination<D>) { send({ type: "replace", destination }); }
41
+ export function back() { send({ type: "back" }); }
42
+
43
+ export function presentPage(page: ReactElement) { send({ type: "present", page }); }
44
+
45
+ export function useRouteParams<Path extends string>(path: Path): PathParams<Path>;
46
+ export function useRouteParams(): Readonly<Partial<Params>>;
47
+ export function useRouteParams<T extends object>(decode: (value: unknown) => T): T;
48
+ export function useRouteParams(decode?: string | ((value: unknown) => object)): object {
49
+ const params = useContext(RouteContext);
50
+ if (!params) throw new Error("useRouteParams must be used inside a page");
51
+ if (typeof decode === "function") return decode(params);
52
+ if (typeof decode === "string") {
53
+ for (const name of parameterNames(decode)) {
54
+ if (typeof params[name] !== "string" || !params[name]) throw new Error(`Missing route parameter: ${name}`);
55
+ }
56
+ }
57
+ return params;
58
+ }
59
+
60
+ function resolveDestination(routes: ReadonlySet<string>, destination: Destination) {
61
+ const target = typeof destination === "string" ? { path: destination, params: {} } : destination;
62
+ if (routes.has(target.path)) {
63
+ const params = { ...target.params };
64
+ for (const name of parameterNames(target.path)) {
65
+ const value = params[name];
66
+ if ((typeof value !== "string" && typeof value !== "number") || String(value) === "") throw new Error(`Missing route parameter: ${name}`);
67
+ params[name] = String(value);
68
+ }
69
+ return { path: target.path, params };
70
+ }
71
+ for (const pattern of [...routes.keys()].sort(comparePaths)) {
72
+ const params = matchPath(pattern, target.path);
73
+ if (params) return { path: pattern, params: { ...target.params, ...params } };
74
+ }
75
+ throw new Error(`Unknown route: ${target.path}`);
76
+ }
77
+
78
+ export function NavigationStack({ routes, renderEntries, initialDestination }: {
79
+ routes: ReadonlySet<string>;
80
+ renderEntries: (entries: Entry[], selectTab: (path: string) => void) => ReactNode;
81
+ initialDestination?: Destination;
82
+ }) {
83
+ const [entries, setEntries] = useState<Entry[]>(() => {
84
+ const start = { key: 0, path: "/", params: {} };
85
+ if (initialDestination === undefined) return [start];
86
+ const target = resolveDestination(routes, initialDestination);
87
+ return target.path === "/" ? [{ ...start, ...target }] : [start, { key: 1, ...target }];
88
+ });
89
+ const nextKey = useRef(initialDestination === undefined ? 1 : 2);
90
+ // Tab membership belongs to the route table, even while its layout is covered or unmounted.
91
+ const tabGroups = useMemo(() => new Map<object, readonly string[]>(), [routes]);
92
+ const registerTabs = useMemo(() => (group: object, paths: readonly string[], ownsStart: boolean) => {
93
+ for (const path of paths) {
94
+ if (!routes.has(path)) throw new Error(`Unknown tab route: ${path}`);
95
+ if ([...tabGroups].some(([owner, members]) => owner !== group && members.includes(path))) {
96
+ throw new Error(`Route belongs to more than one tab layout: ${path}`);
97
+ }
98
+ }
99
+ const previous = tabGroups.get(group);
100
+ if (previous?.length === paths.length && previous.every((path, index) => path === paths[index])) return;
101
+ tabGroups.set(group, paths);
102
+ // Coalesce visits made before the tab layout registered.
103
+ setEntries(current => {
104
+ const members = current.filter(entry => !entry.page && !entry.unavailable && (
105
+ entry.tabGroup === group || paths.includes(entry.path) || ownsStart && entry.key === 0
106
+ ));
107
+ if (!members.length) return current;
108
+ const first = members[0];
109
+ const last = members[members.length - 1];
110
+ const retained = paths.includes(last.path);
111
+ return [...current.slice(0, current.indexOf(first)), { ...first, tabGroup: group, path: retained ? last.path : paths[0], params: retained ? last.params : {} }, ...current.slice(current.indexOf(last) + 1)];
112
+ });
113
+ }, [routes, tabGroups]);
114
+ const resolve = useMemo(() => (destination: Destination) => resolveDestination(routes, destination), [routes]);
115
+ useLayoutEffect(() => {
116
+ if (dispatch) throw new Error("An Ink app can mount one router");
117
+ dispatch = action => {
118
+ if (action.type === "back") {
119
+ setEntries(current => current.length > 1 ? current.slice(0, -1) : current);
120
+ return;
121
+ }
122
+ if (action.type === "present") {
123
+ const key = nextKey.current++;
124
+ setEntries(current => [...current, { key, path: "", params: {}, page: action.page }]);
125
+ return;
126
+ }
127
+ const target = resolve(action.destination);
128
+ const entry = { key: nextKey.current++, ...target };
129
+ setEntries(current => {
130
+ const group = [...tabGroups].find(([, paths]) => paths.includes(target.path))?.[0];
131
+ const index = group ? current.findIndex(entry => entry.tabGroup === group) : -1;
132
+ if (index >= 0) return [...current.slice(0, index), { ...current[index], path: entry.path, params: entry.params }];
133
+ const next = { ...entry, tabGroup: group };
134
+ if (action.type === "open") return current[0].path === next.path ? [next] : [current[0], next];
135
+ return action.type === "replace" ? [...current.slice(0, -1), next] : [...current, next];
136
+ });
137
+ };
138
+ openNativeRoute = destination => {
139
+ const path = typeof destination === "string" ? destination : destination.path;
140
+ try { resolve(destination); } catch {
141
+ setEntries(current => [current[0], {
142
+ key: nextKey.current++, path, params: {}, unavailable: true,
143
+ }]);
144
+ return;
145
+ }
146
+ dispatch?.({ type: "open", destination });
147
+ };
148
+ if (pendingNativeRoute !== undefined) {
149
+ const path = pendingNativeRoute;
150
+ pendingNativeRoute = undefined;
151
+ openNativeRoute(path);
152
+ }
153
+ return () => { dispatch = undefined; openNativeRoute = undefined; };
154
+ }, [routes, tabGroups, resolve]);
155
+ const nativeProps = { onBack: entries.length > 1 ? back : undefined };
156
+ return createElement(TabRoutesContext.Provider, { value: registerTabs }, createElement("Navigator", nativeProps,
157
+ renderEntries(entries, navigate),
158
+ ));
159
+ }
160
+
161
+ export const LayoutContext = /* @__PURE__ */ createContext<{
162
+ content: ReactNode;
163
+ pages: ReadonlyMap<string, { path: string; page: ReactNode }>;
164
+ directories: readonly string[];
165
+ activePath: string;
166
+ group: object;
167
+ tabPath?: string;
168
+ ordinaryPages: (tabPaths: readonly string[]) => ReactNode;
169
+ selectTab: (path: string) => void;
170
+ } | null>(null);
171
+
172
+ export function Slot() {
173
+ const layout = useContext(LayoutContext);
174
+ if (!layout) throw new Error("Slot belongs in an app/_layout.tsx file");
175
+ return layout.content;
176
+ }
177
+
178
+ type TabScreenProps = { name: string; icon: IconAsset };
179
+ function TabScreen(_props: TabScreenProps): ReactNode {
180
+ throw new Error("Tabs.Screen must be a direct child of Tabs");
181
+ }
182
+
183
+ type TabActionProps = { icon: IconAsset; onPress: () => void };
184
+ function TabAction(_props: TabActionProps): ReactNode {
185
+ throw new Error("Tabs.Action must be a direct child of Tabs");
186
+ }
187
+
188
+ function actionItem(action: ReactElement<TabActionProps>) {
189
+ return createElement("Tab", {
190
+ key: `action:${action.key}`, icon: action.props.icon, onPress: action.props.onPress,
191
+ });
192
+ }
193
+
194
+ export function Tabs({ children }: { children: ReactNode }) {
195
+ const layout = useContext(LayoutContext);
196
+ const registerTabs = useContext(TabRoutesContext);
197
+ if (!layout) throw new Error("Tabs.Screen belongs in an app/_layout.tsx file");
198
+ if (layout.directories.length) throw new Error(`Tab layouts support neighbouring page files only. Move detail directories outside this tab group: ${layout.directories.join(", ")}`);
199
+ const names = new Set<string>();
200
+ const items: ({ action: ReactElement<TabActionProps> } | { name: string; icon: IconAsset; path: string; page: ReactNode })[] = [];
201
+ Children.toArray(children).forEach(child => {
202
+ if (isValidElement<TabActionProps>(child) && child.type === TabAction) {
203
+ items.push({ action: child });
204
+ return;
205
+ }
206
+ if (!isValidElement<TabScreenProps>(child) || child.type !== TabScreen) throw new Error("Use Tabs.Screen or Tabs.Action in a file-based tab layout");
207
+ const { name, icon } = child.props;
208
+ if (names.has(name)) throw new Error(`Duplicate tab: ${name}`);
209
+ names.add(name);
210
+ const page = layout.pages.get(name);
211
+ if (!page) throw new Error(`No neighbouring page for tab: ${name}`);
212
+ if (parameterNames(page.path).length) throw new Error(`Tabs need static paths. Move ${page.path} outside the tab group`);
213
+ items.push({ name, icon, ...page });
214
+ });
215
+ const tabs = items.filter(item => "path" in item);
216
+ if (!tabs.length) throw new Error("Tabs requires at least one Tabs.Screen");
217
+ const paths = tabs.map(tab => tab.path);
218
+ const ownsStart = [...layout.pages.values()].some(page => page.path === "/");
219
+ useLayoutEffect(() => {
220
+ registerTabs?.(layout.group, paths, ownsStart);
221
+ }, [layout.group, JSON.stringify(paths), ownsStart, registerTabs]);
222
+ const selected = tabs.find(tab => tab.path === (layout.tabPath ?? layout.activePath)) ?? tabs[0];
223
+ const active = items.indexOf(selected);
224
+ const visible = paths.includes(layout.activePath) || layout.tabPath === layout.activePath;
225
+ const bar = createElement("Tabs", { active }, items.map((tab, index) => {
226
+ if ("action" in tab) return actionItem(tab.action);
227
+ return createElement("Tab", {
228
+ key: tab.name, icon: tab.icon, onPress: () => layout.selectTab(tab.path),
229
+ }, createElement(Activity, { mode: index === active ? "visible" : "hidden", children: tab.page }));
230
+ }));
231
+ return createElement(Fragment, null,
232
+ createElement(Activity, { mode: visible ? "visible" : "hidden", children: bar }),
233
+ layout.ordinaryPages(paths));
234
+ }
235
+
236
+ Tabs.Screen = TabScreen;
237
+ Tabs.Action = TabAction;
@@ -0,0 +1,73 @@
1
+ import { createElement, type ReactNode } from "./react";
2
+ import { Button, Stack, Text } from "./index";
3
+
4
+ export type SettingsChoice<Value extends string> = {
5
+ value: Value;
6
+ label: string;
7
+ };
8
+
9
+ export type SettingsChoicesProps<Value extends string> = {
10
+ options: readonly SettingsChoice<Value>[];
11
+ value: Value;
12
+ onChange: (value: Value) => void;
13
+ disabled?: boolean;
14
+ };
15
+
16
+ export function SettingsChoices<Value extends string>({ options, value, onChange, disabled }: SettingsChoicesProps<Value>) {
17
+ return createElement(Stack, { gap: 47 }, options.map(option => createElement(Button, {
18
+ key: option.value,
19
+ selected: option.value === value,
20
+ disabled,
21
+ onPress: () => onChange(option.value),
22
+ }, option.label)));
23
+ }
24
+
25
+ export function LoadingState({ label = "Loading…" }: { label?: string }) {
26
+ return createElement("ScreenState", { message: label });
27
+ }
28
+
29
+ type StateAction = { label: string; onPress: () => void; disabled?: boolean };
30
+
31
+ export type EmptyStateProps = {
32
+ title: string;
33
+ description?: string;
34
+ action?: StateAction;
35
+ };
36
+
37
+ export function EmptyState({ title, description, action }: EmptyStateProps) {
38
+ return createElement("ScreenState", {
39
+ message: description ? `${title}\n\n${description}` : title,
40
+ retryLabel: action?.label.toUpperCase(),
41
+ onRetry: action?.onPress,
42
+ disabled: action?.disabled,
43
+ });
44
+ }
45
+
46
+ export type ErrorStateProps = {
47
+ message: string;
48
+ onRetry: () => void;
49
+ retryLabel?: string;
50
+ disabled?: boolean;
51
+ };
52
+
53
+ export function ErrorState({ message, onRetry, retryLabel = "Try again", disabled }: ErrorStateProps) {
54
+ return createElement("ScreenState", { message, retryLabel: retryLabel.toUpperCase(), onRetry, disabled });
55
+ }
56
+
57
+ export type ConfirmationProps = {
58
+ title: string;
59
+ children: ReactNode;
60
+ confirmLabel: string;
61
+ onConfirm: () => void;
62
+ pending?: boolean;
63
+ pendingLabel?: string;
64
+ };
65
+
66
+ export function Confirmation({ title, children, confirmLabel, onConfirm, pending = false, pendingLabel = "Working…" }: ConfirmationProps) {
67
+ return createElement("Confirmation", {
68
+ title,
69
+ confirmLabel: (pending ? pendingLabel : confirmLabel).toUpperCase(),
70
+ onConfirm,
71
+ pending,
72
+ }, createElement(Text, { size: 18, align: "start" }, children));
73
+ }
@@ -0,0 +1 @@
1
+ export const playbackController = Symbol("playbackController");
package/src/playing.ts ADDED
@@ -0,0 +1,79 @@
1
+ import { createElement, useRef } from "./react";
2
+ import { Screen, ErrorState, type IconAsset } from "./index";
3
+ import { useAction } from "./action";
4
+ import { playbackController } from "./playback-binding";
5
+ import { forward10Filled, forward30Filled, forward5Filled, pauseFilled, playArrowFilled, replay10Filled, replay30Filled, replay5Filled, skipNextFilled, skipPreviousFilled } from "./icons";
6
+
7
+ type PlaybackAction = () => void | Promise<void>;
8
+ export interface Playback {
9
+ readonly state: { position: number; duration: number; playWhenReady: boolean; loading?: boolean; buffering?: boolean; speed?: number };
10
+ readonly [playbackController]?: number;
11
+ toggle(): void | Promise<void>;
12
+ seek(position: number): void | Promise<void>;
13
+ seekBy?(offset: number): void | Promise<void>;
14
+ previous?(): void | Promise<void>;
15
+ next?(): void | Promise<void>;
16
+ }
17
+
18
+ export type TransportControl = { onLongPress?: PlaybackAction; disabled?: boolean } & (
19
+ | { kind: "track"; seconds?: never; icon?: never; onPress?: never }
20
+ | { kind: "skip"; seconds: 5 | 10 | 30; icon?: never; onPress?: never }
21
+ | { kind: "custom"; icon: IconAsset; onPress: PlaybackAction; seconds?: never }
22
+ );
23
+ export type PlayingScreenProps = {
24
+ image?: string;
25
+ preloadImages?: readonly string[];
26
+ title: string;
27
+ onTitlePress?: () => void;
28
+ artists: readonly { name: string; onPress?: () => void }[];
29
+ playback: Playback;
30
+ previous?: TransportControl;
31
+ next?: TransportControl;
32
+ actions?: readonly (({ icon: IconAsset; label?: never } | { label: string; icon?: never }) & { selected?: boolean; disabled?: boolean; onPress: PlaybackAction })[];
33
+ };
34
+
35
+ const replayIcons = { 5: replay5Filled, 10: replay10Filled, 30: replay30Filled };
36
+ const forwardIcons = { 5: forward5Filled, 10: forward10Filled, 30: forward30Filled };
37
+
38
+ export function PlayingScreen({ image, preloadImages = [], title, onTitlePress, artists, playback, previous = { kind: "track" }, next = { kind: "track" }, actions = [] }: PlayingScreenProps) {
39
+ const retry = useRef<PlaybackAction>(() => {});
40
+ const command = useAction(async (operation: PlaybackAction) => {
41
+ retry.current = operation;
42
+ await operation();
43
+ });
44
+ const run = command.run;
45
+ const transport = (action: TransportControl, direction: -1 | 1): PlaybackAction | undefined => {
46
+ if (action.kind === "custom") return action.onPress;
47
+ if (action.kind === "skip") {
48
+ const seekBy = playback.seekBy;
49
+ return seekBy && (() => seekBy.call(playback, direction * action.seconds * 1000));
50
+ }
51
+ const move = direction === -1 ? playback.previous : playback.next;
52
+ return move && (() => move.call(playback));
53
+ };
54
+ const previousPress = transport(previous, -1);
55
+ const nextPress = transport(next, 1);
56
+ const bind = (operation: PlaybackAction | undefined) => operation && (() => run(operation));
57
+ const { position, duration, playWhenReady: playing, loading = false, buffering = false, speed = 1 } = playback.state;
58
+ const clock = playback[playbackController];
59
+ if (command.status === "error") return createElement(Screen, { title: "Playback" },
60
+ createElement(ErrorState, { message: command.error.message, onRetry: () => run(retry.current) }));
61
+ if (!Number.isFinite(position) || !Number.isFinite(duration) || position < 0 || duration < 0) {
62
+ throw new Error("Playback times must be finite, non-negative milliseconds");
63
+ }
64
+ return createElement("PlayingScreen", {
65
+ image, preloadImages, title, onTitlePress, playing, loading, buffering, position, duration, clock, speed,
66
+ artists: artists.map(artist => ({ name: artist.name, interactive: !!artist.onPress })),
67
+ onArtistPress: (index: number) => artists[index]?.onPress?.(),
68
+ onPlayPause: () => run(() => playback.toggle()), onSeek: (seconds: number) => run(() => playback.seek(seconds * 1000)),
69
+ previousIcon: previous.kind === "custom" ? previous.icon : previous.kind === "skip" ? replayIcons[previous.seconds] : skipPreviousFilled,
70
+ previousDisabled: previous.disabled || !previousPress,
71
+ onPreviousPress: bind(previousPress), onPreviousLongPress: bind(previous.onLongPress),
72
+ nextIcon: next.kind === "custom" ? next.icon : next.kind === "skip" ? forwardIcons[next.seconds] : skipNextFilled,
73
+ nextDisabled: next.disabled || !nextPress,
74
+ onNextPress: bind(nextPress), onNextLongPress: bind(next.onLongPress),
75
+ playIcon: playArrowFilled, pauseIcon: pauseFilled,
76
+ actions: actions.map(({ onPress, ...action }) => action),
77
+ onActionPress: (index: number) => { const action = actions[index]; if (action) run(action.onPress); },
78
+ });
79
+ }
package/src/press.ts ADDED
@@ -0,0 +1,16 @@
1
+ import { navigate, type CheckedDestination, type Destination } from "./navigation";
2
+ import { openLink } from "./external";
3
+
4
+ export type PressProps<D extends Destination = Destination> =
5
+ | { href: D & CheckedDestination<D>; url?: never; onPress?: never }
6
+ | { url: string; href?: never; onPress?: never }
7
+ | { onPress?: () => void; href?: never; url?: never };
8
+
9
+ export function pressHandler({ href, url, onPress }: { href?: Destination; url?: string; onPress?: () => void }) {
10
+ if (href !== undefined && (url !== undefined || onPress !== undefined) || url !== undefined && onPress !== undefined) {
11
+ throw new Error("Use only one of href, url or onPress");
12
+ }
13
+ if (href !== undefined) return () => navigate(href);
14
+ if (url !== undefined) return () => openLink(url);
15
+ return onPress;
16
+ }
package/src/react.ts ADDED
@@ -0,0 +1,6 @@
1
+ // Share the CommonJS React import so unused component modules contribute no wrapper code.
2
+ export {
3
+ Activity, Children, Fragment, createContext, createElement, isValidElement, memo,
4
+ useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState,
5
+ useSyncExternalStore, type ComponentProps, type ReactElement, type ReactNode,
6
+ } from "react";