@legendapp/create-spark 0.0.0-stage → 0.0.1-next.6
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/LICENSE +21 -0
- package/README.md +17 -2
- package/dist/index.js +174697 -0
- package/dist/npm-bin/npm +6 -0
- package/dist/npm-bin/npm.cmd +1 -0
- package/dist/release.json +42 -0
- package/package.json +30 -4
- package/templates/blank-typescript/.claude/skills/spark/SKILL.md +9 -0
- package/templates/blank-typescript/AGENTS.md +4 -0
- package/templates/blank-typescript/App.tsx +23 -0
- package/templates/blank-typescript/CLAUDE.md +4 -0
- package/templates/blank-typescript/README.md +15 -0
- package/templates/blank-typescript/app.json +11 -0
- package/templates/blank-typescript/assets/icon.png +0 -0
- package/templates/blank-typescript/desktop.config.json +21 -0
- package/templates/blank-typescript/gitignore +8 -0
- package/templates/blank-typescript/index.ts +5 -0
- package/templates/blank-typescript/metro.config.js +3 -0
- package/templates/blank-typescript/package.json +101 -0
- package/templates/blank-typescript/tsconfig.json +10 -0
- package/templates/diff-lite/App.tsx +34 -0
- package/templates/diff-lite/Git.macos.tsx +13 -0
- package/templates/diff-lite/Git.tsx +1 -0
- package/templates/diff-lite/compare.ts +12 -0
- package/templates/document-editor/App.tsx +34 -0
- package/templates/document-editor/Integration.macos.tsx +59 -0
- package/templates/document-editor/Integration.tsx +13 -0
- package/templates/document-editor/Integration.web.tsx +14 -0
- package/templates/document-editor/Integration.windows.tsx +59 -0
- package/templates/document-editor/document.ts +58 -0
- package/templates/document-editor/io.desktop.ts +27 -0
- package/templates/document-editor/io.macos.ts +7 -0
- package/templates/document-editor/io.ts +31 -0
- package/templates/document-editor/io.web.ts +21 -0
- package/templates/document-editor/io.windows.ts +1 -0
- package/templates/document-editor/sessions.ts +8 -0
- package/templates/document-editor/useDocument.ts +6 -0
- package/templates/example-shared/Lifecycle.desktop.tsx +25 -0
- package/templates/example-shared/Lifecycle.macos.tsx +1 -0
- package/templates/example-shared/Lifecycle.tsx +14 -0
- package/templates/example-shared/Lifecycle.web.tsx +20 -0
- package/templates/example-shared/Lifecycle.windows.tsx +1 -0
- package/templates/example-shared/OpenFiles.desktop.tsx +23 -0
- package/templates/example-shared/OpenFiles.macos.tsx +1 -0
- package/templates/example-shared/OpenFiles.tsx +2 -0
- package/templates/example-shared/OpenFiles.windows.tsx +1 -0
- package/templates/example-shared/document.ts +58 -0
- package/templates/example-shared/io.desktop.ts +27 -0
- package/templates/example-shared/io.macos.ts +7 -0
- package/templates/example-shared/io.ts +31 -0
- package/templates/example-shared/io.web.ts +21 -0
- package/templates/example-shared/io.windows.ts +1 -0
- package/templates/example-shared/lifecycle-types.ts +10 -0
- package/templates/example-shared/lifetime.ts +23 -0
- package/templates/example-shared/records.ts +51 -0
- package/templates/example-shared/storage.ts +6 -0
- package/templates/example-shared/useModel.ts +6 -0
- package/templates/music-lite/Activation.desktop.tsx +4 -0
- package/templates/music-lite/Activation.macos.tsx +1 -0
- package/templates/music-lite/Activation.tsx +1 -0
- package/templates/music-lite/Activation.windows.tsx +1 -0
- package/templates/music-lite/App.tsx +38 -0
- package/templates/music-lite/assets.desktop.ts +7 -0
- package/templates/music-lite/assets.macos.ts +1 -0
- package/templates/music-lite/assets.ts +18 -0
- package/templates/music-lite/assets.web.ts +37 -0
- package/templates/music-lite/assets.windows.ts +1 -0
- package/templates/music-lite/model.ts +91 -0
- package/templates/music-lite/store.ts +10 -0
- package/templates/notes-lite/Activation.desktop.tsx +5 -0
- package/templates/notes-lite/Activation.macos.tsx +1 -0
- package/templates/notes-lite/Activation.tsx +1 -0
- package/templates/notes-lite/Activation.windows.tsx +1 -0
- package/templates/notes-lite/App.tsx +92 -0
- package/templates/notes-lite/Desktop.desktop.ts +47 -0
- package/templates/notes-lite/Desktop.macos.ts +1 -0
- package/templates/notes-lite/Desktop.ts +7 -0
- package/templates/notes-lite/Desktop.windows.ts +1 -0
- package/templates/notes-lite/Theme.tsx +17 -0
- package/templates/notes-lite/model.ts +61 -0
- package/templates/notes-lite/search.ts +3 -0
- package/templates/notes-lite/session.ts +15 -0
- package/templates/notes-lite/store.ts +4 -0
- package/templates/notes-lite/window-session.ts +63 -0
- package/templates/universal/.claude/skills/spark/SKILL.md +9 -0
- package/templates/universal/AGENTS.md +4 -0
- package/templates/universal/App.tsx +57 -0
- package/templates/universal/CLAUDE.md +4 -0
- package/templates/universal/README.md +15 -0
- package/templates/universal/app.json +15 -0
- package/templates/universal/assets/icon.png +0 -0
- package/templates/universal/desktop.config.json +64 -0
- package/templates/universal/gitignore +10 -0
- package/templates/universal/global.css +31 -0
- package/templates/universal/index.ts +4 -0
- package/templates/universal/metro.config.js +7 -0
- package/templates/universal/package.json +115 -0
- package/templates/universal/react-native.config.js +2 -0
- package/templates/universal/tsconfig.json +10 -0
- package/templates/universal/uniwind-types.d.ts +10 -0
- package/templates/windows/.claude/skills/spark/SKILL.md +9 -0
- package/templates/windows/AGENTS.md +4 -0
- package/templates/windows/App.tsx +16 -0
- package/templates/windows/CLAUDE.md +4 -0
- package/templates/windows/README.md +15 -0
- package/templates/windows/app.json +11 -0
- package/templates/windows/assets/icon.png +0 -0
- package/templates/windows/desktop.config.json +10 -0
- package/templates/windows/gitignore +8 -0
- package/templates/windows/index.ts +3 -0
- package/templates/windows/metro.config.js +3 -0
- package/templates/windows/package.json +102 -0
- package/templates/windows/react-native.config.js +2 -0
- package/templates/windows/tsconfig.json +10 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export type FileReference = { name: string; location?: string; handle?: unknown };
|
|
2
|
+
export type DocumentState = { text: string; savedText: string; file: FileReference; busy: boolean; error: string | null; revision: number };
|
|
3
|
+
export type DocumentIO = {
|
|
4
|
+
open(): Promise<{ file: FileReference; text: string } | null>;
|
|
5
|
+
save(file: FileReference, text: string, expected: string, saveAs: boolean): Promise<FileReference | null>;
|
|
6
|
+
confirmDiscard(name: string): Promise<"save" | "discard" | "cancel">;
|
|
7
|
+
};
|
|
8
|
+
/** One document model is shared by its windows; I/O always works on a snapshot. */
|
|
9
|
+
export class DocumentSession {
|
|
10
|
+
private state: DocumentState = { text: "", savedText: "", file: { name: "Untitled.txt" }, busy: false, error: null, revision: 0 };
|
|
11
|
+
private listeners = new Set<() => void>();
|
|
12
|
+
private active = false;
|
|
13
|
+
constructor(readonly io: DocumentIO) {}
|
|
14
|
+
getSnapshot = () => this.state;
|
|
15
|
+
subscribe = (listener: () => void) => { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; };
|
|
16
|
+
private update(patch: Partial<DocumentState>) { this.state = { ...this.state, ...patch }; for (const listener of this.listeners) listener(); }
|
|
17
|
+
edit = (text: string) => { if (text !== this.state.text) this.update({ text, revision: this.state.revision + 1 }); };
|
|
18
|
+
get dirty() { return this.state.text !== this.state.savedText; }
|
|
19
|
+
private async operation(action: () => Promise<boolean>) {
|
|
20
|
+
if (this.active) return false;
|
|
21
|
+
this.active = true; this.update({ busy: true, error: null });
|
|
22
|
+
try { return await action(); }
|
|
23
|
+
catch (error) { this.update({ error: error instanceof Error ? error.message : String(error) }); return false; }
|
|
24
|
+
finally { this.active = false; this.update({ busy: false }); }
|
|
25
|
+
}
|
|
26
|
+
private async saveSnapshot(saveAs: boolean) {
|
|
27
|
+
const snapshot = this.state;
|
|
28
|
+
const file = await this.io.save(snapshot.file, snapshot.text, snapshot.savedText, saveAs);
|
|
29
|
+
if (!file) return false;
|
|
30
|
+
// Edits typed during an asynchronous save remain dirty.
|
|
31
|
+
this.update({ file, savedText: snapshot.text }); return true;
|
|
32
|
+
}
|
|
33
|
+
save = (saveAs = false) => this.operation(() => this.saveSnapshot(saveAs));
|
|
34
|
+
private async mayReplace() {
|
|
35
|
+
if (!this.dirty) return true;
|
|
36
|
+
const revision = this.state.revision;
|
|
37
|
+
const answer = await this.io.confirmDiscard(this.state.file.name);
|
|
38
|
+
if (answer === "cancel") return false;
|
|
39
|
+
if (answer === "save") return await this.saveSnapshot(false) && !this.dirty;
|
|
40
|
+
return revision === this.state.revision;
|
|
41
|
+
}
|
|
42
|
+
canClose = () => this.operation(() => this.mayReplace());
|
|
43
|
+
newDocument = () => this.operation(async () => {
|
|
44
|
+
if (!await this.mayReplace()) return false;
|
|
45
|
+
this.update({ text: "", savedText: "", file: { name: "Untitled.txt" }, revision: this.state.revision + 1 }); return true;
|
|
46
|
+
});
|
|
47
|
+
open = () => this.operation(async () => {
|
|
48
|
+
if (!await this.mayReplace()) return false;
|
|
49
|
+
const revision = this.state.revision;
|
|
50
|
+
const loaded = await this.io.open();
|
|
51
|
+
if (!loaded || revision !== this.state.revision) return false;
|
|
52
|
+
this.update({ file: loaded.file, text: loaded.text, savedText: loaded.text, revision: revision + 1 }); return true;
|
|
53
|
+
});
|
|
54
|
+
load = (file: FileReference, text: string) => this.operation(async () => {
|
|
55
|
+
if (!await this.mayReplace()) return false;
|
|
56
|
+
this.update({ file, text, savedText: text, revision: this.state.revision + 1 }); return true;
|
|
57
|
+
});
|
|
58
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { openFileDialog, saveFileDialog } from "@legendapp/spark/dialogs";
|
|
2
|
+
import { readText, writeText, writeTextIfUnchanged } from "@legendapp/spark/files";
|
|
3
|
+
import { Alert } from "react-native";
|
|
4
|
+
import type { DocumentIO } from "./document";
|
|
5
|
+
export const io: DocumentIO = {
|
|
6
|
+
async open() {
|
|
7
|
+
const paths = await openFileDialog({ filters: [{ extensions: ["txt", "md", "json"] }], multiple: false });
|
|
8
|
+
if (paths.canceled) return null;
|
|
9
|
+
const location = paths.paths[0];
|
|
10
|
+
return { file: { location, name: location.split(/[\\/]/).pop()! }, text: await readText(location) };
|
|
11
|
+
},
|
|
12
|
+
async save(file, text, expected, saveAs) {
|
|
13
|
+
const result = saveAs || !file.location ? await saveFileDialog({ defaultName: file.name, filters: [{ extensions: ["txt", "md", "json"] }] }) : { canceled: false as const, path: file.location };
|
|
14
|
+
if (result.canceled) return null;
|
|
15
|
+
const location = result.path;
|
|
16
|
+
if (!location) return null;
|
|
17
|
+
if (location === file.location) {
|
|
18
|
+
if (!(await writeTextIfUnchanged(location, expected, text)).written) throw new Error("The file changed outside the editor. Use Save As to keep your edits in another file.");
|
|
19
|
+
} else await writeText(location, text);
|
|
20
|
+
return { location, name: location.split(/[\\/]/).pop()! };
|
|
21
|
+
},
|
|
22
|
+
confirmDiscard: name => new Promise(resolve => Alert.alert("Unsaved changes", `Save changes to ${name}?`, [
|
|
23
|
+
{ text: "Cancel", style: "cancel", onPress: () => resolve("cancel") },
|
|
24
|
+
{ text: "Discard", style: "destructive", onPress: () => resolve("discard") },
|
|
25
|
+
{ text: "Save", onPress: () => resolve("save") },
|
|
26
|
+
], { cancelable: false })),
|
|
27
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { io as desktop } from "./io.desktop";
|
|
2
|
+
import { showMessage } from "@legendapp/spark/dialogs";
|
|
3
|
+
import type { DocumentIO } from "./document";
|
|
4
|
+
export const io: DocumentIO = { ...desktop, async confirmDiscard(name) {
|
|
5
|
+
const { buttonId } = await showMessage({ title: "Unsaved changes", message: `Save changes to ${name}?`, buttons: [{ id: "cancel", label: "Cancel" }, { id: "discard", label: "Discard" }, { id: "save", label: "Save" }], cancelButtonId: "cancel", defaultButtonId: "save" });
|
|
6
|
+
return buttonId === "save" ? "save" : buttonId === "discard" ? "discard" : "cancel";
|
|
7
|
+
} };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import * as DocumentPicker from "expo-document-picker";
|
|
2
|
+
import * as FileSystem from "expo-file-system/legacy";
|
|
3
|
+
import * as Sharing from "expo-sharing";
|
|
4
|
+
import { Alert } from "react-native";
|
|
5
|
+
import type { DocumentIO } from "./document";
|
|
6
|
+
export const io: DocumentIO = {
|
|
7
|
+
async open() {
|
|
8
|
+
const result = await DocumentPicker.getDocumentAsync({ type: ["text/*", "application/json"], copyToCacheDirectory: true });
|
|
9
|
+
if (result.canceled) return null;
|
|
10
|
+
const asset = result.assets[0]!;
|
|
11
|
+
if ((asset.size ?? 0) > 8 * 1024 * 1024) throw new Error("Choose a text file smaller than 8 MB");
|
|
12
|
+
// The picker returns an imported copy, not permission to overwrite the source.
|
|
13
|
+
return { file: { name: asset.name }, text: await FileSystem.readAsStringAsync(asset.uri) };
|
|
14
|
+
},
|
|
15
|
+
async save(file, text) {
|
|
16
|
+
if (!await Sharing.isAvailableAsync()) throw new Error("File sharing is unavailable on this device");
|
|
17
|
+
const directory = FileSystem.documentDirectory;
|
|
18
|
+
if (!directory) throw new Error("Document storage is unavailable");
|
|
19
|
+
const name = file.name.replace(/[^a-zA-Z0-9._-]/g, "_") || "Untitled.txt";
|
|
20
|
+
const location = `${directory}spark-${name}`;
|
|
21
|
+
await FileSystem.writeAsStringAsync(location, text);
|
|
22
|
+
await Sharing.shareAsync(location, { mimeType: "text/plain", UTI: "public.plain-text" });
|
|
23
|
+
// Save commits the app's durable copy, regardless of share-sheet dismissal.
|
|
24
|
+
return { name: file.name, location };
|
|
25
|
+
},
|
|
26
|
+
confirmDiscard: name => new Promise(resolve => Alert.alert("Unsaved changes", `Save a copy of ${name}?`, [
|
|
27
|
+
{ text: "Cancel", style: "cancel", onPress: () => resolve("cancel") },
|
|
28
|
+
{ text: "Discard", style: "destructive", onPress: () => resolve("discard") },
|
|
29
|
+
{ text: "Save", onPress: () => resolve("save") },
|
|
30
|
+
], { cancelable: false })),
|
|
31
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { DocumentIO } from "./document";
|
|
2
|
+
export const io: DocumentIO = {
|
|
3
|
+
open: () => new Promise((resolve, reject) => {
|
|
4
|
+
const input = document.createElement("input"); input.type = "file"; input.accept = ".txt,.md,.json,text/plain";
|
|
5
|
+
input.oncancel = () => resolve(null);
|
|
6
|
+
input.onchange = () => {
|
|
7
|
+
const file = input.files?.[0];
|
|
8
|
+
if (!file) { resolve(null); return; }
|
|
9
|
+
if (file.size > 8 * 1024 * 1024) { reject(new Error("Choose a text file smaller than 8 MB")); return; }
|
|
10
|
+
void file.text().then(text => resolve({ file: { name: file.name }, text }), reject);
|
|
11
|
+
};
|
|
12
|
+
input.click();
|
|
13
|
+
}),
|
|
14
|
+
async save(file, text) {
|
|
15
|
+
const url = URL.createObjectURL(new Blob([text], { type: "text/plain;charset=utf-8" }));
|
|
16
|
+
const anchor = document.createElement("a"); anchor.href = url; anchor.download = file.name; anchor.click();
|
|
17
|
+
setTimeout(() => URL.revokeObjectURL(url), 30_000);
|
|
18
|
+
return { name: file.name };
|
|
19
|
+
},
|
|
20
|
+
async confirmDiscard(name) { return window.confirm(`Discard unsaved changes to ${name}? Choose Cancel to return and save.`) ? "discard" : "cancel"; },
|
|
21
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { io } from "./io.desktop";
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { DocumentSession } from "./document";
|
|
2
|
+
import { io } from "./io";
|
|
3
|
+
// Secondary macOS roots share this JS heap and therefore the same document data.
|
|
4
|
+
export const sessions = new Map<string, DocumentSession>();
|
|
5
|
+
export function sessionFor(id: string) {
|
|
6
|
+
if (!sessions.has(id)) sessions.set(id, new DocumentSession(io));
|
|
7
|
+
return sessions.get(id)!;
|
|
8
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { useSyncExternalStore } from "react";
|
|
2
|
+
import type { DocumentSession } from "./document";
|
|
3
|
+
/** React adapter for the document model shared by native window roots. */
|
|
4
|
+
export function useDocument(session: DocumentSession) {
|
|
5
|
+
return useSyncExternalStore(session.subscribe, session.getSnapshot, session.getSnapshot);
|
|
6
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import { Platform } from "react-native";
|
|
3
|
+
import { beforeWindowClose, setWindowOptions, addWindowListener } from "@legendapp/spark/windows";
|
|
4
|
+
import { beforeQuit } from "@legendapp/spark/app";
|
|
5
|
+
import { createMenu, type MenuItem } from "@legendapp/spark/menus";
|
|
6
|
+
import { registerShortcut } from "@legendapp/spark/shortcuts";
|
|
7
|
+
import { mountSerial } from "./lifetime";
|
|
8
|
+
import type { LifecycleProps } from "./lifecycle-types";
|
|
9
|
+
export function Lifecycle({ title, windowId = "main", flush, quit, commands, onError }: LifecycleProps) {
|
|
10
|
+
useEffect(() => { void setWindowOptions(windowId, { title: title }).catch(error => onError(String(error))); }, [title, windowId, onError]);
|
|
11
|
+
useEffect(() => mountSerial(`window-${windowId}`, async retain => {
|
|
12
|
+
const owner = `example-${windowId}`;
|
|
13
|
+
const items: MenuItem[] = [{ type: "submenu", id: "file", target: { menu: "file" }, label: "File", items: commands.map(({ id, title, key }) => ({ type: "action", id, label: title, shortcut: `CmdOrCtrl+${key}` })) }];
|
|
14
|
+
const menu = await createMenu({ id: owner, items, onAction: event => { commands.find(command => command.id === event.itemId)?.run(); } });
|
|
15
|
+
await retain(Promise.resolve(menu));
|
|
16
|
+
await retain(addWindowListener(windowId, "focusChanged", event => { if (event.focused) void menu.update({ items }).catch(error => onError(String(error))); }));
|
|
17
|
+
const guarded = (save: () => Promise<boolean>) => async () => {
|
|
18
|
+
try { return await save(); } catch (error) { onError(String(error)); return false; }
|
|
19
|
+
};
|
|
20
|
+
await retain(beforeWindowClose(windowId, guarded(flush)));
|
|
21
|
+
if (windowId === "main") await retain(beforeQuit(guarded(quit ?? flush)));
|
|
22
|
+
for (const command of commands) await retain(registerShortcut(`${Platform.OS === "windows" ? "Control" : "Command"}+${command.key}`, command.run, { windowId }));
|
|
23
|
+
}, onError), [windowId, flush, quit, commands, onError]);
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Lifecycle } from "./Lifecycle.desktop";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import { AppState, BackHandler } from "react-native";
|
|
3
|
+
import type { LifecycleProps } from "./lifecycle-types";
|
|
4
|
+
export function Lifecycle({ flush, dirty }: LifecycleProps) {
|
|
5
|
+
useEffect(() => {
|
|
6
|
+
const state = AppState.addEventListener("change", state => { if (state !== "active") void flush(); });
|
|
7
|
+
const back = BackHandler.addEventListener("hardwareBackPress", () => {
|
|
8
|
+
if (!dirty()) return false;
|
|
9
|
+
void flush().then(ok => { if (ok && !dirty()) BackHandler.exitApp(); }); return true;
|
|
10
|
+
});
|
|
11
|
+
return () => { state.remove(); back.remove(); };
|
|
12
|
+
}, [flush, dirty]);
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import type { LifecycleProps } from "./lifecycle-types";
|
|
3
|
+
export function Lifecycle({ title, dirty, flush, commands }: LifecycleProps) {
|
|
4
|
+
useEffect(() => { document.title = title; }, [title]);
|
|
5
|
+
useEffect(() => {
|
|
6
|
+
const close = (event: BeforeUnloadEvent) => { if (dirty()) { event.preventDefault(); event.returnValue = ""; } };
|
|
7
|
+
const background = () => { if (document.visibilityState === "hidden") void flush(); };
|
|
8
|
+
const key = (event: KeyboardEvent) => {
|
|
9
|
+
if (event.isComposing || event.altKey || !(event.metaKey || event.ctrlKey)) return;
|
|
10
|
+
const command = commands.find(command => {
|
|
11
|
+
const parts = command.key.toLowerCase().split("+");
|
|
12
|
+
return parts.includes("shift") === event.shiftKey && parts.at(-1) === event.key.toLowerCase();
|
|
13
|
+
});
|
|
14
|
+
if (command) { event.preventDefault(); command.run(); }
|
|
15
|
+
};
|
|
16
|
+
window.addEventListener("beforeunload", close); window.addEventListener("keydown", key); document.addEventListener("visibilitychange", background);
|
|
17
|
+
return () => { window.removeEventListener("beforeunload", close); window.removeEventListener("keydown", key); document.removeEventListener("visibilitychange", background); };
|
|
18
|
+
}, [dirty, flush, commands]);
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Lifecycle } from "./Lifecycle.desktop";
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import { subscribeToOpenRequests } from "@legendapp/spark/app/documents";
|
|
3
|
+
import { mountSerial } from "./lifetime";
|
|
4
|
+
import type { OpenFilesProps } from "./OpenFiles";
|
|
5
|
+
// Each subscription replays queued launches to each subscription. Remounts must not import twice.
|
|
6
|
+
const imported = new Set<string>();
|
|
7
|
+
const importing = new Set<string>();
|
|
8
|
+
export function OpenFiles({ windowId = "main", onFile, onError }: OpenFilesProps) {
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
if (windowId !== "main") return;
|
|
11
|
+
return mountSerial("file-activation", async retain => { await retain(subscribeToOpenRequests(event => {
|
|
12
|
+
if (event.type !== "file") return;
|
|
13
|
+
if (imported.has(event.id) || importing.has(event.id)) return;
|
|
14
|
+
importing.add(event.id);
|
|
15
|
+
void (async () => {
|
|
16
|
+
await onFile(event.path);
|
|
17
|
+
imported.add(event.id);
|
|
18
|
+
if (imported.size > 200) imported.delete(imported.values().next().value!);
|
|
19
|
+
})().catch(error => onError(String(error))).finally(() => importing.delete(event.id));
|
|
20
|
+
})); }, onError);
|
|
21
|
+
}, [windowId, onFile, onError]);
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { OpenFiles } from "./OpenFiles.desktop";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { OpenFiles } from "./OpenFiles.desktop";
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export type FileReference = { name: string; location?: string; handle?: unknown };
|
|
2
|
+
export type DocumentState = { text: string; savedText: string; file: FileReference; busy: boolean; error: string | null; revision: number };
|
|
3
|
+
export type DocumentIO = {
|
|
4
|
+
open(): Promise<{ file: FileReference; text: string } | null>;
|
|
5
|
+
save(file: FileReference, text: string, expected: string, saveAs: boolean): Promise<FileReference | null>;
|
|
6
|
+
confirmDiscard(name: string): Promise<"save" | "discard" | "cancel">;
|
|
7
|
+
};
|
|
8
|
+
/** One document model is shared by its windows; I/O always works on a snapshot. */
|
|
9
|
+
export class DocumentSession {
|
|
10
|
+
private state: DocumentState = { text: "", savedText: "", file: { name: "Untitled.txt" }, busy: false, error: null, revision: 0 };
|
|
11
|
+
private listeners = new Set<() => void>();
|
|
12
|
+
private active = false;
|
|
13
|
+
constructor(readonly io: DocumentIO) {}
|
|
14
|
+
getSnapshot = () => this.state;
|
|
15
|
+
subscribe = (listener: () => void) => { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; };
|
|
16
|
+
private update(patch: Partial<DocumentState>) { this.state = { ...this.state, ...patch }; for (const listener of this.listeners) listener(); }
|
|
17
|
+
edit = (text: string) => { if (text !== this.state.text) this.update({ text, revision: this.state.revision + 1 }); };
|
|
18
|
+
get dirty() { return this.state.text !== this.state.savedText; }
|
|
19
|
+
private async operation(action: () => Promise<boolean>) {
|
|
20
|
+
if (this.active) return false;
|
|
21
|
+
this.active = true; this.update({ busy: true, error: null });
|
|
22
|
+
try { return await action(); }
|
|
23
|
+
catch (error) { this.update({ error: error instanceof Error ? error.message : String(error) }); return false; }
|
|
24
|
+
finally { this.active = false; this.update({ busy: false }); }
|
|
25
|
+
}
|
|
26
|
+
private async saveSnapshot(saveAs: boolean) {
|
|
27
|
+
const snapshot = this.state;
|
|
28
|
+
const file = await this.io.save(snapshot.file, snapshot.text, snapshot.savedText, saveAs);
|
|
29
|
+
if (!file) return false;
|
|
30
|
+
// Edits typed during an asynchronous save remain dirty.
|
|
31
|
+
this.update({ file, savedText: snapshot.text }); return true;
|
|
32
|
+
}
|
|
33
|
+
save = (saveAs = false) => this.operation(() => this.saveSnapshot(saveAs));
|
|
34
|
+
private async mayReplace() {
|
|
35
|
+
if (!this.dirty) return true;
|
|
36
|
+
const revision = this.state.revision;
|
|
37
|
+
const answer = await this.io.confirmDiscard(this.state.file.name);
|
|
38
|
+
if (answer === "cancel") return false;
|
|
39
|
+
if (answer === "save") return await this.saveSnapshot(false) && !this.dirty;
|
|
40
|
+
return revision === this.state.revision;
|
|
41
|
+
}
|
|
42
|
+
canClose = () => this.operation(() => this.mayReplace());
|
|
43
|
+
newDocument = () => this.operation(async () => {
|
|
44
|
+
if (!await this.mayReplace()) return false;
|
|
45
|
+
this.update({ text: "", savedText: "", file: { name: "Untitled.txt" }, revision: this.state.revision + 1 }); return true;
|
|
46
|
+
});
|
|
47
|
+
open = () => this.operation(async () => {
|
|
48
|
+
if (!await this.mayReplace()) return false;
|
|
49
|
+
const revision = this.state.revision;
|
|
50
|
+
const loaded = await this.io.open();
|
|
51
|
+
if (!loaded || revision !== this.state.revision) return false;
|
|
52
|
+
this.update({ file: loaded.file, text: loaded.text, savedText: loaded.text, revision: revision + 1 }); return true;
|
|
53
|
+
});
|
|
54
|
+
load = (file: FileReference, text: string) => this.operation(async () => {
|
|
55
|
+
if (!await this.mayReplace()) return false;
|
|
56
|
+
this.update({ file, text, savedText: text, revision: this.state.revision + 1 }); return true;
|
|
57
|
+
});
|
|
58
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { openFileDialog, saveFileDialog } from "@legendapp/spark/dialogs";
|
|
2
|
+
import { readText, writeText, writeTextIfUnchanged } from "@legendapp/spark/files";
|
|
3
|
+
import { Alert } from "react-native";
|
|
4
|
+
import type { DocumentIO } from "./document";
|
|
5
|
+
export const io: DocumentIO = {
|
|
6
|
+
async open() {
|
|
7
|
+
const paths = await openFileDialog({ filters: [{ extensions: ["txt", "md", "json"] }], multiple: false });
|
|
8
|
+
if (paths.canceled) return null;
|
|
9
|
+
const location = paths.paths[0];
|
|
10
|
+
return { file: { location, name: location.split(/[\\/]/).pop()! }, text: await readText(location) };
|
|
11
|
+
},
|
|
12
|
+
async save(file, text, expected, saveAs) {
|
|
13
|
+
const result = saveAs || !file.location ? await saveFileDialog({ defaultName: file.name, filters: [{ extensions: ["txt", "md", "json"] }] }) : { canceled: false as const, path: file.location };
|
|
14
|
+
if (result.canceled) return null;
|
|
15
|
+
const location = result.path;
|
|
16
|
+
if (!location) return null;
|
|
17
|
+
if (location === file.location) {
|
|
18
|
+
if (!(await writeTextIfUnchanged(location, expected, text)).written) throw new Error("The file changed outside the editor. Use Save As to keep your edits in another file.");
|
|
19
|
+
} else await writeText(location, text);
|
|
20
|
+
return { location, name: location.split(/[\\/]/).pop()! };
|
|
21
|
+
},
|
|
22
|
+
confirmDiscard: name => new Promise(resolve => Alert.alert("Unsaved changes", `Save changes to ${name}?`, [
|
|
23
|
+
{ text: "Cancel", style: "cancel", onPress: () => resolve("cancel") },
|
|
24
|
+
{ text: "Discard", style: "destructive", onPress: () => resolve("discard") },
|
|
25
|
+
{ text: "Save", onPress: () => resolve("save") },
|
|
26
|
+
], { cancelable: false })),
|
|
27
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { io as desktop } from "./io.desktop";
|
|
2
|
+
import { showMessage } from "@legendapp/spark/dialogs";
|
|
3
|
+
import type { DocumentIO } from "./document";
|
|
4
|
+
export const io: DocumentIO = { ...desktop, async confirmDiscard(name) {
|
|
5
|
+
const { buttonId } = await showMessage({ title: "Unsaved changes", message: `Save changes to ${name}?`, buttons: [{ id: "cancel", label: "Cancel" }, { id: "discard", label: "Discard" }, { id: "save", label: "Save" }], cancelButtonId: "cancel", defaultButtonId: "save" });
|
|
6
|
+
return buttonId === "save" ? "save" : buttonId === "discard" ? "discard" : "cancel";
|
|
7
|
+
} };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import * as DocumentPicker from "expo-document-picker";
|
|
2
|
+
import * as FileSystem from "expo-file-system/legacy";
|
|
3
|
+
import * as Sharing from "expo-sharing";
|
|
4
|
+
import { Alert } from "react-native";
|
|
5
|
+
import type { DocumentIO } from "./document";
|
|
6
|
+
export const io: DocumentIO = {
|
|
7
|
+
async open() {
|
|
8
|
+
const result = await DocumentPicker.getDocumentAsync({ type: ["text/*", "application/json"], copyToCacheDirectory: true });
|
|
9
|
+
if (result.canceled) return null;
|
|
10
|
+
const asset = result.assets[0]!;
|
|
11
|
+
if ((asset.size ?? 0) > 8 * 1024 * 1024) throw new Error("Choose a text file smaller than 8 MB");
|
|
12
|
+
// The picker returns an imported copy, not permission to overwrite the source.
|
|
13
|
+
return { file: { name: asset.name }, text: await FileSystem.readAsStringAsync(asset.uri) };
|
|
14
|
+
},
|
|
15
|
+
async save(file, text) {
|
|
16
|
+
if (!await Sharing.isAvailableAsync()) throw new Error("File sharing is unavailable on this device");
|
|
17
|
+
const directory = FileSystem.documentDirectory;
|
|
18
|
+
if (!directory) throw new Error("Document storage is unavailable");
|
|
19
|
+
const name = file.name.replace(/[^a-zA-Z0-9._-]/g, "_") || "Untitled.txt";
|
|
20
|
+
const location = `${directory}spark-${name}`;
|
|
21
|
+
await FileSystem.writeAsStringAsync(location, text);
|
|
22
|
+
await Sharing.shareAsync(location, { mimeType: "text/plain", UTI: "public.plain-text" });
|
|
23
|
+
// Save commits the app's durable copy, regardless of share-sheet dismissal.
|
|
24
|
+
return { name: file.name, location };
|
|
25
|
+
},
|
|
26
|
+
confirmDiscard: name => new Promise(resolve => Alert.alert("Unsaved changes", `Save a copy of ${name}?`, [
|
|
27
|
+
{ text: "Cancel", style: "cancel", onPress: () => resolve("cancel") },
|
|
28
|
+
{ text: "Discard", style: "destructive", onPress: () => resolve("discard") },
|
|
29
|
+
{ text: "Save", onPress: () => resolve("save") },
|
|
30
|
+
], { cancelable: false })),
|
|
31
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { DocumentIO } from "./document";
|
|
2
|
+
export const io: DocumentIO = {
|
|
3
|
+
open: () => new Promise((resolve, reject) => {
|
|
4
|
+
const input = document.createElement("input"); input.type = "file"; input.accept = ".txt,.md,.json,text/plain";
|
|
5
|
+
input.oncancel = () => resolve(null);
|
|
6
|
+
input.onchange = () => {
|
|
7
|
+
const file = input.files?.[0];
|
|
8
|
+
if (!file) { resolve(null); return; }
|
|
9
|
+
if (file.size > 8 * 1024 * 1024) { reject(new Error("Choose a text file smaller than 8 MB")); return; }
|
|
10
|
+
void file.text().then(text => resolve({ file: { name: file.name }, text }), reject);
|
|
11
|
+
};
|
|
12
|
+
input.click();
|
|
13
|
+
}),
|
|
14
|
+
async save(file, text) {
|
|
15
|
+
const url = URL.createObjectURL(new Blob([text], { type: "text/plain;charset=utf-8" }));
|
|
16
|
+
const anchor = document.createElement("a"); anchor.href = url; anchor.download = file.name; anchor.click();
|
|
17
|
+
setTimeout(() => URL.revokeObjectURL(url), 30_000);
|
|
18
|
+
return { name: file.name };
|
|
19
|
+
},
|
|
20
|
+
async confirmDiscard(name) { return window.confirm(`Discard unsaved changes to ${name}? Choose Cancel to return and save.`) ? "discard" : "cancel"; },
|
|
21
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { io } from "./io.desktop";
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type Command = { id: string; title: string; key: string; run(): void };
|
|
2
|
+
export type LifecycleProps = {
|
|
3
|
+
windowId?: string;
|
|
4
|
+
title: string;
|
|
5
|
+
dirty(): boolean;
|
|
6
|
+
flush(): Promise<boolean>;
|
|
7
|
+
quit?(): Promise<boolean>;
|
|
8
|
+
commands: readonly Command[];
|
|
9
|
+
onError(message: string): void;
|
|
10
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
type Subscription = { remove(): void | Promise<void> };
|
|
2
|
+
const lifetimes = new Map<string, Promise<void>>();
|
|
3
|
+
/** Serialize teardown/re-registration across Strict Mode and Fast Refresh. */
|
|
4
|
+
export function mountSerial(key: string, setup: (retain: (item: Promise<Subscription>) => Promise<void>) => Promise<void>, onError: (message: string) => void) {
|
|
5
|
+
const previous = lifetimes.get(key) ?? Promise.resolve();
|
|
6
|
+
let finished!: () => void;
|
|
7
|
+
const closed = new Promise<void>(resolve => { finished = resolve; });
|
|
8
|
+
lifetimes.set(key, closed);
|
|
9
|
+
let removed = false;
|
|
10
|
+
const subscriptions: Subscription[] = [];
|
|
11
|
+
const ready = previous.then(async () => {
|
|
12
|
+
if (removed) return;
|
|
13
|
+
await setup(async item => { const subscription = await item; if (removed) await subscription.remove(); else subscriptions.push(subscription); });
|
|
14
|
+
}).catch(error => onError(String(error)));
|
|
15
|
+
return () => {
|
|
16
|
+
if (removed) return;
|
|
17
|
+
removed = true;
|
|
18
|
+
void ready.then(async () => {
|
|
19
|
+
try { for (const subscription of subscriptions.reverse()) { try { await subscription.remove(); } catch (error) { onError(String(error)); } } }
|
|
20
|
+
finally { finished(); if (lifetimes.get(key) === closed) lifetimes.delete(key); }
|
|
21
|
+
});
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export interface RecordStorage {
|
|
2
|
+
getItem(key: string): Promise<string | null>;
|
|
3
|
+
setItem(key: string, value: string): Promise<void>;
|
|
4
|
+
}
|
|
5
|
+
type Envelope = { format: 1; revision: number; data: string; checksum: string };
|
|
6
|
+
function checksum(text: string) {
|
|
7
|
+
let hash = 2166136261;
|
|
8
|
+
for (let i = 0; i < text.length; i++) hash = Math.imul(hash ^ text.charCodeAt(i), 16777619);
|
|
9
|
+
return (hash >>> 0).toString(16);
|
|
10
|
+
}
|
|
11
|
+
/** Alternating complete snapshots retain the previous version if a write is interrupted.
|
|
12
|
+
* One writer per key in this JS runtime; not a cross-process database or encryption.
|
|
13
|
+
*/
|
|
14
|
+
export class Records<T> {
|
|
15
|
+
private revision = 0;
|
|
16
|
+
private loaded = false;
|
|
17
|
+
private tail: Promise<void> = Promise.resolve();
|
|
18
|
+
constructor(private storage: RecordStorage, private key: string, private decode: (value: unknown) => T) {}
|
|
19
|
+
async load(): Promise<{ value: T | null; recovered: boolean }> {
|
|
20
|
+
const raw = await Promise.all([0, 1].map(slot => this.storage.getItem(`${this.key}:${slot}`)));
|
|
21
|
+
const valid: { revision: number; value: T }[] = [];
|
|
22
|
+
let invalid = false;
|
|
23
|
+
for (const text of raw) {
|
|
24
|
+
if (text === null) continue;
|
|
25
|
+
let item: Envelope;
|
|
26
|
+
try {
|
|
27
|
+
item = JSON.parse(text) as Envelope;
|
|
28
|
+
if (item.format !== 1 || !Number.isSafeInteger(item.revision) || item.revision < 1 || typeof item.data !== "string" || item.checksum !== checksum(item.data)) throw new Error("Invalid record");
|
|
29
|
+
} catch { invalid = true; continue; }
|
|
30
|
+
// A valid envelope with an unknown application schema needs migration, not rollback.
|
|
31
|
+
valid.push({ revision: item.revision, value: this.decode(JSON.parse(item.data)) });
|
|
32
|
+
}
|
|
33
|
+
valid.sort((a, b) => b.revision - a.revision);
|
|
34
|
+
if (!valid.length && invalid) throw new Error("Stored data could not be read. It has been preserved; restore a backup before editing.");
|
|
35
|
+
this.revision = valid[0]?.revision ?? 0;
|
|
36
|
+
this.loaded = true;
|
|
37
|
+
return { value: valid[0]?.value ?? null, recovered: invalid };
|
|
38
|
+
}
|
|
39
|
+
save(value: T): Promise<void> {
|
|
40
|
+
// Serialize now so later edits cannot change an in-flight snapshot.
|
|
41
|
+
const data = JSON.stringify(value);
|
|
42
|
+
const write = this.tail.then(async () => {
|
|
43
|
+
if (!this.loaded) throw new Error("Load records before saving");
|
|
44
|
+
const revision = this.revision + 1;
|
|
45
|
+
await this.storage.setItem(`${this.key}:${revision % 2}`, JSON.stringify({ format: 1, revision, data, checksum: checksum(data) }));
|
|
46
|
+
this.revision = revision;
|
|
47
|
+
});
|
|
48
|
+
this.tail = write.catch(() => {});
|
|
49
|
+
return write;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
2
|
+
import { Records } from "./records";
|
|
3
|
+
import { projectId } from "./identity";
|
|
4
|
+
export function records<T>(name: string, decode: (value: unknown) => T) {
|
|
5
|
+
return new Records(AsyncStorage, `spark:${projectId.length}:${projectId}:${name}`, decode);
|
|
6
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { useSyncExternalStore } from "react";
|
|
2
|
+
export interface Model<T> { subscribe(listener: () => void): () => void; getSnapshot(): T }
|
|
3
|
+
/** React adapter for these examples' external application models. */
|
|
4
|
+
export function useModel<T>(model: Model<T>): T {
|
|
5
|
+
return useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
|
|
6
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { OpenFiles } from "./shared/OpenFiles";
|
|
2
|
+
import { music } from "./store";
|
|
3
|
+
const open = async (uri: string) => { await music.load(); await music.add([{ id: uri, uri, name: uri.split(/[\\/]/).pop()! }]); };
|
|
4
|
+
export function Activation({ windowId, onError }: { windowId: string; onError(message: string): void }) { return <OpenFiles windowId={windowId} onFile={open} onError={onError} />; }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Activation } from "./Activation.desktop";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export function Activation(_props: { windowId: string; onError(message: string): void }) { return null; }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Activation } from "./Activation.desktop";
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Activation } from "./Activation";
|
|
2
|
+
import { useEffect, useMemo, useState } from "react";
|
|
3
|
+
import { FlatList, Platform, SafeAreaView, StyleSheet, Text, View } from "react-native";
|
|
4
|
+
import { Button } from "@legendapp/spark/ui";
|
|
5
|
+
import { Lifecycle } from "./shared/Lifecycle";
|
|
6
|
+
import { useModel } from "./shared/useModel";
|
|
7
|
+
import { mountSerial } from "./shared/lifetime";
|
|
8
|
+
import { music, dirty } from "./store";
|
|
9
|
+
import { importTracks } from "./assets";
|
|
10
|
+
const time = (seconds: number) => `${Math.floor(seconds / 60)}:${String(Math.floor(seconds % 60)).padStart(2, "0")}`;
|
|
11
|
+
export default function App({ windowId = "main" }: { windowId?: string }) {
|
|
12
|
+
const state = useModel(music);
|
|
13
|
+
const [error, setError] = useState<string | null>(null);
|
|
14
|
+
const [importing, setImporting] = useState(false);
|
|
15
|
+
const commands = useMemo(() => [{ id: "import", title: "Import tracks…", key: "O", run: () => {
|
|
16
|
+
setImporting(true); void importTracks().then(music.add).catch(error => setError(String(error))).finally(() => setImporting(false));
|
|
17
|
+
} }], []);
|
|
18
|
+
useEffect(() => mountSerial("music-player", async retain => {
|
|
19
|
+
await music.load();
|
|
20
|
+
const timer = setInterval(() => { void music.tick(); }, 500);
|
|
21
|
+
await retain(Promise.resolve({ remove: async () => { clearInterval(timer); await music.dispose(); } }));
|
|
22
|
+
}, setError), []);
|
|
23
|
+
const current = state.tracks.find(track => track.id === state.currentId);
|
|
24
|
+
return <SafeAreaView style={styles.root}>
|
|
25
|
+
<Activation windowId={windowId} onError={setError} />
|
|
26
|
+
<Lifecycle title="Music" windowId={windowId} flush={music.flush} dirty={dirty} commands={commands} onError={setError} />
|
|
27
|
+
<Text style={styles.title}>Music</Text>
|
|
28
|
+
<Text>{["macos", "windows"].includes(Platform.OS) ? "Tracks stay in their original location. Keep those files available." : "Imported audio is stored on this device."}</Text>
|
|
29
|
+
<View style={styles.toolbar}><Button disabled={!state.ready || importing} onPress={commands[0]!.run}>{importing ? "Importing…" : "Import tracks"}</Button></View>
|
|
30
|
+
{error || state.error ? <Text accessibilityRole="alert" style={styles.error}>{error ?? state.error}</Text> : null}
|
|
31
|
+
<FlatList data={state.tracks} keyExtractor={track => track.id} extraData={[state.currentId, state.busy]} renderItem={({ item }) => <View style={[styles.track, item.id === state.currentId && styles.current]}><Text style={styles.name} numberOfLines={2}>{item.name}</Text><Button disabled={state.busy} onPress={() => { void music.play(item.id); }}>Play track</Button></View>} ListEmptyComponent={<Text style={styles.empty}>Import a few audio files to start your queue.</Text>} />
|
|
32
|
+
<View style={styles.player}><Text style={styles.now}>{current?.name ?? "Nothing playing"}</Text><Text accessibilityLiveRegion="none">{time(state.position)} / {time(state.status.duration)}</Text>
|
|
33
|
+
<View style={styles.toolbar}><Button disabled={state.busy || !state.tracks.length} onPress={() => { void (state.status.playing ? music.pause() : music.play()); }}>{state.status.playing ? "Pause" : "Play"}</Button><Button disabled={state.busy || !current || state.status.duration <= 0} onPress={() => { void music.seek(state.status.currentTime - 10); }}>Back 10 seconds</Button><Button disabled={state.busy || !current || state.status.duration <= 0} onPress={() => { void music.seek(Math.min(state.status.duration, state.status.currentTime + 10)); }}>Forward 10 seconds</Button><Button disabled={state.busy || !current || state.status.duration <= 0} onPress={() => { void music.next(); }}>Next track</Button></View>
|
|
34
|
+
<Text>{state.dirty ? "Saving playback position…" : "Queue and position saved"}</Text>
|
|
35
|
+
</View>
|
|
36
|
+
</SafeAreaView>;
|
|
37
|
+
}
|
|
38
|
+
const styles = StyleSheet.create({ root: { flex: 1, padding: 20, gap: 12, backgroundColor: "#faf9f6" }, title: { fontSize: 28, fontWeight: "600", color: "#222" }, toolbar: { flexDirection: "row", flexWrap: "wrap", gap: 8 }, track: { flexDirection: "row", alignItems: "center", padding: 12, gap: 12, borderBottomWidth: 1, borderColor: "#ddd" }, name: { flex: 1, fontSize: 16, color: "#222" }, current: { backgroundColor: "#dce9ef" }, player: { padding: 16, gap: 12, backgroundColor: "white", borderRadius: 8 }, now: { fontSize: 18, fontWeight: "600", color: "#222" }, empty: { padding: 24, color: "#555" }, error: { color: "#a32020" } });
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { openFileDialog } from "@legendapp/spark/dialogs";
|
|
2
|
+
import type { Track } from "./model";
|
|
3
|
+
export async function importTracks(): Promise<Track[]> {
|
|
4
|
+
const paths = await openFileDialog({ filters: [{ extensions: ["mp3", "m4a", "wav", "aac", "flac", "ogg"] }], multiple: true });
|
|
5
|
+
return (paths.canceled ? [] : paths.paths).map(uri => ({ id: uri, uri, name: uri.split(/[\\/]/).pop()! }));
|
|
6
|
+
}
|
|
7
|
+
export const resolveTrack = async (track: Track) => ({ uri: track.uri, release() {} });
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./assets.desktop";
|