mirror of
https://github.com/cranci1/Sora.git
synced 2026-01-11 20:10:24 +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">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Sora may requires access to your device's camera.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Sora may requires access to your device's camera.</string>
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
|
|
@ -38,5 +38,7 @@
|
|||
<string>audio</string>
|
||||
<string>processing</string>
|
||||
</array>
|
||||
<key>UISupportsDocumentBrowser</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ class AniListToken {
|
|||
static let serviceName = "me.cranci.sora.AniListToken"
|
||||
static let accountName = "AniListAccessToken"
|
||||
|
||||
static let authSuccessNotification = Notification.Name("AniListAuthenticationSuccess")
|
||||
static let authFailureNotification = Notification.Name("AniListAuthenticationFailure")
|
||||
|
||||
static func saveTokenToKeychain(token: String) -> Bool {
|
||||
let tokenData = token.data(using: .utf8)!
|
||||
|
||||
|
|
@ -43,7 +46,10 @@ class AniListToken {
|
|||
|
||||
guard let url = URL(string: tokenEndpoint) else {
|
||||
Logger.shared.log("Invalid token endpoint URL", type: "Error")
|
||||
completion(false)
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: authFailureNotification, object: nil, userInfo: ["error": "Invalid token endpoint URL"])
|
||||
completion(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -55,31 +61,43 @@ class AniListToken {
|
|||
request.httpBody = bodyString.data(using: .utf8)
|
||||
|
||||
let task = URLSession.shared.dataTask(with: request) { data, response, error in
|
||||
if let error = error {
|
||||
Logger.shared.log("Error: \(error.localizedDescription)", type: "Error")
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
|
||||
guard let data = data else {
|
||||
Logger.shared.log("No data received", type: "Error")
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
||||
if let accessToken = json["access_token"] as? String {
|
||||
let success = saveTokenToKeychain(token: accessToken)
|
||||
completion(success)
|
||||
} else {
|
||||
Logger.shared.log("Unexpected response: \(json)", type: "Error")
|
||||
completion(false)
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
if let error = error {
|
||||
Logger.shared.log("Error: \(error.localizedDescription)", type: "Error")
|
||||
NotificationCenter.default.post(name: authFailureNotification, object: nil, userInfo: ["error": error.localizedDescription])
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
|
||||
guard let data = data else {
|
||||
Logger.shared.log("No data received", type: "Error")
|
||||
NotificationCenter.default.post(name: authFailureNotification, object: nil, userInfo: ["error": "No data received"])
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
||||
if let accessToken = json["access_token"] as? String {
|
||||
let success = saveTokenToKeychain(token: accessToken)
|
||||
if success {
|
||||
NotificationCenter.default.post(name: authSuccessNotification, object: nil)
|
||||
} else {
|
||||
NotificationCenter.default.post(name: authFailureNotification, object: nil, userInfo: ["error": "Failed to save token to keychain"])
|
||||
}
|
||||
completion(success)
|
||||
} else {
|
||||
let errorMessage = (json["error"] as? String) ?? "Unexpected response"
|
||||
Logger.shared.log("Authentication error: \(errorMessage)", type: "Error")
|
||||
NotificationCenter.default.post(name: authFailureNotification, object: nil, userInfo: ["error": errorMessage])
|
||||
completion(false)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Logger.shared.log("Failed to parse JSON: \(error.localizedDescription)", type: "Error")
|
||||
NotificationCenter.default.post(name: authFailureNotification, object: nil, userInfo: ["error": "Failed to parse response: \(error.localizedDescription)"])
|
||||
completion(false)
|
||||
}
|
||||
} catch {
|
||||
Logger.shared.log("Failed to parse JSON: \(error.localizedDescription)", type: "Error")
|
||||
completion(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 fullUrl: String
|
||||
let subtitles: String?
|
||||
let aniListID: Int?
|
||||
let module: ScrapingModule
|
||||
}
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@ class DownloadManager {
|
|||
|
||||
ffmpegCommand.append(contentsOf: ["-fflags", "+genpts"])
|
||||
ffmpegCommand.append(contentsOf: ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5"])
|
||||
ffmpegCommand.append(contentsOf: ["-headers", "Referer: \(module.metadata.baseUrl)"])
|
||||
ffmpegCommand.append(contentsOf: ["-headers", "Referer: \(module.metadata.baseUrl)\nOrigin: \(module.metadata.baseUrl)"])
|
||||
|
||||
let multiThreads = UserDefaults.standard.bool(forKey: "multiThreads")
|
||||
if multiThreads {
|
||||
|
|
|
|||
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
|
||||
|
||||
class JSController: ObservableObject {
|
||||
private var context: JSContext
|
||||
var context: JSContext
|
||||
|
||||
init() {
|
||||
self.context = JSContext()
|
||||
setupContext()
|
||||
}
|
||||
|
||||
private func setupContext() {
|
||||
func setupContext() {
|
||||
context.setupJavaScriptEnvironment()
|
||||
}
|
||||
|
||||
|
|
@ -27,482 +27,4 @@ class JSController: ObservableObject {
|
|||
Logger.shared.log("Error loading script: \(exception)", type: "Error")
|
||||
}
|
||||
}
|
||||
|
||||
func fetchSearchResults(keyword: String, module: ScrapingModule, completion: @escaping ([SearchItem]) -> Void) {
|
||||
let searchUrl = module.metadata.searchBaseUrl.replacingOccurrences(of: "%s", with: keyword.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")
|
||||
|
||||
guard let url = URL(string: searchUrl) else {
|
||||
completion([])
|
||||
return
|
||||
}
|
||||
|
||||
URLSession.custom.dataTask(with: url) { [weak self] data, _, error in
|
||||
guard let self = self else { return }
|
||||
|
||||
if let error = error {
|
||||
Logger.shared.log("Network error: \(error)",type: "Error")
|
||||
DispatchQueue.main.async { completion([]) }
|
||||
return
|
||||
}
|
||||
|
||||
guard let data = data, let html = String(data: data, encoding: .utf8) else {
|
||||
Logger.shared.log("Failed to decode HTML",type: "Error")
|
||||
DispatchQueue.main.async { completion([]) }
|
||||
return
|
||||
}
|
||||
|
||||
Logger.shared.log(html,type: "HTMLStrings")
|
||||
if let parseFunction = self.context.objectForKeyedSubscript("searchResults"),
|
||||
let results = parseFunction.call(withArguments: [html]).toArray() as? [[String: String]] {
|
||||
let resultItems = results.map { item in
|
||||
SearchItem(
|
||||
title: item["title"] ?? "",
|
||||
imageUrl: item["image"] ?? "",
|
||||
href: item["href"] ?? ""
|
||||
)
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
completion(resultItems)
|
||||
}
|
||||
} else {
|
||||
Logger.shared.log("Failed to parse results",type: "Error")
|
||||
DispatchQueue.main.async { completion([]) }
|
||||
}
|
||||
}.resume()
|
||||
}
|
||||
|
||||
func fetchDetails(url: String, completion: @escaping ([MediaItem], [EpisodeLink]) -> Void) {
|
||||
guard let url = URL(string: url) else {
|
||||
completion([], [])
|
||||
return
|
||||
}
|
||||
|
||||
URLSession.custom.dataTask(with: url) { [weak self] data, _, error in
|
||||
guard let self = self else { return }
|
||||
|
||||
if let error = error {
|
||||
Logger.shared.log("Network error: \(error)",type: "Error")
|
||||
DispatchQueue.main.async { completion([], []) }
|
||||
return
|
||||
}
|
||||
|
||||
guard let data = data, let html = String(data: data, encoding: .utf8) else {
|
||||
Logger.shared.log("Failed to decode HTML",type: "Error")
|
||||
DispatchQueue.main.async { completion([], []) }
|
||||
return
|
||||
}
|
||||
|
||||
var resultItems: [MediaItem] = []
|
||||
var episodeLinks: [EpisodeLink] = []
|
||||
|
||||
Logger.shared.log(html,type: "HTMLStrings")
|
||||
if let parseFunction = self.context.objectForKeyedSubscript("extractDetails"),
|
||||
let results = parseFunction.call(withArguments: [html]).toArray() as? [[String: String]] {
|
||||
resultItems = results.map { item in
|
||||
MediaItem(
|
||||
description: item["description"] ?? "",
|
||||
aliases: item["aliases"] ?? "",
|
||||
airdate: item["airdate"] ?? ""
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Logger.shared.log("Failed to parse results",type: "Error")
|
||||
}
|
||||
|
||||
if let fetchEpisodesFunction = self.context.objectForKeyedSubscript("extractEpisodes"),
|
||||
let episodesResult = fetchEpisodesFunction.call(withArguments: [html]).toArray() as? [[String: String]] {
|
||||
for episodeData in episodesResult {
|
||||
if let num = episodeData["number"], let link = episodeData["href"], let number = Int(num) {
|
||||
episodeLinks.append(EpisodeLink(number: number, href: link))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
completion(resultItems, episodeLinks)
|
||||
}
|
||||
}.resume()
|
||||
}
|
||||
|
||||
func fetchStreamUrl(episodeUrl: String, softsub: Bool = false, completion: @escaping ((stream: String?, subtitles: String?)) -> Void) {
|
||||
guard let url = URL(string: episodeUrl) else {
|
||||
completion((nil, nil))
|
||||
return
|
||||
}
|
||||
|
||||
URLSession.custom.dataTask(with: url) { [weak self] data, _, error in
|
||||
guard let self = self else { return }
|
||||
|
||||
if let error = error {
|
||||
Logger.shared.log("Network error: \(error)", type: "Error")
|
||||
DispatchQueue.main.async { completion((nil, nil)) }
|
||||
return
|
||||
}
|
||||
|
||||
guard let data = data, let html = String(data: data, encoding: .utf8) else {
|
||||
Logger.shared.log("Failed to decode HTML", type: "Error")
|
||||
DispatchQueue.main.async { completion((nil, nil)) }
|
||||
return
|
||||
}
|
||||
|
||||
Logger.shared.log(html, type: "HTMLStrings")
|
||||
if let parseFunction = self.context.objectForKeyedSubscript("extractStreamUrl"),
|
||||
let resultString = parseFunction.call(withArguments: [html]).toString() {
|
||||
if softsub {
|
||||
if let data = resultString.data(using: .utf8),
|
||||
let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] {
|
||||
let streamUrl = json["stream"] as? String
|
||||
let subtitlesUrl = json["subtitles"] as? String
|
||||
Logger.shared.log("Starting stream from: \(streamUrl ?? "nil") with subtitles: \(subtitlesUrl ?? "nil")", type: "Stream")
|
||||
DispatchQueue.main.async {
|
||||
completion((streamUrl, subtitlesUrl))
|
||||
}
|
||||
} else {
|
||||
Logger.shared.log("Failed to parse softsub JSON", type: "Error")
|
||||
DispatchQueue.main.async { completion((nil, nil)) }
|
||||
}
|
||||
} else {
|
||||
Logger.shared.log("Starting stream from: \(resultString)", type: "Stream")
|
||||
DispatchQueue.main.async { completion((resultString, nil)) }
|
||||
}
|
||||
} else {
|
||||
Logger.shared.log("Failed to extract stream URL", type: "Error")
|
||||
DispatchQueue.main.async { completion((nil, nil)) }
|
||||
}
|
||||
}.resume()
|
||||
}
|
||||
|
||||
func fetchJsSearchResults(keyword: String, module: ScrapingModule, completion: @escaping ([SearchItem]) -> Void) {
|
||||
if let exception = context.exception {
|
||||
Logger.shared.log("JavaScript exception: \(exception)",type: "Error")
|
||||
completion([])
|
||||
return
|
||||
}
|
||||
|
||||
guard let searchResultsFunction = context.objectForKeyedSubscript("searchResults") else {
|
||||
Logger.shared.log("No JavaScript function searchResults found",type: "Error")
|
||||
completion([])
|
||||
return
|
||||
}
|
||||
|
||||
let promiseValue = searchResultsFunction.call(withArguments: [keyword])
|
||||
guard let promise = promiseValue else {
|
||||
Logger.shared.log("searchResults did not return a Promise",type: "Error")
|
||||
completion([])
|
||||
return
|
||||
}
|
||||
|
||||
let thenBlock: @convention(block) (JSValue) -> Void = { result in
|
||||
|
||||
Logger.shared.log(result.toString(),type: "HTMLStrings")
|
||||
if let jsonString = result.toString(),
|
||||
let data = jsonString.data(using: .utf8) {
|
||||
do {
|
||||
if let array = try JSONSerialization.jsonObject(with: data, options: []) as? [[String: Any]] {
|
||||
let resultItems = array.compactMap { item -> SearchItem? in
|
||||
guard let title = item["title"] as? String,
|
||||
let imageUrl = item["image"] as? String,
|
||||
let href = item["href"] as? String else {
|
||||
Logger.shared.log("Missing or invalid data in search result item: \(item)", type: "Error")
|
||||
return nil
|
||||
}
|
||||
return SearchItem(title: title, imageUrl: imageUrl, href: href)
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
completion(resultItems)
|
||||
}
|
||||
|
||||
} else {
|
||||
Logger.shared.log("Failed to parse JSON",type: "Error")
|
||||
DispatchQueue.main.async {
|
||||
completion([])
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Logger.shared.log("JSON parsing error: \(error)",type: "Error")
|
||||
DispatchQueue.main.async {
|
||||
completion([])
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Logger.shared.log("Result is not a string",type: "Error")
|
||||
DispatchQueue.main.async {
|
||||
completion([])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let catchBlock: @convention(block) (JSValue) -> Void = { error in
|
||||
Logger.shared.log("Promise rejected: \(String(describing: error.toString()))",type: "Error")
|
||||
DispatchQueue.main.async {
|
||||
completion([])
|
||||
}
|
||||
}
|
||||
|
||||
let thenFunction = JSValue(object: thenBlock, in: context)
|
||||
let catchFunction = JSValue(object: catchBlock, in: context)
|
||||
|
||||
promise.invokeMethod("then", withArguments: [thenFunction as Any])
|
||||
promise.invokeMethod("catch", withArguments: [catchFunction as Any])
|
||||
}
|
||||
|
||||
func fetchDetailsJS(url: String, completion: @escaping ([MediaItem], [EpisodeLink]) -> Void) {
|
||||
guard let url = URL(string: url) else {
|
||||
completion([], [])
|
||||
return
|
||||
}
|
||||
|
||||
if let exception = context.exception {
|
||||
Logger.shared.log("JavaScript exception: \(exception)",type: "Error")
|
||||
completion([], [])
|
||||
return
|
||||
}
|
||||
|
||||
guard let extractDetailsFunction = context.objectForKeyedSubscript("extractDetails") else {
|
||||
Logger.shared.log("No JavaScript function extractDetails found",type: "Error")
|
||||
completion([], [])
|
||||
return
|
||||
}
|
||||
|
||||
guard let extractEpisodesFunction = context.objectForKeyedSubscript("extractEpisodes") else {
|
||||
Logger.shared.log("No JavaScript function extractEpisodes found",type: "Error")
|
||||
completion([], [])
|
||||
return
|
||||
}
|
||||
|
||||
var resultItems: [MediaItem] = []
|
||||
var episodeLinks: [EpisodeLink] = []
|
||||
|
||||
let dispatchGroup = DispatchGroup()
|
||||
|
||||
dispatchGroup.enter()
|
||||
let promiseValueDetails = extractDetailsFunction.call(withArguments: [url.absoluteString])
|
||||
guard let promiseDetails = promiseValueDetails else {
|
||||
Logger.shared.log("extractDetails did not return a Promise",type: "Error")
|
||||
completion([], [])
|
||||
return
|
||||
}
|
||||
|
||||
let thenBlockDetails: @convention(block) (JSValue) -> Void = { result in
|
||||
Logger.shared.log(result.toString(),type: "Debug")
|
||||
if let jsonOfDetails = result.toString(),
|
||||
let dataDetails = jsonOfDetails.data(using: .utf8) {
|
||||
do {
|
||||
if let array = try JSONSerialization.jsonObject(with: dataDetails, options: []) as? [[String: Any]] {
|
||||
resultItems = array.map { item -> MediaItem in
|
||||
MediaItem(
|
||||
description: item["description"] as? String ?? "",
|
||||
aliases: item["aliases"] as? String ?? "",
|
||||
airdate: item["airdate"] as? String ?? ""
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Logger.shared.log("Failed to parse JSON of extractDetails",type: "Error")
|
||||
}
|
||||
} catch {
|
||||
Logger.shared.log("JSON parsing error of extract details: \(error)",type: "Error")
|
||||
}
|
||||
} else {
|
||||
Logger.shared.log("Result is not a string of extractDetails",type: "Error")
|
||||
}
|
||||
dispatchGroup.leave()
|
||||
}
|
||||
|
||||
let catchBlockDetails: @convention(block) (JSValue) -> Void = { error in
|
||||
Logger.shared.log("Promise rejected of extractDetails: \(String(describing: error.toString()))",type: "Error")
|
||||
dispatchGroup.leave()
|
||||
}
|
||||
|
||||
let thenFunctionDetails = JSValue(object: thenBlockDetails, in: context)
|
||||
let catchFunctionDetails = JSValue(object: catchBlockDetails, in: context)
|
||||
|
||||
promiseDetails.invokeMethod("then", withArguments: [thenFunctionDetails as Any])
|
||||
promiseDetails.invokeMethod("catch", withArguments: [catchFunctionDetails as Any])
|
||||
|
||||
dispatchGroup.enter()
|
||||
let promiseValueEpisodes = extractEpisodesFunction.call(withArguments: [url.absoluteString])
|
||||
guard let promiseEpisodes = promiseValueEpisodes else {
|
||||
Logger.shared.log("extractEpisodes did not return a Promise",type: "Error")
|
||||
completion([], [])
|
||||
return
|
||||
}
|
||||
|
||||
let thenBlockEpisodes: @convention(block) (JSValue) -> Void = { result in
|
||||
Logger.shared.log(result.toString(),type: "Debug")
|
||||
if let jsonOfEpisodes = result.toString(),
|
||||
let dataEpisodes = jsonOfEpisodes.data(using: .utf8) {
|
||||
do {
|
||||
if let array = try JSONSerialization.jsonObject(with: dataEpisodes, options: []) as? [[String: Any]] {
|
||||
episodeLinks = array.map { item -> EpisodeLink in
|
||||
EpisodeLink(
|
||||
number: item["number"] as? Int ?? 0,
|
||||
href: item["href"] as? String ?? ""
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Logger.shared.log("Failed to parse JSON of extractEpisodes",type: "Error")
|
||||
}
|
||||
} catch {
|
||||
Logger.shared.log("JSON parsing error of extractEpisodes: \(error)",type: "Error")
|
||||
}
|
||||
} else {
|
||||
Logger.shared.log("Result is not a string of extractEpisodes",type: "Error")
|
||||
}
|
||||
dispatchGroup.leave()
|
||||
}
|
||||
|
||||
let catchBlockEpisodes: @convention(block) (JSValue) -> Void = { error in
|
||||
Logger.shared.log("Promise rejected of extractEpisodes: \(String(describing: error.toString()))",type: "Error")
|
||||
dispatchGroup.leave()
|
||||
}
|
||||
|
||||
let thenFunctionEpisodes = JSValue(object: thenBlockEpisodes, in: context)
|
||||
let catchFunctionEpisodes = JSValue(object: catchBlockEpisodes, in: context)
|
||||
|
||||
promiseEpisodes.invokeMethod("then", withArguments: [thenFunctionEpisodes as Any])
|
||||
promiseEpisodes.invokeMethod("catch", withArguments: [catchFunctionEpisodes as Any])
|
||||
|
||||
dispatchGroup.notify(queue: .main) {
|
||||
completion(resultItems, episodeLinks)
|
||||
}
|
||||
}
|
||||
|
||||
func fetchStreamUrlJS(episodeUrl: String, softsub: Bool = false, completion: @escaping ((stream: String?, subtitles: String?)) -> Void) {
|
||||
if let exception = context.exception {
|
||||
Logger.shared.log("JavaScript exception: \(exception)", type: "Error")
|
||||
completion((nil, nil))
|
||||
return
|
||||
}
|
||||
|
||||
guard let extractStreamUrlFunction = context.objectForKeyedSubscript("extractStreamUrl") else {
|
||||
Logger.shared.log("No JavaScript function extractStreamUrl found", type: "Error")
|
||||
completion((nil, nil))
|
||||
return
|
||||
}
|
||||
|
||||
let promiseValue = extractStreamUrlFunction.call(withArguments: [episodeUrl])
|
||||
guard let promise = promiseValue else {
|
||||
Logger.shared.log("extractStreamUrl did not return a Promise", type: "Error")
|
||||
completion((nil, nil))
|
||||
return
|
||||
}
|
||||
|
||||
let thenBlock: @convention(block) (JSValue) -> Void = { result in
|
||||
if softsub {
|
||||
if let jsonString = result.toString(),
|
||||
let data = jsonString.data(using: .utf8),
|
||||
let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] {
|
||||
let streamUrl = json["stream"] as? String
|
||||
let subtitlesUrl = json["subtitles"] as? String
|
||||
Logger.shared.log("Starting stream from: \(streamUrl ?? "nil") with subtitles: \(subtitlesUrl ?? "nil")", type: "Stream")
|
||||
DispatchQueue.main.async {
|
||||
completion((streamUrl, subtitlesUrl))
|
||||
}
|
||||
} else {
|
||||
Logger.shared.log("Failed to parse softsub JSON in JS", type: "Error")
|
||||
DispatchQueue.main.async {
|
||||
completion((nil, nil))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let streamUrl = result.toString()
|
||||
Logger.shared.log("Starting stream from: \(streamUrl ?? "nil")", type: "Stream")
|
||||
DispatchQueue.main.async {
|
||||
completion((streamUrl, nil))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let catchBlock: @convention(block) (JSValue) -> Void = { error in
|
||||
Logger.shared.log("Promise rejected: \(String(describing: error.toString()))", type: "Error")
|
||||
DispatchQueue.main.async {
|
||||
completion((nil, nil))
|
||||
}
|
||||
}
|
||||
|
||||
let thenFunction = JSValue(object: thenBlock, in: context)
|
||||
let catchFunction = JSValue(object: catchBlock, in: context)
|
||||
|
||||
promise.invokeMethod("then", withArguments: [thenFunction as Any])
|
||||
promise.invokeMethod("catch", withArguments: [catchFunction as Any])
|
||||
}
|
||||
|
||||
func fetchStreamUrlJSSecond(episodeUrl: String, softsub: Bool = false, completion: @escaping ((stream: String?, subtitles: String?)) -> Void) {
|
||||
let url = URL(string: episodeUrl)!
|
||||
let task = URLSession.custom.dataTask(with: url) { data, response, error in
|
||||
if let error = error {
|
||||
Logger.shared.log("URLSession error: \(error.localizedDescription)", type: "Error")
|
||||
DispatchQueue.main.async { completion((nil, nil)) }
|
||||
return
|
||||
}
|
||||
|
||||
guard let data = data, let htmlString = String(data: data, encoding: .utf8) else {
|
||||
Logger.shared.log("Failed to fetch HTML data", type: "Error")
|
||||
DispatchQueue.main.async { completion((nil, nil)) }
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
if let exception = self.context.exception {
|
||||
Logger.shared.log("JavaScript exception: \(exception)", type: "Error")
|
||||
completion((nil, nil))
|
||||
return
|
||||
}
|
||||
|
||||
guard let extractStreamUrlFunction = self.context.objectForKeyedSubscript("extractStreamUrl") else {
|
||||
Logger.shared.log("No JavaScript function extractStreamUrl found", type: "Error")
|
||||
completion((nil, nil))
|
||||
return
|
||||
}
|
||||
|
||||
let promiseValue = extractStreamUrlFunction.call(withArguments: [htmlString])
|
||||
guard let promise = promiseValue else {
|
||||
Logger.shared.log("extractStreamUrl did not return a Promise", type: "Error")
|
||||
completion((nil, nil))
|
||||
return
|
||||
}
|
||||
|
||||
let thenBlock: @convention(block) (JSValue) -> Void = { result in
|
||||
if softsub {
|
||||
if let jsonString = result.toString(),
|
||||
let data = jsonString.data(using: .utf8),
|
||||
let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] {
|
||||
let streamUrl = json["stream"] as? String
|
||||
let subtitlesUrl = json["subtitles"] as? String
|
||||
Logger.shared.log("Starting stream from: \(streamUrl ?? "nil") with subtitles: \(subtitlesUrl ?? "nil")", type: "Stream")
|
||||
DispatchQueue.main.async {
|
||||
completion((streamUrl, subtitlesUrl))
|
||||
}
|
||||
} else {
|
||||
Logger.shared.log("Failed to parse softsub JSON in JSSecond", type: "Error")
|
||||
DispatchQueue.main.async {
|
||||
completion((nil, nil))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let streamUrl = result.toString()
|
||||
Logger.shared.log("Starting stream from: \(streamUrl ?? "nil")", type: "Stream")
|
||||
DispatchQueue.main.async {
|
||||
completion((streamUrl, nil))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let catchBlock: @convention(block) (JSValue) -> Void = { error in
|
||||
Logger.shared.log("Promise rejected: \(String(describing: error.toString()))", type: "Error")
|
||||
DispatchQueue.main.async {
|
||||
completion((nil, nil))
|
||||
}
|
||||
}
|
||||
|
||||
let thenFunction = JSValue(object: thenBlock, in: self.context)
|
||||
let catchFunction = JSValue(object: catchBlock, in: self.context)
|
||||
|
||||
promise.invokeMethod("then", withArguments: [thenFunction as Any])
|
||||
promise.invokeMethod("catch", withArguments: [catchFunction as Any])
|
||||
}
|
||||
}
|
||||
task.resume()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,18 +5,20 @@
|
|||
// Created by Pratik on 08/01/23.
|
||||
//
|
||||
// Thanks to pratikg29 for this code inside his open source project "https://github.com/pratikg29/Custom-Slider-Control?ref=iosexample.com"
|
||||
// I did edit just a little bit the code for my liking
|
||||
//
|
||||
// I did edit some of the code for my liking (added a buffer indicator, etc.)
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct MusicProgressSlider<T: BinaryFloatingPoint>: View {
|
||||
@Binding var value: T
|
||||
@Binding var bufferValue: T // NEW
|
||||
let inRange: ClosedRange<T>
|
||||
|
||||
let activeFillColor: Color
|
||||
let fillColor: Color
|
||||
let emptyColor: Color
|
||||
let height: CGFloat
|
||||
|
||||
let onEditingChanged: (Bool) -> Void
|
||||
|
||||
@State private var localRealProgress: T = 0
|
||||
|
|
@ -27,49 +29,81 @@ struct MusicProgressSlider<T: BinaryFloatingPoint>: View {
|
|||
GeometryReader { bounds in
|
||||
ZStack {
|
||||
VStack {
|
||||
// Base track + buffer indicator + current progress
|
||||
ZStack(alignment: .center) {
|
||||
|
||||
// Entire background track
|
||||
Capsule()
|
||||
.fill(emptyColor)
|
||||
|
||||
// 1) The buffer fill portion (behind the actual progress)
|
||||
Capsule() // NEW
|
||||
.fill(fillColor.opacity(0.3)) // or any "bufferColor"
|
||||
.mask({
|
||||
HStack {
|
||||
Rectangle()
|
||||
.frame(
|
||||
width: max(
|
||||
bounds.size.width * CGFloat(getPrgPercentage(bufferValue)),
|
||||
0
|
||||
),
|
||||
alignment: .leading
|
||||
)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
})
|
||||
|
||||
// 2) The actual playback progress
|
||||
Capsule()
|
||||
.fill(isActive ? activeFillColor : fillColor)
|
||||
.mask({
|
||||
HStack {
|
||||
Rectangle()
|
||||
.frame(width: max((bounds.size.width * CGFloat(localRealProgress + localTempProgress)).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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Time labels
|
||||
HStack {
|
||||
// Determine if we should show hours based on the total duration.
|
||||
let shouldShowHours = inRange.upperBound >= 3600
|
||||
Text(value.asTimeString(style: .positional, showHours: shouldShowHours))
|
||||
Spacer(minLength: 0)
|
||||
Text("-" + (inRange.upperBound - value).asTimeString(style: .positional, showHours: shouldShowHours))
|
||||
Text("-" + (inRange.upperBound - value)
|
||||
.asTimeString(style: .positional, showHours: shouldShowHours))
|
||||
}
|
||||
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(isActive ? fillColor : emptyColor)
|
||||
}
|
||||
.frame(width: isActive ? bounds.size.width * 1.04 : bounds.size.width, alignment: .center)
|
||||
.frame(width: isActive ? bounds.size.width * 1.04 : bounds.size.width,
|
||||
alignment: .center)
|
||||
.animation(animation, value: isActive)
|
||||
}
|
||||
.frame(width: bounds.size.width, height: bounds.size.height, alignment: .center)
|
||||
.contentShape(Rectangle())
|
||||
.gesture(DragGesture(minimumDistance: 0, coordinateSpace: .local)
|
||||
.updating($isActive) { _, state, _ in
|
||||
state = true
|
||||
}
|
||||
.onChanged { gesture in
|
||||
localTempProgress = T(gesture.translation.width / bounds.size.width)
|
||||
value = max(min(getPrgValue(), inRange.upperBound), inRange.lowerBound)
|
||||
}.onEnded { _ in
|
||||
localRealProgress = max(min(localRealProgress + localTempProgress, 1), 0)
|
||||
localTempProgress = 0
|
||||
})
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 0, coordinateSpace: .local)
|
||||
.updating($isActive) { _, state, _ in
|
||||
state = true
|
||||
}
|
||||
.onChanged { gesture in
|
||||
localTempProgress = T(gesture.translation.width / bounds.size.width)
|
||||
value = clampValue(getPrgValue())
|
||||
}
|
||||
.onEnded { _ in
|
||||
localRealProgress = getPrgPercentage(value)
|
||||
localTempProgress = 0
|
||||
}
|
||||
)
|
||||
.onChange(of: isActive) { newValue in
|
||||
value = max(min(getPrgValue(), inRange.upperBound), inRange.lowerBound)
|
||||
value = clampValue(getPrgValue())
|
||||
onEditingChanged(newValue)
|
||||
}
|
||||
.onAppear {
|
||||
|
|
@ -85,21 +119,24 @@ struct MusicProgressSlider<T: BinaryFloatingPoint>: View {
|
|||
}
|
||||
|
||||
private var animation: Animation {
|
||||
if isActive {
|
||||
return .spring()
|
||||
} else {
|
||||
return .spring(response: 0.5, dampingFraction: 0.5, blendDuration: 0.6)
|
||||
}
|
||||
isActive
|
||||
? .spring()
|
||||
: .spring(response: 0.5, dampingFraction: 0.5, blendDuration: 0.6)
|
||||
}
|
||||
|
||||
private func getPrgPercentage(_ value: T) -> T {
|
||||
private func clampValue(_ val: T) -> T {
|
||||
max(min(val, inRange.upperBound), inRange.lowerBound)
|
||||
}
|
||||
|
||||
private func getPrgPercentage(_ val: T) -> T {
|
||||
let clampedValue = clampValue(val)
|
||||
let range = inRange.upperBound - inRange.lowerBound
|
||||
let correctedStartValue = value - inRange.lowerBound
|
||||
let percentage = correctedStartValue / range
|
||||
return percentage
|
||||
let pct = (clampedValue - inRange.lowerBound) / range
|
||||
return max(min(pct, 1), 0)
|
||||
}
|
||||
|
||||
private func getPrgValue() -> T {
|
||||
return ((localRealProgress + localTempProgress) * (inRange.upperBound - inRange.lowerBound)) + inRange.lowerBound
|
||||
((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 MarqueeLabel
|
||||
import AVKit
|
||||
import SwiftUI
|
||||
import AVFoundation
|
||||
|
||||
// MARK: - SliderViewModel
|
||||
|
||||
class SliderViewModel: ObservableObject {
|
||||
@Published var sliderValue: Double = 0.0
|
||||
@Published var bufferValue: Double = 0.0
|
||||
}
|
||||
|
||||
// MARK: - CustomMediaPlayerViewController
|
||||
|
||||
class CustomMediaPlayerViewController: UIViewController {
|
||||
let module: ScrapingModule
|
||||
let streamURL: String
|
||||
|
|
@ -23,6 +29,7 @@ class CustomMediaPlayerViewController: UIViewController {
|
|||
let episodeImageUrl: String
|
||||
let subtitlesURL: String?
|
||||
let onWatchNext: () -> Void
|
||||
let aniListID: Int
|
||||
|
||||
var player: AVPlayer!
|
||||
var timeObserverToken: Any?
|
||||
|
|
@ -35,14 +42,31 @@ class CustomMediaPlayerViewController: UIViewController {
|
|||
var currentTimeVal: Double = 0.0
|
||||
var duration: Double = 0.0
|
||||
var isVideoLoaded = false
|
||||
var showWatchNextButton = true
|
||||
|
||||
var brightnessValue: Double = Double(UIScreen.main.brightness)
|
||||
var brightnessSliderHostingController: UIHostingController<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 isWatchNextRepositioned: Bool = false
|
||||
var isWatchNextVisible: Bool = false
|
||||
var lastDuration: Double = 0.0
|
||||
var watchNextButtonAppearedAt: Double?
|
||||
|
||||
var portraitButtonVisibleConstraints: [NSLayoutConstraint] = []
|
||||
var portraitButtonHiddenConstraints: [NSLayoutConstraint] = []
|
||||
var landscapeButtonVisibleConstraints: [NSLayoutConstraint] = []
|
||||
var landscapeButtonHiddenConstraints: [NSLayoutConstraint] = []
|
||||
var currentMarqueeConstraints: [NSLayoutConstraint] = []
|
||||
|
||||
var subtitleForegroundColor: String = "white"
|
||||
var subtitleBackgroundEnabled: Bool = true
|
||||
var subtitleFontSize: Double = 20.0
|
||||
|
|
@ -54,6 +78,7 @@ class CustomMediaPlayerViewController: UIViewController {
|
|||
}
|
||||
}
|
||||
|
||||
var marqueeLabel: MarqueeLabel!
|
||||
var playerViewController: AVPlayerViewController!
|
||||
var controlsContainerView: UIView!
|
||||
var playPauseButton: UIImageView!
|
||||
|
|
@ -82,13 +107,16 @@ class CustomMediaPlayerViewController: UIViewController {
|
|||
var isControlsVisible = false
|
||||
|
||||
var subtitleBottomConstraint: NSLayoutConstraint?
|
||||
|
||||
var subtitleBottomPadding: CGFloat = 10.0 {
|
||||
didSet {
|
||||
updateSubtitleLabelConstraints()
|
||||
}
|
||||
}
|
||||
|
||||
private var playerItemKVOContext = 0
|
||||
private var loadedTimeRangesObservation: NSKeyValueObservation?
|
||||
private var playerTimeControlStatusObserver: NSKeyValueObservation?
|
||||
|
||||
init(module: ScrapingModule,
|
||||
urlString: String,
|
||||
fullUrl: String,
|
||||
|
|
@ -96,6 +124,7 @@ class CustomMediaPlayerViewController: UIViewController {
|
|||
episodeNumber: Int,
|
||||
onWatchNext: @escaping () -> Void,
|
||||
subtitlesURL: String?,
|
||||
aniListID: Int,
|
||||
episodeImageUrl: String) {
|
||||
|
||||
self.module = module
|
||||
|
|
@ -106,15 +135,19 @@ class CustomMediaPlayerViewController: UIViewController {
|
|||
self.episodeImageUrl = episodeImageUrl
|
||||
self.onWatchNext = onWatchNext
|
||||
self.subtitlesURL = subtitlesURL
|
||||
self.aniListID = aniListID
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
|
||||
guard let url = URL(string: urlString) else {
|
||||
fatalError("Invalid URL string")
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Referer")
|
||||
request.addValue("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 playerItem = AVPlayerItem(asset: asset)
|
||||
|
|
@ -123,7 +156,9 @@ class CustomMediaPlayerViewController: UIViewController {
|
|||
let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(fullUrl)")
|
||||
if lastPlayedTime > 0 {
|
||||
let seekTime = CMTime(seconds: lastPlayedTime, preferredTimescale: 1)
|
||||
self.player.seek(to: seekTime)
|
||||
self.player.seek(to: seekTime) { [weak self] _ in
|
||||
self?.updateBufferValue()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -140,23 +175,36 @@ class CustomMediaPlayerViewController: UIViewController {
|
|||
loadSubtitleSettings()
|
||||
setupPlayerViewController()
|
||||
setupControls()
|
||||
brightnessControl()
|
||||
setupSkipAndDismissGestures()
|
||||
addInvisibleControlOverlays()
|
||||
setupSubtitleLabel()
|
||||
setupDismissButton()
|
||||
setupQualityButton()
|
||||
setupSpeedButton()
|
||||
setupQualityButton()
|
||||
setupMenuButton()
|
||||
setupMarqueeLabel()
|
||||
setupSkip85Button()
|
||||
setupWatchNextButton()
|
||||
addTimeObserver()
|
||||
startUpdateTimer()
|
||||
setupAudioSession()
|
||||
|
||||
if let item = player.currentItem {
|
||||
loadedTimeRangesObservation = item.observe(\.loadedTimeRanges, options: [.new, .initial]) { [weak self] (playerItem, change) in
|
||||
self?.updateBufferValue()
|
||||
}
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||
self?.checkForHLSStream()
|
||||
}
|
||||
|
||||
if isHoldPauseEnabled {
|
||||
holdForPause()
|
||||
}
|
||||
|
||||
|
||||
player.play()
|
||||
|
||||
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) {
|
||||
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) {
|
||||
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 {
|
||||
player.removeTimeObserver(token)
|
||||
timeObserverToken = nil
|
||||
}
|
||||
UserDefaults.standard.set(player.rate, forKey: "lastPlaybackSpeed")
|
||||
if let currentItem = player.currentItem, currentItem.duration.seconds > 0 {
|
||||
let progress = currentTimeVal / currentItem.duration.seconds
|
||||
let item = ContinueWatchingItem(
|
||||
id: UUID(),
|
||||
imageUrl: episodeImageUrl,
|
||||
episodeNumber: episodeNumber,
|
||||
mediaTitle: titleText,
|
||||
progress: progress,
|
||||
streamUrl: streamURL,
|
||||
fullUrl: fullUrl,
|
||||
subtitles: subtitlesURL,
|
||||
module: module
|
||||
)
|
||||
ContinueWatchingManager.shared.save(item: item)
|
||||
|
||||
updateTimer?.invalidate()
|
||||
inactivityTimer?.invalidate()
|
||||
|
||||
player.pause()
|
||||
|
||||
if let playbackSpeed = player?.rate {
|
||||
UserDefaults.standard.set(playbackSpeed, forKey: "lastPlaybackSpeed")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
|
||||
guard context == &playerItemKVOContext else {
|
||||
super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
|
||||
return
|
||||
}
|
||||
|
||||
if keyPath == "loadedTimeRanges" {
|
||||
updateBufferValue()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateBufferValue() {
|
||||
guard let item = player.currentItem else { return }
|
||||
|
||||
if let timeRange = item.loadedTimeRanges.first?.timeRangeValue {
|
||||
let buffered = CMTimeGetSeconds(timeRange.start) + CMTimeGetSeconds(timeRange.duration)
|
||||
DispatchQueue.main.async {
|
||||
self.sliderViewModel.bufferValue = buffered
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func playerItemDidChange() {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
if let self = self, self.qualityButton.isHidden && self.isHLSStream {
|
||||
guard let self = self else { return }
|
||||
if self.qualityButton.isHidden && self.isHLSStream {
|
||||
self.qualityButton.isHidden = false
|
||||
self.qualityButton.menu = self.qualitySelectionMenu()
|
||||
}
|
||||
|
|
@ -306,17 +397,39 @@ class CustomMediaPlayerViewController: UIViewController {
|
|||
forwardButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
let sliderView = MusicProgressSlider(
|
||||
value: Binding(get: { self.sliderViewModel.sliderValue },
|
||||
set: { self.sliderViewModel.sliderValue = $0 }),
|
||||
value: Binding(
|
||||
get: { self.sliderViewModel.sliderValue },
|
||||
set: { self.sliderViewModel.sliderValue = $0 }
|
||||
),
|
||||
bufferValue: Binding(
|
||||
get: { self.sliderViewModel.bufferValue }, // NEW
|
||||
set: { self.sliderViewModel.bufferValue = $0 } // NEW
|
||||
),
|
||||
inRange: 0...(duration > 0 ? duration : 1.0),
|
||||
activeFillColor: .white,
|
||||
fillColor: .white.opacity(0.5),
|
||||
emptyColor: .white.opacity(0.3),
|
||||
height: 30,
|
||||
onEditingChanged: { editing in
|
||||
self.isSliderEditing = editing
|
||||
if !editing {
|
||||
self.player.seek(to: CMTime(seconds: self.sliderViewModel.sliderValue, preferredTimescale: 600))
|
||||
if editing {
|
||||
self.isSliderEditing = true
|
||||
} else {
|
||||
let wasPlaying = self.isPlaying
|
||||
let targetTime = CMTime(seconds: self.sliderViewModel.sliderValue,
|
||||
preferredTimescale: 600)
|
||||
self.player.seek(to: targetTime) { [weak self] finished in
|
||||
guard let self = self else { return }
|
||||
|
||||
let final = self.player.currentTime().seconds
|
||||
self.sliderViewModel.sliderValue = final
|
||||
self.currentTimeVal = final
|
||||
self.updateBufferValue()
|
||||
self.isSliderEditing = false
|
||||
|
||||
if wasPlaying {
|
||||
self.player.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
@ -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() {
|
||||
let playPauseOverlay = UIButton(type: .custom)
|
||||
playPauseOverlay.backgroundColor = .clear
|
||||
|
|
@ -364,30 +527,6 @@ class CustomMediaPlayerViewController: UIViewController {
|
|||
playPauseOverlay.widthAnchor.constraint(equalTo: playPauseButton.widthAnchor, constant: 20),
|
||||
playPauseOverlay.heightAnchor.constraint(equalTo: playPauseButton.heightAnchor, constant: 20)
|
||||
])
|
||||
|
||||
let backwardOverlay = UIButton(type: .custom)
|
||||
backwardOverlay.backgroundColor = .clear
|
||||
backwardOverlay.addTarget(self, action: #selector(seekBackward), for: .touchUpInside)
|
||||
view.addSubview(backwardOverlay)
|
||||
backwardOverlay.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
backwardOverlay.centerXAnchor.constraint(equalTo: backwardButton.centerXAnchor),
|
||||
backwardOverlay.centerYAnchor.constraint(equalTo: backwardButton.centerYAnchor),
|
||||
backwardOverlay.widthAnchor.constraint(equalTo: backwardButton.widthAnchor, constant: 20),
|
||||
backwardOverlay.heightAnchor.constraint(equalTo: backwardButton.heightAnchor, constant: 20)
|
||||
])
|
||||
|
||||
let forwardOverlay = UIButton(type: .custom)
|
||||
forwardOverlay.backgroundColor = .clear
|
||||
forwardOverlay.addTarget(self, action: #selector(seekForward), for: .touchUpInside)
|
||||
view.addSubview(forwardOverlay)
|
||||
forwardOverlay.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
forwardOverlay.centerXAnchor.constraint(equalTo: forwardButton.centerXAnchor),
|
||||
forwardOverlay.centerYAnchor.constraint(equalTo: forwardButton.centerYAnchor),
|
||||
forwardOverlay.widthAnchor.constraint(equalTo: forwardButton.widthAnchor, constant: 20),
|
||||
forwardOverlay.heightAnchor.constraint(equalTo: forwardButton.heightAnchor, constant: 20)
|
||||
])
|
||||
}
|
||||
|
||||
func setupSkipAndDismissGestures() {
|
||||
|
|
@ -403,9 +542,8 @@ class CustomMediaPlayerViewController: UIViewController {
|
|||
}
|
||||
}
|
||||
|
||||
let swipeDownGesture = UISwipeGestureRecognizer(target: self, action: #selector(handleSwipeDown(_:)))
|
||||
swipeDownGesture.direction = .down
|
||||
view.addGestureRecognizer(swipeDownGesture)
|
||||
let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture(_:)))
|
||||
view.addGestureRecognizer(panGesture)
|
||||
}
|
||||
|
||||
func showSkipFeedback(direction: String) {
|
||||
|
|
@ -515,6 +653,7 @@ class CustomMediaPlayerViewController: UIViewController {
|
|||
dismissButton.addTarget(self, action: #selector(dismissTapped), for: .touchUpInside)
|
||||
controlsContainerView.addSubview(dismissButton)
|
||||
dismissButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
dismissButton.leadingAnchor.constraint(equalTo: controlsContainerView.leadingAnchor, constant: 16),
|
||||
dismissButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20),
|
||||
|
|
@ -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() {
|
||||
menuButton = UIButton(type: .system)
|
||||
menuButton.setImage(UIImage(systemName: "text.bubble"), for: .normal)
|
||||
menuButton.tintColor = .white
|
||||
|
||||
|
||||
if let subtitlesURL = subtitlesURL, !subtitlesURL.isEmpty {
|
||||
menuButton.showsMenuAsPrimaryAction = true
|
||||
menuButton.menu = buildOptionsMenu()
|
||||
} else {
|
||||
menuButton.isHidden = true
|
||||
}
|
||||
|
||||
|
||||
controlsContainerView.addSubview(menuButton)
|
||||
menuButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
menuButton.topAnchor.constraint(equalTo: controlsContainerView.topAnchor, constant: 20),
|
||||
menuButton.trailingAnchor.constraint(equalTo: speedButton.leadingAnchor, constant: -20),
|
||||
menuButton.trailingAnchor.constraint(equalTo: qualityButton.leadingAnchor, constant: -20),
|
||||
menuButton.widthAnchor.constraint(equalToConstant: 40),
|
||||
menuButton.heightAnchor.constraint(equalToConstant: 40)
|
||||
])
|
||||
|
|
@ -552,23 +770,26 @@ class CustomMediaPlayerViewController: UIViewController {
|
|||
speedButton.tintColor = .white
|
||||
speedButton.showsMenuAsPrimaryAction = true
|
||||
speedButton.menu = speedChangerMenu()
|
||||
|
||||
|
||||
controlsContainerView.addSubview(speedButton)
|
||||
speedButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
// Middle
|
||||
speedButton.topAnchor.constraint(equalTo: controlsContainerView.topAnchor, constant: 20),
|
||||
speedButton.trailingAnchor.constraint(equalTo: qualityButton.leadingAnchor, constant: -20),
|
||||
speedButton.trailingAnchor.constraint(equalTo: controlsContainerView.trailingAnchor, constant: -20),
|
||||
speedButton.widthAnchor.constraint(equalToConstant: 40),
|
||||
speedButton.heightAnchor.constraint(equalToConstant: 40)
|
||||
])
|
||||
}
|
||||
|
||||
func setupWatchNextButton() {
|
||||
let config = UIImage.SymbolConfiguration(pointSize: 14, weight: .regular)
|
||||
let image = UIImage(systemName: "forward.fill", withConfiguration: config)
|
||||
|
||||
watchNextButton = UIButton(type: .system)
|
||||
watchNextButton.setTitle("Play Next", for: .normal)
|
||||
watchNextButton.setImage(UIImage(systemName: "forward.fill"), for: .normal)
|
||||
watchNextButton.setTitle(" Play Next", for: .normal)
|
||||
watchNextButton.titleLabel?.font = UIFont.systemFont(ofSize: 14)
|
||||
watchNextButton.setImage(image, for: .normal)
|
||||
watchNextButton.tintColor = .black
|
||||
watchNextButton.backgroundColor = .white
|
||||
watchNextButton.layer.cornerRadius = 25
|
||||
|
|
@ -590,17 +811,21 @@ class CustomMediaPlayerViewController: UIViewController {
|
|||
watchNextButtonControlsConstraints = [
|
||||
watchNextButton.trailingAnchor.constraint(equalTo: sliderHostingController!.view.trailingAnchor),
|
||||
watchNextButton.bottomAnchor.constraint(equalTo: sliderHostingController!.view.topAnchor, constant: -5),
|
||||
watchNextButton.heightAnchor.constraint(equalToConstant: 50),
|
||||
watchNextButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 120)
|
||||
watchNextButton.heightAnchor.constraint(equalToConstant: 47),
|
||||
watchNextButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 97)
|
||||
]
|
||||
|
||||
NSLayoutConstraint.activate(watchNextButtonNormalConstraints)
|
||||
}
|
||||
|
||||
func setupSkip85Button() {
|
||||
let config = UIImage.SymbolConfiguration(pointSize: 14, weight: .regular)
|
||||
let image = UIImage(systemName: "goforward", withConfiguration: config)
|
||||
|
||||
skip85Button = UIButton(type: .system)
|
||||
skip85Button.setTitle("Skip 85s", for: .normal)
|
||||
skip85Button.setImage(UIImage(systemName: "goforward"), for: .normal)
|
||||
skip85Button.setTitle(" Skip 85s", for: .normal)
|
||||
skip85Button.titleLabel?.font = UIFont.systemFont(ofSize: 14)
|
||||
skip85Button.setImage(image, for: .normal)
|
||||
skip85Button.tintColor = .black
|
||||
skip85Button.backgroundColor = .white
|
||||
skip85Button.layer.cornerRadius = 25
|
||||
|
|
@ -613,10 +838,12 @@ class CustomMediaPlayerViewController: UIViewController {
|
|||
|
||||
NSLayoutConstraint.activate([
|
||||
skip85Button.leadingAnchor.constraint(equalTo: sliderHostingController!.view.leadingAnchor),
|
||||
skip85Button.bottomAnchor.constraint(equalTo: sliderHostingController!.view.topAnchor, constant: -3),
|
||||
skip85Button.heightAnchor.constraint(equalToConstant: 50),
|
||||
skip85Button.widthAnchor.constraint(greaterThanOrEqualToConstant: 120)
|
||||
skip85Button.bottomAnchor.constraint(equalTo: sliderHostingController!.view.topAnchor, constant: -5),
|
||||
skip85Button.heightAnchor.constraint(equalToConstant: 47),
|
||||
skip85Button.widthAnchor.constraint(greaterThanOrEqualToConstant: 97)
|
||||
])
|
||||
|
||||
skip85Button.isHidden = !isSkip85Visible
|
||||
}
|
||||
|
||||
private func setupQualityButton() {
|
||||
|
|
@ -626,18 +853,17 @@ class CustomMediaPlayerViewController: UIViewController {
|
|||
qualityButton.showsMenuAsPrimaryAction = true
|
||||
qualityButton.menu = qualitySelectionMenu()
|
||||
qualityButton.isHidden = true
|
||||
|
||||
|
||||
controlsContainerView.addSubview(qualityButton)
|
||||
qualityButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
qualityButton.topAnchor.constraint(equalTo: controlsContainerView.topAnchor, constant: 20),
|
||||
qualityButton.trailingAnchor.constraint(equalTo: controlsContainerView.trailingAnchor, constant: -20),
|
||||
qualityButton.trailingAnchor.constraint(equalTo: speedButton.leadingAnchor, constant: -20),
|
||||
qualityButton.widthAnchor.constraint(equalToConstant: 40),
|
||||
qualityButton.heightAnchor.constraint(equalToConstant: 40)
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
func updateSubtitleLabelAppearance() {
|
||||
subtitleLabel.font = UIFont.systemFont(ofSize: CGFloat(subtitleFontSize))
|
||||
|
|
@ -665,11 +891,14 @@ class CustomMediaPlayerViewController: UIViewController {
|
|||
|
||||
func addTimeObserver() {
|
||||
let interval = CMTime(seconds: 1.0, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
|
||||
timeObserverToken = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in
|
||||
timeObserverToken = player.addPeriodicTimeObserver(forInterval: interval,
|
||||
queue: .main)
|
||||
{ [weak self] time in
|
||||
guard let self = self,
|
||||
let currentItem = self.player.currentItem,
|
||||
currentItem.duration.seconds.isFinite else { return }
|
||||
|
||||
self.updateBufferValue()
|
||||
let currentDuration = currentItem.duration.seconds
|
||||
if currentDuration.isNaN || currentDuration <= 0 { return }
|
||||
|
||||
|
|
@ -691,28 +920,51 @@ class CustomMediaPlayerViewController: UIViewController {
|
|||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
let remainingPercentage = (self.duration - self.currentTimeVal) / self.duration
|
||||
|
||||
if remainingPercentage < 0.1 && self.module.metadata.type == "anime" && self.aniListID != 0 {
|
||||
let aniListMutation = AniListMutation()
|
||||
aniListMutation.updateAnimeProgress(animeId: self.aniListID, episodeNumber: self.episodeNumber) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
Logger.shared.log("Successfully updated AniList progress for episode \(self.episodeNumber)", type: "General")
|
||||
case .failure(let error):
|
||||
Logger.shared.log("Failed to update AniList progress: \(error.localizedDescription)", type: "Error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.sliderHostingController?.rootView = MusicProgressSlider(
|
||||
value: Binding(get: { max(0, min(self.sliderViewModel.sliderValue, self.duration)) },
|
||||
set: { self.sliderViewModel.sliderValue = max(0, min($0, self.duration)) }),
|
||||
inRange: 0...(self.duration > 0 ? self.duration : 1.0),
|
||||
value: Binding(
|
||||
get: { max(0, min(self.sliderViewModel.sliderValue, self.duration)) },
|
||||
set: {
|
||||
self.sliderViewModel.sliderValue = max(0, min($0, self.duration))
|
||||
}
|
||||
),
|
||||
bufferValue: Binding(get: { self.sliderViewModel.bufferValue },
|
||||
set: { self.sliderViewModel.bufferValue = $0 }), inRange: 0...(self.duration > 0 ? self.duration : 1.0),
|
||||
activeFillColor: .white,
|
||||
fillColor: .white.opacity(0.5),
|
||||
fillColor: .white.opacity(0.6),
|
||||
emptyColor: .white.opacity(0.3),
|
||||
height: 30,
|
||||
onEditingChanged: { editing in
|
||||
self.isSliderEditing = editing
|
||||
if !editing {
|
||||
let seekTime = CMTime(seconds: self.sliderViewModel.sliderValue, preferredTimescale: 600)
|
||||
self.player.seek(to: seekTime)
|
||||
let targetTime = CMTime(
|
||||
seconds: self.sliderViewModel.sliderValue,
|
||||
preferredTimescale: 600
|
||||
)
|
||||
self.player.seek(to: targetTime) { [weak self] finished in
|
||||
self?.updateBufferValue()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
let isNearEnd = (self.duration - self.currentTimeVal) <= (self.duration * 0.10)
|
||||
&& self.currentTimeVal != self.duration
|
||||
&& self.showWatchNextButton
|
||||
&& self.duration != 0
|
||||
&& self.currentTimeVal != self.duration
|
||||
&& self.showWatchNextButton
|
||||
&& self.duration != 0
|
||||
|
||||
if isNearEnd {
|
||||
if !self.isWatchNextVisible {
|
||||
|
|
@ -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() {
|
||||
self.isWatchNextRepositioned = true
|
||||
|
|
@ -773,7 +1040,6 @@ class CustomMediaPlayerViewController: UIViewController {
|
|||
self.watchNextButtonTimer?.invalidate()
|
||||
self.watchNextButtonTimer = nil
|
||||
}
|
||||
|
||||
|
||||
func startUpdateTimer() {
|
||||
updateTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
|
||||
|
|
@ -784,7 +1050,7 @@ class CustomMediaPlayerViewController: UIViewController {
|
|||
|
||||
@objc func toggleControls() {
|
||||
isControlsVisible.toggle()
|
||||
UIView.animate(withDuration: 0.5, delay: 0, options: .curveEaseInOut, animations: {
|
||||
UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseInOut, animations: {
|
||||
self.controlsContainerView.alpha = self.isControlsVisible ? 1 : 0
|
||||
self.skip85Button.alpha = self.isControlsVisible ? 0.8 : 0
|
||||
|
||||
|
|
@ -793,7 +1059,7 @@ class CustomMediaPlayerViewController: UIViewController {
|
|||
NSLayoutConstraint.activate(self.watchNextButtonControlsConstraints)
|
||||
if self.isWatchNextRepositioned || self.isWatchNextVisible {
|
||||
self.watchNextButton.isHidden = false
|
||||
UIView.animate(withDuration: 0.5, animations: {
|
||||
UIView.animate(withDuration: 0.3, animations: {
|
||||
self.watchNextButton.alpha = 0.8
|
||||
})
|
||||
}
|
||||
|
|
@ -819,7 +1085,10 @@ class CustomMediaPlayerViewController: UIViewController {
|
|||
let holdValue = UserDefaults.standard.double(forKey: "skipIncrementHold")
|
||||
let finalSkip = holdValue > 0 ? holdValue : 30
|
||||
currentTimeVal = max(currentTimeVal - finalSkip, 0)
|
||||
player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600))
|
||||
player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) { [weak self] finished in
|
||||
guard let self = self else { return }
|
||||
self.updateBufferValue()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -828,7 +1097,10 @@ class CustomMediaPlayerViewController: UIViewController {
|
|||
let holdValue = UserDefaults.standard.double(forKey: "skipIncrementHold")
|
||||
let finalSkip = holdValue > 0 ? holdValue : 30
|
||||
currentTimeVal = min(currentTimeVal + finalSkip, duration)
|
||||
player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600))
|
||||
player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) { [weak self] finished in
|
||||
guard let self = self else { return }
|
||||
self.updateBufferValue()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -836,14 +1108,20 @@ class CustomMediaPlayerViewController: UIViewController {
|
|||
let skipValue = UserDefaults.standard.double(forKey: "skipIncrement")
|
||||
let finalSkip = skipValue > 0 ? skipValue : 10
|
||||
currentTimeVal = max(currentTimeVal - finalSkip, 0)
|
||||
player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600))
|
||||
player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) { [weak self] finished in
|
||||
guard let self = self else { return }
|
||||
self.updateBufferValue()
|
||||
}
|
||||
}
|
||||
|
||||
@objc func seekForward() {
|
||||
let skipValue = UserDefaults.standard.double(forKey: "skipIncrement")
|
||||
let finalSkip = skipValue > 0 ? skipValue : 10
|
||||
currentTimeVal = min(currentTimeVal + finalSkip, duration)
|
||||
player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600))
|
||||
player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) { [weak self] finished in
|
||||
guard let self = self else { return }
|
||||
self.updateBufferValue()
|
||||
}
|
||||
}
|
||||
|
||||
@objc func handleDoubleTap(_ gesture: UITapGestureRecognizer) {
|
||||
|
|
@ -856,33 +1134,30 @@ class CustomMediaPlayerViewController: UIViewController {
|
|||
showSkipFeedback(direction: "forward")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@objc func handleSwipeDown(_ gesture: UISwipeGestureRecognizer) {
|
||||
dismiss(animated: true, completion: nil)
|
||||
}
|
||||
|
||||
@objc func togglePlayPause() {
|
||||
if isPlaying {
|
||||
player.pause()
|
||||
isPlaying = false
|
||||
playPauseButton.image = UIImage(systemName: "play.fill")
|
||||
|
||||
if !isControlsVisible {
|
||||
isControlsVisible = true
|
||||
UIView.animate(withDuration: 0.5) {
|
||||
UIView.animate(withDuration: 0.2) {
|
||||
self.controlsContainerView.alpha = 1.0
|
||||
self.skip85Button.alpha = 0.8
|
||||
self.view.layoutIfNeeded()
|
||||
}
|
||||
}
|
||||
player.pause()
|
||||
playPauseButton.image = UIImage(systemName: "play.fill")
|
||||
} else {
|
||||
player.play()
|
||||
isPlaying = true
|
||||
playPauseButton.image = UIImage(systemName: "pause.fill")
|
||||
}
|
||||
isPlaying.toggle()
|
||||
}
|
||||
|
||||
@objc func sliderEditingEnded() {
|
||||
let newTime = sliderViewModel.sliderValue
|
||||
player.seek(to: CMTime(seconds: newTime, preferredTimescale: 600))
|
||||
}
|
||||
|
||||
@objc func dismissTapped() {
|
||||
|
|
@ -901,6 +1176,14 @@ class CustomMediaPlayerViewController: UIViewController {
|
|||
player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600))
|
||||
}
|
||||
|
||||
@objc private func handleHoldForPause(_ gesture: UILongPressGestureRecognizer) {
|
||||
guard isHoldPauseEnabled else { return }
|
||||
|
||||
if gesture.state == .began {
|
||||
togglePlayPause()
|
||||
}
|
||||
}
|
||||
|
||||
func speedChangerMenu() -> UIMenu {
|
||||
let speeds: [Double] = [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0]
|
||||
let playbackSpeedActions = speeds.map { speed in
|
||||
|
|
@ -917,10 +1200,14 @@ class CustomMediaPlayerViewController: UIViewController {
|
|||
private func parseM3U8(url: URL, completion: @escaping () -> Void) {
|
||||
var request = URLRequest(url: url)
|
||||
request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Referer")
|
||||
request.addValue("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
|
||||
guard let self = self, let data = data, let content = String(data: data, encoding: .utf8) else {
|
||||
guard let self = self,
|
||||
let data = data,
|
||||
let content = String(data: data, encoding: .utf8) else {
|
||||
print("Failed to load m3u8 file")
|
||||
DispatchQueue.main.async {
|
||||
self?.qualities = []
|
||||
|
|
@ -946,7 +1233,8 @@ class CustomMediaPlayerViewController: UIViewController {
|
|||
for (index, line) in lines.enumerated() {
|
||||
if line.contains("#EXT-X-STREAM-INF"), index + 1 < lines.count {
|
||||
if let resolutionRange = line.range(of: "RESOLUTION="),
|
||||
let resolutionEndRange = line[resolutionRange.upperBound...].range(of: ",") ?? line[resolutionRange.upperBound...].range(of: "\n") {
|
||||
let resolutionEndRange = line[resolutionRange.upperBound...].range(of: ",")
|
||||
?? line[resolutionRange.upperBound...].range(of: "\n") {
|
||||
|
||||
let resolutionPart = String(line[resolutionRange.upperBound..<resolutionEndRange.lowerBound])
|
||||
if let heightStr = resolutionPart.components(separatedBy: "x").last,
|
||||
|
|
@ -959,7 +1247,8 @@ class CustomMediaPlayerViewController: UIViewController {
|
|||
if !nextLine.hasPrefix("http") && nextLine.contains(".m3u8") {
|
||||
if let baseURL = self.baseM3U8URL {
|
||||
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) {
|
||||
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 wasPlaying = player.rate > 0
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
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 playerItem = AVPlayerItem(asset: asset)
|
||||
|
||||
player.replaceCurrentItem(with: playerItem)
|
||||
|
||||
player.seek(to: currentTime)
|
||||
if wasPlaying {
|
||||
player.play()
|
||||
|
|
@ -1015,7 +1306,10 @@ class CustomMediaPlayerViewController: UIViewController {
|
|||
qualityButton.menu = qualitySelectionMenu()
|
||||
|
||||
if let selectedQuality = qualities.first(where: { $0.1 == urlString })?.0 {
|
||||
DropManager.shared.showDrop(title: "Quality: \(selectedQuality)", subtitle: "", duration: 0.5, icon: UIImage(systemName: "eye"))
|
||||
DropManager.shared.showDrop(title: "Quality: \(selectedQuality)",
|
||||
subtitle: "",
|
||||
duration: 0.5,
|
||||
icon: UIImage(systemName: "eye"))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1074,6 +1368,7 @@ class CustomMediaPlayerViewController: UIViewController {
|
|||
|
||||
self.qualityButton.isHidden = false
|
||||
self.qualityButton.menu = self.qualitySelectionMenu()
|
||||
self.updateMarqueeConstraints()
|
||||
}
|
||||
} else {
|
||||
isHLSStream = false
|
||||
|
|
@ -1204,7 +1499,9 @@ class CustomMediaPlayerViewController: UIViewController {
|
|||
]
|
||||
let paddingMenu = UIMenu(title: "Bottom Padding", children: paddingActions)
|
||||
|
||||
let subtitleOptionsMenu = UIMenu(title: "Subtitle Options", children: [subtitlesToggleAction, colorMenu, fontSizeMenu, shadowMenu, backgroundMenu, paddingMenu])
|
||||
let subtitleOptionsMenu = UIMenu(title: "Subtitle Options", children: [
|
||||
subtitlesToggleAction, colorMenu, fontSizeMenu, shadowMenu, backgroundMenu, paddingMenu
|
||||
])
|
||||
|
||||
menuElements = [subtitleOptionsMenu]
|
||||
}
|
||||
|
|
@ -1282,7 +1579,6 @@ class CustomMediaPlayerViewController: UIViewController {
|
|||
let audioSession = AVAudioSession.sharedInstance()
|
||||
try audioSession.setCategory(.playback, mode: .moviePlayback, options: .mixWithOthers)
|
||||
try audioSession.setActive(true)
|
||||
|
||||
try audioSession.overrideOutputAudioPort(.speaker)
|
||||
} catch {
|
||||
Logger.shared.log("Failed to set up AVAudioSession: \(error)")
|
||||
|
|
@ -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() {
|
||||
guard let player = player else { return }
|
||||
originalRate = player.rate
|
||||
|
|
@ -1325,8 +1634,22 @@ class CustomMediaPlayerViewController: UIViewController {
|
|||
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
|
||||
// low taper fade the meme is massive -cranci
|
||||
// cranci still doesnt have a job -seiike
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ class VideoPlayerViewController: UIViewController {
|
|||
var streamUrl: String?
|
||||
var fullUrl: String = ""
|
||||
var subtitles: String = ""
|
||||
var aniListID: Int = 0
|
||||
|
||||
var episodeNumber: Int = 0
|
||||
var episodeImageUrl: String = ""
|
||||
|
|
@ -40,6 +41,7 @@ class VideoPlayerViewController: UIViewController {
|
|||
|
||||
var request = URLRequest(url: url)
|
||||
request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Referer")
|
||||
request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Origin")
|
||||
request.addValue("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36", forHTTPHeaderField: "User-Agent")
|
||||
|
||||
let asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": request.allHTTPHeaderFields ?? [:]])
|
||||
|
|
@ -86,25 +88,6 @@ class VideoPlayerViewController: UIViewController {
|
|||
player?.removeTimeObserver(timeObserverToken)
|
||||
self.timeObserverToken = nil
|
||||
}
|
||||
|
||||
if let currentItem = player?.currentItem, currentItem.duration.seconds > 0,
|
||||
let streamUrl = streamUrl {
|
||||
let currentTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(fullUrl)")
|
||||
let duration = currentItem.duration.seconds
|
||||
let progress = currentTime / duration
|
||||
let item = ContinueWatchingItem(
|
||||
id: UUID(),
|
||||
imageUrl: episodeImageUrl,
|
||||
episodeNumber: episodeNumber,
|
||||
mediaTitle: mediaTitle,
|
||||
progress: progress,
|
||||
streamUrl: streamUrl,
|
||||
fullUrl: fullUrl,
|
||||
subtitles: subtitles,
|
||||
module: module
|
||||
)
|
||||
ContinueWatchingManager.shared.save(item: item)
|
||||
}
|
||||
}
|
||||
|
||||
private func setInitialPlayerRate() {
|
||||
|
|
@ -118,8 +101,9 @@ class VideoPlayerViewController: UIViewController {
|
|||
guard let player = self.player else { return }
|
||||
|
||||
let interval = CMTime(seconds: 1.0, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
|
||||
timeObserverToken = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { time in
|
||||
guard let currentItem = player.currentItem,
|
||||
timeObserverToken = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in
|
||||
guard let self = self,
|
||||
let currentItem = player.currentItem,
|
||||
currentItem.duration.seconds.isFinite else {
|
||||
return
|
||||
}
|
||||
|
|
@ -129,6 +113,37 @@ class VideoPlayerViewController: UIViewController {
|
|||
|
||||
UserDefaults.standard.set(currentTime, forKey: "lastPlayedTime_\(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 streamAsyncJS: Bool?
|
||||
let softsub: Bool?
|
||||
let multiStream: Bool?
|
||||
let multiSubs: Bool?
|
||||
let type: String?
|
||||
|
||||
struct Author: Codable, Hashable {
|
||||
let name: String
|
||||
|
|
|
|||
|
|
@ -233,6 +233,7 @@ struct ContinueWatchingCell: View {
|
|||
videoPlayerViewController.episodeNumber = item.episodeNumber
|
||||
videoPlayerViewController.mediaTitle = item.mediaTitle
|
||||
videoPlayerViewController.subtitles = item.subtitles ?? ""
|
||||
videoPlayerViewController.aniListID = item.aniListID ?? 0
|
||||
videoPlayerViewController.modalPresentationStyle = .fullScreen
|
||||
|
||||
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
|
|
@ -248,6 +249,7 @@ struct ContinueWatchingCell: View {
|
|||
episodeNumber: item.episodeNumber,
|
||||
onWatchNext: { },
|
||||
subtitlesURL: item.subtitles,
|
||||
aniListID: item.aniListID ?? 0,
|
||||
episodeImageUrl: item.imageUrl
|
||||
)
|
||||
customMediaPlayer.modalPresentationStyle = .fullScreen
|
||||
|
|
|
|||
|
|
@ -91,29 +91,44 @@ struct EpisodeCell: View {
|
|||
updateProgress()
|
||||
}
|
||||
.onTapGesture {
|
||||
onTap(episodeImageUrl)
|
||||
let imageUrl = episodeImageUrl.isEmpty ? "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/main/assets/banner2.png" : episodeImageUrl
|
||||
onTap(imageUrl)
|
||||
}
|
||||
}
|
||||
|
||||
private func markAsWatched() {
|
||||
UserDefaults.standard.set(99999999.0, forKey: "lastPlayedTime_\(episode)")
|
||||
UserDefaults.standard.set(99999999.0, forKey: "totalTime_\(episode)")
|
||||
updateProgress()
|
||||
let userDefaults = UserDefaults.standard
|
||||
let totalTime = 1000.0
|
||||
let watchedTime = totalTime
|
||||
userDefaults.set(watchedTime, forKey: "lastPlayedTime_\(episode)")
|
||||
userDefaults.set(totalTime, forKey: "totalTime_\(episode)")
|
||||
DispatchQueue.main.async {
|
||||
self.updateProgress()
|
||||
}
|
||||
}
|
||||
|
||||
private func resetProgress() {
|
||||
UserDefaults.standard.set(0.0, forKey: "lastPlayedTime_\(episode)")
|
||||
UserDefaults.standard.set(0.0, forKey: "totalTime_\(episode)")
|
||||
updateProgress()
|
||||
let userDefaults = UserDefaults.standard
|
||||
userDefaults.set(0.0, forKey: "lastPlayedTime_\(episode)")
|
||||
userDefaults.set(0.0, forKey: "totalTime_\(episode)")
|
||||
DispatchQueue.main.async {
|
||||
self.updateProgress()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateProgress() {
|
||||
let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(episode)")
|
||||
let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(episode)")
|
||||
currentProgress = totalTime > 0 ? lastPlayedTime / totalTime : 0
|
||||
let userDefaults = UserDefaults.standard
|
||||
let lastPlayedTime = userDefaults.double(forKey: "lastPlayedTime_\(episode)")
|
||||
let totalTime = userDefaults.double(forKey: "totalTime_\(episode)")
|
||||
currentProgress = totalTime > 0 ? min(lastPlayedTime / totalTime, 1.0) : 0
|
||||
}
|
||||
|
||||
private func fetchEpisodeDetails() {
|
||||
guard episodeID != 0 else {
|
||||
isLoading = false
|
||||
return
|
||||
}
|
||||
|
||||
guard let url = URL(string: "https://api.ani.zip/mappings?anilist_id=\(itemID)") else {
|
||||
isLoading = false
|
||||
return
|
||||
|
|
|
|||
|
|
@ -250,17 +250,27 @@ struct MediaInfoView: View {
|
|||
}
|
||||
},
|
||||
onMarkAllPrevious: {
|
||||
let userDefaults = UserDefaults.standard
|
||||
var updates = [String: Double]()
|
||||
|
||||
for ep2 in seasons[selectedSeason] where ep2.number < ep.number {
|
||||
let href = ep2.href
|
||||
UserDefaults.standard.set(99999999.0, forKey: "lastPlayedTime_\(href)")
|
||||
UserDefaults.standard.set(99999999.0, forKey: "totalTime_\(href)")
|
||||
updates["lastPlayedTime_\(href)"] = 99999999.0
|
||||
updates["totalTime_\(href)"] = 99999999.0
|
||||
}
|
||||
|
||||
for (key, value) in updates {
|
||||
userDefaults.set(value, forKey: key)
|
||||
}
|
||||
|
||||
userDefaults.synchronize()
|
||||
|
||||
refreshTrigger.toggle()
|
||||
Logger.shared.log("Marked episodes watched within season \(selectedSeason + 1) of \"\(title)\".", type: "General")
|
||||
}
|
||||
)
|
||||
.id(refreshTrigger)
|
||||
.disabled(isFetchingEpisode)
|
||||
.id(refreshTrigger)
|
||||
.disabled(isFetchingEpisode)
|
||||
}
|
||||
} else {
|
||||
Text("No episodes available")
|
||||
|
|
@ -290,17 +300,27 @@ struct MediaInfoView: View {
|
|||
}
|
||||
},
|
||||
onMarkAllPrevious: {
|
||||
let userDefaults = UserDefaults.standard
|
||||
var updates = [String: Double]()
|
||||
|
||||
for idx in 0..<i {
|
||||
let href = episodeLinks[idx].href
|
||||
UserDefaults.standard.set(99999999.0, forKey: "lastPlayedTime_\(href)")
|
||||
UserDefaults.standard.set(99999999.0, forKey: "totalTime_\(href)")
|
||||
if idx < episodeLinks.count {
|
||||
let href = episodeLinks[idx].href
|
||||
updates["lastPlayedTime_\(href)"] = 1000.0
|
||||
updates["totalTime_\(href)"] = 1000.0
|
||||
}
|
||||
}
|
||||
|
||||
for (key, value) in updates {
|
||||
userDefaults.set(value, forKey: key)
|
||||
}
|
||||
|
||||
refreshTrigger.toggle()
|
||||
Logger.shared.log("Marked \(ep.number - 1) episodes watched within series \"\(title)\".", type: "General")
|
||||
}
|
||||
)
|
||||
.id(refreshTrigger)
|
||||
.disabled(isFetchingEpisode)
|
||||
.id(refreshTrigger)
|
||||
.disabled(isFetchingEpisode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -448,7 +468,7 @@ struct MediaInfoView: View {
|
|||
groups.append(currentGroup)
|
||||
return groups
|
||||
}
|
||||
|
||||
|
||||
|
||||
func fetchDetails() {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
|
|
@ -499,9 +519,13 @@ struct MediaInfoView: View {
|
|||
|
||||
if module.metadata.softsub == true {
|
||||
if module.metadata.asyncJS == true {
|
||||
jsController.fetchStreamUrlJS(episodeUrl: href, softsub: true) { result in
|
||||
if let streamUrl = result.stream {
|
||||
self.playStream(url: streamUrl, fullURL: href, subtitles: result.subtitles)
|
||||
jsController.fetchStreamUrlJS(episodeUrl: href, softsub: true, module: module) { result in
|
||||
if let streams = result.streams, !streams.isEmpty {
|
||||
if streams.count > 1 {
|
||||
self.showStreamSelectionAlert(streams: streams, fullURL: href, subtitles: result.subtitles?.first)
|
||||
} else {
|
||||
self.playStream(url: streams[0], fullURL: href, subtitles: result.subtitles?.first)
|
||||
}
|
||||
} else {
|
||||
self.handleStreamFailure(error: nil)
|
||||
}
|
||||
|
|
@ -510,9 +534,13 @@ struct MediaInfoView: View {
|
|||
}
|
||||
}
|
||||
} else if module.metadata.streamAsyncJS == true {
|
||||
jsController.fetchStreamUrlJSSecond(episodeUrl: href, softsub: true) { result in
|
||||
if let streamUrl = result.stream {
|
||||
self.playStream(url: streamUrl, fullURL: href, subtitles: result.subtitles)
|
||||
jsController.fetchStreamUrlJSSecond(episodeUrl: href, softsub: true, module: module) { result in
|
||||
if let streams = result.streams, !streams.isEmpty {
|
||||
if streams.count > 1 {
|
||||
self.showStreamSelectionAlert(streams: streams, fullURL: href, subtitles: result.subtitles?.first)
|
||||
} else {
|
||||
self.playStream(url: streams[0], fullURL: href, subtitles: result.subtitles?.first)
|
||||
}
|
||||
} else {
|
||||
self.handleStreamFailure(error: nil)
|
||||
}
|
||||
|
|
@ -521,9 +549,13 @@ struct MediaInfoView: View {
|
|||
}
|
||||
}
|
||||
} else {
|
||||
jsController.fetchStreamUrl(episodeUrl: href, softsub: true) { result in
|
||||
if let streamUrl = result.stream {
|
||||
self.playStream(url: streamUrl, fullURL: href, subtitles: result.subtitles)
|
||||
jsController.fetchStreamUrl(episodeUrl: href, softsub: true, module: module) { result in
|
||||
if let streams = result.streams, !streams.isEmpty {
|
||||
if streams.count > 1 {
|
||||
self.showStreamSelectionAlert(streams: streams, fullURL: href, subtitles: result.subtitles?.first)
|
||||
} else {
|
||||
self.playStream(url: streams[0], fullURL: href, subtitles: result.subtitles?.first)
|
||||
}
|
||||
} else {
|
||||
self.handleStreamFailure(error: nil)
|
||||
}
|
||||
|
|
@ -534,9 +566,13 @@ struct MediaInfoView: View {
|
|||
}
|
||||
} else {
|
||||
if module.metadata.asyncJS == true {
|
||||
jsController.fetchStreamUrlJS(episodeUrl: href) { result in
|
||||
if let streamUrl = result.stream {
|
||||
self.playStream(url: streamUrl, fullURL: href)
|
||||
jsController.fetchStreamUrlJS(episodeUrl: href, module: module) { result in
|
||||
if let streams = result.streams, !streams.isEmpty {
|
||||
if streams.count > 1 {
|
||||
self.showStreamSelectionAlert(streams: streams, fullURL: href, subtitles: result.subtitles?.first)
|
||||
} else {
|
||||
self.playStream(url: streams[0], fullURL: href, subtitles: result.subtitles?.first)
|
||||
}
|
||||
} else {
|
||||
self.handleStreamFailure(error: nil)
|
||||
}
|
||||
|
|
@ -545,9 +581,13 @@ struct MediaInfoView: View {
|
|||
}
|
||||
}
|
||||
} else if module.metadata.streamAsyncJS == true {
|
||||
jsController.fetchStreamUrlJSSecond(episodeUrl: href) { result in
|
||||
if let streamUrl = result.stream {
|
||||
self.playStream(url: streamUrl, fullURL: href)
|
||||
jsController.fetchStreamUrlJSSecond(episodeUrl: href, module: module) { result in
|
||||
if let streams = result.streams, !streams.isEmpty {
|
||||
if streams.count > 1 {
|
||||
self.showStreamSelectionAlert(streams: streams, fullURL: href, subtitles: result.subtitles?.first)
|
||||
} else {
|
||||
self.playStream(url: streams[0], fullURL: href, subtitles: result.subtitles?.first)
|
||||
}
|
||||
} else {
|
||||
self.handleStreamFailure(error: nil)
|
||||
}
|
||||
|
|
@ -556,9 +596,13 @@ struct MediaInfoView: View {
|
|||
}
|
||||
}
|
||||
} else {
|
||||
jsController.fetchStreamUrl(episodeUrl: href) { result in
|
||||
if let streamUrl = result.stream {
|
||||
self.playStream(url: streamUrl, fullURL: href)
|
||||
jsController.fetchStreamUrl(episodeUrl: href, module: module) { result in
|
||||
if let streams = result.streams, !streams.isEmpty {
|
||||
if streams.count > 1 {
|
||||
self.showStreamSelectionAlert(streams: streams, fullURL: href, subtitles: result.subtitles?.first)
|
||||
} else {
|
||||
self.playStream(url: streams[0], fullURL: href, subtitles: result.subtitles?.first)
|
||||
}
|
||||
} else {
|
||||
self.handleStreamFailure(error: nil)
|
||||
}
|
||||
|
|
@ -589,6 +633,45 @@ struct MediaInfoView: View {
|
|||
self.isLoading = false
|
||||
}
|
||||
|
||||
func showStreamSelectionAlert(streams: [String], fullURL: String, subtitles: String? = nil) {
|
||||
DispatchQueue.main.async {
|
||||
let alert = UIAlertController(title: "Select Server", message: "Choose a server to play from", preferredStyle: .actionSheet)
|
||||
|
||||
for (index, stream) in streams.enumerated() {
|
||||
let quality = "Stream \(index + 1)"
|
||||
alert.addAction(UIAlertAction(title: quality, style: .default) { _ in
|
||||
self.playStream(url: stream, fullURL: fullURL, subtitles: subtitles)
|
||||
})
|
||||
}
|
||||
|
||||
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
|
||||
|
||||
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let 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) {
|
||||
DispatchQueue.main.async {
|
||||
let externalPlayer = UserDefaults.standard.string(forKey: "externalPlayer") ?? "Sora"
|
||||
|
|
@ -611,6 +694,7 @@ struct MediaInfoView: View {
|
|||
videoPlayerViewController.episodeImageUrl = selectedEpisodeImage
|
||||
videoPlayerViewController.mediaTitle = title
|
||||
videoPlayerViewController.subtitles = subtitles ?? ""
|
||||
videoPlayerViewController.aniListID = itemID ?? 0
|
||||
videoPlayerViewController.modalPresentationStyle = .fullScreen
|
||||
|
||||
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
|
|
@ -642,6 +726,7 @@ struct MediaInfoView: View {
|
|||
selectNextEpisode()
|
||||
},
|
||||
subtitlesURL: subtitles,
|
||||
aniListID: itemID ?? 0,
|
||||
episodeImageUrl: selectedEpisodeImage
|
||||
)
|
||||
customMediaPlayer.modalPresentationStyle = .fullScreen
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ struct SettingsViewModule: View {
|
|||
@State private var isLoading = false
|
||||
@State private var isRefreshing = false
|
||||
@State private var moduleUrl: String = ""
|
||||
@State private var refreshTask: Task<Void, Never>?
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
|
|
@ -113,23 +114,30 @@ struct SettingsViewModule: View {
|
|||
})
|
||||
.refreshable {
|
||||
isRefreshing = true
|
||||
await moduleManager.refreshModules()
|
||||
isRefreshing = false
|
||||
refreshTask?.cancel()
|
||||
refreshTask = Task {
|
||||
await moduleManager.refreshModules()
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
Task {
|
||||
refreshTask = Task {
|
||||
await moduleManager.refreshModules()
|
||||
}
|
||||
}
|
||||
.alert(isPresented: .constant(errorMessage != nil)) {
|
||||
Alert(
|
||||
title: Text("Error"),
|
||||
message: Text(errorMessage ?? "Unknown error"),
|
||||
dismissButton: .default(Text("OK")) {
|
||||
errorMessage = nil
|
||||
}
|
||||
)
|
||||
.onDisappear {
|
||||
refreshTask?.cancel()
|
||||
}
|
||||
.alert("Error", isPresented: Binding(
|
||||
get: { errorMessage != nil },
|
||||
set: { if !$0 { errorMessage = nil } }
|
||||
)) {
|
||||
Button("OK") {
|
||||
errorMessage = nil
|
||||
}
|
||||
} message: {
|
||||
Text(errorMessage ?? "Unknown error")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ struct SettingsViewPlayer: View {
|
|||
@AppStorage("holdSpeedPlayer") private var holdSpeedPlayer: Double = 2.0
|
||||
@AppStorage("skipIncrement") private var skipIncrement: Double = 10.0
|
||||
@AppStorage("skipIncrementHold") private var skipIncrementHold: Double = 30.0
|
||||
@AppStorage("holdForPauseEnabled") private var holdForPauseEnabled = false
|
||||
@AppStorage("skip85Visible") private var skip85Visible: Bool = true
|
||||
|
||||
|
||||
private let mediaPlayers = ["Default", "VLC", "OutPlayer", "Infuse", "nPlayer", "Sora"]
|
||||
|
||||
|
|
@ -40,6 +43,9 @@ struct SettingsViewPlayer: View {
|
|||
|
||||
Toggle("Force Landscape", isOn: $isAlwaysLandscape)
|
||||
.tint(.accentColor)
|
||||
|
||||
Toggle("Two Finger Hold for Pause",isOn: $holdForPauseEnabled)
|
||||
.tint(.accentColor)
|
||||
}
|
||||
|
||||
Section(header: Text("Speed Settings")) {
|
||||
|
|
@ -70,6 +76,8 @@ struct SettingsViewPlayer: View {
|
|||
Spacer()
|
||||
Stepper("\(Int(skipIncrementHold))s", value: $skipIncrementHold, in: 5...300, step: 5)
|
||||
}
|
||||
Toggle("Show Skip 85s Button", isOn: $skip85Visible)
|
||||
.tint(.accentColor)
|
||||
}
|
||||
SubtitleSettingsSection()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import Security
|
|||
import Kingfisher
|
||||
|
||||
struct SettingsViewTrackers: View {
|
||||
@AppStorage("sendPushUpdates") private var isSendPushUpdates = true
|
||||
|
||||
@State private var status: String = "You are not logged in"
|
||||
@State private var isLoggedIn: Bool = false
|
||||
@State private var username: String = ""
|
||||
|
|
@ -18,7 +20,7 @@ struct SettingsViewTrackers: View {
|
|||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section(header: Text("AniList"), footer: Text("Sora and cranci1 are not affiliated with AniList in any way.")) {
|
||||
Section(header: Text("AniList"), footer: Text("Sora and cranci1 are not affiliated with AniList in any way.\n\nNote that push updates may not be 100% acurate.")) {
|
||||
HStack() {
|
||||
KFImage(URL(string: "https://raw.githubusercontent.com/cranci1/Ryu/2f10226aa087154974a70c1ec78aa83a47daced9/Ryu/Assets.xcassets/Listing/Anilist.imageset/anilist.png"))
|
||||
.placeholder {
|
||||
|
|
@ -50,6 +52,10 @@ struct SettingsViewTrackers: View {
|
|||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
if isLoggedIn {
|
||||
Toggle("Send push updates", isOn: $isSendPushUpdates)
|
||||
.tint(.accentColor)
|
||||
}
|
||||
Button(isLoggedIn ? "Log Out from AniList.co" : "Log In with AniList.co") {
|
||||
if isLoggedIn {
|
||||
logout()
|
||||
|
|
@ -63,11 +69,38 @@ struct SettingsViewTrackers: View {
|
|||
.navigationTitle("Trackers")
|
||||
.onAppear {
|
||||
updateStatus()
|
||||
setupNotificationObservers()
|
||||
}
|
||||
.onDisappear {
|
||||
removeNotificationObservers()
|
||||
}
|
||||
}
|
||||
|
||||
func setupNotificationObservers() {
|
||||
NotificationCenter.default.addObserver(forName: AniListToken.authSuccessNotification, object: nil, queue: .main) { _ in
|
||||
self.status = "Authentication successful!"
|
||||
self.updateStatus()
|
||||
}
|
||||
|
||||
NotificationCenter.default.addObserver(forName: AniListToken.authFailureNotification, object: nil, queue: .main) { notification in
|
||||
if let error = notification.userInfo?["error"] as? String {
|
||||
self.status = "Login failed: \(error)"
|
||||
} else {
|
||||
self.status = "Login failed with unknown error"
|
||||
}
|
||||
self.isLoggedIn = false
|
||||
self.isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
func removeNotificationObservers() {
|
||||
NotificationCenter.default.removeObserver(self, name: AniListToken.authSuccessNotification, object: nil)
|
||||
NotificationCenter.default.removeObserver(self, name: AniListToken.authFailureNotification, object: nil)
|
||||
}
|
||||
|
||||
func login() {
|
||||
status = "Starting authentication..."
|
||||
isLoading = true
|
||||
AniListLogin.authenticate()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,9 +21,9 @@ struct SettingsView: View {
|
|||
NavigationLink(destination: SettingsViewModule()) {
|
||||
Text("Modules")
|
||||
}
|
||||
//NavigationLink(destination: SettingsViewTrackers()) {
|
||||
// Text("Trackers")
|
||||
//}
|
||||
NavigationLink(destination: SettingsViewTrackers()) {
|
||||
Text("Trackers")
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: Text("Info")) {
|
||||
|
|
|
|||
|
|
@ -9,19 +9,17 @@
|
|||
/* Begin PBXBuildFile section */
|
||||
130217CC2D81C55E0011EFF5 /* DownloadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 130217CB2D81C55E0011EFF5 /* DownloadView.swift */; };
|
||||
130C6BFA2D53AB1F00DC1432 /* SettingsViewData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 130C6BF92D53AB1F00DC1432 /* SettingsViewData.swift */; };
|
||||
13103E892D58A39A000F0673 /* AniListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13103E882D58A39A000F0673 /* AniListItem.swift */; };
|
||||
13103E8B2D58E028000F0673 /* View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13103E8A2D58E028000F0673 /* View.swift */; };
|
||||
13103E8E2D58E04A000F0673 /* SkeletonCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13103E8D2D58E04A000F0673 /* SkeletonCell.swift */; };
|
||||
131845F92D47C62D00CA7A54 /* SettingsViewGeneral.swift in Sources */ = {isa = PBXBuildFile; fileRef = 131845F82D47C62D00CA7A54 /* SettingsViewGeneral.swift */; };
|
||||
1327FBA72D758CEA00FC6689 /* Analytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1327FBA62D758CEA00FC6689 /* Analytics.swift */; };
|
||||
1327FBA92D758DEA00FC6689 /* UIDevice+Model.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1327FBA82D758DEA00FC6689 /* UIDevice+Model.swift */; };
|
||||
132AF1212D99951700A0140B /* JSController-Streams.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132AF1202D99951700A0140B /* JSController-Streams.swift */; };
|
||||
132AF1232D9995C300A0140B /* JSController-Details.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132AF1222D9995C300A0140B /* JSController-Details.swift */; };
|
||||
132AF1252D9995F900A0140B /* JSController-Search.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132AF1242D9995F900A0140B /* JSController-Search.swift */; };
|
||||
132E351D2D959DDB0007800E /* Drops in Frameworks */ = {isa = PBXBuildFile; productRef = 132E351C2D959DDB0007800E /* Drops */; };
|
||||
132E35202D959E1D0007800E /* FFmpeg-iOS-Lame in Frameworks */ = {isa = PBXBuildFile; productRef = 132E351F2D959E1D0007800E /* FFmpeg-iOS-Lame */; };
|
||||
132E35232D959E410007800E /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 132E35222D959E410007800E /* Kingfisher */; };
|
||||
1334FF4D2D786C93007E289F /* TMDB-Seasonal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1334FF4C2D786C93007E289F /* TMDB-Seasonal.swift */; };
|
||||
1334FF4F2D786C9E007E289F /* TMDB-Trending.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1334FF4E2D786C9E007E289F /* TMDB-Trending.swift */; };
|
||||
1334FF522D7871B7007E289F /* TMDBItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1334FF512D7871B7007E289F /* TMDBItem.swift */; };
|
||||
1334FF542D787217007E289F /* TMDBRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1334FF532D787217007E289F /* TMDBRequest.swift */; };
|
||||
133D7C6E2D2BE2500075467E /* SoraApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133D7C6D2D2BE2500075467E /* SoraApp.swift */; };
|
||||
133D7C702D2BE2500075467E /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133D7C6F2D2BE2500075467E /* ContentView.swift */; };
|
||||
133D7C722D2BE2520075467E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 133D7C712D2BE2520075467E /* Assets.xcassets */; };
|
||||
|
|
@ -42,6 +40,8 @@
|
|||
139935662D468C450065CEFF /* ModuleManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 139935652D468C450065CEFF /* ModuleManager.swift */; };
|
||||
1399FAD42D3AB38C00E97C31 /* SettingsViewLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1399FAD32D3AB38C00E97C31 /* SettingsViewLogger.swift */; };
|
||||
1399FAD62D3AB3DB00E97C31 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1399FAD52D3AB3DB00E97C31 /* Logger.swift */; };
|
||||
13B77E192DA44F8300126FDF /* MarqueeLabel in Frameworks */ = {isa = PBXBuildFile; productRef = 13B77E182DA44F8300126FDF /* MarqueeLabel */; };
|
||||
13B77E202DA457AA00126FDF /* AniListPushUpdates.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13B77E1F2DA457AA00126FDF /* AniListPushUpdates.swift */; };
|
||||
13B7F4C12D58FFDD0045714A /* Shimmer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13B7F4C02D58FFDD0045714A /* Shimmer.swift */; };
|
||||
13C0E5EA2D5F85EA00E7F619 /* ContinueWatchingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13C0E5E92D5F85EA00E7F619 /* ContinueWatchingManager.swift */; };
|
||||
13C0E5EC2D5F85F800E7F619 /* ContinueWatchingItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13C0E5EB2D5F85F800E7F619 /* ContinueWatchingItem.swift */; };
|
||||
|
|
@ -59,6 +59,7 @@
|
|||
13EA2BD52D32D97400C1EBD7 /* CustomPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EA2BD12D32D97400C1EBD7 /* CustomPlayer.swift */; };
|
||||
13EA2BD62D32D97400C1EBD7 /* Double+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EA2BD32D32D97400C1EBD7 /* Double+Extension.swift */; };
|
||||
13EA2BD92D32D98400C1EBD7 /* NormalPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EA2BD82D32D98400C1EBD7 /* NormalPlayer.swift */; };
|
||||
1E3F5EC82D9F16B7003F310F /* VerticalBrightnessSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E3F5EC72D9F16B7003F310F /* VerticalBrightnessSlider.swift */; };
|
||||
1E9FF1D32D403E49008AC100 /* SettingsViewLoggerFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */; };
|
||||
1EAC7A322D888BC50083984D /* MusicProgressSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EAC7A312D888BC50083984D /* MusicProgressSlider.swift */; };
|
||||
73D164D52D8B5B470011A360 /* JavaScriptCore+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */; };
|
||||
|
|
@ -68,16 +69,14 @@
|
|||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
1334FF4C2D786C93007E289F /* TMDB-Seasonal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TMDB-Seasonal.swift"; sourceTree = "<group>"; };
|
||||
1334FF4E2D786C9E007E289F /* TMDB-Trending.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TMDB-Trending.swift"; sourceTree = "<group>"; };
|
||||
1334FF512D7871B7007E289F /* TMDBItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TMDBItem.swift; sourceTree = "<group>"; };
|
||||
1334FF532D787217007E289F /* TMDBRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TMDBRequest.swift; sourceTree = "<group>"; };
|
||||
132AF1202D99951700A0140B /* JSController-Streams.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-Streams.swift"; sourceTree = "<group>"; };
|
||||
132AF1222D9995C300A0140B /* JSController-Details.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-Details.swift"; sourceTree = "<group>"; };
|
||||
132AF1242D9995F900A0140B /* JSController-Search.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-Search.swift"; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
|
@ -117,6 +117,7 @@
|
|||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
|
@ -127,6 +128,7 @@
|
|||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
13B77E192DA44F8300126FDF /* MarqueeLabel in Frameworks */,
|
||||
132E35232D959E410007800E /* Kingfisher in Frameworks */,
|
||||
132E35202D959E1D0007800E /* FFmpeg-iOS-Lame in Frameworks */,
|
||||
132E351D2D959DDB0007800E /* Drops in Frameworks */,
|
||||
|
|
@ -139,7 +141,6 @@
|
|||
13103E802D589D6C000F0673 /* Tracking Services */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
1334FF4A2D786C6D007E289F /* TMDB */,
|
||||
13103E812D589D77000F0673 /* AniList */,
|
||||
);
|
||||
path = "Tracking Services";
|
||||
|
|
@ -148,20 +149,12 @@
|
|||
13103E812D589D77000F0673 /* AniList */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
13B77E1E2DA4577D00126FDF /* Mutations */,
|
||||
13DB468A2D900919008CBC03 /* Auth */,
|
||||
13103E872D58A392000F0673 /* Struct */,
|
||||
);
|
||||
path = AniList;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
13103E872D58A392000F0673 /* Struct */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
13103E882D58A39A000F0673 /* AniListItem.swift */,
|
||||
);
|
||||
path = Struct;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
13103E8C2D58E037000F0673 /* SkeletonCells */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
|
@ -179,33 +172,6 @@
|
|||
path = Analytics;
|
||||
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 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
|
@ -326,6 +292,9 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
133D7C8B2D2BE2640075467E /* JSController.swift */,
|
||||
132AF1202D99951700A0140B /* JSController-Streams.swift */,
|
||||
132AF1222D9995C300A0140B /* JSController-Details.swift */,
|
||||
132AF1242D9995F900A0140B /* JSController-Search.swift */,
|
||||
);
|
||||
path = JSLoader;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -374,6 +343,14 @@
|
|||
path = SettingsView;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
13B77E1E2DA4577D00126FDF /* Mutations */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
13B77E1F2DA457AA00126FDF /* AniListPushUpdates.swift */,
|
||||
);
|
||||
path = Mutations;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
13C0E5E82D5F85DD00E7F619 /* ContinueWatching */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
|
@ -431,6 +408,7 @@
|
|||
13EA2BD22D32D97400C1EBD7 /* Components */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
1E3F5EC72D9F16B7003F310F /* VerticalBrightnessSlider.swift */,
|
||||
13EA2BD32D32D97400C1EBD7 /* Double+Extension.swift */,
|
||||
1EAC7A312D888BC50083984D /* MusicProgressSlider.swift */,
|
||||
);
|
||||
|
|
@ -457,6 +435,7 @@
|
|||
132E351C2D959DDB0007800E /* Drops */,
|
||||
132E351F2D959E1D0007800E /* FFmpeg-iOS-Lame */,
|
||||
132E35222D959E410007800E /* Kingfisher */,
|
||||
13B77E182DA44F8300126FDF /* MarqueeLabel */,
|
||||
);
|
||||
productName = Sora;
|
||||
productReference = 133D7C6A2D2BE2500075467E /* Sulfur.app */;
|
||||
|
|
@ -489,6 +468,7 @@
|
|||
132E351B2D959DDB0007800E /* XCRemoteSwiftPackageReference "Drops" */,
|
||||
132E351E2D959E1D0007800E /* XCRemoteSwiftPackageReference "FFmpeg-iOS-Lame" */,
|
||||
132E35212D959E410007800E /* XCRemoteSwiftPackageReference "Kingfisher" */,
|
||||
13B77E172DA44F8300126FDF /* XCRemoteSwiftPackageReference "MarqueeLabel" */,
|
||||
);
|
||||
productRefGroup = 133D7C6B2D2BE2500075467E /* Products */;
|
||||
projectDirPath = "";
|
||||
|
|
@ -524,24 +504,22 @@
|
|||
13B7F4C12D58FFDD0045714A /* Shimmer.swift in Sources */,
|
||||
139935662D468C450065CEFF /* ModuleManager.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 */,
|
||||
13DB46902D900A38008CBC03 /* URL.swift in Sources */,
|
||||
130C6BFA2D53AB1F00DC1432 /* SettingsViewData.swift in Sources */,
|
||||
1334FF542D787217007E289F /* TMDBRequest.swift in Sources */,
|
||||
1E9FF1D32D403E49008AC100 /* SettingsViewLoggerFilter.swift in Sources */,
|
||||
13EA2BD92D32D98400C1EBD7 /* NormalPlayer.swift in Sources */,
|
||||
133D7C932D2BE2640075467E /* Modules.swift in Sources */,
|
||||
13DB7CC32D7D99C0004371D3 /* SubtitleSettingsManager.swift in Sources */,
|
||||
133D7C702D2BE2500075467E /* ContentView.swift in Sources */,
|
||||
13DB7CEC2D7DED5D004371D3 /* DownloadManager.swift in Sources */,
|
||||
1334FF522D7871B7007E289F /* TMDBItem.swift in Sources */,
|
||||
13D99CF72D4E73C300250A86 /* ModuleAdditionSettingsView.swift in Sources */,
|
||||
13C0E5EC2D5F85F800E7F619 /* ContinueWatchingItem.swift in Sources */,
|
||||
13CBA0882D60F19C00EFE70A /* VTTSubtitlesLoader.swift in Sources */,
|
||||
13EA2BD62D32D97400C1EBD7 /* Double+Extension.swift in Sources */,
|
||||
133D7C8F2D2BE2640075467E /* MediaInfoView.swift in Sources */,
|
||||
13103E892D58A39A000F0673 /* AniListItem.swift in Sources */,
|
||||
132AF1212D99951700A0140B /* JSController-Streams.swift in Sources */,
|
||||
131845F92D47C62D00CA7A54 /* SettingsViewGeneral.swift in Sources */,
|
||||
13103E8E2D58E04A000F0673 /* SkeletonCell.swift in Sources */,
|
||||
13D842552D45267500EBBFA6 /* DropManager.swift in Sources */,
|
||||
|
|
@ -559,9 +537,11 @@
|
|||
133D7C8E2D2BE2640075467E /* LibraryView.swift in Sources */,
|
||||
133D7C6E2D2BE2500075467E /* SoraApp.swift in Sources */,
|
||||
138AA1B92D2D66FD0021F9DF /* CircularProgressBar.swift in Sources */,
|
||||
1334FF4D2D786C93007E289F /* TMDB-Seasonal.swift in Sources */,
|
||||
1E3F5EC82D9F16B7003F310F /* VerticalBrightnessSlider.swift in Sources */,
|
||||
13DB468D2D90093A008CBC03 /* Anilist-Login.swift in Sources */,
|
||||
73D164D52D8B5B470011A360 /* JavaScriptCore+Extensions.swift in Sources */,
|
||||
132AF1232D9995C300A0140B /* JSController-Details.swift in Sources */,
|
||||
13B77E202DA457AA00126FDF /* AniListPushUpdates.swift in Sources */,
|
||||
13EA2BD52D32D97400C1EBD7 /* CustomPlayer.swift in Sources */,
|
||||
1399FAD42D3AB38C00E97C31 /* SettingsViewLogger.swift in Sources */,
|
||||
13DB46922D900BCE008CBC03 /* SettingsViewTrackers.swift in Sources */,
|
||||
|
|
@ -708,6 +688,7 @@
|
|||
INFOPLIST_FILE = Sora/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Sora;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment";
|
||||
INFOPLIST_KEY_NSCameraUsageDescription = "Sora may requires access to your device's camera.";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
|
|
@ -720,7 +701,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.2.1;
|
||||
MARKETING_VERSION = 0.2.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = me.cranci.sulfur;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
|
@ -750,6 +731,7 @@
|
|||
INFOPLIST_FILE = Sora/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Sora;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment";
|
||||
INFOPLIST_KEY_NSCameraUsageDescription = "Sora may requires access to your device's camera.";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
|
|
@ -762,7 +744,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.2.1;
|
||||
MARKETING_VERSION = 0.2.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = me.cranci.sulfur;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
|
@ -822,6 +804,14 @@
|
|||
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 */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
|
|
@ -840,6 +830,11 @@
|
|||
package = 132E35212D959E410007800E /* XCRemoteSwiftPackageReference "Kingfisher" */;
|
||||
productName = Kingfisher;
|
||||
};
|
||||
13B77E182DA44F8300126FDF /* MarqueeLabel */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 13B77E172DA44F8300126FDF /* XCRemoteSwiftPackageReference "MarqueeLabel" */;
|
||||
productName = MarqueeLabel;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = 133D7C622D2BE2500075467E /* Project object */;
|
||||
|
|
|
|||
|
|
@ -36,6 +36,15 @@
|
|||
"revision": "b6f62758f21a8c03cd64f4009c037cfa580a256e",
|
||||
"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