Merge branch 'dev'

This commit is contained in:
Francesco 2025-06-07 14:52:23 +02:00
commit 76ac93bce1
89 changed files with 8998 additions and 4554 deletions

View file

@ -22,15 +22,16 @@
## Features
- [x] iOS/iPadOS 15.0+ support
- [x] macOS 12.0+ support
- [x] JavaScript Main Core
- [ ] Download support (HLS & MP4)
- [x] iOS/iPadOS 15.0+ support
- [x] JavaScript as main Loader
- [x] Download support (HLS & MP4)
- [x] Tracking Services (AniList, Trakt)
- [x] Apple KeyChain support for auth Tokens
- [x] Streams support (Jellyfin/Plex like servers)
- [x] External media player support (VLC, Infuse, Outplayer, nPlayer, SenPlayer, IINA)
- [x] External Metadata providers (TMDB, AniList)
- [x] Background playback and Picture-in-Picture (PiP) support
- [x] External media player support (VLC, Infuse, Outplayer, nPlayer, SenPlayer, IINA, TracyPlayer)
## Installation

View file

@ -2,8 +2,31 @@
"colors" : [
{
"color" : {
"platform" : "universal",
"reference" : "systemMintColor"
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "1.000",
"green" : "1.000",
"red" : "1.000"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.000",
"green" : "0.000",
"red" : "0.000"
}
},
"idiom" : "universal"
}

View file

@ -0,0 +1,28 @@
{
"images" : [
{
"filename" : "darkmode.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"filename" : "lightmode.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024",
"unassigned" : true
},
{
"filename" : "tinting.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024",
"unassigned" : true
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

View file

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

View file

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 77 KiB

View file

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "preview.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

View file

@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "original.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

View file

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "preview.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

View file

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "preview.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

View file

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -4,28 +4,60 @@
//
// Created by Francesco on 06/01/25.
//
import SwiftUI
struct ContentView: View {
var body: some View {
TabView {
LibraryView()
.tabItem {
Label("Library", systemImage: "books.vertical")
}
DownloadView()
.tabItem {
Label("Downloads", systemImage: "arrow.down.app.fill")
}
SearchView()
.tabItem {
Label("Search", systemImage: "magnifyingglass")
}
SettingsView()
.tabItem {
Label("Settings", systemImage: "gear")
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
.environmentObject(LibraryManager())
.environmentObject(ModuleManager())
.environmentObject(Settings())
}
}
struct ContentView: View {
@StateObject private var tabBarController = TabBarController()
@State var selectedTab: Int = 0
@State var lastTab: Int = 0
@State private var searchQuery: String = ""
let tabs: [TabItem] = [
TabItem(icon: "square.stack", title: ""),
TabItem(icon: "arrow.down.circle", title: ""),
TabItem(icon: "gearshape", title: ""),
TabItem(icon: "magnifyingglass", title: "")
]
var body: some View {
ZStack(alignment: .bottom) {
switch selectedTab {
case 0:
LibraryView()
.environmentObject(tabBarController)
case 1:
DownloadView()
.environmentObject(tabBarController)
case 2:
SettingsView()
.environmentObject(tabBarController)
case 3:
SearchView(searchQuery: $searchQuery)
.environmentObject(tabBarController)
default:
LibraryView()
.environmentObject(tabBarController)
}
TabBar(
tabs: tabs,
selectedTab: $selectedTab,
lastTab: $lastTab,
searchQuery: $searchQuery,
controller: tabBarController
)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
.ignoresSafeArea(.keyboard, edges: .bottom)
.padding(.bottom, -20)
}
}

View file

@ -6,6 +6,110 @@
<array>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
</array>
<key>CFBundleIcons</key>
<dict>
<key>CFBundleAlternateIcons</key>
<dict>
<key>AppIcon_CiroChrome</key>
<dict>
<key>CFBundleIconFiles</key>
<array>
<string>AppIcon_CiroChrome</string>
</array>
</dict>
<key>AppIcon_CiroGold</key>
<dict>
<key>CFBundleIconFiles</key>
<array>
<string>AppIcon_CiroGold</string>
</array>
</dict>
<key>AppIcon_CiroGoldThree</key>
<dict>
<key>CFBundleIconFiles</key>
<array>
<string>AppIcon_CiroGoldThree</string>
</array>
</dict>
<key>AppIcon_CiroGoldTwo</key>
<dict>
<key>CFBundleIconFiles</key>
<array>
<string>AppIcon_CiroGoldTwo</string>
</array>
</dict>
<key>AppIcon_CiroPink</key>
<dict>
<key>CFBundleIconFiles</key>
<array>
<string>AppIcon_CiroPink</string>
</array>
</dict>
<key>AppIcon_CiroPurple</key>
<dict>
<key>CFBundleIconFiles</key>
<array>
<string>AppIcon_CiroPurple</string>
</array>
</dict>
<key>AppIcon_CiroRed</key>
<dict>
<key>CFBundleIconFiles</key>
<array>
<string>AppIcon_CiroRed</string>
</array>
</dict>
<key>AppIcon_CiroRoseGold</key>
<dict>
<key>CFBundleIconFiles</key>
<array>
<string>AppIcon_CiroRoseGold</string>
</array>
</dict>
<key>AppIcon_CiroSilver</key>
<dict>
<key>CFBundleIconFiles</key>
<array>
<string>AppIcon_CiroSilver</string>
</array>
</dict>
<key>AppIcon_CiroSilverTwo</key>
<dict>
<key>CFBundleIconFiles</key>
<array>
<string>AppIcon_CiroSilverTwo</string>
</array>
</dict>
<key>AppIcon_CiroYellow</key>
<dict>
<key>CFBundleIconFiles</key>
<array>
<string>AppIcon_CiroYellow</string>
</array>
</dict>
<key>AppIcon_Original</key>
<dict>
<key>CFBundleIconFiles</key>
<array>
<string>AppIcon_Original</string>
</array>
</dict>
<key>AppIcon_Pixel</key>
<dict>
<key>CFBundleIconFiles</key>
<array>
<string>AppIcon_Pixel</string>
</array>
</dict>
<key>AppIcon_SoraAlt</key>
<dict>
<key>CFBundleIconFiles</key>
<array>
<string>AppIcon_SoraAlt</string>
</array>
</dict>
</dict>
</dict>
<key>CFBundleURLTypes</key>
<array>
<dict>
@ -21,6 +125,7 @@
</array>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>tracy</string>
<string>iina</string>
<string>outplayer</string>
<string>infuse</string>
@ -36,6 +141,7 @@
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
<string>fetch</string>
<string>processing</string>
</array>
</dict>

View file

@ -1,646 +0,0 @@
//
// EpisodeMetadataManager.swift
// Sora
//
// Created by doomsboygaming on 5/22/25
//
import Foundation
import Combine
/// A model representing episode metadata
struct EpisodeMetadataInfo: Codable, Equatable {
let title: [String: String]
let imageUrl: String
let anilistId: Int
let episodeNumber: Int
var cacheKey: String {
return "anilist_\(anilistId)_episode_\(episodeNumber)"
}
}
/// Status of a metadata fetch request
enum MetadataFetchStatus {
case notRequested
case fetching
case fetched(EpisodeMetadataInfo)
case failed(Error)
}
/// Central manager for fetching, caching, and prefetching episode metadata
class EpisodeMetadataManager: ObservableObject {
static let shared = EpisodeMetadataManager()
private init() {
// Initialize any resources here
Logger.shared.log("EpisodeMetadataManager initialized", type: "Info")
}
// Published properties that trigger UI updates
@Published private var metadataCache: [String: MetadataFetchStatus] = [:]
// In-flight requests to prevent duplicate API calls
private var activeRequests: [String: AnyCancellable] = [:]
// Queue for managing concurrent requests
private let fetchQueue = DispatchQueue(label: "com.sora.metadataFetch", qos: .userInitiated, attributes: .concurrent)
// Add retry configuration properties
private let maxRetryAttempts = 3
private let initialBackoffDelay: TimeInterval = 1.0 // in seconds
private var currentRetryAttempts: [String: Int] = [:] // Track retry attempts by cache key
// MARK: - Public Interface
/// Fetch metadata for a single episode
/// - Parameters:
/// - anilistId: The Anilist ID of the anime
/// - episodeNumber: The episode number to fetch
/// - completion: Callback with the result
func fetchMetadata(anilistId: Int, episodeNumber: Int, completion: @escaping (Result<EpisodeMetadataInfo, Error>) -> Void) {
let cacheKey = "anilist_\(anilistId)_episode_\(episodeNumber)"
// Check if we already have this metadata
if let existingStatus = metadataCache[cacheKey] {
switch existingStatus {
case .fetched(let metadata):
// Return cached data immediately
completion(.success(metadata))
return
case .fetching:
// Already fetching, will be notified via publisher
// Set up a listener for when this request completes
waitForRequest(cacheKey: cacheKey, completion: completion)
return
case .failed:
// Previous attempt failed, try again
break
case .notRequested:
// Should not happen but continue to fetch
break
}
}
// Check persistent cache
if let cachedData = MetadataCacheManager.shared.getMetadata(forKey: cacheKey),
let metadata = EpisodeMetadata.fromData(cachedData) {
let metadataInfo = EpisodeMetadataInfo(
title: metadata.title,
imageUrl: metadata.imageUrl,
anilistId: anilistId,
episodeNumber: episodeNumber
)
// Update memory cache
DispatchQueue.main.async {
self.metadataCache[cacheKey] = .fetched(metadataInfo)
}
completion(.success(metadataInfo))
return
}
// Need to fetch from network
DispatchQueue.main.async {
self.metadataCache[cacheKey] = .fetching
}
performFetch(anilistId: anilistId, episodeNumber: episodeNumber, cacheKey: cacheKey, completion: completion)
}
/// Fetch metadata for multiple episodes in batch
/// - Parameters:
/// - anilistId: The Anilist ID of the anime
/// - episodeNumbers: Array of episode numbers to fetch
func batchFetchMetadata(anilistId: Int, episodeNumbers: [Int]) {
// First check which episodes we need to fetch
let episodesToFetch = episodeNumbers.filter { episodeNumber in
let cacheKey = "anilist_\(anilistId)_episode_\(episodeNumber)"
if let status = metadataCache[cacheKey] {
switch status {
case .fetched, .fetching:
return false
default:
return true
}
}
return true
}
guard !episodesToFetch.isEmpty else {
Logger.shared.log("No new episodes to fetch in batch", type: "Debug")
return
}
// Mark all as fetching
for episodeNumber in episodesToFetch {
let cacheKey = "anilist_\(anilistId)_episode_\(episodeNumber)"
DispatchQueue.main.async {
self.metadataCache[cacheKey] = .fetching
}
}
// Perform batch fetch
fetchBatchFromNetwork(anilistId: anilistId, episodeNumbers: episodesToFetch)
}
/// Prefetch metadata for a range of episodes
/// - Parameters:
/// - anilistId: The Anilist ID of the anime
/// - startEpisode: The starting episode number
/// - count: How many episodes to prefetch
func prefetchMetadata(anilistId: Int, startEpisode: Int, count: Int = 5) {
let episodeNumbers = Array(startEpisode..<(startEpisode + count))
batchFetchMetadata(anilistId: anilistId, episodeNumbers: episodeNumbers)
}
/// Get metadata for an episode (non-blocking, returns immediately from cache)
/// - Parameters:
/// - anilistId: The Anilist ID of the anime
/// - episodeNumber: The episode number
/// - Returns: The metadata fetch status
func getMetadataStatus(anilistId: Int, episodeNumber: Int) -> MetadataFetchStatus {
let cacheKey = "anilist_\(anilistId)_episode_\(episodeNumber)"
return metadataCache[cacheKey] ?? .notRequested
}
// MARK: - Private Methods
private func performFetch(anilistId: Int, episodeNumber: Int, cacheKey: String, completion: @escaping (Result<EpisodeMetadataInfo, Error>) -> Void) {
// Check if there's already an active request for this metadata
if activeRequests[cacheKey] != nil {
// Already fetching, wait for it to complete
waitForRequest(cacheKey: cacheKey, completion: completion)
return
}
// Reset retry attempts if this is a new fetch
if currentRetryAttempts[cacheKey] == nil {
currentRetryAttempts[cacheKey] = 0
}
// Create API request
guard let url = URL(string: "https://api.ani.zip/mappings?anilist_id=\(anilistId)") else {
let error = NSError(domain: "com.sora.metadata", code: 1, userInfo: [NSLocalizedDescriptionKey: "Invalid URL"])
DispatchQueue.main.async {
self.metadataCache[cacheKey] = .failed(error)
}
completion(.failure(error))
return
}
Logger.shared.log("Fetching metadata for episode \(episodeNumber) from network", type: "Debug")
// Create publisher for the request
let publisher = URLSession.custom.dataTaskPublisher(for: url)
.subscribe(on: fetchQueue)
.tryMap { [weak self] data, response -> EpisodeMetadataInfo in
guard let self = self else {
throw NSError(domain: "com.sora.metadata", code: 4,
userInfo: [NSLocalizedDescriptionKey: "Manager instance released"])
}
// Validate response
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw NSError(domain: "com.sora.metadata", code: 2,
userInfo: [NSLocalizedDescriptionKey: "Invalid response"])
}
// Parse JSON
let jsonObject = try JSONSerialization.jsonObject(with: data, options: [])
guard let json = jsonObject as? [String: Any] else {
throw NSError(domain: "com.sora.metadata", code: 3,
userInfo: [NSLocalizedDescriptionKey: "Invalid data format"])
}
// Check for episodes object
guard let episodes = json["episodes"] as? [String: Any] else {
Logger.shared.log("Missing 'episodes' object in response for anilistId: \(anilistId)", type: "Error")
throw NSError(domain: "com.sora.metadata", code: 3,
userInfo: [NSLocalizedDescriptionKey: "Missing episodes object in response"])
}
// Check if episode exists in response
let episodeKey = "\(episodeNumber)"
guard let episodeDetails = episodes[episodeKey] as? [String: Any] else {
Logger.shared.log("Episode \(episodeNumber) not found in response for anilistId: \(anilistId)", type: "Error")
throw NSError(domain: "com.sora.metadata", code: 5,
userInfo: [NSLocalizedDescriptionKey: "Episode \(episodeNumber) not found in response"])
}
// Extract available fields, log if they're missing
var title: [String: String] = [:]
var image: String = ""
var missingFields: [String] = []
// Try to get title
if let titleData = episodeDetails["title"] as? [String: String] {
title = titleData
// Check if we have valid title values
if title.isEmpty || title.values.allSatisfy({ $0.isEmpty }) {
missingFields.append("title (all values empty)")
}
} else {
missingFields.append("title")
// Create default empty title dictionary
title = ["en": "Episode \(episodeNumber)"]
}
// Try to get image
if let imageUrl = episodeDetails["image"] as? String {
image = imageUrl
if imageUrl.isEmpty {
missingFields.append("image (empty string)")
}
} else {
missingFields.append("image")
// Use a default placeholder image
image = "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/dev/assets/banner1.png"
}
// Log missing fields but continue processing
if !missingFields.isEmpty {
Logger.shared.log("Episode \(episodeNumber) for anilistId \(anilistId) missing fields: \(missingFields.joined(separator: ", "))", type: "Warning")
}
// Create metadata object with whatever we have
let metadataInfo = EpisodeMetadataInfo(
title: title,
imageUrl: image,
anilistId: anilistId,
episodeNumber: episodeNumber
)
// Cache the metadata
if MetadataCacheManager.shared.isCachingEnabled {
let metadata = EpisodeMetadata(
title: title,
imageUrl: image,
anilistId: anilistId,
episodeNumber: episodeNumber
)
if let metadataData = metadata.toData() {
MetadataCacheManager.shared.storeMetadata(
metadataData,
forKey: cacheKey
)
Logger.shared.log("Cached metadata for episode \(episodeNumber)", type: "Debug")
}
}
// Reset retry count on success (even with missing fields)
self.currentRetryAttempts.removeValue(forKey: cacheKey)
return metadataInfo
}
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { [weak self] result in
// Handle completion
guard let self = self else { return }
switch result {
case .finished:
break
case .failure(let error):
// Handle retry logic
var shouldRetry = false
let currentAttempt = self.currentRetryAttempts[cacheKey] ?? 0
// Check if we should retry based on the error and attempt count
if currentAttempt < self.maxRetryAttempts {
// Increment attempt counter
let nextAttempt = currentAttempt + 1
self.currentRetryAttempts[cacheKey] = nextAttempt
// Calculate backoff delay using exponential backoff
let backoffDelay = self.initialBackoffDelay * pow(2.0, Double(currentAttempt))
Logger.shared.log("Metadata fetch failed, retrying (attempt \(nextAttempt)/\(self.maxRetryAttempts)) in \(backoffDelay) seconds", type: "Debug")
// Schedule retry after backoff delay
DispatchQueue.main.asyncAfter(deadline: .now() + backoffDelay) {
// Remove the current request before retrying
self.activeRequests.removeValue(forKey: cacheKey)
self.performFetch(anilistId: anilistId, episodeNumber: episodeNumber, cacheKey: cacheKey, completion: completion)
}
shouldRetry = true
} else {
// Max retries reached
Logger.shared.log("Metadata fetch failed after \(self.maxRetryAttempts) attempts: \(error.localizedDescription)", type: "Error")
self.currentRetryAttempts.removeValue(forKey: cacheKey)
}
if !shouldRetry {
// Update cache with error
self.metadataCache[cacheKey] = .failed(error)
completion(.failure(error))
// Remove from active requests
self.activeRequests.removeValue(forKey: cacheKey)
}
}
}, receiveValue: { [weak self] metadataInfo in
// Update cache with result
self?.metadataCache[cacheKey] = .fetched(metadataInfo)
completion(.success(metadataInfo))
// Remove from active requests
self?.activeRequests.removeValue(forKey: cacheKey)
})
// Store publisher in active requests
activeRequests[cacheKey] = publisher
}
private func fetchBatchFromNetwork(anilistId: Int, episodeNumbers: [Int]) {
// This API returns all episodes for a show in one call, so we only need one request
guard let url = URL(string: "https://api.ani.zip/mappings?anilist_id=\(anilistId)") else {
Logger.shared.log("Invalid URL for batch fetch", type: "Error")
return
}
Logger.shared.log("Batch fetching \(episodeNumbers.count) episodes from network", type: "Debug")
let batchCacheKey = "batch_\(anilistId)_\(episodeNumbers.map { String($0) }.joined(separator: "_"))"
// Reset retry attempts if this is a new fetch
if currentRetryAttempts[batchCacheKey] == nil {
currentRetryAttempts[batchCacheKey] = 0
}
// Create publisher for the request
let publisher = URLSession.custom.dataTaskPublisher(for: url)
.subscribe(on: fetchQueue)
.tryMap { [weak self] data, response -> [Int: EpisodeMetadataInfo] in
guard let self = self else {
throw NSError(domain: "com.sora.metadata", code: 4,
userInfo: [NSLocalizedDescriptionKey: "Manager instance released"])
}
// Validate response
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw NSError(domain: "com.sora.metadata", code: 2,
userInfo: [NSLocalizedDescriptionKey: "Invalid response"])
}
// Parse JSON
let jsonObject = try JSONSerialization.jsonObject(with: data, options: [])
guard let json = jsonObject as? [String: Any] else {
throw NSError(domain: "com.sora.metadata", code: 3,
userInfo: [NSLocalizedDescriptionKey: "Invalid data format"])
}
guard let episodes = json["episodes"] as? [String: Any] else {
Logger.shared.log("Missing 'episodes' object in response for anilistId: \(anilistId)", type: "Error")
throw NSError(domain: "com.sora.metadata", code: 3,
userInfo: [NSLocalizedDescriptionKey: "Missing episodes object in response"])
}
// Check if we have at least one requested episode
let hasAnyRequestedEpisode = episodeNumbers.contains { episodeNumber in
return episodes["\(episodeNumber)"] != nil
}
if !hasAnyRequestedEpisode {
Logger.shared.log("None of the requested episodes were found for anilistId: \(anilistId)", type: "Error")
throw NSError(domain: "com.sora.metadata", code: 5,
userInfo: [NSLocalizedDescriptionKey: "None of the requested episodes were found"])
}
// Process each requested episode
var results: [Int: EpisodeMetadataInfo] = [:]
var missingEpisodes: [Int] = []
var episodesWithMissingFields: [String] = []
for episodeNumber in episodeNumbers {
let episodeKey = "\(episodeNumber)"
// Check if this episode exists in the response
if let episodeDetails = episodes[episodeKey] as? [String: Any] {
var title: [String: String] = [:]
var image: String = ""
var missingFields: [String] = []
// Try to get title
if let titleData = episodeDetails["title"] as? [String: String] {
title = titleData
// Check if we have valid title values
if title.isEmpty || title.values.allSatisfy({ $0.isEmpty }) {
missingFields.append("title (all values empty)")
}
} else {
missingFields.append("title")
// Create default empty title dictionary
title = ["en": "Episode \(episodeNumber)"]
}
// Try to get image
if let imageUrl = episodeDetails["image"] as? String {
image = imageUrl
if imageUrl.isEmpty {
missingFields.append("image (empty string)")
}
} else {
missingFields.append("image")
// Use a default placeholder image
image = "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/dev/assets/banner1.png"
}
// Log if we're missing any fields
if !missingFields.isEmpty {
episodesWithMissingFields.append("Episode \(episodeNumber): missing \(missingFields.joined(separator: ", "))")
}
// Create metadata object with whatever we have
let metadataInfo = EpisodeMetadataInfo(
title: title,
imageUrl: image,
anilistId: anilistId,
episodeNumber: episodeNumber
)
results[episodeNumber] = metadataInfo
// Cache the metadata
if MetadataCacheManager.shared.isCachingEnabled {
let metadata = EpisodeMetadata(
title: title,
imageUrl: image,
anilistId: anilistId,
episodeNumber: episodeNumber
)
let cacheKey = "anilist_\(anilistId)_episode_\(episodeNumber)"
if let metadataData = metadata.toData() {
MetadataCacheManager.shared.storeMetadata(
metadataData,
forKey: cacheKey
)
}
}
} else {
missingEpisodes.append(episodeNumber)
}
}
// Log information about missing episodes
if !missingEpisodes.isEmpty {
Logger.shared.log("Episodes not found in response: \(missingEpisodes.map { String($0) }.joined(separator: ", "))", type: "Warning")
}
// Log information about episodes with missing fields
if !episodesWithMissingFields.isEmpty {
Logger.shared.log("Episodes with missing fields: \(episodesWithMissingFields.joined(separator: "; "))", type: "Warning")
}
// If we didn't get data for all requested episodes but got some, consider it a partial success
if results.count < episodeNumbers.count && results.count > 0 {
Logger.shared.log("Partial data received: \(results.count)/\(episodeNumbers.count) episodes", type: "Warning")
}
// If we didn't get any valid results, throw an error to trigger retry
if results.isEmpty {
throw NSError(domain: "com.sora.metadata", code: 7,
userInfo: [NSLocalizedDescriptionKey: "No valid episode data found in response"])
}
// Reset retry count on success (even partial)
self.currentRetryAttempts.removeValue(forKey: batchCacheKey)
return results
}
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { [weak self] result in
// Handle completion
guard let self = self else { return }
switch result {
case .finished:
break
case .failure(let error):
// Handle retry logic
var shouldRetry = false
let currentAttempt = self.currentRetryAttempts[batchCacheKey] ?? 0
// Check if we should retry based on the error and attempt count
if currentAttempt < self.maxRetryAttempts {
// Increment attempt counter
let nextAttempt = currentAttempt + 1
self.currentRetryAttempts[batchCacheKey] = nextAttempt
// Calculate backoff delay using exponential backoff
let backoffDelay = self.initialBackoffDelay * pow(2.0, Double(currentAttempt))
Logger.shared.log("Batch fetch failed, retrying (attempt \(nextAttempt)/\(self.maxRetryAttempts)) in \(backoffDelay) seconds", type: "Debug")
// Schedule retry after backoff delay
DispatchQueue.main.asyncAfter(deadline: .now() + backoffDelay) {
// Remove the current request before retrying
self.activeRequests.removeValue(forKey: batchCacheKey)
self.fetchBatchFromNetwork(anilistId: anilistId, episodeNumbers: episodeNumbers)
}
shouldRetry = true
} else {
// Max retries reached
Logger.shared.log("Batch fetch failed after \(self.maxRetryAttempts) attempts: \(error.localizedDescription)", type: "Error")
self.currentRetryAttempts.removeValue(forKey: batchCacheKey)
// Update all requested episodes with error
for episodeNumber in episodeNumbers {
let cacheKey = "anilist_\(anilistId)_episode_\(episodeNumber)"
self.metadataCache[cacheKey] = .failed(error)
}
}
if !shouldRetry {
// Remove from active requests
self.activeRequests.removeValue(forKey: batchCacheKey)
}
}
}, receiveValue: { [weak self] results in
// Update cache with results
for (episodeNumber, metadataInfo) in results {
let cacheKey = "anilist_\(anilistId)_episode_\(episodeNumber)"
self?.metadataCache[cacheKey] = .fetched(metadataInfo)
}
// Log the results
Logger.shared.log("Batch fetch completed with \(results.count) episodes", type: "Debug")
// Remove from active requests
self?.activeRequests.removeValue(forKey: batchCacheKey)
})
// Store publisher in active requests
activeRequests[batchCacheKey] = publisher
}
private func waitForRequest(cacheKey: String, completion: @escaping (Result<EpisodeMetadataInfo, Error>) -> Void) {
// Set up a timer to check the cache periodically
let timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] timer in
guard let self = self else {
timer.invalidate()
return
}
if let status = self.metadataCache[cacheKey] {
switch status {
case .fetched(let metadata):
// Request completed successfully
timer.invalidate()
completion(.success(metadata))
case .failed(let error):
// Request failed
timer.invalidate()
completion(.failure(error))
case .fetching, .notRequested:
// Still in progress
break
}
}
}
// Ensure timer fires even when scrolling
RunLoop.current.add(timer, forMode: .common)
}
}
// Extension to EpisodeMetadata for integration with the new manager
extension EpisodeMetadata {
func toData() -> Data? {
// Convert to EpisodeMetadataInfo first
let info = EpisodeMetadataInfo(
title: self.title,
imageUrl: self.imageUrl,
anilistId: self.anilistId,
episodeNumber: self.episodeNumber
)
// Then encode to Data
return try? JSONEncoder().encode(info)
}
static func fromData(_ data: Data) -> EpisodeMetadata? {
guard let info = try? JSONDecoder().decode(EpisodeMetadataInfo.self, from: data) else {
return nil
}
return EpisodeMetadata(
title: info.title,
imageUrl: info.imageUrl,
anilistId: info.anilistId,
episodeNumber: info.episodeNumber
)
}
}

View file

@ -1,134 +0,0 @@
//
// ImagePrefetchManager.swift
// Sora
//
// Created by doomsboygaming on 5/22/25
//
import Foundation
import Kingfisher
import UIKit
/// Manager for image prefetching, caching, and optimization
class ImagePrefetchManager {
static let shared = ImagePrefetchManager()
// Prefetcher for batch prefetching images
private let prefetcher = ImagePrefetcher(
urls: [],
options: [
.processor(DownsamplingImageProcessor(size: CGSize(width: 100, height: 56))),
.scaleFactor(UIScreen.main.scale),
.cacheOriginalImage
]
)
// Keep track of what's already prefetched to avoid duplication
private var prefetchedURLs = Set<URL>()
private let prefetchQueue = DispatchQueue(label: "com.sora.imagePrefetch", qos: .utility)
init() {
// Set up KingfisherManager for optimal image loading
ImageCache.default.memoryStorage.config.totalCostLimit = 300 * 1024 * 1024 // 300MB
ImageCache.default.diskStorage.config.sizeLimit = 1000 * 1024 * 1024 // 1GB
ImageDownloader.default.downloadTimeout = 15.0 // 15 seconds
}
/// Prefetch a batch of images
func prefetchImages(_ urls: [String]) {
prefetchQueue.async { [weak self] in
guard let self = self else { return }
// Filter out already prefetched URLs and invalid URLs
let urlObjects = urls.compactMap { URL(string: $0) }
.filter { !self.prefetchedURLs.contains($0) }
guard !urlObjects.isEmpty else { return }
// Create a new prefetcher with the URLs and start it
let newPrefetcher = ImagePrefetcher(
urls: urlObjects,
options: [
.processor(DownsamplingImageProcessor(size: CGSize(width: 100, height: 56))),
.scaleFactor(UIScreen.main.scale),
.cacheOriginalImage
]
)
newPrefetcher.start()
// Track prefetched URLs
urlObjects.forEach { self.prefetchedURLs.insert($0) }
}
}
/// Prefetch a single image
func prefetchImage(_ url: String) {
guard let urlObject = URL(string: url),
!prefetchedURLs.contains(urlObject) else {
return
}
prefetchQueue.async { [weak self] in
guard let self = self else { return }
// Create a new prefetcher with the URL and start it
let newPrefetcher = ImagePrefetcher(
urls: [urlObject],
options: [
.processor(DownsamplingImageProcessor(size: CGSize(width: 100, height: 56))),
.scaleFactor(UIScreen.main.scale),
.cacheOriginalImage
]
)
newPrefetcher.start()
// Track prefetched URL
self.prefetchedURLs.insert(urlObject)
}
}
/// Prefetch episode images for a batch of episodes
func prefetchEpisodeImages(anilistId: Int, startEpisode: Int, count: Int) {
prefetchQueue.async { [weak self] in
guard let self = self else { return }
// Get metadata for episodes in the range
for episodeNumber in startEpisode...(startEpisode + count) where episodeNumber > 0 {
EpisodeMetadataManager.shared.fetchMetadata(anilistId: anilistId, episodeNumber: episodeNumber) { result in
switch result {
case .success(let metadata):
self.prefetchImage(metadata.imageUrl)
case .failure:
break
}
}
}
}
}
/// Clear prefetch queue and stop any ongoing prefetch operations
func cancelPrefetching() {
prefetcher.stop()
}
}
// MARK: - KFImage Extension
extension KFImage {
/// Load an image with optimal settings for episode thumbnails
static func optimizedEpisodeThumbnail(url: URL?) -> KFImage {
return KFImage(url)
.setProcessor(DownsamplingImageProcessor(size: CGSize(width: 100, height: 56)))
.memoryCacheExpiration(.seconds(300))
.cacheOriginalImage()
.fade(duration: 0.25)
.onProgress { _, _ in
// Track progress if needed
}
.onSuccess { _ in
// Success logger removed to reduce logs
}
.onFailure { error in
Logger.shared.log("Failed to load image: \(error)", type: "Error")
}
}
}

View file

@ -1,510 +0,0 @@
//
// PerformanceMonitor.swift
// Sora
//
// Created by doomsboygaming on 5/22/25
//
import Foundation
import SwiftUI
import Kingfisher
import QuartzCore
/// Performance metrics tracking system with advanced jitter detection
class PerformanceMonitor: ObservableObject {
static let shared = PerformanceMonitor()
// Published properties to allow UI observation
@Published private(set) var networkRequestCount: Int = 0
@Published private(set) var cacheHitCount: Int = 0
@Published private(set) var cacheMissCount: Int = 0
@Published private(set) var averageLoadTime: TimeInterval = 0
@Published private(set) var memoryUsage: UInt64 = 0
@Published private(set) var diskUsage: UInt64 = 0
@Published private(set) var isEnabled: Bool = false
// Advanced performance metrics for jitter detection
@Published private(set) var currentFPS: Double = 60.0
@Published private(set) var mainThreadBlocks: Int = 0
@Published private(set) var memorySpikes: Int = 0
@Published private(set) var cpuUsage: Double = 0.0
@Published private(set) var jitterEvents: Int = 0
// Internal tracking properties
private var loadTimes: [TimeInterval] = []
private var startTimes: [String: Date] = [:]
private var memoryTimer: Timer?
private var logTimer: Timer?
// Advanced monitoring properties
private var displayLink: CADisplayLink?
private var frameCount: Int = 0
private var lastFrameTime: CFTimeInterval = 0
private var frameTimes: [CFTimeInterval] = []
private var lastMemoryUsage: UInt64 = 0
private var mainThreadOperations: [String: CFTimeInterval] = [:]
private var cpuTimer: Timer?
// Thresholds for performance issues
private let mainThreadBlockingThreshold: TimeInterval = 0.016 // 16ms for 60fps
private let memorySpikeTreshold: UInt64 = 50 * 1024 * 1024 // 50MB spike
private let fpsThreshold: Double = 50.0 // Below 50fps is considered poor
private init() {
// Default is off unless explicitly enabled
isEnabled = UserDefaults.standard.bool(forKey: "enablePerformanceMonitoring")
// Setup memory monitoring if enabled
if isEnabled {
startMonitoring()
}
}
// MARK: - Public Methods
/// Enable or disable the performance monitoring
func setEnabled(_ enabled: Bool) {
isEnabled = enabled
UserDefaults.standard.set(enabled, forKey: "enablePerformanceMonitoring")
if enabled {
startMonitoring()
} else {
stopMonitoring()
}
}
/// Reset all tracked metrics
func resetMetrics() {
networkRequestCount = 0
cacheHitCount = 0
cacheMissCount = 0
averageLoadTime = 0
loadTimes = []
startTimes = [:]
// Reset advanced metrics
mainThreadBlocks = 0
memorySpikes = 0
jitterEvents = 0
frameTimes = []
frameCount = 0
mainThreadOperations = [:]
updateMemoryUsage()
Logger.shared.log("Performance metrics reset", type: "Debug")
}
/// Track a network request starting
func trackRequestStart(identifier: String) {
guard isEnabled else { return }
networkRequestCount += 1
startTimes[identifier] = Date()
}
/// Track a network request completing
func trackRequestEnd(identifier: String) {
guard isEnabled, let startTime = startTimes[identifier] else { return }
let endTime = Date()
let duration = endTime.timeIntervalSince(startTime)
loadTimes.append(duration)
// Update average load time
if !loadTimes.isEmpty {
averageLoadTime = loadTimes.reduce(0, +) / Double(loadTimes.count)
}
// Remove start time to avoid memory leaks
startTimes.removeValue(forKey: identifier)
}
/// Track a cache hit
func trackCacheHit() {
guard isEnabled else { return }
cacheHitCount += 1
}
/// Track a cache miss
func trackCacheMiss() {
guard isEnabled else { return }
cacheMissCount += 1
}
// MARK: - Advanced Performance Monitoring
/// Track the start of a main thread operation
func trackMainThreadOperationStart(operation: String) {
guard isEnabled else { return }
mainThreadOperations[operation] = CACurrentMediaTime()
}
/// Track the end of a main thread operation and detect blocking
func trackMainThreadOperationEnd(operation: String) {
guard isEnabled, let startTime = mainThreadOperations[operation] else { return }
let endTime = CACurrentMediaTime()
let duration = endTime - startTime
if duration > mainThreadBlockingThreshold {
mainThreadBlocks += 1
jitterEvents += 1
let durationMs = Int(duration * 1000)
Logger.shared.log("🚨 Main thread blocked for \(durationMs)ms during: \(operation)", type: "Performance")
}
mainThreadOperations.removeValue(forKey: operation)
}
/// Track memory spikes during downloads
func checkMemorySpike() {
guard isEnabled else { return }
let currentMemory = getAppMemoryUsage()
if lastMemoryUsage > 0 {
let spike = currentMemory > lastMemoryUsage ? currentMemory - lastMemoryUsage : 0
if spike > memorySpikeTreshold {
memorySpikes += 1
jitterEvents += 1
let spikeSize = Double(spike) / (1024 * 1024)
Logger.shared.log("🚨 Memory spike detected: +\(String(format: "%.1f", spikeSize))MB", type: "Performance")
}
}
lastMemoryUsage = currentMemory
memoryUsage = currentMemory
}
/// Start frame rate monitoring
private func startFrameRateMonitoring() {
guard displayLink == nil else { return }
displayLink = CADisplayLink(target: self, selector: #selector(frameCallback))
displayLink?.add(to: .main, forMode: .common)
frameCount = 0
lastFrameTime = CACurrentMediaTime()
frameTimes = []
}
/// Stop frame rate monitoring
private func stopFrameRateMonitoring() {
displayLink?.invalidate()
displayLink = nil
}
/// Frame callback for FPS monitoring
@objc private func frameCallback() {
let currentTime = CACurrentMediaTime()
if lastFrameTime > 0 {
let frameDuration = currentTime - lastFrameTime
frameTimes.append(frameDuration)
// Keep only last 60 frames for rolling average
if frameTimes.count > 60 {
frameTimes.removeFirst()
}
// Calculate current FPS
if !frameTimes.isEmpty {
let averageFrameTime = frameTimes.reduce(0, +) / Double(frameTimes.count)
currentFPS = 1.0 / averageFrameTime
// Detect FPS drops
if currentFPS < fpsThreshold {
jitterEvents += 1
Logger.shared.log("🚨 FPS drop detected: \(String(format: "%.1f", currentFPS))fps", type: "Performance")
}
}
}
lastFrameTime = currentTime
frameCount += 1
}
/// Get current CPU usage
private func getCPUUsage() -> Double {
var info = mach_task_basic_info()
var count = mach_msg_type_number_t(MemoryLayout<mach_task_basic_info>.size) / 4
let kerr: kern_return_t = withUnsafeMutablePointer(to: &info) {
$0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
task_info(mach_task_self_, task_flavor_t(MACH_TASK_BASIC_INFO), $0, &count)
}
}
if kerr == KERN_SUCCESS {
// This is a simplified CPU usage calculation
// For more accurate results, we'd need to track over time
return Double(info.user_time.seconds + info.system_time.seconds)
} else {
return 0.0
}
}
/// Get the current cache hit rate
var cacheHitRate: Double {
let total = cacheHitCount + cacheMissCount
guard total > 0 else { return 0 }
return Double(cacheHitCount) / Double(total)
}
/// Log current performance metrics
func logMetrics() {
guard isEnabled else { return }
checkMemorySpike()
let hitRate = String(format: "%.1f%%", cacheHitRate * 100)
let avgLoad = String(format: "%.2f", averageLoadTime)
let memory = String(format: "%.1f MB", Double(memoryUsage) / (1024 * 1024))
let disk = String(format: "%.1f MB", Double(diskUsage) / (1024 * 1024))
let fps = String(format: "%.1f", currentFPS)
let cpu = String(format: "%.1f%%", cpuUsage)
let metrics = """
📊 Performance Metrics Report:
Network & Cache:
- Network Requests: \(networkRequestCount)
- Cache Hit Rate: \(hitRate) (\(cacheHitCount)/\(cacheHitCount + cacheMissCount))
- Average Load Time: \(avgLoad)s
System Resources:
- Memory Usage: \(memory)
- Disk Usage: \(disk)
- CPU Usage: \(cpu)
Performance Issues:
- Current FPS: \(fps)
- Main Thread Blocks: \(mainThreadBlocks)
- Memory Spikes: \(memorySpikes)
- Total Jitter Events: \(jitterEvents)
"""
Logger.shared.log(metrics, type: "Performance")
// Alert if performance is poor
if jitterEvents > 0 {
Logger.shared.log("⚠️ Performance issues detected! Check logs above for details.", type: "Warning")
}
}
// MARK: - Private Methods
private func startMonitoring() {
// Setup timer to update memory usage periodically and check for spikes
memoryTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] _ in
self?.checkMemorySpike()
}
// Setup timer to log metrics periodically
logTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in
self?.logMetrics()
}
// Setup CPU monitoring timer
cpuTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] _ in
self?.cpuUsage = self?.getCPUUsage() ?? 0.0
}
// Make sure timers run even when scrolling
RunLoop.current.add(memoryTimer!, forMode: .common)
RunLoop.current.add(logTimer!, forMode: .common)
RunLoop.current.add(cpuTimer!, forMode: .common)
// Start frame rate monitoring
startFrameRateMonitoring()
Logger.shared.log("Advanced performance monitoring started - tracking FPS, main thread blocks, memory spikes", type: "Debug")
}
private func stopMonitoring() {
memoryTimer?.invalidate()
memoryTimer = nil
logTimer?.invalidate()
logTimer = nil
cpuTimer?.invalidate()
cpuTimer = nil
stopFrameRateMonitoring()
Logger.shared.log("Performance monitoring stopped", type: "Debug")
}
private func updateMemoryUsage() {
memoryUsage = getAppMemoryUsage()
diskUsage = getCacheDiskUsage()
}
private func getAppMemoryUsage() -> UInt64 {
var info = mach_task_basic_info()
var count = mach_msg_type_number_t(MemoryLayout<mach_task_basic_info>.size) / 4
let kerr: kern_return_t = withUnsafeMutablePointer(to: &info) {
$0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
task_info(mach_task_self_, task_flavor_t(MACH_TASK_BASIC_INFO), $0, &count)
}
}
if kerr == KERN_SUCCESS {
return info.resident_size
} else {
return 0
}
}
private func getCacheDiskUsage() -> UInt64 {
// Try to get Kingfisher's disk cache size
let diskCache = ImageCache.default.diskStorage
do {
let size = try diskCache.totalSize()
return UInt64(size)
} catch {
Logger.shared.log("Failed to get disk cache size: \(error)", type: "Error")
return 0
}
}
}
// MARK: - Extensions to integrate with managers
extension EpisodeMetadataManager {
/// Integrate performance tracking
func trackFetchStart(anilistId: Int, episodeNumber: Int) {
let identifier = "metadata_\(anilistId)_\(episodeNumber)"
PerformanceMonitor.shared.trackRequestStart(identifier: identifier)
}
func trackFetchEnd(anilistId: Int, episodeNumber: Int) {
let identifier = "metadata_\(anilistId)_\(episodeNumber)"
PerformanceMonitor.shared.trackRequestEnd(identifier: identifier)
}
func trackCacheHit() {
PerformanceMonitor.shared.trackCacheHit()
}
func trackCacheMiss() {
PerformanceMonitor.shared.trackCacheMiss()
}
}
extension ImagePrefetchManager {
/// Integrate performance tracking
func trackImageLoadStart(url: String) {
let identifier = "image_\(url.hashValue)"
PerformanceMonitor.shared.trackRequestStart(identifier: identifier)
}
func trackImageLoadEnd(url: String) {
let identifier = "image_\(url.hashValue)"
PerformanceMonitor.shared.trackRequestEnd(identifier: identifier)
}
func trackImageCacheHit() {
PerformanceMonitor.shared.trackCacheHit()
}
func trackImageCacheMiss() {
PerformanceMonitor.shared.trackCacheMiss()
}
}
// MARK: - Debug View
struct PerformanceMetricsView: View {
@ObservedObject private var monitor = PerformanceMonitor.shared
@State private var isExpanded = false
var body: some View {
VStack {
HStack {
Text("Performance Metrics")
.font(.headline)
Spacer()
Button(action: {
isExpanded.toggle()
}) {
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
}
}
.padding(.horizontal)
if isExpanded {
VStack(alignment: .leading, spacing: 4) {
Text("Network Requests: \(monitor.networkRequestCount)")
Text("Cache Hit Rate: \(Int(monitor.cacheHitRate * 100))%")
Text("Avg Load Time: \(String(format: "%.2f", monitor.averageLoadTime))s")
Text("Memory: \(String(format: "%.1f MB", Double(monitor.memoryUsage) / (1024 * 1024)))")
Divider()
// Advanced metrics
Text("FPS: \(String(format: "%.1f", monitor.currentFPS))")
.foregroundColor(monitor.currentFPS < 50 ? .red : .primary)
Text("Main Thread Blocks: \(monitor.mainThreadBlocks)")
.foregroundColor(monitor.mainThreadBlocks > 0 ? .red : .primary)
Text("Memory Spikes: \(monitor.memorySpikes)")
.foregroundColor(monitor.memorySpikes > 0 ? .orange : .primary)
Text("Jitter Events: \(monitor.jitterEvents)")
.foregroundColor(monitor.jitterEvents > 0 ? .red : .primary)
HStack {
Button(action: {
monitor.resetMetrics()
}) {
Text("Reset")
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(4)
}
Button(action: {
monitor.logMetrics()
}) {
Text("Log")
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.green)
.foregroundColor(.white)
.cornerRadius(4)
}
Toggle("", isOn: Binding(
get: { monitor.isEnabled },
set: { monitor.setEnabled($0) }
))
.labelsHidden()
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal)
.padding(.bottom, 8)
.background(Color.secondary.opacity(0.1))
.cornerRadius(8)
.padding(.horizontal)
}
}
.padding(.vertical, 4)
.background(Color.secondary.opacity(0.05))
.cornerRadius(8)
.padding(8)
}
}

View file

@ -0,0 +1,7 @@
struct EpisodeLink: Identifiable {
let id = UUID()
let number: Int
let title: String
let href: String
let duration: Int?
}

View file

@ -4,13 +4,15 @@
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.assets.movies.read-write</key>
<true/>
<key>com.apple.security.assets.music.read-write</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
</dict>
</plist>

View file

@ -8,8 +8,49 @@
import SwiftUI
import UIKit
class OrientationManager: ObservableObject {
static let shared = OrientationManager()
@Published var isLocked = false
private var lockedOrientation: UIInterfaceOrientationMask = .all
private init() {}
func lockOrientation() {
let currentOrientation = UIApplication.shared.windows.first?.windowScene?.interfaceOrientation ?? .portrait
switch currentOrientation {
case .portrait, .portraitUpsideDown:
lockedOrientation = .portrait
case .landscapeLeft, .landscapeRight:
lockedOrientation = .landscape
default:
lockedOrientation = .portrait
}
isLocked = true
UIDevice.current.setValue(currentOrientation.rawValue, forKey: "orientation")
UIViewController.attemptRotationToDeviceOrientation()
}
func unlockOrientation(after delay: TimeInterval = 0.0) {
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
self.isLocked = false
self.lockedOrientation = .all
UIViewController.attemptRotationToDeviceOrientation()
}
}
func supportedOrientations() -> UIInterfaceOrientationMask {
return isLocked ? lockedOrientation : .all
}
}
@main
struct SoraApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@StateObject private var settings = Settings()
@StateObject private var moduleManager = ModuleManager()
@StateObject private var librarykManager = LibraryManager()
@ -17,9 +58,6 @@ struct SoraApp: App {
@StateObject private var jsController = JSController.shared
init() {
_ = MetadataCacheManager.shared
_ = KingfisherCacheManager.shared
if let userAccentColor = UserDefaults.standard.color(forKey: "accentColor") {
UIView.appearance(whenContainedInInstancesOf: [UIAlertController.self]).tintColor = userAccentColor
}
@ -136,9 +174,18 @@ struct SoraApp: App {
}
}
}
class AppInfo: NSObject {
@objc func getBundleIdentifier() -> String {
return Bundle.main.bundleIdentifier ?? "me.cranci.sulfur"
}
@objc func getDisplayName() -> String {
return Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as! String
}
}
class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask {
return OrientationManager.shared.supportedOrientations()
}
}

View file

@ -33,32 +33,41 @@ class AniListMutation {
return String(data: tokenData, encoding: .utf8)
}
func updateAnimeProgress(animeId: Int, episodeNumber: Int, completion: @escaping (Result<Void, Error>) -> Void) {
if let sendPushUpdates = UserDefaults.standard.object(forKey: "sendPushUpdates") as? Bool,
sendPushUpdates == false {
return
}
guard let userToken = getTokenFromKeychain() else {
completion(.failure(NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "Access token not found"])))
return
}
let query = """
mutation ($mediaId: Int, $progress: Int, $status: MediaListStatus) {
SaveMediaListEntry (mediaId: $mediaId, progress: $progress, status: $status) {
id
progress
status
func updateAnimeProgress(
animeId: Int,
episodeNumber: Int,
status: String = "CURRENT",
completion: @escaping (Result<Void, Error>) -> Void
) {
if let sendPushUpdates = UserDefaults.standard.object(forKey: "sendPushUpdates") as? Bool,
sendPushUpdates == false {
return
}
}
"""
let variables: [String: Any] = [
guard let userToken = getTokenFromKeychain() else {
completion(.failure(NSError(
domain: "",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Access token not found"]
)))
return
}
let query = """
mutation ($mediaId: Int, $progress: Int, $status: MediaListStatus) {
SaveMediaListEntry(mediaId: $mediaId, progress: $progress, status: $status) {
id
progress
status
}
}
"""
let variables: [String: Any] = [
"mediaId": animeId,
"progress": episodeNumber,
"status": "CURRENT"
]
"status": status
]
let requestBody: [String: Any] = [
"query": query,
@ -104,6 +113,52 @@ class AniListMutation {
task.resume()
}
func fetchMediaStatus(
mediaId: Int,
completion: @escaping (Result<String, Error>) -> Void
) {
guard let token = getTokenFromKeychain() else {
completion(.failure(NSError(
domain: "", code: -1,
userInfo: [NSLocalizedDescriptionKey: "Access token not found"]
)))
return
}
let query = """
query ($mediaId: Int) {
Media(id: $mediaId) {
status
}
}
"""
let vars = ["mediaId": mediaId]
var req = URLRequest(url: URL(string: "https://graphql.anilist.co")!)
req.httpMethod = "POST"
req.addValue("application/json", forHTTPHeaderField: "Content-Type")
req.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
req.httpBody = try? JSONSerialization.data(
withJSONObject: ["query": query, "variables": vars]
)
URLSession.shared.dataTask(with: req) { data, _, error in
if let e = error { return completion(.failure(e)) }
guard
let d = data,
let json = try? JSONSerialization.jsonObject(with: d) as? [String: Any],
let md = (json["data"] as? [String: Any])?["Media"] as? [String: Any],
let status = md["status"] as? String
else {
return completion(.failure(NSError(
domain: "", code: -2,
userInfo: [NSLocalizedDescriptionKey: "Invalid response"]
)))
}
completion(.success(status))
}.resume()
}
func fetchMalID(animeId: Int, completion: @escaping (Result<Int, Error>) -> Void) {
let query = """
query ($id: Int) {
@ -148,3 +203,10 @@ class AniListMutation {
let data: DataField
}
}
struct EpisodeMetadataInfo: Codable, Equatable {
let title: [String: String]
let imageUrl: String
let anilistId: Int
let episodeNumber: Int
}

View file

@ -0,0 +1,104 @@
//
// TMDB-FetchID.swift
// Sulfur
//
// Created by Francesco on 01/06/25.
//
import Foundation
class TMDBFetcher {
enum MediaType: String, CaseIterable {
case tv, movie
}
struct TMDBResult: Decodable {
let id: Int
let name: String?
let title: String?
let popularity: Double
}
struct TMDBResponse: Decodable {
let results: [TMDBResult]
}
private let apiKey = "738b4edd0a156cc126dc4a4b8aea4aca"
private let session = URLSession.custom
func fetchBestMatchID(for title: String, completion: @escaping (Int?, MediaType?) -> Void) {
let group = DispatchGroup()
var bestResults: [(id: Int, score: Double, type: MediaType)] = []
for type in MediaType.allCases {
group.enter()
fetchBestMatchID(for: title, type: type) { id, score in
if let id = id, let score = score {
bestResults.append((id, score, type))
}
group.leave()
}
}
group.notify(queue: .main) {
let best = bestResults.max { $0.score < $1.score }
completion(best?.id, best?.type)
}
}
private func fetchBestMatchID(for title: String, type: MediaType, completion: @escaping (Int?, Double?) -> Void) {
let query = title.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? ""
let urlString = "https://api.themoviedb.org/3/search/\(type.rawValue)?api_key=\(apiKey)&query=\(query)"
guard let url = URL(string: urlString) else {
completion(nil, nil)
return
}
session.dataTask(with: url) { data, _, error in
guard let data = data, error == nil else {
completion(nil, nil)
return
}
do {
let response = try JSONDecoder().decode(TMDBResponse.self, from: data)
let scored = response.results.map { result -> (Int, Double) in
let candidateTitle = type == .tv ? result.name ?? "" : result.title ?? ""
let similarity = TMDBFetcher.titleSimilarity(title, candidateTitle)
let score = (similarity * 0.7) + ((result.popularity / 100.0) * 0.3)
return (result.id, score)
}
let best = scored.max { $0.1 < $1.1 }
completion(best?.0, best?.1)
} catch {
completion(nil, nil)
}
}.resume()
}
static func titleSimilarity(_ a: String, _ b: String) -> Double {
let lowerA = a.lowercased()
let lowerB = b.lowercased()
let distance = Double(levenshtein(lowerA, lowerB))
let maxLen = Double(max(lowerA.count, lowerB.count))
if maxLen == 0 { return 1.0 }
return 1.0 - (distance / maxLen)
}
static func levenshtein(_ a: String, _ b: String) -> Int {
let a = Array(a)
let b = Array(b)
var dist = Array(repeating: Array(repeating: 0, count: b.count + 1), count: a.count + 1)
for i in 0...a.count { dist[i][0] = i }
for j in 0...b.count { dist[0][j] = j }
for i in 1...a.count {
for j in 1...b.count {
if a[i-1] == b[j-1] {
dist[i][j] = dist[i-1][j-1]
} else {
dist[i][j] = min(dist[i-1][j-1], dist[i][j-1], dist[i-1][j]) + 1
}
}
}
return dist[a.count][b.count]
}
}

View file

@ -1,45 +0,0 @@
//
// EpisodeMetadata.swift
// Sora
//
// Created by doomsboygaming on 5/22/25
//
import Foundation
/// Represents metadata for an episode, used for caching
struct EpisodeMetadata: Codable {
/// Title of the episode
let title: [String: String]
/// Image URL for the episode
let imageUrl: String
/// AniList ID of the show
let anilistId: Int
/// Episode number
let episodeNumber: Int
/// When this metadata was cached
let cacheDate: Date
/// Unique cache key for this episode metadata
var cacheKey: String {
return "anilist_\(anilistId)_episode_\(episodeNumber)"
}
/// Initialize with the basic required data
/// - Parameters:
/// - title: Dictionary of titles by language code
/// - imageUrl: URL of the episode thumbnail image
/// - anilistId: ID of the show in AniList
/// - episodeNumber: Number of the episode
init(title: [String: String], imageUrl: String, anilistId: Int, episodeNumber: Int) {
self.title = title
self.imageUrl = imageUrl
self.anilistId = anilistId
self.episodeNumber = episodeNumber
self.cacheDate = Date()
}
}

View file

@ -1,95 +0,0 @@
//
// KingfisherManager.swift
// Sora
//
// Created by doomsboygaming on 5/22/25
//
import Foundation
import Kingfisher
import SwiftUI
/// Manages Kingfisher image caching configuration
class KingfisherCacheManager {
static let shared = KingfisherCacheManager()
/// Maximum disk cache size (default 500MB)
private let maxDiskCacheSize: UInt = 500 * 1024 * 1024
/// Maximum cache age (default 7 days)
private let maxCacheAgeInDays: TimeInterval = 7
/// UserDefaults keys
private let imageCachingEnabledKey = "imageCachingEnabled"
/// Whether image caching is enabled
var isCachingEnabled: Bool {
get {
// Default to true if not set
UserDefaults.standard.object(forKey: imageCachingEnabledKey) == nil ?
true : UserDefaults.standard.bool(forKey: imageCachingEnabledKey)
}
set {
UserDefaults.standard.set(newValue, forKey: imageCachingEnabledKey)
configureKingfisher()
}
}
private init() {
configureKingfisher()
}
/// Configure Kingfisher with appropriate caching settings
func configureKingfisher() {
let cache = ImageCache.default
// Set disk cache size limit and expiration
cache.diskStorage.config.sizeLimit = isCachingEnabled ? maxDiskCacheSize : 0
cache.diskStorage.config.expiration = isCachingEnabled ?
.days(Int(maxCacheAgeInDays)) : .seconds(1) // 1 second means effectively disabled
// Set memory cache size
cache.memoryStorage.config.totalCostLimit = isCachingEnabled ?
30 * 1024 * 1024 : 0 // 30MB memory cache when enabled
// Configure clean interval
cache.memoryStorage.config.cleanInterval = 60 // Clean memory every 60 seconds
// Configure retry strategy
KingfisherManager.shared.downloader.downloadTimeout = 15.0 // 15 second timeout
Logger.shared.log("Configured Kingfisher cache. Enabled: \(isCachingEnabled)", type: "Debug")
}
/// Clear all cached images
func clearCache(completion: (() -> Void)? = nil) {
KingfisherManager.shared.cache.clearMemoryCache()
KingfisherManager.shared.cache.clearDiskCache {
Logger.shared.log("Cleared Kingfisher image cache", type: "General")
completion?()
}
}
/// Calculate current cache size
/// - Parameter completion: Closure to call with cache size in bytes
func calculateCacheSize(completion: @escaping (UInt) -> Void) {
KingfisherManager.shared.cache.calculateDiskStorageSize { result in
switch result {
case .success(let size):
completion(size)
case .failure(let error):
Logger.shared.log("Failed to calculate image cache size: \(error)", type: "Error")
completion(0)
}
}
}
/// Convert cache size to user-friendly string
/// - Parameter sizeInBytes: Size in bytes
/// - Returns: Formatted string (e.g., "5.2 MB")
static func formatCacheSize(_ sizeInBytes: UInt) -> String {
let formatter = ByteCountFormatter()
formatter.countStyle = .file
return formatter.string(fromByteCount: Int64(sizeInBytes))
}
}

View file

@ -1,276 +0,0 @@
//
// MetadataCacheManager.swift
// Sora
//
// Created by doomsboygaming on 5/22/25
//
import Foundation
import SwiftUI
/// A class to manage episode metadata caching, both in-memory and on disk
class MetadataCacheManager {
static let shared = MetadataCacheManager()
// In-memory cache
private let memoryCache = NSCache<NSString, NSData>()
// File manager for disk operations
private let fileManager = FileManager.default
// Cache directory URL
private var cacheDirectory: URL
// Cache expiration - 7 days by default
private let maxCacheAge: TimeInterval = 7 * 24 * 60 * 60
// UserDefaults keys
private let metadataCachingEnabledKey = "metadataCachingEnabled"
private let memoryOnlyModeKey = "metadataMemoryOnlyCache"
private let lastCacheCleanupKey = "lastMetadataCacheCleanup"
// Analytics counters
private(set) var cacheHits: Int = 0
private(set) var cacheMisses: Int = 0
// MARK: - Public properties
/// Whether metadata caching is enabled (persisted in UserDefaults)
var isCachingEnabled: Bool {
get {
// Default to true if not set
UserDefaults.standard.object(forKey: metadataCachingEnabledKey) == nil ?
true : UserDefaults.standard.bool(forKey: metadataCachingEnabledKey)
}
set {
UserDefaults.standard.set(newValue, forKey: metadataCachingEnabledKey)
}
}
/// Whether to use memory-only mode (no disk caching)
var isMemoryOnlyMode: Bool {
get {
UserDefaults.standard.bool(forKey: memoryOnlyModeKey)
}
set {
UserDefaults.standard.set(newValue, forKey: memoryOnlyModeKey)
}
}
// MARK: - Initialization
private init() {
// Set up cache directory
do {
let cachesDirectory = try fileManager.url(
for: .cachesDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
cacheDirectory = cachesDirectory.appendingPathComponent("EpisodeMetadata", isDirectory: true)
// Create the directory if it doesn't exist
if !fileManager.fileExists(atPath: cacheDirectory.path) {
try fileManager.createDirectory(at: cacheDirectory,
withIntermediateDirectories: true,
attributes: nil)
}
// Set up memory cache
memoryCache.name = "EpisodeMetadataCache"
memoryCache.countLimit = 100 // Limit number of items in memory
// Clean up old files if needed
cleanupOldCacheFilesIfNeeded()
} catch {
Logger.shared.log("Failed to set up metadata cache directory: \(error)", type: "Error")
// Fallback to temporary directory
cacheDirectory = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("EpisodeMetadata")
}
}
// MARK: - Public Methods
/// Store metadata in the cache
/// - Parameters:
/// - data: The metadata to cache
/// - key: The cache key (usually anilist_id + episode_number)
func storeMetadata(_ data: Data, forKey key: String) {
guard isCachingEnabled else { return }
let keyString = key as NSString
// Always store in memory cache
memoryCache.setObject(data as NSData, forKey: keyString)
// Store on disk if not in memory-only mode
if !isMemoryOnlyMode {
let fileURL = cacheDirectory.appendingPathComponent(key)
DispatchQueue.global(qos: .background).async { [weak self] in
do {
try data.write(to: fileURL)
// Add timestamp as a file attribute instead of using extended attributes
let attributes: [FileAttributeKey: Any] = [
.creationDate: Date()
]
try self?.fileManager.setAttributes(attributes, ofItemAtPath: fileURL.path)
Logger.shared.log("Metadata cached for key: \(key)", type: "Debug")
} catch {
Logger.shared.log("Failed to write metadata to disk: \(error)", type: "Error")
}
}
}
}
/// Retrieve metadata from cache
/// - Parameter key: The cache key
/// - Returns: The cached metadata if available and not expired, nil otherwise
func getMetadata(forKey key: String) -> Data? {
guard isCachingEnabled else {
return nil
}
let keyString = key as NSString
// Try memory cache first
if let cachedData = memoryCache.object(forKey: keyString) as Data? {
return cachedData
}
// If not in memory and not in memory-only mode, try disk
if !isMemoryOnlyMode {
let fileURL = cacheDirectory.appendingPathComponent(key)
do {
// Check if file exists
if fileManager.fileExists(atPath: fileURL.path) {
// Check if the file is not expired
if !isFileExpired(at: fileURL) {
let data = try Data(contentsOf: fileURL)
// Store in memory cache for faster access next time
memoryCache.setObject(data as NSData, forKey: keyString)
return data
} else {
// File is expired, remove it
try fileManager.removeItem(at: fileURL)
}
}
} catch {
Logger.shared.log("Error accessing disk cache: \(error)", type: "Error")
}
}
return nil
}
/// Clear all cached metadata
func clearAllCache() {
// Clear memory cache
memoryCache.removeAllObjects()
// Clear disk cache
if !isMemoryOnlyMode {
do {
let fileURLs = try fileManager.contentsOfDirectory(at: cacheDirectory,
includingPropertiesForKeys: nil,
options: .skipsHiddenFiles)
for fileURL in fileURLs {
try fileManager.removeItem(at: fileURL)
}
Logger.shared.log("Cleared all metadata cache", type: "General")
} catch {
Logger.shared.log("Failed to clear disk cache: \(error)", type: "Error")
}
}
// Reset analytics
cacheHits = 0
cacheMisses = 0
}
/// Clear expired cache entries
func clearExpiredCache() {
guard !isMemoryOnlyMode else { return }
do {
let fileURLs = try fileManager.contentsOfDirectory(at: cacheDirectory,
includingPropertiesForKeys: nil,
options: .skipsHiddenFiles)
var removedCount = 0
for fileURL in fileURLs {
if isFileExpired(at: fileURL) {
try fileManager.removeItem(at: fileURL)
removedCount += 1
}
}
if removedCount > 0 {
Logger.shared.log("Cleared \(removedCount) expired metadata cache items", type: "General")
}
} catch {
Logger.shared.log("Failed to clear expired cache: \(error)", type: "Error")
}
}
/// Get the total size of the cache on disk
/// - Returns: Size in bytes
func getCacheSize() -> Int64 {
guard !isMemoryOnlyMode else { return 0 }
do {
let fileURLs = try fileManager.contentsOfDirectory(at: cacheDirectory,
includingPropertiesForKeys: [.fileSizeKey],
options: .skipsHiddenFiles)
return fileURLs.reduce(0) { result, url in
do {
let attributes = try url.resourceValues(forKeys: [.fileSizeKey])
return result + Int64(attributes.fileSize ?? 0)
} catch {
return result
}
}
} catch {
Logger.shared.log("Failed to calculate cache size: \(error)", type: "Error")
return 0
}
}
// MARK: - Private Helper Methods
private func isFileExpired(at url: URL) -> Bool {
do {
let attributes = try fileManager.attributesOfItem(atPath: url.path)
if let creationDate = attributes[.creationDate] as? Date {
return Date().timeIntervalSince(creationDate) > maxCacheAge
}
return true // If can't determine age, consider it expired
} catch {
return true // If error reading attributes, consider it expired
}
}
private func cleanupOldCacheFilesIfNeeded() {
// Only run cleanup once a day
let lastCleanupTime = UserDefaults.standard.double(forKey: lastCacheCleanupKey)
let dayInSeconds: TimeInterval = 24 * 60 * 60
if Date().timeIntervalSince1970 - lastCleanupTime > dayInSeconds {
DispatchQueue.global(qos: .background).async { [weak self] in
self?.clearExpiredCache()
UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: self?.lastCacheCleanupKey ?? "")
}
}
}
}

View file

@ -12,11 +12,12 @@ struct ContinueWatchingItem: Codable, Identifiable {
let imageUrl: String
let episodeNumber: Int
let mediaTitle: String
let progress: Double
var progress: Double
let streamUrl: String
let fullUrl: String
let subtitles: String?
let aniListID: Int?
let module: ScrapingModule
let headers: [String:String]?
let totalEpisodes: Int
}

View file

@ -10,36 +10,110 @@ import Foundation
class ContinueWatchingManager {
static let shared = ContinueWatchingManager()
private let storageKey = "continueWatchingItems"
private let lastCleanupKey = "lastContinueWatchingCleanup"
private init() {
NotificationCenter.default.addObserver(self, selector: #selector(handleiCloudSync), name: .iCloudSyncDidComplete, object: nil)
performCleanupIfNeeded()
}
@objc private func handleiCloudSync() {
NotificationCenter.default.post(name: .ContinueWatchingDidUpdate, object: nil)
}
private func performCleanupIfNeeded() {
let lastCleanup = UserDefaults.standard.double(forKey: lastCleanupKey)
let currentTime = Date().timeIntervalSince1970
if currentTime - lastCleanup > 86400 {
cleanupOldEpisodes()
UserDefaults.standard.set(currentTime, forKey: lastCleanupKey)
}
}
private func cleanupOldEpisodes() {
var items = fetchItems()
var itemsToRemove: Set<UUID> = []
let groupedItems = Dictionary(grouping: items) { item in
let title = item.mediaTitle.replacingOccurrences(of: "Episode \\d+.*$", with: "", options: .regularExpression)
.trimmingCharacters(in: .whitespacesAndNewlines)
return title
}
for (_, showEpisodes) in groupedItems {
let sortedEpisodes = showEpisodes.sorted { $0.episodeNumber < $1.episodeNumber }
for i in 0..<sortedEpisodes.count - 1 {
let currentEpisode = sortedEpisodes[i]
let nextEpisode = sortedEpisodes[i + 1]
if currentEpisode.progress >= 0.8 && nextEpisode.episodeNumber > currentEpisode.episodeNumber {
itemsToRemove.insert(currentEpisode.id)
}
}
}
if !itemsToRemove.isEmpty {
items.removeAll { itemsToRemove.contains($0.id) }
if let data = try? JSONEncoder().encode(items) {
UserDefaults.standard.set(data, forKey: storageKey)
}
}
}
func save(item: ContinueWatchingItem) {
if item.progress >= 0.9 {
// Use real playback times
let lastKey = "lastPlayedTime_\(item.fullUrl)"
let totalKey = "totalTime_\(item.fullUrl)"
let lastPlayed = UserDefaults.standard.double(forKey: lastKey)
let totalTime = UserDefaults.standard.double(forKey: totalKey)
// Compute up-to-date progress
let actualProgress: Double
if totalTime > 0 {
actualProgress = min(max(lastPlayed / totalTime, 0), 1)
} else {
actualProgress = item.progress
}
// If watched 90%, remove it
if actualProgress >= 0.9 {
remove(item: item)
return
}
// Otherwise update progress and remove old episodes from the same show
var updatedItem = item
updatedItem.progress = actualProgress
var items = fetchItems()
let showTitle = item.mediaTitle.replacingOccurrences(of: "Episode \\d+.*$", with: "", options: .regularExpression)
.trimmingCharacters(in: .whitespacesAndNewlines)
items.removeAll { existingItem in
let existingShowTitle = existingItem.mediaTitle.replacingOccurrences(of: "Episode \\d+.*$", with: "", options: .regularExpression)
.trimmingCharacters(in: .whitespacesAndNewlines)
return showTitle == existingShowTitle &&
existingItem.episodeNumber < item.episodeNumber &&
existingItem.progress >= 0.8
}
items.removeAll { existing in
existing.fullUrl == item.fullUrl &&
existing.episodeNumber == item.episodeNumber &&
existing.module.metadata.sourceName == item.module.metadata.sourceName
}
items.append(item)
items.append(updatedItem)
if let data = try? JSONEncoder().encode(items) {
UserDefaults.standard.set(data, forKey: storageKey)
}
}
func fetchItems() -> [ContinueWatchingItem] {
guard
let data = UserDefaults.standard.data(forKey: storageKey),
@ -61,7 +135,7 @@ class ContinueWatchingManager {
return Array(unique)
}
func remove(item: ContinueWatchingItem) {
var items = fetchItems()
items.removeAll { $0.id == item.id }

View file

@ -17,10 +17,8 @@ class DropManager {
private init() {}
func showDrop(title: String, subtitle: String, duration: TimeInterval, icon: UIImage?) {
// Add to queue
notificationQueue.append((title: title, subtitle: subtitle, duration: duration, icon: icon))
// Process queue if not already processing
if !isProcessingQueue {
processQueue()
}
@ -33,11 +31,8 @@ class DropManager {
}
isProcessingQueue = true
// Get the next notification
let notification = notificationQueue.removeFirst()
// Show the notification
let drop = Drop(
title: notification.title,
subtitle: notification.subtitle,
@ -48,7 +43,6 @@ class DropManager {
Drops.show(drop)
// Schedule next notification
DispatchQueue.main.asyncAfter(deadline: .now() + notification.duration) { [weak self] in
self?.processQueue()
}
@ -69,9 +63,7 @@ class DropManager {
showDrop(title: "Info", subtitle: message, duration: duration, icon: icon)
}
// Method for handling download notifications with accurate status determination
func downloadStarted(episodeNumber: Int) {
// Use the JSController method to accurately determine if download will start immediately
let willStartImmediately = JSController.shared.willDownloadStartImmediately()
let message = willStartImmediately

View file

@ -78,10 +78,12 @@ extension JSContext {
}
func setupFetchV2() {
let fetchV2NativeFunction: @convention(block) (String, [String: String]?, String?, String?, ObjCBool,JSValue, JSValue) -> Void = { urlString, headers, method, body, redirect, resolve, reject in
let fetchV2NativeFunction: @convention(block) (String, [String: String]?, String?, String?, ObjCBool, JSValue, JSValue) -> Void = { urlString, headers, method, body, redirect, resolve, reject in
guard let url = URL(string: urlString) else {
Logger.shared.log("Invalid URL", type: "Error")
reject.call(withArguments: ["Invalid URL"])
DispatchQueue.main.async {
reject.call(withArguments: ["Invalid URL"])
}
return
}
@ -93,7 +95,9 @@ extension JSContext {
if httpMethod == "GET", let body = body, !body.isEmpty, body != "null", body != "undefined" {
Logger.shared.log("GET request must not have a body", type: "Error")
reject.call(withArguments: ["GET request must not have a body"])
DispatchQueue.main.async {
reject.call(withArguments: ["GET request must not have a body"])
}
return
}
@ -101,29 +105,49 @@ extension JSContext {
request.httpBody = body.data(using: .utf8)
}
if let headers = headers {
for (key, value) in headers {
request.setValue(value, forHTTPHeaderField: key)
}
}
Logger.shared.log("Redirect value is \(redirect.boolValue)",type:"Error")
Logger.shared.log("Redirect value is \(redirect.boolValue)", type: "Error")
let task = URLSession.fetchData(allowRedirects: redirect.boolValue).downloadTask(with: request) { tempFileURL, response, error in
let callReject: (String) -> Void = { message in
DispatchQueue.main.async {
reject.call(withArguments: [message])
}
}
let callResolve: ([String: Any]) -> Void = { dict in
DispatchQueue.main.async {
resolve.call(withArguments: [dict])
}
}
if let error = error {
Logger.shared.log("Network error in fetchV2NativeFunction: \(error.localizedDescription)", type: "Error")
reject.call(withArguments: [error.localizedDescription])
callReject(error.localizedDescription)
return
}
guard let tempFileURL = tempFileURL else {
Logger.shared.log("No data in response", type: "Error")
reject.call(withArguments: ["No data"])
callReject("No data")
return
}
// initialise return Object
var safeHeaders: [String: String] = [:]
if let httpResponse = response as? HTTPURLResponse {
for (key, value) in httpResponse.allHeaderFields {
if let keyString = key as? String,
let valueString = value as? String {
safeHeaders[keyString] = valueString
}
}
}
var responseDict: [String: Any] = [
"status": (response as? HTTPURLResponse)?.statusCode ?? 0,
"headers": (response as? HTTPURLResponse)?.allHeaderFields ?? [:],
"headers": safeHeaders,
"body": ""
]
@ -132,23 +156,21 @@ extension JSContext {
if data.count > 10_000_000 {
Logger.shared.log("Response exceeds maximum size", type: "Error")
reject.call(withArguments: ["Response exceeds maximum size"])
callReject("Response exceeds maximum size")
return
}
if let text = String(data: data, encoding: .utf8) {
responseDict["body"] = text
resolve.call(withArguments: [responseDict])
callResolve(responseDict)
} else {
// rather than reject -> resolve with empty body as user can utilise reponse headers.
Logger.shared.log("Unable to decode data to text", type: "Error")
resolve.call(withArguments: [responseDict])
callResolve(responseDict)
}
} catch {
Logger.shared.log("Error reading downloaded file: \(error.localizedDescription)", type: "Error")
reject.call(withArguments: ["Error reading downloaded file"])
callReject("Error reading downloaded file")
}
}
task.resume()

View file

@ -27,7 +27,7 @@ extension UserDefaults {
let data = try NSKeyedArchiver.archivedData(withRootObject: color, requiringSecureCoding: false)
set(data, forKey: key)
} catch {
print("Error archiving color: \(error)")
Logger.shared.log("Error archiving color: \(error)", type: "Error")
}
}
}

View file

@ -1,14 +1,28 @@
//
// View.swift
// ScrollViewBottomPadding.swift
// Sora
//
// Created by Francesco on 09/02/25.
// Created by paul on 29/05/25.
//
import SwiftUI
extension View {
func shimmering() -> some View {
self.modifier(Shimmer())
struct ScrollViewBottomPadding: ViewModifier {
func body(content: Content) -> some View {
content
.safeAreaInset(edge: .bottom) {
Color.clear
.frame(height: 60)
}
}
}
extension View {
func shimmering() -> some View {
modifier(Shimmer())
}
func scrollViewBottomPadding() -> some View {
modifier(ScrollViewBottomPadding())
}
}

View file

@ -382,10 +382,3 @@ extension JSController {
)
}
}
// MARK: - Private API Compatibility Extension
// This extension ensures compatibility with the existing JSController-Downloads.swift implementation
private extension JSController {
// No longer needed since JSController-Downloads.swift has been implemented
// Remove the duplicate startDownload method to avoid conflicts
}

View file

@ -38,6 +38,12 @@ extension JSController {
print("Subtitle URL: \(subtitle.absoluteString)")
}
// Validate URL
guard url.scheme == "http" || url.scheme == "https" else {
completionHandler?(false, "Invalid URL scheme")
return
}
// Create metadata for the download
var metadata: AssetMetadata? = nil
if let title = title {
@ -47,7 +53,7 @@ extension JSController {
showTitle: showTitle,
season: season,
episode: episode,
showPosterURL: imageURL // Use the correct show poster URL
showPosterURL: imageURL
)
}
@ -57,12 +63,24 @@ extension JSController {
// Generate a unique download ID
let downloadID = UUID()
// Get access to the download directory
guard let downloadDirectory = getPersistentDownloadDirectory() else {
print("MP4 Download: Failed to get download directory")
completionHandler?(false, "Failed to create download directory")
return
}
// Generate a safe filename for the MP4 file
let sanitizedTitle = title?.replacingOccurrences(of: "[^A-Za-z0-9 ._-]", with: "", options: .regularExpression) ?? "download"
let filename = "\(sanitizedTitle)_\(downloadID.uuidString.prefix(8)).mp4"
let destinationURL = downloadDirectory.appendingPathComponent(filename)
// Create an active download object
let activeDownload = JSActiveDownload(
id: downloadID,
originalURL: url,
task: nil, // We'll set this after creating the task
queueStatus: .queued,
task: nil,
queueStatus: .downloading,
type: downloadType,
metadata: metadata,
title: title,
@ -74,84 +92,78 @@ extension JSController {
// Add to active downloads
activeDownloads.append(activeDownload)
// Create a URL session task for downloading the MP4 file
// Create request with headers
var request = URLRequest(url: url)
request.timeoutInterval = 30.0
for (key, value) in headers {
request.addValue(value, forHTTPHeaderField: key)
}
// Get access to the download directory using the shared instance method
guard let downloadDirectory = getPersistentDownloadDirectory() else {
print("MP4 Download: Failed to get download directory")
completionHandler?(false, "Failed to create download directory")
return
}
// Generate a unique filename for the MP4 file
let filename = "\(downloadID.uuidString).mp4"
let destinationURL = downloadDirectory.appendingPathComponent(filename)
// Use a session configuration that allows handling SSL issues
let sessionConfig = URLSessionConfiguration.default
// Set a longer timeout for large files
sessionConfig.timeoutIntervalForRequest = 60.0
sessionConfig.timeoutIntervalForResource = 600.0
// Create a URL session that handles SSL certificate validation issues
sessionConfig.timeoutIntervalForResource = 1800.0
sessionConfig.httpMaximumConnectionsPerHost = 1
sessionConfig.allowsCellularAccess = true
// Create custom session with delegate (self is JSController, which is persistent)
let customSession = URLSession(configuration: sessionConfig, delegate: self, delegateQueue: nil)
// Create the download task with the custom session
let downloadTask = customSession.downloadTask(with: request) { (tempURL, response, error) in
// Create the download task
let downloadTask = customSession.downloadTask(with: request) { [weak self] (tempURL, response, error) in
guard let self = self else { return }
DispatchQueue.main.async {
defer {
// Clean up resources
self.cleanupDownloadResources(for: downloadID)
}
// Handle error cases - just remove from active downloads
if let error = error {
print("MP4 Download Error: \(error.localizedDescription)")
// Update active download status
if let index = self.activeDownloads.firstIndex(where: { $0.id == downloadID }) {
self.activeDownloads[index].queueStatus = .queued
}
// Clean up resources
self.mp4ProgressObservations?[downloadID] = nil
self.mp4CustomSessions?[downloadID] = nil
// Remove the download after a delay
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) {
self.activeDownloads.removeAll { $0.id == downloadID }
}
self.removeActiveDownload(downloadID: downloadID)
completionHandler?(false, "Download failed: \(error.localizedDescription)")
return
}
// Validate response
guard let httpResponse = response as? HTTPURLResponse else {
print("MP4 Download: Invalid response")
self.removeActiveDownload(downloadID: downloadID)
completionHandler?(false, "Invalid server response")
return
}
if httpResponse.statusCode >= 400 {
guard (200...299).contains(httpResponse.statusCode) else {
print("MP4 Download HTTP Error: \(httpResponse.statusCode)")
self.removeActiveDownload(downloadID: downloadID)
completionHandler?(false, "Server error: \(httpResponse.statusCode)")
return
}
guard let tempURL = tempURL else {
print("MP4 Download: No temporary file URL")
self.removeActiveDownload(downloadID: downloadID)
completionHandler?(false, "Download data not available")
return
}
// Move file to final destination
do {
// Move the temporary file to the permanent location
if FileManager.default.fileExists(atPath: destinationURL.path) {
try FileManager.default.removeItem(at: destinationURL)
}
try FileManager.default.moveItem(at: tempURL, to: destinationURL)
print("MP4 Download: Successfully moved file to \(destinationURL.path)")
print("MP4 Download: Successfully saved to \(destinationURL.path)")
// Create the downloaded asset
// Verify file size
let fileSize = try FileManager.default.attributesOfItem(atPath: destinationURL.path)[.size] as? Int64 ?? 0
guard fileSize > 0 else {
throw NSError(domain: "DownloadError", code: -1, userInfo: [NSLocalizedDescriptionKey: "Downloaded file is empty"])
}
// Create downloaded asset
let downloadedAsset = DownloadedAsset(
name: title ?? url.lastPathComponent,
downloadDate: Date(),
@ -162,112 +174,122 @@ extension JSController {
subtitleURL: subtitleURL
)
// Add to saved assets
// Save asset
self.savedAssets.append(downloadedAsset)
self.saveAssets()
// Update active download and remove after a delay
if let index = self.activeDownloads.firstIndex(where: { $0.id == downloadID }) {
self.activeDownloads[index].progress = 1.0
self.activeDownloads[index].queueStatus = .completed
}
// Update progress to complete and remove after delay
self.updateDownloadProgress(downloadID: downloadID, progress: 1.0)
// Download subtitle if provided
if let subtitleURL = subtitleURL {
self.downloadSubtitle(subtitleURL: subtitleURL, assetID: downloadedAsset.id.uuidString)
}
// Notify observers - use downloadCompleted since the download finished
NotificationCenter.default.post(name: NSNotification.Name("downloadCompleted"), object: nil)
// Notify completion
NotificationCenter.default.post(name: NSNotification.Name("downloadCompleted"), object: downloadedAsset)
completionHandler?(true, "Download completed successfully")
// Clean up resources
self.mp4ProgressObservations?[downloadID] = nil
self.mp4CustomSessions?[downloadID] = nil
// Remove the completed download from active list after a delay
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
self.activeDownloads.removeAll { $0.id == downloadID }
// Remove from active downloads after success
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
self.removeActiveDownload(downloadID: downloadID)
}
} catch {
print("MP4 Download Error moving file: \(error.localizedDescription)")
print("MP4 Download Error saving file: \(error.localizedDescription)")
self.removeActiveDownload(downloadID: downloadID)
completionHandler?(false, "Error saving download: \(error.localizedDescription)")
}
}
}
// Set up progress tracking
// Set up progress observation
setupProgressObservation(for: downloadTask, downloadID: downloadID)
// Store session reference
storeSessionReference(session: customSession, for: downloadID)
// Start download
downloadTask.resume()
print("MP4 Download: Task started for \(filename)")
// Update the task in the active download
if let index = activeDownloads.firstIndex(where: { $0.id == downloadID }) {
activeDownloads[index].queueStatus = .downloading
// Store reference to the downloadTask directly - no need to access private properties
print("MP4 Download: Task started")
// We can't directly store URLSessionDownloadTask in place of AVAssetDownloadTask
// Just continue tracking progress separately
}
// Set up progress observation - fix the key path specification
let observation = downloadTask.progress.observe(\Progress.fractionCompleted) { progress, _ in
// Initial success callback
completionHandler?(true, "Download started")
}
// MARK: - Helper Methods
private func removeActiveDownload(downloadID: UUID) {
activeDownloads.removeAll { $0.id == downloadID }
}
private func updateDownloadProgress(downloadID: UUID, progress: Double) {
guard let index = activeDownloads.firstIndex(where: { $0.id == downloadID }) else { return }
activeDownloads[index].progress = progress
}
private func setupProgressObservation(for task: URLSessionDownloadTask, downloadID: UUID) {
let observation = task.progress.observe(\.fractionCompleted) { [weak self] progress, _ in
DispatchQueue.main.async {
if let index = self.activeDownloads.firstIndex(where: { $0.id == downloadID }) {
self.activeDownloads[index].progress = progress.fractionCompleted
// Notify observers of progress update
NotificationCenter.default.post(name: NSNotification.Name("downloadProgressUpdated"), object: nil)
}
guard let self = self else { return }
self.updateDownloadProgress(downloadID: downloadID, progress: progress.fractionCompleted)
NotificationCenter.default.post(name: NSNotification.Name("downloadProgressChanged"), object: nil)
}
}
// Store the observation somewhere to keep it alive - using nonatomic property from main class
if self.mp4ProgressObservations == nil {
self.mp4ProgressObservations = [:]
if mp4ProgressObservations == nil {
mp4ProgressObservations = [:]
}
self.mp4ProgressObservations?[downloadID] = observation
// Store the custom session to keep it alive until download is complete
if self.mp4CustomSessions == nil {
self.mp4CustomSessions = [:]
mp4ProgressObservations?[downloadID] = observation
}
private func storeSessionReference(session: URLSession, for downloadID: UUID) {
if mp4CustomSessions == nil {
mp4CustomSessions = [:]
}
self.mp4CustomSessions?[downloadID] = customSession
// Notify that download started successfully
completionHandler?(true, "Download started")
mp4CustomSessions?[downloadID] = session
}
private func cleanupDownloadResources(for downloadID: UUID) {
mp4ProgressObservations?[downloadID] = nil
mp4CustomSessions?[downloadID] = nil
}
}
// Extension for handling SSL certificate validation for MP4 downloads
// MARK: - URLSessionDelegate
extension JSController: URLSessionDelegate {
func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
// Handle SSL/TLS certificate validation
if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust {
let host = challenge.protectionSpace.host
print("MP4 Download: Handling server trust challenge for host: \(host)")
// Accept the server's certificate for known problematic domains
// or for domains in our custom session downloads
if host.contains("streamtales.cc") ||
host.contains("frembed.xyz") ||
host.contains("vidclouds.cc") ||
self.mp4CustomSessions?.values.contains(session) == true {
if let serverTrust = challenge.protectionSpace.serverTrust {
// Log detailed info about the trust
print("MP4 Download: Accepting certificate for \(host)")
let credential = URLCredential(trust: serverTrust)
completionHandler(.useCredential, credential)
return
}
}
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust else {
completionHandler(.performDefaultHandling, nil)
return
}
// For other authentication challenges, use default handling
print("MP4 Download: Using default handling for auth challenge")
completionHandler(.performDefaultHandling, nil)
let host = challenge.protectionSpace.host
print("MP4 Download: Handling server trust challenge for host: \(host)")
// Define trusted hosts for MP4 downloads
let trustedHosts = [
"streamtales.cc",
"frembed.xyz",
"vidclouds.cc"
]
let isTrustedHost = trustedHosts.contains { host.contains($0) }
let isCustomSession = mp4CustomSessions?.values.contains(session) == true
if isTrustedHost || isCustomSession {
guard let serverTrust = challenge.protectionSpace.serverTrust else {
completionHandler(.performDefaultHandling, nil)
return
}
print("MP4 Download: Accepting certificate for \(host)")
let credential = URLCredential(trust: serverTrust)
completionHandler(.useCredential, credential)
} else {
print("MP4 Download: Using default handling for \(host)")
completionHandler(.performDefaultHandling, nil)
}
}
}
}

View file

@ -47,17 +47,10 @@ extension JSController {
if let subtitle = subtitleURL {
print("Subtitle URL: \(subtitle.absoluteString)")
}
// Check the stream type from the module metadata
let streamType = module.metadata.streamType.lowercased()
// Determine which download method to use based on streamType
if streamType == "mp4" || streamType == "direct" || url.absoluteString.contains(".mp4") {
print("MP4 URL detected - downloading not supported")
completionHandler?(false, "MP4 direct downloads are not supported. Please use HLS streams for downloading.")
return
} else if streamType == "hls" || streamType == "m3u8" || url.absoluteString.contains(".m3u8") {
print("Using HLS download method")
if streamType == "hls" || streamType == "m3u8" || url.absoluteString.contains(".m3u8") {
Logger.shared.log("Using HLS download method")
downloadWithM3U8Support(
url: url,
headers: headers,
@ -71,22 +64,20 @@ extension JSController {
showPosterURL: showPosterURL,
completionHandler: completionHandler
)
} else {
// Default to M3U8 method for unknown types, as it has fallback mechanisms
print("Using default HLS download method for unknown stream type: \(streamType)")
downloadWithM3U8Support(
}else {
Logger.shared.log("Using MP4 download method")
downloadMP4(
url: url,
headers: headers,
title: title,
imageURL: imageURL,
imageURL: imageURL ?? showPosterURL,
isEpisode: isEpisode,
showTitle: showTitle,
season: season,
episode: episode,
subtitleURL: subtitleURL,
showPosterURL: showPosterURL,
completionHandler: completionHandler
)
}
}
}
}

View file

@ -5,6 +5,7 @@
// Created by Francesco on 30/03/25.
//
import Foundation
import JavaScriptCore
extension JSController {
@ -51,7 +52,7 @@ extension JSController {
let episodesResult = fetchEpisodesFunction.call(withArguments: [html]).toArray() as? [[String: String]] {
for episodeData in episodesResult {
if let num = episodeData["number"], let link = episodeData["href"], let number = Int(num) {
episodeLinks.append(EpisodeLink(number: number, href: link))
episodeLinks.append(EpisodeLink(number: number, title: "", href: link, duration: nil))
}
}
}
@ -152,7 +153,9 @@ extension JSController {
episodeLinks = array.map { item -> EpisodeLink in
EpisodeLink(
number: item["number"] as? Int ?? 0,
href: item["href"] as? String ?? ""
title: "",
href: item["href"] as? String ?? "",
duration: nil
)
}
} else {
@ -183,3 +186,11 @@ extension JSController {
}
}
}
struct EpisodeLink: Identifiable {
let id = UUID()
let number: Int
let title: String
let href: String
let duration: Int?
}

View file

@ -11,39 +11,26 @@ import SwiftUI
import AVKit
import AVFoundation
// Use ScrapingModule from Modules.swift as Module
typealias Module = ScrapingModule
class JSController: NSObject, ObservableObject {
// Shared instance that can be used across the app
static let shared = JSController()
var context: JSContext
// Downloaded assets storage
@Published var savedAssets: [DownloadedAsset] = []
@Published var activeDownloads: [JSActiveDownload] = []
// Tracking map for download tasks
var activeDownloadMap: [URLSessionTask: UUID] = [:]
// Download queue management
@Published var downloadQueue: [JSActiveDownload] = []
var isProcessingQueue: Bool = false
var maxConcurrentDownloads: Int {
UserDefaults.standard.object(forKey: "maxConcurrentDownloads") as? Int ?? 3
}
// Track downloads that have been cancelled to prevent completion processing
var cancelledDownloadIDs: Set<UUID> = []
// Download session
var downloadURLSession: AVAssetDownloadURLSession?
// For MP4 download progress tracking
var mp4ProgressObservations: [UUID: NSKeyValueObservation]?
// For storing custom URLSessions used for MP4 downloads
var mp4CustomSessions: [UUID: URLSession]?
override init() {
@ -58,9 +45,7 @@ class JSController: NSObject, ObservableObject {
setupDownloadSession()
}
// Setup download functionality separately from general context setup
private func setupDownloadSession() {
// Only initialize download session if it doesn't exist already
if downloadURLSession == nil {
initializeDownloadSession()
setupDownloadFunction()
@ -69,7 +54,6 @@ class JSController: NSObject, ObservableObject {
func loadScript(_ script: String) {
context = JSContext()
// Only set up the JavaScript environment without reinitializing the download session
context.setupJavaScriptEnvironment()
context.evaluateScript(script)
if let exception = context.exception {
@ -77,23 +61,15 @@ class JSController: NSObject, ObservableObject {
}
}
// MARK: - Download Settings
/// Updates the maximum number of concurrent downloads and processes the queue if new slots are available
func updateMaxConcurrentDownloads(_ newLimit: Int) {
print("Updating max concurrent downloads from \(maxConcurrentDownloads) to \(newLimit)")
// The maxConcurrentDownloads computed property will automatically use the new UserDefaults value
// If the new limit is higher and we have queued downloads, process the queue
if !downloadQueue.isEmpty && !isProcessingQueue {
print("Processing download queue due to increased concurrent limit. Queue has \(downloadQueue.count) items.")
// Force UI update before processing queue
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.objectWillChange.send()
// Process the queue with a slight delay to ensure UI is ready
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
self?.processDownloadQueue()
}
@ -102,21 +78,6 @@ class JSController: NSObject, ObservableObject {
print("No queued downloads to process or queue is already being processed")
}
}
// MARK: - Stream URL Functions - Convenience methods
func fetchStreamUrl(episodeUrl: String, module: Module, completion: @escaping ((streams: [String]?, subtitles: [String]?)) -> Void) {
// Implementation for the main fetchStreamUrl method
}
func fetchStreamUrlJS(episodeUrl: String, module: Module, completion: @escaping ((streams: [String]?, subtitles: [String]?)) -> Void) {
// Implementation for the JS based stream URL fetching
}
func fetchStreamUrlJSSecond(episodeUrl: String, module: Module, completion: @escaping ((streams: [String]?, subtitles: [String]?)) -> Void) {
// Implementation for the secondary JS based stream URL fetching
}
// MARK: - Header Management
// Header management functions are implemented in JSController-HeaderManager.swift extension file
}

View file

@ -20,8 +20,9 @@ class Logger {
private var logs: [LogEntry] = []
private let logFileURL: URL
private let logFilterViewModel = LogFilterViewModel.shared
private let maxFileSize = 1024 * 512
private let maxLogEntries = 1000
private init() {
let documentDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
@ -35,6 +36,11 @@ class Logger {
queue.async(flags: .barrier) {
self.logs.append(entry)
if self.logs.count > self.maxLogEntries {
self.logs.removeFirst(self.logs.count - self.maxLogEntries)
}
self.saveLogToFile(entry)
self.debugLog(entry)
}
@ -51,6 +57,18 @@ class Logger {
return result
}
func getLogsAsync() async -> String {
return await withCheckedContinuation { continuation in
queue.async {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "dd-MM HH:mm:ss"
let result = self.logs.map { "[\(dateFormatter.string(from: $0.timestamp))] [\($0.type)] \($0.message)" }
.joined(separator: "\n----\n")
continuation.resume(returning: result)
}
}
}
func clearLogs() {
queue.async(flags: .barrier) {
self.logs.removeAll()
@ -58,49 +76,73 @@ class Logger {
}
}
func clearLogsAsync() async {
await withCheckedContinuation { continuation in
queue.async(flags: .barrier) {
self.logs.removeAll()
try? FileManager.default.removeItem(at: self.logFileURL)
continuation.resume()
}
}
}
private func saveLogToFile(_ log: LogEntry) {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "dd-MM HH:mm:ss"
let logString = "[\(dateFormatter.string(from: log.timestamp))] [\(log.type)] \(log.message)\n---\n"
if let data = logString.data(using: .utf8) {
guard let data = logString.data(using: .utf8) else {
print("Failed to encode log string to UTF-8")
return
}
do {
if FileManager.default.fileExists(atPath: logFileURL.path) {
do {
let attributes = try FileManager.default.attributesOfItem(atPath: logFileURL.path)
let fileSize = attributes[.size] as? UInt64 ?? 0
if fileSize + UInt64(data.count) > maxFileSize {
guard var content = try? String(contentsOf: logFileURL, encoding: .utf8) else { return }
while (content.data(using: .utf8)?.count ?? 0) + data.count > maxFileSize {
if let rangeOfFirstLine = content.range(of: "\n---\n") {
content.removeSubrange(content.startIndex...rangeOfFirstLine.upperBound)
} else {
content = ""
break
}
}
content += logString
try? content.data(using: .utf8)?.write(to: logFileURL)
} else {
if let handle = try? FileHandle(forWritingTo: logFileURL) {
handle.seekToEndOfFile()
handle.write(data)
handle.closeFile()
}
}
} catch {
print("Error managing log file: \(error)")
let attributes = try FileManager.default.attributesOfItem(atPath: logFileURL.path)
let fileSize = attributes[.size] as? UInt64 ?? 0
if fileSize + UInt64(data.count) > maxFileSize {
self.truncateLogFile()
}
if let handle = try? FileHandle(forWritingTo: logFileURL) {
handle.seekToEndOfFile()
handle.write(data)
handle.closeFile()
}
} else {
try? data.write(to: logFileURL)
try data.write(to: logFileURL)
}
} catch {
print("Error managing log file: \(error)")
try? data.write(to: logFileURL)
}
}
private func truncateLogFile() {
do {
guard let content = try? String(contentsOf: logFileURL, encoding: .utf8),
!content.isEmpty else {
return
}
let entries = content.components(separatedBy: "\n---\n")
guard entries.count > 10 else { return }
let keepCount = entries.count / 2
let truncatedEntries = Array(entries.suffix(keepCount))
let truncatedContent = truncatedEntries.joined(separator: "\n---\n")
if let truncatedData = truncatedContent.data(using: .utf8) {
try truncatedData.write(to: logFileURL)
}
} catch {
print("Error truncating log file: \(error)")
try? FileManager.default.removeItem(at: logFileURL)
}
}
/// Prints log messages to the Xcode console only in DEBUG mode
private func debugLog(_ entry: LogEntry) {
#if DEBUG
let dateFormatter = DateFormatter()

View file

@ -28,6 +28,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
private var aniListUpdateImpossible: Bool = false
private var aniListRetryCount = 0
private let aniListMaxRetries = 6
private let totalEpisodes: Int
var player: AVPlayer!
var timeObserverToken: Any?
@ -40,6 +41,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
var currentTimeVal: Double = 0.0
var duration: Double = 0.0
var isVideoLoaded = false
var detachedWindow: UIWindow?
private var isHoldPauseEnabled: Bool {
UserDefaults.standard.bool(forKey: "holdForPauseEnabled")
@ -59,6 +61,20 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
return UserDefaults.standard.bool(forKey: "doubleTapSeekEnabled")
}
private var isPipAutoEnabled: Bool {
UserDefaults.standard.bool(forKey: "pipAutoEnabled")
}
private var isPipButtonVisible: Bool {
if UserDefaults.standard.object(forKey: "pipButtonVisible") == nil {
return true
}
return UserDefaults.standard.bool(forKey: "pipButtonVisible")
}
private var pipController: AVPictureInPictureController?
private var pipButton: UIButton!
var portraitButtonVisibleConstraints: [NSLayoutConstraint] = []
var portraitButtonHiddenConstraints: [NSLayoutConstraint] = []
var landscapeButtonVisibleConstraints: [NSLayoutConstraint] = []
@ -138,6 +154,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
private var playerItemKVOContext = 0
private var loadedTimeRangesObservation: NSKeyValueObservation?
private var playerTimeControlStatusObserver: NSKeyValueObservation?
private var playerRateObserver: NSKeyValueObservation?
private var controlsLocked = false
private var lockButtonTimer: Timer?
@ -175,6 +192,10 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
private var subtitleDelay: Double = 0.0
var currentPlaybackSpeed: Float = 1.0
private var wasPlayingBeforeBackground = false
private var backgroundToken: Any?
private var foregroundToken: Any?
init(module: ScrapingModule,
urlString: String,
fullUrl: String,
@ -183,6 +204,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
onWatchNext: @escaping () -> Void,
subtitlesURL: String?,
aniListID: Int,
totalEpisodes: Int,
episodeImageUrl: String,headers:[String:String]?) {
self.module = module
@ -195,6 +217,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
self.subtitlesURL = subtitlesURL
self.aniListID = aniListID
self.headers = headers
self.totalEpisodes = totalEpisodes
super.init(nibName: nil, bundle: nil)
@ -256,6 +279,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
setupAudioSession()
updateSkipButtonsVisibility()
setupHoldSpeedIndicator()
setupPipIfSupported()
view.bringSubviewToFront(subtitleStackView)
@ -286,6 +310,17 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
Logger.shared.log("Error activating audio session: \(error)", type: "Debug")
}
playerRateObserver = player.observe(\.rate, options: [.new, .old]) { [weak self] player, change in
guard let self = self else { return }
DispatchQueue.main.async {
let isActuallyPlaying = player.rate != 0
if self.isPlaying != isActuallyPlaying {
self.isPlaying = isActuallyPlaying
self.playPauseButton.image = UIImage(systemName: isActuallyPlaying ? "pause.fill" : "play.fill")
}
}
}
volumeViewModel.value = Double(audioSession.outputVolume)
volumeObserver = audioSession.observe(\.outputVolume, options: [.new]) { [weak self] session, change in
@ -386,6 +421,24 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
player.pause()
}
deinit {
playerRateObserver?.invalidate()
inactivityTimer?.invalidate()
updateTimer?.invalidate()
lockButtonTimer?.invalidate()
dimButtonTimer?.invalidate()
loadedTimeRangesObservation?.invalidate()
playerTimeControlStatusObserver?.invalidate()
volumeObserver?.invalidate()
player.replaceCurrentItem(with: nil)
player.pause()
playerViewController = nil
sliderHostingController = nil
try? AVAudioSession.sharedInstance().setActive(false)
}
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
guard context == &playerItemKVOContext else {
super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
@ -1048,7 +1101,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
func setupSkipButtons() {
let introConfig = UIImage.SymbolConfiguration(pointSize: 14, weight: .bold)
let introImage = UIImage(systemName: "forward.frame", withConfiguration: introConfig)
skipIntroButton = UIButton(type: .system)
skipIntroButton = GradientOverlayButton(type: .system)
skipIntroButton.setTitle(" Skip Intro", for: .normal)
skipIntroButton.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .bold)
skipIntroButton.setImage(introImage, for: .normal)
@ -1080,7 +1133,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
let outroConfig = UIImage.SymbolConfiguration(pointSize: 14, weight: .bold)
let outroImage = UIImage(systemName: "forward.frame", withConfiguration: outroConfig)
skipOutroButton = UIButton(type: .system)
skipOutroButton = GradientOverlayButton(type: .system)
skipOutroButton.setTitle(" Skip Outro", for: .normal)
skipOutroButton.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .bold)
skipOutroButton.setImage(outroImage, for: .normal)
@ -1186,6 +1239,53 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
}
}
private func setupPipIfSupported() {
guard AVPictureInPictureController.isPictureInPictureSupported() else {
return
}
let pipPlayerLayer = AVPlayerLayer(player: playerViewController.player)
pipPlayerLayer.frame = playerViewController.view.layer.bounds
pipPlayerLayer.videoGravity = .resizeAspect
playerViewController.view.layer.insertSublayer(pipPlayerLayer, at: 0)
pipController = AVPictureInPictureController(playerLayer: pipPlayerLayer)
pipController?.delegate = self
let config = UIImage.SymbolConfiguration(pointSize: 15, weight: .medium)
let Image = UIImage(systemName: "pip", withConfiguration: config)
pipButton = UIButton(type: .system)
pipButton.setImage(Image, for: .normal)
pipButton.tintColor = .white
pipButton.addTarget(self, action: #selector(pipButtonTapped(_:)), for: .touchUpInside)
pipButton.layer.shadowColor = UIColor.black.cgColor
pipButton.layer.shadowOffset = CGSize(width: 0, height: 2)
pipButton.layer.shadowOpacity = 0.6
pipButton.layer.shadowRadius = 4
pipButton.layer.masksToBounds = false
controlsContainerView.addSubview(pipButton)
pipButton.translatesAutoresizingMaskIntoConstraints = false
// NEW: pin pipButton to the left of lockButton:
NSLayoutConstraint.activate([
pipButton.centerYAnchor.constraint(equalTo: dimButton.centerYAnchor),
pipButton.trailingAnchor.constraint(equalTo: dimButton.leadingAnchor, constant: -8),
pipButton.widthAnchor.constraint(equalToConstant: 44),
pipButton.heightAnchor.constraint(equalToConstant: 44)
])
pipButton.isHidden = !isPipButtonVisible
NotificationCenter.default.addObserver(
self,
selector: #selector(startPipIfNeeded),
name: UIApplication.willResignActiveNotification,
object: nil
)
}
func setupMenuButton() {
let config = UIImage.SymbolConfiguration(pointSize: 15, weight: .bold)
let image = UIImage(systemName: "text.bubble", withConfiguration: config)
@ -1280,7 +1380,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
let config = UIImage.SymbolConfiguration(pointSize: 14, weight: .bold)
let image = UIImage(systemName: "goforward", withConfiguration: config)
skip85Button = UIButton(type: .system)
skip85Button = GradientOverlayButton(type: .system)
skip85Button.setTitle(" Skip 85s", for: .normal)
skip85Button.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .bold)
skip85Button.setImage(image, for: .normal)
@ -1424,7 +1524,8 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
subtitles: self.subtitlesURL,
aniListID: self.aniListID,
module: self.module,
headers: self.headers
headers: self.headers,
totalEpisodes: self.totalEpisodes
)
ContinueWatchingManager.shared.save(item: item)
}
@ -1641,6 +1742,24 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
}
}
@objc private func pipButtonTapped(_ sender: UIButton) {
guard let pip = pipController else { return }
if pip.isPictureInPictureActive {
pip.stopPictureInPicture()
} else {
pip.startPictureInPicture()
}
}
@objc private func startPipIfNeeded() {
guard isPipAutoEnabled,
let pip = pipController,
!pip.isPictureInPictureActive else {
return
}
pip.startPictureInPicture()
}
@objc private func lockTapped() {
controlsLocked.toggle()
@ -1681,7 +1800,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
updateSkipButtonsVisibility()
}
}
@objc private func skipIntro() {
if let range = skipIntervals.op {
player.seek(to: range.end)
@ -1697,12 +1816,15 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
}
@objc func dismissTapped() {
dismiss(animated: true, completion: nil)
dismiss(animated: true) { [weak self] in
self?.detachedWindow = nil
}
}
@objc func watchNextTapped() {
player.pause()
dismiss(animated: true) { [weak self] in
self?.detachedWindow = nil
self?.onWatchNext()
}
}
@ -1758,35 +1880,77 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
}
private func tryAniListUpdate() {
let aniListMutation = AniListMutation()
aniListMutation.updateAnimeProgress(animeId: self.aniListID, episodeNumber: self.episodeNumber) { [weak self] result in
guard !aniListUpdatedSuccessfully else { return }
guard aniListID > 0 else {
Logger.shared.log("AniList ID is invalid, skipping update.", type: "Warning")
return
}
let client = AniListMutation()
client.fetchMediaStatus(mediaId: aniListID) { [weak self] statusResult in
guard let self = self else { return }
let newStatus: String = {
switch statusResult {
case .success(let mediaStatus):
if mediaStatus == "RELEASING" {
return "CURRENT"
}
return (self.episodeNumber == self.totalEpisodes) ? "COMPLETED" : "CURRENT"
case .failure(let error):
Logger.shared.log(
"Failed to fetch AniList status: \(error.localizedDescription). " +
"Using default CURRENT/COMPLETED logic.",
type: "Warning"
)
return (self.episodeNumber == self.totalEpisodes) ? "COMPLETED" : "CURRENT"
}
}()
switch result {
case .success:
self.aniListUpdatedSuccessfully = true
Logger.shared.log("Successfully updated AniList progress for episode \(self.episodeNumber)", type: "General")
case .failure(let error):
let errorString = error.localizedDescription.lowercased()
Logger.shared.log("AniList progress update failed: \(errorString)", type: "Error")
if errorString.contains("access token not found") {
Logger.shared.log("AniList update will NOT retry due to missing token.", type: "Error")
self.aniListUpdateImpossible = true
} else {
if self.aniListRetryCount < self.aniListMaxRetries {
self.aniListRetryCount += 1
let delaySeconds = 5.0
Logger.shared.log("AniList update will retry in \(delaySeconds)s (attempt \(self.aniListRetryCount)).", type: "Debug")
DispatchQueue.main.asyncAfter(deadline: .now() + delaySeconds) {
self.tryAniListUpdate()
}
client.updateAnimeProgress(
animeId: self.aniListID,
episodeNumber: self.episodeNumber,
status: newStatus
) { result in
switch result {
case .success:
self.aniListUpdatedSuccessfully = true
Logger.shared.log(
"AniList progress updated to \(newStatus) for ep \(self.episodeNumber)",
type: "General"
)
case .failure(let error):
let errorString = error.localizedDescription.lowercased()
Logger.shared.log("AniList progress update failed: \(errorString)", type: "Error")
if errorString.contains("access token not found") {
Logger.shared.log("AniList update will NOT retry due to missing token.", type: "Error")
self.aniListUpdateImpossible = true
} else {
Logger.shared.log("AniList update reached max retries. No more attempts.", type: "Error")
if self.aniListRetryCount < self.aniListMaxRetries {
self.aniListRetryCount += 1
let delaySeconds = 5.0
Logger.shared.log(
"AniList update will retry in \(delaySeconds)s " +
"(attempt \(self.aniListRetryCount)).",
type: "Debug"
)
DispatchQueue.main.asyncAfter(deadline: .now() + delaySeconds) {
self.tryAniListUpdate()
}
} else {
Logger.shared.log(
"Reached max retry count (\(self.aniListMaxRetries)). Giving up.",
type: "Error"
)
}
}
}
}
@ -2323,7 +2487,9 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
switch gesture.state {
case .ended:
if translation.y > 100 {
dismiss(animated: true, completion: nil)
dismiss(animated: true) { [weak self] in
self?.detachedWindow = nil
}
}
default:
break
@ -2407,7 +2573,62 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
}
}
class GradientOverlayButton: UIButton {
private let gradientLayer = CAGradientLayer()
override init(frame: CGRect) {
super.init(frame: frame)
setupGradient()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setupGradient()
}
private func setupGradient() {
gradientLayer.colors = [
UIColor.white.withAlphaComponent(0.25).cgColor,
UIColor.white.withAlphaComponent(0).cgColor
]
gradientLayer.startPoint = CGPoint(x: 0.5, y: 0)
gradientLayer.endPoint = CGPoint(x: 0.5, y: 1)
layer.addSublayer(gradientLayer)
}
override func layoutSubviews() {
super.layoutSubviews()
gradientLayer.frame = bounds
let path = UIBezierPath(roundedRect: bounds.insetBy(dx: 0.25, dy: 0.25), cornerRadius: bounds.height / 2)
let maskLayer = CAShapeLayer()
maskLayer.path = path.cgPath
maskLayer.fillColor = nil
maskLayer.strokeColor = UIColor.white.cgColor
maskLayer.lineWidth = 0.5
gradientLayer.mask = maskLayer
}
}
extension CustomMediaPlayerViewController: AVPictureInPictureControllerDelegate {
func pictureInPictureControllerWillStartPictureInPicture(_ pipController: AVPictureInPictureController) {
pipButton.alpha = 0.5
}
func pictureInPictureControllerDidStopPictureInPicture(_ pipController: AVPictureInPictureController) {
pipButton.alpha = 1.0
}
func pictureInPictureController(_ pipController: AVPictureInPictureController,
failedToStartPictureInPictureWithError error: Error) {
Logger.shared.log("PiP failed to start: \(error.localizedDescription)", type: "Error")
}
}
// yes? Like the plural of the famous american rapper ye? -IBHRAD
// low taper fade the meme is massive -cranci
// cranci still doesnt have a job -seiike
// The mind is the source of good and evil, only you yourself can decide which you will bring yourself. -seiike
// guys watch Clannad already - ibro
// May the Divine Providence bestow its infinite mercy upon your soul, and may eternal grace find you beyond the shadows of this mortal realm. - paul, 15/11/2005 - 13/05/2023
// this dumbass defo used gpt

View file

@ -46,7 +46,7 @@ class NormalPlayer: AVPlayerViewController {
private func endHoldSpeed() {
player?.rate = originalRate
}
func setupAudioSession() {
do {
let audioSession = AVAudioSession.sharedInstance()

View file

@ -19,10 +19,12 @@ class VideoPlayerViewController: UIViewController {
var subtitles: String = ""
var aniListID: Int = 0
var headers: [String:String]? = nil
var totalEpisodes: Int = 0
var episodeNumber: Int = 0
var episodeImageUrl: String = ""
var mediaTitle: String = ""
var detachedWindow: UIWindow?
init(module: ScrapingModule) {
self.module = module
@ -41,15 +43,11 @@ class VideoPlayerViewController: UIViewController {
}
var request = URLRequest(url: url)
if let mydict = headers, !mydict.isEmpty
{
for (key,value) in mydict
{
if let mydict = headers, !mydict.isEmpty {
for (key,value) in mydict {
request.addValue(value, forHTTPHeaderField: key)
}
}
else
{
} else {
request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Referer")
request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Origin")
}
@ -139,7 +137,8 @@ class VideoPlayerViewController: UIViewController {
subtitles: self.subtitles,
aniListID: self.aniListID,
module: self.module,
headers: self.headers
headers: self.headers,
totalEpisodes: self.totalEpisodes
)
ContinueWatchingManager.shared.save(item: item)
}

View file

@ -0,0 +1,13 @@
//
// TabItem.swift
// SoraPrototype
//
// Created by Inumaki on 26/04/2025.
//
import Foundation
struct TabItem {
let icon: String
let title: String
}

View file

@ -15,6 +15,7 @@ private struct ModuleLink: Identifiable {
struct CommunityLibraryView: View {
@EnvironmentObject var moduleManager: ModuleManager
@EnvironmentObject var tabBarController: TabBarController
@AppStorage("lastCommunityURL") private var inputURL: String = ""
@State private var webURL: URL?
@ -30,7 +31,6 @@ struct CommunityLibraryView: View {
}
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)
@ -38,7 +38,13 @@ struct CommunityLibraryView: View {
}
.ignoresSafeArea(edges: .top)
}
.onAppear(perform: loadURL)
.onAppear {
loadURL()
tabBarController.hideTabBar()
}
.onDisappear {
tabBarController.showTabBar()
}
.sheet(item: $moduleLinkToAdd) { link in
ModuleAdditionSettingsView(moduleUrl: link.url)
.environmentObject(moduleManager)

View file

@ -11,6 +11,7 @@ import Kingfisher
struct ModuleAdditionSettingsView: View {
@Environment(\.presentationMode) var presentationMode
@EnvironmentObject var moduleManager: ModuleManager
@Environment(\.colorScheme) var colorScheme
@State private var moduleMetadata: ModuleMetadata?
@State private var isLoading = false
@ -115,13 +116,14 @@ struct ModuleAdditionSettingsView: View {
Text("Add Module")
}
.font(.headline)
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.foregroundColor(colorScheme == .dark ? .black : .white)
.padding()
.frame(maxWidth: .infinity)
.background(
RoundedRectangle(cornerRadius: 15)
.fill(Color.accentColor)
.foregroundColor(colorScheme == .dark ? .white : .black)
)
.padding(.horizontal)
}
.disabled(isLoading)
@ -131,7 +133,7 @@ struct ModuleAdditionSettingsView: View {
self.presentationMode.wrappedValue.dismiss()
}) {
Text("Cancel")
.foregroundColor((Color.accentColor))
.foregroundColor(colorScheme == .dark ? Color.white : Color.black)
.padding(.top, 10)
}
}

View file

@ -0,0 +1,46 @@
//
// ProgressiveBlurView.swift
// SoraPrototype
//
// Created by Inumaki on 26/04/2025.
//
import SwiftUI
struct ProgressiveBlurView: UIViewRepresentable {
func makeUIView(context: Context) -> CustomBlurView {
let view = CustomBlurView()
view.backgroundColor = .clear
return view
}
func updateUIView(_ uiView: CustomBlurView, context: Context) { }
}
class CustomBlurView: UIVisualEffectView {
override init(effect: UIVisualEffect?) {
super.init(effect: UIBlurEffect(style: .systemUltraThinMaterial))
removeFilters()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
if previousTraitCollection?.userInterfaceStyle != traitCollection.userInterfaceStyle {
DispatchQueue.main.async {
self.removeFilters()
}
}
}
private func removeFilters() {
if let filterLayer = layer.sublayers?.first {
filterLayer.filters = []
}
}
}

View file

@ -8,27 +8,56 @@
import SwiftUI
struct Shimmer: ViewModifier {
@State private var phase: CGFloat = 0
@State private var phase: CGFloat = -1
func body(content: Content) -> some View {
content
.overlay(
Rectangle()
.fill(
LinearGradient(
gradient: Gradient(colors: [Color.clear, Color.white.opacity(0.4), Color.clear]),
startPoint: .top,
endPoint: .bottom
.modifier(AnimatedMask(phase: phase)
.animation(
Animation.linear(duration: 1.2)
.repeatForever(autoreverses: false)
)
)
.rotationEffect(.degrees(30))
.offset(x: self.phase * 350)
)
.mask(content)
.onAppear {
withAnimation(.linear(duration: 1.5).repeatForever(autoreverses: false)) {
self.phase = 1
}
phase = 1.5
}
}
struct AnimatedMask: AnimatableModifier {
var phase: CGFloat = 0
var animatableData: CGFloat {
get { phase }
set { phase = newValue }
}
func body(content: Content) -> some View {
content
.overlay(
GeometryReader { geo in
let width = geo.size.width
let shimmerStart = phase - 0.25
let shimmerEnd = phase + 0.25
Rectangle()
.fill(
LinearGradient(
gradient: Gradient(stops: [
.init(color: Color.white.opacity(0.05), location: shimmerStart - 0.15),
.init(color: Color.white.opacity(0.25), location: shimmerStart),
.init(color: Color.white.opacity(0.85), location: phase),
.init(color: Color.white.opacity(0.25), location: shimmerEnd),
.init(color: Color.white.opacity(0.05), location: shimmerEnd + 0.15)
]),
startPoint: .leading,
endPoint: .trailing
)
)
.blur(radius: 8)
.rotationEffect(.degrees(20))
.offset(x: -width * 0.7 + width * 2 * phase)
}
)
.mask(content)
}
}
}

View file

@ -11,19 +11,11 @@ struct HomeSkeletonCell: View {
let cellWidth: CGFloat
var body: some View {
VStack {
RoundedRectangle(cornerRadius: 10)
.fill(Color.gray.opacity(0.3))
.frame(width: cellWidth, height: cellWidth * 1.5)
.cornerRadius(10)
.shimmering()
RoundedRectangle(cornerRadius: 5)
.fill(Color.gray.opacity(0.3))
.frame(width: cellWidth, height: 20)
.padding(.top, 4)
.shimmering()
}
RoundedRectangle(cornerRadius: 10)
.fill(Color.gray.opacity(0.3))
.frame(width: cellWidth, height: cellWidth * 1.5)
.cornerRadius(10)
.shimmering()
}
}
@ -31,15 +23,9 @@ struct SearchSkeletonCell: View {
let cellWidth: CGFloat
var body: some View {
VStack(alignment: .leading, spacing: 8) {
RoundedRectangle(cornerRadius: 10)
.fill(Color.gray.opacity(0.3))
.frame(width: cellWidth, height: cellWidth * 1.5)
.shimmering()
RoundedRectangle(cornerRadius: 5)
.fill(Color.gray.opacity(0.3))
.frame(width: cellWidth, height: 20)
.shimmering()
}
RoundedRectangle(cornerRadius: 10)
.fill(Color.gray.opacity(0.3))
.frame(width: cellWidth, height: cellWidth * 1.5)
.shimmering()
}
}

View file

@ -0,0 +1,267 @@
//
// TabBar.swift
// SoraPrototype
//
// Created by Inumaki on 26/04/2025.
//
import SwiftUI
extension Color {
init(hex: String) {
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
var int: UInt64 = 0
Scanner(string: hex).scanHexInt64(&int)
let r, g, b, a: UInt64
switch hex.count {
case 6:
(r, g, b, a) = (int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF, 255)
case 8:
(r, g, b, a) = (int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF, int >> 24 & 0xFF)
default:
(r, g, b, a) = (1, 1, 1, 1)
}
self.init(
.sRGB,
red: Double(r) / 255,
green: Double(g) / 255,
blue: Double(b) / 255,
opacity: Double(a) / 255
)
}
}
struct TabBar: View {
let tabs: [TabItem]
@Binding var selectedTab: Int
@Binding var lastTab: Int
@State var showSearch: Bool = false
@State var searchLocked: Bool = false
@FocusState var keyboardFocus: Bool
@State var keyboardHidden: Bool = true
@Binding var searchQuery: String
@ObservedObject var controller: TabBarController
@State private var keyboardHeight: CGFloat = 0
private var gradientOpacity: CGFloat {
let accentColor = UIColor(Color.accentColor)
var white: CGFloat = 0
accentColor.getWhite(&white, alpha: nil)
return white > 0.5 ? 0.5 : 0.3
}
@Namespace private var animation
func slideDown() {
controller.hideTabBar()
}
func slideUp() {
controller.showTabBar()
}
var body: some View {
HStack {
if showSearch && keyboardHidden {
Button(action: {
keyboardFocus = false
withAnimation(.bouncy(duration: 0.3)) {
selectedTab = lastTab
showSearch = false
}
}) {
Image(systemName: "xmark")
.font(.system(size: 20))
.foregroundStyle(.gray)
.frame(width: 20, height: 20)
.matchedGeometryEffect(id: "xmark", in: animation)
.padding(16)
.background(
Circle()
.fill(.ultraThinMaterial)
.overlay(
Circle()
.stroke(
LinearGradient(
gradient: Gradient(stops: [
.init(color: Color.accentColor.opacity(gradientOpacity), location: 0),
.init(color: Color.accentColor.opacity(0), location: 1)
]),
startPoint: .top,
endPoint: .bottom
),
lineWidth: 0.5
)
)
.matchedGeometryEffect(id: "background_circle", in: animation)
)
}
.disabled(!keyboardHidden || searchLocked)
}
HStack {
if showSearch {
HStack {
Image(systemName: "magnifyingglass")
.font(.footnote)
.foregroundStyle(.gray)
.opacity(0.7)
TextField("Search for something...", text: $searchQuery)
.textFieldStyle(.plain)
.font(.footnote)
.foregroundStyle(Color.accentColor)
.frame(maxWidth: .infinity, alignment: .leading)
.focused($keyboardFocus)
.onChange(of: keyboardFocus) { newValue in
withAnimation(.bouncy(duration: 0.3)) {
keyboardHidden = !newValue
}
}
.onDisappear {
keyboardFocus = false
}
if !searchQuery.isEmpty {
Button(action: {
searchQuery = ""
}) {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 16))
.foregroundStyle(.gray)
.opacity(0.7)
}
.buttonStyle(PlainButtonStyle())
}
}
.frame(height: 24)
.padding(8)
} else {
ForEach(0..<tabs.count, id: \.self) { index in
let tab = tabs[index]
tabButton(for: tab, index: index)
}
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(
Capsule()
.fill(.ultraThinMaterial)
)
.clipShape(Capsule())
.overlay(
Capsule()
.strokeBorder(
LinearGradient(
gradient: Gradient(stops: [
.init(color: Color.accentColor.opacity(gradientOpacity), location: 0),
.init(color: Color.accentColor.opacity(0), location: 1)
]),
startPoint: .top,
endPoint: .bottom
),
lineWidth: 0.5
)
)
}
.padding(.horizontal, 20)
.padding(.bottom, 20)
.background {
ProgressiveBlurView()
.blur(radius: 10)
.padding(.horizontal, -20)
.padding(.bottom, -100)
.padding(.top, -10)
.opacity(controller.isHidden ? 0 : 1) // Animate opacity
.animation(.easeInOut(duration: 0.15), value: controller.isHidden)
}
.offset(y: controller.isHidden ? 120 : (keyboardFocus ? -keyboardHeight + 36 : 0))
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: keyboardHeight)
.animation(.easeInOut(duration: 0.15), value: controller.isHidden)
.onChange(of: keyboardHeight) { newValue in
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
}
}
.onAppear {
NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillShowNotification, object: nil, queue: .main) { notification in
if let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect {
keyboardHeight = keyboardFrame.height
}
}
NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillHideNotification, object: nil, queue: .main) { _ in
keyboardHeight = 0
}
}
}
@ViewBuilder
private func tabButton(for tab: TabItem, index: Int) -> some View {
Button(action: {
if index == tabs.count - 1 {
searchLocked = true
withAnimation(.bouncy(duration: 0.3)) {
lastTab = selectedTab
selectedTab = index
showSearch = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
searchLocked = false
}
} else {
if !searchLocked {
withAnimation(.bouncy(duration: 0.3)) {
lastTab = selectedTab
selectedTab = index
}
}
}
}) {
if tab.title.isEmpty {
Image(systemName: tab.icon + (selectedTab == index ? ".fill" : ""))
.frame(width: 28, height: 28)
.matchedGeometryEffect(id: tab.icon, in: animation)
.foregroundStyle(selectedTab == index ? .black : .gray)
.padding(.vertical, 8)
.padding(.horizontal, 10)
.frame(width: 70)
.opacity(selectedTab == index ? 1 : 0.5)
} else {
VStack {
Image(systemName: tab.icon + (selectedTab == index ? ".fill" : ""))
.frame(width: 36, height: 18)
.matchedGeometryEffect(id: tab.icon, in: animation)
.foregroundStyle(selectedTab == index ? .black : .gray)
Text(tab.title)
.font(.caption)
.frame(width: 60)
.lineLimit(1)
.truncationMode(.tail)
}
.padding(.vertical, 8)
.padding(.horizontal, 10)
.frame(width: 80)
.opacity(selectedTab == index ? 1 : 0.5)
}
}
.background(
selectedTab == index ?
Capsule()
.fill(.white)
.shadow(color: .black.opacity(0.2), radius: 6)
.matchedGeometryEffect(id: "background_capsule", in: animation)
: nil
)
}
}

View file

@ -0,0 +1,24 @@
//
// TabBarController.swift
// Sulfur
//
// Created by Mac on 28/05/2025.
//
import SwiftUI
class TabBarController: ObservableObject {
@Published var isHidden = false
func hideTabBar() {
withAnimation(.easeInOut(duration: 0.15)) {
isHidden = true
}
}
func showTabBar() {
withAnimation(.easeInOut(duration: 0.10)) {
isHidden = false
}
}
}

View file

@ -0,0 +1,44 @@
//
// MediaInfoView.swift
// Sora
//
// Created by paul on 28/05/25.
//
import SwiftUI
/*
struct DeviceScaleModifier: ViewModifier {
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
private var scaleFactor: CGFloat {
if UIDevice.current.userInterfaceIdiom == .pad {
return horizontalSizeClass == .regular ? 1.3 : 1.1
}
return 1.0
}
func body(content: Content) -> some View {
GeometryReader { geo in
content
.scaleEffect(scaleFactor)
.frame(
width: geo.size.width / scaleFactor,
height: geo.size.height / scaleFactor
)
.position(x: geo.size.width / 2, y: geo.size.height / 2)
}
}
}*/
struct DeviceScaleModifier: ViewModifier {
func body(content: Content) -> some View {
content // does nothing for now
}
}
extension View {
func deviceScaled() -> some View {
modifier(DeviceScaleModifier())
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,125 @@
//
// AllBookmarks.swift
// Sulfur
//
// Created by paul on 29/04/2025.
//
import SwiftUI
import Kingfisher
import UIKit
extension View {
func circularGradientOutlineTwo() -> some View {
self.background(
Circle()
.stroke(
LinearGradient(
gradient: Gradient(stops: [
.init(color: Color.accentColor.opacity(0.25), location: 0),
.init(color: Color.accentColor.opacity(0), location: 1)
]),
startPoint: .top,
endPoint: .bottom
),
lineWidth: 0.5
)
)
}
}
struct AllBookmarks: View {
@EnvironmentObject var libraryManager: LibraryManager
@EnvironmentObject var moduleManager: ModuleManager
var body: some View {
BookmarkGridView(
bookmarks: libraryManager.bookmarks.sorted { $0.title < $1.title },
moduleManager: moduleManager
)
.navigationBarBackButtonHidden(true)
.navigationBarTitleDisplayMode(.inline)
.onAppear(perform: setupNavigationController)
}
private func setupNavigationController() {
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first,
let navigationController = window.rootViewController?.children.first as? UINavigationController {
navigationController.interactivePopGestureRecognizer?.isEnabled = true
navigationController.interactivePopGestureRecognizer?.delegate = nil
}
}
}
struct BookmarkCell: View {
let bookmark: LibraryItem
@EnvironmentObject private var moduleManager: ModuleManager
var body: some View {
if let module = moduleManager.modules.first(where: { $0.id.uuidString == bookmark.moduleId }) {
ZStack {
KFImage(URL(string: bookmark.imageUrl))
.resizable()
.aspectRatio(0.72, contentMode: .fill)
.frame(width: 162, height: 243)
.cornerRadius(12)
.clipped()
.overlay(
ZStack {
Circle()
.fill(Color.black.opacity(0.5))
.frame(width: 28, height: 28)
.overlay(
KFImage(URL(string: module.metadata.iconUrl))
.resizable()
.scaledToFill()
.frame(width: 32, height: 32)
.clipShape(Circle())
)
}
.padding(8),
alignment: .topLeading
)
VStack {
Spacer()
Text(bookmark.title)
.frame(maxWidth: .infinity, alignment: .leading)
.lineLimit(2)
.foregroundColor(.white)
.padding(12)
.background(
LinearGradient(
colors: [
.black.opacity(0.7),
.black.opacity(0.0)
],
startPoint: .bottom,
endPoint: .top
)
.shadow(color: .black, radius: 4, x: 0, y: 2)
)
}
.frame(width: 162)
}
.clipShape(RoundedRectangle(cornerRadius: 12))
.padding(4)
}
}
}
private extension View {
func withNavigationBarModifiers() -> some View {
self
.navigationBarBackButtonHidden(true)
.navigationBarTitleDisplayMode(.inline)
}
func withGridPadding() -> some View {
self
.padding(.top)
.padding()
.scrollViewBottomPadding()
}
}

View file

@ -0,0 +1,309 @@
//
// AllBookmarks.swift
// Sulfur
//
// Created by paul on 24/05/2025.
//
import SwiftUI
import Kingfisher
import UIKit
extension View {
func circularGradientOutline() -> some View {
self.background(
Circle()
.stroke(
LinearGradient(
gradient: Gradient(stops: [
.init(color: Color.accentColor.opacity(0.25), location: 0),
.init(color: Color.accentColor.opacity(0), location: 1)
]),
startPoint: .top,
endPoint: .bottom
),
lineWidth: 0.5
)
)
}
}
struct AllWatchingView: View {
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var moduleManager: ModuleManager
@State private var continueWatchingItems: [ContinueWatchingItem] = []
@State private var sortOption: SortOption = .dateAdded
enum SortOption: String, CaseIterable {
case dateAdded = "Date Added"
case title = "Title"
case source = "Source"
case progress = "Progress"
}
var sortedItems: [ContinueWatchingItem] {
switch sortOption {
case .dateAdded:
return continueWatchingItems.reversed()
case .title:
return continueWatchingItems.sorted { $0.mediaTitle.lowercased() < $1.mediaTitle.lowercased() }
case .source:
return continueWatchingItems.sorted { $0.module.metadata.sourceName < $1.module.metadata.sourceName }
case .progress:
return continueWatchingItems.sorted { $0.progress > $1.progress }
}
}
var body: some View {
VStack(alignment: .leading) {
HStack {
Button(action: {
dismiss()
}) {
Image(systemName: "chevron.left")
.font(.system(size: 24))
.foregroundColor(.primary)
}
Button(action: {
dismiss()
}) {
Text("All Watching")
.font(.title3)
.fontWeight(.bold)
.foregroundColor(.primary)
}
Spacer()
Menu {
ForEach(SortOption.allCases, id: \.self) { option in
Button {
sortOption = option
} label: {
HStack {
Text(option.rawValue)
if option == sortOption {
Image(systemName: "checkmark")
.foregroundColor(.accentColor)
}
}
}
}
} label: {
Image(systemName: "line.3.horizontal.decrease.circle")
.resizable()
.frame(width: 24, height: 24)
.foregroundColor(.accentColor)
.padding(6)
.background(Color.gray.opacity(0.2))
.clipShape(Circle())
.circularGradientOutline()
}
}
.padding(.horizontal)
.padding(.top)
ScrollView {
LazyVStack(spacing: 12) {
ForEach(sortedItems) { item in
FullWidthContinueWatchingCell(
item: item,
markAsWatched: {
markAsWatched(item: item)
},
removeItem: {
removeItem(item: item)
}
)
}
}
.padding(.top)
.padding()
.scrollViewBottomPadding()
}
}
.navigationBarBackButtonHidden(true)
.navigationBarTitleDisplayMode(.inline)
.onAppear {
loadContinueWatchingItems()
// Enable swipe back gesture
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first,
let navigationController = window.rootViewController?.children.first as? UINavigationController {
navigationController.interactivePopGestureRecognizer?.isEnabled = true
navigationController.interactivePopGestureRecognizer?.delegate = nil
}
}
}
private func loadContinueWatchingItems() {
continueWatchingItems = ContinueWatchingManager.shared.fetchItems()
}
private func markAsWatched(item: ContinueWatchingItem) {
let key = "lastPlayedTime_\(item.fullUrl)"
let totalKey = "totalTime_\(item.fullUrl)"
UserDefaults.standard.set(99999999.0, forKey: key)
UserDefaults.standard.set(99999999.0, forKey: totalKey)
ContinueWatchingManager.shared.remove(item: item)
loadContinueWatchingItems()
}
private func removeItem(item: ContinueWatchingItem) {
ContinueWatchingManager.shared.remove(item: item)
loadContinueWatchingItems()
}
}
struct FullWidthContinueWatchingCell: View {
let item: ContinueWatchingItem
var markAsWatched: () -> Void
var removeItem: () -> Void
@State private var currentProgress: Double = 0.0
var body: some View {
Button(action: {
if UserDefaults.standard.string(forKey: "externalPlayer") == "Default" {
let videoPlayerViewController = VideoPlayerViewController(module: item.module)
videoPlayerViewController.streamUrl = item.streamUrl
videoPlayerViewController.fullUrl = item.fullUrl
videoPlayerViewController.episodeImageUrl = item.imageUrl
videoPlayerViewController.episodeNumber = item.episodeNumber
videoPlayerViewController.mediaTitle = item.mediaTitle
videoPlayerViewController.subtitles = item.subtitles ?? ""
videoPlayerViewController.aniListID = item.aniListID ?? 0
videoPlayerViewController.modalPresentationStyle = .fullScreen
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let rootVC = windowScene.windows.first?.rootViewController {
findTopViewController.findViewController(rootVC).present(videoPlayerViewController, animated: true, completion: nil)
}
} else {
let customMediaPlayer = CustomMediaPlayerViewController(
module: item.module,
urlString: item.streamUrl,
fullUrl: item.fullUrl,
title: item.mediaTitle,
episodeNumber: item.episodeNumber,
onWatchNext: { },
subtitlesURL: item.subtitles,
aniListID: item.aniListID ?? 0,
totalEpisodes: item.totalEpisodes,
episodeImageUrl: item.imageUrl,
headers: item.headers ?? nil
)
customMediaPlayer.modalPresentationStyle = .fullScreen
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let rootVC = windowScene.windows.first?.rootViewController {
findTopViewController.findViewController(rootVC).present(customMediaPlayer, animated: true, completion: nil)
}
}
}) {
GeometryReader { geometry in
ZStack(alignment: .bottomLeading) {
KFImage(URL(string: item.imageUrl.isEmpty ? "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/main/assets/banner2.png" : item.imageUrl))
.placeholder {
RoundedRectangle(cornerRadius: 10)
.fill(Color.gray.opacity(0.3))
.frame(height: 157.03)
.shimmering()
}
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: geometry.size.width, height: 157.03)
.cornerRadius(10)
.clipped()
.overlay(
ZStack {
ProgressiveBlurView()
.cornerRadius(10, corners: [.bottomLeft, .bottomRight])
VStack(alignment: .leading, spacing: 4) {
Spacer()
Text(item.mediaTitle)
.font(.headline)
.foregroundColor(.white)
.lineLimit(1)
HStack {
Text("Episode \(item.episodeNumber)")
.font(.subheadline)
.foregroundColor(.white.opacity(0.9))
Spacer()
Text("\(Int(item.progress * 100))% seen")
.font(.caption)
.foregroundColor(.white.opacity(0.9))
}
}
.padding(10)
.background(
LinearGradient(
colors: [
.black.opacity(0.7),
.black.opacity(0.0)
],
startPoint: .bottom,
endPoint: .top
)
.clipped()
.cornerRadius(10, corners: [.bottomLeft, .bottomRight])
.shadow(color: .black.opacity(0.3), radius: 3, x: 0, y: 1)
)
},
alignment: .bottom
)
.overlay(
ZStack {
Circle()
.fill(Color.black.opacity(0.5))
.frame(width: 28, height: 28)
.overlay(
KFImage(URL(string: item.module.metadata.iconUrl))
.resizable()
.scaledToFill()
.frame(width: 32, height: 32)
.clipShape(Circle())
)
}
.padding(8),
alignment: .topLeading
)
}
}
.frame(height: 157.03)
}
.contextMenu {
Button(action: { markAsWatched() }) {
Label("Mark as Watched", systemImage: "checkmark.circle")
}
Button(role: .destructive, action: { removeItem() }) {
Label("Remove Item", systemImage: "trash")
}
}
.onAppear {
updateProgress()
}
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
updateProgress()
}
}
private func updateProgress() {
let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(item.fullUrl)")
let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(item.fullUrl)")
if totalTime > 0 {
let ratio = lastPlayedTime / totalTime
currentProgress = max(0, min(ratio, 1))
} else {
currentProgress = max(0, min(item.progress, 1))
}
}
}

View file

@ -0,0 +1,25 @@
//
// MediaInfoView.swift
// Sora
//
// Created by paul on 28/05/25.
//
import SwiftUI
struct BookmarkGridItemView: View {
let bookmark: LibraryItem
let moduleManager: ModuleManager
var body: some View {
Group {
if let module = moduleManager.modules.first(where: { $0.id.uuidString == bookmark.moduleId }) {
BookmarkLink(
bookmark: bookmark,
module: module
)
}
}
}
}

View file

@ -0,0 +1,32 @@
//
// MediaInfoView.swift
// Sora
//
// Created by paul on 28/05/25.
//
import SwiftUI
struct BookmarkGridView: View {
let bookmarks: [LibraryItem]
let moduleManager: ModuleManager
private let columns = [
GridItem(.adaptive(minimum: 150))
]
var body: some View {
ScrollView {
LazyVGrid(columns: columns, spacing: 16) {
ForEach(bookmarks) { bookmark in
BookmarkGridItemView(
bookmark: bookmark,
moduleManager: moduleManager
)
}
}
.padding()
.scrollViewBottomPadding()
}
}
}

View file

@ -0,0 +1,24 @@
//
// MediaInfoView.swift
// Sora
//
// Created by paul on 28/05/25.
//
import SwiftUI
struct BookmarkLink: View {
let bookmark: LibraryItem
let module: Module
var body: some View {
NavigationLink(destination: MediaInfoView(
title: bookmark.title,
imageUrl: bookmark.imageUrl,
href: bookmark.href,
module: module
)) {
BookmarkCell(bookmark: bookmark)
}
}
}

View file

@ -0,0 +1,141 @@
//
// MediaInfoView.swift
// Sora
//
// Created by paul on 28/05/25.
//
import SwiftUI
import Kingfisher
import UIKit
struct BookmarksDetailView: View {
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var libraryManager: LibraryManager
@EnvironmentObject private var moduleManager: ModuleManager
@Binding var bookmarks: [LibraryItem]
@State private var sortOption: SortOption = .dateAdded
enum SortOption: String, CaseIterable {
case dateAdded = "Date Added"
case title = "Title"
case source = "Source"
}
var sortedBookmarks: [LibraryItem] {
switch sortOption {
case .dateAdded:
return bookmarks
case .title:
return bookmarks.sorted { $0.title.lowercased() < $1.title.lowercased() }
case .source:
return bookmarks.sorted { item1, item2 in
let module1 = moduleManager.modules.first { $0.id.uuidString == item1.moduleId }
let module2 = moduleManager.modules.first { $0.id.uuidString == item2.moduleId }
return (module1?.metadata.sourceName ?? "") < (module2?.metadata.sourceName ?? "")
}
}
}
var body: some View {
VStack(alignment: .leading) {
HStack(spacing: 8) {
Button(action: { dismiss() }) {
Image(systemName: "chevron.left")
.font(.system(size: 24))
.foregroundColor(.primary)
}
Button(action: { dismiss() }) {
Text("All Bookmarks")
.font(.title3)
.fontWeight(.bold)
.foregroundColor(.primary)
}
Spacer()
SortMenu(sortOption: $sortOption)
}
.padding(.horizontal)
.padding(.top)
BookmarksDetailGrid(
bookmarks: sortedBookmarks,
moduleManager: moduleManager
)
}
.navigationBarBackButtonHidden(true)
.navigationBarTitleDisplayMode(.inline)
.onAppear {
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first,
let navigationController = window.rootViewController?.children.first as? UINavigationController {
navigationController.interactivePopGestureRecognizer?.isEnabled = true
navigationController.interactivePopGestureRecognizer?.delegate = nil
}
}
}
}
private struct SortMenu: View {
@Binding var sortOption: BookmarksDetailView.SortOption
var body: some View {
Menu {
ForEach(BookmarksDetailView.SortOption.allCases, id: \.self) { option in
Button {
sortOption = option
} label: {
HStack {
Text(option.rawValue)
if option == sortOption {
Image(systemName: "checkmark")
.foregroundColor(.accentColor)
}
}
}
}
} label: {
Image(systemName: "line.3.horizontal.decrease.circle")
.resizable()
.frame(width: 24, height: 24)
.foregroundColor(.accentColor)
.padding(6)
.background(Color.gray.opacity(0.2))
.clipShape(Circle())
.circularGradientOutline()
}
}
}
private struct BookmarksDetailGrid: View {
let bookmarks: [LibraryItem]
let moduleManager: ModuleManager
private let columns = [GridItem(.adaptive(minimum: 150))]
var body: some View {
ScrollView {
LazyVGrid(columns: columns, spacing: 16) {
ForEach(bookmarks) { bookmark in
BookmarksDetailGridCell(bookmark: bookmark, moduleManager: moduleManager)
}
}
.padding(.top)
.padding()
.scrollViewBottomPadding()
}
}
}
private struct BookmarksDetailGridCell: View {
let bookmark: LibraryItem
let moduleManager: ModuleManager
var body: some View {
if let module = moduleManager.modules.first(where: { $0.id.uuidString == bookmark.moduleId }) {
NavigationLink(destination: MediaInfoView(
title: bookmark.title,
imageUrl: bookmark.imageUrl,
href: bookmark.href,
module: module
)) {
BookmarkCell(bookmark: bookmark)
}
}
}
}

View file

@ -7,6 +7,7 @@
import SwiftUI
import Kingfisher
import UIKit
struct LibraryView: View {
@EnvironmentObject private var libraryManager: LibraryManager
@ -16,19 +17,23 @@ struct LibraryView: View {
@AppStorage("mediaColumnsLandscape") private var mediaColumnsLandscape: Int = 4
@Environment(\.verticalSizeClass) var verticalSizeClass
@Environment(\.horizontalSizeClass) var horizontalSizeClass
@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
@State private var selectedTab: Int = 0
private let columns = [
GridItem(.adaptive(minimum: 150), spacing: 12)
]
private var columnsCount: Int {
if UIDevice.current.userInterfaceIdiom == .pad {
if UIDevice.current.userInterfaceIdiom == .pad && horizontalSizeClass == .compact {
return verticalSizeClass == .compact ? 3 : 2
} else if UIDevice.current.userInterfaceIdiom == .pad {
let isLandscape = UIScreen.main.bounds.width > UIScreen.main.bounds.height
return isLandscape ? mediaColumnsLandscape : mediaColumnsPortrait
} else {
@ -49,108 +54,97 @@ struct LibraryView: View {
var body: some View {
NavigationView {
ScrollView {
let columnsCount = determineColumns()
VStack(alignment: .leading, spacing: 12) {
Text("Continue Watching")
.font(.title2)
.bold()
.padding(.horizontal, 20)
if continueWatchingItems.isEmpty {
VStack(spacing: 8) {
Image(systemName: "play.circle")
.font(.largeTitle)
.foregroundColor(.secondary)
Text("No items to continue watching.")
.font(.headline)
Text("Recently watched content will appear here.")
.font(.caption)
.foregroundColor(.secondary)
}
.padding()
.frame(maxWidth: .infinity)
} else {
ContinueWatchingSection(items: $continueWatchingItems, markAsWatched: { item in
markContinueWatchingItemAsWatched(item: item)
}, removeItem: { item in
removeContinueWatchingItem(item: item)
})
}
Text("Bookmarks")
.font(.title2)
.bold()
.padding(.horizontal, 20)
if libraryManager.bookmarks.isEmpty {
VStack(spacing: 8) {
Image(systemName: "magazine")
.font(.largeTitle)
.foregroundColor(.secondary)
Text("You have no items saved.")
.font(.headline)
Text("Bookmark items for an easier access later.")
.font(.caption)
.foregroundColor(.secondary)
}
.padding()
.frame(maxWidth: .infinity)
} else {
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 }) {
Button(action: {
selectedBookmark = item
isDetailActive = true
}) {
VStack(alignment: .leading) {
ZStack {
KFImage(URL(string: item.imageUrl))
.placeholder {
RoundedRectangle(cornerRadius: 10)
.fill(Color.gray.opacity(0.3))
.aspectRatio(2/3, contentMode: .fit)
.shimmering()
}
.resizable()
.aspectRatio(contentMode: .fill)
.frame(height: cellWidth * 3 / 2)
.frame(maxWidth: cellWidth)
.cornerRadius(10)
.clipped()
.overlay(
KFImage(URL(string: module.metadata.iconUrl))
.resizable()
.frame(width: 24, height: 24)
.cornerRadius(4)
.padding(4),
alignment: .topLeading
)
}
Text(item.title)
.font(.subheadline)
.foregroundColor(.primary)
.lineLimit(1)
.multilineTextAlignment(.leading)
}
}
.contextMenu {
Button(role: .destructive, action: {
libraryManager.removeBookmark(item: item)
}) {
Label("Remove from Bookmarks", systemImage: "trash")
}
}
}
ZStack {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
Text("Library")
.font(.largeTitle)
.fontWeight(.bold)
.padding(.horizontal, 20)
.padding(.top, 20)
HStack {
HStack(spacing: 4) {
Image(systemName: "play.fill")
.font(.subheadline)
Text("Continue Watching")
.font(.title3)
.fontWeight(.semibold)
}
Spacer()
NavigationLink(destination: AllWatchingView()) {
Text("View All")
.font(.subheadline)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color.gray.opacity(0.2))
.cornerRadius(15)
.gradientOutline()
}
}
.padding(.horizontal, 20)
if continueWatchingItems.isEmpty {
VStack(spacing: 8) {
Image(systemName: "play.circle")
.font(.largeTitle)
.foregroundColor(.secondary)
Text("No items to continue watching.")
.font(.headline)
Text("Recently watched content will appear here.")
.font(.caption)
.foregroundColor(.secondary)
}
.padding()
.frame(maxWidth: .infinity)
} else {
ContinueWatchingSection(items: $continueWatchingItems, markAsWatched: {
item in
markContinueWatchingItemAsWatched(item: item)
}, removeItem: {
item in
removeContinueWatchingItem(item: item)
})
}
HStack {
HStack(spacing: 4) {
Image(systemName: "bookmark.fill")
.font(.subheadline)
Text("Bookmarks")
.font(.title3)
.fontWeight(.semibold)
}
Spacer()
NavigationLink(destination: BookmarksDetailView(bookmarks: $libraryManager.bookmarks)) {
Text("View All")
.font(.subheadline)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color.gray.opacity(0.2))
.cornerRadius(15)
.gradientOutline()
}
}
.padding(.horizontal, 20)
BookmarksSection(
selectedBookmark: $selectedBookmark,
isDetailActive: $isDetailActive
)
Spacer().frame(height: 100)
NavigationLink(
destination: Group {
if let bookmark = selectedBookmark,
let module = moduleManager.modules.first(where: { $0.id.uuidString == bookmark.moduleId }) {
let module = moduleManager.modules.first(where: {
$0.id.uuidString == bookmark.moduleId
}) {
MediaInfoView(title: bookmark.title,
imageUrl: bookmark.imageUrl,
href: bookmark.href,
@ -163,19 +157,14 @@ struct LibraryView: View {
) {
EmptyView()
}
.onAppear {
updateOrientation()
}
.onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in
updateOrientation()
}
}
.padding(.bottom, 20)
}
.scrollViewBottomPadding()
.deviceScaled()
.onAppear {
fetchContinueWatching()
}
.padding(.vertical, 20)
}
.navigationTitle("Library")
.onAppear {
fetchContinueWatching()
}
}
.navigationViewStyle(StackNavigationViewStyle())
@ -191,12 +180,16 @@ struct LibraryView: View {
UserDefaults.standard.set(99999999.0, forKey: key)
UserDefaults.standard.set(99999999.0, forKey: totalKey)
ContinueWatchingManager.shared.remove(item: item)
continueWatchingItems.removeAll { $0.id == item.id }
continueWatchingItems.removeAll {
$0.id == item.id
}
}
private func removeContinueWatchingItem(item: ContinueWatchingItem) {
ContinueWatchingManager.shared.remove(item: item)
continueWatchingItems.removeAll { $0.id == item.id }
continueWatchingItems.removeAll {
$0.id == item.id
}
}
private func updateOrientation() {
@ -206,7 +199,10 @@ struct LibraryView: View {
}
private func determineColumns() -> Int {
if UIDevice.current.userInterfaceIdiom == .pad {
if UIDevice.current.userInterfaceIdiom == .pad && horizontalSizeClass == .compact {
return verticalSizeClass == .compact ? 3 : 2
} else if UIDevice.current.userInterfaceIdiom == .pad {
let isLandscape = UIScreen.main.bounds.width > UIScreen.main.bounds.height
return isLandscape ? mediaColumnsLandscape : mediaColumnsPortrait
} else {
return verticalSizeClass == .compact ? mediaColumnsLandscape : mediaColumnsPortrait
@ -220,20 +216,18 @@ struct ContinueWatchingSection: View {
var removeItem: (ContinueWatchingItem) -> Void
var body: some View {
VStack(alignment: .leading) {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(Array(items.reversed())) { item in
ContinueWatchingCell(item: item, markAsWatched: {
markAsWatched(item)
}, removeItem: {
removeItem(item)
})
}
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(Array(items.reversed().prefix(5))) { item in
ContinueWatchingCell(item: item, markAsWatched: {
markAsWatched(item)
}, removeItem: {
removeItem(item)
})
}
.padding(.horizontal, 20)
}
.frame(height: 190)
.padding(.horizontal, 20)
.frame(height: 157.03)
}
}
}
@ -243,7 +237,8 @@ struct ContinueWatchingCell: View {
var markAsWatched: () -> Void
var removeItem: () -> Void
@State private var currentProgress: Double = 0.0
@State private
var currentProgress: Double = 0.0
var body: some View {
Button(action: {
@ -272,9 +267,9 @@ struct ContinueWatchingCell: View {
onWatchNext: { },
subtitlesURL: item.subtitles,
aniListID: item.aniListID ?? 0,
totalEpisodes: item.totalEpisodes,
episodeImageUrl: item.imageUrl,
headers: item.headers ?? nil
)
customMediaPlayer.modalPresentationStyle = .fullScreen
@ -284,99 +279,311 @@ struct ContinueWatchingCell: View {
}
}
}) {
VStack(alignment: .leading) {
ZStack {
KFImage(URL(string: item.imageUrl.isEmpty
? "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/main/assets/banner2.png"
: item.imageUrl))
.placeholder {
RoundedRectangle(cornerRadius: 10)
.fill(Color.gray.opacity(0.3))
.frame(width: 240, height: 135)
.shimmering()
}
.setProcessor(RoundCornerImageProcessor(cornerRadius: 10))
.resizable()
.aspectRatio(16/9, contentMode: .fill)
.frame(width: 240, height: 135)
.cornerRadius(10)
.clipped()
.overlay(
Group {
if item.streamUrl.hasPrefix("file://") {
Image(systemName: "arrow.down.app.fill")
.resizable()
.scaledToFit()
.frame(width: 24, height: 24)
.foregroundColor(.white)
.background(Color.black.cornerRadius(6)) // black exactly 24×24
.padding(4) // add spacing outside the black
} else {
KFImage(URL(string: item.module.metadata.iconUrl))
.resizable()
.frame(width: 24, height: 24)
.cornerRadius(4)
.padding(4)
ZStack(alignment: .bottomLeading) {
KFImage(URL(string: item.imageUrl.isEmpty ? "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/main/assets/banner2.png" : item.imageUrl))
.placeholder {
RoundedRectangle(cornerRadius: 10)
.fill(Color.gray.opacity(0.3))
.frame(width: 280, height: 157.03)
.shimmering()
}
.resizable()
.aspectRatio(16/9, contentMode: .fill)
.frame(width: 280, height: 157.03)
.cornerRadius(10)
.clipped()
.overlay(
ZStack {
ProgressiveBlurView()
.cornerRadius(10, corners: [.bottomLeft, .bottomRight])
VStack(alignment: .leading, spacing: 4) {
Spacer()
Text(item.mediaTitle)
.font(.headline)
.foregroundColor(.white)
.lineLimit(1)
HStack {
Text("Episode \(item.episodeNumber)")
.font(.subheadline)
.foregroundColor(.white.opacity(0.9))
Spacer()
Text("\(Int(item.progress * 100))% seen")
.font(.caption)
.foregroundColor(.white.opacity(0.9))
}
},
alignment: .topLeading
)
}
.overlay(
ZStack {
Rectangle()
.fill(Color.black.opacity(0.3))
.blur(radius: 3)
.frame(height: 30)
ProgressView(value: currentProgress)
.progressViewStyle(LinearProgressViewStyle(tint: .white))
.padding(.horizontal, 8)
.scaleEffect(x: 1, y: 1.5, anchor: .center)
},
alignment: .bottom
)
VStack(alignment: .leading) {
Text("Episode \(item.episodeNumber)")
.font(.caption)
.lineLimit(1)
.foregroundColor(.secondary)
Text(item.mediaTitle)
.font(.caption)
.lineLimit(2)
.foregroundColor(.primary)
.multilineTextAlignment(.leading)
}
}
.padding(10)
.background(
LinearGradient(
colors: [
.black.opacity(0.7),
.black.opacity(0.0)
],
startPoint: .bottom,
endPoint: .top
)
.clipped()
.cornerRadius(10, corners: [.bottomLeft, .bottomRight])
.shadow(color: .black.opacity(0.3), radius: 3, x: 0, y: 1)
)
},
alignment: .bottom
)
.overlay(
ZStack {
if item.streamUrl.hasPrefix("file://") {
Image(systemName: "arrow.down.app.fill")
.resizable()
.scaledToFit()
.frame(width: 24, height: 24)
.foregroundColor(.white)
.background(Color.black.cornerRadius(6))
.padding(8)
} else {
Circle()
.fill(Color.black.opacity(0.5))
.frame(width: 28, height: 28)
.overlay(
KFImage(URL(string: item.module.metadata.iconUrl))
.resizable()
.scaledToFill()
.frame(width: 32, height: 32)
.clipShape(Circle())
)
.padding(8)
}
},
alignment: .topLeading
)
}
.frame(width: 240, height: 170)
.frame(width: 280, height: 157.03)
}
.contextMenu {
Button(action: { markAsWatched() }) {
Button(action: {
markAsWatched()
}) {
Label("Mark as Watched", systemImage: "checkmark.circle")
}
Button(role: .destructive, action: { removeItem() }) {
Button(role: .destructive, action: {
removeItem()
}) {
Label("Remove Item", systemImage: "trash")
}
}
.onAppear {
updateProgress()
}
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
updateProgress()
}
.onReceive(NotificationCenter.default.publisher(
for: UIApplication.didBecomeActiveNotification)) {
_ in
updateProgress()
}
}
private func updateProgress() {
let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(item.fullUrl)")
let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(item.fullUrl)")
let lastPlayed = UserDefaults.standard.double(forKey: "lastPlayedTime_\(item.fullUrl)")
let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(item.fullUrl)")
let ratio: Double
if totalTime > 0 {
let ratio = lastPlayedTime / totalTime
currentProgress = max(0, min(ratio, 1))
ratio = min(max(lastPlayed / totalTime, 0), 1)
} else {
currentProgress = max(0, min(item.progress, 1))
ratio = min(max(item.progress, 0), 1)
}
currentProgress = ratio
if ratio >= 0.9 {
removeItem()
} else {
var updated = item
updated.progress = ratio
ContinueWatchingManager.shared.save(item: updated)
}
}
}
struct RoundedCorner: Shape {
var radius: CGFloat = .infinity
var corners: UIRectCorner = .allCorners
func path( in rect: CGRect) -> Path {
let path = UIBezierPath(
roundedRect: rect,
byRoundingCorners: corners,
cornerRadii: CGSize(width: radius, height: radius)
)
return Path(path.cgPath)
}
}
extension View {
func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View {
clipShape(RoundedCorner(radius: radius, corners: corners))
}
func gradientOutline() -> some View {
self.background(
RoundedRectangle(cornerRadius: 15)
.stroke(
LinearGradient(
gradient: Gradient(stops: [
.init(color: Color.accentColor.opacity(0.25), location: 0),
.init(color: Color.accentColor.opacity(0), location: 1)
]),
startPoint: .top,
endPoint: .bottom
),
lineWidth: 0.5
)
)
}
}
struct BookmarksSection: View {
@EnvironmentObject private var libraryManager: LibraryManager
@EnvironmentObject private var moduleManager: ModuleManager
@Binding var selectedBookmark: LibraryItem?
@Binding var isDetailActive: Bool
var body: some View {
VStack(alignment: .leading, spacing: 16) {
if libraryManager.bookmarks.isEmpty {
EmptyBookmarksView()
} else {
BookmarksGridView(
selectedBookmark: $selectedBookmark,
isDetailActive: $isDetailActive
)
}
}
}
}
struct EmptyBookmarksView: View {
var body: some View {
VStack(spacing: 8) {
Image(systemName: "magazine")
.font(.largeTitle)
.foregroundColor(.secondary)
Text("You have no items saved.")
.font(.headline)
Text("Bookmark items for an easier access later.")
.font(.caption)
.foregroundColor(.secondary)
}
.padding()
.frame(maxWidth: .infinity)
}
}
struct BookmarksGridView: View {
@EnvironmentObject private var libraryManager: LibraryManager
@EnvironmentObject private var moduleManager: ModuleManager
@Binding var selectedBookmark: LibraryItem?
@Binding var isDetailActive: Bool
private var recentBookmarks: [LibraryItem] {
Array(libraryManager.bookmarks.prefix(5))
}
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(recentBookmarks) { item in
BookmarkItemView(
item: item,
selectedBookmark: $selectedBookmark,
isDetailActive: $isDetailActive
)
}
}
.padding(.horizontal, 20)
.frame(height: 243)
}
}
}
struct BookmarkItemView: View {
@EnvironmentObject private var libraryManager: LibraryManager
@EnvironmentObject private var moduleManager: ModuleManager
let item: LibraryItem
@Binding var selectedBookmark: LibraryItem?
@Binding var isDetailActive: Bool
var body: some View {
if let module = moduleManager.modules.first(where: { $0.id.uuidString == item.moduleId }) {
Button(action: {
selectedBookmark = item
isDetailActive = true
}) {
ZStack {
KFImage(URL(string: item.imageUrl))
.placeholder {
RoundedRectangle(cornerRadius: 12)
.fill(Color.gray.opacity(0.3))
.aspectRatio(2 / 3, contentMode: .fit)
.shimmering()
}
.resizable()
.aspectRatio(0.72, contentMode: .fill)
.frame(width: 162, height: 243)
.cornerRadius(12)
.clipped()
.overlay(
ZStack {
Circle()
.fill(Color.black.opacity(0.5))
.frame(width: 28, height: 28)
.overlay(
KFImage(URL(string: module.metadata.iconUrl))
.resizable()
.scaledToFill()
.frame(width: 32, height: 32)
.clipShape(Circle())
)
}
.padding(8),
alignment: .topLeading
)
VStack {
Spacer()
Text(item.title)
.frame(maxWidth: .infinity, alignment: .leading)
.lineLimit(2)
.foregroundColor(.white)
.padding(12)
.background(
LinearGradient(
colors: [
.black.opacity(0.7),
.black.opacity(0.0)
],
startPoint: .bottom,
endPoint: .top
)
.shadow(color: .black, radius: 4, x: 0, y: 2)
)
}
}
.frame(width: 162, height: 243)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.contextMenu {
Button(role: .destructive, action: {
libraryManager.removeBookmark(item: item)
}) {
Label("Remove from Bookmarks", systemImage: "trash")
}
}
}
}
}

View file

@ -0,0 +1,213 @@
//
// AnilistMatchPopupView.swift
// Sulfur
//
// Created by seiike on 01/06/2025.
//
import SwiftUI
import Kingfisher
struct AnilistMatchPopupView: View {
let seriesTitle: String
let onSelect: (Int) -> Void
@State private var results: [[String: Any]] = []
@State private var isLoading = true
@AppStorage("selectedAppearance") private var selectedAppearance: Appearance = .system
@Environment(\.colorScheme) private var colorScheme
private var isLightMode: Bool {
selectedAppearance == .light
|| (selectedAppearance == .system && colorScheme == .light)
}
@State private var manualIDText: String = ""
@State private var showingManualIDAlert = false
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationView {
ScrollView {
VStack(alignment: .leading, spacing: 4) {
// (Optional) A hidden header; can be omitted if empty
Text("".uppercased())
.font(.footnote)
.foregroundStyle(.gray)
.padding(.horizontal, 10)
VStack(spacing: 0) {
if isLoading {
ProgressView()
.frame(maxWidth: .infinity)
.padding()
} else if results.isEmpty {
Text("No matches found")
.font(.subheadline)
.foregroundStyle(.gray)
.frame(maxWidth: .infinity)
.padding()
} else {
LazyVStack(spacing: 15) {
ForEach(results.indices, id: \.self) { index in
let result = results[index]
Button(action: {
if let id = result["id"] as? Int {
onSelect(id)
}
}) {
HStack(spacing: 12) {
if let cover = result["cover"] as? String,
let url = URL(string: cover) {
KFImage(url)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 50, height: 70)
.cornerRadius(6)
}
VStack(alignment: .leading, spacing: 2) {
Text(result["title"] as? String ?? "Unknown")
.font(.body)
.foregroundStyle(.primary)
if let english = result["title_english"] as? String {
Text(english)
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
}
.padding(11)
.frame(maxWidth: .infinity)
.background(
RoundedRectangle(cornerRadius: 15)
.fill(.ultraThinMaterial)
)
.overlay(
RoundedRectangle(cornerRadius: 15)
.stroke(
LinearGradient(
stops: [
.init(color: Color.accentColor.opacity(0.25), location: 0),
.init(color: Color.accentColor.opacity(0), location: 1)
],
startPoint: .top,
endPoint: .bottom
),
lineWidth: 0.5
)
)
.clipShape(RoundedRectangle(cornerRadius: 15))
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 20)
.padding(.top, 16)
}
}
if !results.isEmpty {
Text("Tap a title to override the current match.")
.font(.footnote)
.foregroundStyle(.gray)
.padding(.horizontal, 20)
.padding(.top, 4)
}
}
.padding(.top, 2)
}
.navigationTitle("AniList Match")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
.foregroundColor(isLightMode ? .black : .white)
}
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: {
manualIDText = ""
showingManualIDAlert = true
}) {
Image(systemName: "number")
.foregroundColor(isLightMode ? .black : .white)
}
}
}
.alert("Set Custom AniList ID", isPresented: $showingManualIDAlert, actions: {
TextField("AniList ID", text: $manualIDText)
.keyboardType(.numberPad)
Button("Cancel", role: .cancel) { }
Button("Save", action: {
if let idInt = Int(manualIDText.trimmingCharacters(in: .whitespaces)) {
onSelect(idInt)
dismiss()
}
})
}, message: {
Text("Enter the AniList ID for this media")
})
}
.onAppear(perform: fetchMatches)
}
private func fetchMatches() {
let query = """
query {
Page(page: 1, perPage: 6) {
media(search: "\(seriesTitle)", type: ANIME) {
id
title {
romaji
english
}
coverImage {
large
}
}
}
}
"""
guard let url = URL(string: "https://graphql.anilist.co") else { return }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try? JSONSerialization.data(withJSONObject: ["query": query])
URLSession.shared.dataTask(with: request) { data, _, _ in
DispatchQueue.main.async {
self.isLoading = false
guard let data = data,
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let dataDict = json["data"] as? [String: Any],
let page = dataDict["Page"] as? [String: Any],
let mediaList = page["media"] as? [[String: Any]] else {
return
}
self.results = mediaList.map { media in
let titleInfo = media["title"] as? [String: Any]
let cover = (media["coverImage"] as? [String: Any])?["large"] as? String
return [
"id": media["id"] ?? 0,
"title": titleInfo?["romaji"] ?? "Unknown",
"title_english": titleInfo?["english"],
"cover": cover
]
}
}
}.resume()
}
}

View file

@ -9,12 +9,6 @@ import SwiftUI
import Kingfisher
import AVFoundation
struct EpisodeLink: Identifiable {
let id = UUID()
let number: Int
let href: String
}
struct EpisodeCell: View {
let episodeIndex: Int
let episode: String
@ -50,6 +44,10 @@ struct EpisodeCell: View {
@State private var lastLoggedStatus: EpisodeDownloadStatus?
@State private var downloadAnimationScale: CGFloat = 1.0
@State private var swipeOffset: CGFloat = 0
@State private var isShowingActions: Bool = false
@State private var actionButtonWidth: CGFloat = 60
@State private var retryAttempts: Int = 0
private let maxRetryAttempts: Int = 3
private let initialBackoffDelay: TimeInterval = 1.0
@ -71,12 +69,18 @@ struct EpisodeCell: View {
}
}
let tmdbID: Int?
let seasonNumber: Int?
init(episodeIndex: Int, episode: String, episodeID: Int, progress: Double,
itemID: Int, totalEpisodes: Int? = nil, defaultBannerImage: String = "",
module: ScrapingModule, parentTitle: String, showPosterURL: String? = nil,
isMultiSelectMode: Bool = false, isSelected: Bool = false,
onSelectionChanged: ((Bool) -> Void)? = nil,
onTap: @escaping (String) -> Void, onMarkAllPrevious: @escaping () -> Void) {
onTap: @escaping (String) -> Void, onMarkAllPrevious: @escaping () -> Void,
tmdbID: Int? = nil,
seasonNumber: Int? = nil
) {
self.episodeIndex = episodeIndex
self.episode = episode
self.episodeID = episodeID
@ -101,28 +105,123 @@ struct EpisodeCell: View {
self.onSelectionChanged = onSelectionChanged
self.onTap = onTap
self.onMarkAllPrevious = onMarkAllPrevious
self.tmdbID = tmdbID
self.seasonNumber = seasonNumber
}
var body: some View {
HStack {
episodeThumbnail
episodeInfo
Spacer()
CircularProgressBar(progress: currentProgress)
.frame(width: 40, height: 40)
.padding(.trailing, 8)
ZStack {
HStack {
Spacer()
actionButtons
}
.zIndex(0)
HStack {
episodeThumbnail
episodeInfo
Spacer()
CircularProgressBar(progress: currentProgress)
.frame(width: 40, height: 40)
.padding(.trailing, 4)
}
.contentShape(Rectangle())
.padding(.horizontal, 8)
.padding(.vertical, 8)
.frame(maxWidth: .infinity)
.background(
RoundedRectangle(cornerRadius: 15)
.fill(Color(UIColor.systemBackground))
.overlay(
RoundedRectangle(cornerRadius: 15)
.fill(Color.gray.opacity(0.2))
)
.overlay(
RoundedRectangle(cornerRadius: 15)
.stroke(
LinearGradient(
gradient: Gradient(stops: [
.init(color: Color.accentColor.opacity(0.25), location: 0),
.init(color: Color.accentColor.opacity(0), location: 1)
]),
startPoint: .top,
endPoint: .bottom
),
lineWidth: 0.5
)
)
)
.clipShape(RoundedRectangle(cornerRadius: 15))
.offset(x: swipeOffset)
.zIndex(1)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: swipeOffset)
.contextMenu {
contextMenuContent
}
.simultaneousGesture(
DragGesture()
.onChanged { value in
let horizontalTranslation = value.translation.width
let verticalTranslation = value.translation.height
let isDefinitelyHorizontalSwipe = abs(horizontalTranslation) > 10 && abs(horizontalTranslation) > abs(verticalTranslation) * 1.5
if isShowingActions || isDefinitelyHorizontalSwipe {
if horizontalTranslation < 0 {
let maxSwipe = calculateMaxSwipeDistance()
swipeOffset = max(horizontalTranslation, -maxSwipe)
} else if isShowingActions {
let maxSwipe = calculateMaxSwipeDistance()
swipeOffset = max(horizontalTranslation - maxSwipe, -maxSwipe)
}
}
}
.onEnded { value in
let horizontalTranslation = value.translation.width
let verticalTranslation = value.translation.height
let wasHandlingGesture = abs(horizontalTranslation) > 10 && abs(horizontalTranslation) > abs(verticalTranslation) * 1.5
if isShowingActions || wasHandlingGesture {
let maxSwipe = calculateMaxSwipeDistance()
let threshold = maxSwipe * 0.2
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
if horizontalTranslation < -threshold && !isShowingActions {
swipeOffset = -maxSwipe
isShowingActions = true
} else if horizontalTranslation > threshold && isShowingActions {
swipeOffset = 0
isShowingActions = false
} else {
swipeOffset = isShowingActions ? -maxSwipe : 0
}
}
}
}
)
}
.contentShape(Rectangle())
.background(isMultiSelectMode && isSelected ? Color.accentColor.opacity(0.1) : Color.clear)
.cornerRadius(8)
.contextMenu {
contextMenuContent
.onTapGesture {
if isShowingActions {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
swipeOffset = 0
isShowingActions = false
}
} else if isMultiSelectMode {
onSelectionChanged?(!isSelected)
} else {
let imageUrl = episodeImageUrl.isEmpty ? defaultBannerImage : episodeImageUrl
onTap(imageUrl)
}
}
.onAppear {
updateProgress()
updateDownloadStatus()
if let type = module.metadata.type?.lowercased(), type == "anime" {
if UserDefaults.standard.string(forKey: "metadataProviders") ?? "TMDB" == "TMDB" {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
fetchTMDBEpisodeImage()
}
} else {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
fetchAnimeEpisodeDetails()
}
@ -139,6 +238,12 @@ struct EpisodeCell: View {
.onChange(of: progress) { _ in
updateProgress()
}
.onChange(of: itemID) { newID in
loadedFromCache = false
isLoading = true
retryAttempts = maxRetryAttempts
fetchEpisodeDetails()
}
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("downloadProgressChanged"))) { _ in
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
updateDownloadStatus()
@ -151,21 +256,8 @@ struct EpisodeCell: View {
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("downloadCompleted"))) { _ in
updateDownloadStatus()
}
.onTapGesture {
if isMultiSelectMode {
onSelectionChanged?(!isSelected)
} else {
let imageUrl = episodeImageUrl.isEmpty ? defaultBannerImage : episodeImageUrl
onTap(imageUrl)
}
}
.alert("Download Episode", isPresented: $showDownloadConfirmation) {
Button("Cancel", role: .cancel) {}
Button("Download") {
downloadEpisode()
}
} message: {
Text("Do you want to download Episode \(episodeID + 1)\(episodeTitle.isEmpty ? "" : ": \(episodeTitle)")?")
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("episodeProgressChanged"))) { _ in
updateProgress()
}
}
@ -176,7 +268,6 @@ struct EpisodeCell: View {
.onFailure { error in
Logger.shared.log("Failed to load episode image: \(error)", type: "Error")
}
.cacheMemoryOnly(!KingfisherCacheManager.shared.isCachingEnabled)
.resizable()
.aspectRatio(16/9, contentMode: .fill)
.frame(width: 100, height: 56)
@ -385,38 +476,136 @@ struct EpisodeCell: View {
return
}
if let streams = result.streams, !streams.isEmpty, let url = URL(string: streams[0]) {
if let sources = result.sources, !sources.isEmpty {
if sources.count > 1 {
showDownloadStreamSelectionAlert(streams: sources, downloadID: downloadID, subtitleURL: result.subtitles?.first)
return
} else if let streamUrl = sources[0]["streamUrl"] as? String, let url = URL(string: streamUrl) {
let subtitleURLString = sources[0]["subtitle"] as? String
let subtitleURL = subtitleURLString.flatMap { URL(string: $0) }
if let subtitleURL = subtitleURL {
Logger.shared.log("[Download] Found subtitle URL: \(subtitleURL.absoluteString)")
}
startActualDownload(url: url, streamUrl: streamUrl, downloadID: downloadID, subtitleURL: subtitleURL)
return
}
}
if let streams = result.streams, !streams.isEmpty {
if streams[0] == "[object Promise]" {
print("[Download] Method #\(methodIndex+1) returned a Promise object, trying next method")
tryNextDownloadMethod(methodIndex: methodIndex + 1, downloadID: downloadID, softsub: softsub)
return
}
print("[Download] Method #\(methodIndex+1) returned valid stream URL: \(streams[0])")
let subtitleURL = result.subtitles?.first.flatMap { URL(string: $0) }
if let subtitleURL = subtitleURL {
print("[Download] Found subtitle URL: \(subtitleURL.absoluteString)")
if streams.count > 1 {
showDownloadStreamSelectionAlert(streams: streams, downloadID: downloadID, subtitleURL: result.subtitles?.first)
return
} else if let url = URL(string: streams[0]) {
let subtitleURL = result.subtitles?.first.flatMap { URL(string: $0) }
if let subtitleURL = subtitleURL {
Logger.shared.log("[Download] Found subtitle URL: \(subtitleURL.absoluteString)")
}
startActualDownload(url: url, streamUrl: streams[0], downloadID: downloadID, subtitleURL: subtitleURL)
return
}
startActualDownload(url: url, streamUrl: streams[0], downloadID: downloadID, subtitleURL: subtitleURL)
} else if let sources = result.sources, !sources.isEmpty,
let streamUrl = sources[0]["streamUrl"] as? String,
let url = URL(string: streamUrl) {
print("[Download] Method #\(methodIndex+1) returned valid stream URL with headers: \(streamUrl)")
let subtitleURLString = sources[0]["subtitle"] as? String
let subtitleURL = subtitleURLString.flatMap { URL(string: $0) }
if let subtitleURL = subtitleURL {
print("[Download] Found subtitle URL: \(subtitleURL.absoluteString)")
}
startActualDownload(url: url, streamUrl: streamUrl, downloadID: downloadID, subtitleURL: subtitleURL)
} else {
print("[Download] Method #\(methodIndex+1) did not return valid streams, trying next method")
tryNextDownloadMethod(methodIndex: methodIndex + 1, downloadID: downloadID, softsub: softsub)
}
tryNextDownloadMethod(methodIndex: methodIndex + 1, downloadID: downloadID, softsub: softsub)
}
private func showDownloadStreamSelectionAlert(streams: [Any], downloadID: UUID, subtitleURL: String? = nil) {
DispatchQueue.main.async {
let alert = UIAlertController(title: "Select Download Server", message: "Choose a server to download from", preferredStyle: .actionSheet)
var index = 0
var streamIndex = 1
while index < streams.count {
var title: String = ""
var streamUrl: String = ""
if let streams = streams as? [String] {
if index + 1 < streams.count {
if !streams[index].lowercased().contains("http") {
title = streams[index]
streamUrl = streams[index + 1]
index += 2
} else {
title = "Server \(streamIndex)"
streamUrl = streams[index]
index += 1
}
} else {
title = "Server \(streamIndex)"
streamUrl = streams[index]
index += 1
}
} else if let streams = streams as? [[String: Any]] {
if let currTitle = streams[index]["title"] as? String {
title = currTitle
} else {
title = "Server \(streamIndex)"
}
streamUrl = (streams[index]["streamUrl"] as? String) ?? ""
index += 1
}
alert.addAction(UIAlertAction(title: title, style: .default) { _ in
guard let url = URL(string: streamUrl) else {
DropManager.shared.error("Invalid stream URL selected")
self.isDownloading = false
return
}
let subtitleURLObj = subtitleURL.flatMap { URL(string: $0) }
self.startActualDownload(url: url, streamUrl: streamUrl, downloadID: downloadID, subtitleURL: subtitleURLObj)
})
streamIndex += 1
}
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in
self.isDownloading = false
})
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first,
let rootVC = window.rootViewController {
if UIDevice.current.userInterfaceIdiom == .pad {
if let popover = alert.popoverPresentationController {
popover.sourceView = window
popover.sourceRect = CGRect(
x: UIScreen.main.bounds.width / 2,
y: UIScreen.main.bounds.height / 2,
width: 0,
height: 0
)
popover.permittedArrowDirections = []
}
}
self.findTopViewController(rootVC).present(alert, animated: true)
}
}
}
private func findTopViewController(_ controller: UIViewController) -> UIViewController {
if let navigationController = controller as? UINavigationController {
return findTopViewController(navigationController.visibleViewController!)
}
if let tabController = controller as? UITabBarController {
if let selected = tabController.selectedViewController {
return findTopViewController(selected)
}
}
if let presented = controller.presentedViewController {
return findTopViewController(presented)
}
return controller
}
private func startActualDownload(url: URL, streamUrl: String, downloadID: UUID, subtitleURL: URL? = nil) {
@ -523,25 +712,6 @@ struct EpisodeCell: View {
}
private func fetchEpisodeDetails() {
if MetadataCacheManager.shared.isCachingEnabled &&
(UserDefaults.standard.object(forKey: "fetchEpisodeMetadata") == nil ||
UserDefaults.standard.bool(forKey: "fetchEpisodeMetadata")) {
let cacheKey = "anilist_\(itemID)_episode_\(episodeID + 1)"
if let cachedData = MetadataCacheManager.shared.getMetadata(forKey: cacheKey),
let metadata = EpisodeMetadata.fromData(cachedData) {
DispatchQueue.main.async {
self.episodeTitle = metadata.title["en"] ?? ""
self.episodeImageUrl = metadata.imageUrl
self.isLoading = false
self.loadedFromCache = true
}
return
}
}
fetchAnimeEpisodeDetails()
}
@ -618,22 +788,6 @@ struct EpisodeCell: View {
Logger.shared.log("Episode \(episodeKey) missing fields: \(missingFields.joined(separator: ", "))", type: "Warning")
}
if MetadataCacheManager.shared.isCachingEnabled && (!title.isEmpty || !image.isEmpty) {
let metadata = EpisodeMetadata(
title: title,
imageUrl: image,
anilistId: self.itemID,
episodeNumber: self.episodeID + 1
)
if let metadataData = metadata.toData() {
MetadataCacheManager.shared.storeMetadata(
metadataData,
forKey: metadata.cacheKey
)
}
}
DispatchQueue.main.async {
self.isLoading = false
self.retryAttempts = 0
@ -678,4 +832,143 @@ struct EpisodeCell: View {
}
}
}
private func fetchTMDBEpisodeImage() {
guard let tmdbID = tmdbID, let season = seasonNumber else { return }
let episodeNum = episodeID + 1
let urlString = "https://api.themoviedb.org/3/tv/\(tmdbID)/season/\(season)/episode/\(episodeNum)?api_key=738b4edd0a156cc126dc4a4b8aea4aca"
guard let url = URL(string: urlString) else { return }
let tmdbImageWidth = UserDefaults.standard.string(forKey: "tmdbImageWidth") ?? "original"
URLSession.custom.dataTask(with: url) { data, _, error in
guard let data = data, error == nil else { return }
do {
if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] {
let name = json["name"] as? String ?? ""
let stillPath = json["still_path"] as? String
let imageUrl: String
if let stillPath = stillPath {
if tmdbImageWidth == "original" {
imageUrl = "https://image.tmdb.org/t/p/original\(stillPath)"
} else {
imageUrl = "https://image.tmdb.org/t/p/w\(tmdbImageWidth)\(stillPath)"
}
} else {
imageUrl = ""
}
DispatchQueue.main.async {
self.episodeTitle = name
self.episodeImageUrl = imageUrl
self.isLoading = false
}
}
} catch {
Logger.shared.log("Failed to parse TMDB episode details: \(error.localizedDescription)", type: "Error")
DispatchQueue.main.async {
self.isLoading = false
}
}
}.resume()
}
private func calculateMaxSwipeDistance() -> CGFloat {
var buttonCount = 1
if progress <= 0.9 { buttonCount += 1 }
if progress != 0 { buttonCount += 1 }
if episodeIndex > 0 { buttonCount += 1 }
var swipeDistance = CGFloat(buttonCount) * actionButtonWidth + 16
if buttonCount == 3 {
swipeDistance += 12
} else if buttonCount == 4 {
swipeDistance += 24
}
return swipeDistance
}
private var actionButtons: some View {
HStack(spacing: 8) {
Button(action: {
closeActionsAndPerform {
downloadEpisode()
}
}) {
VStack(spacing: 2) {
Image(systemName: "arrow.down.circle")
.font(.title3)
Text("Download")
.font(.caption2)
}
}
.foregroundColor(.blue)
.frame(width: actionButtonWidth)
if progress <= 0.9 {
Button(action: {
closeActionsAndPerform {
markAsWatched()
}
}) {
VStack(spacing: 2) {
Image(systemName: "checkmark.circle")
.font(.title3)
Text("Watched")
.font(.caption2)
}
}
.foregroundColor(.green)
.frame(width: actionButtonWidth)
}
if progress != 0 {
Button(action: {
closeActionsAndPerform {
resetProgress()
}
}) {
VStack(spacing: 2) {
Image(systemName: "arrow.counterclockwise")
.font(.title3)
Text("Reset")
.font(.caption2)
}
}
.foregroundColor(.orange)
.frame(width: actionButtonWidth)
}
if episodeIndex > 0 {
Button(action: {
closeActionsAndPerform {
onMarkAllPrevious()
}
}) {
VStack(spacing: 2) {
Image(systemName: "checkmark.circle.fill")
.font(.title3)
Text("All Prev")
.font(.caption2)
}
}
.foregroundColor(.purple)
.frame(width: actionButtonWidth)
}
}
.padding(.horizontal, 8)
}
private func closeActionsAndPerform(action: @escaping () -> Void) {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
swipeOffset = 0
isShowingActions = false
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
action()
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,373 +0,0 @@
//
// SearchView.swift
// Sora
//
// Created by Francesco on 05/01/25.
//
import SwiftUI
import Kingfisher
struct SearchItem: Identifiable {
let id = UUID()
let title: String
let imageUrl: String
let href: String
}
struct SearchView: View {
@AppStorage("selectedModuleId") private var selectedModuleId: String?
@AppStorage("mediaColumnsPortrait") private var mediaColumnsPortrait: Int = 2
@AppStorage("mediaColumnsLandscape") private var mediaColumnsLandscape: Int = 4
@StateObject private var jsController = JSController.shared
@EnvironmentObject var moduleManager: ModuleManager
@Environment(\.verticalSizeClass) var verticalSizeClass
@State private var searchItems: [SearchItem] = []
@State private var selectedSearchItem: SearchItem?
@State private var isSearching = false
@State private var searchText = ""
@State private var hasNoResults = false
@State private var isLandscape: Bool = UIDevice.current.orientation.isLandscape
@State private var isModuleSelectorPresented = false
private var selectedModule: ScrapingModule? {
guard let id = selectedModuleId else { return nil }
return moduleManager.modules.first { $0.id.uuidString == id }
}
private var columnsCount: Int {
if UIDevice.current.userInterfaceIdiom == .pad {
let isLandscape = UIScreen.main.bounds.width > UIScreen.main.bounds.height
return isLandscape ? mediaColumnsLandscape : mediaColumnsPortrait
} else {
return verticalSizeClass == .compact ? mediaColumnsLandscape : mediaColumnsPortrait
}
}
private var cellWidth: CGFloat {
let keyWindow = UIApplication.shared.connectedScenes
.compactMap { ($0 as? UIWindowScene)?.windows.first(where: { $0.isKeyWindow }) }
.first
let safeAreaInsets = keyWindow?.safeAreaInsets ?? .zero
let safeWidth = UIScreen.main.bounds.width - safeAreaInsets.left - safeAreaInsets.right
let totalSpacing: CGFloat = 16 * CGFloat(columnsCount + 1)
let availableWidth = safeWidth - totalSpacing
return availableWidth / CGFloat(columnsCount)
}
var body: some View {
NavigationView {
ScrollView {
let columnsCount = determineColumns()
VStack(spacing: 0) {
HStack {
SearchBar(text: $searchText, onSearchButtonClicked: performSearch)
.padding(.leading)
.padding(.trailing, searchText.isEmpty ? 16 : 0)
.disabled(selectedModule == nil)
.padding(.top)
if !searchText.isEmpty {
Button("Cancel") {
searchText = ""
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}
.padding(.trailing)
.padding(.top)
}
}
if selectedModule == nil {
VStack(spacing: 8) {
Image(systemName: "questionmark.app")
.font(.largeTitle)
.foregroundColor(.secondary)
Text("No Module Selected")
.font(.headline)
Text("Please select a module from settings")
.font(.caption)
.foregroundColor(.secondary)
}
.padding()
.frame(maxWidth: .infinity)
.background(Color(.systemBackground))
.shadow(color: Color.black.opacity(0.1), radius: 2, y: 1)
}
if !searchText.isEmpty {
if isSearching {
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 16), count: columnsCount), spacing: 16) {
ForEach(0..<columnsCount*4, id: \.self) { _ in
SearchSkeletonCell(cellWidth: cellWidth)
}
}
.padding(.top)
.padding()
} else if hasNoResults {
VStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.font(.largeTitle)
.foregroundColor(.secondary)
Text("No Results Found")
.font(.headline)
Text("Try different keywords")
.font(.caption)
.foregroundColor(.secondary)
}
.padding()
.frame(maxWidth: .infinity)
.padding(.top)
} else {
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 16), count: columnsCount), spacing: 16) {
ForEach(searchItems) { item in
NavigationLink(destination: MediaInfoView(title: item.title, imageUrl: item.imageUrl, href: item.href, module: selectedModule!)) {
VStack {
KFImage(URL(string: item.imageUrl))
.resizable()
.aspectRatio(contentMode: .fill)
.frame(height: cellWidth * 3 / 2)
.frame(maxWidth: cellWidth)
.cornerRadius(10)
.clipped()
Text(item.title)
.font(.subheadline)
.foregroundColor(Color.primary)
.padding([.leading, .bottom], 8)
.lineLimit(1)
}
}
}
.onAppear {
updateOrientation()
}
.onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in
updateOrientation()
}
}
.padding(.top)
.padding()
}
}
}
}
.navigationTitle("Search")
.navigationBarTitleDisplayMode(.large)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Menu {
if getModuleLanguageGroups().count == 1 {
ForEach(moduleManager.modules, id: \.id) { module in
Button {
selectedModuleId = module.id.uuidString
} label: {
HStack {
KFImage(URL(string: module.metadata.iconUrl))
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 20, height: 20)
.cornerRadius(4)
Text(module.metadata.sourceName)
if module.id.uuidString == selectedModuleId {
Image(systemName: "checkmark")
.foregroundColor(.accentColor)
}
}
}
}
} else {
ForEach(getModuleLanguageGroups(), id: \.self) { language in
Menu(language) {
ForEach(getModulesForLanguage(language), id: \.id) { module in
Button {
selectedModuleId = module.id.uuidString
} label: {
HStack {
KFImage(URL(string: module.metadata.iconUrl))
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 20, height: 20)
.cornerRadius(4)
Text(module.metadata.sourceName)
if module.id.uuidString == selectedModuleId {
Image(systemName: "checkmark")
.foregroundColor(.accentColor)
}
}
}
}
}
}
}
} label: {
HStack(spacing: 4) {
if let selectedModule = selectedModule {
Text(selectedModule.metadata.sourceName)
.font(.headline)
.foregroundColor(.secondary)
} else {
Text("Select Module")
.font(.headline)
.foregroundColor(.accentColor)
}
Image(systemName: "chevron.down")
.foregroundColor(.secondary)
}
}
.fixedSize()
}
}
}
.navigationViewStyle(StackNavigationViewStyle())
.onChange(of: selectedModuleId) { _ in
if !searchText.isEmpty {
performSearch()
}
}
.onChange(of: moduleManager.selectedModuleChanged) { _ in
if moduleManager.selectedModuleChanged {
if selectedModuleId == nil && !moduleManager.modules.isEmpty {
selectedModuleId = moduleManager.modules[0].id.uuidString
}
moduleManager.selectedModuleChanged = false
}
}
.onChange(of: searchText) { newValue in
if newValue.isEmpty {
searchItems = []
hasNoResults = false
isSearching = false
}
}
}
private func performSearch() {
Logger.shared.log("Searching for: \(searchText)", type: "General")
guard !searchText.isEmpty, let module = selectedModule else {
searchItems = []
hasNoResults = false
return
}
isSearching = true
hasNoResults = false
searchItems = []
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
Task {
do {
let jsContent = try moduleManager.getModuleContent(module)
jsController.loadScript(jsContent)
if module.metadata.asyncJS == true {
jsController.fetchJsSearchResults(keyword: searchText, module: module) { items in
searchItems = items
hasNoResults = items.isEmpty
isSearching = false
}
} else {
jsController.fetchSearchResults(keyword: searchText, module: module) { items in
searchItems = items
hasNoResults = items.isEmpty
isSearching = false
}
}
} catch {
Logger.shared.log("Error loading module: \(error)", type: "Error")
isSearching = false
hasNoResults = true
}
}
}
}
private func updateOrientation() {
DispatchQueue.main.async {
isLandscape = UIDevice.current.orientation.isLandscape
}
}
private func determineColumns() -> Int {
if UIDevice.current.userInterfaceIdiom == .pad {
return isLandscape ? mediaColumnsLandscape : mediaColumnsPortrait
} else {
return verticalSizeClass == .compact ? mediaColumnsLandscape : mediaColumnsPortrait
}
}
private func cleanLanguageName(_ language: String?) -> String {
guard let language = language else { return "Unknown" }
let cleaned = language.replacingOccurrences(
of: "\\s*\\([^\\)]*\\)",
with: "",
options: .regularExpression
).trimmingCharacters(in: .whitespaces)
return cleaned.isEmpty ? "Unknown" : cleaned
}
private func getModulesByLanguage() -> [String: [ScrapingModule]] {
var result = [String: [ScrapingModule]]()
for module in moduleManager.modules {
let language = cleanLanguageName(module.metadata.language)
if result[language] == nil {
result[language] = [module]
} else {
result[language]?.append(module)
}
}
return result
}
private func getModuleLanguageGroups() -> [String] {
return getModulesByLanguage().keys.sorted()
}
private func getModulesForLanguage(_ language: String) -> [ScrapingModule] {
return getModulesByLanguage()[language] ?? []
}
}
struct SearchBar: View {
@State private var debounceTimer: Timer?
@Binding var text: String
var onSearchButtonClicked: () -> Void
var body: some View {
HStack {
TextField("Search...", text: $text, onCommit: onSearchButtonClicked)
.padding(7)
.padding(.horizontal, 25)
.background(Color(.systemGray6))
.cornerRadius(8)
.onChange(of: text){newValue in
debounceTimer?.invalidate()
debounceTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in
onSearchButtonClicked()
}
}
.overlay(
HStack {
Image(systemName: "magnifyingglass")
.foregroundColor(.secondary)
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
.padding(.leading, 8)
if !text.isEmpty {
Button(action: {
self.text = ""
}) {
Image(systemName: "multiply.circle.fill")
.foregroundColor(.secondary)
.padding(.trailing, 8)
}
}
}
)
}
}
}

View file

@ -0,0 +1,72 @@
//
// SearchComponents.swift
// Sora
//
// Created by Francesco on 27/01/25.
//
import SwiftUI
import Kingfisher
struct SearchItem: Identifiable {
let id = UUID()
let title: String
let imageUrl: String
let href: String
}
struct SearchHistorySection<Content: View>: View {
let title: String
let content: Content
init(title: String, @ViewBuilder content: () -> Content) {
self.title = title
self.content = content()
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(title.uppercased())
.font(.footnote)
.foregroundStyle(Color.secondary)
.padding(.horizontal, 20)
VStack(spacing: 0) {
content
}
}
.padding(.vertical, 16)
}
}
struct SearchHistoryRow: View {
let text: String
let onTap: () -> Void
let onDelete: () -> Void
var showDivider: Bool = true
var body: some View {
HStack {
Image(systemName: "clock")
.frame(width: 24, height: 24)
.foregroundStyle(Color.primary)
Text(text)
.foregroundStyle(Color.primary)
Spacer()
Button(action: onDelete) {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(Color.secondary)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.contentShape(Rectangle())
.onTapGesture(perform: onTap)
if showDivider {
Divider()
.padding(.horizontal, 16)
}
}
}

View file

@ -0,0 +1,74 @@
//
// SearchResultsGrid.swift
// Sora
//
// Created by paul on 28/05/25.
//
import SwiftUI
import Kingfisher
struct SearchResultsGrid: View {
@AppStorage("mediaColumnsPortrait") private var mediaColumnsPortrait: Int = 2
@AppStorage("mediaColumnsLandscape") private var mediaColumnsLandscape: Int = 4
@Environment(\.verticalSizeClass) var verticalSizeClass
let items: [SearchItem]
let columns: [GridItem]
let selectedModule: ScrapingModule
let cellWidth: CGFloat
private var columnsCount: Int {
if UIDevice.current.userInterfaceIdiom == .pad {
let isLandscape = UIScreen.main.bounds.width > UIScreen.main.bounds.height
return isLandscape ? mediaColumnsLandscape : mediaColumnsPortrait
} else {
return verticalSizeClass == .compact ? mediaColumnsLandscape : mediaColumnsPortrait
}
}
var body: some View {
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: columnsCount), spacing: 12) {
ForEach(items) { item in
NavigationLink(destination: MediaInfoView(title: item.title, imageUrl: item.imageUrl, href: item.href, module: selectedModule)) {
ZStack {
KFImage(URL(string: item.imageUrl))
.resizable()
.aspectRatio(0.72, contentMode: .fill)
.frame(width: cellWidth, height: cellWidth * 1.5)
.cornerRadius(12)
.clipped()
VStack {
Spacer()
HStack {
Text(item.title)
.lineLimit(2)
.foregroundColor(.white)
.multilineTextAlignment(.leading)
Spacer()
}
.padding(12)
.background(
LinearGradient(
colors: [
.black.opacity(0.7),
.black.opacity(0.0)
],
startPoint: .bottom,
endPoint: .top
)
.shadow(color: .black, radius: 4, x: 0, y: 2)
)
}
.frame(width: cellWidth)
}
.clipShape(RoundedRectangle(cornerRadius: 12))
.padding(4)
}.id(item.href)
}
}
.padding(.top)
.padding()
}
}

View file

@ -0,0 +1,41 @@
//
// SearchStateView.swift
// Sora
//
// Created by Francesco on 27/01/25.
//
import SwiftUI
struct SearchStateView: View {
let isSearching: Bool
let hasNoResults: Bool
let columnsCount: Int
let cellWidth: CGFloat
var body: some View {
if isSearching {
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 16), count: columnsCount), spacing: 16) {
ForEach(0..<columnsCount*4, id: \.self) { _ in
SearchSkeletonCell(cellWidth: cellWidth)
}
}
.padding(.top)
.padding()
} else if hasNoResults {
VStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.font(.largeTitle)
.foregroundColor(.secondary)
Text("No Results Found")
.font(.headline)
Text("Try different keywords")
.font(.caption)
.foregroundColor(.secondary)
}
.padding()
.frame(maxWidth: .infinity)
.padding(.top)
}
}
}

View file

@ -0,0 +1,335 @@
//
// SearchView.swift
// Sora
//
// Created by Francesco on 05/01/25.
//
import SwiftUI
import Kingfisher
struct ModuleButtonModifier: ViewModifier {
func body(content: Content) -> some View {
content
.buttonStyle(PlainButtonStyle())
.offset(y: 45)
.zIndex(999)
}
}
struct SearchView: View {
@AppStorage("selectedModuleId") private var selectedModuleId: String?
@AppStorage("mediaColumnsPortrait") private var mediaColumnsPortrait: Int = 2
@AppStorage("mediaColumnsLandscape") private var mediaColumnsLandscape: Int = 4
@StateObject private var jsController = JSController.shared
@EnvironmentObject var moduleManager: ModuleManager
@Environment(\.verticalSizeClass) var verticalSizeClass
@Binding public var searchQuery: String
@State private var searchItems: [SearchItem] = []
@State private var selectedSearchItem: SearchItem?
@State private var isSearching = false
@State private var hasNoResults = false
@State private var isLandscape: Bool = UIDevice.current.orientation.isLandscape
@State private var isModuleSelectorPresented = false
@State private var searchHistory: [String] = []
@State private var isSearchFieldFocused = false
@State private var saveDebounceTimer: Timer?
@State private var searchDebounceTimer: Timer?
init(searchQuery: Binding<String>) {
self._searchQuery = searchQuery
}
private var selectedModule: ScrapingModule? {
guard let id = selectedModuleId else { return nil }
return moduleManager.modules.first { $0.id.uuidString == id }
}
private let columns = [
GridItem(.adaptive(minimum: 150), spacing: 12)
]
private var columnsCount: Int {
if UIDevice.current.userInterfaceIdiom == .pad {
let isLandscape = UIScreen.main.bounds.width > UIScreen.main.bounds.height
return isLandscape ? mediaColumnsLandscape : mediaColumnsPortrait
} else {
return verticalSizeClass == .compact ? mediaColumnsLandscape : mediaColumnsPortrait
}
}
private var cellWidth: CGFloat {
let keyWindow = UIApplication.shared.connectedScenes
.compactMap { ($0 as? UIWindowScene)?.windows.first(where: { $0.isKeyWindow }) }
.first
let safeAreaInsets = keyWindow?.safeAreaInsets ?? .zero
let safeWidth = UIScreen.main.bounds.width - safeAreaInsets.left - safeAreaInsets.right
let totalSpacing: CGFloat = 16 * CGFloat(columnsCount + 1)
let availableWidth = safeWidth - totalSpacing
return availableWidth / CGFloat(columnsCount)
}
var body: some View {
NavigationView {
VStack(alignment: .leading) {
HStack {
Text("Search")
.font(.largeTitle)
.fontWeight(.bold)
Spacer()
ModuleSelectorMenu(
selectedModule: selectedModule,
moduleGroups: getModuleLanguageGroups(),
modulesByLanguage: getModulesByLanguage(),
selectedModuleId: selectedModuleId,
onModuleSelected: { moduleId in
selectedModuleId = moduleId
}
)
}
.padding(.horizontal, 20)
.padding(.top, 20)
ScrollView {
SearchContent(
selectedModule: selectedModule,
searchQuery: searchQuery,
searchHistory: searchHistory,
searchItems: searchItems,
isSearching: isSearching,
hasNoResults: hasNoResults,
columns: columns,
columnsCount: columnsCount,
cellWidth: cellWidth,
onHistoryItemSelected: { query in
searchQuery = query
},
onHistoryItemDeleted: { index in
removeFromHistory(at: index)
},
onClearHistory: clearSearchHistory
)
}
.scrollViewBottomPadding()
.simultaneousGesture(
DragGesture().onChanged { _ in
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}
)
}
.navigationBarHidden(true)
}
.navigationViewStyle(.stack)
.onAppear {
loadSearchHistory()
if !searchQuery.isEmpty {
performSearch()
}
}
.onChange(of: selectedModuleId) { _ in
if !searchQuery.isEmpty {
performSearch()
}
}
.onChange(of: moduleManager.selectedModuleChanged) { _ in
if moduleManager.selectedModuleChanged {
if selectedModuleId == nil && !moduleManager.modules.isEmpty {
selectedModuleId = moduleManager.modules[0].id.uuidString
}
moduleManager.selectedModuleChanged = false
}
}
.onChange(of: searchQuery) { newValue in
searchDebounceTimer?.invalidate()
if newValue.isEmpty {
saveDebounceTimer?.invalidate()
searchItems = []
hasNoResults = false
isSearching = false
} else {
searchDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.7, repeats: false) { _ in
performSearch()
}
saveDebounceTimer?.invalidate()
saveDebounceTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { _ in
self.addToSearchHistory(newValue)
}
}
}
}
private func performSearch() {
Logger.shared.log("Searching for: \(searchQuery)", type: "General")
guard !searchQuery.isEmpty, let module = selectedModule else {
searchItems = []
hasNoResults = false
return
}
isSearchFieldFocused = false
isSearching = true
hasNoResults = false
searchItems = []
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
Task {
do {
let jsContent = try moduleManager.getModuleContent(module)
jsController.loadScript(jsContent)
if module.metadata.asyncJS == true {
jsController.fetchJsSearchResults(keyword: searchQuery, module: module) { items in
DispatchQueue.main.async {
searchItems = items
hasNoResults = items.isEmpty
isSearching = false
}
}
} else {
jsController.fetchSearchResults(keyword: searchQuery, module: module) { items in
DispatchQueue.main.async {
searchItems = items
hasNoResults = items.isEmpty
isSearching = false
}
}
}
} catch {
Logger.shared.log("Error loading module: \(error)", type: "Error")
DispatchQueue.main.async {
isSearching = false
hasNoResults = true
}
}
}
}
}
private func loadSearchHistory() {
searchHistory = UserDefaults.standard.stringArray(forKey: "searchHistory") ?? []
}
private func saveSearchHistory() {
UserDefaults.standard.set(searchHistory, forKey: "searchHistory")
}
private func addToSearchHistory(_ term: String) {
let trimmedTerm = term.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedTerm.isEmpty else { return }
searchHistory.removeAll { $0.lowercased() == trimmedTerm.lowercased() }
searchHistory.insert(trimmedTerm, at: 0)
if searchHistory.count > 10 {
searchHistory = Array(searchHistory.prefix(10))
}
saveSearchHistory()
}
private func removeFromHistory(at index: Int) {
guard index < searchHistory.count else { return }
searchHistory.remove(at: index)
saveSearchHistory()
}
private func clearSearchHistory() {
searchHistory.removeAll()
saveSearchHistory()
}
private func determineColumns() -> Int {
if UIDevice.current.userInterfaceIdiom == .pad {
return isLandscape ? mediaColumnsLandscape : mediaColumnsPortrait
} else {
return verticalSizeClass == .compact ? mediaColumnsLandscape : mediaColumnsPortrait
}
}
private func cleanLanguageName(_ language: String?) -> String {
guard let language = language else { return "Unknown" }
let cleaned = language.replacingOccurrences(
of: "\\s*\\([^\\)]*\\)",
with: "",
options: .regularExpression
).trimmingCharacters(in: .whitespaces)
return cleaned.isEmpty ? "Unknown" : cleaned
}
private func getModulesByLanguage() -> [String: [ScrapingModule]] {
var result = [String: [ScrapingModule]]()
for module in moduleManager.modules {
let language = cleanLanguageName(module.metadata.language)
if result[language] == nil {
result[language] = [module]
} else {
result[language]?.append(module)
}
}
return result
}
private func getModuleLanguageGroups() -> [String] {
return getModulesByLanguage().keys.sorted()
}
private func getModulesForLanguage(_ language: String) -> [ScrapingModule] {
return getModulesByLanguage()[language] ?? []
}
}
struct SearchBar: View {
@State private var debounceTimer: Timer?
@Binding var text: String
@Binding var isFocused: Bool
var onSearchButtonClicked: () -> Void
var body: some View {
HStack {
TextField("Search...", text: $text, onEditingChanged: { isEditing in
isFocused = isEditing
}, onCommit: onSearchButtonClicked)
.padding(7)
.padding(.horizontal, 25)
.background(Color(.systemGray6))
.cornerRadius(8)
.onChange(of: text) { newValue in
debounceTimer?.invalidate()
if !newValue.isEmpty {
debounceTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: false) { _ in
onSearchButtonClicked()
}
}
}
.overlay(
HStack {
Image(systemName: "magnifyingglass")
.foregroundColor(.secondary)
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
.padding(.leading, 8)
if !text.isEmpty {
Button(action: {
self.text = ""
}) {
Image(systemName: "multiply.circle.fill")
.foregroundColor(.secondary)
.padding(.trailing, 8)
}
}
}
)
}
}
}

View file

@ -0,0 +1,195 @@
//
// SearchViewComponents.swift
// Sora
//
// Created by Francesco on 27/01/25.
//
import SwiftUI
import Kingfisher
struct ModuleSelectorMenu: View {
let selectedModule: ScrapingModule?
let moduleGroups: [String]
let modulesByLanguage: [String: [ScrapingModule]]
let selectedModuleId: String?
let onModuleSelected: (String) -> Void
@Namespace private var animation
let gradientOpacity: Double = 0.5
var body: some View {
Menu {
ForEach(moduleGroups, id: \.self) { language in
Menu(language) {
ForEach(modulesByLanguage[language] ?? [], id: \.id) { module in
Button {
onModuleSelected(module.id.uuidString)
} label: {
HStack {
KFImage(URL(string: module.metadata.iconUrl))
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 20, height: 20)
.cornerRadius(4)
Text(module.metadata.sourceName)
if module.id.uuidString == selectedModuleId {
Image(systemName: "checkmark")
.foregroundColor(.accentColor)
}
}
}
}
}
}
} label: {
HStack(spacing: 8) {
if let selectedModule = selectedModule {
Text(selectedModule.metadata.sourceName)
.font(.headline)
.foregroundColor(.primary)
KFImage(URL(string: selectedModule.metadata.iconUrl))
.resizable()
.frame(width: 36, height: 36)
.clipShape(Circle())
.background(
Circle()
.fill(.ultraThinMaterial)
.overlay(
Circle()
.stroke(
LinearGradient(
gradient: Gradient(stops: [
.init(color: Color.accentColor.opacity(gradientOpacity), location: 0),
.init(color: Color.accentColor.opacity(0), location: 1)
]),
startPoint: .top,
endPoint: .bottom
),
lineWidth: 0.5
)
)
.matchedGeometryEffect(id: "background_circle", in: animation)
)
} else {
Text("Select Module")
.font(.headline)
.foregroundColor(.secondary)
Image(systemName: "questionmark.app.fill")
.resizable()
.frame(width: 36, height: 36)
.clipShape(Circle())
.foregroundColor(.secondary)
.background(
Circle()
.fill(.ultraThinMaterial)
.overlay(
Circle()
.stroke(
LinearGradient(
gradient: Gradient(stops: [
.init(color: Color.accentColor.opacity(gradientOpacity), location: 0),
.init(color: Color.accentColor.opacity(0), location: 1)
]),
startPoint: .top,
endPoint: .bottom
),
lineWidth: 0.5
)
)
.matchedGeometryEffect(id: "background_circle", in: animation)
)
}
}
}
}
}
struct SearchContent: View {
let selectedModule: ScrapingModule?
let searchQuery: String
let searchHistory: [String]
let searchItems: [SearchItem]
let isSearching: Bool
let hasNoResults: Bool
let columns: [GridItem]
let columnsCount: Int
let cellWidth: CGFloat
let onHistoryItemSelected: (String) -> Void
let onHistoryItemDeleted: (Int) -> Void
let onClearHistory: () -> Void
var body: some View {
VStack(spacing: 0) {
if selectedModule == nil {
VStack(spacing: 8) {
Image(systemName: "questionmark.app")
.font(.largeTitle)
.foregroundColor(.secondary)
Text("No Module Selected")
.font(.headline)
Text("Please select a module from settings")
.font(.caption)
.foregroundColor(.secondary)
}
.padding()
.frame(maxWidth: .infinity)
.background(Color(.systemBackground))
.shadow(color: Color.black.opacity(0.1), radius: 2, y: 1)
}
if searchQuery.isEmpty {
if !searchHistory.isEmpty {
SearchHistorySection(title: "Recent Searches") {
VStack(spacing: 0) {
Divider()
.padding(.horizontal, 16)
ForEach(searchHistory.indices, id: \.self) { index in
SearchHistoryRow(
text: searchHistory[index],
onTap: {
onHistoryItemSelected(searchHistory[index])
},
onDelete: {
onHistoryItemDeleted(index)
},
showDivider: index < searchHistory.count - 1
)
}
Divider()
.padding(.horizontal, 16)
Spacer()
HStack {
Button(action: onClearHistory) {
Text("Clear")
.foregroundColor(.accentColor)
}
.frame(maxWidth: .infinity, alignment: .center)
}
}
}
.padding(.vertical)
}
} else {
if let module = selectedModule {
if !searchItems.isEmpty {
SearchResultsGrid(
items: searchItems,
columns: columns,
selectedModule: module,
cellWidth: cellWidth
)
} else {
SearchStateView(
isSearching: isSearching,
hasNoResults: hasNoResults,
columnsCount: columnsCount,
cellWidth: cellWidth
)
}
}
}
}
}
}

View file

@ -0,0 +1,251 @@
//
// SettingsSharedComponents.swift
// Sora
//
import SwiftUI
// MARK: - Settings Section
struct SettingsSection<Content: View>: View {
let title: String
let footer: String?
let content: Content
init(title: String, footer: String? = nil, @ViewBuilder content: () -> Content) {
self.title = title
self.footer = footer
self.content = content()
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(title.uppercased())
.font(.footnote)
.foregroundStyle(.gray)
.padding(.horizontal, 20)
VStack(spacing: 0) {
content
}
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.strokeBorder(
LinearGradient(
gradient: Gradient(stops: [
.init(color: Color.accentColor.opacity(0.3), location: 0),
.init(color: Color.accentColor.opacity(0), location: 1)
]),
startPoint: .top,
endPoint: .bottom
),
lineWidth: 0.5
)
)
.padding(.horizontal, 20)
if let footer = footer {
Text(footer)
.font(.footnote)
.foregroundStyle(.gray)
.padding(.horizontal, 20)
.padding(.top, 4)
}
}
}
}
// MARK: - Settings Row
struct SettingsRow: View {
let icon: String
let title: String
var value: String? = nil
var isExternal: Bool = false
var textColor: Color = .primary
var showDivider: Bool = true
init(icon: String, title: String, value: String? = nil, isExternal: Bool = false, textColor: Color = .primary, showDivider: Bool = true) {
self.icon = icon
self.title = title
self.value = value
self.isExternal = isExternal
self.textColor = textColor
self.showDivider = showDivider
}
var body: some View {
VStack(spacing: 0) {
HStack {
Image(systemName: icon)
.frame(width: 24, height: 24)
.foregroundStyle(textColor)
Text(title)
.foregroundStyle(textColor)
Spacer()
if let value = value {
Text(value)
.foregroundStyle(.gray)
}
if isExternal {
Image(systemName: "arrow.up.forward")
.foregroundStyle(.gray)
.font(.footnote)
} else {
Image(systemName: "chevron.right")
.foregroundStyle(.gray)
.font(.footnote)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
if showDivider {
Divider()
.padding(.horizontal, 16)
}
}
}
}
// MARK: - Settings Toggle Row
struct SettingsToggleRow: View {
let icon: String
let title: String
@Binding var isOn: Bool
var showDivider: Bool = true
init(icon: String, title: String, isOn: Binding<Bool>, showDivider: Bool = true) {
self.icon = icon
self.title = title
self._isOn = isOn
self.showDivider = showDivider
}
var body: some View {
VStack(spacing: 0) {
HStack {
Image(systemName: icon)
.frame(width: 24, height: 24)
.foregroundStyle(.primary)
Text(title)
.foregroundStyle(.primary)
Spacer()
Toggle("", isOn: $isOn)
.labelsHidden()
.tint(.accentColor)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
if showDivider {
Divider()
.padding(.horizontal, 16)
}
}
}
}
// MARK: - Settings Picker Row
struct SettingsPickerRow<T: Hashable>: View {
let icon: String
let title: String
let options: [T]
let optionToString: (T) -> String
@Binding var selection: T
var showDivider: Bool = true
init(icon: String, title: String, options: [T], optionToString: @escaping (T) -> String, selection: Binding<T>, showDivider: Bool = true) {
self.icon = icon
self.title = title
self.options = options
self.optionToString = optionToString
self._selection = selection
self.showDivider = showDivider
}
var body: some View {
VStack(spacing: 0) {
HStack {
Image(systemName: icon)
.frame(width: 24, height: 24)
.foregroundStyle(.primary)
Text(title)
.foregroundStyle(.primary)
Spacer()
Menu {
ForEach(options, id: \.self) { option in
Button(action: { selection = option }) {
Text(optionToString(option))
}
}
} label: {
Text(optionToString(selection))
.foregroundStyle(.gray)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
if showDivider {
Divider()
.padding(.horizontal, 16)
}
}
}
}
// MARK: - Settings Stepper Row
struct SettingsStepperRow: View {
let icon: String
let title: String
@Binding var value: Double
let range: ClosedRange<Double>
let step: Double
var formatter: (Double) -> String = { "\(Int($0))" }
var showDivider: Bool = true
init(icon: String, title: String, value: Binding<Double>, range: ClosedRange<Double>, step: Double, formatter: @escaping (Double) -> String = { "\(Int($0))" }, showDivider: Bool = true) {
self.icon = icon
self.title = title
self._value = value
self.range = range
self.step = step
self.formatter = formatter
self.showDivider = showDivider
}
var body: some View {
VStack(spacing: 0) {
HStack {
Image(systemName: icon)
.frame(width: 24, height: 24)
.foregroundStyle(.primary)
Text(title)
.foregroundStyle(.primary)
Spacer()
Stepper(formatter(value), value: $value, in: range, step: step)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
if showDivider {
Divider()
.padding(.horizontal, 16)
}
}
}
}

View file

@ -0,0 +1,246 @@
//
// SettingsComponents.swift
// Sora
//
import SwiftUI
internal struct SettingsSection<Content: View>: View {
internal let title: String
internal let footer: String?
internal let content: Content
internal init(title: String, footer: String? = nil, @ViewBuilder content: () -> Content) {
self.title = title
self.footer = footer
self.content = content()
}
internal var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(title.uppercased())
.font(.footnote)
.foregroundStyle(.gray)
.padding(.horizontal, 20)
VStack(spacing: 0) {
content
}
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.strokeBorder(
LinearGradient(
gradient: Gradient(stops: [
.init(color: Color.accentColor.opacity(0.3), location: 0),
.init(color: Color.accentColor.opacity(0), location: 1)
]),
startPoint: .top,
endPoint: .bottom
),
lineWidth: 0.5
)
)
.padding(.horizontal, 20)
if let footer = footer {
Text(footer)
.font(.footnote)
.foregroundStyle(.gray)
.padding(.horizontal, 20)
.padding(.top, 4)
}
}
}
}
internal struct SettingsRow: View {
internal let icon: String
internal let title: String
internal var value: String? = nil
internal var isExternal: Bool = false
internal var textColor: Color = .primary
internal var showDivider: Bool = true
internal init(icon: String, title: String, value: String? = nil, isExternal: Bool = false, textColor: Color = .primary, showDivider: Bool = true) {
self.icon = icon
self.title = title
self.value = value
self.isExternal = isExternal
self.textColor = textColor
self.showDivider = showDivider
}
internal var body: some View {
VStack(spacing: 0) {
HStack {
Image(systemName: icon)
.frame(width: 24, height: 24)
.foregroundStyle(textColor)
Text(title)
.foregroundStyle(textColor)
Spacer()
if let value = value {
Text(value)
.foregroundStyle(.gray)
}
if isExternal {
Image(systemName: "arrow.up.forward")
.foregroundStyle(.gray)
.font(.footnote)
} else {
Image(systemName: "chevron.right")
.foregroundStyle(.gray)
.font(.footnote)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
if showDivider {
Divider()
.padding(.horizontal, 16)
}
}
}
}
internal struct SettingsToggleRow: View {
internal let icon: String
internal let title: String
@Binding internal var isOn: Bool
internal var showDivider: Bool = true
internal init(icon: String, title: String, isOn: Binding<Bool>, showDivider: Bool = true) {
self.icon = icon
self.title = title
self._isOn = isOn
self.showDivider = showDivider
}
internal var body: some View {
VStack(spacing: 0) {
HStack {
Image(systemName: icon)
.frame(width: 24, height: 24)
.foregroundStyle(.primary)
Text(title)
.foregroundStyle(.primary)
Spacer()
Toggle("", isOn: $isOn)
.labelsHidden()
.tint(.accentColor)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
if showDivider {
Divider()
.padding(.horizontal, 16)
}
}
}
}
internal struct SettingsPickerRow<T: Hashable>: View {
internal let icon: String
internal let title: String
internal let options: [T]
internal let optionToString: (T) -> String
@Binding internal var selection: T
internal var showDivider: Bool = true
internal init(icon: String, title: String, options: [T], optionToString: @escaping (T) -> String, selection: Binding<T>, showDivider: Bool = true) {
self.icon = icon
self.title = title
self.options = options
self.optionToString = optionToString
self._selection = selection
self.showDivider = showDivider
}
internal var body: some View {
VStack(spacing: 0) {
HStack {
Image(systemName: icon)
.frame(width: 24, height: 24)
.foregroundStyle(.primary)
Text(title)
.foregroundStyle(.primary)
Spacer()
Menu {
ForEach(options, id: \.self) { option in
Button(action: { selection = option }) {
Text(optionToString(option))
}
}
} label: {
Text(optionToString(selection))
.foregroundStyle(.gray)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
if showDivider {
Divider()
.padding(.horizontal, 16)
}
}
}
}
internal struct SettingsStepperRow: View {
internal let icon: String
internal let title: String
@Binding internal var value: Double
internal let range: ClosedRange<Double>
internal let step: Double
internal var formatter: (Double) -> String = { "\(Int($0))" }
internal var showDivider: Bool = true
internal init(icon: String, title: String, value: Binding<Double>, range: ClosedRange<Double>, step: Double, formatter: @escaping (Double) -> String = { "\(Int($0))" }, showDivider: Bool = true) {
self.icon = icon
self.title = title
self._value = value
self.range = range
self.step = step
self.formatter = formatter
self.showDivider = showDivider
}
internal var body: some View {
VStack(spacing: 0) {
HStack {
Image(systemName: icon)
.frame(width: 24, height: 24)
.foregroundStyle(.primary)
Text(title)
.foregroundStyle(.primary)
Spacer()
Stepper(formatter(value), value: $value, in: range, step: step)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
if showDivider {
Divider()
.padding(.horizontal, 16)
}
}
}
}

View file

@ -0,0 +1,270 @@
//
// SettingsViewAbout.swift
// Sulfur
//
// Created by Francesco on 26/05/25.
//
import SwiftUI
import Kingfisher
fileprivate struct SettingsSection<Content: View>: View {
let title: String
let footer: String?
let content: Content
init(title: String, footer: String? = nil, @ViewBuilder content: () -> Content) {
self.title = title
self.footer = footer
self.content = content()
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(title.uppercased())
.font(.footnote)
.foregroundStyle(.gray)
.padding(.horizontal, 20)
VStack(spacing: 0) {
content
}
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.strokeBorder(
LinearGradient(
gradient: Gradient(stops: [
.init(color: Color.accentColor.opacity(0.3), location: 0),
.init(color: Color.accentColor.opacity(0), location: 1)
]),
startPoint: .top,
endPoint: .bottom
),
lineWidth: 0.5
)
)
.padding(.horizontal, 20)
if let footer = footer {
Text(footer)
.font(.footnote)
.foregroundStyle(.gray)
.padding(.horizontal, 20)
.padding(.top, 4)
}
}
}
}
struct SettingsViewAbout: View {
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "ALPHA"
var body: some View {
ScrollView {
VStack(spacing: 24) {
SettingsSection(title: "App Info", footer: "Sora/Sulfur will always remain free with no ADs!") {
HStack(alignment: .center, spacing: 16) {
KFImage(URL(string: "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/dev/Sora/Assets.xcassets/AppIcons/AppIcon_Default.appiconset/darkmode.png"))
.placeholder {
ProgressView()
}
.resizable()
.frame(width: 100, height: 100)
.cornerRadius(20)
.shadow(radius: 5)
VStack(alignment: .leading, spacing: 8) {
Text("Sora")
.font(.title)
.bold()
Text("AKA Sulfur")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
SettingsSection(title: "Main Developer") {
Button(action: {
if let url = URL(string: "https://github.com/cranci1") {
UIApplication.shared.open(url)
}
}) {
HStack {
KFImage(URL(string: "https://avatars.githubusercontent.com/u/100066266?v=4"))
.placeholder {
ProgressView()
}
.resizable()
.frame(width: 40, height: 40)
.clipShape(Circle())
VStack(alignment: .leading) {
Text("cranci1")
.font(.headline)
.foregroundColor(.indigo)
Text("me frfr")
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
Image(systemName: "safari")
.foregroundColor(.indigo)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
}
SettingsSection(title: "Contributors") {
ContributorsView()
}
}
.padding(.vertical, 20)
}
.navigationTitle("About")
.scrollViewBottomPadding()
}
}
struct ContributorsView: View {
@State private var contributors: [Contributor] = []
@State private var isLoading = true
@State private var error: Error?
var body: some View {
Group {
if isLoading {
HStack {
Spacer()
ProgressView()
Spacer()
}
.padding(.vertical, 12)
} else if error != nil {
Text("Failed to load contributors")
.foregroundColor(.secondary)
.padding(.vertical, 12)
} else {
ForEach(filteredContributors) { contributor in
ContributorView(contributor: contributor)
if contributor.id != filteredContributors.last?.id {
Divider()
.padding(.horizontal, 16)
}
}
}
}
.onAppear {
loadContributors()
}
}
private var filteredContributors: [Contributor] {
contributors.filter { contributor in
!["cranci1", "code-factor"].contains(contributor.login.lowercased())
}
}
private func loadContributors() {
let url = URL(string: "https://api.github.com/repos/cranci1/Sora/contributors")!
URLSession.shared.dataTask(with: url) { data, response, error in
DispatchQueue.main.async {
isLoading = false
if let error = error {
self.error = error
return
}
if let data = data {
do {
self.contributors = try JSONDecoder().decode([Contributor].self, from: data)
} catch {
self.error = error
}
}
}
}.resume()
}
}
struct ContributorView: View {
let contributor: Contributor
var body: some View {
Button(action: {
if let url = URL(string: "https://github.com/\(contributor.login)") {
UIApplication.shared.open(url)
}
}) {
HStack {
KFImage(URL(string: contributor.avatarUrl))
.placeholder {
ProgressView()
}
.resizable()
.frame(width: 40, height: 40)
.clipShape(Circle())
Text(contributor.login)
.font(.headline)
.foregroundColor(
contributor.login == "IBH-RAD" ? Color(hexTwo: "#41127b") :
contributor.login == "50n50" ? Color(hexTwo: "#fa4860") :
.accentColor
)
Spacer()
Image(systemName: "safari")
.foregroundColor(.accentColor)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
}
}
struct Contributor: Identifiable, Decodable {
let id: Int
let login: String
let avatarUrl: String
enum CodingKeys: String, CodingKey {
case id
case login
case avatarUrl = "avatar_url"
}
}
extension Color {
init(hexTwo: String) {
let hexTwo = hexTwo.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
var int: UInt64 = 0
Scanner(string: hexTwo).scanHexInt64(&int)
let a, r, g, b: UInt64
switch hexTwo.count {
case 3:
(a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
case 6:
(a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
case 8:
(a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
default:
(a, r, g, b) = (1, 1, 1, 0)
}
self.init(
.sRGB,
red: Double(r) / 255,
green: Double(g) / 255,
blue: Double(b) / 255,
opacity: Double(a) / 255
)
}
}

View file

@ -0,0 +1,69 @@
//
// SettingsViewAlternateAppIconPicker.swift
// Sulfur
//
// Created by Dominic on 20.04.25.
//
import SwiftUI
struct SettingsViewAlternateAppIconPicker: View {
@Binding var isPresented: Bool
@AppStorage("currentAppIcon") private var currentAppIcon = "Default"
let icons: [(name: String, icon: String)] = [
("Default", "Default"),
("Original", "Original"),
("Pixel", "Pixel")
]
var body: some View {
VStack {
Text("Select an App Icon")
.font(.headline)
.padding()
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 20) {
ForEach(icons, id: \.name) { icon in
VStack {
Image("AppIcon_\(icon.icon)_Preview", bundle: .main)
.resizable()
.scaledToFit()
.frame(width: 60, height: 60)
.cornerRadius(10)
.shadow(radius: 6)
.padding()
.background(
currentAppIcon == icon.name ? Color.accentColor.opacity(0.3) : Color.clear
)
.cornerRadius(10)
.accessibilityLabel("Alternative App Icon")
Text(icon.name)
.font(.caption)
.foregroundColor(currentAppIcon == icon.name ? .accentColor : .primary)
}
.accessibilityAddTraits(.isButton)
.onTapGesture {
currentAppIcon = icon.name
setAppIcon(named: icon.icon)
}
}
}
.padding()
}
Spacer()
}
}
private func setAppIcon(named iconName: String) {
if UIApplication.shared.supportsAlternateIcons {
UIApplication.shared.setAlternateIconName(iconName == "Default" ? nil : "AppIcon_\(iconName)", completionHandler: { error in
if let error = error {
Logger.shared.log("Failed to set alternate icon: \(error.localizedDescription)", type: "Error")
isPresented = false
}
})
}
}
}

View file

@ -7,310 +7,347 @@
import SwiftUI
fileprivate struct SettingsSection<Content: View>: View {
let title: String
let footer: String?
let content: Content
init(title: String, footer: String? = nil, @ViewBuilder content: () -> Content) {
self.title = title
self.footer = footer
self.content = content()
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(title.uppercased())
.font(.footnote)
.foregroundStyle(.gray)
.padding(.horizontal, 20)
VStack(spacing: 0) {
content
}
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.strokeBorder(
LinearGradient(
gradient: Gradient(stops: [
.init(color: Color.accentColor.opacity(0.3), location: 0),
.init(color: Color.accentColor.opacity(0), location: 1)
]),
startPoint: .top,
endPoint: .bottom
),
lineWidth: 0.5
)
)
.padding(.horizontal, 20)
if let footer = footer {
Text(footer)
.font(.footnote)
.foregroundStyle(.gray)
.padding(.horizontal, 20)
.padding(.top, 4)
}
}
}
}
fileprivate struct SettingsToggleRow: View {
let icon: String
let title: String
@Binding var isOn: Bool
var showDivider: Bool = true
init(icon: String, title: String, isOn: Binding<Bool>, showDivider: Bool = true) {
self.icon = icon
self.title = title
self._isOn = isOn
self.showDivider = showDivider
}
var body: some View {
VStack(spacing: 0) {
HStack {
Image(systemName: icon)
.frame(width: 24, height: 24)
.foregroundStyle(.primary)
Text(title)
.foregroundStyle(.primary)
Spacer()
Toggle("", isOn: $isOn)
.labelsHidden()
.tint(.accentColor.opacity(0.5))
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
if showDivider {
Divider()
.padding(.horizontal, 16)
}
}
}
}
fileprivate struct SettingsButtonRow: View {
let icon: String
let title: String
let subtitle: String?
let action: () -> Void
init(icon: String, title: String, subtitle: String? = nil, action: @escaping () -> Void) {
self.icon = icon
self.title = title
self.subtitle = subtitle
self.action = action
}
var body: some View {
Button(action: action) {
HStack {
Image(systemName: icon)
.frame(width: 24, height: 24)
.foregroundStyle(.red)
Text(title)
.foregroundStyle(.red)
Spacer()
if let subtitle = subtitle {
Text(subtitle)
.font(.subheadline)
.foregroundStyle(.gray)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
.buttonStyle(PlainButtonStyle())
}
}
struct SettingsViewData: View {
@State private var showEraseAppDataAlert = false
@State private var showRemoveDocumentsAlert = false
@State private var showSizeAlert = false
@State private var showAlert = false
@State private var cacheSizeText: String = "Calculating..."
@State private var isCalculatingSize: Bool = false
@State private var cacheSize: Int64 = 0
@State private var documentsSize: Int64 = 0
@State private var movPkgSize: Int64 = 0
@State private var showRemoveMovPkgAlert = false
// State bindings for cache settings
@State private var isMetadataCachingEnabled: Bool = true
@State private var isImageCachingEnabled: Bool = true
@State private var isMemoryOnlyMode: Bool = false
enum ActiveAlert {
case eraseData, removeDocs
}
@State private var activeAlert: ActiveAlert = .eraseData
var body: some View {
Form {
// New section for cache settings
Section(header: Text("Cache Settings"), footer: Text("Caching helps reduce network usage and load content faster. You can disable it to save storage space.")) {
Toggle("Enable Metadata Caching", isOn: $isMetadataCachingEnabled)
.onChange(of: isMetadataCachingEnabled) { newValue in
MetadataCacheManager.shared.isCachingEnabled = newValue
if !newValue {
calculateCacheSize()
return ScrollView {
VStack(spacing: 24) {
SettingsSection(
title: "Cache",
footer: "Caching helps reduce network usage and load content faster. You can disable it to save storage space."
) {
HStack {
Image(systemName: "folder.badge.gearshape")
.frame(width: 24, height: 24)
.foregroundStyle(.primary)
Text("Current Cache Size")
.foregroundStyle(.primary)
Spacer()
if isCalculatingSize {
ProgressView()
.scaleEffect(0.7)
.padding(.trailing, 5)
}
Text(cacheSizeText)
.foregroundStyle(.gray)
}
Toggle("Enable Image Caching", isOn: $isImageCachingEnabled)
.onChange(of: isImageCachingEnabled) { newValue in
KingfisherCacheManager.shared.isCachingEnabled = newValue
if !newValue {
calculateCacheSize()
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
Divider().padding(.horizontal, 16)
Button(action: clearAllCaches) {
Text("Clear All Caches")
.foregroundColor(.red)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
if isMetadataCachingEnabled {
Toggle("Memory-Only Mode", isOn: $isMemoryOnlyMode)
.onChange(of: isMemoryOnlyMode) { newValue in
MetadataCacheManager.shared.isMemoryOnlyMode = newValue
if newValue {
// Clear disk cache when switching to memory-only
MetadataCacheManager.shared.clearAllCache()
calculateCacheSize()
SettingsSection(
title: "App Storage",
footer: "The App Data should never be erased if you don't know what that will cause.\nClearing the documents folder will remove all the modules and downloads\n "
) {
VStack(spacing: 0) {
SettingsButtonRow(
icon: "doc.text",
title: "Remove All Files in Documents",
subtitle: formatSize(documentsSize),
action: {
activeAlert = .removeDocs
showAlert = true
}
}
}
HStack {
Text("Current Cache Size")
Spacer()
if isCalculatingSize {
ProgressView()
.scaleEffect(0.7)
.padding(.trailing, 5)
)
Divider().padding(.horizontal, 16)
SettingsButtonRow(
icon: "exclamationmark.triangle",
title: "Erase all App Data",
action: {
activeAlert = .eraseData
showAlert = true
}
)
}
Text(cacheSizeText)
.foregroundColor(.secondary)
}
Button(action: clearAllCaches) {
Text("Clear All Caches")
.foregroundColor(.red)
}
}
Section(header: Text("App storage"), footer: Text("The caches used by Sora are stored images that help load content faster\n\nThe App Data should never be erased if you dont know what that will cause.\n\nClearing the documents folder will remove all the modules and downloads")) {
HStack {
Button(action: clearCache) {
Text("Clear Cache")
}
Spacer()
Text("\(formatSize(cacheSize))")
.font(.subheadline)
.foregroundColor(.secondary)
}
HStack {
Button(action: {
showRemoveDocumentsAlert = true
}) {
Text("Remove All Files in Documents")
}
Spacer()
Text("\(formatSize(documentsSize))")
.font(.subheadline)
.foregroundColor(.secondary)
}
HStack {
Button(action: {
showRemoveMovPkgAlert = true
}) {
Text("Remove Downloads")
}
Spacer()
Text("\(formatSize(movPkgSize))")
.font(.subheadline)
.foregroundColor(.secondary)
}
Button(action: {
showEraseAppDataAlert = true
}) {
Text("Erase all App Data")
}
}
}
.navigationTitle("App Data")
.navigationViewStyle(StackNavigationViewStyle())
.onAppear {
// Initialize state with current values
isMetadataCachingEnabled = MetadataCacheManager.shared.isCachingEnabled
isImageCachingEnabled = KingfisherCacheManager.shared.isCachingEnabled
isMemoryOnlyMode = MetadataCacheManager.shared.isMemoryOnlyMode
calculateCacheSize()
updateSizes()
}
.alert(isPresented: $showEraseAppDataAlert) {
Alert(
title: Text("Erase App Data"),
message: Text("Are you sure you want to erase all app data? This action cannot be undone."),
primaryButton: .destructive(Text("Erase")) {
eraseAppData()
},
secondaryButton: .cancel()
)
}
.alert(isPresented: $showRemoveDocumentsAlert) {
Alert(
title: Text("Remove Documents"),
message: Text("Are you sure you want to remove all files in the Documents folder? This will remove all modules."),
primaryButton: .destructive(Text("Remove")) {
removeAllFilesInDocuments()
},
secondaryButton: .cancel()
)
}
.alert(isPresented: $showRemoveMovPkgAlert) {
Alert(
title: Text("Remove Downloads"),
message: Text("Are you sure you want to remove all Downloads?"),
primaryButton: .destructive(Text("Remove")) {
removeMovPkgFiles()
},
secondaryButton: .cancel()
)
}
}
// Calculate and update the combined cache size
func calculateCacheSize() {
isCalculatingSize = true
cacheSizeText = "Calculating..."
// Group all cache size calculations
DispatchQueue.global(qos: .background).async {
var totalSize: Int64 = 0
// Get metadata cache size
let metadataSize = MetadataCacheManager.shared.getCacheSize()
totalSize += metadataSize
// Get image cache size asynchronously
KingfisherCacheManager.shared.calculateCacheSize { imageSize in
totalSize += Int64(imageSize)
// Update the UI on the main thread
DispatchQueue.main.async {
self.cacheSizeText = KingfisherCacheManager.formatCacheSize(UInt(totalSize))
self.isCalculatingSize = false
}
}
}
}
// Clear all caches (both metadata and images)
func clearAllCaches() {
// Clear metadata cache
MetadataCacheManager.shared.clearAllCache()
// Clear image cache
KingfisherCacheManager.shared.clearCache {
// Update cache size after clearing
calculateCacheSize()
}
Logger.shared.log("All caches cleared", type: "General")
}
func eraseAppData() {
if let domain = Bundle.main.bundleIdentifier {
UserDefaults.standard.removePersistentDomain(forName: domain)
UserDefaults.standard.synchronize()
Logger.shared.log("Cleared app data!", type: "General")
exit(0)
}
}
func clearCache() {
let cacheURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
do {
if let cacheURL = cacheURL {
let filePaths = try FileManager.default.contentsOfDirectory(at: cacheURL, includingPropertiesForKeys: nil, options: [])
for filePath in filePaths {
try FileManager.default.removeItem(at: filePath)
}
Logger.shared.log("Cache cleared successfully!", type: "General")
.scrollViewBottomPadding()
.navigationTitle("App Data")
.onAppear {
calculateCacheSize()
updateSizes()
}
} catch {
Logger.shared.log("Failed to clear cache.", type: "Error")
}
}
func removeAllFilesInDocuments() {
let fileManager = FileManager.default
if let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first {
do {
let fileURLs = try fileManager.contentsOfDirectory(at: documentsURL, includingPropertiesForKeys: nil)
for fileURL in fileURLs {
try fileManager.removeItem(at: fileURL)
.alert(isPresented: $showAlert) {
switch activeAlert {
case .eraseData:
return Alert(
title: Text("Erase App Data"),
message: Text("Are you sure you want to erase all app data? This action cannot be undone."),
primaryButton: .destructive(Text("Erase")) {
eraseAppData()
},
secondaryButton: .cancel()
)
case .removeDocs:
return Alert(
title: Text("Remove Documents"),
message: Text("Are you sure you want to remove all files in the Documents folder? This will remove all modules."),
primaryButton: .destructive(Text("Remove")) {
removeAllFilesInDocuments()
},
secondaryButton: .cancel()
)
}
Logger.shared.log("All files in documents folder removed", type: "General")
exit(0)
} catch {
Logger.shared.log("Error removing files in documents folder: \(error)", type: "Error")
}
}
}
func removeMovPkgFiles() {
let fileManager = FileManager.default
if let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first {
do {
let fileURLs = try fileManager.contentsOfDirectory(at: documentsURL, includingPropertiesForKeys: nil)
for fileURL in fileURLs {
if fileURL.pathExtension == "movpkg" {
try fileManager.removeItem(at: fileURL)
func calculateCacheSize() {
isCalculatingSize = true
cacheSizeText = "Calculating..."
DispatchQueue.global(qos: .background).async {
if let cacheURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first {
let size = calculateDirectorySize(for: cacheURL)
DispatchQueue.main.async {
self.cacheSize = size
self.cacheSizeText = formatSize(size)
self.isCalculatingSize = false
}
} else {
DispatchQueue.main.async {
self.cacheSizeText = "Unknown"
self.isCalculatingSize = false
}
}
Logger.shared.log("All Downloads files removed", type: "General")
updateSizes()
} catch {
Logger.shared.log("Error removing Downloads files: \(error)", type: "Error")
}
}
}
private func calculateDirectorySize(for url: URL) -> Int64 {
let fileManager = FileManager.default
var totalSize: Int64 = 0
do {
let contents = try fileManager.contentsOfDirectory(at: url, includingPropertiesForKeys: [.fileSizeKey])
for url in contents {
let resourceValues = try url.resourceValues(forKeys: [.fileSizeKey, .isDirectoryKey])
if resourceValues.isDirectory == true {
totalSize += calculateDirectorySize(for: url)
} else {
totalSize += Int64(resourceValues.fileSize ?? 0)
func updateSizes() {
DispatchQueue.global(qos: .background).async {
if let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first {
let size = calculateDirectorySize(for: documentsURL)
DispatchQueue.main.async {
self.documentsSize = size
}
}
}
} catch {
Logger.shared.log("Error calculating directory size: \(error)", type: "Error")
}
return totalSize
}
private func formatSize(_ bytes: Int64) -> String {
let formatter = ByteCountFormatter()
formatter.allowedUnits = [.useBytes, .useKB, .useMB, .useGB]
formatter.countStyle = .file
return formatter.string(fromByteCount: bytes)
}
private func updateSizes() {
if let cacheURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first {
cacheSize = calculateDirectorySize(for: cacheURL)
func clearAllCaches() {
clearCache()
}
if let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first {
documentsSize = calculateDirectorySize(for: documentsURL)
movPkgSize = calculateMovPkgSize(in: documentsURL)
}
}
private func calculateMovPkgSize(in url: URL) -> Int64 {
let fileManager = FileManager.default
var totalSize: Int64 = 0
do {
let contents = try fileManager.contentsOfDirectory(at: url, includingPropertiesForKeys: [.fileSizeKey])
for url in contents where url.pathExtension == "movpkg" {
let resourceValues = try url.resourceValues(forKeys: [.fileSizeKey])
totalSize += Int64(resourceValues.fileSize ?? 0)
func clearCache() {
let cacheURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
do {
if let cacheURL = cacheURL {
let filePaths = try FileManager.default.contentsOfDirectory(at: cacheURL, includingPropertiesForKeys: nil, options: [])
for filePath in filePaths {
try FileManager.default.removeItem(at: filePath)
}
Logger.shared.log("Cache cleared successfully!", type: "General")
calculateCacheSize()
updateSizes()
}
} catch {
Logger.shared.log("Failed to clear cache.", type: "Error")
}
} catch {
Logger.shared.log("Error calculating MovPkg size: \(error)", type: "Error")
}
return totalSize
func removeAllFilesInDocuments() {
let fileManager = FileManager.default
if let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first {
do {
let fileURLs = try fileManager.contentsOfDirectory(at: documentsURL, includingPropertiesForKeys: nil)
for fileURL in fileURLs {
try fileManager.removeItem(at: fileURL)
}
Logger.shared.log("All files in documents folder removed", type: "General")
exit(0)
} catch {
Logger.shared.log("Error removing files in documents folder: \(error)", type: "Error")
}
}
}
func eraseAppData() {
if let domain = Bundle.main.bundleIdentifier {
UserDefaults.standard.removePersistentDomain(forName: domain)
UserDefaults.standard.synchronize()
Logger.shared.log("Cleared app data!", type: "General")
exit(0)
}
}
func calculateDirectorySize(for url: URL) -> Int64 {
let fileManager = FileManager.default
var totalSize: Int64 = 0
do {
let contents = try fileManager.contentsOfDirectory(at: url, includingPropertiesForKeys: [.fileSizeKey])
for url in contents {
let resourceValues = try url.resourceValues(forKeys: [.fileSizeKey, .isDirectoryKey])
if resourceValues.isDirectory == true {
totalSize += calculateDirectorySize(for: url)
} else {
totalSize += Int64(resourceValues.fileSize ?? 0)
}
}
} catch {
Logger.shared.log("Error calculating directory size: \(error)", type: "Error")
}
return totalSize
}
func formatSize(_ bytes: Int64) -> String {
let formatter = ByteCountFormatter()
formatter.allowedUnits = [.useBytes, .useKB, .useMB, .useGB]
formatter.countStyle = .file
return formatter.string(fromByteCount: bytes)
}
}
}

View file

@ -8,6 +8,147 @@
import SwiftUI
import Drops
fileprivate struct SettingsSection<Content: View>: View {
let title: String
let footer: String?
let content: Content
init(title: String, footer: String? = nil, @ViewBuilder content: () -> Content) {
self.title = title
self.footer = footer
self.content = content()
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(title.uppercased())
.font(.footnote)
.foregroundStyle(.gray)
.padding(.horizontal, 20)
VStack(spacing: 0) {
content
}
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.strokeBorder(
LinearGradient(
gradient: Gradient(stops: [
.init(color: Color.accentColor.opacity(0.3), location: 0),
.init(color: Color.accentColor.opacity(0), location: 1)
]),
startPoint: .top,
endPoint: .bottom
),
lineWidth: 0.5
)
)
.padding(.horizontal, 20)
if let footer = footer {
Text(footer)
.font(.footnote)
.foregroundStyle(.gray)
.padding(.horizontal, 20)
.padding(.top, 4)
}
}
}
}
fileprivate struct SettingsToggleRow: View {
let icon: String
let title: String
@Binding var isOn: Bool
var showDivider: Bool = true
init(icon: String, title: String, isOn: Binding<Bool>, showDivider: Bool = true) {
self.icon = icon
self.title = title
self._isOn = isOn
self.showDivider = showDivider
}
var body: some View {
VStack(spacing: 0) {
HStack {
Image(systemName: icon)
.frame(width: 24, height: 24)
.foregroundStyle(.primary)
Text(title)
.foregroundStyle(.primary)
Spacer()
Toggle("", isOn: $isOn)
.labelsHidden()
.tint(.accentColor.opacity(0.7))
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
if showDivider {
Divider()
.padding(.horizontal, 16)
}
}
}
}
fileprivate struct SettingsPickerRow<T: Hashable>: View {
let icon: String
let title: String
let options: [T]
let optionToString: (T) -> String
@Binding var selection: T
var showDivider: Bool = true
init(icon: String, title: String, options: [T], optionToString: @escaping (T) -> String, selection: Binding<T>, showDivider: Bool = true) {
self.icon = icon
self.title = title
self.options = options
self.optionToString = optionToString
self._selection = selection
self.showDivider = showDivider
}
var body: some View {
VStack(spacing: 0) {
HStack {
Image(systemName: icon)
.frame(width: 24, height: 24)
.foregroundStyle(.primary)
Text(title)
.foregroundStyle(.primary)
Spacer()
Menu {
ForEach(options, id: \.self) { option in
Button(action: { selection = option }) {
Text(optionToString(option))
}
}
} label: {
Text(optionToString(selection))
.foregroundStyle(.gray)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
if showDivider {
Divider()
.padding(.horizontal, 16)
}
}
}
}
struct SettingsViewDownloads: View {
@EnvironmentObject private var jsController: JSController
@AppStorage(DownloadQualityPreference.userDefaultsKey)
@ -20,94 +161,168 @@ struct SettingsViewDownloads: View {
@State private var isCalculating: Bool = false
var body: some View {
Form {
Section(header: Text("Download Settings"), footer: Text("Max concurrent downloads controls how many episodes can download simultaneously. Higher values may use more bandwidth and device resources.")) {
Picker("Quality", selection: $downloadQuality) {
ForEach(DownloadQualityPreference.allCases, id: \.rawValue) { option in
Text(option.rawValue)
.tag(option.rawValue)
}
}
.onChange(of: downloadQuality) { newValue in
print("Download quality preference changed to: \(newValue)")
}
HStack {
Text("Max Concurrent Downloads")
Spacer()
Stepper("\(maxConcurrentDownloads)", value: $maxConcurrentDownloads, in: 1...10)
.onChange(of: maxConcurrentDownloads) { newValue in
jsController.updateMaxConcurrentDownloads(newValue)
ScrollView {
VStack(spacing: 24) {
SettingsSection(
title: "Download Settings",
footer: "Max concurrent downloads controls how many episodes can download simultaneously. Higher values may use more bandwidth and device resources."
) {
SettingsPickerRow(
icon: "4k.tv",
title: "Quality",
options: DownloadQualityPreference.allCases.map { $0.rawValue },
optionToString: { $0 },
selection: $downloadQuality
)
VStack(spacing: 0) {
HStack {
Image(systemName: "arrow.down.circle")
.frame(width: 24, height: 24)
.foregroundStyle(.primary)
Text("Max Concurrent Downloads")
.foregroundStyle(.primary)
Spacer()
Stepper("\(maxConcurrentDownloads)", value: $maxConcurrentDownloads, in: 1...10)
.onChange(of: maxConcurrentDownloads) { newValue in
jsController.updateMaxConcurrentDownloads(newValue)
}
}
}
Toggle("Allow Cellular Downloads", isOn: $allowCellularDownloads)
.tint(.accentColor)
}
Section(header: Text("Quality Information")) {
if let preferenceDescription = DownloadQualityPreference(rawValue: downloadQuality)?.description {
Text(preferenceDescription)
.font(.caption)
.foregroundColor(.secondary)
}
}
Section(header: Text("Storage Management")) {
HStack {
Text("Storage Used")
Spacer()
if isCalculating {
ProgressView()
.scaleEffect(0.7)
.padding(.trailing, 5)
.padding(.horizontal, 16)
.padding(.vertical, 12)
Divider()
.padding(.horizontal, 16)
}
Text(formatFileSize(totalStorageSize))
.foregroundColor(.secondary)
SettingsToggleRow(
icon: "antenna.radiowaves.left.and.right",
title: "Allow Cellular Downloads",
isOn: $allowCellularDownloads,
showDivider: false
)
}
HStack {
Text("Files Downloaded")
Spacer()
Text("\(existingDownloadCount) of \(jsController.savedAssets.count)")
.foregroundColor(.secondary)
}
Button(action: {
calculateTotalStorage()
}) {
HStack {
Image(systemName: "arrow.clockwise")
Text("Refresh Storage Info")
SettingsSection(
title: "Quality Information"
) {
if let preferenceDescription = DownloadQualityPreference(rawValue: downloadQuality)?.description {
HStack {
Text(preferenceDescription)
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
}
Button(action: {
showClearConfirmation = true
}) {
HStack {
Image(systemName: "trash")
.foregroundColor(.red)
Text("Clear All Downloads")
.foregroundColor(.red)
SettingsSection(
title: "Storage Management"
) {
VStack(spacing: 0) {
HStack {
Image(systemName: "externaldrive")
.frame(width: 24, height: 24)
.foregroundStyle(.primary)
Text("Storage Used")
.foregroundStyle(.primary)
Spacer()
if isCalculating {
ProgressView()
.scaleEffect(0.7)
.padding(.trailing, 5)
}
Text(formatFileSize(totalStorageSize))
.foregroundStyle(.secondary)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
Divider()
.padding(.horizontal, 16)
HStack {
Image(systemName: "doc.text")
.frame(width: 24, height: 24)
.foregroundStyle(.primary)
Text("Files Downloaded")
.foregroundStyle(.primary)
Spacer()
Text("\(existingDownloadCount) of \(jsController.savedAssets.count)")
.foregroundStyle(.secondary)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
Divider()
.padding(.horizontal, 16)
Button(action: {
calculateTotalStorage()
}) {
HStack {
Image(systemName: "arrow.clockwise")
.frame(width: 24, height: 24)
.foregroundStyle(.primary)
Text("Refresh Storage Info")
.foregroundStyle(.primary)
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
Divider()
.padding(.horizontal, 16)
Button(action: {
showClearConfirmation = true
}) {
HStack {
Image(systemName: "trash")
.frame(width: 24, height: 24)
.foregroundStyle(.red)
Text("Clear All Downloads")
.foregroundStyle(.red)
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
}
}
.alert("Delete All Downloads", isPresented: $showClearConfirmation) {
Button("Cancel", role: .cancel) { }
Button("Delete All", role: .destructive) {
clearAllDownloads(preservePersistentDownloads: false)
}
Button("Clear Library Only", role: .destructive) {
clearAllDownloads(preservePersistentDownloads: true)
}
} message: {
Text("Are you sure you want to delete all downloaded assets? You can choose to clear only the library while preserving the downloaded files for future use.")
}
}
.padding(.vertical, 20)
}
.navigationTitle("Downloads")
.scrollViewBottomPadding()
.alert("Delete All Downloads", isPresented: $showClearConfirmation) {
Button("Cancel", role: .cancel) { }
Button("Delete All", role: .destructive) {
clearAllDownloads(preservePersistentDownloads: false)
}
Button("Clear Library Only", role: .destructive) {
clearAllDownloads(preservePersistentDownloads: true)
}
} message: {
Text("Are you sure you want to delete all downloaded assets? You can choose to clear only the library while preserving the downloaded files for future use.")
}
.onAppear {
calculateTotalStorage()
jsController.updateMaxConcurrentDownloads(maxConcurrentDownloads)

View file

@ -7,106 +7,310 @@
import SwiftUI
struct SettingsViewGeneral: View {
@AppStorage("episodeChunkSize") private var episodeChunkSize: Int = 100
@AppStorage("refreshModulesOnLaunch") private var refreshModulesOnLaunch: Bool = false
@AppStorage("fetchEpisodeMetadata") private var fetchEpisodeMetadata: Bool = true
@AppStorage("analyticsEnabled") private var analyticsEnabled: Bool = false
@AppStorage("multiThreads") private var multiThreadsEnabled: Bool = false
@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"
fileprivate struct SettingsSection<Content: View>: View {
let title: String
let footer: String?
let content: Content
private let metadataProvidersList = ["AniList"]
private let sortOrderOptions = ["Ascending", "Descending"]
@EnvironmentObject var settings: Settings
init(title: String, footer: String? = nil, @ViewBuilder content: () -> Content) {
self.title = title
self.footer = footer
self.content = content()
}
var body: some View {
Form {
Section(header: Text("Interface")) {
ColorPicker("Accent Color", selection: $settings.accentColor)
HStack {
Text("Appearance")
Picker("Appearance", selection: $settings.selectedAppearance) {
Text("System").tag(Appearance.system)
Text("Light").tag(Appearance.light)
Text("Dark").tag(Appearance.dark)
}
.pickerStyle(SegmentedPickerStyle())
}
}
VStack(alignment: .leading, spacing: 4) {
Text(title.uppercased())
.font(.footnote)
.foregroundStyle(.gray)
.padding(.horizontal, 20)
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()
Menu {
Button(action: { episodeChunkSize = 25 }) { Text("25") }
Button(action: { episodeChunkSize = 50 }) { Text("50") }
Button(action: { episodeChunkSize = 75 }) { Text("75") }
Button(action: { episodeChunkSize = 100 }) { Text("100") }
} label: {
Text("\(episodeChunkSize)")
}
}
Toggle("Fetch Episode metadata", isOn: $fetchEpisodeMetadata)
.tint(.accentColor)
HStack {
Text("Metadata Provider")
Spacer()
Menu(metadataProviders) {
ForEach(metadataProvidersList, id: \.self) { provider in
Button(action: { metadataProviders = provider }) {
Text(provider)
}
}
}
}
VStack(spacing: 0) {
content
}
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.strokeBorder(
LinearGradient(
gradient: Gradient(stops: [
.init(color: Color.accentColor.opacity(0.3), location: 0),
.init(color: Color.accentColor.opacity(0), location: 1)
]),
startPoint: .top,
endPoint: .bottom
),
lineWidth: 0.5
)
)
.padding(.horizontal, 20)
Section(header: Text("Media Grid Layout"), footer: Text("Adjust the number of media items per row in portrait and landscape modes.")) {
HStack {
if UIDevice.current.userInterfaceIdiom == .pad {
Picker("Portrait Columns", selection: $mediaColumnsPortrait) {
ForEach(1..<6) { i in Text("\(i)").tag(i) }
}
.pickerStyle(MenuPickerStyle())
} else {
Picker("Portrait Columns", selection: $mediaColumnsPortrait) {
ForEach(1..<5) { i in Text("\(i)").tag(i) }
}
.pickerStyle(MenuPickerStyle())
}
}
HStack {
if UIDevice.current.userInterfaceIdiom == .pad {
Picker("Landscape Columns", selection: $mediaColumnsLandscape) {
ForEach(2..<9) { i in Text("\(i)").tag(i) }
}
.pickerStyle(MenuPickerStyle())
} else {
Picker("Landscape Columns", selection: $mediaColumnsLandscape) {
ForEach(2..<6) { i in Text("\(i)").tag(i) }
}
.pickerStyle(MenuPickerStyle())
}
}
}
Section(header: Text("Modules"), footer: Text("Note that the modules will be replaced only if there is a different version string inside the JSON file.")) {
Toggle("Refresh Modules on Launch", isOn: $refreshModulesOnLaunch)
.tint(.accentColor)
}
Section(header: Text("Advanced"), footer: Text("Anonymous data is collected to improve the app. No personal information is collected. This can be disabled at any time.")) {
Toggle("Enable Analytics", isOn: $analyticsEnabled)
.tint(.accentColor)
if let footer = footer {
Text(footer)
.font(.footnote)
.foregroundStyle(.gray)
.padding(.horizontal, 20)
.padding(.top, 4)
}
}
}
}
fileprivate struct SettingsToggleRow: View {
let icon: String
let title: String
@Binding var isOn: Bool
var showDivider: Bool = true
init(icon: String, title: String, isOn: Binding<Bool>, showDivider: Bool = true) {
self.icon = icon
self.title = title
self._isOn = isOn
self.showDivider = showDivider
}
var body: some View {
VStack(spacing: 0) {
HStack {
Image(systemName: icon)
.frame(width: 24, height: 24)
.foregroundStyle(.primary)
Text(title)
.foregroundStyle(.primary)
Spacer()
Toggle("", isOn: $isOn)
.labelsHidden()
.tint(.accentColor.opacity(0.7))
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
if showDivider {
Divider()
.padding(.horizontal, 16)
}
}
}
}
fileprivate struct SettingsPickerRow<T: Hashable>: View {
let icon: String
let title: String
let options: [T]
let optionToString: (T) -> String
@Binding var selection: T
var showDivider: Bool = true
init(icon: String, title: String, options: [T], optionToString: @escaping (T) -> String, selection: Binding<T>, showDivider: Bool = true) {
self.icon = icon
self.title = title
self.options = options
self.optionToString = optionToString
self._selection = selection
self.showDivider = showDivider
}
var body: some View {
VStack(spacing: 0) {
HStack {
Image(systemName: icon)
.frame(width: 24, height: 24)
.foregroundStyle(.primary)
Text(title)
.foregroundStyle(.primary)
Spacer()
Menu {
ForEach(options, id: \.self) { option in
Button(action: { selection = option }) {
Text(optionToString(option))
}
}
} label: {
Text(optionToString(selection))
.foregroundStyle(.gray)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
if showDivider {
Divider()
.padding(.horizontal, 16)
}
}
}
}
struct SettingsViewGeneral: View {
@AppStorage("episodeChunkSize") private var episodeChunkSize: Int = 100
@AppStorage("refreshModulesOnLaunch") private var refreshModulesOnLaunch: Bool = true
@AppStorage("fetchEpisodeMetadata") private var fetchEpisodeMetadata: Bool = true
@AppStorage("analyticsEnabled") private var analyticsEnabled: Bool = false
@AppStorage("metadataProviders") private var metadataProviders: String = "TMDB"
@AppStorage("tmdbImageWidth") private var TMDBimageWidht: String = "original"
@AppStorage("mediaColumnsPortrait") private var mediaColumnsPortrait: Int = 2
@AppStorage("mediaColumnsLandscape") private var mediaColumnsLandscape: Int = 4
@AppStorage("currentAppIcon") private var currentAppIcon = "Default"
private let metadataProvidersList = ["AniList", "TMDB"]
private let TMDBimageWidhtList = ["300", "500", "780", "1280", "original"]
private let sortOrderOptions = ["Ascending", "Descending"]
@EnvironmentObject var settings: Settings
@State private var showAppIconPicker = false
var body: some View {
ScrollView {
VStack(spacing: 24) {
SettingsSection(title: "Interface") {
SettingsPickerRow(
icon: "paintbrush",
title: "Appearance",
options: [Appearance.system, .light, .dark],
optionToString: { appearance in
switch appearance {
case .system: return "System"
case .light: return "Light"
case .dark: return "Dark"
}
},
selection: $settings.selectedAppearance
)
VStack(spacing: 0) {
HStack {
Image(systemName: "app")
.frame(width: 24, height: 24)
.foregroundStyle(.primary)
Text("App Icon")
.foregroundStyle(.primary)
Spacer()
Button(action: {
showAppIconPicker = true
}) {
Text(currentAppIcon)
.foregroundStyle(.gray)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
}
SettingsSection(
title: "Media View",
footer: "The episode range controls how many episodes appear on each page. Episodes are grouped into sets (like 125, 2650, and so on), allowing you to navigate through them more easily.\n\nFor episode metadata, it refers to the episode thumbnail and title, since sometimes it can contain spoilers."
) {
SettingsPickerRow(
icon: "list.number",
title: "Episodes Range",
options: [25, 50, 75, 100],
optionToString: { "\($0)" },
selection: $episodeChunkSize
)
SettingsToggleRow(
icon: "info.circle",
title: "Fetch Episode metadata",
isOn: $fetchEpisodeMetadata
)
if metadataProviders == "TMDB" {
SettingsPickerRow(
icon: "server.rack",
title: "Metadata Provider",
options: metadataProvidersList,
optionToString: { $0 },
selection: $metadataProviders,
showDivider: true
)
SettingsPickerRow(
icon: "square.stack.3d.down.right",
title: "Thumbnails Width",
options: TMDBimageWidhtList,
optionToString: { $0 },
selection: $TMDBimageWidht,
showDivider: false
)
} else {
SettingsPickerRow(
icon: "server.rack",
title: "Metadata Provider",
options: metadataProvidersList,
optionToString: { $0 },
selection: $metadataProviders,
showDivider: false
)
}
}
SettingsSection(
title: "Media Grid Layout",
footer: "Adjust the number of media items per row in portrait and landscape modes."
) {
SettingsPickerRow(
icon: "rectangle.portrait",
title: "Portrait Columns",
options: UIDevice.current.userInterfaceIdiom == .pad ? Array(1...5) : Array(1...4),
optionToString: { "\($0)" },
selection: $mediaColumnsPortrait
)
SettingsPickerRow(
icon: "rectangle",
title: "Landscape Columns",
options: UIDevice.current.userInterfaceIdiom == .pad ? Array(2...8) : Array(2...5),
optionToString: { "\($0)" },
selection: $mediaColumnsLandscape,
showDivider: false
)
}
SettingsSection(
title: "Modules",
footer: "Note that the modules will be replaced only if there is a different version string inside the JSON file."
) {
SettingsToggleRow(
icon: "arrow.clockwise",
title: "Refresh Modules on Launch",
isOn: $refreshModulesOnLaunch,
showDivider: false
)
}
SettingsSection(
title: "Advanced",
footer: "Anonymous data is collected to improve the app. No personal information is collected. This can be disabled at any time."
) {
SettingsToggleRow(
icon: "chart.bar",
title: "Enable Analytics",
isOn: $analyticsEnabled,
showDivider: false
)
}
}
.padding(.vertical, 20)
}
.navigationTitle("General")
.scrollViewBottomPadding()
.sheet(isPresented: $showAppIconPicker) {
if #available(iOS 16.0, *) {
SettingsViewAlternateAppIconPicker(isPresented: $showAppIconPicker)
.presentationDetents([.height(200)])
} else {
SettingsViewAlternateAppIconPicker(isPresented: $showAppIconPicker)
}
}
.navigationTitle("General")
}
}

View file

@ -6,25 +6,117 @@
//
import SwiftUI
import Kingfisher
fileprivate struct SettingsSection<Content: View>: View {
let title: String
let footer: String?
let content: Content
init(title: String, footer: String? = nil, @ViewBuilder content: () -> Content) {
self.title = title
self.footer = footer
self.content = content()
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(title.uppercased())
.font(.footnote)
.foregroundStyle(.gray)
.padding(.horizontal, 20)
VStack(spacing: 0) {
content
}
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.strokeBorder(
LinearGradient(
gradient: Gradient(stops: [
.init(color: Color.accentColor.opacity(0.3), location: 0),
.init(color: Color.accentColor.opacity(0), location: 1)
]),
startPoint: .top,
endPoint: .bottom
),
lineWidth: 0.5
)
)
.padding(.horizontal, 20)
if let footer = footer {
Text(footer)
.font(.footnote)
.foregroundStyle(.gray)
.padding(.horizontal, 20)
.padding(.top, 4)
}
}
.scrollViewBottomPadding()
}
}
struct SettingsViewLogger: View {
@State private var logs: String = ""
@State private var isLoading: Bool = true
@State private var showFullLogs: Bool = false
@StateObject private var filterViewModel = LogFilterViewModel.shared
private let displayCharacterLimit = 50_000
var displayedLogs: String {
if showFullLogs || logs.count <= displayCharacterLimit {
return logs
}
return String(logs.suffix(displayCharacterLimit))
}
var body: some View {
VStack {
ScrollView {
Text(logs)
.font(.footnote)
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.textSelection(.enabled)
}
.navigationTitle("Logs")
.onAppear {
logs = Logger.shared.getLogs()
ScrollView {
VStack(spacing: 24) {
SettingsSection(title: "Logs") {
if isLoading {
HStack {
ProgressView()
.scaleEffect(0.8)
Text("Loading logs...")
.font(.footnote)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 20)
} else {
VStack(alignment: .leading, spacing: 8) {
Text(displayedLogs)
.font(.footnote)
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
if logs.count > displayCharacterLimit && !showFullLogs {
Button(action: {
showFullLogs = true
}) {
Text("Show More (\(logs.count - displayCharacterLimit) more characters)")
.font(.footnote)
.foregroundColor(.accentColor)
}
.padding(.top, 8)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
}
}
.padding(.vertical, 20)
}
.navigationTitle("Logs")
.onAppear {
loadLogsAsync()
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
@ -37,8 +129,7 @@ struct SettingsViewLogger: View {
Label("Copy to Clipboard", systemImage: "doc.on.doc")
}
Button(role: .destructive, action: {
Logger.shared.clearLogs()
logs = Logger.shared.getLogs()
clearLogsAsync()
}) {
Label("Clear Logs", systemImage: "trash")
}
@ -55,4 +146,24 @@ struct SettingsViewLogger: View {
}
}
}
private func loadLogsAsync() {
Task {
let loadedLogs = await Logger.shared.getLogsAsync()
await MainActor.run {
self.logs = loadedLogs
self.isLoading = false
}
}
}
private func clearLogsAsync() {
Task {
await Logger.shared.clearLogsAsync()
await MainActor.run {
self.logs = ""
self.showFullLogs = false
}
}
}
}

View file

@ -7,6 +7,96 @@
import SwiftUI
fileprivate struct SettingsSection<Content: View>: View {
let title: String
let footer: String?
let content: Content
init(title: String, footer: String? = nil, @ViewBuilder content: () -> Content) {
self.title = title
self.footer = footer
self.content = content()
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(title.uppercased())
.font(.footnote)
.foregroundStyle(.gray)
.padding(.horizontal, 20)
VStack(spacing: 0) {
content
}
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.strokeBorder(
LinearGradient(
gradient: Gradient(stops: [
.init(color: Color.accentColor.opacity(0.3), location: 0),
.init(color: Color.accentColor.opacity(0), location: 1)
]),
startPoint: .top,
endPoint: .bottom
),
lineWidth: 0.5
)
)
.padding(.horizontal, 20)
if let footer = footer {
Text(footer)
.font(.footnote)
.foregroundStyle(.gray)
.padding(.horizontal, 20)
.padding(.top, 4)
}
}
}
}
fileprivate struct SettingsToggleRow: View {
let icon: String
let title: String
@Binding var isOn: Bool
var showDivider: Bool = true
init(icon: String, title: String, isOn: Binding<Bool>, showDivider: Bool = true) {
self.icon = icon
self.title = title
self._isOn = isOn
self.showDivider = showDivider
}
var body: some View {
VStack(spacing: 0) {
HStack {
Image(systemName: icon)
.frame(width: 24, height: 24)
.foregroundStyle(.primary)
Text(title)
.foregroundStyle(.primary)
Spacer()
Toggle("", isOn: $isOn)
.labelsHidden()
.tint(.accentColor.opacity(0.7))
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
if showDivider {
Divider()
.padding(.horizontal, 16)
}
}
}
}
struct LogFilter: Identifiable, Hashable {
let id = UUID()
let type: String
@ -74,14 +164,33 @@ class LogFilterViewModel: ObservableObject {
struct SettingsViewLoggerFilter: View {
@ObservedObject var viewModel = LogFilterViewModel.shared
private func iconForFilter(_ type: String) -> String {
switch type {
case "General": return "gear"
case "Stream": return "play.circle"
case "Error": return "exclamationmark.triangle"
case "Debug": return "ladybug"
case "Download": return "arrow.down.circle"
case "HTMLStrings": return "text.alignleft"
default: return "circle"
}
}
var body: some View {
List {
ForEach($viewModel.filters) { $filter in
VStack(alignment: .leading, spacing: 0) {
Toggle(filter.type, isOn: $filter.isEnabled)
.tint(.accentColor)
ScrollView {
VStack(spacing: 24) {
SettingsSection(title: "Log Types") {
ForEach($viewModel.filters) { $filter in
SettingsToggleRow(
icon: iconForFilter(filter.type),
title: filter.type,
isOn: $filter.isEnabled,
showDivider: viewModel.filters.last?.id != filter.id
)
}
}
}
.padding(.vertical, 20)
}
.navigationTitle("Log Filters")
}

View file

@ -8,6 +8,137 @@
import SwiftUI
import Kingfisher
fileprivate struct SettingsSection<Content: View>: View {
let title: String
let footer: String?
let content: Content
init(title: String, footer: String? = nil, @ViewBuilder content: () -> Content) {
self.title = title
self.footer = footer
self.content = content()
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(title.uppercased())
.font(.footnote)
.foregroundStyle(.gray)
.padding(.horizontal, 20)
VStack(spacing: 0) {
content
}
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.strokeBorder(
LinearGradient(
gradient: Gradient(stops: [
.init(color: Color.accentColor.opacity(0.3), location: 0),
.init(color: Color.accentColor.opacity(0), location: 1)
]),
startPoint: .top,
endPoint: .bottom
),
lineWidth: 0.5
)
)
.padding(.horizontal, 20)
if let footer = footer {
Text(footer)
.font(.footnote)
.foregroundStyle(.gray)
.padding(.horizontal, 20)
.padding(.top, 4)
}
}
}
}
fileprivate struct ModuleListItemView: View {
let module: Module
let selectedModuleId: String?
let onDelete: () -> Void
let onSelect: () -> Void
var body: some View {
VStack(spacing: 0) {
HStack {
KFImage(URL(string: module.metadata.iconUrl))
.resizable()
.frame(width: 40, height: 40)
.clipShape(Circle())
.padding(.trailing, 10)
VStack(alignment: .leading, spacing: 2) {
HStack(alignment: .bottom, spacing: 4) {
Text(module.metadata.sourceName)
.font(.headline)
.foregroundStyle(.primary)
Text("v\(module.metadata.version)")
.font(.caption)
.foregroundStyle(.gray)
}
HStack(spacing: 8) {
Text(module.metadata.author.name)
.font(.caption)
.foregroundStyle(.gray)
Text("")
.font(.caption)
.foregroundStyle(.gray)
Text(module.metadata.language)
.font(.caption)
.foregroundStyle(.gray)
}
}
Spacer()
if module.id.uuidString == selectedModuleId {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Color.accentColor)
.frame(width: 20, height: 20)
}
}
.contentShape(Rectangle())
.onTapGesture(perform: onSelect)
.contextMenu {
Button(action: {
UIPasteboard.general.string = module.metadataUrl
DropManager.shared.showDrop(title: "Copied to Clipboard", subtitle: "", duration: 1.0, icon: UIImage(systemName: "doc.on.clipboard.fill"))
}) {
Label("Copy URL", systemImage: "doc.on.doc")
}
Button(role: .destructive) {
if selectedModuleId != module.id.uuidString {
onDelete()
}
} label: {
Label("Delete", systemImage: "trash")
}
.disabled(selectedModuleId == module.id.uuidString)
}
.swipeActions {
if selectedModuleId != module.id.uuidString {
Button(role: .destructive) {
onDelete()
} label: {
Label("Delete", systemImage: "trash")
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
}
}
struct SettingsViewModule: View {
@AppStorage("selectedModuleId") private var selectedModuleId: String?
@EnvironmentObject var moduleManager: ModuleManager
@ -21,142 +152,101 @@ struct SettingsViewModule: View {
@State private var showLibrary = false
var body: some View {
VStack {
Form {
ScrollView {
VStack(spacing: 24) {
if moduleManager.modules.isEmpty {
VStack(spacing: 8) {
Image(systemName: "plus.app")
.font(.largeTitle)
.foregroundColor(.secondary)
Text("No Modules")
.font(.headline)
SettingsSection(title: "Modules") {
VStack(spacing: 16) {
Image(systemName: "plus.app")
.font(.largeTitle)
.foregroundColor(.secondary)
Text("No Modules")
.font(.headline)
if didReceiveDefaultPageLink {
NavigationLink(destination: CommunityLibraryView()
.environmentObject(moduleManager)) {
Text("Check out some community modules here!")
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(.accentColor)
.foregroundColor(.secondary)
.frame(maxWidth: .infinity)
}
.buttonStyle(PlainButtonStyle())
} else {
Text("Click the plus button to add a module!")
.font(.caption)
.foregroundColor(.secondary)
.frame(maxWidth: .infinity)
}
.padding(.vertical, 24)
.frame(maxWidth: .infinity)
}
.padding()
.frame(maxWidth: .infinity)
} else {
ForEach(moduleManager.modules) { module in
HStack {
KFImage(URL(string: module.metadata.iconUrl))
.resizable()
.frame(width: 50, height: 50)
.clipShape(Circle())
.padding(.trailing, 10)
VStack(alignment: .leading) {
HStack(alignment: .bottom, spacing: 4) {
Text(module.metadata.sourceName)
.font(.headline)
.foregroundColor(.primary)
Text("v\(module.metadata.version)")
.font(.subheadline)
.foregroundColor(.secondary)
}
Text("Author: \(module.metadata.author.name)")
.font(.subheadline)
.foregroundColor(.secondary)
Text("Language: \(module.metadata.language)")
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
if module.id.uuidString == selectedModuleId {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.accentColor)
.frame(width: 25, height: 25)
}
}
.contentShape(Rectangle())
.onTapGesture {
selectedModuleId = module.id.uuidString
}
.contextMenu {
Button(action: {
UIPasteboard.general.string = module.metadataUrl
DropManager.shared.showDrop(title: "Copied to Clipboard", subtitle: "", duration: 1.0, icon: UIImage(systemName: "doc.on.clipboard.fill"))
}) {
Label("Copy URL", systemImage: "doc.on.doc")
}
Button(role: .destructive) {
if selectedModuleId != module.id.uuidString {
SettingsSection(title: "Installed Modules") {
ForEach(moduleManager.modules) { module in
ModuleListItemView(
module: module,
selectedModuleId: selectedModuleId,
onDelete: {
moduleManager.deleteModule(module)
DropManager.shared.showDrop(title: "Module Removed", subtitle: "", duration: 1.0, icon: UIImage(systemName: "trash"))
},
onSelect: {
selectedModuleId = module.id.uuidString
}
} label: {
Label("Delete", systemImage: "trash")
}
.disabled(selectedModuleId == module.id.uuidString)
}
.swipeActions {
if selectedModuleId != module.id.uuidString {
Button(role: .destructive) {
moduleManager.deleteModule(module)
DropManager.shared.showDrop(title: "Module Removed", subtitle: "", duration: 1.0, icon: UIImage(systemName: "trash"))
} label: {
Label("Delete", systemImage: "trash")
}
)
if module.id != moduleManager.modules.last?.id {
Divider()
.padding(.horizontal, 16)
}
}
}
}
}
.navigationTitle("Modules")
.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")
}
.padding(.vertical, 20)
}
.scrollViewBottomPadding()
.navigationTitle("Modules")
.navigationBarItems(trailing:
HStack(spacing: 16) {
if didReceiveDefaultPageLink && !moduleManager.modules.isEmpty {
Button(action: {
showAddModuleAlert()
showLibrary = true
}) {
Image(systemName: "plus")
Image(systemName: "books.vertical.fill")
.resizable()
.frame(width: 20, height: 20)
.padding(5)
}
.accessibilityLabel("Add Module")
.accessibilityLabel("Open Community Library")
}
)
.background(
NavigationLink(
destination: CommunityLibraryView()
.environmentObject(moduleManager),
isActive: $showLibrary
) { EmptyView() }
)
.refreshable {
isRefreshing = true
refreshTask?.cancel()
refreshTask = Task {
await moduleManager.refreshModules()
isRefreshing = false
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()
refreshTask = Task {
await moduleManager.refreshModules()
isRefreshing = false
}
}
.onAppear {

View file

@ -7,6 +7,191 @@
import SwiftUI
fileprivate struct SettingsSection<Content: View>: View {
let title: String
let footer: String?
let content: Content
init(title: String, footer: String? = nil, @ViewBuilder content: () -> Content) {
self.title = title
self.footer = footer
self.content = content()
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(title.uppercased())
.font(.footnote)
.foregroundStyle(.gray)
.padding(.horizontal, 20)
VStack(spacing: 0) {
content
}
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.strokeBorder(
LinearGradient(
gradient: Gradient(stops: [
.init(color: Color.accentColor.opacity(0.3), location: 0),
.init(color: Color.accentColor.opacity(0), location: 1)
]),
startPoint: .top,
endPoint: .bottom
),
lineWidth: 0.5
)
)
.padding(.horizontal, 20)
if let footer = footer {
Text(footer)
.font(.footnote)
.foregroundStyle(.gray)
.padding(.horizontal, 20)
.padding(.top, 4)
}
}
}
}
fileprivate struct SettingsToggleRow: View {
let icon: String
let title: String
@Binding var isOn: Bool
var showDivider: Bool = true
init(icon: String, title: String, isOn: Binding<Bool>, showDivider: Bool = true) {
self.icon = icon
self.title = title
self._isOn = isOn
self.showDivider = showDivider
}
var body: some View {
VStack(spacing: 0) {
HStack {
Image(systemName: icon)
.frame(width: 24, height: 24)
.foregroundStyle(.primary)
Text(title)
.foregroundStyle(.primary)
Spacer()
Toggle("", isOn: $isOn)
.labelsHidden()
.tint(.accentColor.opacity(0.7))
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
if showDivider {
Divider()
.padding(.horizontal, 16)
}
}
}
}
fileprivate struct SettingsPickerRow<T: Hashable>: View {
let icon: String
let title: String
let options: [T]
let optionToString: (T) -> String
@Binding var selection: T
var showDivider: Bool = true
init(icon: String, title: String, options: [T], optionToString: @escaping (T) -> String, selection: Binding<T>, showDivider: Bool = true) {
self.icon = icon
self.title = title
self.options = options
self.optionToString = optionToString
self._selection = selection
self.showDivider = showDivider
}
var body: some View {
VStack(spacing: 0) {
HStack {
Image(systemName: icon)
.frame(width: 24, height: 24)
.foregroundStyle(.primary)
Text(title)
.foregroundStyle(.primary)
Spacer()
Menu {
ForEach(options, id: \.self) { option in
Button(action: { selection = option }) {
Text(optionToString(option))
}
}
} label: {
Text(optionToString(selection))
.foregroundStyle(.gray)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
if showDivider {
Divider()
.padding(.horizontal, 16)
}
}
}
}
fileprivate struct SettingsStepperRow: View {
let icon: String
let title: String
@Binding var value: Double
let range: ClosedRange<Double>
let step: Double
var formatter: (Double) -> String = { "\(Int($0))" }
var showDivider: Bool = true
init(icon: String, title: String, value: Binding<Double>, range: ClosedRange<Double>, step: Double, formatter: @escaping (Double) -> String = { "\(Int($0))" }, showDivider: Bool = true) {
self.icon = icon
self.title = title
self._value = value
self.range = range
self.step = step
self.formatter = formatter
self.showDivider = showDivider
}
var body: some View {
VStack(spacing: 0) {
HStack {
Image(systemName: icon)
.frame(width: 24, height: 24)
.foregroundStyle(.primary)
Text(title)
.foregroundStyle(.primary)
Spacer()
Stepper(formatter(value), value: $value, in: range, step: step)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
if showDivider {
Divider()
.padding(.horizontal, 16)
}
}
}
}
struct SettingsViewPlayer: View {
@AppStorage("externalPlayer") private var externalPlayer: String = "Sora"
@AppStorage("alwaysLandscape") private var isAlwaysLandscape = false
@ -18,107 +203,134 @@ struct SettingsViewPlayer: View {
@AppStorage("skip85Visible") private var skip85Visible: Bool = true
@AppStorage("doubleTapSeekEnabled") private var doubleTapSeekEnabled: Bool = false
@AppStorage("skipIntroOutroVisible") private var skipIntroOutroVisible: Bool = true
@AppStorage("pipButtonVisible") private var pipButtonVisible: Bool = true
private let mediaPlayers = ["Default", "Sora", "VLC", "OutPlayer", "Infuse", "nPlayer", "SenPlayer", "IINA"]
private let mediaPlayers = ["Default", "Sora", "VLC", "OutPlayer", "Infuse", "nPlayer", "SenPlayer", "IINA", "TracyPlayer"]
var body: some View {
Form {
Section(header: Text("Media Player"), footer: Text("Some features are limited to the Sora and Default player, such as ForceLandscape, holdSpeed and custom time skip increments.")) {
HStack {
Text("Media Player")
Spacer()
Menu(externalPlayer) {
Menu("In-App Players") {
ForEach(mediaPlayers.prefix(2), id: \.self) { player in
Button(action: {
externalPlayer = player
}) {
Text(player)
}
}
}
ScrollView {
VStack(spacing: 24) {
SettingsSection(
title: "Media Player",
footer: "Some features are limited to the Sora and Default player, such as ForceLandscape, holdSpeed and custom time skip increments."
) {
SettingsPickerRow(
icon: "play.circle",
title: "Media Player",
options: mediaPlayers,
optionToString: { $0 },
selection: $externalPlayer
)
Menu("External Players") {
ForEach(mediaPlayers.dropFirst(2), id: \.self) { player in
Button(action: {
externalPlayer = player
}) {
Text(player)
SettingsToggleRow(
icon: "rotate.right",
title: "Force Landscape",
isOn: $isAlwaysLandscape
)
SettingsToggleRow(
icon: "hand.tap",
title: "Two Finger Hold for Pause",
isOn: $holdForPauseEnabled,
showDivider: true
)
SettingsToggleRow(
icon: "pip",
title: "Show PiP Button",
isOn: $pipButtonVisible,
showDivider: false
)
}
SettingsSection(title: "Speed Settings") {
SettingsToggleRow(
icon: "speedometer",
title: "Remember Playback speed",
isOn: $isRememberPlaySpeed
)
SettingsStepperRow(
icon: "forward.fill",
title: "Hold Speed",
value: $holdSpeedPlayer,
range: 0.25...2.5,
step: 0.25,
formatter: { String(format: "%.2f", $0) },
showDivider: false
)
}
SettingsSection(title: "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")
}
}
}
}
}
Toggle("Force Landscape", isOn: $isAlwaysLandscape)
.tint(.accentColor)
Toggle("Two Finger Hold for Pause",isOn: $holdForPauseEnabled)
.tint(.accentColor)
}
Section(header: Text("Speed Settings")) {
Toggle("Remember Playback speed", isOn: $isRememberPlaySpeed)
.tint(.accentColor)
HStack {
Text("Hold Speed:")
Spacer()
Stepper(
value: $holdSpeedPlayer,
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:")
Spacer()
Stepper("\(Int(skipIncrement))s", value: $skipIncrement, in: 5...300, step: 5)
))
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
HStack {
Text("Long press Skip:")
Spacer()
Stepper("\(Int(skipIncrementHold))s", value: $skipIncrementHold, in: 5...300, step: 5)
SettingsSection(
title: "Skip Settings",
footer: "Double tapping the screen on it's sides will skip with the short tap setting."
) {
SettingsStepperRow(
icon: "goforward",
title: "Tap Skip",
value: $skipIncrement,
range: 5...300,
step: 5,
formatter: { "\(Int($0))s" }
)
SettingsStepperRow(
icon: "goforward.plus",
title: "Long press Skip",
value: $skipIncrementHold,
range: 5...300,
step: 5,
formatter: { "\(Int($0))s" }
)
SettingsToggleRow(
icon: "hand.tap.fill",
title: "Double Tap to Seek",
isOn: $doubleTapSeekEnabled
)
SettingsToggleRow(
icon: "forward.end",
title: "Show Skip 85s Button",
isOn: $skip85Visible
)
SettingsToggleRow(
icon: "forward.frame",
title: "Show Skip Intro / Outro Buttons",
isOn: $skipIntroOutroVisible,
showDivider: false
)
}
Toggle("Double Tap to Seek", isOn: $doubleTapSeekEnabled)
.tint(.accentColor)
Toggle("Show Skip 85s Button", isOn: $skip85Visible)
.tint(.accentColor)
Toggle("Show Skip Intro / Outro Buttons", isOn: $skipIntroOutroVisible)
.tint(.accentColor)
SubtitleSettingsSection()
}
SubtitleSettingsSection()
.padding(.vertical, 20)
}
.scrollViewBottomPadding()
.navigationTitle("Player")
}
}
@ -128,97 +340,78 @@ struct SubtitleSettingsSection: View {
@State private var fontSize: Double = SubtitleSettingsManager.shared.settings.fontSize
@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 bottomPadding: Double = Double(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]
var body: some View {
Section(header: Text("Subtitle Settings")) {
HStack {
Text("Subtitle Color")
Spacer()
Menu(foregroundColor) {
ForEach(colors, id: \.self) { color in
Button(action: {
foregroundColor = color
SubtitleSettingsManager.shared.update { settings in
settings.foregroundColor = color
}
}) {
Text(color.capitalized)
}
}
SettingsSection(title: "Subtitle Settings") {
SettingsPickerRow(
icon: "paintbrush",
title: "Subtitle Color",
options: colors,
optionToString: { $0.capitalized },
selection: $foregroundColor
)
.onChange(of: foregroundColor) { newValue in
SubtitleSettingsManager.shared.update { settings in
settings.foregroundColor = newValue
}
}
HStack {
Text("Shadow")
Spacer()
Menu("\(Int(shadowRadius))") {
ForEach(shadowOptions, id: \.self) { option in
Button(action: {
shadowRadius = Double(option)
SubtitleSettingsManager.shared.update { settings in
settings.shadowRadius = Double(option)
}
}) {
Text("\(option)")
}
}
SettingsPickerRow(
icon: "shadow",
title: "Shadow",
options: shadowOptions,
optionToString: { "\($0)" },
selection: Binding(
get: { Int(shadowRadius) },
set: { shadowRadius = Double($0) }
)
)
.onChange(of: shadowRadius) { newValue in
SubtitleSettingsManager.shared.update { settings in
settings.shadowRadius = newValue
}
}
Toggle("Background Enabled", isOn: $backgroundEnabled)
.tint(.accentColor)
.onChange(of: backgroundEnabled) { newValue in
SubtitleSettingsManager.shared.update { settings in
settings.backgroundEnabled = newValue
}
SettingsToggleRow(
icon: "rectangle.fill",
title: "Background Enabled",
isOn: $backgroundEnabled
)
.onChange(of: backgroundEnabled) { newValue in
SubtitleSettingsManager.shared.update { settings in
settings.backgroundEnabled = newValue
}
HStack {
Text("Font Size:")
Spacer()
Stepper("\(Int(fontSize))", value: $fontSize, in: 12...36, step: 1)
.onChange(of: fontSize) { newValue in
SubtitleSettingsManager.shared.update { settings in
settings.fontSize = newValue
}
}
}
HStack {
Text("Bottom Padding:")
Spacer()
Stepper("\(Int(bottomPadding))", value: $bottomPadding, in: 0...50, step: 1)
.onChange(of: bottomPadding) { newValue in
SubtitleSettingsManager.shared.update { settings in
settings.bottomPadding = newValue
}
}
SettingsStepperRow(
icon: "textformat.size",
title: "Font Size",
value: $fontSize,
range: 12...36,
step: 1
)
.onChange(of: fontSize) { newValue in
SubtitleSettingsManager.shared.update { settings in
settings.fontSize = newValue
}
}
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)
SettingsStepperRow(
icon: "arrow.up.and.down",
title: "Bottom Padding",
value: $bottomPadding,
range: 0...50,
step: 1,
showDivider: false
)
.onChange(of: bottomPadding) { newValue in
SubtitleSettingsManager.shared.update { settings in
settings.bottomPadding = CGFloat(newValue)
}
}
}

View file

@ -9,6 +9,97 @@ import SwiftUI
import Security
import Kingfisher
fileprivate struct SettingsSection<Content: View>: View {
let title: String
let footer: String?
let content: Content
init(title: String, footer: String? = nil, @ViewBuilder content: () -> Content) {
self.title = title
self.footer = footer
self.content = content()
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(title.uppercased())
.font(.footnote)
.foregroundStyle(.gray)
.padding(.horizontal, 20)
VStack(spacing: 0) {
content
}
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.strokeBorder(
LinearGradient(
gradient: Gradient(stops: [
.init(color: Color.accentColor.opacity(0.3), location: 0),
.init(color: Color.accentColor.opacity(0), location: 1)
]),
startPoint: .top,
endPoint: .bottom
),
lineWidth: 0.5
)
)
.padding(.horizontal, 20)
if let footer = footer {
Text(footer)
.font(.footnote)
.foregroundStyle(.gray)
.padding(.horizontal, 20)
.padding(.top, 4)
}
}
}
}
fileprivate struct SettingsToggleRow: View {
let icon: String
let title: String
@Binding var isOn: Bool
var showDivider: Bool = true
init(icon: String, title: String, isOn: Binding<Bool>, showDivider: Bool = true) {
self.icon = icon
self.title = title
self._isOn = isOn
self.showDivider = showDivider
}
var body: some View {
VStack(spacing: 0) {
HStack {
Image(systemName: icon)
.frame(width: 24, height: 24)
.foregroundStyle(.primary)
Text(title)
.foregroundStyle(.primary)
Spacer()
Toggle("", isOn: $isOn)
.labelsHidden()
.tint(.accentColor.opacity(0.7))
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.frame(height: 48)
if showDivider {
Divider()
.padding(.horizontal, 16)
}
}
}
}
struct SettingsViewTrackers: View {
@AppStorage("sendPushUpdates") private var isSendPushUpdates = true
@State private var anilistStatus: String = "You are not logged in"
@ -24,101 +115,188 @@ struct SettingsViewTrackers: View {
@State private var isTraktLoading: Bool = false
var body: some View {
Form {
Section(header: Text("AniList")) {
HStack() {
KFImage(URL(string: "https://raw.githubusercontent.com/cranci1/Ryu/2f10226aa087154974a70c1ec78aa83a47daced9/Ryu/Assets.xcassets/Listing/Anilist.imageset/anilist.png"))
.placeholder {
RoundedRectangle(cornerRadius: 10)
.fill(Color.gray.opacity(0.3))
.frame(width: 80, height: 80)
.shimmering()
ScrollView {
VStack(spacing: 24) {
SettingsSection(title: "AniList") {
VStack(spacing: 0) {
HStack(alignment: .center, spacing: 10) {
KFImage(URL(string: "https://raw.githubusercontent.com/cranci1/Ryu/2f10226aa087154974a70c1ec78aa83a47daced9/Ryu/Assets.xcassets/Listing/Anilist.imageset/anilist.png"))
.placeholder {
RoundedRectangle(cornerRadius: 10)
.fill(Color.gray.opacity(0.3))
.frame(width: 60, height: 60)
.shimmering()
}
.resizable()
.frame(width: 60, height: 60)
.clipShape(Rectangle())
.cornerRadius(10)
.padding(.trailing, 10)
VStack(alignment: .leading, spacing: 4) {
Text("AniList.co")
.font(.title3)
.fontWeight(.semibold)
Group {
if isAnilistLoading {
ProgressView()
.scaleEffect(0.8)
.frame(height: 18)
} else if isAnilistLoggedIn {
HStack(spacing: 0) {
Text("Logged in as ")
.font(.footnote)
.foregroundStyle(.gray)
Text(anilistUsername)
.font(.footnote)
.fontWeight(.medium)
.foregroundStyle(profileColor)
}
.frame(height: 18)
} else {
Text(anilistStatus)
.font(.footnote)
.foregroundStyle(.gray)
.frame(height: 18)
}
}
}
.frame(height: 60, alignment: .center)
Spacer()
}
.resizable()
.frame(width: 80, height: 80)
.clipShape(Rectangle())
.cornerRadius(10)
Text("AniList.co")
.font(.title2)
}
if isAnilistLoading {
ProgressView()
} else {
if isAnilistLoggedIn {
HStack(spacing: 0) {
Text("Logged in as ")
Text(anilistUsername)
.foregroundColor(profileColor)
.font(.body)
.fontWeight(.semibold)
.padding(.horizontal, 16)
.padding(.vertical, 12)
.frame(height: 84)
if isAnilistLoggedIn {
Divider()
.padding(.horizontal, 16)
SettingsToggleRow(
icon: "arrow.triangle.2.circlepath",
title: "Sync anime progress",
isOn: $isSendPushUpdates,
showDivider: false
)
}
Divider()
.padding(.horizontal, 16)
Button(action: {
if isAnilistLoggedIn {
logoutAniList()
} else {
loginAniList()
}
}) {
HStack {
Image(systemName: isAnilistLoggedIn ? "rectangle.portrait.and.arrow.right" : "person.badge.key")
.frame(width: 24, height: 24)
.foregroundStyle(isAnilistLoggedIn ? .red : .accentColor)
Text(isAnilistLoggedIn ? "Log Out from AniList" : "Log In with AniList")
.foregroundStyle(isAnilistLoggedIn ? .red : .accentColor)
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.frame(height: 48)
}
} else {
Text(anilistStatus)
.multilineTextAlignment(.center)
}
}
if isAnilistLoggedIn {
Toggle("Sync anime progress", isOn: $isSendPushUpdates)
.tint(.accentColor)
}
Button(isAnilistLoggedIn ? "Log Out from AniList" : "Log In with AniList") {
if isAnilistLoggedIn {
logoutAniList()
} else {
loginAniList()
SettingsSection(title: "Trakt") {
VStack(spacing: 0) {
HStack(alignment: .center, spacing: 10) {
KFImage(URL(string: "https://static-00.iconduck.com/assets.00/trakt-icon-2048x2048-2633ksxg.png"))
.placeholder {
RoundedRectangle(cornerRadius: 10)
.fill(Color.gray.opacity(0.3))
.frame(width: 60, height: 60)
.shimmering()
}
.resizable()
.frame(width: 60, height: 60)
.clipShape(Rectangle())
.cornerRadius(10)
.padding(.trailing, 10)
VStack(alignment: .leading, spacing: 4) {
Text("Trakt.tv")
.font(.title3)
.fontWeight(.semibold)
Group {
if isTraktLoading {
ProgressView()
.scaleEffect(0.8)
.frame(height: 18)
} else if isTraktLoggedIn {
HStack(spacing: 0) {
Text("Logged in as ")
.font(.footnote)
.foregroundStyle(.gray)
Text(traktUsername)
.font(.footnote)
.fontWeight(.medium)
.foregroundStyle(.primary)
}
.frame(height: 18)
} else {
Text(traktStatus)
.font(.footnote)
.foregroundStyle(.gray)
.frame(height: 18)
}
}
}
.frame(height: 60, alignment: .center)
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.frame(height: 84)
Divider()
.padding(.horizontal, 16)
Button(action: {
if isTraktLoggedIn {
logoutTrakt()
} else {
loginTrakt()
}
}) {
HStack {
Image(systemName: isTraktLoggedIn ? "rectangle.portrait.and.arrow.right" : "person.badge.key")
.frame(width: 24, height: 24)
.foregroundStyle(isTraktLoggedIn ? .red : .accentColor)
Text(isTraktLoggedIn ? "Log Out from Trakt" : "Log In with Trakt")
.foregroundStyle(isTraktLoggedIn ? .red : .accentColor)
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.frame(height: 48)
}
}
}
.font(.body)
SettingsSection(
title: "Info",
footer: "Sora and cranci1 are not affiliated with AniList nor Trakt in any way.\n\nAlso note that progresses update 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 {
RoundedRectangle(cornerRadius: 10)
.fill(Color.gray.opacity(0.3))
.frame(width: 80, height: 80)
.shimmering()
}
.resizable()
.frame(width: 80, height: 80)
.clipShape(Rectangle())
.cornerRadius(10)
Text("Trakt.tv")
.font(.title2)
}
if isTraktLoading {
ProgressView()
} else {
if isTraktLoggedIn {
HStack(spacing: 0) {
Text("Logged in as ")
Text(traktUsername)
.font(.body)
.fontWeight(.semibold)
}
} else {
Text(traktStatus)
.multilineTextAlignment(.center)
}
}
Button(isTraktLoggedIn ? "Log Out from Trakt" : "Log In with Trakt") {
if isTraktLoggedIn {
logoutTrakt()
} else {
loginTrakt()
}
}
.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.")) {}
.padding(.vertical, 20)
}
.scrollViewBottomPadding()
.navigationTitle("Trackers")
.onAppear {
updateAniListStatus()
@ -244,8 +422,8 @@ struct SettingsViewTrackers: View {
guard status == errSecSuccess,
let tokenData = item as? Data,
let token = String(data: tokenData, encoding: .utf8) else {
return nil
}
return nil
}
return token
}

View file

@ -7,96 +7,237 @@
import SwiftUI
fileprivate struct SettingsNavigationRow: View {
let icon: String
let title: String
let isExternal: Bool
let textColor: Color
init(icon: String, title: String, isExternal: Bool = false, textColor: Color = .primary) {
self.icon = icon
self.title = title
self.isExternal = isExternal
self.textColor = textColor
}
var body: some View {
HStack {
Image(systemName: icon)
.frame(width: 24, height: 24)
.foregroundStyle(textColor)
Text(title)
.foregroundStyle(textColor)
Spacer()
if isExternal {
Image(systemName: "safari")
.foregroundStyle(.gray)
} else {
Image(systemName: "chevron.right")
.foregroundStyle(.gray)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
}
struct SettingsView: View {
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "ALPHA"
@Environment(\.colorScheme) var colorScheme
@StateObject var settings = Settings()
var body: some View {
NavigationView {
Form {
Section(header: Text("Main")) {
NavigationLink(destination: SettingsViewGeneral()) {
Text("General Preferences")
ScrollView {
VStack(spacing: 24) {
Text("Settings")
.font(.largeTitle)
.fontWeight(.bold)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 20)
.padding(.top, 16)
VStack(alignment: .leading, spacing: 4) {
Text("MAIN")
.font(.footnote)
.foregroundStyle(.gray)
.padding(.horizontal, 20)
VStack(spacing: 0) {
NavigationLink(destination: SettingsViewGeneral()) {
SettingsNavigationRow(icon: "gearshape", title: "General Preferences")
}
Divider().padding(.horizontal, 16)
NavigationLink(destination: SettingsViewPlayer()) {
SettingsNavigationRow(icon: "play.circle", title: "Video Player")
}
Divider().padding(.horizontal, 16)
NavigationLink(destination: SettingsViewDownloads()) {
SettingsNavigationRow(icon: "arrow.down.circle", title: "Download")
}
Divider().padding(.horizontal, 16)
NavigationLink(destination: SettingsViewModule()) {
SettingsNavigationRow(icon: "cube", title: "Modules")
}
Divider().padding(.horizontal, 16)
NavigationLink(destination: SettingsViewTrackers()) {
SettingsNavigationRow(icon: "square.stack.3d.up", title: "Trackers")
}
}
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.strokeBorder(
LinearGradient(
gradient: Gradient(stops: [
.init(color: Color.accentColor.opacity(0.3), location: 0),
.init(color: Color.accentColor.opacity(0), location: 1)
]),
startPoint: .top,
endPoint: .bottom
),
lineWidth: 0.5
)
)
.padding(.horizontal, 20)
}
NavigationLink(destination: SettingsViewPlayer()) {
Text("Media Player")
VStack(alignment: .leading, spacing: 4) {
Text("DATA/LOGS")
.font(.footnote)
.foregroundStyle(.gray)
.padding(.horizontal, 20)
VStack(spacing: 0) {
NavigationLink(destination: SettingsViewData()) {
SettingsNavigationRow(icon: "folder", title: "Data")
}
Divider().padding(.horizontal, 16)
NavigationLink(destination: SettingsViewLogger()) {
SettingsNavigationRow(icon: "doc.text", title: "Logs")
}
}
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.strokeBorder(
LinearGradient(
gradient: Gradient(stops: [
.init(color: Color.accentColor.opacity(0.3), location: 0),
.init(color: Color.accentColor.opacity(0), location: 1)
]),
startPoint: .top,
endPoint: .bottom
),
lineWidth: 0.5
)
)
.padding(.horizontal, 20)
}
NavigationLink(destination: SettingsViewDownloads().environmentObject(JSController.shared)) {
Text("Downloads")
}
NavigationLink(destination: SettingsViewModule()) {
Text("Modules")
}
NavigationLink(destination: SettingsViewTrackers()) {
Text("Trackers")
VStack(alignment: .leading, spacing: 4) {
Text("INFOS")
.font(.footnote)
.foregroundStyle(.gray)
.padding(.horizontal, 20)
VStack(spacing: 0) {
NavigationLink(destination: SettingsViewAbout()) {
SettingsNavigationRow(icon: "info.circle", title: "About Sora")
}
Divider().padding(.horizontal, 16)
Link(destination: URL(string: "https://github.com/cranci1/Sora")!) {
SettingsNavigationRow(
icon: "chevron.left.forwardslash.chevron.right",
title: "Sora GitHub Repository",
isExternal: true,
textColor: .gray
)
}
Divider().padding(.horizontal, 16)
Link(destination: URL(string: "https://discord.gg/x7hppDWFDZ")!) {
SettingsNavigationRow(
icon: "bubble.left.and.bubble.right",
title: "Join the Discord",
isExternal: true,
textColor: .gray
)
}
Divider().padding(.horizontal, 16)
Link(destination: URL(string: "https://github.com/cranci1/Sora/issues")!) {
SettingsNavigationRow(
icon: "exclamationmark.circle",
title: "Report an Issue",
isExternal: true,
textColor: .gray
)
}
Divider().padding(.horizontal, 16)
Link(destination: URL(string: "https://github.com/cranci1/Sora/blob/dev/LICENSE")!) {
SettingsNavigationRow(
icon: "doc.text",
title: "License (GPLv3.0)",
isExternal: true,
textColor: .gray
)
}
}
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.strokeBorder(
LinearGradient(
gradient: Gradient(stops: [
.init(color: Color.accentColor.opacity(0.3), location: 0),
.init(color: Color.accentColor.opacity(0), location: 1)
]),
startPoint: .top,
endPoint: .bottom
),
lineWidth: 0.5
)
)
.padding(.horizontal, 20)
}
Text("Running Sora \(version) - cranci1")
.font(.footnote)
.foregroundStyle(.gray)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.top, 8)
}
Section(header: Text("Info")) {
NavigationLink(destination: SettingsViewData()) {
Text("Data")
}
NavigationLink(destination: SettingsViewLogger()) {
Text("Logs")
}
}
Section(header: Text("Info")) {
Button(action: {
if let url = URL(string: "https://github.com/cranci1/Sora") {
UIApplication.shared.open(url)
}
}) {
HStack {
Text("Sora github repo")
.foregroundColor(.primary)
Spacer()
Image(systemName: "safari")
.foregroundColor(.secondary)
}
}
Button(action: {
if let url = URL(string: "https://discord.gg/x7hppDWFDZ") {
UIApplication.shared.open(url)
}
}) {
HStack {
Text("Join the Discord")
.foregroundColor(.primary)
Spacer()
Image(systemName: "safari")
.foregroundColor(.secondary)
}
}
Button(action: {
if let url = URL(string: "https://github.com/cranci1/Sora/issues") {
UIApplication.shared.open(url)
}
}) {
HStack {
Text("Report an issue")
.foregroundColor(.primary)
Spacer()
Image(systemName: "safari")
.foregroundColor(.secondary)
}
}
Button(action: {
if let url = URL(string: "https://github.com/cranci1/Sora/blob/dev/LICENSE") {
UIApplication.shared.open(url)
}
}) {
HStack {
Text("Licensed under GPLv3.0")
.foregroundColor(.primary)
Spacer()
Image(systemName: "safari")
.foregroundColor(.secondary)
}
}
}
Section(footer: Text("Running Sora 0.3.0 - cranci1")) {}
.scrollViewBottomPadding()
.padding(.bottom, 20)
}
.navigationTitle("Settings")
.deviceScaled()
}
.navigationViewStyle(StackNavigationViewStyle())
.navigationBarHidden(true)
.onChange(of: colorScheme) { newScheme in
if settings.selectedAppearance == .system {
settings.updateAccentColor(currentColorScheme: newScheme)
}
}
.onChange(of: settings.selectedAppearance) { _ in
settings.updateAccentColor(currentColorScheme: colorScheme)
}
.onAppear {
settings.updateAccentColor(currentColorScheme: colorScheme)
}
}
}
@ -109,7 +250,6 @@ enum Appearance: String, CaseIterable, Identifiable {
class Settings: ObservableObject {
@Published var accentColor: Color {
didSet {
saveAccentColor(accentColor)
}
}
@Published var selectedAppearance: Appearance {
@ -120,12 +260,7 @@ class Settings: ObservableObject {
}
init() {
if let colorData = UserDefaults.standard.data(forKey: "accentColor"),
let uiColor = try? NSKeyedUnarchiver.unarchivedObject(ofClass: UIColor.self, from: colorData) {
self.accentColor = Color(uiColor)
} else {
self.accentColor = .accentColor
}
self.accentColor = .primary
if let appearanceRawValue = UserDefaults.standard.string(forKey: "selectedAppearance"),
let appearance = Appearance(rawValue: appearanceRawValue) {
self.selectedAppearance = appearance
@ -135,13 +270,20 @@ class Settings: ObservableObject {
updateAppearance()
}
private func saveAccentColor(_ color: Color) {
let uiColor = UIColor(color)
do {
let colorData = try NSKeyedArchiver.archivedData(withRootObject: uiColor, requiringSecureCoding: false)
UserDefaults.standard.set(colorData, forKey: "accentColor")
} catch {
Logger.shared.log("Failed to save accent color: \(error.localizedDescription)")
func updateAccentColor(currentColorScheme: ColorScheme? = nil) {
switch selectedAppearance {
case .system:
if let scheme = currentColorScheme {
accentColor = scheme == .dark ? .white : .black
} else {
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first else { return }
accentColor = window.traitCollection.userInterfaceStyle == .dark ? .white : .black
}
case .light:
accentColor = .black
case .dark:
accentColor = .white
}
}

View file

@ -7,6 +7,21 @@
objects = {
/* Begin PBXBuildFile section */
0402DA0E2DE7AA01003BB42C /* TabBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0402DA0D2DE7A9FC003BB42C /* TabBarController.swift */; };
0402DA132DE7B5EC003BB42C /* SearchStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0402DA112DE7B5EC003BB42C /* SearchStateView.swift */; };
0402DA142DE7B5EC003BB42C /* SearchResultsGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0402DA102DE7B5EC003BB42C /* SearchResultsGrid.swift */; };
0402DA152DE7B5EC003BB42C /* SearchComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0402DA0F2DE7B5EC003BB42C /* SearchComponents.swift */; };
0402DA172DE7B7B8003BB42C /* SearchViewComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0402DA162DE7B7B8003BB42C /* SearchViewComponents.swift */; };
0457C5972DE7712A000AFBD9 /* DeviceScaleModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0457C5942DE7712A000AFBD9 /* DeviceScaleModifier.swift */; };
0457C59D2DE78267000AFBD9 /* BookmarkGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0457C59A2DE78267000AFBD9 /* BookmarkGridView.swift */; };
0457C59E2DE78267000AFBD9 /* BookmarkLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0457C59B2DE78267000AFBD9 /* BookmarkLink.swift */; };
0457C59F2DE78267000AFBD9 /* BookmarkGridItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0457C5992DE78267000AFBD9 /* BookmarkGridItemView.swift */; };
0457C5A12DE78385000AFBD9 /* BookmarksDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0457C5A02DE78385000AFBD9 /* BookmarksDetailView.swift */; };
04CD76DB2DE20F2200733536 /* AllWatching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04CD76DA2DE20F2200733536 /* AllWatching.swift */; };
04F08EDC2DE10BF3006B29D9 /* ProgressiveBlurView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F08EDB2DE10BEC006B29D9 /* ProgressiveBlurView.swift */; };
04F08EDF2DE10C1D006B29D9 /* TabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F08EDE2DE10C1A006B29D9 /* TabBar.swift */; };
04F08EE22DE10C40006B29D9 /* TabItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F08EE12DE10C27006B29D9 /* TabItem.swift */; };
04F08EE42DE10D6F006B29D9 /* AllBookmarks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F08EE32DE10D6B006B29D9 /* AllBookmarks.swift */; };
130C6BFA2D53AB1F00DC1432 /* SettingsViewData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 130C6BF92D53AB1F00DC1432 /* SettingsViewData.swift */; };
13103E8B2D58E028000F0673 /* View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13103E8A2D58E028000F0673 /* View.swift */; };
13103E8E2D58E04A000F0673 /* SkeletonCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13103E8D2D58E04A000F0673 /* SkeletonCell.swift */; };
@ -17,6 +32,7 @@
132AF1212D99951700A0140B /* JSController-Streams.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132AF1202D99951700A0140B /* JSController-Streams.swift */; };
132AF1232D9995C300A0140B /* JSController-Details.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132AF1222D9995C300A0140B /* JSController-Details.swift */; };
132AF1252D9995F900A0140B /* JSController-Search.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132AF1242D9995F900A0140B /* JSController-Search.swift */; };
132FC5B32DE31DAE009A80F7 /* SettingsViewAlternateAppIconPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132FC5B22DE31DAD009A80F7 /* SettingsViewAlternateAppIconPicker.swift */; };
133D7C6E2D2BE2500075467E /* SoraApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133D7C6D2D2BE2500075467E /* SoraApp.swift */; };
133D7C702D2BE2500075467E /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133D7C6F2D2BE2500075467E /* ContentView.swift */; };
133D7C722D2BE2520075467E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 133D7C712D2BE2520075467E /* Assets.xcassets */; };
@ -39,6 +55,8 @@
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 */; };
138FE1D02DECA00D00936D81 /* TMDB-FetchID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138FE1CF2DECA00D00936D81 /* TMDB-FetchID.swift */; };
1398FB3F2DE4E161004D3F5F /* SettingsViewAbout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1398FB3E2DE4E161004D3F5F /* SettingsViewAbout.swift */; };
139935662D468C450065CEFF /* ModuleManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 139935652D468C450065CEFF /* ModuleManager.swift */; };
1399FAD42D3AB38C00E97C31 /* SettingsViewLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1399FAD32D3AB38C00E97C31 /* SettingsViewLogger.swift */; };
1399FAD62D3AB3DB00E97C31 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1399FAD52D3AB3DB00E97C31 /* Logger.swift */; };
@ -63,12 +81,10 @@
13EA2BD62D32D97400C1EBD7 /* Double+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EA2BD32D32D97400C1EBD7 /* Double+Extension.swift */; };
13EA2BD92D32D98400C1EBD7 /* NormalPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EA2BD82D32D98400C1EBD7 /* NormalPlayer.swift */; };
1E26E9E72DA9577900B9DC02 /* VolumeSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E26E9E62DA9577900B9DC02 /* VolumeSlider.swift */; };
1E47859B2DEBC5960095BF2F /* AnilistMatchPopupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E47859A2DEBC5960095BF2F /* AnilistMatchPopupView.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 */; };
7205AEDB2DCCEF9500943F3F /* EpisodeMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7205AED72DCCEF9500943F3F /* EpisodeMetadata.swift */; };
7205AEDC2DCCEF9500943F3F /* MetadataCacheManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7205AED92DCCEF9500943F3F /* MetadataCacheManager.swift */; };
7205AEDD2DCCEF9500943F3F /* KingfisherManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7205AED82DCCEF9500943F3F /* KingfisherManager.swift */; };
7222485F2DCBAA2C00CABE2D /* DownloadModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7222485D2DCBAA2C00CABE2D /* DownloadModels.swift */; };
722248602DCBAA2C00CABE2D /* M3U8StreamExtractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7222485E2DCBAA2C00CABE2D /* M3U8StreamExtractor.swift */; };
722248632DCBAA4700CABE2D /* JSController-HeaderManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 722248612DCBAA4700CABE2D /* JSController-HeaderManager.swift */; };
@ -78,13 +94,25 @@
72443C7F2DC8038300A61321 /* SettingsViewDownloads.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72443C7E2DC8038300A61321 /* SettingsViewDownloads.swift */; };
727220712DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7272206F2DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift */; };
727220722DD642B100C2A4A2 /* JSController+MP4Download.swift in Sources */ = {isa = PBXBuildFile; fileRef = 727220702DD642B100C2A4A2 /* JSController+MP4Download.swift */; };
72AC3A012DD4DAEB00C60B96 /* ImagePrefetchManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72AC39FE2DD4DAEA00C60B96 /* ImagePrefetchManager.swift */; };
72AC3A022DD4DAEB00C60B96 /* EpisodeMetadataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72AC39FD2DD4DAEA00C60B96 /* EpisodeMetadataManager.swift */; };
72AC3A032DD4DAEB00C60B96 /* PerformanceMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72AC39FF2DD4DAEA00C60B96 /* PerformanceMonitor.swift */; };
73D164D52D8B5B470011A360 /* JavaScriptCore+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
0402DA0D2DE7A9FC003BB42C /* TabBarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarController.swift; sourceTree = "<group>"; };
0402DA0F2DE7B5EC003BB42C /* SearchComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchComponents.swift; sourceTree = "<group>"; };
0402DA102DE7B5EC003BB42C /* SearchResultsGrid.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsGrid.swift; sourceTree = "<group>"; };
0402DA112DE7B5EC003BB42C /* SearchStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchStateView.swift; sourceTree = "<group>"; };
0402DA162DE7B7B8003BB42C /* SearchViewComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewComponents.swift; sourceTree = "<group>"; };
0457C5942DE7712A000AFBD9 /* DeviceScaleModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceScaleModifier.swift; sourceTree = "<group>"; };
0457C5992DE78267000AFBD9 /* BookmarkGridItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkGridItemView.swift; sourceTree = "<group>"; };
0457C59A2DE78267000AFBD9 /* BookmarkGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkGridView.swift; sourceTree = "<group>"; };
0457C59B2DE78267000AFBD9 /* BookmarkLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkLink.swift; sourceTree = "<group>"; };
0457C5A02DE78385000AFBD9 /* BookmarksDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksDetailView.swift; sourceTree = "<group>"; };
04CD76DA2DE20F2200733536 /* AllWatching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllWatching.swift; sourceTree = "<group>"; };
04F08EDB2DE10BEC006B29D9 /* ProgressiveBlurView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressiveBlurView.swift; sourceTree = "<group>"; };
04F08EDE2DE10C1A006B29D9 /* TabBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBar.swift; sourceTree = "<group>"; };
04F08EE12DE10C27006B29D9 /* TabItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabItem.swift; sourceTree = "<group>"; };
04F08EE32DE10D6B006B29D9 /* AllBookmarks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllBookmarks.swift; sourceTree = "<group>"; };
130C6BF82D53A4C200DC1432 /* Sora.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Sora.entitlements; sourceTree = "<group>"; };
130C6BF92D53AB1F00DC1432 /* SettingsViewData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewData.swift; sourceTree = "<group>"; };
13103E8A2D58E028000F0673 /* View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = View.swift; sourceTree = "<group>"; };
@ -96,6 +124,7 @@
132AF1202D99951700A0140B /* JSController-Streams.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-Streams.swift"; sourceTree = "<group>"; };
132AF1222D9995C300A0140B /* JSController-Details.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-Details.swift"; sourceTree = "<group>"; };
132AF1242D9995F900A0140B /* JSController-Search.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-Search.swift"; sourceTree = "<group>"; };
132FC5B22DE31DAD009A80F7 /* SettingsViewAlternateAppIconPicker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewAlternateAppIconPicker.swift; sourceTree = "<group>"; };
133D7C6A2D2BE2500075467E /* Sulfur.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Sulfur.app; sourceTree = BUILT_PRODUCTS_DIR; };
133D7C6D2D2BE2500075467E /* SoraApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoraApp.swift; sourceTree = "<group>"; };
133D7C6F2D2BE2500075467E /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
@ -116,6 +145,8 @@
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>"; };
138FE1CF2DECA00D00936D81 /* TMDB-FetchID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TMDB-FetchID.swift"; sourceTree = "<group>"; };
1398FB3E2DE4E161004D3F5F /* SettingsViewAbout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewAbout.swift; sourceTree = "<group>"; };
139935652D468C450065CEFF /* ModuleManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModuleManager.swift; sourceTree = "<group>"; };
1399FAD32D3AB38C00E97C31 /* SettingsViewLogger.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewLogger.swift; sourceTree = "<group>"; };
1399FAD52D3AB3DB00E97C31 /* Logger.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = "<group>"; };
@ -141,12 +172,10 @@
13EA2BD32D32D97400C1EBD7 /* Double+Extension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Double+Extension.swift"; sourceTree = "<group>"; };
13EA2BD82D32D98400C1EBD7 /* NormalPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NormalPlayer.swift; sourceTree = "<group>"; };
1E26E9E62DA9577900B9DC02 /* VolumeSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeSlider.swift; sourceTree = "<group>"; };
1E47859A2DEBC5960095BF2F /* AnilistMatchPopupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnilistMatchPopupView.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>"; };
7205AED72DCCEF9500943F3F /* EpisodeMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeMetadata.swift; sourceTree = "<group>"; };
7205AED82DCCEF9500943F3F /* KingfisherManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KingfisherManager.swift; sourceTree = "<group>"; };
7205AED92DCCEF9500943F3F /* MetadataCacheManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetadataCacheManager.swift; sourceTree = "<group>"; };
7222485D2DCBAA2C00CABE2D /* DownloadModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadModels.swift; sourceTree = "<group>"; };
7222485E2DCBAA2C00CABE2D /* M3U8StreamExtractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = M3U8StreamExtractor.swift; sourceTree = "<group>"; };
722248612DCBAA4700CABE2D /* JSController-HeaderManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-HeaderManager.swift"; sourceTree = "<group>"; };
@ -156,9 +185,6 @@
72443C7E2DC8038300A61321 /* SettingsViewDownloads.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewDownloads.swift; sourceTree = "<group>"; };
7272206F2DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-StreamTypeDownload.swift"; sourceTree = "<group>"; };
727220702DD642B100C2A4A2 /* JSController+MP4Download.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController+MP4Download.swift"; sourceTree = "<group>"; };
72AC39FD2DD4DAEA00C60B96 /* EpisodeMetadataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeMetadataManager.swift; sourceTree = "<group>"; };
72AC39FE2DD4DAEA00C60B96 /* ImagePrefetchManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePrefetchManager.swift; sourceTree = "<group>"; };
72AC39FF2DD4DAEA00C60B96 /* PerformanceMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerformanceMonitor.swift; sourceTree = "<group>"; };
73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JavaScriptCore+Extensions.swift"; sourceTree = "<group>"; };
/* End PBXFileReference section */
@ -176,9 +202,66 @@
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
0402DA122DE7B5EC003BB42C /* SearchView */ = {
isa = PBXGroup;
children = (
133D7C7C2D2BE2630075467E /* SearchView.swift */,
0402DA162DE7B7B8003BB42C /* SearchViewComponents.swift */,
0402DA0F2DE7B5EC003BB42C /* SearchComponents.swift */,
0402DA102DE7B5EC003BB42C /* SearchResultsGrid.swift */,
0402DA112DE7B5EC003BB42C /* SearchStateView.swift */,
);
path = SearchView;
sourceTree = "<group>";
};
0457C5962DE7712A000AFBD9 /* ViewModifiers */ = {
isa = PBXGroup;
children = (
0457C5942DE7712A000AFBD9 /* DeviceScaleModifier.swift */,
);
path = ViewModifiers;
sourceTree = "<group>";
};
0457C59C2DE78267000AFBD9 /* BookmarkComponents */ = {
isa = PBXGroup;
children = (
0457C5A02DE78385000AFBD9 /* BookmarksDetailView.swift */,
0457C5992DE78267000AFBD9 /* BookmarkGridItemView.swift */,
0457C59A2DE78267000AFBD9 /* BookmarkGridView.swift */,
0457C59B2DE78267000AFBD9 /* BookmarkLink.swift */,
);
path = BookmarkComponents;
sourceTree = "<group>";
};
04F08EDA2DE10BE3006B29D9 /* ProgressiveBlurView */ = {
isa = PBXGroup;
children = (
04F08EDB2DE10BEC006B29D9 /* ProgressiveBlurView.swift */,
);
path = ProgressiveBlurView;
sourceTree = "<group>";
};
04F08EDD2DE10C05006B29D9 /* TabBar */ = {
isa = PBXGroup;
children = (
0402DA0D2DE7A9FC003BB42C /* TabBarController.swift */,
04F08EDE2DE10C1A006B29D9 /* TabBar.swift */,
);
path = TabBar;
sourceTree = "<group>";
};
04F08EE02DE10C22006B29D9 /* Models */ = {
isa = PBXGroup;
children = (
04F08EE12DE10C27006B29D9 /* TabItem.swift */,
);
path = Models;
sourceTree = "<group>";
};
13103E802D589D6C000F0673 /* Tracking Services */ = {
isa = PBXGroup;
children = (
138FE1CE2DEC9FFA00936D81 /* TMDB */,
13E62FBF2DABC3A20007E259 /* Trakt */,
13103E812D589D77000F0673 /* AniList */,
);
@ -197,8 +280,8 @@
13103E8C2D58E037000F0673 /* SkeletonCells */ = {
isa = PBXGroup;
children = (
13103E8D2D58E04A000F0673 /* SkeletonCell.swift */,
13B7F4C02D58FFDD0045714A /* Shimmer.swift */,
13103E8D2D58E04A000F0673 /* SkeletonCell.swift */,
);
path = SkeletonCells;
sourceTree = "<group>";
@ -230,7 +313,6 @@
133D7C6C2D2BE2500075467E /* Sora */ = {
isa = PBXGroup;
children = (
72AC3A002DD4DAEA00C60B96 /* Managers */,
130C6BF82D53A4C200DC1432 /* Sora.entitlements */,
13DC0C412D2EC9BA00D0F966 /* Info.plist */,
13103E802D589D6C000F0673 /* Tracking Services */,
@ -256,10 +338,10 @@
isa = PBXGroup;
children = (
72443C7C2DC8036500A61321 /* DownloadView.swift */,
0402DA122DE7B5EC003BB42C /* SearchView */,
133D7C7F2D2BE2630075467E /* MediaInfoView */,
1399FAD22D3AB34F00E97C31 /* SettingsView */,
133F55B92D33B53E00E08EEA /* LibraryView */,
133D7C7C2D2BE2630075467E /* SearchView.swift */,
);
path = Views;
sourceTree = "<group>";
@ -268,6 +350,7 @@
isa = PBXGroup;
children = (
138AA1B52D2D66EC0021F9DF /* EpisodeCell */,
1E47859A2DEBC5960095BF2F /* AnilistMatchPopupView.swift */,
133D7C802D2BE2630075467E /* MediaInfoView.swift */,
);
path = MediaInfoView;
@ -276,6 +359,7 @@
133D7C832D2BE2630075467E /* SettingsSubViews */ = {
isa = PBXGroup;
children = (
132FC5B22DE31DAD009A80F7 /* SettingsViewAlternateAppIconPicker.swift */,
1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */,
1399FAD32D3AB38C00E97C31 /* SettingsViewLogger.swift */,
133D7C842D2BE2630075467E /* SettingsViewModule.swift */,
@ -284,6 +368,7 @@
130C6BF92D53AB1F00DC1432 /* SettingsViewData.swift */,
13DB46912D900BCE008CBC03 /* SettingsViewTrackers.swift */,
72443C7E2DC8038300A61321 /* SettingsViewDownloads.swift */,
1398FB3E2DE4E161004D3F5F /* SettingsViewAbout.swift */,
);
path = SettingsSubViews;
sourceTree = "<group>";
@ -291,7 +376,10 @@
133D7C852D2BE2640075467E /* Utils */ = {
isa = PBXGroup;
children = (
7205AEDA2DCCEF9500943F3F /* Cache */,
0457C5962DE7712A000AFBD9 /* ViewModifiers */,
04F08EE02DE10C22006B29D9 /* Models */,
04F08EDD2DE10C05006B29D9 /* TabBar */,
04F08EDA2DE10BE3006B29D9 /* ProgressiveBlurView */,
13D842532D45266900EBBFA6 /* Drops */,
1399FAD12D3AB33D00E97C31 /* Logger */,
133D7C882D2BE2640075467E /* Modules */,
@ -336,11 +424,7 @@
133D7C8A2D2BE2640075467E /* JSLoader */ = {
isa = PBXGroup;
children = (
7272206F2DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift */,
727220702DD642B100C2A4A2 /* JSController+MP4Download.swift */,
722248652DCBC13E00CABE2D /* JSController-Downloads.swift */,
722248612DCBAA4700CABE2D /* JSController-HeaderManager.swift */,
722248622DCBAA4700CABE2D /* JSController+M3U8Download.swift */,
134A387B2DE4B5B90041B687 /* Downloads */,
133D7C8B2D2BE2640075467E /* JSController.swift */,
132AF1202D99951700A0140B /* JSController-Streams.swift */,
132AF1222D9995C300A0140B /* JSController-Details.swift */,
@ -352,12 +436,27 @@
133F55B92D33B53E00E08EEA /* LibraryView */ = {
isa = PBXGroup;
children = (
0457C59C2DE78267000AFBD9 /* BookmarkComponents */,
04CD76DA2DE20F2200733536 /* AllWatching.swift */,
04F08EE32DE10D6B006B29D9 /* AllBookmarks.swift */,
133F55BA2D33B55100E08EEA /* LibraryManager.swift */,
133D7C7E2D2BE2630075467E /* LibraryView.swift */,
);
path = LibraryView;
sourceTree = "<group>";
};
134A387B2DE4B5B90041B687 /* Downloads */ = {
isa = PBXGroup;
children = (
7272206F2DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift */,
727220702DD642B100C2A4A2 /* JSController+MP4Download.swift */,
722248652DCBC13E00CABE2D /* JSController-Downloads.swift */,
722248612DCBAA4700CABE2D /* JSController-HeaderManager.swift */,
722248622DCBAA4700CABE2D /* JSController+M3U8Download.swift */,
);
path = Downloads;
sourceTree = "<group>";
};
1384DCDF2D89BE870094797A /* Helpers */ = {
isa = PBXGroup;
children = (
@ -376,6 +475,14 @@
path = EpisodeCell;
sourceTree = "<group>";
};
138FE1CE2DEC9FFA00936D81 /* TMDB */ = {
isa = PBXGroup;
children = (
138FE1CF2DECA00D00936D81 /* TMDB-FetchID.swift */,
);
path = TMDB;
sourceTree = "<group>";
};
1399FAD12D3AB33D00E97C31 /* Logger */ = {
isa = PBXGroup;
children = (
@ -483,16 +590,6 @@
path = Components;
sourceTree = "<group>";
};
7205AEDA2DCCEF9500943F3F /* Cache */ = {
isa = PBXGroup;
children = (
7205AED72DCCEF9500943F3F /* EpisodeMetadata.swift */,
7205AED82DCCEF9500943F3F /* KingfisherManager.swift */,
7205AED92DCCEF9500943F3F /* MetadataCacheManager.swift */,
);
path = Cache;
sourceTree = "<group>";
};
72443C832DC8046500A61321 /* DownloadUtils */ = {
isa = PBXGroup;
children = (
@ -503,16 +600,6 @@
path = DownloadUtils;
sourceTree = "<group>";
};
72AC3A002DD4DAEA00C60B96 /* Managers */ = {
isa = PBXGroup;
children = (
72AC39FD2DD4DAEA00C60B96 /* EpisodeMetadataManager.swift */,
72AC39FE2DD4DAEA00C60B96 /* ImagePrefetchManager.swift */,
72AC39FF2DD4DAEA00C60B96 /* PerformanceMonitor.swift */,
);
path = Managers;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@ -604,16 +691,20 @@
132AF1252D9995F900A0140B /* JSController-Search.swift in Sources */,
1EF5C3A92DB988E40032BF07 /* CommunityLib.swift in Sources */,
13CBEFDA2D5F7D1200D011EE /* String.swift in Sources */,
7205AEDB2DCCEF9500943F3F /* EpisodeMetadata.swift in Sources */,
7205AEDC2DCCEF9500943F3F /* MetadataCacheManager.swift in Sources */,
7205AEDD2DCCEF9500943F3F /* KingfisherManager.swift in Sources */,
04F08EDC2DE10BF3006B29D9 /* ProgressiveBlurView.swift in Sources */,
1E26E9E72DA9577900B9DC02 /* VolumeSlider.swift in Sources */,
136BBE802DB1038000906B5E /* Notification+Name.swift in Sources */,
13DB46902D900A38008CBC03 /* URL.swift in Sources */,
04F08EE42DE10D6F006B29D9 /* AllBookmarks.swift in Sources */,
138FE1D02DECA00D00936D81 /* TMDB-FetchID.swift in Sources */,
130C6BFA2D53AB1F00DC1432 /* SettingsViewData.swift in Sources */,
0402DA172DE7B7B8003BB42C /* SearchViewComponents.swift in Sources */,
1E9FF1D32D403E49008AC100 /* SettingsViewLoggerFilter.swift in Sources */,
0402DA0E2DE7AA01003BB42C /* TabBarController.swift in Sources */,
1E47859B2DEBC5960095BF2F /* AnilistMatchPopupView.swift in Sources */,
13EA2BD92D32D98400C1EBD7 /* NormalPlayer.swift in Sources */,
133D7C932D2BE2640075467E /* Modules.swift in Sources */,
0457C5972DE7712A000AFBD9 /* DeviceScaleModifier.swift in Sources */,
13DB7CC32D7D99C0004371D3 /* SubtitleSettingsManager.swift in Sources */,
133D7C702D2BE2500075467E /* ContentView.swift in Sources */,
13D99CF72D4E73C300250A86 /* ModuleAdditionSettingsView.swift in Sources */,
@ -621,8 +712,10 @@
13CBA0882D60F19C00EFE70A /* VTTSubtitlesLoader.swift in Sources */,
13EA2BD62D32D97400C1EBD7 /* Double+Extension.swift in Sources */,
133D7C8F2D2BE2640075467E /* MediaInfoView.swift in Sources */,
04F08EE22DE10C40006B29D9 /* TabItem.swift in Sources */,
132AF1212D99951700A0140B /* JSController-Streams.swift in Sources */,
131845F92D47C62D00CA7A54 /* SettingsViewGeneral.swift in Sources */,
04CD76DB2DE20F2200733536 /* AllWatching.swift in Sources */,
13103E8E2D58E04A000F0673 /* SkeletonCell.swift in Sources */,
13D842552D45267500EBBFA6 /* DropManager.swift in Sources */,
13103E8B2D58E028000F0673 /* View.swift in Sources */,
@ -637,10 +730,12 @@
722248642DCBAA4700CABE2D /* JSController+M3U8Download.swift in Sources */,
133D7C942D2BE2640075467E /* JSController.swift in Sources */,
133D7C922D2BE2640075467E /* URLSession.swift in Sources */,
0457C5A12DE78385000AFBD9 /* BookmarksDetailView.swift in Sources */,
133D7C912D2BE2640075467E /* SettingsViewModule.swift in Sources */,
13E62FC22DABC5830007E259 /* Trakt-Login.swift in Sources */,
133F55BB2D33B55100E08EEA /* LibraryManager.swift in Sources */,
13E62FC42DABC58C0007E259 /* Trakt-Token.swift in Sources */,
1398FB3F2DE4E161004D3F5F /* SettingsViewAbout.swift in Sources */,
133D7C8E2D2BE2640075467E /* LibraryView.swift in Sources */,
13E62FC72DABFE900007E259 /* TraktPushUpdates.swift in Sources */,
133D7C6E2D2BE2500075467E /* SoraApp.swift in Sources */,
@ -652,16 +747,21 @@
13EA2BD52D32D97400C1EBD7 /* CustomPlayer.swift in Sources */,
727220712DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift in Sources */,
727220722DD642B100C2A4A2 /* JSController+MP4Download.swift in Sources */,
132FC5B32DE31DAE009A80F7 /* SettingsViewAlternateAppIconPicker.swift in Sources */,
0402DA132DE7B5EC003BB42C /* SearchStateView.swift in Sources */,
0402DA142DE7B5EC003BB42C /* SearchResultsGrid.swift in Sources */,
0402DA152DE7B5EC003BB42C /* SearchComponents.swift in Sources */,
1399FAD42D3AB38C00E97C31 /* SettingsViewLogger.swift in Sources */,
72AC3A012DD4DAEB00C60B96 /* ImagePrefetchManager.swift in Sources */,
72AC3A022DD4DAEB00C60B96 /* EpisodeMetadataManager.swift in Sources */,
72AC3A032DD4DAEB00C60B96 /* PerformanceMonitor.swift in Sources */,
04F08EDF2DE10C1D006B29D9 /* TabBar.swift in Sources */,
72443C7F2DC8038300A61321 /* SettingsViewDownloads.swift in Sources */,
13DB46922D900BCE008CBC03 /* SettingsViewTrackers.swift in Sources */,
7222485F2DCBAA2C00CABE2D /* DownloadModels.swift in Sources */,
722248602DCBAA2C00CABE2D /* M3U8StreamExtractor.swift in Sources */,
13C0E5EA2D5F85EA00E7F619 /* ContinueWatchingManager.swift in Sources */,
13637B8A2DE0EA1100BDA2FC /* UserDefaults.swift in Sources */,
0457C59D2DE78267000AFBD9 /* BookmarkGridView.swift in Sources */,
0457C59E2DE78267000AFBD9 /* BookmarkLink.swift in Sources */,
0457C59F2DE78267000AFBD9 /* BookmarkGridItemView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -793,8 +893,10 @@
133D7C792D2BE2520075467E /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = "AppIcon_Original AppIcon_Pixel";
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon_Default;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
CODE_SIGN_ENTITLEMENTS = Sora/Sora.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
@ -820,12 +922,12 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.3.0;
MARKETING_VERSION = 1.0.0;
PRODUCT_BUNDLE_IDENTIFIER = me.cranci.sulfur;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SUPPORTS_MACCATALYST = YES;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,6";
@ -835,8 +937,10 @@
133D7C7A2D2BE2520075467E /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = "AppIcon_Original AppIcon_Pixel";
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon_Default;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
CODE_SIGN_ENTITLEMENTS = Sora/Sora.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
@ -862,12 +966,12 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.3.0;
MARKETING_VERSION = 1.0.0;
PRODUCT_BUNDLE_IDENTIFIER = me.cranci.sulfur;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SUPPORTS_MACCATALYST = YES;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,6";