From 37808f6af905f294dfe6f2458536cb3d643002de Mon Sep 17 00:00:00 2001 From: Seiike <122684677+Seeike@users.noreply.github.com> Date: Fri, 28 Mar 2025 23:02:20 +0100 Subject: [PATCH 01/10] save before editing xcodeproj --- Sora/Utils/Networking/ResolverDNS.swift | 280 ++++++++++++++++++ .../SettingsViewGeneral.swift | 20 ++ Sulfur.xcodeproj/project.pbxproj | 12 + 3 files changed, 312 insertions(+) create mode 100644 Sora/Utils/Networking/ResolverDNS.swift diff --git a/Sora/Utils/Networking/ResolverDNS.swift b/Sora/Utils/Networking/ResolverDNS.swift new file mode 100644 index 0000000..4342f6c --- /dev/null +++ b/Sora/Utils/Networking/ResolverDNS.swift @@ -0,0 +1,280 @@ +// +// ResolverDNS.swift +// Sulfur +// +// Created by seiike on 28/03/2025. +// + +import Foundation +import Network + +// MARK: - DNS Provider Enum + +enum DNSProvider: String, CaseIterable, Hashable { + case cloudflare = "Cloudflare" + case google = "Google" + case openDNS = "OpenDNS" + case quad9 = "Quad9" + case adGuard = "AdGuard" + case cleanbrowsing = "CleanBrowsing" + case controld = "ControlD" + + var servers: [String] { + switch self { + case .cloudflare: + return ["1.1.1.1", "1.0.0.1"] + case .google: + return ["8.8.8.8", "8.8.4.4"] + case .openDNS: + return ["208.67.222.222", "208.67.220.220"] + case .quad9: + return ["9.9.9.9", "149.112.112.112"] + case .adGuard: + return ["94.140.14.14", "94.140.15.15"] + case .cleanbrowsing: + return ["185.228.168.168", "185.228.169.168"] + case .controld: + return ["76.76.2.0", "76.76.10.0"] + } + } + + static var current: DNSProvider { + get { + let raw = UserDefaults.standard.string(forKey: "SelectedDNSProvider") ?? DNSProvider.cloudflare.rawValue + return DNSProvider(rawValue: raw) ?? .cloudflare + } + set { + UserDefaults.standard.setValue(newValue.rawValue, forKey: "SelectedDNSProvider") + } + } +} + +// MARK: - DNS Resolver Errors + +enum DNSResolverError: Error { + case invalidResponse + case noAnswer + case connectionError(String) + case timeout +} + +// MARK: - CustomDNSResolver Class + +class CustomDNSResolver { + + /// Returns an array of DNS servers. + /// If a custom provider ("Custom") is selected in UserDefaults, it returns the custom primary and secondary values; + /// otherwise, it falls back to the default provider's servers. + var dnsServers: [String] { + if let provider = UserDefaults.standard.string(forKey: "CustomDNSProvider"), + provider == "Custom" { + let primary = UserDefaults.standard.string(forKey: "customPrimaryDNS") ?? "" + let secondary = UserDefaults.standard.string(forKey: "customSecondaryDNS") ?? "" + var servers = [String]() + if !primary.isEmpty { servers.append(primary) } + if !secondary.isEmpty { servers.append(secondary) } + if !servers.isEmpty { + return servers + } + } + return DNSProvider.current.servers + } + + /// Resolves the provided hostname by sending a DNS query over UDP. + /// - Parameters: + /// - hostname: The hostname to resolve. + /// - timeout: How long to wait for a response (default 5 seconds). + /// - completion: A closure called with the result: a list of IPv4 addresses or an error. + func resolve(hostname: String, timeout: TimeInterval = 5.0, completion: @escaping (Result<[String], Error>) -> Void) { + // Use the first DNS server from our list + guard let dnsServer = dnsServers.first else { + completion(.failure(DNSResolverError.connectionError("No DNS server available"))) + return + } + + let port: NWEndpoint.Port = 53 + let queryID = UInt16.random(in: 0...UInt16.max) + + guard let queryData = buildDNSQuery(hostname: hostname, queryID: queryID) else { + completion(.failure(DNSResolverError.connectionError("Failed to build DNS query"))) + return + } + + // Create a new UDP connection + let connection = NWConnection(host: NWEndpoint.Host(dnsServer), port: port, using: .udp) + + // Track connection state manually + var localState = NWConnection.State.setup + + connection.stateUpdateHandler = { [weak self] newState in + localState = newState + switch newState { + case .ready: + // Send the DNS query + connection.send(content: queryData, completion: .contentProcessed({ error in + if let error = error { + connection.cancel() + completion(.failure(DNSResolverError.connectionError(error.localizedDescription))) + } else { + // Receive the DNS response + self?.receiveDNSResponse(connection: connection, + expectedQueryID: queryID, + completion: completion) + } + })) + case .failed(let error): + connection.cancel() + completion(.failure(DNSResolverError.connectionError(error.localizedDescription))) + default: + break + } + } + + // Start the connection + connection.start(queue: DispatchQueue.global()) + + // Implement a timeout for the query using a switch on localState + DispatchQueue.global().asyncAfter(deadline: .now() + timeout) { + switch localState { + case .failed(_), .cancelled: + // Already failed or canceled; do nothing + break + default: + // Not failed or canceled => consider it timed out + connection.cancel() + completion(.failure(DNSResolverError.timeout)) + } + } + } + + // MARK: - Receiving and Parsing + + private func receiveDNSResponse(connection: NWConnection, + expectedQueryID: UInt16, + completion: @escaping (Result<[String], Error>) -> Void) { + connection.receiveMessage { [weak self] data, _, _, error in + connection.cancel() + + if let error = error { + completion(.failure(DNSResolverError.connectionError(error.localizedDescription))) + return + } + guard let data = data else { + completion(.failure(DNSResolverError.invalidResponse)) + return + } + + if let ips = self?.parseDNSResponse(data: data, queryID: expectedQueryID), !ips.isEmpty { + completion(.success(ips)) + } else { + completion(.failure(DNSResolverError.noAnswer)) + } + } + } + + // MARK: - DNS Query Construction + + /// Constructs a DNS query packet for the given hostname. + /// - Parameters: + /// - hostname: The hostname to resolve. + /// - queryID: A randomly generated query identifier. + /// - Returns: A Data object representing the DNS query. + private func buildDNSQuery(hostname: String, queryID: UInt16) -> Data? { + var data = Data() + + // Header: ID (2 bytes) + data.append(contentsOf: withUnsafeBytes(of: queryID.bigEndian, Array.init)) + + // Flags: standard query with recursion desired (0x0100) + let flags: UInt16 = 0x0100 + data.append(contentsOf: withUnsafeBytes(of: flags.bigEndian, Array.init)) + + // QDCOUNT = 1 + let qdcount: UInt16 = 1 + data.append(contentsOf: withUnsafeBytes(of: qdcount.bigEndian, Array.init)) + + // ANCOUNT = 0, NSCOUNT = 0, ARCOUNT = 0 + let zero: UInt16 = 0 + data.append(contentsOf: withUnsafeBytes(of: zero.bigEndian, Array.init)) // ANCOUNT + data.append(contentsOf: withUnsafeBytes(of: zero.bigEndian, Array.init)) // NSCOUNT + data.append(contentsOf: withUnsafeBytes(of: zero.bigEndian, Array.init)) // ARCOUNT + + // Question section: + // QNAME: Encode hostname by splitting into labels. + let labels = hostname.split(separator: ".") + for label in labels { + guard let labelData = label.data(using: .utf8) else { + return nil + } + data.append(UInt8(labelData.count)) + data.append(labelData) + } + // Terminate QNAME with zero byte. + data.append(0) + + // QTYPE: A record (1) + let qtype: UInt16 = 1 + data.append(contentsOf: withUnsafeBytes(of: qtype.bigEndian, Array.init)) + + // QCLASS: IN (1) + let qclass: UInt16 = 1 + data.append(contentsOf: withUnsafeBytes(of: qclass.bigEndian, Array.init)) + + return data + } + + // MARK: - DNS Response Parsing + + /// Parses the DNS response packet and extracts IPv4 addresses from A record answers. + /// - Parameters: + /// - data: The DNS response data. + /// - queryID: The expected query identifier. + /// - Returns: An array of IPv4 address strings, or nil if parsing fails. + private func parseDNSResponse(data: Data, queryID: UInt16) -> [String]? { + // Ensure the response is at least long enough for a header. + guard data.count >= 12 else { return nil } + + // ID is the first 2 bytes + let responseID = data.subdata(in: 0..<2).withUnsafeBytes { $0.load(as: UInt16.self).bigEndian } + guard responseID == queryID else { return nil } + + // ANCOUNT is at offset 6. + let ancount = data.subdata(in: 6..<8).withUnsafeBytes { $0.load(as: UInt16.self).bigEndian } + if ancount == 0 { return nil } + + // Skip the header and question section. + var offset = 12 + // Skip QNAME + while offset < data.count && data[offset] != 0 { + offset += Int(data[offset]) + 1 + } + offset += 1 // Skip the terminating zero. + + // Skip QTYPE (2 bytes) and QCLASS (2 bytes) + offset += 4 + + var ips: [String] = [] + + // Loop through answer records. + for _ in 0.. data.count { break } + offset += 2 // Skip NAME (pointer) + let type = data.subdata(in: offset..<(offset+2)).withUnsafeBytes { $0.load(as: UInt16.self).bigEndian } + offset += 2 // TYPE + offset += 2 // CLASS + offset += 4 // TTL + let rdlength = data.subdata(in: offset..<(offset+2)).withUnsafeBytes { $0.load(as: UInt16.self).bigEndian } + offset += 2 + + // If the record is an A record and the length is 4 bytes, extract the IPv4 address. + if type == 1 && rdlength == 4 && offset + 4 <= data.count { + let ipBytes = data.subdata(in: offset..<(offset+4)) + let ip = ipBytes.map { String($0) }.joined(separator: ".") + ips.append(ip) + } + offset += Int(rdlength) + } + + return ips + } +} diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift index 154d1d2..a3414dc 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift @@ -101,6 +101,26 @@ struct SettingsViewGeneral: View { .tint(.accentColor) } + Section(header: Text("Network"), footer: Text("Try between some of the providers in case something is not loading if it should be, as it might be the fault of your ISP.")){ + HStack { + Text("DNS service") + Spacer() + Menu(customDNSProvider) { + ForEach(customDNSProviderList, id: \.self) { provider in + Button(action: { customDNSProvider = provider }) { + Text(provider) + } + } + } + } + if customDNSProvider == "Custom" { + TextField("Primary DNS", text: $customPrimaryDNS) + .keyboardType(.numbersAndPunctuation) + TextField("Secondary DNS", text: $customSecondaryDNS) + .keyboardType(.numbersAndPunctuation) + } + } + Section(header: Text("Advanced"), footer: Text("Anonymous data is collected to improve the app. No personal information is collected. This can be disabled at any time.")) { Toggle("Enable Analytics", isOn: $analyticsEnabled) .tint(.accentColor) diff --git a/Sulfur.xcodeproj/project.pbxproj b/Sulfur.xcodeproj/project.pbxproj index ad75420..66fe45f 100644 --- a/Sulfur.xcodeproj/project.pbxproj +++ b/Sulfur.xcodeproj/project.pbxproj @@ -59,6 +59,7 @@ 13EA2BD52D32D97400C1EBD7 /* CustomPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EA2BD12D32D97400C1EBD7 /* CustomPlayer.swift */; }; 13EA2BD62D32D97400C1EBD7 /* Double+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EA2BD32D32D97400C1EBD7 /* Double+Extension.swift */; }; 13EA2BD92D32D98400C1EBD7 /* NormalPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EA2BD82D32D98400C1EBD7 /* NormalPlayer.swift */; }; + 1E2719D92D975126008C4BD0 /* ResolverDNS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E2719D82D975120008C4BD0 /* ResolverDNS.swift */; }; 1E9FF1D32D403E49008AC100 /* SettingsViewLoggerFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */; }; 1EAC7A322D888BC50083984D /* MusicProgressSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EAC7A312D888BC50083984D /* MusicProgressSlider.swift */; }; 73D164D52D8B5B470011A360 /* JavaScriptCore+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */; }; @@ -117,6 +118,7 @@ 13EA2BD12D32D97400C1EBD7 /* CustomPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomPlayer.swift; sourceTree = ""; }; 13EA2BD32D32D97400C1EBD7 /* Double+Extension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Double+Extension.swift"; sourceTree = ""; }; 13EA2BD82D32D98400C1EBD7 /* NormalPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NormalPlayer.swift; sourceTree = ""; }; + 1E2719D82D975120008C4BD0 /* ResolverDNS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResolverDNS.swift; sourceTree = ""; }; 1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewLoggerFilter.swift; sourceTree = ""; }; 1EAC7A312D888BC50083984D /* MusicProgressSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicProgressSlider.swift; sourceTree = ""; }; 73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JavaScriptCore+Extensions.swift"; sourceTree = ""; }; @@ -284,6 +286,7 @@ 133D7C852D2BE2640075467E /* Utils */ = { isa = PBXGroup; children = ( + 1E2719D72D9750FB008C4BD0 /* Networking */, 13DB7CEA2D7DED50004371D3 /* DownloadManager */, 13C0E5E82D5F85DD00E7F619 /* ContinueWatching */, 13103E8C2D58E037000F0673 /* SkeletonCells */, @@ -437,6 +440,14 @@ path = Components; sourceTree = ""; }; + 1E2719D72D9750FB008C4BD0 /* Networking */ = { + isa = PBXGroup; + children = ( + 1E2719D82D975120008C4BD0 /* ResolverDNS.swift */, + ); + path = Networking; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -524,6 +535,7 @@ 13B7F4C12D58FFDD0045714A /* Shimmer.swift in Sources */, 139935662D468C450065CEFF /* ModuleManager.swift in Sources */, 133D7C902D2BE2640075467E /* SettingsView.swift in Sources */, + 1E2719D92D975126008C4BD0 /* ResolverDNS.swift in Sources */, 1334FF4F2D786C9E007E289F /* TMDB-Trending.swift in Sources */, 13CBEFDA2D5F7D1200D011EE /* String.swift in Sources */, 13DB46902D900A38008CBC03 /* URL.swift in Sources */, From 1652a0d0a0b3360d981f4b62d94c22aea798f7e2 Mon Sep 17 00:00:00 2001 From: Seiike <122684677+Seeike@users.noreply.github.com> Date: Sat, 29 Mar 2025 08:02:23 +0100 Subject: [PATCH 02/10] =?UTF-8?q?js=20use=201.1.1.1=20warp=20=F0=9F=99=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sora/Utils/Networking/ResolverDNS.swift | 280 ------------------------ Sulfur.xcodeproj/project.pbxproj | 12 - 2 files changed, 292 deletions(-) delete mode 100644 Sora/Utils/Networking/ResolverDNS.swift diff --git a/Sora/Utils/Networking/ResolverDNS.swift b/Sora/Utils/Networking/ResolverDNS.swift deleted file mode 100644 index 4342f6c..0000000 --- a/Sora/Utils/Networking/ResolverDNS.swift +++ /dev/null @@ -1,280 +0,0 @@ -// -// ResolverDNS.swift -// Sulfur -// -// Created by seiike on 28/03/2025. -// - -import Foundation -import Network - -// MARK: - DNS Provider Enum - -enum DNSProvider: String, CaseIterable, Hashable { - case cloudflare = "Cloudflare" - case google = "Google" - case openDNS = "OpenDNS" - case quad9 = "Quad9" - case adGuard = "AdGuard" - case cleanbrowsing = "CleanBrowsing" - case controld = "ControlD" - - var servers: [String] { - switch self { - case .cloudflare: - return ["1.1.1.1", "1.0.0.1"] - case .google: - return ["8.8.8.8", "8.8.4.4"] - case .openDNS: - return ["208.67.222.222", "208.67.220.220"] - case .quad9: - return ["9.9.9.9", "149.112.112.112"] - case .adGuard: - return ["94.140.14.14", "94.140.15.15"] - case .cleanbrowsing: - return ["185.228.168.168", "185.228.169.168"] - case .controld: - return ["76.76.2.0", "76.76.10.0"] - } - } - - static var current: DNSProvider { - get { - let raw = UserDefaults.standard.string(forKey: "SelectedDNSProvider") ?? DNSProvider.cloudflare.rawValue - return DNSProvider(rawValue: raw) ?? .cloudflare - } - set { - UserDefaults.standard.setValue(newValue.rawValue, forKey: "SelectedDNSProvider") - } - } -} - -// MARK: - DNS Resolver Errors - -enum DNSResolverError: Error { - case invalidResponse - case noAnswer - case connectionError(String) - case timeout -} - -// MARK: - CustomDNSResolver Class - -class CustomDNSResolver { - - /// Returns an array of DNS servers. - /// If a custom provider ("Custom") is selected in UserDefaults, it returns the custom primary and secondary values; - /// otherwise, it falls back to the default provider's servers. - var dnsServers: [String] { - if let provider = UserDefaults.standard.string(forKey: "CustomDNSProvider"), - provider == "Custom" { - let primary = UserDefaults.standard.string(forKey: "customPrimaryDNS") ?? "" - let secondary = UserDefaults.standard.string(forKey: "customSecondaryDNS") ?? "" - var servers = [String]() - if !primary.isEmpty { servers.append(primary) } - if !secondary.isEmpty { servers.append(secondary) } - if !servers.isEmpty { - return servers - } - } - return DNSProvider.current.servers - } - - /// Resolves the provided hostname by sending a DNS query over UDP. - /// - Parameters: - /// - hostname: The hostname to resolve. - /// - timeout: How long to wait for a response (default 5 seconds). - /// - completion: A closure called with the result: a list of IPv4 addresses or an error. - func resolve(hostname: String, timeout: TimeInterval = 5.0, completion: @escaping (Result<[String], Error>) -> Void) { - // Use the first DNS server from our list - guard let dnsServer = dnsServers.first else { - completion(.failure(DNSResolverError.connectionError("No DNS server available"))) - return - } - - let port: NWEndpoint.Port = 53 - let queryID = UInt16.random(in: 0...UInt16.max) - - guard let queryData = buildDNSQuery(hostname: hostname, queryID: queryID) else { - completion(.failure(DNSResolverError.connectionError("Failed to build DNS query"))) - return - } - - // Create a new UDP connection - let connection = NWConnection(host: NWEndpoint.Host(dnsServer), port: port, using: .udp) - - // Track connection state manually - var localState = NWConnection.State.setup - - connection.stateUpdateHandler = { [weak self] newState in - localState = newState - switch newState { - case .ready: - // Send the DNS query - connection.send(content: queryData, completion: .contentProcessed({ error in - if let error = error { - connection.cancel() - completion(.failure(DNSResolverError.connectionError(error.localizedDescription))) - } else { - // Receive the DNS response - self?.receiveDNSResponse(connection: connection, - expectedQueryID: queryID, - completion: completion) - } - })) - case .failed(let error): - connection.cancel() - completion(.failure(DNSResolverError.connectionError(error.localizedDescription))) - default: - break - } - } - - // Start the connection - connection.start(queue: DispatchQueue.global()) - - // Implement a timeout for the query using a switch on localState - DispatchQueue.global().asyncAfter(deadline: .now() + timeout) { - switch localState { - case .failed(_), .cancelled: - // Already failed or canceled; do nothing - break - default: - // Not failed or canceled => consider it timed out - connection.cancel() - completion(.failure(DNSResolverError.timeout)) - } - } - } - - // MARK: - Receiving and Parsing - - private func receiveDNSResponse(connection: NWConnection, - expectedQueryID: UInt16, - completion: @escaping (Result<[String], Error>) -> Void) { - connection.receiveMessage { [weak self] data, _, _, error in - connection.cancel() - - if let error = error { - completion(.failure(DNSResolverError.connectionError(error.localizedDescription))) - return - } - guard let data = data else { - completion(.failure(DNSResolverError.invalidResponse)) - return - } - - if let ips = self?.parseDNSResponse(data: data, queryID: expectedQueryID), !ips.isEmpty { - completion(.success(ips)) - } else { - completion(.failure(DNSResolverError.noAnswer)) - } - } - } - - // MARK: - DNS Query Construction - - /// Constructs a DNS query packet for the given hostname. - /// - Parameters: - /// - hostname: The hostname to resolve. - /// - queryID: A randomly generated query identifier. - /// - Returns: A Data object representing the DNS query. - private func buildDNSQuery(hostname: String, queryID: UInt16) -> Data? { - var data = Data() - - // Header: ID (2 bytes) - data.append(contentsOf: withUnsafeBytes(of: queryID.bigEndian, Array.init)) - - // Flags: standard query with recursion desired (0x0100) - let flags: UInt16 = 0x0100 - data.append(contentsOf: withUnsafeBytes(of: flags.bigEndian, Array.init)) - - // QDCOUNT = 1 - let qdcount: UInt16 = 1 - data.append(contentsOf: withUnsafeBytes(of: qdcount.bigEndian, Array.init)) - - // ANCOUNT = 0, NSCOUNT = 0, ARCOUNT = 0 - let zero: UInt16 = 0 - data.append(contentsOf: withUnsafeBytes(of: zero.bigEndian, Array.init)) // ANCOUNT - data.append(contentsOf: withUnsafeBytes(of: zero.bigEndian, Array.init)) // NSCOUNT - data.append(contentsOf: withUnsafeBytes(of: zero.bigEndian, Array.init)) // ARCOUNT - - // Question section: - // QNAME: Encode hostname by splitting into labels. - let labels = hostname.split(separator: ".") - for label in labels { - guard let labelData = label.data(using: .utf8) else { - return nil - } - data.append(UInt8(labelData.count)) - data.append(labelData) - } - // Terminate QNAME with zero byte. - data.append(0) - - // QTYPE: A record (1) - let qtype: UInt16 = 1 - data.append(contentsOf: withUnsafeBytes(of: qtype.bigEndian, Array.init)) - - // QCLASS: IN (1) - let qclass: UInt16 = 1 - data.append(contentsOf: withUnsafeBytes(of: qclass.bigEndian, Array.init)) - - return data - } - - // MARK: - DNS Response Parsing - - /// Parses the DNS response packet and extracts IPv4 addresses from A record answers. - /// - Parameters: - /// - data: The DNS response data. - /// - queryID: The expected query identifier. - /// - Returns: An array of IPv4 address strings, or nil if parsing fails. - private func parseDNSResponse(data: Data, queryID: UInt16) -> [String]? { - // Ensure the response is at least long enough for a header. - guard data.count >= 12 else { return nil } - - // ID is the first 2 bytes - let responseID = data.subdata(in: 0..<2).withUnsafeBytes { $0.load(as: UInt16.self).bigEndian } - guard responseID == queryID else { return nil } - - // ANCOUNT is at offset 6. - let ancount = data.subdata(in: 6..<8).withUnsafeBytes { $0.load(as: UInt16.self).bigEndian } - if ancount == 0 { return nil } - - // Skip the header and question section. - var offset = 12 - // Skip QNAME - while offset < data.count && data[offset] != 0 { - offset += Int(data[offset]) + 1 - } - offset += 1 // Skip the terminating zero. - - // Skip QTYPE (2 bytes) and QCLASS (2 bytes) - offset += 4 - - var ips: [String] = [] - - // Loop through answer records. - for _ in 0.. data.count { break } - offset += 2 // Skip NAME (pointer) - let type = data.subdata(in: offset..<(offset+2)).withUnsafeBytes { $0.load(as: UInt16.self).bigEndian } - offset += 2 // TYPE - offset += 2 // CLASS - offset += 4 // TTL - let rdlength = data.subdata(in: offset..<(offset+2)).withUnsafeBytes { $0.load(as: UInt16.self).bigEndian } - offset += 2 - - // If the record is an A record and the length is 4 bytes, extract the IPv4 address. - if type == 1 && rdlength == 4 && offset + 4 <= data.count { - let ipBytes = data.subdata(in: offset..<(offset+4)) - let ip = ipBytes.map { String($0) }.joined(separator: ".") - ips.append(ip) - } - offset += Int(rdlength) - } - - return ips - } -} diff --git a/Sulfur.xcodeproj/project.pbxproj b/Sulfur.xcodeproj/project.pbxproj index 66fe45f..ad75420 100644 --- a/Sulfur.xcodeproj/project.pbxproj +++ b/Sulfur.xcodeproj/project.pbxproj @@ -59,7 +59,6 @@ 13EA2BD52D32D97400C1EBD7 /* CustomPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EA2BD12D32D97400C1EBD7 /* CustomPlayer.swift */; }; 13EA2BD62D32D97400C1EBD7 /* Double+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EA2BD32D32D97400C1EBD7 /* Double+Extension.swift */; }; 13EA2BD92D32D98400C1EBD7 /* NormalPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EA2BD82D32D98400C1EBD7 /* NormalPlayer.swift */; }; - 1E2719D92D975126008C4BD0 /* ResolverDNS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E2719D82D975120008C4BD0 /* ResolverDNS.swift */; }; 1E9FF1D32D403E49008AC100 /* SettingsViewLoggerFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */; }; 1EAC7A322D888BC50083984D /* MusicProgressSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EAC7A312D888BC50083984D /* MusicProgressSlider.swift */; }; 73D164D52D8B5B470011A360 /* JavaScriptCore+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */; }; @@ -118,7 +117,6 @@ 13EA2BD12D32D97400C1EBD7 /* CustomPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomPlayer.swift; sourceTree = ""; }; 13EA2BD32D32D97400C1EBD7 /* Double+Extension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Double+Extension.swift"; sourceTree = ""; }; 13EA2BD82D32D98400C1EBD7 /* NormalPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NormalPlayer.swift; sourceTree = ""; }; - 1E2719D82D975120008C4BD0 /* ResolverDNS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResolverDNS.swift; sourceTree = ""; }; 1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewLoggerFilter.swift; sourceTree = ""; }; 1EAC7A312D888BC50083984D /* MusicProgressSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicProgressSlider.swift; sourceTree = ""; }; 73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JavaScriptCore+Extensions.swift"; sourceTree = ""; }; @@ -286,7 +284,6 @@ 133D7C852D2BE2640075467E /* Utils */ = { isa = PBXGroup; children = ( - 1E2719D72D9750FB008C4BD0 /* Networking */, 13DB7CEA2D7DED50004371D3 /* DownloadManager */, 13C0E5E82D5F85DD00E7F619 /* ContinueWatching */, 13103E8C2D58E037000F0673 /* SkeletonCells */, @@ -440,14 +437,6 @@ path = Components; sourceTree = ""; }; - 1E2719D72D9750FB008C4BD0 /* Networking */ = { - isa = PBXGroup; - children = ( - 1E2719D82D975120008C4BD0 /* ResolverDNS.swift */, - ); - path = Networking; - sourceTree = ""; - }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -535,7 +524,6 @@ 13B7F4C12D58FFDD0045714A /* Shimmer.swift in Sources */, 139935662D468C450065CEFF /* ModuleManager.swift in Sources */, 133D7C902D2BE2640075467E /* SettingsView.swift in Sources */, - 1E2719D92D975126008C4BD0 /* ResolverDNS.swift in Sources */, 1334FF4F2D786C9E007E289F /* TMDB-Trending.swift in Sources */, 13CBEFDA2D5F7D1200D011EE /* String.swift in Sources */, 13DB46902D900A38008CBC03 /* URL.swift in Sources */, From e0c2092563dee6545c4c13a24f451b470feb5e70 Mon Sep 17 00:00:00 2001 From: Seiike <122684677+Seeike@users.noreply.github.com> Date: Tue, 1 Apr 2025 21:30:01 +0200 Subject: [PATCH 03/10] everyting same as org repo --- .../SettingsViewGeneral.swift | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift index a3414dc..154d1d2 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift @@ -101,26 +101,6 @@ struct SettingsViewGeneral: View { .tint(.accentColor) } - Section(header: Text("Network"), footer: Text("Try between some of the providers in case something is not loading if it should be, as it might be the fault of your ISP.")){ - HStack { - Text("DNS service") - Spacer() - Menu(customDNSProvider) { - ForEach(customDNSProviderList, id: \.self) { provider in - Button(action: { customDNSProvider = provider }) { - Text(provider) - } - } - } - } - if customDNSProvider == "Custom" { - TextField("Primary DNS", text: $customPrimaryDNS) - .keyboardType(.numbersAndPunctuation) - TextField("Secondary DNS", text: $customSecondaryDNS) - .keyboardType(.numbersAndPunctuation) - } - } - Section(header: Text("Advanced"), footer: Text("Anonymous data is collected to improve the app. No personal information is collected. This can be disabled at any time.")) { Toggle("Enable Analytics", isOn: $analyticsEnabled) .tint(.accentColor) From 47f45f63bb654e1b73c332985d11f74283154268 Mon Sep 17 00:00:00 2001 From: Seiike <122684677+Seeike@users.noreply.github.com> Date: Wed, 2 Apr 2025 22:43:14 +0200 Subject: [PATCH 04/10] long press seek buttons now work again --- .../CustomPlayer/CustomPlayer.swift | 39 +------------------ 1 file changed, 1 insertion(+), 38 deletions(-) diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift index f30a6f3..a3d8623 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift @@ -142,7 +142,6 @@ class CustomMediaPlayerViewController: UIViewController { setupPlayerViewController() setupControls() setupSkipAndDismissGestures() - addInvisibleControlOverlays() setupSubtitleLabel() setupDismissButton() setupQualityButton() @@ -353,43 +352,7 @@ class CustomMediaPlayerViewController: UIViewController { ]) } - func addInvisibleControlOverlays() { - let playPauseOverlay = UIButton(type: .custom) - playPauseOverlay.backgroundColor = .clear - playPauseOverlay.addTarget(self, action: #selector(togglePlayPause), for: .touchUpInside) - view.addSubview(playPauseOverlay) - playPauseOverlay.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - playPauseOverlay.centerXAnchor.constraint(equalTo: playPauseButton.centerXAnchor), - playPauseOverlay.centerYAnchor.constraint(equalTo: playPauseButton.centerYAnchor), - playPauseOverlay.widthAnchor.constraint(equalTo: playPauseButton.widthAnchor, constant: 20), - playPauseOverlay.heightAnchor.constraint(equalTo: playPauseButton.heightAnchor, constant: 20) - ]) - - let backwardOverlay = UIButton(type: .custom) - backwardOverlay.backgroundColor = .clear - backwardOverlay.addTarget(self, action: #selector(seekBackward), for: .touchUpInside) - view.addSubview(backwardOverlay) - backwardOverlay.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - backwardOverlay.centerXAnchor.constraint(equalTo: backwardButton.centerXAnchor), - backwardOverlay.centerYAnchor.constraint(equalTo: backwardButton.centerYAnchor), - backwardOverlay.widthAnchor.constraint(equalTo: backwardButton.widthAnchor, constant: 20), - backwardOverlay.heightAnchor.constraint(equalTo: backwardButton.heightAnchor, constant: 20) - ]) - - let forwardOverlay = UIButton(type: .custom) - forwardOverlay.backgroundColor = .clear - forwardOverlay.addTarget(self, action: #selector(seekForward), for: .touchUpInside) - view.addSubview(forwardOverlay) - forwardOverlay.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - forwardOverlay.centerXAnchor.constraint(equalTo: forwardButton.centerXAnchor), - forwardOverlay.centerYAnchor.constraint(equalTo: forwardButton.centerYAnchor), - forwardOverlay.widthAnchor.constraint(equalTo: forwardButton.widthAnchor, constant: 20), - forwardOverlay.heightAnchor.constraint(equalTo: forwardButton.heightAnchor, constant: 20) - ]) - } + func setupSkipAndDismissGestures() { let doubleTapGesture = UITapGestureRecognizer(target: self, action: #selector(handleDoubleTap(_:))) From 22e01248ae741b8ff5cd6f038eeff768dc38fc01 Mon Sep 17 00:00:00 2001 From: Seiike <122684677+Seeike@users.noreply.github.com> Date: Wed, 2 Apr 2025 22:46:50 +0200 Subject: [PATCH 05/10] =?UTF-8?q?its=20finally=20symetrical=20=F0=9F=99=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift index a3d8623..aeae185 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift @@ -577,7 +577,7 @@ class CustomMediaPlayerViewController: UIViewController { NSLayoutConstraint.activate([ skip85Button.leadingAnchor.constraint(equalTo: sliderHostingController!.view.leadingAnchor), - skip85Button.bottomAnchor.constraint(equalTo: sliderHostingController!.view.topAnchor, constant: -3), + skip85Button.bottomAnchor.constraint(equalTo: sliderHostingController!.view.topAnchor, constant: -5), skip85Button.heightAnchor.constraint(equalToConstant: 50), skip85Button.widthAnchor.constraint(greaterThanOrEqualToConstant: 120) ]) From 6869f97689449104eff848e246fb53a80e91e674 Mon Sep 17 00:00:00 2001 From: Seiike <122684677+Seeike@users.noreply.github.com> Date: Wed, 2 Apr 2025 23:47:24 +0200 Subject: [PATCH 06/10] serie name and episode number in the player --- .../MediaPlayer/CustomPlayer/CustomPlayer.swift | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift index aeae185..30c1f21 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift @@ -479,12 +479,29 @@ class CustomMediaPlayerViewController: UIViewController { dismissButton.addTarget(self, action: #selector(dismissTapped), for: .touchUpInside) controlsContainerView.addSubview(dismissButton) dismissButton.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ dismissButton.leadingAnchor.constraint(equalTo: controlsContainerView.leadingAnchor, constant: 16), dismissButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20), dismissButton.widthAnchor.constraint(equalToConstant: 40), dismissButton.heightAnchor.constraint(equalToConstant: 40) ]) + + let episodeLabel = UILabel() + episodeLabel.text = "\(titleText) • Ep \(episodeNumber)" + episodeLabel.textColor = .white + episodeLabel.font = UIFont.systemFont(ofSize: 15, weight: .medium) + episodeLabel.numberOfLines = 1 + episodeLabel.lineBreakMode = .byTruncatingTail + + controlsContainerView.addSubview(episodeLabel) + episodeLabel.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + episodeLabel.centerYAnchor.constraint(equalTo: dismissButton.centerYAnchor), + episodeLabel.leadingAnchor.constraint(equalTo: dismissButton.trailingAnchor, constant: 12), + episodeLabel.trailingAnchor.constraint(lessThanOrEqualTo: controlsContainerView.trailingAnchor, constant: -16) + ]) } func setupMenuButton() { From 6de740ee6c9c45e30a2297d73c515eda4ca19862 Mon Sep 17 00:00:00 2001 From: Seiike <122684677+Seeike@users.noreply.github.com> Date: Thu, 3 Apr 2025 14:51:22 +0200 Subject: [PATCH 07/10] js fix the crashing --- .../Components/MusicProgressSlider.swift | 218 ++++++++++++------ .../CustomPlayer/CustomPlayer.swift | 182 +++++++++++---- 2 files changed, 282 insertions(+), 118 deletions(-) diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/Components/MusicProgressSlider.swift b/Sora/Utils/MediaPlayer/CustomPlayer/Components/MusicProgressSlider.swift index e8a3ca8..fcc7a2f 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/Components/MusicProgressSlider.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/Components/MusicProgressSlider.swift @@ -5,82 +5,129 @@ // Created by Pratik on 08/01/23. // // Thanks to pratikg29 for this code inside his open source project "https://github.com/pratikg29/Custom-Slider-Control?ref=iosexample.com" -// I did edit just a little bit the code for my liking -// +// I did edit just a little bit the code for my liking (added a buffer indicator, etc.) import SwiftUI -struct MusicProgressSlider: View { - @Binding var value: T - let inRange: ClosedRange - let activeFillColor: Color - let fillColor: Color - let emptyColor: Color - let height: CGFloat - let onEditingChanged: (Bool) -> Void - - @State private var localRealProgress: T = 0 - @State private var localTempProgress: T = 0 - @GestureState private var isActive: Bool = false - - var body: some View { - GeometryReader { bounds in - ZStack { - VStack { - ZStack(alignment: .center) { - Capsule() - .fill(emptyColor) - Capsule() - .fill(isActive ? activeFillColor : fillColor) - .mask({ - HStack { - Rectangle() - .frame(width: max((bounds.size.width * CGFloat(localRealProgress + localTempProgress)).isFinite ? bounds.size.width * CGFloat(localRealProgress + localTempProgress) : 0, 0), alignment: .leading) - Spacer(minLength: 0) - } - }) + struct MusicProgressSlider: View { + @Binding var value: T + let inRange: ClosedRange + + let bufferValue: T + let activeFillColor: Color + let fillColor: Color + let bufferColor: Color + let emptyColor: Color + let height: CGFloat + + let onEditingChanged: (Bool) -> Void + + @State private var localRealProgress: T = 0 + @State private var localTempProgress: T = 0 + @GestureState private var isActive: Bool = false + + var body: some View { + GeometryReader { bounds in + ZStack { + VStack { + ZStack(alignment: .center) { + Capsule() + .fill(emptyColor) + + Capsule() + .fill(bufferColor) + .mask({ + HStack { + Rectangle() + .frame( + width: max( + (bounds.size.width + * CGFloat(getPrgPercentage(bufferValue))) + .isFinite + ? bounds.size.width + * CGFloat(getPrgPercentage(bufferValue)) + : 0, + 0 + ), + alignment: .leading + ) + Spacer(minLength: 0) + } + }) + + Capsule() + .fill(isActive ? activeFillColor : fillColor) + .mask({ + HStack { + Rectangle() + .frame( + width: max( + (bounds.size.width + * CGFloat(localRealProgress + localTempProgress)) + .isFinite + ? bounds.size.width + * CGFloat(localRealProgress + localTempProgress) + : 0, + 0 + ), + alignment: .leading + ) + Spacer(minLength: 0) + } + }) + } + + HStack { + let shouldShowHours = inRange.upperBound >= 3600 + Text(value.asTimeString(style: .positional, showHours: shouldShowHours)) + Spacer(minLength: 0) + Text("-" + (inRange.upperBound - value).asTimeString( + style: .positional, + showHours: shouldShowHours + )) + } + .font(.system(size: 12)) + .foregroundColor(isActive ? fillColor : emptyColor) } - - HStack { - // Determine if we should show hours based on the total duration. - let shouldShowHours = inRange.upperBound >= 3600 - Text(value.asTimeString(style: .positional, showHours: shouldShowHours)) - Spacer(minLength: 0) - Text("-" + (inRange.upperBound - value).asTimeString(style: .positional, showHours: shouldShowHours)) - } - - .font(.system(size: 12)) - .foregroundColor(isActive ? fillColor : emptyColor) + .frame( + width: isActive ? bounds.size.width * 1.04 : bounds.size.width, + alignment: .center + ) + .animation(animation, value: isActive) } - .frame(width: isActive ? bounds.size.width * 1.04 : bounds.size.width, alignment: .center) - .animation(animation, value: isActive) - } - .frame(width: bounds.size.width, height: bounds.size.height, alignment: .center) - .contentShape(Rectangle()) - .gesture(DragGesture(minimumDistance: 0, coordinateSpace: .local) + .frame( + width: bounds.size.width, + height: bounds.size.height, + alignment: .center + ) + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0, coordinateSpace: .local) .updating($isActive) { _, state, _ in - state = true - } + state = true + } .onChanged { gesture in - localTempProgress = T(gesture.translation.width / bounds.size.width) - value = max(min(getPrgValue(), inRange.upperBound), inRange.lowerBound) - }.onEnded { _ in - localRealProgress = max(min(localRealProgress + localTempProgress, 1), 0) - localTempProgress = 0 - }) - .onChange(of: isActive) { newValue in - value = max(min(getPrgValue(), inRange.upperBound), inRange.lowerBound) - onEditingChanged(newValue) - } - .onAppear { - localRealProgress = getPrgPercentage(value) - } - .onChange(of: value) { newValue in - if !isActive { - localRealProgress = getPrgPercentage(newValue) + localTempProgress = T(gesture.translation.width / bounds.size.width) + value = max(min(getPrgValue(), inRange.upperBound), inRange.lowerBound) + } + .onEnded { _ in + localRealProgress = max(min(localRealProgress + localTempProgress, 1), 0) + localTempProgress = 0 + } + ) + .onChange(of: isActive) { newValue in + value = max(min(getPrgValue(), inRange.upperBound), inRange.lowerBound) + onEditingChanged(newValue) + } + .onAppear { + localRealProgress = getPrgPercentage(value) + } + .onChange(of: value) { newValue in + if !isActive { + localRealProgress = getPrgPercentage(newValue) + } } } - } .frame(height: isActive ? height * 1.25 : height, alignment: .center) } @@ -100,6 +147,41 @@ struct MusicProgressSlider: View { } private func getPrgValue() -> T { - return ((localRealProgress + localTempProgress) * (inRange.upperBound - inRange.lowerBound)) + inRange.lowerBound + return ((localRealProgress + localTempProgress) * (inRange.upperBound - inRange.lowerBound)) + inRange.lowerBound} + // MARK: - Helpers + + private func fraction(of val: T) -> T { + let total = inRange.upperBound - inRange.lowerBound + let normalized = val - inRange.lowerBound + return (total > 0) ? (normalized / total) : 0 + } + + // Convert fraction to clamped 0...1 + private func clampedFraction(_ f: T) -> T { + max(0, min(f, 1)) + } + + // Convert fraction to actual time in seconds (or whatever T is) + private func getCurrentValue() -> T { + let total = inRange.upperBound - inRange.lowerBound + let frac = clampedFraction(localRealProgress + localTempProgress) + return frac * total + inRange.lowerBound + } + + // Clamp the final value to [lowerBound, upperBound] + private func clampedValue(_ raw: T) -> T { + max(inRange.lowerBound, min(raw, inRange.upperBound)) + } + + // The actual width of the played portion in pixels + private func playedWidth(boundsWidth: CGFloat) -> CGFloat { + let frac = fraction(of: value) + return max(0, min(boundsWidth * CGFloat(frac), boundsWidth)) + } + + // The actual width of the buffered portion in pixels + private func bufferWidth(boundsWidth: CGFloat) -> CGFloat { + let frac = fraction(of: bufferValue) + return max(0, min(boundsWidth * CGFloat(frac), boundsWidth)) } } diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift index 30c1f21..4949335 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift @@ -10,10 +10,15 @@ import AVKit import SwiftUI import AVFoundation +// MARK: - SliderViewModel + class SliderViewModel: ObservableObject { @Published var sliderValue: Double = 0.0 + @Published var bufferValue: Double = 0.0 } +// MARK: - CustomMediaPlayerViewController + class CustomMediaPlayerViewController: UIViewController { let module: ScrapingModule let streamURL: String @@ -82,13 +87,17 @@ class CustomMediaPlayerViewController: UIViewController { var isControlsVisible = false var subtitleBottomConstraint: NSLayoutConstraint? - var subtitleBottomPadding: CGFloat = 10.0 { didSet { updateSubtitleLabelConstraints() } } + // We’ll use this context to KVO loadedTimeRanges + private var playerItemKVOContext = 0 + private var loadedTimeRangesObservation: NSKeyValueObservation? + + init(module: ScrapingModule, urlString: String, fullUrl: String, @@ -112,10 +121,12 @@ class CustomMediaPlayerViewController: UIViewController { guard let url = URL(string: urlString) else { fatalError("Invalid URL string") } + var request = URLRequest(url: url) request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Referer") request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Origin") - request.addValue("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36", forHTTPHeaderField: "User-Agent") + request.addValue("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36", + forHTTPHeaderField: "User-Agent") let asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": request.allHTTPHeaderFields ?? [:]]) let playerItem = AVPlayerItem(asset: asset) @@ -153,6 +164,12 @@ class CustomMediaPlayerViewController: UIViewController { startUpdateTimer() setupAudioSession() + if let item = player.currentItem { + loadedTimeRangesObservation = item.observe(\.loadedTimeRanges, options: [.new, .initial]) { [weak self] (playerItem, change) in + self?.updateBufferValue() + } + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in self?.checkForHLSStream() } @@ -174,7 +191,10 @@ class CustomMediaPlayerViewController: UIViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - NotificationCenter.default.addObserver(self, selector: #selector(playerItemDidChange), name: .AVPlayerItemNewAccessLogEntry, object: nil) + NotificationCenter.default.addObserver(self, + selector: #selector(playerItemDidChange), + name: .AVPlayerItemNewAccessLogEntry, + object: nil) } override func viewWillDisappear(_ animated: Bool) { @@ -182,17 +202,25 @@ class CustomMediaPlayerViewController: UIViewController { NotificationCenter.default.removeObserver(self, name: .AVPlayerItemNewAccessLogEntry, object: nil) + // Remove KVO observer for loadedTimeRanges + player.currentItem?.removeObserver(self, + forKeyPath: "loadedTimeRanges", + context: &playerItemKVOContext) + if let playbackSpeed = player?.rate { UserDefaults.standard.set(playbackSpeed, forKey: "lastPlaybackSpeed") } player.pause() updateTimer?.invalidate() inactivityTimer?.invalidate() + if let token = timeObserverToken { player.removeTimeObserver(token) timeObserverToken = nil } + UserDefaults.standard.set(player.rate, forKey: "lastPlaybackSpeed") + if let currentItem = player.currentItem, currentItem.duration.seconds > 0 { let progress = currentTimeVal / currentItem.duration.seconds let item = ContinueWatchingItem( @@ -210,9 +238,38 @@ class CustomMediaPlayerViewController: UIViewController { } } + // Observing loadedTimeRanges: + override func observeValue(forKeyPath keyPath: String?, + of object: Any?, + change: [NSKeyValueChangeKey : Any]?, + context: UnsafeMutableRawPointer?) { + + guard context == &playerItemKVOContext else { + super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context) + return + } + + if keyPath == "loadedTimeRanges" { + updateBufferValue() + } + } + + private func updateBufferValue() { + guard let item = player.currentItem else { return } + + if let timeRange = item.loadedTimeRanges.first?.timeRangeValue { + let buffered = CMTimeGetSeconds(timeRange.start) + CMTimeGetSeconds(timeRange.duration) + // This is how many seconds are currently buffered: + DispatchQueue.main.async { + self.sliderViewModel.bufferValue = buffered + } + } + } + @objc private func playerItemDidChange() { DispatchQueue.main.async { [weak self] in - if let self = self, self.qualityButton.isHidden && self.isHLSStream { + guard let self = self else { return } + if self.qualityButton.isHidden && self.isHLSStream { self.qualityButton.isHidden = false self.qualityButton.menu = self.qualitySelectionMenu() } @@ -309,8 +366,10 @@ class CustomMediaPlayerViewController: UIViewController { value: Binding(get: { self.sliderViewModel.sliderValue }, set: { self.sliderViewModel.sliderValue = $0 }), inRange: 0...(duration > 0 ? duration : 1.0), + bufferValue: self.sliderViewModel.bufferValue, // <--- pass in buffer activeFillColor: .white, fillColor: .white.opacity(0.5), + bufferColor: .white.opacity(0.2), // <--- buffer color emptyColor: .white.opacity(0.3), height: 30, onEditingChanged: { editing in @@ -353,7 +412,6 @@ class CustomMediaPlayerViewController: UIViewController { } - func setupSkipAndDismissGestures() { let doubleTapGesture = UITapGestureRecognizer(target: self, action: #selector(handleDoubleTap(_:))) doubleTapGesture.numberOfTapsRequired = 2 @@ -479,24 +537,24 @@ class CustomMediaPlayerViewController: UIViewController { dismissButton.addTarget(self, action: #selector(dismissTapped), for: .touchUpInside) controlsContainerView.addSubview(dismissButton) dismissButton.translatesAutoresizingMaskIntoConstraints = false - + NSLayoutConstraint.activate([ dismissButton.leadingAnchor.constraint(equalTo: controlsContainerView.leadingAnchor, constant: 16), dismissButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20), dismissButton.widthAnchor.constraint(equalToConstant: 40), dismissButton.heightAnchor.constraint(equalToConstant: 40) ]) - + let episodeLabel = UILabel() episodeLabel.text = "\(titleText) • Ep \(episodeNumber)" episodeLabel.textColor = .white episodeLabel.font = UIFont.systemFont(ofSize: 15, weight: .medium) episodeLabel.numberOfLines = 1 episodeLabel.lineBreakMode = .byTruncatingTail - + controlsContainerView.addSubview(episodeLabel) episodeLabel.translatesAutoresizingMaskIntoConstraints = false - + NSLayoutConstraint.activate([ episodeLabel.centerYAnchor.constraint(equalTo: dismissButton.centerYAnchor), episodeLabel.leadingAnchor.constraint(equalTo: dismissButton.trailingAnchor, constant: 12), @@ -508,17 +566,17 @@ class CustomMediaPlayerViewController: UIViewController { menuButton = UIButton(type: .system) menuButton.setImage(UIImage(systemName: "text.bubble"), for: .normal) menuButton.tintColor = .white - + if let subtitlesURL = subtitlesURL, !subtitlesURL.isEmpty { menuButton.showsMenuAsPrimaryAction = true menuButton.menu = buildOptionsMenu() } else { menuButton.isHidden = true } - + controlsContainerView.addSubview(menuButton) menuButton.translatesAutoresizingMaskIntoConstraints = false - + NSLayoutConstraint.activate([ menuButton.topAnchor.constraint(equalTo: controlsContainerView.topAnchor, constant: 20), menuButton.trailingAnchor.constraint(equalTo: speedButton.leadingAnchor, constant: -20), @@ -533,12 +591,11 @@ class CustomMediaPlayerViewController: UIViewController { speedButton.tintColor = .white speedButton.showsMenuAsPrimaryAction = true speedButton.menu = speedChangerMenu() - + controlsContainerView.addSubview(speedButton) speedButton.translatesAutoresizingMaskIntoConstraints = false - + NSLayoutConstraint.activate([ - // Middle speedButton.topAnchor.constraint(equalTo: controlsContainerView.topAnchor, constant: 20), speedButton.trailingAnchor.constraint(equalTo: qualityButton.leadingAnchor, constant: -20), speedButton.widthAnchor.constraint(equalToConstant: 40), @@ -607,10 +664,10 @@ class CustomMediaPlayerViewController: UIViewController { qualityButton.showsMenuAsPrimaryAction = true qualityButton.menu = qualitySelectionMenu() qualityButton.isHidden = true - + controlsContainerView.addSubview(qualityButton) qualityButton.translatesAutoresizingMaskIntoConstraints = false - + NSLayoutConstraint.activate([ qualityButton.topAnchor.constraint(equalTo: controlsContainerView.topAnchor, constant: 20), qualityButton.trailingAnchor.constraint(equalTo: controlsContainerView.trailingAnchor, constant: -20), @@ -618,7 +675,6 @@ class CustomMediaPlayerViewController: UIViewController { qualityButton.heightAnchor.constraint(equalToConstant: 40) ]) } - func updateSubtitleLabelAppearance() { subtitleLabel.font = UIFont.systemFont(ofSize: CGFloat(subtitleFontSize)) @@ -646,7 +702,9 @@ class CustomMediaPlayerViewController: UIViewController { func addTimeObserver() { let interval = CMTime(seconds: 1.0, preferredTimescale: CMTimeScale(NSEC_PER_SEC)) - timeObserverToken = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in + timeObserverToken = player.addPeriodicTimeObserver(forInterval: interval, + queue: .main) + { [weak self] time in guard let self = self, let currentItem = self.player.currentItem, currentItem.duration.seconds.isFinite else { return } @@ -657,6 +715,7 @@ class CustomMediaPlayerViewController: UIViewController { self.currentTimeVal = time.seconds self.duration = currentDuration + // If user is not dragging the slider, keep it in sync: if !self.isSliderEditing { self.sliderViewModel.sliderValue = max(0, min(self.currentTimeVal, self.duration)) } @@ -671,25 +730,33 @@ class CustomMediaPlayerViewController: UIViewController { self.subtitleLabel.text = "" } + // Rebuild the slider if needed (to ensure the SwiftUI view updates). DispatchQueue.main.async { self.sliderHostingController?.rootView = MusicProgressSlider( - value: Binding(get: { max(0, min(self.sliderViewModel.sliderValue, self.duration)) }, - set: { self.sliderViewModel.sliderValue = max(0, min($0, self.duration)) }), + value: Binding(get: { + max(0, min(self.sliderViewModel.sliderValue, self.duration)) + }, set: { + self.sliderViewModel.sliderValue = max(0, min($0, self.duration)) + }), inRange: 0...(self.duration > 0 ? self.duration : 1.0), + bufferValue: self.sliderViewModel.bufferValue, // pass buffer activeFillColor: .white, - fillColor: .white.opacity(0.5), + fillColor: .white.opacity(0.6), + bufferColor: .white.opacity(0.36), emptyColor: .white.opacity(0.3), height: 30, onEditingChanged: { editing in self.isSliderEditing = editing if !editing { - let seekTime = CMTime(seconds: self.sliderViewModel.sliderValue, preferredTimescale: 600) + let seekTime = CMTime(seconds: self.sliderViewModel.sliderValue, + preferredTimescale: 600) self.player.seek(to: seekTime) } } ) } + // Check whether to show/hide "Watch Next" button let isNearEnd = (self.duration - self.currentTimeVal) <= (self.duration * 0.10) && self.currentTimeVal != self.duration && self.showWatchNextButton @@ -738,8 +805,6 @@ class CustomMediaPlayerViewController: UIViewController { } } } - - func repositionWatchNextButton() { self.isWatchNextRepositioned = true @@ -754,7 +819,6 @@ class CustomMediaPlayerViewController: UIViewController { self.watchNextButtonTimer?.invalidate() self.watchNextButtonTimer = nil } - func startUpdateTimer() { updateTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in @@ -800,8 +864,11 @@ class CustomMediaPlayerViewController: UIViewController { let holdValue = UserDefaults.standard.double(forKey: "skipIncrementHold") let finalSkip = holdValue > 0 ? holdValue : 30 currentTimeVal = max(currentTimeVal - finalSkip, 0) - player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) - } + player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) { [weak self] finished in + guard let self = self else { return } + self.updateBufferValue() + } + } } @objc func seekForwardLongPress(_ gesture: UILongPressGestureRecognizer) { @@ -809,23 +876,32 @@ class CustomMediaPlayerViewController: UIViewController { let holdValue = UserDefaults.standard.double(forKey: "skipIncrementHold") let finalSkip = holdValue > 0 ? holdValue : 30 currentTimeVal = min(currentTimeVal + finalSkip, duration) - player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) - } + player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) { [weak self] finished in + guard let self = self else { return } + self.updateBufferValue() + } + } } @objc func seekBackward() { let skipValue = UserDefaults.standard.double(forKey: "skipIncrement") let finalSkip = skipValue > 0 ? skipValue : 10 currentTimeVal = max(currentTimeVal - finalSkip, 0) - player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) - } + player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) { [weak self] finished in + guard let self = self else { return } + self.updateBufferValue() + } + } @objc func seekForward() { let skipValue = UserDefaults.standard.double(forKey: "skipIncrement") let finalSkip = skipValue > 0 ? skipValue : 10 currentTimeVal = min(currentTimeVal + finalSkip, duration) - player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) - } + player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) { [weak self] finished in + guard let self = self else { return } + self.updateBufferValue() + } + } @objc func handleDoubleTap(_ gesture: UITapGestureRecognizer) { let tapLocation = gesture.location(in: view) @@ -837,7 +913,7 @@ class CustomMediaPlayerViewController: UIViewController { showSkipFeedback(direction: "forward") } } - + @objc func handleSwipeDown(_ gesture: UISwipeGestureRecognizer) { dismiss(animated: true, completion: nil) } @@ -861,11 +937,6 @@ class CustomMediaPlayerViewController: UIViewController { isPlaying.toggle() } - @objc func sliderEditingEnded() { - let newTime = sliderViewModel.sliderValue - player.seek(to: CMTime(seconds: newTime, preferredTimescale: 600)) - } - @objc func dismissTapped() { dismiss(animated: true, completion: nil) } @@ -899,10 +970,13 @@ class CustomMediaPlayerViewController: UIViewController { var request = URLRequest(url: url) request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Referer") request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Origin") - request.addValue("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36", forHTTPHeaderField: "User-Agent") + request.addValue("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36", + forHTTPHeaderField: "User-Agent") URLSession.shared.dataTask(with: request) { [weak self] data, response, error in - guard let self = self, let data = data, let content = String(data: data, encoding: .utf8) else { + guard let self = self, + let data = data, + let content = String(data: data, encoding: .utf8) else { print("Failed to load m3u8 file") DispatchQueue.main.async { self?.qualities = [] @@ -928,7 +1002,8 @@ class CustomMediaPlayerViewController: UIViewController { for (index, line) in lines.enumerated() { if line.contains("#EXT-X-STREAM-INF"), index + 1 < lines.count { if let resolutionRange = line.range(of: "RESOLUTION="), - let resolutionEndRange = line[resolutionRange.upperBound...].range(of: ",") ?? line[resolutionRange.upperBound...].range(of: "\n") { + let resolutionEndRange = line[resolutionRange.upperBound...].range(of: ",") + ?? line[resolutionRange.upperBound...].range(of: "\n") { let resolutionPart = String(line[resolutionRange.upperBound.. 0 @@ -980,13 +1057,13 @@ class CustomMediaPlayerViewController: UIViewController { var request = URLRequest(url: url) request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Referer") request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Origin") - request.addValue("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36", forHTTPHeaderField: "User-Agent") + request.addValue("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36", + forHTTPHeaderField: "User-Agent") let asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": request.allHTTPHeaderFields ?? [:]]) let playerItem = AVPlayerItem(asset: asset) player.replaceCurrentItem(with: playerItem) - player.seek(to: currentTime) if wasPlaying { player.play() @@ -998,7 +1075,10 @@ class CustomMediaPlayerViewController: UIViewController { qualityButton.menu = qualitySelectionMenu() if let selectedQuality = qualities.first(where: { $0.1 == urlString })?.0 { - DropManager.shared.showDrop(title: "Quality: \(selectedQuality)", subtitle: "", duration: 0.5, icon: UIImage(systemName: "eye")) + DropManager.shared.showDrop(title: "Quality: \(selectedQuality)", + subtitle: "", + duration: 0.5, + icon: UIImage(systemName: "eye")) } } @@ -1187,7 +1267,9 @@ class CustomMediaPlayerViewController: UIViewController { ] let paddingMenu = UIMenu(title: "Bottom Padding", children: paddingActions) - let subtitleOptionsMenu = UIMenu(title: "Subtitle Options", children: [subtitlesToggleAction, colorMenu, fontSizeMenu, shadowMenu, backgroundMenu, paddingMenu]) + let subtitleOptionsMenu = UIMenu(title: "Subtitle Options", children: [ + subtitlesToggleAction, colorMenu, fontSizeMenu, shadowMenu, backgroundMenu, paddingMenu + ]) menuElements = [subtitleOptionsMenu] } @@ -1265,7 +1347,6 @@ class CustomMediaPlayerViewController: UIViewController { let audioSession = AVAudioSession.sharedInstance() try audioSession.setCategory(.playback, mode: .moviePlayback, options: .mixWithOthers) try audioSession.setActive(true) - try audioSession.overrideOutputAudioPort(.speaker) } catch { Logger.shared.log("Failed to set up AVAudioSession: \(error)") @@ -1310,6 +1391,7 @@ class CustomMediaPlayerViewController: UIViewController { } } + // yes? Like the plural of the famous american rapper ye? -IBHRAD // low taper fade the meme is massive -cranci // cranci still doesnt have a job -seiike From b4ea1bc29b3fe21e523bd95324c6af0167577972 Mon Sep 17 00:00:00 2001 From: Seiike <122684677+Seeike@users.noreply.github.com> Date: Thu, 3 Apr 2025 15:36:33 +0200 Subject: [PATCH 08/10] perfect --- .../Components/MusicProgressSlider.swift | 7 +-- .../CustomPlayer/CustomPlayer.swift | 59 ++++++++++--------- 2 files changed, 33 insertions(+), 33 deletions(-) diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/Components/MusicProgressSlider.swift b/Sora/Utils/MediaPlayer/CustomPlayer/Components/MusicProgressSlider.swift index fcc7a2f..55ae983 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/Components/MusicProgressSlider.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/Components/MusicProgressSlider.swift @@ -5,7 +5,7 @@ // Created by Pratik on 08/01/23. // // Thanks to pratikg29 for this code inside his open source project "https://github.com/pratikg29/Custom-Slider-Control?ref=iosexample.com" -// I did edit just a little bit the code for my liking (added a buffer indicator, etc.) +// I did edit some of the code for my liking (added a buffer indicator, etc.) import SwiftUI @@ -156,30 +156,25 @@ import SwiftUI return (total > 0) ? (normalized / total) : 0 } - // Convert fraction to clamped 0...1 private func clampedFraction(_ f: T) -> T { max(0, min(f, 1)) } - // Convert fraction to actual time in seconds (or whatever T is) private func getCurrentValue() -> T { let total = inRange.upperBound - inRange.lowerBound let frac = clampedFraction(localRealProgress + localTempProgress) return frac * total + inRange.lowerBound } - // Clamp the final value to [lowerBound, upperBound] private func clampedValue(_ raw: T) -> T { max(inRange.lowerBound, min(raw, inRange.upperBound)) } - // The actual width of the played portion in pixels private func playedWidth(boundsWidth: CGFloat) -> CGFloat { let frac = fraction(of: value) return max(0, min(boundsWidth * CGFloat(frac), boundsWidth)) } - // The actual width of the buffered portion in pixels private func bufferWidth(boundsWidth: CGFloat) -> CGFloat { let frac = fraction(of: bufferValue) return max(0, min(boundsWidth * CGFloat(frac), boundsWidth)) diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift index 4949335..d160ddb 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift @@ -93,7 +93,6 @@ class CustomMediaPlayerViewController: UIViewController { } } - // We’ll use this context to KVO loadedTimeRanges private var playerItemKVOContext = 0 private var loadedTimeRangesObservation: NSKeyValueObservation? @@ -153,6 +152,7 @@ class CustomMediaPlayerViewController: UIViewController { setupPlayerViewController() setupControls() setupSkipAndDismissGestures() + addInvisibleControlOverlays() setupSubtitleLabel() setupDismissButton() setupQualityButton() @@ -200,26 +200,22 @@ class CustomMediaPlayerViewController: UIViewController { override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) - NotificationCenter.default.removeObserver(self, name: .AVPlayerItemNewAccessLogEntry, object: nil) - - // Remove KVO observer for loadedTimeRanges - player.currentItem?.removeObserver(self, - forKeyPath: "loadedTimeRanges", - context: &playerItemKVOContext) - - if let playbackSpeed = player?.rate { - UserDefaults.standard.set(playbackSpeed, forKey: "lastPlaybackSpeed") - } - player.pause() - updateTimer?.invalidate() - inactivityTimer?.invalidate() + loadedTimeRangesObservation?.invalidate() + loadedTimeRangesObservation = nil if let token = timeObserverToken { player.removeTimeObserver(token) timeObserverToken = nil } - UserDefaults.standard.set(player.rate, forKey: "lastPlaybackSpeed") + updateTimer?.invalidate() + inactivityTimer?.invalidate() + + player.pause() + + if let playbackSpeed = player?.rate { + UserDefaults.standard.set(playbackSpeed, forKey: "lastPlaybackSpeed") + } if let currentItem = player.currentItem, currentItem.duration.seconds > 0 { let progress = currentTimeVal / currentItem.duration.seconds @@ -238,7 +234,6 @@ class CustomMediaPlayerViewController: UIViewController { } } - // Observing loadedTimeRanges: override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, @@ -259,7 +254,6 @@ class CustomMediaPlayerViewController: UIViewController { if let timeRange = item.loadedTimeRanges.first?.timeRangeValue { let buffered = CMTimeGetSeconds(timeRange.start) + CMTimeGetSeconds(timeRange.duration) - // This is how many seconds are currently buffered: DispatchQueue.main.async { self.sliderViewModel.bufferValue = buffered } @@ -366,10 +360,10 @@ class CustomMediaPlayerViewController: UIViewController { value: Binding(get: { self.sliderViewModel.sliderValue }, set: { self.sliderViewModel.sliderValue = $0 }), inRange: 0...(duration > 0 ? duration : 1.0), - bufferValue: self.sliderViewModel.bufferValue, // <--- pass in buffer + bufferValue: self.sliderViewModel.bufferValue, activeFillColor: .white, fillColor: .white.opacity(0.5), - bufferColor: .white.opacity(0.2), // <--- buffer color + bufferColor: .white.opacity(0.2), emptyColor: .white.opacity(0.3), height: 30, onEditingChanged: { editing in @@ -411,6 +405,20 @@ class CustomMediaPlayerViewController: UIViewController { ]) } + + func addInvisibleControlOverlays() { + let playPauseOverlay = UIButton(type: .custom) + playPauseOverlay.backgroundColor = .clear + playPauseOverlay.addTarget(self, action: #selector(togglePlayPause), for: .touchUpInside) + view.addSubview(playPauseOverlay) + playPauseOverlay.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + playPauseOverlay.centerXAnchor.constraint(equalTo: playPauseButton.centerXAnchor), + playPauseOverlay.centerYAnchor.constraint(equalTo: playPauseButton.centerYAnchor), + playPauseOverlay.widthAnchor.constraint(equalTo: playPauseButton.widthAnchor, constant: 20), + playPauseOverlay.heightAnchor.constraint(equalTo: playPauseButton.heightAnchor, constant: 20) + ]) + } func setupSkipAndDismissGestures() { let doubleTapGesture = UITapGestureRecognizer(target: self, action: #selector(handleDoubleTap(_:))) @@ -715,7 +723,6 @@ class CustomMediaPlayerViewController: UIViewController { self.currentTimeVal = time.seconds self.duration = currentDuration - // If user is not dragging the slider, keep it in sync: if !self.isSliderEditing { self.sliderViewModel.sliderValue = max(0, min(self.currentTimeVal, self.duration)) } @@ -730,7 +737,6 @@ class CustomMediaPlayerViewController: UIViewController { self.subtitleLabel.text = "" } - // Rebuild the slider if needed (to ensure the SwiftUI view updates). DispatchQueue.main.async { self.sliderHostingController?.rootView = MusicProgressSlider( value: Binding(get: { @@ -739,24 +745,23 @@ class CustomMediaPlayerViewController: UIViewController { self.sliderViewModel.sliderValue = max(0, min($0, self.duration)) }), inRange: 0...(self.duration > 0 ? self.duration : 1.0), - bufferValue: self.sliderViewModel.bufferValue, // pass buffer + bufferValue: self.sliderViewModel.bufferValue, activeFillColor: .white, fillColor: .white.opacity(0.6), bufferColor: .white.opacity(0.36), emptyColor: .white.opacity(0.3), height: 30, onEditingChanged: { editing in - self.isSliderEditing = editing if !editing { - let seekTime = CMTime(seconds: self.sliderViewModel.sliderValue, - preferredTimescale: 600) - self.player.seek(to: seekTime) + let targetTime = CMTime(seconds: self.sliderViewModel.sliderValue, preferredTimescale: 600) + self.player.seek(to: targetTime) { [weak self] finished in + self?.updateBufferValue() + } } } ) } - // Check whether to show/hide "Watch Next" button let isNearEnd = (self.duration - self.currentTimeVal) <= (self.duration * 0.10) && self.currentTimeVal != self.duration && self.showWatchNextButton From fc261e06ab9ea93845a206f0aa29880f5daee3c9 Mon Sep 17 00:00:00 2001 From: Seiike <122684677+Seeike@users.noreply.github.com> Date: Thu, 3 Apr 2025 16:58:28 +0200 Subject: [PATCH 09/10] buttons are smaller for aesthetic --- .../CustomPlayer/CustomPlayer.swift | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift index d160ddb..11f7d9f 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift @@ -612,9 +612,13 @@ class CustomMediaPlayerViewController: UIViewController { } func setupWatchNextButton() { + let config = UIImage.SymbolConfiguration(pointSize: 14, weight: .regular) + let image = UIImage(systemName: "forward.fill", withConfiguration: config) + watchNextButton = UIButton(type: .system) watchNextButton.setTitle("Play Next", for: .normal) - watchNextButton.setImage(UIImage(systemName: "forward.fill"), for: .normal) + watchNextButton.titleLabel?.font = UIFont.systemFont(ofSize: 14) + watchNextButton.setImage(image, for: .normal) watchNextButton.tintColor = .black watchNextButton.backgroundColor = .white watchNextButton.layer.cornerRadius = 25 @@ -636,17 +640,21 @@ class CustomMediaPlayerViewController: UIViewController { watchNextButtonControlsConstraints = [ watchNextButton.trailingAnchor.constraint(equalTo: sliderHostingController!.view.trailingAnchor), watchNextButton.bottomAnchor.constraint(equalTo: sliderHostingController!.view.topAnchor, constant: -5), - watchNextButton.heightAnchor.constraint(equalToConstant: 50), - watchNextButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 120) + watchNextButton.heightAnchor.constraint(equalToConstant: 47), + watchNextButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 97) ] NSLayoutConstraint.activate(watchNextButtonNormalConstraints) } func setupSkip85Button() { + let config = UIImage.SymbolConfiguration(pointSize: 14, weight: .regular) + let image = UIImage(systemName: "goforward", withConfiguration: config) + skip85Button = UIButton(type: .system) skip85Button.setTitle("Skip 85s", for: .normal) - skip85Button.setImage(UIImage(systemName: "goforward"), for: .normal) + skip85Button.titleLabel?.font = UIFont.systemFont(ofSize: 14) + skip85Button.setImage(image, for: .normal) skip85Button.tintColor = .black skip85Button.backgroundColor = .white skip85Button.layer.cornerRadius = 25 @@ -660,8 +668,8 @@ class CustomMediaPlayerViewController: UIViewController { NSLayoutConstraint.activate([ skip85Button.leadingAnchor.constraint(equalTo: sliderHostingController!.view.leadingAnchor), skip85Button.bottomAnchor.constraint(equalTo: sliderHostingController!.view.topAnchor, constant: -5), - skip85Button.heightAnchor.constraint(equalToConstant: 50), - skip85Button.widthAnchor.constraint(greaterThanOrEqualToConstant: 120) + skip85Button.heightAnchor.constraint(equalToConstant: 47), + skip85Button.widthAnchor.constraint(greaterThanOrEqualToConstant: 97) ]) } From ce22ffa50e0f7f52404fb7c36bfae56127bfe26a Mon Sep 17 00:00:00 2001 From: Seiike <122684677+Seeike@users.noreply.github.com> Date: Thu, 3 Apr 2025 17:15:17 +0200 Subject: [PATCH 10/10] donezo --- .../MediaPlayer/CustomPlayer/CustomPlayer.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift index 11f7d9f..c8c198f 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift @@ -155,8 +155,8 @@ class CustomMediaPlayerViewController: UIViewController { addInvisibleControlOverlays() setupSubtitleLabel() setupDismissButton() - setupQualityButton() setupSpeedButton() + setupQualityButton() setupMenuButton() setupSkip85Button() setupWatchNextButton() @@ -587,7 +587,7 @@ class CustomMediaPlayerViewController: UIViewController { NSLayoutConstraint.activate([ menuButton.topAnchor.constraint(equalTo: controlsContainerView.topAnchor, constant: 20), - menuButton.trailingAnchor.constraint(equalTo: speedButton.leadingAnchor, constant: -20), + menuButton.trailingAnchor.constraint(equalTo: qualityButton.leadingAnchor, constant: -20), menuButton.widthAnchor.constraint(equalToConstant: 40), menuButton.heightAnchor.constraint(equalToConstant: 40) ]) @@ -605,7 +605,7 @@ class CustomMediaPlayerViewController: UIViewController { NSLayoutConstraint.activate([ speedButton.topAnchor.constraint(equalTo: controlsContainerView.topAnchor, constant: 20), - speedButton.trailingAnchor.constraint(equalTo: qualityButton.leadingAnchor, constant: -20), + speedButton.trailingAnchor.constraint(equalTo: controlsContainerView.trailingAnchor, constant: -20), speedButton.widthAnchor.constraint(equalToConstant: 40), speedButton.heightAnchor.constraint(equalToConstant: 40) ]) @@ -616,7 +616,7 @@ class CustomMediaPlayerViewController: UIViewController { let image = UIImage(systemName: "forward.fill", withConfiguration: config) watchNextButton = UIButton(type: .system) - watchNextButton.setTitle("Play Next", for: .normal) + watchNextButton.setTitle(" Play Next", for: .normal) watchNextButton.titleLabel?.font = UIFont.systemFont(ofSize: 14) watchNextButton.setImage(image, for: .normal) watchNextButton.tintColor = .black @@ -652,7 +652,7 @@ class CustomMediaPlayerViewController: UIViewController { let image = UIImage(systemName: "goforward", withConfiguration: config) skip85Button = UIButton(type: .system) - skip85Button.setTitle("Skip 85s", for: .normal) + skip85Button.setTitle(" Skip 85s", for: .normal) skip85Button.titleLabel?.font = UIFont.systemFont(ofSize: 14) skip85Button.setImage(image, for: .normal) skip85Button.tintColor = .black @@ -686,7 +686,7 @@ class CustomMediaPlayerViewController: UIViewController { NSLayoutConstraint.activate([ qualityButton.topAnchor.constraint(equalTo: controlsContainerView.topAnchor, constant: 20), - qualityButton.trailingAnchor.constraint(equalTo: controlsContainerView.trailingAnchor, constant: -20), + qualityButton.trailingAnchor.constraint(equalTo: speedButton.leadingAnchor, constant: -20), qualityButton.widthAnchor.constraint(equalToConstant: 40), qualityButton.heightAnchor.constraint(equalToConstant: 40) ])