ink-framework-files 0.1.0-alpha.12 → 0.1.0-alpha.13
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/ink-native.json +4 -0
- package/package.json +7 -5
- package/src/library.ts +133 -0
- package/src/media-grid.ts +74 -0
- package/src/media.ts +7 -26
package/ink-native.json
CHANGED
package/package.json
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ink-framework-files",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.13",
|
|
4
4
|
"exports": {
|
|
5
5
|
".": "./src/index.ts",
|
|
6
6
|
"./images": "./src/images.ts",
|
|
7
|
-
"./media": "./src/media.ts"
|
|
7
|
+
"./media": "./src/media.ts",
|
|
8
|
+
"./library": "./src/library.ts"
|
|
8
9
|
},
|
|
9
10
|
"peerDependencies": {
|
|
10
|
-
"ink": "npm:ink-framework@0.1.0-alpha.
|
|
11
|
+
"ink": "npm:ink-framework@0.1.0-alpha.13",
|
|
11
12
|
"react": "19.2.8"
|
|
12
13
|
},
|
|
13
14
|
"dependencies": {
|
|
14
|
-
"@ink/lightos": "npm:ink-framework-lightos@0.1.0-alpha.
|
|
15
|
+
"@ink/lightos": "npm:ink-framework-lightos@0.1.0-alpha.13"
|
|
15
16
|
},
|
|
16
17
|
"license": "MIT",
|
|
17
18
|
"repository": {
|
|
@@ -22,6 +23,7 @@
|
|
|
22
23
|
"files": [
|
|
23
24
|
"src",
|
|
24
25
|
"*.json",
|
|
25
|
-
"*.ts"
|
|
26
|
+
"*.ts",
|
|
27
|
+
"*.md"
|
|
26
28
|
]
|
|
27
29
|
}
|
package/src/library.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { createElement, type ReactNode } from "ink/internal/react";
|
|
2
|
+
import { actionBarProps, type ActionBarItem, type IconAsset } from "ink";
|
|
3
|
+
import { callNative, NativeError, onNativeMessage } from "ink/native";
|
|
4
|
+
import { MediaGridView } from "./media-grid";
|
|
5
|
+
|
|
6
|
+
export type MediaKind = "image" | "video" | "all";
|
|
7
|
+
|
|
8
|
+
export type MediaItem = {
|
|
9
|
+
id: string;
|
|
10
|
+
src: string;
|
|
11
|
+
name: string;
|
|
12
|
+
mimeType: string;
|
|
13
|
+
width: number;
|
|
14
|
+
height: number;
|
|
15
|
+
/** Seconds since the epoch when the item was added to the library. */
|
|
16
|
+
added: number;
|
|
17
|
+
duration?: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type MediaPage = { items: MediaItem[]; nextCursor: string | null; version: string };
|
|
21
|
+
export type MediaCounts = { all: number; image: number; video: number; groups: Record<string, number> };
|
|
22
|
+
|
|
23
|
+
function decodeItem(item: unknown): MediaItem {
|
|
24
|
+
if (typeof item !== "object" || item === null || !["id", "src", "name", "mimeType"].every(key => typeof Reflect.get(item, key) === "string")
|
|
25
|
+
|| !["width", "height", "added"].every(key => typeof Reflect.get(item, key) === "number")) throw new NativeError("protocol", "Invalid media item");
|
|
26
|
+
return item as MediaItem;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const mediaLibrary = {
|
|
30
|
+
/** Newest additions first. Pass the previous page's `nextCursor` to continue. */
|
|
31
|
+
async list(options: { kind?: MediaKind; ids?: readonly string[]; cursor?: string | null; limit?: number; signal?: AbortSignal } = {}): Promise<MediaPage> {
|
|
32
|
+
const page: unknown = JSON.parse(await callNative("files", "media-list", {
|
|
33
|
+
kind: options.kind ?? "all", ids: options.ids, cursor: options.cursor ?? undefined, limit: options.limit ?? 60, order: "added", previews: true,
|
|
34
|
+
}, { signal: options.signal }));
|
|
35
|
+
if (typeof page !== "object" || page === null || !("items" in page) || !Array.isArray(page.items) || !("nextCursor" in page) || !("version" in page)
|
|
36
|
+
|| (page.nextCursor !== null && typeof page.nextCursor !== "string") || typeof page.version !== "string") throw new NativeError("protocol", "Invalid media page");
|
|
37
|
+
return { items: page.items.map(decodeItem), nextCursor: page.nextCursor, version: page.version };
|
|
38
|
+
},
|
|
39
|
+
/** Counts the whole library and optional groups of IDs without loading their media items. */
|
|
40
|
+
async counts(options: { groups?: Readonly<Record<string, readonly string[]>>; signal?: AbortSignal } = {}): Promise<MediaCounts> {
|
|
41
|
+
const counts: unknown = JSON.parse(await callNative("files", "media-counts", { groups: options.groups ?? {} }, { signal: options.signal }));
|
|
42
|
+
const count = (value: unknown): value is number => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
43
|
+
if (typeof counts !== "object" || counts === null || !("all" in counts) || !count(counts.all)
|
|
44
|
+
|| !("image" in counts) || !count(counts.image) || !("video" in counts) || !count(counts.video)
|
|
45
|
+
|| !("groups" in counts) || typeof counts.groups !== "object" || counts.groups === null || Array.isArray(counts.groups)
|
|
46
|
+
|| !Object.values(counts.groups).every(count)) throw new NativeError("protocol", "Invalid media counts");
|
|
47
|
+
return counts as MediaCounts;
|
|
48
|
+
},
|
|
49
|
+
/** Deletes items from the device. Resolves with the deleted IDs, or null when declined. */
|
|
50
|
+
async remove(ids: readonly string[]): Promise<string[] | null> {
|
|
51
|
+
const result: unknown = JSON.parse(await callNative("files", "media-delete", { ids }, { timeoutMs: 600_000 }));
|
|
52
|
+
if (result === null) return null;
|
|
53
|
+
if (!Array.isArray(result) || !result.every(id => typeof id === "string")) throw new NativeError("protocol", "Invalid deleted media IDs");
|
|
54
|
+
return result;
|
|
55
|
+
},
|
|
56
|
+
/** Rotates a photo clockwise. Resolves with the updated item, or null when declined. */
|
|
57
|
+
async rotate(id: string, degrees: 90 | 180 | 270): Promise<MediaItem | null> {
|
|
58
|
+
const result: unknown = JSON.parse(await callNative("files", "media-rotate", { id, degrees }, { timeoutMs: 600_000 }));
|
|
59
|
+
return result === null ? null : decodeItem(result);
|
|
60
|
+
},
|
|
61
|
+
/** Prepares instant grid previews for the supplied library items in the background. */
|
|
62
|
+
async preparePreviews(options: { ids: readonly string[]; signal?: AbortSignal }): Promise<number> {
|
|
63
|
+
return Number(await callNative("files", "media-previews", { ids: options.ids }, { signal: options.signal }));
|
|
64
|
+
},
|
|
65
|
+
/** Calls `listener` after photos or videos are added, changed or removed, including while the app was hidden. */
|
|
66
|
+
watch(listener: (version: string) => void, onError: (error: Error) => void): () => void {
|
|
67
|
+
watchers.set(listener, onError);
|
|
68
|
+
const request = watchRequest ??= callNative("files", "media-watch", {});
|
|
69
|
+
void request.catch(error => {
|
|
70
|
+
if (watchRequest === request) watchRequest = null;
|
|
71
|
+
if (watchers.get(listener) === onError) onError(error instanceof Error ? error : new Error(String(error)));
|
|
72
|
+
});
|
|
73
|
+
return () => {
|
|
74
|
+
watchers.delete(listener);
|
|
75
|
+
if (!watchers.size) {
|
|
76
|
+
watchRequest = null;
|
|
77
|
+
void callNative("files", "media-unwatch", {}).catch(error => onError(error instanceof Error ? error : new Error(String(error))));
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const watchers = new Map<(version: string) => void, (error: Error) => void>();
|
|
84
|
+
let watchRequest: Promise<string> | null = null;
|
|
85
|
+
onNativeMessage("media-changed", message => {
|
|
86
|
+
if (typeof message.data !== "object" || message.data === null || !("version" in message.data) || typeof message.data.version !== "string") {
|
|
87
|
+
throw new NativeError("protocol", "Invalid media library change");
|
|
88
|
+
}
|
|
89
|
+
for (const listener of [...watchers.keys()]) listener(message.data.version);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
/** Full-resolution source for a photo, decoded at the size it is displayed. */
|
|
93
|
+
export function fullSource(item: MediaItem) {
|
|
94
|
+
return item.src + "&full=1";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A `content://` URI for video playback. */
|
|
98
|
+
export function contentUri(item: MediaItem) {
|
|
99
|
+
const [kind, id] = item.id.split(":");
|
|
100
|
+
return `content://media/external/${kind === "video" ? "video" : "images"}/media/${id}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export type MediaGridProps = {
|
|
104
|
+
items: readonly MediaItem[];
|
|
105
|
+
/** A selection dims the other items. Omit it or pass null to browse normally. */
|
|
106
|
+
selected?: ReadonlySet<string> | null;
|
|
107
|
+
onPress: (item: MediaItem, index: number) => void;
|
|
108
|
+
onLongPress?: (item: MediaItem, index: number) => void;
|
|
109
|
+
hasMore?: boolean;
|
|
110
|
+
onLoadMore?: () => Promise<void>;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/** Three-column, edge-to-edge grid of library items. Place it in `MediaGridScreen`. */
|
|
114
|
+
export function MediaGrid(props: MediaGridProps) {
|
|
115
|
+
return createElement(MediaGridView<MediaItem>, {
|
|
116
|
+
...props, selectionStyle: "dimmed", hideIncompleteRow: true,
|
|
117
|
+
onLongPress: (item, index) => props.onLongPress?.(item, index),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** A screen without margins for `MediaGrid`, with an optional bottom action bar. */
|
|
122
|
+
export function MediaGridScreen({ title, onTitlePress, titleIcon, rightAction, actionBar, children }: {
|
|
123
|
+
title?: string;
|
|
124
|
+
onTitlePress?: () => void;
|
|
125
|
+
titleIcon?: IconAsset;
|
|
126
|
+
rightAction?: { icon: IconAsset; onPress: () => void };
|
|
127
|
+
actionBar?: readonly ActionBarItem[];
|
|
128
|
+
children?: ReactNode;
|
|
129
|
+
}) {
|
|
130
|
+
return createElement("MediaPickerScreen", {
|
|
131
|
+
title, onTitlePress, titleIcon, scrollBar: "auto", rightIcon: rightAction?.icon, onRightPress: rightAction?.onPress, ...actionBarProps(actionBar),
|
|
132
|
+
}, children);
|
|
133
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { createElement, useLayoutEffect, useMemo, useRef } from "ink/internal/react";
|
|
2
|
+
import { List } from "ink";
|
|
3
|
+
import { fixedListRow } from "ink/internal/list";
|
|
4
|
+
import { checkCircleFilled, videoFileFilled } from "ink/icons";
|
|
5
|
+
|
|
6
|
+
type MediaGridItem = { id: string; src: string; mimeType: string };
|
|
7
|
+
type MediaGridViewProps<T> = {
|
|
8
|
+
items: readonly T[];
|
|
9
|
+
selected?: ReadonlySet<string> | null;
|
|
10
|
+
selectionStyle: "selected" | "dimmed";
|
|
11
|
+
onPress: (item: T, index: number) => void;
|
|
12
|
+
onLongPress?: (item: T, index: number) => void;
|
|
13
|
+
hideIncompleteRow?: boolean;
|
|
14
|
+
hasMore?: boolean;
|
|
15
|
+
onLoadMore?: () => Promise<void>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const mediaRow = /* @__PURE__ */ fixedListRow<ReturnType<typeof gridCell>[]>((node, field) => node("MediaGridRow", {},
|
|
19
|
+
Array.from({ length: 3 }, (_, index) => node("MediaCell", {
|
|
20
|
+
key: field(cells => cells[index].key),
|
|
21
|
+
hidden: field(cells => cells[index].hidden), src: field(cells => cells[index].src),
|
|
22
|
+
selected: field(cells => cells[index].selected), dimmed: field(cells => cells[index].dimmed),
|
|
23
|
+
video: field(cells => cells[index].video), checkIcon: checkCircleFilled, videoIcon: videoFileFilled,
|
|
24
|
+
onPress: field(cells => cells[index].onPress), onLongPress: field(cells => cells[index].onLongPress),
|
|
25
|
+
}))));
|
|
26
|
+
|
|
27
|
+
type GridCell<T> = { item: T; index: number; selected: boolean; dimmed: boolean };
|
|
28
|
+
type GridRow<T> = { key: string; cells: GridCell<T>[]; longPress: boolean };
|
|
29
|
+
type GridHandlers<T> = Pick<MediaGridViewProps<T>, "onPress" | "onLongPress">;
|
|
30
|
+
|
|
31
|
+
function gridCell<T extends MediaGridItem>(handlers: { current: GridHandlers<T> }, row: GridRow<T>, index: number) {
|
|
32
|
+
const entry = row.cells[index];
|
|
33
|
+
return {
|
|
34
|
+
key: entry?.item.id,
|
|
35
|
+
hidden: !entry, src: (entry ?? row.cells[0]).item.src, selected: !!entry?.selected, dimmed: !!entry?.dimmed,
|
|
36
|
+
video: !!entry?.item.mimeType.startsWith("video/"),
|
|
37
|
+
// Handlers are read when pressed, so unchanged rows never hold stale callbacks.
|
|
38
|
+
onPress: entry && (() => handlers.current.onPress(entry.item, entry.index)),
|
|
39
|
+
onLongPress: entry && row.longPress ? () => handlers.current.onLongPress?.(entry.item, entry.index) : undefined,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function MediaGridView<T extends MediaGridItem>({ items, selected, selectionStyle, onPress, onLongPress, hideIncompleteRow, hasMore, onLoadMore }: MediaGridViewProps<T>) {
|
|
44
|
+
const handlers = useRef<GridHandlers<T>>({ onPress, onLongPress });
|
|
45
|
+
useLayoutEffect(() => { handlers.current = { onPress, onLongPress }; });
|
|
46
|
+
const cache = useRef(new Map<string, GridRow<T>>());
|
|
47
|
+
const longPress = !!onLongPress;
|
|
48
|
+
const rows = useMemo(() => {
|
|
49
|
+
const next = new Map<string, GridRow<T>>();
|
|
50
|
+
const rows: GridRow<T>[] = [];
|
|
51
|
+
const count = hideIncompleteRow && hasMore ? items.length - items.length % 3 : items.length;
|
|
52
|
+
for (let index = 0; index < count; index += 3) {
|
|
53
|
+
const cells = items.slice(index, index + 3).map((item, offset) => ({
|
|
54
|
+
item, index: index + offset,
|
|
55
|
+
selected: selectionStyle === "selected" && !!selected?.has(item.id),
|
|
56
|
+
dimmed: selectionStyle === "dimmed" && selected != null && !selected.has(item.id),
|
|
57
|
+
}));
|
|
58
|
+
const key = cells[0].item.id;
|
|
59
|
+
const previous = cache.current.get(key);
|
|
60
|
+
// Rows keep their identity unless an item, its position or its presentation changes.
|
|
61
|
+
const row = previous && previous.longPress === longPress && previous.cells.length === cells.length && previous.cells.every((cell, offset) =>
|
|
62
|
+
cell.item === cells[offset].item && cell.index === cells[offset].index && cell.selected === cells[offset].selected && cell.dimmed === cells[offset].dimmed)
|
|
63
|
+
? previous : { key, cells, longPress };
|
|
64
|
+
next.set(key, row);
|
|
65
|
+
rows.push(row);
|
|
66
|
+
}
|
|
67
|
+
cache.current = next;
|
|
68
|
+
return rows;
|
|
69
|
+
}, [items, selected, selectionStyle, hideIncompleteRow, hasMore, longPress]);
|
|
70
|
+
return createElement(List<GridRow<T>>, {
|
|
71
|
+
items: rows, keyExtractor: row => row.key, gap: 0, hasMore, onLoadMore,
|
|
72
|
+
renderItem: mediaRow((row: GridRow<T>) => Array.from({ length: 3 }, (_, index) => gridCell(handlers, row, index))),
|
|
73
|
+
});
|
|
74
|
+
}
|
package/src/media.ts
CHANGED
|
@@ -1,18 +1,11 @@
|
|
|
1
1
|
import { lightos, type PermissionStatus } from "@ink/lightos";
|
|
2
|
-
import { createElement, useCallback, useEffect,
|
|
3
|
-
import { EmptyState, ErrorState,
|
|
4
|
-
import { nativeListRow, nativeListTemplate } from "ink/internal/list";
|
|
2
|
+
import { createElement, useCallback, useEffect, useRef, useState } from "ink/internal/react";
|
|
3
|
+
import { EmptyState, ErrorState, LoadingState, Screen, Text } from "ink";
|
|
5
4
|
import { callNative, NativeError } from "ink/native";
|
|
6
5
|
import { files, type FileRef } from "./index";
|
|
7
6
|
import { decodeFileRef } from "./decode";
|
|
8
|
-
import { check
|
|
9
|
-
|
|
10
|
-
const mediaTemplate = nativeListTemplate((node, field) => node("MediaGridRow", {},
|
|
11
|
-
Array.from({ length: 3 }, (_, index) => node("MediaCell", {
|
|
12
|
-
hidden: field(index, "hidden"), src: field(index, "src"), selected: field(index, "selected"),
|
|
13
|
-
video: field(index, "video"), checkIcon: checkCircleFilled, videoIcon: videoFileFilled,
|
|
14
|
-
onPress: field(index, "onPress"),
|
|
15
|
-
}))));
|
|
7
|
+
import { check } from "ink/icons";
|
|
8
|
+
import { MediaGridView } from "./media-grid";
|
|
16
9
|
|
|
17
10
|
type MediaItem = { id: string; src: string; name: string; mimeType: string; width: number; height: number };
|
|
18
11
|
type MediaPage = { items: MediaItem[]; nextCursor: string | null };
|
|
@@ -129,11 +122,6 @@ export function MediaPicker({ kind = "all", title = kind === "image" ? "Photos"
|
|
|
129
122
|
}
|
|
130
123
|
};
|
|
131
124
|
|
|
132
|
-
const rows = useMemo(() => {
|
|
133
|
-
const rows: MediaItem[][] = [];
|
|
134
|
-
for (let index = 0; index < items.length; index += 3) rows.push(items.slice(index, index + 3));
|
|
135
|
-
return rows;
|
|
136
|
-
}, [items]);
|
|
137
125
|
if (loading) return createElement(Screen, { title }, createElement(LoadingState, { label: "Loading library…" }));
|
|
138
126
|
if (error) return createElement(Screen, { title }, createElement(ErrorState, {
|
|
139
127
|
message: error.message,
|
|
@@ -144,16 +132,9 @@ export function MediaPicker({ kind = "all", title = kind === "image" ? "Photos"
|
|
|
144
132
|
if (importing) return createElement(Screen, { title }, createElement(LoadingState, { label: "Preparing attachments…" }));
|
|
145
133
|
return createElement("MediaPickerScreen", { title, rightIcon: selected.size ? check : undefined, onRightPress: selected.size ? confirm : undefined },
|
|
146
134
|
items.length === 0 ? createElement(EmptyState, { title: kind === "video" ? "No videos" : kind === "image" ? "No photos" : "No photos or videos" }) :
|
|
147
|
-
createElement(
|
|
148
|
-
items
|
|
135
|
+
createElement(MediaGridView<MediaItem>, {
|
|
136
|
+
items, selected, selectionStyle: "selected", hasMore,
|
|
137
|
+
onPress: item => toggleSelected(item.id),
|
|
149
138
|
onLoadMore: async () => { if (active.current) await loadPage(active.current.signal); },
|
|
150
|
-
renderItem: nativeListRow((row: MediaItem[]) => createElement("MediaGridRow", null, row.map(item => createElement("MediaCell", {
|
|
151
|
-
key: item.id, src: item.src, selected: selected.has(item.id), video: item.mimeType.startsWith("video/"), checkIcon: checkCircleFilled, videoIcon: videoFileFilled,
|
|
152
|
-
onPress: () => toggleSelected(item.id),
|
|
153
|
-
}))), row => [Array.from({ length: 3 }, (_, index) => {
|
|
154
|
-
const item = row[index];
|
|
155
|
-
return { hidden: !item, src: item?.src ?? row[0].src, selected: !!item && selected.has(item.id),
|
|
156
|
-
video: !!item?.mimeType.startsWith("video/"), onPress: item && (() => toggleSelected(item.id)) };
|
|
157
|
-
})], mediaTemplate),
|
|
158
139
|
}));
|
|
159
140
|
}
|