Add Safari DRM video playback via server-side decryption
Safari/iOS browsers lack Widevine EME support, so DRM videos couldn't play. This adds a server-side decrypt-and-stream pipeline that reuses the existing downloadDrmMedia() code to decrypt videos on demand, cache them, and serve plain MP4s with Range support for native <video> playback. - server/drm-stream.js: new router with decrypt, status, and serve endpoints - client/src/components/ServerDrmVideo.jsx: decrypt→poll→play lifecycle component - DrmVideo.jsx: Widevine detection, falls back to ServerDrmVideo for Safari - MediaGrid.jsx: passes raw mpdUrl through to DrmVideo Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,44 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import shaka from 'shaka-player'
|
||||
import ServerDrmVideo from './ServerDrmVideo'
|
||||
|
||||
export default function DrmVideo({ dashSrc, cookies, mediaId, entityId, entityType, poster, className = '' }) {
|
||||
function isWidevineSupported() {
|
||||
const ua = navigator.userAgent
|
||||
// Safari (desktop & mobile) and all iOS browsers (WebKit-only) lack Widevine
|
||||
if (/Safari/i.test(ua) && !/Chrome/i.test(ua)) return false
|
||||
if (/iPad|iPhone|iPod/i.test(ua)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
export default function DrmVideo({ dashSrc, mpdUrl, cookies, mediaId, entityId, entityType, poster, className = '' }) {
|
||||
if (!isWidevineSupported()) {
|
||||
return (
|
||||
<ServerDrmVideo
|
||||
mpdUrl={mpdUrl}
|
||||
cookies={cookies}
|
||||
mediaId={mediaId}
|
||||
entityType={entityType}
|
||||
entityId={entityId}
|
||||
poster={poster}
|
||||
className={className}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ShakaPlayer
|
||||
dashSrc={dashSrc}
|
||||
cookies={cookies}
|
||||
mediaId={mediaId}
|
||||
entityId={entityId}
|
||||
entityType={entityType}
|
||||
poster={poster}
|
||||
className={className}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ShakaPlayer({ dashSrc, cookies, mediaId, entityId, entityType, poster, className = '' }) {
|
||||
const videoRef = useRef(null)
|
||||
const playerRef = useRef(null)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
@@ -52,6 +52,7 @@ function getDrmDashInfo(item, { entityId, entityType } = {}) {
|
||||
|
||||
return {
|
||||
dashSrc: `/api/drm-hls?url=${encodeURIComponent(drm.manifest.dash)}&cp=${encodeURIComponent(sig['CloudFront-Policy'])}&cs=${encodeURIComponent(sig['CloudFront-Signature'])}&ck=${encodeURIComponent(sig['CloudFront-Key-Pair-Id'])}`,
|
||||
mpdUrl: drm.manifest.dash,
|
||||
cookies: {
|
||||
cp: sig['CloudFront-Policy'],
|
||||
cs: sig['CloudFront-Signature'],
|
||||
@@ -128,6 +129,7 @@ function MediaItem({ item, className = '', entityId, entityType }) {
|
||||
>
|
||||
<DrmVideo
|
||||
dashSrc={drmInfo.dashSrc}
|
||||
mpdUrl={drmInfo.mpdUrl}
|
||||
cookies={drmInfo.cookies}
|
||||
mediaId={drmInfo.mediaId}
|
||||
entityId={drmInfo.entityId}
|
||||
|
||||
111
client/src/components/ServerDrmVideo.jsx
Normal file
111
client/src/components/ServerDrmVideo.jsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
|
||||
export default function ServerDrmVideo({ mpdUrl, cookies, mediaId, entityType, entityId, poster, className = '' }) {
|
||||
const [state, setState] = useState('idle') // idle | processing | ready | error
|
||||
const [error, setError] = useState(null)
|
||||
const pollRef = useRef(null)
|
||||
const mountedRef = useRef(true)
|
||||
|
||||
const triggerDecrypt = useCallback(async () => {
|
||||
setState('processing')
|
||||
try {
|
||||
const res = await fetch('/api/drm-stream/decrypt', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mpdUrl, cfCookies: cookies, mediaId, entityType, entityId }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!mountedRef.current) return
|
||||
|
||||
if (!res.ok) {
|
||||
setState('error')
|
||||
setError(data.error || `Server error ${res.status}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (data.status === 'ready') {
|
||||
setState('ready')
|
||||
}
|
||||
// else 'processing' — polling will pick it up
|
||||
} catch (err) {
|
||||
if (!mountedRef.current) return
|
||||
setState('error')
|
||||
setError(err.message)
|
||||
}
|
||||
}, [mpdUrl, cookies, mediaId, entityType, entityId])
|
||||
|
||||
// Start decrypt on mount
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
triggerDecrypt()
|
||||
return () => { mountedRef.current = false }
|
||||
}, [triggerDecrypt])
|
||||
|
||||
// Poll for status while processing
|
||||
useEffect(() => {
|
||||
if (state !== 'processing') return
|
||||
|
||||
pollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/drm-stream/${mediaId}/status`)
|
||||
const data = await res.json()
|
||||
if (!mountedRef.current) return
|
||||
|
||||
if (data.status === 'ready') {
|
||||
setState('ready')
|
||||
} else if (data.status === 'error') {
|
||||
setState('error')
|
||||
setError(data.error || 'Decryption failed')
|
||||
} else if (data.status === 'none') {
|
||||
// Server restarted, re-trigger
|
||||
triggerDecrypt()
|
||||
}
|
||||
} catch {}
|
||||
}, 2000)
|
||||
|
||||
return () => {
|
||||
if (pollRef.current) clearInterval(pollRef.current)
|
||||
}
|
||||
}, [state, mediaId, triggerDecrypt])
|
||||
|
||||
if (state === 'ready') {
|
||||
return (
|
||||
<video
|
||||
src={`/api/drm-stream/${mediaId}`}
|
||||
controls
|
||||
preload="metadata"
|
||||
playsInline
|
||||
className={className}
|
||||
poster={poster}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (state === 'error') {
|
||||
return (
|
||||
<div className={`relative bg-[#1a1a1a] rounded-lg overflow-hidden flex items-center justify-center ${className}`}>
|
||||
{poster && <img src={poster} alt="" className="w-full h-full object-cover opacity-30" />}
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<svg className="w-10 h-10 text-gray-500 mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
|
||||
</svg>
|
||||
<span className="text-gray-400 text-xs text-center px-4">{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Processing / idle — show spinner
|
||||
return (
|
||||
<div className={`relative bg-[#1a1a1a] rounded-lg overflow-hidden flex items-center justify-center ${className}`}>
|
||||
{poster && <img src={poster} alt="" className="w-full h-full object-cover opacity-30" />}
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<svg className="w-8 h-8 text-gray-400 animate-spin mb-2" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<span className="text-gray-400 text-xs">Decrypting video...</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user