This commit is contained in:
cranci 2025-03-28 16:58:51 +01:00 committed by GitHub
commit 2a08ae5f16
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 1050 additions and 966 deletions

View file

@ -2,8 +2,6 @@
<!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>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>

View file

@ -29,7 +29,11 @@ struct SoraApp: App {
}
}
.onOpenURL { url in
handleURL(url)
if let params = url.queryParameters, params["code"] != nil {
Self.handleRedirect(url: url)
} else {
handleURL(url)
}
}
}
}
@ -52,4 +56,20 @@ struct SoraApp: App {
Logger.shared.log("Failed to present module addition view: No window scene found", type: "Error")
}
}
static func handleRedirect(url: URL) {
guard let params = url.queryParameters,
let code = params["code"] else {
Logger.shared.log("Failed to extract authorization code")
return
}
AniListToken.exchangeAuthorizationCodeForToken(code: code) { success in
if success {
Logger.shared.log("Token exchange successful")
} else {
Logger.shared.log("Token exchange failed", type: "Error")
}
}
}
}

View file

@ -0,0 +1,34 @@
//
// Login.swift
// Ryu
//
// Created by Francesco on 08/08/24.
//
import UIKit
class AniListLogin {
static let clientID = "19551"
static let redirectURI = "sora://anilist"
static let authorizationEndpoint = "https://anilist.co/api/v2/oauth/authorize"
static func authenticate() {
let urlString = "\(authorizationEndpoint)?client_id=\(clientID)&redirect_uri=\(redirectURI)&response_type=code"
guard let url = URL(string: urlString) else {
return
}
if UIApplication.shared.canOpenURL(url) {
UIApplication.shared.open(url, options: [:]) { success in
if success {
Logger.shared.log("Safari opened successfully", type: "Debug")
} else {
Logger.shared.log("Failed to open Safari", type: "Error")
}
}
} else {
Logger.shared.log("Cannot open URL", type: "Error")
}
}
}

View file

@ -0,0 +1,88 @@
//
// Token.swift
// Ryu
//
// Created by Francesco on 08/08/24.
//
import UIKit
import Security
class AniListToken {
static let clientID = "19551"
static let clientSecret = "fk8EgkyFbXk95TbPwLYQLaiMaNIryMpDBwJsPXoX"
static let redirectURI = "sora://anilist"
static let tokenEndpoint = "https://anilist.co/api/v2/oauth/token"
static let serviceName = "me.cranci.sora.AniListToken"
static let accountName = "AniListAccessToken"
static func saveTokenToKeychain(token: String) -> Bool {
let tokenData = token.data(using: .utf8)!
let deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceName,
kSecAttrAccount as String: accountName
]
SecItemDelete(deleteQuery as CFDictionary)
let addQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceName,
kSecAttrAccount as String: accountName,
kSecValueData as String: tokenData
]
let status = SecItemAdd(addQuery as CFDictionary, nil)
return status == errSecSuccess
}
static func exchangeAuthorizationCodeForToken(code: String, completion: @escaping (Bool) -> Void) {
Logger.shared.log("Exchanging authorization code for access token...")
guard let url = URL(string: tokenEndpoint) else {
Logger.shared.log("Invalid token endpoint URL", type: "Error")
completion(false)
return
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
let bodyString = "grant_type=authorization_code&client_id=\(clientID)&client_secret=\(clientSecret)&redirect_uri=\(redirectURI)&code=\(code)"
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)
}
}
} catch {
Logger.shared.log("Failed to parse JSON: \(error.localizedDescription)", type: "Error")
completion(false)
}
}
task.resume()
}
}

View file

@ -1,117 +0,0 @@
//
// AniList-Seasonal.swift
// Sora
//
// Created by Francesco on 09/02/25.
//
import Foundation
class AnilistServiceSeasonalAnime {
func fetchSeasonalAnime(completion: @escaping ([AniListItem]?) -> Void) {
let currentDate = Date()
let calendar = Calendar.current
let year = calendar.component(.year, from: currentDate)
let month = calendar.component(.month, from: currentDate)
let season: String
switch month {
case 1...3:
season = "WINTER"
case 4...6:
season = "SPRING"
case 7...9:
season = "SUMMER"
default:
season = "FALL"
}
let query = """
query {
Page(page: 1, perPage: 100) {
media(season: \(season), seasonYear: \(year), type: ANIME, isAdult: false) {
id
title {
romaji
english
native
}
coverImage {
large
}
}
}
}
"""
guard let url = URL(string: "https://graphql.anilist.co") else {
print("Invalid URL")
completion(nil)
return
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
let parameters: [String: Any] = ["query": query]
do {
request.httpBody = try JSONSerialization.data(withJSONObject: parameters, options: [])
} catch {
print("Error encoding JSON: \(error.localizedDescription)")
completion(nil)
return
}
let task = URLSession.custom.dataTask(with: request) { data, response, error in
DispatchQueue.main.async {
if let error = error {
print("Error fetching seasonal anime: \(error.localizedDescription)")
completion(nil)
return
}
guard let data = data else {
print("No data returned")
completion(nil)
return
}
do {
if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
let dataObject = json["data"] as? [String: Any],
let page = dataObject["Page"] as? [String: Any],
let media = page["media"] as? [[String: Any]] {
let seasonalAnime: [AniListItem] = media.compactMap { item -> AniListItem? in
guard let id = item["id"] as? Int,
let titleData = item["title"] as? [String: Any],
let romaji = titleData["romaji"] as? String,
let english = titleData["english"] as? String?,
let native = titleData["native"] as? String?,
let coverImageData = item["coverImage"] as? [String: Any],
let largeImageUrl = coverImageData["large"] as? String,
URL(string: largeImageUrl) != nil else {
return nil
}
return AniListItem(
id: id,
title: AniListTitle(romaji: romaji, english: english, native: native),
coverImage: AniListCoverImage(large: largeImageUrl)
)
}
completion(seasonalAnime)
} else {
print("Error parsing JSON or missing expected fields")
completion(nil)
}
} catch {
print("Error decoding JSON: \(error.localizedDescription)")
completion(nil)
}
}
}
task.resume()
}
}

View file

@ -1,93 +0,0 @@
//
// AniList-Trending.swift
// Sora
//
// Created by Francesco on 09/02/25.
//
import Foundation
class AnilistServiceTrendingAnime {
func fetchTrendingAnime(completion: @escaping ([AniListItem]?) -> Void) {
let query = """
query {
Page(page: 1, perPage: 100) {
media(sort: TRENDING_DESC, type: ANIME, isAdult: false) {
id
title {
romaji
english
native
}
coverImage {
large
}
}
}
}
"""
guard let url = URL(string: "https://graphql.anilist.co") else {
print("Invalid URL")
completion(nil)
return
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
let parameters: [String: Any] = ["query": query]
do {
request.httpBody = try JSONSerialization.data(withJSONObject: parameters, options: [])
} catch {
print("Error encoding JSON: \(error.localizedDescription)")
completion(nil)
return
}
let task = URLSession.custom.dataTask(with: request) { data, response, error in
DispatchQueue.main.async {
if let error = error {
print("Error fetching trending anime: \(error.localizedDescription)")
completion(nil)
return
}
guard let data = data else {
print("No data returned")
completion(nil)
return
}
do {
if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
let dataObject = json["data"] as? [String: Any],
let page = dataObject["Page"] as? [String: Any],
let media = page["media"] as? [[String: Any]] {
let trendingAnime: [AniListItem] = media.compactMap { item in
guard let id = item["id"] as? Int,
let titleData = item["title"] as? [String: Any],
let romaji = titleData["romaji"] as? String,
let coverImageData = item["coverImage"] as? [String: Any],
let largeImageUrl = coverImageData["large"] as? String else {
return nil
}
return AniListItem(
id: id,
title: AniListTitle(romaji: romaji, english: titleData["english"] as? String, native: titleData["native"] as? String),
coverImage: AniListCoverImage(large: largeImageUrl)
)
}
completion(trendingAnime)
} else {
print("Error parsing JSON or missing expected fields")
completion(nil)
}
} catch {
print("Error decoding JSON: \(error.localizedDescription)")
completion(nil)
}
}
}
task.resume()
}
}

View file

@ -1,316 +0,0 @@
//
// AniList-DetailsView.swift
// Sora
//
// Created by Francesco on 11/02/25.
//
import SwiftUI
import Kingfisher
struct AniListDetailsView: View {
let animeID: Int
@StateObject private var viewModel: AniListDetailsViewModel
init(animeID: Int) {
self.animeID = animeID
_viewModel = StateObject(wrappedValue: AniListDetailsViewModel(animeID: animeID))
}
var body: some View {
ScrollView {
VStack(spacing: 16) {
if viewModel.isLoading {
ProgressView()
.padding()
} else if let media = viewModel.mediaInfo {
MediaHeaderView(media: media)
Divider()
MediaDetailsScrollView(media: media)
Divider()
SynopsisView(synopsis: media["description"] as? String)
Divider()
CharactersView(characters: media["characters"] as? [String: Any])
Divider()
ScoreDistributionView(stats: media["stats"] as? [String: Any])
} else {
Text("Failed to load media details.")
.padding()
}
}
}
.navigationBarTitle("", displayMode: .inline)
.navigationViewStyle(StackNavigationViewStyle())
.onAppear {
viewModel.fetchDetails()
}
}
}
class AniListDetailsViewModel: ObservableObject {
@Published var mediaInfo: [String: AnyHashable]?
@Published var isLoading: Bool = true
let animeID: Int
init(animeID: Int) {
self.animeID = animeID
}
func fetchDetails() {
AnilistServiceMediaInfo.fetchAnimeDetails(animeID: animeID) { result in
DispatchQueue.main.async {
switch result {
case .success(let media):
var convertedMedia: [String: AnyHashable] = [:]
for (key, value) in media {
if let value = value as? AnyHashable {
convertedMedia[key] = value
}
}
self.mediaInfo = convertedMedia
case .failure(let error):
print("Error: \(error)")
}
self.isLoading = false
}
}
}
}
struct MediaHeaderView: View {
let media: [String: Any]
var body: some View {
HStack(alignment: .top, spacing: 16) {
if let coverDict = media["coverImage"] as? [String: Any],
let posterURLString = coverDict["extraLarge"] as? String,
let posterURL = URL(string: posterURLString) {
KFImage(posterURL)
.placeholder {
RoundedRectangle(cornerRadius: 10)
.fill(Color.gray.opacity(0.3))
.frame(width: 150, height: 225)
.shimmering()
}
.resizable()
.aspectRatio(2/3, contentMode: .fill)
.cornerRadius(10)
.frame(width: 150, height: 225)
}
VStack(alignment: .leading) {
if let titleDict = media["title"] as? [String: Any],
let userPreferred = titleDict["english"] as? String {
Text(userPreferred)
.font(.system(size: 17))
.fontWeight(.bold)
.onLongPressGesture {
UIPasteboard.general.string = userPreferred
DropManager.shared.showDrop(title: "Copied to Clipboard", subtitle: "", duration: 1.0, icon: UIImage(systemName: "doc.on.clipboard.fill"))
}
}
if let titleDict = media["title"] as? [String: Any],
let userPreferred = titleDict["romaji"] as? String {
Text(userPreferred)
.font(.system(size: 13))
.foregroundColor(.secondary)
}
if let titleDict = media["title"] as? [String: Any],
let userPreferred = titleDict["native"] as? String {
Text(userPreferred)
.font(.system(size: 13))
.foregroundColor(.secondary)
}
}
Spacer()
}
.padding()
}
}
struct MediaDetailsScrollView: View {
let media: [String: Any]
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 4) {
if let type = media["type"] as? String {
MediaDetailItem(title: "Type", value: type)
Divider()
}
if let episodes = media["episodes"] as? Int {
MediaDetailItem(title: "Episodes", value: "\(episodes)")
Divider()
}
if let duration = media["duration"] as? Int {
MediaDetailItem(title: "Length", value: "\(duration) mins")
Divider()
}
if let format = media["format"] as? String {
MediaDetailItem(title: "Format", value: format)
Divider()
}
if let status = media["status"] as? String {
MediaDetailItem(title: "Status", value: status)
Divider()
}
if let season = media["season"] as? String {
MediaDetailItem(title: "Season", value: season)
Divider()
}
if let startDate = media["startDate"] as? [String: Any],
let year = startDate["year"] as? Int,
let month = startDate["month"] as? Int,
let day = startDate["day"] as? Int {
MediaDetailItem(title: "Start Date", value: "\(year)-\(month)-\(day)")
Divider()
}
if let endDate = media["endDate"] as? [String: Any],
let year = endDate["year"] as? Int,
let month = endDate["month"] as? Int,
let day = endDate["day"] as? Int {
MediaDetailItem(title: "End Date", value: "\(year)-\(month)-\(day)")
}
}
}
}
}
struct SynopsisView: View {
let synopsis: String?
var body: some View {
if let synopsis = synopsis {
Text(synopsis.strippedHTML)
.padding(.horizontal)
.foregroundColor(.secondary)
.font(.system(size: 14))
} else {
EmptyView()
}
}
}
struct CharactersView: View {
let characters: [String: Any]?
var body: some View {
if let charactersDict = characters,
let edges = charactersDict["edges"] as? [[String: Any]] {
VStack(alignment: .leading, spacing: 8) {
Text("Characters")
.font(.headline)
.padding(.horizontal)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(Array(edges.prefix(15).enumerated()), id: \.offset) { _, edge in
if let node = edge["node"] as? [String: Any],
let nameDict = node["name"] as? [String: Any],
let fullName = nameDict["full"] as? String,
let imageDict = node["image"] as? [String: Any],
let imageUrlStr = imageDict["large"] as? String,
let imageUrl = URL(string: imageUrlStr) {
CharacterItemView(imageUrl: imageUrl, name: fullName)
}
}
}
.padding(.horizontal)
}
}
} else {
EmptyView()
}
}
}
struct CharacterItemView: View {
let imageUrl: URL
let name: String
var body: some View {
VStack {
KFImage(imageUrl)
.placeholder {
Circle()
.fill(Color.gray.opacity(0.3))
.frame(width: 90, height: 90)
.shimmering()
}
.resizable()
.scaledToFill()
.frame(width: 90, height: 90)
.clipShape(Circle())
Text(name)
.font(.caption)
.lineLimit(1)
}
.frame(width: 105, height: 110)
}
}
struct ScoreDistributionView: View {
let stats: [String: Any]?
@State private var barHeights: [CGFloat] = []
var body: some View {
if let stats = stats,
let scoreDistribution = stats["scoreDistribution"] as? [[String: AnyHashable]] {
let maxValue: Int = scoreDistribution.compactMap { $0["amount"] as? Int }.max() ?? 1
let calculatedHeights = scoreDistribution.map { dataPoint -> CGFloat in
guard let amount = dataPoint["amount"] as? Int else { return 0 }
return CGFloat(amount) / CGFloat(maxValue) * 100
}
VStack {
Text("Score Distribution")
.font(.headline)
HStack(alignment: .bottom) {
ForEach(Array(scoreDistribution.enumerated()), id: \.offset) { index, dataPoint in
if let score = dataPoint["score"] as? Int {
VStack {
Rectangle()
.fill(Color.accentColor)
.frame(width: 20, height: calculatedHeights[index])
Text("\(score)")
.font(.caption)
}
}
}
}
}
.frame(maxWidth: .infinity)
.padding(.horizontal)
.onAppear {
barHeights = calculatedHeights
}
.onChange(of: scoreDistribution) { _ in
barHeights = calculatedHeights
}
} else {
EmptyView()
}
}
}
struct MediaDetailItem: View {
var title: String
var value: String
var body: some View {
VStack {
Text(value)
.font(.system(size: 17))
Text(title)
.font(.system(size: 13))
.foregroundColor(.secondary)
}
.padding(.horizontal)
}
}

View file

@ -1,135 +0,0 @@
//
// AniList-MediaInfo.swift
// Sora
//
// Created by Francesco on 11/02/25.
//
import Foundation
class AnilistServiceMediaInfo {
static func fetchAnimeDetails(animeID: Int, completion: @escaping (Result<[String: Any], Error>) -> Void) {
let query = """
query {
Media(id: \(animeID), type: ANIME) {
id
idMal
title {
romaji
english
native
userPreferred
}
type
format
status
description
startDate {
year
month
day
}
endDate {
year
month
day
}
season
episodes
duration
countryOfOrigin
isLicensed
source
hashtag
trailer {
id
site
}
updatedAt
coverImage {
extraLarge
}
bannerImage
genres
popularity
tags {
id
name
}
relations {
nodes {
id
coverImage { extraLarge }
title { userPreferred },
mediaListEntry { status }
}
}
characters {
edges {
node {
name {
full
}
image {
large
}
}
role
voiceActors {
name {
first
last
native
}
}
}
}
siteUrl
stats {
scoreDistribution {
score
amount
}
}
airingSchedule(notYetAired: true) {
nodes {
airingAt
episode
}
}
}
}
"""
let apiUrl = URL(string: "https://graphql.anilist.co")!
var request = URLRequest(url: apiUrl)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try? JSONSerialization.data(withJSONObject: ["query": query], options: [])
URLSession.custom.dataTask(with: request) { data, response, error in
if let error = error {
completion(.failure(error))
return
}
guard let data = data else {
completion(.failure(NSError(domain: "AnimeService", code: 0, userInfo: [NSLocalizedDescriptionKey: "No data received"])))
return
}
do {
if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
let data = json["data"] as? [String: Any],
let media = data["Media"] as? [String: Any] {
completion(.success(media))
} else {
completion(.failure(NSError(domain: "AnimeService", code: 0, userInfo: [NSLocalizedDescriptionKey: "Invalid response format"])))
}
} catch {
completion(.failure(error))
}
}.resume()
}
}

View file

@ -14,6 +14,11 @@ class ContinueWatchingManager {
private init() {}
func save(item: ContinueWatchingItem) {
if item.progress >= 0.9 {
remove(item: item)
return
}
var items = fetchItems()
if let index = items.firstIndex(where: { $0.streamUrl == item.streamUrl && $0.episodeNumber == item.episodeNumber }) {
items[index] = item

View file

@ -0,0 +1,226 @@
//
// JSContext+Extensions.swift
// Sora
//
// Created by Hamzo on 19/03/25.
//
import JavaScriptCore
extension JSContext {
func setupConsoleLogging() {
let consoleObject = JSValue(newObjectIn: self)
let consoleLogFunction: @convention(block) (String) -> Void = { message in
Logger.shared.log(message, type: "Debug")
}
consoleObject?.setObject(consoleLogFunction, forKeyedSubscript: "log" as NSString)
let consoleErrorFunction: @convention(block) (String) -> Void = { message in
Logger.shared.log(message, type: "Error")
}
consoleObject?.setObject(consoleErrorFunction, forKeyedSubscript: "error" as NSString)
self.setObject(consoleObject, forKeyedSubscript: "console" as NSString)
let logFunction: @convention(block) (String) -> Void = { message in
Logger.shared.log("JavaScript log: \(message)", type: "Debug")
}
self.setObject(logFunction, forKeyedSubscript: "log" as NSString)
}
func setupNativeFetch() {
let fetchNativeFunction: @convention(block) (String, [String: String]?, JSValue, JSValue) -> Void = { urlString, headers, resolve, reject in
guard let url = URL(string: urlString) else {
Logger.shared.log("Invalid URL", type: "Error")
reject.call(withArguments: ["Invalid URL"])
return
}
var request = URLRequest(url: url)
if let headers = headers {
for (key, value) in headers {
request.setValue(value, forHTTPHeaderField: key)
}
}
let task = URLSession.custom.dataTask(with: request) { data, _, error in
if let error = error {
Logger.shared.log("Network error in fetchNativeFunction: \(error.localizedDescription)", type: "Error")
reject.call(withArguments: [error.localizedDescription])
return
}
guard let data = data else {
Logger.shared.log("No data in response", type: "Error")
reject.call(withArguments: ["No data"])
return
}
if let text = String(data: data, encoding: .utf8) {
resolve.call(withArguments: [text])
} else {
Logger.shared.log("Unable to decode data to text", type: "Error")
reject.call(withArguments: ["Unable to decode data"])
}
}
task.resume()
}
self.setObject(fetchNativeFunction, forKeyedSubscript: "fetchNative" as NSString)
let fetchDefinition = """
function fetch(url, headers) {
return new Promise(function(resolve, reject) {
fetchNative(url, headers, resolve, reject);
});
}
"""
self.evaluateScript(fetchDefinition)
}
func setupFetchV2() {
let fetchV2NativeFunction: @convention(block) (String, [String: String]?, String?, String?, JSValue, JSValue) -> Void = { urlString, headers, method, body, resolve, reject in
guard let url = URL(string: urlString) else {
Logger.shared.log("Invalid URL", type: "Error")
reject.call(withArguments: ["Invalid URL"])
return
}
let httpMethod = method ?? "GET"
var request = URLRequest(url: url)
request.httpMethod = httpMethod
Logger.shared.log("FetchV2 Request: URL=\(url), Method=\(httpMethod), Body=\(body ?? "nil")", type: "Debug")
if httpMethod == "GET", let body = body, !body.isEmpty, body != "null", body != "undefined" {
Logger.shared.log("GET request must not have a body", type: "Error")
reject.call(withArguments: ["GET request must not have a body"])
return
}
if httpMethod != "GET", let body = body, !body.isEmpty, body != "null", body != "undefined" {
request.httpBody = body.data(using: .utf8)
}
if let headers = headers {
for (key, value) in headers {
request.setValue(value, forHTTPHeaderField: key)
}
}
let task = URLSession.custom.downloadTask(with: request) { tempFileURL, response, error in
if let error = error {
Logger.shared.log("Network error in fetchV2NativeFunction: \(error.localizedDescription)", type: "Error")
reject.call(withArguments: [error.localizedDescription])
return
}
guard let tempFileURL = tempFileURL else {
Logger.shared.log("No data in response", type: "Error")
reject.call(withArguments: ["No data"])
return
}
do {
let data = try Data(contentsOf: tempFileURL)
if data.count > 10_000_000 {
Logger.shared.log("Response exceeds maximum size", type: "Error")
reject.call(withArguments: ["Response exceeds maximum size"])
return
}
if let text = String(data: data, encoding: .utf8) {
resolve.call(withArguments: [text])
} else {
Logger.shared.log("Unable to decode data to text", type: "Error")
reject.call(withArguments: ["Unable to decode data"])
}
} catch {
Logger.shared.log("Error reading downloaded file: \(error.localizedDescription)", type: "Error")
reject.call(withArguments: ["Error reading downloaded file"])
}
}
task.resume()
}
self.setObject(fetchV2NativeFunction, forKeyedSubscript: "fetchV2Native" as NSString)
let fetchv2Definition = """
function fetchv2(url, headers = {}, method = "GET", body = null) {
if (method === "GET") {
return new Promise(function(resolve, reject) {
fetchV2Native(url, headers, method, null, function(rawText) { // Pass `null` explicitly
const responseObj = {
_data: rawText,
text: function() {
return Promise.resolve(this._data);
},
json: function() {
try {
return Promise.resolve(JSON.parse(this._data));
} catch (e) {
return Promise.reject("JSON parse error: " + e.message);
}
}
};
resolve(responseObj);
}, reject);
});
}
// Ensure body is properly serialized
const processedBody = body ? JSON.stringify(body) : null;
return new Promise(function(resolve, reject) {
fetchV2Native(url, headers, method, processedBody, function(rawText) {
const responseObj = {
_data: rawText,
text: function() {
return Promise.resolve(this._data);
},
json: function() {
try {
return Promise.resolve(JSON.parse(this._data));
} catch (e) {
return Promise.reject("JSON parse error: " + e.message);
}
}
};
resolve(responseObj);
}, reject);
});
}
"""
self.evaluateScript(fetchv2Definition)
}
func setupBase64Functions() {
let btoaFunction: @convention(block) (String) -> String? = { data in
guard let data = data.data(using: .utf8) else {
Logger.shared.log("btoa: Failed to encode input as UTF-8", type: "Error")
return nil
}
return data.base64EncodedString()
}
let atobFunction: @convention(block) (String) -> String? = { base64String in
guard let data = Data(base64Encoded: base64String) else {
Logger.shared.log("atob: Invalid base64 input", type: "Error")
return nil
}
return String(data: data, encoding: .utf8)
}
self.setObject(btoaFunction, forKeyedSubscript: "btoa" as NSString)
self.setObject(atobFunction, forKeyedSubscript: "atob" as NSString)
}
func setupJavaScriptEnvironment() {
setupConsoleLogging()
setupNativeFetch()
setupFetchV2()
setupBase64Functions()
}
}

View file

@ -0,0 +1,20 @@
//
// URL.swift
// Sulfur
//
// Created by Francesco on 23/03/25.
//
import Foundation
extension URL {
var queryParameters: [String: String]? {
guard let components = URLComponents(url: self, resolvingAgainstBaseURL: true),
let queryItems = components.queryItems else { return nil }
var params = [String: String]()
for queryItem in queryItems {
params[queryItem.name] = queryItem.value
}
return params
}
}

View file

@ -9,40 +9,38 @@ import Foundation
extension URLSession {
static let userAgents = [
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:122.0) Gecko/20100101 Firefox/122.0",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 Edg/122.0.2365.92",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36 Edg/121.0.2277.128",
"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",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_3_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_2_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14.3; rv:123.0) Gecko/20100101 Firefox/123.0",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14.2; rv:122.0) Gecko/20100101 Firefox/122.0",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
"Mozilla/5.0 (X11; Linux x86_64; rv:123.0) Gecko/20100101 Firefox/123.0",
"Mozilla/5.0 (X11; Linux x86_64; rv:122.0) Gecko/20100101 Firefox/122.0",
"Mozilla/5.0 (Linux; Android 14; SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.6261.105 Mobile Safari/537.36",
"Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.6261.105 Mobile Safari/537.36",
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1",
"Mozilla/5.0 (iPad; CPU OS 17_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1",
"Mozilla/5.0 (Android 14; Mobile; rv:123.0) Gecko/123.0 Firefox/123.0",
"Mozilla/5.0 (Android 13; Mobile; rv:122.0) Gecko/122.0 Firefox/122.0"
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:127.0) Gecko/20100101 Firefox/127.0",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.2569.45",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 Edg/128.0.2478.89",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_6_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 15_1_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/19.1 Safari/605.1.15",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 15_0_2) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/19.0 Safari/605.1.15",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 15.1; rv:128.0) Gecko/20100101 Firefox/128.0",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 15.0; rv:127.0) Gecko/20100101 Firefox/127.0",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
"Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0",
"Mozilla/5.0 (X11; Linux x86_64; rv:127.0) Gecko/20100101 Firefox/127.0",
"Mozilla/5.0 (Linux; Android 15; SM-S928B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6782.112 Mobile Safari/537.36",
"Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6782.112 Mobile Safari/537.36",
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/19.3 Mobile/15E148 Safari/604.1",
"Mozilla/5.0 (iPad; CPU OS 18_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/19.3 Mobile/15E148 Safari/604.1",
"Mozilla/5.0 (Android 15; Mobile; rv:128.0) Gecko/128.0 Firefox/128.0",
"Mozilla/5.0 (Android 15; Mobile; rv:127.0) Gecko/127.0 Firefox/127.0"
]
static let randomUserAgent: String = {
static var randomUserAgent: String = {
userAgents.randomElement() ?? userAgents[0]
}()
static let custom: URLSession = {
let configuration = URLSessionConfiguration.default
configuration.httpAdditionalHeaders = [
"User-Agent": randomUserAgent
]
configuration.httpAdditionalHeaders = ["User-Agent": randomUserAgent]
return URLSession(configuration: configuration)
}()
}

View file

@ -16,66 +16,16 @@ class JSController: ObservableObject {
}
private func setupContext() {
let consoleObject = JSValue(newObjectIn: context)
let consoleLogFunction: @convention(block) (String) -> Void = { message in
Logger.shared.log(message, type: "Debug")
}
consoleObject?.setObject(consoleLogFunction, forKeyedSubscript: "log" as NSString)
context.setObject(consoleObject, forKeyedSubscript: "console" as NSString)
let logFunction: @convention(block) (String) -> Void = { message in
Logger.shared.log("JavaScript log: \(message)", type: "Debug")
}
context.setObject(logFunction, forKeyedSubscript: "log" as NSString)
let fetchNativeFunction: @convention(block) (String, [String: String]?, JSValue, JSValue) -> Void = { urlString, headers, resolve, reject in
guard let url = URL(string: urlString) else {
Logger.shared.log("Invalid URL", type: "Error")
reject.call(withArguments: ["Invalid URL"])
return
}
var request = URLRequest(url: url)
if let headers = headers {
for (key, value) in headers {
request.setValue(value, forHTTPHeaderField: key)
}
}
let task = URLSession.custom.dataTask(with: request) { data, _, error in
if let error = error {
Logger.shared.log("Network error in fetchNativeFunction: \(error.localizedDescription)", type: "Error")
reject.call(withArguments: [error.localizedDescription])
return
}
guard let data = data else {
Logger.shared.log("No data in response", type: "Error")
reject.call(withArguments: ["No data"])
return
}
if let text = String(data: data, encoding: .utf8) {
resolve.call(withArguments: [text])
} else {
Logger.shared.log("Unable to decode data to text", type: "Error")
reject.call(withArguments: ["Unable to decode data"])
}
}
task.resume()
}
context.setObject(fetchNativeFunction, forKeyedSubscript: "fetchNative" as NSString)
let fetchDefinition = """
function fetch(url, headers) {
return new Promise(function(resolve, reject) {
fetchNative(url, headers, resolve, reject);
});
}
"""
context.evaluateScript(fetchDefinition)
context.setupJavaScriptEnvironment()
}
func loadScript(_ script: String) {
context = JSContext()
setupContext()
context.evaluateScript(script)
if let exception = context.exception {
Logger.shared.log("Error loading script: \(exception)", type: "Error")
}
}
func fetchSearchResults(keyword: String, module: ScrapingModule, completion: @escaping ([SearchItem]) -> Void) {
@ -249,10 +199,13 @@ class JSController: ObservableObject {
let data = jsonString.data(using: .utf8) {
do {
if let array = try JSONSerialization.jsonObject(with: data, options: []) as? [[String: Any]] {
let resultItems = array.map { item -> SearchItem in
let title = item["title"] as? String ?? ""
let imageUrl = item["image"] as? String ?? "https://s4.anilist.co/file/anilistcdn/character/large/default.jpg"
let href = item["href"] as? String ?? ""
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)
}

View file

@ -31,6 +31,8 @@ class Logger {
let entry = LogEntry(message: message, type: type, timestamp: Date())
logs.append(entry)
saveLogToFile(entry)
debugLog(entry)
}
@ -38,7 +40,7 @@ class Logger {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "dd-MM HH:mm:ss"
return logs.map { "[\(dateFormatter.string(from: $0.timestamp))] [\($0.type)] \($0.message)" }
.joined(separator: "\n----\n")
.joined(separator: "\n----\n")
}
func clearLogs() {
@ -64,4 +66,13 @@ class Logger {
}
}
}
}
/// Prints log messages to the Xcode console only in DEBUG mode
private func debugLog(_ entry: LogEntry) {
#if DEBUG
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "dd-MM HH:mm:ss"
let formattedMessage = "[\(dateFormatter.string(from: entry.timestamp))] [\(entry.type)] \(entry.message)"
print(formattedMessage)
#endif
}}

View file

@ -20,11 +20,21 @@ extension Double {
}
extension BinaryFloatingPoint {
func asTimeString(style: DateComponentsFormatter.UnitsStyle) -> String {
let formatter = DateComponentsFormatter()
formatter.allowedUnits = [.minute, .second]
formatter.unitsStyle = style
formatter.zeroFormattingBehavior = .pad
return formatter.string(from: TimeInterval(self)) ?? ""
func asTimeString(style: TimeStringStyle, showHours: Bool = false) -> String {
let totalSeconds = Int(self)
let hours = totalSeconds / 3600
let minutes = (totalSeconds % 3600) / 60
let seconds = totalSeconds % 60
if showHours || hours > 0 {
return String(format: "%02d:%02d:%02d", hours, minutes, seconds)
} else {
return String(format: "%02d:%02d", minutes, seconds)
}
}
}
enum TimeStringStyle {
case positional
case standard
}

View file

@ -42,10 +42,13 @@ struct MusicProgressSlider<T: BinaryFloatingPoint>: View {
}
HStack {
Text(value.asTimeString(style: .positional))
// 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))
Text("-" + (inRange.upperBound - value).asTimeString(style: .positional, showHours: shouldShowHours))
}
.font(.system(size: 12))
.foregroundColor(isActive ? fillColor : emptyColor)
}

View file

@ -42,13 +42,17 @@ class CustomMediaPlayerViewController: UIViewController {
var isWatchNextVisible: Bool = false
var lastDuration: Double = 0.0
var watchNextButtonAppearedAt: Double?
var subtitleForegroundColor: String = "white"
var subtitleBackgroundEnabled: Bool = true
var subtitleFontSize: Double = 20.0
var subtitleShadowRadius: Double = 1.0
var subtitlesLoader = VTTSubtitlesLoader()
var subtitlesEnabled: Bool = true {
didSet {
subtitleLabel.isHidden = !subtitlesEnabled
}
}
var playerViewController: AVPlayerViewController!
var controlsContainerView: UIView!
@ -136,11 +140,13 @@ class CustomMediaPlayerViewController: UIViewController {
loadSubtitleSettings()
setupPlayerViewController()
setupControls()
setupSkipAndDismissGestures()
addInvisibleControlOverlays()
setupSubtitleLabel()
setupDismissButton()
setupMenuButton()
setupSpeedButton()
setupQualityButton()
setupSpeedButton()
setupMenuButton()
setupSkip85Button()
setupWatchNextButton()
addTimeObserver()
@ -346,6 +352,135 @@ class CustomMediaPlayerViewController: UIViewController {
])
}
func addInvisibleControlOverlays() {
let playPauseOverlay = UIButton(type: .custom)
playPauseOverlay.backgroundColor = .clear
playPauseOverlay.addTarget(self, action: #selector(togglePlayPause), for: .touchUpInside)
view.addSubview(playPauseOverlay)
playPauseOverlay.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
playPauseOverlay.centerXAnchor.constraint(equalTo: playPauseButton.centerXAnchor),
playPauseOverlay.centerYAnchor.constraint(equalTo: playPauseButton.centerYAnchor),
playPauseOverlay.widthAnchor.constraint(equalTo: playPauseButton.widthAnchor, constant: 20),
playPauseOverlay.heightAnchor.constraint(equalTo: playPauseButton.heightAnchor, constant: 20)
])
let backwardOverlay = UIButton(type: .custom)
backwardOverlay.backgroundColor = .clear
backwardOverlay.addTarget(self, action: #selector(seekBackward), for: .touchUpInside)
view.addSubview(backwardOverlay)
backwardOverlay.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
backwardOverlay.centerXAnchor.constraint(equalTo: backwardButton.centerXAnchor),
backwardOverlay.centerYAnchor.constraint(equalTo: backwardButton.centerYAnchor),
backwardOverlay.widthAnchor.constraint(equalTo: backwardButton.widthAnchor, constant: 20),
backwardOverlay.heightAnchor.constraint(equalTo: backwardButton.heightAnchor, constant: 20)
])
let forwardOverlay = UIButton(type: .custom)
forwardOverlay.backgroundColor = .clear
forwardOverlay.addTarget(self, action: #selector(seekForward), for: .touchUpInside)
view.addSubview(forwardOverlay)
forwardOverlay.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
forwardOverlay.centerXAnchor.constraint(equalTo: forwardButton.centerXAnchor),
forwardOverlay.centerYAnchor.constraint(equalTo: forwardButton.centerYAnchor),
forwardOverlay.widthAnchor.constraint(equalTo: forwardButton.widthAnchor, constant: 20),
forwardOverlay.heightAnchor.constraint(equalTo: forwardButton.heightAnchor, constant: 20)
])
}
func setupSkipAndDismissGestures() {
let doubleTapGesture = UITapGestureRecognizer(target: self, action: #selector(handleDoubleTap(_:)))
doubleTapGesture.numberOfTapsRequired = 2
view.addGestureRecognizer(doubleTapGesture)
if let gestures = view.gestureRecognizers {
for gesture in gestures {
if let tapGesture = gesture as? UITapGestureRecognizer, tapGesture.numberOfTapsRequired == 1 {
tapGesture.require(toFail: doubleTapGesture)
}
}
}
let swipeDownGesture = UISwipeGestureRecognizer(target: self, action: #selector(handleSwipeDown(_:)))
swipeDownGesture.direction = .down
view.addGestureRecognizer(swipeDownGesture)
}
func showSkipFeedback(direction: String) {
let diameter: CGFloat = 600
if let existingFeedback = view.viewWithTag(999) {
existingFeedback.layer.removeAllAnimations()
existingFeedback.removeFromSuperview()
}
let circleView = UIView()
circleView.backgroundColor = UIColor.white.withAlphaComponent(0.0)
circleView.layer.cornerRadius = diameter / 2
circleView.clipsToBounds = true
circleView.translatesAutoresizingMaskIntoConstraints = false
circleView.isUserInteractionEnabled = false
circleView.tag = 999
let iconName = (direction == "forward") ? "goforward" : "gobackward"
let imageView = UIImageView(image: UIImage(systemName: iconName))
imageView.tintColor = .black
imageView.contentMode = .scaleAspectFit
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.alpha = 0.8
circleView.addSubview(imageView)
if direction == "forward" {
NSLayoutConstraint.activate([
imageView.centerYAnchor.constraint(equalTo: circleView.centerYAnchor),
imageView.centerXAnchor.constraint(equalTo: circleView.leadingAnchor, constant: diameter / 4),
imageView.widthAnchor.constraint(equalToConstant: 100),
imageView.heightAnchor.constraint(equalToConstant: 100)
])
} else {
NSLayoutConstraint.activate([
imageView.centerYAnchor.constraint(equalTo: circleView.centerYAnchor),
imageView.centerXAnchor.constraint(equalTo: circleView.trailingAnchor, constant: -diameter / 4),
imageView.widthAnchor.constraint(equalToConstant: 100),
imageView.heightAnchor.constraint(equalToConstant: 100)
])
}
view.addSubview(circleView)
if direction == "forward" {
NSLayoutConstraint.activate([
circleView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
circleView.centerXAnchor.constraint(equalTo: view.trailingAnchor),
circleView.widthAnchor.constraint(equalToConstant: diameter),
circleView.heightAnchor.constraint(equalToConstant: diameter)
])
} else {
NSLayoutConstraint.activate([
circleView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
circleView.centerXAnchor.constraint(equalTo: view.leadingAnchor),
circleView.widthAnchor.constraint(equalToConstant: diameter),
circleView.heightAnchor.constraint(equalToConstant: diameter)
])
}
UIView.animate(withDuration: 0.2, animations: {
circleView.backgroundColor = UIColor.white.withAlphaComponent(0.5)
imageView.alpha = 0.8
}) { _ in
UIView.animate(withDuration: 0.5, delay: 0.5, options: [], animations: {
circleView.backgroundColor = UIColor.white.withAlphaComponent(0.0)
imageView.alpha = 0.0
}, completion: { _ in
circleView.removeFromSuperview()
imageView.removeFromSuperview()
})
}
}
func setupSubtitleLabel() {
subtitleLabel = UILabel()
subtitleLabel.textAlignment = .center
@ -392,20 +527,20 @@ class CustomMediaPlayerViewController: UIViewController {
menuButton = UIButton(type: .system)
menuButton.setImage(UIImage(systemName: "text.bubble"), for: .normal)
menuButton.tintColor = .white
if let subtitlesURL = subtitlesURL, !subtitlesURL.isEmpty {
menuButton.showsMenuAsPrimaryAction = true
menuButton.menu = buildOptionsMenu()
} else {
menuButton.isHidden = true
}
controlsContainerView.addSubview(menuButton)
menuButton.translatesAutoresizingMaskIntoConstraints = false
guard let sliderView = sliderHostingController?.view else { return }
NSLayoutConstraint.activate([
menuButton.bottomAnchor.constraint(equalTo: sliderView.topAnchor),
menuButton.trailingAnchor.constraint(equalTo: sliderView.trailingAnchor),
menuButton.topAnchor.constraint(equalTo: controlsContainerView.topAnchor, constant: 20),
menuButton.trailingAnchor.constraint(equalTo: speedButton.leadingAnchor, constant: -20),
menuButton.widthAnchor.constraint(equalToConstant: 40),
menuButton.heightAnchor.constraint(equalToConstant: 40)
])
@ -415,30 +550,19 @@ class CustomMediaPlayerViewController: UIViewController {
speedButton = UIButton(type: .system)
speedButton.setImage(UIImage(systemName: "speedometer"), for: .normal)
speedButton.tintColor = .white
speedButton.showsMenuAsPrimaryAction = true
speedButton.menu = speedChangerMenu()
controlsContainerView.addSubview(speedButton)
speedButton.translatesAutoresizingMaskIntoConstraints = false
guard let sliderView = sliderHostingController?.view else { return }
if menuButton.isHidden {
NSLayoutConstraint.activate([
speedButton.bottomAnchor.constraint(equalTo: controlsContainerView.bottomAnchor, constant: -50),
speedButton.trailingAnchor.constraint(equalTo: sliderView.trailingAnchor),
speedButton.widthAnchor.constraint(equalToConstant: 40),
speedButton.heightAnchor.constraint(equalToConstant: 40)
])
} else {
NSLayoutConstraint.activate([
speedButton.bottomAnchor.constraint(equalTo: controlsContainerView.bottomAnchor, constant: -50),
speedButton.trailingAnchor.constraint(equalTo: menuButton.leadingAnchor),
speedButton.widthAnchor.constraint(equalToConstant: 40),
speedButton.heightAnchor.constraint(equalToConstant: 40)
])
}
NSLayoutConstraint.activate([
// Middle
speedButton.topAnchor.constraint(equalTo: controlsContainerView.topAnchor, constant: 20),
speedButton.trailingAnchor.constraint(equalTo: qualityButton.leadingAnchor, constant: -20),
speedButton.widthAnchor.constraint(equalToConstant: 40),
speedButton.heightAnchor.constraint(equalToConstant: 40)
])
}
func setupWatchNextButton() {
@ -464,8 +588,8 @@ class CustomMediaPlayerViewController: UIViewController {
]
watchNextButtonControlsConstraints = [
watchNextButton.trailingAnchor.constraint(equalTo: qualityButton.leadingAnchor),
watchNextButton.bottomAnchor.constraint(equalTo: skip85Button.bottomAnchor),
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)
]
@ -502,17 +626,18 @@ class CustomMediaPlayerViewController: UIViewController {
qualityButton.showsMenuAsPrimaryAction = true
qualityButton.menu = qualitySelectionMenu()
qualityButton.isHidden = true
controlsContainerView.addSubview(qualityButton)
qualityButton.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
qualityButton.bottomAnchor.constraint(equalTo: controlsContainerView.bottomAnchor, constant: -50),
qualityButton.trailingAnchor.constraint(equalTo: speedButton.leadingAnchor),
qualityButton.topAnchor.constraint(equalTo: controlsContainerView.topAnchor, constant: 20),
qualityButton.trailingAnchor.constraint(equalTo: controlsContainerView.trailingAnchor, constant: -20),
qualityButton.widthAnchor.constraint(equalToConstant: 40),
qualityButton.heightAnchor.constraint(equalToConstant: 40)
])
}
func updateSubtitleLabelAppearance() {
subtitleLabel.font = UIFont.systemFont(ofSize: CGFloat(subtitleFontSize))
@ -558,13 +683,13 @@ class CustomMediaPlayerViewController: UIViewController {
UserDefaults.standard.set(self.currentTimeVal, forKey: "lastPlayedTime_\(self.fullUrl)")
UserDefaults.standard.set(self.duration, forKey: "totalTime_\(self.fullUrl)")
if let currentCue = self.subtitlesLoader.cues.first(where: { self.currentTimeVal >= $0.startTime && self.currentTimeVal <= $0.endTime }) {
if self.subtitlesEnabled,
let currentCue = self.subtitlesLoader.cues.first(where: { self.currentTimeVal >= $0.startTime && self.currentTimeVal <= $0.endTime }) {
self.subtitleLabel.text = currentCue.text.strippedHTML
} else {
self.subtitleLabel.text = ""
}
// ORIGINAL PROGRESS BAR CODE:
DispatchQueue.main.async {
self.sliderHostingController?.rootView = MusicProgressSlider(
value: Binding(get: { max(0, min(self.sliderViewModel.sliderValue, self.duration)) },
@ -584,20 +709,16 @@ class CustomMediaPlayerViewController: UIViewController {
)
}
// Watch Next Button Logic:
let hideNext = UserDefaults.standard.bool(forKey: "hideNextButton")
let isNearEnd = (self.duration - self.currentTimeVal) <= (self.duration * 0.10)
&& self.currentTimeVal != self.duration
&& self.showWatchNextButton
&& self.duration != 0
if isNearEnd {
// First appearance: show the button in its normal position.
if !self.isWatchNextVisible {
self.isWatchNextVisible = true
self.watchNextButtonAppearedAt = self.currentTimeVal
// Choose constraints based on current controls visibility.
if self.isControlsVisible {
NSLayoutConstraint.deactivate(self.watchNextButtonNormalConstraints)
NSLayoutConstraint.activate(self.watchNextButtonControlsConstraints)
@ -605,7 +726,6 @@ class CustomMediaPlayerViewController: UIViewController {
NSLayoutConstraint.deactivate(self.watchNextButtonControlsConstraints)
NSLayoutConstraint.activate(self.watchNextButtonNormalConstraints)
}
// Soft fade-in.
self.watchNextButton.isHidden = false
self.watchNextButton.alpha = 0.0
UIView.animate(withDuration: 0.5, delay: 0, options: .curveEaseInOut, animations: {
@ -613,23 +733,19 @@ class CustomMediaPlayerViewController: UIViewController {
}, completion: nil)
}
// When 5 seconds have elapsed from when the button first appeared:
if let appearedAt = self.watchNextButtonAppearedAt,
(self.currentTimeVal - appearedAt) >= 5,
!self.isWatchNextRepositioned {
// Fade out the button first (even if controls are visible).
UIView.animate(withDuration: 0.5, delay: 0, options: .curveEaseInOut, animations: {
self.watchNextButton.alpha = 0.0
}, completion: { _ in
self.watchNextButton.isHidden = true
// Then lock it to the controls-attached constraints.
NSLayoutConstraint.deactivate(self.watchNextButtonNormalConstraints)
NSLayoutConstraint.activate(self.watchNextButtonControlsConstraints)
self.isWatchNextRepositioned = true
})
}
} else {
// Not near end: reset the watch-next button state.
self.watchNextButtonAppearedAt = nil
self.isWatchNextVisible = false
self.isWatchNextRepositioned = false
@ -646,7 +762,6 @@ class CustomMediaPlayerViewController: UIViewController {
func repositionWatchNextButton() {
self.isWatchNextRepositioned = true
// Update constraints so the button is now attached next to the playback controls.
UIView.animate(withDuration: 0.3, animations: {
NSLayoutConstraint.deactivate(self.watchNextButtonNormalConstraints)
NSLayoutConstraint.activate(self.watchNextButtonControlsConstraints)
@ -674,7 +789,6 @@ class CustomMediaPlayerViewController: UIViewController {
self.skip85Button.alpha = self.isControlsVisible ? 0.8 : 0
if self.isControlsVisible {
// Always use the controls-attached constraints.
NSLayoutConstraint.deactivate(self.watchNextButtonNormalConstraints)
NSLayoutConstraint.activate(self.watchNextButtonControlsConstraints)
if self.isWatchNextRepositioned || self.isWatchNextVisible {
@ -684,7 +798,6 @@ class CustomMediaPlayerViewController: UIViewController {
})
}
} else {
// When controls are hidden:
if !self.isWatchNextRepositioned && self.isWatchNextVisible {
NSLayoutConstraint.deactivate(self.watchNextButtonControlsConstraints)
NSLayoutConstraint.activate(self.watchNextButtonNormalConstraints)
@ -733,8 +846,31 @@ class CustomMediaPlayerViewController: UIViewController {
player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600))
}
@objc func handleDoubleTap(_ gesture: UITapGestureRecognizer) {
let tapLocation = gesture.location(in: view)
if tapLocation.x < view.bounds.width / 2 {
seekBackward()
showSkipFeedback(direction: "backward")
} else {
seekForward()
showSkipFeedback(direction: "forward")
}
}
@objc func handleSwipeDown(_ gesture: UISwipeGestureRecognizer) {
dismiss(animated: true, completion: nil)
}
@objc func togglePlayPause() {
if isPlaying {
if !isControlsVisible {
isControlsVisible = true
UIView.animate(withDuration: 0.5) {
self.controlsContainerView.alpha = 1.0
self.skip85Button.alpha = 0.8
self.view.layoutIfNeeded()
}
}
player.pause()
playPauseButton.image = UIImage(systemName: "play.fill")
} else {
@ -949,6 +1085,11 @@ class CustomMediaPlayerViewController: UIViewController {
var menuElements: [UIMenuElement] = []
if let subURL = subtitlesURL, !subURL.isEmpty {
let subtitlesToggleAction = UIAction(title: "Toggle Subtitles") { [weak self] _ in
guard let self = self else { return }
self.subtitlesEnabled.toggle()
}
let foregroundActions = [
UIAction(title: "White") { _ in
SubtitleSettingsManager.shared.update { settings in settings.foregroundColor = "white" }
@ -1063,7 +1204,7 @@ class CustomMediaPlayerViewController: UIViewController {
]
let paddingMenu = UIMenu(title: "Bottom Padding", children: paddingActions)
let subtitleOptionsMenu = UIMenu(title: "Subtitle Options", children: [colorMenu, fontSizeMenu, shadowMenu, backgroundMenu, paddingMenu])
let subtitleOptionsMenu = UIMenu(title: "Subtitle Options", children: [subtitlesToggleAction, colorMenu, fontSizeMenu, shadowMenu, backgroundMenu, paddingMenu])
menuElements = [subtitleOptionsMenu]
}

View file

@ -29,7 +29,7 @@ class VTTSubtitlesLoader: ObservableObject {
let format = determineSubtitleFormat(from: url)
URLSession.custom.dataTask(with: url) { data, _, error in
URLSession.shared.dataTask(with: url) { data, _, error in
guard let data = data,
let content = String(data: data, encoding: .utf8),
error == nil else { return }

View file

@ -146,22 +146,22 @@ struct ModuleAdditionSettingsView: View {
errorMessage = nil
Task {
do {
guard let url = URL(string: moduleUrl) else {
DispatchQueue.main.async {
self.errorMessage = "Invalid URL"
self.isLoading = false
}
return
guard let url = URL(string: moduleUrl) else {
await MainActor.run {
self.errorMessage = "Invalid URL"
self.isLoading = false
}
return
}
do {
let (data, _) = try await URLSession.custom.data(from: url)
let metadata = try JSONDecoder().decode(ModuleMetadata.self, from: data)
DispatchQueue.main.async {
await MainActor.run {
self.moduleMetadata = metadata
self.isLoading = false
}
} catch {
DispatchQueue.main.async {
await MainActor.run {
self.errorMessage = "Failed to fetch module: \(error.localizedDescription)"
self.isLoading = false
}
@ -174,13 +174,13 @@ struct ModuleAdditionSettingsView: View {
Task {
do {
let _ = try await moduleManager.addModule(metadataUrl: moduleUrl)
DispatchQueue.main.async {
await MainActor.run {
isLoading = false
DropManager.shared.showDrop(title: "Module Added", subtitle: "click it to select it", duration: 2.0, icon: UIImage(systemName:"gear.badge.checkmark"))
self.presentationMode.wrappedValue.dismiss()
}
} catch {
DispatchQueue.main.async {
await MainActor.run {
isLoading = false
if (error as NSError).domain == "Module already exists" {
errorMessage = "Module already exists"

View file

@ -24,6 +24,26 @@ struct LibraryView: View {
GridItem(.adaptive(minimum: 150), spacing: 12)
]
private var columnsCount: Int {
if UIDevice.current.userInterfaceIdiom == .pad {
let isLandscape = UIScreen.main.bounds.width > UIScreen.main.bounds.height
return isLandscape ? mediaColumnsLandscape : mediaColumnsPortrait
} else {
return verticalSizeClass == .compact ? mediaColumnsLandscape : mediaColumnsPortrait
}
}
private var cellWidth: CGFloat {
let keyWindow = UIApplication.shared.connectedScenes
.compactMap { ($0 as? UIWindowScene)?.windows.first(where: { $0.isKeyWindow }) }
.first
let safeAreaInsets = keyWindow?.safeAreaInsets ?? .zero
let safeWidth = UIScreen.main.bounds.width - safeAreaInsets.left - safeAreaInsets.right
let totalSpacing: CGFloat = 16 * CGFloat(columnsCount + 1)
let availableWidth = safeWidth - totalSpacing
return availableWidth / CGFloat(columnsCount)
}
var body: some View {
NavigationView {
ScrollView {
@ -76,10 +96,6 @@ struct LibraryView: View {
.frame(maxWidth: .infinity)
} else {
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: columnsCount), spacing: 12) {
let totalSpacing: CGFloat = 16 * CGFloat(columnsCount + 1)
let availableWidth = UIScreen.main.bounds.width - totalSpacing
let cellWidth = availableWidth / CGFloat(columnsCount)
ForEach(libraryManager.bookmarks) { item in
if let module = moduleManager.modules.first(where: { $0.id.uuidString == item.moduleId }) {
NavigationLink(destination: MediaInfoView(title: item.title, imageUrl: item.imageUrl, href: item.href, module: module)) {

View file

@ -250,7 +250,7 @@ struct MediaInfoView: View {
}
},
onMarkAllPrevious: {
for ep2 in seasons[selectedSeason] {
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)")
@ -296,7 +296,7 @@ struct MediaInfoView: View {
UserDefaults.standard.set(99999999.0, forKey: "totalTime_\(href)")
}
refreshTrigger.toggle()
Logger.shared.log("Marked \(ep.number - 1) episodes watched within anime \"\(title)\".", type: "General")
Logger.shared.log("Marked \(ep.number - 1) episodes watched within series \"\(title)\".", type: "General")
}
)
.id(refreshTrigger)
@ -626,9 +626,15 @@ struct MediaInfoView: View {
UIApplication.shared.open(url, options: [:], completionHandler: nil)
Logger.shared.log("Opening external app with scheme: \(url)", type: "General")
} else {
guard let url = URL(string: url) else {
Logger.shared.log("Invalid stream URL: \(url)", type: "Error")
DropManager.shared.showDrop(title: "Error", subtitle: "Invalid stream URL", duration: 2.0, icon: UIImage(systemName: "xmark.circle"))
return
}
let customMediaPlayer = CustomMediaPlayerViewController(
module: module,
urlString: url,
urlString: url.absoluteString,
fullUrl: fullURL,
title: title,
episodeNumber: selectedEpisodeNumber,
@ -644,6 +650,9 @@ struct MediaInfoView: View {
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let rootVC = windowScene.windows.first?.rootViewController {
findTopViewController.findViewController(rootVC).present(customMediaPlayer, animated: true, completion: nil)
} else {
Logger.shared.log("Failed to find root view controller", type: "Error")
DropManager.shared.showDrop(title: "Error", subtitle: "Failed to present player", duration: 2.0, icon: UIImage(systemName: "xmark.circle"))
}
}
}

View file

@ -19,7 +19,7 @@ struct SearchView: View {
@AppStorage("selectedModuleId") private var selectedModuleId: String?
@AppStorage("mediaColumnsPortrait") private var mediaColumnsPortrait: Int = 2
@AppStorage("mediaColumnsLandscape") private var mediaColumnsLandscape: Int = 4
@StateObject private var jsController = JSController()
@EnvironmentObject var moduleManager: ModuleManager
@Environment(\.verticalSizeClass) var verticalSizeClass
@ -30,6 +30,7 @@ struct SearchView: View {
@State private var searchText = ""
@State private var hasNoResults = false
@State private var isLandscape: Bool = UIDevice.current.orientation.isLandscape
@State private var isModuleSelectorPresented = false
private var selectedModule: ScrapingModule? {
guard let id = selectedModuleId else { return nil }
@ -63,12 +64,11 @@ struct SearchView: View {
let availableWidth = safeWidth - totalSpacing
return availableWidth / CGFloat(columnsCount)
}
var body: some View {
NavigationView {
ScrollView {
let columnsCount = determineColumns()
VStack(spacing: 0) {
HStack {
SearchBar(text: $searchText, onSearchButtonClicked: performSearch)
@ -164,38 +164,44 @@ struct SearchView: View {
.navigationBarTitleDisplayMode(.large)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
HStack(spacing: 4) {
if let selectedModule = selectedModule {
Text(selectedModule.metadata.sourceName)
.font(.headline)
.foregroundColor(.secondary)
}
Menu {
ForEach(moduleManager.modules) { module in
Button {
selectedModuleId = module.id.uuidString
} label: {
HStack {
KFImage(URL(string: module.metadata.iconUrl))
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 20, height: 20)
.cornerRadius(4)
Text(module.metadata.sourceName)
Spacer()
if module.id.uuidString == selectedModuleId {
Image(systemName: "checkmark")
Menu {
ForEach(getModuleLanguageGroups(), id: \.self) { language in
Menu(language) {
ForEach(getModulesForLanguage(language), id: \.id) { module in
Button {
selectedModuleId = module.id.uuidString
} label: {
HStack {
KFImage(URL(string: module.metadata.iconUrl))
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 20, height: 20)
.cornerRadius(4)
Text(module.metadata.sourceName)
if module.id.uuidString == selectedModuleId {
Image(systemName: "checkmark")
.foregroundColor(.accentColor)
}
}
}
}
}
} label: {
Image(systemName: "chevron.up.chevron.down")
}
} label: {
HStack(spacing: 4) {
if let selectedModule = selectedModule {
Text(selectedModule.metadata.sourceName)
.font(.headline)
.foregroundColor(.secondary)
} else {
Text("Select Module")
.font(.headline)
.foregroundColor(.accentColor)
}
Image(systemName: "chevron.down")
.foregroundColor(.secondary)
}
}
.id("moduleMenuHStack")
.fixedSize()
}
}
@ -267,6 +273,41 @@ struct SearchView: View {
return verticalSizeClass == .compact ? mediaColumnsLandscape : mediaColumnsPortrait
}
}
private func cleanLanguageName(_ language: String?) -> String {
guard let language = language else { return "Unknown" }
let cleaned = language.replacingOccurrences(
of: "\\s*\\([^\\)]*\\)",
with: "",
options: .regularExpression
).trimmingCharacters(in: .whitespaces)
return cleaned.isEmpty ? "Unknown" : cleaned
}
private func getModulesByLanguage() -> [String: [ScrapingModule]] {
var result = [String: [ScrapingModule]]()
for module in moduleManager.modules {
let language = cleanLanguageName(module.metadata.language)
if result[language] == nil {
result[language] = [module]
} else {
result[language]?.append(module)
}
}
return result
}
private func getModuleLanguageGroups() -> [String] {
return getModulesByLanguage().keys.sorted()
}
private func getModulesForLanguage(_ language: String) -> [ScrapingModule] {
return getModulesByLanguage()[language] ?? []
}
}
struct SearchBar: View {

View file

@ -14,9 +14,13 @@ struct SettingsViewGeneral: View {
@AppStorage("analyticsEnabled") private var analyticsEnabled: Bool = false
@AppStorage("multiThreads") private var multiThreadsEnabled: Bool = false
@AppStorage("metadataProviders") private var metadataProviders: String = "AniList"
@AppStorage("CustomDNSProvider") private var customDNSProvider: String = "Cloudflare"
@AppStorage("customPrimaryDNS") private var customPrimaryDNS: String = ""
@AppStorage("customSecondaryDNS") private var customSecondaryDNS: String = ""
@AppStorage("mediaColumnsPortrait") private var mediaColumnsPortrait: Int = 2
@AppStorage("mediaColumnsLandscape") private var mediaColumnsLandscape: Int = 4
private let customDNSProviderList = ["Cloudflare", "Google", "OpenDNS", "Quad9", "AdGuard", "CleanBrowsing", "ControlD", "Custom"]
private let metadataProvidersList = ["AniList"]
@EnvironmentObject var settings: Settings
@ -24,7 +28,7 @@ struct SettingsViewGeneral: View {
Form {
Section(header: Text("Interface")) {
ColorPicker("Accent Color", selection: $settings.accentColor)
HStack() {
HStack {
Text("Appearance")
Picker("Appearance", selection: $settings.selectedAppearance) {
Text("System").tag(Appearance.system)
@ -40,18 +44,10 @@ struct SettingsViewGeneral: View {
Text("Episodes Range")
Spacer()
Menu {
Button(action: { episodeChunkSize = 25 }) {
Text("25")
}
Button(action: { episodeChunkSize = 50 }) {
Text("50")
}
Button(action: { episodeChunkSize = 75 }) {
Text("75")
}
Button(action: { episodeChunkSize = 100 }) {
Text("100")
}
Button(action: { episodeChunkSize = 25 }) { Text("25") }
Button(action: { episodeChunkSize = 50 }) { Text("50") }
Button(action: { episodeChunkSize = 75 }) { Text("75") }
Button(action: { episodeChunkSize = 100 }) { Text("100") }
} label: {
Text("\(episodeChunkSize)")
}
@ -63,36 +59,24 @@ struct SettingsViewGeneral: View {
Spacer()
Menu(metadataProviders) {
ForEach(metadataProvidersList, id: \.self) { provider in
Button(action: {
metadataProviders = provider
}) {
Button(action: { metadataProviders = provider }) {
Text(provider)
}
}
}
}
}
//Section(header: Text("Downloads"), footer: Text("Note that the modules will be replaced only if there is a different version string inside the JSON file.")) {
// Toggle("Multi Threads conversion", isOn: $multiThreadsEnabled)
// .tint(.accentColor)
//}
Section(header: Text("Media Grid Layout"), footer: Text("Adjust the number of media items per row in portrait and landscape modes.")) {
HStack {
if UIDevice.current.userInterfaceIdiom == .pad {
Picker("Portrait Columns", selection: $mediaColumnsPortrait) {
ForEach(1..<6) { i in
Text("\(i)").tag(i)
}
ForEach(1..<6) { i in Text("\(i)").tag(i) }
}
.pickerStyle(MenuPickerStyle())
} else {
Picker("Portrait Columns", selection: $mediaColumnsPortrait) {
ForEach(1..<5) { i in
Text("\(i)").tag(i)
}
ForEach(1..<5) { i in Text("\(i)").tag(i) }
}
.pickerStyle(MenuPickerStyle())
}
@ -100,16 +84,12 @@ struct SettingsViewGeneral: View {
HStack {
if UIDevice.current.userInterfaceIdiom == .pad {
Picker("Landscape Columns", selection: $mediaColumnsLandscape) {
ForEach(2..<9) { i in
Text("\(i)").tag(i)
}
ForEach(2..<9) { i in Text("\(i)").tag(i) }
}
.pickerStyle(MenuPickerStyle())
} else {
Picker("Landscape Columns", selection: $mediaColumnsLandscape) {
ForEach(2..<6) { i in
Text("\(i)").tag(i)
}
ForEach(2..<6) { i in Text("\(i)").tag(i) }
}
.pickerStyle(MenuPickerStyle())
}
@ -121,7 +101,7 @@ struct SettingsViewGeneral: View {
.tint(.accentColor)
}
Section(header: Text("Analytics"), footer: Text("Allow Sora to collect anonymous data to improve the app. No personal information is collected. This can be disabled at any time.\n\n Information collected: \n- App version\n- Device model\n- Module Name/Version\n- Error Messages\n- Title of Watched Content")) {
Section(header: Text("Advanced"), footer: Text("Anonymous data is collected to improve the app. No personal information is collected. This can be disabled at any time.")) {
Toggle("Enable Analytics", isOn: $analyticsEnabled)
.tint(.accentColor)
}

View file

@ -58,15 +58,13 @@ struct SettingsViewPlayer: View {
}
}
}
Section(header: Text("Skip Settings")) {
// Normal skip
Section(header: Text("Skip Settings"), footer : Text("Double tapping the screen on it's sides will skip with the short tap setting.")) {
HStack {
Text("Tap Skip:")
Spacer()
Stepper("\(Int(skipIncrement))s", value: $skipIncrement, in: 5...300, step: 5)
}
// Long-press skip
HStack {
Text("Long press Skip:")
Spacer()

View file

@ -0,0 +1,203 @@
//
// SettingsViewTrackers.swift
// Sora
//
// Created by Francesco on 23/03/25.
//
import SwiftUI
import Security
import Kingfisher
struct SettingsViewTrackers: View {
@State private var status: String = "You are not logged in"
@State private var isLoggedIn: Bool = false
@State private var username: String = ""
@State private var isLoading: Bool = false
@State private var profileColor: Color = .accentColor
var body: some View {
Form {
Section(header: Text("AniList"), footer: Text("Sora and cranci1 are not affiliated with AniList in any way.")) {
HStack() {
KFImage(URL(string: "https://raw.githubusercontent.com/cranci1/Ryu/2f10226aa087154974a70c1ec78aa83a47daced9/Ryu/Assets.xcassets/Listing/Anilist.imageset/anilist.png"))
.placeholder {
RoundedRectangle(cornerRadius: 10)
.fill(Color.gray.opacity(0.3))
.frame(width: 80, height: 80)
.shimmering()
}
.resizable()
.frame(width: 80, height: 80)
.clipShape(Rectangle())
.cornerRadius(10)
Text("AniList.co")
.font(.title2)
}
if isLoading {
ProgressView()
} else {
if isLoggedIn {
HStack(spacing: 0) {
Text("Logged in as ")
Text(username)
.foregroundColor(profileColor)
.font(.body)
.fontWeight(.semibold)
}
} else {
Text(status)
.multilineTextAlignment(.center)
}
}
Button(isLoggedIn ? "Log Out from AniList.co" : "Log In with AniList.co") {
if isLoggedIn {
logout()
} else {
login()
}
}
.font(.body)
}
}
.navigationTitle("Trackers")
.onAppear {
updateStatus()
}
}
func login() {
status = "Starting authentication..."
AniListLogin.authenticate()
}
func logout() {
removeTokenFromKeychain()
status = "You are not logged in"
isLoggedIn = false
username = ""
profileColor = .primary
}
func updateStatus() {
if let token = getTokenFromKeychain() {
isLoggedIn = true
fetchUserInfo(token: token)
} else {
isLoggedIn = false
status = "You are not logged in"
}
}
func fetchUserInfo(token: String) {
isLoading = true
let userInfoURL = URL(string: "https://graphql.anilist.co")!
var request = URLRequest(url: userInfoURL)
request.httpMethod = "POST"
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let query = """
{
Viewer {
id
name
options {
profileColor
}
}
}
"""
let body: [String: Any] = ["query": query]
do {
request.httpBody = try JSONSerialization.data(withJSONObject: body, options: [])
} catch {
status = "Failed to serialize request"
Logger.shared.log("Failed to serialize request", type: "Error")
isLoading = false
return
}
URLSession.shared.dataTask(with: request) { data, response, error in
DispatchQueue.main.async {
isLoading = false
if let error = error {
status = "Error: \(error.localizedDescription)"
Logger.shared.log("Error: \(error.localizedDescription)", type: "Error")
return
}
guard let data = data else {
status = "No data received"
Logger.shared.log("No data received", type: "Error")
return
}
do {
if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
let dataDict = json["data"] as? [String: Any],
let viewer = dataDict["Viewer"] as? [String: Any],
let name = viewer["name"] as? String,
let options = viewer["options"] as? [String: Any],
let colorName = options["profileColor"] as? String {
username = name
profileColor = colorFromName(colorName)
status = "Logged in as \(name)"
} else {
status = "Unexpected response format!"
Logger.shared.log("Unexpected response format!", type: "Error")
}
} catch {
status = "Failed to parse response: \(error.localizedDescription)"
Logger.shared.log("Failed to parse response: \(error.localizedDescription)", type: "Error")
}
}
}.resume()
}
func colorFromName(_ name: String) -> Color {
switch name.lowercased() {
case "blue":
return .blue
case "purple":
return .purple
case "green":
return .green
case "orange":
return .orange
case "red":
return .red
case "pink":
return .pink
case "gray":
return .gray
default:
return .accentColor
}
}
func getTokenFromKeychain() -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "me.cranci.sora.AniListToken",
kSecAttrAccount as String: "AniListAccessToken",
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 removeTokenFromKeychain() {
let deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "me.cranci.sora.AniListToken",
kSecAttrAccount as String: "AniListAccessToken"
]
SecItemDelete(deleteQuery as CFDictionary)
}
}

View file

@ -11,9 +11,9 @@ struct SettingsView: View {
var body: some View {
NavigationView {
Form {
Section(header: Text("Main Settings")) {
Section(header: Text("Main")) {
NavigationLink(destination: SettingsViewGeneral()) {
Text("General Settings")
Text("General Preferences")
}
NavigationLink(destination: SettingsViewPlayer()) {
Text("Media Player")
@ -21,6 +21,9 @@ struct SettingsView: View {
NavigationLink(destination: SettingsViewModule()) {
Text("Modules")
}
//NavigationLink(destination: SettingsViewTrackers()) {
// Text("Trackers")
//}
}
Section(header: Text("Info")) {

View file

@ -9,14 +9,15 @@
/* 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 */; };
13103E842D589D8B000F0673 /* AniList-Seasonal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13103E832D589D8B000F0673 /* AniList-Seasonal.swift */; };
13103E862D58A328000F0673 /* AniList-Trending.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13103E852D58A328000F0673 /* AniList-Trending.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 */; };
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 */; };
@ -33,13 +34,9 @@
133D7C922D2BE2640075467E /* URLSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133D7C872D2BE2640075467E /* URLSession.swift */; };
133D7C932D2BE2640075467E /* Modules.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133D7C892D2BE2640075467E /* Modules.swift */; };
133D7C942D2BE2640075467E /* JSController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133D7C8B2D2BE2640075467E /* JSController.swift */; };
133D7C972D2BE2AF0075467E /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 133D7C962D2BE2AF0075467E /* Kingfisher */; };
133F55BB2D33B55100E08EEA /* LibraryManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133F55BA2D33B55100E08EEA /* LibraryManager.swift */; };
1359ED142D76F49900C13034 /* finTopView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1359ED132D76F49900C13034 /* finTopView.swift */; };
1359ED1A2D76FA7D00C13034 /* Drops in Frameworks */ = {isa = PBXBuildFile; productRef = 1359ED192D76FA7D00C13034 /* Drops */; };
135CCBE22D4D1138008B9C0E /* SettingsViewPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 135CCBE12D4D1138008B9C0E /* SettingsViewPlayer.swift */; };
136F21B92D5B8DD8006409AC /* AniList-MediaInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 136F21B82D5B8DD8006409AC /* AniList-MediaInfo.swift */; };
136F21BC2D5B8F29006409AC /* AniList-DetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 136F21BB2D5B8F29006409AC /* AniList-DetailsView.swift */; };
138AA1B82D2D66FD0021F9DF /* EpisodeCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138AA1B62D2D66FD0021F9DF /* EpisodeCell.swift */; };
138AA1B92D2D66FD0021F9DF /* CircularProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138AA1B72D2D66FD0021F9DF /* CircularProgressBar.swift */; };
139935662D468C450065CEFF /* ModuleManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 139935652D468C450065CEFF /* ModuleManager.swift */; };
@ -52,8 +49,11 @@
13CBEFDA2D5F7D1200D011EE /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13CBEFD92D5F7D1200D011EE /* String.swift */; };
13D842552D45267500EBBFA6 /* DropManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13D842542D45267500EBBFA6 /* DropManager.swift */; };
13D99CF72D4E73C300250A86 /* ModuleAdditionSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13D99CF62D4E73C300250A86 /* ModuleAdditionSettingsView.swift */; };
13DB468D2D90093A008CBC03 /* Anilist-Login.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13DB468B2D900939008CBC03 /* Anilist-Login.swift */; };
13DB468E2D90093A008CBC03 /* Anilist-Token.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13DB468C2D90093A008CBC03 /* Anilist-Token.swift */; };
13DB46902D900A38008CBC03 /* URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13DB468F2D900A38008CBC03 /* URL.swift */; };
13DB46922D900BCE008CBC03 /* SettingsViewTrackers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13DB46912D900BCE008CBC03 /* SettingsViewTrackers.swift */; };
13DB7CC32D7D99C0004371D3 /* SubtitleSettingsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13DB7CC22D7D99C0004371D3 /* SubtitleSettingsManager.swift */; };
13DB7CC62D7DC7D2004371D3 /* FFmpeg-iOS-Lame in Frameworks */ = {isa = PBXBuildFile; productRef = 13DB7CC52D7DC7D2004371D3 /* FFmpeg-iOS-Lame */; };
13DB7CEC2D7DED5D004371D3 /* DownloadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13DB7CEB2D7DED5D004371D3 /* DownloadManager.swift */; };
13DC0C462D302C7500D0F966 /* VideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13DC0C452D302C7500D0F966 /* VideoPlayer.swift */; };
13EA2BD52D32D97400C1EBD7 /* CustomPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EA2BD12D32D97400C1EBD7 /* CustomPlayer.swift */; };
@ -61,14 +61,13 @@
13EA2BD92D32D98400C1EBD7 /* NormalPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EA2BD82D32D98400C1EBD7 /* NormalPlayer.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 */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
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>"; };
13103E832D589D8B000F0673 /* AniList-Seasonal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AniList-Seasonal.swift"; sourceTree = "<group>"; };
13103E852D58A328000F0673 /* AniList-Trending.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AniList-Trending.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>"; };
@ -95,8 +94,6 @@
133F55BA2D33B55100E08EEA /* LibraryManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryManager.swift; sourceTree = "<group>"; };
1359ED132D76F49900C13034 /* finTopView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = finTopView.swift; sourceTree = "<group>"; };
135CCBE12D4D1138008B9C0E /* SettingsViewPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewPlayer.swift; sourceTree = "<group>"; };
136F21B82D5B8DD8006409AC /* AniList-MediaInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AniList-MediaInfo.swift"; sourceTree = "<group>"; };
136F21BB2D5B8F29006409AC /* AniList-DetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AniList-DetailsView.swift"; sourceTree = "<group>"; };
138AA1B62D2D66FD0021F9DF /* EpisodeCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EpisodeCell.swift; sourceTree = "<group>"; };
138AA1B72D2D66FD0021F9DF /* CircularProgressBar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CircularProgressBar.swift; sourceTree = "<group>"; };
139935652D468C450065CEFF /* ModuleManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModuleManager.swift; sourceTree = "<group>"; };
@ -109,6 +106,10 @@
13CBEFD92D5F7D1200D011EE /* String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.swift; sourceTree = "<group>"; };
13D842542D45267500EBBFA6 /* DropManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DropManager.swift; sourceTree = "<group>"; };
13D99CF62D4E73C300250A86 /* ModuleAdditionSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModuleAdditionSettingsView.swift; sourceTree = "<group>"; };
13DB468B2D900939008CBC03 /* Anilist-Login.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Anilist-Login.swift"; sourceTree = "<group>"; };
13DB468C2D90093A008CBC03 /* Anilist-Token.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Anilist-Token.swift"; sourceTree = "<group>"; };
13DB468F2D900A38008CBC03 /* URL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URL.swift; sourceTree = "<group>"; };
13DB46912D900BCE008CBC03 /* SettingsViewTrackers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewTrackers.swift; sourceTree = "<group>"; };
13DB7CC22D7D99C0004371D3 /* SubtitleSettingsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubtitleSettingsManager.swift; sourceTree = "<group>"; };
13DB7CEB2D7DED5D004371D3 /* DownloadManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadManager.swift; sourceTree = "<group>"; };
13DC0C412D2EC9BA00D0F966 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
@ -118,6 +119,7 @@
13EA2BD82D32D98400C1EBD7 /* NormalPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NormalPlayer.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>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@ -125,9 +127,9 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
13DB7CC62D7DC7D2004371D3 /* FFmpeg-iOS-Lame in Frameworks */,
1359ED1A2D76FA7D00C13034 /* Drops in Frameworks */,
133D7C972D2BE2AF0075467E /* Kingfisher in Frameworks */,
132E35232D959E410007800E /* Kingfisher in Frameworks */,
132E35202D959E1D0007800E /* FFmpeg-iOS-Lame in Frameworks */,
132E351D2D959DDB0007800E /* Drops in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -146,23 +148,12 @@
13103E812D589D77000F0673 /* AniList */ = {
isa = PBXGroup;
children = (
136F21B72D5B8DAC006409AC /* MediaInfo */,
13DB468A2D900919008CBC03 /* Auth */,
13103E872D58A392000F0673 /* Struct */,
13103E822D589D7D000F0673 /* HomePage */,
);
path = AniList;
sourceTree = "<group>";
};
13103E822D589D7D000F0673 /* HomePage */ = {
isa = PBXGroup;
children = (
136F21BA2D5B8F17006409AC /* DetailsView */,
13103E832D589D8B000F0673 /* AniList-Seasonal.swift */,
13103E852D58A328000F0673 /* AniList-Trending.swift */,
);
path = HomePage;
sourceTree = "<group>";
};
13103E872D58A392000F0673 /* Struct */ = {
isa = PBXGroup;
children = (
@ -285,6 +276,7 @@
131845F82D47C62D00CA7A54 /* SettingsViewGeneral.swift */,
135CCBE12D4D1138008B9C0E /* SettingsViewPlayer.swift */,
130C6BF92D53AB1F00DC1432 /* SettingsViewData.swift */,
13DB46912D900BCE008CBC03 /* SettingsViewTrackers.swift */,
);
path = SettingsSubViews;
sourceTree = "<group>";
@ -309,11 +301,13 @@
133D7C862D2BE2640075467E /* Extensions */ = {
isa = PBXGroup;
children = (
73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */,
1327FBA82D758DEA00FC6689 /* UIDevice+Model.swift */,
133D7C872D2BE2640075467E /* URLSession.swift */,
1359ED132D76F49900C13034 /* finTopView.swift */,
13CBEFD92D5F7D1200D011EE /* String.swift */,
13103E8A2D58E028000F0673 /* View.swift */,
13DB468F2D900A38008CBC03 /* URL.swift */,
);
path = Extensions;
sourceTree = "<group>";
@ -345,22 +339,6 @@
path = LibraryView;
sourceTree = "<group>";
};
136F21B72D5B8DAC006409AC /* MediaInfo */ = {
isa = PBXGroup;
children = (
136F21B82D5B8DD8006409AC /* AniList-MediaInfo.swift */,
);
path = MediaInfo;
sourceTree = "<group>";
};
136F21BA2D5B8F17006409AC /* DetailsView */ = {
isa = PBXGroup;
children = (
136F21BB2D5B8F29006409AC /* AniList-DetailsView.swift */,
);
path = DetailsView;
sourceTree = "<group>";
};
1384DCDF2D89BE870094797A /* Helpers */ = {
isa = PBXGroup;
children = (
@ -413,6 +391,15 @@
path = Drops;
sourceTree = "<group>";
};
13DB468A2D900919008CBC03 /* Auth */ = {
isa = PBXGroup;
children = (
13DB468B2D900939008CBC03 /* Anilist-Login.swift */,
13DB468C2D90093A008CBC03 /* Anilist-Token.swift */,
);
path = Auth;
sourceTree = "<group>";
};
13DB7CEA2D7DED50004371D3 /* DownloadManager */ = {
isa = PBXGroup;
children = (
@ -467,9 +454,9 @@
);
name = Sulfur;
packageProductDependencies = (
133D7C962D2BE2AF0075467E /* Kingfisher */,
1359ED192D76FA7D00C13034 /* Drops */,
13DB7CC52D7DC7D2004371D3 /* FFmpeg-iOS-Lame */,
132E351C2D959DDB0007800E /* Drops */,
132E351F2D959E1D0007800E /* FFmpeg-iOS-Lame */,
132E35222D959E410007800E /* Kingfisher */,
);
productName = Sora;
productReference = 133D7C6A2D2BE2500075467E /* Sulfur.app */;
@ -499,9 +486,9 @@
);
mainGroup = 133D7C612D2BE2500075467E;
packageReferences = (
133D7C952D2BE2AF0075467E /* XCRemoteSwiftPackageReference "Kingfisher" */,
1359ED182D76FA7D00C13034 /* XCRemoteSwiftPackageReference "Drops" */,
13DB7CC42D7DC7D2004371D3 /* XCRemoteSwiftPackageReference "FFmpeg-iOS-Lame" */,
132E351B2D959DDB0007800E /* XCRemoteSwiftPackageReference "Drops" */,
132E351E2D959E1D0007800E /* XCRemoteSwiftPackageReference "FFmpeg-iOS-Lame" */,
132E35212D959E410007800E /* XCRemoteSwiftPackageReference "Kingfisher" */,
);
productRefGroup = 133D7C6B2D2BE2500075467E /* Products */;
projectDirPath = "";
@ -539,12 +526,12 @@
133D7C902D2BE2640075467E /* SettingsView.swift in Sources */,
1334FF4F2D786C9E007E289F /* TMDB-Trending.swift in Sources */,
13CBEFDA2D5F7D1200D011EE /* String.swift in Sources */,
13DB46902D900A38008CBC03 /* URL.swift in Sources */,
130C6BFA2D53AB1F00DC1432 /* SettingsViewData.swift in Sources */,
1334FF542D787217007E289F /* TMDBRequest.swift in Sources */,
1E9FF1D32D403E49008AC100 /* SettingsViewLoggerFilter.swift in Sources */,
13EA2BD92D32D98400C1EBD7 /* NormalPlayer.swift in Sources */,
133D7C932D2BE2640075467E /* Modules.swift in Sources */,
136F21B92D5B8DD8006409AC /* AniList-MediaInfo.swift in Sources */,
13DB7CC32D7D99C0004371D3 /* SubtitleSettingsManager.swift in Sources */,
133D7C702D2BE2500075467E /* ContentView.swift in Sources */,
13DB7CEC2D7DED5D004371D3 /* DownloadManager.swift in Sources */,
@ -552,7 +539,6 @@
13D99CF72D4E73C300250A86 /* ModuleAdditionSettingsView.swift in Sources */,
13C0E5EC2D5F85F800E7F619 /* ContinueWatchingItem.swift in Sources */,
13CBA0882D60F19C00EFE70A /* VTTSubtitlesLoader.swift in Sources */,
13103E862D58A328000F0673 /* AniList-Trending.swift in Sources */,
13EA2BD62D32D97400C1EBD7 /* Double+Extension.swift in Sources */,
133D7C8F2D2BE2640075467E /* MediaInfoView.swift in Sources */,
13103E892D58A39A000F0673 /* AniListItem.swift in Sources */,
@ -563,20 +549,22 @@
1327FBA92D758DEA00FC6689 /* UIDevice+Model.swift in Sources */,
138AA1B82D2D66FD0021F9DF /* EpisodeCell.swift in Sources */,
133D7C8C2D2BE2640075467E /* SearchView.swift in Sources */,
13DB468E2D90093A008CBC03 /* Anilist-Token.swift in Sources */,
1EAC7A322D888BC50083984D /* MusicProgressSlider.swift in Sources */,
133D7C942D2BE2640075467E /* JSController.swift in Sources */,
133D7C922D2BE2640075467E /* URLSession.swift in Sources */,
133D7C912D2BE2640075467E /* SettingsViewModule.swift in Sources */,
136F21BC2D5B8F29006409AC /* AniList-DetailsView.swift in Sources */,
130217CC2D81C55E0011EFF5 /* DownloadView.swift in Sources */,
133F55BB2D33B55100E08EEA /* LibraryManager.swift in Sources */,
13103E842D589D8B000F0673 /* AniList-Seasonal.swift in Sources */,
133D7C8E2D2BE2640075467E /* LibraryView.swift in Sources */,
133D7C6E2D2BE2500075467E /* SoraApp.swift in Sources */,
138AA1B92D2D66FD0021F9DF /* CircularProgressBar.swift in Sources */,
1334FF4D2D786C93007E289F /* TMDB-Seasonal.swift in Sources */,
13DB468D2D90093A008CBC03 /* Anilist-Login.swift in Sources */,
73D164D52D8B5B470011A360 /* JavaScriptCore+Extensions.swift in Sources */,
13EA2BD52D32D97400C1EBD7 /* CustomPlayer.swift in Sources */,
1399FAD42D3AB38C00E97C31 /* SettingsViewLogger.swift in Sources */,
13DB46922D900BCE008CBC03 /* SettingsViewTrackers.swift in Sources */,
13C0E5EA2D5F85EA00E7F619 /* ContinueWatchingManager.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -810,15 +798,7 @@
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
133D7C952D2BE2AF0075467E /* XCRemoteSwiftPackageReference "Kingfisher" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/onevcat/Kingfisher.git";
requirement = {
kind = exactVersion;
version = 7.9.1;
};
};
1359ED182D76FA7D00C13034 /* XCRemoteSwiftPackageReference "Drops" */ = {
132E351B2D959DDB0007800E /* XCRemoteSwiftPackageReference "Drops" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/omaralbeik/Drops.git";
requirement = {
@ -826,7 +806,7 @@
kind = branch;
};
};
13DB7CC42D7DC7D2004371D3 /* XCRemoteSwiftPackageReference "FFmpeg-iOS-Lame" */ = {
132E351E2D959E1D0007800E /* XCRemoteSwiftPackageReference "FFmpeg-iOS-Lame" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/kewlbear/FFmpeg-iOS-Lame";
requirement = {
@ -834,24 +814,32 @@
kind = branch;
};
};
132E35212D959E410007800E /* XCRemoteSwiftPackageReference "Kingfisher" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/onevcat/Kingfisher.git";
requirement = {
kind = exactVersion;
version = 7.9.1;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
133D7C962D2BE2AF0075467E /* Kingfisher */ = {
132E351C2D959DDB0007800E /* Drops */ = {
isa = XCSwiftPackageProductDependency;
package = 133D7C952D2BE2AF0075467E /* XCRemoteSwiftPackageReference "Kingfisher" */;
productName = Kingfisher;
};
1359ED192D76FA7D00C13034 /* Drops */ = {
isa = XCSwiftPackageProductDependency;
package = 1359ED182D76FA7D00C13034 /* XCRemoteSwiftPackageReference "Drops" */;
package = 132E351B2D959DDB0007800E /* XCRemoteSwiftPackageReference "Drops" */;
productName = Drops;
};
13DB7CC52D7DC7D2004371D3 /* FFmpeg-iOS-Lame */ = {
132E351F2D959E1D0007800E /* FFmpeg-iOS-Lame */ = {
isa = XCSwiftPackageProductDependency;
package = 13DB7CC42D7DC7D2004371D3 /* XCRemoteSwiftPackageReference "FFmpeg-iOS-Lame" */;
package = 132E351E2D959E1D0007800E /* XCRemoteSwiftPackageReference "FFmpeg-iOS-Lame" */;
productName = "FFmpeg-iOS-Lame";
};
132E35222D959E410007800E /* Kingfisher */ = {
isa = XCSwiftPackageProductDependency;
package = 132E35212D959E410007800E /* XCRemoteSwiftPackageReference "Kingfisher" */;
productName = Kingfisher;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 133D7C622D2BE2500075467E /* Project object */;