Merge branch 'cranci1:dev' into dev

This commit is contained in:
D Osman 2025-04-27 23:33:05 +01:00 committed by GitHub
commit 1fca6b712b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1778 additions and 944 deletions

View file

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

View file

@ -2,8 +2,6 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSCameraUsageDescription</key>
<string>Sora may requires access to your device&apos;s camera.</string>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB