mirror of
https://github.com/cranci1/Sora.git
synced 2026-04-21 08:32:00 +00:00
v0.2.1 - beta 2 (#69)
This commit is contained in:
commit
97b366d4c3
28 changed files with 1683 additions and 1023 deletions
|
|
@ -2,8 +2,8 @@
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>Sora may requires access to your device's camera.</string>
|
<string>Sora may requires access to your device's camera.</string>
|
||||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||||
<array>
|
<array>
|
||||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
|
@ -38,5 +38,7 @@
|
||||||
<string>audio</string>
|
<string>audio</string>
|
||||||
<string>processing</string>
|
<string>processing</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>UISupportsDocumentBrowser</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@ class AniListToken {
|
||||||
static let serviceName = "me.cranci.sora.AniListToken"
|
static let serviceName = "me.cranci.sora.AniListToken"
|
||||||
static let accountName = "AniListAccessToken"
|
static let accountName = "AniListAccessToken"
|
||||||
|
|
||||||
|
static let authSuccessNotification = Notification.Name("AniListAuthenticationSuccess")
|
||||||
|
static let authFailureNotification = Notification.Name("AniListAuthenticationFailure")
|
||||||
|
|
||||||
static func saveTokenToKeychain(token: String) -> Bool {
|
static func saveTokenToKeychain(token: String) -> Bool {
|
||||||
let tokenData = token.data(using: .utf8)!
|
let tokenData = token.data(using: .utf8)!
|
||||||
|
|
||||||
|
|
@ -43,7 +46,10 @@ class AniListToken {
|
||||||
|
|
||||||
guard let url = URL(string: tokenEndpoint) else {
|
guard let url = URL(string: tokenEndpoint) else {
|
||||||
Logger.shared.log("Invalid token endpoint URL", type: "Error")
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -55,31 +61,43 @@ class AniListToken {
|
||||||
request.httpBody = bodyString.data(using: .utf8)
|
request.httpBody = bodyString.data(using: .utf8)
|
||||||
|
|
||||||
let task = URLSession.shared.dataTask(with: request) { data, response, error in
|
let task = URLSession.shared.dataTask(with: request) { data, response, error in
|
||||||
if let error = error {
|
DispatchQueue.main.async {
|
||||||
Logger.shared.log("Error: \(error.localizedDescription)", type: "Error")
|
if let error = error {
|
||||||
completion(false)
|
Logger.shared.log("Error: \(error.localizedDescription)", type: "Error")
|
||||||
return
|
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")
|
|
||||||
completion(false)
|
guard let data = data else {
|
||||||
return
|
Logger.shared.log("No data received", type: "Error")
|
||||||
}
|
NotificationCenter.default.post(name: authFailureNotification, object: nil, userInfo: ["error": "No data received"])
|
||||||
|
completion(false)
|
||||||
do {
|
return
|
||||||
if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
}
|
||||||
if let accessToken = json["access_token"] as? String {
|
|
||||||
let success = saveTokenToKeychain(token: accessToken)
|
do {
|
||||||
completion(success)
|
if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
||||||
} else {
|
if let accessToken = json["access_token"] as? String {
|
||||||
Logger.shared.log("Unexpected response: \(json)", type: "Error")
|
let success = saveTokenToKeychain(token: accessToken)
|
||||||
completion(false)
|
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
//
|
||||||
|
// 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, Error>) -> 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 ?? ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -16,5 +16,6 @@ struct ContinueWatchingItem: Codable, Identifiable {
|
||||||
let streamUrl: String
|
let streamUrl: String
|
||||||
let fullUrl: String
|
let fullUrl: String
|
||||||
let subtitles: String?
|
let subtitles: String?
|
||||||
|
let aniListID: Int?
|
||||||
let module: ScrapingModule
|
let module: ScrapingModule
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -126,7 +126,7 @@ class DownloadManager {
|
||||||
|
|
||||||
ffmpegCommand.append(contentsOf: ["-fflags", "+genpts"])
|
ffmpegCommand.append(contentsOf: ["-fflags", "+genpts"])
|
||||||
ffmpegCommand.append(contentsOf: ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5"])
|
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")
|
let multiThreads = UserDefaults.standard.bool(forKey: "multiThreads")
|
||||||
if multiThreads {
|
if multiThreads {
|
||||||
|
|
|
||||||
185
Sora/Utils/JSLoader/JSController-Details.swift
Normal file
185
Sora/Utils/JSLoader/JSController-Details.swift
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
129
Sora/Utils/JSLoader/JSController-Search.swift
Normal file
129
Sora/Utils/JSLoader/JSController-Search.swift
Normal file
|
|
@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
265
Sora/Utils/JSLoader/JSController-Streams.swift
Normal file
265
Sora/Utils/JSLoader/JSController-Streams.swift
Normal file
|
|
@ -0,0 +1,265 @@
|
||||||
|
//
|
||||||
|
// JSLoader-Streams.swift
|
||||||
|
// Sulfur
|
||||||
|
//
|
||||||
|
// Created by Francesco on 30/03/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import JavaScriptCore
|
||||||
|
|
||||||
|
extension JSController {
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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)) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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, 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))
|
||||||
|
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 self != nil else { return }
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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)) }
|
||||||
|
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
|
||||||
|
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, 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 }
|
||||||
|
|
||||||
|
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 self != nil else { return }
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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)) }
|
||||||
|
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
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,14 +8,14 @@
|
||||||
import JavaScriptCore
|
import JavaScriptCore
|
||||||
|
|
||||||
class JSController: ObservableObject {
|
class JSController: ObservableObject {
|
||||||
private var context: JSContext
|
var context: JSContext
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
self.context = JSContext()
|
self.context = JSContext()
|
||||||
setupContext()
|
setupContext()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupContext() {
|
func setupContext() {
|
||||||
context.setupJavaScriptEnvironment()
|
context.setupJavaScriptEnvironment()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -27,482 +27,4 @@ class JSController: ObservableObject {
|
||||||
Logger.shared.log("Error loading script: \(exception)", type: "Error")
|
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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,18 +5,20 @@
|
||||||
// Created by Pratik on 08/01/23.
|
// 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"
|
// 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 some of the code for my liking (added a buffer indicator, etc.)
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct MusicProgressSlider<T: BinaryFloatingPoint>: View {
|
struct MusicProgressSlider<T: BinaryFloatingPoint>: View {
|
||||||
@Binding var value: T
|
@Binding var value: T
|
||||||
|
@Binding var bufferValue: T // NEW
|
||||||
let inRange: ClosedRange<T>
|
let inRange: ClosedRange<T>
|
||||||
|
|
||||||
let activeFillColor: Color
|
let activeFillColor: Color
|
||||||
let fillColor: Color
|
let fillColor: Color
|
||||||
let emptyColor: Color
|
let emptyColor: Color
|
||||||
let height: CGFloat
|
let height: CGFloat
|
||||||
|
|
||||||
let onEditingChanged: (Bool) -> Void
|
let onEditingChanged: (Bool) -> Void
|
||||||
|
|
||||||
@State private var localRealProgress: T = 0
|
@State private var localRealProgress: T = 0
|
||||||
|
|
@ -27,49 +29,81 @@ struct MusicProgressSlider<T: BinaryFloatingPoint>: View {
|
||||||
GeometryReader { bounds in
|
GeometryReader { bounds in
|
||||||
ZStack {
|
ZStack {
|
||||||
VStack {
|
VStack {
|
||||||
|
// Base track + buffer indicator + current progress
|
||||||
ZStack(alignment: .center) {
|
ZStack(alignment: .center) {
|
||||||
|
|
||||||
|
// Entire background track
|
||||||
Capsule()
|
Capsule()
|
||||||
.fill(emptyColor)
|
.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()
|
Capsule()
|
||||||
.fill(isActive ? activeFillColor : fillColor)
|
.fill(isActive ? activeFillColor : fillColor)
|
||||||
.mask({
|
.mask({
|
||||||
HStack {
|
HStack {
|
||||||
Rectangle()
|
Rectangle()
|
||||||
.frame(width: max((bounds.size.width * CGFloat(localRealProgress + localTempProgress)).isFinite ? bounds.size.width * CGFloat(localRealProgress + localTempProgress) : 0, 0), alignment: .leading)
|
.frame(
|
||||||
|
width: max(
|
||||||
|
bounds.size.width * CGFloat(localRealProgress + localTempProgress),
|
||||||
|
0
|
||||||
|
),
|
||||||
|
alignment: .leading
|
||||||
|
)
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Time labels
|
||||||
HStack {
|
HStack {
|
||||||
// Determine if we should show hours based on the total duration.
|
|
||||||
let shouldShowHours = inRange.upperBound >= 3600
|
let shouldShowHours = inRange.upperBound >= 3600
|
||||||
Text(value.asTimeString(style: .positional, showHours: shouldShowHours))
|
Text(value.asTimeString(style: .positional, showHours: shouldShowHours))
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
Text("-" + (inRange.upperBound - value).asTimeString(style: .positional, showHours: shouldShowHours))
|
Text("-" + (inRange.upperBound - value)
|
||||||
|
.asTimeString(style: .positional, showHours: shouldShowHours))
|
||||||
}
|
}
|
||||||
|
|
||||||
.font(.system(size: 12))
|
.font(.system(size: 12))
|
||||||
.foregroundColor(isActive ? fillColor : emptyColor)
|
.foregroundColor(isActive ? fillColor : emptyColor)
|
||||||
}
|
}
|
||||||
.frame(width: isActive ? bounds.size.width * 1.04 : bounds.size.width, alignment: .center)
|
.frame(width: isActive ? bounds.size.width * 1.04 : bounds.size.width,
|
||||||
|
alignment: .center)
|
||||||
.animation(animation, value: isActive)
|
.animation(animation, value: isActive)
|
||||||
}
|
}
|
||||||
.frame(width: bounds.size.width, height: bounds.size.height, alignment: .center)
|
.frame(width: bounds.size.width, height: bounds.size.height, alignment: .center)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.gesture(DragGesture(minimumDistance: 0, coordinateSpace: .local)
|
.gesture(
|
||||||
.updating($isActive) { _, state, _ in
|
DragGesture(minimumDistance: 0, coordinateSpace: .local)
|
||||||
state = true
|
.updating($isActive) { _, state, _ in
|
||||||
}
|
state = true
|
||||||
.onChanged { gesture in
|
}
|
||||||
localTempProgress = T(gesture.translation.width / bounds.size.width)
|
.onChanged { gesture in
|
||||||
value = max(min(getPrgValue(), inRange.upperBound), inRange.lowerBound)
|
localTempProgress = T(gesture.translation.width / bounds.size.width)
|
||||||
}.onEnded { _ in
|
value = clampValue(getPrgValue())
|
||||||
localRealProgress = max(min(localRealProgress + localTempProgress, 1), 0)
|
}
|
||||||
localTempProgress = 0
|
.onEnded { _ in
|
||||||
})
|
localRealProgress = getPrgPercentage(value)
|
||||||
|
localTempProgress = 0
|
||||||
|
}
|
||||||
|
)
|
||||||
.onChange(of: isActive) { newValue in
|
.onChange(of: isActive) { newValue in
|
||||||
value = max(min(getPrgValue(), inRange.upperBound), inRange.lowerBound)
|
value = clampValue(getPrgValue())
|
||||||
onEditingChanged(newValue)
|
onEditingChanged(newValue)
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
|
@ -85,21 +119,24 @@ struct MusicProgressSlider<T: BinaryFloatingPoint>: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private var animation: Animation {
|
private var animation: Animation {
|
||||||
if isActive {
|
isActive
|
||||||
return .spring()
|
? .spring()
|
||||||
} else {
|
: .spring(response: 0.5, dampingFraction: 0.5, blendDuration: 0.6)
|
||||||
return .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 range = inRange.upperBound - inRange.lowerBound
|
||||||
let correctedStartValue = value - inRange.lowerBound
|
let pct = (clampedValue - inRange.lowerBound) / range
|
||||||
let percentage = correctedStartValue / range
|
return max(min(pct, 1), 0)
|
||||||
return percentage
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func getPrgValue() -> T {
|
private func getPrgValue() -> T {
|
||||||
return ((localRealProgress + localTempProgress) * (inRange.upperBound - inRange.lowerBound)) + inRange.lowerBound
|
((localRealProgress + localTempProgress) * (inRange.upperBound - inRange.lowerBound))
|
||||||
|
+ inRange.lowerBound
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,136 @@
|
||||||
|
//
|
||||||
|
// VerticalBrightnessSlider.swift
|
||||||
|
// Custom Brighness bar
|
||||||
|
//
|
||||||
|
// Created by Pratik on 08/01/23.
|
||||||
|
// Modified to update screen brightness when used as a brightness slider.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct VerticalBrightnessSlider<T: BinaryFloatingPoint>: View {
|
||||||
|
@Binding var value: T
|
||||||
|
let inRange: ClosedRange<T>
|
||||||
|
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: isActive ? 16 : 12, weight: .medium, design: .rounded))
|
||||||
|
.foregroundColor(isActive ? fillColor : Color.white)
|
||||||
|
.animation(.spring(), value: isActive)
|
||||||
|
.frame(maxHeight: .infinity, alignment: .bottom)
|
||||||
|
.padding(.bottom)
|
||||||
|
.overlay {
|
||||||
|
Image(systemName: getIconName)
|
||||||
|
.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 {
|
||||||
|
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 * 1.9 : width, alignment: .center)
|
||||||
|
.offset(x: isActive ? -10 : 0)
|
||||||
|
.onChange(of: value) { newValue in
|
||||||
|
UIScreen.main.brightness = CGFloat(newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var getIconName: String {
|
||||||
|
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:
|
||||||
|
return "sun.max.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,14 +6,20 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import MarqueeLabel
|
||||||
import AVKit
|
import AVKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
|
|
||||||
|
// MARK: - SliderViewModel
|
||||||
|
|
||||||
class SliderViewModel: ObservableObject {
|
class SliderViewModel: ObservableObject {
|
||||||
@Published var sliderValue: Double = 0.0
|
@Published var sliderValue: Double = 0.0
|
||||||
|
@Published var bufferValue: Double = 0.0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - CustomMediaPlayerViewController
|
||||||
|
|
||||||
class CustomMediaPlayerViewController: UIViewController {
|
class CustomMediaPlayerViewController: UIViewController {
|
||||||
let module: ScrapingModule
|
let module: ScrapingModule
|
||||||
let streamURL: String
|
let streamURL: String
|
||||||
|
|
@ -23,6 +29,7 @@ class CustomMediaPlayerViewController: UIViewController {
|
||||||
let episodeImageUrl: String
|
let episodeImageUrl: String
|
||||||
let subtitlesURL: String?
|
let subtitlesURL: String?
|
||||||
let onWatchNext: () -> Void
|
let onWatchNext: () -> Void
|
||||||
|
let aniListID: Int
|
||||||
|
|
||||||
var player: AVPlayer!
|
var player: AVPlayer!
|
||||||
var timeObserverToken: Any?
|
var timeObserverToken: Any?
|
||||||
|
|
@ -35,14 +42,31 @@ class CustomMediaPlayerViewController: UIViewController {
|
||||||
var currentTimeVal: Double = 0.0
|
var currentTimeVal: Double = 0.0
|
||||||
var duration: Double = 0.0
|
var duration: Double = 0.0
|
||||||
var isVideoLoaded = false
|
var isVideoLoaded = false
|
||||||
var showWatchNextButton = true
|
|
||||||
|
|
||||||
|
var brightnessValue: Double = Double(UIScreen.main.brightness)
|
||||||
|
var brightnessSliderHostingController: UIHostingController<VerticalBrightnessSlider<Double>>?
|
||||||
|
|
||||||
|
private var isHoldPauseEnabled: Bool {
|
||||||
|
UserDefaults.standard.bool(forKey: "holdForPauseEnabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isSkip85Visible: Bool {
|
||||||
|
return UserDefaults.standard.bool(forKey: "skip85Visible")
|
||||||
|
}
|
||||||
|
|
||||||
|
var showWatchNextButton = true
|
||||||
var watchNextButtonTimer: Timer?
|
var watchNextButtonTimer: Timer?
|
||||||
var isWatchNextRepositioned: Bool = false
|
var isWatchNextRepositioned: Bool = false
|
||||||
var isWatchNextVisible: Bool = false
|
var isWatchNextVisible: Bool = false
|
||||||
var lastDuration: Double = 0.0
|
var lastDuration: Double = 0.0
|
||||||
var watchNextButtonAppearedAt: Double?
|
var watchNextButtonAppearedAt: Double?
|
||||||
|
|
||||||
|
var portraitButtonVisibleConstraints: [NSLayoutConstraint] = []
|
||||||
|
var portraitButtonHiddenConstraints: [NSLayoutConstraint] = []
|
||||||
|
var landscapeButtonVisibleConstraints: [NSLayoutConstraint] = []
|
||||||
|
var landscapeButtonHiddenConstraints: [NSLayoutConstraint] = []
|
||||||
|
var currentMarqueeConstraints: [NSLayoutConstraint] = []
|
||||||
|
|
||||||
var subtitleForegroundColor: String = "white"
|
var subtitleForegroundColor: String = "white"
|
||||||
var subtitleBackgroundEnabled: Bool = true
|
var subtitleBackgroundEnabled: Bool = true
|
||||||
var subtitleFontSize: Double = 20.0
|
var subtitleFontSize: Double = 20.0
|
||||||
|
|
@ -54,6 +78,7 @@ class CustomMediaPlayerViewController: UIViewController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var marqueeLabel: MarqueeLabel!
|
||||||
var playerViewController: AVPlayerViewController!
|
var playerViewController: AVPlayerViewController!
|
||||||
var controlsContainerView: UIView!
|
var controlsContainerView: UIView!
|
||||||
var playPauseButton: UIImageView!
|
var playPauseButton: UIImageView!
|
||||||
|
|
@ -82,13 +107,16 @@ class CustomMediaPlayerViewController: UIViewController {
|
||||||
var isControlsVisible = false
|
var isControlsVisible = false
|
||||||
|
|
||||||
var subtitleBottomConstraint: NSLayoutConstraint?
|
var subtitleBottomConstraint: NSLayoutConstraint?
|
||||||
|
|
||||||
var subtitleBottomPadding: CGFloat = 10.0 {
|
var subtitleBottomPadding: CGFloat = 10.0 {
|
||||||
didSet {
|
didSet {
|
||||||
updateSubtitleLabelConstraints()
|
updateSubtitleLabelConstraints()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var playerItemKVOContext = 0
|
||||||
|
private var loadedTimeRangesObservation: NSKeyValueObservation?
|
||||||
|
private var playerTimeControlStatusObserver: NSKeyValueObservation?
|
||||||
|
|
||||||
init(module: ScrapingModule,
|
init(module: ScrapingModule,
|
||||||
urlString: String,
|
urlString: String,
|
||||||
fullUrl: String,
|
fullUrl: String,
|
||||||
|
|
@ -96,6 +124,7 @@ class CustomMediaPlayerViewController: UIViewController {
|
||||||
episodeNumber: Int,
|
episodeNumber: Int,
|
||||||
onWatchNext: @escaping () -> Void,
|
onWatchNext: @escaping () -> Void,
|
||||||
subtitlesURL: String?,
|
subtitlesURL: String?,
|
||||||
|
aniListID: Int,
|
||||||
episodeImageUrl: String) {
|
episodeImageUrl: String) {
|
||||||
|
|
||||||
self.module = module
|
self.module = module
|
||||||
|
|
@ -106,15 +135,19 @@ class CustomMediaPlayerViewController: UIViewController {
|
||||||
self.episodeImageUrl = episodeImageUrl
|
self.episodeImageUrl = episodeImageUrl
|
||||||
self.onWatchNext = onWatchNext
|
self.onWatchNext = onWatchNext
|
||||||
self.subtitlesURL = subtitlesURL
|
self.subtitlesURL = subtitlesURL
|
||||||
|
self.aniListID = aniListID
|
||||||
|
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
|
||||||
guard let url = URL(string: urlString) else {
|
guard let url = URL(string: urlString) else {
|
||||||
fatalError("Invalid URL string")
|
fatalError("Invalid URL string")
|
||||||
}
|
}
|
||||||
|
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Referer")
|
request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Referer")
|
||||||
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("\(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 ?? [:]])
|
let asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": request.allHTTPHeaderFields ?? [:]])
|
||||||
let playerItem = AVPlayerItem(asset: asset)
|
let playerItem = AVPlayerItem(asset: asset)
|
||||||
|
|
@ -123,7 +156,9 @@ class CustomMediaPlayerViewController: UIViewController {
|
||||||
let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(fullUrl)")
|
let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(fullUrl)")
|
||||||
if lastPlayedTime > 0 {
|
if lastPlayedTime > 0 {
|
||||||
let seekTime = CMTime(seconds: lastPlayedTime, preferredTimescale: 1)
|
let seekTime = CMTime(seconds: lastPlayedTime, preferredTimescale: 1)
|
||||||
self.player.seek(to: seekTime)
|
self.player.seek(to: seekTime) { [weak self] _ in
|
||||||
|
self?.updateBufferValue()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -140,23 +175,36 @@ class CustomMediaPlayerViewController: UIViewController {
|
||||||
loadSubtitleSettings()
|
loadSubtitleSettings()
|
||||||
setupPlayerViewController()
|
setupPlayerViewController()
|
||||||
setupControls()
|
setupControls()
|
||||||
|
brightnessControl()
|
||||||
setupSkipAndDismissGestures()
|
setupSkipAndDismissGestures()
|
||||||
addInvisibleControlOverlays()
|
addInvisibleControlOverlays()
|
||||||
setupSubtitleLabel()
|
setupSubtitleLabel()
|
||||||
setupDismissButton()
|
setupDismissButton()
|
||||||
setupQualityButton()
|
|
||||||
setupSpeedButton()
|
setupSpeedButton()
|
||||||
|
setupQualityButton()
|
||||||
setupMenuButton()
|
setupMenuButton()
|
||||||
|
setupMarqueeLabel()
|
||||||
setupSkip85Button()
|
setupSkip85Button()
|
||||||
setupWatchNextButton()
|
setupWatchNextButton()
|
||||||
addTimeObserver()
|
addTimeObserver()
|
||||||
startUpdateTimer()
|
startUpdateTimer()
|
||||||
setupAudioSession()
|
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
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||||
self?.checkForHLSStream()
|
self?.checkForHLSStream()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if isHoldPauseEnabled {
|
||||||
|
holdForPause()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
player.play()
|
player.play()
|
||||||
|
|
||||||
if let url = subtitlesURL, !url.isEmpty {
|
if let url = subtitlesURL, !url.isEmpty {
|
||||||
|
|
@ -172,47 +220,90 @@ 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) {
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
super.viewWillAppear(animated)
|
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)
|
||||||
|
|
||||||
|
skip85Button?.isHidden = !isSkip85Visible
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewWillDisappear(_ animated: Bool) {
|
override func viewWillDisappear(_ animated: Bool) {
|
||||||
super.viewWillDisappear(animated)
|
super.viewWillDisappear(animated)
|
||||||
|
|
||||||
NotificationCenter.default.removeObserver(self, name: .AVPlayerItemNewAccessLogEntry, object: nil)
|
loadedTimeRangesObservation?.invalidate()
|
||||||
|
loadedTimeRangesObservation = nil
|
||||||
|
|
||||||
if let playbackSpeed = player?.rate {
|
|
||||||
UserDefaults.standard.set(playbackSpeed, forKey: "lastPlaybackSpeed")
|
|
||||||
}
|
|
||||||
player.pause()
|
|
||||||
updateTimer?.invalidate()
|
|
||||||
inactivityTimer?.invalidate()
|
|
||||||
if let token = timeObserverToken {
|
if let token = timeObserverToken {
|
||||||
player.removeTimeObserver(token)
|
player.removeTimeObserver(token)
|
||||||
timeObserverToken = nil
|
timeObserverToken = nil
|
||||||
}
|
}
|
||||||
UserDefaults.standard.set(player.rate, forKey: "lastPlaybackSpeed")
|
|
||||||
if let currentItem = player.currentItem, currentItem.duration.seconds > 0 {
|
updateTimer?.invalidate()
|
||||||
let progress = currentTimeVal / currentItem.duration.seconds
|
inactivityTimer?.invalidate()
|
||||||
let item = ContinueWatchingItem(
|
|
||||||
id: UUID(),
|
player.pause()
|
||||||
imageUrl: episodeImageUrl,
|
|
||||||
episodeNumber: episodeNumber,
|
if let playbackSpeed = player?.rate {
|
||||||
mediaTitle: titleText,
|
UserDefaults.standard.set(playbackSpeed, forKey: "lastPlaybackSpeed")
|
||||||
progress: progress,
|
}
|
||||||
streamUrl: streamURL,
|
|
||||||
fullUrl: fullUrl,
|
}
|
||||||
subtitles: subtitlesURL,
|
|
||||||
module: module
|
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
|
||||||
)
|
guard context == &playerItemKVOContext else {
|
||||||
ContinueWatchingManager.shared.save(item: item)
|
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)
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.sliderViewModel.bufferValue = buffered
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func playerItemDidChange() {
|
@objc private func playerItemDidChange() {
|
||||||
DispatchQueue.main.async { [weak self] in
|
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.isHidden = false
|
||||||
self.qualityButton.menu = self.qualitySelectionMenu()
|
self.qualityButton.menu = self.qualitySelectionMenu()
|
||||||
}
|
}
|
||||||
|
|
@ -306,17 +397,39 @@ class CustomMediaPlayerViewController: UIViewController {
|
||||||
forwardButton.translatesAutoresizingMaskIntoConstraints = false
|
forwardButton.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
let sliderView = MusicProgressSlider(
|
let sliderView = MusicProgressSlider(
|
||||||
value: Binding(get: { self.sliderViewModel.sliderValue },
|
value: Binding(
|
||||||
set: { self.sliderViewModel.sliderValue = $0 }),
|
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),
|
inRange: 0...(duration > 0 ? duration : 1.0),
|
||||||
activeFillColor: .white,
|
activeFillColor: .white,
|
||||||
fillColor: .white.opacity(0.5),
|
fillColor: .white.opacity(0.5),
|
||||||
emptyColor: .white.opacity(0.3),
|
emptyColor: .white.opacity(0.3),
|
||||||
height: 30,
|
height: 30,
|
||||||
onEditingChanged: { editing in
|
onEditingChanged: { editing in
|
||||||
self.isSliderEditing = editing
|
if editing {
|
||||||
if !editing {
|
self.isSliderEditing = true
|
||||||
self.player.seek(to: CMTime(seconds: self.sliderViewModel.sliderValue, preferredTimescale: 600))
|
} 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -352,6 +465,56 @@ class CustomMediaPlayerViewController: UIViewController {
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func holdForPause() {
|
||||||
|
let holdForPauseGesture = UILongPressGestureRecognizer(target: self, action: #selector(handleHoldForPause(_:)))
|
||||||
|
holdForPauseGesture.minimumPressDuration = 1
|
||||||
|
holdForPauseGesture.numberOfTouchesRequired = 2
|
||||||
|
view.addGestureRecognizer(holdForPauseGesture)
|
||||||
|
}
|
||||||
|
|
||||||
|
func brightnessControl() {
|
||||||
|
let brightnessSlider = VerticalBrightnessSlider(
|
||||||
|
value: Binding(
|
||||||
|
get: { self.brightnessValue },
|
||||||
|
set: { newValue in self.brightnessValue = newValue }
|
||||||
|
),
|
||||||
|
inRange: 0...1,
|
||||||
|
activeFillColor: .white,
|
||||||
|
fillColor: .white.opacity(0.5),
|
||||||
|
emptyColor: .white.opacity(0.3),
|
||||||
|
width: 22,
|
||||||
|
onEditingChanged: { editing in }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create the container for the brightness slider
|
||||||
|
let brightnessContainer = UIView()
|
||||||
|
brightnessContainer.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
brightnessContainer.backgroundColor = .clear
|
||||||
|
|
||||||
|
controlsContainerView.addSubview(brightnessContainer)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
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)
|
||||||
|
])
|
||||||
|
|
||||||
|
brightnessSliderHostingController = UIHostingController(rootView: brightnessSlider)
|
||||||
|
guard let brightnessSliderView = brightnessSliderHostingController?.view else { return }
|
||||||
|
brightnessSliderView.backgroundColor = .clear
|
||||||
|
brightnessSliderView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
|
brightnessContainer.addSubview(brightnessSliderView)
|
||||||
|
|
||||||
|
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() {
|
func addInvisibleControlOverlays() {
|
||||||
let playPauseOverlay = UIButton(type: .custom)
|
let playPauseOverlay = UIButton(type: .custom)
|
||||||
playPauseOverlay.backgroundColor = .clear
|
playPauseOverlay.backgroundColor = .clear
|
||||||
|
|
@ -364,30 +527,6 @@ class CustomMediaPlayerViewController: UIViewController {
|
||||||
playPauseOverlay.widthAnchor.constraint(equalTo: playPauseButton.widthAnchor, constant: 20),
|
playPauseOverlay.widthAnchor.constraint(equalTo: playPauseButton.widthAnchor, constant: 20),
|
||||||
playPauseOverlay.heightAnchor.constraint(equalTo: playPauseButton.heightAnchor, 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() {
|
func setupSkipAndDismissGestures() {
|
||||||
|
|
@ -403,9 +542,8 @@ class CustomMediaPlayerViewController: UIViewController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let swipeDownGesture = UISwipeGestureRecognizer(target: self, action: #selector(handleSwipeDown(_:)))
|
let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture(_:)))
|
||||||
swipeDownGesture.direction = .down
|
view.addGestureRecognizer(panGesture)
|
||||||
view.addGestureRecognizer(swipeDownGesture)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func showSkipFeedback(direction: String) {
|
func showSkipFeedback(direction: String) {
|
||||||
|
|
@ -515,6 +653,7 @@ class CustomMediaPlayerViewController: UIViewController {
|
||||||
dismissButton.addTarget(self, action: #selector(dismissTapped), for: .touchUpInside)
|
dismissButton.addTarget(self, action: #selector(dismissTapped), for: .touchUpInside)
|
||||||
controlsContainerView.addSubview(dismissButton)
|
controlsContainerView.addSubview(dismissButton)
|
||||||
dismissButton.translatesAutoresizingMaskIntoConstraints = false
|
dismissButton.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
dismissButton.leadingAnchor.constraint(equalTo: controlsContainerView.leadingAnchor, constant: 16),
|
dismissButton.leadingAnchor.constraint(equalTo: controlsContainerView.leadingAnchor, constant: 16),
|
||||||
dismissButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20),
|
dismissButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20),
|
||||||
|
|
@ -523,24 +662,103 @@ class CustomMediaPlayerViewController: UIViewController {
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setupMarqueeLabel() {
|
||||||
|
marqueeLabel = MarqueeLabel()
|
||||||
|
marqueeLabel.text = "\(titleText) • Ep \(episodeNumber)"
|
||||||
|
marqueeLabel.type = .continuous
|
||||||
|
marqueeLabel.textColor = .white
|
||||||
|
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
|
||||||
|
marqueeLabel.leadingBuffer = 1.0 // Left inset for scrolling
|
||||||
|
marqueeLabel.trailingBuffer = 16.0 // Right inset for scrolling
|
||||||
|
marqueeLabel.animationDelay = 2.5
|
||||||
|
|
||||||
|
marqueeLabel.lineBreakMode = .byTruncatingTail
|
||||||
|
marqueeLabel.textAlignment = .left
|
||||||
|
|
||||||
|
controlsContainerView.addSubview(marqueeLabel)
|
||||||
|
marqueeLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
]
|
||||||
|
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() {
|
func setupMenuButton() {
|
||||||
menuButton = UIButton(type: .system)
|
menuButton = UIButton(type: .system)
|
||||||
menuButton.setImage(UIImage(systemName: "text.bubble"), for: .normal)
|
menuButton.setImage(UIImage(systemName: "text.bubble"), for: .normal)
|
||||||
menuButton.tintColor = .white
|
menuButton.tintColor = .white
|
||||||
|
|
||||||
if let subtitlesURL = subtitlesURL, !subtitlesURL.isEmpty {
|
if let subtitlesURL = subtitlesURL, !subtitlesURL.isEmpty {
|
||||||
menuButton.showsMenuAsPrimaryAction = true
|
menuButton.showsMenuAsPrimaryAction = true
|
||||||
menuButton.menu = buildOptionsMenu()
|
menuButton.menu = buildOptionsMenu()
|
||||||
} else {
|
} else {
|
||||||
menuButton.isHidden = true
|
menuButton.isHidden = true
|
||||||
}
|
}
|
||||||
|
|
||||||
controlsContainerView.addSubview(menuButton)
|
controlsContainerView.addSubview(menuButton)
|
||||||
menuButton.translatesAutoresizingMaskIntoConstraints = false
|
menuButton.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
menuButton.topAnchor.constraint(equalTo: controlsContainerView.topAnchor, constant: 20),
|
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.widthAnchor.constraint(equalToConstant: 40),
|
||||||
menuButton.heightAnchor.constraint(equalToConstant: 40)
|
menuButton.heightAnchor.constraint(equalToConstant: 40)
|
||||||
])
|
])
|
||||||
|
|
@ -552,23 +770,26 @@ class CustomMediaPlayerViewController: UIViewController {
|
||||||
speedButton.tintColor = .white
|
speedButton.tintColor = .white
|
||||||
speedButton.showsMenuAsPrimaryAction = true
|
speedButton.showsMenuAsPrimaryAction = true
|
||||||
speedButton.menu = speedChangerMenu()
|
speedButton.menu = speedChangerMenu()
|
||||||
|
|
||||||
controlsContainerView.addSubview(speedButton)
|
controlsContainerView.addSubview(speedButton)
|
||||||
speedButton.translatesAutoresizingMaskIntoConstraints = false
|
speedButton.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
// Middle
|
|
||||||
speedButton.topAnchor.constraint(equalTo: controlsContainerView.topAnchor, constant: 20),
|
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.widthAnchor.constraint(equalToConstant: 40),
|
||||||
speedButton.heightAnchor.constraint(equalToConstant: 40)
|
speedButton.heightAnchor.constraint(equalToConstant: 40)
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupWatchNextButton() {
|
func setupWatchNextButton() {
|
||||||
|
let config = UIImage.SymbolConfiguration(pointSize: 14, weight: .regular)
|
||||||
|
let image = UIImage(systemName: "forward.fill", withConfiguration: config)
|
||||||
|
|
||||||
watchNextButton = UIButton(type: .system)
|
watchNextButton = UIButton(type: .system)
|
||||||
watchNextButton.setTitle("Play Next", for: .normal)
|
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.tintColor = .black
|
||||||
watchNextButton.backgroundColor = .white
|
watchNextButton.backgroundColor = .white
|
||||||
watchNextButton.layer.cornerRadius = 25
|
watchNextButton.layer.cornerRadius = 25
|
||||||
|
|
@ -590,17 +811,21 @@ class CustomMediaPlayerViewController: UIViewController {
|
||||||
watchNextButtonControlsConstraints = [
|
watchNextButtonControlsConstraints = [
|
||||||
watchNextButton.trailingAnchor.constraint(equalTo: sliderHostingController!.view.trailingAnchor),
|
watchNextButton.trailingAnchor.constraint(equalTo: sliderHostingController!.view.trailingAnchor),
|
||||||
watchNextButton.bottomAnchor.constraint(equalTo: sliderHostingController!.view.topAnchor, constant: -5),
|
watchNextButton.bottomAnchor.constraint(equalTo: sliderHostingController!.view.topAnchor, constant: -5),
|
||||||
watchNextButton.heightAnchor.constraint(equalToConstant: 50),
|
watchNextButton.heightAnchor.constraint(equalToConstant: 47),
|
||||||
watchNextButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 120)
|
watchNextButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 97)
|
||||||
]
|
]
|
||||||
|
|
||||||
NSLayoutConstraint.activate(watchNextButtonNormalConstraints)
|
NSLayoutConstraint.activate(watchNextButtonNormalConstraints)
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupSkip85Button() {
|
func setupSkip85Button() {
|
||||||
|
let config = UIImage.SymbolConfiguration(pointSize: 14, weight: .regular)
|
||||||
|
let image = UIImage(systemName: "goforward", withConfiguration: config)
|
||||||
|
|
||||||
skip85Button = UIButton(type: .system)
|
skip85Button = UIButton(type: .system)
|
||||||
skip85Button.setTitle("Skip 85s", for: .normal)
|
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.tintColor = .black
|
||||||
skip85Button.backgroundColor = .white
|
skip85Button.backgroundColor = .white
|
||||||
skip85Button.layer.cornerRadius = 25
|
skip85Button.layer.cornerRadius = 25
|
||||||
|
|
@ -613,10 +838,12 @@ class CustomMediaPlayerViewController: UIViewController {
|
||||||
|
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
skip85Button.leadingAnchor.constraint(equalTo: sliderHostingController!.view.leadingAnchor),
|
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.heightAnchor.constraint(equalToConstant: 47),
|
||||||
skip85Button.widthAnchor.constraint(greaterThanOrEqualToConstant: 120)
|
skip85Button.widthAnchor.constraint(greaterThanOrEqualToConstant: 97)
|
||||||
])
|
])
|
||||||
|
|
||||||
|
skip85Button.isHidden = !isSkip85Visible
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupQualityButton() {
|
private func setupQualityButton() {
|
||||||
|
|
@ -626,18 +853,17 @@ class CustomMediaPlayerViewController: UIViewController {
|
||||||
qualityButton.showsMenuAsPrimaryAction = true
|
qualityButton.showsMenuAsPrimaryAction = true
|
||||||
qualityButton.menu = qualitySelectionMenu()
|
qualityButton.menu = qualitySelectionMenu()
|
||||||
qualityButton.isHidden = true
|
qualityButton.isHidden = true
|
||||||
|
|
||||||
controlsContainerView.addSubview(qualityButton)
|
controlsContainerView.addSubview(qualityButton)
|
||||||
qualityButton.translatesAutoresizingMaskIntoConstraints = false
|
qualityButton.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
qualityButton.topAnchor.constraint(equalTo: controlsContainerView.topAnchor, constant: 20),
|
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.widthAnchor.constraint(equalToConstant: 40),
|
||||||
qualityButton.heightAnchor.constraint(equalToConstant: 40)
|
qualityButton.heightAnchor.constraint(equalToConstant: 40)
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func updateSubtitleLabelAppearance() {
|
func updateSubtitleLabelAppearance() {
|
||||||
subtitleLabel.font = UIFont.systemFont(ofSize: CGFloat(subtitleFontSize))
|
subtitleLabel.font = UIFont.systemFont(ofSize: CGFloat(subtitleFontSize))
|
||||||
|
|
@ -665,11 +891,14 @@ class CustomMediaPlayerViewController: UIViewController {
|
||||||
|
|
||||||
func addTimeObserver() {
|
func addTimeObserver() {
|
||||||
let interval = CMTime(seconds: 1.0, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
|
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,
|
guard let self = self,
|
||||||
let currentItem = self.player.currentItem,
|
let currentItem = self.player.currentItem,
|
||||||
currentItem.duration.seconds.isFinite else { return }
|
currentItem.duration.seconds.isFinite else { return }
|
||||||
|
|
||||||
|
self.updateBufferValue()
|
||||||
let currentDuration = currentItem.duration.seconds
|
let currentDuration = currentItem.duration.seconds
|
||||||
if currentDuration.isNaN || currentDuration <= 0 { return }
|
if currentDuration.isNaN || currentDuration <= 0 { return }
|
||||||
|
|
||||||
|
|
@ -691,28 +920,51 @@ class CustomMediaPlayerViewController: UIViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
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(
|
self.sliderHostingController?.rootView = MusicProgressSlider(
|
||||||
value: Binding(get: { max(0, min(self.sliderViewModel.sliderValue, self.duration)) },
|
value: Binding(
|
||||||
set: { self.sliderViewModel.sliderValue = max(0, min($0, self.duration)) }),
|
get: { max(0, min(self.sliderViewModel.sliderValue, self.duration)) },
|
||||||
inRange: 0...(self.duration > 0 ? self.duration : 1.0),
|
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,
|
activeFillColor: .white,
|
||||||
fillColor: .white.opacity(0.5),
|
fillColor: .white.opacity(0.6),
|
||||||
emptyColor: .white.opacity(0.3),
|
emptyColor: .white.opacity(0.3),
|
||||||
height: 30,
|
height: 30,
|
||||||
onEditingChanged: { editing in
|
onEditingChanged: { editing in
|
||||||
self.isSliderEditing = editing
|
|
||||||
if !editing {
|
if !editing {
|
||||||
let seekTime = CMTime(seconds: self.sliderViewModel.sliderValue, preferredTimescale: 600)
|
let targetTime = CMTime(
|
||||||
self.player.seek(to: seekTime)
|
seconds: self.sliderViewModel.sliderValue,
|
||||||
|
preferredTimescale: 600
|
||||||
|
)
|
||||||
|
self.player.seek(to: targetTime) { [weak self] finished in
|
||||||
|
self?.updateBufferValue()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
let isNearEnd = (self.duration - self.currentTimeVal) <= (self.duration * 0.10)
|
let isNearEnd = (self.duration - self.currentTimeVal) <= (self.duration * 0.10)
|
||||||
&& self.currentTimeVal != self.duration
|
&& self.currentTimeVal != self.duration
|
||||||
&& self.showWatchNextButton
|
&& self.showWatchNextButton
|
||||||
&& self.duration != 0
|
&& self.duration != 0
|
||||||
|
|
||||||
if isNearEnd {
|
if isNearEnd {
|
||||||
if !self.isWatchNextVisible {
|
if !self.isWatchNextVisible {
|
||||||
|
|
@ -756,9 +1008,24 @@ 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() {
|
func repositionWatchNextButton() {
|
||||||
self.isWatchNextRepositioned = true
|
self.isWatchNextRepositioned = true
|
||||||
|
|
@ -773,7 +1040,6 @@ class CustomMediaPlayerViewController: UIViewController {
|
||||||
self.watchNextButtonTimer?.invalidate()
|
self.watchNextButtonTimer?.invalidate()
|
||||||
self.watchNextButtonTimer = nil
|
self.watchNextButtonTimer = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func startUpdateTimer() {
|
func startUpdateTimer() {
|
||||||
updateTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
|
updateTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
|
||||||
|
|
@ -784,7 +1050,7 @@ class CustomMediaPlayerViewController: UIViewController {
|
||||||
|
|
||||||
@objc func toggleControls() {
|
@objc func toggleControls() {
|
||||||
isControlsVisible.toggle()
|
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.controlsContainerView.alpha = self.isControlsVisible ? 1 : 0
|
||||||
self.skip85Button.alpha = self.isControlsVisible ? 0.8 : 0
|
self.skip85Button.alpha = self.isControlsVisible ? 0.8 : 0
|
||||||
|
|
||||||
|
|
@ -793,7 +1059,7 @@ class CustomMediaPlayerViewController: UIViewController {
|
||||||
NSLayoutConstraint.activate(self.watchNextButtonControlsConstraints)
|
NSLayoutConstraint.activate(self.watchNextButtonControlsConstraints)
|
||||||
if self.isWatchNextRepositioned || self.isWatchNextVisible {
|
if self.isWatchNextRepositioned || self.isWatchNextVisible {
|
||||||
self.watchNextButton.isHidden = false
|
self.watchNextButton.isHidden = false
|
||||||
UIView.animate(withDuration: 0.5, animations: {
|
UIView.animate(withDuration: 0.3, animations: {
|
||||||
self.watchNextButton.alpha = 0.8
|
self.watchNextButton.alpha = 0.8
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -819,7 +1085,10 @@ class CustomMediaPlayerViewController: UIViewController {
|
||||||
let holdValue = UserDefaults.standard.double(forKey: "skipIncrementHold")
|
let holdValue = UserDefaults.standard.double(forKey: "skipIncrementHold")
|
||||||
let finalSkip = holdValue > 0 ? holdValue : 30
|
let finalSkip = holdValue > 0 ? holdValue : 30
|
||||||
currentTimeVal = max(currentTimeVal - finalSkip, 0)
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -828,7 +1097,10 @@ class CustomMediaPlayerViewController: UIViewController {
|
||||||
let holdValue = UserDefaults.standard.double(forKey: "skipIncrementHold")
|
let holdValue = UserDefaults.standard.double(forKey: "skipIncrementHold")
|
||||||
let finalSkip = holdValue > 0 ? holdValue : 30
|
let finalSkip = holdValue > 0 ? holdValue : 30
|
||||||
currentTimeVal = min(currentTimeVal + finalSkip, duration)
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -836,14 +1108,20 @@ class CustomMediaPlayerViewController: UIViewController {
|
||||||
let skipValue = UserDefaults.standard.double(forKey: "skipIncrement")
|
let skipValue = UserDefaults.standard.double(forKey: "skipIncrement")
|
||||||
let finalSkip = skipValue > 0 ? skipValue : 10
|
let finalSkip = skipValue > 0 ? skipValue : 10
|
||||||
currentTimeVal = max(currentTimeVal - finalSkip, 0)
|
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() {
|
@objc func seekForward() {
|
||||||
let skipValue = UserDefaults.standard.double(forKey: "skipIncrement")
|
let skipValue = UserDefaults.standard.double(forKey: "skipIncrement")
|
||||||
let finalSkip = skipValue > 0 ? skipValue : 10
|
let finalSkip = skipValue > 0 ? skipValue : 10
|
||||||
currentTimeVal = min(currentTimeVal + finalSkip, duration)
|
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) {
|
@objc func handleDoubleTap(_ gesture: UITapGestureRecognizer) {
|
||||||
|
|
@ -856,33 +1134,30 @@ class CustomMediaPlayerViewController: UIViewController {
|
||||||
showSkipFeedback(direction: "forward")
|
showSkipFeedback(direction: "forward")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func handleSwipeDown(_ gesture: UISwipeGestureRecognizer) {
|
@objc func handleSwipeDown(_ gesture: UISwipeGestureRecognizer) {
|
||||||
dismiss(animated: true, completion: nil)
|
dismiss(animated: true, completion: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func togglePlayPause() {
|
@objc func togglePlayPause() {
|
||||||
if isPlaying {
|
if isPlaying {
|
||||||
|
player.pause()
|
||||||
|
isPlaying = false
|
||||||
|
playPauseButton.image = UIImage(systemName: "play.fill")
|
||||||
|
|
||||||
if !isControlsVisible {
|
if !isControlsVisible {
|
||||||
isControlsVisible = true
|
isControlsVisible = true
|
||||||
UIView.animate(withDuration: 0.5) {
|
UIView.animate(withDuration: 0.2) {
|
||||||
self.controlsContainerView.alpha = 1.0
|
self.controlsContainerView.alpha = 1.0
|
||||||
self.skip85Button.alpha = 0.8
|
self.skip85Button.alpha = 0.8
|
||||||
self.view.layoutIfNeeded()
|
self.view.layoutIfNeeded()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
player.pause()
|
|
||||||
playPauseButton.image = UIImage(systemName: "play.fill")
|
|
||||||
} else {
|
} else {
|
||||||
player.play()
|
player.play()
|
||||||
|
isPlaying = true
|
||||||
playPauseButton.image = UIImage(systemName: "pause.fill")
|
playPauseButton.image = UIImage(systemName: "pause.fill")
|
||||||
}
|
}
|
||||||
isPlaying.toggle()
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func sliderEditingEnded() {
|
|
||||||
let newTime = sliderViewModel.sliderValue
|
|
||||||
player.seek(to: CMTime(seconds: newTime, preferredTimescale: 600))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func dismissTapped() {
|
@objc func dismissTapped() {
|
||||||
|
|
@ -901,6 +1176,14 @@ class CustomMediaPlayerViewController: UIViewController {
|
||||||
player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600))
|
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 {
|
func speedChangerMenu() -> UIMenu {
|
||||||
let speeds: [Double] = [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0]
|
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
|
let playbackSpeedActions = speeds.map { speed in
|
||||||
|
|
@ -917,10 +1200,14 @@ class CustomMediaPlayerViewController: UIViewController {
|
||||||
private func parseM3U8(url: URL, completion: @escaping () -> Void) {
|
private func parseM3U8(url: URL, completion: @escaping () -> Void) {
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Referer")
|
request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Referer")
|
||||||
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("\(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
|
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")
|
print("Failed to load m3u8 file")
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self?.qualities = []
|
self?.qualities = []
|
||||||
|
|
@ -946,7 +1233,8 @@ class CustomMediaPlayerViewController: UIViewController {
|
||||||
for (index, line) in lines.enumerated() {
|
for (index, line) in lines.enumerated() {
|
||||||
if line.contains("#EXT-X-STREAM-INF"), index + 1 < lines.count {
|
if line.contains("#EXT-X-STREAM-INF"), index + 1 < lines.count {
|
||||||
if let resolutionRange = line.range(of: "RESOLUTION="),
|
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..<resolutionEndRange.lowerBound])
|
let resolutionPart = String(line[resolutionRange.upperBound..<resolutionEndRange.lowerBound])
|
||||||
if let heightStr = resolutionPart.components(separatedBy: "x").last,
|
if let heightStr = resolutionPart.components(separatedBy: "x").last,
|
||||||
|
|
@ -959,7 +1247,8 @@ class CustomMediaPlayerViewController: UIViewController {
|
||||||
if !nextLine.hasPrefix("http") && nextLine.contains(".m3u8") {
|
if !nextLine.hasPrefix("http") && nextLine.contains(".m3u8") {
|
||||||
if let baseURL = self.baseM3U8URL {
|
if let baseURL = self.baseM3U8URL {
|
||||||
let baseURLString = baseURL.deletingLastPathComponent().absoluteString
|
let baseURLString = baseURL.deletingLastPathComponent().absoluteString
|
||||||
qualityURL = URL(string: nextLine, relativeTo: baseURL)?.absoluteString ?? baseURLString + "/" + nextLine
|
qualityURL = URL(string: nextLine, relativeTo: baseURL)?.absoluteString
|
||||||
|
?? baseURLString + "/" + nextLine
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -990,20 +1279,22 @@ class CustomMediaPlayerViewController: UIViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func switchToQuality(urlString: String) {
|
private func switchToQuality(urlString: String) {
|
||||||
guard let url = URL(string: urlString), currentQualityURL?.absoluteString != urlString else { return }
|
guard let url = URL(string: urlString),
|
||||||
|
currentQualityURL?.absoluteString != urlString else { return }
|
||||||
|
|
||||||
let currentTime = player.currentTime()
|
let currentTime = player.currentTime()
|
||||||
let wasPlaying = player.rate > 0
|
let wasPlaying = player.rate > 0
|
||||||
|
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Referer")
|
request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Referer")
|
||||||
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("\(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 ?? [:]])
|
let asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": request.allHTTPHeaderFields ?? [:]])
|
||||||
let playerItem = AVPlayerItem(asset: asset)
|
let playerItem = AVPlayerItem(asset: asset)
|
||||||
|
|
||||||
player.replaceCurrentItem(with: playerItem)
|
player.replaceCurrentItem(with: playerItem)
|
||||||
|
|
||||||
player.seek(to: currentTime)
|
player.seek(to: currentTime)
|
||||||
if wasPlaying {
|
if wasPlaying {
|
||||||
player.play()
|
player.play()
|
||||||
|
|
@ -1015,7 +1306,10 @@ class CustomMediaPlayerViewController: UIViewController {
|
||||||
qualityButton.menu = qualitySelectionMenu()
|
qualityButton.menu = qualitySelectionMenu()
|
||||||
|
|
||||||
if let selectedQuality = qualities.first(where: { $0.1 == urlString })?.0 {
|
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"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1074,6 +1368,7 @@ class CustomMediaPlayerViewController: UIViewController {
|
||||||
|
|
||||||
self.qualityButton.isHidden = false
|
self.qualityButton.isHidden = false
|
||||||
self.qualityButton.menu = self.qualitySelectionMenu()
|
self.qualityButton.menu = self.qualitySelectionMenu()
|
||||||
|
self.updateMarqueeConstraints()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
isHLSStream = false
|
isHLSStream = false
|
||||||
|
|
@ -1204,7 +1499,9 @@ class CustomMediaPlayerViewController: UIViewController {
|
||||||
]
|
]
|
||||||
let paddingMenu = UIMenu(title: "Bottom Padding", children: paddingActions)
|
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]
|
menuElements = [subtitleOptionsMenu]
|
||||||
}
|
}
|
||||||
|
|
@ -1282,7 +1579,6 @@ class CustomMediaPlayerViewController: UIViewController {
|
||||||
let audioSession = AVAudioSession.sharedInstance()
|
let audioSession = AVAudioSession.sharedInstance()
|
||||||
try audioSession.setCategory(.playback, mode: .moviePlayback, options: .mixWithOthers)
|
try audioSession.setCategory(.playback, mode: .moviePlayback, options: .mixWithOthers)
|
||||||
try audioSession.setActive(true)
|
try audioSession.setActive(true)
|
||||||
|
|
||||||
try audioSession.overrideOutputAudioPort(.speaker)
|
try audioSession.overrideOutputAudioPort(.speaker)
|
||||||
} catch {
|
} catch {
|
||||||
Logger.shared.log("Failed to set up AVAudioSession: \(error)")
|
Logger.shared.log("Failed to set up AVAudioSession: \(error)")
|
||||||
|
|
@ -1308,6 +1604,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() {
|
private func beginHoldSpeed() {
|
||||||
guard let player = player else { return }
|
guard let player = player else { return }
|
||||||
originalRate = player.rate
|
originalRate = player.rate
|
||||||
|
|
@ -1325,8 +1634,22 @@ class CustomMediaPlayerViewController: UIViewController {
|
||||||
player?.rate = lastPlayedSpeed > 0 ? lastPlayedSpeed : 1.0
|
player?.rate = lastPlayedSpeed > 0 ? lastPlayedSpeed : 1.0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setupTimeControlStatusObservation() {
|
||||||
|
playerTimeControlStatusObserver = player.observe(\.timeControlStatus, options: [.new]) { [weak self] player, _ in
|
||||||
|
guard self != nil else { return }
|
||||||
|
if player.timeControlStatus == .paused,
|
||||||
|
let reason = player.reasonForWaitingToPlay {
|
||||||
|
Logger.shared.log("Paused reason: \(reason)", type: "Error")
|
||||||
|
if reason == .toMinimizeStalls || reason == .evaluatingBufferingRate {
|
||||||
|
player.play()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// yes? Like the plural of the famous american rapper ye? -IBHRAD
|
// yes? Like the plural of the famous american rapper ye? -IBHRAD
|
||||||
// low taper fade the meme is massive -cranci
|
// low taper fade the meme is massive -cranci
|
||||||
// cranci still doesnt have a job -seiike
|
// cranci still doesnt have a job -seiike
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ class VideoPlayerViewController: UIViewController {
|
||||||
var streamUrl: String?
|
var streamUrl: String?
|
||||||
var fullUrl: String = ""
|
var fullUrl: String = ""
|
||||||
var subtitles: String = ""
|
var subtitles: String = ""
|
||||||
|
var aniListID: Int = 0
|
||||||
|
|
||||||
var episodeNumber: Int = 0
|
var episodeNumber: Int = 0
|
||||||
var episodeImageUrl: String = ""
|
var episodeImageUrl: String = ""
|
||||||
|
|
@ -40,6 +41,7 @@ class VideoPlayerViewController: UIViewController {
|
||||||
|
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Referer")
|
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 asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": request.allHTTPHeaderFields ?? [:]])
|
||||||
|
|
@ -86,25 +88,6 @@ class VideoPlayerViewController: UIViewController {
|
||||||
player?.removeTimeObserver(timeObserverToken)
|
player?.removeTimeObserver(timeObserverToken)
|
||||||
self.timeObserverToken = nil
|
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() {
|
private func setInitialPlayerRate() {
|
||||||
|
|
@ -118,8 +101,9 @@ class VideoPlayerViewController: UIViewController {
|
||||||
guard let player = self.player else { return }
|
guard let player = self.player else { return }
|
||||||
|
|
||||||
let interval = CMTime(seconds: 1.0, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
|
let interval = CMTime(seconds: 1.0, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
|
||||||
timeObserverToken = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { time in
|
timeObserverToken = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in
|
||||||
guard let currentItem = player.currentItem,
|
guard let self = self,
|
||||||
|
let currentItem = player.currentItem,
|
||||||
currentItem.duration.seconds.isFinite else {
|
currentItem.duration.seconds.isFinite else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -129,6 +113,37 @@ class VideoPlayerViewController: UIViewController {
|
||||||
|
|
||||||
UserDefaults.standard.set(currentTime, forKey: "lastPlayedTime_\(fullURL)")
|
UserDefaults.standard.set(currentTime, forKey: "lastPlayedTime_\(fullURL)")
|
||||||
UserDefaults.standard.set(duration, forKey: "totalTime_\(fullURL)")
|
UserDefaults.standard.set(duration, forKey: "totalTime_\(fullURL)")
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,9 @@ struct ModuleMetadata: Codable, Hashable {
|
||||||
let asyncJS: Bool?
|
let asyncJS: Bool?
|
||||||
let streamAsyncJS: Bool?
|
let streamAsyncJS: Bool?
|
||||||
let softsub: Bool?
|
let softsub: Bool?
|
||||||
|
let multiStream: Bool?
|
||||||
|
let multiSubs: Bool?
|
||||||
|
let type: String?
|
||||||
|
|
||||||
struct Author: Codable, Hashable {
|
struct Author: Codable, Hashable {
|
||||||
let name: String
|
let name: String
|
||||||
|
|
|
||||||
|
|
@ -233,6 +233,7 @@ struct ContinueWatchingCell: View {
|
||||||
videoPlayerViewController.episodeNumber = item.episodeNumber
|
videoPlayerViewController.episodeNumber = item.episodeNumber
|
||||||
videoPlayerViewController.mediaTitle = item.mediaTitle
|
videoPlayerViewController.mediaTitle = item.mediaTitle
|
||||||
videoPlayerViewController.subtitles = item.subtitles ?? ""
|
videoPlayerViewController.subtitles = item.subtitles ?? ""
|
||||||
|
videoPlayerViewController.aniListID = item.aniListID ?? 0
|
||||||
videoPlayerViewController.modalPresentationStyle = .fullScreen
|
videoPlayerViewController.modalPresentationStyle = .fullScreen
|
||||||
|
|
||||||
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||||
|
|
@ -248,6 +249,7 @@ struct ContinueWatchingCell: View {
|
||||||
episodeNumber: item.episodeNumber,
|
episodeNumber: item.episodeNumber,
|
||||||
onWatchNext: { },
|
onWatchNext: { },
|
||||||
subtitlesURL: item.subtitles,
|
subtitlesURL: item.subtitles,
|
||||||
|
aniListID: item.aniListID ?? 0,
|
||||||
episodeImageUrl: item.imageUrl
|
episodeImageUrl: item.imageUrl
|
||||||
)
|
)
|
||||||
customMediaPlayer.modalPresentationStyle = .fullScreen
|
customMediaPlayer.modalPresentationStyle = .fullScreen
|
||||||
|
|
|
||||||
|
|
@ -91,29 +91,44 @@ struct EpisodeCell: View {
|
||||||
updateProgress()
|
updateProgress()
|
||||||
}
|
}
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
onTap(episodeImageUrl)
|
let imageUrl = episodeImageUrl.isEmpty ? "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/main/assets/banner2.png" : episodeImageUrl
|
||||||
|
onTap(imageUrl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func markAsWatched() {
|
private func markAsWatched() {
|
||||||
UserDefaults.standard.set(99999999.0, forKey: "lastPlayedTime_\(episode)")
|
let userDefaults = UserDefaults.standard
|
||||||
UserDefaults.standard.set(99999999.0, forKey: "totalTime_\(episode)")
|
let totalTime = 1000.0
|
||||||
updateProgress()
|
let watchedTime = totalTime
|
||||||
|
userDefaults.set(watchedTime, forKey: "lastPlayedTime_\(episode)")
|
||||||
|
userDefaults.set(totalTime, forKey: "totalTime_\(episode)")
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.updateProgress()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resetProgress() {
|
private func resetProgress() {
|
||||||
UserDefaults.standard.set(0.0, forKey: "lastPlayedTime_\(episode)")
|
let userDefaults = UserDefaults.standard
|
||||||
UserDefaults.standard.set(0.0, forKey: "totalTime_\(episode)")
|
userDefaults.set(0.0, forKey: "lastPlayedTime_\(episode)")
|
||||||
updateProgress()
|
userDefaults.set(0.0, forKey: "totalTime_\(episode)")
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.updateProgress()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateProgress() {
|
private func updateProgress() {
|
||||||
let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(episode)")
|
let userDefaults = UserDefaults.standard
|
||||||
let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(episode)")
|
let lastPlayedTime = userDefaults.double(forKey: "lastPlayedTime_\(episode)")
|
||||||
currentProgress = totalTime > 0 ? lastPlayedTime / totalTime : 0
|
let totalTime = userDefaults.double(forKey: "totalTime_\(episode)")
|
||||||
|
currentProgress = totalTime > 0 ? min(lastPlayedTime / totalTime, 1.0) : 0
|
||||||
}
|
}
|
||||||
|
|
||||||
private func fetchEpisodeDetails() {
|
private func fetchEpisodeDetails() {
|
||||||
|
guard episodeID != 0 else {
|
||||||
|
isLoading = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
guard let url = URL(string: "https://api.ani.zip/mappings?anilist_id=\(itemID)") else {
|
guard let url = URL(string: "https://api.ani.zip/mappings?anilist_id=\(itemID)") else {
|
||||||
isLoading = false
|
isLoading = false
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -250,17 +250,27 @@ struct MediaInfoView: View {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onMarkAllPrevious: {
|
onMarkAllPrevious: {
|
||||||
|
let userDefaults = UserDefaults.standard
|
||||||
|
var updates = [String: Double]()
|
||||||
|
|
||||||
for ep2 in seasons[selectedSeason] where ep2.number < ep.number {
|
for ep2 in seasons[selectedSeason] where ep2.number < ep.number {
|
||||||
let href = ep2.href
|
let href = ep2.href
|
||||||
UserDefaults.standard.set(99999999.0, forKey: "lastPlayedTime_\(href)")
|
updates["lastPlayedTime_\(href)"] = 99999999.0
|
||||||
UserDefaults.standard.set(99999999.0, forKey: "totalTime_\(href)")
|
updates["totalTime_\(href)"] = 99999999.0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (key, value) in updates {
|
||||||
|
userDefaults.set(value, forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
userDefaults.synchronize()
|
||||||
|
|
||||||
refreshTrigger.toggle()
|
refreshTrigger.toggle()
|
||||||
Logger.shared.log("Marked episodes watched within season \(selectedSeason + 1) of \"\(title)\".", type: "General")
|
Logger.shared.log("Marked episodes watched within season \(selectedSeason + 1) of \"\(title)\".", type: "General")
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.id(refreshTrigger)
|
.id(refreshTrigger)
|
||||||
.disabled(isFetchingEpisode)
|
.disabled(isFetchingEpisode)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Text("No episodes available")
|
Text("No episodes available")
|
||||||
|
|
@ -290,17 +300,27 @@ struct MediaInfoView: View {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onMarkAllPrevious: {
|
onMarkAllPrevious: {
|
||||||
|
let userDefaults = UserDefaults.standard
|
||||||
|
var updates = [String: Double]()
|
||||||
|
|
||||||
for idx in 0..<i {
|
for idx in 0..<i {
|
||||||
let href = episodeLinks[idx].href
|
if idx < episodeLinks.count {
|
||||||
UserDefaults.standard.set(99999999.0, forKey: "lastPlayedTime_\(href)")
|
let href = episodeLinks[idx].href
|
||||||
UserDefaults.standard.set(99999999.0, forKey: "totalTime_\(href)")
|
updates["lastPlayedTime_\(href)"] = 1000.0
|
||||||
|
updates["totalTime_\(href)"] = 1000.0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (key, value) in updates {
|
||||||
|
userDefaults.set(value, forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
refreshTrigger.toggle()
|
refreshTrigger.toggle()
|
||||||
Logger.shared.log("Marked \(ep.number - 1) episodes watched within series \"\(title)\".", type: "General")
|
Logger.shared.log("Marked \(ep.number - 1) episodes watched within series \"\(title)\".", type: "General")
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.id(refreshTrigger)
|
.id(refreshTrigger)
|
||||||
.disabled(isFetchingEpisode)
|
.disabled(isFetchingEpisode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -448,7 +468,7 @@ struct MediaInfoView: View {
|
||||||
groups.append(currentGroup)
|
groups.append(currentGroup)
|
||||||
return groups
|
return groups
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func fetchDetails() {
|
func fetchDetails() {
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||||
|
|
@ -499,9 +519,13 @@ struct MediaInfoView: View {
|
||||||
|
|
||||||
if module.metadata.softsub == true {
|
if module.metadata.softsub == true {
|
||||||
if module.metadata.asyncJS == true {
|
if module.metadata.asyncJS == true {
|
||||||
jsController.fetchStreamUrlJS(episodeUrl: href, softsub: true) { result in
|
jsController.fetchStreamUrlJS(episodeUrl: href, softsub: true, module: module) { result in
|
||||||
if let streamUrl = result.stream {
|
if let streams = result.streams, !streams.isEmpty {
|
||||||
self.playStream(url: streamUrl, fullURL: href, subtitles: result.subtitles)
|
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 {
|
} else {
|
||||||
self.handleStreamFailure(error: nil)
|
self.handleStreamFailure(error: nil)
|
||||||
}
|
}
|
||||||
|
|
@ -510,9 +534,13 @@ struct MediaInfoView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if module.metadata.streamAsyncJS == true {
|
} 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 streamUrl = result.stream {
|
if let streams = result.streams, !streams.isEmpty {
|
||||||
self.playStream(url: streamUrl, fullURL: href, subtitles: result.subtitles)
|
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 {
|
} else {
|
||||||
self.handleStreamFailure(error: nil)
|
self.handleStreamFailure(error: nil)
|
||||||
}
|
}
|
||||||
|
|
@ -521,9 +549,13 @@ struct MediaInfoView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
jsController.fetchStreamUrl(episodeUrl: href, softsub: true) { result in
|
jsController.fetchStreamUrl(episodeUrl: href, softsub: true, module: module) { result in
|
||||||
if let streamUrl = result.stream {
|
if let streams = result.streams, !streams.isEmpty {
|
||||||
self.playStream(url: streamUrl, fullURL: href, subtitles: result.subtitles)
|
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 {
|
} else {
|
||||||
self.handleStreamFailure(error: nil)
|
self.handleStreamFailure(error: nil)
|
||||||
}
|
}
|
||||||
|
|
@ -534,9 +566,13 @@ struct MediaInfoView: View {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if module.metadata.asyncJS == true {
|
if module.metadata.asyncJS == true {
|
||||||
jsController.fetchStreamUrlJS(episodeUrl: href) { result in
|
jsController.fetchStreamUrlJS(episodeUrl: href, module: module) { result in
|
||||||
if let streamUrl = result.stream {
|
if let streams = result.streams, !streams.isEmpty {
|
||||||
self.playStream(url: streamUrl, 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 {
|
} else {
|
||||||
self.handleStreamFailure(error: nil)
|
self.handleStreamFailure(error: nil)
|
||||||
}
|
}
|
||||||
|
|
@ -545,9 +581,13 @@ struct MediaInfoView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if module.metadata.streamAsyncJS == true {
|
} else if module.metadata.streamAsyncJS == true {
|
||||||
jsController.fetchStreamUrlJSSecond(episodeUrl: href) { result in
|
jsController.fetchStreamUrlJSSecond(episodeUrl: href, module: module) { result in
|
||||||
if let streamUrl = result.stream {
|
if let streams = result.streams, !streams.isEmpty {
|
||||||
self.playStream(url: streamUrl, 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 {
|
} else {
|
||||||
self.handleStreamFailure(error: nil)
|
self.handleStreamFailure(error: nil)
|
||||||
}
|
}
|
||||||
|
|
@ -556,9 +596,13 @@ struct MediaInfoView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
jsController.fetchStreamUrl(episodeUrl: href) { result in
|
jsController.fetchStreamUrl(episodeUrl: href, module: module) { result in
|
||||||
if let streamUrl = result.stream {
|
if let streams = result.streams, !streams.isEmpty {
|
||||||
self.playStream(url: streamUrl, 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 {
|
} else {
|
||||||
self.handleStreamFailure(error: nil)
|
self.handleStreamFailure(error: nil)
|
||||||
}
|
}
|
||||||
|
|
@ -589,6 +633,45 @@ struct MediaInfoView: View {
|
||||||
self.isLoading = false
|
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 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.isFetchingEpisode = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func playStream(url: String, fullURL: String, subtitles: String? = nil) {
|
func playStream(url: String, fullURL: String, subtitles: String? = nil) {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
let externalPlayer = UserDefaults.standard.string(forKey: "externalPlayer") ?? "Sora"
|
let externalPlayer = UserDefaults.standard.string(forKey: "externalPlayer") ?? "Sora"
|
||||||
|
|
@ -611,6 +694,7 @@ struct MediaInfoView: View {
|
||||||
videoPlayerViewController.episodeImageUrl = selectedEpisodeImage
|
videoPlayerViewController.episodeImageUrl = selectedEpisodeImage
|
||||||
videoPlayerViewController.mediaTitle = title
|
videoPlayerViewController.mediaTitle = title
|
||||||
videoPlayerViewController.subtitles = subtitles ?? ""
|
videoPlayerViewController.subtitles = subtitles ?? ""
|
||||||
|
videoPlayerViewController.aniListID = itemID ?? 0
|
||||||
videoPlayerViewController.modalPresentationStyle = .fullScreen
|
videoPlayerViewController.modalPresentationStyle = .fullScreen
|
||||||
|
|
||||||
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||||
|
|
@ -642,6 +726,7 @@ struct MediaInfoView: View {
|
||||||
selectNextEpisode()
|
selectNextEpisode()
|
||||||
},
|
},
|
||||||
subtitlesURL: subtitles,
|
subtitlesURL: subtitles,
|
||||||
|
aniListID: itemID ?? 0,
|
||||||
episodeImageUrl: selectedEpisodeImage
|
episodeImageUrl: selectedEpisodeImage
|
||||||
)
|
)
|
||||||
customMediaPlayer.modalPresentationStyle = .fullScreen
|
customMediaPlayer.modalPresentationStyle = .fullScreen
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ struct SettingsViewModule: View {
|
||||||
@State private var isLoading = false
|
@State private var isLoading = false
|
||||||
@State private var isRefreshing = false
|
@State private var isRefreshing = false
|
||||||
@State private var moduleUrl: String = ""
|
@State private var moduleUrl: String = ""
|
||||||
|
@State private var refreshTask: Task<Void, Never>?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
VStack {
|
||||||
|
|
@ -113,23 +114,30 @@ struct SettingsViewModule: View {
|
||||||
})
|
})
|
||||||
.refreshable {
|
.refreshable {
|
||||||
isRefreshing = true
|
isRefreshing = true
|
||||||
await moduleManager.refreshModules()
|
refreshTask?.cancel()
|
||||||
isRefreshing = false
|
refreshTask = Task {
|
||||||
|
await moduleManager.refreshModules()
|
||||||
|
isRefreshing = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
Task {
|
refreshTask = Task {
|
||||||
await moduleManager.refreshModules()
|
await moduleManager.refreshModules()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.alert(isPresented: .constant(errorMessage != nil)) {
|
.onDisappear {
|
||||||
Alert(
|
refreshTask?.cancel()
|
||||||
title: Text("Error"),
|
}
|
||||||
message: Text(errorMessage ?? "Unknown error"),
|
.alert("Error", isPresented: Binding(
|
||||||
dismissButton: .default(Text("OK")) {
|
get: { errorMessage != nil },
|
||||||
errorMessage = nil
|
set: { if !$0 { errorMessage = nil } }
|
||||||
}
|
)) {
|
||||||
)
|
Button("OK") {
|
||||||
|
errorMessage = nil
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
Text(errorMessage ?? "Unknown error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,9 @@ struct SettingsViewPlayer: View {
|
||||||
@AppStorage("holdSpeedPlayer") private var holdSpeedPlayer: Double = 2.0
|
@AppStorage("holdSpeedPlayer") private var holdSpeedPlayer: Double = 2.0
|
||||||
@AppStorage("skipIncrement") private var skipIncrement: Double = 10.0
|
@AppStorage("skipIncrement") private var skipIncrement: Double = 10.0
|
||||||
@AppStorage("skipIncrementHold") private var skipIncrementHold: Double = 30.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"]
|
private let mediaPlayers = ["Default", "VLC", "OutPlayer", "Infuse", "nPlayer", "Sora"]
|
||||||
|
|
||||||
|
|
@ -40,6 +43,9 @@ struct SettingsViewPlayer: View {
|
||||||
|
|
||||||
Toggle("Force Landscape", isOn: $isAlwaysLandscape)
|
Toggle("Force Landscape", isOn: $isAlwaysLandscape)
|
||||||
.tint(.accentColor)
|
.tint(.accentColor)
|
||||||
|
|
||||||
|
Toggle("Two Finger Hold for Pause",isOn: $holdForPauseEnabled)
|
||||||
|
.tint(.accentColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
Section(header: Text("Speed Settings")) {
|
Section(header: Text("Speed Settings")) {
|
||||||
|
|
@ -70,6 +76,8 @@ struct SettingsViewPlayer: View {
|
||||||
Spacer()
|
Spacer()
|
||||||
Stepper("\(Int(skipIncrementHold))s", value: $skipIncrementHold, in: 5...300, step: 5)
|
Stepper("\(Int(skipIncrementHold))s", value: $skipIncrementHold, in: 5...300, step: 5)
|
||||||
}
|
}
|
||||||
|
Toggle("Show Skip 85s Button", isOn: $skip85Visible)
|
||||||
|
.tint(.accentColor)
|
||||||
}
|
}
|
||||||
SubtitleSettingsSection()
|
SubtitleSettingsSection()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ import Security
|
||||||
import Kingfisher
|
import Kingfisher
|
||||||
|
|
||||||
struct SettingsViewTrackers: View {
|
struct SettingsViewTrackers: View {
|
||||||
|
@AppStorage("sendPushUpdates") private var isSendPushUpdates = true
|
||||||
|
|
||||||
@State private var status: String = "You are not logged in"
|
@State private var status: String = "You are not logged in"
|
||||||
@State private var isLoggedIn: Bool = false
|
@State private var isLoggedIn: Bool = false
|
||||||
@State private var username: String = ""
|
@State private var username: String = ""
|
||||||
|
|
@ -18,7 +20,7 @@ struct SettingsViewTrackers: View {
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
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() {
|
HStack() {
|
||||||
KFImage(URL(string: "https://raw.githubusercontent.com/cranci1/Ryu/2f10226aa087154974a70c1ec78aa83a47daced9/Ryu/Assets.xcassets/Listing/Anilist.imageset/anilist.png"))
|
KFImage(URL(string: "https://raw.githubusercontent.com/cranci1/Ryu/2f10226aa087154974a70c1ec78aa83a47daced9/Ryu/Assets.xcassets/Listing/Anilist.imageset/anilist.png"))
|
||||||
.placeholder {
|
.placeholder {
|
||||||
|
|
@ -50,6 +52,10 @@ struct SettingsViewTrackers: View {
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if isLoggedIn {
|
||||||
|
Toggle("Send push updates", isOn: $isSendPushUpdates)
|
||||||
|
.tint(.accentColor)
|
||||||
|
}
|
||||||
Button(isLoggedIn ? "Log Out from AniList.co" : "Log In with AniList.co") {
|
Button(isLoggedIn ? "Log Out from AniList.co" : "Log In with AniList.co") {
|
||||||
if isLoggedIn {
|
if isLoggedIn {
|
||||||
logout()
|
logout()
|
||||||
|
|
@ -63,11 +69,38 @@ struct SettingsViewTrackers: View {
|
||||||
.navigationTitle("Trackers")
|
.navigationTitle("Trackers")
|
||||||
.onAppear {
|
.onAppear {
|
||||||
updateStatus()
|
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() {
|
func login() {
|
||||||
status = "Starting authentication..."
|
status = "Starting authentication..."
|
||||||
|
isLoading = true
|
||||||
AniListLogin.authenticate()
|
AniListLogin.authenticate()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,9 @@ struct SettingsView: View {
|
||||||
NavigationLink(destination: SettingsViewModule()) {
|
NavigationLink(destination: SettingsViewModule()) {
|
||||||
Text("Modules")
|
Text("Modules")
|
||||||
}
|
}
|
||||||
//NavigationLink(destination: SettingsViewTrackers()) {
|
NavigationLink(destination: SettingsViewTrackers()) {
|
||||||
// Text("Trackers")
|
Text("Trackers")
|
||||||
//}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Section(header: Text("Info")) {
|
Section(header: Text("Info")) {
|
||||||
|
|
|
||||||
|
|
@ -9,19 +9,17 @@
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
130217CC2D81C55E0011EFF5 /* DownloadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 130217CB2D81C55E0011EFF5 /* DownloadView.swift */; };
|
130217CC2D81C55E0011EFF5 /* DownloadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 130217CB2D81C55E0011EFF5 /* DownloadView.swift */; };
|
||||||
130C6BFA2D53AB1F00DC1432 /* SettingsViewData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 130C6BF92D53AB1F00DC1432 /* SettingsViewData.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 */; };
|
13103E8B2D58E028000F0673 /* View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13103E8A2D58E028000F0673 /* View.swift */; };
|
||||||
13103E8E2D58E04A000F0673 /* SkeletonCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13103E8D2D58E04A000F0673 /* SkeletonCell.swift */; };
|
13103E8E2D58E04A000F0673 /* SkeletonCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13103E8D2D58E04A000F0673 /* SkeletonCell.swift */; };
|
||||||
131845F92D47C62D00CA7A54 /* SettingsViewGeneral.swift in Sources */ = {isa = PBXBuildFile; fileRef = 131845F82D47C62D00CA7A54 /* SettingsViewGeneral.swift */; };
|
131845F92D47C62D00CA7A54 /* SettingsViewGeneral.swift in Sources */ = {isa = PBXBuildFile; fileRef = 131845F82D47C62D00CA7A54 /* SettingsViewGeneral.swift */; };
|
||||||
1327FBA72D758CEA00FC6689 /* Analytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1327FBA62D758CEA00FC6689 /* Analytics.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 */; };
|
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 */; };
|
132E351D2D959DDB0007800E /* Drops in Frameworks */ = {isa = PBXBuildFile; productRef = 132E351C2D959DDB0007800E /* Drops */; };
|
||||||
132E35202D959E1D0007800E /* FFmpeg-iOS-Lame in Frameworks */ = {isa = PBXBuildFile; productRef = 132E351F2D959E1D0007800E /* FFmpeg-iOS-Lame */; };
|
132E35202D959E1D0007800E /* FFmpeg-iOS-Lame in Frameworks */ = {isa = PBXBuildFile; productRef = 132E351F2D959E1D0007800E /* FFmpeg-iOS-Lame */; };
|
||||||
132E35232D959E410007800E /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 132E35222D959E410007800E /* Kingfisher */; };
|
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 */; };
|
133D7C6E2D2BE2500075467E /* SoraApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133D7C6D2D2BE2500075467E /* SoraApp.swift */; };
|
||||||
133D7C702D2BE2500075467E /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133D7C6F2D2BE2500075467E /* ContentView.swift */; };
|
133D7C702D2BE2500075467E /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133D7C6F2D2BE2500075467E /* ContentView.swift */; };
|
||||||
133D7C722D2BE2520075467E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 133D7C712D2BE2520075467E /* Assets.xcassets */; };
|
133D7C722D2BE2520075467E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 133D7C712D2BE2520075467E /* Assets.xcassets */; };
|
||||||
|
|
@ -42,6 +40,8 @@
|
||||||
139935662D468C450065CEFF /* ModuleManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 139935652D468C450065CEFF /* ModuleManager.swift */; };
|
139935662D468C450065CEFF /* ModuleManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 139935652D468C450065CEFF /* ModuleManager.swift */; };
|
||||||
1399FAD42D3AB38C00E97C31 /* SettingsViewLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1399FAD32D3AB38C00E97C31 /* SettingsViewLogger.swift */; };
|
1399FAD42D3AB38C00E97C31 /* SettingsViewLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1399FAD32D3AB38C00E97C31 /* SettingsViewLogger.swift */; };
|
||||||
1399FAD62D3AB3DB00E97C31 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1399FAD52D3AB3DB00E97C31 /* Logger.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 */; };
|
13B7F4C12D58FFDD0045714A /* Shimmer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13B7F4C02D58FFDD0045714A /* Shimmer.swift */; };
|
||||||
13C0E5EA2D5F85EA00E7F619 /* ContinueWatchingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13C0E5E92D5F85EA00E7F619 /* ContinueWatchingManager.swift */; };
|
13C0E5EA2D5F85EA00E7F619 /* ContinueWatchingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13C0E5E92D5F85EA00E7F619 /* ContinueWatchingManager.swift */; };
|
||||||
13C0E5EC2D5F85F800E7F619 /* ContinueWatchingItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13C0E5EB2D5F85F800E7F619 /* ContinueWatchingItem.swift */; };
|
13C0E5EC2D5F85F800E7F619 /* ContinueWatchingItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13C0E5EB2D5F85F800E7F619 /* ContinueWatchingItem.swift */; };
|
||||||
|
|
@ -59,6 +59,7 @@
|
||||||
13EA2BD52D32D97400C1EBD7 /* CustomPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EA2BD12D32D97400C1EBD7 /* CustomPlayer.swift */; };
|
13EA2BD52D32D97400C1EBD7 /* CustomPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EA2BD12D32D97400C1EBD7 /* CustomPlayer.swift */; };
|
||||||
13EA2BD62D32D97400C1EBD7 /* Double+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EA2BD32D32D97400C1EBD7 /* Double+Extension.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 */; };
|
13EA2BD92D32D98400C1EBD7 /* NormalPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EA2BD82D32D98400C1EBD7 /* NormalPlayer.swift */; };
|
||||||
|
1E3F5EC82D9F16B7003F310F /* VerticalBrightnessSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E3F5EC72D9F16B7003F310F /* VerticalBrightnessSlider.swift */; };
|
||||||
1E9FF1D32D403E49008AC100 /* SettingsViewLoggerFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */; };
|
1E9FF1D32D403E49008AC100 /* SettingsViewLoggerFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */; };
|
||||||
1EAC7A322D888BC50083984D /* MusicProgressSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EAC7A312D888BC50083984D /* MusicProgressSlider.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 */; };
|
73D164D52D8B5B470011A360 /* JavaScriptCore+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */; };
|
||||||
|
|
@ -68,16 +69,14 @@
|
||||||
130217CB2D81C55E0011EFF5 /* DownloadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadView.swift; sourceTree = "<group>"; };
|
130217CB2D81C55E0011EFF5 /* DownloadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadView.swift; sourceTree = "<group>"; };
|
||||||
130C6BF82D53A4C200DC1432 /* Sora.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Sora.entitlements; sourceTree = "<group>"; };
|
130C6BF82D53A4C200DC1432 /* Sora.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Sora.entitlements; sourceTree = "<group>"; };
|
||||||
130C6BF92D53AB1F00DC1432 /* SettingsViewData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewData.swift; sourceTree = "<group>"; };
|
130C6BF92D53AB1F00DC1432 /* SettingsViewData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewData.swift; sourceTree = "<group>"; };
|
||||||
13103E882D58A39A000F0673 /* AniListItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AniListItem.swift; sourceTree = "<group>"; };
|
|
||||||
13103E8A2D58E028000F0673 /* View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = View.swift; sourceTree = "<group>"; };
|
13103E8A2D58E028000F0673 /* View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = View.swift; sourceTree = "<group>"; };
|
||||||
13103E8D2D58E04A000F0673 /* SkeletonCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SkeletonCell.swift; sourceTree = "<group>"; };
|
13103E8D2D58E04A000F0673 /* SkeletonCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SkeletonCell.swift; sourceTree = "<group>"; };
|
||||||
131845F82D47C62D00CA7A54 /* SettingsViewGeneral.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewGeneral.swift; sourceTree = "<group>"; };
|
131845F82D47C62D00CA7A54 /* SettingsViewGeneral.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewGeneral.swift; sourceTree = "<group>"; };
|
||||||
1327FBA62D758CEA00FC6689 /* Analytics.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Analytics.swift; sourceTree = "<group>"; };
|
1327FBA62D758CEA00FC6689 /* Analytics.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Analytics.swift; sourceTree = "<group>"; };
|
||||||
1327FBA82D758DEA00FC6689 /* UIDevice+Model.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIDevice+Model.swift"; sourceTree = "<group>"; };
|
1327FBA82D758DEA00FC6689 /* UIDevice+Model.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIDevice+Model.swift"; sourceTree = "<group>"; };
|
||||||
1334FF4C2D786C93007E289F /* TMDB-Seasonal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TMDB-Seasonal.swift"; sourceTree = "<group>"; };
|
132AF1202D99951700A0140B /* JSController-Streams.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-Streams.swift"; sourceTree = "<group>"; };
|
||||||
1334FF4E2D786C9E007E289F /* TMDB-Trending.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TMDB-Trending.swift"; sourceTree = "<group>"; };
|
132AF1222D9995C300A0140B /* JSController-Details.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-Details.swift"; sourceTree = "<group>"; };
|
||||||
1334FF512D7871B7007E289F /* TMDBItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TMDBItem.swift; sourceTree = "<group>"; };
|
132AF1242D9995F900A0140B /* JSController-Search.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-Search.swift"; sourceTree = "<group>"; };
|
||||||
1334FF532D787217007E289F /* TMDBRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TMDBRequest.swift; sourceTree = "<group>"; };
|
|
||||||
133D7C6A2D2BE2500075467E /* Sulfur.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Sulfur.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
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 = "<group>"; };
|
133D7C6D2D2BE2500075467E /* SoraApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoraApp.swift; sourceTree = "<group>"; };
|
||||||
133D7C6F2D2BE2500075467E /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
133D7C6F2D2BE2500075467E /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||||
|
|
@ -99,6 +98,7 @@
|
||||||
139935652D468C450065CEFF /* ModuleManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModuleManager.swift; sourceTree = "<group>"; };
|
139935652D468C450065CEFF /* ModuleManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModuleManager.swift; sourceTree = "<group>"; };
|
||||||
1399FAD32D3AB38C00E97C31 /* SettingsViewLogger.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewLogger.swift; sourceTree = "<group>"; };
|
1399FAD32D3AB38C00E97C31 /* SettingsViewLogger.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewLogger.swift; sourceTree = "<group>"; };
|
||||||
1399FAD52D3AB3DB00E97C31 /* Logger.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = "<group>"; };
|
1399FAD52D3AB3DB00E97C31 /* Logger.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = "<group>"; };
|
||||||
|
13B77E1F2DA457AA00126FDF /* AniListPushUpdates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AniListPushUpdates.swift; sourceTree = "<group>"; };
|
||||||
13B7F4C02D58FFDD0045714A /* Shimmer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Shimmer.swift; sourceTree = "<group>"; };
|
13B7F4C02D58FFDD0045714A /* Shimmer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Shimmer.swift; sourceTree = "<group>"; };
|
||||||
13C0E5E92D5F85EA00E7F619 /* ContinueWatchingManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContinueWatchingManager.swift; sourceTree = "<group>"; };
|
13C0E5E92D5F85EA00E7F619 /* ContinueWatchingManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContinueWatchingManager.swift; sourceTree = "<group>"; };
|
||||||
13C0E5EB2D5F85F800E7F619 /* ContinueWatchingItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContinueWatchingItem.swift; sourceTree = "<group>"; };
|
13C0E5EB2D5F85F800E7F619 /* ContinueWatchingItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContinueWatchingItem.swift; sourceTree = "<group>"; };
|
||||||
|
|
@ -117,6 +117,7 @@
|
||||||
13EA2BD12D32D97400C1EBD7 /* CustomPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomPlayer.swift; sourceTree = "<group>"; };
|
13EA2BD12D32D97400C1EBD7 /* CustomPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomPlayer.swift; sourceTree = "<group>"; };
|
||||||
13EA2BD32D32D97400C1EBD7 /* Double+Extension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Double+Extension.swift"; sourceTree = "<group>"; };
|
13EA2BD32D32D97400C1EBD7 /* Double+Extension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Double+Extension.swift"; sourceTree = "<group>"; };
|
||||||
13EA2BD82D32D98400C1EBD7 /* NormalPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NormalPlayer.swift; sourceTree = "<group>"; };
|
13EA2BD82D32D98400C1EBD7 /* NormalPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NormalPlayer.swift; sourceTree = "<group>"; };
|
||||||
|
1E3F5EC72D9F16B7003F310F /* VerticalBrightnessSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerticalBrightnessSlider.swift; sourceTree = "<group>"; };
|
||||||
1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewLoggerFilter.swift; sourceTree = "<group>"; };
|
1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewLoggerFilter.swift; sourceTree = "<group>"; };
|
||||||
1EAC7A312D888BC50083984D /* MusicProgressSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicProgressSlider.swift; sourceTree = "<group>"; };
|
1EAC7A312D888BC50083984D /* MusicProgressSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicProgressSlider.swift; sourceTree = "<group>"; };
|
||||||
73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JavaScriptCore+Extensions.swift"; sourceTree = "<group>"; };
|
73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JavaScriptCore+Extensions.swift"; sourceTree = "<group>"; };
|
||||||
|
|
@ -127,6 +128,7 @@
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
13B77E192DA44F8300126FDF /* MarqueeLabel in Frameworks */,
|
||||||
132E35232D959E410007800E /* Kingfisher in Frameworks */,
|
132E35232D959E410007800E /* Kingfisher in Frameworks */,
|
||||||
132E35202D959E1D0007800E /* FFmpeg-iOS-Lame in Frameworks */,
|
132E35202D959E1D0007800E /* FFmpeg-iOS-Lame in Frameworks */,
|
||||||
132E351D2D959DDB0007800E /* Drops in Frameworks */,
|
132E351D2D959DDB0007800E /* Drops in Frameworks */,
|
||||||
|
|
@ -139,7 +141,6 @@
|
||||||
13103E802D589D6C000F0673 /* Tracking Services */ = {
|
13103E802D589D6C000F0673 /* Tracking Services */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
1334FF4A2D786C6D007E289F /* TMDB */,
|
|
||||||
13103E812D589D77000F0673 /* AniList */,
|
13103E812D589D77000F0673 /* AniList */,
|
||||||
);
|
);
|
||||||
path = "Tracking Services";
|
path = "Tracking Services";
|
||||||
|
|
@ -148,20 +149,12 @@
|
||||||
13103E812D589D77000F0673 /* AniList */ = {
|
13103E812D589D77000F0673 /* AniList */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
13B77E1E2DA4577D00126FDF /* Mutations */,
|
||||||
13DB468A2D900919008CBC03 /* Auth */,
|
13DB468A2D900919008CBC03 /* Auth */,
|
||||||
13103E872D58A392000F0673 /* Struct */,
|
|
||||||
);
|
);
|
||||||
path = AniList;
|
path = AniList;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
13103E872D58A392000F0673 /* Struct */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
13103E882D58A39A000F0673 /* AniListItem.swift */,
|
|
||||||
);
|
|
||||||
path = Struct;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
13103E8C2D58E037000F0673 /* SkeletonCells */ = {
|
13103E8C2D58E037000F0673 /* SkeletonCells */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
|
@ -179,33 +172,6 @@
|
||||||
path = Analytics;
|
path = Analytics;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
1334FF4A2D786C6D007E289F /* TMDB */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
1334FF502D7871A4007E289F /* Struct */,
|
|
||||||
1334FF4B2D786C81007E289F /* HomePage */,
|
|
||||||
);
|
|
||||||
path = TMDB;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
1334FF4B2D786C81007E289F /* HomePage */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
1334FF4C2D786C93007E289F /* TMDB-Seasonal.swift */,
|
|
||||||
1334FF4E2D786C9E007E289F /* TMDB-Trending.swift */,
|
|
||||||
);
|
|
||||||
path = HomePage;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
1334FF502D7871A4007E289F /* Struct */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
1334FF512D7871B7007E289F /* TMDBItem.swift */,
|
|
||||||
1334FF532D787217007E289F /* TMDBRequest.swift */,
|
|
||||||
);
|
|
||||||
path = Struct;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
133D7C612D2BE2500075467E = {
|
133D7C612D2BE2500075467E = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
|
@ -326,6 +292,9 @@
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
133D7C8B2D2BE2640075467E /* JSController.swift */,
|
133D7C8B2D2BE2640075467E /* JSController.swift */,
|
||||||
|
132AF1202D99951700A0140B /* JSController-Streams.swift */,
|
||||||
|
132AF1222D9995C300A0140B /* JSController-Details.swift */,
|
||||||
|
132AF1242D9995F900A0140B /* JSController-Search.swift */,
|
||||||
);
|
);
|
||||||
path = JSLoader;
|
path = JSLoader;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
|
@ -374,6 +343,14 @@
|
||||||
path = SettingsView;
|
path = SettingsView;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
13B77E1E2DA4577D00126FDF /* Mutations */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
13B77E1F2DA457AA00126FDF /* AniListPushUpdates.swift */,
|
||||||
|
);
|
||||||
|
path = Mutations;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
13C0E5E82D5F85DD00E7F619 /* ContinueWatching */ = {
|
13C0E5E82D5F85DD00E7F619 /* ContinueWatching */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
|
@ -431,6 +408,7 @@
|
||||||
13EA2BD22D32D97400C1EBD7 /* Components */ = {
|
13EA2BD22D32D97400C1EBD7 /* Components */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
1E3F5EC72D9F16B7003F310F /* VerticalBrightnessSlider.swift */,
|
||||||
13EA2BD32D32D97400C1EBD7 /* Double+Extension.swift */,
|
13EA2BD32D32D97400C1EBD7 /* Double+Extension.swift */,
|
||||||
1EAC7A312D888BC50083984D /* MusicProgressSlider.swift */,
|
1EAC7A312D888BC50083984D /* MusicProgressSlider.swift */,
|
||||||
);
|
);
|
||||||
|
|
@ -457,6 +435,7 @@
|
||||||
132E351C2D959DDB0007800E /* Drops */,
|
132E351C2D959DDB0007800E /* Drops */,
|
||||||
132E351F2D959E1D0007800E /* FFmpeg-iOS-Lame */,
|
132E351F2D959E1D0007800E /* FFmpeg-iOS-Lame */,
|
||||||
132E35222D959E410007800E /* Kingfisher */,
|
132E35222D959E410007800E /* Kingfisher */,
|
||||||
|
13B77E182DA44F8300126FDF /* MarqueeLabel */,
|
||||||
);
|
);
|
||||||
productName = Sora;
|
productName = Sora;
|
||||||
productReference = 133D7C6A2D2BE2500075467E /* Sulfur.app */;
|
productReference = 133D7C6A2D2BE2500075467E /* Sulfur.app */;
|
||||||
|
|
@ -489,6 +468,7 @@
|
||||||
132E351B2D959DDB0007800E /* XCRemoteSwiftPackageReference "Drops" */,
|
132E351B2D959DDB0007800E /* XCRemoteSwiftPackageReference "Drops" */,
|
||||||
132E351E2D959E1D0007800E /* XCRemoteSwiftPackageReference "FFmpeg-iOS-Lame" */,
|
132E351E2D959E1D0007800E /* XCRemoteSwiftPackageReference "FFmpeg-iOS-Lame" */,
|
||||||
132E35212D959E410007800E /* XCRemoteSwiftPackageReference "Kingfisher" */,
|
132E35212D959E410007800E /* XCRemoteSwiftPackageReference "Kingfisher" */,
|
||||||
|
13B77E172DA44F8300126FDF /* XCRemoteSwiftPackageReference "MarqueeLabel" */,
|
||||||
);
|
);
|
||||||
productRefGroup = 133D7C6B2D2BE2500075467E /* Products */;
|
productRefGroup = 133D7C6B2D2BE2500075467E /* Products */;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
|
|
@ -524,24 +504,22 @@
|
||||||
13B7F4C12D58FFDD0045714A /* Shimmer.swift in Sources */,
|
13B7F4C12D58FFDD0045714A /* Shimmer.swift in Sources */,
|
||||||
139935662D468C450065CEFF /* ModuleManager.swift in Sources */,
|
139935662D468C450065CEFF /* ModuleManager.swift in Sources */,
|
||||||
133D7C902D2BE2640075467E /* SettingsView.swift in Sources */,
|
133D7C902D2BE2640075467E /* SettingsView.swift in Sources */,
|
||||||
1334FF4F2D786C9E007E289F /* TMDB-Trending.swift in Sources */,
|
132AF1252D9995F900A0140B /* JSController-Search.swift in Sources */,
|
||||||
13CBEFDA2D5F7D1200D011EE /* String.swift in Sources */,
|
13CBEFDA2D5F7D1200D011EE /* String.swift in Sources */,
|
||||||
13DB46902D900A38008CBC03 /* URL.swift in Sources */,
|
13DB46902D900A38008CBC03 /* URL.swift in Sources */,
|
||||||
130C6BFA2D53AB1F00DC1432 /* SettingsViewData.swift in Sources */,
|
130C6BFA2D53AB1F00DC1432 /* SettingsViewData.swift in Sources */,
|
||||||
1334FF542D787217007E289F /* TMDBRequest.swift in Sources */,
|
|
||||||
1E9FF1D32D403E49008AC100 /* SettingsViewLoggerFilter.swift in Sources */,
|
1E9FF1D32D403E49008AC100 /* SettingsViewLoggerFilter.swift in Sources */,
|
||||||
13EA2BD92D32D98400C1EBD7 /* NormalPlayer.swift in Sources */,
|
13EA2BD92D32D98400C1EBD7 /* NormalPlayer.swift in Sources */,
|
||||||
133D7C932D2BE2640075467E /* Modules.swift in Sources */,
|
133D7C932D2BE2640075467E /* Modules.swift in Sources */,
|
||||||
13DB7CC32D7D99C0004371D3 /* SubtitleSettingsManager.swift in Sources */,
|
13DB7CC32D7D99C0004371D3 /* SubtitleSettingsManager.swift in Sources */,
|
||||||
133D7C702D2BE2500075467E /* ContentView.swift in Sources */,
|
133D7C702D2BE2500075467E /* ContentView.swift in Sources */,
|
||||||
13DB7CEC2D7DED5D004371D3 /* DownloadManager.swift in Sources */,
|
13DB7CEC2D7DED5D004371D3 /* DownloadManager.swift in Sources */,
|
||||||
1334FF522D7871B7007E289F /* TMDBItem.swift in Sources */,
|
|
||||||
13D99CF72D4E73C300250A86 /* ModuleAdditionSettingsView.swift in Sources */,
|
13D99CF72D4E73C300250A86 /* ModuleAdditionSettingsView.swift in Sources */,
|
||||||
13C0E5EC2D5F85F800E7F619 /* ContinueWatchingItem.swift in Sources */,
|
13C0E5EC2D5F85F800E7F619 /* ContinueWatchingItem.swift in Sources */,
|
||||||
13CBA0882D60F19C00EFE70A /* VTTSubtitlesLoader.swift in Sources */,
|
13CBA0882D60F19C00EFE70A /* VTTSubtitlesLoader.swift in Sources */,
|
||||||
13EA2BD62D32D97400C1EBD7 /* Double+Extension.swift in Sources */,
|
13EA2BD62D32D97400C1EBD7 /* Double+Extension.swift in Sources */,
|
||||||
133D7C8F2D2BE2640075467E /* MediaInfoView.swift in Sources */,
|
133D7C8F2D2BE2640075467E /* MediaInfoView.swift in Sources */,
|
||||||
13103E892D58A39A000F0673 /* AniListItem.swift in Sources */,
|
132AF1212D99951700A0140B /* JSController-Streams.swift in Sources */,
|
||||||
131845F92D47C62D00CA7A54 /* SettingsViewGeneral.swift in Sources */,
|
131845F92D47C62D00CA7A54 /* SettingsViewGeneral.swift in Sources */,
|
||||||
13103E8E2D58E04A000F0673 /* SkeletonCell.swift in Sources */,
|
13103E8E2D58E04A000F0673 /* SkeletonCell.swift in Sources */,
|
||||||
13D842552D45267500EBBFA6 /* DropManager.swift in Sources */,
|
13D842552D45267500EBBFA6 /* DropManager.swift in Sources */,
|
||||||
|
|
@ -559,9 +537,11 @@
|
||||||
133D7C8E2D2BE2640075467E /* LibraryView.swift in Sources */,
|
133D7C8E2D2BE2640075467E /* LibraryView.swift in Sources */,
|
||||||
133D7C6E2D2BE2500075467E /* SoraApp.swift in Sources */,
|
133D7C6E2D2BE2500075467E /* SoraApp.swift in Sources */,
|
||||||
138AA1B92D2D66FD0021F9DF /* CircularProgressBar.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 */,
|
13DB468D2D90093A008CBC03 /* Anilist-Login.swift in Sources */,
|
||||||
73D164D52D8B5B470011A360 /* JavaScriptCore+Extensions.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 */,
|
13EA2BD52D32D97400C1EBD7 /* CustomPlayer.swift in Sources */,
|
||||||
1399FAD42D3AB38C00E97C31 /* SettingsViewLogger.swift in Sources */,
|
1399FAD42D3AB38C00E97C31 /* SettingsViewLogger.swift in Sources */,
|
||||||
13DB46922D900BCE008CBC03 /* SettingsViewTrackers.swift in Sources */,
|
13DB46922D900BCE008CBC03 /* SettingsViewTrackers.swift in Sources */,
|
||||||
|
|
@ -708,6 +688,7 @@
|
||||||
INFOPLIST_FILE = Sora/Info.plist;
|
INFOPLIST_FILE = Sora/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = Sora;
|
INFOPLIST_KEY_CFBundleDisplayName = Sora;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment";
|
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_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
|
|
@ -720,7 +701,7 @@
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.2.1;
|
MARKETING_VERSION = 0.2.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = me.cranci.sulfur;
|
PRODUCT_BUNDLE_IDENTIFIER = me.cranci.sulfur;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
|
|
@ -750,6 +731,7 @@
|
||||||
INFOPLIST_FILE = Sora/Info.plist;
|
INFOPLIST_FILE = Sora/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = Sora;
|
INFOPLIST_KEY_CFBundleDisplayName = Sora;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment";
|
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_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
|
|
@ -762,7 +744,7 @@
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.2.1;
|
MARKETING_VERSION = 0.2.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = me.cranci.sulfur;
|
PRODUCT_BUNDLE_IDENTIFIER = me.cranci.sulfur;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
|
|
@ -822,6 +804,14 @@
|
||||||
version = 7.9.1;
|
version = 7.9.1;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
13B77E172DA44F8300126FDF /* XCRemoteSwiftPackageReference "MarqueeLabel" */ = {
|
||||||
|
isa = XCRemoteSwiftPackageReference;
|
||||||
|
repositoryURL = "https://github.com/cbpowell/MarqueeLabel";
|
||||||
|
requirement = {
|
||||||
|
kind = exactVersion;
|
||||||
|
version = 4.2.1;
|
||||||
|
};
|
||||||
|
};
|
||||||
/* End XCRemoteSwiftPackageReference section */
|
/* End XCRemoteSwiftPackageReference section */
|
||||||
|
|
||||||
/* Begin XCSwiftPackageProductDependency section */
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
|
|
@ -840,6 +830,11 @@
|
||||||
package = 132E35212D959E410007800E /* XCRemoteSwiftPackageReference "Kingfisher" */;
|
package = 132E35212D959E410007800E /* XCRemoteSwiftPackageReference "Kingfisher" */;
|
||||||
productName = Kingfisher;
|
productName = Kingfisher;
|
||||||
};
|
};
|
||||||
|
13B77E182DA44F8300126FDF /* MarqueeLabel */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = 13B77E172DA44F8300126FDF /* XCRemoteSwiftPackageReference "MarqueeLabel" */;
|
||||||
|
productName = MarqueeLabel;
|
||||||
|
};
|
||||||
/* End XCSwiftPackageProductDependency section */
|
/* End XCSwiftPackageProductDependency section */
|
||||||
};
|
};
|
||||||
rootObject = 133D7C622D2BE2500075467E /* Project object */;
|
rootObject = 133D7C622D2BE2500075467E /* Project object */;
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,15 @@
|
||||||
"revision": "b6f62758f21a8c03cd64f4009c037cfa580a256e",
|
"revision": "b6f62758f21a8c03cd64f4009c037cfa580a256e",
|
||||||
"version": "7.9.1"
|
"version": "7.9.1"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"package": "MarqueeLabel",
|
||||||
|
"repositoryURL": "https://github.com/cbpowell/MarqueeLabel",
|
||||||
|
"state": {
|
||||||
|
"branch": null,
|
||||||
|
"revision": "cffb6938940d3242882e6a2f9170b7890a4729ea",
|
||||||
|
"version": "4.2.1"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue