mirror of
https://github.com/cranci1/Sora.git
synced 2026-04-19 23:52:09 +00:00
Merge branch 'cranci1:dev' into dev
This commit is contained in:
commit
1fca6b712b
28 changed files with 1778 additions and 944 deletions
|
|
@ -1,6 +1,6 @@
|
|||
# Sora
|
||||
> Also known as Sulfur, for copyright issues.
|
||||
|
||||
|
||||
<div align="center">
|
||||
|
||||
<img src="https://raw.githubusercontent.com/cranci1/Sora/refs/heads/main/assets/Sulfur.png" width="750px">
|
||||
|
|
@ -24,10 +24,11 @@ An iOS and macOS modular web scraping app, under the GPLv3.0 License.
|
|||
- [x] iOS/iPadOS 15.0+ support
|
||||
- [x] macOS support 12.0+
|
||||
- [x] JavaScript module support
|
||||
- [x] Local Library
|
||||
- [x] Tracking Services (AniList, Trakt)
|
||||
- [x] Apple KeyChain support for auth Tokens
|
||||
- [x] Streams support (Jellyfin/Plex like servers)
|
||||
- [x] External Media players (VLC, infuse, Outplayer, nPlayer)
|
||||
- [x] Tracking Services (AniList, Trakt)
|
||||
- [x] Background playback and Picture-in-Picture (PiP) support
|
||||
|
||||
## Frequently Asked Questions
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -2,10 +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>com.apple.developer.icloud-container-identifiers</key>
|
||||
<array/>
|
||||
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
||||
<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
|
|
|
|||
|
|
@ -12,11 +12,17 @@ struct SoraApp: App {
|
|||
@StateObject private var settings = Settings()
|
||||
@StateObject private var moduleManager = ModuleManager()
|
||||
@StateObject private var librarykManager = LibraryManager()
|
||||
|
||||
|
||||
init() {
|
||||
_ = iCloudSyncManager.shared
|
||||
TraktToken.checkAuthenticationStatus { isAuthenticated in
|
||||
if isAuthenticated {
|
||||
Logger.shared.log("Trakt authentication is valid")
|
||||
} else {
|
||||
Logger.shared.log("Trakt authentication required", type: "Error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
|
|
@ -26,8 +32,8 @@ struct SoraApp: App {
|
|||
.accentColor(settings.accentColor)
|
||||
.onAppear {
|
||||
settings.updateAppearance()
|
||||
if UserDefaults.standard.bool(forKey: "refreshModulesOnLaunch") {
|
||||
Task {
|
||||
Task {
|
||||
if UserDefaults.standard.bool(forKey: "refreshModulesOnLaunch") {
|
||||
await moduleManager.refreshModules()
|
||||
}
|
||||
}
|
||||
|
|
@ -41,33 +47,71 @@ struct SoraApp: App {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func handleURL(_ url: URL) {
|
||||
guard url.scheme == "sora",
|
||||
url.host == "module",
|
||||
let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
|
||||
let moduleURL = components.queryItems?.first(where: { $0.name == "url" })?.value else {
|
||||
return
|
||||
}
|
||||
|
||||
let addModuleView = ModuleAdditionSettingsView(moduleUrl: moduleURL).environmentObject(moduleManager)
|
||||
let hostingController = UIHostingController(rootView: addModuleView)
|
||||
|
||||
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let window = windowScene.windows.first {
|
||||
window.rootViewController?.present(hostingController, animated: true)
|
||||
} else {
|
||||
Logger.shared.log("Failed to present module addition view: No window scene found", type: "Error")
|
||||
guard url.scheme == "sora", let host = url.host else { return }
|
||||
switch host {
|
||||
case "default_page":
|
||||
if let comps = URLComponents(url: url, resolvingAgainstBaseURL: true),
|
||||
let libraryURL = comps.queryItems?.first(where: { $0.name == "url" })?.value {
|
||||
|
||||
UserDefaults.standard.set(libraryURL, forKey: "lastCommunityURL")
|
||||
UserDefaults.standard.set(true, forKey: "didReceiveDefaultPageLink")
|
||||
|
||||
let communityView = CommunityLibraryView()
|
||||
.environmentObject(moduleManager)
|
||||
let hostingController = UIHostingController(rootView: communityView)
|
||||
DispatchQueue.main.async {
|
||||
if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let window = scene.windows.first,
|
||||
let root = window.rootViewController {
|
||||
root.present(hostingController, animated: true) {
|
||||
DropManager.shared.showDrop(
|
||||
title: "Module Library Added",
|
||||
subtitle: "You can browse the community library in settings.",
|
||||
duration: 2,
|
||||
icon: UIImage(systemName: "books.vertical.circle.fill")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case "module":
|
||||
guard url.scheme == "sora",
|
||||
url.host == "module",
|
||||
let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
|
||||
let moduleURL = components.queryItems?.first(where: { $0.name == "url" })?.value
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
let addModuleView = ModuleAdditionSettingsView(moduleUrl: moduleURL)
|
||||
.environmentObject(moduleManager)
|
||||
let hostingController = UIHostingController(rootView: addModuleView)
|
||||
|
||||
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let window = windowScene.windows.first {
|
||||
window.rootViewController?.present(hostingController, animated: true)
|
||||
} else {
|
||||
Logger.shared.log(
|
||||
"Failed to present module addition view: No window scene found",
|
||||
type: "Error"
|
||||
)
|
||||
}
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
static func handleRedirect(url: URL) {
|
||||
guard let params = url.queryParameters,
|
||||
let code = params["code"] else {
|
||||
Logger.shared.log("Failed to extract authorization code")
|
||||
return
|
||||
}
|
||||
|
||||
Logger.shared.log("Failed to extract authorization code")
|
||||
return
|
||||
}
|
||||
|
||||
switch url.host {
|
||||
case "anilist":
|
||||
AniListToken.exchangeAuthorizationCodeForToken(code: code) { success in
|
||||
|
|
@ -77,6 +121,7 @@ struct SoraApp: App {
|
|||
Logger.shared.log("AniList token exchange failed", type: "Error")
|
||||
}
|
||||
}
|
||||
|
||||
case "trakt":
|
||||
TraktToken.exchangeAuthorizationCodeForToken(code: code) { success in
|
||||
if success {
|
||||
|
|
@ -85,6 +130,7 @@ struct SoraApp: App {
|
|||
Logger.shared.log("Trakt token exchange failed", type: "Error")
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
Logger.shared.log("Unknown authentication service", type: "Error")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,17 +45,19 @@ class AniListMutation {
|
|||
}
|
||||
|
||||
let query = """
|
||||
mutation ($mediaId: Int, $progress: Int) {
|
||||
SaveMediaListEntry (mediaId: $mediaId, progress: $progress) {
|
||||
mutation ($mediaId: Int, $progress: Int, $status: MediaListStatus) {
|
||||
SaveMediaListEntry (mediaId: $mediaId, progress: $progress, status: $status) {
|
||||
id
|
||||
progress
|
||||
status
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
let variables: [String: Any] = [
|
||||
"mediaId": animeId,
|
||||
"progress": episodeNumber
|
||||
"progress": episodeNumber,
|
||||
"status": "CURRENT"
|
||||
]
|
||||
|
||||
let requestBody: [String: Any] = [
|
||||
|
|
@ -101,4 +103,50 @@ class AniListMutation {
|
|||
|
||||
task.resume()
|
||||
}
|
||||
|
||||
func fetchMalID(animeId: Int, completion: @escaping (Result<Int, Error>) -> Void) {
|
||||
let query = """
|
||||
query ($id: Int) {
|
||||
Media(id: $id) {
|
||||
idMal
|
||||
}
|
||||
}
|
||||
"""
|
||||
let variables: [String: Any] = ["id": animeId]
|
||||
let requestBody: [String: Any] = [
|
||||
"query": query,
|
||||
"variables": variables
|
||||
]
|
||||
guard let jsonData = try? JSONSerialization.data(withJSONObject: requestBody, options: []) else {
|
||||
completion(.failure(NSError(domain: "", code: -1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Failed to serialize GraphQL request"])))
|
||||
return
|
||||
}
|
||||
|
||||
var request = URLRequest(url: apiURL)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = jsonData
|
||||
|
||||
URLSession.shared.dataTask(with: request) { data, resp, error in
|
||||
if let e = error {
|
||||
return completion(.failure(e))
|
||||
}
|
||||
guard let data = data,
|
||||
let json = try? JSONDecoder().decode(AniListMediaResponse.self, from: data),
|
||||
let mal = json.data.Media?.idMal else {
|
||||
return completion(.failure(NSError(domain: "", code: -1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Failed to decode AniList response or idMal missing"])))
|
||||
}
|
||||
completion(.success(mal))
|
||||
}.resume()
|
||||
}
|
||||
|
||||
private struct AniListMediaResponse: Decodable {
|
||||
struct DataField: Decodable {
|
||||
struct Media: Decodable { let idMal: Int? }
|
||||
let Media: Media?
|
||||
}
|
||||
let data: DataField
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -164,5 +164,83 @@ class TraktToken {
|
|||
|
||||
return token
|
||||
}
|
||||
|
||||
static func getAccessToken() -> String? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: serviceName,
|
||||
kSecAttrAccount as String: accessTokenKey,
|
||||
kSecReturnData as String: true
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
|
||||
guard status == errSecSuccess,
|
||||
let tokenData = result as? Data,
|
||||
let token = String(data: tokenData, encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
static func validateToken(completion: @escaping (Bool) -> Void) {
|
||||
guard let token = getAccessToken() else {
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
|
||||
guard let url = URL(string: "https://api.trakt.tv/users/settings") else {
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "GET"
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("2", forHTTPHeaderField: "trakt-api-version")
|
||||
request.setValue(clientID, forHTTPHeaderField: "trakt-api-key")
|
||||
|
||||
let task = URLSession.shared.dataTask(with: request) { _, response, _ in
|
||||
DispatchQueue.main.async {
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
let isValid = httpResponse.statusCode == 200
|
||||
completion(isValid)
|
||||
} else {
|
||||
completion(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
task.resume()
|
||||
}
|
||||
|
||||
static func validateAndRefreshTokenIfNeeded(completion: @escaping (Bool) -> Void) {
|
||||
if getAccessToken() == nil {
|
||||
if getRefreshToken() != nil {
|
||||
refreshAccessToken(completion: completion)
|
||||
} else {
|
||||
completion(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
validateToken { isValid in
|
||||
if isValid {
|
||||
completion(true)
|
||||
} else {
|
||||
if getRefreshToken() != nil {
|
||||
refreshAccessToken(completion: completion)
|
||||
} else {
|
||||
completion(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func checkAuthenticationStatus(completion: @escaping (Bool) -> Void) {
|
||||
validateAndRefreshTokenIfNeeded(completion: completion)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,211 +0,0 @@
|
|||
//
|
||||
// DownloadManager.swift
|
||||
// Sulfur
|
||||
//
|
||||
// Created by Francesco on 09/03/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import FFmpegSupport
|
||||
import UIKit
|
||||
|
||||
class DownloadManager {
|
||||
static let shared = DownloadManager()
|
||||
|
||||
private var backgroundTaskIdentifier: UIBackgroundTaskIdentifier = .invalid
|
||||
private var activeConversions = [String: Bool]()
|
||||
|
||||
private init() {
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(applicationWillResignActive), name: UIApplication.willResignActiveNotification, object: nil)
|
||||
}
|
||||
|
||||
@objc private func applicationWillResignActive() {
|
||||
if !activeConversions.isEmpty {
|
||||
backgroundTaskIdentifier = UIApplication.shared.beginBackgroundTask { [weak self] in
|
||||
self?.endBackgroundTask()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func endBackgroundTask() {
|
||||
if backgroundTaskIdentifier != .invalid {
|
||||
UIApplication.shared.endBackgroundTask(backgroundTaskIdentifier)
|
||||
backgroundTaskIdentifier = .invalid
|
||||
}
|
||||
}
|
||||
|
||||
func downloadAndConvertHLS(from url: URL, title: String, episode: Int, subtitleURL: URL? = nil, module: ScrapingModule, completion: @escaping (Bool, URL?) -> Void) {
|
||||
guard let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else {
|
||||
completion(false, nil)
|
||||
return
|
||||
}
|
||||
|
||||
let folderURL = documentsDirectory.appendingPathComponent(title + "-" + module.metadata.sourceName)
|
||||
if (!FileManager.default.fileExists(atPath: folderURL.path)) {
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true, attributes: nil)
|
||||
} catch {
|
||||
Logger.shared.log("Error creating folder: \(error)")
|
||||
completion(false, nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
let outputFileName = "\(title)_Episode\(episode)_\(module.metadata.sourceName).mp4"
|
||||
let outputFileURL = folderURL.appendingPathComponent(outputFileName)
|
||||
|
||||
let fileExtension = url.pathExtension.lowercased()
|
||||
|
||||
if fileExtension == "mp4" {
|
||||
NotificationCenter.default.post(name: .DownloadManagerStatusUpdate, object: nil, userInfo: [
|
||||
"title": title,
|
||||
"episode": episode,
|
||||
"type": "mp4",
|
||||
"status": "Downloading",
|
||||
"progress": 0.0
|
||||
])
|
||||
|
||||
let task = URLSession.custom.downloadTask(with: url) { tempLocalURL, response, error in
|
||||
if let tempLocalURL = tempLocalURL {
|
||||
do {
|
||||
try FileManager.default.moveItem(at: tempLocalURL, to: outputFileURL)
|
||||
NotificationCenter.default.post(name: .DownloadManagerStatusUpdate, object: nil, userInfo: [
|
||||
"title": title,
|
||||
"episode": episode,
|
||||
"type": "mp4",
|
||||
"status": "Completed",
|
||||
"progress": 1.0
|
||||
])
|
||||
DispatchQueue.main.async {
|
||||
Logger.shared.log("Download successful: \(outputFileURL)")
|
||||
completion(true, outputFileURL)
|
||||
}
|
||||
} catch {
|
||||
DispatchQueue.main.async {
|
||||
Logger.shared.log("Download failed: \(error)")
|
||||
completion(false, nil)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
DispatchQueue.main.async {
|
||||
Logger.shared.log("Download failed: \(error?.localizedDescription ?? "Unknown error")")
|
||||
completion(false, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
task.resume()
|
||||
} else if fileExtension == "m3u8" {
|
||||
let conversionKey = "\(title)_\(episode)_\(module.metadata.sourceName)"
|
||||
activeConversions[conversionKey] = true
|
||||
|
||||
if UIApplication.shared.applicationState != .active && backgroundTaskIdentifier == .invalid {
|
||||
backgroundTaskIdentifier = UIApplication.shared.beginBackgroundTask { [weak self] in
|
||||
self?.endBackgroundTask()
|
||||
}
|
||||
}
|
||||
|
||||
DispatchQueue.global(qos: .background).async {
|
||||
NotificationCenter.default.post(name: .DownloadManagerStatusUpdate, object: nil, userInfo: [
|
||||
"title": title,
|
||||
"episode": episode,
|
||||
"type": "hls",
|
||||
"status": "Converting",
|
||||
"progress": 0.0
|
||||
])
|
||||
|
||||
let processorCount = ProcessInfo.processInfo.processorCount
|
||||
let physicalMemory = ProcessInfo.processInfo.physicalMemory / (1024 * 1024)
|
||||
|
||||
var ffmpegCommand = ["ffmpeg", "-y"]
|
||||
|
||||
ffmpegCommand.append(contentsOf: ["-protocol_whitelist", "file,http,https,tcp,tls"])
|
||||
|
||||
ffmpegCommand.append(contentsOf: ["-fflags", "+genpts"])
|
||||
ffmpegCommand.append(contentsOf: ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5"])
|
||||
ffmpegCommand.append(contentsOf: ["-headers", "Referer: \(module.metadata.baseUrl)\nOrigin: \(module.metadata.baseUrl)"])
|
||||
|
||||
let multiThreads = UserDefaults.standard.bool(forKey: "multiThreads")
|
||||
if multiThreads {
|
||||
let threadCount = max(2, processorCount - 1)
|
||||
ffmpegCommand.append(contentsOf: ["-threads", "\(threadCount)"])
|
||||
} else {
|
||||
ffmpegCommand.append(contentsOf: ["-threads", "2"])
|
||||
}
|
||||
|
||||
let bufferSize = min(32, max(8, Int(physicalMemory) / 256))
|
||||
ffmpegCommand.append(contentsOf: ["-bufsize", "\(bufferSize)M"])
|
||||
ffmpegCommand.append(contentsOf: ["-i", url.absoluteString])
|
||||
|
||||
if let subtitleURL = subtitleURL {
|
||||
do {
|
||||
let subtitleData = try Data(contentsOf: subtitleURL)
|
||||
let subtitleFileExtension = subtitleURL.pathExtension.lowercased()
|
||||
if subtitleFileExtension != "srt" && subtitleFileExtension != "vtt" {
|
||||
Logger.shared.log("Unsupported subtitle format: \(subtitleFileExtension)")
|
||||
}
|
||||
let subtitleFileName = "\(title)_Episode\(episode).\(subtitleFileExtension)"
|
||||
let subtitleLocalURL = folderURL.appendingPathComponent(subtitleFileName)
|
||||
try subtitleData.write(to: subtitleLocalURL)
|
||||
ffmpegCommand.append(contentsOf: ["-i", subtitleLocalURL.path])
|
||||
|
||||
ffmpegCommand.append(contentsOf: [
|
||||
"-c:v", "copy",
|
||||
"-c:a", "copy",
|
||||
"-c:s", "mov_text",
|
||||
"-disposition:s:0", "default+forced",
|
||||
"-metadata:s:s:0", "handler_name=English",
|
||||
"-metadata:s:s:0", "language=eng"
|
||||
])
|
||||
|
||||
ffmpegCommand.append(outputFileURL.path)
|
||||
} catch {
|
||||
Logger.shared.log("Subtitle download failed: \(error)")
|
||||
ffmpegCommand.append(contentsOf: ["-c:v", "copy", "-c:a", "copy"])
|
||||
ffmpegCommand.append(contentsOf: ["-movflags", "+faststart"])
|
||||
ffmpegCommand.append(outputFileURL.path)
|
||||
}
|
||||
} else {
|
||||
ffmpegCommand.append(contentsOf: ["-c:v", "copy", "-c:a", "copy"])
|
||||
ffmpegCommand.append(contentsOf: ["-movflags", "+faststart"])
|
||||
ffmpegCommand.append(outputFileURL.path)
|
||||
}
|
||||
Logger.shared.log("FFmpeg command: \(ffmpegCommand.joined(separator: " "))", type: "Debug")
|
||||
|
||||
NotificationCenter.default.post(name: .DownloadManagerStatusUpdate, object: nil, userInfo: [
|
||||
"title": title,
|
||||
"episode": episode,
|
||||
"type": "hls",
|
||||
"status": "Converting",
|
||||
"progress": 0.5
|
||||
])
|
||||
|
||||
let success = ffmpeg(ffmpegCommand)
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
if success == 0 {
|
||||
NotificationCenter.default.post(name: .DownloadManagerStatusUpdate, object: nil, userInfo: [
|
||||
"title": title,
|
||||
"episode": episode,
|
||||
"type": "hls",
|
||||
"status": "Completed",
|
||||
"progress": 1.0
|
||||
])
|
||||
Logger.shared.log("Conversion successful: \(outputFileURL)")
|
||||
completion(true, outputFileURL)
|
||||
} else {
|
||||
Logger.shared.log("Conversion failed")
|
||||
completion(false, nil)
|
||||
}
|
||||
|
||||
self?.activeConversions[conversionKey] = nil
|
||||
|
||||
if self?.activeConversions.isEmpty ?? true {
|
||||
self?.endBackgroundTask()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Logger.shared.log("Unsupported file type: \(fileExtension)")
|
||||
completion(false, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,8 @@ import Foundation
|
|||
|
||||
extension Notification.Name {
|
||||
static let iCloudSyncDidComplete = Notification.Name("iCloudSyncDidComplete")
|
||||
static let iCloudSyncDidFail = Notification.Name("iCloudSyncDidFail")
|
||||
static let ContinueWatchingDidUpdate = Notification.Name("ContinueWatchingDidUpdate")
|
||||
static let DownloadManagerStatusUpdate = Notification.Name("DownloadManagerStatusUpdate")
|
||||
static let modulesSyncDidComplete = Notification.Name("modulesSyncDidComplete")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,3 +44,30 @@ class VolumeViewModel: ObservableObject {
|
|||
@Published var value: Double = 0.0
|
||||
}
|
||||
|
||||
class SliderViewModel: ObservableObject {
|
||||
@Published var sliderValue: Double = 0.0
|
||||
@Published var introSegments: [ClosedRange<Double>] = []
|
||||
@Published var outroSegments: [ClosedRange<Double>] = []
|
||||
}
|
||||
|
||||
struct AniListMediaResponse: Decodable {
|
||||
struct DataField: Decodable {
|
||||
struct Media: Decodable { let idMal: Int? }
|
||||
let Media: Media?
|
||||
}
|
||||
let data: DataField
|
||||
}
|
||||
|
||||
struct AniSkipResponse: Decodable {
|
||||
struct Result: Decodable {
|
||||
struct Interval: Decodable {
|
||||
let startTime: Double
|
||||
let endTime: Double
|
||||
}
|
||||
let interval: Interval
|
||||
let skipType: String
|
||||
}
|
||||
let found: Bool
|
||||
let results: [Result]
|
||||
let statusCode: Int
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,10 @@ struct MusicProgressSlider<T: BinaryFloatingPoint>: View {
|
|||
let emptyColor: Color
|
||||
let height: CGFloat
|
||||
let onEditingChanged: (Bool) -> Void
|
||||
let introSegments: [ClosedRange<T>]
|
||||
let outroSegments: [ClosedRange<T>]
|
||||
let introColor: Color
|
||||
let outroColor: Color
|
||||
|
||||
@State private var localRealProgress: T = 0
|
||||
@State private var localTempProgress: T = 0
|
||||
|
|
@ -26,10 +30,38 @@ struct MusicProgressSlider<T: BinaryFloatingPoint>: View {
|
|||
var body: some View {
|
||||
GeometryReader { bounds in
|
||||
ZStack {
|
||||
VStack (spacing: 8) {
|
||||
VStack(spacing: 8) {
|
||||
ZStack(alignment: .center) {
|
||||
Capsule()
|
||||
.fill(emptyColor)
|
||||
ZStack(alignment: .center) {
|
||||
// Intro Segments
|
||||
ForEach(introSegments, id: \.self) { segment in
|
||||
HStack(spacing: 0) {
|
||||
Spacer()
|
||||
.frame(width: bounds.size.width * CGFloat(segment.lowerBound))
|
||||
Rectangle()
|
||||
.fill(introColor.opacity(0.5))
|
||||
.frame(width: bounds.size.width * CGFloat(segment.upperBound - segment.lowerBound))
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
// Outro Segments
|
||||
ForEach(outroSegments, id: \.self) { segment in
|
||||
HStack(spacing: 0) {
|
||||
Spacer()
|
||||
.frame(width: bounds.size.width * CGFloat(segment.lowerBound))
|
||||
Rectangle()
|
||||
.fill(outroColor.opacity(0.5))
|
||||
.frame(width: bounds.size.width * CGFloat(segment.upperBound - segment.lowerBound))
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
Capsule()
|
||||
.fill(emptyColor)
|
||||
}
|
||||
.clipShape(Capsule())
|
||||
|
||||
Capsule()
|
||||
.fill(isActive ? activeFillColor : fillColor)
|
||||
.mask({
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -13,6 +13,7 @@ struct SubtitleSettings: Codable {
|
|||
var shadowRadius: Double = 1.0
|
||||
var backgroundEnabled: Bool = true
|
||||
var bottomPadding: CGFloat = 20.0
|
||||
var subtitleDelay: Double = 0.0
|
||||
}
|
||||
|
||||
class SubtitleSettingsManager {
|
||||
|
|
|
|||
104
Sora/Utils/Modules/CommunityLib.swift
Normal file
104
Sora/Utils/Modules/CommunityLib.swift
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
//
|
||||
// CommunityLib.swift
|
||||
// Sulfur
|
||||
//
|
||||
// Created by seiike on 23/04/2025.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import WebKit
|
||||
|
||||
private struct ModuleLink: Identifiable {
|
||||
let id = UUID()
|
||||
let url: String
|
||||
}
|
||||
|
||||
struct CommunityLibraryView: View {
|
||||
@EnvironmentObject var moduleManager: ModuleManager
|
||||
|
||||
@AppStorage("lastCommunityURL") private var inputURL: String = ""
|
||||
@State private var webURL: URL?
|
||||
@State private var errorMessage: String?
|
||||
@State private var moduleLinkToAdd: ModuleLink?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
if let err = errorMessage {
|
||||
Text(err)
|
||||
.foregroundColor(.red)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
WebView(url: webURL) { linkURL in
|
||||
|
||||
if let comps = URLComponents(url: linkURL, resolvingAgainstBaseURL: false),
|
||||
let m = comps.queryItems?.first(where: { $0.name == "url" })?.value {
|
||||
moduleLinkToAdd = ModuleLink(url: m)
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea(edges: .top)
|
||||
}
|
||||
.onAppear(perform: loadURL)
|
||||
.sheet(item: $moduleLinkToAdd) { link in
|
||||
ModuleAdditionSettingsView(moduleUrl: link.url)
|
||||
.environmentObject(moduleManager)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadURL() {
|
||||
var s = inputURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !s.hasPrefix("http://") && !s.hasPrefix("https://") {
|
||||
s = "https://" + s
|
||||
}
|
||||
inputURL = s
|
||||
if let u = URL(string: s) {
|
||||
webURL = u
|
||||
errorMessage = nil
|
||||
} else {
|
||||
webURL = nil
|
||||
errorMessage = "Invalid URL"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct WebView: UIViewRepresentable {
|
||||
let url: URL?
|
||||
let onCustomScheme: (URL) -> Void
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(onCustom: onCustomScheme)
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> WKWebView {
|
||||
let cfg = WKWebViewConfiguration()
|
||||
cfg.preferences.javaScriptEnabled = true
|
||||
let wv = WKWebView(frame: .zero, configuration: cfg)
|
||||
wv.navigationDelegate = context.coordinator
|
||||
return wv
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: WKWebView, context: Context) {
|
||||
if let u = url {
|
||||
uiView.load(URLRequest(url: u))
|
||||
}
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, WKNavigationDelegate {
|
||||
let onCustom: (URL) -> Void
|
||||
init(onCustom: @escaping (URL) -> Void) { self.onCustom = onCustom }
|
||||
|
||||
func webView(_ webView: WKWebView,
|
||||
decidePolicyFor action: WKNavigationAction,
|
||||
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void)
|
||||
{
|
||||
if let url = action.request.url,
|
||||
url.scheme == "sora", url.host == "module"
|
||||
{
|
||||
onCustom(url)
|
||||
decisionHandler(.cancel)
|
||||
} else {
|
||||
decisionHandler(.allow)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -150,6 +150,7 @@ struct ModuleAdditionSettingsView: View {
|
|||
await MainActor.run {
|
||||
self.errorMessage = "Invalid URL"
|
||||
self.isLoading = false
|
||||
Logger.shared.log("Failed to open add module ui with url: \(moduleUrl)", type: "Error")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,48 @@ class ModuleManager: ObservableObject {
|
|||
private let modulesFileName = "modules.json"
|
||||
|
||||
init() {
|
||||
let url = getModulesFilePath()
|
||||
if (!FileManager.default.fileExists(atPath: url.path)) {
|
||||
do {
|
||||
try "[]".write(to: url, atomically: true, encoding: .utf8)
|
||||
Logger.shared.log("Created empty modules file", type: "Info")
|
||||
} catch {
|
||||
Logger.shared.log("Failed to create modules file: \(error.localizedDescription)", type: "Error")
|
||||
}
|
||||
}
|
||||
loadModules()
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(handleModulesSyncCompleted), name: .modulesSyncDidComplete, object: nil)
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
@objc private func handleModulesSyncCompleted() {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
let url = self.getModulesFilePath()
|
||||
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||
Logger.shared.log("No modules file found after sync", type: "Error")
|
||||
self.modules = []
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let data = try Data(contentsOf: url)
|
||||
let decodedModules = try JSONDecoder().decode([ScrapingModule].self, from: data)
|
||||
self.modules = decodedModules
|
||||
|
||||
Task {
|
||||
await self.checkJSModuleFiles()
|
||||
}
|
||||
Logger.shared.log("Reloaded modules after iCloud sync")
|
||||
} catch {
|
||||
Logger.shared.log("Error handling modules sync: \(error.localizedDescription)", type: "Error")
|
||||
self.modules = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func getDocumentsDirectory() -> URL {
|
||||
|
|
@ -27,14 +68,82 @@ class ModuleManager: ObservableObject {
|
|||
|
||||
func loadModules() {
|
||||
let url = getModulesFilePath()
|
||||
guard let data = try? Data(contentsOf: url) else { return }
|
||||
modules = (try? JSONDecoder().decode([ScrapingModule].self, from: data)) ?? []
|
||||
|
||||
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||
Logger.shared.log("Modules file does not exist, creating empty one", type: "Info")
|
||||
do {
|
||||
try "[]".write(to: url, atomically: true, encoding: .utf8)
|
||||
modules = []
|
||||
} catch {
|
||||
Logger.shared.log("Failed to create modules file: \(error.localizedDescription)", type: "Error")
|
||||
modules = []
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let data = try Data(contentsOf: url)
|
||||
do {
|
||||
let decodedModules = try JSONDecoder().decode([ScrapingModule].self, from: data)
|
||||
modules = decodedModules
|
||||
|
||||
Task {
|
||||
await checkJSModuleFiles()
|
||||
}
|
||||
} catch {
|
||||
Logger.shared.log("Failed to decode modules: \(error.localizedDescription)", type: "Error")
|
||||
try "[]".write(to: url, atomically: true, encoding: .utf8)
|
||||
modules = []
|
||||
}
|
||||
} catch {
|
||||
Logger.shared.log("Failed to load modules file: \(error.localizedDescription)", type: "Error")
|
||||
modules = []
|
||||
}
|
||||
}
|
||||
|
||||
func checkJSModuleFiles() async {
|
||||
Logger.shared.log("Checking JS module files...", type: "Info")
|
||||
var missingCount = 0
|
||||
|
||||
for module in modules {
|
||||
let localUrl = getDocumentsDirectory().appendingPathComponent(module.localPath)
|
||||
if !fileManager.fileExists(atPath: localUrl.path) {
|
||||
missingCount += 1
|
||||
do {
|
||||
guard let scriptUrl = URL(string: module.metadata.scriptUrl) else {
|
||||
Logger.shared.log("Invalid script URL for module: \(module.metadata.sourceName)", type: "Error")
|
||||
continue
|
||||
}
|
||||
|
||||
Logger.shared.log("Downloading missing JS file for: \(module.metadata.sourceName)", type: "Info")
|
||||
|
||||
let (scriptData, _) = try await URLSession.custom.data(from: scriptUrl)
|
||||
guard let jsContent = String(data: scriptData, encoding: .utf8) else {
|
||||
Logger.shared.log("Invalid script encoding for module: \(module.metadata.sourceName)", type: "Error")
|
||||
continue
|
||||
}
|
||||
|
||||
try jsContent.write(to: localUrl, atomically: true, encoding: .utf8)
|
||||
Logger.shared.log("Successfully downloaded JS file for module: \(module.metadata.sourceName)")
|
||||
} catch {
|
||||
Logger.shared.log("Failed to download JS file for module: \(module.metadata.sourceName) - \(error.localizedDescription)", type: "Error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if missingCount > 0 {
|
||||
Logger.shared.log("Downloaded \(missingCount) missing module JS files", type: "Info")
|
||||
} else {
|
||||
Logger.shared.log("All module JS files are present", type: "Info")
|
||||
}
|
||||
}
|
||||
|
||||
private func saveModules() {
|
||||
let url = getModulesFilePath()
|
||||
guard let data = try? JSONEncoder().encode(modules) else { return }
|
||||
try? data.write(to: url)
|
||||
DispatchQueue.main.async {
|
||||
let url = self.getModulesFilePath()
|
||||
guard let data = try? JSONEncoder().encode(self.modules) else { return }
|
||||
try? data.write(to: url)
|
||||
}
|
||||
}
|
||||
|
||||
func addModule(metadataUrl: String) async throws -> ScrapingModule {
|
||||
|
|
|
|||
|
|
@ -1,94 +0,0 @@
|
|||
//
|
||||
// iCloudSyncManager.swift
|
||||
// Sulfur
|
||||
//
|
||||
// Created by Francesco on 17/04/25.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class iCloudSyncManager {
|
||||
static let shared = iCloudSyncManager()
|
||||
|
||||
private let defaultsToSync: [String] = [
|
||||
"externalPlayer",
|
||||
"alwaysLandscape",
|
||||
"rememberPlaySpeed",
|
||||
"holdSpeedPlayer",
|
||||
"skipIncrement",
|
||||
"skipIncrementHold",
|
||||
"holdForPauseEnabled",
|
||||
"skip85Visible",
|
||||
"doubleTapSeekEnabled",
|
||||
"selectedModuleId",
|
||||
"mediaColumnsPortrait",
|
||||
"mediaColumnsLandscape",
|
||||
"sendPushUpdates",
|
||||
"sendTraktUpdates",
|
||||
"bookmarkedItems",
|
||||
"continueWatchingItems"
|
||||
]
|
||||
|
||||
private init() {
|
||||
setupSync()
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(willEnterBackground), name: UIApplication.willResignActiveNotification, object: nil)
|
||||
}
|
||||
|
||||
private func setupSync() {
|
||||
NSUbiquitousKeyValueStore.default.synchronize()
|
||||
|
||||
syncFromiCloud()
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(iCloudDidChangeExternally), name: NSUbiquitousKeyValueStore.didChangeExternallyNotification, object: NSUbiquitousKeyValueStore.default)
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(userDefaultsDidChange), name: UserDefaults.didChangeNotification, object: nil)
|
||||
}
|
||||
|
||||
@objc private func willEnterBackground() {
|
||||
syncToiCloud()
|
||||
}
|
||||
|
||||
private func syncFromiCloud() {
|
||||
let iCloud = NSUbiquitousKeyValueStore.default
|
||||
let defaults = UserDefaults.standard
|
||||
|
||||
for key in defaultsToSync {
|
||||
if let value = iCloud.object(forKey: key) {
|
||||
defaults.set(value, forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
defaults.synchronize()
|
||||
NotificationCenter.default.post(name: .iCloudSyncDidComplete, object: nil)
|
||||
}
|
||||
|
||||
private func syncToiCloud() {
|
||||
let iCloud = NSUbiquitousKeyValueStore.default
|
||||
let defaults = UserDefaults.standard
|
||||
|
||||
for key in defaultsToSync {
|
||||
if let value = defaults.object(forKey: key) {
|
||||
iCloud.set(value, forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
iCloud.synchronize()
|
||||
}
|
||||
|
||||
@objc private func iCloudDidChangeExternally(_ notification: Notification) {
|
||||
guard let userInfo = notification.userInfo,
|
||||
let reason = userInfo[NSUbiquitousKeyValueStoreChangeReasonKey] as? Int else {
|
||||
return
|
||||
}
|
||||
|
||||
if reason == NSUbiquitousKeyValueStoreServerChange ||
|
||||
reason == NSUbiquitousKeyValueStoreInitialSyncChange {
|
||||
syncFromiCloud()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func userDefaultsDidChange(_ notification: Notification) {
|
||||
syncToiCloud()
|
||||
}
|
||||
}
|
||||
|
|
@ -69,7 +69,7 @@ class LibraryManager: ObservableObject {
|
|||
let encoded = try JSONEncoder().encode(bookmarks)
|
||||
UserDefaults.standard.set(encoded, forKey: bookmarksKey)
|
||||
} catch {
|
||||
Logger.shared.log("Failed to encode bookmarks: \(error.localizedDescription)", type: "Error")
|
||||
Logger.shared.log("Failed to save bookmarks: \(error)", type: "Error")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ struct LibraryView: View {
|
|||
|
||||
@Environment(\.verticalSizeClass) var verticalSizeClass
|
||||
|
||||
@State private var selectedBookmark: LibraryItem? = nil
|
||||
@State private var isDetailActive: Bool = false
|
||||
|
||||
@State private var continueWatchingItems: [ContinueWatchingItem] = []
|
||||
@State private var isLandscape: Bool = UIDevice.current.orientation.isLandscape
|
||||
|
||||
|
|
@ -98,7 +101,10 @@ struct LibraryView: View {
|
|||
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: columnsCount), spacing: 12) {
|
||||
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)) {
|
||||
Button(action: {
|
||||
selectedBookmark = item
|
||||
isDetailActive = true
|
||||
}) {
|
||||
VStack(alignment: .leading) {
|
||||
ZStack {
|
||||
KFImage(URL(string: item.imageUrl))
|
||||
|
|
@ -141,6 +147,22 @@ struct LibraryView: View {
|
|||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
NavigationLink(
|
||||
destination: Group {
|
||||
if let bookmark = selectedBookmark,
|
||||
let module = moduleManager.modules.first(where: { $0.id.uuidString == bookmark.moduleId }) {
|
||||
MediaInfoView(title: bookmark.title,
|
||||
imageUrl: bookmark.imageUrl,
|
||||
href: bookmark.href,
|
||||
module: module)
|
||||
} else {
|
||||
Text("No Data Available")
|
||||
}
|
||||
},
|
||||
isActive: $isDetailActive
|
||||
) {
|
||||
EmptyView()
|
||||
}
|
||||
.onAppear {
|
||||
updateOrientation()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,17 @@ struct EpisodeCell: View {
|
|||
@State private var isLoading: Bool = true
|
||||
@State private var currentProgress: Double = 0.0
|
||||
|
||||
init(episodeIndex: Int, episode: String, episodeID: Int, progress: Double,
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@AppStorage("selectedAppearance") private var selectedAppearance: Appearance = .system
|
||||
|
||||
var defaultBannerImage: String {
|
||||
let isLightMode = selectedAppearance == .light || (selectedAppearance == .system && colorScheme == .light)
|
||||
return isLightMode
|
||||
? "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/dev/assets/banner1.png"
|
||||
: "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/dev/assets/banner2.png"
|
||||
}
|
||||
|
||||
init(episodeIndex: Int, episode: String, episodeID: Int, progress: Double,
|
||||
itemID: Int, onTap: @escaping (String) -> Void, onMarkAllPrevious: @escaping () -> Void) {
|
||||
self.episodeIndex = episodeIndex
|
||||
self.episode = episode
|
||||
|
|
@ -43,7 +53,7 @@ struct EpisodeCell: View {
|
|||
var body: some View {
|
||||
HStack {
|
||||
ZStack {
|
||||
KFImage(URL(string: episodeImageUrl.isEmpty ? "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/main/assets/banner2.png" : episodeImageUrl))
|
||||
KFImage(URL(string: episodeImageUrl.isEmpty ? defaultBannerImage : episodeImageUrl))
|
||||
.resizable()
|
||||
.aspectRatio(16/9, contentMode: .fill)
|
||||
.frame(width: 100, height: 56)
|
||||
|
|
@ -92,17 +102,13 @@ struct EpisodeCell: View {
|
|||
}
|
||||
.onAppear {
|
||||
updateProgress()
|
||||
|
||||
if UserDefaults.standard.object(forKey: "fetchEpisodeMetadata") == nil
|
||||
|| UserDefaults.standard.bool(forKey: "fetchEpisodeMetadata") {
|
||||
fetchEpisodeDetails()
|
||||
}
|
||||
fetchEpisodeDetails()
|
||||
}
|
||||
.onChange(of: progress) { newProgress in
|
||||
.onChange(of: progress) { _ in
|
||||
updateProgress()
|
||||
}
|
||||
.onTapGesture {
|
||||
let imageUrl = episodeImageUrl.isEmpty ? "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/main/assets/banner2.png" : episodeImageUrl
|
||||
let imageUrl = episodeImageUrl.isEmpty ? defaultBannerImage : episodeImageUrl
|
||||
onTap(imageUrl)
|
||||
}
|
||||
}
|
||||
|
|
@ -147,16 +153,12 @@ struct EpisodeCell: View {
|
|||
URLSession.custom.dataTask(with: url) { data, _, error in
|
||||
if let error = error {
|
||||
Logger.shared.log("Failed to fetch anime episode details: \(error)", type: "Error")
|
||||
DispatchQueue.main.async {
|
||||
self.isLoading = false
|
||||
}
|
||||
DispatchQueue.main.async { self.isLoading = false }
|
||||
return
|
||||
}
|
||||
|
||||
guard let data = data else {
|
||||
DispatchQueue.main.async {
|
||||
self.isLoading = false
|
||||
}
|
||||
DispatchQueue.main.async { self.isLoading = false }
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -168,21 +170,20 @@ struct EpisodeCell: View {
|
|||
let title = episodeDetails["title"] as? [String: String],
|
||||
let image = episodeDetails["image"] as? String else {
|
||||
Logger.shared.log("Invalid anime response format", type: "Error")
|
||||
DispatchQueue.main.async {
|
||||
self.isLoading = false
|
||||
}
|
||||
DispatchQueue.main.async { self.isLoading = false }
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.episodeTitle = title["en"] ?? ""
|
||||
self.episodeImageUrl = image
|
||||
self.isLoading = false
|
||||
if UserDefaults.standard.object(forKey: "fetchEpisodeMetadata") == nil
|
||||
|| UserDefaults.standard.bool(forKey: "fetchEpisodeMetadata") {
|
||||
self.episodeTitle = title["en"] ?? ""
|
||||
self.episodeImageUrl = image
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
DispatchQueue.main.async {
|
||||
self.isLoading = false
|
||||
}
|
||||
DispatchQueue.main.async { self.isLoading = false }
|
||||
}
|
||||
}.resume()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,242 +52,300 @@ struct MediaInfoView: View {
|
|||
@State private var selectedRange: Range<Int> = 0..<100
|
||||
@State private var showSettingsMenu = false
|
||||
@State private var customAniListID: Int?
|
||||
@State private var showStreamLoadingView: Bool = false
|
||||
@State private var currentStreamTitle: String = ""
|
||||
|
||||
@State private var activeFetchID: UUID? = nil
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var orientationChanged: Bool = false
|
||||
|
||||
private var isGroupedBySeasons: Bool {
|
||||
return groupedEpisodes().count > 1
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
.padding()
|
||||
} else {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
KFImage(URL(string: imageUrl))
|
||||
.placeholder {
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(Color.gray.opacity(0.3))
|
||||
.frame(width: 150, height: 225)
|
||||
.shimmering()
|
||||
}
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 150, height: 225)
|
||||
.clipped()
|
||||
.cornerRadius(10)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.system(size: 17))
|
||||
.fontWeight(.bold)
|
||||
.onLongPressGesture {
|
||||
UIPasteboard.general.string = title
|
||||
DropManager.shared.showDrop(title: "Copied to Clipboard", subtitle: "", duration: 1.0, icon: UIImage(systemName: "doc.on.clipboard.fill"))
|
||||
ZStack {
|
||||
Group {
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
.padding()
|
||||
} else {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
KFImage(URL(string: imageUrl))
|
||||
.placeholder {
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(Color.gray.opacity(0.3))
|
||||
.frame(width: 150, height: 225)
|
||||
.shimmering()
|
||||
}
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 150, height: 225)
|
||||
.clipped()
|
||||
.cornerRadius(10)
|
||||
|
||||
if !aliases.isEmpty && aliases != title && aliases != "N/A" && aliases != "No Data" {
|
||||
Text(aliases)
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if !airdate.isEmpty && airdate != "N/A" && airdate != "No Data" {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "calendar")
|
||||
.resizable()
|
||||
.frame(width: 15, height: 15)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text(airdate)
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.secondary)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.system(size: 17))
|
||||
.fontWeight(.bold)
|
||||
.onLongPressGesture {
|
||||
UIPasteboard.general.string = title
|
||||
DropManager.shared.showDrop(title: "Copied to Clipboard", subtitle: "", duration: 1.0, icon: UIImage(systemName: "doc.on.clipboard.fill"))
|
||||
}
|
||||
.padding(4)
|
||||
|
||||
if !aliases.isEmpty && aliases != title && aliases != "N/A" && aliases != "No Data" {
|
||||
Text(aliases)
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
Button(action: {
|
||||
openSafariViewController(with: href)
|
||||
}) {
|
||||
HStack(spacing: 4) {
|
||||
Text(module.metadata.sourceName)
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
if !airdate.isEmpty && airdate != "N/A" && airdate != "No Data" {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "calendar")
|
||||
.resizable()
|
||||
.frame(width: 15, height: 15)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text(airdate)
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(4)
|
||||
}
|
||||
}
|
||||
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
Button(action: {
|
||||
openSafariViewController(with: href)
|
||||
}) {
|
||||
HStack(spacing: 4) {
|
||||
Text(module.metadata.sourceName)
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Image(systemName: "safari")
|
||||
.resizable()
|
||||
.frame(width: 20, height: 20)
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
.padding(4)
|
||||
.background(Capsule().fill(Color.accentColor.opacity(0.4)))
|
||||
}
|
||||
|
||||
Menu {
|
||||
Button(action: {
|
||||
showCustomIDAlert()
|
||||
}) {
|
||||
Label("Set Custom AniList ID", systemImage: "number")
|
||||
}
|
||||
|
||||
Image(systemName: "safari")
|
||||
if let customID = customAniListID {
|
||||
Button(action: {
|
||||
customAniListID = nil
|
||||
itemID = nil
|
||||
fetchItemID(byTitle: cleanTitle(title)) { result in
|
||||
switch result {
|
||||
case .success(let id):
|
||||
itemID = id
|
||||
case .failure(let error):
|
||||
Logger.shared.log("Failed to fetch AniList ID: \(error)")
|
||||
}
|
||||
}
|
||||
}) {
|
||||
Label("Reset AniList ID", systemImage: "arrow.clockwise")
|
||||
}
|
||||
}
|
||||
|
||||
if let id = itemID ?? customAniListID {
|
||||
Button(action: {
|
||||
if let url = URL(string: "https://anilist.co/anime/\(id)") {
|
||||
openSafariViewController(with: url.absoluteString)
|
||||
}
|
||||
}) {
|
||||
Label("Open in AniList", systemImage: "link")
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Button(action: {
|
||||
Logger.shared.log("Debug Info:\nTitle: \(title)\nHref: \(href)\nModule: \(module.metadata.sourceName)\nAniList ID: \(itemID ?? -1)\nCustom ID: \(customAniListID ?? -1)", type: "Debug")
|
||||
DropManager.shared.showDrop(title: "Debug Info Logged", subtitle: "", duration: 1.0, icon: UIImage(systemName: "terminal"))
|
||||
}) {
|
||||
Label("Log Debug Info", systemImage: "terminal")
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
.resizable()
|
||||
.frame(width: 20, height: 20)
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
.padding(4)
|
||||
.background(Capsule().fill(Color.accentColor.opacity(0.4)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !synopsis.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(alignment: .center) {
|
||||
Text("Synopsis")
|
||||
.font(.system(size: 18))
|
||||
.fontWeight(.bold)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: {
|
||||
showFullSynopsis.toggle()
|
||||
}) {
|
||||
Text(showFullSynopsis ? "Less" : "More")
|
||||
.font(.system(size: 14))
|
||||
}
|
||||
}
|
||||
|
||||
Menu {
|
||||
Button(action: {
|
||||
showCustomIDAlert()
|
||||
}) {
|
||||
Label("Set Custom AniList ID", systemImage: "number")
|
||||
}
|
||||
|
||||
if let customID = customAniListID {
|
||||
Button(action: {
|
||||
customAniListID = nil
|
||||
itemID = nil
|
||||
fetchItemID(byTitle: cleanTitle(title)) { result in
|
||||
switch result {
|
||||
case .success(let id):
|
||||
itemID = id
|
||||
case .failure(let error):
|
||||
Logger.shared.log("Failed to fetch AniList ID: \(error)")
|
||||
}
|
||||
}
|
||||
}) {
|
||||
Label("Reset AniList ID", systemImage: "arrow.clockwise")
|
||||
}
|
||||
}
|
||||
|
||||
if let id = itemID ?? customAniListID {
|
||||
Button(action: {
|
||||
if let url = URL(string: "https://anilist.co/anime/\(id)") {
|
||||
openSafariViewController(with: url.absoluteString)
|
||||
}
|
||||
}) {
|
||||
Label("Open in AniList", systemImage: "link")
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Button(action: {
|
||||
Logger.shared.log("Debug Info:\nTitle: \(title)\nHref: \(href)\nModule: \(module.metadata.sourceName)\nAniList ID: \(itemID ?? -1)\nCustom ID: \(customAniListID ?? -1)", type: "Debug")
|
||||
DropManager.shared.showDrop(title: "Debug Info Logged", subtitle: "", duration: 1.0, icon: UIImage(systemName: "terminal"))
|
||||
}) {
|
||||
Label("Log Debug Info", systemImage: "terminal")
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
.resizable()
|
||||
.frame(width: 20, height: 20)
|
||||
Text(synopsis)
|
||||
.lineLimit(showFullSynopsis ? nil : 4)
|
||||
.font(.system(size: 14))
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Button(action: {
|
||||
playFirstUnwatchedEpisode()
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "play.fill")
|
||||
.foregroundColor(.primary)
|
||||
Text(startWatchingText)
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color.accentColor)
|
||||
.cornerRadius(10)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !synopsis.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(alignment: .center) {
|
||||
Text("Synopsis")
|
||||
.font(.system(size: 18))
|
||||
.fontWeight(.bold)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: {
|
||||
showFullSynopsis.toggle()
|
||||
}) {
|
||||
Text(showFullSynopsis ? "Less" : "More")
|
||||
.font(.system(size: 14))
|
||||
}
|
||||
}
|
||||
.disabled(isFetchingEpisode)
|
||||
.id(buttonRefreshTrigger)
|
||||
|
||||
Text(synopsis)
|
||||
.lineLimit(showFullSynopsis ? nil : 4)
|
||||
.font(.system(size: 14))
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Button(action: {
|
||||
playFirstUnwatchedEpisode()
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "play.fill")
|
||||
.foregroundColor(.primary)
|
||||
Text(startWatchingText)
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
Button(action: {
|
||||
libraryManager.toggleBookmark(
|
||||
title: title,
|
||||
imageUrl: imageUrl,
|
||||
href: href,
|
||||
moduleId: module.id.uuidString,
|
||||
moduleName: module.metadata.sourceName
|
||||
)
|
||||
}) {
|
||||
Image(systemName: libraryManager.isBookmarked(href: href, moduleName: module.metadata.sourceName) ? "bookmark.fill" : "bookmark")
|
||||
.resizable()
|
||||
.frame(width: 20, height: 27)
|
||||
.foregroundColor(Color.accentColor)
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color.accentColor)
|
||||
.cornerRadius(10)
|
||||
}
|
||||
.disabled(isFetchingEpisode)
|
||||
.id(buttonRefreshTrigger)
|
||||
|
||||
Button(action: {
|
||||
libraryManager.toggleBookmark(
|
||||
title: title,
|
||||
imageUrl: imageUrl,
|
||||
href: href,
|
||||
moduleId: module.id.uuidString,
|
||||
moduleName: module.metadata.sourceName
|
||||
)
|
||||
}) {
|
||||
Image(systemName: libraryManager.isBookmarked(href: href, moduleName: module.metadata.sourceName) ? "bookmark.fill" : "bookmark")
|
||||
.resizable()
|
||||
.frame(width: 20, height: 27)
|
||||
.foregroundColor(Color.accentColor)
|
||||
}
|
||||
}
|
||||
|
||||
if !episodeLinks.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
Text("Episodes")
|
||||
.font(.system(size: 18))
|
||||
.fontWeight(.bold)
|
||||
|
||||
Spacer()
|
||||
|
||||
if !isGroupedBySeasons, episodeLinks.count > episodeChunkSize {
|
||||
Menu {
|
||||
ForEach(generateRanges(), id: \.self) { range in
|
||||
Button(action: { selectedRange = range }) {
|
||||
Text("\(range.lowerBound + 1)-\(range.upperBound)")
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Text("\(selectedRange.lowerBound + 1)-\(selectedRange.upperBound)")
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
} else if isGroupedBySeasons {
|
||||
let seasons = groupedEpisodes()
|
||||
if seasons.count > 1 {
|
||||
if !episodeLinks.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
Text("Episodes")
|
||||
.font(.system(size: 18))
|
||||
.fontWeight(.bold)
|
||||
|
||||
Spacer()
|
||||
|
||||
if !isGroupedBySeasons, episodeLinks.count > episodeChunkSize {
|
||||
Menu {
|
||||
ForEach(0..<seasons.count, id: \.self) { index in
|
||||
Button(action: { selectedSeason = index }) {
|
||||
Text("Season \(index + 1)")
|
||||
ForEach(generateRanges(), id: \.self) { range in
|
||||
Button(action: { selectedRange = range }) {
|
||||
Text("\(range.lowerBound + 1)-\(range.upperBound)")
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Text("Season \(selectedSeason + 1)")
|
||||
Text("\(selectedRange.lowerBound + 1)-\(selectedRange.upperBound)")
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
} else if isGroupedBySeasons {
|
||||
let seasons = groupedEpisodes()
|
||||
if seasons.count > 1 {
|
||||
Menu {
|
||||
ForEach(0..<seasons.count, id: \.self) { index in
|
||||
Button(action: { selectedSeason = index }) {
|
||||
Text("Season \(index + 1)")
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Text("Season \(selectedSeason + 1)")
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if isGroupedBySeasons {
|
||||
let seasons = groupedEpisodes()
|
||||
if !seasons.isEmpty, selectedSeason < seasons.count {
|
||||
ForEach(seasons[selectedSeason]) { ep in
|
||||
if isGroupedBySeasons {
|
||||
let seasons = groupedEpisodes()
|
||||
if !seasons.isEmpty, selectedSeason < seasons.count {
|
||||
ForEach(seasons[selectedSeason]) { ep in
|
||||
let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(ep.href)")
|
||||
let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(ep.href)")
|
||||
let progress = totalTime > 0 ? lastPlayedTime / totalTime : 0
|
||||
|
||||
EpisodeCell(
|
||||
episodeIndex: selectedSeason,
|
||||
episode: ep.href,
|
||||
episodeID: ep.number - 1,
|
||||
progress: progress,
|
||||
itemID: itemID ?? 0,
|
||||
onTap: { imageUrl in
|
||||
if !isFetchingEpisode {
|
||||
selectedEpisodeNumber = ep.number
|
||||
selectedEpisodeImage = imageUrl
|
||||
fetchStream(href: ep.href)
|
||||
AnalyticsManager.shared.sendEvent(
|
||||
event: "watch",
|
||||
additionalData: ["title": title, "episode": ep.number]
|
||||
)
|
||||
}
|
||||
},
|
||||
onMarkAllPrevious: {
|
||||
let userDefaults = UserDefaults.standard
|
||||
var updates = [String: Double]()
|
||||
|
||||
for ep2 in seasons[selectedSeason] where ep2.number < ep.number {
|
||||
let href = ep2.href
|
||||
updates["lastPlayedTime_\(href)"] = 99999999.0
|
||||
updates["totalTime_\(href)"] = 99999999.0
|
||||
}
|
||||
|
||||
for (key, value) in updates {
|
||||
userDefaults.set(value, forKey: key)
|
||||
}
|
||||
|
||||
userDefaults.synchronize()
|
||||
|
||||
refreshTrigger.toggle()
|
||||
Logger.shared.log("Marked episodes watched within season \(selectedSeason + 1) of \"\(title)\".", type: "General")
|
||||
}
|
||||
)
|
||||
.id(refreshTrigger)
|
||||
.disabled(isFetchingEpisode)
|
||||
}
|
||||
} else {
|
||||
Text("No episodes available")
|
||||
}
|
||||
} else {
|
||||
ForEach(episodeLinks.indices.filter { selectedRange.contains($0) }, id: \.self) { i in
|
||||
let ep = episodeLinks[i]
|
||||
let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(ep.href)")
|
||||
let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(ep.href)")
|
||||
let progress = totalTime > 0 ? lastPlayedTime / totalTime : 0
|
||||
|
||||
EpisodeCell(
|
||||
episodeIndex: selectedSeason,
|
||||
episodeIndex: i,
|
||||
episode: ep.href,
|
||||
episodeID: ep.number - 1,
|
||||
progress: progress,
|
||||
|
|
@ -307,142 +365,150 @@ struct MediaInfoView: View {
|
|||
let userDefaults = UserDefaults.standard
|
||||
var updates = [String: Double]()
|
||||
|
||||
for ep2 in seasons[selectedSeason] where ep2.number < ep.number {
|
||||
let href = ep2.href
|
||||
updates["lastPlayedTime_\(href)"] = 99999999.0
|
||||
updates["totalTime_\(href)"] = 99999999.0
|
||||
for idx in 0..<i {
|
||||
if idx < episodeLinks.count {
|
||||
let href = episodeLinks[idx].href
|
||||
updates["lastPlayedTime_\(href)"] = 1000.0
|
||||
updates["totalTime_\(href)"] = 1000.0
|
||||
}
|
||||
}
|
||||
|
||||
for (key, value) in updates {
|
||||
userDefaults.set(value, forKey: key)
|
||||
}
|
||||
|
||||
userDefaults.synchronize()
|
||||
|
||||
refreshTrigger.toggle()
|
||||
Logger.shared.log("Marked episodes watched within season \(selectedSeason + 1) of \"\(title)\".", type: "General")
|
||||
Logger.shared.log("Marked \(ep.number - 1) episodes watched within series \"\(title)\".", type: "General")
|
||||
}
|
||||
)
|
||||
.id(refreshTrigger)
|
||||
.disabled(isFetchingEpisode)
|
||||
}
|
||||
} else {
|
||||
Text("No episodes available")
|
||||
}
|
||||
} else {
|
||||
ForEach(episodeLinks.indices.filter { selectedRange.contains($0) }, id: \.self) { i in
|
||||
let ep = episodeLinks[i]
|
||||
let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(ep.href)")
|
||||
let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(ep.href)")
|
||||
let progress = totalTime > 0 ? lastPlayedTime / totalTime : 0
|
||||
|
||||
EpisodeCell(
|
||||
episodeIndex: i,
|
||||
episode: ep.href,
|
||||
episodeID: ep.number - 1,
|
||||
progress: progress,
|
||||
itemID: itemID ?? 0,
|
||||
onTap: { imageUrl in
|
||||
if !isFetchingEpisode {
|
||||
selectedEpisodeNumber = ep.number
|
||||
selectedEpisodeImage = imageUrl
|
||||
fetchStream(href: ep.href)
|
||||
AnalyticsManager.shared.sendEvent(
|
||||
event: "watch",
|
||||
additionalData: ["title": title, "episode": ep.number]
|
||||
)
|
||||
}
|
||||
},
|
||||
onMarkAllPrevious: {
|
||||
let userDefaults = UserDefaults.standard
|
||||
var updates = [String: Double]()
|
||||
|
||||
for idx in 0..<i {
|
||||
if idx < episodeLinks.count {
|
||||
let href = episodeLinks[idx].href
|
||||
updates["lastPlayedTime_\(href)"] = 1000.0
|
||||
updates["totalTime_\(href)"] = 1000.0
|
||||
}
|
||||
}
|
||||
|
||||
for (key, value) in updates {
|
||||
userDefaults.set(value, forKey: key)
|
||||
}
|
||||
|
||||
refreshTrigger.toggle()
|
||||
Logger.shared.log("Marked \(ep.number - 1) episodes watched within series \"\(title)\".", type: "General")
|
||||
}
|
||||
)
|
||||
.id(refreshTrigger)
|
||||
.disabled(isFetchingEpisode)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Episodes")
|
||||
.font(.system(size: 18))
|
||||
.fontWeight(.bold)
|
||||
}
|
||||
VStack(spacing: 8) {
|
||||
if isRefetching {
|
||||
ProgressView()
|
||||
.padding()
|
||||
} else {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.largeTitle)
|
||||
.foregroundColor(.secondary)
|
||||
HStack(spacing: 2) {
|
||||
Text("No episodes Found:")
|
||||
.foregroundColor(.secondary)
|
||||
Button(action: {
|
||||
isRefetching = true
|
||||
fetchDetails()
|
||||
}) {
|
||||
Text("Retry")
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Episodes")
|
||||
.font(.system(size: 18))
|
||||
.fontWeight(.bold)
|
||||
}
|
||||
VStack(spacing: 8) {
|
||||
if isRefetching {
|
||||
ProgressView()
|
||||
.padding()
|
||||
} else {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.largeTitle)
|
||||
.foregroundColor(.secondary)
|
||||
HStack(spacing: 2) {
|
||||
Text("No episodes Found:")
|
||||
.foregroundColor(.secondary)
|
||||
Button(action: {
|
||||
isRefetching = true
|
||||
fetchDetails()
|
||||
}) {
|
||||
Text("Retry")
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarTitle("")
|
||||
.navigationViewStyle(StackNavigationViewStyle())
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
buttonRefreshTrigger.toggle()
|
||||
|
||||
if !hasFetched {
|
||||
DropManager.shared.showDrop(title: "Fetching Data", subtitle: "Please wait while fetching.", duration: 0.5, icon: UIImage(systemName: "arrow.triangle.2.circlepath"))
|
||||
fetchDetails()
|
||||
|
||||
if let savedID = UserDefaults.standard.object(forKey: "custom_anilist_id_\(href)") as? Int {
|
||||
customAniListID = savedID
|
||||
itemID = savedID
|
||||
Logger.shared.log("Using custom AniList ID: \(savedID)", type: "Debug")
|
||||
} else {
|
||||
fetchItemID(byTitle: cleanTitle(title)) { result in
|
||||
switch result {
|
||||
case .success(let id):
|
||||
itemID = id
|
||||
case .failure(let error):
|
||||
Logger.shared.log("Failed to fetch AniList ID: \(error)")
|
||||
AnalyticsManager.shared.sendEvent(event: "error", additionalData: ["error": error, "message": "Failed to fetch AniList ID"])
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarTitle("")
|
||||
.navigationViewStyle(StackNavigationViewStyle())
|
||||
|
||||
hasFetched = true
|
||||
AnalyticsManager.shared.sendEvent(event: "search", additionalData: ["title": title])
|
||||
}
|
||||
selectedRange = 0..<episodeChunkSize
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in
|
||||
orientationChanged.toggle()
|
||||
}
|
||||
|
||||
if showStreamLoadingView {
|
||||
VStack(spacing: 16) {
|
||||
Text("Loading \(currentStreamTitle)…")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Button("Cancel") {
|
||||
withAnimation(.spring(response: 0.4, dampingFraction: 0.6)) {
|
||||
activeFetchID = nil
|
||||
isFetchingEpisode = false
|
||||
showStreamLoadingView = false
|
||||
}
|
||||
}
|
||||
.font(.subheadline)
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 24)
|
||||
.background(
|
||||
Color(red: 1.0, green: 112/255.0, blue: 94/255.0)
|
||||
)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(8)
|
||||
}
|
||||
.padding(.top, 20)
|
||||
.padding(.bottom, 16)
|
||||
.padding(.horizontal, 40)
|
||||
.background(.ultraThinMaterial)
|
||||
.cornerRadius(16)
|
||||
.shadow(color: Color.black.opacity(0.3), radius: 12, x: 0, y: 8)
|
||||
.frame(maxWidth: 300)
|
||||
.transition(.scale.combined(with: .opacity))
|
||||
.animation(.spring(response: 0.4, dampingFraction: 0.6), value: showStreamLoadingView)
|
||||
}
|
||||
}
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button(action: {
|
||||
activeFetchID = nil
|
||||
isFetchingEpisode = false
|
||||
showStreamLoadingView = false
|
||||
dismiss()
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "chevron.left")
|
||||
Text("Search")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
buttonRefreshTrigger.toggle()
|
||||
|
||||
if !hasFetched {
|
||||
DropManager.shared.showDrop(title: "Fetching Data", subtitle: "Please wait while fetching.", duration: 0.5, icon: UIImage(systemName: "arrow.triangle.2.circlepath"))
|
||||
fetchDetails()
|
||||
|
||||
if let savedID = UserDefaults.standard.object(forKey: "custom_anilist_id_\(href)") as? Int {
|
||||
customAniListID = savedID
|
||||
itemID = savedID
|
||||
Logger.shared.log("Using custom AniList ID: \(savedID)", type: "Debug")
|
||||
} else {
|
||||
fetchItemID(byTitle: cleanTitle(title)) { result in
|
||||
switch result {
|
||||
case .success(let id):
|
||||
itemID = id
|
||||
case .failure(let error):
|
||||
Logger.shared.log("Failed to fetch AniList ID: \(error)")
|
||||
AnalyticsManager.shared.sendEvent(event: "error", additionalData: ["error": error, "message": "Failed to fetch AniList ID"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hasFetched = true
|
||||
AnalyticsManager.shared.sendEvent(event: "search", additionalData: ["title": title])
|
||||
}
|
||||
selectedRange = 0..<episodeChunkSize
|
||||
.onDisappear {
|
||||
activeFetchID = nil
|
||||
isFetchingEpisode = false
|
||||
showStreamLoadingView = false
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -533,7 +599,6 @@ struct MediaInfoView: View {
|
|||
return groups
|
||||
}
|
||||
|
||||
|
||||
func fetchDetails() {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
Task {
|
||||
|
|
@ -573,7 +638,10 @@ struct MediaInfoView: View {
|
|||
}
|
||||
|
||||
func fetchStream(href: String) {
|
||||
DropManager.shared.showDrop(title: "Fetching Stream", subtitle: "", duration: 0.5, icon: UIImage(systemName: "arrow.triangle.2.circlepath"))
|
||||
let fetchID = UUID()
|
||||
activeFetchID = fetchID
|
||||
currentStreamTitle = "Episode \(selectedEpisodeNumber)"
|
||||
showStreamLoadingView = true
|
||||
isFetchingEpisode = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
Task {
|
||||
|
|
@ -584,6 +652,8 @@ struct MediaInfoView: View {
|
|||
if module.metadata.softsub == true {
|
||||
if module.metadata.asyncJS == true {
|
||||
jsController.fetchStreamUrlJS(episodeUrl: href, softsub: true, module: module) { result in
|
||||
guard self.activeFetchID == fetchID else { return }
|
||||
|
||||
if let streams = result.streams, !streams.isEmpty {
|
||||
if streams.count > 1 {
|
||||
self.showStreamSelectionAlert(streams: streams, fullURL: href, subtitles: result.subtitles?.first)
|
||||
|
|
@ -599,6 +669,8 @@ struct MediaInfoView: View {
|
|||
}
|
||||
} else if module.metadata.streamAsyncJS == true {
|
||||
jsController.fetchStreamUrlJSSecond(episodeUrl: href, softsub: true, module: module) { result in
|
||||
guard self.activeFetchID == fetchID else { return }
|
||||
|
||||
if let streams = result.streams, !streams.isEmpty {
|
||||
if streams.count > 1 {
|
||||
self.showStreamSelectionAlert(streams: streams, fullURL: href, subtitles: result.subtitles?.first)
|
||||
|
|
@ -614,6 +686,8 @@ struct MediaInfoView: View {
|
|||
}
|
||||
} else {
|
||||
jsController.fetchStreamUrl(episodeUrl: href, softsub: true, module: module) { result in
|
||||
guard self.activeFetchID == fetchID else { return }
|
||||
|
||||
if let streams = result.streams, !streams.isEmpty {
|
||||
if streams.count > 1 {
|
||||
self.showStreamSelectionAlert(streams: streams, fullURL: href, subtitles: result.subtitles?.first)
|
||||
|
|
@ -631,6 +705,8 @@ struct MediaInfoView: View {
|
|||
} else {
|
||||
if module.metadata.asyncJS == true {
|
||||
jsController.fetchStreamUrlJS(episodeUrl: href, module: module) { result in
|
||||
guard self.activeFetchID == fetchID else { return }
|
||||
|
||||
if let streams = result.streams, !streams.isEmpty {
|
||||
if streams.count > 1 {
|
||||
self.showStreamSelectionAlert(streams: streams, fullURL: href, subtitles: result.subtitles?.first)
|
||||
|
|
@ -646,6 +722,8 @@ struct MediaInfoView: View {
|
|||
}
|
||||
} else if module.metadata.streamAsyncJS == true {
|
||||
jsController.fetchStreamUrlJSSecond(episodeUrl: href, module: module) { result in
|
||||
guard self.activeFetchID == fetchID else { return }
|
||||
|
||||
if let streams = result.streams, !streams.isEmpty {
|
||||
if streams.count > 1 {
|
||||
self.showStreamSelectionAlert(streams: streams, fullURL: href, subtitles: result.subtitles?.first)
|
||||
|
|
@ -661,6 +739,8 @@ struct MediaInfoView: View {
|
|||
}
|
||||
} else {
|
||||
jsController.fetchStreamUrl(episodeUrl: href, module: module) { result in
|
||||
guard self.activeFetchID == fetchID else { return }
|
||||
|
||||
if let streams = result.streams, !streams.isEmpty {
|
||||
if streams.count > 1 {
|
||||
self.showStreamSelectionAlert(streams: streams, fullURL: href, subtitles: result.subtitles?.first)
|
||||
|
|
@ -687,6 +767,8 @@ struct MediaInfoView: View {
|
|||
}
|
||||
|
||||
func handleStreamFailure(error: Error? = nil) {
|
||||
self.isFetchingEpisode = false
|
||||
self.showStreamLoadingView = false
|
||||
if let error = error {
|
||||
Logger.shared.log("Error loading module: \(error)", type: "Error")
|
||||
AnalyticsManager.shared.sendEvent(event: "error", additionalData: ["error": error, "message": "Failed to fetch stream"])
|
||||
|
|
@ -698,6 +780,8 @@ struct MediaInfoView: View {
|
|||
}
|
||||
|
||||
func showStreamSelectionAlert(streams: [String], fullURL: String, subtitles: String? = nil) {
|
||||
self.isFetchingEpisode = false
|
||||
self.showStreamLoadingView = false
|
||||
DispatchQueue.main.async {
|
||||
let alert = UIAlertController(title: "Select Server", message: "Choose a server to play from", preferredStyle: .actionSheet)
|
||||
|
||||
|
|
@ -760,6 +844,8 @@ struct MediaInfoView: View {
|
|||
}
|
||||
|
||||
func playStream(url: String, fullURL: String, subtitles: String? = nil) {
|
||||
self.isFetchingEpisode = false
|
||||
self.showStreamLoadingView = false
|
||||
DispatchQueue.main.async {
|
||||
let externalPlayer = UserDefaults.standard.string(forKey: "externalPlayer") ?? "Sora"
|
||||
var scheme: String?
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ struct SearchItem: Identifiable {
|
|||
let href: String
|
||||
}
|
||||
|
||||
|
||||
struct SearchView: View {
|
||||
@AppStorage("selectedModuleId") private var selectedModuleId: String?
|
||||
@AppStorage("mediaColumnsPortrait") private var mediaColumnsPortrait: Int = 2
|
||||
|
|
|
|||
|
|
@ -16,8 +16,10 @@ struct SettingsViewGeneral: View {
|
|||
@AppStorage("metadataProviders") private var metadataProviders: String = "AniList"
|
||||
@AppStorage("mediaColumnsPortrait") private var mediaColumnsPortrait: Int = 2
|
||||
@AppStorage("mediaColumnsLandscape") private var mediaColumnsLandscape: Int = 4
|
||||
@AppStorage("episodeSortOrder") private var episodeSortOrder: String = "Ascending"
|
||||
|
||||
private let metadataProvidersList = ["AniList"]
|
||||
private let sortOrderOptions = ["Ascending", "Descending"]
|
||||
@EnvironmentObject var settings: Settings
|
||||
|
||||
var body: some View {
|
||||
|
|
@ -36,6 +38,7 @@ struct SettingsViewGeneral: View {
|
|||
}
|
||||
|
||||
Section(header: Text("Media View"), footer: Text("The episode range controls how many episodes appear on each page. Episodes are grouped into sets (like 1-25, 26-50, and so on), allowing you to navigate through them more easily.\n\nFor episode metadata it is refering to the episode thumbnail and title, since sometimes it can contain spoilers.")) {
|
||||
|
||||
HStack {
|
||||
Text("Episodes Range")
|
||||
Spacer()
|
||||
|
|
@ -48,8 +51,10 @@ struct SettingsViewGeneral: View {
|
|||
Text("\(episodeChunkSize)")
|
||||
}
|
||||
}
|
||||
|
||||
Toggle("Fetch Episode metadata", isOn: $fetchEpisodeMetadata)
|
||||
.tint(.accentColor)
|
||||
|
||||
HStack {
|
||||
Text("Metadata Provider")
|
||||
Spacer()
|
||||
|
|
|
|||
|
|
@ -11,12 +11,14 @@ import Kingfisher
|
|||
struct SettingsViewModule: View {
|
||||
@AppStorage("selectedModuleId") private var selectedModuleId: String?
|
||||
@EnvironmentObject var moduleManager: ModuleManager
|
||||
@AppStorage("didReceiveDefaultPageLink") private var didReceiveDefaultPageLink: Bool = false
|
||||
|
||||
@State private var errorMessage: String?
|
||||
@State private var isLoading = false
|
||||
@State private var isRefreshing = false
|
||||
@State private var moduleUrl: String = ""
|
||||
@State private var refreshTask: Task<Void, Never>?
|
||||
@State private var showLibrary = false
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
|
|
@ -28,15 +30,26 @@ struct SettingsViewModule: View {
|
|||
.foregroundColor(.secondary)
|
||||
Text("No Modules")
|
||||
.font(.headline)
|
||||
Text("Click the plus button to add a module!")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
if didReceiveDefaultPageLink {
|
||||
NavigationLink(destination: CommunityLibraryView()
|
||||
.environmentObject(moduleManager)) {
|
||||
Text("Check out some community modules here!")
|
||||
.font(.caption)
|
||||
.foregroundColor(.accentColor)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
} else {
|
||||
Text("Click the plus button to add a module!")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
ForEach(moduleManager.modules) { module in
|
||||
HStack {
|
||||
KFImage(URL(string: module.metadata.iconUrl))
|
||||
|
|
@ -105,13 +118,38 @@ struct SettingsViewModule: View {
|
|||
}
|
||||
}
|
||||
.navigationTitle("Modules")
|
||||
.navigationBarItems(trailing: Button(action: {
|
||||
showAddModuleAlert()
|
||||
}) {
|
||||
Image(systemName: "plus")
|
||||
.resizable()
|
||||
.padding(5)
|
||||
})
|
||||
.navigationBarItems(trailing:
|
||||
HStack(spacing: 16) {
|
||||
if didReceiveDefaultPageLink && !moduleManager.modules.isEmpty {
|
||||
Button(action: {
|
||||
showLibrary = true
|
||||
}) {
|
||||
Image(systemName: "books.vertical.fill")
|
||||
.resizable()
|
||||
.frame(width: 20, height: 20)
|
||||
.padding(5)
|
||||
}
|
||||
.accessibilityLabel("Open Community Library")
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
showAddModuleAlert()
|
||||
}) {
|
||||
Image(systemName: "plus")
|
||||
.resizable()
|
||||
.frame(width: 20, height: 20)
|
||||
.padding(5)
|
||||
}
|
||||
.accessibilityLabel("Add Module")
|
||||
}
|
||||
)
|
||||
.background(
|
||||
NavigationLink(
|
||||
destination: CommunityLibraryView()
|
||||
.environmentObject(moduleManager),
|
||||
isActive: $showLibrary
|
||||
) { EmptyView() }
|
||||
)
|
||||
.refreshable {
|
||||
isRefreshing = true
|
||||
refreshTask?.cancel()
|
||||
|
|
@ -205,5 +243,4 @@ struct SettingsViewModule: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ struct SettingsViewPlayer: View {
|
|||
@AppStorage("skipIncrementHold") private var skipIncrementHold: Double = 30.0
|
||||
@AppStorage("holdForPauseEnabled") private var holdForPauseEnabled = false
|
||||
@AppStorage("skip85Visible") private var skip85Visible: Bool = true
|
||||
@AppStorage("doubleTapSeekEnabled") private var doubleTapSeekEnabled: Bool = true
|
||||
|
||||
@AppStorage("doubleTapSeekEnabled") private var doubleTapSeekEnabled: Bool = false
|
||||
@AppStorage("skipIntroOutroVisible") private var skipIntroOutroVisible: Bool = true
|
||||
|
||||
private let mediaPlayers = ["Default", "VLC", "OutPlayer", "Infuse", "nPlayer", "Sora"]
|
||||
|
||||
|
|
@ -54,13 +54,35 @@ struct SettingsViewPlayer: View {
|
|||
Spacer()
|
||||
Stepper(
|
||||
value: $holdSpeedPlayer,
|
||||
in: 0.25...2.0,
|
||||
in: 0.25...2.5,
|
||||
step: 0.25
|
||||
) {
|
||||
Text(String(format: "%.2f", holdSpeedPlayer))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: Text("Progress bar Marker Color")) {
|
||||
ColorPicker("Segments Color", selection: Binding(
|
||||
get: {
|
||||
if let data = UserDefaults.standard.data(forKey: "segmentsColorData"),
|
||||
let uiColor = try? NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(data) as? UIColor {
|
||||
return Color(uiColor)
|
||||
}
|
||||
return .yellow
|
||||
},
|
||||
set: { newColor in
|
||||
let uiColor = UIColor(newColor)
|
||||
if let data = try? NSKeyedArchiver.archivedData(
|
||||
withRootObject: uiColor,
|
||||
requiringSecureCoding: false
|
||||
) {
|
||||
UserDefaults.standard.set(data, forKey: "segmentsColorData")
|
||||
}
|
||||
}
|
||||
))
|
||||
}
|
||||
|
||||
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:")
|
||||
|
|
@ -79,6 +101,9 @@ struct SettingsViewPlayer: View {
|
|||
|
||||
Toggle("Show Skip 85s Button", isOn: $skip85Visible)
|
||||
.tint(.accentColor)
|
||||
|
||||
Toggle("Show Skip Intro / Outro Buttons", isOn: $skipIntroOutroVisible)
|
||||
.tint(.accentColor)
|
||||
}
|
||||
SubtitleSettingsSection()
|
||||
}
|
||||
|
|
@ -92,6 +117,7 @@ struct SubtitleSettingsSection: View {
|
|||
@State private var shadowRadius: Double = SubtitleSettingsManager.shared.settings.shadowRadius
|
||||
@State private var backgroundEnabled: Bool = SubtitleSettingsManager.shared.settings.backgroundEnabled
|
||||
@State private var bottomPadding: CGFloat = SubtitleSettingsManager.shared.settings.bottomPadding
|
||||
@State private var subtitleDelay: Double = SubtitleSettingsManager.shared.settings.subtitleDelay
|
||||
|
||||
private let colors = ["white", "yellow", "green", "blue", "red", "purple"]
|
||||
private let shadowOptions = [0, 1, 3, 6]
|
||||
|
|
@ -161,6 +187,28 @@ struct SubtitleSettingsSection: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text("Subtitle Delay: \(String(format: "%.1fs", subtitleDelay))")
|
||||
.padding(.bottom, 1)
|
||||
|
||||
HStack {
|
||||
Text("-10s")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Slider(value: $subtitleDelay, in: -10...10, step: 0.1)
|
||||
.onChange(of: subtitleDelay) { newValue in
|
||||
SubtitleSettingsManager.shared.update { settings in
|
||||
settings.subtitleDelay = newValue
|
||||
}
|
||||
}
|
||||
|
||||
Text("+10s")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ struct SettingsViewTrackers: View {
|
|||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section(header: Text("AniList"), footer: Text("Sora and cranci1 are not affiliated with AniList in any way.\n\nNote that progresses update may not be 100% acurate.")) {
|
||||
Section(header: Text("AniList")) {
|
||||
HStack() {
|
||||
KFImage(URL(string: "https://raw.githubusercontent.com/cranci1/Ryu/2f10226aa087154974a70c1ec78aa83a47daced9/Ryu/Assets.xcassets/Listing/Anilist.imageset/anilist.png"))
|
||||
.placeholder {
|
||||
|
|
@ -74,7 +74,7 @@ struct SettingsViewTrackers: View {
|
|||
.font(.body)
|
||||
}
|
||||
|
||||
Section(header: Text("Trakt"), footer: Text("Sora and cranci1 are not affiliated with Trakt in any way.\n\nNote that progress updates may not be 100% accurate.")) {
|
||||
Section(header: Text("Trakt")) {
|
||||
HStack() {
|
||||
KFImage(URL(string: "https://static-00.iconduck.com/assets.00/trakt-icon-2048x2048-2633ksxg.png"))
|
||||
.placeholder {
|
||||
|
|
@ -107,11 +107,6 @@ struct SettingsViewTrackers: View {
|
|||
}
|
||||
}
|
||||
|
||||
if isTraktLoggedIn {
|
||||
Toggle("Sync watch progress", isOn: $isSendTraktUpdates)
|
||||
.tint(.accentColor)
|
||||
}
|
||||
|
||||
Button(isTraktLoggedIn ? "Log Out from Trakt" : "Log In with Trakt") {
|
||||
if isTraktLoggedIn {
|
||||
logoutTrakt()
|
||||
|
|
@ -121,6 +116,8 @@ struct SettingsViewTrackers: View {
|
|||
}
|
||||
.font(.body)
|
||||
}
|
||||
|
||||
Section(footer: Text("Sora and cranci1 are not affiliated with AniList nor Trakt in any way.\n\nAlso note that progresses update may not be 100% accurate.")) {}
|
||||
}
|
||||
.navigationTitle("Trackers")
|
||||
.onAppear {
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@
|
|||
132AF1232D9995C300A0140B /* JSController-Details.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132AF1222D9995C300A0140B /* JSController-Details.swift */; };
|
||||
132AF1252D9995F900A0140B /* JSController-Search.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132AF1242D9995F900A0140B /* JSController-Search.swift */; };
|
||||
132E351D2D959DDB0007800E /* Drops in Frameworks */ = {isa = PBXBuildFile; productRef = 132E351C2D959DDB0007800E /* Drops */; };
|
||||
132E35202D959E1D0007800E /* FFmpeg-iOS-Lame in Frameworks */ = {isa = PBXBuildFile; productRef = 132E351F2D959E1D0007800E /* FFmpeg-iOS-Lame */; };
|
||||
132E35232D959E410007800E /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 132E35222D959E410007800E /* Kingfisher */; };
|
||||
133D7C6E2D2BE2500075467E /* SoraApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133D7C6D2D2BE2500075467E /* SoraApp.swift */; };
|
||||
133D7C702D2BE2500075467E /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133D7C6F2D2BE2500075467E /* ContentView.swift */; };
|
||||
|
|
@ -35,7 +34,6 @@
|
|||
133F55BB2D33B55100E08EEA /* LibraryManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133F55BA2D33B55100E08EEA /* LibraryManager.swift */; };
|
||||
1359ED142D76F49900C13034 /* finTopView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1359ED132D76F49900C13034 /* finTopView.swift */; };
|
||||
135CCBE22D4D1138008B9C0E /* SettingsViewPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 135CCBE12D4D1138008B9C0E /* SettingsViewPlayer.swift */; };
|
||||
136BBE7E2DB102D600906B5E /* iCloudSyncManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 136BBE7D2DB102D600906B5E /* iCloudSyncManager.swift */; };
|
||||
136BBE802DB1038000906B5E /* Notification+Name.swift in Sources */ = {isa = PBXBuildFile; fileRef = 136BBE7F2DB1038000906B5E /* Notification+Name.swift */; };
|
||||
138AA1B82D2D66FD0021F9DF /* EpisodeCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138AA1B62D2D66FD0021F9DF /* EpisodeCell.swift */; };
|
||||
138AA1B92D2D66FD0021F9DF /* CircularProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138AA1B72D2D66FD0021F9DF /* CircularProgressBar.swift */; };
|
||||
|
|
@ -56,7 +54,6 @@
|
|||
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 */; };
|
||||
13DB7CEC2D7DED5D004371D3 /* DownloadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13DB7CEB2D7DED5D004371D3 /* DownloadManager.swift */; };
|
||||
13DC0C462D302C7500D0F966 /* VideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13DC0C452D302C7500D0F966 /* VideoPlayer.swift */; };
|
||||
13E62FC22DABC5830007E259 /* Trakt-Login.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13E62FC12DABC5830007E259 /* Trakt-Login.swift */; };
|
||||
13E62FC42DABC58C0007E259 /* Trakt-Token.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13E62FC32DABC58C0007E259 /* Trakt-Token.swift */; };
|
||||
|
|
@ -67,6 +64,7 @@
|
|||
1E26E9E72DA9577900B9DC02 /* VolumeSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E26E9E62DA9577900B9DC02 /* VolumeSlider.swift */; };
|
||||
1E9FF1D32D403E49008AC100 /* SettingsViewLoggerFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */; };
|
||||
1EAC7A322D888BC50083984D /* MusicProgressSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EAC7A312D888BC50083984D /* MusicProgressSlider.swift */; };
|
||||
1EF5C3A92DB988E40032BF07 /* CommunityLib.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EF5C3A82DB988D70032BF07 /* CommunityLib.swift */; };
|
||||
73D164D52D8B5B470011A360 /* JavaScriptCore+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
|
|
@ -98,7 +96,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>"; };
|
||||
136BBE7D2DB102D600906B5E /* iCloudSyncManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iCloudSyncManager.swift; sourceTree = "<group>"; };
|
||||
136BBE7F2DB1038000906B5E /* Notification+Name.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Notification+Name.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>"; };
|
||||
|
|
@ -118,7 +115,6 @@
|
|||
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>"; };
|
||||
13DC0C452D302C7500D0F966 /* VideoPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayer.swift; sourceTree = "<group>"; };
|
||||
13E62FC12DABC5830007E259 /* Trakt-Login.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Trakt-Login.swift"; sourceTree = "<group>"; };
|
||||
|
|
@ -130,6 +126,7 @@
|
|||
1E26E9E62DA9577900B9DC02 /* VolumeSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeSlider.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>"; };
|
||||
1EF5C3A82DB988D70032BF07 /* CommunityLib.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityLib.swift; sourceTree = "<group>"; };
|
||||
73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JavaScriptCore+Extensions.swift"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
|
|
@ -140,7 +137,6 @@
|
|||
files = (
|
||||
13B77E192DA44F8300126FDF /* MarqueeLabel in Frameworks */,
|
||||
132E35232D959E410007800E /* Kingfisher in Frameworks */,
|
||||
132E35202D959E1D0007800E /* FFmpeg-iOS-Lame in Frameworks */,
|
||||
132E351D2D959DDB0007800E /* Drops in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
|
@ -261,8 +257,6 @@
|
|||
133D7C852D2BE2640075467E /* Utils */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
136BBE7C2DB102BE00906B5E /* iCloudSyncManager */,
|
||||
13DB7CEA2D7DED50004371D3 /* DownloadManager */,
|
||||
13C0E5E82D5F85DD00E7F619 /* ContinueWatching */,
|
||||
13103E8C2D58E037000F0673 /* SkeletonCells */,
|
||||
13DC0C442D302C6A00D0F966 /* MediaPlayer */,
|
||||
|
|
@ -294,6 +288,7 @@
|
|||
133D7C882D2BE2640075467E /* Modules */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
1EF5C3A82DB988D70032BF07 /* CommunityLib.swift */,
|
||||
13D99CF62D4E73C300250A86 /* ModuleAdditionSettingsView.swift */,
|
||||
139935652D468C450065CEFF /* ModuleManager.swift */,
|
||||
133D7C892D2BE2640075467E /* Modules.swift */,
|
||||
|
|
@ -321,14 +316,6 @@
|
|||
path = LibraryView;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
136BBE7C2DB102BE00906B5E /* iCloudSyncManager */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
136BBE7D2DB102D600906B5E /* iCloudSyncManager.swift */,
|
||||
);
|
||||
path = iCloudSyncManager;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
1384DCDF2D89BE870094797A /* Helpers */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
|
@ -398,14 +385,6 @@
|
|||
path = Auth;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
13DB7CEA2D7DED50004371D3 /* DownloadManager */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
13DB7CEB2D7DED5D004371D3 /* DownloadManager.swift */,
|
||||
);
|
||||
path = DownloadManager;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
13DC0C442D302C6A00D0F966 /* MediaPlayer */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
|
@ -480,7 +459,6 @@
|
|||
name = Sulfur;
|
||||
packageProductDependencies = (
|
||||
132E351C2D959DDB0007800E /* Drops */,
|
||||
132E351F2D959E1D0007800E /* FFmpeg-iOS-Lame */,
|
||||
132E35222D959E410007800E /* Kingfisher */,
|
||||
13B77E182DA44F8300126FDF /* MarqueeLabel */,
|
||||
);
|
||||
|
|
@ -513,7 +491,6 @@
|
|||
mainGroup = 133D7C612D2BE2500075467E;
|
||||
packageReferences = (
|
||||
132E351B2D959DDB0007800E /* XCRemoteSwiftPackageReference "Drops" */,
|
||||
132E351E2D959E1D0007800E /* XCRemoteSwiftPackageReference "FFmpeg-iOS-Lame" */,
|
||||
132E35212D959E410007800E /* XCRemoteSwiftPackageReference "Kingfisher" */,
|
||||
13B77E172DA44F8300126FDF /* XCRemoteSwiftPackageReference "MarqueeLabel" */,
|
||||
);
|
||||
|
|
@ -552,8 +529,8 @@
|
|||
139935662D468C450065CEFF /* ModuleManager.swift in Sources */,
|
||||
133D7C902D2BE2640075467E /* SettingsView.swift in Sources */,
|
||||
132AF1252D9995F900A0140B /* JSController-Search.swift in Sources */,
|
||||
1EF5C3A92DB988E40032BF07 /* CommunityLib.swift in Sources */,
|
||||
13CBEFDA2D5F7D1200D011EE /* String.swift in Sources */,
|
||||
136BBE7E2DB102D600906B5E /* iCloudSyncManager.swift in Sources */,
|
||||
1E26E9E72DA9577900B9DC02 /* VolumeSlider.swift in Sources */,
|
||||
136BBE802DB1038000906B5E /* Notification+Name.swift in Sources */,
|
||||
13DB46902D900A38008CBC03 /* URL.swift in Sources */,
|
||||
|
|
@ -563,7 +540,6 @@
|
|||
133D7C932D2BE2640075467E /* Modules.swift in Sources */,
|
||||
13DB7CC32D7D99C0004371D3 /* SubtitleSettingsManager.swift in Sources */,
|
||||
133D7C702D2BE2500075467E /* ContentView.swift in Sources */,
|
||||
13DB7CEC2D7DED5D004371D3 /* DownloadManager.swift in Sources */,
|
||||
13D99CF72D4E73C300250A86 /* ModuleAdditionSettingsView.swift in Sources */,
|
||||
13C0E5EC2D5F85F800E7F619 /* ContinueWatchingItem.swift in Sources */,
|
||||
13CBA0882D60F19C00EFE70A /* VTTSubtitlesLoader.swift in Sources */,
|
||||
|
|
@ -840,14 +816,6 @@
|
|||
kind = branch;
|
||||
};
|
||||
};
|
||||
132E351E2D959E1D0007800E /* XCRemoteSwiftPackageReference "FFmpeg-iOS-Lame" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/kewlbear/FFmpeg-iOS-Lame";
|
||||
requirement = {
|
||||
branch = main;
|
||||
kind = branch;
|
||||
};
|
||||
};
|
||||
132E35212D959E410007800E /* XCRemoteSwiftPackageReference "Kingfisher" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/onevcat/Kingfisher.git";
|
||||
|
|
@ -872,11 +840,6 @@
|
|||
package = 132E351B2D959DDB0007800E /* XCRemoteSwiftPackageReference "Drops" */;
|
||||
productName = Drops;
|
||||
};
|
||||
132E351F2D959E1D0007800E /* FFmpeg-iOS-Lame */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 132E351E2D959E1D0007800E /* XCRemoteSwiftPackageReference "FFmpeg-iOS-Lame" */;
|
||||
productName = "FFmpeg-iOS-Lame";
|
||||
};
|
||||
132E35222D959E410007800E /* Kingfisher */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 132E35212D959E410007800E /* XCRemoteSwiftPackageReference "Kingfisher" */;
|
||||
|
|
|
|||
|
|
@ -10,24 +10,6 @@
|
|||
"version": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "FFmpeg-iOS-Lame",
|
||||
"repositoryURL": "https://github.com/kewlbear/FFmpeg-iOS-Lame",
|
||||
"state": {
|
||||
"branch": "main",
|
||||
"revision": "1808fa5a1263c5e216646cd8421fc7dcb70520cc",
|
||||
"version": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "FFmpeg-iOS-Support",
|
||||
"repositoryURL": "https://github.com/kewlbear/FFmpeg-iOS-Support",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "be3bd9149ac53760e8725652eee99c405b2be47a",
|
||||
"version": "0.0.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "Kingfisher",
|
||||
"repositoryURL": "https://github.com/onevcat/Kingfisher.git",
|
||||
|
|
|
|||
BIN
assets/banner1.png
Normal file
BIN
assets/banner1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 181 KiB |
Loading…
Reference in a new issue