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

View file

@ -2,8 +2,31 @@
"colors" : [ "colors" : [
{ {
"color" : { "color" : {
"platform" : "universal", "color-space" : "srgb",
"reference" : "systemMintColor" "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" "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

View file

@ -35,4 +35,4 @@
"author" : "xcode", "author" : "xcode",
"version" : 1 "version" : 1
} }
} }

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. // Created by Francesco on 06/01/25.
// //
import SwiftUI import SwiftUI
struct ContentView: View { struct ContentView_Previews: PreviewProvider {
var body: some View { static var previews: some View {
TabView { ContentView()
LibraryView() .environmentObject(LibraryManager())
.tabItem { .environmentObject(ModuleManager())
Label("Library", systemImage: "books.vertical") .environmentObject(Settings())
} }
DownloadView() }
.tabItem {
Label("Downloads", systemImage: "arrow.down.app.fill") struct ContentView: View {
} @StateObject private var tabBarController = TabBarController()
SearchView() @State var selectedTab: Int = 0
.tabItem { @State var lastTab: Int = 0
Label("Search", systemImage: "magnifyingglass") @State private var searchQuery: String = ""
}
SettingsView() let tabs: [TabItem] = [
.tabItem { TabItem(icon: "square.stack", title: ""),
Label("Settings", systemImage: "gear") 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> <array>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
</array> </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> <key>CFBundleURLTypes</key>
<array> <array>
<dict> <dict>
@ -21,6 +125,7 @@
</array> </array>
<key>LSApplicationQueriesSchemes</key> <key>LSApplicationQueriesSchemes</key>
<array> <array>
<string>tracy</string>
<string>iina</string> <string>iina</string>
<string>outplayer</string> <string>outplayer</string>
<string>infuse</string> <string>infuse</string>
@ -36,6 +141,7 @@
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>
<string>audio</string> <string>audio</string>
<string>fetch</string>
<string>processing</string> <string>processing</string>
</array> </array>
</dict> </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> <dict>
<key>com.apple.security.app-sandbox</key> <key>com.apple.security.app-sandbox</key>
<true/> <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> <key>com.apple.security.assets.movies.read-write</key>
<true/> <true/>
<key>com.apple.security.assets.music.read-write</key> <key>com.apple.security.assets.music.read-write</key>
<true/> <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> </dict>
</plist> </plist>

View file

@ -8,8 +8,49 @@
import SwiftUI import SwiftUI
import UIKit 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 @main
struct SoraApp: App { struct SoraApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@StateObject private var settings = Settings() @StateObject private var settings = Settings()
@StateObject private var moduleManager = ModuleManager() @StateObject private var moduleManager = ModuleManager()
@StateObject private var librarykManager = LibraryManager() @StateObject private var librarykManager = LibraryManager()
@ -17,9 +58,6 @@ struct SoraApp: App {
@StateObject private var jsController = JSController.shared @StateObject private var jsController = JSController.shared
init() { init() {
_ = MetadataCacheManager.shared
_ = KingfisherCacheManager.shared
if let userAccentColor = UserDefaults.standard.color(forKey: "accentColor") { if let userAccentColor = UserDefaults.standard.color(forKey: "accentColor") {
UIView.appearance(whenContainedInInstancesOf: [UIAlertController.self]).tintColor = userAccentColor UIView.appearance(whenContainedInInstancesOf: [UIAlertController.self]).tintColor = userAccentColor
} }
@ -136,9 +174,18 @@ struct SoraApp: App {
} }
} }
} }
class AppInfo: NSObject { class AppInfo: NSObject {
@objc func getBundleIdentifier() -> String { @objc func getBundleIdentifier() -> String {
return Bundle.main.bundleIdentifier ?? "me.cranci.sulfur" 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) return String(data: tokenData, encoding: .utf8)
} }
func updateAnimeProgress(animeId: Int, episodeNumber: Int, completion: @escaping (Result<Void, Error>) -> Void) { func updateAnimeProgress(
if let sendPushUpdates = UserDefaults.standard.object(forKey: "sendPushUpdates") as? Bool, animeId: Int,
sendPushUpdates == false { episodeNumber: Int,
return status: String = "CURRENT",
} completion: @escaping (Result<Void, Error>) -> Void
) {
guard let userToken = getTokenFromKeychain() else { if let sendPushUpdates = UserDefaults.standard.object(forKey: "sendPushUpdates") as? Bool,
completion(.failure(NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "Access token not found"]))) sendPushUpdates == false {
return return
}
let query = """
mutation ($mediaId: Int, $progress: Int, $status: MediaListStatus) {
SaveMediaListEntry (mediaId: $mediaId, progress: $progress, status: $status) {
id
progress
status
} }
}
""" guard let userToken = getTokenFromKeychain() else {
completion(.failure(NSError(
let variables: [String: Any] = [ 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, "mediaId": animeId,
"progress": episodeNumber, "progress": episodeNumber,
"status": "CURRENT" "status": status
] ]
let requestBody: [String: Any] = [ let requestBody: [String: Any] = [
"query": query, "query": query,
@ -104,6 +113,52 @@ class AniListMutation {
task.resume() 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) { func fetchMalID(animeId: Int, completion: @escaping (Result<Int, Error>) -> Void) {
let query = """ let query = """
query ($id: Int) { query ($id: Int) {
@ -148,3 +203,10 @@ class AniListMutation {
let data: DataField 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 imageUrl: String
let episodeNumber: Int let episodeNumber: Int
let mediaTitle: String let mediaTitle: String
let progress: Double var progress: Double
let streamUrl: String let streamUrl: String
let fullUrl: String let fullUrl: String
let subtitles: String? let subtitles: String?
let aniListID: Int? let aniListID: Int?
let module: ScrapingModule let module: ScrapingModule
let headers: [String:String]? let headers: [String:String]?
let totalEpisodes: Int
} }

View file

@ -10,36 +10,110 @@ import Foundation
class ContinueWatchingManager { class ContinueWatchingManager {
static let shared = ContinueWatchingManager() static let shared = ContinueWatchingManager()
private let storageKey = "continueWatchingItems" private let storageKey = "continueWatchingItems"
private let lastCleanupKey = "lastContinueWatchingCleanup"
private init() { private init() {
NotificationCenter.default.addObserver(self, selector: #selector(handleiCloudSync), name: .iCloudSyncDidComplete, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(handleiCloudSync), name: .iCloudSyncDidComplete, object: nil)
performCleanupIfNeeded()
} }
@objc private func handleiCloudSync() { @objc private func handleiCloudSync() {
NotificationCenter.default.post(name: .ContinueWatchingDidUpdate, object: nil) 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) { 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) remove(item: item)
return return
} }
// Otherwise update progress and remove old episodes from the same show
var updatedItem = item
updatedItem.progress = actualProgress
var items = fetchItems() 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 items.removeAll { existing in
existing.fullUrl == item.fullUrl && existing.fullUrl == item.fullUrl &&
existing.episodeNumber == item.episodeNumber && existing.episodeNumber == item.episodeNumber &&
existing.module.metadata.sourceName == item.module.metadata.sourceName existing.module.metadata.sourceName == item.module.metadata.sourceName
} }
items.append(item) items.append(updatedItem)
if let data = try? JSONEncoder().encode(items) { if let data = try? JSONEncoder().encode(items) {
UserDefaults.standard.set(data, forKey: storageKey) UserDefaults.standard.set(data, forKey: storageKey)
} }
} }
func fetchItems() -> [ContinueWatchingItem] { func fetchItems() -> [ContinueWatchingItem] {
guard guard
let data = UserDefaults.standard.data(forKey: storageKey), let data = UserDefaults.standard.data(forKey: storageKey),
@ -61,7 +135,7 @@ class ContinueWatchingManager {
return Array(unique) return Array(unique)
} }
func remove(item: ContinueWatchingItem) { func remove(item: ContinueWatchingItem) {
var items = fetchItems() var items = fetchItems()
items.removeAll { $0.id == item.id } items.removeAll { $0.id == item.id }

View file

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

View file

@ -78,10 +78,12 @@ extension JSContext {
} }
func setupFetchV2() { 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 { guard let url = URL(string: urlString) else {
Logger.shared.log("Invalid URL", type: "Error") Logger.shared.log("Invalid URL", type: "Error")
reject.call(withArguments: ["Invalid URL"]) DispatchQueue.main.async {
reject.call(withArguments: ["Invalid URL"])
}
return return
} }
@ -93,7 +95,9 @@ extension JSContext {
if httpMethod == "GET", let body = body, !body.isEmpty, body != "null", body != "undefined" { if httpMethod == "GET", let body = body, !body.isEmpty, body != "null", body != "undefined" {
Logger.shared.log("GET request must not have a body", type: "Error") 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 return
} }
@ -101,29 +105,49 @@ extension JSContext {
request.httpBody = body.data(using: .utf8) request.httpBody = body.data(using: .utf8)
} }
if let headers = headers { if let headers = headers {
for (key, value) in headers { for (key, value) in headers {
request.setValue(value, forHTTPHeaderField: key) 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 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 { if let error = error {
Logger.shared.log("Network error in fetchV2NativeFunction: \(error.localizedDescription)", type: "Error") Logger.shared.log("Network error in fetchV2NativeFunction: \(error.localizedDescription)", type: "Error")
reject.call(withArguments: [error.localizedDescription]) callReject(error.localizedDescription)
return return
} }
guard let tempFileURL = tempFileURL else { guard let tempFileURL = tempFileURL else {
Logger.shared.log("No data in response", type: "Error") Logger.shared.log("No data in response", type: "Error")
reject.call(withArguments: ["No data"]) callReject("No data")
return 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] = [ var responseDict: [String: Any] = [
"status": (response as? HTTPURLResponse)?.statusCode ?? 0, "status": (response as? HTTPURLResponse)?.statusCode ?? 0,
"headers": (response as? HTTPURLResponse)?.allHeaderFields ?? [:], "headers": safeHeaders,
"body": "" "body": ""
] ]
@ -132,23 +156,21 @@ extension JSContext {
if data.count > 10_000_000 { if data.count > 10_000_000 {
Logger.shared.log("Response exceeds maximum size", type: "Error") Logger.shared.log("Response exceeds maximum size", type: "Error")
reject.call(withArguments: ["Response exceeds maximum size"]) callReject("Response exceeds maximum size")
return return
} }
if let text = String(data: data, encoding: .utf8) { if let text = String(data: data, encoding: .utf8) {
responseDict["body"] = text responseDict["body"] = text
resolve.call(withArguments: [responseDict]) callResolve(responseDict)
} else { } 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") Logger.shared.log("Unable to decode data to text", type: "Error")
resolve.call(withArguments: [responseDict]) callResolve(responseDict)
} }
} catch { } catch {
Logger.shared.log("Error reading downloaded file: \(error.localizedDescription)", type: "Error") 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() task.resume()

View file

@ -27,7 +27,7 @@ extension UserDefaults {
let data = try NSKeyedArchiver.archivedData(withRootObject: color, requiringSecureCoding: false) let data = try NSKeyedArchiver.archivedData(withRootObject: color, requiringSecureCoding: false)
set(data, forKey: key) set(data, forKey: key)
} catch { } 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 // Sora
// //
// Created by Francesco on 09/02/25. // Created by paul on 29/05/25.
// //
import SwiftUI import SwiftUI
extension View { struct ScrollViewBottomPadding: ViewModifier {
func shimmering() -> some View { func body(content: Content) -> some View {
self.modifier(Shimmer()) 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)") 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 // Create metadata for the download
var metadata: AssetMetadata? = nil var metadata: AssetMetadata? = nil
if let title = title { if let title = title {
@ -47,7 +53,7 @@ extension JSController {
showTitle: showTitle, showTitle: showTitle,
season: season, season: season,
episode: episode, episode: episode,
showPosterURL: imageURL // Use the correct show poster URL showPosterURL: imageURL
) )
} }
@ -57,12 +63,24 @@ extension JSController {
// Generate a unique download ID // Generate a unique download ID
let downloadID = UUID() 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 // Create an active download object
let activeDownload = JSActiveDownload( let activeDownload = JSActiveDownload(
id: downloadID, id: downloadID,
originalURL: url, originalURL: url,
task: nil, // We'll set this after creating the task task: nil,
queueStatus: .queued, queueStatus: .downloading,
type: downloadType, type: downloadType,
metadata: metadata, metadata: metadata,
title: title, title: title,
@ -74,84 +92,78 @@ extension JSController {
// Add to active downloads // Add to active downloads
activeDownloads.append(activeDownload) activeDownloads.append(activeDownload)
// Create a URL session task for downloading the MP4 file // Create request with headers
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.timeoutInterval = 30.0
for (key, value) in headers { for (key, value) in headers {
request.addValue(value, forHTTPHeaderField: key) 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 let sessionConfig = URLSessionConfiguration.default
// Set a longer timeout for large files
sessionConfig.timeoutIntervalForRequest = 60.0 sessionConfig.timeoutIntervalForRequest = 60.0
sessionConfig.timeoutIntervalForResource = 600.0 sessionConfig.timeoutIntervalForResource = 1800.0
sessionConfig.httpMaximumConnectionsPerHost = 1
// Create a URL session that handles SSL certificate validation issues sessionConfig.allowsCellularAccess = true
// Create custom session with delegate (self is JSController, which is persistent)
let customSession = URLSession(configuration: sessionConfig, delegate: self, delegateQueue: nil) let customSession = URLSession(configuration: sessionConfig, delegate: self, delegateQueue: nil)
// Create the download task with the custom session // Create the download task
let downloadTask = customSession.downloadTask(with: request) { (tempURL, response, error) in let downloadTask = customSession.downloadTask(with: request) { [weak self] (tempURL, response, error) in
guard let self = self else { return }
DispatchQueue.main.async { DispatchQueue.main.async {
defer {
// Clean up resources
self.cleanupDownloadResources(for: downloadID)
}
// Handle error cases - just remove from active downloads
if let error = error { if let error = error {
print("MP4 Download Error: \(error.localizedDescription)") print("MP4 Download Error: \(error.localizedDescription)")
self.removeActiveDownload(downloadID: downloadID)
// 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 }
}
completionHandler?(false, "Download failed: \(error.localizedDescription)") completionHandler?(false, "Download failed: \(error.localizedDescription)")
return return
} }
// Validate response
guard let httpResponse = response as? HTTPURLResponse else { guard let httpResponse = response as? HTTPURLResponse else {
print("MP4 Download: Invalid response") print("MP4 Download: Invalid response")
self.removeActiveDownload(downloadID: downloadID)
completionHandler?(false, "Invalid server response") completionHandler?(false, "Invalid server response")
return return
} }
if httpResponse.statusCode >= 400 { guard (200...299).contains(httpResponse.statusCode) else {
print("MP4 Download HTTP Error: \(httpResponse.statusCode)") print("MP4 Download HTTP Error: \(httpResponse.statusCode)")
self.removeActiveDownload(downloadID: downloadID)
completionHandler?(false, "Server error: \(httpResponse.statusCode)") completionHandler?(false, "Server error: \(httpResponse.statusCode)")
return return
} }
guard let tempURL = tempURL else { guard let tempURL = tempURL else {
print("MP4 Download: No temporary file URL") print("MP4 Download: No temporary file URL")
self.removeActiveDownload(downloadID: downloadID)
completionHandler?(false, "Download data not available") completionHandler?(false, "Download data not available")
return return
} }
// Move file to final destination
do { do {
// Move the temporary file to the permanent location
if FileManager.default.fileExists(atPath: destinationURL.path) { if FileManager.default.fileExists(atPath: destinationURL.path) {
try FileManager.default.removeItem(at: destinationURL) try FileManager.default.removeItem(at: destinationURL)
} }
try FileManager.default.moveItem(at: tempURL, to: 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( let downloadedAsset = DownloadedAsset(
name: title ?? url.lastPathComponent, name: title ?? url.lastPathComponent,
downloadDate: Date(), downloadDate: Date(),
@ -162,112 +174,122 @@ extension JSController {
subtitleURL: subtitleURL subtitleURL: subtitleURL
) )
// Add to saved assets // Save asset
self.savedAssets.append(downloadedAsset) self.savedAssets.append(downloadedAsset)
self.saveAssets() self.saveAssets()
// Update active download and remove after a delay // Update progress to complete and remove after delay
if let index = self.activeDownloads.firstIndex(where: { $0.id == downloadID }) { self.updateDownloadProgress(downloadID: downloadID, progress: 1.0)
self.activeDownloads[index].progress = 1.0
self.activeDownloads[index].queueStatus = .completed
}
// Download subtitle if provided // Download subtitle if provided
if let subtitleURL = subtitleURL { if let subtitleURL = subtitleURL {
self.downloadSubtitle(subtitleURL: subtitleURL, assetID: downloadedAsset.id.uuidString) self.downloadSubtitle(subtitleURL: subtitleURL, assetID: downloadedAsset.id.uuidString)
} }
// Notify observers - use downloadCompleted since the download finished // Notify completion
NotificationCenter.default.post(name: NSNotification.Name("downloadCompleted"), object: nil) NotificationCenter.default.post(name: NSNotification.Name("downloadCompleted"), object: downloadedAsset)
completionHandler?(true, "Download completed successfully") completionHandler?(true, "Download completed successfully")
// Clean up resources // Remove from active downloads after success
self.mp4ProgressObservations?[downloadID] = nil DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
self.mp4CustomSessions?[downloadID] = nil self.removeActiveDownload(downloadID: downloadID)
// Remove the completed download from active list after a delay
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
self.activeDownloads.removeAll { $0.id == downloadID }
} }
} catch { } 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)") 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() downloadTask.resume()
print("MP4 Download: Task started for \(filename)")
// Update the task in the active download // Initial success callback
if let index = activeDownloads.firstIndex(where: { $0.id == downloadID }) { completionHandler?(true, "Download started")
activeDownloads[index].queueStatus = .downloading }
// Store reference to the downloadTask directly - no need to access private properties // MARK: - Helper Methods
print("MP4 Download: Task started")
// We can't directly store URLSessionDownloadTask in place of AVAssetDownloadTask private func removeActiveDownload(downloadID: UUID) {
// Just continue tracking progress separately activeDownloads.removeAll { $0.id == downloadID }
} }
// Set up progress observation - fix the key path specification private func updateDownloadProgress(downloadID: UUID, progress: Double) {
let observation = downloadTask.progress.observe(\Progress.fractionCompleted) { progress, _ in 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 { DispatchQueue.main.async {
if let index = self.activeDownloads.firstIndex(where: { $0.id == downloadID }) { guard let self = self else { return }
self.activeDownloads[index].progress = progress.fractionCompleted self.updateDownloadProgress(downloadID: downloadID, progress: progress.fractionCompleted)
NotificationCenter.default.post(name: NSNotification.Name("downloadProgressChanged"), object: nil)
// Notify observers of progress update
NotificationCenter.default.post(name: NSNotification.Name("downloadProgressUpdated"), object: nil)
}
} }
} }
// Store the observation somewhere to keep it alive - using nonatomic property from main class if mp4ProgressObservations == nil {
if self.mp4ProgressObservations == nil { mp4ProgressObservations = [:]
self.mp4ProgressObservations = [:]
} }
self.mp4ProgressObservations?[downloadID] = observation mp4ProgressObservations?[downloadID] = observation
}
// Store the custom session to keep it alive until download is complete
if self.mp4CustomSessions == nil { private func storeSessionReference(session: URLSession, for downloadID: UUID) {
self.mp4CustomSessions = [:] if mp4CustomSessions == nil {
mp4CustomSessions = [:]
} }
self.mp4CustomSessions?[downloadID] = customSession mp4CustomSessions?[downloadID] = session
}
// Notify that download started successfully
completionHandler?(true, "Download started") 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 { extension JSController: URLSessionDelegate {
func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
// Handle SSL/TLS certificate validation
if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust { guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust else {
let host = challenge.protectionSpace.host completionHandler(.performDefaultHandling, nil)
print("MP4 Download: Handling server trust challenge for host: \(host)") return
// 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
}
}
} }
// For other authentication challenges, use default handling let host = challenge.protectionSpace.host
print("MP4 Download: Using default handling for auth challenge") print("MP4 Download: Handling server trust challenge for host: \(host)")
completionHandler(.performDefaultHandling, nil)
// 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 { if let subtitle = subtitleURL {
print("Subtitle URL: \(subtitle.absoluteString)") print("Subtitle URL: \(subtitle.absoluteString)")
} }
// Check the stream type from the module metadata
let streamType = module.metadata.streamType.lowercased() let streamType = module.metadata.streamType.lowercased()
// Determine which download method to use based on streamType if streamType == "hls" || streamType == "m3u8" || url.absoluteString.contains(".m3u8") {
if streamType == "mp4" || streamType == "direct" || url.absoluteString.contains(".mp4") { Logger.shared.log("Using HLS download method")
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")
downloadWithM3U8Support( downloadWithM3U8Support(
url: url, url: url,
headers: headers, headers: headers,
@ -71,22 +64,20 @@ extension JSController {
showPosterURL: showPosterURL, showPosterURL: showPosterURL,
completionHandler: completionHandler completionHandler: completionHandler
) )
} else { }else {
// Default to M3U8 method for unknown types, as it has fallback mechanisms Logger.shared.log("Using MP4 download method")
print("Using default HLS download method for unknown stream type: \(streamType)") downloadMP4(
downloadWithM3U8Support(
url: url, url: url,
headers: headers, headers: headers,
title: title, title: title,
imageURL: imageURL, imageURL: imageURL ?? showPosterURL,
isEpisode: isEpisode, isEpisode: isEpisode,
showTitle: showTitle, showTitle: showTitle,
season: season, season: season,
episode: episode, episode: episode,
subtitleURL: subtitleURL, subtitleURL: subtitleURL,
showPosterURL: showPosterURL,
completionHandler: completionHandler completionHandler: completionHandler
) )
} }
} }
} }

View file

@ -5,6 +5,7 @@
// Created by Francesco on 30/03/25. // Created by Francesco on 30/03/25.
// //
import Foundation
import JavaScriptCore import JavaScriptCore
extension JSController { extension JSController {
@ -51,7 +52,7 @@ extension JSController {
let episodesResult = fetchEpisodesFunction.call(withArguments: [html]).toArray() as? [[String: String]] { let episodesResult = fetchEpisodesFunction.call(withArguments: [html]).toArray() as? [[String: String]] {
for episodeData in episodesResult { for episodeData in episodesResult {
if let num = episodeData["number"], let link = episodeData["href"], let number = Int(num) { 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 episodeLinks = array.map { item -> EpisodeLink in
EpisodeLink( EpisodeLink(
number: item["number"] as? Int ?? 0, number: item["number"] as? Int ?? 0,
href: item["href"] as? String ?? "" title: "",
href: item["href"] as? String ?? "",
duration: nil
) )
} }
} else { } 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 AVKit
import AVFoundation import AVFoundation
// Use ScrapingModule from Modules.swift as Module
typealias Module = ScrapingModule typealias Module = ScrapingModule
class JSController: NSObject, ObservableObject { class JSController: NSObject, ObservableObject {
// Shared instance that can be used across the app
static let shared = JSController() static let shared = JSController()
var context: JSContext var context: JSContext
// Downloaded assets storage
@Published var savedAssets: [DownloadedAsset] = [] @Published var savedAssets: [DownloadedAsset] = []
@Published var activeDownloads: [JSActiveDownload] = [] @Published var activeDownloads: [JSActiveDownload] = []
// Tracking map for download tasks
var activeDownloadMap: [URLSessionTask: UUID] = [:] var activeDownloadMap: [URLSessionTask: UUID] = [:]
// Download queue management
@Published var downloadQueue: [JSActiveDownload] = [] @Published var downloadQueue: [JSActiveDownload] = []
var isProcessingQueue: Bool = false var isProcessingQueue: Bool = false
var maxConcurrentDownloads: Int { var maxConcurrentDownloads: Int {
UserDefaults.standard.object(forKey: "maxConcurrentDownloads") as? Int ?? 3 UserDefaults.standard.object(forKey: "maxConcurrentDownloads") as? Int ?? 3
} }
// Track downloads that have been cancelled to prevent completion processing
var cancelledDownloadIDs: Set<UUID> = [] var cancelledDownloadIDs: Set<UUID> = []
// Download session
var downloadURLSession: AVAssetDownloadURLSession? var downloadURLSession: AVAssetDownloadURLSession?
// For MP4 download progress tracking
var mp4ProgressObservations: [UUID: NSKeyValueObservation]? var mp4ProgressObservations: [UUID: NSKeyValueObservation]?
// For storing custom URLSessions used for MP4 downloads
var mp4CustomSessions: [UUID: URLSession]? var mp4CustomSessions: [UUID: URLSession]?
override init() { override init() {
@ -58,9 +45,7 @@ class JSController: NSObject, ObservableObject {
setupDownloadSession() setupDownloadSession()
} }
// Setup download functionality separately from general context setup
private func setupDownloadSession() { private func setupDownloadSession() {
// Only initialize download session if it doesn't exist already
if downloadURLSession == nil { if downloadURLSession == nil {
initializeDownloadSession() initializeDownloadSession()
setupDownloadFunction() setupDownloadFunction()
@ -69,7 +54,6 @@ class JSController: NSObject, ObservableObject {
func loadScript(_ script: String) { func loadScript(_ script: String) {
context = JSContext() context = JSContext()
// Only set up the JavaScript environment without reinitializing the download session
context.setupJavaScriptEnvironment() context.setupJavaScriptEnvironment()
context.evaluateScript(script) context.evaluateScript(script)
if let exception = context.exception { 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) { func updateMaxConcurrentDownloads(_ newLimit: Int) {
print("Updating max concurrent downloads from \(maxConcurrentDownloads) to \(newLimit)") 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 { if !downloadQueue.isEmpty && !isProcessingQueue {
print("Processing download queue due to increased concurrent limit. Queue has \(downloadQueue.count) items.") 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 DispatchQueue.main.async { [weak self] in
guard let self = self else { return } guard let self = self else { return }
self.objectWillChange.send() 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 DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
self?.processDownloadQueue() self?.processDownloadQueue()
} }
@ -102,21 +78,6 @@ class JSController: NSObject, ObservableObject {
print("No queued downloads to process or queue is already being processed") 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 var logs: [LogEntry] = []
private let logFileURL: URL private let logFileURL: URL
private let logFilterViewModel = LogFilterViewModel.shared private let logFilterViewModel = LogFilterViewModel.shared
private let maxFileSize = 1024 * 512 private let maxFileSize = 1024 * 512
private let maxLogEntries = 1000
private init() { private init() {
let documentDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! let documentDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
@ -35,6 +36,11 @@ class Logger {
queue.async(flags: .barrier) { queue.async(flags: .barrier) {
self.logs.append(entry) self.logs.append(entry)
if self.logs.count > self.maxLogEntries {
self.logs.removeFirst(self.logs.count - self.maxLogEntries)
}
self.saveLogToFile(entry) self.saveLogToFile(entry)
self.debugLog(entry) self.debugLog(entry)
} }
@ -51,6 +57,18 @@ class Logger {
return result 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() { func clearLogs() {
queue.async(flags: .barrier) { queue.async(flags: .barrier) {
self.logs.removeAll() 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) { private func saveLogToFile(_ log: LogEntry) {
let dateFormatter = DateFormatter() let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "dd-MM HH:mm:ss" dateFormatter.dateFormat = "dd-MM HH:mm:ss"
let logString = "[\(dateFormatter.string(from: log.timestamp))] [\(log.type)] \(log.message)\n---\n" 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) { if FileManager.default.fileExists(atPath: logFileURL.path) {
do { let attributes = try FileManager.default.attributesOfItem(atPath: logFileURL.path)
let attributes = try FileManager.default.attributesOfItem(atPath: logFileURL.path) let fileSize = attributes[.size] as? UInt64 ?? 0
let fileSize = attributes[.size] as? UInt64 ?? 0
if fileSize + UInt64(data.count) > maxFileSize {
if fileSize + UInt64(data.count) > maxFileSize { self.truncateLogFile()
guard var content = try? String(contentsOf: logFileURL, encoding: .utf8) else { return } }
while (content.data(using: .utf8)?.count ?? 0) + data.count > maxFileSize { if let handle = try? FileHandle(forWritingTo: logFileURL) {
if let rangeOfFirstLine = content.range(of: "\n---\n") { handle.seekToEndOfFile()
content.removeSubrange(content.startIndex...rangeOfFirstLine.upperBound) handle.write(data)
} else { handle.closeFile()
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)")
} }
} else { } 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) { private func debugLog(_ entry: LogEntry) {
#if DEBUG #if DEBUG
let dateFormatter = DateFormatter() let dateFormatter = DateFormatter()

View file

@ -28,6 +28,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
private var aniListUpdateImpossible: Bool = false private var aniListUpdateImpossible: Bool = false
private var aniListRetryCount = 0 private var aniListRetryCount = 0
private let aniListMaxRetries = 6 private let aniListMaxRetries = 6
private let totalEpisodes: Int
var player: AVPlayer! var player: AVPlayer!
var timeObserverToken: Any? var timeObserverToken: Any?
@ -40,6 +41,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
var currentTimeVal: Double = 0.0 var currentTimeVal: Double = 0.0
var duration: Double = 0.0 var duration: Double = 0.0
var isVideoLoaded = false var isVideoLoaded = false
var detachedWindow: UIWindow?
private var isHoldPauseEnabled: Bool { private var isHoldPauseEnabled: Bool {
UserDefaults.standard.bool(forKey: "holdForPauseEnabled") UserDefaults.standard.bool(forKey: "holdForPauseEnabled")
@ -59,6 +61,20 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
return UserDefaults.standard.bool(forKey: "doubleTapSeekEnabled") 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 portraitButtonVisibleConstraints: [NSLayoutConstraint] = []
var portraitButtonHiddenConstraints: [NSLayoutConstraint] = [] var portraitButtonHiddenConstraints: [NSLayoutConstraint] = []
var landscapeButtonVisibleConstraints: [NSLayoutConstraint] = [] var landscapeButtonVisibleConstraints: [NSLayoutConstraint] = []
@ -138,6 +154,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
private var playerItemKVOContext = 0 private var playerItemKVOContext = 0
private var loadedTimeRangesObservation: NSKeyValueObservation? private var loadedTimeRangesObservation: NSKeyValueObservation?
private var playerTimeControlStatusObserver: NSKeyValueObservation? private var playerTimeControlStatusObserver: NSKeyValueObservation?
private var playerRateObserver: NSKeyValueObservation?
private var controlsLocked = false private var controlsLocked = false
private var lockButtonTimer: Timer? private var lockButtonTimer: Timer?
@ -175,6 +192,10 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
private var subtitleDelay: Double = 0.0 private var subtitleDelay: Double = 0.0
var currentPlaybackSpeed: Float = 1.0 var currentPlaybackSpeed: Float = 1.0
private var wasPlayingBeforeBackground = false
private var backgroundToken: Any?
private var foregroundToken: Any?
init(module: ScrapingModule, init(module: ScrapingModule,
urlString: String, urlString: String,
fullUrl: String, fullUrl: String,
@ -183,6 +204,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
onWatchNext: @escaping () -> Void, onWatchNext: @escaping () -> Void,
subtitlesURL: String?, subtitlesURL: String?,
aniListID: Int, aniListID: Int,
totalEpisodes: Int,
episodeImageUrl: String,headers:[String:String]?) { episodeImageUrl: String,headers:[String:String]?) {
self.module = module self.module = module
@ -195,6 +217,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
self.subtitlesURL = subtitlesURL self.subtitlesURL = subtitlesURL
self.aniListID = aniListID self.aniListID = aniListID
self.headers = headers self.headers = headers
self.totalEpisodes = totalEpisodes
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
@ -256,6 +279,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
setupAudioSession() setupAudioSession()
updateSkipButtonsVisibility() updateSkipButtonsVisibility()
setupHoldSpeedIndicator() setupHoldSpeedIndicator()
setupPipIfSupported()
view.bringSubviewToFront(subtitleStackView) view.bringSubviewToFront(subtitleStackView)
@ -286,6 +310,17 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
Logger.shared.log("Error activating audio session: \(error)", type: "Debug") 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) volumeViewModel.value = Double(audioSession.outputVolume)
volumeObserver = audioSession.observe(\.outputVolume, options: [.new]) { [weak self] session, change in volumeObserver = audioSession.observe(\.outputVolume, options: [.new]) { [weak self] session, change in
@ -386,6 +421,24 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
player.pause() 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?) { override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
guard context == &playerItemKVOContext else { guard context == &playerItemKVOContext else {
super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context) super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
@ -1048,7 +1101,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
func setupSkipButtons() { func setupSkipButtons() {
let introConfig = UIImage.SymbolConfiguration(pointSize: 14, weight: .bold) let introConfig = UIImage.SymbolConfiguration(pointSize: 14, weight: .bold)
let introImage = UIImage(systemName: "forward.frame", withConfiguration: introConfig) let introImage = UIImage(systemName: "forward.frame", withConfiguration: introConfig)
skipIntroButton = UIButton(type: .system) skipIntroButton = GradientOverlayButton(type: .system)
skipIntroButton.setTitle(" Skip Intro", for: .normal) skipIntroButton.setTitle(" Skip Intro", for: .normal)
skipIntroButton.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .bold) skipIntroButton.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .bold)
skipIntroButton.setImage(introImage, for: .normal) skipIntroButton.setImage(introImage, for: .normal)
@ -1080,7 +1133,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
let outroConfig = UIImage.SymbolConfiguration(pointSize: 14, weight: .bold) let outroConfig = UIImage.SymbolConfiguration(pointSize: 14, weight: .bold)
let outroImage = UIImage(systemName: "forward.frame", withConfiguration: outroConfig) let outroImage = UIImage(systemName: "forward.frame", withConfiguration: outroConfig)
skipOutroButton = UIButton(type: .system) skipOutroButton = GradientOverlayButton(type: .system)
skipOutroButton.setTitle(" Skip Outro", for: .normal) skipOutroButton.setTitle(" Skip Outro", for: .normal)
skipOutroButton.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .bold) skipOutroButton.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .bold)
skipOutroButton.setImage(outroImage, for: .normal) 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() { func setupMenuButton() {
let config = UIImage.SymbolConfiguration(pointSize: 15, weight: .bold) let config = UIImage.SymbolConfiguration(pointSize: 15, weight: .bold)
let image = UIImage(systemName: "text.bubble", withConfiguration: config) 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 config = UIImage.SymbolConfiguration(pointSize: 14, weight: .bold)
let image = UIImage(systemName: "goforward", withConfiguration: config) let image = UIImage(systemName: "goforward", withConfiguration: config)
skip85Button = UIButton(type: .system) skip85Button = GradientOverlayButton(type: .system)
skip85Button.setTitle(" Skip 85s", for: .normal) skip85Button.setTitle(" Skip 85s", for: .normal)
skip85Button.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .bold) skip85Button.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .bold)
skip85Button.setImage(image, for: .normal) skip85Button.setImage(image, for: .normal)
@ -1424,7 +1524,8 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
subtitles: self.subtitlesURL, subtitles: self.subtitlesURL,
aniListID: self.aniListID, aniListID: self.aniListID,
module: self.module, module: self.module,
headers: self.headers headers: self.headers,
totalEpisodes: self.totalEpisodes
) )
ContinueWatchingManager.shared.save(item: item) 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() { @objc private func lockTapped() {
controlsLocked.toggle() controlsLocked.toggle()
@ -1681,7 +1800,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
updateSkipButtonsVisibility() updateSkipButtonsVisibility()
} }
} }
@objc private func skipIntro() { @objc private func skipIntro() {
if let range = skipIntervals.op { if let range = skipIntervals.op {
player.seek(to: range.end) player.seek(to: range.end)
@ -1697,12 +1816,15 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
} }
@objc func dismissTapped() { @objc func dismissTapped() {
dismiss(animated: true, completion: nil) dismiss(animated: true) { [weak self] in
self?.detachedWindow = nil
}
} }
@objc func watchNextTapped() { @objc func watchNextTapped() {
player.pause() player.pause()
dismiss(animated: true) { [weak self] in dismiss(animated: true) { [weak self] in
self?.detachedWindow = nil
self?.onWatchNext() self?.onWatchNext()
} }
} }
@ -1758,35 +1880,77 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
} }
private func tryAniListUpdate() { private func tryAniListUpdate() {
let aniListMutation = AniListMutation() guard !aniListUpdatedSuccessfully else { return }
aniListMutation.updateAnimeProgress(animeId: self.aniListID, episodeNumber: self.episodeNumber) { [weak self] result in
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 } 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 { client.updateAnimeProgress(
case .success: animeId: self.aniListID,
self.aniListUpdatedSuccessfully = true episodeNumber: self.episodeNumber,
Logger.shared.log("Successfully updated AniList progress for episode \(self.episodeNumber)", type: "General") status: newStatus
) { result in
case .failure(let error): switch result {
let errorString = error.localizedDescription.lowercased() case .success:
Logger.shared.log("AniList progress update failed: \(errorString)", type: "Error") self.aniListUpdatedSuccessfully = true
Logger.shared.log(
if errorString.contains("access token not found") { "AniList progress updated to \(newStatus) for ep \(self.episodeNumber)",
Logger.shared.log("AniList update will NOT retry due to missing token.", type: "Error") type: "General"
self.aniListUpdateImpossible = true )
} else { case .failure(let error):
if self.aniListRetryCount < self.aniListMaxRetries { let errorString = error.localizedDescription.lowercased()
self.aniListRetryCount += 1 Logger.shared.log("AniList progress update failed: \(errorString)", type: "Error")
let delaySeconds = 5.0 if errorString.contains("access token not found") {
Logger.shared.log("AniList update will retry in \(delaySeconds)s (attempt \(self.aniListRetryCount)).", type: "Debug") Logger.shared.log("AniList update will NOT retry due to missing token.", type: "Error")
self.aniListUpdateImpossible = true
DispatchQueue.main.asyncAfter(deadline: .now() + delaySeconds) {
self.tryAniListUpdate()
}
} else { } 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 { switch gesture.state {
case .ended: case .ended:
if translation.y > 100 { if translation.y > 100 {
dismiss(animated: true, completion: nil) dismiss(animated: true) { [weak self] in
self?.detachedWindow = nil
}
} }
default: default:
break 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 // yes? Like the plural of the famous american rapper ye? -IBHRAD
// low taper fade the meme is massive -cranci // 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 // 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() { private func endHoldSpeed() {
player?.rate = originalRate player?.rate = originalRate
} }
func setupAudioSession() { func setupAudioSession() {
do { do {
let audioSession = AVAudioSession.sharedInstance() let audioSession = AVAudioSession.sharedInstance()

View file

@ -19,10 +19,12 @@ class VideoPlayerViewController: UIViewController {
var subtitles: String = "" var subtitles: String = ""
var aniListID: Int = 0 var aniListID: Int = 0
var headers: [String:String]? = nil var headers: [String:String]? = nil
var totalEpisodes: Int = 0
var episodeNumber: Int = 0 var episodeNumber: Int = 0
var episodeImageUrl: String = "" var episodeImageUrl: String = ""
var mediaTitle: String = "" var mediaTitle: String = ""
var detachedWindow: UIWindow?
init(module: ScrapingModule) { init(module: ScrapingModule) {
self.module = module self.module = module
@ -41,15 +43,11 @@ class VideoPlayerViewController: UIViewController {
} }
var request = URLRequest(url: url) var request = URLRequest(url: url)
if let mydict = headers, !mydict.isEmpty if let mydict = headers, !mydict.isEmpty {
{ for (key,value) in mydict {
for (key,value) in mydict
{
request.addValue(value, forHTTPHeaderField: key) request.addValue(value, forHTTPHeaderField: key)
} }
} } else {
else
{
request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Referer") request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Referer")
request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Origin") request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Origin")
} }
@ -139,7 +137,8 @@ class VideoPlayerViewController: UIViewController {
subtitles: self.subtitles, subtitles: self.subtitles,
aniListID: self.aniListID, aniListID: self.aniListID,
module: self.module, module: self.module,
headers: self.headers headers: self.headers,
totalEpisodes: self.totalEpisodes
) )
ContinueWatchingManager.shared.save(item: item) 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 { struct CommunityLibraryView: View {
@EnvironmentObject var moduleManager: ModuleManager @EnvironmentObject var moduleManager: ModuleManager
@EnvironmentObject var tabBarController: TabBarController
@AppStorage("lastCommunityURL") private var inputURL: String = "" @AppStorage("lastCommunityURL") private var inputURL: String = ""
@State private var webURL: URL? @State private var webURL: URL?
@ -30,7 +31,6 @@ struct CommunityLibraryView: View {
} }
WebView(url: webURL) { linkURL in WebView(url: webURL) { linkURL in
if let comps = URLComponents(url: linkURL, resolvingAgainstBaseURL: false), if let comps = URLComponents(url: linkURL, resolvingAgainstBaseURL: false),
let m = comps.queryItems?.first(where: { $0.name == "url" })?.value { let m = comps.queryItems?.first(where: { $0.name == "url" })?.value {
moduleLinkToAdd = ModuleLink(url: m) moduleLinkToAdd = ModuleLink(url: m)
@ -38,7 +38,13 @@ struct CommunityLibraryView: View {
} }
.ignoresSafeArea(edges: .top) .ignoresSafeArea(edges: .top)
} }
.onAppear(perform: loadURL) .onAppear {
loadURL()
tabBarController.hideTabBar()
}
.onDisappear {
tabBarController.showTabBar()
}
.sheet(item: $moduleLinkToAdd) { link in .sheet(item: $moduleLinkToAdd) { link in
ModuleAdditionSettingsView(moduleUrl: link.url) ModuleAdditionSettingsView(moduleUrl: link.url)
.environmentObject(moduleManager) .environmentObject(moduleManager)

View file

@ -11,6 +11,7 @@ import Kingfisher
struct ModuleAdditionSettingsView: View { struct ModuleAdditionSettingsView: View {
@Environment(\.presentationMode) var presentationMode @Environment(\.presentationMode) var presentationMode
@EnvironmentObject var moduleManager: ModuleManager @EnvironmentObject var moduleManager: ModuleManager
@Environment(\.colorScheme) var colorScheme
@State private var moduleMetadata: ModuleMetadata? @State private var moduleMetadata: ModuleMetadata?
@State private var isLoading = false @State private var isLoading = false
@ -115,13 +116,14 @@ struct ModuleAdditionSettingsView: View {
Text("Add Module") Text("Add Module")
} }
.font(.headline) .font(.headline)
.foregroundColor(.white) .foregroundColor(colorScheme == .dark ? .black : .white)
.frame(maxWidth: .infinity)
.padding() .padding()
.frame(maxWidth: .infinity)
.background( .background(
RoundedRectangle(cornerRadius: 15) RoundedRectangle(cornerRadius: 15)
.fill(Color.accentColor) .foregroundColor(colorScheme == .dark ? .white : .black)
) )
.padding(.horizontal) .padding(.horizontal)
} }
.disabled(isLoading) .disabled(isLoading)
@ -131,7 +133,7 @@ struct ModuleAdditionSettingsView: View {
self.presentationMode.wrappedValue.dismiss() self.presentationMode.wrappedValue.dismiss()
}) { }) {
Text("Cancel") Text("Cancel")
.foregroundColor((Color.accentColor)) .foregroundColor(colorScheme == .dark ? Color.white : Color.black)
.padding(.top, 10) .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 import SwiftUI
struct Shimmer: ViewModifier { struct Shimmer: ViewModifier {
@State private var phase: CGFloat = 0 @State private var phase: CGFloat = -1
func body(content: Content) -> some View { func body(content: Content) -> some View {
content content
.overlay( .modifier(AnimatedMask(phase: phase)
Rectangle() .animation(
.fill( Animation.linear(duration: 1.2)
LinearGradient( .repeatForever(autoreverses: false)
gradient: Gradient(colors: [Color.clear, Color.white.opacity(0.4), Color.clear]),
startPoint: .top,
endPoint: .bottom
) )
)
.rotationEffect(.degrees(30))
.offset(x: self.phase * 350)
) )
.mask(content)
.onAppear { .onAppear {
withAnimation(.linear(duration: 1.5).repeatForever(autoreverses: false)) { phase = 1.5
self.phase = 1
}
} }
} }
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 let cellWidth: CGFloat
var body: some View { var body: some View {
VStack { RoundedRectangle(cornerRadius: 10)
RoundedRectangle(cornerRadius: 10) .fill(Color.gray.opacity(0.3))
.fill(Color.gray.opacity(0.3)) .frame(width: cellWidth, height: cellWidth * 1.5)
.frame(width: cellWidth, height: cellWidth * 1.5) .cornerRadius(10)
.cornerRadius(10) .shimmering()
.shimmering()
RoundedRectangle(cornerRadius: 5)
.fill(Color.gray.opacity(0.3))
.frame(width: cellWidth, height: 20)
.padding(.top, 4)
.shimmering()
}
} }
} }
@ -31,15 +23,9 @@ struct SearchSkeletonCell: View {
let cellWidth: CGFloat let cellWidth: CGFloat
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 8) { RoundedRectangle(cornerRadius: 10)
RoundedRectangle(cornerRadius: 10) .fill(Color.gray.opacity(0.3))
.fill(Color.gray.opacity(0.3)) .frame(width: cellWidth, height: cellWidth * 1.5)
.frame(width: cellWidth, height: cellWidth * 1.5) .shimmering()
.shimmering()
RoundedRectangle(cornerRadius: 5)
.fill(Color.gray.opacity(0.3))
.frame(width: cellWidth, height: 20)
.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 SwiftUI
import Kingfisher import Kingfisher
import UIKit
struct LibraryView: View { struct LibraryView: View {
@EnvironmentObject private var libraryManager: LibraryManager @EnvironmentObject private var libraryManager: LibraryManager
@ -16,19 +17,23 @@ struct LibraryView: View {
@AppStorage("mediaColumnsLandscape") private var mediaColumnsLandscape: Int = 4 @AppStorage("mediaColumnsLandscape") private var mediaColumnsLandscape: Int = 4
@Environment(\.verticalSizeClass) var verticalSizeClass @Environment(\.verticalSizeClass) var verticalSizeClass
@Environment(\.horizontalSizeClass) var horizontalSizeClass
@State private var selectedBookmark: LibraryItem? = nil @State private var selectedBookmark: LibraryItem? = nil
@State private var isDetailActive: Bool = false @State private var isDetailActive: Bool = false
@State private var continueWatchingItems: [ContinueWatchingItem] = [] @State private var continueWatchingItems: [ContinueWatchingItem] = []
@State private var isLandscape: Bool = UIDevice.current.orientation.isLandscape @State private var isLandscape: Bool = UIDevice.current.orientation.isLandscape
@State private var selectedTab: Int = 0
private let columns = [ private let columns = [
GridItem(.adaptive(minimum: 150), spacing: 12) GridItem(.adaptive(minimum: 150), spacing: 12)
] ]
private var columnsCount: Int { 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 let isLandscape = UIScreen.main.bounds.width > UIScreen.main.bounds.height
return isLandscape ? mediaColumnsLandscape : mediaColumnsPortrait return isLandscape ? mediaColumnsLandscape : mediaColumnsPortrait
} else { } else {
@ -49,108 +54,97 @@ struct LibraryView: View {
var body: some View { var body: some View {
NavigationView { NavigationView {
ScrollView { ZStack {
let columnsCount = determineColumns() ScrollView {
VStack(alignment: .leading, spacing: 20) {
VStack(alignment: .leading, spacing: 12) { Text("Library")
Text("Continue Watching") .font(.largeTitle)
.font(.title2) .fontWeight(.bold)
.bold() .padding(.horizontal, 20)
.padding(.horizontal, 20) .padding(.top, 20)
HStack {
if continueWatchingItems.isEmpty { HStack(spacing: 4) {
VStack(spacing: 8) { Image(systemName: "play.fill")
Image(systemName: "play.circle") .font(.subheadline)
.font(.largeTitle) Text("Continue Watching")
.foregroundColor(.secondary) .font(.title3)
Text("No items to continue watching.") .fontWeight(.semibold)
.font(.headline) }
Text("Recently watched content will appear here.")
.font(.caption) Spacer()
.foregroundColor(.secondary)
} NavigationLink(destination: AllWatchingView()) {
.padding() Text("View All")
.frame(maxWidth: .infinity) .font(.subheadline)
} else { .padding(.horizontal, 12)
ContinueWatchingSection(items: $continueWatchingItems, markAsWatched: { item in .padding(.vertical, 6)
markContinueWatchingItemAsWatched(item: item) .background(Color.gray.opacity(0.2))
}, removeItem: { item in .cornerRadius(15)
removeContinueWatchingItem(item: item) .gradientOutline()
})
}
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")
}
}
}
} }
} }
.padding(.horizontal, 20) .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( NavigationLink(
destination: Group { destination: Group {
if let bookmark = selectedBookmark, 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, MediaInfoView(title: bookmark.title,
imageUrl: bookmark.imageUrl, imageUrl: bookmark.imageUrl,
href: bookmark.href, href: bookmark.href,
@ -163,19 +157,14 @@ struct LibraryView: View {
) { ) {
EmptyView() 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()) .navigationViewStyle(StackNavigationViewStyle())
@ -191,12 +180,16 @@ struct LibraryView: View {
UserDefaults.standard.set(99999999.0, forKey: key) UserDefaults.standard.set(99999999.0, forKey: key)
UserDefaults.standard.set(99999999.0, forKey: totalKey) UserDefaults.standard.set(99999999.0, forKey: totalKey)
ContinueWatchingManager.shared.remove(item: item) ContinueWatchingManager.shared.remove(item: item)
continueWatchingItems.removeAll { $0.id == item.id } continueWatchingItems.removeAll {
$0.id == item.id
}
} }
private func removeContinueWatchingItem(item: ContinueWatchingItem) { private func removeContinueWatchingItem(item: ContinueWatchingItem) {
ContinueWatchingManager.shared.remove(item: item) ContinueWatchingManager.shared.remove(item: item)
continueWatchingItems.removeAll { $0.id == item.id } continueWatchingItems.removeAll {
$0.id == item.id
}
} }
private func updateOrientation() { private func updateOrientation() {
@ -206,7 +199,10 @@ struct LibraryView: View {
} }
private func determineColumns() -> Int { 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 return isLandscape ? mediaColumnsLandscape : mediaColumnsPortrait
} else { } else {
return verticalSizeClass == .compact ? mediaColumnsLandscape : mediaColumnsPortrait return verticalSizeClass == .compact ? mediaColumnsLandscape : mediaColumnsPortrait
@ -220,20 +216,18 @@ struct ContinueWatchingSection: View {
var removeItem: (ContinueWatchingItem) -> Void var removeItem: (ContinueWatchingItem) -> Void
var body: some View { var body: some View {
VStack(alignment: .leading) { ScrollView(.horizontal, showsIndicators: false) {
ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 12) {
HStack(spacing: 8) { ForEach(Array(items.reversed().prefix(5))) { item in
ForEach(Array(items.reversed())) { item in ContinueWatchingCell(item: item, markAsWatched: {
ContinueWatchingCell(item: item, markAsWatched: { markAsWatched(item)
markAsWatched(item) }, removeItem: {
}, removeItem: { removeItem(item)
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 markAsWatched: () -> Void
var removeItem: () -> Void var removeItem: () -> Void
@State private var currentProgress: Double = 0.0 @State private
var currentProgress: Double = 0.0
var body: some View { var body: some View {
Button(action: { Button(action: {
@ -272,9 +267,9 @@ struct ContinueWatchingCell: View {
onWatchNext: { }, onWatchNext: { },
subtitlesURL: item.subtitles, subtitlesURL: item.subtitles,
aniListID: item.aniListID ?? 0, aniListID: item.aniListID ?? 0,
totalEpisodes: item.totalEpisodes,
episodeImageUrl: item.imageUrl, episodeImageUrl: item.imageUrl,
headers: item.headers ?? nil headers: item.headers ?? nil
) )
customMediaPlayer.modalPresentationStyle = .fullScreen customMediaPlayer.modalPresentationStyle = .fullScreen
@ -284,99 +279,311 @@ struct ContinueWatchingCell: View {
} }
} }
}) { }) {
VStack(alignment: .leading) { ZStack(alignment: .bottomLeading) {
ZStack { KFImage(URL(string: item.imageUrl.isEmpty ? "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/main/assets/banner2.png" : item.imageUrl))
KFImage(URL(string: item.imageUrl.isEmpty .placeholder {
? "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/main/assets/banner2.png" RoundedRectangle(cornerRadius: 10)
: item.imageUrl)) .fill(Color.gray.opacity(0.3))
.placeholder { .frame(width: 280, height: 157.03)
RoundedRectangle(cornerRadius: 10) .shimmering()
.fill(Color.gray.opacity(0.3)) }
.frame(width: 240, height: 135) .resizable()
.shimmering() .aspectRatio(16/9, contentMode: .fill)
} .frame(width: 280, height: 157.03)
.setProcessor(RoundCornerImageProcessor(cornerRadius: 10)) .cornerRadius(10)
.resizable() .clipped()
.aspectRatio(16/9, contentMode: .fill) .overlay(
.frame(width: 240, height: 135) ZStack {
.cornerRadius(10) ProgressiveBlurView()
.clipped() .cornerRadius(10, corners: [.bottomLeft, .bottomRight])
.overlay(
Group { VStack(alignment: .leading, spacing: 4) {
if item.streamUrl.hasPrefix("file://") { Spacer()
Image(systemName: "arrow.down.app.fill") Text(item.mediaTitle)
.resizable() .font(.headline)
.scaledToFit() .foregroundColor(.white)
.frame(width: 24, height: 24) .lineLimit(1)
.foregroundColor(.white)
.background(Color.black.cornerRadius(6)) // black exactly 24×24 HStack {
.padding(4) // add spacing outside the black Text("Episode \(item.episodeNumber)")
} else { .font(.subheadline)
KFImage(URL(string: item.module.metadata.iconUrl)) .foregroundColor(.white.opacity(0.9))
.resizable()
.frame(width: 24, height: 24) Spacer()
.cornerRadius(4)
.padding(4) Text("\(Int(item.progress * 100))% seen")
.font(.caption)
.foregroundColor(.white.opacity(0.9))
} }
}, }
alignment: .topLeading .padding(10)
) .background(
} LinearGradient(
.overlay( colors: [
ZStack { .black.opacity(0.7),
Rectangle() .black.opacity(0.0)
.fill(Color.black.opacity(0.3)) ],
.blur(radius: 3) startPoint: .bottom,
.frame(height: 30) endPoint: .top
)
ProgressView(value: currentProgress) .clipped()
.progressViewStyle(LinearProgressViewStyle(tint: .white)) .cornerRadius(10, corners: [.bottomLeft, .bottomRight])
.padding(.horizontal, 8) .shadow(color: .black.opacity(0.3), radius: 3, x: 0, y: 1)
.scaleEffect(x: 1, y: 1.5, anchor: .center) )
}, },
alignment: .bottom alignment: .bottom
) )
.overlay(
VStack(alignment: .leading) { ZStack {
Text("Episode \(item.episodeNumber)") if item.streamUrl.hasPrefix("file://") {
.font(.caption) Image(systemName: "arrow.down.app.fill")
.lineLimit(1) .resizable()
.foregroundColor(.secondary) .scaledToFit()
.frame(width: 24, height: 24)
Text(item.mediaTitle) .foregroundColor(.white)
.font(.caption) .background(Color.black.cornerRadius(6))
.lineLimit(2) .padding(8)
.foregroundColor(.primary) } else {
.multilineTextAlignment(.leading) 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 { .contextMenu {
Button(action: { markAsWatched() }) { Button(action: {
markAsWatched()
}) {
Label("Mark as Watched", systemImage: "checkmark.circle") Label("Mark as Watched", systemImage: "checkmark.circle")
} }
Button(role: .destructive, action: { removeItem() }) { Button(role: .destructive, action: {
removeItem()
}) {
Label("Remove Item", systemImage: "trash") Label("Remove Item", systemImage: "trash")
} }
} }
.onAppear { .onAppear {
updateProgress() updateProgress()
} }
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in .onReceive(NotificationCenter.default.publisher(
updateProgress() for: UIApplication.didBecomeActiveNotification)) {
} _ in
updateProgress()
}
} }
private func updateProgress() { private func updateProgress() {
let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(item.fullUrl)") let lastPlayed = UserDefaults.standard.double(forKey: "lastPlayedTime_\(item.fullUrl)")
let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(item.fullUrl)") let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(item.fullUrl)")
let ratio: Double
if totalTime > 0 { if totalTime > 0 {
let ratio = lastPlayedTime / totalTime ratio = min(max(lastPlayed / totalTime, 0), 1)
currentProgress = max(0, min(ratio, 1))
} else { } 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 Kingfisher
import AVFoundation import AVFoundation
struct EpisodeLink: Identifiable {
let id = UUID()
let number: Int
let href: String
}
struct EpisodeCell: View { struct EpisodeCell: View {
let episodeIndex: Int let episodeIndex: Int
let episode: String let episode: String
@ -50,6 +44,10 @@ struct EpisodeCell: View {
@State private var lastLoggedStatus: EpisodeDownloadStatus? @State private var lastLoggedStatus: EpisodeDownloadStatus?
@State private var downloadAnimationScale: CGFloat = 1.0 @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 @State private var retryAttempts: Int = 0
private let maxRetryAttempts: Int = 3 private let maxRetryAttempts: Int = 3
private let initialBackoffDelay: TimeInterval = 1.0 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, init(episodeIndex: Int, episode: String, episodeID: Int, progress: Double,
itemID: Int, totalEpisodes: Int? = nil, defaultBannerImage: String = "", itemID: Int, totalEpisodes: Int? = nil, defaultBannerImage: String = "",
module: ScrapingModule, parentTitle: String, showPosterURL: String? = nil, module: ScrapingModule, parentTitle: String, showPosterURL: String? = nil,
isMultiSelectMode: Bool = false, isSelected: Bool = false, isMultiSelectMode: Bool = false, isSelected: Bool = false,
onSelectionChanged: ((Bool) -> Void)? = nil, 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.episodeIndex = episodeIndex
self.episode = episode self.episode = episode
self.episodeID = episodeID self.episodeID = episodeID
@ -101,28 +105,123 @@ struct EpisodeCell: View {
self.onSelectionChanged = onSelectionChanged self.onSelectionChanged = onSelectionChanged
self.onTap = onTap self.onTap = onTap
self.onMarkAllPrevious = onMarkAllPrevious self.onMarkAllPrevious = onMarkAllPrevious
self.tmdbID = tmdbID
self.seasonNumber = seasonNumber
} }
var body: some View { var body: some View {
HStack { ZStack {
episodeThumbnail HStack {
episodeInfo Spacer()
Spacer() actionButtons
CircularProgressBar(progress: currentProgress) }
.frame(width: 40, height: 40) .zIndex(0)
.padding(.trailing, 8)
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()) .onTapGesture {
.background(isMultiSelectMode && isSelected ? Color.accentColor.opacity(0.1) : Color.clear) if isShowingActions {
.cornerRadius(8) withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
.contextMenu { swipeOffset = 0
contextMenuContent isShowingActions = false
}
} else if isMultiSelectMode {
onSelectionChanged?(!isSelected)
} else {
let imageUrl = episodeImageUrl.isEmpty ? defaultBannerImage : episodeImageUrl
onTap(imageUrl)
}
} }
.onAppear { .onAppear {
updateProgress() updateProgress()
updateDownloadStatus() updateDownloadStatus()
if UserDefaults.standard.string(forKey: "metadataProviders") ?? "TMDB" == "TMDB" {
if let type = module.metadata.type?.lowercased(), type == "anime" { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
fetchTMDBEpisodeImage()
}
} else {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
fetchAnimeEpisodeDetails() fetchAnimeEpisodeDetails()
} }
@ -139,6 +238,12 @@ struct EpisodeCell: View {
.onChange(of: progress) { _ in .onChange(of: progress) { _ in
updateProgress() updateProgress()
} }
.onChange(of: itemID) { newID in
loadedFromCache = false
isLoading = true
retryAttempts = maxRetryAttempts
fetchEpisodeDetails()
}
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("downloadProgressChanged"))) { _ in .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("downloadProgressChanged"))) { _ in
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
updateDownloadStatus() updateDownloadStatus()
@ -151,21 +256,8 @@ struct EpisodeCell: View {
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("downloadCompleted"))) { _ in .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("downloadCompleted"))) { _ in
updateDownloadStatus() updateDownloadStatus()
} }
.onTapGesture { .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("episodeProgressChanged"))) { _ in
if isMultiSelectMode { updateProgress()
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)")?")
} }
} }
@ -176,7 +268,6 @@ struct EpisodeCell: View {
.onFailure { error in .onFailure { error in
Logger.shared.log("Failed to load episode image: \(error)", type: "Error") Logger.shared.log("Failed to load episode image: \(error)", type: "Error")
} }
.cacheMemoryOnly(!KingfisherCacheManager.shared.isCachingEnabled)
.resizable() .resizable()
.aspectRatio(16/9, contentMode: .fill) .aspectRatio(16/9, contentMode: .fill)
.frame(width: 100, height: 56) .frame(width: 100, height: 56)
@ -385,38 +476,136 @@ struct EpisodeCell: View {
return 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]" { 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) tryNextDownloadMethod(methodIndex: methodIndex + 1, downloadID: downloadID, softsub: softsub)
return return
} }
print("[Download] Method #\(methodIndex+1) returned valid stream URL: \(streams[0])") if streams.count > 1 {
showDownloadStreamSelectionAlert(streams: streams, downloadID: downloadID, subtitleURL: result.subtitles?.first)
let subtitleURL = result.subtitles?.first.flatMap { URL(string: $0) } return
if let subtitleURL = subtitleURL { } else if let url = URL(string: streams[0]) {
print("[Download] Found subtitle URL: \(subtitleURL.absoluteString)") 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) { private func startActualDownload(url: URL, streamUrl: String, downloadID: UUID, subtitleURL: URL? = nil) {
@ -523,25 +712,6 @@ struct EpisodeCell: View {
} }
private func fetchEpisodeDetails() { 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() fetchAnimeEpisodeDetails()
} }
@ -618,22 +788,6 @@ struct EpisodeCell: View {
Logger.shared.log("Episode \(episodeKey) missing fields: \(missingFields.joined(separator: ", "))", type: "Warning") 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 { DispatchQueue.main.async {
self.isLoading = false self.isLoading = false
self.retryAttempts = 0 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 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 { struct SettingsViewData: View {
@State private var showEraseAppDataAlert = false @State private var showAlert = false
@State private var showRemoveDocumentsAlert = false
@State private var showSizeAlert = false
@State private var cacheSizeText: String = "Calculating..." @State private var cacheSizeText: String = "Calculating..."
@State private var isCalculatingSize: Bool = false @State private var isCalculatingSize: Bool = false
@State private var cacheSize: Int64 = 0 @State private var cacheSize: Int64 = 0
@State private var documentsSize: Int64 = 0 @State private var documentsSize: Int64 = 0
@State private var movPkgSize: Int64 = 0
@State private var showRemoveMovPkgAlert = false
// State bindings for cache settings enum ActiveAlert {
@State private var isMetadataCachingEnabled: Bool = true case eraseData, removeDocs
@State private var isImageCachingEnabled: Bool = true }
@State private var isMemoryOnlyMode: Bool = false
@State private var activeAlert: ActiveAlert = .eraseData
var body: some View { var body: some View {
Form { return ScrollView {
// New section for cache settings VStack(spacing: 24) {
Section(header: Text("Cache Settings"), footer: Text("Caching helps reduce network usage and load content faster. You can disable it to save storage space.")) { SettingsSection(
Toggle("Enable Metadata Caching", isOn: $isMetadataCachingEnabled) title: "Cache",
.onChange(of: isMetadataCachingEnabled) { newValue in footer: "Caching helps reduce network usage and load content faster. You can disable it to save storage space."
MetadataCacheManager.shared.isCachingEnabled = newValue ) {
if !newValue { HStack {
calculateCacheSize() 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)
} }
.padding(.horizontal, 16)
Toggle("Enable Image Caching", isOn: $isImageCachingEnabled) .padding(.vertical, 12)
.onChange(of: isImageCachingEnabled) { newValue in
KingfisherCacheManager.shared.isCachingEnabled = newValue Divider().padding(.horizontal, 16)
if !newValue {
calculateCacheSize() Button(action: clearAllCaches) {
} Text("Clear All Caches")
.foregroundColor(.red)
} }
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
if isMetadataCachingEnabled { SettingsSection(
Toggle("Memory-Only Mode", isOn: $isMemoryOnlyMode) title: "App Storage",
.onChange(of: isMemoryOnlyMode) { newValue in 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 "
MetadataCacheManager.shared.isMemoryOnlyMode = newValue ) {
if newValue { VStack(spacing: 0) {
// Clear disk cache when switching to memory-only SettingsButtonRow(
MetadataCacheManager.shared.clearAllCache() icon: "doc.text",
calculateCacheSize() title: "Remove All Files in Documents",
subtitle: formatSize(documentsSize),
action: {
activeAlert = .removeDocs
showAlert = true
} }
} )
} Divider().padding(.horizontal, 16)
HStack { SettingsButtonRow(
Text("Current Cache Size") icon: "exclamationmark.triangle",
Spacer() title: "Erase all App Data",
if isCalculatingSize { action: {
ProgressView() activeAlert = .eraseData
.scaleEffect(0.7) showAlert = true
.padding(.trailing, 5) }
)
} }
Text(cacheSizeText)
.foregroundColor(.secondary)
}
Button(action: clearAllCaches) {
Text("Clear All Caches")
.foregroundColor(.red)
} }
} }
.scrollViewBottomPadding()
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")) { .navigationTitle("App Data")
HStack { .onAppear {
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")
calculateCacheSize() calculateCacheSize()
updateSizes() updateSizes()
} }
} catch { .alert(isPresented: $showAlert) {
Logger.shared.log("Failed to clear cache.", type: "Error") switch activeAlert {
} case .eraseData:
} return Alert(
title: Text("Erase App Data"),
func removeAllFilesInDocuments() { message: Text("Are you sure you want to erase all app data? This action cannot be undone."),
let fileManager = FileManager.default primaryButton: .destructive(Text("Erase")) {
if let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first { eraseAppData()
do { },
let fileURLs = try fileManager.contentsOfDirectory(at: documentsURL, includingPropertiesForKeys: nil) secondaryButton: .cancel()
for fileURL in fileURLs { )
try fileManager.removeItem(at: fileURL) 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 calculateCacheSize() {
func removeMovPkgFiles() { isCalculatingSize = true
let fileManager = FileManager.default cacheSizeText = "Calculating..."
if let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first {
do { DispatchQueue.global(qos: .background).async {
let fileURLs = try fileManager.contentsOfDirectory(at: documentsURL, includingPropertiesForKeys: nil) if let cacheURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first {
for fileURL in fileURLs { let size = calculateDirectorySize(for: cacheURL)
if fileURL.pathExtension == "movpkg" { DispatchQueue.main.async {
try fileManager.removeItem(at: fileURL) 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 { func updateSizes() {
let contents = try fileManager.contentsOfDirectory(at: url, includingPropertiesForKeys: [.fileSizeKey]) DispatchQueue.global(qos: .background).async {
for url in contents { if let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first {
let resourceValues = try url.resourceValues(forKeys: [.fileSizeKey, .isDirectoryKey]) let size = calculateDirectorySize(for: documentsURL)
if resourceValues.isDirectory == true { DispatchQueue.main.async {
totalSize += calculateDirectorySize(for: url) self.documentsSize = size
} else { }
totalSize += Int64(resourceValues.fileSize ?? 0)
} }
} }
} catch {
Logger.shared.log("Error calculating directory size: \(error)", type: "Error")
} }
return totalSize func clearAllCaches() {
} clearCache()
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)
} }
if let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first { func clearCache() {
documentsSize = calculateDirectorySize(for: documentsURL) let cacheURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
movPkgSize = calculateMovPkgSize(in: documentsURL) do {
} if let cacheURL = cacheURL {
} let filePaths = try FileManager.default.contentsOfDirectory(at: cacheURL, includingPropertiesForKeys: nil, options: [])
for filePath in filePaths {
private func calculateMovPkgSize(in url: URL) -> Int64 { try FileManager.default.removeItem(at: filePath)
let fileManager = FileManager.default }
var totalSize: Int64 = 0 Logger.shared.log("Cache cleared successfully!", type: "General")
calculateCacheSize()
do { updateSizes()
let contents = try fileManager.contentsOfDirectory(at: url, includingPropertiesForKeys: [.fileSizeKey]) }
for url in contents where url.pathExtension == "movpkg" { } catch {
let resourceValues = try url.resourceValues(forKeys: [.fileSizeKey]) Logger.shared.log("Failed to clear cache.", type: "Error")
totalSize += Int64(resourceValues.fileSize ?? 0)
} }
} 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 SwiftUI
import Drops 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 { struct SettingsViewDownloads: View {
@EnvironmentObject private var jsController: JSController @EnvironmentObject private var jsController: JSController
@AppStorage(DownloadQualityPreference.userDefaultsKey) @AppStorage(DownloadQualityPreference.userDefaultsKey)
@ -20,94 +161,168 @@ struct SettingsViewDownloads: View {
@State private var isCalculating: Bool = false @State private var isCalculating: Bool = false
var body: some View { var body: some View {
Form { ScrollView {
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.")) { VStack(spacing: 24) {
Picker("Quality", selection: $downloadQuality) { SettingsSection(
ForEach(DownloadQualityPreference.allCases, id: \.rawValue) { option in title: "Download Settings",
Text(option.rawValue) footer: "Max concurrent downloads controls how many episodes can download simultaneously. Higher values may use more bandwidth and device resources."
.tag(option.rawValue) ) {
} SettingsPickerRow(
} icon: "4k.tv",
.onChange(of: downloadQuality) { newValue in title: "Quality",
print("Download quality preference changed to: \(newValue)") options: DownloadQualityPreference.allCases.map { $0.rawValue },
} optionToString: { $0 },
selection: $downloadQuality
HStack { )
Text("Max Concurrent Downloads")
Spacer() VStack(spacing: 0) {
Stepper("\(maxConcurrentDownloads)", value: $maxConcurrentDownloads, in: 1...10) HStack {
.onChange(of: maxConcurrentDownloads) { newValue in Image(systemName: "arrow.down.circle")
jsController.updateMaxConcurrentDownloads(newValue) .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)
}
} }
} .padding(.horizontal, 16)
.padding(.vertical, 12)
Toggle("Allow Cellular Downloads", isOn: $allowCellularDownloads)
.tint(.accentColor) Divider()
} .padding(.horizontal, 16)
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)
} }
Text(formatFileSize(totalStorageSize)) SettingsToggleRow(
.foregroundColor(.secondary) icon: "antenna.radiowaves.left.and.right",
title: "Allow Cellular Downloads",
isOn: $allowCellularDownloads,
showDivider: false
)
} }
HStack { SettingsSection(
Text("Files Downloaded") title: "Quality Information"
Spacer() ) {
Text("\(existingDownloadCount) of \(jsController.savedAssets.count)") if let preferenceDescription = DownloadQualityPreference(rawValue: downloadQuality)?.description {
.foregroundColor(.secondary) HStack {
} Text(preferenceDescription)
.font(.caption)
Button(action: { .foregroundStyle(.secondary)
calculateTotalStorage() Spacer()
}) { }
HStack { .padding(.horizontal, 16)
Image(systemName: "arrow.clockwise") .padding(.vertical, 12)
Text("Refresh Storage Info")
} }
} }
Button(action: { SettingsSection(
showClearConfirmation = true title: "Storage Management"
}) { ) {
HStack { VStack(spacing: 0) {
Image(systemName: "trash") HStack {
.foregroundColor(.red) Image(systemName: "externaldrive")
Text("Clear All Downloads") .frame(width: 24, height: 24)
.foregroundColor(.red) .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") .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 { .onAppear {
calculateTotalStorage() calculateTotalStorage()
jsController.updateMaxConcurrentDownloads(maxConcurrentDownloads) jsController.updateMaxConcurrentDownloads(maxConcurrentDownloads)

View file

@ -7,106 +7,310 @@
import SwiftUI import SwiftUI
struct SettingsViewGeneral: View { fileprivate struct SettingsSection<Content: View>: View {
@AppStorage("episodeChunkSize") private var episodeChunkSize: Int = 100 let title: String
@AppStorage("refreshModulesOnLaunch") private var refreshModulesOnLaunch: Bool = false let footer: String?
@AppStorage("fetchEpisodeMetadata") private var fetchEpisodeMetadata: Bool = true let content: Content
@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"
private let metadataProvidersList = ["AniList"] init(title: String, footer: String? = nil, @ViewBuilder content: () -> Content) {
private let sortOrderOptions = ["Ascending", "Descending"] self.title = title
@EnvironmentObject var settings: Settings self.footer = footer
self.content = content()
}
var body: some View { var body: some View {
Form { VStack(alignment: .leading, spacing: 4) {
Section(header: Text("Interface")) { Text(title.uppercased())
ColorPicker("Accent Color", selection: $settings.accentColor) .font(.footnote)
HStack { .foregroundStyle(.gray)
Text("Appearance") .padding(.horizontal, 20)
Picker("Appearance", selection: $settings.selectedAppearance) {
Text("System").tag(Appearance.system)
Text("Light").tag(Appearance.light)
Text("Dark").tag(Appearance.dark)
}
.pickerStyle(SegmentedPickerStyle())
}
}
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.")) { VStack(spacing: 0) {
content
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)
}
}
}
}
} }
.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.")) { if let footer = footer {
HStack { Text(footer)
if UIDevice.current.userInterfaceIdiom == .pad { .font(.footnote)
Picker("Portrait Columns", selection: $mediaColumnsPortrait) { .foregroundStyle(.gray)
ForEach(1..<6) { i in Text("\(i)").tag(i) } .padding(.horizontal, 20)
} .padding(.top, 4)
.pickerStyle(MenuPickerStyle()) }
} else { }
Picker("Portrait Columns", selection: $mediaColumnsPortrait) { }
ForEach(1..<5) { i in Text("\(i)").tag(i) } }
}
.pickerStyle(MenuPickerStyle()) fileprivate struct SettingsToggleRow: View {
} let icon: String
} let title: String
HStack { @Binding var isOn: Bool
if UIDevice.current.userInterfaceIdiom == .pad { var showDivider: Bool = true
Picker("Landscape Columns", selection: $mediaColumnsLandscape) {
ForEach(2..<9) { i in Text("\(i)").tag(i) } init(icon: String, title: String, isOn: Binding<Bool>, showDivider: Bool = true) {
} self.icon = icon
.pickerStyle(MenuPickerStyle()) self.title = title
} else { self._isOn = isOn
Picker("Landscape Columns", selection: $mediaColumnsLandscape) { self.showDivider = showDivider
ForEach(2..<6) { i in Text("\(i)").tag(i) } }
}
.pickerStyle(MenuPickerStyle()) var body: some View {
} VStack(spacing: 0) {
} HStack {
} Image(systemName: icon)
.frame(width: 24, height: 24)
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.")) { .foregroundStyle(.primary)
Toggle("Refresh Modules on Launch", isOn: $refreshModulesOnLaunch)
.tint(.accentColor) Text(title)
} .foregroundStyle(.primary)
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.")) { Spacer()
Toggle("Enable Analytics", isOn: $analyticsEnabled)
.tint(.accentColor) 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 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 { struct SettingsViewLogger: View {
@State private var logs: String = "" @State private var logs: String = ""
@State private var isLoading: Bool = true
@State private var showFullLogs: Bool = false
@StateObject private var filterViewModel = LogFilterViewModel.shared @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 { var body: some View {
VStack { ScrollView {
ScrollView { VStack(spacing: 24) {
Text(logs) SettingsSection(title: "Logs") {
.font(.footnote) if isLoading {
.foregroundColor(.secondary) HStack {
.frame(maxWidth: .infinity, alignment: .leading) ProgressView()
.padding() .scaleEffect(0.8)
.textSelection(.enabled) Text("Loading logs...")
} .font(.footnote)
.navigationTitle("Logs") .foregroundColor(.secondary)
.onAppear { }
logs = Logger.shared.getLogs() .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 { .toolbar {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
@ -37,8 +129,7 @@ struct SettingsViewLogger: View {
Label("Copy to Clipboard", systemImage: "doc.on.doc") Label("Copy to Clipboard", systemImage: "doc.on.doc")
} }
Button(role: .destructive, action: { Button(role: .destructive, action: {
Logger.shared.clearLogs() clearLogsAsync()
logs = Logger.shared.getLogs()
}) { }) {
Label("Clear Logs", systemImage: "trash") 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 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 { struct LogFilter: Identifiable, Hashable {
let id = UUID() let id = UUID()
let type: String let type: String
@ -74,14 +164,33 @@ class LogFilterViewModel: ObservableObject {
struct SettingsViewLoggerFilter: View { struct SettingsViewLoggerFilter: View {
@ObservedObject var viewModel = LogFilterViewModel.shared @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 { var body: some View {
List { ScrollView {
ForEach($viewModel.filters) { $filter in VStack(spacing: 24) {
VStack(alignment: .leading, spacing: 0) { SettingsSection(title: "Log Types") {
Toggle(filter.type, isOn: $filter.isEnabled) ForEach($viewModel.filters) { $filter in
.tint(.accentColor) SettingsToggleRow(
icon: iconForFilter(filter.type),
title: filter.type,
isOn: $filter.isEnabled,
showDivider: viewModel.filters.last?.id != filter.id
)
}
} }
} }
.padding(.vertical, 20)
} }
.navigationTitle("Log Filters") .navigationTitle("Log Filters")
} }

View file

@ -8,6 +8,137 @@
import SwiftUI import SwiftUI
import Kingfisher 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 { struct SettingsViewModule: View {
@AppStorage("selectedModuleId") private var selectedModuleId: String? @AppStorage("selectedModuleId") private var selectedModuleId: String?
@EnvironmentObject var moduleManager: ModuleManager @EnvironmentObject var moduleManager: ModuleManager
@ -21,142 +152,101 @@ struct SettingsViewModule: View {
@State private var showLibrary = false @State private var showLibrary = false
var body: some View { var body: some View {
VStack { ScrollView {
Form { VStack(spacing: 24) {
if moduleManager.modules.isEmpty { if moduleManager.modules.isEmpty {
VStack(spacing: 8) { SettingsSection(title: "Modules") {
Image(systemName: "plus.app") VStack(spacing: 16) {
.font(.largeTitle) Image(systemName: "plus.app")
.foregroundColor(.secondary) .font(.largeTitle)
Text("No Modules") .foregroundColor(.secondary)
.font(.headline) Text("No Modules")
.font(.headline)
if didReceiveDefaultPageLink { if didReceiveDefaultPageLink {
NavigationLink(destination: CommunityLibraryView() NavigationLink(destination: CommunityLibraryView()
.environmentObject(moduleManager)) { .environmentObject(moduleManager)) {
Text("Check out some community modules here!") 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) .font(.caption)
.foregroundColor(.accentColor) .foregroundColor(.secondary)
.frame(maxWidth: .infinity) .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 { } else {
ForEach(moduleManager.modules) { module in SettingsSection(title: "Installed Modules") {
HStack { ForEach(moduleManager.modules) { module in
KFImage(URL(string: module.metadata.iconUrl)) ModuleListItemView(
.resizable() module: module,
.frame(width: 50, height: 50) selectedModuleId: selectedModuleId,
.clipShape(Circle()) onDelete: {
.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 {
moduleManager.deleteModule(module) moduleManager.deleteModule(module)
DropManager.shared.showDrop(title: "Module Removed", subtitle: "", duration: 1.0, icon: UIImage(systemName: "trash")) DropManager.shared.showDrop(title: "Module Removed", subtitle: "", duration: 1.0, icon: UIImage(systemName: "trash"))
},
onSelect: {
selectedModuleId = module.id.uuidString
} }
} label: { )
Label("Delete", systemImage: "trash")
} if module.id != moduleManager.modules.last?.id {
.disabled(selectedModuleId == module.id.uuidString) Divider()
} .padding(.horizontal, 16)
.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")
}
} }
} }
} }
} }
} }
.navigationTitle("Modules") .padding(.vertical, 20)
.navigationBarItems(trailing: }
HStack(spacing: 16) { .scrollViewBottomPadding()
if didReceiveDefaultPageLink && !moduleManager.modules.isEmpty { .navigationTitle("Modules")
Button(action: { .navigationBarItems(trailing:
showLibrary = true HStack(spacing: 16) {
}) { if didReceiveDefaultPageLink && !moduleManager.modules.isEmpty {
Image(systemName: "books.vertical.fill")
.resizable()
.frame(width: 20, height: 20)
.padding(5)
}
.accessibilityLabel("Open Community Library")
}
Button(action: { Button(action: {
showAddModuleAlert() showLibrary = true
}) { }) {
Image(systemName: "plus") Image(systemName: "books.vertical.fill")
.resizable() .resizable()
.frame(width: 20, height: 20) .frame(width: 20, height: 20)
.padding(5) .padding(5)
} }
.accessibilityLabel("Add Module") .accessibilityLabel("Open Community Library")
} }
)
.background( Button(action: {
NavigationLink( showAddModuleAlert()
destination: CommunityLibraryView() }) {
.environmentObject(moduleManager), Image(systemName: "plus")
isActive: $showLibrary .resizable()
) { EmptyView() } .frame(width: 20, height: 20)
) .padding(5)
.refreshable {
isRefreshing = true
refreshTask?.cancel()
refreshTask = Task {
await moduleManager.refreshModules()
isRefreshing = false
} }
.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 { .onAppear {

View file

@ -7,6 +7,191 @@
import SwiftUI 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 { struct SettingsViewPlayer: View {
@AppStorage("externalPlayer") private var externalPlayer: String = "Sora" @AppStorage("externalPlayer") private var externalPlayer: String = "Sora"
@AppStorage("alwaysLandscape") private var isAlwaysLandscape = false @AppStorage("alwaysLandscape") private var isAlwaysLandscape = false
@ -18,107 +203,134 @@ struct SettingsViewPlayer: View {
@AppStorage("skip85Visible") private var skip85Visible: Bool = true @AppStorage("skip85Visible") private var skip85Visible: Bool = true
@AppStorage("doubleTapSeekEnabled") private var doubleTapSeekEnabled: Bool = false @AppStorage("doubleTapSeekEnabled") private var doubleTapSeekEnabled: Bool = false
@AppStorage("skipIntroOutroVisible") private var skipIntroOutroVisible: Bool = true @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 { var body: some View {
Form { ScrollView {
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.")) { VStack(spacing: 24) {
HStack { SettingsSection(
Text("Media Player") title: "Media Player",
Spacer() footer: "Some features are limited to the Sora and Default player, such as ForceLandscape, holdSpeed and custom time skip increments."
Menu(externalPlayer) { ) {
Menu("In-App Players") { SettingsPickerRow(
ForEach(mediaPlayers.prefix(2), id: \.self) { player in icon: "play.circle",
Button(action: { title: "Media Player",
externalPlayer = player options: mediaPlayers,
}) { optionToString: { $0 },
Text(player) selection: $externalPlayer
} )
}
}
Menu("External Players") { SettingsToggleRow(
ForEach(mediaPlayers.dropFirst(2), id: \.self) { player in icon: "rotate.right",
Button(action: { title: "Force Landscape",
externalPlayer = player isOn: $isAlwaysLandscape
}) { )
Text(player)
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")
} }
} }
} ))
} .padding(.horizontal, 16)
} .padding(.vertical, 12)
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)
} }
HStack { SettingsSection(
Text("Long press Skip:") title: "Skip Settings",
Spacer() footer: "Double tapping the screen on it's sides will skip with the short tap setting."
Stepper("\(Int(skipIncrementHold))s", value: $skipIncrementHold, in: 5...300, step: 5) ) {
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) SubtitleSettingsSection()
.tint(.accentColor)
Toggle("Show Skip 85s Button", isOn: $skip85Visible)
.tint(.accentColor)
Toggle("Show Skip Intro / Outro Buttons", isOn: $skipIntroOutroVisible)
.tint(.accentColor)
} }
SubtitleSettingsSection() .padding(.vertical, 20)
} }
.scrollViewBottomPadding()
.navigationTitle("Player") .navigationTitle("Player")
} }
} }
@ -128,97 +340,78 @@ struct SubtitleSettingsSection: View {
@State private var fontSize: Double = SubtitleSettingsManager.shared.settings.fontSize @State private var fontSize: Double = SubtitleSettingsManager.shared.settings.fontSize
@State private var shadowRadius: Double = SubtitleSettingsManager.shared.settings.shadowRadius @State private var shadowRadius: Double = SubtitleSettingsManager.shared.settings.shadowRadius
@State private var backgroundEnabled: Bool = SubtitleSettingsManager.shared.settings.backgroundEnabled @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 @State private var subtitleDelay: Double = SubtitleSettingsManager.shared.settings.subtitleDelay
private let colors = ["white", "yellow", "green", "blue", "red", "purple"] private let colors = ["white", "yellow", "green", "blue", "red", "purple"]
private let shadowOptions = [0, 1, 3, 6] private let shadowOptions = [0, 1, 3, 6]
var body: some View { var body: some View {
Section(header: Text("Subtitle Settings")) { SettingsSection(title: "Subtitle Settings") {
HStack { SettingsPickerRow(
Text("Subtitle Color") icon: "paintbrush",
Spacer() title: "Subtitle Color",
Menu(foregroundColor) { options: colors,
ForEach(colors, id: \.self) { color in optionToString: { $0.capitalized },
Button(action: { selection: $foregroundColor
foregroundColor = color )
SubtitleSettingsManager.shared.update { settings in .onChange(of: foregroundColor) { newValue in
settings.foregroundColor = color SubtitleSettingsManager.shared.update { settings in
} settings.foregroundColor = newValue
}) {
Text(color.capitalized)
}
}
} }
} }
HStack { SettingsPickerRow(
Text("Shadow") icon: "shadow",
Spacer() title: "Shadow",
Menu("\(Int(shadowRadius))") { options: shadowOptions,
ForEach(shadowOptions, id: \.self) { option in optionToString: { "\($0)" },
Button(action: { selection: Binding(
shadowRadius = Double(option) get: { Int(shadowRadius) },
SubtitleSettingsManager.shared.update { settings in set: { shadowRadius = Double($0) }
settings.shadowRadius = Double(option) )
} )
}) { .onChange(of: shadowRadius) { newValue in
Text("\(option)") SubtitleSettingsManager.shared.update { settings in
} settings.shadowRadius = newValue
}
} }
} }
Toggle("Background Enabled", isOn: $backgroundEnabled) SettingsToggleRow(
.tint(.accentColor) icon: "rectangle.fill",
.onChange(of: backgroundEnabled) { newValue in title: "Background Enabled",
SubtitleSettingsManager.shared.update { settings in isOn: $backgroundEnabled
settings.backgroundEnabled = newValue )
} .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 { SettingsStepperRow(
Text("Bottom Padding:") icon: "textformat.size",
Spacer() title: "Font Size",
Stepper("\(Int(bottomPadding))", value: $bottomPadding, in: 0...50, step: 1) value: $fontSize,
.onChange(of: bottomPadding) { newValue in range: 12...36,
SubtitleSettingsManager.shared.update { settings in step: 1
settings.bottomPadding = newValue )
} .onChange(of: fontSize) { newValue in
} SubtitleSettingsManager.shared.update { settings in
settings.fontSize = newValue
}
} }
VStack(alignment: .leading) { SettingsStepperRow(
Text("Subtitle Delay: \(String(format: "%.1fs", subtitleDelay))") icon: "arrow.up.and.down",
.padding(.bottom, 1) title: "Bottom Padding",
value: $bottomPadding,
HStack { range: 0...50,
Text("-10s") step: 1,
.font(.system(size: 12)) showDivider: false
.foregroundColor(.secondary) )
.onChange(of: bottomPadding) { newValue in
Slider(value: $subtitleDelay, in: -10...10, step: 0.1) SubtitleSettingsManager.shared.update { settings in
.onChange(of: subtitleDelay) { newValue in settings.bottomPadding = CGFloat(newValue)
SubtitleSettingsManager.shared.update { settings in
settings.subtitleDelay = newValue
}
}
Text("+10s")
.font(.system(size: 12))
.foregroundColor(.secondary)
} }
} }
} }

View file

@ -9,6 +9,97 @@ import SwiftUI
import Security import Security
import Kingfisher 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 { struct SettingsViewTrackers: View {
@AppStorage("sendPushUpdates") private var isSendPushUpdates = true @AppStorage("sendPushUpdates") private var isSendPushUpdates = true
@State private var anilistStatus: String = "You are not logged in" @State private var anilistStatus: String = "You are not logged in"
@ -24,101 +115,188 @@ struct SettingsViewTrackers: View {
@State private var isTraktLoading: Bool = false @State private var isTraktLoading: Bool = false
var body: some View { var body: some View {
Form { ScrollView {
Section(header: Text("AniList")) { VStack(spacing: 24) {
HStack() { SettingsSection(title: "AniList") {
KFImage(URL(string: "https://raw.githubusercontent.com/cranci1/Ryu/2f10226aa087154974a70c1ec78aa83a47daced9/Ryu/Assets.xcassets/Listing/Anilist.imageset/anilist.png")) VStack(spacing: 0) {
.placeholder { HStack(alignment: .center, spacing: 10) {
RoundedRectangle(cornerRadius: 10) KFImage(URL(string: "https://raw.githubusercontent.com/cranci1/Ryu/2f10226aa087154974a70c1ec78aa83a47daced9/Ryu/Assets.xcassets/Listing/Anilist.imageset/anilist.png"))
.fill(Color.gray.opacity(0.3)) .placeholder {
.frame(width: 80, height: 80) RoundedRectangle(cornerRadius: 10)
.shimmering() .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() .padding(.horizontal, 16)
.frame(width: 80, height: 80) .padding(.vertical, 12)
.clipShape(Rectangle()) .frame(height: 84)
.cornerRadius(10)
Text("AniList.co") if isAnilistLoggedIn {
.font(.title2) Divider()
} .padding(.horizontal, 16)
if isAnilistLoading { SettingsToggleRow(
ProgressView() icon: "arrow.triangle.2.circlepath",
} else { title: "Sync anime progress",
if isAnilistLoggedIn { isOn: $isSendPushUpdates,
HStack(spacing: 0) { showDivider: false
Text("Logged in as ") )
Text(anilistUsername) }
.foregroundColor(profileColor)
.font(.body) Divider()
.fontWeight(.semibold) .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 { SettingsSection(title: "Trakt") {
Toggle("Sync anime progress", isOn: $isSendPushUpdates) VStack(spacing: 0) {
.tint(.accentColor) HStack(alignment: .center, spacing: 10) {
} KFImage(URL(string: "https://static-00.iconduck.com/assets.00/trakt-icon-2048x2048-2633ksxg.png"))
.placeholder {
Button(isAnilistLoggedIn ? "Log Out from AniList" : "Log In with AniList") { RoundedRectangle(cornerRadius: 10)
if isAnilistLoggedIn { .fill(Color.gray.opacity(0.3))
logoutAniList() .frame(width: 60, height: 60)
} else { .shimmering()
loginAniList() }
.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."
) {}
} }
.padding(.vertical, 20)
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.")) {}
} }
.scrollViewBottomPadding()
.navigationTitle("Trackers") .navigationTitle("Trackers")
.onAppear { .onAppear {
updateAniListStatus() updateAniListStatus()
@ -244,8 +422,8 @@ struct SettingsViewTrackers: View {
guard status == errSecSuccess, guard status == errSecSuccess,
let tokenData = item as? Data, let tokenData = item as? Data,
let token = String(data: tokenData, encoding: .utf8) else { let token = String(data: tokenData, encoding: .utf8) else {
return nil return nil
} }
return token return token
} }

View file

@ -7,96 +7,237 @@
import SwiftUI 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 { struct SettingsView: View {
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "ALPHA"
@Environment(\.colorScheme) var colorScheme
@StateObject var settings = Settings()
var body: some View { var body: some View {
NavigationView { NavigationView {
Form { ScrollView {
Section(header: Text("Main")) { VStack(spacing: 24) {
NavigationLink(destination: SettingsViewGeneral()) { Text("Settings")
Text("General Preferences") .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") VStack(alignment: .leading, spacing: 4) {
} Text("INFOS")
NavigationLink(destination: SettingsViewModule()) { .font(.footnote)
Text("Modules") .foregroundStyle(.gray)
} .padding(.horizontal, 20)
NavigationLink(destination: SettingsViewTrackers()) {
Text("Trackers") 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)
} }
.scrollViewBottomPadding()
Section(header: Text("Info")) { .padding(.bottom, 20)
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")) {}
} }
.navigationTitle("Settings") .deviceScaled()
} }
.navigationViewStyle(StackNavigationViewStyle()) .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 { class Settings: ObservableObject {
@Published var accentColor: Color { @Published var accentColor: Color {
didSet { didSet {
saveAccentColor(accentColor)
} }
} }
@Published var selectedAppearance: Appearance { @Published var selectedAppearance: Appearance {
@ -120,12 +260,7 @@ class Settings: ObservableObject {
} }
init() { init() {
if let colorData = UserDefaults.standard.data(forKey: "accentColor"), self.accentColor = .primary
let uiColor = try? NSKeyedUnarchiver.unarchivedObject(ofClass: UIColor.self, from: colorData) {
self.accentColor = Color(uiColor)
} else {
self.accentColor = .accentColor
}
if let appearanceRawValue = UserDefaults.standard.string(forKey: "selectedAppearance"), if let appearanceRawValue = UserDefaults.standard.string(forKey: "selectedAppearance"),
let appearance = Appearance(rawValue: appearanceRawValue) { let appearance = Appearance(rawValue: appearanceRawValue) {
self.selectedAppearance = appearance self.selectedAppearance = appearance
@ -135,13 +270,20 @@ class Settings: ObservableObject {
updateAppearance() updateAppearance()
} }
private func saveAccentColor(_ color: Color) { func updateAccentColor(currentColorScheme: ColorScheme? = nil) {
let uiColor = UIColor(color) switch selectedAppearance {
do { case .system:
let colorData = try NSKeyedArchiver.archivedData(withRootObject: uiColor, requiringSecureCoding: false) if let scheme = currentColorScheme {
UserDefaults.standard.set(colorData, forKey: "accentColor") accentColor = scheme == .dark ? .white : .black
} catch { } else {
Logger.shared.log("Failed to save accent color: \(error.localizedDescription)") 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 = { objects = {
/* Begin PBXBuildFile section */ /* 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 */; }; 130C6BFA2D53AB1F00DC1432 /* SettingsViewData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 130C6BF92D53AB1F00DC1432 /* SettingsViewData.swift */; };
13103E8B2D58E028000F0673 /* View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13103E8A2D58E028000F0673 /* View.swift */; }; 13103E8B2D58E028000F0673 /* View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13103E8A2D58E028000F0673 /* View.swift */; };
13103E8E2D58E04A000F0673 /* SkeletonCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13103E8D2D58E04A000F0673 /* SkeletonCell.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 */; }; 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 */; }; 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 */; }; 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 */; }; 133D7C6E2D2BE2500075467E /* SoraApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133D7C6D2D2BE2500075467E /* SoraApp.swift */; };
133D7C702D2BE2500075467E /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133D7C6F2D2BE2500075467E /* ContentView.swift */; }; 133D7C702D2BE2500075467E /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133D7C6F2D2BE2500075467E /* ContentView.swift */; };
133D7C722D2BE2520075467E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 133D7C712D2BE2520075467E /* Assets.xcassets */; }; 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 */; }; 136BBE802DB1038000906B5E /* Notification+Name.swift in Sources */ = {isa = PBXBuildFile; fileRef = 136BBE7F2DB1038000906B5E /* Notification+Name.swift */; };
138AA1B82D2D66FD0021F9DF /* EpisodeCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138AA1B62D2D66FD0021F9DF /* EpisodeCell.swift */; }; 138AA1B82D2D66FD0021F9DF /* EpisodeCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138AA1B62D2D66FD0021F9DF /* EpisodeCell.swift */; };
138AA1B92D2D66FD0021F9DF /* CircularProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138AA1B72D2D66FD0021F9DF /* CircularProgressBar.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 */; }; 139935662D468C450065CEFF /* ModuleManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 139935652D468C450065CEFF /* ModuleManager.swift */; };
1399FAD42D3AB38C00E97C31 /* SettingsViewLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1399FAD32D3AB38C00E97C31 /* SettingsViewLogger.swift */; }; 1399FAD42D3AB38C00E97C31 /* SettingsViewLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1399FAD32D3AB38C00E97C31 /* SettingsViewLogger.swift */; };
1399FAD62D3AB3DB00E97C31 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1399FAD52D3AB3DB00E97C31 /* Logger.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 */; }; 13EA2BD62D32D97400C1EBD7 /* Double+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EA2BD32D32D97400C1EBD7 /* Double+Extension.swift */; };
13EA2BD92D32D98400C1EBD7 /* NormalPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EA2BD82D32D98400C1EBD7 /* NormalPlayer.swift */; }; 13EA2BD92D32D98400C1EBD7 /* NormalPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EA2BD82D32D98400C1EBD7 /* NormalPlayer.swift */; };
1E26E9E72DA9577900B9DC02 /* VolumeSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E26E9E62DA9577900B9DC02 /* VolumeSlider.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 */; }; 1E9FF1D32D403E49008AC100 /* SettingsViewLoggerFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */; };
1EAC7A322D888BC50083984D /* MusicProgressSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EAC7A312D888BC50083984D /* MusicProgressSlider.swift */; }; 1EAC7A322D888BC50083984D /* MusicProgressSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EAC7A312D888BC50083984D /* MusicProgressSlider.swift */; };
1EF5C3A92DB988E40032BF07 /* CommunityLib.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EF5C3A82DB988D70032BF07 /* CommunityLib.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 */; }; 7222485F2DCBAA2C00CABE2D /* DownloadModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7222485D2DCBAA2C00CABE2D /* DownloadModels.swift */; };
722248602DCBAA2C00CABE2D /* M3U8StreamExtractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7222485E2DCBAA2C00CABE2D /* M3U8StreamExtractor.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 */; }; 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 */; }; 72443C7F2DC8038300A61321 /* SettingsViewDownloads.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72443C7E2DC8038300A61321 /* SettingsViewDownloads.swift */; };
727220712DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7272206F2DD642B100C2A4A2 /* JSController-StreamTypeDownload.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 */; }; 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 */; }; 73D164D52D8B5B470011A360 /* JavaScriptCore+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXFileReference 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JavaScriptCore+Extensions.swift"; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
@ -176,9 +202,66 @@
/* End PBXFrameworksBuildPhase section */ /* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup 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 */ = { 13103E802D589D6C000F0673 /* Tracking Services */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
138FE1CE2DEC9FFA00936D81 /* TMDB */,
13E62FBF2DABC3A20007E259 /* Trakt */, 13E62FBF2DABC3A20007E259 /* Trakt */,
13103E812D589D77000F0673 /* AniList */, 13103E812D589D77000F0673 /* AniList */,
); );
@ -197,8 +280,8 @@
13103E8C2D58E037000F0673 /* SkeletonCells */ = { 13103E8C2D58E037000F0673 /* SkeletonCells */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
13103E8D2D58E04A000F0673 /* SkeletonCell.swift */,
13B7F4C02D58FFDD0045714A /* Shimmer.swift */, 13B7F4C02D58FFDD0045714A /* Shimmer.swift */,
13103E8D2D58E04A000F0673 /* SkeletonCell.swift */,
); );
path = SkeletonCells; path = SkeletonCells;
sourceTree = "<group>"; sourceTree = "<group>";
@ -230,7 +313,6 @@
133D7C6C2D2BE2500075467E /* Sora */ = { 133D7C6C2D2BE2500075467E /* Sora */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
72AC3A002DD4DAEA00C60B96 /* Managers */,
130C6BF82D53A4C200DC1432 /* Sora.entitlements */, 130C6BF82D53A4C200DC1432 /* Sora.entitlements */,
13DC0C412D2EC9BA00D0F966 /* Info.plist */, 13DC0C412D2EC9BA00D0F966 /* Info.plist */,
13103E802D589D6C000F0673 /* Tracking Services */, 13103E802D589D6C000F0673 /* Tracking Services */,
@ -256,10 +338,10 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
72443C7C2DC8036500A61321 /* DownloadView.swift */, 72443C7C2DC8036500A61321 /* DownloadView.swift */,
0402DA122DE7B5EC003BB42C /* SearchView */,
133D7C7F2D2BE2630075467E /* MediaInfoView */, 133D7C7F2D2BE2630075467E /* MediaInfoView */,
1399FAD22D3AB34F00E97C31 /* SettingsView */, 1399FAD22D3AB34F00E97C31 /* SettingsView */,
133F55B92D33B53E00E08EEA /* LibraryView */, 133F55B92D33B53E00E08EEA /* LibraryView */,
133D7C7C2D2BE2630075467E /* SearchView.swift */,
); );
path = Views; path = Views;
sourceTree = "<group>"; sourceTree = "<group>";
@ -268,6 +350,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
138AA1B52D2D66EC0021F9DF /* EpisodeCell */, 138AA1B52D2D66EC0021F9DF /* EpisodeCell */,
1E47859A2DEBC5960095BF2F /* AnilistMatchPopupView.swift */,
133D7C802D2BE2630075467E /* MediaInfoView.swift */, 133D7C802D2BE2630075467E /* MediaInfoView.swift */,
); );
path = MediaInfoView; path = MediaInfoView;
@ -276,6 +359,7 @@
133D7C832D2BE2630075467E /* SettingsSubViews */ = { 133D7C832D2BE2630075467E /* SettingsSubViews */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
132FC5B22DE31DAD009A80F7 /* SettingsViewAlternateAppIconPicker.swift */,
1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */, 1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */,
1399FAD32D3AB38C00E97C31 /* SettingsViewLogger.swift */, 1399FAD32D3AB38C00E97C31 /* SettingsViewLogger.swift */,
133D7C842D2BE2630075467E /* SettingsViewModule.swift */, 133D7C842D2BE2630075467E /* SettingsViewModule.swift */,
@ -284,6 +368,7 @@
130C6BF92D53AB1F00DC1432 /* SettingsViewData.swift */, 130C6BF92D53AB1F00DC1432 /* SettingsViewData.swift */,
13DB46912D900BCE008CBC03 /* SettingsViewTrackers.swift */, 13DB46912D900BCE008CBC03 /* SettingsViewTrackers.swift */,
72443C7E2DC8038300A61321 /* SettingsViewDownloads.swift */, 72443C7E2DC8038300A61321 /* SettingsViewDownloads.swift */,
1398FB3E2DE4E161004D3F5F /* SettingsViewAbout.swift */,
); );
path = SettingsSubViews; path = SettingsSubViews;
sourceTree = "<group>"; sourceTree = "<group>";
@ -291,7 +376,10 @@
133D7C852D2BE2640075467E /* Utils */ = { 133D7C852D2BE2640075467E /* Utils */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
7205AEDA2DCCEF9500943F3F /* Cache */, 0457C5962DE7712A000AFBD9 /* ViewModifiers */,
04F08EE02DE10C22006B29D9 /* Models */,
04F08EDD2DE10C05006B29D9 /* TabBar */,
04F08EDA2DE10BE3006B29D9 /* ProgressiveBlurView */,
13D842532D45266900EBBFA6 /* Drops */, 13D842532D45266900EBBFA6 /* Drops */,
1399FAD12D3AB33D00E97C31 /* Logger */, 1399FAD12D3AB33D00E97C31 /* Logger */,
133D7C882D2BE2640075467E /* Modules */, 133D7C882D2BE2640075467E /* Modules */,
@ -336,11 +424,7 @@
133D7C8A2D2BE2640075467E /* JSLoader */ = { 133D7C8A2D2BE2640075467E /* JSLoader */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
7272206F2DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift */, 134A387B2DE4B5B90041B687 /* Downloads */,
727220702DD642B100C2A4A2 /* JSController+MP4Download.swift */,
722248652DCBC13E00CABE2D /* JSController-Downloads.swift */,
722248612DCBAA4700CABE2D /* JSController-HeaderManager.swift */,
722248622DCBAA4700CABE2D /* JSController+M3U8Download.swift */,
133D7C8B2D2BE2640075467E /* JSController.swift */, 133D7C8B2D2BE2640075467E /* JSController.swift */,
132AF1202D99951700A0140B /* JSController-Streams.swift */, 132AF1202D99951700A0140B /* JSController-Streams.swift */,
132AF1222D9995C300A0140B /* JSController-Details.swift */, 132AF1222D9995C300A0140B /* JSController-Details.swift */,
@ -352,12 +436,27 @@
133F55B92D33B53E00E08EEA /* LibraryView */ = { 133F55B92D33B53E00E08EEA /* LibraryView */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
0457C59C2DE78267000AFBD9 /* BookmarkComponents */,
04CD76DA2DE20F2200733536 /* AllWatching.swift */,
04F08EE32DE10D6B006B29D9 /* AllBookmarks.swift */,
133F55BA2D33B55100E08EEA /* LibraryManager.swift */, 133F55BA2D33B55100E08EEA /* LibraryManager.swift */,
133D7C7E2D2BE2630075467E /* LibraryView.swift */, 133D7C7E2D2BE2630075467E /* LibraryView.swift */,
); );
path = LibraryView; path = LibraryView;
sourceTree = "<group>"; 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 */ = { 1384DCDF2D89BE870094797A /* Helpers */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -376,6 +475,14 @@
path = EpisodeCell; path = EpisodeCell;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
138FE1CE2DEC9FFA00936D81 /* TMDB */ = {
isa = PBXGroup;
children = (
138FE1CF2DECA00D00936D81 /* TMDB-FetchID.swift */,
);
path = TMDB;
sourceTree = "<group>";
};
1399FAD12D3AB33D00E97C31 /* Logger */ = { 1399FAD12D3AB33D00E97C31 /* Logger */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -483,16 +590,6 @@
path = Components; path = Components;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
7205AEDA2DCCEF9500943F3F /* Cache */ = {
isa = PBXGroup;
children = (
7205AED72DCCEF9500943F3F /* EpisodeMetadata.swift */,
7205AED82DCCEF9500943F3F /* KingfisherManager.swift */,
7205AED92DCCEF9500943F3F /* MetadataCacheManager.swift */,
);
path = Cache;
sourceTree = "<group>";
};
72443C832DC8046500A61321 /* DownloadUtils */ = { 72443C832DC8046500A61321 /* DownloadUtils */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -503,16 +600,6 @@
path = DownloadUtils; path = DownloadUtils;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
72AC3A002DD4DAEA00C60B96 /* Managers */ = {
isa = PBXGroup;
children = (
72AC39FD2DD4DAEA00C60B96 /* EpisodeMetadataManager.swift */,
72AC39FE2DD4DAEA00C60B96 /* ImagePrefetchManager.swift */,
72AC39FF2DD4DAEA00C60B96 /* PerformanceMonitor.swift */,
);
path = Managers;
sourceTree = "<group>";
};
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
@ -604,16 +691,20 @@
132AF1252D9995F900A0140B /* JSController-Search.swift in Sources */, 132AF1252D9995F900A0140B /* JSController-Search.swift in Sources */,
1EF5C3A92DB988E40032BF07 /* CommunityLib.swift in Sources */, 1EF5C3A92DB988E40032BF07 /* CommunityLib.swift in Sources */,
13CBEFDA2D5F7D1200D011EE /* String.swift in Sources */, 13CBEFDA2D5F7D1200D011EE /* String.swift in Sources */,
7205AEDB2DCCEF9500943F3F /* EpisodeMetadata.swift in Sources */, 04F08EDC2DE10BF3006B29D9 /* ProgressiveBlurView.swift in Sources */,
7205AEDC2DCCEF9500943F3F /* MetadataCacheManager.swift in Sources */,
7205AEDD2DCCEF9500943F3F /* KingfisherManager.swift in Sources */,
1E26E9E72DA9577900B9DC02 /* VolumeSlider.swift in Sources */, 1E26E9E72DA9577900B9DC02 /* VolumeSlider.swift in Sources */,
136BBE802DB1038000906B5E /* Notification+Name.swift in Sources */, 136BBE802DB1038000906B5E /* Notification+Name.swift in Sources */,
13DB46902D900A38008CBC03 /* URL.swift in Sources */, 13DB46902D900A38008CBC03 /* URL.swift in Sources */,
04F08EE42DE10D6F006B29D9 /* AllBookmarks.swift in Sources */,
138FE1D02DECA00D00936D81 /* TMDB-FetchID.swift in Sources */,
130C6BFA2D53AB1F00DC1432 /* SettingsViewData.swift in Sources */, 130C6BFA2D53AB1F00DC1432 /* SettingsViewData.swift in Sources */,
0402DA172DE7B7B8003BB42C /* SearchViewComponents.swift in Sources */,
1E9FF1D32D403E49008AC100 /* SettingsViewLoggerFilter.swift in Sources */, 1E9FF1D32D403E49008AC100 /* SettingsViewLoggerFilter.swift in Sources */,
0402DA0E2DE7AA01003BB42C /* TabBarController.swift in Sources */,
1E47859B2DEBC5960095BF2F /* AnilistMatchPopupView.swift in Sources */,
13EA2BD92D32D98400C1EBD7 /* NormalPlayer.swift in Sources */, 13EA2BD92D32D98400C1EBD7 /* NormalPlayer.swift in Sources */,
133D7C932D2BE2640075467E /* Modules.swift in Sources */, 133D7C932D2BE2640075467E /* Modules.swift in Sources */,
0457C5972DE7712A000AFBD9 /* DeviceScaleModifier.swift in Sources */,
13DB7CC32D7D99C0004371D3 /* SubtitleSettingsManager.swift in Sources */, 13DB7CC32D7D99C0004371D3 /* SubtitleSettingsManager.swift in Sources */,
133D7C702D2BE2500075467E /* ContentView.swift in Sources */, 133D7C702D2BE2500075467E /* ContentView.swift in Sources */,
13D99CF72D4E73C300250A86 /* ModuleAdditionSettingsView.swift in Sources */, 13D99CF72D4E73C300250A86 /* ModuleAdditionSettingsView.swift in Sources */,
@ -621,8 +712,10 @@
13CBA0882D60F19C00EFE70A /* VTTSubtitlesLoader.swift in Sources */, 13CBA0882D60F19C00EFE70A /* VTTSubtitlesLoader.swift in Sources */,
13EA2BD62D32D97400C1EBD7 /* Double+Extension.swift in Sources */, 13EA2BD62D32D97400C1EBD7 /* Double+Extension.swift in Sources */,
133D7C8F2D2BE2640075467E /* MediaInfoView.swift in Sources */, 133D7C8F2D2BE2640075467E /* MediaInfoView.swift in Sources */,
04F08EE22DE10C40006B29D9 /* TabItem.swift in Sources */,
132AF1212D99951700A0140B /* JSController-Streams.swift in Sources */, 132AF1212D99951700A0140B /* JSController-Streams.swift in Sources */,
131845F92D47C62D00CA7A54 /* SettingsViewGeneral.swift in Sources */, 131845F92D47C62D00CA7A54 /* SettingsViewGeneral.swift in Sources */,
04CD76DB2DE20F2200733536 /* AllWatching.swift in Sources */,
13103E8E2D58E04A000F0673 /* SkeletonCell.swift in Sources */, 13103E8E2D58E04A000F0673 /* SkeletonCell.swift in Sources */,
13D842552D45267500EBBFA6 /* DropManager.swift in Sources */, 13D842552D45267500EBBFA6 /* DropManager.swift in Sources */,
13103E8B2D58E028000F0673 /* View.swift in Sources */, 13103E8B2D58E028000F0673 /* View.swift in Sources */,
@ -637,10 +730,12 @@
722248642DCBAA4700CABE2D /* JSController+M3U8Download.swift in Sources */, 722248642DCBAA4700CABE2D /* JSController+M3U8Download.swift in Sources */,
133D7C942D2BE2640075467E /* JSController.swift in Sources */, 133D7C942D2BE2640075467E /* JSController.swift in Sources */,
133D7C922D2BE2640075467E /* URLSession.swift in Sources */, 133D7C922D2BE2640075467E /* URLSession.swift in Sources */,
0457C5A12DE78385000AFBD9 /* BookmarksDetailView.swift in Sources */,
133D7C912D2BE2640075467E /* SettingsViewModule.swift in Sources */, 133D7C912D2BE2640075467E /* SettingsViewModule.swift in Sources */,
13E62FC22DABC5830007E259 /* Trakt-Login.swift in Sources */, 13E62FC22DABC5830007E259 /* Trakt-Login.swift in Sources */,
133F55BB2D33B55100E08EEA /* LibraryManager.swift in Sources */, 133F55BB2D33B55100E08EEA /* LibraryManager.swift in Sources */,
13E62FC42DABC58C0007E259 /* Trakt-Token.swift in Sources */, 13E62FC42DABC58C0007E259 /* Trakt-Token.swift in Sources */,
1398FB3F2DE4E161004D3F5F /* SettingsViewAbout.swift in Sources */,
133D7C8E2D2BE2640075467E /* LibraryView.swift in Sources */, 133D7C8E2D2BE2640075467E /* LibraryView.swift in Sources */,
13E62FC72DABFE900007E259 /* TraktPushUpdates.swift in Sources */, 13E62FC72DABFE900007E259 /* TraktPushUpdates.swift in Sources */,
133D7C6E2D2BE2500075467E /* SoraApp.swift in Sources */, 133D7C6E2D2BE2500075467E /* SoraApp.swift in Sources */,
@ -652,16 +747,21 @@
13EA2BD52D32D97400C1EBD7 /* CustomPlayer.swift in Sources */, 13EA2BD52D32D97400C1EBD7 /* CustomPlayer.swift in Sources */,
727220712DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift in Sources */, 727220712DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift in Sources */,
727220722DD642B100C2A4A2 /* JSController+MP4Download.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 */, 1399FAD42D3AB38C00E97C31 /* SettingsViewLogger.swift in Sources */,
72AC3A012DD4DAEB00C60B96 /* ImagePrefetchManager.swift in Sources */, 04F08EDF2DE10C1D006B29D9 /* TabBar.swift in Sources */,
72AC3A022DD4DAEB00C60B96 /* EpisodeMetadataManager.swift in Sources */,
72AC3A032DD4DAEB00C60B96 /* PerformanceMonitor.swift in Sources */,
72443C7F2DC8038300A61321 /* SettingsViewDownloads.swift in Sources */, 72443C7F2DC8038300A61321 /* SettingsViewDownloads.swift in Sources */,
13DB46922D900BCE008CBC03 /* SettingsViewTrackers.swift in Sources */, 13DB46922D900BCE008CBC03 /* SettingsViewTrackers.swift in Sources */,
7222485F2DCBAA2C00CABE2D /* DownloadModels.swift in Sources */, 7222485F2DCBAA2C00CABE2D /* DownloadModels.swift in Sources */,
722248602DCBAA2C00CABE2D /* M3U8StreamExtractor.swift in Sources */, 722248602DCBAA2C00CABE2D /* M3U8StreamExtractor.swift in Sources */,
13C0E5EA2D5F85EA00E7F619 /* ContinueWatchingManager.swift in Sources */, 13C0E5EA2D5F85EA00E7F619 /* ContinueWatchingManager.swift in Sources */,
13637B8A2DE0EA1100BDA2FC /* UserDefaults.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; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -793,8 +893,10 @@
133D7C792D2BE2520075467E /* Debug */ = { 133D7C792D2BE2520075467E /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { 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_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
CODE_SIGN_ENTITLEMENTS = Sora/Sora.entitlements; CODE_SIGN_ENTITLEMENTS = Sora/Sora.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
@ -820,12 +922,12 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 0.3.0; MARKETING_VERSION = 1.0.0;
PRODUCT_BUNDLE_IDENTIFIER = me.cranci.sulfur; PRODUCT_BUNDLE_IDENTIFIER = me.cranci.sulfur;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
SUPPORTS_MACCATALYST = YES; SUPPORTS_MACCATALYST = YES;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,6"; TARGETED_DEVICE_FAMILY = "1,2,6";
@ -835,8 +937,10 @@
133D7C7A2D2BE2520075467E /* Release */ = { 133D7C7A2D2BE2520075467E /* Release */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { 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_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
CODE_SIGN_ENTITLEMENTS = Sora/Sora.entitlements; CODE_SIGN_ENTITLEMENTS = Sora/Sora.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
@ -862,12 +966,12 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 0.3.0; MARKETING_VERSION = 1.0.0;
PRODUCT_BUNDLE_IDENTIFIER = me.cranci.sulfur; PRODUCT_BUNDLE_IDENTIFIER = me.cranci.sulfur;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
SUPPORTS_MACCATALYST = YES; SUPPORTS_MACCATALYST = YES;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,6"; TARGETED_DEVICE_FAMILY = "1,2,6";