ink-framework-audio 0.1.0-alpha.10 → 0.1.0-alpha.12

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 (2) hide show
  1. package/package.json +3 -3
  2. package/src/index.ts +85 -9
package/package.json CHANGED
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "name": "ink-framework-audio",
3
- "version": "0.1.0-alpha.10",
3
+ "version": "0.1.0-alpha.12",
4
4
  "exports": {
5
5
  ".": "./src/index.ts",
6
6
  "./microphone": "./src/microphone.ts",
7
7
  "./recording": "./src/recording.ts"
8
8
  },
9
9
  "peerDependencies": {
10
- "@ink/files": "npm:ink-framework-files@0.1.0-alpha.10",
11
- "ink": "npm:ink-framework@0.1.0-alpha.10",
10
+ "@ink/files": "npm:ink-framework-files@0.1.0-alpha.12",
11
+ "ink": "npm:ink-framework@0.1.0-alpha.12",
12
12
  "react": "19.2.8"
13
13
  },
14
14
  "license": "MIT",
package/src/index.ts CHANGED
@@ -14,6 +14,18 @@ export interface AudioItem {
14
14
  }
15
15
  export type SourceReplacement = { prepare?: boolean } & ({ position?: number; offset?: never } | { offset: number; position?: never });
16
16
 
17
+ export interface PlayerOptions {
18
+ session?: string;
19
+ mode?: "attached" | "detached";
20
+ usage?: "music" | "speech";
21
+ /** Reuse HTTP(S) audio in a shared 256 MiB disc cache. Off by default. */
22
+ cache?: boolean;
23
+ /** Milliseconds to buffer for the next queued item. Off by default. */
24
+ preloadNext?: number;
25
+ /** Milliseconds of audio to buffer before streaming starts. Defaults to 1000. */
26
+ startupBuffer?: number;
27
+ }
28
+
17
29
  export interface PlayerState {
18
30
  ready: boolean;
19
31
  playing: boolean;
@@ -21,17 +33,48 @@ export interface PlayerState {
21
33
  buffering: boolean;
22
34
  loading: boolean;
23
35
  ended: boolean;
36
+ queue: readonly AudioItem[];
24
37
  current: AudioItem | null;
25
38
  index: number;
26
39
  position: number;
27
40
  duration: number;
28
41
  speed: number;
42
+ shuffle: boolean;
43
+ repeat: "off" | "queue" | "track";
29
44
  skipSilence: boolean;
30
45
  voiceBoost: boolean;
31
46
  silenceSaved: number;
32
47
  error: Error | null;
33
48
  }
34
- const initial: PlayerState = { ready: false, playing: false, playWhenReady: false, buffering: false, loading: false, ended: false, current: null, index: -1, position: 0, duration: 0, speed: 1, skipSilence: false, voiceBoost: false, silenceSaved: 0, error: null };
49
+ const initial: PlayerState = { ready: false, playing: false, playWhenReady: false, buffering: false, loading: false, ended: false, queue: [], current: null, index: -1, position: 0, duration: 0, speed: 1, shuffle: false, repeat: "off", skipSilence: false, voiceBoost: false, silenceSaved: 0, error: null };
50
+
51
+ function decodeItem(value: unknown): AudioItem {
52
+ if (typeof value !== "object" || value === null) throw new NativeError("protocol", "Invalid audio queue item");
53
+ const text = (name: string): string => {
54
+ const result: unknown = Reflect.get(value, name);
55
+ if (typeof result !== "string") throw new NativeError("protocol", `Invalid audio item ${name}`);
56
+ return result;
57
+ };
58
+ const duration: unknown = Reflect.get(value, "duration");
59
+ if (duration !== undefined && (typeof duration !== "number" || !Number.isFinite(duration) || duration < 0)) {
60
+ throw new NativeError("protocol", "Invalid audio item duration");
61
+ }
62
+ return { id: text("id"), src: text("src"), title: text("title"), artist: text("artist"), album: text("album"), artwork: text("artwork"), duration };
63
+ }
64
+
65
+ function validateId(id: string) {
66
+ if (typeof id !== "string" || !id.trim()) throw new TypeError("Audio item IDs must not be blank");
67
+ }
68
+
69
+ function validateQueue(items: readonly AudioItem[]) {
70
+ if (items.length === 0) throw new RangeError("Audio queues cannot be empty");
71
+ const ids = new Set<string>();
72
+ for (const item of items) {
73
+ validateId(item.id);
74
+ if (ids.has(item.id)) throw new TypeError("Audio queue item IDs must be unique");
75
+ ids.add(item.id);
76
+ }
77
+ }
35
78
 
36
79
  function decode(value: unknown): PlayerState {
37
80
  if (typeof value !== "object" || value === null) throw new NativeError("protocol", "Invalid player state");
@@ -64,6 +107,12 @@ function decode(value: unknown): PlayerState {
64
107
  throw new NativeError("protocol", "Invalid player queue index");
65
108
  }
66
109
  const duration = number("durationMs");
110
+ const queue = field("queue");
111
+ const repeat = field("repeat");
112
+ if (repeat !== "off" && repeat !== "queue" && repeat !== "track") throw new NativeError("protocol", "Invalid repeat mode");
113
+ const shuffle = field("shuffle");
114
+ if (typeof shuffle !== "boolean") throw new NativeError("protocol", "Invalid shuffle mode");
115
+ if (!Array.isArray(queue)) throw new NativeError("protocol", "Invalid audio queue");
67
116
  return {
68
117
  ready: field("ready") === true,
69
118
  playing: status === "playing",
@@ -71,24 +120,28 @@ function decode(value: unknown): PlayerState {
71
120
  buffering: status === "loading",
72
121
  loading: field("loading") === true,
73
122
  ended: status === "ended",
123
+ queue: queue.map(decodeItem),
74
124
  index,
75
125
  current: src ? { id: text("id"), src, title: text("title"), artist: text("artist"), album: text("album"), artwork: text("artwork"), duration } : null,
76
126
  position: number("positionMs"), duration, speed: number("speed"),
127
+ shuffle, repeat,
77
128
  skipSilence: field("skipSilence") === true, voiceBoost: field("voiceBoost") === true,
78
129
  silenceSaved: number("silenceSavedMs"), error,
79
130
  };
80
131
  }
81
132
 
82
- export function usePlayer(options: { session?: string; mode?: "attached" | "detached"; usage?: "music" | "speech" } = {}) {
83
- const { session = "main", mode = "attached", usage = "music" } = options;
133
+ export function usePlayer(options: PlayerOptions = {}) {
134
+ const { session = "main", mode = "attached", usage = "music", cache = false, preloadNext = 0, startupBuffer = 1000 } = options;
84
135
  if (!/^[A-Za-z0-9._-]{1,64}$/.test(session)) throw new TypeError("Audio session names must be 1–64 letters, numbers, dots, underscores or hyphens");
136
+ if (!Number.isSafeInteger(preloadNext) || preloadNext < 0 || preloadNext > 30_000) throw new RangeError("Next-item preloading must be between 0 and 30000 milliseconds");
137
+ if (!Number.isSafeInteger(startupBuffer) || startupBuffer < 0 || startupBuffer > 30_000) throw new RangeError("Startup buffering must be between 0 and 30000 milliseconds");
85
138
  const [state, setState] = useState(initial);
86
139
  const current = useRef(initial);
87
140
  const controller = useRef<ReturnType<typeof attachNativeController> | null>(null);
88
141
  useEffect(() => {
89
142
  const publish = (next: PlayerState) => { current.current = next; setState(next); };
90
143
  publish(initial);
91
- const attachment = attachNativeController("audio", { kind: "player", config: { session, playback: mode, usage } }, value => {
144
+ const attachment = attachNativeController("audio", { kind: "player", config: { session, playback: mode, usage, cache, preloadNext, startupBuffer } }, value => {
92
145
  try { publish(decode(value)); }
93
146
  catch (error) { publish({ ...initial, error: error instanceof Error ? error : new Error(String(error)) }); }
94
147
  });
@@ -101,7 +154,7 @@ export function usePlayer(options: { session?: string; mode?: "attached" | "deta
101
154
  current.current = initial;
102
155
  void attachment.dispose().catch(error => console.error("Could not release audio player", error));
103
156
  };
104
- }, [session, mode, usage]);
157
+ }, [session, mode, usage, cache, preloadNext, startupBuffer]);
105
158
  const commands = useMemo(() => {
106
159
  const call = async (operation: string, payload: unknown = {}) => {
107
160
  if (!controller.current || !current.current.ready) throw new NativeError("unavailable", "Audio player is not ready");
@@ -126,15 +179,38 @@ export function usePlayer(options: { session?: string; mode?: "attached" | "deta
126
179
  }
127
180
  return call("replaceSource", { id, src, ...options });
128
181
  },
129
- setQueue(items: readonly AudioItem[], options: { startIndex?: number; startPosition?: number; prepare?: boolean } = {}) {
182
+ async setQueue(items: readonly AudioItem[], options: { startIndex?: number; startPosition?: number; prepare?: boolean } = {}) {
183
+ validateQueue(items);
130
184
  const startPosition = options.startPosition ?? 0;
131
- if (!Number.isFinite(startPosition) || startPosition < 0) return Promise.reject(new RangeError("Audio position must be a non-negative number of milliseconds"));
132
- return call("setQueue", { items, startIndex: options.startIndex ?? 0, startPosition, prepare: options.prepare ?? true });
185
+ if (!Number.isFinite(startPosition) || startPosition < 0) throw new RangeError("Audio position must be a non-negative number of milliseconds");
186
+ await call("setQueue", { items, startIndex: options.startIndex ?? 0, startPosition, prepare: options.prepare ?? true });
187
+ },
188
+ async add(item: AudioItem, options: { at?: number | "next" } = {}) {
189
+ validateId(item.id);
190
+ if (options.at !== undefined && options.at !== "next" && (!Number.isSafeInteger(options.at) || options.at < 0)) {
191
+ throw new RangeError("Audio insertion index must be a non-negative integer");
192
+ }
193
+ await call("add", { item, ...options });
194
+ },
195
+ remove: (id: string) => call("remove", { id }),
196
+ async move(id: string, index: number) {
197
+ if (!Number.isSafeInteger(index) || index < 0) throw new RangeError("Audio destination index must be a non-negative integer");
198
+ await call("move", { id, index });
199
+ },
200
+ async replace(id: string, item: AudioItem) {
201
+ validateId(item.id);
202
+ await call("replace", { id, item });
203
+ },
204
+ async play(id?: string) {
205
+ if (id !== undefined) validateId(id);
206
+ await call(id === undefined ? "play" : "playItem", { id });
133
207
  },
134
- play: () => call("play"),
208
+ prepare: () => call("prepare"),
135
209
  pause: () => call("pause"),
136
210
  toggle: () => call("toggle"),
137
211
  stop: () => call("stop"),
212
+ setShuffle: (enabled: boolean) => call("setShuffle", { value: enabled }),
213
+ setRepeat: (mode: PlayerState["repeat"]) => call("setRepeat", { value: mode }),
138
214
  setSpeed(speed: number) {
139
215
  if (!Number.isFinite(speed) || speed < 0.25 || speed > 4) return Promise.reject(new RangeError("Playback speed must be between 0.25 and 4"));
140
216
  return call("setSpeed", { value: speed });