From 60cc3cbdae53f6d1d2ef44b64da414ad66486ab0 Mon Sep 17 00:00:00 2001 From: Ceyhun Ozgoc Date: Tue, 8 Sep 2026 12:46:09 +0200 Subject: [PATCH 1/7] Prevent partial sideloaded subtitle playlists Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../AVSubtitlesLoader.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Code/Sideloaded-TextTracks/Sources/THEOplayerConnectorSideloadedSubtitle/AVSubtitlesLoader.swift b/Code/Sideloaded-TextTracks/Sources/THEOplayerConnectorSideloadedSubtitle/AVSubtitlesLoader.swift index 6ae35533..bdfa8b53 100644 --- a/Code/Sideloaded-TextTracks/Sources/THEOplayerConnectorSideloadedSubtitle/AVSubtitlesLoader.swift +++ b/Code/Sideloaded-TextTracks/Sources/THEOplayerConnectorSideloadedSubtitle/AVSubtitlesLoader.swift @@ -166,6 +166,9 @@ enum URLScheme: String { extension AVSubtitlesLoader: MediaPlaylistInterceptor { func shouldInterceptPlaylistRequest(type: HlsPlaylistType) -> Bool { true } func didInterceptPlaylistRequest(type: HlsPlaylistType, request: URLRequest) async throws -> URLRequest { + var request = request + request.setValue(nil, forHTTPHeaderField: "Range") + request.setValue(nil, forHTTPHeaderField: "If-Range") if let url = request.url { requestMap[url] = request } From 28e7e93bb513eb8c1868a5177b1f36dcf662a121 Mon Sep 17 00:00:00 2001 From: Ceyhun Ozgoc Date: Tue, 8 Sep 2026 12:46:27 +0200 Subject: [PATCH 2/7] Cover ranged playlist request handling Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/workflows/build-and-test.yml | 1 + ...rConnectorSideloadedSubtitleTests.xcscheme | 53 +++++++++++ ...ayerSideloadedSubtitleConnectorTests.swift | 94 ++++++++++++++++++- Package.swift | 8 ++ 4 files changed, 151 insertions(+), 5 deletions(-) create mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/THEOplayerConnectorSideloadedSubtitleTests.xcscheme diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 10ee148d..3f445ae0 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -26,6 +26,7 @@ jobs: THEOplayerConnectorUplynk # Test schemes to run TEST_SCHEMES: >- + THEOplayerConnectorSideloadedSubtitleTests THEOplayerConnectorUplynkTests steps: diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/THEOplayerConnectorSideloadedSubtitleTests.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/THEOplayerConnectorSideloadedSubtitleTests.xcscheme new file mode 100644 index 00000000..86de6954 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/THEOplayerConnectorSideloadedSubtitleTests.xcscheme @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/Code/Sideloaded-TextTracks/Tests/THEOplayerConnectorSideloadedSubtitleTests/THEOplayerSideloadedSubtitleConnectorTests.swift b/Code/Sideloaded-TextTracks/Tests/THEOplayerConnectorSideloadedSubtitleTests/THEOplayerSideloadedSubtitleConnectorTests.swift index e3de017b..56397581 100644 --- a/Code/Sideloaded-TextTracks/Tests/THEOplayerConnectorSideloadedSubtitleTests/THEOplayerSideloadedSubtitleConnectorTests.swift +++ b/Code/Sideloaded-TextTracks/Tests/THEOplayerConnectorSideloadedSubtitleTests/THEOplayerSideloadedSubtitleConnectorTests.swift @@ -1,13 +1,97 @@ +import Foundation import XCTest -import THEOplayerConnectorSideloadedSubtitle +@testable import THEOplayerConnectorSideloadedSubtitle import THEOplayerSDK +@MainActor final class THEOplayerSideloadedSubtitleConnectorTests: XCTestCase { - func testExample() throws { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct - // results. + func testSetNilSourceDoesNotThrow() { let player = THEOplayer(with: nil) XCTAssertNoThrow(player.setSourceWithSubtitles(source: nil)) } + + func testPlaylistRequestsRemoveRangeHeaders() async throws { + let loader = AVSubtitlesLoader(subtitles: [], id: #function, player: nil) + let rangeValues = ["bytes=0-1", "bytes=100-200", "bytes=500-"] + + for type in HlsPlaylistType.allCases { + for rangeValue in rangeValues { + var request = URLRequest(url: URL(string: "https://example.com/playlist.m3u8")!) + request.setValue(rangeValue, forHTTPHeaderField: "rAnGe") + request.setValue("\"etag\"", forHTTPHeaderField: "iF-rAnGe") + + let modifiedRequest = try await loader.didInterceptPlaylistRequest(type: type, request: request) + + XCTAssertNil(modifiedRequest.value(forHTTPHeaderField: "Range"), "Range was retained for \(type)") + XCTAssertNil(modifiedRequest.value(forHTTPHeaderField: "If-Range"), "If-Range was retained for \(type)") + XCTAssertEqual(request.value(forHTTPHeaderField: "Range"), rangeValue) + XCTAssertEqual(request.value(forHTTPHeaderField: "If-Range"), "\"etag\"") + + let sanitizedRequest = try await loader.didInterceptPlaylistRequest(type: type, request: modifiedRequest) + XCTAssertNil(sanitizedRequest.value(forHTTPHeaderField: "Range")) + XCTAssertNil(sanitizedRequest.value(forHTTPHeaderField: "If-Range")) + } + } + } + + func testPlaylistRequestPreservesUnrelatedProperties() async throws { + let loader = AVSubtitlesLoader(subtitles: [], id: #function, player: nil) + var request = URLRequest( + url: URL(string: "https://example.com/playlist.m3u8?token=value")!, + cachePolicy: .reloadIgnoringLocalCacheData, + timeoutInterval: 42 + ) + request.httpMethod = "POST" + request.httpBody = Data("body".utf8) + request.setValue("Bearer token", forHTTPHeaderField: "Authorization") + request.setValue("session=value", forHTTPHeaderField: "Cookie") + + let modifiedRequest = try await loader.didInterceptPlaylistRequest(type: .master, request: request) + + XCTAssertEqual(modifiedRequest.url, request.url) + XCTAssertEqual(modifiedRequest.httpMethod, request.httpMethod) + XCTAssertEqual(modifiedRequest.httpBody, request.httpBody) + XCTAssertEqual(modifiedRequest.cachePolicy, request.cachePolicy) + XCTAssertEqual(modifiedRequest.timeoutInterval, request.timeoutInterval) + XCTAssertEqual(modifiedRequest.value(forHTTPHeaderField: "Authorization"), "Bearer token") + XCTAssertEqual(modifiedRequest.value(forHTTPHeaderField: "Cookie"), "session=value") + } + + func testSanitizedRequestObtainsCompleteManifest() async throws { + let loader = AVSubtitlesLoader(subtitles: [], id: #function, player: nil) + var request = URLRequest(url: URL(string: "https://example.com/master.m3u8")!) + request.setValue("bytes=0-1", forHTTPHeaderField: "Range") + let modifiedRequest = try await loader.didInterceptPlaylistRequest(type: .master, request: request) + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [RangeAwareURLProtocol.self] + + let (data, response) = try await URLSession(configuration: configuration).data(for: modifiedRequest) + + XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 200) + XCTAssertEqual(String(data: data, encoding: .utf8), RangeAwareURLProtocol.manifest) + } +} + +private final class RangeAwareURLProtocol: URLProtocol { + static let manifest = "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1000\nvideo.m3u8" + + override class func canInit(with request: URLRequest) -> Bool { true } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + let isRanged = request.value(forHTTPHeaderField: "Range") != nil + let data = Data((isRanged ? "#E" : Self.manifest).utf8) + let response = HTTPURLResponse( + url: request.url!, + statusCode: isRanged ? 206 : 200, + httpVersion: "HTTP/1.1", + headerFields: isRanged ? ["Content-Range": "bytes 0-1/\(Self.manifest.utf8.count)"] : nil + )! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} } diff --git a/Package.swift b/Package.swift index 6e03fc94..4819c251 100644 --- a/Package.swift +++ b/Package.swift @@ -66,6 +66,14 @@ let package = Package( ], path: "Code/Sideloaded-TextTracks/Sources/THEOplayerConnectorSideloadedSubtitle" ), + .testTarget( + name: "THEOplayerConnectorSideloadedSubtitleTests", + dependencies: [ + "THEOplayerConnectorSideloadedSubtitle", + "THEOplayerSDK", + ], + path: "Code/Sideloaded-TextTracks/Tests/THEOplayerConnectorSideloadedSubtitleTests" + ), .target( name: "THEOplayerConnectorYospace", From 889de29919dafbb75427a69e6fb21d5495abd53b Mon Sep 17 00:00:00 2001 From: Ceyhun Ozgoc Date: Tue, 8 Sep 2026 12:46:49 +0200 Subject: [PATCH 3/7] Document partial playlist playback fix Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f9ab101..8f91559f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Fixed + +- SideloadedSubtitle + - Fixed an issue where partial playlist responses could cause playback to fail. + ## [11.0.5] - 2026-08-28 ### Fixed From 1388577e08c7ed96457811dd6ab3cd7dba17a413 Mon Sep 17 00:00:00 2001 From: Ceyhun Ozgoc Date: Tue, 8 Sep 2026 13:00:05 +0200 Subject: [PATCH 4/7] Exercise partial playlist transformation path Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ...ayerSideloadedSubtitleConnectorTests.swift | 90 ++++++++++++++++++- Package.swift | 1 + 2 files changed, 88 insertions(+), 3 deletions(-) diff --git a/Code/Sideloaded-TextTracks/Tests/THEOplayerConnectorSideloadedSubtitleTests/THEOplayerSideloadedSubtitleConnectorTests.swift b/Code/Sideloaded-TextTracks/Tests/THEOplayerConnectorSideloadedSubtitleTests/THEOplayerSideloadedSubtitleConnectorTests.swift index 56397581..b22c334b 100644 --- a/Code/Sideloaded-TextTracks/Tests/THEOplayerConnectorSideloadedSubtitleTests/THEOplayerSideloadedSubtitleConnectorTests.swift +++ b/Code/Sideloaded-TextTracks/Tests/THEOplayerConnectorSideloadedSubtitleTests/THEOplayerSideloadedSubtitleConnectorTests.swift @@ -1,4 +1,5 @@ import Foundation +import Swifter import XCTest @testable import THEOplayerConnectorSideloadedSubtitle import THEOplayerSDK @@ -12,7 +13,7 @@ final class THEOplayerSideloadedSubtitleConnectorTests: XCTestCase { func testPlaylistRequestsRemoveRangeHeaders() async throws { let loader = AVSubtitlesLoader(subtitles: [], id: #function, player: nil) - let rangeValues = ["bytes=0-1", "bytes=100-200", "bytes=500-"] + let rangeValues = ["bytes=0-1", "bytes=100-200", "bytes=500-", "items=0-1"] for type in HlsPlaylistType.allCases { for rangeValue in rangeValues { @@ -61,15 +62,98 @@ final class THEOplayerSideloadedSubtitleConnectorTests: XCTestCase { let loader = AVSubtitlesLoader(subtitles: [], id: #function, player: nil) var request = URLRequest(url: URL(string: "https://example.com/master.m3u8")!) request.setValue("bytes=0-1", forHTTPHeaderField: "Range") - let modifiedRequest = try await loader.didInterceptPlaylistRequest(type: .master, request: request) let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [RangeAwareURLProtocol.self] + let session = URLSession(configuration: configuration) + + let (partialData, partialResponse) = try await session.data(for: request) + XCTAssertEqual((partialResponse as? HTTPURLResponse)?.statusCode, 206) + XCTAssertEqual(String(data: partialData, encoding: .utf8), "#E") - let (data, response) = try await URLSession(configuration: configuration).data(for: modifiedRequest) + let modifiedRequest = try await loader.didInterceptPlaylistRequest(type: .master, request: request) + let (data, response) = try await session.data(for: modifiedRequest) XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 200) XCTAssertEqual(String(data: data, encoding: .utf8), RangeAwareURLProtocol.manifest) } + + func testSanitizedManifestReloadsTransformCompletePlaylists() async throws { + let server = HttpServer() + let masterManifest = "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1000\nvideo.m3u8" + let variantManifest = "#EXTM3U\n#EXT-X-TARGETDURATION:10\n#EXT-X-PLAYLIST-TYPE:VOD\n#EXTINF:10.000,\nsegment.ts\n#EXT-X-ENDLIST" + let lock = NSLock() + var rangedRequestPaths: [String] = [] + + func manifestResponse(for request: HttpRequest, manifest: String) -> HttpResponse { + guard request.headers["range"] == nil else { + lock.lock() + rangedRequestPaths.append(request.path) + lock.unlock() + return .raw( + 206, + "Partial Content", + ["Content-Range": "bytes 0-1/\(manifest.utf8.count)"], + { try $0.write([UInt8]("#E".utf8)) } + ) + } + return .ok(.data(Data(manifest.utf8), contentType: "application/vnd.apple.mpegurl")) + } + + server["/master.m3u8"] = { manifestResponse(for: $0, manifest: masterManifest) } + server["/video.m3u8"] = { manifestResponse(for: $0, manifest: variantManifest) } + server["/subtitle.vtt"] = { _ in .ok(.text("WEBVTT\n\n00:00:00.000 --> 00:00:01.000\nSubtitle")) } + let port = in_port_t.random(in: 20000..<49151) + try server.start(port, forceIPv4: true) + defer { server.stop() } + + let baseURL = URL(string: "http://127.0.0.1:\(port)")! + let masterURL = baseURL.appendingPathComponent("master.m3u8") + let variantURL = baseURL.appendingPathComponent("video.m3u8") + let subtitleURL = baseURL.appendingPathComponent("subtitle.vtt") + let subtitle = SSTextTrackDescription( + src: subtitleURL.absoluteString, + srclang: "en", + isDefault: true, + kind: .subtitles, + label: "English", + format: .WebVTT + ) + let loader = AVSubtitlesLoader(subtitles: [subtitle], id: #function, player: nil) + + var masterRequest = URLRequest(url: masterURL) + masterRequest.setValue("bytes=0-1", forHTTPHeaderField: "Range") + let sanitizedMasterRequest = try await loader.didInterceptPlaylistRequest(type: .master, request: masterRequest) + let (masterData, masterResponse) = try await URLSession.shared.data(for: sanitizedMasterRequest) + let transformedMaster = try await loader.didInterceptPlaylistResponse( + type: .master, + url: masterURL, + response: masterResponse, + data: masterData + ) + + let transformedMasterString = String(decoding: transformedMaster, as: UTF8.self) + XCTAssertTrue(transformedMasterString.contains("#EXT-X-MEDIA:TYPE=SUBTITLES")) + XCTAssertTrue(transformedMasterString.contains("SUBTITLES=\"THEOsubs\"")) + + var variantRequest = URLRequest(url: variantURL) + variantRequest.setValue("bytes=100-200", forHTTPHeaderField: "Range") + let sanitizedVariantRequest = try await loader.didInterceptPlaylistRequest(type: .video, request: variantRequest) + let (variantData, variantResponse) = try await URLSession.shared.data(for: sanitizedVariantRequest) + let transformedVariant = try await loader.didInterceptPlaylistResponse( + type: .video, + url: variantURL, + response: variantResponse, + data: variantData + ) + + let transformedVariantString = String(decoding: transformedVariant, as: UTF8.self) + XCTAssertTrue(transformedVariantString.contains("#EXTINF:10.000")) + XCTAssertTrue(transformedVariantString.contains(baseURL.appendingPathComponent("segment.ts").absoluteString)) + lock.lock() + let interceptedRanges = rangedRequestPaths + lock.unlock() + XCTAssertTrue(interceptedRanges.isEmpty) + } } private final class RangeAwareURLProtocol: URLProtocol { diff --git a/Package.swift b/Package.swift index 4819c251..9500e289 100644 --- a/Package.swift +++ b/Package.swift @@ -71,6 +71,7 @@ let package = Package( dependencies: [ "THEOplayerConnectorSideloadedSubtitle", "THEOplayerSDK", + "Swifter", ], path: "Code/Sideloaded-TextTracks/Tests/THEOplayerConnectorSideloadedSubtitleTests" ), From 6595b7790ac553899def4acb9a08167b644a56aa Mon Sep 17 00:00:00 2001 From: Ceyhun Ozgoc Date: Tue, 8 Sep 2026 13:10:46 +0200 Subject: [PATCH 5/7] Cover subtitle connector smoke paths Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ...ayerSideloadedSubtitleConnectorTests.swift | 64 +++++++++++++++++-- 1 file changed, 59 insertions(+), 5 deletions(-) diff --git a/Code/Sideloaded-TextTracks/Tests/THEOplayerConnectorSideloadedSubtitleTests/THEOplayerSideloadedSubtitleConnectorTests.swift b/Code/Sideloaded-TextTracks/Tests/THEOplayerConnectorSideloadedSubtitleTests/THEOplayerSideloadedSubtitleConnectorTests.swift index b22c334b..fffdb617 100644 --- a/Code/Sideloaded-TextTracks/Tests/THEOplayerConnectorSideloadedSubtitleTests/THEOplayerSideloadedSubtitleConnectorTests.swift +++ b/Code/Sideloaded-TextTracks/Tests/THEOplayerConnectorSideloadedSubtitleTests/THEOplayerSideloadedSubtitleConnectorTests.swift @@ -101,7 +101,8 @@ final class THEOplayerSideloadedSubtitleConnectorTests: XCTestCase { server["/master.m3u8"] = { manifestResponse(for: $0, manifest: masterManifest) } server["/video.m3u8"] = { manifestResponse(for: $0, manifest: variantManifest) } - server["/subtitle.vtt"] = { _ in .ok(.text("WEBVTT\n\n00:00:00.000 --> 00:00:01.000\nSubtitle")) } + server["/subtitle-en.vtt"] = { _ in .ok(.text("WEBVTT\n\n00:00:00.000 --> 00:00:01.000\nEnglish")) } + server["/subtitle-es.vtt"] = { _ in .ok(.text("WEBVTT\n\n00:00:00.000 --> 00:00:01.000\nSpanish")) } let port = in_port_t.random(in: 20000..<49151) try server.start(port, forceIPv4: true) defer { server.stop() } @@ -109,16 +110,25 @@ final class THEOplayerSideloadedSubtitleConnectorTests: XCTestCase { let baseURL = URL(string: "http://127.0.0.1:\(port)")! let masterURL = baseURL.appendingPathComponent("master.m3u8") let variantURL = baseURL.appendingPathComponent("video.m3u8") - let subtitleURL = baseURL.appendingPathComponent("subtitle.vtt") - let subtitle = SSTextTrackDescription( - src: subtitleURL.absoluteString, + let englishSubtitleURL = baseURL.appendingPathComponent("subtitle-en.vtt") + let spanishSubtitleURL = baseURL.appendingPathComponent("subtitle-es.vtt") + let englishSubtitle = SSTextTrackDescription( + src: englishSubtitleURL.absoluteString, srclang: "en", isDefault: true, kind: .subtitles, label: "English", format: .WebVTT ) - let loader = AVSubtitlesLoader(subtitles: [subtitle], id: #function, player: nil) + let spanishSubtitle = SSTextTrackDescription( + src: spanishSubtitleURL.absoluteString, + srclang: "es", + isDefault: false, + kind: .subtitles, + label: "Spanish", + format: .WebVTT + ) + let loader = AVSubtitlesLoader(subtitles: [englishSubtitle, spanishSubtitle], id: #function, player: nil) var masterRequest = URLRequest(url: masterURL) masterRequest.setValue("bytes=0-1", forHTTPHeaderField: "Range") @@ -134,6 +144,8 @@ final class THEOplayerSideloadedSubtitleConnectorTests: XCTestCase { let transformedMasterString = String(decoding: transformedMaster, as: UTF8.self) XCTAssertTrue(transformedMasterString.contains("#EXT-X-MEDIA:TYPE=SUBTITLES")) XCTAssertTrue(transformedMasterString.contains("SUBTITLES=\"THEOsubs\"")) + XCTAssertTrue(transformedMasterString.contains("NAME=\"English\"")) + XCTAssertTrue(transformedMasterString.contains("NAME=\"Spanish\"")) var variantRequest = URLRequest(url: variantURL) variantRequest.setValue("bytes=100-200", forHTTPHeaderField: "Range") @@ -149,11 +161,53 @@ final class THEOplayerSideloadedSubtitleConnectorTests: XCTestCase { let transformedVariantString = String(decoding: transformedVariant, as: UTF8.self) XCTAssertTrue(transformedVariantString.contains("#EXTINF:10.000")) XCTAssertTrue(transformedVariantString.contains(baseURL.appendingPathComponent("segment.ts").absoluteString)) + + for subtitleURL in [englishSubtitleURL, spanishSubtitleURL] { + let subtitleManifest = try XCTUnwrap(loader.handleSubtitles(subtitleURL)) + let subtitleManifestString = String(decoding: subtitleManifest, as: UTF8.self) + XCTAssertTrue(subtitleManifestString.contains("#EXT-X-TARGETDURATION:10")) + XCTAssertTrue(subtitleManifestString.contains("#EXTINF:10.000")) + XCTAssertTrue(subtitleManifestString.contains(subtitleURL.absoluteString)) + } + lock.lock() let interceptedRanges = rangedRequestPaths lock.unlock() XCTAssertTrue(interceptedRanges.isEmpty) } + + func testSourceReplacementWithSubtitlesDoesNotThrow() { + let player = THEOplayer(with: nil) + player.preload = .none + let source = makeSourceDescription() + + XCTAssertNoThrow(player.setSourceWithSubtitles(source: source)) + XCTAssertNotNil(player.source) + XCTAssertNoThrow(player.setSourceWithSubtitles(source: nil)) + XCTAssertNil(player.source) + } + + #if os(iOS) + func testCachingTaskCreationWithSubtitlesDoesNotThrow() { + let task = THEOplayer.cache.createTaskWithSubtitles(source: makeSourceDescription(), parameters: nil) + defer { task?.remove() } + + XCTAssertNotNil(task) + } + #endif + + private func makeSourceDescription() -> SourceDescription { + let source = TypedSource(src: "https://example.com/master.m3u8", type: "application/x-mpegurl") + let subtitle = SSTextTrackDescription( + src: "https://example.com/subtitle.vtt", + srclang: "en", + isDefault: true, + kind: .subtitles, + label: "English", + format: .WebVTT + ) + return SourceDescription(source: source, textTracks: [subtitle]) + } } private final class RangeAwareURLProtocol: URLProtocol { From 5db7f7f263e828a2d57d6cf0728c7dcb5301862b Mon Sep 17 00:00:00 2001 From: Ceyhun Ozgoc Date: Tue, 8 Sep 2026 14:07:12 +0200 Subject: [PATCH 6/7] Stabilize subtitle manifest fixture Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ...OplayerSideloadedSubtitleConnectorTests.swift | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Code/Sideloaded-TextTracks/Tests/THEOplayerConnectorSideloadedSubtitleTests/THEOplayerSideloadedSubtitleConnectorTests.swift b/Code/Sideloaded-TextTracks/Tests/THEOplayerConnectorSideloadedSubtitleTests/THEOplayerSideloadedSubtitleConnectorTests.swift index fffdb617..8a5b2030 100644 --- a/Code/Sideloaded-TextTracks/Tests/THEOplayerConnectorSideloadedSubtitleTests/THEOplayerSideloadedSubtitleConnectorTests.swift +++ b/Code/Sideloaded-TextTracks/Tests/THEOplayerConnectorSideloadedSubtitleTests/THEOplayerSideloadedSubtitleConnectorTests.swift @@ -86,9 +86,9 @@ final class THEOplayerSideloadedSubtitleConnectorTests: XCTestCase { func manifestResponse(for request: HttpRequest, manifest: String) -> HttpResponse { guard request.headers["range"] == nil else { - lock.lock() - rangedRequestPaths.append(request.path) - lock.unlock() + lock.withLock { + rangedRequestPaths.append(request.path) + } return .raw( 206, "Partial Content", @@ -103,10 +103,10 @@ final class THEOplayerSideloadedSubtitleConnectorTests: XCTestCase { server["/video.m3u8"] = { manifestResponse(for: $0, manifest: variantManifest) } server["/subtitle-en.vtt"] = { _ in .ok(.text("WEBVTT\n\n00:00:00.000 --> 00:00:01.000\nEnglish")) } server["/subtitle-es.vtt"] = { _ in .ok(.text("WEBVTT\n\n00:00:00.000 --> 00:00:01.000\nSpanish")) } - let port = in_port_t.random(in: 20000..<49151) - try server.start(port, forceIPv4: true) + try server.start(0, forceIPv4: true, priority: .userInitiated) defer { server.stop() } + let port = try server.port() let baseURL = URL(string: "http://127.0.0.1:\(port)")! let masterURL = baseURL.appendingPathComponent("master.m3u8") let variantURL = baseURL.appendingPathComponent("video.m3u8") @@ -170,9 +170,9 @@ final class THEOplayerSideloadedSubtitleConnectorTests: XCTestCase { XCTAssertTrue(subtitleManifestString.contains(subtitleURL.absoluteString)) } - lock.lock() - let interceptedRanges = rangedRequestPaths - lock.unlock() + let interceptedRanges = lock.withLock { + rangedRequestPaths + } XCTAssertTrue(interceptedRanges.isEmpty) } From 3e8f6e1424a99a5fc8637cd9c99647409333aa5d Mon Sep 17 00:00:00 2001 From: Ceyhun Ozgoc Date: Wed, 9 Sep 2026 11:49:16 +0200 Subject: [PATCH 7/7] Document ignored playlist range requests Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- Code/Sideloaded-TextTracks/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/Code/Sideloaded-TextTracks/README.md b/Code/Sideloaded-TextTracks/README.md index 278a504f..b030d8de 100644 --- a/Code/Sideloaded-TextTracks/README.md +++ b/Code/Sideloaded-TextTracks/README.md @@ -58,6 +58,7 @@ theoplayer.textTracks.addEventListener(type: TextTrackListEventTypes.ADD_TRACK) ``` 5. Loading thumbnail metadata through sideloaded WebVTT subtitles is not supported. +6. HTTP range requests for HLS playlists are ignored. The connector removes `Range` and `If-Range` headers because it requires complete playlist data. Media segment range requests are unaffected. ## Setting a time offset