@banou/media-player 0.2.6 → 0.4.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 (76) hide show
  1. package/README.md +0 -10
  2. package/build/components/chrome.d.ts +4 -0
  3. package/build/components/control-bar.d.ts +5 -0
  4. package/build/components/overlay.d.ts +1 -0
  5. package/build/components/progress-bar.d.ts +1 -0
  6. package/build/components/settings.d.ts +2 -0
  7. package/build/components/sound.d.ts +4 -0
  8. package/build/components/tooltip-display.d.ts +20 -0
  9. package/build/components/volume-slider.d.ts +6 -0
  10. package/build/index.d.ts +23 -48
  11. package/build/index.js +2593 -2230
  12. package/build/main.d.ts +1 -1
  13. package/build/state-machines/data-source.d.ts +27 -0
  14. package/build/state-machines/index.d.ts +33185 -0
  15. package/build/state-machines/media-properties.d.ts +45 -0
  16. package/build/state-machines/media-source.d.ts +34 -0
  17. package/build/state-machines/media.d.ts +8344 -0
  18. package/build/state-machines/subtitles.d.ts +53 -0
  19. package/build/state-machines/thumbnails.d.ts +24 -0
  20. package/build/state-machines/utils.d.ts +26 -0
  21. package/build/utils/actor-utils.d.ts +2 -0
  22. package/build/utils/colors.d.ts +8 -0
  23. package/build/utils/context.d.ts +14 -0
  24. package/build/utils/fonts.d.ts +28 -0
  25. package/build/{utils.d.ts → utils/index.d.ts} +4 -12
  26. package/build/utils/languages.d.ts +260 -0
  27. package/build/{mp4box.d.ts → utils/mp4box.d.ts} +61 -61
  28. package/build/utils/time.d.ts +2 -0
  29. package/build/utils/use-local-storage.d.ts +3 -0
  30. package/build/{use-scrub.d.ts → utils/use-scrub.d.ts} +11 -10
  31. package/build/utils/volume-utils.d.ts +17 -0
  32. package/build/utils/window-height.d.ts +2 -0
  33. package/package.json +15 -12
  34. package/src/assets/picture-in-picture.svg +5 -0
  35. package/src/components/chrome.tsx +89 -0
  36. package/src/components/control-bar.tsx +330 -0
  37. package/src/components/overlay.tsx +28 -0
  38. package/src/components/progress-bar.tsx +252 -0
  39. package/src/components/settings.tsx +269 -0
  40. package/src/components/sound.tsx +101 -0
  41. package/src/components/tooltip-display.tsx +83 -0
  42. package/src/components/volume-slider.tsx +156 -0
  43. package/src/index.tsx +145 -434
  44. package/src/main.tsx +66 -39
  45. package/src/state-machines/data-source.ts +83 -0
  46. package/src/state-machines/index.ts +5 -0
  47. package/src/state-machines/media-properties.ts +78 -0
  48. package/src/state-machines/media-source.ts +129 -0
  49. package/src/state-machines/media.ts +245 -0
  50. package/src/state-machines/subtitles.ts +247 -0
  51. package/src/state-machines/thumbnails.ts +123 -0
  52. package/src/state-machines/utils.ts +94 -0
  53. package/src/utils/actor-utils.ts +19 -0
  54. package/src/utils/colors.ts +9 -0
  55. package/src/utils/context.ts +23 -0
  56. package/src/utils/fonts.ts +156 -0
  57. package/src/{utils.ts → utils/index.ts} +2 -26
  58. package/src/utils/time.ts +16 -0
  59. package/src/utils/use-local-storage.ts +30 -0
  60. package/src/{use-scrub.ts → utils/use-scrub.ts} +2 -1
  61. package/src/utils/volume-utils.ts +39 -0
  62. package/src/utils/window-height.ts +19 -0
  63. package/src/vite-env.d.ts +1 -1
  64. package/tsconfig.json +4 -2
  65. package/tsconfig.node.json +9 -9
  66. package/build/chrome/bottom.d.ts +0 -20
  67. package/build/chrome/index.d.ts +0 -27
  68. package/build/chrome/overlay.d.ts +0 -9
  69. package/build/languages.d.ts +0 -260
  70. package/build/use-local-storage.d.ts +0 -6
  71. package/src/chrome/bottom.tsx +0 -651
  72. package/src/chrome/index.tsx +0 -199
  73. package/src/chrome/overlay.tsx +0 -104
  74. package/src/use-local-storage.ts +0 -32
  75. /package/src/{languages.ts → utils/languages.ts} +0 -0
  76. /package/src/{mp4box.ts → utils/mp4box.ts} +0 -0
@@ -0,0 +1,245 @@
1
+ import type { Attachment, SubtitleFragment } from 'libav-wasm/build/worker'
2
+
3
+ import { makeRemuxer } from 'libav-wasm'
4
+ import { assign, setup, sendTo, enqueueActions } from 'xstate'
5
+
6
+ import mediaPropertiesLogic from './media-properties'
7
+ import mediaSourceLogic, { MediaSourceOptions } from './media-source'
8
+ import dataSourceLogic from './data-source'
9
+ import subtitlesLogic, { SubtitleStream } from './subtitles'
10
+ import thumbnailsLogic, { Thumbnail } from './thumbnails'
11
+ import { JassubOptions } from 'jassub'
12
+ import { DownloadedRange } from '../utils/context'
13
+
14
+ export default setup({
15
+ types: {} as {
16
+ context: {
17
+ mediaSourceOptions: MediaSourceOptions | undefined
18
+ subtitlesRendererOptions: Omit<JassubOptions, 'video' | 'canvas'> | undefined
19
+ remuxerOptions: Parameters<typeof makeRemuxer>[0] | undefined
20
+ videoElement: HTMLVideoElement | undefined
21
+ canvasElement: HTMLCanvasElement | undefined
22
+ media: {
23
+ duration?: number
24
+ paused: boolean
25
+ currentTime: number
26
+ volume: number
27
+ muted: boolean
28
+ playbackRate: number
29
+ },
30
+ attachments: Attachment[]
31
+ subtitleFragments: SubtitleFragment[]
32
+ subtitleStreams: SubtitleStream[]
33
+ selectedSubtitleStreamIndex: number | undefined
34
+ thumbnails: Thumbnail[]
35
+ isReady: boolean
36
+ },
37
+ events:
38
+ | { type: 'MEDIA_SOURCE_OPTIONS', mediaSourceOptions: MediaSourceOptions }
39
+ | { type: 'SUBTITLES_RENDERER_OPTIONS', subtitlesRendererOptions: Omit<JassubOptions, 'video' | 'canvas'> }
40
+ | { type: 'REMUXER_OPTIONS', remuxerOptions: Parameters<typeof makeRemuxer>[0] }
41
+ | { type: 'SET_VIDEO_ELEMENT', videoElement: HTMLVideoElement }
42
+ | { type: 'SET_CANVAS_ELEMENT', canvasElement: HTMLCanvasElement }
43
+ | { type: 'IS_READY' }
44
+ | { type: 'PLAY' }
45
+ | { type: 'PAUSE' }
46
+ | { type: 'SET_TIME', value: number }
47
+ | { type: 'PLAYING' }
48
+ | { type: 'PAUSED' }
49
+ | { type: 'ENDED' }
50
+ | { type: 'TIME_UPDATE', currentTime: number }
51
+ | { type: 'VOLUME_UPDATE', muted: boolean, volume: number }
52
+ | { type: 'PLAYBACK_RATE_UPDATE', playbackRate: number }
53
+ | { type: 'DURATION_UPDATE', duration: number }
54
+ | { type: 'SET_VOLUME', muted: boolean, volume: number }
55
+ | { type: 'SET_PLAYBACK_RATE', playbackRate: number }
56
+ | { type: 'SEEKING', currentTime: number }
57
+ | { type: 'NEED_DATA' }
58
+ | { type: 'METADATA', mimeType: string, duration: number, data: ArrayBuffer }
59
+ | { type: 'DATA', data: ArrayBuffer }
60
+ | { type: 'TIMESTAMP_OFFSET', timestampOffset: number }
61
+ | { type: 'NEW_ATTACHMENTS', attachments: Attachment[] }
62
+ | { type: 'NEW_SUBTITLE_FRAGMENTS', subtitles: SubtitleFragment[] }
63
+ | { type: 'SUBTITLE_STREAMS_UPDATED', subtitlesStreams: SubtitleStream[] }
64
+ | { type: 'SELECT_SUBTITLE_STREAM', streamIndex: number }
65
+ | { type: 'NEW_THUMBNAIL', thumbnail: Thumbnail }
66
+ | { type: 'DOWNLOADED_RANGES_UPDATED', downloadedRanges: DownloadedRange[] }
67
+ | { type: 'DESTROY' }
68
+ },
69
+ actions: {
70
+ isReady: enqueueActions(({ context, enqueue }) => {
71
+ if (context.videoElement && context.canvasElement && context.remuxerOptions && context.subtitlesRendererOptions && context.mediaSourceOptions) {
72
+ enqueue.raise({ type: 'IS_READY' })
73
+ }
74
+ })
75
+ },
76
+ actors: {
77
+ mediaLogic: mediaPropertiesLogic,
78
+ mediaSourceLogic: mediaSourceLogic,
79
+ dataSourceLogic: dataSourceLogic,
80
+ thumbnailsLogic: thumbnailsLogic,
81
+ subtitlesLogic: subtitlesLogic,
82
+ },
83
+ }).createMachine({
84
+ context: {
85
+ mediaSourceOptions: undefined,
86
+ subtitlesRendererOptions: undefined,
87
+ remuxerOptions: undefined,
88
+ videoElement: undefined,
89
+ canvasElement: undefined,
90
+ media: {
91
+ paused: true,
92
+ currentTime: 0,
93
+ volume: 1,
94
+ muted: false,
95
+ playbackRate: 1,
96
+ duration: undefined
97
+ },
98
+ attachments: [],
99
+ subtitleFragments: [],
100
+ subtitleStreams: [],
101
+ selectedSubtitleStreamIndex: undefined,
102
+ thumbnails: [],
103
+ isReady: false
104
+ },
105
+ initial: 'WAITING',
106
+ on: {
107
+ 'SET_VIDEO_ELEMENT': {
108
+ actions: [
109
+ assign({
110
+ videoElement: ({ event }) => event.videoElement,
111
+ }),
112
+ { type: 'isReady' }
113
+ ]
114
+ },
115
+ 'SET_CANVAS_ELEMENT': {
116
+ actions: [
117
+ assign({
118
+ canvasElement: ({ event }) => event.canvasElement,
119
+ }),
120
+ { type: 'isReady' }
121
+ ]
122
+ },
123
+ 'IS_READY': {
124
+ target: '.OK',
125
+ actions: assign({ isReady: () => true })
126
+ },
127
+ },
128
+ states: {
129
+ WAITING: {
130
+ on: {
131
+ 'MEDIA_SOURCE_OPTIONS': {
132
+ actions: [
133
+ assign({
134
+ mediaSourceOptions: ({ event }) => event.mediaSourceOptions
135
+ }),
136
+ { type: 'isReady' }
137
+ ]
138
+ },
139
+ 'REMUXER_OPTIONS': {
140
+ actions: [
141
+ assign({
142
+ remuxerOptions: ({ event }) => event.remuxerOptions
143
+ }),
144
+ { type: 'isReady' }
145
+ ]
146
+ },
147
+ 'SUBTITLES_RENDERER_OPTIONS': {
148
+ actions: [
149
+ assign({
150
+ subtitlesRendererOptions: ({ event }) => event.subtitlesRendererOptions
151
+ }),
152
+ { type: 'isReady' }
153
+ ]
154
+ },
155
+ }
156
+ },
157
+ OK: {
158
+ type: 'parallel',
159
+ invoke: [
160
+ {
161
+ id: 'media',
162
+ src: 'mediaLogic',
163
+ input: ({ context }) => ({ videoElement: context.videoElement!, remuxerOptions: context.remuxerOptions! }),
164
+ },
165
+ {
166
+ id: 'mediaSource',
167
+ src: 'mediaSourceLogic',
168
+ input: ({ context }) => ({ videoElement: context.videoElement!, remuxerOptions: context.remuxerOptions! }),
169
+ },
170
+ {
171
+ id: 'dataSource',
172
+ src: 'dataSourceLogic',
173
+ input: ({ context }) => ({ remuxerOptions: context.remuxerOptions! }),
174
+ },
175
+ {
176
+ id: 'thumbnails',
177
+ src: 'thumbnailsLogic',
178
+ input: ({ context }) => ({ remuxerOptions: context.remuxerOptions! }),
179
+ },
180
+ {
181
+ id: 'subtitles',
182
+ src: 'subtitlesLogic',
183
+ input: ({ context }) => ({
184
+ publicPath: context.remuxerOptions!.publicPath,
185
+ videoElement: context.videoElement!,
186
+ canvasElement: context.canvasElement!,
187
+ subtitlesRendererOptions: context.subtitlesRendererOptions!
188
+ }),
189
+ }
190
+ ],
191
+ on: {
192
+ 'PLAY': { actions: sendTo('media', ({ event }) => event) },
193
+ 'PAUSE': { actions: sendTo('media', ({ event }) => event) },
194
+ 'SET_TIME': { actions: sendTo('media', ({ event }) => event) },
195
+ 'SET_PLAYBACK_RATE': { actions: sendTo('media', ({ event }) => event) },
196
+ 'PLAYING': { actions: assign({ media: ({ context }) => ({ ...context.media, paused: false }) }) },
197
+ 'PAUSED': { actions: assign({ media: ({ context }) => ({ ...context.media, paused: true }) }) },
198
+ 'ENDED': { actions: assign({ media: ({ context }) => ({ ...context.media, paused: true }) }) },
199
+ 'TIME_UPDATE': { actions: assign({ media: ({ context, event }) => ({ ...context.media, currentTime: event.currentTime }) }) },
200
+ 'VOLUME_UPDATE': { actions: assign({ media: ({ context, event }) => ({ ...context.media, muted: event.muted, volume: event.volume }) }) },
201
+ 'SET_VOLUME': { actions: sendTo('media', ({ event }) => event) },
202
+ 'PLAYBACK_RATE_UPDATE': { actions: assign({ media: ({ context, event }) => ({ ...context.media, playbackRate: event.playbackRate }) }) },
203
+ 'DURATION_UPDATE': { actions: assign({ media: ({ context, event }) => ({ ...context.media, duration: event.duration }) }) },
204
+ 'NEED_DATA': { actions: sendTo('dataSource', ({ event }) => event) },
205
+ 'METADATA': { actions: sendTo('mediaSource', ({ event }) => event) },
206
+ 'SEEKING': { actions: sendTo('dataSource', ({ event }) => event) },
207
+ 'DATA': { actions: sendTo('mediaSource', ({ event }) => event) },
208
+ 'TIMESTAMP_OFFSET': { actions: sendTo('mediaSource', ({ event }) => event) },
209
+ 'NEW_THUMBNAIL': { actions: [assign({ thumbnails: ({ context, event }) => [...context.thumbnails, event.thumbnail] })] },
210
+ 'DOWNLOADED_RANGES_UPDATED': { actions: sendTo('thumbnails', ({ event }) => event) },
211
+ 'NEW_ATTACHMENTS': {
212
+ actions: [
213
+ assign({ attachments: ({ context, event }) => [...context.attachments, ...event.attachments] }),
214
+ sendTo('subtitles', ({ event }) => event)
215
+ ]
216
+ },
217
+ 'NEW_SUBTITLE_FRAGMENTS': {
218
+ actions: [
219
+ assign({ subtitleFragments: ({ context, event }) => [...context.subtitleFragments, ...event.subtitles] }),
220
+ sendTo('subtitles', ({ event }) => event)
221
+ // emit(({ event }) => ({ type: 'NEW_SUBTITLE_FRAGMENTS', subtitles: event.subtitles }))
222
+ ]
223
+ },
224
+ 'SUBTITLE_STREAMS_UPDATED': {
225
+ actions: [
226
+ assign({ subtitleStreams: ({ event }) => event.subtitlesStreams }),
227
+ sendTo('subtitles', ({ event }) => event)
228
+ ]
229
+ },
230
+ 'SELECT_SUBTITLE_STREAM': {
231
+ actions: [
232
+ sendTo('subtitles', ({ event }) => event)
233
+ ]
234
+ },
235
+ 'SELECTED_SUBTITLE_STREAM_UPDATED': {
236
+ actions: [
237
+ assign({ selectedSubtitleStreamIndex: ({ event }) => event.streamIndex })
238
+ ]
239
+ },
240
+ 'DESTROY': { target: 'WAITING' }
241
+ }
242
+ },
243
+ DESTROYED: {}
244
+ }
245
+ })
@@ -0,0 +1,247 @@
1
+ import type { ParsedASS, ParsedASSStyles } from 'ass-compiler'
2
+ import type { ASS_Event, JassubOptions } from 'jassub'
3
+
4
+ import JASSUB from 'jassub'
5
+ import { Attachment, SubtitleFragment } from 'libav-wasm/build/worker'
6
+
7
+ import { parse } from 'ass-compiler'
8
+ import { fromAsyncCallback } from './utils'
9
+
10
+ type SubtitlesEvents =
11
+ | { type: 'NEW_SUBTITLE_FRAGMENTS', subtitles: SubtitleFragment[] }
12
+ | { type: 'NEW_ATTACHMENTS', attachments: Attachment[] }
13
+ | { type: 'SELECT_SUBTITLE_STREAM', streamIndex: number }
14
+
15
+ type SubtitlesEmittedEvents =
16
+ | { type: 'WAITING' }
17
+ | { type: 'SUBTITLE_STREAMS_UPDATED', subtitlesStreams: SubtitleStream[] }
18
+ | { type: 'SELECTED_SUBTITLE_STREAM_UPDATED', streamIndex: number }
19
+
20
+ type SubtitlesInput = {
21
+ publicPath: string
22
+ videoElement: HTMLVideoElement
23
+ canvasElement: HTMLCanvasElement
24
+ subtitlesRendererOptions: Omit<JassubOptions, 'video' | 'canvas'>
25
+ }
26
+
27
+ export type SubtitlePart =
28
+ | { type: 'header', streamIndex: number, content: string, eventsContent: string, dialogueFormatContent: string, parsed: ParsedASS }
29
+ | { type: 'dialogue', streamIndex: number, index: number, content: string, parsed: ParsedASS, assEvent: ASS_Event }
30
+
31
+ export type SubtitleStream = {
32
+ header: SubtitlePart & { type: 'header' }
33
+ dialogues: (SubtitlePart & { type: 'dialogue' })[]
34
+ }
35
+
36
+ const convertTimestamp = (ms: number) =>
37
+ new Date(ms)
38
+ .toISOString()
39
+ .slice(11, 22)
40
+
41
+ const appendParsedStyle = (jassubInstance: JASSUB, style: ParsedASSStyles['style'][number]) => {
42
+ jassubInstance.createStyle({
43
+ ...style,
44
+ SecondaryColour: Number(style.SecondaryColour),
45
+ treat_fontname_as_pattern: 0,
46
+ Blur: 0,
47
+ Justify: 0,
48
+ FontName: style.Fontname,
49
+ FontSize: Number(style.Fontsize),
50
+ PrimaryColour: Number(style.PrimaryColour),
51
+ BackColour: Number(style.BackColour),
52
+ OutlineColour: Number(style.OutlineColour),
53
+ Bold: Number(style.Bold),
54
+ Italic: Number(style.Italic),
55
+ Underline: Number(style.Underline),
56
+ StrikeOut: Number(style.StrikeOut),
57
+ ScaleX: Number(style.ScaleX),
58
+ ScaleY: Number(style.ScaleY),
59
+ Spacing: Number(style.Spacing),
60
+ Angle: Number(style.Angle),
61
+ BorderStyle: Number(style.BorderStyle),
62
+ Outline: Number(style.Outline),
63
+ Shadow: Number(style.Shadow),
64
+ Alignment: Number(style.Alignment),
65
+ MarginL: Number(style.MarginL),
66
+ MarginR: Number(style.MarginR),
67
+ MarginV: Number(style.MarginV),
68
+ Encoding: Number(style.Encoding)
69
+ })
70
+ }
71
+
72
+ const subtitleHeaderFragmentToSubtitleHeaderPart = (subtitleHeaderFragment: SubtitleFragment) => {
73
+ const eventsContent =
74
+ subtitleHeaderFragment
75
+ .content
76
+ .match(/\r\n\[Events\]\r\nFormat: (.*)/)
77
+ ?.[0]
78
+ const dialogueFormatContent =
79
+ eventsContent
80
+ ?.trim()
81
+ .split('\n')
82
+ [1]
83
+ if (!eventsContent || !dialogueFormatContent) {
84
+ throw new Error('dialogueFormatContent is undefined')
85
+ }
86
+ return {
87
+ type: 'header',
88
+ streamIndex: subtitleHeaderFragment.streamIndex,
89
+ content: subtitleHeaderFragment.content,
90
+ eventsContent,
91
+ dialogueFormatContent,
92
+ parsed: parse(subtitleHeaderFragment.content)
93
+ } as SubtitlePart & { type: 'header' }
94
+ }
95
+
96
+ const subtitleDialogueFragmentToSubtitleDialoguePart = (
97
+ header: SubtitlePart & { type: 'header' },
98
+ subtitleFragment: SubtitleFragment & { type: 'dialogue' }
99
+ ) => {
100
+ const [dialogueIndexString, layer] = subtitleFragment.content.split(',')
101
+ const dialogueIndex = Number(dialogueIndexString)
102
+ const startTimestamp = convertTimestamp(subtitleFragment.start)
103
+ const endTimestamp = convertTimestamp(subtitleFragment.end)
104
+ const dialogueSourceContent = subtitleFragment.content.replace(`${dialogueIndex},${layer},`, '')
105
+ const dialogueContent = `Dialogue: ${layer},${startTimestamp},${endTimestamp},${dialogueSourceContent}`
106
+ const parsedEvent = parse(`${header.eventsContent}\r\n${dialogueContent}`)
107
+ const dialogueEvent = parsedEvent.events.dialogue[0]
108
+ if (!dialogueEvent) {
109
+ throw new Error('dialogueEvent is undefined')
110
+ }
111
+ return {
112
+ type: 'dialogue',
113
+ streamIndex: subtitleFragment.streamIndex,
114
+ index: dialogueIndex,
115
+ content: dialogueContent,
116
+ parsed: parse(`${header.eventsContent}\r\n${dialogueContent}`),
117
+ assEvent: {
118
+ ...dialogueEvent,
119
+ Effect: dialogueEvent.Effect ?? '',
120
+ Text: dialogueEvent.Text.raw,
121
+ Duration: (dialogueEvent.End - dialogueEvent.Start) * 1000,
122
+ Start: dialogueEvent.Start * 1000,
123
+ End: dialogueEvent.End * 1000,
124
+ ReadOrder: dialogueIndex,
125
+ _index: dialogueIndex
126
+ } as ASS_Event
127
+ } as SubtitlePart & { type: 'dialogue' }
128
+ }
129
+
130
+ export default fromAsyncCallback<SubtitlesEvents, SubtitlesInput, SubtitlesEmittedEvents>(async ({ sendBack, receive, input, self, emit }) => {
131
+ const { publicPath, subtitlesRendererOptions, videoElement: video, canvasElement: canvas } = input
132
+
133
+ let attachments: [string, Uint8Array][] = []
134
+ let jassubInstance: JASSUB | undefined
135
+
136
+ const subtitlesStreams = new Map<number, SubtitleStream>()
137
+ let appendedSubtitleParts: SubtitlePart[] = []
138
+ let selectedStreamIndex: number | undefined
139
+
140
+ receive((event) => {
141
+ if (event.type === 'SELECT_SUBTITLE_STREAM') {
142
+ selectedStreamIndex = event.streamIndex
143
+ sendBack({ type: 'SELECTED_SUBTITLE_STREAM_UPDATED', streamIndex: event.streamIndex })
144
+ if (!jassubInstance) return
145
+ jassubInstance.freeTrack()
146
+ appendedSubtitleParts = []
147
+ const newSubtitleStreams = subtitlesStreams.get(selectedStreamIndex)
148
+ if (!newSubtitleStreams) {
149
+ throw new Error('newSubtitleStreams is undefined')
150
+ }
151
+ jassubInstance.setTrack(newSubtitleStreams.header.content)
152
+ jassubInstance.setCurrentTime(video.paused, video.currentTime, video.playbackRate)
153
+ for (const styleIndex in newSubtitleStreams.header.parsed.styles) {
154
+ const style = newSubtitleStreams.header.parsed.styles.style[Number(styleIndex)]
155
+ if (!style) continue
156
+ appendParsedStyle(jassubInstance, style)
157
+ }
158
+ for (const dialogue of newSubtitleStreams?.dialogues ?? []) {
159
+ const alreadyAppended =
160
+ appendedSubtitleParts
161
+ .filter(appendedSubtitlePart => appendedSubtitlePart.type === 'dialogue')
162
+ .find(appendedSubtitlePart =>
163
+ appendedSubtitlePart.streamIndex === dialogue.streamIndex
164
+ && appendedSubtitlePart.index === dialogue.index
165
+ )
166
+ if (alreadyAppended || selectedStreamIndex !== dialogue.streamIndex) {
167
+ continue
168
+ }
169
+ jassubInstance?.createEvent(dialogue.assEvent)
170
+ appendedSubtitleParts.push(dialogue)
171
+ }
172
+ }
173
+ if (event.type === 'NEW_SUBTITLE_FRAGMENTS') {
174
+ const subtitleParts = event.subtitles.map((subtitleFragment) => {
175
+ if (subtitleFragment.type === 'header') {
176
+ const header = subtitleHeaderFragmentToSubtitleHeaderPart(subtitleFragment)
177
+ subtitlesStreams.set(subtitleFragment.streamIndex, { header, dialogues: [] })
178
+ if (selectedStreamIndex === undefined) {
179
+ selectedStreamIndex = subtitleFragment.streamIndex
180
+ sendBack({ type: 'SELECTED_SUBTITLE_STREAM_UPDATED', streamIndex: subtitleFragment.streamIndex })
181
+ }
182
+ sendBack({ type: 'SUBTITLE_STREAMS_UPDATED', subtitlesStreams: [...subtitlesStreams.values()] })
183
+ return header
184
+ } else {
185
+ const header = subtitlesStreams.get(subtitleFragment.streamIndex)?.header
186
+ if (!header) {
187
+ throw new Error('SubtitleStream or its header is undefined')
188
+ }
189
+ const dialogue = subtitleDialogueFragmentToSubtitleDialoguePart(header, subtitleFragment)
190
+ subtitlesStreams.get(subtitleFragment.streamIndex)?.dialogues.push(dialogue)
191
+ sendBack({ type: 'SUBTITLE_STREAMS_UPDATED', subtitlesStreams: [...subtitlesStreams.values()] })
192
+ return dialogue
193
+ }
194
+ })
195
+
196
+ for (const subtitlePart of subtitleParts) {
197
+ if (subtitlePart.type === 'dialogue') {
198
+ const alreadyAppended =
199
+ appendedSubtitleParts
200
+ .filter(appendedSubtitlePart => appendedSubtitlePart.type === 'dialogue')
201
+ .find(appendedSubtitlePart =>
202
+ appendedSubtitlePart.streamIndex === subtitlePart.streamIndex
203
+ && appendedSubtitlePart.index === subtitlePart.index
204
+ )
205
+ if (alreadyAppended || selectedStreamIndex !== subtitlePart.streamIndex) {
206
+ continue
207
+ }
208
+ jassubInstance?.createEvent(subtitlePart.assEvent)
209
+ appendedSubtitleParts.push(subtitlePart)
210
+ } else if (subtitlePart.type === 'header') {
211
+ if (!jassubInstance) {
212
+ jassubInstance = new JASSUB({
213
+ video,
214
+ canvas,
215
+ subContent: subtitlePart.content,
216
+ workerUrl: subtitlesRendererOptions.workerUrl,
217
+ modernWasmUrl: subtitlesRendererOptions.wasmUrl,
218
+ fonts: attachments.filter(Boolean).map(([filename, data]) => data),
219
+ availableFonts: { ...Object.fromEntries(attachments), 'liberation sans': `${publicPath}default.woff2` },
220
+ // fallbackFont: 'liberation sans',
221
+ })
222
+ }
223
+ for (const styleIndex in subtitlePart.parsed.styles) {
224
+ const style = subtitlePart.parsed.styles.style[Number(styleIndex)]
225
+ if (!style) continue
226
+ appendParsedStyle(jassubInstance, style)
227
+ }
228
+ } else {
229
+ throw new Error('Unknown subtitlePart type')
230
+ }
231
+ }
232
+ }
233
+ if (event.type === 'NEW_ATTACHMENTS') {
234
+ attachments =
235
+ event
236
+ .attachments
237
+ .map(attachment => [
238
+ attachment.filename,
239
+ new Uint8Array(attachment.data)
240
+ ])
241
+ }
242
+ })
243
+
244
+ return () => {
245
+ jassubInstance?.destroy()
246
+ }
247
+ })
@@ -0,0 +1,123 @@
1
+ import type { Index } from 'libav-wasm/build/worker'
2
+
3
+ import { makeRemuxer } from 'libav-wasm'
4
+ import PQueue from 'p-queue'
5
+
6
+ import { fromAsyncCallback } from './utils'
7
+ import { toStreamChunkSize } from '../utils'
8
+ import { DownloadedRange } from '../utils/context'
9
+
10
+ type ExtendedIndex = Index & { duration?: number }
11
+
12
+ export type Thumbnail = {
13
+ url: string
14
+ blob: Blob
15
+ timestamp: number
16
+ duration: number
17
+ index: ExtendedIndex
18
+ }
19
+
20
+ type DataSourceEvents =
21
+ | { type: 'DOWNLOADED_RANGES_UPDATED', downloadedRanges: DownloadedRange[] }
22
+
23
+ type DataSourceEmittedEvents =
24
+ | { type: 'NEW_THUMBNAIL', thumbnail: Thumbnail }
25
+
26
+ type DataSourceInput = {
27
+ remuxerOptions: Parameters<typeof makeRemuxer>[0]
28
+ }
29
+
30
+ export default fromAsyncCallback<DataSourceEvents, DataSourceInput, DataSourceEmittedEvents>(async ({ sendBack, receive, input, self, emit }) => {
31
+ const { remuxerOptions } = input
32
+ const { publicPath, workerUrl, bufferSize, length, read } = remuxerOptions
33
+
34
+ let resolve: (value: void) => void
35
+ const readyPromise = new Promise<void>(_resolve => {
36
+ resolve = _resolve
37
+ })
38
+
39
+ receive(async (event) => {
40
+ await readyPromise
41
+ if (event.type === 'DOWNLOADED_RANGES_UPDATED') {
42
+ // take 1 index every 5seconds
43
+ const selectedIndexes =
44
+ metadata
45
+ .indexes
46
+ .reduce((acc, index) => {
47
+ const lastIndex = acc.at(-1)
48
+ if (lastIndex && index.timestamp - lastIndex.timestamp < 5) {
49
+ return acc
50
+ } else {
51
+ const nextValidIndex =
52
+ metadata
53
+ .indexes
54
+ .slice(index.index + 1)
55
+ .find(nextIndex => nextIndex.timestamp > index.timestamp + 5)
56
+ if (!nextValidIndex) return [...acc, index]
57
+ return [...acc, { ...index, duration: nextValidIndex.timestamp - index.timestamp }]
58
+ }
59
+ }, [] as Index[])
60
+
61
+ const readyIndexes =
62
+ selectedIndexes
63
+ .filter((index) => {
64
+ const nextIndex = metadata.indexes.at(index.index + 1)
65
+ const endByte = nextIndex ? nextIndex.pos : remuxerOptions.length
66
+ const startByte = index.pos
67
+ const isWithinDownloadedRange =
68
+ event
69
+ .downloadedRanges
70
+ .some(({ startByteOffset, endByteOffset }) =>
71
+ startByteOffset <= startByte && endByte <=endByteOffset
72
+ )
73
+ return isWithinDownloadedRange
74
+ })
75
+ .sort((a, b) => a.timestamp - b.timestamp)
76
+
77
+ readyIndexes.forEach(index => loadMore(index))
78
+ }
79
+ })
80
+
81
+ const remuxer = await makeRemuxer({
82
+ publicPath,
83
+ workerUrl,
84
+ bufferSize,
85
+ length,
86
+ read
87
+ })
88
+
89
+ const metadata = await remuxer.init()
90
+
91
+ const queue = new PQueue({ concurrency: 1 })
92
+
93
+ let thumbnails: Thumbnail[] = []
94
+ const loadMore = (index: ExtendedIndex) =>
95
+ queue.add(async () => {
96
+ if (thumbnails.find(thumbnail => thumbnail.index.index === index.index)) return
97
+ const buffer = await remuxer.readKeyframe(index.timestamp)
98
+
99
+ const blob = new Blob([buffer], { type: 'image/png' })
100
+ const url = URL.createObjectURL(blob)
101
+ const nextIndex = metadata.indexes.at(index.index + 1)
102
+ const duration =
103
+ nextIndex
104
+ ? nextIndex.timestamp - index.timestamp
105
+ : metadata.info.input.duration - index.timestamp
106
+ const thumbnail = {
107
+ blob,
108
+ url,
109
+ timestamp: index.timestamp,
110
+ duration: index.duration ?? duration,
111
+ index
112
+ } satisfies Thumbnail
113
+ thumbnails.push(thumbnail)
114
+ sendBack({ type: 'NEW_THUMBNAIL', thumbnail })
115
+ })
116
+
117
+ // @ts-expect-error
118
+ resolve()
119
+
120
+ return () => {
121
+ remuxer.destroy()
122
+ }
123
+ })