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/37] 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/37] =?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 ee81815afa9d2e6ef1ea880ac62249d693cede09 Mon Sep 17 00:00:00 2001 From: Francesco <100066266+cranci1@users.noreply.github.com> Date: Sun, 30 Mar 2025 17:02:10 +0200 Subject: [PATCH 03/37] added Origin --- Sora/Utils/DownloadManager/DownloadManager.swift | 2 +- Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift | 3 +++ Sora/Utils/MediaPlayer/VideoPlayer.swift | 1 + 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/Sora/Utils/DownloadManager/DownloadManager.swift b/Sora/Utils/DownloadManager/DownloadManager.swift index d86e355..bb3d207 100644 --- a/Sora/Utils/DownloadManager/DownloadManager.swift +++ b/Sora/Utils/DownloadManager/DownloadManager.swift @@ -126,7 +126,7 @@ class DownloadManager { ffmpegCommand.append(contentsOf: ["-fflags", "+genpts"]) ffmpegCommand.append(contentsOf: ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5"]) - ffmpegCommand.append(contentsOf: ["-headers", "Referer: \(module.metadata.baseUrl)"]) + ffmpegCommand.append(contentsOf: ["-headers", "Referer: \(module.metadata.baseUrl)\nOrigin: \(module.metadata.baseUrl)"]) let multiThreads = UserDefaults.standard.bool(forKey: "multiThreads") if multiThreads { diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift index f6e1f71..f30a6f3 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift @@ -114,6 +114,7 @@ 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") let asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": request.allHTTPHeaderFields ?? [:]]) @@ -917,6 +918,7 @@ class CustomMediaPlayerViewController: UIViewController { private func parseM3U8(url: URL, completion: @escaping () -> Void) { 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") URLSession.shared.dataTask(with: request) { [weak self] data, response, error in @@ -997,6 +999,7 @@ 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") let asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": request.allHTTPHeaderFields ?? [:]]) diff --git a/Sora/Utils/MediaPlayer/VideoPlayer.swift b/Sora/Utils/MediaPlayer/VideoPlayer.swift index 3ee7d48..88a5314 100644 --- a/Sora/Utils/MediaPlayer/VideoPlayer.swift +++ b/Sora/Utils/MediaPlayer/VideoPlayer.swift @@ -40,6 +40,7 @@ class VideoPlayerViewController: 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") let asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": request.allHTTPHeaderFields ?? [:]]) From 6243d456ca77122768099fd3d84a9b493e598150 Mon Sep 17 00:00:00 2001 From: Francesco <100066266+cranci1@users.noreply.github.com> Date: Sun, 30 Mar 2025 17:25:39 +0200 Subject: [PATCH 04/37] made it use an Array if needed --- .../Utils/JSLoader/JSController-Details.swift | 185 +++++++ Sora/Utils/JSLoader/JSController-Search.swift | 129 +++++ .../Utils/JSLoader/JSController-Streams.swift | 307 +++++++++++ Sora/Utils/JSLoader/JSController.swift | 482 +----------------- Sora/Utils/Modules/Modules.swift | 2 + Sora/Views/MediaInfoView/MediaInfoView.swift | 25 +- Sulfur.xcodeproj/project.pbxproj | 12 + 7 files changed, 650 insertions(+), 492 deletions(-) create mode 100644 Sora/Utils/JSLoader/JSController-Details.swift create mode 100644 Sora/Utils/JSLoader/JSController-Search.swift create mode 100644 Sora/Utils/JSLoader/JSController-Streams.swift diff --git a/Sora/Utils/JSLoader/JSController-Details.swift b/Sora/Utils/JSLoader/JSController-Details.swift new file mode 100644 index 0000000..8ba89b9 --- /dev/null +++ b/Sora/Utils/JSLoader/JSController-Details.swift @@ -0,0 +1,185 @@ +// +// JSControllerDetails.swift +// Sulfur +// +// Created by Francesco on 30/03/25. +// + +import JavaScriptCore + +extension JSController { + + func fetchDetails(url: String, completion: @escaping ([MediaItem], [EpisodeLink]) -> Void) { + guard let url = URL(string: url) else { + completion([], []) + return + } + + URLSession.custom.dataTask(with: url) { [weak self] data, _, error in + guard let self = self else { return } + + if let error = error { + Logger.shared.log("Network error: \(error)",type: "Error") + DispatchQueue.main.async { completion([], []) } + return + } + + guard let data = data, let html = String(data: data, encoding: .utf8) else { + Logger.shared.log("Failed to decode HTML",type: "Error") + DispatchQueue.main.async { completion([], []) } + return + } + + var resultItems: [MediaItem] = [] + var episodeLinks: [EpisodeLink] = [] + + Logger.shared.log(html,type: "HTMLStrings") + if let parseFunction = self.context.objectForKeyedSubscript("extractDetails"), + let results = parseFunction.call(withArguments: [html]).toArray() as? [[String: String]] { + resultItems = results.map { item in + MediaItem( + description: item["description"] ?? "", + aliases: item["aliases"] ?? "", + airdate: item["airdate"] ?? "" + ) + } + } else { + Logger.shared.log("Failed to parse results",type: "Error") + } + + if let fetchEpisodesFunction = self.context.objectForKeyedSubscript("extractEpisodes"), + let episodesResult = fetchEpisodesFunction.call(withArguments: [html]).toArray() as? [[String: String]] { + for episodeData in episodesResult { + if let num = episodeData["number"], let link = episodeData["href"], let number = Int(num) { + episodeLinks.append(EpisodeLink(number: number, href: link)) + } + } + } + + DispatchQueue.main.async { + completion(resultItems, episodeLinks) + } + }.resume() + } + + func fetchDetailsJS(url: String, completion: @escaping ([MediaItem], [EpisodeLink]) -> Void) { + guard let url = URL(string: url) else { + completion([], []) + return + } + + if let exception = context.exception { + Logger.shared.log("JavaScript exception: \(exception)",type: "Error") + completion([], []) + return + } + + guard let extractDetailsFunction = context.objectForKeyedSubscript("extractDetails") else { + Logger.shared.log("No JavaScript function extractDetails found",type: "Error") + completion([], []) + return + } + + guard let extractEpisodesFunction = context.objectForKeyedSubscript("extractEpisodes") else { + Logger.shared.log("No JavaScript function extractEpisodes found",type: "Error") + completion([], []) + return + } + + var resultItems: [MediaItem] = [] + var episodeLinks: [EpisodeLink] = [] + + let dispatchGroup = DispatchGroup() + + dispatchGroup.enter() + let promiseValueDetails = extractDetailsFunction.call(withArguments: [url.absoluteString]) + guard let promiseDetails = promiseValueDetails else { + Logger.shared.log("extractDetails did not return a Promise",type: "Error") + completion([], []) + return + } + + let thenBlockDetails: @convention(block) (JSValue) -> Void = { result in + Logger.shared.log(result.toString(),type: "Debug") + if let jsonOfDetails = result.toString(), + let dataDetails = jsonOfDetails.data(using: .utf8) { + do { + if let array = try JSONSerialization.jsonObject(with: dataDetails, options: []) as? [[String: Any]] { + resultItems = array.map { item -> MediaItem in + MediaItem( + description: item["description"] as? String ?? "", + aliases: item["aliases"] as? String ?? "", + airdate: item["airdate"] as? String ?? "" + ) + } + } else { + Logger.shared.log("Failed to parse JSON of extractDetails",type: "Error") + } + } catch { + Logger.shared.log("JSON parsing error of extract details: \(error)",type: "Error") + } + } else { + Logger.shared.log("Result is not a string of extractDetails",type: "Error") + } + dispatchGroup.leave() + } + + let catchBlockDetails: @convention(block) (JSValue) -> Void = { error in + Logger.shared.log("Promise rejected of extractDetails: \(String(describing: error.toString()))",type: "Error") + dispatchGroup.leave() + } + + let thenFunctionDetails = JSValue(object: thenBlockDetails, in: context) + let catchFunctionDetails = JSValue(object: catchBlockDetails, in: context) + + promiseDetails.invokeMethod("then", withArguments: [thenFunctionDetails as Any]) + promiseDetails.invokeMethod("catch", withArguments: [catchFunctionDetails as Any]) + + dispatchGroup.enter() + let promiseValueEpisodes = extractEpisodesFunction.call(withArguments: [url.absoluteString]) + guard let promiseEpisodes = promiseValueEpisodes else { + Logger.shared.log("extractEpisodes did not return a Promise",type: "Error") + completion([], []) + return + } + + let thenBlockEpisodes: @convention(block) (JSValue) -> Void = { result in + Logger.shared.log(result.toString(),type: "Debug") + if let jsonOfEpisodes = result.toString(), + let dataEpisodes = jsonOfEpisodes.data(using: .utf8) { + do { + if let array = try JSONSerialization.jsonObject(with: dataEpisodes, options: []) as? [[String: Any]] { + episodeLinks = array.map { item -> EpisodeLink in + EpisodeLink( + number: item["number"] as? Int ?? 0, + href: item["href"] as? String ?? "" + ) + } + } else { + Logger.shared.log("Failed to parse JSON of extractEpisodes",type: "Error") + } + } catch { + Logger.shared.log("JSON parsing error of extractEpisodes: \(error)",type: "Error") + } + } else { + Logger.shared.log("Result is not a string of extractEpisodes",type: "Error") + } + dispatchGroup.leave() + } + + let catchBlockEpisodes: @convention(block) (JSValue) -> Void = { error in + Logger.shared.log("Promise rejected of extractEpisodes: \(String(describing: error.toString()))",type: "Error") + dispatchGroup.leave() + } + + let thenFunctionEpisodes = JSValue(object: thenBlockEpisodes, in: context) + let catchFunctionEpisodes = JSValue(object: catchBlockEpisodes, in: context) + + promiseEpisodes.invokeMethod("then", withArguments: [thenFunctionEpisodes as Any]) + promiseEpisodes.invokeMethod("catch", withArguments: [catchFunctionEpisodes as Any]) + + dispatchGroup.notify(queue: .main) { + completion(resultItems, episodeLinks) + } + } +} diff --git a/Sora/Utils/JSLoader/JSController-Search.swift b/Sora/Utils/JSLoader/JSController-Search.swift new file mode 100644 index 0000000..007019e --- /dev/null +++ b/Sora/Utils/JSLoader/JSController-Search.swift @@ -0,0 +1,129 @@ +// +// JSController-Search.swift +// Sulfur +// +// Created by Francesco on 30/03/25. +// + +import JavaScriptCore + +extension JSController { + + func fetchSearchResults(keyword: String, module: ScrapingModule, completion: @escaping ([SearchItem]) -> Void) { + let searchUrl = module.metadata.searchBaseUrl.replacingOccurrences(of: "%s", with: keyword.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "") + + guard let url = URL(string: searchUrl) else { + completion([]) + return + } + + URLSession.custom.dataTask(with: url) { [weak self] data, _, error in + guard let self = self else { return } + + if let error = error { + Logger.shared.log("Network error: \(error)",type: "Error") + DispatchQueue.main.async { completion([]) } + return + } + + guard let data = data, let html = String(data: data, encoding: .utf8) else { + Logger.shared.log("Failed to decode HTML",type: "Error") + DispatchQueue.main.async { completion([]) } + return + } + + Logger.shared.log(html,type: "HTMLStrings") + if let parseFunction = self.context.objectForKeyedSubscript("searchResults"), + let results = parseFunction.call(withArguments: [html]).toArray() as? [[String: String]] { + let resultItems = results.map { item in + SearchItem( + title: item["title"] ?? "", + imageUrl: item["image"] ?? "", + href: item["href"] ?? "" + ) + } + DispatchQueue.main.async { + completion(resultItems) + } + } else { + Logger.shared.log("Failed to parse results",type: "Error") + DispatchQueue.main.async { completion([]) } + } + }.resume() + } + + func fetchJsSearchResults(keyword: String, module: ScrapingModule, completion: @escaping ([SearchItem]) -> Void) { + if let exception = context.exception { + Logger.shared.log("JavaScript exception: \(exception)",type: "Error") + completion([]) + return + } + + guard let searchResultsFunction = context.objectForKeyedSubscript("searchResults") else { + Logger.shared.log("No JavaScript function searchResults found",type: "Error") + completion([]) + return + } + + let promiseValue = searchResultsFunction.call(withArguments: [keyword]) + guard let promise = promiseValue else { + Logger.shared.log("searchResults did not return a Promise",type: "Error") + completion([]) + return + } + + let thenBlock: @convention(block) (JSValue) -> Void = { result in + + Logger.shared.log(result.toString(),type: "HTMLStrings") + if let jsonString = result.toString(), + let data = jsonString.data(using: .utf8) { + do { + if let array = try JSONSerialization.jsonObject(with: data, options: []) as? [[String: Any]] { + let resultItems = array.compactMap { item -> SearchItem? in + guard let title = item["title"] as? String, + let imageUrl = item["image"] as? String, + let href = item["href"] as? String else { + Logger.shared.log("Missing or invalid data in search result item: \(item)", type: "Error") + return nil + } + return SearchItem(title: title, imageUrl: imageUrl, href: href) + } + + DispatchQueue.main.async { + completion(resultItems) + } + + } else { + Logger.shared.log("Failed to parse JSON",type: "Error") + DispatchQueue.main.async { + completion([]) + } + } + } catch { + Logger.shared.log("JSON parsing error: \(error)",type: "Error") + DispatchQueue.main.async { + completion([]) + } + } + } else { + Logger.shared.log("Result is not a string",type: "Error") + DispatchQueue.main.async { + completion([]) + } + } + } + + let catchBlock: @convention(block) (JSValue) -> Void = { error in + Logger.shared.log("Promise rejected: \(String(describing: error.toString()))",type: "Error") + DispatchQueue.main.async { + completion([]) + } + } + + let thenFunction = JSValue(object: thenBlock, in: context) + let catchFunction = JSValue(object: catchBlock, in: context) + + promise.invokeMethod("then", withArguments: [thenFunction as Any]) + promise.invokeMethod("catch", withArguments: [catchFunction as Any]) + } +} diff --git a/Sora/Utils/JSLoader/JSController-Streams.swift b/Sora/Utils/JSLoader/JSController-Streams.swift new file mode 100644 index 0000000..58a5148 --- /dev/null +++ b/Sora/Utils/JSLoader/JSController-Streams.swift @@ -0,0 +1,307 @@ +// +// JSLoader-Streams.swift +// Sulfur +// +// Created by Francesco on 30/03/25. +// + +import JavaScriptCore + +extension JSController { + + func fetchStreamUrl(episodeUrl: String, softsub: Bool = false, completion: @escaping ((streams: [String]?, subtitles: [String]?)) -> Void) { + guard let url = URL(string: episodeUrl) else { + completion((nil, nil)) + return + } + + URLSession.custom.dataTask(with: url) { [weak self] data, _, error in + guard let self = self else { return } + + if let error = error { + Logger.shared.log("Network error: \(error)", type: "Error") + DispatchQueue.main.async { completion((nil, nil)) } + return + } + + guard let data = data, let html = String(data: data, encoding: .utf8) else { + Logger.shared.log("Failed to decode HTML", type: "Error") + DispatchQueue.main.async { completion((nil, nil)) } + return + } + + Logger.shared.log(html, type: "HTMLStrings") + if let parseFunction = self.context.objectForKeyedSubscript("extractStreamUrl"), + let resultString = parseFunction.call(withArguments: [html]).toString() { + if softsub { + if let data = resultString.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] { + let moduleMetadata = self.context.objectForKeyedSubscript("module")?.objectForKeyedSubscript("metadata") + let isMultiStream = moduleMetadata?.objectForKeyedSubscript("multiStream")?.toBool() == true + let isMultiSubs = moduleMetadata?.objectForKeyedSubscript("multiSubs")?.toBool() == true + + var streamUrls: [String]? + if isMultiStream, let streamsArray = json["streams"] as? [String] { + streamUrls = streamsArray + Logger.shared.log("Found \(streamsArray.count) streams", type: "Stream") + } else if let streamUrl = json["stream"] as? String { + streamUrls = [streamUrl] + Logger.shared.log("Found single stream", type: "Stream") + } + + var subtitleUrls: [String]? + if isMultiSubs, let subsArray = json["subtitles"] as? [String] { + subtitleUrls = subsArray + Logger.shared.log("Found \(subsArray.count) subtitle tracks", type: "Stream") + } else if let subtitleUrl = json["subtitles"] as? String { + subtitleUrls = [subtitleUrl] + Logger.shared.log("Found single subtitle track", type: "Stream") + } + + Logger.shared.log("Starting stream with \(streamUrls?.count ?? 0) sources and \(subtitleUrls?.count ?? 0) subtitles", type: "Stream") + DispatchQueue.main.async { + completion((streamUrls, subtitleUrls)) + } + } else { + Logger.shared.log("Failed to parse softsub JSON", type: "Error") + DispatchQueue.main.async { completion((nil, nil)) } + } + } else { + let moduleMetadata = self.context.objectForKeyedSubscript("module")?.objectForKeyedSubscript("metadata") + let isMultiStream = moduleMetadata?.objectForKeyedSubscript("multiStream")?.toBool() == true + + if isMultiStream { + if let data = resultString.data(using: .utf8), + let streamsArray = try? JSONSerialization.jsonObject(with: data, options: []) as? [String] { + Logger.shared.log("Starting multi-stream with \(streamsArray.count) sources", type: "Stream") + DispatchQueue.main.async { completion((streamsArray, nil)) } + } else { + Logger.shared.log("Failed to parse multi-stream JSON array", type: "Error") + DispatchQueue.main.async { completion((nil, nil)) } + } + } else { + Logger.shared.log("Starting stream from: \(resultString)", type: "Stream") + DispatchQueue.main.async { completion(([resultString], nil)) } + } + } + } else { + Logger.shared.log("Failed to extract stream URL", type: "Error") + DispatchQueue.main.async { completion((nil, nil)) } + } + }.resume() + } + + func fetchStreamUrlJS(episodeUrl: String, softsub: Bool = false, completion: @escaping ((streams: [String]?, subtitles: [String]?)) -> Void) { + if let exception = context.exception { + Logger.shared.log("JavaScript exception: \(exception)", type: "Error") + completion((nil, nil)) + return + } + + guard let extractStreamUrlFunction = context.objectForKeyedSubscript("extractStreamUrl") else { + Logger.shared.log("No JavaScript function extractStreamUrl found", type: "Error") + completion((nil, nil)) + return + } + + let promiseValue = extractStreamUrlFunction.call(withArguments: [episodeUrl]) + guard let promise = promiseValue else { + Logger.shared.log("extractStreamUrl did not return a Promise", type: "Error") + completion((nil, nil)) + return + } + + let thenBlock: @convention(block) (JSValue) -> Void = { [weak self] result in + guard let self = self else { return } + + if softsub { + if let jsonString = result.toString(), + let data = jsonString.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] { + let moduleMetadata = self.context.objectForKeyedSubscript("module")?.objectForKeyedSubscript("metadata") + let isMultiStream = moduleMetadata?.objectForKeyedSubscript("multiStream")?.toBool() == true + let isMultiSubs = moduleMetadata?.objectForKeyedSubscript("multiSubs")?.toBool() == true + + var streamUrls: [String]? + if isMultiStream, let streamsArray = json["streams"] as? [String] { + streamUrls = streamsArray + Logger.shared.log("Found \(streamsArray.count) streams", type: "Stream") + } else if let streamUrl = json["stream"] as? String { + streamUrls = [streamUrl] + Logger.shared.log("Found single stream", type: "Stream") + } + + var subtitleUrls: [String]? + if isMultiSubs, let subsArray = json["subtitles"] as? [String] { + subtitleUrls = subsArray + Logger.shared.log("Found \(subsArray.count) subtitle tracks", type: "Stream") + } else if let subtitleUrl = json["subtitles"] as? String { + subtitleUrls = [subtitleUrl] + Logger.shared.log("Found single subtitle track", type: "Stream") + } + + Logger.shared.log("Starting stream with \(streamUrls?.count ?? 0) sources and \(subtitleUrls?.count ?? 0) subtitles", type: "Stream") + DispatchQueue.main.async { + completion((streamUrls, subtitleUrls)) + } + } else { + Logger.shared.log("Failed to parse softsub JSON in JS", type: "Error") + DispatchQueue.main.async { + completion((nil, nil)) + } + } + } else { + let moduleMetadata = self.context.objectForKeyedSubscript("module")?.objectForKeyedSubscript("metadata") + let isMultiStream = moduleMetadata?.objectForKeyedSubscript("multiStream")?.toBool() == true + + if isMultiStream { + if let jsonString = result.toString(), + let data = jsonString.data(using: .utf8), + let streamsArray = try? JSONSerialization.jsonObject(with: data, options: []) as? [String] { + Logger.shared.log("Starting multi-stream with \(streamsArray.count) sources", type: "Stream") + DispatchQueue.main.async { completion((streamsArray, nil)) } + } else { + Logger.shared.log("Failed to parse multi-stream JSON array", type: "Error") + DispatchQueue.main.async { completion((nil, nil)) } + } + } else { + let streamUrl = result.toString() + Logger.shared.log("Starting stream from: \(streamUrl ?? "nil")", type: "Stream") + DispatchQueue.main.async { + completion((streamUrl != nil ? [streamUrl!] : nil, nil)) + } + } + } + } + + let catchBlock: @convention(block) (JSValue) -> Void = { error in + Logger.shared.log("Promise rejected: \(String(describing: error.toString()))", type: "Error") + DispatchQueue.main.async { + completion((nil, nil)) + } + } + + let thenFunction = JSValue(object: thenBlock, in: context) + let catchFunction = JSValue(object: catchBlock, in: context) + + promise.invokeMethod("then", withArguments: [thenFunction as Any]) + promise.invokeMethod("catch", withArguments: [catchFunction as Any]) + } + + func fetchStreamUrlJSSecond(episodeUrl: String, softsub: Bool = false, completion: @escaping ((streams: [String]?, subtitles: [String]?)) -> Void) { + let url = URL(string: episodeUrl)! + let task = URLSession.custom.dataTask(with: url) { [weak self] data, response, error in + guard let self = self else { return } + + if let error = error { + Logger.shared.log("URLSession error: \(error.localizedDescription)", type: "Error") + DispatchQueue.main.async { completion((nil, nil)) } + return + } + + guard let data = data, let htmlString = String(data: data, encoding: .utf8) else { + Logger.shared.log("Failed to fetch HTML data", type: "Error") + DispatchQueue.main.async { completion((nil, nil)) } + return + } + + DispatchQueue.main.async { + if let exception = self.context.exception { + Logger.shared.log("JavaScript exception: \(exception)", type: "Error") + completion((nil, nil)) + return + } + + guard let extractStreamUrlFunction = self.context.objectForKeyedSubscript("extractStreamUrl") else { + Logger.shared.log("No JavaScript function extractStreamUrl found", type: "Error") + completion((nil, nil)) + return + } + + let promiseValue = extractStreamUrlFunction.call(withArguments: [htmlString]) + guard let promise = promiseValue else { + Logger.shared.log("extractStreamUrl did not return a Promise", type: "Error") + completion((nil, nil)) + return + } + + let thenBlock: @convention(block) (JSValue) -> Void = { [weak self] result in + guard let self = self else { return } + + if softsub { + if let jsonString = result.toString(), + let data = jsonString.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] { + let moduleMetadata = self.context.objectForKeyedSubscript("module")?.objectForKeyedSubscript("metadata") + let isMultiStream = moduleMetadata?.objectForKeyedSubscript("multiStream")?.toBool() == true + let isMultiSubs = moduleMetadata?.objectForKeyedSubscript("multiSubs")?.toBool() == true + + var streamUrls: [String]? + if isMultiStream, let streamsArray = json["streams"] as? [String] { + streamUrls = streamsArray + Logger.shared.log("Found \(streamsArray.count) streams", type: "Stream") + } else if let streamUrl = json["stream"] as? String { + streamUrls = [streamUrl] + Logger.shared.log("Found single stream", type: "Stream") + } + + var subtitleUrls: [String]? + if isMultiSubs, let subsArray = json["subtitles"] as? [String] { + subtitleUrls = subsArray + Logger.shared.log("Found \(subsArray.count) subtitle tracks", type: "Stream") + } else if let subtitleUrl = json["subtitles"] as? String { + subtitleUrls = [subtitleUrl] + Logger.shared.log("Found single subtitle track", type: "Stream") + } + + Logger.shared.log("Starting stream with \(streamUrls?.count ?? 0) sources and \(subtitleUrls?.count ?? 0) subtitles", type: "Stream") + DispatchQueue.main.async { + completion((streamUrls, subtitleUrls)) + } + } else { + Logger.shared.log("Failed to parse softsub JSON in JSSecond", type: "Error") + DispatchQueue.main.async { + completion((nil, nil)) + } + } + } else { + let moduleMetadata = self.context.objectForKeyedSubscript("module")?.objectForKeyedSubscript("metadata") + let isMultiStream = moduleMetadata?.objectForKeyedSubscript("multiStream")?.toBool() == true + + if isMultiStream { + if let jsonString = result.toString(), + let data = jsonString.data(using: .utf8), + let streamsArray = try? JSONSerialization.jsonObject(with: data, options: []) as? [String] { + Logger.shared.log("Starting multi-stream with \(streamsArray.count) sources", type: "Stream") + DispatchQueue.main.async { completion((streamsArray, nil)) } + } else { + Logger.shared.log("Failed to parse multi-stream JSON array", type: "Error") + DispatchQueue.main.async { completion((nil, nil)) } + } + } else { + let streamUrl = result.toString() + Logger.shared.log("Starting stream from: \(streamUrl ?? "nil")", type: "Stream") + DispatchQueue.main.async { + completion((streamUrl != nil ? [streamUrl!] : nil, nil)) + } + } + } + } + + let catchBlock: @convention(block) (JSValue) -> Void = { error in + Logger.shared.log("Promise rejected: \(String(describing: error.toString()))", type: "Error") + DispatchQueue.main.async { + completion((nil, nil)) + } + } + + let thenFunction = JSValue(object: thenBlock, in: self.context) + let catchFunction = JSValue(object: catchBlock, in: self.context) + + promise.invokeMethod("then", withArguments: [thenFunction as Any]) + promise.invokeMethod("catch", withArguments: [catchFunction as Any]) + } + } + task.resume() + } +} diff --git a/Sora/Utils/JSLoader/JSController.swift b/Sora/Utils/JSLoader/JSController.swift index a0a220b..2485ded 100644 --- a/Sora/Utils/JSLoader/JSController.swift +++ b/Sora/Utils/JSLoader/JSController.swift @@ -8,14 +8,14 @@ import JavaScriptCore class JSController: ObservableObject { - private var context: JSContext + var context: JSContext init() { self.context = JSContext() setupContext() } - private func setupContext() { + func setupContext() { context.setupJavaScriptEnvironment() } @@ -27,482 +27,4 @@ class JSController: ObservableObject { Logger.shared.log("Error loading script: \(exception)", type: "Error") } } - - func fetchSearchResults(keyword: String, module: ScrapingModule, completion: @escaping ([SearchItem]) -> Void) { - let searchUrl = module.metadata.searchBaseUrl.replacingOccurrences(of: "%s", with: keyword.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "") - - guard let url = URL(string: searchUrl) else { - completion([]) - return - } - - URLSession.custom.dataTask(with: url) { [weak self] data, _, error in - guard let self = self else { return } - - if let error = error { - Logger.shared.log("Network error: \(error)",type: "Error") - DispatchQueue.main.async { completion([]) } - return - } - - guard let data = data, let html = String(data: data, encoding: .utf8) else { - Logger.shared.log("Failed to decode HTML",type: "Error") - DispatchQueue.main.async { completion([]) } - return - } - - Logger.shared.log(html,type: "HTMLStrings") - if let parseFunction = self.context.objectForKeyedSubscript("searchResults"), - let results = parseFunction.call(withArguments: [html]).toArray() as? [[String: String]] { - let resultItems = results.map { item in - SearchItem( - title: item["title"] ?? "", - imageUrl: item["image"] ?? "", - href: item["href"] ?? "" - ) - } - DispatchQueue.main.async { - completion(resultItems) - } - } else { - Logger.shared.log("Failed to parse results",type: "Error") - DispatchQueue.main.async { completion([]) } - } - }.resume() - } - - func fetchDetails(url: String, completion: @escaping ([MediaItem], [EpisodeLink]) -> Void) { - guard let url = URL(string: url) else { - completion([], []) - return - } - - URLSession.custom.dataTask(with: url) { [weak self] data, _, error in - guard let self = self else { return } - - if let error = error { - Logger.shared.log("Network error: \(error)",type: "Error") - DispatchQueue.main.async { completion([], []) } - return - } - - guard let data = data, let html = String(data: data, encoding: .utf8) else { - Logger.shared.log("Failed to decode HTML",type: "Error") - DispatchQueue.main.async { completion([], []) } - return - } - - var resultItems: [MediaItem] = [] - var episodeLinks: [EpisodeLink] = [] - - Logger.shared.log(html,type: "HTMLStrings") - if let parseFunction = self.context.objectForKeyedSubscript("extractDetails"), - let results = parseFunction.call(withArguments: [html]).toArray() as? [[String: String]] { - resultItems = results.map { item in - MediaItem( - description: item["description"] ?? "", - aliases: item["aliases"] ?? "", - airdate: item["airdate"] ?? "" - ) - } - } else { - Logger.shared.log("Failed to parse results",type: "Error") - } - - if let fetchEpisodesFunction = self.context.objectForKeyedSubscript("extractEpisodes"), - let episodesResult = fetchEpisodesFunction.call(withArguments: [html]).toArray() as? [[String: String]] { - for episodeData in episodesResult { - if let num = episodeData["number"], let link = episodeData["href"], let number = Int(num) { - episodeLinks.append(EpisodeLink(number: number, href: link)) - } - } - } - - DispatchQueue.main.async { - completion(resultItems, episodeLinks) - } - }.resume() - } - - func fetchStreamUrl(episodeUrl: String, softsub: Bool = false, completion: @escaping ((stream: String?, subtitles: String?)) -> Void) { - guard let url = URL(string: episodeUrl) else { - completion((nil, nil)) - return - } - - URLSession.custom.dataTask(with: url) { [weak self] data, _, error in - guard let self = self else { return } - - if let error = error { - Logger.shared.log("Network error: \(error)", type: "Error") - DispatchQueue.main.async { completion((nil, nil)) } - return - } - - guard let data = data, let html = String(data: data, encoding: .utf8) else { - Logger.shared.log("Failed to decode HTML", type: "Error") - DispatchQueue.main.async { completion((nil, nil)) } - return - } - - Logger.shared.log(html, type: "HTMLStrings") - if let parseFunction = self.context.objectForKeyedSubscript("extractStreamUrl"), - let resultString = parseFunction.call(withArguments: [html]).toString() { - if softsub { - if let data = resultString.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] { - let streamUrl = json["stream"] as? String - let subtitlesUrl = json["subtitles"] as? String - Logger.shared.log("Starting stream from: \(streamUrl ?? "nil") with subtitles: \(subtitlesUrl ?? "nil")", type: "Stream") - DispatchQueue.main.async { - completion((streamUrl, subtitlesUrl)) - } - } else { - Logger.shared.log("Failed to parse softsub JSON", type: "Error") - DispatchQueue.main.async { completion((nil, nil)) } - } - } else { - Logger.shared.log("Starting stream from: \(resultString)", type: "Stream") - DispatchQueue.main.async { completion((resultString, nil)) } - } - } else { - Logger.shared.log("Failed to extract stream URL", type: "Error") - DispatchQueue.main.async { completion((nil, nil)) } - } - }.resume() - } - - func fetchJsSearchResults(keyword: String, module: ScrapingModule, completion: @escaping ([SearchItem]) -> Void) { - if let exception = context.exception { - Logger.shared.log("JavaScript exception: \(exception)",type: "Error") - completion([]) - return - } - - guard let searchResultsFunction = context.objectForKeyedSubscript("searchResults") else { - Logger.shared.log("No JavaScript function searchResults found",type: "Error") - completion([]) - return - } - - let promiseValue = searchResultsFunction.call(withArguments: [keyword]) - guard let promise = promiseValue else { - Logger.shared.log("searchResults did not return a Promise",type: "Error") - completion([]) - return - } - - let thenBlock: @convention(block) (JSValue) -> Void = { result in - - Logger.shared.log(result.toString(),type: "HTMLStrings") - if let jsonString = result.toString(), - let data = jsonString.data(using: .utf8) { - do { - if let array = try JSONSerialization.jsonObject(with: data, options: []) as? [[String: Any]] { - let resultItems = array.compactMap { item -> SearchItem? in - guard let title = item["title"] as? String, - let imageUrl = item["image"] as? String, - let href = item["href"] as? String else { - Logger.shared.log("Missing or invalid data in search result item: \(item)", type: "Error") - return nil - } - return SearchItem(title: title, imageUrl: imageUrl, href: href) - } - - DispatchQueue.main.async { - completion(resultItems) - } - - } else { - Logger.shared.log("Failed to parse JSON",type: "Error") - DispatchQueue.main.async { - completion([]) - } - } - } catch { - Logger.shared.log("JSON parsing error: \(error)",type: "Error") - DispatchQueue.main.async { - completion([]) - } - } - } else { - Logger.shared.log("Result is not a string",type: "Error") - DispatchQueue.main.async { - completion([]) - } - } - } - - let catchBlock: @convention(block) (JSValue) -> Void = { error in - Logger.shared.log("Promise rejected: \(String(describing: error.toString()))",type: "Error") - DispatchQueue.main.async { - completion([]) - } - } - - let thenFunction = JSValue(object: thenBlock, in: context) - let catchFunction = JSValue(object: catchBlock, in: context) - - promise.invokeMethod("then", withArguments: [thenFunction as Any]) - promise.invokeMethod("catch", withArguments: [catchFunction as Any]) - } - - func fetchDetailsJS(url: String, completion: @escaping ([MediaItem], [EpisodeLink]) -> Void) { - guard let url = URL(string: url) else { - completion([], []) - return - } - - if let exception = context.exception { - Logger.shared.log("JavaScript exception: \(exception)",type: "Error") - completion([], []) - return - } - - guard let extractDetailsFunction = context.objectForKeyedSubscript("extractDetails") else { - Logger.shared.log("No JavaScript function extractDetails found",type: "Error") - completion([], []) - return - } - - guard let extractEpisodesFunction = context.objectForKeyedSubscript("extractEpisodes") else { - Logger.shared.log("No JavaScript function extractEpisodes found",type: "Error") - completion([], []) - return - } - - var resultItems: [MediaItem] = [] - var episodeLinks: [EpisodeLink] = [] - - let dispatchGroup = DispatchGroup() - - dispatchGroup.enter() - let promiseValueDetails = extractDetailsFunction.call(withArguments: [url.absoluteString]) - guard let promiseDetails = promiseValueDetails else { - Logger.shared.log("extractDetails did not return a Promise",type: "Error") - completion([], []) - return - } - - let thenBlockDetails: @convention(block) (JSValue) -> Void = { result in - Logger.shared.log(result.toString(),type: "Debug") - if let jsonOfDetails = result.toString(), - let dataDetails = jsonOfDetails.data(using: .utf8) { - do { - if let array = try JSONSerialization.jsonObject(with: dataDetails, options: []) as? [[String: Any]] { - resultItems = array.map { item -> MediaItem in - MediaItem( - description: item["description"] as? String ?? "", - aliases: item["aliases"] as? String ?? "", - airdate: item["airdate"] as? String ?? "" - ) - } - } else { - Logger.shared.log("Failed to parse JSON of extractDetails",type: "Error") - } - } catch { - Logger.shared.log("JSON parsing error of extract details: \(error)",type: "Error") - } - } else { - Logger.shared.log("Result is not a string of extractDetails",type: "Error") - } - dispatchGroup.leave() - } - - let catchBlockDetails: @convention(block) (JSValue) -> Void = { error in - Logger.shared.log("Promise rejected of extractDetails: \(String(describing: error.toString()))",type: "Error") - dispatchGroup.leave() - } - - let thenFunctionDetails = JSValue(object: thenBlockDetails, in: context) - let catchFunctionDetails = JSValue(object: catchBlockDetails, in: context) - - promiseDetails.invokeMethod("then", withArguments: [thenFunctionDetails as Any]) - promiseDetails.invokeMethod("catch", withArguments: [catchFunctionDetails as Any]) - - dispatchGroup.enter() - let promiseValueEpisodes = extractEpisodesFunction.call(withArguments: [url.absoluteString]) - guard let promiseEpisodes = promiseValueEpisodes else { - Logger.shared.log("extractEpisodes did not return a Promise",type: "Error") - completion([], []) - return - } - - let thenBlockEpisodes: @convention(block) (JSValue) -> Void = { result in - Logger.shared.log(result.toString(),type: "Debug") - if let jsonOfEpisodes = result.toString(), - let dataEpisodes = jsonOfEpisodes.data(using: .utf8) { - do { - if let array = try JSONSerialization.jsonObject(with: dataEpisodes, options: []) as? [[String: Any]] { - episodeLinks = array.map { item -> EpisodeLink in - EpisodeLink( - number: item["number"] as? Int ?? 0, - href: item["href"] as? String ?? "" - ) - } - } else { - Logger.shared.log("Failed to parse JSON of extractEpisodes",type: "Error") - } - } catch { - Logger.shared.log("JSON parsing error of extractEpisodes: \(error)",type: "Error") - } - } else { - Logger.shared.log("Result is not a string of extractEpisodes",type: "Error") - } - dispatchGroup.leave() - } - - let catchBlockEpisodes: @convention(block) (JSValue) -> Void = { error in - Logger.shared.log("Promise rejected of extractEpisodes: \(String(describing: error.toString()))",type: "Error") - dispatchGroup.leave() - } - - let thenFunctionEpisodes = JSValue(object: thenBlockEpisodes, in: context) - let catchFunctionEpisodes = JSValue(object: catchBlockEpisodes, in: context) - - promiseEpisodes.invokeMethod("then", withArguments: [thenFunctionEpisodes as Any]) - promiseEpisodes.invokeMethod("catch", withArguments: [catchFunctionEpisodes as Any]) - - dispatchGroup.notify(queue: .main) { - completion(resultItems, episodeLinks) - } - } - - func fetchStreamUrlJS(episodeUrl: String, softsub: Bool = false, completion: @escaping ((stream: String?, subtitles: String?)) -> Void) { - if let exception = context.exception { - Logger.shared.log("JavaScript exception: \(exception)", type: "Error") - completion((nil, nil)) - return - } - - guard let extractStreamUrlFunction = context.objectForKeyedSubscript("extractStreamUrl") else { - Logger.shared.log("No JavaScript function extractStreamUrl found", type: "Error") - completion((nil, nil)) - return - } - - let promiseValue = extractStreamUrlFunction.call(withArguments: [episodeUrl]) - guard let promise = promiseValue else { - Logger.shared.log("extractStreamUrl did not return a Promise", type: "Error") - completion((nil, nil)) - return - } - - let thenBlock: @convention(block) (JSValue) -> Void = { result in - if softsub { - if let jsonString = result.toString(), - let data = jsonString.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] { - let streamUrl = json["stream"] as? String - let subtitlesUrl = json["subtitles"] as? String - Logger.shared.log("Starting stream from: \(streamUrl ?? "nil") with subtitles: \(subtitlesUrl ?? "nil")", type: "Stream") - DispatchQueue.main.async { - completion((streamUrl, subtitlesUrl)) - } - } else { - Logger.shared.log("Failed to parse softsub JSON in JS", type: "Error") - DispatchQueue.main.async { - completion((nil, nil)) - } - } - } else { - let streamUrl = result.toString() - Logger.shared.log("Starting stream from: \(streamUrl ?? "nil")", type: "Stream") - DispatchQueue.main.async { - completion((streamUrl, nil)) - } - } - } - - let catchBlock: @convention(block) (JSValue) -> Void = { error in - Logger.shared.log("Promise rejected: \(String(describing: error.toString()))", type: "Error") - DispatchQueue.main.async { - completion((nil, nil)) - } - } - - let thenFunction = JSValue(object: thenBlock, in: context) - let catchFunction = JSValue(object: catchBlock, in: context) - - promise.invokeMethod("then", withArguments: [thenFunction as Any]) - promise.invokeMethod("catch", withArguments: [catchFunction as Any]) - } - - func fetchStreamUrlJSSecond(episodeUrl: String, softsub: Bool = false, completion: @escaping ((stream: String?, subtitles: String?)) -> Void) { - let url = URL(string: episodeUrl)! - let task = URLSession.custom.dataTask(with: url) { data, response, error in - if let error = error { - Logger.shared.log("URLSession error: \(error.localizedDescription)", type: "Error") - DispatchQueue.main.async { completion((nil, nil)) } - return - } - - guard let data = data, let htmlString = String(data: data, encoding: .utf8) else { - Logger.shared.log("Failed to fetch HTML data", type: "Error") - DispatchQueue.main.async { completion((nil, nil)) } - return - } - - DispatchQueue.main.async { - if let exception = self.context.exception { - Logger.shared.log("JavaScript exception: \(exception)", type: "Error") - completion((nil, nil)) - return - } - - guard let extractStreamUrlFunction = self.context.objectForKeyedSubscript("extractStreamUrl") else { - Logger.shared.log("No JavaScript function extractStreamUrl found", type: "Error") - completion((nil, nil)) - return - } - - let promiseValue = extractStreamUrlFunction.call(withArguments: [htmlString]) - guard let promise = promiseValue else { - Logger.shared.log("extractStreamUrl did not return a Promise", type: "Error") - completion((nil, nil)) - return - } - - let thenBlock: @convention(block) (JSValue) -> Void = { result in - if softsub { - if let jsonString = result.toString(), - let data = jsonString.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] { - let streamUrl = json["stream"] as? String - let subtitlesUrl = json["subtitles"] as? String - Logger.shared.log("Starting stream from: \(streamUrl ?? "nil") with subtitles: \(subtitlesUrl ?? "nil")", type: "Stream") - DispatchQueue.main.async { - completion((streamUrl, subtitlesUrl)) - } - } else { - Logger.shared.log("Failed to parse softsub JSON in JSSecond", type: "Error") - DispatchQueue.main.async { - completion((nil, nil)) - } - } - } else { - let streamUrl = result.toString() - Logger.shared.log("Starting stream from: \(streamUrl ?? "nil")", type: "Stream") - DispatchQueue.main.async { - completion((streamUrl, nil)) - } - } - } - - let catchBlock: @convention(block) (JSValue) -> Void = { error in - Logger.shared.log("Promise rejected: \(String(describing: error.toString()))", type: "Error") - DispatchQueue.main.async { - completion((nil, nil)) - } - } - - let thenFunction = JSValue(object: thenBlock, in: self.context) - let catchFunction = JSValue(object: catchBlock, in: self.context) - - promise.invokeMethod("then", withArguments: [thenFunction as Any]) - promise.invokeMethod("catch", withArguments: [catchFunction as Any]) - } - } - task.resume() - } } diff --git a/Sora/Utils/Modules/Modules.swift b/Sora/Utils/Modules/Modules.swift index c5f93e4..b22d7cb 100644 --- a/Sora/Utils/Modules/Modules.swift +++ b/Sora/Utils/Modules/Modules.swift @@ -21,6 +21,8 @@ struct ModuleMetadata: Codable, Hashable { let asyncJS: Bool? let streamAsyncJS: Bool? let softsub: Bool? + let multiStream: Bool? + let multiSubs: Bool? struct Author: Codable, Hashable { let name: String diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index 6e1af48..786decb 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -500,8 +500,9 @@ struct MediaInfoView: View { if module.metadata.softsub == true { if module.metadata.asyncJS == true { jsController.fetchStreamUrlJS(episodeUrl: href, softsub: true) { result in - if let streamUrl = result.stream { - self.playStream(url: streamUrl, fullURL: href, subtitles: result.subtitles) + // Use first stream from array + if let streams = result.streams, !streams.isEmpty { + self.playStream(url: streams[0], fullURL: href, subtitles: result.subtitles?.first) } else { self.handleStreamFailure(error: nil) } @@ -511,8 +512,8 @@ struct MediaInfoView: View { } } else if module.metadata.streamAsyncJS == true { jsController.fetchStreamUrlJSSecond(episodeUrl: href, softsub: true) { result in - if let streamUrl = result.stream { - self.playStream(url: streamUrl, fullURL: href, subtitles: result.subtitles) + if let streams = result.streams, !streams.isEmpty { + self.playStream(url: streams[0], fullURL: href, subtitles: result.subtitles?.first) } else { self.handleStreamFailure(error: nil) } @@ -522,8 +523,8 @@ struct MediaInfoView: View { } } else { jsController.fetchStreamUrl(episodeUrl: href, softsub: true) { result in - if let streamUrl = result.stream { - self.playStream(url: streamUrl, fullURL: href, subtitles: result.subtitles) + if let streams = result.streams, !streams.isEmpty { + self.playStream(url: streams[0], fullURL: href, subtitles: result.subtitles?.first) } else { self.handleStreamFailure(error: nil) } @@ -535,8 +536,8 @@ struct MediaInfoView: View { } else { if module.metadata.asyncJS == true { jsController.fetchStreamUrlJS(episodeUrl: href) { result in - if let streamUrl = result.stream { - self.playStream(url: streamUrl, fullURL: href) + if let streams = result.streams, !streams.isEmpty { + self.playStream(url: streams[0], fullURL: href) } else { self.handleStreamFailure(error: nil) } @@ -546,8 +547,8 @@ struct MediaInfoView: View { } } else if module.metadata.streamAsyncJS == true { jsController.fetchStreamUrlJSSecond(episodeUrl: href) { result in - if let streamUrl = result.stream { - self.playStream(url: streamUrl, fullURL: href) + if let streams = result.streams, !streams.isEmpty { + self.playStream(url: streams[0], fullURL: href) } else { self.handleStreamFailure(error: nil) } @@ -557,8 +558,8 @@ struct MediaInfoView: View { } } else { jsController.fetchStreamUrl(episodeUrl: href) { result in - if let streamUrl = result.stream { - self.playStream(url: streamUrl, fullURL: href) + if let streams = result.streams, !streams.isEmpty { + self.playStream(url: streams[0], fullURL: href) } else { self.handleStreamFailure(error: nil) } diff --git a/Sulfur.xcodeproj/project.pbxproj b/Sulfur.xcodeproj/project.pbxproj index ad75420..7915612 100644 --- a/Sulfur.xcodeproj/project.pbxproj +++ b/Sulfur.xcodeproj/project.pbxproj @@ -15,6 +15,9 @@ 131845F92D47C62D00CA7A54 /* SettingsViewGeneral.swift in Sources */ = {isa = PBXBuildFile; fileRef = 131845F82D47C62D00CA7A54 /* SettingsViewGeneral.swift */; }; 1327FBA72D758CEA00FC6689 /* Analytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1327FBA62D758CEA00FC6689 /* Analytics.swift */; }; 1327FBA92D758DEA00FC6689 /* UIDevice+Model.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1327FBA82D758DEA00FC6689 /* UIDevice+Model.swift */; }; + 132AF1212D99951700A0140B /* JSController-Streams.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132AF1202D99951700A0140B /* JSController-Streams.swift */; }; + 132AF1232D9995C300A0140B /* JSController-Details.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132AF1222D9995C300A0140B /* JSController-Details.swift */; }; + 132AF1252D9995F900A0140B /* JSController-Search.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132AF1242D9995F900A0140B /* JSController-Search.swift */; }; 132E351D2D959DDB0007800E /* Drops in Frameworks */ = {isa = PBXBuildFile; productRef = 132E351C2D959DDB0007800E /* Drops */; }; 132E35202D959E1D0007800E /* FFmpeg-iOS-Lame in Frameworks */ = {isa = PBXBuildFile; productRef = 132E351F2D959E1D0007800E /* FFmpeg-iOS-Lame */; }; 132E35232D959E410007800E /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 132E35222D959E410007800E /* Kingfisher */; }; @@ -74,6 +77,9 @@ 131845F82D47C62D00CA7A54 /* SettingsViewGeneral.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewGeneral.swift; sourceTree = ""; }; 1327FBA62D758CEA00FC6689 /* Analytics.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Analytics.swift; sourceTree = ""; }; 1327FBA82D758DEA00FC6689 /* UIDevice+Model.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIDevice+Model.swift"; sourceTree = ""; }; + 132AF1202D99951700A0140B /* JSController-Streams.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-Streams.swift"; sourceTree = ""; }; + 132AF1222D9995C300A0140B /* JSController-Details.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-Details.swift"; sourceTree = ""; }; + 132AF1242D9995F900A0140B /* JSController-Search.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-Search.swift"; sourceTree = ""; }; 1334FF4C2D786C93007E289F /* TMDB-Seasonal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TMDB-Seasonal.swift"; sourceTree = ""; }; 1334FF4E2D786C9E007E289F /* TMDB-Trending.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TMDB-Trending.swift"; sourceTree = ""; }; 1334FF512D7871B7007E289F /* TMDBItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TMDBItem.swift; sourceTree = ""; }; @@ -326,6 +332,9 @@ isa = PBXGroup; children = ( 133D7C8B2D2BE2640075467E /* JSController.swift */, + 132AF1202D99951700A0140B /* JSController-Streams.swift */, + 132AF1222D9995C300A0140B /* JSController-Details.swift */, + 132AF1242D9995F900A0140B /* JSController-Search.swift */, ); path = JSLoader; sourceTree = ""; @@ -524,6 +533,7 @@ 13B7F4C12D58FFDD0045714A /* Shimmer.swift in Sources */, 139935662D468C450065CEFF /* ModuleManager.swift in Sources */, 133D7C902D2BE2640075467E /* SettingsView.swift in Sources */, + 132AF1252D9995F900A0140B /* JSController-Search.swift in Sources */, 1334FF4F2D786C9E007E289F /* TMDB-Trending.swift in Sources */, 13CBEFDA2D5F7D1200D011EE /* String.swift in Sources */, 13DB46902D900A38008CBC03 /* URL.swift in Sources */, @@ -542,6 +552,7 @@ 13EA2BD62D32D97400C1EBD7 /* Double+Extension.swift in Sources */, 133D7C8F2D2BE2640075467E /* MediaInfoView.swift in Sources */, 13103E892D58A39A000F0673 /* AniListItem.swift in Sources */, + 132AF1212D99951700A0140B /* JSController-Streams.swift in Sources */, 131845F92D47C62D00CA7A54 /* SettingsViewGeneral.swift in Sources */, 13103E8E2D58E04A000F0673 /* SkeletonCell.swift in Sources */, 13D842552D45267500EBBFA6 /* DropManager.swift in Sources */, @@ -562,6 +573,7 @@ 1334FF4D2D786C93007E289F /* TMDB-Seasonal.swift in Sources */, 13DB468D2D90093A008CBC03 /* Anilist-Login.swift in Sources */, 73D164D52D8B5B470011A360 /* JavaScriptCore+Extensions.swift in Sources */, + 132AF1232D9995C300A0140B /* JSController-Details.swift in Sources */, 13EA2BD52D32D97400C1EBD7 /* CustomPlayer.swift in Sources */, 1399FAD42D3AB38C00E97C31 /* SettingsViewLogger.swift in Sources */, 13DB46922D900BCE008CBC03 /* SettingsViewTrackers.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 05/37] 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 06/37] 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 07/37] =?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 08/37] 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 09/37] 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 10/37] 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 11/37] 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 12/37] 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) ]) From 78a5660bed5896563be47661a427037bb0eab436 Mon Sep 17 00:00:00 2001 From: Seiike <122684677+Seeike@users.noreply.github.com> Date: Thu, 3 Apr 2025 22:34:47 +0200 Subject: [PATCH 13/37] not great --- .../Components/VerticalVolumeSlider.swift | 137 ++++++++++++++++++ .../CustomPlayer/CustomPlayer.swift | 43 +++++- Sulfur.xcodeproj/project.pbxproj | 4 + 3 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 Sora/Utils/MediaPlayer/CustomPlayer/Components/VerticalVolumeSlider.swift diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/Components/VerticalVolumeSlider.swift b/Sora/Utils/MediaPlayer/CustomPlayer/Components/VerticalVolumeSlider.swift new file mode 100644 index 0000000..3807967 --- /dev/null +++ b/Sora/Utils/MediaPlayer/CustomPlayer/Components/VerticalVolumeSlider.swift @@ -0,0 +1,137 @@ +// +// VerticalVolumeSlider.swift +// Custom Seekbar +// +// Created by Pratik on 08/01/23. +// Modified to update screen brightness when used as a brightness slider. +// + +import SwiftUI + +struct VerticalVolumeSlider: View { + @Binding var value: T + let inRange: ClosedRange + let activeFillColor: Color + let fillColor: Color + let emptyColor: Color + let width: CGFloat + let onEditingChanged: (Bool) -> Void + + // private variables + @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 { + GeometryReader { geo in + ZStack(alignment: .bottom) { + RoundedRectangle(cornerRadius: isActive ? width : width/2, style: .continuous) + .fill(emptyColor) + RoundedRectangle(cornerRadius: isActive ? width : width/2, style: .continuous) + .fill(isActive ? activeFillColor : fillColor) + .mask({ + VStack { + Spacer(minLength: 0) + Rectangle() + .frame(height: max(geo.size.height * CGFloat((localRealProgress + localTempProgress)), 0), + alignment: .leading) + } + }) + + Image(systemName: getIconName) + .font(.system(size: 16, weight: .medium, design: .rounded)) + .foregroundColor(fillColor) + .animation(.spring(), value: localRealProgress) + .frame(maxHeight: .infinity, alignment: .bottom) + .padding(.bottom) + .overlay { + Image(systemName: getIconName) + .font(.system(size: 16, weight: .medium, design: .rounded)) + .foregroundColor(.gray) + .animation(.spring(), value: localRealProgress) + .frame(maxHeight: .infinity, alignment: .bottom) + .padding(.bottom) + .mask { + VStack { + Spacer(minLength: 0) + Rectangle() + .frame(height: max(geo.size.height * CGFloat((localRealProgress + localTempProgress)), 0), + alignment: .leading) + } + } + } + .frame(maxWidth: isActive ? .infinity : 0) + .opacity(isActive ? 1 : 0) + } + .clipped() + } + .frame(height: isActive ? bounds.size.height * 1.15 : bounds.size.height, alignment: .center) + // .shadow(color: .black.opacity(0.1), radius: isActive ? 20 : 0, x: 0, y: 0) + .animation(animation, value: isActive) + } + .frame(width: bounds.size.width, height: bounds.size.height, alignment: .center) + .gesture(DragGesture(minimumDistance: 0, coordinateSpace: .local) + .updating($isActive) { value, state, transaction in + state = true + } + .onChanged { gesture in + localTempProgress = T(-gesture.translation.height / bounds.size.height) + 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(width: isActive ? width * 4 : width, alignment: .center) + .offset(x: isActive ? -10 : 0) + .onChange(of: value) { newValue in + UIScreen.main.brightness = CGFloat(newValue) + } + } + + private var getIconName: String { + var name = "speaker.wave." + switch CGFloat((localRealProgress + localTempProgress)) { + case ..<0.01: + name = "speaker.slash.fill" + case ..<0.3: + name += "1.fill" + case ..<0.6: + name += "2.fill" + default: + name += "3.fill" + } + return name + } + + private var animation: Animation { + return .spring() + } + + private func getPrgPercentage(_ value: T) -> T { + let range = inRange.upperBound - inRange.lowerBound + let correctedStartValue = value - inRange.lowerBound + let percentage = correctedStartValue / range + return percentage + } + + private func getPrgValue() -> T { + return ((localRealProgress + localTempProgress) * (inRange.upperBound - inRange.lowerBound)) + inRange.lowerBound + } +} diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift index c8c198f..c61a75f 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift @@ -40,8 +40,11 @@ class CustomMediaPlayerViewController: UIViewController { var currentTimeVal: Double = 0.0 var duration: Double = 0.0 var isVideoLoaded = false - var showWatchNextButton = true + var brightnessValue: Double = Double(UIScreen.main.brightness) + var brightnessSliderHostingController: UIHostingController>? + + var showWatchNextButton = true var watchNextButtonTimer: Timer? var isWatchNextRepositioned: Bool = false var isWatchNextVisible: Bool = false @@ -151,6 +154,7 @@ class CustomMediaPlayerViewController: UIViewController { loadSubtitleSettings() setupPlayerViewController() setupControls() + brightnessControl() setupSkipAndDismissGestures() addInvisibleControlOverlays() setupSubtitleLabel() @@ -406,6 +410,43 @@ class CustomMediaPlayerViewController: UIViewController { } + func brightnessControl() { + let brightnessSlider = VerticalVolumeSlider( + value: Binding( + get: { self.brightnessValue }, + set: { newValue in + self.brightnessValue = newValue + // No need to update UIScreen.main.brightness here since it's handled inside VerticalVolumeSlider. + } + ), + inRange: 0...1, + activeFillColor: .white, // Preserves original active color + fillColor: .white.opacity(0.5), // Preserves original fill color + emptyColor: .white.opacity(0.3), // Preserves original empty color + width: 15, // Keeps the original width and corner style + onEditingChanged: { editing in + // Optionally handle editing events here. + } + ) + + // Embed the slider in a UIHostingController. + brightnessSliderHostingController = UIHostingController(rootView: brightnessSlider) + guard let brightnessSliderView = brightnessSliderHostingController?.view else { return } + + brightnessSliderView.backgroundColor = .clear + brightnessSliderView.translatesAutoresizingMaskIntoConstraints = false + controlsContainerView.addSubview(brightnessSliderView) + + // Position the slider on the left side of the player while preserving its original appearance. + NSLayoutConstraint.activate([ + brightnessSliderView.leadingAnchor.constraint(equalTo: controlsContainerView.leadingAnchor, constant: -10), + brightnessSliderView.centerYAnchor.constraint(equalTo: controlsContainerView.centerYAnchor), + brightnessSliderView.widthAnchor.constraint(equalToConstant: 15), // Match the slider's defined width + brightnessSliderView.heightAnchor.constraint(equalToConstant: 170) // Adjust height to properly display the slider + ]) + + } + func addInvisibleControlOverlays() { let playPauseOverlay = UIButton(type: .custom) playPauseOverlay.backgroundColor = .clear diff --git a/Sulfur.xcodeproj/project.pbxproj b/Sulfur.xcodeproj/project.pbxproj index 7915612..6aae054 100644 --- a/Sulfur.xcodeproj/project.pbxproj +++ b/Sulfur.xcodeproj/project.pbxproj @@ -62,6 +62,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 */; }; + 1E3F5EC82D9F16B7003F310F /* VerticalVolumeSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E3F5EC72D9F16B7003F310F /* VerticalVolumeSlider.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 */; }; @@ -123,6 +124,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 = ""; }; + 1E3F5EC72D9F16B7003F310F /* VerticalVolumeSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerticalVolumeSlider.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 = ""; }; @@ -440,6 +442,7 @@ 13EA2BD22D32D97400C1EBD7 /* Components */ = { isa = PBXGroup; children = ( + 1E3F5EC72D9F16B7003F310F /* VerticalVolumeSlider.swift */, 13EA2BD32D32D97400C1EBD7 /* Double+Extension.swift */, 1EAC7A312D888BC50083984D /* MusicProgressSlider.swift */, ); @@ -571,6 +574,7 @@ 133D7C6E2D2BE2500075467E /* SoraApp.swift in Sources */, 138AA1B92D2D66FD0021F9DF /* CircularProgressBar.swift in Sources */, 1334FF4D2D786C93007E289F /* TMDB-Seasonal.swift in Sources */, + 1E3F5EC82D9F16B7003F310F /* VerticalVolumeSlider.swift in Sources */, 13DB468D2D90093A008CBC03 /* Anilist-Login.swift in Sources */, 73D164D52D8B5B470011A360 /* JavaScriptCore+Extensions.swift in Sources */, 132AF1232D9995C300A0140B /* JSController-Details.swift in Sources */, From 85355336c9766c8470604463a30096473de44caf Mon Sep 17 00:00:00 2001 From: Seiike <122684677+Seeike@users.noreply.github.com> Date: Thu, 3 Apr 2025 23:06:01 +0200 Subject: [PATCH 14/37] a beautiful brightness slider --- ...r.swift => VerticalBrightnessSlider.swift} | 43 ++++++++-------- .../CustomPlayer/CustomPlayer.swift | 49 +++++++++++++------ Sulfur.xcodeproj/project.pbxproj | 8 +-- 3 files changed, 58 insertions(+), 42 deletions(-) rename Sora/Utils/MediaPlayer/CustomPlayer/Components/{VerticalVolumeSlider.swift => VerticalBrightnessSlider.swift} (80%) diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/Components/VerticalVolumeSlider.swift b/Sora/Utils/MediaPlayer/CustomPlayer/Components/VerticalBrightnessSlider.swift similarity index 80% rename from Sora/Utils/MediaPlayer/CustomPlayer/Components/VerticalVolumeSlider.swift rename to Sora/Utils/MediaPlayer/CustomPlayer/Components/VerticalBrightnessSlider.swift index 3807967..3e33aea 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/Components/VerticalVolumeSlider.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/Components/VerticalBrightnessSlider.swift @@ -1,6 +1,6 @@ // -// VerticalVolumeSlider.swift -// Custom Seekbar +// VerticalBrightnessSlider.swift +// Custom Brighness bar // // Created by Pratik on 08/01/23. // Modified to update screen brightness when used as a brightness slider. @@ -8,7 +8,7 @@ import SwiftUI -struct VerticalVolumeSlider: View { +struct VerticalBrightnessSlider: View { @Binding var value: T let inRange: ClosedRange let activeFillColor: Color @@ -41,16 +41,16 @@ struct VerticalVolumeSlider: View { }) Image(systemName: getIconName) - .font(.system(size: 16, weight: .medium, design: .rounded)) - .foregroundColor(fillColor) - .animation(.spring(), value: localRealProgress) + .font(.system(size: isActive ? 16 : 12, weight: .medium, design: .rounded)) + .foregroundColor(isActive ? fillColor : Color.white) // bright white when not active + .animation(.spring(), value: isActive) .frame(maxHeight: .infinity, alignment: .bottom) .padding(.bottom) .overlay { Image(systemName: getIconName) - .font(.system(size: 16, weight: .medium, design: .rounded)) - .foregroundColor(.gray) - .animation(.spring(), value: localRealProgress) + .font(.system(size: isActive ? 16 : 12, weight: .medium, design: .rounded)) + .foregroundColor(isActive ? Color.gray : Color.white.opacity(0.8)) + .animation(.spring(), value: isActive) .frame(maxHeight: .infinity, alignment: .bottom) .padding(.bottom) .mask { @@ -62,8 +62,8 @@ struct VerticalVolumeSlider: View { } } } - .frame(maxWidth: isActive ? .infinity : 0) - .opacity(isActive ? 1 : 0) + //.frame(maxWidth: isActive ? .infinity : 0) + // .opacity(isActive ? 1 : 0) } .clipped() } @@ -98,7 +98,7 @@ struct VerticalVolumeSlider: View { } } } - .frame(width: isActive ? width * 4 : width, alignment: .center) + .frame(width: isActive ? width * 2.2 : width, alignment: .center) .offset(x: isActive ? -10 : 0) .onChange(of: value) { newValue in UIScreen.main.brightness = CGFloat(newValue) @@ -106,18 +106,17 @@ struct VerticalVolumeSlider: View { } private var getIconName: String { - var name = "speaker.wave." - switch CGFloat((localRealProgress + localTempProgress)) { - case ..<0.01: - name = "speaker.slash.fill" - case ..<0.3: - name += "1.fill" - case ..<0.6: - name += "2.fill" + let brightnessLevel = CGFloat(localRealProgress + localTempProgress) + switch brightnessLevel { + case ..<0.2: + return "moon.fill" + case 0.2..<0.38: + return "sun.min" + case 0.38..<0.7: + return "sun.max" default: - name += "3.fill" + return "sun.max.fill" } - return name } private var animation: Animation { diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift index c61a75f..13e6ecb 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift @@ -42,7 +42,7 @@ class CustomMediaPlayerViewController: UIViewController { var isVideoLoaded = false var brightnessValue: Double = Double(UIScreen.main.brightness) - var brightnessSliderHostingController: UIHostingController>? + var brightnessSliderHostingController: UIHostingController>? var showWatchNextButton = true var watchNextButtonTimer: Timer? @@ -411,40 +411,57 @@ class CustomMediaPlayerViewController: UIViewController { func brightnessControl() { - let brightnessSlider = VerticalVolumeSlider( + let brightnessSlider = VerticalBrightnessSlider( value: Binding( get: { self.brightnessValue }, set: { newValue in self.brightnessValue = newValue - // No need to update UIScreen.main.brightness here since it's handled inside VerticalVolumeSlider. + // UIScreen.main.brightness is updated inside VerticalVolumeSlider. } ), inRange: 0...1, activeFillColor: .white, // Preserves original active color fillColor: .white.opacity(0.5), // Preserves original fill color emptyColor: .white.opacity(0.3), // Preserves original empty color - width: 15, // Keeps the original width and corner style + width: 16, // Keeps the original width and corner style onEditingChanged: { editing in // Optionally handle editing events here. - } + } ) - // Embed the slider in a UIHostingController. - brightnessSliderHostingController = UIHostingController(rootView: brightnessSlider) - guard let brightnessSliderView = brightnessSliderHostingController?.view else { return } + // Create a container view for the brightness slider. + let brightnessContainer = UIView() + brightnessContainer.translatesAutoresizingMaskIntoConstraints = false + // Optionally, set backgroundColor to clear. + brightnessContainer.backgroundColor = .clear - brightnessSliderView.backgroundColor = .clear - brightnessSliderView.translatesAutoresizingMaskIntoConstraints = false - controlsContainerView.addSubview(brightnessSliderView) + // Add the container to the controls container. + controlsContainerView.addSubview(brightnessContainer) - // Position the slider on the left side of the player while preserving its original appearance. + // Constrain the container so that its bounds follow the slider's visual position. NSLayoutConstraint.activate([ - brightnessSliderView.leadingAnchor.constraint(equalTo: controlsContainerView.leadingAnchor, constant: -10), - brightnessSliderView.centerYAnchor.constraint(equalTo: controlsContainerView.centerYAnchor), - brightnessSliderView.widthAnchor.constraint(equalToConstant: 15), // Match the slider's defined width - brightnessSliderView.heightAnchor.constraint(equalToConstant: 170) // Adjust height to properly display the slider + brightnessContainer.leadingAnchor.constraint(equalTo: controlsContainerView.leadingAnchor, constant: 1.8), + brightnessContainer.centerYAnchor.constraint(equalTo: controlsContainerView.centerYAnchor, constant: -10), + brightnessContainer.widthAnchor.constraint(equalToConstant: 16), + brightnessContainer.heightAnchor.constraint(equalToConstant: 170) ]) + // Embed the brightness slider in a UIHostingController. + brightnessSliderHostingController = UIHostingController(rootView: brightnessSlider) + guard let brightnessSliderView = brightnessSliderHostingController?.view else { return } + brightnessSliderView.backgroundColor = .clear + brightnessSliderView.translatesAutoresizingMaskIntoConstraints = false + + // Add the slider view to the container. + brightnessContainer.addSubview(brightnessSliderView) + + // Constrain the slider view to the container’s bounds. + NSLayoutConstraint.activate([ + brightnessSliderView.topAnchor.constraint(equalTo: brightnessContainer.topAnchor), + brightnessSliderView.bottomAnchor.constraint(equalTo: brightnessContainer.bottomAnchor), + brightnessSliderView.leadingAnchor.constraint(equalTo: brightnessContainer.leadingAnchor), + brightnessSliderView.trailingAnchor.constraint(equalTo: brightnessContainer.trailingAnchor) + ]) } func addInvisibleControlOverlays() { diff --git a/Sulfur.xcodeproj/project.pbxproj b/Sulfur.xcodeproj/project.pbxproj index 6aae054..a0d0a49 100644 --- a/Sulfur.xcodeproj/project.pbxproj +++ b/Sulfur.xcodeproj/project.pbxproj @@ -62,7 +62,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 */; }; - 1E3F5EC82D9F16B7003F310F /* VerticalVolumeSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E3F5EC72D9F16B7003F310F /* VerticalVolumeSlider.swift */; }; + 1E3F5EC82D9F16B7003F310F /* VerticalBrightnessSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E3F5EC72D9F16B7003F310F /* VerticalBrightnessSlider.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 */; }; @@ -124,7 +124,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 = ""; }; - 1E3F5EC72D9F16B7003F310F /* VerticalVolumeSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerticalVolumeSlider.swift; sourceTree = ""; }; + 1E3F5EC72D9F16B7003F310F /* VerticalBrightnessSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerticalBrightnessSlider.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 = ""; }; @@ -442,7 +442,7 @@ 13EA2BD22D32D97400C1EBD7 /* Components */ = { isa = PBXGroup; children = ( - 1E3F5EC72D9F16B7003F310F /* VerticalVolumeSlider.swift */, + 1E3F5EC72D9F16B7003F310F /* VerticalBrightnessSlider.swift */, 13EA2BD32D32D97400C1EBD7 /* Double+Extension.swift */, 1EAC7A312D888BC50083984D /* MusicProgressSlider.swift */, ); @@ -574,7 +574,7 @@ 133D7C6E2D2BE2500075467E /* SoraApp.swift in Sources */, 138AA1B92D2D66FD0021F9DF /* CircularProgressBar.swift in Sources */, 1334FF4D2D786C93007E289F /* TMDB-Seasonal.swift in Sources */, - 1E3F5EC82D9F16B7003F310F /* VerticalVolumeSlider.swift in Sources */, + 1E3F5EC82D9F16B7003F310F /* VerticalBrightnessSlider.swift in Sources */, 13DB468D2D90093A008CBC03 /* Anilist-Login.swift in Sources */, 73D164D52D8B5B470011A360 /* JavaScriptCore+Extensions.swift in Sources */, 132AF1232D9995C300A0140B /* JSController-Details.swift in Sources */, From 441cb22abc81b469fdbd22f9964adf12ac9f1e1a Mon Sep 17 00:00:00 2001 From: Seiike <122684677+Seeike@users.noreply.github.com> Date: Thu, 3 Apr 2025 23:19:57 +0200 Subject: [PATCH 15/37] mb done --- .../CustomPlayer/CustomPlayer.swift | 22 +++++++++++++++++++ .../SettingsSubViews/SettingsViewPlayer.swift | 4 ++++ 2 files changed, 26 insertions(+) diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift index 13e6ecb..2e1380f 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift @@ -44,6 +44,10 @@ class CustomMediaPlayerViewController: UIViewController { var brightnessValue: Double = Double(UIScreen.main.brightness) var brightnessSliderHostingController: UIHostingController>? + private var isHoldPauseEnabled: Bool { + UserDefaults.standard.bool(forKey: "holdForPauseEnabled") + } + var showWatchNextButton = true var watchNextButtonTimer: Timer? var isWatchNextRepositioned: Bool = false @@ -178,6 +182,10 @@ class CustomMediaPlayerViewController: UIViewController { self?.checkForHLSStream() } + if isHoldPauseEnabled { + holdForPause() + } + player.play() if let url = subtitlesURL, !url.isEmpty { @@ -409,6 +417,12 @@ class CustomMediaPlayerViewController: UIViewController { ]) } + func holdForPause() { + let holdForPauseGesture = UILongPressGestureRecognizer(target: self, action: #selector(handleHoldForPause(_:))) + holdForPauseGesture.minimumPressDuration = 2.0 + holdForPauseGesture.numberOfTouchesRequired = 2 + view.addGestureRecognizer(holdForPauseGesture) + } func brightnessControl() { let brightnessSlider = VerticalBrightnessSlider( @@ -1024,6 +1038,14 @@ class CustomMediaPlayerViewController: UIViewController { player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) } + @objc private func handleHoldForPause(_ gesture: UILongPressGestureRecognizer) { + guard isHoldPauseEnabled else { return } + + if gesture.state == .began { + togglePlayPause() + } + } + func speedChangerMenu() -> UIMenu { let speeds: [Double] = [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0] let playbackSpeedActions = speeds.map { speed in diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift index 2792468..1406013 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift @@ -15,6 +15,7 @@ struct SettingsViewPlayer: View { @AppStorage("holdSpeedPlayer") private var holdSpeedPlayer: Double = 2.0 @AppStorage("skipIncrement") private var skipIncrement: Double = 10.0 @AppStorage("skipIncrementHold") private var skipIncrementHold: Double = 30.0 + @AppStorage("holdForPauseEnabled") private var holdForPauseEnabled = false private let mediaPlayers = ["Default", "VLC", "OutPlayer", "Infuse", "nPlayer", "Sora"] @@ -40,6 +41,9 @@ struct SettingsViewPlayer: View { Toggle("Force Landscape", isOn: $isAlwaysLandscape) .tint(.accentColor) + + Toggle("Two finger hold for pause",isOn: $holdForPauseEnabled) + .tint(.accentColor) } Section(header: Text("Speed Settings")) { From 0f3ed42340ce9ed7a77dbdf9bad54c4cb4feaca2 Mon Sep 17 00:00:00 2001 From: Seiike <122684677+Seeike@users.noreply.github.com> Date: Thu, 3 Apr 2025 23:42:44 +0200 Subject: [PATCH 16/37] added brightness bar and check desc. https://discord.com/channels/1293430817841741899/1357429131054289118/1357442581784559847 --- .../Components/VerticalBrightnessSlider.swift | 2 +- .../CustomPlayer/CustomPlayer.swift | 26 ++++++++++++++----- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/Components/VerticalBrightnessSlider.swift b/Sora/Utils/MediaPlayer/CustomPlayer/Components/VerticalBrightnessSlider.swift index 3e33aea..7bae219 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/Components/VerticalBrightnessSlider.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/Components/VerticalBrightnessSlider.swift @@ -98,7 +98,7 @@ struct VerticalBrightnessSlider: View { } } } - .frame(width: isActive ? width * 2.2 : width, alignment: .center) + .frame(width: isActive ? width * 1.9 : width, alignment: .center) .offset(x: isActive ? -10 : 0) .onChange(of: value) { newValue in UIScreen.main.brightness = CGFloat(newValue) diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift index 2e1380f..6cc7b08 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift @@ -419,7 +419,7 @@ class CustomMediaPlayerViewController: UIViewController { func holdForPause() { let holdForPauseGesture = UILongPressGestureRecognizer(target: self, action: #selector(handleHoldForPause(_:))) - holdForPauseGesture.minimumPressDuration = 2.0 + holdForPauseGesture.minimumPressDuration = 1 holdForPauseGesture.numberOfTouchesRequired = 2 view.addGestureRecognizer(holdForPauseGesture) } @@ -437,7 +437,7 @@ class CustomMediaPlayerViewController: UIViewController { activeFillColor: .white, // Preserves original active color fillColor: .white.opacity(0.5), // Preserves original fill color emptyColor: .white.opacity(0.3), // Preserves original empty color - width: 16, // Keeps the original width and corner style + width: 22, // Keeps the original width and corner style onEditingChanged: { editing in // Optionally handle editing events here. } @@ -454,9 +454,9 @@ class CustomMediaPlayerViewController: UIViewController { // Constrain the container so that its bounds follow the slider's visual position. NSLayoutConstraint.activate([ - brightnessContainer.leadingAnchor.constraint(equalTo: controlsContainerView.leadingAnchor, constant: 1.8), + brightnessContainer.leadingAnchor.constraint(equalTo: controlsContainerView.leadingAnchor, constant: -4), brightnessContainer.centerYAnchor.constraint(equalTo: controlsContainerView.centerYAnchor, constant: -10), - brightnessContainer.widthAnchor.constraint(equalToConstant: 16), + brightnessContainer.widthAnchor.constraint(equalToConstant: 22), brightnessContainer.heightAnchor.constraint(equalToConstant: 170) ]) @@ -505,9 +505,8 @@ class CustomMediaPlayerViewController: UIViewController { } } - let swipeDownGesture = UISwipeGestureRecognizer(target: self, action: #selector(handleSwipeDown(_:))) - swipeDownGesture.direction = .down - view.addGestureRecognizer(swipeDownGesture) + let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture(_:))) + view.addGestureRecognizer(panGesture) } func showSkipFeedback(direction: String) { @@ -1465,6 +1464,19 @@ class CustomMediaPlayerViewController: UIViewController { } } + @objc private func handlePanGesture(_ gesture: UIPanGestureRecognizer) { + let translation = gesture.translation(in: view) + + switch gesture.state { + case .ended: + if translation.y > 100 { + dismiss(animated: true, completion: nil) + } + default: + break + } + } + private func beginHoldSpeed() { guard let player = player else { return } originalRate = player.rate From 2ead0dc6d48d4ab1294698a1a30ca603457d0f48 Mon Sep 17 00:00:00 2001 From: Seiike <122684677+Seeike@users.noreply.github.com> Date: Thu, 3 Apr 2025 23:44:54 +0200 Subject: [PATCH 17/37] rem dev notes --- .../Components/VerticalBrightnessSlider.swift | 2 +- .../MediaPlayer/CustomPlayer/CustomPlayer.swift | 17 ++++------------- 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/Components/VerticalBrightnessSlider.swift b/Sora/Utils/MediaPlayer/CustomPlayer/Components/VerticalBrightnessSlider.swift index 7bae219..1da58ea 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/Components/VerticalBrightnessSlider.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/Components/VerticalBrightnessSlider.swift @@ -42,7 +42,7 @@ struct VerticalBrightnessSlider: View { Image(systemName: getIconName) .font(.system(size: isActive ? 16 : 12, weight: .medium, design: .rounded)) - .foregroundColor(isActive ? fillColor : Color.white) // bright white when not active + .foregroundColor(isActive ? fillColor : Color.white) .animation(.spring(), value: isActive) .frame(maxHeight: .infinity, alignment: .bottom) .padding(.bottom) diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift index 6cc7b08..9aa362c 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift @@ -430,29 +430,23 @@ class CustomMediaPlayerViewController: UIViewController { get: { self.brightnessValue }, set: { newValue in self.brightnessValue = newValue - // UIScreen.main.brightness is updated inside VerticalVolumeSlider. } ), inRange: 0...1, - activeFillColor: .white, // Preserves original active color - fillColor: .white.opacity(0.5), // Preserves original fill color - emptyColor: .white.opacity(0.3), // Preserves original empty color - width: 22, // Keeps the original width and corner style + activeFillColor: .white, + fillColor: .white.opacity(0.5), + emptyColor: .white.opacity(0.3), + width: 22, onEditingChanged: { editing in - // Optionally handle editing events here. } ) - // Create a container view for the brightness slider. let brightnessContainer = UIView() brightnessContainer.translatesAutoresizingMaskIntoConstraints = false - // Optionally, set backgroundColor to clear. brightnessContainer.backgroundColor = .clear - // Add the container to the controls container. controlsContainerView.addSubview(brightnessContainer) - // Constrain the container so that its bounds follow the slider's visual position. NSLayoutConstraint.activate([ brightnessContainer.leadingAnchor.constraint(equalTo: controlsContainerView.leadingAnchor, constant: -4), brightnessContainer.centerYAnchor.constraint(equalTo: controlsContainerView.centerYAnchor, constant: -10), @@ -460,16 +454,13 @@ class CustomMediaPlayerViewController: UIViewController { brightnessContainer.heightAnchor.constraint(equalToConstant: 170) ]) - // Embed the brightness slider in a UIHostingController. brightnessSliderHostingController = UIHostingController(rootView: brightnessSlider) guard let brightnessSliderView = brightnessSliderHostingController?.view else { return } brightnessSliderView.backgroundColor = .clear brightnessSliderView.translatesAutoresizingMaskIntoConstraints = false - // Add the slider view to the container. brightnessContainer.addSubview(brightnessSliderView) - // Constrain the slider view to the container’s bounds. NSLayoutConstraint.activate([ brightnessSliderView.topAnchor.constraint(equalTo: brightnessContainer.topAnchor), brightnessSliderView.bottomAnchor.constraint(equalTo: brightnessContainer.bottomAnchor), From 6c43d792b2881f0f5c181e28bbc632688c28c072 Mon Sep 17 00:00:00 2001 From: Seiike <122684677+Seeike@users.noreply.github.com> Date: Thu, 3 Apr 2025 23:52:40 +0200 Subject: [PATCH 18/37] fixed buffer bar not updating fixed = sometimes when stream is opened from continue watching section the bar doesnt update --- Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift | 4 +++- .../SettingsView/SettingsSubViews/SettingsViewPlayer.swift | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift index 9aa362c..5530058 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift @@ -141,7 +141,9 @@ class CustomMediaPlayerViewController: UIViewController { let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(fullUrl)") if lastPlayedTime > 0 { let seekTime = CMTime(seconds: lastPlayedTime, preferredTimescale: 1) - self.player.seek(to: seekTime) + self.player.seek(to: seekTime) { [weak self] _ in + self?.updateBufferValue() + } } } diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift index 1406013..53c1ce9 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift @@ -42,7 +42,7 @@ struct SettingsViewPlayer: View { Toggle("Force Landscape", isOn: $isAlwaysLandscape) .tint(.accentColor) - Toggle("Two finger hold for pause",isOn: $holdForPauseEnabled) + Toggle("Two Finger Hold for Pause",isOn: $holdForPauseEnabled) .tint(.accentColor) } From c427c597976ea13d6bd92e9f8ca5f52a9b8419bf Mon Sep 17 00:00:00 2001 From: Francesco <100066266+cranci1@users.noreply.github.com> Date: Fri, 4 Apr 2025 17:26:23 +0200 Subject: [PATCH 19/37] this is good? --- .../Utils/JSLoader/JSController-Streams.swift | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/Sora/Utils/JSLoader/JSController-Streams.swift b/Sora/Utils/JSLoader/JSController-Streams.swift index 58a5148..e34e2a2 100644 --- a/Sora/Utils/JSLoader/JSController-Streams.swift +++ b/Sora/Utils/JSLoader/JSController-Streams.swift @@ -9,7 +9,7 @@ import JavaScriptCore extension JSController { - func fetchStreamUrl(episodeUrl: String, softsub: Bool = false, completion: @escaping ((streams: [String]?, subtitles: [String]?)) -> Void) { + func fetchStreamUrl(episodeUrl: String, softsub: Bool = false, module: ScrapingModule, completion: @escaping ((streams: [String]?, subtitles: [String]?)) -> Void) { guard let url = URL(string: episodeUrl) else { completion((nil, nil)) return @@ -36,9 +36,8 @@ extension JSController { if softsub { if let data = resultString.data(using: .utf8), let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] { - let moduleMetadata = self.context.objectForKeyedSubscript("module")?.objectForKeyedSubscript("metadata") - let isMultiStream = moduleMetadata?.objectForKeyedSubscript("multiStream")?.toBool() == true - let isMultiSubs = moduleMetadata?.objectForKeyedSubscript("multiSubs")?.toBool() == true + let isMultiStream = module.metadata.multiStream ?? false + let isMultiSubs = module.metadata.multiSubs ?? false var streamUrls: [String]? if isMultiStream, let streamsArray = json["streams"] as? [String] { @@ -91,7 +90,7 @@ extension JSController { }.resume() } - func fetchStreamUrlJS(episodeUrl: String, softsub: Bool = false, completion: @escaping ((streams: [String]?, subtitles: [String]?)) -> Void) { + func fetchStreamUrlJS(episodeUrl: String, softsub: Bool = false, module: ScrapingModule, completion: @escaping ((streams: [String]?, subtitles: [String]?)) -> Void) { if let exception = context.exception { Logger.shared.log("JavaScript exception: \(exception)", type: "Error") completion((nil, nil)) @@ -118,9 +117,8 @@ extension JSController { if let jsonString = result.toString(), let data = jsonString.data(using: .utf8), let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] { - let moduleMetadata = self.context.objectForKeyedSubscript("module")?.objectForKeyedSubscript("metadata") - let isMultiStream = moduleMetadata?.objectForKeyedSubscript("multiStream")?.toBool() == true - let isMultiSubs = moduleMetadata?.objectForKeyedSubscript("multiSubs")?.toBool() == true + let isMultiStream = module.metadata.multiStream ?? false + let isMultiSubs = module.metadata.multiSubs ?? false var streamUrls: [String]? if isMultiStream, let streamsArray = json["streams"] as? [String] { @@ -188,7 +186,7 @@ extension JSController { promise.invokeMethod("catch", withArguments: [catchFunction as Any]) } - func fetchStreamUrlJSSecond(episodeUrl: String, softsub: Bool = false, completion: @escaping ((streams: [String]?, subtitles: [String]?)) -> Void) { + func fetchStreamUrlJSSecond(episodeUrl: String, softsub: Bool = false, module: ScrapingModule, completion: @escaping ((streams: [String]?, subtitles: [String]?)) -> Void) { let url = URL(string: episodeUrl)! let task = URLSession.custom.dataTask(with: url) { [weak self] data, response, error in guard let self = self else { return } @@ -232,9 +230,8 @@ extension JSController { if let jsonString = result.toString(), let data = jsonString.data(using: .utf8), let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] { - let moduleMetadata = self.context.objectForKeyedSubscript("module")?.objectForKeyedSubscript("metadata") - let isMultiStream = moduleMetadata?.objectForKeyedSubscript("multiStream")?.toBool() == true - let isMultiSubs = moduleMetadata?.objectForKeyedSubscript("multiSubs")?.toBool() == true + let isMultiStream = module.metadata.multiStream ?? false + let isMultiSubs = module.metadata.multiSubs ?? false var streamUrls: [String]? if isMultiStream, let streamsArray = json["streams"] as? [String] { From d878b13528565fb356dbe2e94981a13422b05dd7 Mon Sep 17 00:00:00 2001 From: Francesco <100066266+cranci1@users.noreply.github.com> Date: Fri, 4 Apr 2025 17:27:01 +0200 Subject: [PATCH 20/37] mb mb mb --- Sora/Views/MediaInfoView/MediaInfoView.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index 786decb..6c56700 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -499,7 +499,7 @@ struct MediaInfoView: View { if module.metadata.softsub == true { if module.metadata.asyncJS == true { - jsController.fetchStreamUrlJS(episodeUrl: href, softsub: true) { result in + jsController.fetchStreamUrlJS(episodeUrl: href, softsub: true, module: module) { result in // Use first stream from array if let streams = result.streams, !streams.isEmpty { self.playStream(url: streams[0], fullURL: href, subtitles: result.subtitles?.first) @@ -511,7 +511,7 @@ struct MediaInfoView: View { } } } else if module.metadata.streamAsyncJS == true { - jsController.fetchStreamUrlJSSecond(episodeUrl: href, softsub: true) { result in + jsController.fetchStreamUrlJSSecond(episodeUrl: href, softsub: true, module: module) { result in if let streams = result.streams, !streams.isEmpty { self.playStream(url: streams[0], fullURL: href, subtitles: result.subtitles?.first) } else { @@ -522,7 +522,7 @@ struct MediaInfoView: View { } } } else { - jsController.fetchStreamUrl(episodeUrl: href, softsub: true) { result in + jsController.fetchStreamUrl(episodeUrl: href, softsub: true, module: module) { result in if let streams = result.streams, !streams.isEmpty { self.playStream(url: streams[0], fullURL: href, subtitles: result.subtitles?.first) } else { @@ -535,7 +535,7 @@ struct MediaInfoView: View { } } else { if module.metadata.asyncJS == true { - jsController.fetchStreamUrlJS(episodeUrl: href) { result in + jsController.fetchStreamUrlJS(episodeUrl: href, module: module) { result in if let streams = result.streams, !streams.isEmpty { self.playStream(url: streams[0], fullURL: href) } else { @@ -546,7 +546,7 @@ struct MediaInfoView: View { } } } else if module.metadata.streamAsyncJS == true { - jsController.fetchStreamUrlJSSecond(episodeUrl: href) { result in + jsController.fetchStreamUrlJSSecond(episodeUrl: href, module: module) { result in if let streams = result.streams, !streams.isEmpty { self.playStream(url: streams[0], fullURL: href) } else { @@ -557,7 +557,7 @@ struct MediaInfoView: View { } } } else { - jsController.fetchStreamUrl(episodeUrl: href) { result in + jsController.fetchStreamUrl(episodeUrl: href, module: module) { result in if let streams = result.streams, !streams.isEmpty { self.playStream(url: streams[0], fullURL: href) } else { From 71acdefd0ba62ccea3ee14773ae0983fea3424d6 Mon Sep 17 00:00:00 2001 From: Francesco <100066266+cranci1@users.noreply.github.com> Date: Fri, 4 Apr 2025 17:42:47 +0200 Subject: [PATCH 21/37] MULTI SERVER SUPPORT!!!!!!!!!!!!!!!!!!!!! --- .../Utils/JSLoader/JSController-Streams.swift | 251 ++++++++---------- Sora/Views/MediaInfoView/MediaInfoView.swift | 71 ++++- 2 files changed, 165 insertions(+), 157 deletions(-) diff --git a/Sora/Utils/JSLoader/JSController-Streams.swift b/Sora/Utils/JSLoader/JSController-Streams.swift index e34e2a2..85615de 100644 --- a/Sora/Utils/JSLoader/JSController-Streams.swift +++ b/Sora/Utils/JSLoader/JSController-Streams.swift @@ -33,56 +33,45 @@ extension JSController { Logger.shared.log(html, type: "HTMLStrings") if let parseFunction = self.context.objectForKeyedSubscript("extractStreamUrl"), let resultString = parseFunction.call(withArguments: [html]).toString() { - if softsub { - if let data = resultString.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] { - let isMultiStream = module.metadata.multiStream ?? false - let isMultiSubs = module.metadata.multiSubs ?? false - - var streamUrls: [String]? - if isMultiStream, let streamsArray = json["streams"] as? [String] { - streamUrls = streamsArray - Logger.shared.log("Found \(streamsArray.count) streams", type: "Stream") - } else if let streamUrl = json["stream"] as? String { - streamUrls = [streamUrl] - Logger.shared.log("Found single stream", type: "Stream") + if let data = resultString.data(using: .utf8) { + do { + if let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] { + var streamUrls: [String]? = nil + var subtitleUrls: [String]? = nil + + if let streamsArray = json["streams"] as? [String] { + streamUrls = streamsArray + Logger.shared.log("Found \(streamsArray.count) streams", type: "Stream") + } else if let streamUrl = json["stream"] as? String { + streamUrls = [streamUrl] + Logger.shared.log("Found single stream", type: "Stream") + } + + if let subsArray = json["subtitles"] as? [String] { + subtitleUrls = subsArray + Logger.shared.log("Found \(subsArray.count) subtitle tracks", type: "Stream") + } else if let subtitleUrl = json["subtitles"] as? String { + subtitleUrls = [subtitleUrl] + Logger.shared.log("Found single subtitle track", type: "Stream") + } + + Logger.shared.log("Starting stream with \(streamUrls?.count ?? 0) sources and \(subtitleUrls?.count ?? 0) subtitles", type: "Stream") + DispatchQueue.main.async { + completion((streamUrls, subtitleUrls)) + } + return } - var subtitleUrls: [String]? - if isMultiSubs, let subsArray = json["subtitles"] as? [String] { - subtitleUrls = subsArray - Logger.shared.log("Found \(subsArray.count) subtitle tracks", type: "Stream") - } else if let subtitleUrl = json["subtitles"] as? String { - subtitleUrls = [subtitleUrl] - Logger.shared.log("Found single subtitle track", type: "Stream") - } - - Logger.shared.log("Starting stream with \(streamUrls?.count ?? 0) sources and \(subtitleUrls?.count ?? 0) subtitles", type: "Stream") - DispatchQueue.main.async { - completion((streamUrls, subtitleUrls)) - } - } else { - Logger.shared.log("Failed to parse softsub JSON", type: "Error") - DispatchQueue.main.async { completion((nil, nil)) } - } - } else { - let moduleMetadata = self.context.objectForKeyedSubscript("module")?.objectForKeyedSubscript("metadata") - let isMultiStream = moduleMetadata?.objectForKeyedSubscript("multiStream")?.toBool() == true - - if isMultiStream { - if let data = resultString.data(using: .utf8), - let streamsArray = try? JSONSerialization.jsonObject(with: data, options: []) as? [String] { + if let streamsArray = try? JSONSerialization.jsonObject(with: data, options: []) as? [String] { Logger.shared.log("Starting multi-stream with \(streamsArray.count) sources", type: "Stream") DispatchQueue.main.async { completion((streamsArray, nil)) } - } else { - Logger.shared.log("Failed to parse multi-stream JSON array", type: "Error") - DispatchQueue.main.async { completion((nil, nil)) } + return } - } else { - Logger.shared.log("Starting stream from: \(resultString)", type: "Stream") - DispatchQueue.main.async { completion(([resultString], nil)) } } } + + Logger.shared.log("Starting stream from: \(resultString)", type: "Stream") + DispatchQueue.main.async { completion(([resultString], nil)) } } else { Logger.shared.log("Failed to extract stream URL", type: "Error") DispatchQueue.main.async { completion((nil, nil)) } @@ -111,65 +100,51 @@ extension JSController { } let thenBlock: @convention(block) (JSValue) -> Void = { [weak self] result in - guard let self = self else { return } + guard self != nil else { return } - if softsub { - if let jsonString = result.toString(), - let data = jsonString.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] { - let isMultiStream = module.metadata.multiStream ?? false - let isMultiSubs = module.metadata.multiSubs ?? false - - var streamUrls: [String]? - if isMultiStream, let streamsArray = json["streams"] as? [String] { - streamUrls = streamsArray - Logger.shared.log("Found \(streamsArray.count) streams", type: "Stream") - } else if let streamUrl = json["stream"] as? String { - streamUrls = [streamUrl] - Logger.shared.log("Found single stream", type: "Stream") + if let jsonString = result.toString(), + let data = jsonString.data(using: .utf8) { + do { + if let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] { + var streamUrls: [String]? = nil + var subtitleUrls: [String]? = nil + + if let streamsArray = json["streams"] as? [String] { + streamUrls = streamsArray + Logger.shared.log("Found \(streamsArray.count) streams", type: "Stream") + } else if let streamUrl = json["stream"] as? String { + streamUrls = [streamUrl] + Logger.shared.log("Found single stream", type: "Stream") + } + + if let subsArray = json["subtitles"] as? [String] { + subtitleUrls = subsArray + Logger.shared.log("Found \(subsArray.count) subtitle tracks", type: "Stream") + } else if let subtitleUrl = json["subtitles"] as? String { + subtitleUrls = [subtitleUrl] + Logger.shared.log("Found single subtitle track", type: "Stream") + } + + Logger.shared.log("Starting stream with \(streamUrls?.count ?? 0) sources and \(subtitleUrls?.count ?? 0) subtitles", type: "Stream") + DispatchQueue.main.async { + completion((streamUrls, subtitleUrls)) + } + return } - var subtitleUrls: [String]? - if isMultiSubs, let subsArray = json["subtitles"] as? [String] { - subtitleUrls = subsArray - Logger.shared.log("Found \(subsArray.count) subtitle tracks", type: "Stream") - } else if let subtitleUrl = json["subtitles"] as? String { - subtitleUrls = [subtitleUrl] - Logger.shared.log("Found single subtitle track", type: "Stream") - } - - Logger.shared.log("Starting stream with \(streamUrls?.count ?? 0) sources and \(subtitleUrls?.count ?? 0) subtitles", type: "Stream") - DispatchQueue.main.async { - completion((streamUrls, subtitleUrls)) - } - } else { - Logger.shared.log("Failed to parse softsub JSON in JS", type: "Error") - DispatchQueue.main.async { - completion((nil, nil)) - } - } - } else { - let moduleMetadata = self.context.objectForKeyedSubscript("module")?.objectForKeyedSubscript("metadata") - let isMultiStream = moduleMetadata?.objectForKeyedSubscript("multiStream")?.toBool() == true - - if isMultiStream { - if let jsonString = result.toString(), - let data = jsonString.data(using: .utf8), - let streamsArray = try? JSONSerialization.jsonObject(with: data, options: []) as? [String] { + if let streamsArray = try? JSONSerialization.jsonObject(with: data, options: []) as? [String] { Logger.shared.log("Starting multi-stream with \(streamsArray.count) sources", type: "Stream") DispatchQueue.main.async { completion((streamsArray, nil)) } - } else { - Logger.shared.log("Failed to parse multi-stream JSON array", type: "Error") - DispatchQueue.main.async { completion((nil, nil)) } - } - } else { - let streamUrl = result.toString() - Logger.shared.log("Starting stream from: \(streamUrl ?? "nil")", type: "Stream") - DispatchQueue.main.async { - completion((streamUrl != nil ? [streamUrl!] : nil, nil)) + return } } } + + let streamUrl = result.toString() + Logger.shared.log("Starting stream from: \(streamUrl ?? "nil")", type: "Stream") + DispatchQueue.main.async { + completion((streamUrl != nil ? [streamUrl!] : nil, nil)) + } } let catchBlock: @convention(block) (JSValue) -> Void = { error in @@ -224,65 +199,51 @@ extension JSController { } let thenBlock: @convention(block) (JSValue) -> Void = { [weak self] result in - guard let self = self else { return } + guard self != nil else { return } - if softsub { - if let jsonString = result.toString(), - let data = jsonString.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] { - let isMultiStream = module.metadata.multiStream ?? false - let isMultiSubs = module.metadata.multiSubs ?? false - - var streamUrls: [String]? - if isMultiStream, let streamsArray = json["streams"] as? [String] { - streamUrls = streamsArray - Logger.shared.log("Found \(streamsArray.count) streams", type: "Stream") - } else if let streamUrl = json["stream"] as? String { - streamUrls = [streamUrl] - Logger.shared.log("Found single stream", type: "Stream") + if let jsonString = result.toString(), + let data = jsonString.data(using: .utf8) { + do { + if let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] { + var streamUrls: [String]? = nil + var subtitleUrls: [String]? = nil + + if let streamsArray = json["streams"] as? [String] { + streamUrls = streamsArray + Logger.shared.log("Found \(streamsArray.count) streams", type: "Stream") + } else if let streamUrl = json["stream"] as? String { + streamUrls = [streamUrl] + Logger.shared.log("Found single stream", type: "Stream") + } + + if let subsArray = json["subtitles"] as? [String] { + subtitleUrls = subsArray + Logger.shared.log("Found \(subsArray.count) subtitle tracks", type: "Stream") + } else if let subtitleUrl = json["subtitles"] as? String { + subtitleUrls = [subtitleUrl] + Logger.shared.log("Found single subtitle track", type: "Stream") + } + + Logger.shared.log("Starting stream with \(streamUrls?.count ?? 0) sources and \(subtitleUrls?.count ?? 0) subtitles", type: "Stream") + DispatchQueue.main.async { + completion((streamUrls, subtitleUrls)) + } + return } - var subtitleUrls: [String]? - if isMultiSubs, let subsArray = json["subtitles"] as? [String] { - subtitleUrls = subsArray - Logger.shared.log("Found \(subsArray.count) subtitle tracks", type: "Stream") - } else if let subtitleUrl = json["subtitles"] as? String { - subtitleUrls = [subtitleUrl] - Logger.shared.log("Found single subtitle track", type: "Stream") - } - - Logger.shared.log("Starting stream with \(streamUrls?.count ?? 0) sources and \(subtitleUrls?.count ?? 0) subtitles", type: "Stream") - DispatchQueue.main.async { - completion((streamUrls, subtitleUrls)) - } - } else { - Logger.shared.log("Failed to parse softsub JSON in JSSecond", type: "Error") - DispatchQueue.main.async { - completion((nil, nil)) - } - } - } else { - let moduleMetadata = self.context.objectForKeyedSubscript("module")?.objectForKeyedSubscript("metadata") - let isMultiStream = moduleMetadata?.objectForKeyedSubscript("multiStream")?.toBool() == true - - if isMultiStream { - if let jsonString = result.toString(), - let data = jsonString.data(using: .utf8), - let streamsArray = try? JSONSerialization.jsonObject(with: data, options: []) as? [String] { + if let streamsArray = try? JSONSerialization.jsonObject(with: data, options: []) as? [String] { Logger.shared.log("Starting multi-stream with \(streamsArray.count) sources", type: "Stream") DispatchQueue.main.async { completion((streamsArray, nil)) } - } else { - Logger.shared.log("Failed to parse multi-stream JSON array", type: "Error") - DispatchQueue.main.async { completion((nil, nil)) } - } - } else { - let streamUrl = result.toString() - Logger.shared.log("Starting stream from: \(streamUrl ?? "nil")", type: "Stream") - DispatchQueue.main.async { - completion((streamUrl != nil ? [streamUrl!] : nil, nil)) + return } } } + + let streamUrl = result.toString() + Logger.shared.log("Starting stream from: \(streamUrl ?? "nil")", type: "Stream") + DispatchQueue.main.async { + completion((streamUrl != nil ? [streamUrl!] : nil, nil)) + } } let catchBlock: @convention(block) (JSValue) -> Void = { error in diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index 6c56700..8af8e5f 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -259,8 +259,8 @@ struct MediaInfoView: View { Logger.shared.log("Marked episodes watched within season \(selectedSeason + 1) of \"\(title)\".", type: "General") } ) - .id(refreshTrigger) - .disabled(isFetchingEpisode) + .id(refreshTrigger) + .disabled(isFetchingEpisode) } } else { Text("No episodes available") @@ -299,8 +299,8 @@ struct MediaInfoView: View { Logger.shared.log("Marked \(ep.number - 1) episodes watched within series \"\(title)\".", type: "General") } ) - .id(refreshTrigger) - .disabled(isFetchingEpisode) + .id(refreshTrigger) + .disabled(isFetchingEpisode) } } } @@ -448,7 +448,7 @@ struct MediaInfoView: View { groups.append(currentGroup) return groups } - + func fetchDetails() { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { @@ -500,9 +500,12 @@ struct MediaInfoView: View { if module.metadata.softsub == true { if module.metadata.asyncJS == true { jsController.fetchStreamUrlJS(episodeUrl: href, softsub: true, module: module) { result in - // Use first stream from array if let streams = result.streams, !streams.isEmpty { - self.playStream(url: streams[0], fullURL: href, subtitles: result.subtitles?.first) + if streams.count > 1 { + self.showStreamSelectionAlert(streams: streams, fullURL: href, subtitles: result.subtitles?.first) + } else { + self.playStream(url: streams[0], fullURL: href, subtitles: result.subtitles?.first) + } } else { self.handleStreamFailure(error: nil) } @@ -513,7 +516,11 @@ struct MediaInfoView: View { } else if module.metadata.streamAsyncJS == true { jsController.fetchStreamUrlJSSecond(episodeUrl: href, softsub: true, module: module) { result in if let streams = result.streams, !streams.isEmpty { - self.playStream(url: streams[0], fullURL: href, subtitles: result.subtitles?.first) + if streams.count > 1 { + self.showStreamSelectionAlert(streams: streams, fullURL: href, subtitles: result.subtitles?.first) + } else { + self.playStream(url: streams[0], fullURL: href, subtitles: result.subtitles?.first) + } } else { self.handleStreamFailure(error: nil) } @@ -524,7 +531,11 @@ struct MediaInfoView: View { } else { jsController.fetchStreamUrl(episodeUrl: href, softsub: true, module: module) { result in if let streams = result.streams, !streams.isEmpty { - self.playStream(url: streams[0], fullURL: href, subtitles: result.subtitles?.first) + if streams.count > 1 { + self.showStreamSelectionAlert(streams: streams, fullURL: href, subtitles: result.subtitles?.first) + } else { + self.playStream(url: streams[0], fullURL: href, subtitles: result.subtitles?.first) + } } else { self.handleStreamFailure(error: nil) } @@ -537,7 +548,11 @@ struct MediaInfoView: View { if module.metadata.asyncJS == true { jsController.fetchStreamUrlJS(episodeUrl: href, module: module) { result in if let streams = result.streams, !streams.isEmpty { - self.playStream(url: streams[0], fullURL: href) + if streams.count > 1 { + self.showStreamSelectionAlert(streams: streams, fullURL: href, subtitles: result.subtitles?.first) + } else { + self.playStream(url: streams[0], fullURL: href, subtitles: result.subtitles?.first) + } } else { self.handleStreamFailure(error: nil) } @@ -548,7 +563,11 @@ struct MediaInfoView: View { } else if module.metadata.streamAsyncJS == true { jsController.fetchStreamUrlJSSecond(episodeUrl: href, module: module) { result in if let streams = result.streams, !streams.isEmpty { - self.playStream(url: streams[0], fullURL: href) + if streams.count > 1 { + self.showStreamSelectionAlert(streams: streams, fullURL: href, subtitles: result.subtitles?.first) + } else { + self.playStream(url: streams[0], fullURL: href, subtitles: result.subtitles?.first) + } } else { self.handleStreamFailure(error: nil) } @@ -559,7 +578,11 @@ struct MediaInfoView: View { } else { jsController.fetchStreamUrl(episodeUrl: href, module: module) { result in if let streams = result.streams, !streams.isEmpty { - self.playStream(url: streams[0], fullURL: href) + if streams.count > 1 { + self.showStreamSelectionAlert(streams: streams, fullURL: href, subtitles: result.subtitles?.first) + } else { + self.playStream(url: streams[0], fullURL: href, subtitles: result.subtitles?.first) + } } else { self.handleStreamFailure(error: nil) } @@ -590,6 +613,30 @@ struct MediaInfoView: View { self.isLoading = false } + func showStreamSelectionAlert(streams: [String], fullURL: String, subtitles: String? = nil) { + DispatchQueue.main.async { + let alert = UIAlertController(title: "Select Server", message: "Choose a server to play from", preferredStyle: .actionSheet) + + for (index, stream) in streams.enumerated() { + let quality = "Stream \(index + 1)" + alert.addAction(UIAlertAction(title: quality, style: .default) { _ in + self.playStream(url: stream, fullURL: fullURL, subtitles: subtitles) + }) + } + + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let rootVC = windowScene.windows.first?.rootViewController { + findTopViewController.findViewController(rootVC).present(alert, animated: true) + } + + DispatchQueue.main.async { + self.isFetchingEpisode = false + } + } + } + func playStream(url: String, fullURL: String, subtitles: String? = nil) { DispatchQueue.main.async { let externalPlayer = UserDefaults.standard.string(forKey: "externalPlayer") ?? "Sora" From d6aaf562c512a27e3ffe87402f03615ebebe5939 Mon Sep 17 00:00:00 2001 From: Francesco <100066266+cranci1@users.noreply.github.com> Date: Fri, 4 Apr 2025 17:53:34 +0200 Subject: [PATCH 22/37] fixde ipad support --- Sora/Views/MediaInfoView/MediaInfoView.swift | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index 8af8e5f..15e04b2 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -627,7 +627,22 @@ struct MediaInfoView: View { alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let rootVC = windowScene.windows.first?.rootViewController { + let window = windowScene.windows.first, + let rootVC = window.rootViewController { + + if UIDevice.current.userInterfaceIdiom == .pad { + if let popover = alert.popoverPresentationController { + popover.sourceView = window + popover.sourceRect = CGRect( + x: UIScreen.main.bounds.width / 2, + y: UIScreen.main.bounds.height / 2, + width: 0, + height: 0 + ) + popover.permittedArrowDirections = [] + } + } + findTopViewController.findViewController(rootVC).present(alert, animated: true) } From 315d507485e14efc321a9090ef5305f0287c1b92 Mon Sep 17 00:00:00 2001 From: Francesco <100066266+cranci1@users.noreply.github.com> Date: Fri, 4 Apr 2025 18:04:02 +0200 Subject: [PATCH 23/37] Update Info.plist --- Sora/Info.plist | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Sora/Info.plist b/Sora/Info.plist index a147803..cf823a6 100644 --- a/Sora/Info.plist +++ b/Sora/Info.plist @@ -2,8 +2,8 @@ - NSCameraUsageDescription - Sora may requires access to your device's camera. + NSCameraUsageDescription + Sora may requires access to your device's camera. BGTaskSchedulerPermittedIdentifiers $(PRODUCT_BUNDLE_IDENTIFIER) @@ -38,5 +38,7 @@ audio processing + UISupportsDocumentBrowser + From 9ae8f5eb54af5b528d404286774789e92cfac953 Mon Sep 17 00:00:00 2001 From: Francesco <100066266+cranci1@users.noreply.github.com> Date: Sat, 5 Apr 2025 16:24:12 +0200 Subject: [PATCH 24/37] module refresh crash fixed --- .../SettingsSubViews/SettingsViewModule.swift | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewModule.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewModule.swift index 1535f67..db620b8 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewModule.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewModule.swift @@ -16,6 +16,7 @@ struct SettingsViewModule: View { @State private var isLoading = false @State private var isRefreshing = false @State private var moduleUrl: String = "" + @State private var refreshTask: Task? var body: some View { VStack { @@ -113,23 +114,30 @@ struct SettingsViewModule: View { }) .refreshable { isRefreshing = true - await moduleManager.refreshModules() - isRefreshing = false + refreshTask?.cancel() + refreshTask = Task { + await moduleManager.refreshModules() + isRefreshing = false + } } } .onAppear { - Task { + refreshTask = Task { await moduleManager.refreshModules() } } - .alert(isPresented: .constant(errorMessage != nil)) { - Alert( - title: Text("Error"), - message: Text(errorMessage ?? "Unknown error"), - dismissButton: .default(Text("OK")) { - errorMessage = nil - } - ) + .onDisappear { + refreshTask?.cancel() + } + .alert("Error", isPresented: Binding( + get: { errorMessage != nil }, + set: { if !$0 { errorMessage = nil } } + )) { + Button("OK") { + errorMessage = nil + } + } message: { + Text(errorMessage ?? "Unknown error") } } From c95a0f39e82563b9a3baea66468d86a9f5b7978e Mon Sep 17 00:00:00 2001 From: Francesco <100066266+cranci1@users.noreply.github.com> Date: Sat, 5 Apr 2025 16:26:39 +0200 Subject: [PATCH 25/37] fixed previous episode marking crash --- .../EpisodeCell/EpisodeCell.swift | 25 ++++++++++----- Sora/Views/MediaInfoView/MediaInfoView.swift | 32 ++++++++++++++++--- 2 files changed, 44 insertions(+), 13 deletions(-) diff --git a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift index 282fadd..235c572 100644 --- a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift +++ b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift @@ -96,20 +96,29 @@ struct EpisodeCell: View { } private func markAsWatched() { - UserDefaults.standard.set(99999999.0, forKey: "lastPlayedTime_\(episode)") - UserDefaults.standard.set(99999999.0, forKey: "totalTime_\(episode)") - updateProgress() + let userDefaults = UserDefaults.standard + userDefaults.set(99999999.0, forKey: "lastPlayedTime_\(episode)") + userDefaults.set(99999999.0, forKey: "totalTime_\(episode)") + userDefaults.synchronize() + DispatchQueue.main.async { + self.updateProgress() + } } private func resetProgress() { - UserDefaults.standard.set(0.0, forKey: "lastPlayedTime_\(episode)") - UserDefaults.standard.set(0.0, forKey: "totalTime_\(episode)") - updateProgress() + let userDefaults = UserDefaults.standard + userDefaults.set(0.0, forKey: "lastPlayedTime_\(episode)") + userDefaults.set(0.0, forKey: "totalTime_\(episode)") + userDefaults.synchronize() + DispatchQueue.main.async { + self.updateProgress() + } } private func updateProgress() { - let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(episode)") - let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(episode)") + let userDefaults = UserDefaults.standard + let lastPlayedTime = userDefaults.double(forKey: "lastPlayedTime_\(episode)") + let totalTime = userDefaults.double(forKey: "totalTime_\(episode)") currentProgress = totalTime > 0 ? lastPlayedTime / totalTime : 0 } diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index 15e04b2..ffda64f 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -250,11 +250,21 @@ struct MediaInfoView: View { } }, onMarkAllPrevious: { + let userDefaults = UserDefaults.standard + var updates = [String: Double]() + for ep2 in seasons[selectedSeason] where ep2.number < ep.number { let href = ep2.href - UserDefaults.standard.set(99999999.0, forKey: "lastPlayedTime_\(href)") - UserDefaults.standard.set(99999999.0, forKey: "totalTime_\(href)") + updates["lastPlayedTime_\(href)"] = 99999999.0 + updates["totalTime_\(href)"] = 99999999.0 } + + for (key, value) in updates { + userDefaults.set(value, forKey: key) + } + + userDefaults.synchronize() + refreshTrigger.toggle() Logger.shared.log("Marked episodes watched within season \(selectedSeason + 1) of \"\(title)\".", type: "General") } @@ -290,11 +300,23 @@ struct MediaInfoView: View { } }, onMarkAllPrevious: { + let userDefaults = UserDefaults.standard + var updates = [String: Double]() + for idx in 0.. Date: Sat, 5 Apr 2025 16:29:08 +0200 Subject: [PATCH 26/37] fixed episode cell crash maybe? --- Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift index 235c572..fd1bce0 100644 --- a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift +++ b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift @@ -91,7 +91,8 @@ struct EpisodeCell: View { updateProgress() } .onTapGesture { - onTap(episodeImageUrl) + let imageUrl = episodeImageUrl.isEmpty ? "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/main/assets/banner2.png" : episodeImageUrl + onTap(imageUrl) } } From 7fb6d2d92e73f4525dfc8972e28015f1aac459b7 Mon Sep 17 00:00:00 2001 From: Francesco <100066266+cranci1@users.noreply.github.com> Date: Sat, 5 Apr 2025 16:33:24 +0200 Subject: [PATCH 27/37] episode --- Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift | 10 +++++----- Sora/Views/MediaInfoView/MediaInfoView.swift | 6 ++---- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift index fd1bce0..5511d98 100644 --- a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift +++ b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift @@ -98,9 +98,10 @@ struct EpisodeCell: View { private func markAsWatched() { let userDefaults = UserDefaults.standard - userDefaults.set(99999999.0, forKey: "lastPlayedTime_\(episode)") - userDefaults.set(99999999.0, forKey: "totalTime_\(episode)") - userDefaults.synchronize() + let totalTime = 1000.0 + let watchedTime = totalTime + userDefaults.set(watchedTime, forKey: "lastPlayedTime_\(episode)") + userDefaults.set(totalTime, forKey: "totalTime_\(episode)") DispatchQueue.main.async { self.updateProgress() } @@ -110,7 +111,6 @@ struct EpisodeCell: View { let userDefaults = UserDefaults.standard userDefaults.set(0.0, forKey: "lastPlayedTime_\(episode)") userDefaults.set(0.0, forKey: "totalTime_\(episode)") - userDefaults.synchronize() DispatchQueue.main.async { self.updateProgress() } @@ -120,7 +120,7 @@ struct EpisodeCell: View { let userDefaults = UserDefaults.standard let lastPlayedTime = userDefaults.double(forKey: "lastPlayedTime_\(episode)") let totalTime = userDefaults.double(forKey: "totalTime_\(episode)") - currentProgress = totalTime > 0 ? lastPlayedTime / totalTime : 0 + currentProgress = totalTime > 0 ? min(lastPlayedTime / totalTime, 1.0) : 0 } private func fetchEpisodeDetails() { diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index ffda64f..76a63d9 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -306,8 +306,8 @@ struct MediaInfoView: View { for idx in 0.. Date: Mon, 7 Apr 2025 06:35:37 +0200 Subject: [PATCH 28/37] commits say it all (#66) --- .../Components/MusicProgressSlider.swift | 268 ++++++++---------- .../CustomPlayer/CustomPlayer.swift | 262 +++++++++++++---- Sulfur.xcodeproj/project.pbxproj | 17 ++ .../xcshareddata/swiftpm/Package.resolved | 88 +++--- 4 files changed, 383 insertions(+), 252 deletions(-) diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/Components/MusicProgressSlider.swift b/Sora/Utils/MediaPlayer/CustomPlayer/Components/MusicProgressSlider.swift index 55ae983..6a4b6d4 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/Components/MusicProgressSlider.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/Components/MusicProgressSlider.swift @@ -9,174 +9,134 @@ import SwiftUI - 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) - } - }) - } +struct MusicProgressSlider: View { + @Binding var value: T + @Binding var bufferValue: T // NEW + 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 { + // Base track + buffer indicator + current progress + ZStack(alignment: .center) { - 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) + // Entire background track + Capsule() + .fill(emptyColor) + + // 1) The buffer fill portion (behind the actual progress) + Capsule() // NEW + .fill(fillColor.opacity(0.3)) // or any "bufferColor" + .mask({ + HStack { + Rectangle() + .frame( + width: max( + bounds.size.width * CGFloat(getPrgPercentage(bufferValue)), + 0 + ), + alignment: .leading + ) + Spacer(minLength: 0) + } + }) + + // 2) The actual playback progress + Capsule() + .fill(isActive ? activeFillColor : fillColor) + .mask({ + HStack { + Rectangle() + .frame( + width: max( + bounds.size.width * CGFloat(localRealProgress + localTempProgress), + 0 + ), + alignment: .leading + ) + Spacer(minLength: 0) + } + }) } - .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) - .updating($isActive) { _, state, _ in - 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) + + // Time labels + 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) + } + .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) + .updating($isActive) { _, state, _ in + state = true + } + .onChanged { gesture in + localTempProgress = T(gesture.translation.width / bounds.size.width) + value = clampValue(getPrgValue()) + } + .onEnded { _ in + localRealProgress = getPrgPercentage(value) + localTempProgress = 0 + } + ) + .onChange(of: isActive) { newValue in + value = clampValue(getPrgValue()) + 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) } private var animation: Animation { - if isActive { - return .spring() - } else { - return .spring(response: 0.5, dampingFraction: 0.5, blendDuration: 0.6) - } + isActive + ? .spring() + : .spring(response: 0.5, dampingFraction: 0.5, blendDuration: 0.6) } - private func getPrgPercentage(_ value: T) -> T { + private func clampValue(_ val: T) -> T { + max(min(val, inRange.upperBound), inRange.lowerBound) + } + + private func getPrgPercentage(_ val: T) -> T { + let clampedValue = clampValue(val) let range = inRange.upperBound - inRange.lowerBound - let correctedStartValue = value - inRange.lowerBound - let percentage = correctedStartValue / range - return percentage + let pct = (clampedValue - inRange.lowerBound) / range + return max(min(pct, 1), 0) } private func getPrgValue() -> T { - 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 - } - - private func clampedFraction(_ f: T) -> T { - max(0, min(f, 1)) - } - - private func getCurrentValue() -> T { - let total = inRange.upperBound - inRange.lowerBound - let frac = clampedFraction(localRealProgress + localTempProgress) - return frac * total + inRange.lowerBound - } - - private func clampedValue(_ raw: T) -> T { - max(inRange.lowerBound, min(raw, inRange.upperBound)) - } - - private func playedWidth(boundsWidth: CGFloat) -> CGFloat { - let frac = fraction(of: value) - return max(0, min(boundsWidth * CGFloat(frac), boundsWidth)) - } - - private func bufferWidth(boundsWidth: CGFloat) -> CGFloat { - let frac = fraction(of: bufferValue) - return max(0, min(boundsWidth * CGFloat(frac), boundsWidth)) + ((localRealProgress + localTempProgress) * (inRange.upperBound - inRange.lowerBound)) + + inRange.lowerBound } } diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift index 5530058..95f94f4 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift @@ -6,6 +6,7 @@ // import UIKit +import MarqueeLabel import AVKit import SwiftUI import AVFoundation @@ -55,6 +56,13 @@ class CustomMediaPlayerViewController: UIViewController { var lastDuration: Double = 0.0 var watchNextButtonAppearedAt: Double? + // MARK: - Constraint Sets + var portraitButtonVisibleConstraints: [NSLayoutConstraint] = [] + var portraitButtonHiddenConstraints: [NSLayoutConstraint] = [] + var landscapeButtonVisibleConstraints: [NSLayoutConstraint] = [] + var landscapeButtonHiddenConstraints: [NSLayoutConstraint] = [] + var currentMarqueeConstraints: [NSLayoutConstraint] = [] + var subtitleForegroundColor: String = "white" var subtitleBackgroundEnabled: Bool = true var subtitleFontSize: Double = 20.0 @@ -66,6 +74,7 @@ class CustomMediaPlayerViewController: UIViewController { } } + var marqueeLabel: MarqueeLabel! var playerViewController: AVPlayerViewController! var controlsContainerView: UIView! var playPauseButton: UIImageView! @@ -102,7 +111,7 @@ class CustomMediaPlayerViewController: UIViewController { private var playerItemKVOContext = 0 private var loadedTimeRangesObservation: NSKeyValueObservation? - + private var playerTimeControlStatusObserver: NSKeyValueObservation? init(module: ScrapingModule, urlString: String, @@ -168,6 +177,7 @@ class CustomMediaPlayerViewController: UIViewController { setupSpeedButton() setupQualityButton() setupMenuButton() + setupMarqueeLabel() setupSkip85Button() setupWatchNextButton() addTimeObserver() @@ -179,7 +189,7 @@ class CustomMediaPlayerViewController: UIViewController { self?.updateBufferValue() } } - + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in self?.checkForHLSStream() } @@ -188,6 +198,7 @@ class CustomMediaPlayerViewController: UIViewController { holdForPause() } + player.play() if let url = subtitlesURL, !url.isEmpty { @@ -203,6 +214,32 @@ class CustomMediaPlayerViewController: UIViewController { } } + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + coordinator.animate(alongsideTransition: { _ in + self.updateMarqueeConstraints() + }) + } + + /// In layoutSubviews, check if the text width is larger than the available space and update the label’s properties. + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + // Safely unwrap marqueeLabel + guard let marqueeLabel = marqueeLabel else { + return // or handle the error gracefully + } + + let availableWidth = marqueeLabel.frame.width + let textWidth = marqueeLabel.intrinsicContentSize.width + + if textWidth > availableWidth { + marqueeLabel.lineBreakMode = .byTruncatingTail + } else { + marqueeLabel.lineBreakMode = .byClipping + } + } + override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) NotificationCenter.default.addObserver(self, @@ -371,19 +408,39 @@ class CustomMediaPlayerViewController: UIViewController { forwardButton.translatesAutoresizingMaskIntoConstraints = false let sliderView = MusicProgressSlider( - value: Binding(get: { self.sliderViewModel.sliderValue }, - set: { self.sliderViewModel.sliderValue = $0 }), + value: Binding( + get: { self.sliderViewModel.sliderValue }, + set: { self.sliderViewModel.sliderValue = $0 } + ), + bufferValue: Binding( + get: { self.sliderViewModel.bufferValue }, // NEW + set: { self.sliderViewModel.bufferValue = $0 } // NEW + ), inRange: 0...(duration > 0 ? duration : 1.0), - bufferValue: self.sliderViewModel.bufferValue, activeFillColor: .white, fillColor: .white.opacity(0.5), - bufferColor: .white.opacity(0.2), emptyColor: .white.opacity(0.3), height: 30, onEditingChanged: { editing in - self.isSliderEditing = editing - if !editing { - self.player.seek(to: CMTime(seconds: self.sliderViewModel.sliderValue, preferredTimescale: 600)) + if editing { + self.isSliderEditing = true + } else { + let wasPlaying = self.isPlaying + let targetTime = CMTime(seconds: self.sliderViewModel.sliderValue, + preferredTimescale: 600) + self.player.seek(to: targetTime) { [weak self] finished in + guard let self = self else { return } + + let final = self.player.currentTime().seconds + self.sliderViewModel.sliderValue = final + self.currentTimeVal = final + self.updateBufferValue() + self.isSliderEditing = false + + if wasPlaying { + self.player.play() + } + } } } ) @@ -440,7 +497,7 @@ class CustomMediaPlayerViewController: UIViewController { emptyColor: .white.opacity(0.3), width: 22, onEditingChanged: { editing in - } + } ) let brightnessContainer = UIView() @@ -616,22 +673,90 @@ class CustomMediaPlayerViewController: UIViewController { dismissButton.widthAnchor.constraint(equalToConstant: 40), dismissButton.heightAnchor.constraint(equalToConstant: 40) ]) + } + + func setupMarqueeLabel() { + // Create the MarqueeLabel and configure its scrolling behavior + marqueeLabel = MarqueeLabel() + marqueeLabel.text = "\(titleText) • Ep \(episodeNumber)" + marqueeLabel.type = .continuous + marqueeLabel.textColor = .white + marqueeLabel.font = UIFont.systemFont(ofSize: 15, weight: .medium) - 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 + marqueeLabel.speed = .rate(30) // Adjust scrolling speed as needed + marqueeLabel.fadeLength = 10.0 // Fading at the label’s edges + marqueeLabel.leadingBuffer = 1.0 // Left inset for scrolling + marqueeLabel.trailingBuffer = 16.0 // Right inset for scrolling + marqueeLabel.animationDelay = 2.5 - controlsContainerView.addSubview(episodeLabel) - episodeLabel.translatesAutoresizingMaskIntoConstraints = false + // Set default lineBreakMode (will be updated later based on available width) + marqueeLabel.lineBreakMode = .byTruncatingTail + marqueeLabel.textAlignment = .left - NSLayoutConstraint.activate([ - episodeLabel.centerYAnchor.constraint(equalTo: dismissButton.centerYAnchor), - episodeLabel.leadingAnchor.constraint(equalTo: dismissButton.trailingAnchor, constant: 12), - episodeLabel.trailingAnchor.constraint(lessThanOrEqualTo: controlsContainerView.trailingAnchor, constant: -16) - ]) + controlsContainerView.addSubview(marqueeLabel) + marqueeLabel.translatesAutoresizingMaskIntoConstraints = false + + // Define four sets of constraints: + // 1. Portrait mode with button visible + portraitButtonVisibleConstraints = [ + marqueeLabel.leadingAnchor.constraint(equalTo: dismissButton.trailingAnchor, constant: 12), + marqueeLabel.trailingAnchor.constraint(equalTo: menuButton.leadingAnchor, constant: -16), + marqueeLabel.centerYAnchor.constraint(equalTo: dismissButton.centerYAnchor) + ] + + // 2. Portrait mode with button hidden + portraitButtonHiddenConstraints = [ + marqueeLabel.leadingAnchor.constraint(equalTo: dismissButton.trailingAnchor, constant: 12), + marqueeLabel.trailingAnchor.constraint(equalTo: controlsContainerView.trailingAnchor, constant: -16), + marqueeLabel.centerYAnchor.constraint(equalTo: dismissButton.centerYAnchor) + ] + + // 3. Landscape mode with button visible (using smaller margins) + landscapeButtonVisibleConstraints = [ + marqueeLabel.leadingAnchor.constraint(equalTo: dismissButton.trailingAnchor, constant: 8), + marqueeLabel.trailingAnchor.constraint(equalTo: menuButton.leadingAnchor, constant: -8), + marqueeLabel.centerYAnchor.constraint(equalTo: dismissButton.centerYAnchor) + ] + + // 4. Landscape mode with button hidden + landscapeButtonHiddenConstraints = [ + marqueeLabel.leadingAnchor.constraint(equalTo: dismissButton.trailingAnchor, constant: 8), + marqueeLabel.trailingAnchor.constraint(equalTo: controlsContainerView.trailingAnchor, constant: -8), + marqueeLabel.centerYAnchor.constraint(equalTo: dismissButton.centerYAnchor) + ] + + // Activate an initial set based on the current orientation and menuButton state + updateMarqueeConstraints() + } + + func updateMarqueeConstraints() { + // First, remove any existing marquee constraints. + NSLayoutConstraint.deactivate(currentMarqueeConstraints) + + // Decide on spacing constants based on orientation. + let isPortrait = UIDevice.current.orientation.isPortrait || view.bounds.height > view.bounds.width + let leftSpacing: CGFloat = isPortrait ? 2 : 1 + let rightSpacing: CGFloat = isPortrait ? 16 : 8 + + // Determine which button to use for the trailing anchor. + var trailingAnchor: NSLayoutXAxisAnchor = controlsContainerView.trailingAnchor // default fallback + if let menu = menuButton, !menu.isHidden { + trailingAnchor = menu.leadingAnchor + } else if let quality = qualityButton, !quality.isHidden { + trailingAnchor = quality.leadingAnchor + } else if let speed = speedButton, !speed.isHidden { + trailingAnchor = speed.leadingAnchor + } + + // Create new constraints for the marquee label. + currentMarqueeConstraints = [ + marqueeLabel.leadingAnchor.constraint(equalTo: dismissButton.trailingAnchor, constant: leftSpacing), + marqueeLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -rightSpacing), + marqueeLabel.centerYAnchor.constraint(equalTo: dismissButton.centerYAnchor) + ] + + NSLayoutConstraint.activate(currentMarqueeConstraints) + view.layoutIfNeeded() } func setupMenuButton() { @@ -789,6 +914,7 @@ class CustomMediaPlayerViewController: UIViewController { let currentItem = self.player.currentItem, currentItem.duration.seconds.isFinite else { return } + self.updateBufferValue() let currentDuration = currentItem.duration.seconds if currentDuration.isNaN || currentDuration <= 0 { return } @@ -811,21 +937,24 @@ class CustomMediaPlayerViewController: UIViewController { 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)) - }), - inRange: 0...(self.duration > 0 ? self.duration : 1.0), - bufferValue: self.sliderViewModel.bufferValue, + value: Binding( + get: { max(0, min(self.sliderViewModel.sliderValue, self.duration)) }, + set: { + self.sliderViewModel.sliderValue = max(0, min($0, self.duration)) + } + ), + bufferValue: Binding(get: { self.sliderViewModel.bufferValue }, + set: { self.sliderViewModel.bufferValue = $0 }), inRange: 0...(self.duration > 0 ? self.duration : 1.0), activeFillColor: .white, fillColor: .white.opacity(0.6), - bufferColor: .white.opacity(0.36), emptyColor: .white.opacity(0.3), height: 30, onEditingChanged: { editing in if !editing { - let targetTime = CMTime(seconds: self.sliderViewModel.sliderValue, preferredTimescale: 600) + let targetTime = CMTime( + seconds: self.sliderViewModel.sliderValue, + preferredTimescale: 600 + ) self.player.seek(to: targetTime) { [weak self] finished in self?.updateBufferValue() } @@ -835,9 +964,9 @@ class CustomMediaPlayerViewController: UIViewController { } let isNearEnd = (self.duration - self.currentTimeVal) <= (self.duration * 0.10) - && self.currentTimeVal != self.duration - && self.showWatchNextButton - && self.duration != 0 + && self.currentTimeVal != self.duration + && self.showWatchNextButton + && self.duration != 0 if isNearEnd { if !self.isWatchNextVisible { @@ -942,10 +1071,10 @@ class CustomMediaPlayerViewController: UIViewController { let finalSkip = holdValue > 0 ? holdValue : 30 currentTimeVal = max(currentTimeVal - finalSkip, 0) player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) { [weak self] finished in - guard let self = self else { return } - self.updateBufferValue() - } - } + guard let self = self else { return } + self.updateBufferValue() + } + } } @objc func seekForwardLongPress(_ gesture: UILongPressGestureRecognizer) { @@ -954,10 +1083,10 @@ class CustomMediaPlayerViewController: UIViewController { let finalSkip = holdValue > 0 ? holdValue : 30 currentTimeVal = min(currentTimeVal + finalSkip, duration) player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) { [weak self] finished in - guard let self = self else { return } - self.updateBufferValue() - } - } + guard let self = self else { return } + self.updateBufferValue() + } + } } @objc func seekBackward() { @@ -965,20 +1094,20 @@ class CustomMediaPlayerViewController: UIViewController { let finalSkip = skipValue > 0 ? skipValue : 10 currentTimeVal = max(currentTimeVal - finalSkip, 0) player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) { [weak self] finished in - guard let self = self else { return } - self.updateBufferValue() - } - } + 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)) { [weak self] finished in - guard let self = self else { return } - self.updateBufferValue() - } - } + guard let self = self else { return } + self.updateBufferValue() + } + } @objc func handleDoubleTap(_ gesture: UITapGestureRecognizer) { let tapLocation = gesture.location(in: view) @@ -997,21 +1126,23 @@ class CustomMediaPlayerViewController: UIViewController { @objc func togglePlayPause() { if isPlaying { + player.pause() + isPlaying = false + playPauseButton.image = UIImage(systemName: "play.fill") + if !isControlsVisible { isControlsVisible = true - UIView.animate(withDuration: 0.5) { + UIView.animate(withDuration: 0.2) { self.controlsContainerView.alpha = 1.0 self.skip85Button.alpha = 0.8 self.view.layoutIfNeeded() } } - player.pause() - playPauseButton.image = UIImage(systemName: "play.fill") } else { player.play() + isPlaying = true playPauseButton.image = UIImage(systemName: "pause.fill") } - isPlaying.toggle() } @objc func dismissTapped() { @@ -1032,7 +1163,7 @@ class CustomMediaPlayerViewController: UIViewController { @objc private func handleHoldForPause(_ gesture: UILongPressGestureRecognizer) { guard isHoldPauseEnabled else { return } - + if gesture.state == .began { togglePlayPause() } @@ -1088,7 +1219,7 @@ class CustomMediaPlayerViewController: UIViewController { 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") { + ?? line[resolutionRange.upperBound...].range(of: "\n") { let resolutionPart = String(line[resolutionRange.upperBound.. 0 ? lastPlayedSpeed : 1.0 } } + + func setupTimeControlStatusObservation() { + playerTimeControlStatusObserver = player.observe(\.timeControlStatus, options: [.new]) { [weak self] player, _ in + guard let self = self else { return } + if player.timeControlStatus == .paused, + let reason = player.reasonForWaitingToPlay { + // If we are paused for a “stall/minimize stalls” reason, forcibly resume: + Logger.shared.log("Paused reason: \(reason)", type: "Error") + if reason == .toMinimizeStalls || reason == .evaluatingBufferingRate { + player.play() + } + } + } + } } diff --git a/Sulfur.xcodeproj/project.pbxproj b/Sulfur.xcodeproj/project.pbxproj index a0d0a49..7176dc8 100644 --- a/Sulfur.xcodeproj/project.pbxproj +++ b/Sulfur.xcodeproj/project.pbxproj @@ -62,6 +62,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 */; }; + 1E048A722DA3262900D9DD3F /* MarqueeLabel in Frameworks */ = {isa = PBXBuildFile; productRef = 1E048A712DA3262900D9DD3F /* MarqueeLabel */; }; 1E3F5EC82D9F16B7003F310F /* VerticalBrightnessSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E3F5EC72D9F16B7003F310F /* VerticalBrightnessSlider.swift */; }; 1E9FF1D32D403E49008AC100 /* SettingsViewLoggerFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */; }; 1EAC7A322D888BC50083984D /* MusicProgressSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EAC7A312D888BC50083984D /* MusicProgressSlider.swift */; }; @@ -135,6 +136,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 1E048A722DA3262900D9DD3F /* MarqueeLabel in Frameworks */, 132E35232D959E410007800E /* Kingfisher in Frameworks */, 132E35202D959E1D0007800E /* FFmpeg-iOS-Lame in Frameworks */, 132E351D2D959DDB0007800E /* Drops in Frameworks */, @@ -469,6 +471,7 @@ 132E351C2D959DDB0007800E /* Drops */, 132E351F2D959E1D0007800E /* FFmpeg-iOS-Lame */, 132E35222D959E410007800E /* Kingfisher */, + 1E048A712DA3262900D9DD3F /* MarqueeLabel */, ); productName = Sora; productReference = 133D7C6A2D2BE2500075467E /* Sulfur.app */; @@ -501,6 +504,7 @@ 132E351B2D959DDB0007800E /* XCRemoteSwiftPackageReference "Drops" */, 132E351E2D959E1D0007800E /* XCRemoteSwiftPackageReference "FFmpeg-iOS-Lame" */, 132E35212D959E410007800E /* XCRemoteSwiftPackageReference "Kingfisher" */, + 1E048A702DA3262900D9DD3F /* XCRemoteSwiftPackageReference "MarqueeLabel" */, ); productRefGroup = 133D7C6B2D2BE2500075467E /* Products */; projectDirPath = ""; @@ -838,6 +842,14 @@ version = 7.9.1; }; }; + 1E048A702DA3262900D9DD3F /* XCRemoteSwiftPackageReference "MarqueeLabel" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/cbpowell/MarqueeLabel"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 4.5.0; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -856,6 +868,11 @@ package = 132E35212D959E410007800E /* XCRemoteSwiftPackageReference "Kingfisher" */; productName = Kingfisher; }; + 1E048A712DA3262900D9DD3F /* MarqueeLabel */ = { + isa = XCSwiftPackageProductDependency; + package = 1E048A702DA3262900D9DD3F /* XCRemoteSwiftPackageReference "MarqueeLabel" */; + productName = MarqueeLabel; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 133D7C622D2BE2500075467E /* Project object */; diff --git a/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 9784b35..5bbeff8 100644 --- a/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,43 +1,51 @@ { - "object": { - "pins": [ - { - "package": "Drops", - "repositoryURL": "https://github.com/omaralbeik/Drops.git", - "state": { - "branch": "main", - "revision": "5824681795286c36bdc4a493081a63e64e2a064e", - "version": null - } - }, - { - "package": "FFmpeg-iOS-Lame", - "repositoryURL": "https://github.com/kewlbear/FFmpeg-iOS-Lame", - "state": { - "branch": "main", - "revision": "1808fa5a1263c5e216646cd8421fc7dcb70520cc", - "version": null - } - }, - { - "package": "FFmpeg-iOS-Support", - "repositoryURL": "https://github.com/kewlbear/FFmpeg-iOS-Support", - "state": { - "branch": null, - "revision": "be3bd9149ac53760e8725652eee99c405b2be47a", - "version": "0.0.2" - } - }, - { - "package": "Kingfisher", - "repositoryURL": "https://github.com/onevcat/Kingfisher.git", - "state": { - "branch": null, - "revision": "b6f62758f21a8c03cd64f4009c037cfa580a256e", - "version": "7.9.1" - } + "originHash" : "e772caa8d6a8793d24bf04e3d77695cd5ac695f3605d2b657e40115caedf8863", + "pins" : [ + { + "identity" : "drops", + "kind" : "remoteSourceControl", + "location" : "https://github.com/omaralbeik/Drops.git", + "state" : { + "branch" : "main", + "revision" : "5824681795286c36bdc4a493081a63e64e2a064e" } - ] - }, - "version": 1 + }, + { + "identity" : "ffmpeg-ios-lame", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kewlbear/FFmpeg-iOS-Lame", + "state" : { + "branch" : "main", + "revision" : "1808fa5a1263c5e216646cd8421fc7dcb70520cc" + } + }, + { + "identity" : "ffmpeg-ios-support", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kewlbear/FFmpeg-iOS-Support", + "state" : { + "revision" : "be3bd9149ac53760e8725652eee99c405b2be47a", + "version" : "0.0.2" + } + }, + { + "identity" : "kingfisher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/onevcat/Kingfisher.git", + "state" : { + "revision" : "b6f62758f21a8c03cd64f4009c037cfa580a256e", + "version" : "7.9.1" + } + }, + { + "identity" : "marqueelabel", + "kind" : "remoteSourceControl", + "location" : "https://github.com/cbpowell/MarqueeLabel", + "state" : { + "revision" : "877e810534cda9afabb8143ae319b7c3341b121b", + "version" : "4.5.0" + } + } + ], + "version" : 3 } From ec3507a16bcf1f67bdf66eeb4c7001958fd4918f Mon Sep 17 00:00:00 2001 From: Seiike <122684677+Seeike@users.noreply.github.com> Date: Mon, 7 Apr 2025 16:12:20 +0200 Subject: [PATCH 29/37] its now in safe view (#67) * bug fixes + optimization buffer indicator * snappier pause unpause buttons (animations are the same) * buffer indicator and bar fixes * player optmizations * beautiful title text in the player * brightness slider in safe view --- .../MediaPlayer/CustomPlayer/CustomPlayer.swift | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift index 95f94f4..72fa09e 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift @@ -496,19 +496,19 @@ class CustomMediaPlayerViewController: UIViewController { fillColor: .white.opacity(0.5), emptyColor: .white.opacity(0.3), width: 22, - onEditingChanged: { editing in - } + onEditingChanged: { editing in } ) + // Create the container for the brightness slider let brightnessContainer = UIView() brightnessContainer.translatesAutoresizingMaskIntoConstraints = false brightnessContainer.backgroundColor = .clear - controlsContainerView.addSubview(brightnessContainer) - + // Add the container to the main view and anchor it to the safe area. + view.addSubview(brightnessContainer) NSLayoutConstraint.activate([ - brightnessContainer.leadingAnchor.constraint(equalTo: controlsContainerView.leadingAnchor, constant: -4), - brightnessContainer.centerYAnchor.constraint(equalTo: controlsContainerView.centerYAnchor, constant: -10), + brightnessContainer.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 16), + brightnessContainer.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor), brightnessContainer.widthAnchor.constraint(equalToConstant: 22), brightnessContainer.heightAnchor.constraint(equalToConstant: 170) ]) @@ -527,6 +527,7 @@ class CustomMediaPlayerViewController: UIViewController { brightnessSliderView.trailingAnchor.constraint(equalTo: brightnessContainer.trailingAnchor) ]) } + func addInvisibleControlOverlays() { let playPauseOverlay = UIButton(type: .custom) From f80f18cd1cef4b9965c15c7c7f63279c5b5fe5da Mon Sep 17 00:00:00 2001 From: Francesco <100066266+cranci1@users.noreply.github.com> Date: Mon, 7 Apr 2025 20:23:58 +0200 Subject: [PATCH 30/37] fiexd seiike mess --- Sulfur.xcodeproj/project.pbxproj | 24 ++--- .../xcshareddata/swiftpm/Package.resolved | 97 ++++++++++--------- 2 files changed, 62 insertions(+), 59 deletions(-) diff --git a/Sulfur.xcodeproj/project.pbxproj b/Sulfur.xcodeproj/project.pbxproj index 7176dc8..44d7f43 100644 --- a/Sulfur.xcodeproj/project.pbxproj +++ b/Sulfur.xcodeproj/project.pbxproj @@ -45,6 +45,7 @@ 139935662D468C450065CEFF /* ModuleManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 139935652D468C450065CEFF /* ModuleManager.swift */; }; 1399FAD42D3AB38C00E97C31 /* SettingsViewLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1399FAD32D3AB38C00E97C31 /* SettingsViewLogger.swift */; }; 1399FAD62D3AB3DB00E97C31 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1399FAD52D3AB3DB00E97C31 /* Logger.swift */; }; + 13B77E192DA44F8300126FDF /* MarqueeLabel in Frameworks */ = {isa = PBXBuildFile; productRef = 13B77E182DA44F8300126FDF /* MarqueeLabel */; }; 13B7F4C12D58FFDD0045714A /* Shimmer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13B7F4C02D58FFDD0045714A /* Shimmer.swift */; }; 13C0E5EA2D5F85EA00E7F619 /* ContinueWatchingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13C0E5E92D5F85EA00E7F619 /* ContinueWatchingManager.swift */; }; 13C0E5EC2D5F85F800E7F619 /* ContinueWatchingItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13C0E5EB2D5F85F800E7F619 /* ContinueWatchingItem.swift */; }; @@ -62,7 +63,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 */; }; - 1E048A722DA3262900D9DD3F /* MarqueeLabel in Frameworks */ = {isa = PBXBuildFile; productRef = 1E048A712DA3262900D9DD3F /* MarqueeLabel */; }; 1E3F5EC82D9F16B7003F310F /* VerticalBrightnessSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E3F5EC72D9F16B7003F310F /* VerticalBrightnessSlider.swift */; }; 1E9FF1D32D403E49008AC100 /* SettingsViewLoggerFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */; }; 1EAC7A322D888BC50083984D /* MusicProgressSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EAC7A312D888BC50083984D /* MusicProgressSlider.swift */; }; @@ -136,7 +136,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 1E048A722DA3262900D9DD3F /* MarqueeLabel in Frameworks */, + 13B77E192DA44F8300126FDF /* MarqueeLabel in Frameworks */, 132E35232D959E410007800E /* Kingfisher in Frameworks */, 132E35202D959E1D0007800E /* FFmpeg-iOS-Lame in Frameworks */, 132E351D2D959DDB0007800E /* Drops in Frameworks */, @@ -471,7 +471,7 @@ 132E351C2D959DDB0007800E /* Drops */, 132E351F2D959E1D0007800E /* FFmpeg-iOS-Lame */, 132E35222D959E410007800E /* Kingfisher */, - 1E048A712DA3262900D9DD3F /* MarqueeLabel */, + 13B77E182DA44F8300126FDF /* MarqueeLabel */, ); productName = Sora; productReference = 133D7C6A2D2BE2500075467E /* Sulfur.app */; @@ -504,7 +504,7 @@ 132E351B2D959DDB0007800E /* XCRemoteSwiftPackageReference "Drops" */, 132E351E2D959E1D0007800E /* XCRemoteSwiftPackageReference "FFmpeg-iOS-Lame" */, 132E35212D959E410007800E /* XCRemoteSwiftPackageReference "Kingfisher" */, - 1E048A702DA3262900D9DD3F /* XCRemoteSwiftPackageReference "MarqueeLabel" */, + 13B77E172DA44F8300126FDF /* XCRemoteSwiftPackageReference "MarqueeLabel" */, ); productRefGroup = 133D7C6B2D2BE2500075467E /* Products */; projectDirPath = ""; @@ -728,6 +728,7 @@ INFOPLIST_FILE = Sora/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Sora; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment"; + INFOPLIST_KEY_NSCameraUsageDescription = "Sora may requires access to your device's camera."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -740,7 +741,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.2.1; + MARKETING_VERSION = 0.2.2; PRODUCT_BUNDLE_IDENTIFIER = me.cranci.sulfur; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -770,6 +771,7 @@ INFOPLIST_FILE = Sora/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Sora; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment"; + INFOPLIST_KEY_NSCameraUsageDescription = "Sora may requires access to your device's camera."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -782,7 +784,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.2.1; + MARKETING_VERSION = 0.2.2; PRODUCT_BUNDLE_IDENTIFIER = me.cranci.sulfur; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -842,12 +844,12 @@ version = 7.9.1; }; }; - 1E048A702DA3262900D9DD3F /* XCRemoteSwiftPackageReference "MarqueeLabel" */ = { + 13B77E172DA44F8300126FDF /* XCRemoteSwiftPackageReference "MarqueeLabel" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/cbpowell/MarqueeLabel"; requirement = { - kind = upToNextMajorVersion; - minimumVersion = 4.5.0; + kind = exactVersion; + version = 4.2.1; }; }; /* End XCRemoteSwiftPackageReference section */ @@ -868,9 +870,9 @@ package = 132E35212D959E410007800E /* XCRemoteSwiftPackageReference "Kingfisher" */; productName = Kingfisher; }; - 1E048A712DA3262900D9DD3F /* MarqueeLabel */ = { + 13B77E182DA44F8300126FDF /* MarqueeLabel */ = { isa = XCSwiftPackageProductDependency; - package = 1E048A702DA3262900D9DD3F /* XCRemoteSwiftPackageReference "MarqueeLabel" */; + package = 13B77E172DA44F8300126FDF /* XCRemoteSwiftPackageReference "MarqueeLabel" */; productName = MarqueeLabel; }; /* End XCSwiftPackageProductDependency section */ diff --git a/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 5bbeff8..5fe78fe 100644 --- a/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,51 +1,52 @@ { - "originHash" : "e772caa8d6a8793d24bf04e3d77695cd5ac695f3605d2b657e40115caedf8863", - "pins" : [ - { - "identity" : "drops", - "kind" : "remoteSourceControl", - "location" : "https://github.com/omaralbeik/Drops.git", - "state" : { - "branch" : "main", - "revision" : "5824681795286c36bdc4a493081a63e64e2a064e" + "object": { + "pins": [ + { + "package": "Drops", + "repositoryURL": "https://github.com/omaralbeik/Drops.git", + "state": { + "branch": "main", + "revision": "5824681795286c36bdc4a493081a63e64e2a064e", + "version": null + } + }, + { + "package": "FFmpeg-iOS-Lame", + "repositoryURL": "https://github.com/kewlbear/FFmpeg-iOS-Lame", + "state": { + "branch": "main", + "revision": "1808fa5a1263c5e216646cd8421fc7dcb70520cc", + "version": null + } + }, + { + "package": "FFmpeg-iOS-Support", + "repositoryURL": "https://github.com/kewlbear/FFmpeg-iOS-Support", + "state": { + "branch": null, + "revision": "be3bd9149ac53760e8725652eee99c405b2be47a", + "version": "0.0.2" + } + }, + { + "package": "Kingfisher", + "repositoryURL": "https://github.com/onevcat/Kingfisher.git", + "state": { + "branch": null, + "revision": "b6f62758f21a8c03cd64f4009c037cfa580a256e", + "version": "7.9.1" + } + }, + { + "package": "MarqueeLabel", + "repositoryURL": "https://github.com/cbpowell/MarqueeLabel", + "state": { + "branch": null, + "revision": "cffb6938940d3242882e6a2f9170b7890a4729ea", + "version": "4.2.1" + } } - }, - { - "identity" : "ffmpeg-ios-lame", - "kind" : "remoteSourceControl", - "location" : "https://github.com/kewlbear/FFmpeg-iOS-Lame", - "state" : { - "branch" : "main", - "revision" : "1808fa5a1263c5e216646cd8421fc7dcb70520cc" - } - }, - { - "identity" : "ffmpeg-ios-support", - "kind" : "remoteSourceControl", - "location" : "https://github.com/kewlbear/FFmpeg-iOS-Support", - "state" : { - "revision" : "be3bd9149ac53760e8725652eee99c405b2be47a", - "version" : "0.0.2" - } - }, - { - "identity" : "kingfisher", - "kind" : "remoteSourceControl", - "location" : "https://github.com/onevcat/Kingfisher.git", - "state" : { - "revision" : "b6f62758f21a8c03cd64f4009c037cfa580a256e", - "version" : "7.9.1" - } - }, - { - "identity" : "marqueelabel", - "kind" : "remoteSourceControl", - "location" : "https://github.com/cbpowell/MarqueeLabel", - "state" : { - "revision" : "877e810534cda9afabb8143ae319b7c3341b121b", - "version" : "4.5.0" - } - } - ], - "version" : 3 + ] + }, + "version": 1 } From 14affcdd017b595162a57a1145488a5393951b37 Mon Sep 17 00:00:00 2001 From: Francesco <100066266+cranci1@users.noreply.github.com> Date: Mon, 7 Apr 2025 21:11:08 +0200 Subject: [PATCH 31/37] test --- .../Mutations/AniListPushUpdates.swift | 100 ++++++++++++++++++ .../AniList/Struct/AniListItem.swift | 24 ----- .../TMDB/HomePage/TMDB-Seasonal.swift | 57 ---------- .../TMDB/HomePage/TMDB-Trending.swift | 61 ----------- .../TMDB/Struct/TMDBItem.swift | 61 ----------- .../TMDB/Struct/TMDBRequest.swift | 33 ------ Sora/Utils/Modules/Modules.swift | 1 + .../EpisodeCell/EpisodeCell.swift | 6 ++ Sora/Views/MediaInfoView/MediaInfoView.swift | 1 + Sulfur.xcodeproj/project.pbxproj | 64 +++-------- 10 files changed, 120 insertions(+), 288 deletions(-) create mode 100644 Sora/Tracking Services/AniList/Mutations/AniListPushUpdates.swift delete mode 100644 Sora/Tracking Services/AniList/Struct/AniListItem.swift delete mode 100644 Sora/Tracking Services/TMDB/HomePage/TMDB-Seasonal.swift delete mode 100644 Sora/Tracking Services/TMDB/HomePage/TMDB-Trending.swift delete mode 100644 Sora/Tracking Services/TMDB/Struct/TMDBItem.swift delete mode 100644 Sora/Tracking Services/TMDB/Struct/TMDBRequest.swift diff --git a/Sora/Tracking Services/AniList/Mutations/AniListPushUpdates.swift b/Sora/Tracking Services/AniList/Mutations/AniListPushUpdates.swift new file mode 100644 index 0000000..69db061 --- /dev/null +++ b/Sora/Tracking Services/AniList/Mutations/AniListPushUpdates.swift @@ -0,0 +1,100 @@ +// +// AniListPushUpdates.swift +// Sulfur +// +// Created by Francesco on 07/04/25. +// + +import UIKit +import Security + +class AniListMutation { + let apiURL = URL(string: "https://graphql.anilist.co")! + + func getTokenFromKeychain() -> String? { + let serviceName = "me.cranci.sora.AniListToken" + let accountName = "AniListAccessToken" + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrAccount as String: accountName, + kSecReturnData as String: kCFBooleanTrue!, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + + guard status == errSecSuccess, let tokenData = item as? Data else { + return nil + } + + return String(data: tokenData, encoding: .utf8) + } + + func updateAnimeProgress(animeId: Int, episodeNumber: Int, completion: @escaping (Result) -> Void) { + guard let userToken = getTokenFromKeychain() else { + completion(.failure(NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "Access token not found"]))) + return + } + + let query = """ + mutation ($mediaId: Int, $progress: Int) { + SaveMediaListEntry (mediaId: $mediaId, progress: $progress) { + id + progress + } + } + """ + + let variables: [String: Any] = [ + "mediaId": animeId, + "progress": episodeNumber + ] + + let requestBody: [String: Any] = [ + "query": query, + "variables": variables + ] + + guard let jsonData = try? JSONSerialization.data(withJSONObject: requestBody, options: []) else { + completion(.failure(NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to serialize JSON"]))) + return + } + + var request = URLRequest(url: apiURL) + request.httpMethod = "POST" + request.setValue("Bearer \(userToken)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = jsonData + + let task = URLSession.shared.dataTask(with: request) { data, response, error in + if let error = error { + completion(.failure(error)) + return + } + + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1 + completion(.failure(NSError(domain: "", code: statusCode, userInfo: [NSLocalizedDescriptionKey: "Unexpected response or status code"]))) + return + } + + if let data = data { + do { + let responseJSON = try JSONSerialization.jsonObject(with: data, options: []) + print("Successfully updated anime progress") + print(responseJSON) + completion(.success(())) + } catch { + completion(.failure(error)) + } + } else { + completion(.failure(NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "No data received"]))) + } + } + + task.resume() + } +} diff --git a/Sora/Tracking Services/AniList/Struct/AniListItem.swift b/Sora/Tracking Services/AniList/Struct/AniListItem.swift deleted file mode 100644 index e868318..0000000 --- a/Sora/Tracking Services/AniList/Struct/AniListItem.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// AniListItem.swift -// Sora -// -// Created by Francesco on 09/02/25. -// - -import Foundation - -struct AniListItem: Codable { - let id: Int - let title: AniListTitle - let coverImage: AniListCoverImage -} - -struct AniListTitle: Codable { - let romaji: String - let english: String? - let native: String? -} - -struct AniListCoverImage: Codable { - let large: String -} diff --git a/Sora/Tracking Services/TMDB/HomePage/TMDB-Seasonal.swift b/Sora/Tracking Services/TMDB/HomePage/TMDB-Seasonal.swift deleted file mode 100644 index b28a512..0000000 --- a/Sora/Tracking Services/TMDB/HomePage/TMDB-Seasonal.swift +++ /dev/null @@ -1,57 +0,0 @@ -// -// TMDB-Seasonal.swift -// Sulfur -// -// Created by Francesco on 05/03/25. -// - -import Foundation - -class TMDBSeasonal { - static func fetchTMDBSeasonal(completion: @escaping ([AniListItem]?) -> Void) { - Task { - do { - let url = URL(string: "https://api.themoviedb.org/3/movie/upcoming")! - var components = URLComponents(url: url, resolvingAgainstBaseURL: true)! - components.queryItems = [ - URLQueryItem(name: "language", value: "en-US") - ] - - var request = URLRequest(url: components.url!) - let token = TMBDRequest.getToken() - - request.allHTTPHeaderFields = [ - "accept": "application/json", - "User-Agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", - "Authorization": "Bearer \(token)" - ] - - let (data, _) = try await URLSession.shared.data(for: request) - let response = try JSONDecoder().decode(TMDBResponse.self, from: data) - - let anilistItems = response.results.map { item in - AniListItem( - id: item.id, - title: AniListTitle( - romaji: item.displayTitle, - english: item.originalTitle ?? item.originalName ?? item.displayTitle, - native: "" - ), - coverImage: AniListCoverImage( - large: item.posterURL - ) - ) - } - - DispatchQueue.main.async { - completion(anilistItems) - } - } catch { - DispatchQueue.main.async { - Logger.shared.log("Error fetching TMDB seasonal: \(error.localizedDescription)") - completion(nil) - } - } - } - } -} diff --git a/Sora/Tracking Services/TMDB/HomePage/TMDB-Trending.swift b/Sora/Tracking Services/TMDB/HomePage/TMDB-Trending.swift deleted file mode 100644 index c1715ac..0000000 --- a/Sora/Tracking Services/TMDB/HomePage/TMDB-Trending.swift +++ /dev/null @@ -1,61 +0,0 @@ -// -// TMDB-Trending.swift -// Sulfur -// -// Created by Francesco on 05/03/25. -// - -import Foundation - -class TMBDTrending { - static func fetchTMDBTrending(completion: @escaping ([AniListItem]?) -> Void) { - Task { - do { - let items = try await fetchTrendingItems() - - let anilistItems = items.map { item in - AniListItem( - id: item.id, - title: AniListTitle( - romaji: item.displayTitle, - english: item.originalTitle ?? item.originalName ?? item.displayTitle, - native: "" - ), - coverImage: AniListCoverImage( - large: item.posterURL - ) - ) - } - - DispatchQueue.main.async { - completion(anilistItems) - } - } catch { - DispatchQueue.main.async { - Logger.shared.log("Error fetching TMDB trending: \(error.localizedDescription)") - completion(nil) - } - } - } - } - - private static func fetchTrendingItems() async throws -> [TMDBItem] { - let url = URL(string: "https://api.themoviedb.org/3/trending/all/day")! - var components = URLComponents(url: url, resolvingAgainstBaseURL: true)! - let queryItems: [URLQueryItem] = [ - URLQueryItem(name: "language", value: "en-US") - ] - components.queryItems = queryItems - let token = TMBDRequest.getToken() - - var request = URLRequest(url: components.url!) - request.allHTTPHeaderFields = [ - "accept": "application/json", - "Authorization": "Bearer \(token)" - ] - - let (data, _) = try await URLSession.shared.data(for: request) - let response = try JSONDecoder().decode(TMDBResponse.self, from: data) - return response.results - } -} diff --git a/Sora/Tracking Services/TMDB/Struct/TMDBItem.swift b/Sora/Tracking Services/TMDB/Struct/TMDBItem.swift deleted file mode 100644 index 38d5998..0000000 --- a/Sora/Tracking Services/TMDB/Struct/TMDBItem.swift +++ /dev/null @@ -1,61 +0,0 @@ -// -// TMDBItem.swift -// Sulfur -// -// Created by Francesco on 05/03/25. -// - -import Foundation - -struct TMDBItem: Codable { - let id: Int - let mediaType: String? - - let title: String? - let originalTitle: String? - let releaseDate: String? - - let name: String? - let originalName: String? - let firstAirDate: String? - - let posterPath: String? - let backdropPath: String? - let overview: String - let voteAverage: Double? - - enum CodingKeys: String, CodingKey { - case id, overview - case mediaType = "media_type" - case title, name - case originalTitle = "original_title" - case originalName = "original_name" - case posterPath = "poster_path" - case backdropPath = "backdrop_path" - case releaseDate = "release_date" - case firstAirDate = "first_air_date" - case voteAverage = "vote_average" - } - - var displayTitle: String { - return title ?? name ?? "Unknown Title" - } - - var posterURL: String { - if let path = posterPath { - return "https://image.tmdb.org/t/p/w500\(path)" - } - return "" - } - - var backdropURL: String { - if let path = backdropPath { - return "https://image.tmdb.org/t/p/original\(path)" - } - return "" - } - - var displayDate: String { - return releaseDate ?? firstAirDate ?? "" - } -} diff --git a/Sora/Tracking Services/TMDB/Struct/TMDBRequest.swift b/Sora/Tracking Services/TMDB/Struct/TMDBRequest.swift deleted file mode 100644 index 56ffab3..0000000 --- a/Sora/Tracking Services/TMDB/Struct/TMDBRequest.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// TMDBRequest.swift -// Sulfur -// -// Created by Francesco on 05/03/25. -// - -import Foundation - -struct TMDBResponse: Codable { - let results: [TMDBItem] - let page: Int - let totalPages: Int - let totalResults: Int - - enum CodingKeys: String, CodingKey { - case results, page - case totalPages = "total_pages" - case totalResults = "total_results" - } -} - -class TMBDRequest { - private static let Token = "ZXlKaGJHY2lPaUpJVXpJMU5pSjkuZXlKaGRXUWlPaUkzTXpoaU5HVmtaREJoTVRVMlkyTXhNalprWXpSaE5HSTRZV1ZoTkdGallTSXNJbTVpWmlJNk1UYzBNVEUzTXpjd01pNDNPRGN3TURBeUxDSnpkV0lpT2lJMk4yTTRNek5qTm1RM05ERTVZMlJtWkRnMlpUSmtaR1lpTENKelkyOXdaWE1pT2xzaVlYQnBYM0psWVdRaVhTd2lkbVZ5YzJsdmJpSTZNWDAuR2ZlN0YtOENXSlhnT052MzRtZzNqSFhmTDZCeGJqLWhBWWY5ZllpOUNrRQ==" - - static func getToken() -> String { - guard let tokenData = Data(base64Encoded: Token), - let token = String(data: tokenData, encoding: .utf8) else { - fatalError("Failed to decode token.") - } - return token - } -} diff --git a/Sora/Utils/Modules/Modules.swift b/Sora/Utils/Modules/Modules.swift index b22d7cb..7debdbb 100644 --- a/Sora/Utils/Modules/Modules.swift +++ b/Sora/Utils/Modules/Modules.swift @@ -23,6 +23,7 @@ struct ModuleMetadata: Codable, Hashable { let softsub: Bool? let multiStream: Bool? let multiSubs: Bool? + let type: String? struct Author: Codable, Hashable { let name: String diff --git a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift index 5511d98..498bcc3 100644 --- a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift +++ b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift @@ -20,6 +20,7 @@ struct EpisodeCell: View { let episodeID: Int let progress: Double let itemID: Int + let module: ScrapingModule let onTap: (String) -> Void let onMarkAllPrevious: () -> Void @@ -124,6 +125,11 @@ struct EpisodeCell: View { } private func fetchEpisodeDetails() { + guard module.metadata.type == "anime" else { + isLoading = false + return + } + guard let url = URL(string: "https://api.ani.zip/mappings?anilist_id=\(itemID)") else { isLoading = false return diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index 76a63d9..fa80f3f 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -288,6 +288,7 @@ struct MediaInfoView: View { episodeID: ep.number - 1, progress: progress, itemID: itemID ?? 0, + module: module, onTap: { imageUrl in if !isFetchingEpisode { selectedEpisodeNumber = ep.number diff --git a/Sulfur.xcodeproj/project.pbxproj b/Sulfur.xcodeproj/project.pbxproj index 44d7f43..a2d7091 100644 --- a/Sulfur.xcodeproj/project.pbxproj +++ b/Sulfur.xcodeproj/project.pbxproj @@ -9,7 +9,6 @@ /* Begin PBXBuildFile section */ 130217CC2D81C55E0011EFF5 /* DownloadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 130217CB2D81C55E0011EFF5 /* DownloadView.swift */; }; 130C6BFA2D53AB1F00DC1432 /* SettingsViewData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 130C6BF92D53AB1F00DC1432 /* SettingsViewData.swift */; }; - 13103E892D58A39A000F0673 /* AniListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13103E882D58A39A000F0673 /* AniListItem.swift */; }; 13103E8B2D58E028000F0673 /* View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13103E8A2D58E028000F0673 /* View.swift */; }; 13103E8E2D58E04A000F0673 /* SkeletonCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13103E8D2D58E04A000F0673 /* SkeletonCell.swift */; }; 131845F92D47C62D00CA7A54 /* SettingsViewGeneral.swift in Sources */ = {isa = PBXBuildFile; fileRef = 131845F82D47C62D00CA7A54 /* SettingsViewGeneral.swift */; }; @@ -21,10 +20,6 @@ 132E351D2D959DDB0007800E /* Drops in Frameworks */ = {isa = PBXBuildFile; productRef = 132E351C2D959DDB0007800E /* Drops */; }; 132E35202D959E1D0007800E /* FFmpeg-iOS-Lame in Frameworks */ = {isa = PBXBuildFile; productRef = 132E351F2D959E1D0007800E /* FFmpeg-iOS-Lame */; }; 132E35232D959E410007800E /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 132E35222D959E410007800E /* Kingfisher */; }; - 1334FF4D2D786C93007E289F /* TMDB-Seasonal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1334FF4C2D786C93007E289F /* TMDB-Seasonal.swift */; }; - 1334FF4F2D786C9E007E289F /* TMDB-Trending.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1334FF4E2D786C9E007E289F /* TMDB-Trending.swift */; }; - 1334FF522D7871B7007E289F /* TMDBItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1334FF512D7871B7007E289F /* TMDBItem.swift */; }; - 1334FF542D787217007E289F /* TMDBRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1334FF532D787217007E289F /* TMDBRequest.swift */; }; 133D7C6E2D2BE2500075467E /* SoraApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133D7C6D2D2BE2500075467E /* SoraApp.swift */; }; 133D7C702D2BE2500075467E /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133D7C6F2D2BE2500075467E /* ContentView.swift */; }; 133D7C722D2BE2520075467E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 133D7C712D2BE2520075467E /* Assets.xcassets */; }; @@ -46,6 +41,7 @@ 1399FAD42D3AB38C00E97C31 /* SettingsViewLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1399FAD32D3AB38C00E97C31 /* SettingsViewLogger.swift */; }; 1399FAD62D3AB3DB00E97C31 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1399FAD52D3AB3DB00E97C31 /* Logger.swift */; }; 13B77E192DA44F8300126FDF /* MarqueeLabel in Frameworks */ = {isa = PBXBuildFile; productRef = 13B77E182DA44F8300126FDF /* MarqueeLabel */; }; + 13B77E202DA457AA00126FDF /* AniListPushUpdates.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13B77E1F2DA457AA00126FDF /* AniListPushUpdates.swift */; }; 13B7F4C12D58FFDD0045714A /* Shimmer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13B7F4C02D58FFDD0045714A /* Shimmer.swift */; }; 13C0E5EA2D5F85EA00E7F619 /* ContinueWatchingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13C0E5E92D5F85EA00E7F619 /* ContinueWatchingManager.swift */; }; 13C0E5EC2D5F85F800E7F619 /* ContinueWatchingItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13C0E5EB2D5F85F800E7F619 /* ContinueWatchingItem.swift */; }; @@ -73,7 +69,6 @@ 130217CB2D81C55E0011EFF5 /* DownloadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadView.swift; sourceTree = ""; }; 130C6BF82D53A4C200DC1432 /* Sora.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Sora.entitlements; sourceTree = ""; }; 130C6BF92D53AB1F00DC1432 /* SettingsViewData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewData.swift; sourceTree = ""; }; - 13103E882D58A39A000F0673 /* AniListItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AniListItem.swift; sourceTree = ""; }; 13103E8A2D58E028000F0673 /* View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = View.swift; sourceTree = ""; }; 13103E8D2D58E04A000F0673 /* SkeletonCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SkeletonCell.swift; sourceTree = ""; }; 131845F82D47C62D00CA7A54 /* SettingsViewGeneral.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewGeneral.swift; sourceTree = ""; }; @@ -82,10 +77,6 @@ 132AF1202D99951700A0140B /* JSController-Streams.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-Streams.swift"; sourceTree = ""; }; 132AF1222D9995C300A0140B /* JSController-Details.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-Details.swift"; sourceTree = ""; }; 132AF1242D9995F900A0140B /* JSController-Search.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-Search.swift"; sourceTree = ""; }; - 1334FF4C2D786C93007E289F /* TMDB-Seasonal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TMDB-Seasonal.swift"; sourceTree = ""; }; - 1334FF4E2D786C9E007E289F /* TMDB-Trending.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TMDB-Trending.swift"; sourceTree = ""; }; - 1334FF512D7871B7007E289F /* TMDBItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TMDBItem.swift; sourceTree = ""; }; - 1334FF532D787217007E289F /* TMDBRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TMDBRequest.swift; sourceTree = ""; }; 133D7C6A2D2BE2500075467E /* Sulfur.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Sulfur.app; sourceTree = BUILT_PRODUCTS_DIR; }; 133D7C6D2D2BE2500075467E /* SoraApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoraApp.swift; sourceTree = ""; }; 133D7C6F2D2BE2500075467E /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; @@ -107,6 +98,7 @@ 139935652D468C450065CEFF /* ModuleManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModuleManager.swift; sourceTree = ""; }; 1399FAD32D3AB38C00E97C31 /* SettingsViewLogger.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewLogger.swift; sourceTree = ""; }; 1399FAD52D3AB3DB00E97C31 /* Logger.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; + 13B77E1F2DA457AA00126FDF /* AniListPushUpdates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AniListPushUpdates.swift; sourceTree = ""; }; 13B7F4C02D58FFDD0045714A /* Shimmer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Shimmer.swift; sourceTree = ""; }; 13C0E5E92D5F85EA00E7F619 /* ContinueWatchingManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContinueWatchingManager.swift; sourceTree = ""; }; 13C0E5EB2D5F85F800E7F619 /* ContinueWatchingItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContinueWatchingItem.swift; sourceTree = ""; }; @@ -149,7 +141,6 @@ 13103E802D589D6C000F0673 /* Tracking Services */ = { isa = PBXGroup; children = ( - 1334FF4A2D786C6D007E289F /* TMDB */, 13103E812D589D77000F0673 /* AniList */, ); path = "Tracking Services"; @@ -158,20 +149,12 @@ 13103E812D589D77000F0673 /* AniList */ = { isa = PBXGroup; children = ( + 13B77E1E2DA4577D00126FDF /* Mutations */, 13DB468A2D900919008CBC03 /* Auth */, - 13103E872D58A392000F0673 /* Struct */, ); path = AniList; sourceTree = ""; }; - 13103E872D58A392000F0673 /* Struct */ = { - isa = PBXGroup; - children = ( - 13103E882D58A39A000F0673 /* AniListItem.swift */, - ); - path = Struct; - sourceTree = ""; - }; 13103E8C2D58E037000F0673 /* SkeletonCells */ = { isa = PBXGroup; children = ( @@ -189,33 +172,6 @@ path = Analytics; sourceTree = ""; }; - 1334FF4A2D786C6D007E289F /* TMDB */ = { - isa = PBXGroup; - children = ( - 1334FF502D7871A4007E289F /* Struct */, - 1334FF4B2D786C81007E289F /* HomePage */, - ); - path = TMDB; - sourceTree = ""; - }; - 1334FF4B2D786C81007E289F /* HomePage */ = { - isa = PBXGroup; - children = ( - 1334FF4C2D786C93007E289F /* TMDB-Seasonal.swift */, - 1334FF4E2D786C9E007E289F /* TMDB-Trending.swift */, - ); - path = HomePage; - sourceTree = ""; - }; - 1334FF502D7871A4007E289F /* Struct */ = { - isa = PBXGroup; - children = ( - 1334FF512D7871B7007E289F /* TMDBItem.swift */, - 1334FF532D787217007E289F /* TMDBRequest.swift */, - ); - path = Struct; - sourceTree = ""; - }; 133D7C612D2BE2500075467E = { isa = PBXGroup; children = ( @@ -387,6 +343,14 @@ path = SettingsView; sourceTree = ""; }; + 13B77E1E2DA4577D00126FDF /* Mutations */ = { + isa = PBXGroup; + children = ( + 13B77E1F2DA457AA00126FDF /* AniListPushUpdates.swift */, + ); + path = Mutations; + sourceTree = ""; + }; 13C0E5E82D5F85DD00E7F619 /* ContinueWatching */ = { isa = PBXGroup; children = ( @@ -541,24 +505,20 @@ 139935662D468C450065CEFF /* ModuleManager.swift in Sources */, 133D7C902D2BE2640075467E /* SettingsView.swift in Sources */, 132AF1252D9995F900A0140B /* JSController-Search.swift in Sources */, - 1334FF4F2D786C9E007E289F /* TMDB-Trending.swift in Sources */, 13CBEFDA2D5F7D1200D011EE /* String.swift in Sources */, 13DB46902D900A38008CBC03 /* URL.swift in Sources */, 130C6BFA2D53AB1F00DC1432 /* SettingsViewData.swift in Sources */, - 1334FF542D787217007E289F /* TMDBRequest.swift in Sources */, 1E9FF1D32D403E49008AC100 /* SettingsViewLoggerFilter.swift in Sources */, 13EA2BD92D32D98400C1EBD7 /* NormalPlayer.swift in Sources */, 133D7C932D2BE2640075467E /* Modules.swift in Sources */, 13DB7CC32D7D99C0004371D3 /* SubtitleSettingsManager.swift in Sources */, 133D7C702D2BE2500075467E /* ContentView.swift in Sources */, 13DB7CEC2D7DED5D004371D3 /* DownloadManager.swift in Sources */, - 1334FF522D7871B7007E289F /* TMDBItem.swift in Sources */, 13D99CF72D4E73C300250A86 /* ModuleAdditionSettingsView.swift in Sources */, 13C0E5EC2D5F85F800E7F619 /* ContinueWatchingItem.swift in Sources */, 13CBA0882D60F19C00EFE70A /* VTTSubtitlesLoader.swift in Sources */, 13EA2BD62D32D97400C1EBD7 /* Double+Extension.swift in Sources */, 133D7C8F2D2BE2640075467E /* MediaInfoView.swift in Sources */, - 13103E892D58A39A000F0673 /* AniListItem.swift in Sources */, 132AF1212D99951700A0140B /* JSController-Streams.swift in Sources */, 131845F92D47C62D00CA7A54 /* SettingsViewGeneral.swift in Sources */, 13103E8E2D58E04A000F0673 /* SkeletonCell.swift in Sources */, @@ -577,11 +537,11 @@ 133D7C8E2D2BE2640075467E /* LibraryView.swift in Sources */, 133D7C6E2D2BE2500075467E /* SoraApp.swift in Sources */, 138AA1B92D2D66FD0021F9DF /* CircularProgressBar.swift in Sources */, - 1334FF4D2D786C93007E289F /* TMDB-Seasonal.swift in Sources */, 1E3F5EC82D9F16B7003F310F /* VerticalBrightnessSlider.swift in Sources */, 13DB468D2D90093A008CBC03 /* Anilist-Login.swift in Sources */, 73D164D52D8B5B470011A360 /* JavaScriptCore+Extensions.swift in Sources */, 132AF1232D9995C300A0140B /* JSController-Details.swift in Sources */, + 13B77E202DA457AA00126FDF /* AniListPushUpdates.swift in Sources */, 13EA2BD52D32D97400C1EBD7 /* CustomPlayer.swift in Sources */, 1399FAD42D3AB38C00E97C31 /* SettingsViewLogger.swift in Sources */, 13DB46922D900BCE008CBC03 /* SettingsViewTrackers.swift in Sources */, From cb6ce4c720ad44954974261a265513db2aa9b62d Mon Sep 17 00:00:00 2001 From: Francesco <100066266+cranci1@users.noreply.github.com> Date: Mon, 7 Apr 2025 21:17:44 +0200 Subject: [PATCH 32/37] yeah imma sleep --- Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift | 3 +-- Sora/Views/MediaInfoView/MediaInfoView.swift | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift index 498bcc3..54ec7ed 100644 --- a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift +++ b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift @@ -20,7 +20,6 @@ struct EpisodeCell: View { let episodeID: Int let progress: Double let itemID: Int - let module: ScrapingModule let onTap: (String) -> Void let onMarkAllPrevious: () -> Void @@ -125,7 +124,7 @@ struct EpisodeCell: View { } private func fetchEpisodeDetails() { - guard module.metadata.type == "anime" else { + guard episodeID != 0 else { isLoading = false return } diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index fa80f3f..76a63d9 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -288,7 +288,6 @@ struct MediaInfoView: View { episodeID: ep.number - 1, progress: progress, itemID: itemID ?? 0, - module: module, onTap: { imageUrl in if !isFetchingEpisode { selectedEpisodeNumber = ep.number From c00267128117ca099507e86bf3e753a0d7cec470 Mon Sep 17 00:00:00 2001 From: Francesco <100066266+cranci1@users.noreply.github.com> Date: Tue, 8 Apr 2025 14:25:40 +0200 Subject: [PATCH 33/37] fixed media player --- .../ContinueWatchingItem.swift | 1 + .../CustomPlayer/CustomPlayer.swift | 42 +++++++++---------- Sora/Utils/MediaPlayer/VideoPlayer.swift | 40 +++++++++--------- Sora/Views/LibraryView/LibraryView.swift | 1 + Sora/Views/MediaInfoView/MediaInfoView.swift | 1 + 5 files changed, 45 insertions(+), 40 deletions(-) diff --git a/Sora/Utils/ContinueWatching/ContinueWatchingItem.swift b/Sora/Utils/ContinueWatching/ContinueWatchingItem.swift index c91d677..7490295 100644 --- a/Sora/Utils/ContinueWatching/ContinueWatchingItem.swift +++ b/Sora/Utils/ContinueWatching/ContinueWatchingItem.swift @@ -16,5 +16,6 @@ struct ContinueWatchingItem: Codable, Identifiable { let streamUrl: String let fullUrl: String let subtitles: String? + let aniListID: Int? let module: ScrapingModule } diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift index 72fa09e..c6e71b1 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift @@ -29,6 +29,7 @@ class CustomMediaPlayerViewController: UIViewController { let episodeImageUrl: String let subtitlesURL: String? let onWatchNext: () -> Void + let aniListID: Int var player: AVPlayer! var timeObserverToken: Any? @@ -56,7 +57,6 @@ class CustomMediaPlayerViewController: UIViewController { var lastDuration: Double = 0.0 var watchNextButtonAppearedAt: Double? - // MARK: - Constraint Sets var portraitButtonVisibleConstraints: [NSLayoutConstraint] = [] var portraitButtonHiddenConstraints: [NSLayoutConstraint] = [] var landscapeButtonVisibleConstraints: [NSLayoutConstraint] = [] @@ -120,6 +120,7 @@ class CustomMediaPlayerViewController: UIViewController { episodeNumber: Int, onWatchNext: @escaping () -> Void, subtitlesURL: String?, + aniListID: Int, episodeImageUrl: String) { self.module = module @@ -130,6 +131,7 @@ class CustomMediaPlayerViewController: UIViewController { self.episodeImageUrl = episodeImageUrl self.onWatchNext = onWatchNext self.subtitlesURL = subtitlesURL + self.aniListID = aniListID super.init(nibName: nil, bundle: nil) @@ -268,28 +270,9 @@ class CustomMediaPlayerViewController: UIViewController { UserDefaults.standard.set(playbackSpeed, forKey: "lastPlaybackSpeed") } - if let currentItem = player.currentItem, currentItem.duration.seconds > 0 { - let progress = currentTimeVal / currentItem.duration.seconds - let item = ContinueWatchingItem( - id: UUID(), - imageUrl: episodeImageUrl, - episodeNumber: episodeNumber, - mediaTitle: titleText, - progress: progress, - streamUrl: streamURL, - fullUrl: fullUrl, - subtitles: subtitlesURL, - module: module - ) - ContinueWatchingManager.shared.save(item: item) - } } - override func observeValue(forKeyPath keyPath: String?, - of object: Any?, - change: [NSKeyValueChangeKey : Any]?, - context: UnsafeMutableRawPointer?) { - + 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 @@ -1011,6 +994,23 @@ class CustomMediaPlayerViewController: UIViewController { }) } } + + if let currentItem = player.currentItem, currentItem.duration.seconds > 0 { + let progress = currentTimeVal / currentItem.duration.seconds + let item = ContinueWatchingItem( + id: UUID(), + imageUrl: episodeImageUrl, + episodeNumber: episodeNumber, + mediaTitle: titleText, + progress: progress, + streamUrl: streamURL, + fullUrl: fullUrl, + subtitles: subtitlesURL, + aniListID: aniListID, + module: module + ) + ContinueWatchingManager.shared.save(item: item) + } } func repositionWatchNextButton() { diff --git a/Sora/Utils/MediaPlayer/VideoPlayer.swift b/Sora/Utils/MediaPlayer/VideoPlayer.swift index 88a5314..1f001c3 100644 --- a/Sora/Utils/MediaPlayer/VideoPlayer.swift +++ b/Sora/Utils/MediaPlayer/VideoPlayer.swift @@ -17,6 +17,7 @@ class VideoPlayerViewController: UIViewController { var streamUrl: String? var fullUrl: String = "" var subtitles: String = "" + var aniListID: Int = 0 var episodeNumber: Int = 0 var episodeImageUrl: String = "" @@ -87,25 +88,6 @@ class VideoPlayerViewController: UIViewController { player?.removeTimeObserver(timeObserverToken) self.timeObserverToken = nil } - - if let currentItem = player?.currentItem, currentItem.duration.seconds > 0, - let streamUrl = streamUrl { - let currentTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(fullUrl)") - let duration = currentItem.duration.seconds - let progress = currentTime / duration - let item = ContinueWatchingItem( - id: UUID(), - imageUrl: episodeImageUrl, - episodeNumber: episodeNumber, - mediaTitle: mediaTitle, - progress: progress, - streamUrl: streamUrl, - fullUrl: fullUrl, - subtitles: subtitles, - module: module - ) - ContinueWatchingManager.shared.save(item: item) - } } private func setInitialPlayerRate() { @@ -131,6 +113,26 @@ class VideoPlayerViewController: UIViewController { UserDefaults.standard.set(currentTime, forKey: "lastPlayedTime_\(fullURL)") UserDefaults.standard.set(duration, forKey: "totalTime_\(fullURL)") } + + if let currentItem = player.currentItem, currentItem.duration.seconds > 0, + let streamUrl = streamUrl { + let currentTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(fullUrl)") + let duration = currentItem.duration.seconds + let progress = currentTime / duration + let item = ContinueWatchingItem( + id: UUID(), + imageUrl: episodeImageUrl, + episodeNumber: episodeNumber, + mediaTitle: mediaTitle, + progress: progress, + streamUrl: streamUrl, + fullUrl: fullUrl, + subtitles: subtitles, + aniListID: aniListID, + module: module + ) + ContinueWatchingManager.shared.save(item: item) + } } override var supportedInterfaceOrientations: UIInterfaceOrientationMask { diff --git a/Sora/Views/LibraryView/LibraryView.swift b/Sora/Views/LibraryView/LibraryView.swift index ade2c46..9dc269b 100644 --- a/Sora/Views/LibraryView/LibraryView.swift +++ b/Sora/Views/LibraryView/LibraryView.swift @@ -248,6 +248,7 @@ struct ContinueWatchingCell: View { episodeNumber: item.episodeNumber, onWatchNext: { }, subtitlesURL: item.subtitles, + aniListID: item.aniListID ?? 0, episodeImageUrl: item.imageUrl ) customMediaPlayer.modalPresentationStyle = .fullScreen diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index 76a63d9..2c1788a 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -725,6 +725,7 @@ struct MediaInfoView: View { selectNextEpisode() }, subtitlesURL: subtitles, + aniListID: itemID ?? 0, episodeImageUrl: selectedEpisodeImage ) customMediaPlayer.modalPresentationStyle = .fullScreen From aadea3f1780f757557e5e4da908a9b24848866b6 Mon Sep 17 00:00:00 2001 From: Francesco <100066266+cranci1@users.noreply.github.com> Date: Tue, 8 Apr 2025 16:35:11 +0200 Subject: [PATCH 34/37] test --- Sulfur.xcodeproj/project.pbxproj | 17 +++++++++++++++++ .../xcshareddata/swiftpm/Package.resolved | 9 +++++++++ 2 files changed, 26 insertions(+) diff --git a/Sulfur.xcodeproj/project.pbxproj b/Sulfur.xcodeproj/project.pbxproj index a2d7091..d21e31e 100644 --- a/Sulfur.xcodeproj/project.pbxproj +++ b/Sulfur.xcodeproj/project.pbxproj @@ -45,6 +45,7 @@ 13B7F4C12D58FFDD0045714A /* Shimmer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13B7F4C02D58FFDD0045714A /* Shimmer.swift */; }; 13C0E5EA2D5F85EA00E7F619 /* ContinueWatchingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13C0E5E92D5F85EA00E7F619 /* ContinueWatchingManager.swift */; }; 13C0E5EC2D5F85F800E7F619 /* ContinueWatchingItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13C0E5EB2D5F85F800E7F619 /* ContinueWatchingItem.swift */; }; + 13C285F62DA56B6F009FB0D0 /* SlideOverCard in Frameworks */ = {isa = PBXBuildFile; productRef = 13C285F52DA56B6F009FB0D0 /* SlideOverCard */; }; 13CBA0882D60F19C00EFE70A /* VTTSubtitlesLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13CBA0872D60F19C00EFE70A /* VTTSubtitlesLoader.swift */; }; 13CBEFDA2D5F7D1200D011EE /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13CBEFD92D5F7D1200D011EE /* String.swift */; }; 13D842552D45267500EBBFA6 /* DropManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13D842542D45267500EBBFA6 /* DropManager.swift */; }; @@ -128,6 +129,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 13C285F62DA56B6F009FB0D0 /* SlideOverCard in Frameworks */, 13B77E192DA44F8300126FDF /* MarqueeLabel in Frameworks */, 132E35232D959E410007800E /* Kingfisher in Frameworks */, 132E35202D959E1D0007800E /* FFmpeg-iOS-Lame in Frameworks */, @@ -436,6 +438,7 @@ 132E351F2D959E1D0007800E /* FFmpeg-iOS-Lame */, 132E35222D959E410007800E /* Kingfisher */, 13B77E182DA44F8300126FDF /* MarqueeLabel */, + 13C285F52DA56B6F009FB0D0 /* SlideOverCard */, ); productName = Sora; productReference = 133D7C6A2D2BE2500075467E /* Sulfur.app */; @@ -469,6 +472,7 @@ 132E351E2D959E1D0007800E /* XCRemoteSwiftPackageReference "FFmpeg-iOS-Lame" */, 132E35212D959E410007800E /* XCRemoteSwiftPackageReference "Kingfisher" */, 13B77E172DA44F8300126FDF /* XCRemoteSwiftPackageReference "MarqueeLabel" */, + 13C285F42DA56B6F009FB0D0 /* XCRemoteSwiftPackageReference "SlideOverCard" */, ); productRefGroup = 133D7C6B2D2BE2500075467E /* Products */; projectDirPath = ""; @@ -812,6 +816,14 @@ version = 4.2.1; }; }; + 13C285F42DA56B6F009FB0D0 /* XCRemoteSwiftPackageReference "SlideOverCard" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/joogps/SlideOverCard"; + requirement = { + kind = exactVersion; + version = 3.0.1; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -835,6 +847,11 @@ package = 13B77E172DA44F8300126FDF /* XCRemoteSwiftPackageReference "MarqueeLabel" */; productName = MarqueeLabel; }; + 13C285F52DA56B6F009FB0D0 /* SlideOverCard */ = { + isa = XCSwiftPackageProductDependency; + package = 13C285F42DA56B6F009FB0D0 /* XCRemoteSwiftPackageReference "SlideOverCard" */; + productName = SlideOverCard; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 133D7C622D2BE2500075467E /* Project object */; diff --git a/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 5fe78fe..2754ca7 100644 --- a/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -45,6 +45,15 @@ "revision": "cffb6938940d3242882e6a2f9170b7890a4729ea", "version": "4.2.1" } + }, + { + "package": "SlideOverCard", + "repositoryURL": "https://github.com/joogps/SlideOverCard", + "state": { + "branch": null, + "revision": "e38be074ab7a5b46d0a18f550bf94972a0f582c6", + "version": "3.0.1" + } } ] }, From 89c8311809a966ee743f0ab2a3a7ac0168069d6d Mon Sep 17 00:00:00 2001 From: Francesco <100066266+cranci1@users.noreply.github.com> Date: Tue, 8 Apr 2025 20:17:22 +0200 Subject: [PATCH 35/37] HELL YEAHHH --- .../CustomPlayer/CustomPlayer.swift | 16 +++++- Sora/Utils/MediaPlayer/VideoPlayer.swift | 56 +++++++++++-------- Sora/Views/LibraryView/LibraryView.swift | 1 + Sora/Views/MediaInfoView/MediaInfoView.swift | 1 + Sulfur.xcodeproj/project.pbxproj | 17 ------ .../xcshareddata/swiftpm/Package.resolved | 9 --- 6 files changed, 51 insertions(+), 49 deletions(-) diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift index c6e71b1..44a7eb8 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift @@ -920,6 +920,20 @@ class CustomMediaPlayerViewController: UIViewController { } DispatchQueue.main.async { + let remainingPercentage = (self.duration - self.currentTimeVal) / self.duration + + if remainingPercentage < 0.1 && self.module.metadata.type == "anime" && self.aniListID != 0 { + let aniListMutation = AniListMutation() + aniListMutation.updateAnimeProgress(animeId: self.aniListID, episodeNumber: self.episodeNumber) { result in + switch result { + case .success: + Logger.shared.log("Successfully updated AniList progress for episode \(self.episodeNumber)", type: "General") + case .failure(let error): + Logger.shared.log("Failed to update AniList progress: \(error.localizedDescription)", type: "Error") + } + } + } + self.sliderHostingController?.rootView = MusicProgressSlider( value: Binding( get: { max(0, min(self.sliderViewModel.sliderValue, self.duration)) }, @@ -1623,7 +1637,7 @@ class CustomMediaPlayerViewController: UIViewController { func setupTimeControlStatusObservation() { playerTimeControlStatusObserver = player.observe(\.timeControlStatus, options: [.new]) { [weak self] player, _ in - guard let self = self else { return } + guard self != nil else { return } if player.timeControlStatus == .paused, let reason = player.reasonForWaitingToPlay { // If we are paused for a “stall/minimize stalls” reason, forcibly resume: diff --git a/Sora/Utils/MediaPlayer/VideoPlayer.swift b/Sora/Utils/MediaPlayer/VideoPlayer.swift index 1f001c3..e26f29a 100644 --- a/Sora/Utils/MediaPlayer/VideoPlayer.swift +++ b/Sora/Utils/MediaPlayer/VideoPlayer.swift @@ -101,8 +101,9 @@ class VideoPlayerViewController: UIViewController { guard let player = self.player else { return } let interval = CMTime(seconds: 1.0, preferredTimescale: CMTimeScale(NSEC_PER_SEC)) - timeObserverToken = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { time in - guard let currentItem = player.currentItem, + timeObserverToken = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in + guard let self = self, + let currentItem = player.currentItem, currentItem.duration.seconds.isFinite else { return } @@ -112,26 +113,37 @@ class VideoPlayerViewController: UIViewController { UserDefaults.standard.set(currentTime, forKey: "lastPlayedTime_\(fullURL)") UserDefaults.standard.set(duration, forKey: "totalTime_\(fullURL)") - } - - if let currentItem = player.currentItem, currentItem.duration.seconds > 0, - let streamUrl = streamUrl { - let currentTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(fullUrl)") - let duration = currentItem.duration.seconds - let progress = currentTime / duration - let item = ContinueWatchingItem( - id: UUID(), - imageUrl: episodeImageUrl, - episodeNumber: episodeNumber, - mediaTitle: mediaTitle, - progress: progress, - streamUrl: streamUrl, - fullUrl: fullUrl, - subtitles: subtitles, - aniListID: aniListID, - module: module - ) - ContinueWatchingManager.shared.save(item: item) + + let remainingPercentage = (duration - currentTime) / duration + + if remainingPercentage < 0.1 && self.module.metadata.type == "anime" && self.aniListID != 0 { + let aniListMutation = AniListMutation() + aniListMutation.updateAnimeProgress(animeId: self.aniListID, episodeNumber: self.episodeNumber) { result in + switch result { + case .success: + Logger.shared.log("Successfully updated AniList progress for episode \(self.episodeNumber)", type: "General") + case .failure(let error): + Logger.shared.log("Failed to update AniList progress: \(error.localizedDescription)", type: "Error") + } + } + } + + if let streamUrl = self.streamUrl { + let progress = currentTime / duration + let item = ContinueWatchingItem( + id: UUID(), + imageUrl: self.episodeImageUrl, + episodeNumber: self.episodeNumber, + mediaTitle: self.mediaTitle, + progress: progress, + streamUrl: streamUrl, + fullUrl: self.fullUrl, + subtitles: self.subtitles, + aniListID: self.aniListID, + module: self.module + ) + ContinueWatchingManager.shared.save(item: item) + } } } diff --git a/Sora/Views/LibraryView/LibraryView.swift b/Sora/Views/LibraryView/LibraryView.swift index 9dc269b..0048c41 100644 --- a/Sora/Views/LibraryView/LibraryView.swift +++ b/Sora/Views/LibraryView/LibraryView.swift @@ -233,6 +233,7 @@ struct ContinueWatchingCell: View { videoPlayerViewController.episodeNumber = item.episodeNumber videoPlayerViewController.mediaTitle = item.mediaTitle videoPlayerViewController.subtitles = item.subtitles ?? "" + videoPlayerViewController.aniListID = item.aniListID ?? 0 videoPlayerViewController.modalPresentationStyle = .fullScreen if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index 2c1788a..6d0ee74 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -694,6 +694,7 @@ struct MediaInfoView: View { videoPlayerViewController.episodeImageUrl = selectedEpisodeImage videoPlayerViewController.mediaTitle = title videoPlayerViewController.subtitles = subtitles ?? "" + videoPlayerViewController.aniListID = itemID ?? 0 videoPlayerViewController.modalPresentationStyle = .fullScreen if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, diff --git a/Sulfur.xcodeproj/project.pbxproj b/Sulfur.xcodeproj/project.pbxproj index d21e31e..a2d7091 100644 --- a/Sulfur.xcodeproj/project.pbxproj +++ b/Sulfur.xcodeproj/project.pbxproj @@ -45,7 +45,6 @@ 13B7F4C12D58FFDD0045714A /* Shimmer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13B7F4C02D58FFDD0045714A /* Shimmer.swift */; }; 13C0E5EA2D5F85EA00E7F619 /* ContinueWatchingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13C0E5E92D5F85EA00E7F619 /* ContinueWatchingManager.swift */; }; 13C0E5EC2D5F85F800E7F619 /* ContinueWatchingItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13C0E5EB2D5F85F800E7F619 /* ContinueWatchingItem.swift */; }; - 13C285F62DA56B6F009FB0D0 /* SlideOverCard in Frameworks */ = {isa = PBXBuildFile; productRef = 13C285F52DA56B6F009FB0D0 /* SlideOverCard */; }; 13CBA0882D60F19C00EFE70A /* VTTSubtitlesLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13CBA0872D60F19C00EFE70A /* VTTSubtitlesLoader.swift */; }; 13CBEFDA2D5F7D1200D011EE /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13CBEFD92D5F7D1200D011EE /* String.swift */; }; 13D842552D45267500EBBFA6 /* DropManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13D842542D45267500EBBFA6 /* DropManager.swift */; }; @@ -129,7 +128,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 13C285F62DA56B6F009FB0D0 /* SlideOverCard in Frameworks */, 13B77E192DA44F8300126FDF /* MarqueeLabel in Frameworks */, 132E35232D959E410007800E /* Kingfisher in Frameworks */, 132E35202D959E1D0007800E /* FFmpeg-iOS-Lame in Frameworks */, @@ -438,7 +436,6 @@ 132E351F2D959E1D0007800E /* FFmpeg-iOS-Lame */, 132E35222D959E410007800E /* Kingfisher */, 13B77E182DA44F8300126FDF /* MarqueeLabel */, - 13C285F52DA56B6F009FB0D0 /* SlideOverCard */, ); productName = Sora; productReference = 133D7C6A2D2BE2500075467E /* Sulfur.app */; @@ -472,7 +469,6 @@ 132E351E2D959E1D0007800E /* XCRemoteSwiftPackageReference "FFmpeg-iOS-Lame" */, 132E35212D959E410007800E /* XCRemoteSwiftPackageReference "Kingfisher" */, 13B77E172DA44F8300126FDF /* XCRemoteSwiftPackageReference "MarqueeLabel" */, - 13C285F42DA56B6F009FB0D0 /* XCRemoteSwiftPackageReference "SlideOverCard" */, ); productRefGroup = 133D7C6B2D2BE2500075467E /* Products */; projectDirPath = ""; @@ -816,14 +812,6 @@ version = 4.2.1; }; }; - 13C285F42DA56B6F009FB0D0 /* XCRemoteSwiftPackageReference "SlideOverCard" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/joogps/SlideOverCard"; - requirement = { - kind = exactVersion; - version = 3.0.1; - }; - }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -847,11 +835,6 @@ package = 13B77E172DA44F8300126FDF /* XCRemoteSwiftPackageReference "MarqueeLabel" */; productName = MarqueeLabel; }; - 13C285F52DA56B6F009FB0D0 /* SlideOverCard */ = { - isa = XCSwiftPackageProductDependency; - package = 13C285F42DA56B6F009FB0D0 /* XCRemoteSwiftPackageReference "SlideOverCard" */; - productName = SlideOverCard; - }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 133D7C622D2BE2500075467E /* Project object */; diff --git a/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 2754ca7..5fe78fe 100644 --- a/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -45,15 +45,6 @@ "revision": "cffb6938940d3242882e6a2f9170b7890a4729ea", "version": "4.2.1" } - }, - { - "package": "SlideOverCard", - "repositoryURL": "https://github.com/joogps/SlideOverCard", - "state": { - "branch": null, - "revision": "e38be074ab7a5b46d0a18f550bf94972a0f582c6", - "version": "3.0.1" - } } ] }, From 5fef2f15b8eca9c3a08e6b0431d3130df3412ecc Mon Sep 17 00:00:00 2001 From: Francesco <100066266+cranci1@users.noreply.github.com> Date: Tue, 8 Apr 2025 20:33:16 +0200 Subject: [PATCH 36/37] =?UTF-8?q?better=20=F0=9F=92=B4=20=F0=9F=92=B4?= =?UTF-8?q?=F0=9F=92=B4=F0=9F=92=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AniList/Auth/Anilist-Token.swift | 68 ++++++++++++------- .../Mutations/AniListPushUpdates.swift | 5 ++ .../CustomPlayer/CustomPlayer.swift | 1 - .../SettingsViewTrackers.swift | 35 +++++++++- Sora/Views/SettingsView/SettingsView.swift | 6 +- 5 files changed, 85 insertions(+), 30 deletions(-) diff --git a/Sora/Tracking Services/AniList/Auth/Anilist-Token.swift b/Sora/Tracking Services/AniList/Auth/Anilist-Token.swift index 68bc232..883e0e9 100644 --- a/Sora/Tracking Services/AniList/Auth/Anilist-Token.swift +++ b/Sora/Tracking Services/AniList/Auth/Anilist-Token.swift @@ -17,6 +17,9 @@ class AniListToken { static let serviceName = "me.cranci.sora.AniListToken" static let accountName = "AniListAccessToken" + static let authSuccessNotification = Notification.Name("AniListAuthenticationSuccess") + static let authFailureNotification = Notification.Name("AniListAuthenticationFailure") + static func saveTokenToKeychain(token: String) -> Bool { let tokenData = token.data(using: .utf8)! @@ -43,7 +46,10 @@ class AniListToken { guard let url = URL(string: tokenEndpoint) else { Logger.shared.log("Invalid token endpoint URL", type: "Error") - completion(false) + DispatchQueue.main.async { + NotificationCenter.default.post(name: authFailureNotification, object: nil, userInfo: ["error": "Invalid token endpoint URL"]) + completion(false) + } return } @@ -55,31 +61,43 @@ class AniListToken { request.httpBody = bodyString.data(using: .utf8) let task = URLSession.shared.dataTask(with: request) { data, response, error in - if let error = error { - Logger.shared.log("Error: \(error.localizedDescription)", type: "Error") - completion(false) - return - } - - guard let data = data else { - Logger.shared.log("No data received", type: "Error") - completion(false) - return - } - - do { - if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] { - if let accessToken = json["access_token"] as? String { - let success = saveTokenToKeychain(token: accessToken) - completion(success) - } else { - Logger.shared.log("Unexpected response: \(json)", type: "Error") - completion(false) - } + DispatchQueue.main.async { + if let error = error { + Logger.shared.log("Error: \(error.localizedDescription)", type: "Error") + NotificationCenter.default.post(name: authFailureNotification, object: nil, userInfo: ["error": error.localizedDescription]) + completion(false) + return + } + + guard let data = data else { + Logger.shared.log("No data received", type: "Error") + NotificationCenter.default.post(name: authFailureNotification, object: nil, userInfo: ["error": "No data received"]) + completion(false) + return + } + + do { + if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] { + if let accessToken = json["access_token"] as? String { + let success = saveTokenToKeychain(token: accessToken) + if success { + NotificationCenter.default.post(name: authSuccessNotification, object: nil) + } else { + NotificationCenter.default.post(name: authFailureNotification, object: nil, userInfo: ["error": "Failed to save token to keychain"]) + } + completion(success) + } else { + let errorMessage = (json["error"] as? String) ?? "Unexpected response" + Logger.shared.log("Authentication error: \(errorMessage)", type: "Error") + NotificationCenter.default.post(name: authFailureNotification, object: nil, userInfo: ["error": errorMessage]) + completion(false) + } + } + } catch { + Logger.shared.log("Failed to parse JSON: \(error.localizedDescription)", type: "Error") + NotificationCenter.default.post(name: authFailureNotification, object: nil, userInfo: ["error": "Failed to parse response: \(error.localizedDescription)"]) + completion(false) } - } catch { - Logger.shared.log("Failed to parse JSON: \(error.localizedDescription)", type: "Error") - completion(false) } } diff --git a/Sora/Tracking Services/AniList/Mutations/AniListPushUpdates.swift b/Sora/Tracking Services/AniList/Mutations/AniListPushUpdates.swift index 69db061..ade8898 100644 --- a/Sora/Tracking Services/AniList/Mutations/AniListPushUpdates.swift +++ b/Sora/Tracking Services/AniList/Mutations/AniListPushUpdates.swift @@ -34,6 +34,11 @@ class AniListMutation { } func updateAnimeProgress(animeId: Int, episodeNumber: Int, completion: @escaping (Result) -> Void) { + if let sendPushUpdates = UserDefaults.standard.object(forKey: "sendPushUpdates") as? Bool, + sendPushUpdates == false { + return + } + guard let userToken = getTokenFromKeychain() else { completion(.failure(NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "Access token not found"]))) return diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift index 44a7eb8..4580c3e 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift @@ -1640,7 +1640,6 @@ class CustomMediaPlayerViewController: UIViewController { guard self != nil else { return } if player.timeControlStatus == .paused, let reason = player.reasonForWaitingToPlay { - // If we are paused for a “stall/minimize stalls” reason, forcibly resume: Logger.shared.log("Paused reason: \(reason)", type: "Error") if reason == .toMinimizeStalls || reason == .evaluatingBufferingRate { player.play() diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewTrackers.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewTrackers.swift index 6727051..18ca0c2 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewTrackers.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewTrackers.swift @@ -10,6 +10,8 @@ import Security import Kingfisher struct SettingsViewTrackers: View { + @AppStorage("sendPushUpdates") private var isSendPushUpdates = true + @State private var status: String = "You are not logged in" @State private var isLoggedIn: Bool = false @State private var username: String = "" @@ -18,7 +20,7 @@ struct SettingsViewTrackers: View { var body: some View { Form { - Section(header: Text("AniList"), footer: Text("Sora and cranci1 are not affiliated with AniList in any way.")) { + Section(header: Text("AniList"), footer: Text("Sora and cranci1 are not affiliated with AniList in any way.\n\nNote that push updates may not be 100% acurate.")) { HStack() { KFImage(URL(string: "https://raw.githubusercontent.com/cranci1/Ryu/2f10226aa087154974a70c1ec78aa83a47daced9/Ryu/Assets.xcassets/Listing/Anilist.imageset/anilist.png")) .placeholder { @@ -50,6 +52,10 @@ struct SettingsViewTrackers: View { .multilineTextAlignment(.center) } } + if isLoggedIn { + Toggle("Send push updates", isOn: $isSendPushUpdates) + .tint(.accentColor) + } Button(isLoggedIn ? "Log Out from AniList.co" : "Log In with AniList.co") { if isLoggedIn { logout() @@ -63,11 +69,38 @@ struct SettingsViewTrackers: View { .navigationTitle("Trackers") .onAppear { updateStatus() + setupNotificationObservers() } + .onDisappear { + removeNotificationObservers() + } + } + + func setupNotificationObservers() { + NotificationCenter.default.addObserver(forName: AniListToken.authSuccessNotification, object: nil, queue: .main) { _ in + self.status = "Authentication successful!" + self.updateStatus() + } + + NotificationCenter.default.addObserver(forName: AniListToken.authFailureNotification, object: nil, queue: .main) { notification in + if let error = notification.userInfo?["error"] as? String { + self.status = "Login failed: \(error)" + } else { + self.status = "Login failed with unknown error" + } + self.isLoggedIn = false + self.isLoading = false + } + } + + func removeNotificationObservers() { + NotificationCenter.default.removeObserver(self, name: AniListToken.authSuccessNotification, object: nil) + NotificationCenter.default.removeObserver(self, name: AniListToken.authFailureNotification, object: nil) } func login() { status = "Starting authentication..." + isLoading = true AniListLogin.authenticate() } diff --git a/Sora/Views/SettingsView/SettingsView.swift b/Sora/Views/SettingsView/SettingsView.swift index eacb628..27d255c 100644 --- a/Sora/Views/SettingsView/SettingsView.swift +++ b/Sora/Views/SettingsView/SettingsView.swift @@ -21,9 +21,9 @@ struct SettingsView: View { NavigationLink(destination: SettingsViewModule()) { Text("Modules") } - //NavigationLink(destination: SettingsViewTrackers()) { - // Text("Trackers") - //} + NavigationLink(destination: SettingsViewTrackers()) { + Text("Trackers") + } } Section(header: Text("Info")) { From 67433e1143bed6e211497c474a324e2068c1f80a Mon Sep 17 00:00:00 2001 From: Seiike <122684677+Seeike@users.noreply.github.com> Date: Tue, 8 Apr 2025 20:41:33 +0200 Subject: [PATCH 37/37] =?UTF-8?q?catch=20mario=20=F0=9F=8F=88=20=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fixed brightness bar not hiding * u can now toggle skip 85s button * more appealing title text --- .../CustomPlayer/CustomPlayer.swift | 28 +++++++++---------- .../SettingsSubViews/SettingsViewPlayer.swift | 4 +++ 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift index 4580c3e..109b792 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift @@ -50,6 +50,10 @@ class CustomMediaPlayerViewController: UIViewController { UserDefaults.standard.bool(forKey: "holdForPauseEnabled") } + private var isSkip85Visible: Bool { + return UserDefaults.standard.bool(forKey: "skip85Visible") + } + var showWatchNextButton = true var watchNextButtonTimer: Timer? var isWatchNextRepositioned: Bool = false @@ -248,6 +252,8 @@ class CustomMediaPlayerViewController: UIViewController { selector: #selector(playerItemDidChange), name: .AVPlayerItemNewAccessLogEntry, object: nil) + + skip85Button?.isHidden = !isSkip85Visible } override func viewWillDisappear(_ animated: Bool) { @@ -470,9 +476,7 @@ class CustomMediaPlayerViewController: UIViewController { let brightnessSlider = VerticalBrightnessSlider( value: Binding( get: { self.brightnessValue }, - set: { newValue in - self.brightnessValue = newValue - } + set: { newValue in self.brightnessValue = newValue } ), inRange: 0...1, activeFillColor: .white, @@ -487,8 +491,8 @@ class CustomMediaPlayerViewController: UIViewController { brightnessContainer.translatesAutoresizingMaskIntoConstraints = false brightnessContainer.backgroundColor = .clear - // Add the container to the main view and anchor it to the safe area. - view.addSubview(brightnessContainer) + controlsContainerView.addSubview(brightnessContainer) + NSLayoutConstraint.activate([ brightnessContainer.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 16), brightnessContainer.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor), @@ -511,7 +515,6 @@ class CustomMediaPlayerViewController: UIViewController { ]) } - func addInvisibleControlOverlays() { let playPauseOverlay = UIButton(type: .custom) playPauseOverlay.backgroundColor = .clear @@ -660,12 +663,11 @@ class CustomMediaPlayerViewController: UIViewController { } func setupMarqueeLabel() { - // Create the MarqueeLabel and configure its scrolling behavior marqueeLabel = MarqueeLabel() marqueeLabel.text = "\(titleText) • Ep \(episodeNumber)" marqueeLabel.type = .continuous marqueeLabel.textColor = .white - marqueeLabel.font = UIFont.systemFont(ofSize: 15, weight: .medium) + marqueeLabel.font = UIFont.systemFont(ofSize: 14, weight: .heavy) marqueeLabel.speed = .rate(30) // Adjust scrolling speed as needed marqueeLabel.fadeLength = 10.0 // Fading at the label’s edges @@ -673,14 +675,12 @@ class CustomMediaPlayerViewController: UIViewController { marqueeLabel.trailingBuffer = 16.0 // Right inset for scrolling marqueeLabel.animationDelay = 2.5 - // Set default lineBreakMode (will be updated later based on available width) marqueeLabel.lineBreakMode = .byTruncatingTail marqueeLabel.textAlignment = .left controlsContainerView.addSubview(marqueeLabel) marqueeLabel.translatesAutoresizingMaskIntoConstraints = false - // Define four sets of constraints: // 1. Portrait mode with button visible portraitButtonVisibleConstraints = [ marqueeLabel.leadingAnchor.constraint(equalTo: dismissButton.trailingAnchor, constant: 12), @@ -708,8 +708,6 @@ class CustomMediaPlayerViewController: UIViewController { marqueeLabel.trailingAnchor.constraint(equalTo: controlsContainerView.trailingAnchor, constant: -8), marqueeLabel.centerYAnchor.constraint(equalTo: dismissButton.centerYAnchor) ] - - // Activate an initial set based on the current orientation and menuButton state updateMarqueeConstraints() } @@ -844,6 +842,8 @@ class CustomMediaPlayerViewController: UIViewController { skip85Button.heightAnchor.constraint(equalToConstant: 47), skip85Button.widthAnchor.constraint(greaterThanOrEqualToConstant: 97) ]) + + skip85Button.isHidden = !isSkip85Visible } private func setupQualityButton() { @@ -1050,7 +1050,7 @@ class CustomMediaPlayerViewController: UIViewController { @objc func toggleControls() { isControlsVisible.toggle() - UIView.animate(withDuration: 0.5, delay: 0, options: .curveEaseInOut, animations: { + UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseInOut, animations: { self.controlsContainerView.alpha = self.isControlsVisible ? 1 : 0 self.skip85Button.alpha = self.isControlsVisible ? 0.8 : 0 @@ -1059,7 +1059,7 @@ class CustomMediaPlayerViewController: UIViewController { NSLayoutConstraint.activate(self.watchNextButtonControlsConstraints) if self.isWatchNextRepositioned || self.isWatchNextVisible { self.watchNextButton.isHidden = false - UIView.animate(withDuration: 0.5, animations: { + UIView.animate(withDuration: 0.3, animations: { self.watchNextButton.alpha = 0.8 }) } diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift index 53c1ce9..78e61a5 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift @@ -16,6 +16,8 @@ struct SettingsViewPlayer: View { @AppStorage("skipIncrement") private var skipIncrement: Double = 10.0 @AppStorage("skipIncrementHold") private var skipIncrementHold: Double = 30.0 @AppStorage("holdForPauseEnabled") private var holdForPauseEnabled = false + @AppStorage("skip85Visible") private var skip85Visible: Bool = true + private let mediaPlayers = ["Default", "VLC", "OutPlayer", "Infuse", "nPlayer", "Sora"] @@ -74,6 +76,8 @@ struct SettingsViewPlayer: View { Spacer() Stepper("\(Int(skipIncrementHold))s", value: $skipIncrementHold, in: 5...300, step: 5) } + Toggle("Show Skip 85s Button", isOn: $skip85Visible) + .tint(.accentColor) } SubtitleSettingsSection() }