peertube-plugin-live-casting 0.0.0-stage → 1.0.2
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/LICENSE +661 -0
- package/README.md +22 -2
- package/client/common-client-plugin.js +181 -0
- package/client/media-session-listener.js +25 -0
- package/client/style.scss +23 -0
- package/dist/common-client-plugin.css +1 -0
- package/dist/common-client-plugin.js +14 -0
- package/main.js +25 -0
- package/package.json +45 -4
- package/scripts/build.js +28 -0
- package/scripts/chromecast-compatibility.js +32 -0
- package/test/client.test.cjs +269 -0
- package/test/media-session.test.cjs +68 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
const assert = require('node:assert/strict')
|
|
2
|
+
const fs = require('node:fs')
|
|
3
|
+
const path = require('node:path')
|
|
4
|
+
const test = require('node:test')
|
|
5
|
+
const vm = require('node:vm')
|
|
6
|
+
|
|
7
|
+
const source = fs.readFileSync(path.join(__dirname, '../client/common-client-plugin.js'), 'utf8')
|
|
8
|
+
.replace("import './style.scss'", '')
|
|
9
|
+
.replace(/export \{\s*register\s*\}/, '')
|
|
10
|
+
|
|
11
|
+
function setup (ChromecastTech = class {}) {
|
|
12
|
+
const hooks = new Map()
|
|
13
|
+
const scripts = []
|
|
14
|
+
const registrations = []
|
|
15
|
+
let registeredTech = ChromecastTech
|
|
16
|
+
const videojs = {
|
|
17
|
+
getTech: () => registeredTech,
|
|
18
|
+
registerTech: (name, tech) => {
|
|
19
|
+
assert.equal(name, 'chromecast')
|
|
20
|
+
registeredTech = tech
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const context = vm.createContext({
|
|
24
|
+
require: name => () => registrations.push(name),
|
|
25
|
+
console,
|
|
26
|
+
setInterval,
|
|
27
|
+
clearInterval,
|
|
28
|
+
window: {
|
|
29
|
+
chrome: { cast: { media: { StreamType: { LIVE: 'LIVE' } } } }
|
|
30
|
+
},
|
|
31
|
+
document: {
|
|
32
|
+
createElement: () => ({}),
|
|
33
|
+
body: { appendChild: script => scripts.push(script) }
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
vm.runInContext(source, context)
|
|
37
|
+
context.register({
|
|
38
|
+
registerHook: hook => hooks.set(hook.target, hook.handler),
|
|
39
|
+
peertubeHelpers: { getSettings: async () => ({ 'chromecast-receiver-app-id': 'receiver' }) }
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
context,
|
|
44
|
+
scripts,
|
|
45
|
+
registrations,
|
|
46
|
+
videojs,
|
|
47
|
+
load: video => hooks.get('action:video-watch.video.loaded')({ videojs, video }),
|
|
48
|
+
options: () => hooks.get('filter:internal.player.videojs.options.result')({
|
|
49
|
+
plugins: { existing: {} }
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const playlistUrl = 'https://example.com/live/master.m3u8?token=test'
|
|
55
|
+
const mp4Url = 'https://example.com/videos/video.mp4'
|
|
56
|
+
|
|
57
|
+
async function casting (video) {
|
|
58
|
+
const client = setup()
|
|
59
|
+
await client.load(video)
|
|
60
|
+
client.context.window.__onGCastApiAvailable(true)
|
|
61
|
+
const options = await client.options()
|
|
62
|
+
return { client, options, modify: options.chromecast.modifyLoadRequestFn }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function request (contentId) {
|
|
66
|
+
return {
|
|
67
|
+
media: { contentId, contentType: 'original', duration: 120, streamType: 'BUFFERED' },
|
|
68
|
+
currentTime: 42,
|
|
69
|
+
autoplay: true
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
test('live metadata selects the HLS playlist and live receiver payload', async () => {
|
|
74
|
+
const { modify, options } = await casting({
|
|
75
|
+
isLive: true,
|
|
76
|
+
files: [{ fileUrl: mp4Url }],
|
|
77
|
+
streamingPlaylists: [{ playlistUrl }]
|
|
78
|
+
})
|
|
79
|
+
const payload = request('blob:local-player-source')
|
|
80
|
+
assert.equal(modify(payload), payload)
|
|
81
|
+
assert.equal(payload.media.contentId, playlistUrl)
|
|
82
|
+
assert.equal(payload.media.contentType, 'application/x-mpegURL')
|
|
83
|
+
assert.equal(payload.media.streamType, 'LIVE')
|
|
84
|
+
assert.equal(payload.media.hlsSegmentFormat, 'ts')
|
|
85
|
+
assert.equal(payload.media.hlsVideoSegmentFormat, 'mpeg2_ts')
|
|
86
|
+
assert.equal('duration' in payload.media, false)
|
|
87
|
+
assert.equal('currentTime' in payload, false)
|
|
88
|
+
assert.equal(payload.autoplay, true)
|
|
89
|
+
assert.equal(options.plugins.chromecast.receiverAppID, 'receiver')
|
|
90
|
+
assert.ok(options.plugins.existing)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('a playlist without static files is live when metadata is absent', async () => {
|
|
94
|
+
const { modify } = await casting({ streamingPlaylists: [{ playlistUrl }] })
|
|
95
|
+
const payload = modify(request('blob:local-player-source'))
|
|
96
|
+
assert.equal(payload.media.contentId, playlistUrl)
|
|
97
|
+
assert.equal(payload.media.streamType, 'LIVE')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('HLS-only VOD metadata prevents false live detection', async () => {
|
|
101
|
+
const { modify } = await casting({
|
|
102
|
+
isLive: false,
|
|
103
|
+
files: [],
|
|
104
|
+
streamingPlaylists: [{ playlistUrl }]
|
|
105
|
+
})
|
|
106
|
+
const payload = modify(request(playlistUrl))
|
|
107
|
+
assert.equal(payload.media.contentType, 'application/x-mpegURL')
|
|
108
|
+
assert.equal(payload.media.hlsSegmentFormat, 'fmp4')
|
|
109
|
+
assert.equal(payload.media.hlsVideoSegmentFormat, 'fmp4')
|
|
110
|
+
assert.equal(payload.media.streamType, 'BUFFERED')
|
|
111
|
+
assert.equal(payload.currentTime, 42)
|
|
112
|
+
assert.equal(payload.media.duration, 120)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('static MP4 extraction handles query strings and retains buffered playback', async () => {
|
|
116
|
+
const fileUrl = `${mp4Url}?token=test`
|
|
117
|
+
const { modify } = await casting({ files: [{ fileUrl }], isLive: false })
|
|
118
|
+
const payload = modify(request('blob:local-player-source'))
|
|
119
|
+
assert.equal(payload.media.contentId, fileUrl)
|
|
120
|
+
assert.equal(payload.media.contentType, 'video/mp4')
|
|
121
|
+
assert.equal(payload.media.streamType, 'BUFFERED')
|
|
122
|
+
assert.equal(payload.currentTime, 42)
|
|
123
|
+
assert.equal('hlsSegmentFormat' in payload.media, false)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('VOD preserves the player-selected MP4 rendition', async () => {
|
|
127
|
+
const { modify } = await casting({ files: [{ fileUrl: mp4Url }], isLive: false })
|
|
128
|
+
const selected = 'https://example.com/videos/720.MP4#fragment'
|
|
129
|
+
assert.equal(modify(request(selected)).media.contentId, selected)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('navigation updates casting state even after SDK initialization', async () => {
|
|
133
|
+
const { client, modify } = await casting({
|
|
134
|
+
isLive: true,
|
|
135
|
+
streamingPlaylists: [{ playlistUrl }]
|
|
136
|
+
})
|
|
137
|
+
await client.load({ isLive: false, files: [{ fileUrl: mp4Url }] })
|
|
138
|
+
const payload = modify(request(mp4Url))
|
|
139
|
+
assert.equal(payload.media.contentId, mp4Url)
|
|
140
|
+
assert.equal(payload.media.contentType, 'video/mp4')
|
|
141
|
+
assert.equal(payload.media.streamType, 'BUFFERED')
|
|
142
|
+
assert.equal(client.scripts.length, 1)
|
|
143
|
+
assert.equal(client.registrations.length, 1)
|
|
144
|
+
await client.load({ isLive: true, streamingPlaylists: [{ playlistUrl }] })
|
|
145
|
+
assert.equal(modify(request('blob:local-player-source')).media.contentId, playlistUrl)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('stale live state does not hijack a VOD MP4 source', async () => {
|
|
149
|
+
const { modify } = await casting({ isLive: true, streamingPlaylists: [{ playlistUrl }] })
|
|
150
|
+
const payload = modify(request(mp4Url))
|
|
151
|
+
assert.equal(payload.media.contentId, mp4Url)
|
|
152
|
+
assert.equal(payload.media.contentType, 'video/mp4')
|
|
153
|
+
assert.equal(payload.media.streamType, 'BUFFERED')
|
|
154
|
+
assert.equal(payload.currentTime, 42)
|
|
155
|
+
assert.equal(payload.media.duration, 120)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
test('missing video metadata preserves the source provided by the player', async () => {
|
|
159
|
+
const { modify } = await casting(undefined)
|
|
160
|
+
const payload = modify(request(mp4Url))
|
|
161
|
+
assert.equal(payload.media.contentId, mp4Url)
|
|
162
|
+
assert.equal(payload.media.contentType, 'video/mp4')
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
test('incomplete hook metadata does not throw or replace the player source', async () => {
|
|
166
|
+
const { modify } = await casting({ files: {}, streamingPlaylists: {} })
|
|
167
|
+
const payload = modify(request(mp4Url))
|
|
168
|
+
assert.equal(payload.media.contentId, mp4Url)
|
|
169
|
+
assert.equal(payload.media.contentType, 'video/mp4')
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
test('null playlist and file entries are ignored', async () => {
|
|
173
|
+
const { modify } = await casting({
|
|
174
|
+
isLive: true,
|
|
175
|
+
files: [null],
|
|
176
|
+
streamingPlaylists: [null, { playlistUrl }]
|
|
177
|
+
})
|
|
178
|
+
const payload = modify(request('blob:local-player-source'))
|
|
179
|
+
assert.equal(payload.media.contentId, playlistUrl)
|
|
180
|
+
assert.equal(payload.media.hlsSegmentFormat, 'ts')
|
|
181
|
+
assert.equal(payload.media.hlsVideoSegmentFormat, 'mpeg2_ts')
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
test('AirPlay and unavailable Chromecast keep their existing player options', async () => {
|
|
185
|
+
const client = setup()
|
|
186
|
+
client.context.window.WebKitPlaybackTargetAvailabilityEvent = function () {}
|
|
187
|
+
await client.load({ isLive: true, streamingPlaylists: [{ playlistUrl }] })
|
|
188
|
+
client.context.window.__onGCastApiAvailable(false)
|
|
189
|
+
const options = await client.options()
|
|
190
|
+
assert.ok(options.plugins.airPlay)
|
|
191
|
+
assert.ok(options.plugins.existing)
|
|
192
|
+
assert.equal(options.chromecast, undefined)
|
|
193
|
+
assert.deepEqual(client.registrations, ['@silvermine/videojs-airplay'])
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
test('Chromecast tech supports seeking queries from PeerTube play and pause handlers', async () => {
|
|
197
|
+
class ChromecastTech {
|
|
198
|
+
setCurrentTime (time) { this.currentTime = time }
|
|
199
|
+
}
|
|
200
|
+
const client = setup(ChromecastTech)
|
|
201
|
+
await client.load({ isLive: true, streamingPlaylists: [{ playlistUrl }] })
|
|
202
|
+
client.context.window.__onGCastApiAvailable(true)
|
|
203
|
+
const tech = new (client.videojs.getTech('Chromecast'))()
|
|
204
|
+
assert.equal(tech.seeking(), false)
|
|
205
|
+
tech.setCurrentTime(42)
|
|
206
|
+
assert.equal(tech.currentTime, 42)
|
|
207
|
+
assert.equal(tech.seeking(), false)
|
|
208
|
+
assert.equal(ChromecastTech.prototype.seeking, undefined)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
test('missing videojs reference does not throw during seeking compatibility', async () => {
|
|
212
|
+
const client = setup()
|
|
213
|
+
await client.load({ files: [{ fileUrl: mp4Url }] })
|
|
214
|
+
assert.doesNotThrow(() => client.context.registerChromecastSeekingCompatibility(undefined))
|
|
215
|
+
assert.doesNotThrow(() => client.context.registerChromecastSeekingCompatibility({ getTech: () => undefined }))
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
test('an existing Chromecast seeking implementation is preserved', async () => {
|
|
219
|
+
class ChromecastTech {
|
|
220
|
+
seeking () { return true }
|
|
221
|
+
}
|
|
222
|
+
const client = setup(ChromecastTech)
|
|
223
|
+
await client.load({ files: [{ fileUrl: mp4Url }] })
|
|
224
|
+
client.context.window.__onGCastApiAvailable(true)
|
|
225
|
+
assert.equal(client.videojs.getTech('Chromecast'), ChromecastTech)
|
|
226
|
+
assert.equal(new ChromecastTech().seeking(), true)
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
test('real upstream tech retains VOD seeking and play/pause queries do not throw', () => {
|
|
230
|
+
const client = setup()
|
|
231
|
+
client.videojs.getComponent = () => class {}
|
|
232
|
+
require('@silvermine/videojs-chromecast/src/js/tech/ChromecastTech')(client.videojs)
|
|
233
|
+
const UpstreamTech = client.videojs.getTech('Chromecast')
|
|
234
|
+
client.context.registerChromecastSeekingCompatibility(client.videojs)
|
|
235
|
+
const CompatibleTech = client.videojs.getTech('Chromecast')
|
|
236
|
+
assert.equal(CompatibleTech.canPlaySource, UpstreamTech.canPlaySource)
|
|
237
|
+
assert.equal(CompatibleTech.isSupported, UpstreamTech.isSupported)
|
|
238
|
+
|
|
239
|
+
const tech = Object.create(CompatibleTech.prototype)
|
|
240
|
+
const events = []
|
|
241
|
+
let seekCommands = 0
|
|
242
|
+
tech.trigger = event => {
|
|
243
|
+
events.push(event)
|
|
244
|
+
if (event === 'play' || event === 'playing' || event === 'pause') {
|
|
245
|
+
assert.equal(tech.seeking(), false)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
tech._remotePlayer = { duration: 120, canSeek: true, playerState: 'PLAYING' }
|
|
249
|
+
tech._hasPlayedAnyItem = true
|
|
250
|
+
tech._remotePlayerController = { seek: () => { seekCommands++ } }
|
|
251
|
+
tech.setCurrentTime(42)
|
|
252
|
+
assert.equal(tech._remotePlayer.currentTime, 42)
|
|
253
|
+
assert.equal(seekCommands, 1)
|
|
254
|
+
client.context.chrome = {
|
|
255
|
+
cast: { media: { PlayerState: { PLAYING: 'PLAYING', PAUSED: 'PAUSED' } } }
|
|
256
|
+
}
|
|
257
|
+
// Upstream code runs in Node, so supply the same SDK global for its event handler.
|
|
258
|
+
const previousChrome = global.chrome
|
|
259
|
+
global.chrome = client.context.chrome
|
|
260
|
+
try {
|
|
261
|
+
tech._onPlayerStateChanged()
|
|
262
|
+
tech._remotePlayer.playerState = 'PAUSED'
|
|
263
|
+
tech._onPlayerStateChanged()
|
|
264
|
+
assert.deepEqual(events, ['timeupdate', 'play', 'playing', 'pause'])
|
|
265
|
+
} finally {
|
|
266
|
+
if (previousChrome === undefined) delete global.chrome
|
|
267
|
+
else global.chrome = previousChrome
|
|
268
|
+
}
|
|
269
|
+
})
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
const assert = require('node:assert/strict')
|
|
2
|
+
const test = require('node:test')
|
|
3
|
+
const fs = require('node:fs')
|
|
4
|
+
const { attachMediaSessionListener } = require('../client/media-session-listener')
|
|
5
|
+
const { patchChromecastTech } = require('../scripts/chromecast-compatibility')
|
|
6
|
+
|
|
7
|
+
function setup () {
|
|
8
|
+
const callbacks = []
|
|
9
|
+
const session = {}
|
|
10
|
+
const tech = {
|
|
11
|
+
mediaSession: null,
|
|
12
|
+
_eventListeners: [],
|
|
13
|
+
_getMediaSession () { return this.mediaSession },
|
|
14
|
+
_onMediaSessionStatusChanged (alive) { this.alive = alive },
|
|
15
|
+
_addEventListener (target, type, callback) {
|
|
16
|
+
this._eventListeners.push({ target, type, callback })
|
|
17
|
+
},
|
|
18
|
+
_removeEventListener (listener) {
|
|
19
|
+
assert.ok(listener)
|
|
20
|
+
this._eventListeners.splice(this._eventListeners.indexOf(listener), 1)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const mediaSession = { addUpdateListener: callback => callbacks.push(callback) }
|
|
24
|
+
return { tech, session, mediaSession, callbacks }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
test('available media session gets its update listener immediately', () => {
|
|
28
|
+
const { tech, session, mediaSession, callbacks } = setup()
|
|
29
|
+
tech.mediaSession = mediaSession
|
|
30
|
+
attachMediaSessionListener(tech, session, 'MEDIA_SESSION')
|
|
31
|
+
assert.equal(callbacks.length, 1)
|
|
32
|
+
assert.equal(tech._eventListeners.length, 0)
|
|
33
|
+
callbacks[0](false)
|
|
34
|
+
assert.equal(tech.alive, false)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('null session waits for SDK event and then removes the temporary listener', () => {
|
|
38
|
+
const { tech, session, mediaSession, callbacks } = setup()
|
|
39
|
+
attachMediaSessionListener(tech, session, 'MEDIA_SESSION')
|
|
40
|
+
assert.equal(callbacks.length, 0)
|
|
41
|
+
assert.equal(tech._eventListeners.length, 1)
|
|
42
|
+
const listener = tech._eventListeners[0]
|
|
43
|
+
assert.equal(listener.target, session)
|
|
44
|
+
assert.equal(listener.type, 'MEDIA_SESSION')
|
|
45
|
+
listener.callback()
|
|
46
|
+
assert.equal(tech._eventListeners.length, 1)
|
|
47
|
+
tech.mediaSession = mediaSession
|
|
48
|
+
listener.callback()
|
|
49
|
+
assert.equal(callbacks.length, 1)
|
|
50
|
+
assert.equal(tech._eventListeners.length, 0)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('temporary listener participates in upstream disposal cleanup', () => {
|
|
54
|
+
const { tech, session } = setup()
|
|
55
|
+
attachMediaSessionListener(tech, session, 'MEDIA_SESSION')
|
|
56
|
+
while (tech._eventListeners.length) tech._removeEventListener(tech._eventListeners[0])
|
|
57
|
+
assert.equal(tech._eventListeners.length, 0)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('build patch matches the pinned upstream tech and fails on changed code', () => {
|
|
61
|
+
const source = fs.readFileSync(
|
|
62
|
+
require.resolve('@silvermine/videojs-chromecast/src/js/tech/ChromecastTech'), 'utf8'
|
|
63
|
+
)
|
|
64
|
+
const patched = patchChromecastTech(source)
|
|
65
|
+
assert.ok(patched.includes('attachMediaSessionListener(this, castSession, cast.framework.SessionEventType.MEDIA_SESSION)'))
|
|
66
|
+
assert.ok(!patched.includes('this._getMediaSession().addUpdateListener('))
|
|
67
|
+
assert.throws(() => patchChromecastTech('changed upstream source'), /does not match/)
|
|
68
|
+
})
|