@banou/media-player 0.3.0 → 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 (74) 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 +2576 -2250
  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 -11
  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 -435
  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/utils/volume-utils.ts +39 -0
  61. package/src/utils/window-height.ts +19 -0
  62. package/tsconfig.json +4 -2
  63. package/build/chrome/bottom.d.ts +0 -22
  64. package/build/chrome/index.d.ts +0 -28
  65. package/build/chrome/overlay.d.ts +0 -9
  66. package/build/languages.d.ts +0 -260
  67. package/build/use-local-storage.d.ts +0 -6
  68. package/src/chrome/bottom.tsx +0 -697
  69. package/src/chrome/index.tsx +0 -203
  70. package/src/chrome/overlay.tsx +0 -104
  71. package/src/use-local-storage.ts +0 -32
  72. /package/src/{languages.ts → utils/languages.ts} +0 -0
  73. /package/src/{mp4box.ts → utils/mp4box.ts} +0 -0
  74. /package/src/{use-scrub.ts → utils/use-scrub.ts} +0 -0
package/src/main.tsx CHANGED
@@ -1,48 +1,46 @@
1
1
  /// <reference types="@emotion/react/types/css-prop" />
2
- import { useEffect, useMemo, useState } from 'react'
2
+ import { useCallback, useEffect, useMemo, useState } from 'react'
3
3
  import { createRoot } from 'react-dom/client'
4
4
  import { css, Global } from '@emotion/react'
5
5
 
6
6
  import MediaPlayer from './index'
7
+ import { DownloadedRange } from './utils/context'
7
8
 
8
9
  const mountStyle = css`
9
10
  display: grid;
10
- height: 100%;
11
- width: 100%;
11
+ height: 100vh;
12
+ width: 100vw;
12
13
  `
13
14
 
14
- const BASE_BUFFER_SIZE = 5_000_000
15
- const BACKPRESSURE_STREAM_ENABLED = !navigator.userAgent.includes("Firefox")
15
+ const BASE_BUFFER_SIZE = 2_500_000
16
+ const url = '/video2.mkv'
16
17
 
17
18
  const Mount = () => {
18
- const [videoElemRef, setVideoElemRef] = useState<HTMLVideoElement | null>()
19
- const [size, setSize] = useState<number>()
20
-
21
- useEffect(() => {
22
- if (!videoElemRef) return
23
- videoElemRef.addEventListener('error', err => console.log('err', err))
24
- }, [videoElemRef])
25
-
26
- const onFetch = async (offset: number, end?: number) =>
27
- fetch(
28
- '/video.mkv',
29
- {
30
- headers: {
31
- Range: `bytes=${offset}-${end ?? (!BACKPRESSURE_STREAM_ENABLED ? Math.min(offset + BASE_BUFFER_SIZE, size!) : '')}`
32
- }
33
- }
34
- )
19
+ const [contentLength, setContentLength] = useState<number>()
20
+
21
+ const read = useCallback(
22
+ (offset: number, size: number) => {
23
+ if (contentLength === undefined) return Promise.resolve(new Uint8Array(0).buffer)
24
+ if (offset >= contentLength) return Promise.resolve(new Uint8Array(0).buffer)
25
+ return (
26
+ fetch(url, { headers: { Range: `bytes=${offset}-${Math.min(offset + size, contentLength) - 1}` } })
27
+ .then(res => res.arrayBuffer())
28
+ )
29
+ },
30
+ [contentLength]
31
+ )
35
32
 
36
33
  useEffect(() => {
37
- onFetch(0, 1).then(async ({ headers, body }) => {
38
- if (!body) throw new Error('no body')
39
- const contentRangeContentLength = headers.get('Content-Range')?.split('/').at(1)
40
- const contentLength =
41
- contentRangeContentLength
42
- ? Number(contentRangeContentLength)
43
- : Number(headers.get('Content-Length'))
44
- setSize(contentLength)
45
- })
34
+ fetch(url, { headers: { Range: `bytes=${0}-${1}` } })
35
+ .then(async ({ headers, body }) => {
36
+ if (!body) throw new Error('no body')
37
+ const contentRangeContentLength = headers.get('Content-Range')?.split('/').at(1)
38
+ const contentLength =
39
+ contentRangeContentLength
40
+ ? Number(contentRangeContentLength)
41
+ : Number(headers.get('Content-Length'))
42
+ setContentLength(contentLength)
43
+ })
46
44
  }, [])
47
45
 
48
46
  const jassubWorkerUrl = useMemo(() => {
@@ -57,17 +55,47 @@ const Mount = () => {
57
55
  return URL.createObjectURL(blob)
58
56
  }, [])
59
57
 
58
+ const jassubWasmUrl = useMemo(() => {
59
+ return new URL('/build/jassub-worker.wasm', new URL(window.location.toString()).origin).toString()
60
+ }, [])
61
+
62
+ const jassubModernWasmUrl = useMemo(() => {
63
+ return new URL('/build/jassub-modern-worker.wasm', new URL(window.location.toString()).origin).toString()
64
+ }, [])
65
+
66
+ const [downloadedRanges, setDownloadedRanges] = useState<DownloadedRange[]>([])
67
+
68
+ useEffect(() => {
69
+ if (!contentLength) return
70
+ let i = 0
71
+ const increaseDownloadedRanges = () => {
72
+ setDownloadedRanges(() => [
73
+ {
74
+ startByteOffset: 0,
75
+ endByteOffset: contentLength * i
76
+ }
77
+ ])
78
+ i += 0.1
79
+ if (i < 1) {
80
+ setTimeout(increaseDownloadedRanges, 1000)
81
+ }
82
+ }
83
+ increaseDownloadedRanges()
84
+ }, [contentLength])
85
+
60
86
  return (
61
87
  <div css={mountStyle}>
62
88
  <MediaPlayer
63
- baseBufferSize={BASE_BUFFER_SIZE}
64
- ref={setVideoElemRef}
65
- size={size}
66
- fetch={(offset, end) => onFetch(offset, end)}
89
+ title={'video.mkv'}
90
+ downloadedRanges={contentLength ? downloadedRanges : undefined}
91
+ bufferSize={BASE_BUFFER_SIZE}
92
+ read={read}
93
+ size={contentLength}
67
94
  publicPath={new URL('/build/', new URL(import.meta.url).origin).toString()}
68
- wasmUrl={new URL('/build/jassub-worker-modern.wasm', new URL(import.meta.url).origin).toString()}
95
+ jassubModernWasmUrl={jassubModernWasmUrl}
96
+ jassubWorkerUrl={jassubWorkerUrl}
97
+ jassubWasmUrl={jassubWasmUrl}
69
98
  libavWorkerUrl={libavWorkerUrl}
70
- libassWorkerUrl={jassubWorkerUrl}
71
99
  />
72
100
  </div>
73
101
  )
@@ -97,7 +125,6 @@ const globalStyle = css`
97
125
  color: #fff;
98
126
 
99
127
  font-family: Montserrat;
100
- // font-family: "Segoe UI", Roboto, "Fira Sans", "Helvetica Neue", Arial, sans-serif;
101
128
  }
102
129
 
103
130
  body > div {
@@ -134,7 +161,7 @@ root.render(
134
161
  )
135
162
 
136
163
  if (import.meta.hot) {
137
- import.meta.hot.dispose((data) => {
164
+ import.meta.hot.dispose(() => {
138
165
  root.unmount()
139
166
  mountElement.remove()
140
167
  })
@@ -0,0 +1,83 @@
1
+ import { makeRemuxer } from 'libav-wasm'
2
+
3
+ import { fromAsyncCallback } from './utils'
4
+ import { queuedThrottleWithLastCall, toStreamChunkSize } from '../utils'
5
+ import { Attachment, SubtitleFragment } from 'libav-wasm/build/worker'
6
+
7
+ type DataSourceEvents =
8
+ | { type: 'METADATA', mimeType: string, duration: number }
9
+ | { type: 'SEEKING', currentTime: number }
10
+ | { type: 'NEED_DATA' }
11
+
12
+ type DataSourceEmittedEvents =
13
+ | { type: 'DATA', data: Uint8Array }
14
+ | { type: 'NEW_SUBTITLE_FRAGMENTS', subtitles: SubtitleFragment[] }
15
+ | { type: 'NEW_ATTACHMENTS', attachments: Attachment[] }
16
+
17
+ type DataSourceInput = {
18
+ remuxerOptions: Parameters<typeof makeRemuxer>[0]
19
+ }
20
+
21
+ export default fromAsyncCallback<DataSourceEvents, DataSourceInput, DataSourceEmittedEvents>(async ({ sendBack, receive, input, self, emit }) => {
22
+ const { remuxerOptions } = input
23
+ const { publicPath, workerUrl, bufferSize, length, read } = remuxerOptions
24
+
25
+ const remuxer = await makeRemuxer({
26
+ publicPath,
27
+ workerUrl,
28
+ bufferSize,
29
+ length,
30
+ read
31
+ })
32
+
33
+ const metadata = await remuxer.init()
34
+ if (metadata.attachments?.length) sendBack({ type: 'NEW_ATTACHMENTS', attachments: metadata.attachments })
35
+ if (metadata.subtitles?.length) sendBack({ type: 'NEW_SUBTITLE_FRAGMENTS', subtitles: metadata.subtitles })
36
+ sendBack({ type: 'METADATA', ...metadata })
37
+
38
+ let isFinished = false
39
+ let currentSeeks: { currentTime: number }[] = []
40
+ const loadMore = queuedThrottleWithLastCall(100, async () => {
41
+ if (currentSeeks.length || isFinished) return
42
+ try {
43
+ const { data, subtitles, finished } = await remuxer.read()
44
+ if (finished) {
45
+ isFinished = true
46
+ }
47
+ if (subtitles.length) {
48
+ sendBack({ type: 'NEW_SUBTITLE_FRAGMENTS', subtitles })
49
+ }
50
+ sendBack({ type: 'DATA', data })
51
+ } catch (err: any) {
52
+ if (err.message === 'Cancelled') return
53
+ console.error(err)
54
+ }
55
+ })
56
+
57
+ receive(async (event) => {
58
+ if (event.type === 'NEED_DATA') {
59
+ loadMore()
60
+ } else if (event.type === 'SEEKING') {
61
+ isFinished = false
62
+ const { currentTime } = event
63
+ const seekObject = { currentTime }
64
+ currentSeeks = [...currentSeeks, seekObject]
65
+ try {
66
+ const { data, pts } = await remuxer
67
+ .seek(currentTime)
68
+ .finally(() => {
69
+ currentSeeks = currentSeeks.filter(seekObj => seekObj !== seekObject)
70
+ })
71
+ sendBack({ type: 'TIMESTAMP_OFFSET', timestampOffset: pts })
72
+ sendBack({ type: 'DATA', data })
73
+ } catch (err: any) {
74
+ if (err.message === 'Cancelled') return
75
+ console.error(err)
76
+ }
77
+ }
78
+ })
79
+
80
+ return () => {
81
+ remuxer.destroy()
82
+ }
83
+ })
@@ -0,0 +1,5 @@
1
+ import { createActorContext } from '@xstate/react'
2
+
3
+ import mediaMachine from './media'
4
+
5
+ export const MediaMachineContext = createActorContext(mediaMachine)
@@ -0,0 +1,78 @@
1
+ import { fromCallback } from 'xstate'
2
+
3
+ type MediaPropertiesEvents =
4
+ | { type: 'PLAY' }
5
+ | { type: 'PAUSE' }
6
+ | { type: 'SET_TIME', value: number }
7
+ | { type: 'SET_VOLUME', muted: boolean, volume: number }
8
+ | { type: 'SET_PLAYBACK_RATE', playbackRate: number }
9
+ | { type: 'DESTROY' }
10
+
11
+ type MediaPropertiesEmittedEvents =
12
+ | { type: 'PLAYING' }
13
+ | { type: 'PAUSED' }
14
+ | { type: 'TIME_UPDATE', currentTime: number }
15
+ | { type: 'VOLUME_UPDATE', muted: boolean, volume: number }
16
+ | { type: 'PLAYBACK_RATE_UPDATE', playbackRate: number }
17
+ | { type: 'DURATION_UPDATE', duration: number }
18
+ | { type: 'SEEKING', currentTime: number }
19
+ | { type: 'ENDED' }
20
+
21
+ type MediaPropertiesInput = { videoElement: HTMLVideoElement }
22
+
23
+ export default fromCallback<MediaPropertiesEvents, MediaPropertiesInput, MediaPropertiesEmittedEvents>(({ sendBack, receive, input }) => {
24
+ const { videoElement } = input
25
+
26
+ receive((event) => {
27
+ if (event.type === 'PLAY') {
28
+ videoElement.play()
29
+ handlePlay()
30
+ }
31
+ if (event.type === 'PAUSE') {
32
+ videoElement.pause()
33
+ handlePlay()
34
+ }
35
+ if (event.type === 'SET_TIME') {
36
+ videoElement.currentTime = event.value
37
+ handleTimeUpdate()
38
+ }
39
+ if (event.type === 'SET_VOLUME') {
40
+ videoElement.volume = event.volume
41
+ videoElement.muted = event.muted
42
+ handleVolumeUpdate()
43
+ }
44
+ if (event.type === 'SET_PLAYBACK_RATE') {
45
+ videoElement.playbackRate = event.playbackRate
46
+ handlePlaybackRateUpdate()
47
+ }
48
+ })
49
+
50
+ const handlePlay = () => sendBack({ type: 'PLAYING' })
51
+ const handlePause = () => sendBack({ type: 'PAUSED' })
52
+ const handleEnded = () => sendBack({ type: 'ENDED' })
53
+ const handleTimeUpdate = () => sendBack({ type: 'TIME_UPDATE', currentTime: videoElement.currentTime })
54
+ const handleVolumeUpdate = () => sendBack({ type: 'VOLUME_UPDATE', muted: videoElement.muted, volume: videoElement.volume })
55
+ const handlePlaybackRateUpdate = () => sendBack({ type: 'PLAYBACK_RATE_UPDATE', playbackRate: videoElement.playbackRate })
56
+ const handleSeeking = () => sendBack({ type: 'SEEKING', currentTime: videoElement.currentTime })
57
+ const handleDurationChange = () => sendBack({ type: 'DURATION_UPDATE', duration: videoElement.duration })
58
+
59
+ videoElement.addEventListener('play', handlePlay)
60
+ videoElement.addEventListener('pause', handlePause)
61
+ videoElement.addEventListener('ended', handleEnded)
62
+ videoElement.addEventListener('timeupdate', handleTimeUpdate)
63
+ videoElement.addEventListener('volumechange', handleVolumeUpdate)
64
+ videoElement.addEventListener('ratechange', handlePlaybackRateUpdate)
65
+ videoElement.addEventListener('seeking', handleSeeking)
66
+ videoElement.addEventListener('durationchange', handleDurationChange)
67
+
68
+ return () => {
69
+ videoElement.removeEventListener('play', handlePlay)
70
+ videoElement.removeEventListener('pause', handlePause)
71
+ videoElement.removeEventListener('ended', handleEnded)
72
+ videoElement.removeEventListener('timeupdate', handleTimeUpdate)
73
+ videoElement.removeEventListener('volumechange', handleVolumeUpdate)
74
+ videoElement.removeEventListener('ratechange', handlePlaybackRateUpdate)
75
+ videoElement.removeEventListener('seeking', handleSeeking)
76
+ videoElement.removeEventListener('durationchange', handleDurationChange)
77
+ }
78
+ })
@@ -0,0 +1,129 @@
1
+ import { makeRemuxer } from 'libav-wasm'
2
+
3
+ import { fromAsyncCallback, getTimeRanges, updateSourceBuffer } from './utils'
4
+
5
+ type MediaSourceEvents =
6
+ | ({ type: 'METADATA' } & Awaited<ReturnType<Awaited<ReturnType<typeof makeRemuxer>>['init']>>)
7
+ | { type: 'TIMESTAMP_OFFSET', timestampOffset: number }
8
+ | { type: 'DATA', data: ArrayBuffer }
9
+
10
+ type MediaSourceEmittedEvents =
11
+ | { type: 'SOURCE_BUFFER_READY' }
12
+ | { type: 'SOURCE_OPEN' }
13
+ | { type: 'SOURCE_ENDED' }
14
+ | { type: 'SOURCE_ERROR' }
15
+ | { type: 'NEED_DATA' }
16
+
17
+ export type MediaSourceOptions = {
18
+ preEvictionTime?: number
19
+ postEvictionTime?: number
20
+ bufferTargetTime?: number
21
+ }
22
+
23
+ type MediaSourceInput = {
24
+ videoElement: HTMLVideoElement
25
+ preEvictionTime?: number
26
+ postEvictionTime?: number
27
+ bufferTargetTime?: number
28
+ }
29
+
30
+ const defaultPreEvictionTime = -20
31
+ const defaultPostEvictionTime = 60
32
+ const defaultBufferTargetTime = 30
33
+
34
+ export default fromAsyncCallback<MediaSourceEvents, MediaSourceInput, MediaSourceEmittedEvents>(async ({ sendBack, receive, input, self, emit }) => {
35
+ const { videoElement, preEvictionTime, postEvictionTime, bufferTargetTime } = input
36
+
37
+ const handlError = () => console.error(videoElement.error)
38
+ videoElement.addEventListener('error', handlError)
39
+
40
+ const mediaSource = new MediaSource()
41
+ const mediaSourceUrl = URL.createObjectURL(mediaSource)
42
+ videoElement.src = mediaSourceUrl
43
+
44
+ const getPreEvictionTime = () => videoElement.currentTime + (preEvictionTime ?? defaultPreEvictionTime)
45
+ const getPostEvictionTime = () => videoElement.currentTime + (postEvictionTime ?? defaultPostEvictionTime)
46
+ const getBufferTargetTime = () => videoElement.currentTime + (bufferTargetTime ?? defaultBufferTargetTime)
47
+
48
+ let headerBuffer: ArrayBuffer | undefined
49
+ const sourceBuffer =
50
+ await new Promise<SourceBuffer>(resolve =>
51
+ mediaSource.addEventListener(
52
+ 'sourceopen',
53
+ () => {
54
+ let resolved = false
55
+ receive((event) => {
56
+ if (resolved) return
57
+ if (event.type === 'METADATA') {
58
+ const sourceBuffer = mediaSource.addSourceBuffer(`video/mp4; codecs="${event.info.input.videoMimeType},${event.info.input.audioMimeType}"`)
59
+ sourceBuffer.mode = 'segments'
60
+ mediaSource.duration = event.info.input.duration
61
+ headerBuffer = event.data
62
+ resolved = true
63
+ resolve(sourceBuffer)
64
+ }
65
+ })
66
+ },
67
+ { once: true }
68
+ )
69
+ )
70
+
71
+ if (!headerBuffer) throw new Error('No header buffer')
72
+
73
+ const { appendBuffer, unbufferRange, updateTimestampOffset } = updateSourceBuffer(sourceBuffer)
74
+ appendBuffer(headerBuffer)
75
+
76
+ receive(async (event) => {
77
+ if (event.type === 'DATA') {
78
+ await appendBuffer(event.data)
79
+ } else if (event.type === 'TIMESTAMP_OFFSET') {
80
+ await updateTimestampOffset(event.timestampOffset)
81
+ }
82
+ })
83
+
84
+ const unbuffer = async () => {
85
+ const preEvictionTime = getPreEvictionTime()
86
+ const postEvictionTime = getPostEvictionTime()
87
+ const bufferedRanges = getTimeRanges(sourceBuffer)
88
+ for (const { start, end } of bufferedRanges) {
89
+ if (start < preEvictionTime) {
90
+ await unbufferRange(
91
+ start,
92
+ preEvictionTime
93
+ )
94
+ }
95
+ if (end > postEvictionTime) {
96
+ await unbufferRange(
97
+ postEvictionTime,
98
+ end
99
+ )
100
+ }
101
+ }
102
+ }
103
+
104
+ const interval = setInterval(async () => {
105
+ await unbuffer()
106
+ const timeRanges = getTimeRanges(sourceBuffer)
107
+ const maxBufferedTime = Math.max(...timeRanges.map(({ end }) => end))
108
+ if (maxBufferedTime < getBufferTargetTime()) {
109
+ sendBack({ type: 'NEED_DATA' })
110
+ }
111
+ }, 100)
112
+
113
+ const handleSourceOpen = () => sendBack({ type: 'SOURCE_OPEN' })
114
+ const handleSourceEnded = () => sendBack({ type: 'SOURCE_ENDED' })
115
+ const handleSourceError = () => sendBack({ type: 'SOURCE_ERROR' })
116
+
117
+ mediaSource.addEventListener('sourceopen', handleSourceOpen)
118
+ mediaSource.addEventListener('sourceended', handleSourceEnded)
119
+ mediaSource.addEventListener('error', handleSourceError)
120
+
121
+ return () => {
122
+ URL.revokeObjectURL(mediaSourceUrl)
123
+ clearInterval(interval)
124
+ videoElement.removeEventListener('error', handlError)
125
+ mediaSource.removeEventListener('sourceopen', handleSourceOpen)
126
+ mediaSource.removeEventListener('sourceended', handleSourceEnded)
127
+ mediaSource.removeEventListener('error', handleSourceError)
128
+ }
129
+ })