v0.2.1 - beta 2 (#69)

This commit is contained in:
cranci 2025-04-08 20:43:47 +02:00 committed by GitHub
commit 97b366d4c3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1683 additions and 1023 deletions

View file

@ -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&apos;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>

View file

@ -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)
}
}

View file

@ -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()
}
}

View file

@ -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
}

View file

@ -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)
}
}
}
}
}

View file

@ -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
}
}

View file

@ -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 ?? ""
}
}

View file

@ -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
}
}

View file

@ -16,5 +16,6 @@ struct ContinueWatchingItem: Codable, Identifiable {
let streamUrl: String
let fullUrl: String
let subtitles: String?
let aniListID: Int?
let module: ScrapingModule
}

View file

@ -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 {

View 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)
}
}
}

View 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])
}
}

View 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()
}
}

View file

@ -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()
}
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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 labels 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 labels 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

View file

@ -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)
}
}
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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")
}
}

View file

@ -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()
}

View file

@ -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()
}

View file

@ -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")) {

View file

@ -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 */;

View file

@ -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"
}
}
]
},