@kolkrabbi/kol-component 0.105.0 → 0.107.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.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-component",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.107.0",
|
|
4
4
|
"description": "KOL design-system components — atoms through organisms, emitting canonical kol-* classes. Pairs with @kolkrabbi/kol-theme for styling.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"react-syntax-highlighter": "^16.1.1"
|
|
21
21
|
},
|
|
22
22
|
"peerDependencies": {
|
|
23
|
-
"@kolkrabbi/kol-icons": ">=0.
|
|
23
|
+
"@kolkrabbi/kol-icons": ">=0.22.0",
|
|
24
24
|
"framer-motion": "^12.0.0",
|
|
25
25
|
"gsap": "^3.13.0",
|
|
26
26
|
"hls.js": "^1.6.0",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"react-router-dom": "^6.0.0 || ^7.0.0"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
|
-
"@kolkrabbi/kol-icons": "^0.
|
|
33
|
+
"@kolkrabbi/kol-icons": "^0.22.0"
|
|
34
34
|
},
|
|
35
35
|
"files": [
|
|
36
36
|
"src",
|
package/src/index.js
CHANGED
|
@@ -142,6 +142,9 @@ export { default as SectionNewsletter } from './organisms/SectionNewsletter.jsx'
|
|
|
142
142
|
export { default as NewsletterBand } from './organisms/NewsletterBand.jsx'
|
|
143
143
|
export { default as ColumnBrowser } from './organisms/ColumnBrowser.jsx'
|
|
144
144
|
export { default as KindPreview } from './molecules/KindPreview.jsx'
|
|
145
|
+
export { default as AudioPreview, AudioTile, VideoTile, formatLength } from './molecules/AudioPreview.jsx'
|
|
146
|
+
export { default as VideoSheet } from './molecules/VideoSheet.jsx'
|
|
147
|
+
export { readCover } from './utilities/id3.js'
|
|
145
148
|
export { kindOf, extOf, isSystemFile, KINDS, KIND_LABEL } from './utilities/mediaKinds.js'
|
|
146
149
|
export { default as markdownToHtml, inlineToHtml } from './utilities/markdownToHtml.js'
|
|
147
150
|
export { default as RecordManager } from './organisms/RecordManager.jsx'
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/* eslint-disable react-hooks/refs -- floating-ui's `refs.setReference` is a callback ref, not a ref read; the DS Tooltip wires it the same way */
|
|
2
|
+
import { useEffect, useRef, useState } from 'react';
|
|
3
|
+
import IconFrame from '../atoms/IconFrame.jsx';
|
|
4
|
+
import Slider from './Slider.jsx';
|
|
5
|
+
import { usePopover, PopoverPanel } from '../utilities/Popover.jsx';
|
|
6
|
+
import { readCover } from '../utilities/id3.js';
|
|
7
|
+
|
|
8
|
+
/* taxonomy-ok: molecule — nests IconFrame (atom) + Slider / PopoverPanel (relative). */
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* AudioPreview · AudioTile · VideoTile — kol-r2b2's players, promoted verbatim
|
|
12
|
+
* (ColumnBrowserMediaFacts 2026-08-27; the tiles re-ruled the same day,
|
|
13
|
+
* PlayDiscAndVideoBar): players composed from DS parts (IconFrame · Slider ·
|
|
14
|
+
* Popover) — the DS AudioPlayer is a native <audio controls>, whose one-row
|
|
15
|
+
* layout can't be reshaped. Finder model (user ruling 2026-08-27): the COLUMN
|
|
16
|
+
* gets a square tile over full-bleed artwork with ONE control — the Finder
|
|
17
|
+
* play/pause disc (`.kol-play-disc`: round, hidden at rest, shown on hover of
|
|
18
|
+
* `.kol-media-tile`) — `AudioTile` (artwork = the file's embedded ID3 cover,
|
|
19
|
+
* `readCover`) / `VideoTile` (artwork = the poster; the media element is hidden
|
|
20
|
+
* — the tile never shows a decoded video frame). Timeline + volume belong to
|
|
21
|
+
* the OVERLAY / Quick Look — `AudioPreview` (play/pause · Slider seek with an
|
|
22
|
+
* m:ss readout · volume behind `slider-01`, a vertical `slider-black` range in
|
|
23
|
+
* a PopoverPanel, placement top; no title line — the name sits in the facts)
|
|
24
|
+
* and `VideoSheet` (the QuickTime bar).
|
|
25
|
+
*
|
|
26
|
+
* @param {string} src the media URL
|
|
27
|
+
* @param {string} poster VideoTile — the poster URL
|
|
28
|
+
* @param {Function} onDuration AudioPreview — (seconds) => void once metadata lands
|
|
29
|
+
* @param {string} className AudioPreview — wrapper classes (the width is the consumer's)
|
|
30
|
+
*/
|
|
31
|
+
export const formatLength = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`;
|
|
32
|
+
const fmt = formatLength;
|
|
33
|
+
|
|
34
|
+
function useCover(src) {
|
|
35
|
+
const [cover, setCover] = useState(null);
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
let live = true;
|
|
38
|
+
readCover(src).then((url) => { if (live) setCover(url); });
|
|
39
|
+
return () => { live = false; };
|
|
40
|
+
}, [src]);
|
|
41
|
+
return cover;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/* One disc for both tiles — the ruled Finder play/pause. The theme's
|
|
45
|
+
* `.kol-play-disc` paints it after the IconFrame variants, so nothing is restated here. */
|
|
46
|
+
function PlayDisc({ playing, onClick }) {
|
|
47
|
+
return (
|
|
48
|
+
<IconFrame name={playing ? 'pause' : 'play'} variant="secondary" size="lg" radius="full" onClick={onClick} aria-label={playing ? 'Pause' : 'Play'} className="relative kol-play-disc" />
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function AudioTile({ src }) {
|
|
53
|
+
const ref = useRef(null);
|
|
54
|
+
const [playing, setPlaying] = useState(false);
|
|
55
|
+
const cover = useCover(src);
|
|
56
|
+
return (
|
|
57
|
+
<div className="kol-media-tile relative w-full aspect-square rounded overflow-hidden flex items-center justify-center">
|
|
58
|
+
<audio ref={ref} src={src} preload="metadata" onPlay={() => setPlaying(true)} onPause={() => setPlaying(false)} onEnded={() => setPlaying(false)} />
|
|
59
|
+
{cover && <img src={cover} alt="" className="absolute inset-0 w-full h-full object-cover" />}
|
|
60
|
+
<PlayDisc playing={playing} onClick={() => (playing ? ref.current.pause() : ref.current.play())} />
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function VideoTile({ src, poster }) {
|
|
66
|
+
const ref = useRef(null);
|
|
67
|
+
const [playing, setPlaying] = useState(false);
|
|
68
|
+
return (
|
|
69
|
+
<div className="kol-media-tile relative w-full aspect-square rounded overflow-hidden flex items-center justify-center">
|
|
70
|
+
{/* Same as audio: the media element is hidden, the artwork (the poster) is the tile. */}
|
|
71
|
+
<video ref={ref} src={src} playsInline preload="metadata" className={poster ? 'hidden' : 'absolute inset-0 w-full h-full object-cover'} onPlay={() => setPlaying(true)} onPause={() => setPlaying(false)} onEnded={() => setPlaying(false)} />
|
|
72
|
+
{poster && <img src={poster} alt="" className="absolute inset-0 w-full h-full object-cover" />}
|
|
73
|
+
<PlayDisc playing={playing} onClick={() => (playing ? ref.current.pause() : ref.current.play())} />
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function useAudio(onDuration) {
|
|
79
|
+
const ref = useRef(null);
|
|
80
|
+
const [playing, setPlaying] = useState(false);
|
|
81
|
+
const [time, setTime] = useState(0);
|
|
82
|
+
const [duration, setDuration] = useState(0);
|
|
83
|
+
const toggle = () => (playing ? ref.current.pause() : ref.current.play());
|
|
84
|
+
const element = (src) => (
|
|
85
|
+
<audio
|
|
86
|
+
ref={ref}
|
|
87
|
+
src={src}
|
|
88
|
+
preload="metadata"
|
|
89
|
+
onPlay={() => setPlaying(true)}
|
|
90
|
+
onPause={() => setPlaying(false)}
|
|
91
|
+
onEnded={() => setPlaying(false)}
|
|
92
|
+
onTimeUpdate={(e) => setTime(e.target.currentTime)}
|
|
93
|
+
onLoadedMetadata={(e) => { setDuration(e.target.duration); onDuration?.(e.target.duration); }}
|
|
94
|
+
/>
|
|
95
|
+
);
|
|
96
|
+
return { ref, playing, time, duration, toggle, element, setTime };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export default function AudioPreview({ src, className = '', onDuration }) {
|
|
100
|
+
const { ref, playing, time, duration, toggle, element, setTime } = useAudio(onDuration);
|
|
101
|
+
const [volume, setVolume] = useState(100);
|
|
102
|
+
const [open, setOpen] = useState(false);
|
|
103
|
+
const pop = usePopover({ open, onOpenChange: setOpen, placement: 'top' });
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<div className={`flex items-center gap-2 ${className}`}>
|
|
107
|
+
{element(src)}
|
|
108
|
+
<IconFrame name={playing ? 'pause' : 'play'} variant="ghost" size="sm" onClick={toggle} />
|
|
109
|
+
<Slider
|
|
110
|
+
className="flex-1"
|
|
111
|
+
min={0}
|
|
112
|
+
max={duration || 0}
|
|
113
|
+
step={0.1}
|
|
114
|
+
value={time}
|
|
115
|
+
onChange={(v) => { ref.current.currentTime = v; setTime(v); }}
|
|
116
|
+
formatValue={fmt}
|
|
117
|
+
displayWidth={5}
|
|
118
|
+
/>
|
|
119
|
+
<span ref={pop.refs.setReference} {...pop.getReferenceProps()} className="inline-flex">
|
|
120
|
+
<IconFrame name="slider-01" variant={open ? 'secondary' : 'ghost'} size="sm" onClick={() => {}} />
|
|
121
|
+
</span>
|
|
122
|
+
<PopoverPanel popover={pop} className="p-2">
|
|
123
|
+
{/* DS track class on a native range, turned upright: the one thing the DS Slider can't do. */}
|
|
124
|
+
<div className="w-6 h-24 flex items-center justify-center">
|
|
125
|
+
<input
|
|
126
|
+
type="range"
|
|
127
|
+
min={0}
|
|
128
|
+
max={100}
|
|
129
|
+
value={volume}
|
|
130
|
+
onChange={(e) => { const v = Number(e.target.value); ref.current.volume = v / 100; setVolume(v); }}
|
|
131
|
+
className="slider-black cursor-pointer w-24 -rotate-90"
|
|
132
|
+
aria-label="Volume"
|
|
133
|
+
/>
|
|
134
|
+
</div>
|
|
135
|
+
</PopoverPanel>
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react'
|
|
2
|
-
import
|
|
2
|
+
import { AudioTile, VideoTile } from './AudioPreview.jsx'
|
|
3
3
|
import HlsVideo from '../atoms/HlsVideo.jsx'
|
|
4
4
|
import CodeBlock from './CodeBlock.jsx'
|
|
5
5
|
import AssetPlaceholder from '../utilities/AssetPlaceholder.jsx'
|
|
@@ -13,7 +13,10 @@ import markdownToHtml from '../utilities/markdownToHtml.js'
|
|
|
13
13
|
* promoted 2026-08-27 (SettingsPanelChromeAndColumnPreview). Before it, anything
|
|
14
14
|
* that was not an image or a video showed a grey box with the word "text".
|
|
15
15
|
* HLS → `HlsVideo` (inert — the DS's background-video atom, preview only),
|
|
16
|
-
*
|
|
16
|
+
* video → `VideoTile` and audio → `AudioTile` (ColumnBrowserMediaFacts
|
|
17
|
+
* 2026-08-27 — the square column tile with one play/pause control; no Figure, no
|
|
18
|
+
* border — `VideoBlock`'s Figure border is why kol-r2b2 bypassed it; the overlay
|
|
19
|
+
* player is `AudioPreview`), markdown → rendered prose in `.kol-prose`
|
|
17
20
|
* (markdownToHtml, KindPreviewMarkdown 2026-08-27), json / yaml / text / code →
|
|
18
21
|
* `CodeBlock` (language by kind or extension), the rest
|
|
19
22
|
* → `AssetPlaceholder`. Images are the caller's (ColumnBrowser keeps its own
|
|
@@ -71,13 +74,8 @@ export default function KindPreview({ o, urlOf = (x) => x.url, poster, kindOf =
|
|
|
71
74
|
</div>
|
|
72
75
|
)
|
|
73
76
|
}
|
|
74
|
-
if (kind === '
|
|
75
|
-
|
|
76
|
-
<div className="flex justify-center p-8">
|
|
77
|
-
<AudioPlayer src={url} label={name} className="items-center w-[420px] max-w-full" />
|
|
78
|
-
</div>
|
|
79
|
-
)
|
|
80
|
-
}
|
|
77
|
+
if (kind === 'video') return <VideoTile src={url} poster={poster} />
|
|
78
|
+
if (kind === 'audio') return <AudioTile src={url} />
|
|
81
79
|
if (isText) {
|
|
82
80
|
if (loading) return <span className="kol-mono-12 text-meta">Loading…</span>
|
|
83
81
|
if (error) return <span className="kol-mono-12 text-ui-error">Couldn’t load: {error}</span>
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/* eslint-disable react-hooks/refs -- floating-ui's `refs.setReference` is a callback ref, as in the DS Tooltip */
|
|
2
|
+
import { useRef, useState } from 'react';
|
|
3
|
+
import IconFrame from '../atoms/IconFrame.jsx';
|
|
4
|
+
import Slider from './Slider.jsx';
|
|
5
|
+
import { usePopover, PopoverPanel } from '../utilities/Popover.jsx';
|
|
6
|
+
import { formatLength } from './AudioPreview.jsx';
|
|
7
|
+
|
|
8
|
+
/* taxonomy-ok: molecule — nests IconFrame (atom) + Slider / PopoverPanel (relative). */
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* VideoSheet — the overlay for video: the QuickTime bar (ruled in kol-r2b2
|
|
12
|
+
* 2026-08-27, PlayDiscAndVideoBar; promoted verbatim). No native controls; a
|
|
13
|
+
* frosted strip over the video's bottom edge, inset 16px, radius 4px (never
|
|
14
|
+
* more), `bg-fg-absolute-64` (absolute black — it sits on video, not on the
|
|
15
|
+
* theme) + `backdrop-blur-md`, `h-14 px-4 gap-3`: skip-back 15 · play/pause ·
|
|
16
|
+
* skip-forward 15 · elapsed · the DS Slider as the scrubber with the REMAINING
|
|
17
|
+
* time as its readout · volume behind `slider-01` (the vertical `slider-black`
|
|
18
|
+
* range in a PopoverPanel, as AudioPreview). Click on the video toggles play;
|
|
19
|
+
* `autoPlay`. Aria-labels only, no `title` tooltips.
|
|
20
|
+
*
|
|
21
|
+
* @param {string} src the video URL
|
|
22
|
+
* @param {string} poster the poster URL
|
|
23
|
+
* @param {Function} onMeta ({ w, h, len }) => void once metadata lands — the overlay's facts
|
|
24
|
+
*/
|
|
25
|
+
export default function VideoSheet({ src, poster, onMeta }) {
|
|
26
|
+
const ref = useRef(null);
|
|
27
|
+
const [playing, setPlaying] = useState(false);
|
|
28
|
+
const [time, setTime] = useState(0);
|
|
29
|
+
const [duration, setDuration] = useState(0);
|
|
30
|
+
const [volume, setVolume] = useState(100);
|
|
31
|
+
const [open, setOpen] = useState(false);
|
|
32
|
+
const pop = usePopover({ open, onOpenChange: setOpen, placement: 'top' });
|
|
33
|
+
const toggle = () => (playing ? ref.current.pause() : ref.current.play());
|
|
34
|
+
const seek = (t) => { const v = Math.max(0, Math.min(duration || 0, t)); ref.current.currentTime = v; setTime(v); };
|
|
35
|
+
return (
|
|
36
|
+
<div className="relative max-w-full">
|
|
37
|
+
<video
|
|
38
|
+
ref={ref}
|
|
39
|
+
src={src}
|
|
40
|
+
poster={poster}
|
|
41
|
+
autoPlay
|
|
42
|
+
playsInline
|
|
43
|
+
preload="metadata"
|
|
44
|
+
className="max-w-full max-h-[78vh] rounded"
|
|
45
|
+
onClick={toggle}
|
|
46
|
+
onPlay={() => setPlaying(true)}
|
|
47
|
+
onPause={() => setPlaying(false)}
|
|
48
|
+
onEnded={() => setPlaying(false)}
|
|
49
|
+
onTimeUpdate={(e) => setTime(e.target.currentTime)}
|
|
50
|
+
onLoadedMetadata={(e) => { setDuration(e.target.duration); onMeta?.({ w: e.target.videoWidth, h: e.target.videoHeight, len: e.target.duration }); }}
|
|
51
|
+
/>
|
|
52
|
+
<div className="absolute left-4 right-4 bottom-4 h-14 px-4 rounded flex items-center gap-3 bg-fg-absolute-64 backdrop-blur-md">
|
|
53
|
+
<IconFrame name="skip-back-15" variant="ghost" size="sm" onClick={() => seek(time - 15)} aria-label="Back 15 seconds" />
|
|
54
|
+
<IconFrame name={playing ? 'pause' : 'play'} variant="ghost" size="sm" onClick={toggle} aria-label={playing ? 'Pause' : 'Play'} />
|
|
55
|
+
<IconFrame name="skip-forward-15" variant="ghost" size="sm" onClick={() => seek(time + 15)} aria-label="Forward 15 seconds" />
|
|
56
|
+
<span className="kol-mono-14 text-fg-64 tabular-nums">{formatLength(time)}</span>
|
|
57
|
+
{/* DS Slider: the track is the scrubber, its readout is the remaining time. */}
|
|
58
|
+
<Slider
|
|
59
|
+
className="flex-1 min-w-0"
|
|
60
|
+
min={0}
|
|
61
|
+
max={duration || 0}
|
|
62
|
+
step={0.1}
|
|
63
|
+
value={time}
|
|
64
|
+
onChange={seek}
|
|
65
|
+
formatValue={(v) => formatLength(Math.max(0, (duration || 0) - v))}
|
|
66
|
+
displayWidth={5}
|
|
67
|
+
/>
|
|
68
|
+
<span ref={pop.refs.setReference} {...pop.getReferenceProps()} className="inline-flex">
|
|
69
|
+
<IconFrame name="slider-01" variant={open ? 'secondary' : 'ghost'} size="sm" onClick={() => {}} aria-label="Volume" />
|
|
70
|
+
</span>
|
|
71
|
+
<PopoverPanel popover={pop} className="p-2">
|
|
72
|
+
<div className="w-6 h-24 flex items-center justify-center">
|
|
73
|
+
<input
|
|
74
|
+
type="range"
|
|
75
|
+
min={0}
|
|
76
|
+
max={100}
|
|
77
|
+
value={volume}
|
|
78
|
+
onChange={(e) => { const v = Number(e.target.value); ref.current.volume = v / 100; setVolume(v); }}
|
|
79
|
+
className="slider-black cursor-pointer w-24 -rotate-90"
|
|
80
|
+
aria-label="Volume"
|
|
81
|
+
/>
|
|
82
|
+
</div>
|
|
83
|
+
</PopoverPanel>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from 'react'
|
|
2
2
|
import { Icon } from '@kolkrabbi/kol-icons'
|
|
3
3
|
import KindPreview from '../molecules/KindPreview.jsx'
|
|
4
|
+
import { formatLength } from '../molecules/AudioPreview.jsx'
|
|
4
5
|
import { kindOf as dsKindOf, KIND_LABEL as DS_KIND_LABEL } from '../utilities/mediaKinds.js'
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -43,7 +44,7 @@ import { kindOf as dsKindOf, KIND_LABEL as DS_KIND_LABEL } from '../utilities/me
|
|
|
43
44
|
* @param {Object} kindLabel kind → label shown when there is no visual preview
|
|
44
45
|
* @param {Function} formatSize (bytes) => string
|
|
45
46
|
* @param {Function} partition (objects, level) => { folders: string[], files: object[] }
|
|
46
|
-
* @param {Function} renderPreview (file) => ReactNode — replaces the preview column's media frame (the facts stay); without it images render the organism's <img>, everything else the DS KindPreview
|
|
47
|
+
* @param {Function} renderPreview (file) => ReactNode — replaces the preview column's media frame (the facts stay — Dimensions and Length are read off whatever <img> / <video> / <audio> the node loads); without it images render the organism's <img>, everything else the DS KindPreview
|
|
47
48
|
* @param {string} className extra classes on the browser
|
|
48
49
|
*/
|
|
49
50
|
|
|
@@ -96,14 +97,21 @@ function Row({ icon, label, active, cursor = false, trailing, onClick, muted = f
|
|
|
96
97
|
}
|
|
97
98
|
|
|
98
99
|
function Preview({ o, urlOf, kindOf, kindLabel, formatSize, renderPreview }) {
|
|
99
|
-
// Pixel size
|
|
100
|
+
// Pixel size and length come from the loaded media itself — the bucket stores
|
|
101
|
+
// none. `{ w, h }` off an <img> load, `{ w, h, len }` off a <video>'s and
|
|
102
|
+
// `{ len }` off an <audio>'s loadedmetadata (ColumnBrowserMediaFacts, kol-r2b2
|
|
103
|
+
// 2026-08-27 — "missing pixel dimensions and length in info").
|
|
100
104
|
const [dims, setDims] = useState(null)
|
|
105
|
+
const kind = kindOf(o)
|
|
101
106
|
const src = isImage(o) ? urlOf?.(o) : null
|
|
107
|
+
const sized = src || kind === 'video'
|
|
108
|
+
const timed = kind === 'video' || kind === 'audio'
|
|
102
109
|
const facts = [
|
|
103
|
-
['Kind', kindLabel[
|
|
110
|
+
['Kind', kindLabel[kind] || 'file'],
|
|
104
111
|
['Type', o.contentType || '—'],
|
|
105
112
|
['Size', formatSize(o.size)],
|
|
106
|
-
...(
|
|
113
|
+
...(sized ? [['Dimensions', dims?.w ? `${dims.w} × ${dims.h} px` : '…']] : []),
|
|
114
|
+
...(timed ? [['Length', dims?.len != null ? formatLength(dims.len) : '…']] : []),
|
|
107
115
|
['Date', o.uploaded ? new Date(o.uploaded).toISOString().slice(0, 10) : '—'],
|
|
108
116
|
]
|
|
109
117
|
return (
|
|
@@ -111,7 +119,9 @@ function Preview({ o, urlOf, kindOf, kindLabel, formatSize, renderPreview }) {
|
|
|
111
119
|
{/* the media frame: an image is the organism's own <img> (it reads the
|
|
112
120
|
* dimensions); anything else is `renderPreview(o)` or the DS KindPreview
|
|
113
121
|
* (video · audio · code · text — SettingsPanelChromeAndColumnPreview,
|
|
114
|
-
* 2026-08-27). Dimensions also come off any <img>
|
|
122
|
+
* 2026-08-27). Dimensions and Length also come off any <img> / <video> /
|
|
123
|
+
* <audio> a custom node loads — captured on the frame, so consumer
|
|
124
|
+
* `renderPreview` nodes count. */}
|
|
115
125
|
{src && !renderPreview ? (
|
|
116
126
|
<div className="w-full aspect-square bg-fg-04 rounded flex items-center justify-center overflow-hidden">
|
|
117
127
|
<img
|
|
@@ -126,6 +136,11 @@ function Preview({ o, urlOf, kindOf, kindLabel, formatSize, renderPreview }) {
|
|
|
126
136
|
<div
|
|
127
137
|
className="kol-column-browser-media w-full min-h-[160px] max-h-[60vh] overflow-auto rounded bg-fg-04 flex items-center justify-center"
|
|
128
138
|
onLoadCapture={(e) => { if (e.target?.tagName === 'IMG') setDims({ w: e.target.naturalWidth, h: e.target.naturalHeight }) }}
|
|
139
|
+
onLoadedMetadataCapture={(e) => {
|
|
140
|
+
const t = e.target
|
|
141
|
+
if (t?.tagName === 'VIDEO') setDims({ w: t.videoWidth, h: t.videoHeight, len: t.duration })
|
|
142
|
+
else if (t?.tagName === 'AUDIO') setDims({ len: t.duration })
|
|
143
|
+
}}
|
|
129
144
|
>
|
|
130
145
|
{renderPreview ? renderPreview(o) : <KindPreview o={o} urlOf={urlOf} kindOf={kindOf} kindLabel={kindLabel} />}
|
|
131
146
|
</div>
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Embedded cover art: the ID3v2.3 / 2.4 `APIC` frame at the head of an MP3, read with one ranged fetch
|
|
2
|
+
// (`Range` with a single byte range is CORS-safelisted — no preflight; R2 and the B2 proxy honour it).
|
|
3
|
+
// ponytail: no ID3v2.2 (`PIC`), no unsynchronisation — add when a file in the buckets needs either.
|
|
4
|
+
const CHUNK = 1 << 20
|
|
5
|
+
const covers = new Map(); // url → Promise<string|null> object URL. ponytail: never revoked — a few hundred KB per file viewed.
|
|
6
|
+
|
|
7
|
+
const syncsafe = (b, i) => ((b[i] & 0x7f) << 21) | ((b[i + 1] & 0x7f) << 14) | ((b[i + 2] & 0x7f) << 7) | (b[i + 3] & 0x7f)
|
|
8
|
+
const be32 = (b, i) => ((b[i] << 24) | (b[i + 1] << 16) | (b[i + 2] << 8) | b[i + 3]) >>> 0
|
|
9
|
+
const latin1 = (b) => String.fromCharCode(...b)
|
|
10
|
+
|
|
11
|
+
async function range(url, from, to) {
|
|
12
|
+
const r = await fetch(url, { headers: { Range: `bytes=${from}-${to}` } })
|
|
13
|
+
if (!r.ok) throw new Error(String(r.status))
|
|
14
|
+
return new Uint8Array(await r.arrayBuffer())
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function concat(a, b) { const out = new Uint8Array(a.length + b.length); out.set(a); out.set(b, a.length); return out; }
|
|
18
|
+
|
|
19
|
+
function apic(tag, version) {
|
|
20
|
+
let i = 0
|
|
21
|
+
let first = null; // Finder shows the FRONT cover (type 3) when a file carries several pictures
|
|
22
|
+
while (i + 10 <= tag.length) {
|
|
23
|
+
const id = latin1(tag.subarray(i, i + 4))
|
|
24
|
+
if (!/^[A-Z0-9]{4}$/.test(id)) break; // padding
|
|
25
|
+
const size = version === 4 ? syncsafe(tag, i + 4) : be32(tag, i + 4)
|
|
26
|
+
if (id === 'APIC') {
|
|
27
|
+
const body = tag.subarray(i + 10, i + 10 + size)
|
|
28
|
+
const enc = body[0]
|
|
29
|
+
let p = 1
|
|
30
|
+
while (p < body.length && body[p] !== 0) p++
|
|
31
|
+
const mime = latin1(body.subarray(1, p))
|
|
32
|
+
const type = body[p + 1]
|
|
33
|
+
p += 2; // mime terminator + picture type
|
|
34
|
+
if (enc === 1 || enc === 2) { while (p + 1 < body.length && !(body[p] === 0 && body[p + 1] === 0)) p += 2; p += 2; } // UTF-16 description
|
|
35
|
+
else { while (p < body.length && body[p] !== 0) p++; p++; }
|
|
36
|
+
const blob = new Blob([body.subarray(p)], { type: mime || 'image/jpeg' })
|
|
37
|
+
if (type === 3) return blob
|
|
38
|
+
first ??= blob
|
|
39
|
+
}
|
|
40
|
+
i += 10 + size
|
|
41
|
+
}
|
|
42
|
+
return first
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function readCover(url) {
|
|
46
|
+
if (!covers.has(url)) {
|
|
47
|
+
covers.set(url, (async () => {
|
|
48
|
+
try {
|
|
49
|
+
let bytes = await range(url, 0, CHUNK - 1)
|
|
50
|
+
if (latin1(bytes.subarray(0, 3)) !== 'ID3' || bytes[3] < 3) return null
|
|
51
|
+
const version = bytes[3]
|
|
52
|
+
const flags = bytes[5]
|
|
53
|
+
const size = syncsafe(bytes, 6)
|
|
54
|
+
if (bytes.length < 10 + size) bytes = concat(bytes, await range(url, bytes.length, 10 + size - 1))
|
|
55
|
+
let tag = bytes.subarray(10, 10 + size)
|
|
56
|
+
if (flags & 0x40) tag = tag.subarray(version === 4 ? syncsafe(tag, 0) : be32(tag, 0) + 4); // extended header
|
|
57
|
+
const blob = apic(tag, version)
|
|
58
|
+
return blob ? URL.createObjectURL(blob) : null
|
|
59
|
+
} catch {
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
})())
|
|
63
|
+
}
|
|
64
|
+
return covers.get(url)
|
|
65
|
+
}
|