From ef566e2756054647ff264b6c93f1773c792eb22b Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Mon, 28 Sep 2026 00:50:49 +0200 Subject: [PATCH] iOS 0.5.47: seek bar on F1 VOD, hide controls after tracks sheet, prefer NL then EN audio. --- apps/ios/VMC.xcodeproj/project.pbxproj | 8 +- apps/ios/VMC/Playback/ClearKeyDashProxy.swift | 79 ++++++++++++++++++- apps/ios/VMC/Playback/PlayerScreen.swift | 10 ++- .../VMC/Playback/VLCPlayerController.swift | 54 +++++++++++++ apps/ios/VMC/UI/SettingsScreen.swift | 2 +- 5 files changed, 142 insertions(+), 11 deletions(-) diff --git a/apps/ios/VMC.xcodeproj/project.pbxproj b/apps/ios/VMC.xcodeproj/project.pbxproj index 01ba275..c98f911 100644 --- a/apps/ios/VMC.xcodeproj/project.pbxproj +++ b/apps/ios/VMC.xcodeproj/project.pbxproj @@ -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)"; diff --git a/apps/ios/VMC/Playback/ClearKeyDashProxy.swift b/apps/ios/VMC/Playback/ClearKeyDashProxy.swift index 7187700..64adcb8 100644 --- a/apps/ios/VMC/Playback/ClearKeyDashProxy.swift +++ b/apps/ios/VMC/Playback/ClearKeyDashProxy.swift @@ -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) diff --git a/apps/ios/VMC/Playback/PlayerScreen.swift b/apps/ios/VMC/Playback/PlayerScreen.swift index 4bf8224..9789e0f 100644 --- a/apps/ios/VMC/Playback/PlayerScreen.swift +++ b/apps/ios/VMC/Playback/PlayerScreen.swift @@ -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() diff --git a/apps/ios/VMC/Playback/VLCPlayerController.swift b/apps/ios/VMC/Playback/VLCPlayerController.swift index bd68b45..a4aae8f 100644 --- a/apps/ios/VMC/Playback/VLCPlayerController.swift +++ b/apps/ios/VMC/Playback/VLCPlayerController.swift @@ -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 { diff --git a/apps/ios/VMC/UI/SettingsScreen.swift b/apps/ios/VMC/UI/SettingsScreen.swift index 83f9529..2976cd3 100644 --- a/apps/ios/VMC/UI/SettingsScreen.swift +++ b/apps/ios/VMC/UI/SettingsScreen.swift @@ -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") }