@khorsheed/dsh-reader 0.3.0 → 0.3.1

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 (87) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.en.md +26 -121
  3. package/README.i18n.yaml +3 -3
  4. package/README.md +26 -121
  5. package/cordis.patch.yml +7 -0
  6. package/lib/client.js +29054 -6698
  7. package/lib/index.js +3416 -334
  8. package/lib/typert.host.js +2145 -259
  9. package/lib/typert.remote-client.d.ts +97 -3
  10. package/lib/typert.remote-client.js +1590 -250
  11. package/lib/types/captions.d.ts +99 -0
  12. package/lib/types/captions.js +236 -0
  13. package/lib/types/client/MediaPlayer.d.ts +55 -0
  14. package/lib/types/client/MediaPlayer.js +106 -0
  15. package/lib/types/client/NotesView.d.ts +57 -0
  16. package/lib/types/client/NotesView.js +124 -0
  17. package/lib/types/client/ReaderPane.d.ts +20 -3
  18. package/lib/types/client/ReaderPane.js +860 -164
  19. package/lib/types/client/SpeakerLegend.d.ts +18 -0
  20. package/lib/types/client/SpeakerLegend.js +26 -0
  21. package/lib/types/client/canvas-sync.d.ts +84 -0
  22. package/lib/types/client/canvas-sync.js +59 -0
  23. package/lib/types/client/contract.d.ts +130 -3
  24. package/lib/types/client/extract-article.d.ts +38 -1
  25. package/lib/types/client/extract-article.js +501 -28
  26. package/lib/types/client/icons.d.ts +1 -1
  27. package/lib/types/client/icons.js +1 -1
  28. package/lib/types/client/index.js +81 -2
  29. package/lib/types/client/locales.d.ts +279 -11
  30. package/lib/types/client/locales.js +278 -10
  31. package/lib/types/client/notes.d.ts +142 -0
  32. package/lib/types/client/notes.js +334 -0
  33. package/lib/types/client/parse-rss.js +40 -0
  34. package/lib/types/client/preset-visibility.d.ts +93 -0
  35. package/lib/types/client/preset-visibility.js +115 -0
  36. package/lib/types/client/selectors.d.ts +5 -0
  37. package/lib/types/client/selectors.js +5 -0
  38. package/lib/types/client/session.d.ts +21 -1
  39. package/lib/types/client/session.js +12 -0
  40. package/lib/types/client/store.js +4 -1
  41. package/lib/types/client/translate.d.ts +26 -1
  42. package/lib/types/client/translate.js +29 -12
  43. package/lib/types/client/useLocalTranscript.d.ts +43 -0
  44. package/lib/types/client/useLocalTranscript.js +129 -0
  45. package/lib/types/client/useMedia.d.ts +68 -0
  46. package/lib/types/client/useMedia.js +299 -0
  47. package/lib/types/client/useMediaStates.d.ts +9 -0
  48. package/lib/types/client/useMediaStates.js +43 -0
  49. package/lib/types/client/useNotes.d.ts +110 -0
  50. package/lib/types/client/useNotes.js +423 -0
  51. package/lib/types/client/usePolish.d.ts +47 -0
  52. package/lib/types/client/usePolish.js +162 -0
  53. package/lib/types/index.d.ts +6 -0
  54. package/lib/types/index.js +3 -0
  55. package/lib/types/local-transcribe.d.ts +171 -0
  56. package/lib/types/local-transcribe.js +473 -0
  57. package/lib/types/media-host.d.ts +91 -0
  58. package/lib/types/media-host.js +230 -0
  59. package/lib/types/media.d.ts +100 -0
  60. package/lib/types/media.js +207 -0
  61. package/lib/types/notes-types.d.ts +158 -0
  62. package/lib/types/notes-types.js +8 -0
  63. package/lib/types/notes.d.ts +213 -0
  64. package/lib/types/notes.js +718 -0
  65. package/lib/types/polish.d.ts +41 -0
  66. package/lib/types/polish.js +136 -0
  67. package/lib/types/remote.d.ts +118 -0
  68. package/lib/types/remote.js +151 -1
  69. package/lib/types/service.d.ts +71 -1
  70. package/lib/types/service.js +280 -59
  71. package/lib/types/skill.d.ts +20 -0
  72. package/lib/types/skill.js +78 -0
  73. package/lib/types/store.js +30 -8
  74. package/lib/types/tool.d.ts +23 -0
  75. package/lib/types/tool.js +40 -0
  76. package/lib/types/tools.d.ts +69 -0
  77. package/lib/types/tools.js +400 -0
  78. package/lib/types/types.d.ts +87 -0
  79. package/lib/types/types.js +66 -0
  80. package/lib/types/xiaoyuzhou.d.ts +43 -0
  81. package/lib/types/xiaoyuzhou.js +134 -0
  82. package/lib/types/ytdlp.d.ts +111 -0
  83. package/lib/types/ytdlp.js +269 -0
  84. package/package.json +29 -20
  85. package/lib/invariant.js +0 -38
  86. package/lib/types/invariant.d.ts +0 -29
  87. package/lib/types/invariant.js +0 -38
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Captions → the transcript a video or audio entry reads as.
3
+ *
4
+ * Every platform's caption file is parsed into the same flat cue list (start,
5
+ * end, text), merged into paragraphs a person can read (sentence ends, pauses,
6
+ * a length cap), and rendered as article markup where each paragraph carries
7
+ * its start time. The markup is the entry's body: notes, highlights,
8
+ * translation and the model's ¶ numbering all work on it unchanged, and the
9
+ * player reads `data-t` to seek.
10
+ *
11
+ * Pure: no fs, no network — the formats are measured on real files and pinned
12
+ * by the specs.
13
+ *
14
+ * @module @khorsheed/dsh-reader/captions
15
+ */
16
+ /** One caption line. Times are seconds. */
17
+ export interface Cue {
18
+ readonly start: number;
19
+ readonly end: number;
20
+ readonly text: string;
21
+ }
22
+ /** One transcript paragraph. */
23
+ export interface TranscriptSegment {
24
+ readonly start: number;
25
+ readonly end: number;
26
+ readonly text: string;
27
+ /** The start of every cue merged in, so a note can anchor to its line. */
28
+ readonly lines: readonly {
29
+ readonly start: number;
30
+ readonly text: string;
31
+ }[];
32
+ }
33
+ /**
34
+ * YouTube's `json3` captions: `events[]` with `tStartMs`, `dDurationMs` and
35
+ * `segs[].utf8`. Auto captions also carry `aAppend` newline events and
36
+ * word-level `tOffsetMs`; only whole events become cues.
37
+ */
38
+ export declare function parseJson3(raw: string): Cue[];
39
+ /**
40
+ * WebVTT. YouTube's auto captions in VTT are ROLLING: each cue repeats the
41
+ * previous line and adds the next, with inline word timings. The rolling
42
+ * duplicates are dropped by keeping only the text a cue adds over the one
43
+ * before it.
44
+ */
45
+ export declare function parseVtt(raw: string): Cue[];
46
+ /** SubRip (`.srt`). */
47
+ export declare function parseSrt(raw: string): Cue[];
48
+ /** Bilibili's subtitle JSON: `body[]` with `from`, `to`, `content` (seconds). */
49
+ export declare function parseBiliJson(raw: string): Cue[];
50
+ /**
51
+ * Parse a caption file by its extension (the one yt-dlp wrote).
52
+ *
53
+ * @param raw - the file's text.
54
+ * @param ext - `json3`, `vtt`, `srt` or `json`.
55
+ */
56
+ export declare function parseCaptions(raw: string, ext: string): Cue[];
57
+ /** The merge rules (exported for the specs). */
58
+ export declare const MERGE: {
59
+ /** A paragraph closes at a sentence end once it is at least this long (seconds). */
60
+ readonly minSeconds: 15;
61
+ /** …and always closes past this length, sentence end or not. */
62
+ readonly maxSeconds: 60;
63
+ /** A pause this long between cues closes the paragraph too. */
64
+ readonly pauseSeconds: 2.5;
65
+ /**
66
+ * Unpunctuated captions (YouTube's automatic ones carry no punctuation at
67
+ * all — measured 2026-10-20): close at the first pause of this length once
68
+ * the paragraph has run {@link MERGE.bareMinSeconds}, and always by
69
+ * {@link MERGE.bareMaxSeconds}.
70
+ */
71
+ readonly barePauseSeconds: 0.7;
72
+ readonly bareMinSeconds: 15;
73
+ readonly bareMaxSeconds: 35;
74
+ /** Below this share of sentence-ending cues, captions count as unpunctuated. */
75
+ readonly punctuatedShare: 0.1;
76
+ };
77
+ /**
78
+ * Merge caption cues into readable paragraphs.
79
+ *
80
+ * Punctuated captions close a paragraph at a sentence end once it has run
81
+ * {@link MERGE.minSeconds}, at a pause of {@link MERGE.pauseSeconds}, or past
82
+ * {@link MERGE.maxSeconds}. Unpunctuated ones have no sentence ends to use, so
83
+ * they close at a short pause past {@link MERGE.bareMinSeconds} — short enough
84
+ * to point at, long enough to read as prose rather than subtitles.
85
+ *
86
+ * @param cues - the caption cues in time order.
87
+ * @returns the paragraphs.
88
+ */
89
+ export declare function mergeCues(cues: readonly Cue[]): TranscriptSegment[];
90
+ /** `mm:ss` or `h:mm:ss`. */
91
+ export declare function formatClock(seconds: number): string;
92
+ /**
93
+ * The transcript as article markup: one `<p>` per paragraph, its start time in
94
+ * `data-t` (seconds, one decimal) — the attribute the player seeks by.
95
+ *
96
+ * @param segments - the merged paragraphs.
97
+ */
98
+ export declare function renderTranscript(segments: readonly TranscriptSegment[]): string;
99
+ //# sourceMappingURL=captions.d.ts.map
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Captions → the transcript a video or audio entry reads as.
3
+ *
4
+ * Every platform's caption file is parsed into the same flat cue list (start,
5
+ * end, text), merged into paragraphs a person can read (sentence ends, pauses,
6
+ * a length cap), and rendered as article markup where each paragraph carries
7
+ * its start time. The markup is the entry's body: notes, highlights,
8
+ * translation and the model's ¶ numbering all work on it unchanged, and the
9
+ * player reads `data-t` to seek.
10
+ *
11
+ * Pure: no fs, no network — the formats are measured on real files and pinned
12
+ * by the specs.
13
+ *
14
+ * @module @khorsheed/dsh-reader/captions
15
+ */
16
+ /** Collapse whitespace and strip the markup some caption files carry. */
17
+ function clean(text) {
18
+ return text
19
+ .replace(/<[^>]+>/g, '')
20
+ .replace(/&nbsp;/g, ' ')
21
+ .replace(/&amp;/g, '&')
22
+ .replace(/&lt;/g, '<')
23
+ .replace(/&gt;/g, '>')
24
+ .replace(/&#39;/g, "'")
25
+ .replace(/&quot;/g, '"')
26
+ .replace(/\s+/g, ' ')
27
+ .trim();
28
+ }
29
+ /** Seconds from `hh:mm:ss.mmm` / `mm:ss.mmm` / `hh:mm:ss,mmm`. */
30
+ function clock(value) {
31
+ const parts = value.trim().replace(',', '.').split(':').map(Number);
32
+ if (parts.some(part => !Number.isFinite(part)))
33
+ return Number.NaN;
34
+ return parts.reduce((total, part) => total * 60 + part, 0);
35
+ }
36
+ /**
37
+ * YouTube's `json3` captions: `events[]` with `tStartMs`, `dDurationMs` and
38
+ * `segs[].utf8`. Auto captions also carry `aAppend` newline events and
39
+ * word-level `tOffsetMs`; only whole events become cues.
40
+ */
41
+ export function parseJson3(raw) {
42
+ const data = JSON.parse(raw);
43
+ const cues = [];
44
+ for (const event of data.events ?? []) {
45
+ if (event.segs === undefined || event.aAppend === 1)
46
+ continue;
47
+ const text = clean(event.segs.map(seg => seg.utf8 ?? '').join(''));
48
+ if (text === '')
49
+ continue;
50
+ const startMs = event.tStartMs ?? 0;
51
+ cues.push({ start: startMs / 1000, end: (startMs + (event.dDurationMs ?? 0)) / 1000, text });
52
+ }
53
+ return cues;
54
+ }
55
+ /**
56
+ * WebVTT. YouTube's auto captions in VTT are ROLLING: each cue repeats the
57
+ * previous line and adds the next, with inline word timings. The rolling
58
+ * duplicates are dropped by keeping only the text a cue adds over the one
59
+ * before it.
60
+ */
61
+ export function parseVtt(raw) {
62
+ const blocks = raw.replace(/\r/g, '').split(/\n\n+/);
63
+ const cues = [];
64
+ let previous = '';
65
+ for (const block of blocks) {
66
+ const lines = block.split('\n');
67
+ const timing = lines.findIndex(line => line.includes('-->'));
68
+ if (timing < 0)
69
+ continue;
70
+ const [from, to] = lines[timing].split('-->');
71
+ const start = clock(from ?? '');
72
+ const end = clock((to ?? '').trim().split(/\s+/)[0] ?? '');
73
+ if (!Number.isFinite(start))
74
+ continue;
75
+ const textLines = lines.slice(timing + 1).map(line => clean(line)).filter(line => line !== '');
76
+ let text = textLines.join(' ');
77
+ if (text === '')
78
+ continue;
79
+ if (previous !== '' && text.startsWith(previous))
80
+ text = text.slice(previous.length).trim();
81
+ else if (textLines.length > 1 && textLines[0] === previous)
82
+ text = textLines.slice(1).join(' ');
83
+ previous = textLines[textLines.length - 1] ?? previous;
84
+ if (text === '' || (cues.length > 0 && cues[cues.length - 1].text === text))
85
+ continue;
86
+ cues.push({ start, end: Number.isFinite(end) ? end : start, text });
87
+ }
88
+ return cues;
89
+ }
90
+ /** SubRip (`.srt`). */
91
+ export function parseSrt(raw) {
92
+ const cues = [];
93
+ for (const block of raw.replace(/\r/g, '').split(/\n\n+/)) {
94
+ const lines = block.split('\n');
95
+ const timing = lines.findIndex(line => line.includes('-->'));
96
+ if (timing < 0)
97
+ continue;
98
+ const [from, to] = lines[timing].split('-->');
99
+ const start = clock(from ?? '');
100
+ const end = clock(to ?? '');
101
+ const text = clean(lines.slice(timing + 1).join(' '));
102
+ if (!Number.isFinite(start) || text === '')
103
+ continue;
104
+ cues.push({ start, end: Number.isFinite(end) ? end : start, text });
105
+ }
106
+ return cues;
107
+ }
108
+ /** Bilibili's subtitle JSON: `body[]` with `from`, `to`, `content` (seconds). */
109
+ export function parseBiliJson(raw) {
110
+ const data = JSON.parse(raw);
111
+ return (data.body ?? []).flatMap(item => {
112
+ const text = clean(item.content ?? '');
113
+ if (text === '' || typeof item.from !== 'number')
114
+ return [];
115
+ return [{ start: item.from, end: typeof item.to === 'number' ? item.to : item.from, text }];
116
+ });
117
+ }
118
+ /**
119
+ * Parse a caption file by its extension (the one yt-dlp wrote).
120
+ *
121
+ * @param raw - the file's text.
122
+ * @param ext - `json3`, `vtt`, `srt` or `json`.
123
+ */
124
+ export function parseCaptions(raw, ext) {
125
+ switch (ext.toLowerCase()) {
126
+ case 'json3': return parseJson3(raw);
127
+ case 'vtt': return parseVtt(raw);
128
+ case 'srt': return parseSrt(raw);
129
+ case 'json': return parseBiliJson(raw);
130
+ default: return [];
131
+ }
132
+ }
133
+ /** True when a line ends a sentence (Latin or CJK punctuation). */
134
+ function endsSentence(text) {
135
+ return /[.!?。!?…]["'”’))]?$/.test(text);
136
+ }
137
+ /** True for text that is mostly CJK (joined without spaces). */
138
+ function isCjk(text) {
139
+ return /[㐀-鿿]/.test(text);
140
+ }
141
+ /** The merge rules (exported for the specs). */
142
+ export const MERGE = {
143
+ /** A paragraph closes at a sentence end once it is at least this long (seconds). */
144
+ minSeconds: 15,
145
+ /** …and always closes past this length, sentence end or not. */
146
+ maxSeconds: 60,
147
+ /** A pause this long between cues closes the paragraph too. */
148
+ pauseSeconds: 2.5,
149
+ /**
150
+ * Unpunctuated captions (YouTube's automatic ones carry no punctuation at
151
+ * all — measured 2026-10-20): close at the first pause of this length once
152
+ * the paragraph has run {@link MERGE.bareMinSeconds}, and always by
153
+ * {@link MERGE.bareMaxSeconds}.
154
+ */
155
+ barePauseSeconds: 0.7,
156
+ bareMinSeconds: 15,
157
+ bareMaxSeconds: 35,
158
+ /** Below this share of sentence-ending cues, captions count as unpunctuated. */
159
+ punctuatedShare: 0.1,
160
+ };
161
+ /**
162
+ * Merge caption cues into readable paragraphs.
163
+ *
164
+ * Punctuated captions close a paragraph at a sentence end once it has run
165
+ * {@link MERGE.minSeconds}, at a pause of {@link MERGE.pauseSeconds}, or past
166
+ * {@link MERGE.maxSeconds}. Unpunctuated ones have no sentence ends to use, so
167
+ * they close at a short pause past {@link MERGE.bareMinSeconds} — short enough
168
+ * to point at, long enough to read as prose rather than subtitles.
169
+ *
170
+ * @param cues - the caption cues in time order.
171
+ * @returns the paragraphs.
172
+ */
173
+ export function mergeCues(cues) {
174
+ const sorted = [...cues].sort((left, right) => left.start - right.start);
175
+ const punctuated = sorted.length > 0 && sorted.filter(cue => endsSentence(cue.text)).length / sorted.length >= MERGE.punctuatedShare;
176
+ const segments = [];
177
+ let current;
178
+ const flush = () => {
179
+ if (current === undefined || current.lines.length === 0)
180
+ return;
181
+ const joiner = isCjk(current.lines.map(line => line.text).join('')) ? '' : ' ';
182
+ segments.push({
183
+ start: current.start,
184
+ end: current.end,
185
+ text: current.lines.map(line => line.text).join(joiner).replace(/\s+/g, ' ').trim(),
186
+ lines: current.lines,
187
+ });
188
+ current = undefined;
189
+ };
190
+ for (let index = 0; index < sorted.length; index++) {
191
+ const cue = sorted[index];
192
+ if (current !== undefined && cue.start - current.end >= MERGE.pauseSeconds)
193
+ flush();
194
+ if (current === undefined)
195
+ current = { start: cue.start, end: cue.end, lines: [] };
196
+ current.lines.push({ start: cue.start, text: cue.text });
197
+ current.end = Math.max(current.end, cue.end);
198
+ const length = current.end - current.start;
199
+ if (punctuated) {
200
+ if ((length >= MERGE.minSeconds && endsSentence(cue.text)) || length >= MERGE.maxSeconds)
201
+ flush();
202
+ }
203
+ else {
204
+ const next = sorted[index + 1];
205
+ const gap = next === undefined ? Infinity : next.start - cue.end;
206
+ if ((length >= MERGE.bareMinSeconds && gap >= MERGE.barePauseSeconds) || length >= MERGE.bareMaxSeconds)
207
+ flush();
208
+ }
209
+ }
210
+ flush();
211
+ return segments;
212
+ }
213
+ /** `mm:ss` or `h:mm:ss`. */
214
+ export function formatClock(seconds) {
215
+ const total = Math.max(0, Math.floor(seconds));
216
+ const h = Math.floor(total / 3600);
217
+ const m = Math.floor((total % 3600) / 60);
218
+ const s = total % 60;
219
+ const mm = h > 0 ? String(m).padStart(2, '0') : String(m);
220
+ return `${h > 0 ? `${String(h)}:` : ''}${mm}:${String(s).padStart(2, '0')}`;
221
+ }
222
+ /** Escape text for HTML content. */
223
+ function escapeHtml(text) {
224
+ return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
225
+ }
226
+ /**
227
+ * The transcript as article markup: one `<p>` per paragraph, its start time in
228
+ * `data-t` (seconds, one decimal) — the attribute the player seeks by.
229
+ *
230
+ * @param segments - the merged paragraphs.
231
+ */
232
+ export function renderTranscript(segments) {
233
+ return segments
234
+ .map(segment => `<p data-t="${segment.start.toFixed(1)}">${escapeHtml(segment.text)}</p>`)
235
+ .join('\n');
236
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The player at the top of a video or audio entry.
3
+ *
4
+ * Three players, one surface (measured 2026-10-20):
5
+ * - **YouTube**: the official embed in a frame of OUR making (scripts +
6
+ * its own origin + presentation — the article body's strict sandbox would
7
+ * break it), driven over postMessage (`enablejsapi=1`): `seekTo` / `playVideo`
8
+ * / `pauseVideo` commands, and `infoDelivery` messages report the current
9
+ * time (about four times a second) and the play state, which is what
10
+ * follows the transcript.
11
+ * - **Bilibili**: the official external player. It exposes no control
12
+ * channel and reports nothing, so a seek reloads it with `t`, and there is
13
+ * no current time to follow.
14
+ * - **Audio** (a feed enclosure, a Xiaoyuzhou episode): the native element.
15
+ *
16
+ * A video has two sizes: full (16:9, at most ~40% of the window high) and a
17
+ * MINI bar (a small live picture, the title, the time) the pane switches to
18
+ * while the reader is down in the transcript. Both are the SAME frame resized
19
+ * by CSS — the video keeps playing; nothing reloads.
20
+ *
21
+ * @module @khorsheed/dsh-reader/client/MediaPlayer
22
+ */
23
+ import { type ReactNode } from 'react';
24
+ import { type ReaderMedia } from '../media.ts';
25
+ /** What the pane can ask of the player. */
26
+ export interface MediaPlayerHandle {
27
+ /** Jump to `seconds` and play. */
28
+ seek: (seconds: number) => void;
29
+ }
30
+ /** The player's props. */
31
+ export interface MediaPlayerProps {
32
+ readonly media: ReaderMedia;
33
+ readonly title: string;
34
+ /** Where to start (a remembered position). */
35
+ readonly startAt: number;
36
+ /** The current time, whenever the player knows it (YouTube, audio). */
37
+ readonly onTime: (seconds: number) => void;
38
+ /** The mini bar instead of the full picture (videos only). */
39
+ readonly mini: boolean;
40
+ readonly onMini: (mini: boolean) => void;
41
+ /** The control row's own items: before the 收起 toggle, and at its end. */
42
+ readonly leading?: ReactNode;
43
+ readonly trailing?: ReactNode;
44
+ readonly labels: {
45
+ readonly collapse: string;
46
+ readonly expand: string;
47
+ readonly noFollow: string;
48
+ readonly play: string;
49
+ readonly pause: string;
50
+ readonly startedAt: string;
51
+ };
52
+ }
53
+ /** The player. */
54
+ export declare const MediaPlayer: import("react").ForwardRefExoticComponent<MediaPlayerProps & import("react").RefAttributes<MediaPlayerHandle>>;
55
+ //# sourceMappingURL=MediaPlayer.d.ts.map
@@ -0,0 +1,106 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ /**
3
+ * The player at the top of a video or audio entry.
4
+ *
5
+ * Three players, one surface (measured 2026-10-20):
6
+ * - **YouTube**: the official embed in a frame of OUR making (scripts +
7
+ * its own origin + presentation — the article body's strict sandbox would
8
+ * break it), driven over postMessage (`enablejsapi=1`): `seekTo` / `playVideo`
9
+ * / `pauseVideo` commands, and `infoDelivery` messages report the current
10
+ * time (about four times a second) and the play state, which is what
11
+ * follows the transcript.
12
+ * - **Bilibili**: the official external player. It exposes no control
13
+ * channel and reports nothing, so a seek reloads it with `t`, and there is
14
+ * no current time to follow.
15
+ * - **Audio** (a feed enclosure, a Xiaoyuzhou episode): the native element.
16
+ *
17
+ * A video has two sizes: full (16:9, at most ~40% of the window high) and a
18
+ * MINI bar (a small live picture, the title, the time) the pane switches to
19
+ * while the reader is down in the transcript. Both are the SAME frame resized
20
+ * by CSS — the video keeps playing; nothing reloads.
21
+ *
22
+ * @module @khorsheed/dsh-reader/client/MediaPlayer
23
+ */
24
+ import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
25
+ import { embedUrl } from "../media.js";
26
+ import { formatClock } from "../captions.js";
27
+ import css from './MediaPlayer.module.css';
28
+ /** Post one YouTube IFrame API message. */
29
+ function postYouTube(frame, message) {
30
+ frame?.contentWindow?.postMessage(JSON.stringify(message), 'https://www.youtube.com');
31
+ }
32
+ /** The player. */
33
+ export const MediaPlayer = forwardRef(function MediaPlayer(props, ref) {
34
+ const { media, title, startAt, onTime, mini, onMini, leading, trailing, labels } = props;
35
+ const frameRef = useRef(null);
36
+ const audioRef = useRef(null);
37
+ const [time, setTime] = useState(startAt);
38
+ const [duration, setDuration] = useState(media.durationSec);
39
+ const [playing, setPlaying] = useState(false);
40
+ // Bilibili seeks by reloading: the start of the CURRENT load.
41
+ const [biliStart, setBiliStart] = useState(startAt);
42
+ const origin = typeof window === 'undefined' ? '' : window.location.origin;
43
+ const [ytSrc] = useState(() => media.provider === 'youtube' ? embedUrl(media, startAt, origin) : undefined);
44
+ const report = useCallback((seconds) => {
45
+ setTime(seconds);
46
+ onTime(seconds);
47
+ }, [onTime]);
48
+ // YouTube: subscribe to its state messages once the frame loads.
49
+ useEffect(() => {
50
+ if (media.provider !== 'youtube')
51
+ return;
52
+ const listen = (event) => {
53
+ if (!/^https:\/\/www\.youtube(-nocookie)?\.com$/.test(event.origin) || typeof event.data !== 'string')
54
+ return;
55
+ try {
56
+ const data = JSON.parse(event.data);
57
+ if (data.event !== 'infoDelivery' && data.event !== 'initialDelivery')
58
+ return;
59
+ const current = data.info?.currentTime;
60
+ if (typeof current === 'number')
61
+ report(current);
62
+ if (typeof data.info?.duration === 'number' && data.info.duration > 0)
63
+ setDuration(data.info.duration);
64
+ // 1 playing, 2 paused, 0 ended, 3 buffering.
65
+ const state = data.info?.playerState;
66
+ if (typeof state === 'number')
67
+ setPlaying(state === 1 || state === 3);
68
+ }
69
+ catch {
70
+ // Not a player message.
71
+ }
72
+ };
73
+ window.addEventListener('message', listen);
74
+ return () => { window.removeEventListener('message', listen); };
75
+ }, [media.provider, report]);
76
+ useImperativeHandle(ref, () => ({
77
+ seek: (seconds) => {
78
+ if (media.provider === 'youtube') {
79
+ postYouTube(frameRef.current, { event: 'command', func: 'seekTo', args: [seconds, true] });
80
+ postYouTube(frameRef.current, { event: 'command', func: 'playVideo', args: [] });
81
+ report(seconds);
82
+ }
83
+ else if (media.provider === 'bilibili') {
84
+ setBiliStart(seconds);
85
+ report(seconds);
86
+ }
87
+ else if (audioRef.current !== null) {
88
+ audioRef.current.currentTime = seconds;
89
+ void audioRef.current.play().catch(() => undefined);
90
+ }
91
+ },
92
+ }), [media.provider, report]);
93
+ const toggle = () => {
94
+ postYouTube(frameRef.current, { event: 'command', func: playing ? 'pauseVideo' : 'playVideo', args: [] });
95
+ setPlaying(!playing);
96
+ };
97
+ const video = media.kind === 'video' && (media.provider === 'youtube' || media.provider === 'bilibili');
98
+ const small = video && mini;
99
+ const knowsTime = media.provider === 'youtube';
100
+ return (_jsxs("div", { className: css.player, "data-mini": small ? 'true' : undefined, children: [video && (_jsx("div", { className: css.videoStage, children: _jsxs("div", { className: css.frameBox, children: [media.provider === 'youtube'
101
+ ? (_jsx("iframe", { ref: frameRef, className: css.frame, title: title, src: ytSrc, sandbox: "allow-scripts allow-same-origin allow-presentation allow-popups", allow: "autoplay; encrypted-media; fullscreen; picture-in-picture", referrerPolicy: "strict-origin-when-cross-origin", onLoad: () => { postYouTube(frameRef.current, { event: 'listening', id: 'dsh-reader' }); } }))
102
+ : (_jsx("iframe", { className: css.frame, title: title, src: embedUrl(media, biliStart, origin), sandbox: "allow-scripts allow-same-origin allow-presentation allow-popups", allow: "autoplay; fullscreen", referrerPolicy: "strict-origin-when-cross-origin" }, biliStart)), small && _jsx("button", { type: "button", className: css.miniCover, "aria-label": labels.expand, onClick: () => { onMini(false); } })] }) })), small && (_jsxs(_Fragment, { children: [_jsxs("div", { className: css.miniInfo, children: [_jsx("b", { children: title }), _jsx("span", { children: knowsTime
103
+ ? `${formatClock(time)}${duration === undefined ? '' : ` / ${formatClock(duration)}`}`
104
+ : labels.startedAt.replace('{time}', formatClock(biliStart)) })] }), knowsTime && (_jsx("button", { type: "button", className: css.miniButton, "aria-label": playing ? labels.pause : labels.play, title: playing ? labels.pause : labels.play, onClick: toggle, children: playing ? _jsx("span", { className: css.pauseGlyph }) : _jsx("span", { className: css.playGlyph }) })), _jsx("button", { type: "button", className: css.miniButton, "aria-label": labels.expand, title: labels.expand, onClick: () => { onMini(false); }, children: _jsx("span", { className: css.expandGlyph }) }), knowsTime && duration !== undefined && (_jsx("span", { className: css.miniProgress, children: _jsx("i", { style: { width: `${Math.min(100, (time / duration) * 100)}%` } }) }))] })), !video && (_jsx("audio", { ref: audioRef, className: css.audio, controls: true, preload: "metadata", src: media.ref, onLoadedMetadata: event => { if (startAt > 0)
105
+ event.currentTarget.currentTime = startAt; }, onTimeUpdate: event => { report(event.currentTarget.currentTime); } })), !small && (_jsxs("div", { className: css.strip, children: [leading, video && (_jsx("button", { type: "button", className: css.link, onClick: () => { onMini(true); }, children: labels.collapse })), _jsx("span", { className: css.spacer }), trailing, media.provider === 'bilibili' && (_jsx("span", { className: css.info, title: labels.noFollow, "aria-label": labels.noFollow, tabIndex: 0, children: "i" }))] }))] }));
106
+ });
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The notes face of the detail view: the article's excerpts with their
3
+ * comments, the conversations that discussed it, and the sheet a new note is
4
+ * written in.
5
+ *
6
+ * One layout for every width (docs/panel-design.md): the list is a single
7
+ * column that only tightens under the panel's compact container, and the
8
+ * sheet rises from the bottom of the pane on a desk and on a phone alike.
9
+ *
10
+ * @module @khorsheed/dsh-reader/client/NotesView
11
+ */
12
+ import { type ReactNode } from 'react';
13
+ import type { ReaderDiscussion, ReaderEntryNotes, ReaderNote } from '../notes-types.ts';
14
+ import type { ReaderPaneProps } from './contract.ts';
15
+ import type { NoteSelection } from './notes.ts';
16
+ import type { SyncFace } from './useNotes.ts';
17
+ type T = ReaderPaneProps['t'];
18
+ /** The notes view's props. */
19
+ export interface NotesViewProps {
20
+ readonly t: T;
21
+ readonly entry: ReaderEntryNotes | undefined;
22
+ /** Notes whose excerpt is no longer in the article on screen. */
23
+ readonly missing: ReadonlySet<string>;
24
+ /** A conversation's live title, when the session list knows it. */
25
+ readonly sessionTitle: (sessionId: string) => string | undefined;
26
+ /** Whether a conversation can be opened from here. */
27
+ readonly canOpenSession: boolean;
28
+ readonly onJump: (note: ReaderNote) => void;
29
+ readonly onEdit: (note: ReaderNote, comment: string) => Promise<boolean>;
30
+ readonly onDelete: (note: ReaderNote) => void;
31
+ /** Bring these notes (all of them when empty) into the conversation draft. */
32
+ readonly onDiscuss: (noteIds: readonly string[]) => void;
33
+ readonly onOpenSession: (sessionId: string) => void;
34
+ readonly onAddReply: (note: ReaderNote, text: string) => Promise<boolean>;
35
+ readonly onEditReply: (note: ReaderNote, index: number, text: string) => Promise<boolean>;
36
+ readonly onDeleteDiscussion: (discussion: ReaderDiscussion) => void;
37
+ /** Show the article at this paragraph (1-based ¶). */
38
+ readonly onJumpPara: (para: number) => void;
39
+ /** Put the 「让Agent考考我」 request into the conversation draft. */
40
+ readonly onCheck: () => void;
41
+ readonly sync: SyncFace;
42
+ /** Play a note's moment; absent for articles. */
43
+ readonly onPlay?: (note: ReaderNote) => void;
44
+ }
45
+ /** The article's notes, and the conversations that discussed it. */
46
+ export declare function NotesView(props: NotesViewProps): ReactNode;
47
+ /** The sheet a new note is written in. */
48
+ export declare function NoteSheet({ t, selection, saving, failed, onSave, onCancel }: {
49
+ t: T;
50
+ selection: NoteSelection;
51
+ saving: boolean;
52
+ failed: boolean;
53
+ onSave: (comment: string) => void;
54
+ onCancel: () => void;
55
+ }): ReactNode;
56
+ export {};
57
+ //# sourceMappingURL=NotesView.d.ts.map