@3sln/trove 0.0.13 → 0.0.16

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.
@@ -17,6 +17,6 @@
17
17
  </head>
18
18
  <body>
19
19
  <div class="workbench"></div>
20
- <script type="module" src="/assets/main-828yzsr7.js"></script>
20
+ <script type="module" src="/assets/main-wpfmmbfd.js"></script>
21
21
  </body>
22
22
  </html>
@@ -18,7 +18,7 @@
18
18
  // API and FILES deliberately do NOT rotate. API is data, not code, and FILES holds the
19
19
  // bytes of files the user pinned for offline use — naming it per build would throw away
20
20
  // someone's offline library every time the app was redeployed.
21
- const SHELL = 'trove-shell-fbe8c37315';
21
+ const SHELL = 'trove-shell-5c503618bc';
22
22
  const API = 'trove-api-v1';
23
23
  const FILES = 'trove-files-v1';
24
24
  const KEEP = new Set([SHELL, API, FILES]);
@@ -43,6 +43,49 @@ export function iconForKind(node) {
43
43
  return ICONS[kindOf(node)] || 'file';
44
44
  }
45
45
 
46
+ /**
47
+ * The key an indexer writes a tile picture under, and the ONE key the grid looks for.
48
+ *
49
+ * A preset name rather than a per-plugin registration, because a thumbnail is not a
50
+ * contribution TYPE — it is a fact about a file that any indexer may happen to know. The
51
+ * audiobook plugin extracts cover art from an m4b's `covr` atom and an LPF's manifest;
52
+ * a PDF indexer could write its first page under the same key and the grid would draw it
53
+ * with no change here.
54
+ *
55
+ * @see plugins/audiobook/src/coverIndexer.js
56
+ */
57
+ export const THUMBNAIL_KEY = 'thumbnail';
58
+
59
+ /**
60
+ * A picture to put on this node's tile, or null.
61
+ *
62
+ * Contributions are namespaced by contributor, and a view deliberately does not care WHICH
63
+ * one supplied it: the first that did wins, and a drive with two thumbnail indexers for one
64
+ * file type has a configuration problem rather than a rendering one.
65
+ *
66
+ * TWO SHAPES, because a thumbnail is either already somewhere or it is not:
67
+ *
68
+ * { range: {start, end}, contentType } the bytes live in the FILE — an m4b's cover art
69
+ * sits in its `covr` atom — so the contribution
70
+ * points at them. Eighty bytes on the wire instead
71
+ * of a base64 copy on every listing.
72
+ * { src } a self-contained URL, for the cases with nothing
73
+ * to point at. Small by construction; see the
74
+ * indexer's cap.
75
+ *
76
+ * @returns {{range?: {start: number, end: number}, src?: string, contentType?: string}|null}
77
+ */
78
+ export function thumbnailOf(node) {
79
+ const contributions = node?.contributions;
80
+ if (!contributions) return null;
81
+ for (const contribution of Object.values(contributions)) {
82
+ const thumb = contribution?.metadata?.[THUMBNAIL_KEY];
83
+ if (typeof thumb?.src === 'string' && thumb.src) return thumb;
84
+ if (Number.isFinite(thumb?.range?.start) && Number.isFinite(thumb?.range?.end)) return thumb;
85
+ }
86
+ return null;
87
+ }
88
+
46
89
  /** Whether a node holds indexable text (drives offline text extraction). */
47
90
  export function isTexty(node) {
48
91
  return kindOf(node) === 'text';
@@ -15,8 +15,9 @@
15
15
 
16
16
  import { dd } from '../../../runtime.js';
17
17
  import { icon } from '../../icon.js';
18
+ import { thumbnailOf } from '../../../bl/fileType.js';
18
19
  import { groupHeader, menuButton, openRowMenu } from './parts.js';
19
- import { attachMedia } from '../../media.js';
20
+ import { attachMedia, attachThumbnail } from '../../media.js';
20
21
  import { activate } from '../../activate.js';
21
22
 
22
23
  const { div, span, img } = dd;
@@ -81,7 +82,11 @@ function columns() {
81
82
 
82
83
  function tile(it, active, { hover, select }, ui) {
83
84
  const node = it.node;
84
- const pictorial = (node?.contentType || '').startsWith('image/');
85
+ // A contributed thumbnail beats the file itself. It is already a picture of the right
86
+ // size, it needs no minted URL, and for anything that is not an image it is the only
87
+ // picture there is — an audiobook's cover art comes this way. See bl/fileType.js.
88
+ const thumb = thumbnailOf(node);
89
+ const pictorial = !thumb && (node?.contentType || '').startsWith('image/');
85
90
  return div({ className: `grid-tile ${active ? 'active' : ''}`, title: it.detail ? `${it.title} — ${it.detail}` : it.title },
86
91
  div({ className: 'gt-media' },
87
92
  // The icon is drawn underneath, always. When the image loads it covers it; when
@@ -92,6 +97,14 @@ function tile(it, active, { hover, select }, ui) {
92
97
  // The `src` is minted rather than built — a tile fetches without our Authorization
93
98
  // header, so the URL has to carry its own grant. Minting is batched (see
94
99
  // platform/mediaUrls.js), so a wall of tiles costs one request, not one each.
100
+ // A contributed thumbnail needs no minted URL: it is either self-contained or a
101
+ // range inside a file this session may already hold. See ui/media.js.
102
+ thumb
103
+ ? img({ className: 'gt-img', alt: '', loading: 'lazy', decoding: 'async' }).on({
104
+ $attach: (dom) => { dom._detachThumb = attachThumbnail(dom, node, thumb, ui); },
105
+ $detach: (dom) => { dom._detachThumb?.(); dom._detachThumb = null; },
106
+ }).opaque()
107
+ : null,
95
108
  pictorial
96
109
  ? img({ className: 'gt-img', alt: '', loading: 'lazy', decoding: 'async' }).on({
97
110
  $attach: (dom) => {
@@ -112,3 +112,37 @@ export function attachMedia(el, node, ui, { op = 'media', onError } = {}) {
112
112
  function isMedia(el) {
113
113
  return typeof el?.play === 'function' && typeof el?.load === 'function';
114
114
  }
115
+
116
+ /**
117
+ * Point an `<img>` at a contributed thumbnail — see bl/fileType.js for the two shapes.
118
+ *
119
+ * A `src` needs no help. A `range` is bytes inside the file, so they are fetched through
120
+ * `fileChunks`, which means a pinned book draws its cover with the network off and a book
121
+ * being kept offline contributes the read. The blob URL is revoked on detach: a wall of
122
+ * tiles that minted one each and never gave them back would hold every cover in memory for
123
+ * as long as the tab lived.
124
+ *
125
+ * @returns {() => void} detach
126
+ */
127
+ export function attachThumbnail(el, node, thumb, ui) {
128
+ if (thumb?.src) {
129
+ el.src = thumb.src;
130
+ return () => {};
131
+ }
132
+ let url = null;
133
+ let live = true;
134
+ ui.platform.fileChunks.read(node.id, thumb.range)
135
+ .then(({ bytes }) => {
136
+ if (!live || !bytes?.length) return;
137
+ url = URL.createObjectURL(new Blob([bytes], { type: thumb.contentType || 'image/jpeg' }));
138
+ el.src = url;
139
+ })
140
+ // A cover that will not load is a tile with an icon on it, which is what the list shows
141
+ // anyway — not an error worth putting in front of anyone.
142
+ .catch(() => { el.style.display = 'none'; });
143
+ return () => {
144
+ live = false;
145
+ if (url) URL.revokeObjectURL(url);
146
+ url = null;
147
+ };
148
+ }