Send Chrome User-Agent and Referer on Viaplay CDN fetches from Android, iOS and Tizen players.

This commit is contained in:
Jos Vooges | STH 2026-09-28 20:30:33 +02:00
parent 75969d87ec
commit 752ab8dc8a
8 changed files with 205 additions and 29 deletions

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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,

View file

@ -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) {

View file

@ -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).

View file

@ -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()

View file

@ -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;