iOS 0.5.47: seek bar on F1 VOD, hide controls after tracks sheet, prefer NL then EN audio.

This commit is contained in:
Jos Vooges | STH 2026-09-28 00:50:49 +02:00
parent 42c856eb47
commit ef566e2756
5 changed files with 142 additions and 11 deletions

View file

@ -345,7 +345,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 59;
CURRENT_PROJECT_VERSION = 60;
DEVELOPMENT_TEAM = X25NYX3K49;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@ -360,7 +360,7 @@
"$(inherited)",
"@executable_path/Frameworks"
);
MARKETING_VERSION = 0.5.46;
MARKETING_VERSION = 0.5.47;
OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
PRODUCT_NAME = "$(TARGET_NAME)";
@ -378,7 +378,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 59;
CURRENT_PROJECT_VERSION = 60;
DEVELOPMENT_TEAM = X25NYX3K49;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@ -393,7 +393,7 @@
"$(inherited)",
"@executable_path/Frameworks"
);
MARKETING_VERSION = 0.5.46;
MARKETING_VERSION = 0.5.47;
OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
PRODUCT_NAME = "$(TARGET_NAME)";

View file

@ -792,6 +792,7 @@ final class ClearKeyDashProxy {
/// Master: strip DRM keys, proxy alle variant/audio URI's — audio→ck=a, video→ck=v.
private func rewriteHlsMaster(_ lines: [String], playlistURL: URL) -> String {
var out: [String] = []
var audioLines: [String] = []
var i = 0
while i < lines.count {
let line = lines[i]
@ -818,7 +819,12 @@ final class ClearKeyDashProxy {
}
if trimmed.hasPrefix("#EXT-X-MEDIA:") {
let lane: CryptoLane = trimmed.contains("TYPE=AUDIO") ? .audio : .shared
out.append(rewriteHlsTagURIs(line, base: playlistURL, lane: lane))
let rewritten = rewriteHlsTagURIs(line, base: playlistURL, lane: lane)
if lane == .audio {
audioLines.append(rewritten)
} else {
out.append(rewritten)
}
i += 1
continue
}
@ -832,9 +838,78 @@ final class ClearKeyDashProxy {
}
i += 1
}
if !audioLines.isEmpty {
let ranked = Self.rankHlsAudioLines(audioLines)
if let idx = out.firstIndex(where: { $0.hasPrefix("#EXT-X-STREAM-INF") }) {
out.insert(contentsOf: ranked, at: idx)
} else {
out.append(contentsOf: ranked)
}
}
return out.joined(separator: "\n")
}
/// NL → Engels → rest. Winnaar krijgt DEFAULT=YES zodat VLC niet op Spaans start.
private static func rankHlsAudioLines(_ lines: [String]) -> [String] {
let scored = lines.enumerated().map { i, line in (line, hlsAudioScore(line) * 100 - i) }
let sorted = scored.sorted { $0.1 > $1.1 }.map(\.0)
guard let first = sorted.first else { return lines }
var out: [String] = []
for (i, line) in sorted.enumerated() {
out.append(hlsSetAttr(hlsSetAttr(line, key: "DEFAULT", value: i == 0 ? "YES" : "NO"), key: "AUTOSELECT", value: "YES"))
}
let lang = hlsMediaAttr(first, "LANGUAGE") ?? "-"
let name = hlsMediaAttr(first, "NAME") ?? "-"
proxyLog.info("hls audio default lang=\(lang, privacy: .public) name=\(name, privacy: .public) count=\(sorted.count, privacy: .public)")
return out
}
private static func hlsAudioScore(_ line: String) -> Int {
let lang = (hlsMediaAttr(line, "LANGUAGE") ?? "").lowercased()
let name = (hlsMediaAttr(line, "NAME") ?? "").lowercased()
if ["nl", "dut", "nld"].contains(lang) || name.contains("nederlands") || name.contains("dutch") {
return 100
}
if ["en", "eng"].contains(lang) || name.contains("english") || name.contains("engels") {
return 50
}
if name.contains("international") { return 35 }
return 0
}
private static func hlsMediaAttr(_ line: String, _ key: String) -> String? {
let pattern = #"\#(key)=(?:"([^"]*)"|([^,]*))"#
guard let re = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) else { return nil }
let ns = line as NSString
guard let m = re.firstMatch(in: line, options: [], range: NSRange(location: 0, length: ns.length)) else { return nil }
if m.numberOfRanges > 1, m.range(at: 1).location != NSNotFound {
let v = ns.substring(with: m.range(at: 1)).trimmingCharacters(in: .whitespaces)
return v.isEmpty ? nil : v
}
if m.numberOfRanges > 2, m.range(at: 2).location != NSNotFound {
let v = ns.substring(with: m.range(at: 2)).trimmingCharacters(in: .whitespaces)
return v.isEmpty ? nil : v
}
return nil
}
private static func hlsSetAttr(_ line: String, key: String, value: String) -> String {
let pattern = #"\#(key)=(?:"[^"]*"|[^,]*)"#
let replacement = "\(key)=\"\(value)\""
guard let re = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) else {
return line + ",\(replacement)"
}
let ns = line as NSString
let range = NSRange(location: 0, length: ns.length)
if re.firstMatch(in: line, options: [], range: range) != nil {
return re.stringByReplacingMatches(in: line, options: [], range: range, withTemplate: replacement)
}
if line.hasPrefix("#EXT-X-MEDIA:") {
return "#EXT-X-MEDIA:\(replacement)," + String(line.dropFirst("#EXT-X-MEDIA:".count))
}
return line + ",\(replacement)"
}
private func rewriteHlsTagURIs(_ line: String, base: URL, lane: CryptoLane = .shared) -> String {
guard let regex = try? NSRegularExpression(pattern: #"URI="([^"]+)""#, options: []) else {
return line
@ -1093,7 +1168,7 @@ final class ClearKeyDashProxy {
private static func preferredAudioOrder(_ audios: [DashHlsRep]) -> [DashHlsRep] {
let aac = audios.filter(\.isAac)
let pool = aac.isEmpty ? audios : aac
let preferred = Locale.current.language.languageCode?.identifier.lowercased() ?? "nl"
let preferred = "nl"
return pool.sorted { a, b in
let aScore = Self.audioLocaleScore(a, preferred: preferred)
let bScore = Self.audioLocaleScore(b, preferred: preferred)

View file

@ -95,6 +95,8 @@ struct PlayerScreen: View {
private var isLive: Bool { request.isLive }
private var useClearKeyAV: Bool { request.isLive && request.needsClearKey && clearKeyPreferAV }
/// F1-replay e.d. komen via live-request binnen, maar hebben een echte duur.
private var showSeekChrome: Bool { !isLive || (!useClearKeyAV && vlc.canSeek) }
private var stateText: String { useClearKeyAV ? av.stateText : vlc.stateText }
private var isPlaying: Bool { useClearKeyAV ? av.isPlaying : vlc.isPlaying }
@ -186,7 +188,7 @@ struct PlayerScreen: View {
}
}
}
.sheet(isPresented: $showTracks) {
.sheet(isPresented: $showTracks, onDismiss: { scheduleHide() }) {
tracksSheet
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
@ -269,7 +271,7 @@ struct PlayerScreen: View {
.onTapGesture { hideControls() }
VStack(spacing: 14) {
if !isLive {
if showSeekChrome {
Slider(
value: Binding(
get: { Double(vlc.position) },
@ -290,7 +292,7 @@ struct PlayerScreen: View {
}
HStack(spacing: 36) {
if !isLive {
if showSeekChrome {
Button {
vlc.seek(toMs: max(vlc.currentPositionMs() - 10_000, 0))
scheduleHide()
@ -307,7 +309,7 @@ struct PlayerScreen: View {
.foregroundStyle(.white)
.shadow(radius: 8)
}
if !isLive {
if showSeekChrome {
Button {
vlc.seek(toMs: vlc.currentPositionMs() + 10_000)
scheduleHide()

View file

@ -24,6 +24,8 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
@Published var currentAudioId: String = ""
@Published var pipPossible = false
@Published var pipActive = false
/// VOD / F1-replay via playLive: finite duration + VLC seekable.
@Published var canSeek = false
static let offSubtitleId = PlayerTrack.offId
@ -52,6 +54,7 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
private var usePipDrawable = false
private var stopping = false
private var stopGeneration = 0
private var didApplyPreferredAudio = false
override init() {
// Shared library = alle demux/access plugins (DASH adaptive in VLC 4).
@ -191,6 +194,8 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
stateText = "Verbinden…"
subtitleTracks = []
audioTracks = []
canSeek = false
didApplyPreferredAudio = false
liveMode = false
clearKeyProxyActive = false
plainVideoView?.layoutIfNeeded()
@ -255,6 +260,8 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
liveRetryCount = 0
everPlayed = false
liveStartedAt = Date()
canSeek = false
didApplyPreferredAudio = false
liveMode = true
usePipDrawable = false
pipPossible = false
@ -345,6 +352,15 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
}
mediaPlayer.media = media
mediaPlayer.play()
let gen = stopGeneration
DispatchQueue.main.asyncAfter(deadline: .now() + 1.6) { [weak self] in
guard let self, self.stopGeneration == gen, !self.stopping else { return }
self.refreshTracks()
}
DispatchQueue.main.asyncAfter(deadline: .now() + 4.0) { [weak self] in
guard let self, self.stopGeneration == gen, !self.stopping else { return }
self.refreshTracks()
}
}
private func dutchClearKeyError(_ error: Error) -> String {
@ -523,6 +539,40 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
}
audioTracks = audios
currentAudioId = selectedAudio
applyPreferredAudioIfNeeded()
}
private func applyPreferredAudioIfNeeded() {
guard !didApplyPreferredAudio else { return }
let list = mediaPlayer.audioTracks
guard !list.isEmpty else { return }
didApplyPreferredAudio = true
let ranked = list.enumerated().map { i, t in
(track: t, score: Self.audioPreferenceScore(language: t.language, name: t.trackName, description: t.trackDescription, index: i))
}
guard let best = ranked.max(by: { $0.score < $1.score }), best.score > 0 else { return }
if !best.track.isSelected {
best.track.isSelectedExclusively = true
}
currentAudioId = best.track.trackId
}
/// NL eerst, anders Engels. Geen match → 0 (niet forceren).
static func audioPreferenceScore(language: String?, name: String?, description: String?, index: Int) -> Int {
let blob = [language, name, description]
.compactMap { $0?.lowercased() }
.joined(separator: " ")
if blob.isEmpty { return 0 }
let nl = ["nederlands", "dutch", " holland", "nl ", "dut", "nld"]
if nl.contains(where: { blob.contains($0) }) || blob == "nl" || blob.hasPrefix("nl-") || blob.hasPrefix("nl_") {
return 100 - index
}
let en = ["engels", "english", "international", "eng", "en "]
if en.contains(where: { blob.contains($0) }) || blob == "en" || blob.hasPrefix("en-") || blob.hasPrefix("en_")
|| blob == "eng" {
return 50 - index
}
return 0
}
private enum TrackKind { case subtitle, audio }
@ -688,6 +738,8 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
self.liveRetryCount = 0
self.everPlayed = false
self.liveMode = false
self.canSeek = false
self.didApplyPreferredAudio = false
self.isPlaying = false
self.hasError = false
self.errorMessage = nil
@ -804,6 +856,8 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
timeText = Self.format(ms: t)
lengthText = Self.format(ms: lengthMs)
isPlaying = mediaPlayer.isPlaying
let seekable = mediaPlayer.isSeekable && lengthMs > 3000
if seekable != canSeek { canSeek = seekable }
}
private static func format(ms: Int32) -> String {

View file

@ -96,7 +96,7 @@ struct SettingsScreen: View {
Section("Info") {
LabeledContent("App", value: "VMC iOS")
LabeledContent("Versie", value: "0.5.46")
LabeledContent("Versie", value: "0.5.47")
LabeledContent("Live TV", value: app.iptvEnabled ? "Aan" : "Uit")
LabeledContent("Events", value: app.eventsEnabled ? "Aan" : "Uit")
}