mirror of
https://github.com/cranci1/Sora.git
synced 2026-04-05 00:59:55 +00:00
v0.2.1 (#62)
This commit is contained in:
commit
2a08ae5f16
27 changed files with 1050 additions and 966 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
34
Sora/Tracking Services/AniList/Auth/Anilist-Login.swift
Normal file
34
Sora/Tracking Services/AniList/Auth/Anilist-Login.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
88
Sora/Tracking Services/AniList/Auth/Anilist-Token.swift
Normal file
88
Sora/Tracking Services/AniList/Auth/Anilist-Token.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
226
Sora/Utils/Extensions/JavaScriptCore+Extensions.swift
Normal file
226
Sora/Utils/Extensions/JavaScriptCore+Extensions.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
20
Sora/Utils/Extensions/URL.swift
Normal file
20
Sora/Utils/Extensions/URL.swift
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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")) {
|
||||
|
|
|
|||
|
|
@ -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 */;
|
||||
|
|
|
|||
Loading…
Reference in a new issue