@streamscloud/kit 0.30.0 → 0.32.0

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 (40) hide show
  1. package/dist/core/files/app-upload-activity.svelte.d.ts +20 -0
  2. package/dist/core/files/app-upload-activity.svelte.js +34 -0
  3. package/dist/core/files/file-types.d.ts +7 -0
  4. package/dist/core/files/file-types.js +8 -0
  5. package/dist/core/files/file-validation-rules.d.ts +1 -0
  6. package/dist/core/files/file-validation-rules.js +32 -38
  7. package/dist/core/files/index.d.ts +4 -3
  8. package/dist/core/files/index.js +2 -1
  9. package/dist/core/files/upload-media-store.svelte.d.ts +39 -9
  10. package/dist/core/files/upload-media-store.svelte.js +71 -14
  11. package/dist/core/files/upload-types.d.ts +16 -0
  12. package/dist/core/media/index.d.ts +2 -0
  13. package/dist/core/media/index.js +1 -0
  14. package/dist/core/media/video-probe.d.ts +28 -0
  15. package/dist/core/media/video-probe.js +82 -0
  16. package/dist/ui/file-uploader/cmp.file-row.svelte +19 -15
  17. package/dist/ui/file-uploader/cmp.file-row.svelte.d.ts +9 -8
  18. package/dist/ui/file-uploader/cmp.file-upload-progress.svelte +10 -31
  19. package/dist/ui/file-uploader/cmp.file-upload-progress.svelte.d.ts +2 -3
  20. package/dist/ui/file-uploader/cmp.upload-progress-toaster.svelte +132 -0
  21. package/dist/ui/file-uploader/cmp.upload-progress-toaster.svelte.d.ts +27 -0
  22. package/dist/ui/file-uploader/file-type-colors.d.ts +5 -0
  23. package/dist/ui/file-uploader/file-type-colors.js +11 -1
  24. package/dist/ui/file-uploader/file-type-stamp.svelte +71 -0
  25. package/dist/ui/file-uploader/file-type-stamp.svelte.d.ts +8 -0
  26. package/dist/ui/file-uploader/index.d.ts +3 -1
  27. package/dist/ui/file-uploader/index.js +2 -1
  28. package/dist/ui/file-uploader/types.d.ts +16 -0
  29. package/dist/ui/file-uploader/types.js +1 -0
  30. package/dist/ui/file-uploader/upload-progress-toaster-localization.d.ts +4 -0
  31. package/dist/ui/file-uploader/upload-progress-toaster-localization.js +23 -0
  32. package/dist/ui/select/_select-trigger.scss +16 -12
  33. package/dist/ui/select/cmp.input-suggest.svelte +495 -0
  34. package/dist/ui/select/cmp.input-suggest.svelte.d.ts +97 -0
  35. package/dist/ui/select/index.d.ts +3 -1
  36. package/dist/ui/select/index.js +1 -0
  37. package/dist/ui/select/types.d.ts +10 -0
  38. package/package.json +1 -1
  39. package/dist/ui/file-uploader/to-uploading-file.d.ts +0 -9
  40. package/dist/ui/file-uploader/to-uploading-file.js +0 -24
@@ -0,0 +1,20 @@
1
+ import type { UploadingFile } from './upload-types';
2
+ declare class AppUploadActivityContainer {
3
+ private _runs;
4
+ private _nextRunId;
5
+ /** Files of every in-flight run, including ones that already finished inside a still-running run. Deduplicated by id. */
6
+ get files(): UploadingFile[];
7
+ /** True while at least one run is in flight. Flips only on run start / end, never on a progress tick. */
8
+ get isUploading(): boolean;
9
+ /**
10
+ * Register an in-flight upload run and get its id back. `files` is called on every read, so pass
11
+ * an accessor over live state, not a snapshot. `UploadMediaStore.upload()` registers itself — call
12
+ * this directly only when uploading outside the store.
13
+ */
14
+ beginRun: (files: () => UploadingFile[]) => number;
15
+ /** End a run started with `beginRun` — always from a `finally`, or the run never leaves the list. */
16
+ endRun: (id: number) => void;
17
+ }
18
+ /** Reactive, app-wide view of what is uploading right now. Drives `UploadProgressToaster`. */
19
+ export declare const AppUploadActivity: AppUploadActivityContainer;
20
+ export {};
@@ -0,0 +1,34 @@
1
+ class AppUploadActivityContainer {
2
+ _runs = $state.raw([]);
3
+ _nextRunId = 0;
4
+ /** Files of every in-flight run, including ones that already finished inside a still-running run. Deduplicated by id. */
5
+ get files() {
6
+ const byId = new Map();
7
+ for (const run of this._runs) {
8
+ for (const file of run.files()) {
9
+ byId.set(file.id, file);
10
+ }
11
+ }
12
+ return [...byId.values()];
13
+ }
14
+ /** True while at least one run is in flight. Flips only on run start / end, never on a progress tick. */
15
+ get isUploading() {
16
+ return this._runs.length > 0;
17
+ }
18
+ /**
19
+ * Register an in-flight upload run and get its id back. `files` is called on every read, so pass
20
+ * an accessor over live state, not a snapshot. `UploadMediaStore.upload()` registers itself — call
21
+ * this directly only when uploading outside the store.
22
+ */
23
+ beginRun = (files) => {
24
+ const id = this._nextRunId++;
25
+ this._runs = [...this._runs, { id, files }];
26
+ return id;
27
+ };
28
+ /** End a run started with `beginRun` — always from a `finally`, or the run never leaves the list. */
29
+ endRun = (id) => {
30
+ this._runs = this._runs.filter((run) => run.id !== id);
31
+ };
32
+ }
33
+ /** Reactive, app-wide view of what is uploading right now. Drives `UploadProgressToaster`. */
34
+ export const AppUploadActivity = new AppUploadActivityContainer();
@@ -13,6 +13,13 @@ export declare class AcceptFileType {
13
13
  static readonly webAssetImageOrVideo: string;
14
14
  static readonly webAssetIcon = "image/png";
15
15
  }
16
+ /**
17
+ * Whether a MIME type names media a browser can present on its own — an image, a video or a
18
+ * sound. `false` for documents and archives, and for a blank type (`.heic`, `.mkv` and files with
19
+ * no extension often arrive with `file.type === ''`). Drives `UploadMediaStore`'s
20
+ * `{ preview: 'mime' }` mode.
21
+ */
22
+ export declare const isPreviewableType: (type: string) => boolean;
16
23
  /**
17
24
  * `accept`-style matcher. Tokens may be MIMEs (`image/*`, `*\/*`, `application/pdf`) or
18
25
  * extensions (`.pdf`), case-insensitive. Empty accept = match anything.
@@ -14,6 +14,14 @@ export class AcceptFileType {
14
14
  static webAssetImageOrVideo = [this.webAssetImage, this.webAssetVideo].join(',');
15
15
  static webAssetIcon = 'image/png';
16
16
  }
17
+ const PREVIEWABLE_TYPE_PREFIXES = ['image/', 'video/', 'audio/'];
18
+ /**
19
+ * Whether a MIME type names media a browser can present on its own — an image, a video or a
20
+ * sound. `false` for documents and archives, and for a blank type (`.heic`, `.mkv` and files with
21
+ * no extension often arrive with `file.type === ''`). Drives `UploadMediaStore`'s
22
+ * `{ preview: 'mime' }` mode.
23
+ */
24
+ export const isPreviewableType = (type) => PREVIEWABLE_TYPE_PREFIXES.some((prefix) => type.toLowerCase().startsWith(prefix));
17
25
  /**
18
26
  * `accept`-style matcher. Tokens may be MIMEs (`image/*`, `*\/*`, `application/pdf`) or
19
27
  * extensions (`.pdf`), case-insensitive. Empty accept = match anything.
@@ -16,6 +16,7 @@ import type { AspectRatioBound, FileValidationRule, VideoOrientationValue } from
16
16
  * ```
17
17
  */
18
18
  export declare const FileRules: {
19
+ /** Passes when the container reports no duration (e.g. a live-stream WebM) — bitrate cannot be derived without it. */
19
20
  AudioBitrate: (maxKbps: number, message?: string) => FileValidationRule;
20
21
  AudioDuration: (maxSeconds: number, message?: string) => FileValidationRule;
21
22
  AudioOnly: (message?: string) => FileValidationRule;
@@ -1,3 +1,4 @@
1
+ import { probeVideo } from '../media';
1
2
  import { StringHelper } from '../utils';
2
3
  import { FileHelper } from './file-helper';
3
4
  import { matchesAcceptedFileTypes } from './file-types';
@@ -15,34 +16,26 @@ const readImageDimensions = (file) => new Promise((resolve, reject) => {
15
16
  };
16
17
  img.src = url;
17
18
  });
18
- const readMediaDuration = (file, kind) => new Promise((resolve, reject) => {
19
+ const readMedia = async (file) => {
19
20
  const url = URL.createObjectURL(file);
20
- const el = kind === 'video' ? document.createElement('video') : document.createElement('audio');
21
- el.preload = 'metadata';
22
- el.onloadedmetadata = () => {
23
- URL.revokeObjectURL(url);
24
- resolve(el.duration);
25
- };
26
- el.onerror = () => {
27
- URL.revokeObjectURL(url);
28
- reject(new Error(`Failed to read ${kind} duration`));
29
- };
30
- el.src = url;
31
- });
32
- const readVideoDimensions = (file) => new Promise((resolve, reject) => {
33
- const url = URL.createObjectURL(file);
34
- const video = document.createElement('video');
35
- video.preload = 'metadata';
36
- video.onloadedmetadata = () => {
37
- URL.revokeObjectURL(url);
38
- resolve({ width: video.videoWidth, height: video.videoHeight });
39
- };
40
- video.onerror = () => {
21
+ try {
22
+ const probe = await probeVideo(url, { cover: false });
23
+ if (!probe.readable) {
24
+ throw new Error('Failed to read media metadata');
25
+ }
26
+ return probe;
27
+ }
28
+ finally {
41
29
  URL.revokeObjectURL(url);
42
- reject(new Error('Failed to read video dimensions'));
43
- };
44
- video.src = url;
45
- });
30
+ }
31
+ };
32
+ const readDuration = async (file) => {
33
+ const { durationSec } = await readMedia(file);
34
+ if (durationSec === null) {
35
+ throw new Error('Media duration is not reported by the container');
36
+ }
37
+ return durationSec;
38
+ };
46
39
  const parseRatio = (value) => {
47
40
  if (typeof value === 'number') {
48
41
  return Number.isFinite(value) && value > 0 ? value : NaN;
@@ -81,20 +74,21 @@ const messages = new FileValidationLocalization();
81
74
  * ```
82
75
  */
83
76
  export const FileRules = {
77
+ /** Passes when the container reports no duration (e.g. a live-stream WebM) — bitrate cannot be derived without it. */
84
78
  AudioBitrate: (maxKbps, message) => async (file) => {
85
- const duration = await readMediaDuration(file, 'audio');
86
- if (!duration) {
79
+ const { durationSec } = await readMedia(file);
80
+ if (durationSec === null || durationSec === 0) {
87
81
  return null;
88
82
  }
89
- const kbps = (file.size * 8) / duration / 1000;
83
+ const kbps = (file.size * 8) / durationSec / 1000;
90
84
  return kbps > maxKbps ? (message ?? messages.maxBitrate(maxKbps)) : null;
91
85
  },
92
86
  AudioDuration: (maxSeconds, message) => async (file) => {
93
- const duration = await readMediaDuration(file, 'audio');
94
- return duration > maxSeconds ? (message ?? messages.maxDuration(maxSeconds)) : null;
87
+ const durationSec = await readDuration(file);
88
+ return durationSec > maxSeconds ? (message ?? messages.maxDuration(maxSeconds)) : null;
95
89
  },
96
90
  AudioOnly: (message) => async (file) => {
97
- const { width, height } = await readVideoDimensions(file);
91
+ const { width, height } = await readMedia(file);
98
92
  return width > 0 || height > 0 ? (message ?? messages.audioOnly) : null;
99
93
  },
100
94
  Extensions: (extensions, message) => (file) => {
@@ -117,7 +111,7 @@ export const FileRules = {
117
111
  return rule;
118
112
  },
119
113
  VideoAspectRatio: (ratios, message, tolerancePercent = 1) => async (file) => {
120
- const { width, height } = await readVideoDimensions(file);
114
+ const { width, height } = await readMedia(file);
121
115
  if (!width || !height) {
122
116
  return message ?? messages.aspectRatio(ratios.join(', '));
123
117
  }
@@ -135,7 +129,7 @@ export const FileRules = {
135
129
  * interval outward on both edges. An empty `{}` specifies no constraint and accepts everything.
136
130
  */
137
131
  VideoAspectRatioRange: (range, message, tolerancePercent = 1) => async (file) => {
138
- const { width, height } = await readVideoDimensions(file);
132
+ const { width, height } = await readMedia(file);
139
133
  if (!width || !height) {
140
134
  return message ?? messages.aspectRatioRange(formatBound(range.min), formatBound(range.max));
141
135
  }
@@ -148,7 +142,7 @@ export const FileRules = {
148
142
  return okLo && okHi ? null : (message ?? messages.aspectRatioRange(formatBound(range.min), formatBound(range.max)));
149
143
  },
150
144
  VideoOrientation: (orientation, message, tolerancePercent = 1) => async (file) => {
151
- const { width, height } = await readVideoDimensions(file);
145
+ const { width, height } = await readMedia(file);
152
146
  if (!width || !height) {
153
147
  return message ?? messages.orientation(orientation);
154
148
  }
@@ -158,11 +152,11 @@ export const FileRules = {
158
152
  return ok ? null : (message ?? messages.orientation(orientation));
159
153
  },
160
154
  VideoDimensions: (max, message) => async (file) => {
161
- const { width, height } = await readVideoDimensions(file);
155
+ const { width, height } = await readMedia(file);
162
156
  return width > max.width || height > max.height ? (message ?? messages.maxDimensions(max.width, max.height)) : null;
163
157
  },
164
158
  VideoDuration: (maxSeconds, message) => async (file) => {
165
- const duration = await readMediaDuration(file, 'video');
166
- return duration > maxSeconds ? (message ?? messages.maxDuration(maxSeconds)) : null;
159
+ const durationSec = await readDuration(file);
160
+ return durationSec > maxSeconds ? (message ?? messages.maxDuration(maxSeconds)) : null;
167
161
  }
168
162
  };
@@ -1,13 +1,13 @@
1
1
  export type { BlobWithName, FileWithBlobUrl, FileWithUploadUrl } from './types';
2
2
  export type { AspectRatioBound, FileValidationResult, FileValidationRule, FileValidationRuleSets, VideoOrientationValue } from './file-validation-types';
3
- export type { BlobUpload, BlobUploadStrategy, UploadingFile, UploadingFileStatus } from './upload-types';
4
- export type { UploadMediaStoreOptions } from './upload-media-store.svelte';
3
+ export type { BlobUpload, BlobUploadStrategy, PreviewableUploadingFile, UploadPreviewMode, UploadingFile, UploadingFileStatus } from './upload-types';
4
+ export type { AddOptions, UploadMediaStoreOptions } from './upload-media-store.svelte';
5
5
  export { toBlobWithName, toFileWithUploadUrl } from './types';
6
6
  export { uploadBlob } from './blob-storage';
7
7
  export { openFile } from './open-file';
8
8
  export { FileHelper } from './file-helper';
9
9
  export { downloadBlob, downloadFromUrl, downloadJson, fetchFile } from './file-service';
10
- export { AcceptFileType, matchesAcceptedFileTypes } from './file-types';
10
+ export { AcceptFileType, isPreviewableType, matchesAcceptedFileTypes } from './file-types';
11
11
  export { deriveAccept } from './file-validation-rule-sets';
12
12
  export { resizeBlob, resizeImage } from './image-resizer';
13
13
  export { FileWithBlobDataHelper } from './file-with-blob-data-helper';
@@ -15,3 +15,4 @@ export { FilesProvider } from './files-provider';
15
15
  export { FileValidator } from './file-validator';
16
16
  export { FileRules } from './file-validation-rules';
17
17
  export { UploadMediaStore } from './upload-media-store.svelte';
18
+ export { AppUploadActivity } from './app-upload-activity.svelte';
@@ -4,7 +4,7 @@ export { uploadBlob } from './blob-storage';
4
4
  export { openFile } from './open-file';
5
5
  export { FileHelper } from './file-helper';
6
6
  export { downloadBlob, downloadFromUrl, downloadJson, fetchFile } from './file-service';
7
- export { AcceptFileType, matchesAcceptedFileTypes } from './file-types';
7
+ export { AcceptFileType, isPreviewableType, matchesAcceptedFileTypes } from './file-types';
8
8
  export { deriveAccept } from './file-validation-rule-sets';
9
9
  export { resizeBlob, resizeImage } from './image-resizer';
10
10
  export { FileWithBlobDataHelper } from './file-with-blob-data-helper';
@@ -12,3 +12,4 @@ export { FilesProvider } from './files-provider';
12
12
  export { FileValidator } from './file-validator';
13
13
  export { FileRules } from './file-validation-rules';
14
14
  export { UploadMediaStore } from './upload-media-store.svelte';
15
+ export { AppUploadActivity } from './app-upload-activity.svelte';
@@ -1,10 +1,14 @@
1
- import type { BlobUploadStrategy, UploadingFile } from './upload-types';
1
+ import type { BlobUploadStrategy, PreviewableUploadingFile, UploadPreviewMode, UploadingFile } from './upload-types';
2
2
  type SuccessfulUpload = Extract<UploadingFile, {
3
3
  status: 'success';
4
4
  }>;
5
5
  type FailedUpload = Extract<UploadingFile, {
6
6
  status: 'error';
7
7
  }>;
8
+ export type AddOptions = {
9
+ /** Stage an object URL for local preview. @default 'never' */
10
+ preview?: UploadPreviewMode;
11
+ };
8
12
  export type UploadMediaStoreOptions = {
9
13
  /**
10
14
  * Auto-resize images before upload. Pass `false` to disable; pass an object to override the
@@ -48,25 +52,51 @@ export type UploadMediaStoreOptions = {
48
52
  */
49
53
  export declare class UploadMediaStore {
50
54
  private _files;
55
+ private _inFlight;
56
+ private _nextRunId;
51
57
  private _strategy;
52
58
  private opts;
53
59
  constructor(strategy: BlobUploadStrategy, opts?: UploadMediaStoreOptions);
54
60
  /** Reactive read of the queue. */
55
61
  get files(): UploadingFile[];
56
- /** Append a raw `File`, returns its `UploadingFile` wrapper (with a stable `id`). */
57
- add(file: File): UploadingFile;
58
- /** Append multiple raw files at once. */
59
- addMany(files: File[]): UploadingFile[];
60
- /** Remove a file from the queue by its `UploadingFile.id`. Does NOT cancel an in-flight upload. */
62
+ /**
63
+ * Append a raw `File`, returns its `UploadingFile` wrapper with a stable `id`. Pass
64
+ * `{ preview: 'always' }` when the caller knows this file gets previewed — the returned wrapper is
65
+ * then typed with a non-null `previewUrl`. The wrapper is a snapshot: `previewUrl` never changes,
66
+ * but read `status` / `progress` back from `files` rather than from it.
67
+ *
68
+ * `'always'` is the only mode that narrows the type — `'mime'` resolves against `file.type` at
69
+ * runtime, so its `previewUrl` stays `string | null` however previewable the file looks.
70
+ */
71
+ add(file: File, options: AddOptions & {
72
+ preview: 'always';
73
+ }): PreviewableUploadingFile;
74
+ add(file: File, options?: AddOptions): UploadingFile;
75
+ /** Append multiple raw files at once, with the same `preview` semantics as `add()` — only `'always'` narrows the returned type. */
76
+ addMany(files: File[], options: AddOptions & {
77
+ preview: 'always';
78
+ }): PreviewableUploadingFile[];
79
+ addMany(files: File[], options?: AddOptions): UploadingFile[];
80
+ /** Remove a file from the queue by its `UploadingFile.id`, revoking its `previewUrl`. Does NOT cancel an in-flight upload. */
61
81
  remove(id: string): void;
62
- /** Clear all queued / completed files. Does NOT cancel any in-flight uploads. */
82
+ /** Clear all queued / completed files, revoking their `previewUrl`s. Does NOT cancel any in-flight uploads. */
63
83
  clear(): void;
84
+ /** Resolved blob for a file id — `null` while it is still queued / uploading, or if it failed. */
85
+ resolve(id: string): {
86
+ blobId: string;
87
+ readUrl: string;
88
+ } | null;
64
89
  /**
65
90
  * Run the upload pipeline for every file currently in `'queued'` status. Files already
66
- * `uploading` / `success` / `error` are skipped. Resolves once every targeted file has a
67
- * terminal status.
91
+ * `uploading` / `success` / `error` are skipped. Resolves once **every** run of this store —
92
+ * including ones started by earlier `upload()` calls — has reached a terminal status, so
93
+ * `await store.upload()` always means "nothing of mine is in flight any more". Each run registers
94
+ * itself in `AppUploadActivity` for its duration, so a mounted `UploadProgressToaster` picks it up
95
+ * with no extra wiring.
68
96
  */
69
97
  upload(): Promise<void>;
98
+ private settled;
99
+ private runQueued;
70
100
  private maybeResize;
71
101
  private fail;
72
102
  private failAll;
@@ -1,9 +1,17 @@
1
+ import { AppUploadActivity } from './app-upload-activity.svelte';
1
2
  import { uploadBlob } from './blob-storage';
3
+ import { isPreviewableType } from './file-types';
2
4
  import { resizeBlob } from './image-resizer';
3
5
  import { nanoid } from 'nanoid';
4
6
  import { default as pLimit } from 'p-limit';
5
7
  const DEFAULT_RESIZE_LIMITS = { max1: 1600, max2: 1000 };
6
8
  const DEFAULT_CONCURRENCY = 20;
9
+ const toPreviewUrl = (file, mode) => mode === 'always' || (mode === 'mime' && isPreviewableType(file.type)) ? URL.createObjectURL(file) : null;
10
+ const revokePreviewUrl = (file) => {
11
+ if (file.previewUrl !== null) {
12
+ URL.revokeObjectURL(file.previewUrl);
13
+ }
14
+ };
7
15
  /**
8
16
  * Queue-and-upload coordinator. Pure logic — no UI. Pair with `FileRow` / `FileUploadProgress`
9
17
  * for visual progress, both of which consume the `files: UploadingFile[]` array reactively.
@@ -26,6 +34,8 @@ const DEFAULT_CONCURRENCY = 20;
26
34
  */
27
35
  export class UploadMediaStore {
28
36
  _files = $state.raw([]);
37
+ _inFlight = new Map();
38
+ _nextRunId = 0;
29
39
  _strategy;
30
40
  opts;
31
41
  constructor(strategy, opts = {}) {
@@ -36,36 +46,76 @@ export class UploadMediaStore {
36
46
  get files() {
37
47
  return this._files;
38
48
  }
39
- /** Append a raw `File`, returns its `UploadingFile` wrapper (with a stable `id`). */
40
- add(file) {
41
- const uploadingFile = { id: nanoid(), file, progress: 0, status: 'queued' };
49
+ add(file, options = {}) {
50
+ const uploadingFile = { id: nanoid(), file, progress: 0, previewUrl: toPreviewUrl(file, options.preview ?? 'never'), status: 'queued' };
42
51
  this._files = [...this._files, uploadingFile];
43
52
  return uploadingFile;
44
53
  }
45
- /** Append multiple raw files at once. */
46
- addMany(files) {
47
- const wrappers = files.map((file) => ({ id: nanoid(), file, progress: 0, status: 'queued' }));
54
+ addMany(files, options = {}) {
55
+ const preview = options.preview ?? 'never';
56
+ const wrappers = files.map((file) => ({ id: nanoid(), file, progress: 0, previewUrl: toPreviewUrl(file, preview), status: 'queued' }));
48
57
  this._files = [...this._files, ...wrappers];
49
58
  return wrappers;
50
59
  }
51
- /** Remove a file from the queue by its `UploadingFile.id`. Does NOT cancel an in-flight upload. */
60
+ /** Remove a file from the queue by its `UploadingFile.id`, revoking its `previewUrl`. Does NOT cancel an in-flight upload. */
52
61
  remove(id) {
53
- this._files = this._files.filter((f) => f.id !== id);
62
+ this._files = this._files.filter((f) => {
63
+ if (f.id !== id) {
64
+ return true;
65
+ }
66
+ revokePreviewUrl(f);
67
+ return false;
68
+ });
54
69
  }
55
- /** Clear all queued / completed files. Does NOT cancel any in-flight uploads. */
70
+ /** Clear all queued / completed files, revoking their `previewUrl`s. Does NOT cancel any in-flight uploads. */
56
71
  clear() {
72
+ this._files.forEach(revokePreviewUrl);
57
73
  this._files = [];
58
74
  }
75
+ /** Resolved blob for a file id — `null` while it is still queued / uploading, or if it failed. */
76
+ resolve(id) {
77
+ const file = this._files.find((f) => f.id === id);
78
+ return file?.status === 'success' ? { blobId: file.blobId, readUrl: file.readUrl } : null;
79
+ }
59
80
  /**
60
81
  * Run the upload pipeline for every file currently in `'queued'` status. Files already
61
- * `uploading` / `success` / `error` are skipped. Resolves once every targeted file has a
62
- * terminal status.
82
+ * `uploading` / `success` / `error` are skipped. Resolves once **every** run of this store —
83
+ * including ones started by earlier `upload()` calls — has reached a terminal status, so
84
+ * `await store.upload()` always means "nothing of mine is in flight any more". Each run registers
85
+ * itself in `AppUploadActivity` for its duration, so a mounted `UploadProgressToaster` picks it up
86
+ * with no extra wiring.
63
87
  */
64
88
  async upload() {
65
89
  const queued = this._files.filter((f) => f.status === 'queued');
66
90
  if (queued.length === 0) {
91
+ await this.settled();
67
92
  return;
68
93
  }
94
+ // Claim before the first await, or a second upload() during the strategy call re-claims the same files.
95
+ for (const entry of queued) {
96
+ this.transition(entry.id, (f) => ({ id: f.id, file: f.file, progress: 0, previewUrl: f.previewUrl, status: 'uploading' }));
97
+ }
98
+ const fileIds = new Set(queued.map((f) => f.id));
99
+ const activityRunId = AppUploadActivity.beginRun(() => this._files.filter((f) => fileIds.has(f.id)));
100
+ const runId = this._nextRunId++;
101
+ const run = (async () => {
102
+ try {
103
+ await this.runQueued(queued);
104
+ }
105
+ finally {
106
+ AppUploadActivity.endRun(activityRunId);
107
+ this._inFlight.delete(runId);
108
+ }
109
+ })();
110
+ this._inFlight.set(runId, run);
111
+ await this.settled();
112
+ }
113
+ async settled() {
114
+ while (this._inFlight.size > 0) {
115
+ await Promise.all([...this._inFlight.values()]);
116
+ }
117
+ }
118
+ async runQueued(queued) {
69
119
  let presigned;
70
120
  try {
71
121
  presigned = await this._strategy(queued.length);
@@ -81,14 +131,21 @@ export class UploadMediaStore {
81
131
  const limit = pLimit(this.opts.concurrency ?? DEFAULT_CONCURRENCY);
82
132
  const resizeOpt = this.opts.resize;
83
133
  await Promise.all(queued.map((entry, i) => limit(async () => {
84
- this.transition(entry.id, (f) => ({ id: f.id, file: f.file, progress: 0, status: 'uploading' }));
85
134
  try {
86
135
  const blob = resizeOpt === false ? entry.file : await this.maybeResize(entry.file, resizeOpt ?? DEFAULT_RESIZE_LIMITS);
87
136
  const slot = presigned[i];
88
137
  await uploadBlob(slot.uploadUrl, blob, (loaded, total) => {
89
138
  this.transition(entry.id, (f) => (f.status === 'uploading' ? { ...f, progress: total > 0 ? loaded / total : 0 } : f));
90
139
  });
91
- const done = this.transition(entry.id, (f) => ({ id: f.id, file: f.file, progress: 1, status: 'success', blobId: slot.id, readUrl: slot.readUrl }));
140
+ const done = this.transition(entry.id, (f) => ({
141
+ id: f.id,
142
+ file: f.file,
143
+ progress: 1,
144
+ previewUrl: f.previewUrl,
145
+ status: 'success',
146
+ blobId: slot.id,
147
+ readUrl: slot.readUrl
148
+ }));
92
149
  if (done?.status === 'success') {
93
150
  this.opts.on?.blobId?.(done, slot.id);
94
151
  }
@@ -114,7 +171,7 @@ export class UploadMediaStore {
114
171
  }
115
172
  fail(id, error) {
116
173
  const message = error instanceof Error ? error.message : 'Upload failed';
117
- const failed = this.transition(id, (f) => ({ id: f.id, file: f.file, progress: f.progress, status: 'error', error: message }));
174
+ const failed = this.transition(id, (f) => ({ id: f.id, file: f.file, progress: f.progress, previewUrl: f.previewUrl, status: 'error', error: message }));
118
175
  if (failed?.status === 'error') {
119
176
  this.opts.on?.error?.(failed, error);
120
177
  }
@@ -20,6 +20,12 @@ type UploadingFileBase = {
20
20
  file: File;
21
21
  /** 0..1 — fraction uploaded. 1 on success. */
22
22
  progress: number;
23
+ /**
24
+ * Object URL for local preview, or `null` when none was staged. Controlled per call by
25
+ * `add()` / `addMany()`'s `preview` option, which defaults to `'never'`. Revoked by `remove()` /
26
+ * `clear()`; never revoke it yourself.
27
+ */
28
+ previewUrl: string | null;
23
29
  };
24
30
  /**
25
31
  * Canonical shape passed to `FileRow` / `FileUploadProgress` and emitted by `UploadMediaStore`,
@@ -39,4 +45,14 @@ export type UploadingFile = (UploadingFileBase & {
39
45
  status: 'error';
40
46
  error: string;
41
47
  });
48
+ /** What `add()` / `addMany()` return for `{ preview: 'always' }` — a `previewUrl` that needs no null check. */
49
+ export type PreviewableUploadingFile = UploadingFile & {
50
+ previewUrl: string;
51
+ };
52
+ /**
53
+ * Whether `add()` / `addMany()` stage an object URL for local preview: `'always'` regardless of
54
+ * type, `'never'`, or `'mime'` to let `isPreviewableType(file.type)` decide (images, videos and
55
+ * audio get one). @default 'never'
56
+ */
57
+ export type UploadPreviewMode = 'always' | 'never' | 'mime';
42
58
  export {};
@@ -1,2 +1,4 @@
1
1
  export { ImageHelper } from './image-helper';
2
2
  export type { ImageSize } from './image-helper';
3
+ export { probeVideo } from './video-probe';
4
+ export type { VideoProbe, VideoProbeOptions } from './video-probe';
@@ -1 +1,2 @@
1
1
  export { ImageHelper } from './image-helper';
2
+ export { probeVideo } from './video-probe';
@@ -0,0 +1,28 @@
1
+ export type VideoProbe = {
2
+ /** `false` when the browser could not read the media at all (decode error / timeout before metadata) — every other field is then meaningless. */
3
+ readable: boolean;
4
+ /** `0` for a readable file with no video track (e.g. an audio-only container). */
5
+ width: number;
6
+ height: number;
7
+ /** Exact, unrounded seconds, or `null` when the container reports no usable duration (`Infinity` for MediaRecorder WebM, `NaN` for broken metadata). */
8
+ durationSec: number | null;
9
+ /** First readable frame as a JPEG blob. `null` when `cover` was not requested, or the browser could not decode / export one. */
10
+ cover: Blob | null;
11
+ };
12
+ export type VideoProbeOptions = {
13
+ /** Grab the first frame as a JPEG. Off means metadata-only — no full buffering, no decode, no canvas encode. @default true */
14
+ cover?: boolean;
15
+ /** @default 10000 */
16
+ timeoutMs?: number;
17
+ };
18
+ /**
19
+ * Read dimensions, duration and (optionally) a first-frame cover out of a local media URL — an
20
+ * object URL from `UploadingFile.previewUrl` or `URL.createObjectURL`. Always resolves: media the
21
+ * browser cannot decode comes back as `readable: false` with zeroed metrics, and a readable file
22
+ * whose cover cannot be exported comes back `readable: true` with `cover: null`.
23
+ *
24
+ * The single media-probing primitive in the kit — every `FileRules` audio / video rule reads through
25
+ * it with `cover: false`. Audio-only containers are read by the same `<video>` element (same media
26
+ * pipeline, same codec support) and come back `readable` with `width` / `height` of `0`.
27
+ */
28
+ export declare const probeVideo: (url: string, options?: VideoProbeOptions) => Promise<VideoProbe>;
@@ -0,0 +1,82 @@
1
+ const READ_TIMEOUT_MS = 10000;
2
+ const COVER_SEEK_SEC = 0.001;
3
+ const COVER_QUALITY = 0.85;
4
+ /**
5
+ * Read dimensions, duration and (optionally) a first-frame cover out of a local media URL — an
6
+ * object URL from `UploadingFile.previewUrl` or `URL.createObjectURL`. Always resolves: media the
7
+ * browser cannot decode comes back as `readable: false` with zeroed metrics, and a readable file
8
+ * whose cover cannot be exported comes back `readable: true` with `cover: null`.
9
+ *
10
+ * The single media-probing primitive in the kit — every `FileRules` audio / video rule reads through
11
+ * it with `cover: false`. Audio-only containers are read by the same `<video>` element (same media
12
+ * pipeline, same codec support) and come back `readable` with `width` / `height` of `0`.
13
+ */
14
+ export const probeVideo = (url, options = {}) => new Promise((resolve) => {
15
+ const { cover: withCover = true, timeoutMs = READ_TIMEOUT_MS } = options;
16
+ const video = document.createElement('video');
17
+ video.preload = withCover ? 'auto' : 'metadata';
18
+ video.muted = true;
19
+ video.crossOrigin = 'anonymous';
20
+ let settled = false;
21
+ let metadataRead = false;
22
+ let corsRetried = false;
23
+ const finish = (readable, cover) => {
24
+ if (settled) {
25
+ return;
26
+ }
27
+ settled = true;
28
+ clearTimeout(timeoutId);
29
+ const duration = video.duration;
30
+ const probe = {
31
+ readable,
32
+ width: video.videoWidth,
33
+ height: video.videoHeight,
34
+ durationSec: Number.isFinite(duration) ? duration : null,
35
+ cover
36
+ };
37
+ video.pause();
38
+ video.removeAttribute('src');
39
+ video.load();
40
+ resolve(probe);
41
+ };
42
+ // Unseekable containers fire neither seeked nor error — without the timeout the promise never settles.
43
+ const timeoutId = setTimeout(() => finish(metadataRead, null), timeoutMs);
44
+ video.onerror = () => {
45
+ // Without this retry a non-CORS remote URL never loads at all while crossOrigin is set.
46
+ if (!corsRetried && video.crossOrigin !== null) {
47
+ corsRetried = true;
48
+ video.crossOrigin = null;
49
+ video.src = url;
50
+ return;
51
+ }
52
+ finish(false, null);
53
+ };
54
+ video.onloadedmetadata = () => {
55
+ metadataRead = true;
56
+ if (!withCover) {
57
+ finish(true, null);
58
+ }
59
+ };
60
+ video.onloadeddata = () => {
61
+ video.currentTime = COVER_SEEK_SEC;
62
+ };
63
+ video.onseeked = () => {
64
+ try {
65
+ const canvas = document.createElement('canvas');
66
+ canvas.width = video.videoWidth;
67
+ canvas.height = video.videoHeight;
68
+ const context = canvas.getContext('2d');
69
+ if (!context || !canvas.width || !canvas.height) {
70
+ finish(true, null);
71
+ return;
72
+ }
73
+ context.drawImage(video, 0, 0);
74
+ canvas.toBlob((blob) => finish(true, blob), 'image/jpeg', COVER_QUALITY);
75
+ }
76
+ catch {
77
+ // toBlob throws on a tainted canvas; uncaught here it would leave the promise pending until the timeout.
78
+ finish(true, null);
79
+ }
80
+ };
81
+ video.src = url;
82
+ });