@kolkrabbi/kol-component 0.106.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.106.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.18.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.20.0"
33
+ "@kolkrabbi/kol-icons": "^0.22.0"
34
34
  },
35
35
  "files": [
36
36
  "src",
package/src/index.js CHANGED
@@ -143,6 +143,8 @@ 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
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'
146
148
  export { kindOf, extOf, isSystemFile, KINDS, KIND_LABEL } from './utilities/mediaKinds.js'
147
149
  export { default as markdownToHtml, inlineToHtml } from './utilities/markdownToHtml.js'
148
150
  export { default as RecordManager } from './organisms/RecordManager.jsx'
@@ -1,22 +1,27 @@
1
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 { useRef, useState } from 'react';
2
+ import { useEffect, useRef, useState } from 'react';
3
3
  import IconFrame from '../atoms/IconFrame.jsx';
4
4
  import Slider from './Slider.jsx';
5
5
  import { usePopover, PopoverPanel } from '../utilities/Popover.jsx';
6
+ import { readCover } from '../utilities/id3.js';
6
7
 
7
8
  /* taxonomy-ok: molecule — nests IconFrame (atom) + Slider / PopoverPanel (relative). */
8
9
 
9
10
  /**
10
- * AudioPreview · AudioTile · VideoTile — kol-r2b2's `AudioPreview.jsx`, promoted
11
- * verbatim 2026-08-27 (ColumnBrowserMediaFacts): players composed from DS parts
12
- * (IconFrame · Slider · Popover) — the DS AudioPlayer is a native <audio controls>,
13
- * whose one-row layout can't be reshaped. Finder model (user ruling 2026-08-27):
14
- * the COLUMN gets a square tile with one play/pause control and nothing else —
15
- * `AudioTile` / `VideoTile` (KindPreview's audio and video branches); timeline +
16
- * volume belong to the OVERLAY / Quick Look `AudioPreview` (play/pause ·
17
- * Slider seek with an m:ss readout · volume behind `slider-01`, a vertical
18
- * `slider-black` range in a PopoverPanel, placement top; no title line the
19
- * name sits in the facts).
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).
20
25
  *
21
26
  * @param {string} src the media URL
22
27
  * @param {string} poster VideoTile — the poster URL
@@ -26,6 +31,50 @@ import { usePopover, PopoverPanel } from '../utilities/Popover.jsx';
26
31
  export const formatLength = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`;
27
32
  const fmt = formatLength;
28
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
+
29
78
  function useAudio(onDuration) {
30
79
  const ref = useRef(null);
31
80
  const [playing, setPlaying] = useState(false);
@@ -47,40 +96,6 @@ function useAudio(onDuration) {
47
96
  return { ref, playing, time, duration, toggle, element, setTime };
48
97
  }
49
98
 
50
- export function AudioTile({ src }) {
51
- const { playing, toggle, element } = useAudio();
52
- return (
53
- <div className="w-full aspect-square flex items-center justify-center">
54
- {element(src)}
55
- <IconFrame name={playing ? 'pause' : 'play'} variant="secondary" size="lg" onClick={toggle} />
56
- </div>
57
- );
58
- }
59
-
60
- // Video tile: covers the square, one play/pause control — native controls stay in the overlay.
61
- export function VideoTile({ src, poster }) {
62
- const ref = useRef(null);
63
- const [playing, setPlaying] = useState(false);
64
- return (
65
- <div className="relative w-full aspect-square rounded overflow-hidden">
66
- <video
67
- ref={ref}
68
- src={src}
69
- poster={poster}
70
- playsInline
71
- preload="metadata"
72
- className="w-full h-full object-cover"
73
- onPlay={() => setPlaying(true)}
74
- onPause={() => setPlaying(false)}
75
- onEnded={() => setPlaying(false)}
76
- />
77
- <div className="absolute inset-0 flex items-center justify-center">
78
- <IconFrame name={playing ? 'pause' : 'play'} variant="secondary" size="lg" onClick={() => (playing ? ref.current.pause() : ref.current.play())} />
79
- </div>
80
- </div>
81
- );
82
- }
83
-
84
99
  export default function AudioPreview({ src, className = '', onDuration }) {
85
100
  const { ref, playing, time, duration, toggle, element, setTime } = useAudio(onDuration);
86
101
  const [volume, setVolume] = useState(100);
@@ -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
+ }
@@ -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
+ }