Skip to content

Commit 3af0bf3

Browse files
committed
fix(hls): use source element for Safari AirPlay auto MSE
1 parent 116f906 commit 3af0bf3

5 files changed

Lines changed: 87 additions & 7 deletions

File tree

packages/xgplayer-cast/__tests__/airplay.spec.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,31 @@ describe('Airplay native source preparation', () => {
139139
expect(player.media.webkitShowPlaybackTargetPicker).toHaveBeenCalled()
140140
})
141141

142+
test('adds AirPlay fallback source while keeping MSE blob src before opening picker', () => {
143+
const media = document.createElement('video')
144+
media.src = 'blob:https://example.com/mse'
145+
media.load = jest.fn()
146+
media.webkitShowPlaybackTargetPicker = jest.fn()
147+
148+
const { airplay, player, plugin } = createAirplay(
149+
{},
150+
{
151+
media,
152+
config: { url: 'https://cdn.example.com/main.m3u8' }
153+
}
154+
)
155+
156+
airplay._onRequestCast({ protocol: 'airplay' })
157+
158+
const airplaySource = player.media.querySelector(
159+
'source[data-xgplayer-cast-airplay="true"]'
160+
)
161+
expect(plugin._suspendMSEPlugin).not.toHaveBeenCalled()
162+
expect(player.media.getAttribute('src')).toBe('blob:https://example.com/mse')
163+
expect(airplaySource.src).toBe('https://cdn.example.com/main.m3u8')
164+
expect(player.media.webkitShowPlaybackTargetPicker).toHaveBeenCalled()
165+
})
166+
142167
test('keeps srcObject while opening picker and only adds AirPlay fallback source', () => {
143168
const srcObject = { type: 'ManagedMediaSource' }
144169
const media = document.createElement('video')

packages/xgplayer-hls/__tests__/mse-airplay.spec.js

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ describe('MSE AirPlay handoff restore', () => {
44
const originalMediaSource = window.MediaSource
55
const originalCreateObjectURL = URL.createObjectURL
66
const originalRevokeObjectURL = URL.revokeObjectURL
7+
const originalWebKitPlaybackTargetAvailabilityEvent =
8+
window.WebKitPlaybackTargetAvailabilityEvent
79

810
beforeEach(() => {
911
class FakeMediaSource {
@@ -19,12 +21,56 @@ describe('MSE AirPlay handoff restore', () => {
1921
window.MediaSource = FakeMediaSource
2022
URL.createObjectURL = jest.fn(() => 'blob:https://example.com/mse')
2123
URL.revokeObjectURL = jest.fn()
24+
delete window.WebKitPlaybackTargetAvailabilityEvent
2225
})
2326

2427
afterEach(() => {
2528
window.MediaSource = originalMediaSource
2629
URL.createObjectURL = originalCreateObjectURL
2730
URL.revokeObjectURL = originalRevokeObjectURL
31+
if (originalWebKitPlaybackTargetAvailabilityEvent === undefined) {
32+
delete window.WebKitPlaybackTargetAvailabilityEvent
33+
} else {
34+
window.WebKitPlaybackTargetAvailabilityEvent =
35+
originalWebKitPlaybackTargetAvailabilityEvent
36+
}
37+
})
38+
39+
test('auto keeps non-AirPlay MSE attached through media src', async () => {
40+
const media = document.createElement('video')
41+
const mse = new MSE(null, {
42+
attachMode: 'auto'
43+
})
44+
45+
mse.bindMedia(media)
46+
47+
expect(media.getAttribute('src')).toBe('blob:https://example.com/mse')
48+
expect(media.querySelectorAll('source')).toHaveLength(0)
49+
50+
await mse.unbindMedia()
51+
})
52+
53+
test('auto uses source element for AirPlay-capable WebKit MSE', async () => {
54+
window.WebKitPlaybackTargetAvailabilityEvent =
55+
function WebKitPlaybackTargetAvailabilityEvent() {}
56+
const media = document.createElement('video')
57+
media.webkitShowPlaybackTargetPicker = jest.fn()
58+
59+
const mse = new MSE(null, {
60+
attachMode: 'auto'
61+
})
62+
63+
mse.bindMedia(media)
64+
65+
const sources = Array.from(media.querySelectorAll('source'))
66+
67+
expect(media.getAttribute('src')).toBe(null)
68+
expect(media.src).toBe('')
69+
expect(sources.map(source => source.src)).toEqual([
70+
'blob:https://example.com/mse'
71+
])
72+
73+
await mse.unbindMedia()
2874
})
2975

3076
test('binds owned MSE source element without removing cast fallback source', () => {

packages/xgplayer-hls/__tests__/options.spec.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ describe('getOption', () => {
1313
expect(opts.bufferBehind).toBe(10)
1414
expect(opts.maxJumpDistance).toBe(3)
1515
expect(opts.startTime).toBe(0)
16-
expect(opts.mseAttachMode).toBe('source-element')
16+
expect(opts.mseAttachMode).toBe('auto')
1717
expect(opts.fetchOptions).toBe(undefined)
1818
expect(opts.isLive).toBe(undefined)
1919
})

packages/xgplayer-hls/src/hls/config.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,10 @@ export function getConfig(cfg) {
6565
preferMMS: false,
6666
preferMMSStreaming: false,
6767
// MSE object URL 绑定方式:
68+
// - 'auto': ManagedMediaSource 或 AirPlay-capable WebKit 使用 'source-element',其他 MediaSource 使用 'src'
6869
// - 'source-element': 通过生成的 <source> 挂载,便于 AirPlay fallback source 共存
6970
// - 'src': 通过 video.src 挂载
70-
// - 'auto': ManagedMediaSource 使用 'source-element',其他 MediaSource 使用 'src'
71-
mseAttachMode: 'source-element',
71+
mseAttachMode: 'auto',
7272
mseLowLatency: true, // mse 低延迟模式渲染 https://issues.chromium.org/issues/41161663
7373
fixerConfig: {
7474
forceFixLargeGap: false, // 强制修复音视频PTS LargeGap, PTS从0开始

packages/xgplayer-streaming-shared/src/mse.js

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,20 @@ function appendMseSource(media, mimeType, url) {
4141
return source
4242
}
4343

44-
function getAttachMode(mode, useMMS) {
44+
function isAirPlayCapableWebKit(media) {
45+
const win = media?.ownerDocument?.defaultView || (isBrowser ? window : null)
46+
return (
47+
!!media &&
48+
typeof media.webkitShowPlaybackTargetPicker === 'function' &&
49+
typeof win?.WebKitPlaybackTargetAvailabilityEvent === 'function'
50+
)
51+
}
52+
53+
function getAttachMode(mode, useMMS, media) {
4554
if (mode === 'src' || mode === 'source-element') {
4655
return mode
4756
}
48-
return useMMS ? 'source-element' : 'src'
57+
return useMMS || isAirPlayCapableWebKit(media) ? 'source-element' : 'src'
4958
}
5059

5160
/**
@@ -145,9 +154,9 @@ export class MSE {
145154
openLog: false,
146155
preferMMS: false,
147156
// MediaSource object URL 绑定方式:
157+
// - 'auto': ManagedMediaSource 或 AirPlay-capable WebKit 使用 'source-element',其他 MediaSource 使用 'src'
148158
// - 'source-element': 通过生成的 <source> 挂载,便于 AirPlay fallback source 共存
149159
// - 'src': 通过 video.src 挂载
150-
// - 'auto': ManagedMediaSource 使用 'source-element',其他 MediaSource 使用 'src'
151160
attachMode: 'auto'
152161
}
153162
}
@@ -300,7 +309,7 @@ export class MSE {
300309

301310
this._removeAttachedSource()
302311

303-
const attachMode = getAttachMode(config.attachMode, useMMS)
312+
const attachMode = getAttachMode(config.attachMode, useMMS, media)
304313

305314
if (attachMode === 'source-element') {
306315
removeSrc(media)

0 commit comments

Comments
 (0)