Send Chrome User-Agent and Referer on Viaplay CDN fetches from Android, iOS and Tizen players.
This commit is contained in:
parent
75969d87ec
commit
752ab8dc8a
8 changed files with 205 additions and 29 deletions
|
|
@ -39,6 +39,7 @@ import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE
|
||||||
import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
|
import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
|
||||||
import nl.vonas.mediacluster.tv.data.ClearKeyEntry
|
import nl.vonas.mediacluster.tv.data.ClearKeyEntry
|
||||||
import nl.vonas.mediacluster.tv.data.DrmInfo
|
import nl.vonas.mediacluster.tv.data.DrmInfo
|
||||||
|
import okhttp3.Interceptor
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
@ -190,11 +191,32 @@ fun buildTvPlayer(context: Context): ExoPlayer {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Gedeelde HTTP-client voor HLS-segmenten (connectie-hergebruik). */
|
/** Gedeelde HTTP-client voor HLS-segmenten (connectie-hergebruik). */
|
||||||
|
private const val VIAPLAY_BROWSER_UA =
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36"
|
||||||
|
|
||||||
|
private fun isViaplayCdnHost(host: String): Boolean =
|
||||||
|
host.contains("viaplay", ignoreCase = true)
|
||||||
|
|
||||||
|
/** Viaplay-CDN geeft Don't go terug op app-UA's; zelfde headers als de server-play. */
|
||||||
|
private val viaplayBrowserHeaders = Interceptor { chain ->
|
||||||
|
val req = chain.request()
|
||||||
|
if (!isViaplayCdnHost(req.url.host)) return@Interceptor chain.proceed(req)
|
||||||
|
chain.proceed(
|
||||||
|
req.newBuilder()
|
||||||
|
.header("User-Agent", VIAPLAY_BROWSER_UA)
|
||||||
|
.header("Referer", "https://viaplay.com/")
|
||||||
|
.header("Origin", "https://viaplay.com")
|
||||||
|
.header("Accept", "*/*")
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private val liveOkHttp: OkHttpClient by lazy {
|
private val liveOkHttp: OkHttpClient by lazy {
|
||||||
OkHttpClient.Builder()
|
OkHttpClient.Builder()
|
||||||
.connectTimeout(15, TimeUnit.SECONDS)
|
.connectTimeout(15, TimeUnit.SECONDS)
|
||||||
.readTimeout(30, TimeUnit.SECONDS)
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
.retryOnConnectionFailure(true)
|
.retryOnConnectionFailure(true)
|
||||||
|
.addInterceptor(viaplayBrowserHeaders)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -334,6 +356,7 @@ fun liveMediaItem(
|
||||||
url: String,
|
url: String,
|
||||||
format: String,
|
format: String,
|
||||||
drm: DrmInfo? = null,
|
drm: DrmInfo? = null,
|
||||||
|
live: Boolean = true,
|
||||||
): MediaItem {
|
): MediaItem {
|
||||||
// URL wint: ESPN/FAAB levert .m3u8 terwijl de API soms format=dash hardcodet.
|
// URL wint: ESPN/FAAB levert .m3u8 terwijl de API soms format=dash hardcodet.
|
||||||
val isHls = url.contains("m3u8", ignoreCase = true) ||
|
val isHls = url.contains("m3u8", ignoreCase = true) ||
|
||||||
|
|
@ -347,7 +370,7 @@ fun liveMediaItem(
|
||||||
else -> MimeTypes.VIDEO_MP2T
|
else -> MimeTypes.VIDEO_MP2T
|
||||||
}
|
}
|
||||||
val builder = MediaItem.Builder().setUri(url).setMimeType(mime)
|
val builder = MediaItem.Builder().setUri(url).setMimeType(mime)
|
||||||
if (isHls) {
|
if (live && isHls) {
|
||||||
builder.setLiveConfiguration(
|
builder.setLiveConfiguration(
|
||||||
MediaItem.LiveConfiguration.Builder()
|
MediaItem.LiveConfiguration.Builder()
|
||||||
.setTargetOffsetMs(2_000)
|
.setTargetOffsetMs(2_000)
|
||||||
|
|
@ -356,7 +379,7 @@ fun liveMediaItem(
|
||||||
.build(),
|
.build(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (isDash) {
|
if (live && isDash && !url.contains("/start/") && !url.contains("/stop/")) {
|
||||||
builder.setLiveConfiguration(
|
builder.setLiveConfiguration(
|
||||||
MediaItem.LiveConfiguration.Builder()
|
MediaItem.LiveConfiguration.Builder()
|
||||||
.setTargetOffsetMs(3_000)
|
.setTargetOffsetMs(3_000)
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,8 @@ fun EventPlayerScreen(
|
||||||
api: ApiClient,
|
api: ApiClient,
|
||||||
eventId: String,
|
eventId: String,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
hubPlay: Boolean = false,
|
||||||
|
productUrl: String? = null,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val playerRef = remember { AtomicReference<ExoPlayer?>(null) }
|
val playerRef = remember { AtomicReference<ExoPlayer?>(null) }
|
||||||
|
|
@ -132,7 +134,7 @@ fun EventPlayerScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(eventId) {
|
LaunchedEffect(eventId, hubPlay, productUrl) {
|
||||||
loading = true
|
loading = true
|
||||||
error = null
|
error = null
|
||||||
watchSessionId = null
|
watchSessionId = null
|
||||||
|
|
@ -142,7 +144,19 @@ fun EventPlayerScreen(
|
||||||
audioTracks = emptyList()
|
audioTracks = emptyList()
|
||||||
textTracks = emptyList()
|
textTracks = emptyList()
|
||||||
try {
|
try {
|
||||||
val play = api.scheduleEventPlay(eventId)
|
val play = when {
|
||||||
|
hubPlay && eventId.startsWith("viaplay-content:", ignoreCase = true) -> {
|
||||||
|
val guid = eventId.removePrefix("viaplay-content:").removePrefix("VIAPLAY-CONTENT:")
|
||||||
|
api.viaplayContentPlay(guid, productUrl)
|
||||||
|
}
|
||||||
|
hubPlay -> {
|
||||||
|
val contentId = Regex("""(?i)^f1tv:(\d+)$""").find(eventId.trim())
|
||||||
|
?.groupValues?.getOrNull(1)
|
||||||
|
?: eventId.trim().removePrefix("f1tv:").removePrefix("F1TV:")
|
||||||
|
api.f1tvContentPlay(contentId)
|
||||||
|
}
|
||||||
|
else -> api.scheduleEventPlay(eventId)
|
||||||
|
}
|
||||||
if (!isActive) return@LaunchedEffect
|
if (!isActive) return@LaunchedEffect
|
||||||
title = play.name
|
title = play.name
|
||||||
watchSessionId = play.watchSessionId
|
watchSessionId = play.watchSessionId
|
||||||
|
|
@ -151,14 +165,14 @@ fun EventPlayerScreen(
|
||||||
error = "Geen stream-URL beschikbaar"
|
error = "Geen stream-URL beschikbaar"
|
||||||
return@LaunchedEffect
|
return@LaunchedEffect
|
||||||
}
|
}
|
||||||
if (play.drm == null || play.drm.keys.isEmpty()) {
|
val kapWindow = url.contains("/start/") && url.contains("/stop/")
|
||||||
error = "Encryptiesleutels ontbreken — stream start binnenkort"
|
val asVod = play.live == false ||
|
||||||
return@LaunchedEffect
|
kapWindow ||
|
||||||
}
|
(hubPlay && eventId.startsWith("viaplay-content:", ignoreCase = true))
|
||||||
val item = try {
|
val item = try {
|
||||||
liveMediaItem(url, play.format, play.drm)
|
liveMediaItem(url, play.format, play.drm, live = !asVod)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
error = e.message ?: "Ongeldige DRM-sleutels"
|
error = e.message ?: "Kon mediastream niet openen"
|
||||||
return@LaunchedEffect
|
return@LaunchedEffect
|
||||||
}
|
}
|
||||||
if (!isActive) return@LaunchedEffect
|
if (!isActive) return@LaunchedEffect
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ import io.github.anilbeesetti.nextlib.media3ext.ffdecoder.NextRenderersFactory
|
||||||
import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF
|
import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF
|
||||||
import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
|
import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
|
||||||
import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
|
import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
|
||||||
|
import okhttp3.Interceptor
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
@ -199,11 +200,32 @@ fun buildTvPlayer(context: Context, audioPassthrough: Boolean = false): ExoPlaye
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Gedeelde HTTP-client voor HLS-segmenten (connectie-hergebruik). */
|
/** Gedeelde HTTP-client voor HLS-segmenten (connectie-hergebruik). */
|
||||||
|
private const val VIAPLAY_BROWSER_UA =
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36"
|
||||||
|
|
||||||
|
private fun isViaplayCdnHost(host: String): Boolean =
|
||||||
|
host.contains("viaplay", ignoreCase = true)
|
||||||
|
|
||||||
|
/** Viaplay-CDN geeft Don't go terug op app-UA's; zelfde headers als de server-play. */
|
||||||
|
private val viaplayBrowserHeaders = Interceptor { chain ->
|
||||||
|
val req = chain.request()
|
||||||
|
if (!isViaplayCdnHost(req.url.host)) return@Interceptor chain.proceed(req)
|
||||||
|
chain.proceed(
|
||||||
|
req.newBuilder()
|
||||||
|
.header("User-Agent", VIAPLAY_BROWSER_UA)
|
||||||
|
.header("Referer", "https://viaplay.com/")
|
||||||
|
.header("Origin", "https://viaplay.com")
|
||||||
|
.header("Accept", "*/*")
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private val liveOkHttp: OkHttpClient by lazy {
|
private val liveOkHttp: OkHttpClient by lazy {
|
||||||
OkHttpClient.Builder()
|
OkHttpClient.Builder()
|
||||||
.connectTimeout(15, TimeUnit.SECONDS)
|
.connectTimeout(15, TimeUnit.SECONDS)
|
||||||
.readTimeout(30, TimeUnit.SECONDS)
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
.retryOnConnectionFailure(true)
|
.retryOnConnectionFailure(true)
|
||||||
|
.addInterceptor(viaplayBrowserHeaders)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -414,7 +436,7 @@ fun liveMediaItem(
|
||||||
.build(),
|
.build(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (live && isDash) {
|
if (live && isDash && !url.contains("/start/") && !url.contains("/stop/")) {
|
||||||
builder.setLiveConfiguration(
|
builder.setLiveConfiguration(
|
||||||
MediaItem.LiveConfiguration.Builder()
|
MediaItem.LiveConfiguration.Builder()
|
||||||
.setTargetOffsetMs(3_000)
|
.setTargetOffsetMs(3_000)
|
||||||
|
|
|
||||||
|
|
@ -170,8 +170,11 @@ fun EventPlayerScreen(
|
||||||
return@LaunchedEffect
|
return@LaunchedEffect
|
||||||
}
|
}
|
||||||
// ClearKey optioneel: F1TV plain HLS heeft drm=null.
|
// ClearKey optioneel: F1TV plain HLS heeft drm=null.
|
||||||
// VOD/clips: geen live-config — anders seek naar “live edge” = eindscherm.
|
// Catch-up/VOD: geen live-config — anders seek naar live-edge.
|
||||||
val asVod = hubPlay && eventId.startsWith("viaplay-content:", ignoreCase = true)
|
val kapWindow = url.contains("/start/") && url.contains("/stop/")
|
||||||
|
val asVod = play.live == false ||
|
||||||
|
kapWindow ||
|
||||||
|
(hubPlay && eventId.startsWith("viaplay-content:", ignoreCase = true))
|
||||||
val item = try {
|
val item = try {
|
||||||
liveMediaItem(url, play.format, play.drm, live = !asVod)
|
liveMediaItem(url, play.format, play.drm, live = !asVod)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|
@ -350,6 +353,7 @@ fun EventPlayerScreen(
|
||||||
factory = { ctx ->
|
factory = { ctx ->
|
||||||
PlayerView(ctx).apply {
|
PlayerView(ctx).apply {
|
||||||
useController = false
|
useController = false
|
||||||
|
keepScreenOn = true
|
||||||
layoutParams = ViewGroup.LayoutParams(
|
layoutParams = ViewGroup.LayoutParams(
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
|
|
||||||
|
|
@ -1047,7 +1047,17 @@ final class ClearKeyDashProxy {
|
||||||
var req = URLRequest(url: url)
|
var req = URLRequest(url: url)
|
||||||
req.timeoutInterval = 15
|
req.timeoutInterval = 15
|
||||||
req.cachePolicy = .reloadIgnoringLocalCacheData
|
req.cachePolicy = .reloadIgnoringLocalCacheData
|
||||||
req.setValue("VMC-iOS-ClearKey/0.5.5", forHTTPHeaderField: "User-Agent")
|
let host = url.host?.lowercased() ?? ""
|
||||||
|
if host.contains("viaplay") {
|
||||||
|
req.setValue(
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
|
||||||
|
forHTTPHeaderField: "User-Agent"
|
||||||
|
)
|
||||||
|
req.setValue("https://viaplay.com/", forHTTPHeaderField: "Referer")
|
||||||
|
req.setValue("https://viaplay.com", forHTTPHeaderField: "Origin")
|
||||||
|
} else {
|
||||||
|
req.setValue("VMC-iOS-ClearKey/0.5.5", forHTTPHeaderField: "User-Agent")
|
||||||
|
}
|
||||||
req.setValue("bytes", forHTTPHeaderField: "Accept-Ranges")
|
req.setValue("bytes", forHTTPHeaderField: "Accept-Ranges")
|
||||||
let (data, resp) = try await urlSession.data(for: req)
|
let (data, resp) = try await urlSession.data(for: req)
|
||||||
if let http = resp as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
|
if let http = resp as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,8 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
private var knownVodDurationMs: Int64 = 0
|
private var knownVodDurationMs: Int64 = 0
|
||||||
/// Playlist is geknipt vanaf dit punt (VLC-tijd 0 = deze offset).
|
/// Playlist is geknipt vanaf dit punt (VLC-tijd 0 = deze offset).
|
||||||
private var seekOriginMs: Int64 = 0
|
private var seekOriginMs: Int64 = 0
|
||||||
|
/// Taal/naam van het gekozen audiospoor — na seek-reload terugzetten (VLC pakt anders weer Spaans).
|
||||||
|
private var restoreAudioHint: String = ""
|
||||||
|
|
||||||
override init() {
|
override init() {
|
||||||
// Shared library = alle demux/access plugins (DASH adaptive in VLC 4).
|
// Shared library = alle demux/access plugins (DASH adaptive in VLC 4).
|
||||||
|
|
@ -93,6 +95,11 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
drawableHost.attach(to: view)
|
drawableHost.attach(to: view)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// VOD: PiP-host zetten vóór VLC een gewone vout opent (anders komt pictureInPictureReady nooit).
|
||||||
|
if wantsPipDrawable, mediaPlayer.media == nil, view.bounds.width > 1 {
|
||||||
|
applyDrawable(force: true)
|
||||||
|
return
|
||||||
|
}
|
||||||
if mediaPlayer.drawable as AnyObject? === view {
|
if mediaPlayer.drawable as AnyObject? === view {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -118,27 +125,31 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
drawableHost.attach(to: view)
|
drawableHost.attach(to: view)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if wantsPipDrawable, mediaPlayer.media == nil {
|
||||||
|
applyDrawable(force: true)
|
||||||
|
return
|
||||||
|
}
|
||||||
if mediaPlayer.drawable == nil {
|
if mediaPlayer.drawable == nil {
|
||||||
applyDrawable(force: true)
|
applyDrawable(force: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var wantsPipDrawable: Bool { !liveMode && !clearKeyProxyActive && !stopping }
|
||||||
|
|
||||||
/// Live/ClearKey → plain UIView. VOD → VLCPipDrawableHost (PiP vanaf start).
|
/// Live/ClearKey → plain UIView. VOD → VLCPipDrawableHost (PiP vanaf start).
|
||||||
private func applyDrawable(force: Bool = false) {
|
private func applyDrawable(force: Bool = false) {
|
||||||
guard !stopping else { return }
|
guard !stopping else { return }
|
||||||
guard let view = plainVideoView else { return }
|
guard let view = plainVideoView else { return }
|
||||||
// Nooit binden op 0×0 — VLC opent dan vout zonder beeld (audio-only).
|
// Nooit binden op 0×0 — VLC opent dan vout zonder beeld (audio-only).
|
||||||
guard view.bounds.width > 1, view.bounds.height > 1 else { return }
|
guard view.bounds.width > 1, view.bounds.height > 1 else { return }
|
||||||
let wantPipHost = !liveMode && !clearKeyProxyActive
|
let wantPipHost = wantsPipDrawable
|
||||||
usePipDrawable = wantPipHost
|
usePipDrawable = wantPipHost
|
||||||
if wantPipHost {
|
if wantPipHost {
|
||||||
drawableHost.attach(to: view)
|
drawableHost.attach(to: view)
|
||||||
if force || mediaPlayer.drawable as AnyObject? !== drawableHost {
|
if force || mediaPlayer.drawable as AnyObject? !== drawableHost {
|
||||||
mediaPlayer.drawable = drawableHost
|
mediaPlayer.drawable = drawableHost
|
||||||
}
|
}
|
||||||
if pipWindow != nil {
|
pipPossible = AVPictureInPictureController.isPictureInPictureSupported()
|
||||||
pipPossible = AVPictureInPictureController.isPictureInPictureSupported()
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// ClearKey/live: plain UIView. Nooit detach/rebind als al gezet tijdens play.
|
// ClearKey/live: plain UIView. Nooit detach/rebind als al gezet tijdens play.
|
||||||
if mediaPlayer.drawable as AnyObject? === view, !force { return }
|
if mediaPlayer.drawable as AnyObject? === view, !force { return }
|
||||||
|
|
@ -188,9 +199,10 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
else { startPictureInPicture() }
|
else { startPictureInPicture() }
|
||||||
}
|
}
|
||||||
|
|
||||||
func play(url: URL, startMs: Int64 = 0, subtitleFileURL: URL? = nil) {
|
func play(url: URL, startMs: Int64 = 0, subtitleFileURL: URL? = nil) async {
|
||||||
// Annuleer pending finishStop van eerdere/spurious onDisappear.
|
// Annuleer pending finishStop van eerdere/spurious onDisappear.
|
||||||
stopGeneration &+= 1
|
stopGeneration &+= 1
|
||||||
|
let playGen = stopGeneration
|
||||||
stopping = false
|
stopping = false
|
||||||
hasError = false
|
hasError = false
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
|
@ -199,12 +211,18 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
audioTracks = []
|
audioTracks = []
|
||||||
canSeek = false
|
canSeek = false
|
||||||
didApplyPreferredAudio = false
|
didApplyPreferredAudio = false
|
||||||
|
restoreAudioHint = ""
|
||||||
knownVodDurationMs = 0
|
knownVodDurationMs = 0
|
||||||
seekOriginMs = 0
|
seekOriginMs = 0
|
||||||
liveMode = false
|
liveMode = false
|
||||||
clearKeyProxyActive = false
|
clearKeyProxyActive = false
|
||||||
plainVideoView?.layoutIfNeeded()
|
await waitForSurface(timeout: 2.0)
|
||||||
applyDrawable(force: true)
|
guard stopGeneration == playGen, !stopping else { return }
|
||||||
|
await MainActor.run {
|
||||||
|
plainVideoView?.layoutIfNeeded()
|
||||||
|
applyDrawable(force: true)
|
||||||
|
}
|
||||||
|
guard stopGeneration == playGen, !stopping else { return }
|
||||||
pipPossible = AVPictureInPictureController.isPictureInPictureSupported()
|
pipPossible = AVPictureInPictureController.isPictureInPictureSupported()
|
||||||
guard let media = VLCMedia(url: url) else {
|
guard let media = VLCMedia(url: url) else {
|
||||||
hasError = true
|
hasError = true
|
||||||
|
|
@ -221,6 +239,9 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
media.addOption(":freetype-rel-fontsize=\(Self.subtitleRelFontSize)")
|
media.addOption(":freetype-rel-fontsize=\(Self.subtitleRelFontSize)")
|
||||||
media.addOption(":freetype-bold=1")
|
media.addOption(":freetype-bold=1")
|
||||||
media.addOption(":sub-margin=0")
|
media.addOption(":sub-margin=0")
|
||||||
|
if mediaPlayer.drawable as AnyObject? !== drawableHost {
|
||||||
|
applyDrawable(force: true)
|
||||||
|
}
|
||||||
mediaPlayer.media = media
|
mediaPlayer.media = media
|
||||||
_ = VLCMediaParser.shared().queue(
|
_ = VLCMediaParser.shared().queue(
|
||||||
media,
|
media,
|
||||||
|
|
@ -267,6 +288,7 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
liveStartedAt = Date()
|
liveStartedAt = Date()
|
||||||
canSeek = false
|
canSeek = false
|
||||||
didApplyPreferredAudio = false
|
didApplyPreferredAudio = false
|
||||||
|
restoreAudioHint = ""
|
||||||
knownVodDurationMs = 0
|
knownVodDurationMs = 0
|
||||||
seekOriginMs = 0
|
seekOriginMs = 0
|
||||||
liveMode = true
|
liveMode = true
|
||||||
|
|
@ -436,8 +458,13 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
media.addOption(":http-reconnect")
|
media.addOption(":http-reconnect")
|
||||||
media.addOption(":avcodec-hw=any")
|
media.addOption(":avcodec-hw=any")
|
||||||
media.addOption(":freetype-rel-fontsize=\(subtitleRelFontSize)")
|
media.addOption(":freetype-rel-fontsize=\(subtitleRelFontSize)")
|
||||||
// IPTV/CDN's weigeren soms exotic UA's.
|
let viaplay = url.host?.localizedCaseInsensitiveContains("viaplay") == true
|
||||||
media.addOption(":http-user-agent=VLC/3.0.21 LibVLC/3.0.21")
|
if viaplay && !viaLocalProxy {
|
||||||
|
media.addOption(":http-user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36")
|
||||||
|
media.addOption(":http-referrer=https://viaplay.com/")
|
||||||
|
} else {
|
||||||
|
media.addOption(":http-user-agent=VLC/3.0.21 LibVLC/3.0.21")
|
||||||
|
}
|
||||||
// VLC 4: unified adaptive demux (DASH+HLS). Forceer GEEN :demux=dash (VLC3-module).
|
// VLC 4: unified adaptive demux (DASH+HLS). Forceer GEEN :demux=dash (VLC3-module).
|
||||||
media.addOption(":adaptive-logic=highest")
|
media.addOption(":adaptive-logic=highest")
|
||||||
if !viaLocalProxy {
|
if !viaLocalProxy {
|
||||||
|
|
@ -472,6 +499,8 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
if let url = comps?.url {
|
if let url = comps?.url {
|
||||||
position = knownVodDurationMs > 0 ? Float(Double(clamped) / Double(knownVodDurationMs)) : 0
|
position = knownVodDurationMs > 0 ? Float(Double(clamped) / Double(knownVodDurationMs)) : 0
|
||||||
timeText = Self.format(ms: Int32(min(clamped, Int64(Int32.max))))
|
timeText = Self.format(ms: Int32(min(clamped, Int64(Int32.max))))
|
||||||
|
captureAudioRestoreHint()
|
||||||
|
didApplyPreferredAudio = false
|
||||||
startLiveMedia(url: url, format: livePlayFormat)
|
startLiveMedia(url: url, format: livePlayFormat)
|
||||||
invalidatePipPlaybackState()
|
invalidatePipPlaybackState()
|
||||||
return
|
return
|
||||||
|
|
@ -516,6 +545,7 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
if let track = mediaPlayer.audioTracks.first(where: { $0.trackId == id }) {
|
if let track = mediaPlayer.audioTracks.first(where: { $0.trackId == id }) {
|
||||||
track.isSelectedExclusively = true
|
track.isSelectedExclusively = true
|
||||||
currentAudioId = id
|
currentAudioId = id
|
||||||
|
restoreAudioHint = Self.audioHint(language: track.language, name: track.trackName, description: track.trackDescription)
|
||||||
}
|
}
|
||||||
invalidatePipPlaybackState()
|
invalidatePipPlaybackState()
|
||||||
}
|
}
|
||||||
|
|
@ -592,10 +622,36 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
applyPreferredAudioIfNeeded()
|
applyPreferredAudioIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func captureAudioRestoreHint() {
|
||||||
|
if let track = mediaPlayer.audioTracks.first(where: { $0.isSelected || $0.trackId == currentAudioId }) {
|
||||||
|
restoreAudioHint = Self.audioHint(language: track.language, name: track.trackName, description: track.trackDescription)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if restoreAudioHint.isEmpty, let name = audioTracks.first(where: { $0.id == currentAudioId })?.name {
|
||||||
|
restoreAudioHint = name.lowercased()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func applyPreferredAudioIfNeeded() {
|
private func applyPreferredAudioIfNeeded() {
|
||||||
guard !didApplyPreferredAudio else { return }
|
|
||||||
let list = mediaPlayer.audioTracks
|
let list = mediaPlayer.audioTracks
|
||||||
guard !list.isEmpty else { return }
|
guard !list.isEmpty else { return }
|
||||||
|
if !restoreAudioHint.isEmpty {
|
||||||
|
let hint = restoreAudioHint
|
||||||
|
if let match = list.max(by: {
|
||||||
|
Self.audioHintScore(language: $0.language, name: $0.trackName, description: $0.trackDescription, hint: hint)
|
||||||
|
< Self.audioHintScore(language: $1.language, name: $1.trackName, description: $1.trackDescription, hint: hint)
|
||||||
|
}),
|
||||||
|
Self.audioHintScore(language: match.language, name: match.trackName, description: match.trackDescription, hint: hint) > 0
|
||||||
|
{
|
||||||
|
if !match.isSelected {
|
||||||
|
match.isSelectedExclusively = true
|
||||||
|
}
|
||||||
|
currentAudioId = match.trackId
|
||||||
|
didApplyPreferredAudio = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard !didApplyPreferredAudio else { return }
|
||||||
didApplyPreferredAudio = true
|
didApplyPreferredAudio = true
|
||||||
let ranked = list.enumerated().map { i, t in
|
let ranked = list.enumerated().map { i, t in
|
||||||
(track: t, score: Self.audioPreferenceScore(language: t.language, name: t.trackName, description: t.trackDescription, index: i))
|
(track: t, score: Self.audioPreferenceScore(language: t.language, name: t.trackName, description: t.trackDescription, index: i))
|
||||||
|
|
@ -605,6 +661,26 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
best.track.isSelectedExclusively = true
|
best.track.isSelectedExclusively = true
|
||||||
}
|
}
|
||||||
currentAudioId = best.track.trackId
|
currentAudioId = best.track.trackId
|
||||||
|
restoreAudioHint = Self.audioHint(language: best.track.language, name: best.track.trackName, description: best.track.trackDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func audioHint(language: String?, name: String?, description: String?) -> String {
|
||||||
|
[language, name, description]
|
||||||
|
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
.joined(separator: " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func audioHintScore(language: String?, name: String?, description: String?, hint: String) -> Int {
|
||||||
|
let blob = audioHint(language: language, name: name, description: description)
|
||||||
|
if blob.isEmpty || hint.isEmpty { return 0 }
|
||||||
|
if blob == hint { return 100 }
|
||||||
|
let lang = (language ?? "").lowercased()
|
||||||
|
if !lang.isEmpty, hint.contains(lang) { return 80 }
|
||||||
|
let trackName = (name ?? "").lowercased()
|
||||||
|
if !trackName.isEmpty, hint.contains(trackName) { return 70 }
|
||||||
|
let tokens = hint.split(whereSeparator: { !$0.isLetter }).map(String.init).filter { $0.count >= 2 }
|
||||||
|
return tokens.contains(where: { blob.contains($0) }) ? 40 : 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/// NL eerst, anders Engels. Geen match → 0 (niet forceren).
|
/// NL eerst, anders Engels. Geen match → 0 (niet forceren).
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,13 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
private var lengthMs: Int32 = 0
|
private var lengthMs: Int32 = 0
|
||||||
private var tick: AnyCancellable?
|
private var tick: AnyCancellable?
|
||||||
|
|
||||||
|
private static func applyCdnHeaders(to media: VLCMedia, url: URL) {
|
||||||
|
if url.host?.localizedCaseInsensitiveContains("viaplay") == true {
|
||||||
|
media.addOption(":http-user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36")
|
||||||
|
media.addOption(":http-referrer=https://viaplay.com/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override init() {
|
override init() {
|
||||||
super.init()
|
super.init()
|
||||||
mediaPlayer.delegate = self
|
mediaPlayer.delegate = self
|
||||||
|
|
@ -44,6 +51,7 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
media.addOption(":sub-track=0")
|
media.addOption(":sub-track=0")
|
||||||
media.addOption(":freetype-rel-fontsize=\(Self.subtitleRelFontSize)")
|
media.addOption(":freetype-rel-fontsize=\(Self.subtitleRelFontSize)")
|
||||||
media.addOption(":freetype-bold=1")
|
media.addOption(":freetype-bold=1")
|
||||||
|
Self.applyCdnHeaders(to: media, url: url)
|
||||||
mediaPlayer.media = media
|
mediaPlayer.media = media
|
||||||
media.parse()
|
media.parse()
|
||||||
mediaPlayer.play()
|
mediaPlayer.play()
|
||||||
|
|
@ -75,6 +83,7 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
media.addOption(":http-reconnect")
|
media.addOption(":http-reconnect")
|
||||||
media.addOption(":live-caching=1000")
|
media.addOption(":live-caching=1000")
|
||||||
media.addOption(":freetype-rel-fontsize=\(Self.subtitleRelFontSize)")
|
media.addOption(":freetype-rel-fontsize=\(Self.subtitleRelFontSize)")
|
||||||
|
Self.applyCdnHeaders(to: media, url: url)
|
||||||
mediaPlayer.media = media
|
mediaPlayer.media = media
|
||||||
media.parse()
|
media.parse()
|
||||||
mediaPlayer.play()
|
mediaPlayer.play()
|
||||||
|
|
@ -87,6 +96,7 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
fb.addOption(":network-caching=1200")
|
fb.addOption(":network-caching=1200")
|
||||||
fb.addOption(":http-reconnect")
|
fb.addOption(":http-reconnect")
|
||||||
fb.addOption(":freetype-rel-fontsize=\(Self.subtitleRelFontSize)")
|
fb.addOption(":freetype-rel-fontsize=\(Self.subtitleRelFontSize)")
|
||||||
|
Self.applyCdnHeaders(to: fb, url: fallback)
|
||||||
self.mediaPlayer.media = fb
|
self.mediaPlayer.media = fb
|
||||||
fb.parse()
|
fb.parse()
|
||||||
self.mediaPlayer.play()
|
self.mediaPlayer.play()
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
var lastAvSubText = "";
|
var lastAvSubText = "";
|
||||||
var playSession = 0;
|
var playSession = 0;
|
||||||
var fallbackOnce = false;
|
var fallbackOnce = false;
|
||||||
|
var pendingStartMs = 0;
|
||||||
|
|
||||||
function $(id) {
|
function $(id) {
|
||||||
return document.getElementById(id);
|
return document.getElementById(id);
|
||||||
|
|
@ -228,9 +229,20 @@
|
||||||
setSubOverlay("");
|
setSubOverlay("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyPendingStart() {
|
||||||
|
var ms = pendingStartMs;
|
||||||
|
if (!ms || ms < 5000) return;
|
||||||
|
pendingStartMs = 0;
|
||||||
|
try {
|
||||||
|
if (usingAvplay) webapis.avplay.seekTo(ms);
|
||||||
|
else if (htmlVideo) htmlVideo.currentTime = ms / 1000;
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
function stop() {
|
function stop() {
|
||||||
playSession += 1;
|
playSession += 1;
|
||||||
fallbackOnce = false;
|
fallbackOnce = false;
|
||||||
|
pendingStartMs = 0;
|
||||||
stopTick();
|
stopTick();
|
||||||
setError(null);
|
setError(null);
|
||||||
clearExternal();
|
clearExternal();
|
||||||
|
|
@ -275,6 +287,7 @@
|
||||||
if (session !== playSession) return;
|
if (session !== playSession) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
setPlayingIcon(true);
|
setPlayingIcon(true);
|
||||||
|
applyPendingStart();
|
||||||
};
|
};
|
||||||
htmlVideo.onended = function () {
|
htmlVideo.onended = function () {
|
||||||
if (session !== playSession) return;
|
if (session !== playSession) return;
|
||||||
|
|
@ -368,10 +381,12 @@
|
||||||
webapis.avplay.setDisplayRect(0, 0, 1920, 1080);
|
webapis.avplay.setDisplayRect(0, 0, 1920, 1080);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
webapis.avplay.setStreamingProperty(
|
var ua = "Mozilla/5.0 (SMART-TV; Linux; Tizen 6.0) AppleWebKit/537.36";
|
||||||
"USER_AGENT",
|
if (/viaplay/i.test(url)) {
|
||||||
"Mozilla/5.0 (SMART-TV; Linux; Tizen 6.0) AppleWebKit/537.36"
|
ua =
|
||||||
);
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36";
|
||||||
|
}
|
||||||
|
webapis.avplay.setStreamingProperty("USER_AGENT", ua);
|
||||||
} catch (e2) {}
|
} catch (e2) {}
|
||||||
|
|
||||||
webapis.avplay.setListener({
|
webapis.avplay.setListener({
|
||||||
|
|
@ -417,6 +432,7 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
webapis.avplay.play();
|
webapis.avplay.play();
|
||||||
|
applyPendingStart();
|
||||||
startTick();
|
startTick();
|
||||||
setError(null);
|
setError(null);
|
||||||
setPlayingIcon(true);
|
setPlayingIcon(true);
|
||||||
|
|
@ -437,10 +453,11 @@
|
||||||
}, 80);
|
}, 80);
|
||||||
}
|
}
|
||||||
|
|
||||||
function playUrl(url, title) {
|
function playUrl(url, title, startMs) {
|
||||||
stop();
|
stop();
|
||||||
playSession += 1;
|
playSession += 1;
|
||||||
fallbackOnce = false;
|
fallbackOnce = false;
|
||||||
|
pendingStartMs = startMs > 5000 ? startMs : 0;
|
||||||
currentUrl = url;
|
currentUrl = url;
|
||||||
currentTitle = title || "";
|
currentTitle = title || "";
|
||||||
$("player-title").textContent = currentTitle;
|
$("player-title").textContent = currentTitle;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue