Merge branch 'dev'
|
|
@ -22,15 +22,16 @@
|
|||
|
||||
## Features
|
||||
|
||||
- [x] iOS/iPadOS 15.0+ support
|
||||
- [x] macOS 12.0+ support
|
||||
- [x] JavaScript Main Core
|
||||
- [ ] Download support (HLS & MP4)
|
||||
- [x] iOS/iPadOS 15.0+ support
|
||||
- [x] JavaScript as main Loader
|
||||
- [x] Download support (HLS & MP4)
|
||||
- [x] Tracking Services (AniList, Trakt)
|
||||
- [x] Apple KeyChain support for auth Tokens
|
||||
- [x] Streams support (Jellyfin/Plex like servers)
|
||||
- [x] External media player support (VLC, Infuse, Outplayer, nPlayer, SenPlayer, IINA)
|
||||
- [x] External Metadata providers (TMDB, AniList)
|
||||
- [x] Background playback and Picture-in-Picture (PiP) support
|
||||
- [x] External media player support (VLC, Infuse, Outplayer, nPlayer, SenPlayer, IINA, TracyPlayer)
|
||||
|
||||
## Installation
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,31 @@
|
|||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"platform" : "universal",
|
||||
"reference" : "systemMintColor"
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "1.000",
|
||||
"green" : "1.000",
|
||||
"red" : "1.000"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.000",
|
||||
"green" : "0.000",
|
||||
"red" : "0.000"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 77 KiB |
12
Sora/Assets.xcassets/AppIcons/AppIcon_Default_Preview.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "preview.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
Sora/Assets.xcassets/AppIcons/AppIcon_Default_Preview.imageset/preview.png
vendored
Normal file
|
After Width: | Height: | Size: 82 KiB |
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "original.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 54 KiB |
12
Sora/Assets.xcassets/AppIcons/AppIcon_Original_Preview.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "preview.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
Sora/Assets.xcassets/AppIcons/AppIcon_Original_Preview.imageset/preview.png
vendored
Normal file
|
After Width: | Height: | Size: 54 KiB |
|
|
@ -35,4 +35,4 @@
|
|||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 75 KiB |
12
Sora/Assets.xcassets/AppIcons/AppIcon_Pixel_Preview.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "preview.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
Sora/Assets.xcassets/AppIcons/AppIcon_Pixel_Preview.imageset/preview.png
vendored
Normal file
|
After Width: | Height: | Size: 107 KiB |
6
Sora/Assets.xcassets/AppIcons/Contents.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
|
@ -4,28 +4,60 @@
|
|||
//
|
||||
// Created by Francesco on 06/01/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
var body: some View {
|
||||
TabView {
|
||||
LibraryView()
|
||||
.tabItem {
|
||||
Label("Library", systemImage: "books.vertical")
|
||||
}
|
||||
DownloadView()
|
||||
.tabItem {
|
||||
Label("Downloads", systemImage: "arrow.down.app.fill")
|
||||
}
|
||||
SearchView()
|
||||
.tabItem {
|
||||
Label("Search", systemImage: "magnifyingglass")
|
||||
}
|
||||
SettingsView()
|
||||
.tabItem {
|
||||
Label("Settings", systemImage: "gear")
|
||||
}
|
||||
}
|
||||
struct ContentView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ContentView()
|
||||
.environmentObject(LibraryManager())
|
||||
.environmentObject(ModuleManager())
|
||||
.environmentObject(Settings())
|
||||
}
|
||||
}
|
||||
|
||||
struct ContentView: View {
|
||||
@StateObject private var tabBarController = TabBarController()
|
||||
@State var selectedTab: Int = 0
|
||||
@State var lastTab: Int = 0
|
||||
@State private var searchQuery: String = ""
|
||||
|
||||
let tabs: [TabItem] = [
|
||||
TabItem(icon: "square.stack", title: ""),
|
||||
TabItem(icon: "arrow.down.circle", title: ""),
|
||||
TabItem(icon: "gearshape", title: ""),
|
||||
TabItem(icon: "magnifyingglass", title: "")
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
switch selectedTab {
|
||||
case 0:
|
||||
LibraryView()
|
||||
.environmentObject(tabBarController)
|
||||
case 1:
|
||||
DownloadView()
|
||||
.environmentObject(tabBarController)
|
||||
case 2:
|
||||
SettingsView()
|
||||
.environmentObject(tabBarController)
|
||||
case 3:
|
||||
SearchView(searchQuery: $searchQuery)
|
||||
.environmentObject(tabBarController)
|
||||
default:
|
||||
LibraryView()
|
||||
.environmentObject(tabBarController)
|
||||
}
|
||||
|
||||
TabBar(
|
||||
tabs: tabs,
|
||||
selectedTab: $selectedTab,
|
||||
lastTab: $lastTab,
|
||||
searchQuery: $searchQuery,
|
||||
controller: tabBarController
|
||||
)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
|
||||
.ignoresSafeArea(.keyboard, edges: .bottom)
|
||||
.padding(.bottom, -20)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
106
Sora/Info.plist
|
|
@ -6,6 +6,110 @@
|
|||
<array>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
</array>
|
||||
<key>CFBundleIcons</key>
|
||||
<dict>
|
||||
<key>CFBundleAlternateIcons</key>
|
||||
<dict>
|
||||
<key>AppIcon_CiroChrome</key>
|
||||
<dict>
|
||||
<key>CFBundleIconFiles</key>
|
||||
<array>
|
||||
<string>AppIcon_CiroChrome</string>
|
||||
</array>
|
||||
</dict>
|
||||
<key>AppIcon_CiroGold</key>
|
||||
<dict>
|
||||
<key>CFBundleIconFiles</key>
|
||||
<array>
|
||||
<string>AppIcon_CiroGold</string>
|
||||
</array>
|
||||
</dict>
|
||||
<key>AppIcon_CiroGoldThree</key>
|
||||
<dict>
|
||||
<key>CFBundleIconFiles</key>
|
||||
<array>
|
||||
<string>AppIcon_CiroGoldThree</string>
|
||||
</array>
|
||||
</dict>
|
||||
<key>AppIcon_CiroGoldTwo</key>
|
||||
<dict>
|
||||
<key>CFBundleIconFiles</key>
|
||||
<array>
|
||||
<string>AppIcon_CiroGoldTwo</string>
|
||||
</array>
|
||||
</dict>
|
||||
<key>AppIcon_CiroPink</key>
|
||||
<dict>
|
||||
<key>CFBundleIconFiles</key>
|
||||
<array>
|
||||
<string>AppIcon_CiroPink</string>
|
||||
</array>
|
||||
</dict>
|
||||
<key>AppIcon_CiroPurple</key>
|
||||
<dict>
|
||||
<key>CFBundleIconFiles</key>
|
||||
<array>
|
||||
<string>AppIcon_CiroPurple</string>
|
||||
</array>
|
||||
</dict>
|
||||
<key>AppIcon_CiroRed</key>
|
||||
<dict>
|
||||
<key>CFBundleIconFiles</key>
|
||||
<array>
|
||||
<string>AppIcon_CiroRed</string>
|
||||
</array>
|
||||
</dict>
|
||||
<key>AppIcon_CiroRoseGold</key>
|
||||
<dict>
|
||||
<key>CFBundleIconFiles</key>
|
||||
<array>
|
||||
<string>AppIcon_CiroRoseGold</string>
|
||||
</array>
|
||||
</dict>
|
||||
<key>AppIcon_CiroSilver</key>
|
||||
<dict>
|
||||
<key>CFBundleIconFiles</key>
|
||||
<array>
|
||||
<string>AppIcon_CiroSilver</string>
|
||||
</array>
|
||||
</dict>
|
||||
<key>AppIcon_CiroSilverTwo</key>
|
||||
<dict>
|
||||
<key>CFBundleIconFiles</key>
|
||||
<array>
|
||||
<string>AppIcon_CiroSilverTwo</string>
|
||||
</array>
|
||||
</dict>
|
||||
<key>AppIcon_CiroYellow</key>
|
||||
<dict>
|
||||
<key>CFBundleIconFiles</key>
|
||||
<array>
|
||||
<string>AppIcon_CiroYellow</string>
|
||||
</array>
|
||||
</dict>
|
||||
<key>AppIcon_Original</key>
|
||||
<dict>
|
||||
<key>CFBundleIconFiles</key>
|
||||
<array>
|
||||
<string>AppIcon_Original</string>
|
||||
</array>
|
||||
</dict>
|
||||
<key>AppIcon_Pixel</key>
|
||||
<dict>
|
||||
<key>CFBundleIconFiles</key>
|
||||
<array>
|
||||
<string>AppIcon_Pixel</string>
|
||||
</array>
|
||||
</dict>
|
||||
<key>AppIcon_SoraAlt</key>
|
||||
<dict>
|
||||
<key>CFBundleIconFiles</key>
|
||||
<array>
|
||||
<string>AppIcon_SoraAlt</string>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
|
|
@ -21,6 +125,7 @@
|
|||
</array>
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
<array>
|
||||
<string>tracy</string>
|
||||
<string>iina</string>
|
||||
<string>outplayer</string>
|
||||
<string>infuse</string>
|
||||
|
|
@ -36,6 +141,7 @@
|
|||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
<string>fetch</string>
|
||||
<string>processing</string>
|
||||
</array>
|
||||
</dict>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
7
Sora/Models/EpisodeLink.swift
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
struct EpisodeLink: Identifiable {
|
||||
let id = UUID()
|
||||
let number: Int
|
||||
let title: String
|
||||
let href: String
|
||||
let duration: Int?
|
||||
}
|
||||
|
|
@ -4,13 +4,15 @@
|
|||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-write</key>
|
||||
<true/>
|
||||
<key>com.apple.security.assets.movies.read-write</key>
|
||||
<true/>
|
||||
<key>com.apple.security.assets.music.read-write</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-write</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.server</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
|||
|
|
@ -8,8 +8,49 @@
|
|||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
class OrientationManager: ObservableObject {
|
||||
static let shared = OrientationManager()
|
||||
|
||||
@Published var isLocked = false
|
||||
private var lockedOrientation: UIInterfaceOrientationMask = .all
|
||||
|
||||
private init() {}
|
||||
|
||||
func lockOrientation() {
|
||||
let currentOrientation = UIApplication.shared.windows.first?.windowScene?.interfaceOrientation ?? .portrait
|
||||
|
||||
switch currentOrientation {
|
||||
case .portrait, .portraitUpsideDown:
|
||||
lockedOrientation = .portrait
|
||||
case .landscapeLeft, .landscapeRight:
|
||||
lockedOrientation = .landscape
|
||||
default:
|
||||
lockedOrientation = .portrait
|
||||
}
|
||||
|
||||
isLocked = true
|
||||
|
||||
UIDevice.current.setValue(currentOrientation.rawValue, forKey: "orientation")
|
||||
UIViewController.attemptRotationToDeviceOrientation()
|
||||
}
|
||||
|
||||
func unlockOrientation(after delay: TimeInterval = 0.0) {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
|
||||
self.isLocked = false
|
||||
self.lockedOrientation = .all
|
||||
|
||||
UIViewController.attemptRotationToDeviceOrientation()
|
||||
}
|
||||
}
|
||||
|
||||
func supportedOrientations() -> UIInterfaceOrientationMask {
|
||||
return isLocked ? lockedOrientation : .all
|
||||
}
|
||||
}
|
||||
|
||||
@main
|
||||
struct SoraApp: App {
|
||||
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||
@StateObject private var settings = Settings()
|
||||
@StateObject private var moduleManager = ModuleManager()
|
||||
@StateObject private var librarykManager = LibraryManager()
|
||||
|
|
@ -17,9 +58,6 @@ struct SoraApp: App {
|
|||
@StateObject private var jsController = JSController.shared
|
||||
|
||||
init() {
|
||||
_ = MetadataCacheManager.shared
|
||||
_ = KingfisherCacheManager.shared
|
||||
|
||||
if let userAccentColor = UserDefaults.standard.color(forKey: "accentColor") {
|
||||
UIView.appearance(whenContainedInInstancesOf: [UIAlertController.self]).tintColor = userAccentColor
|
||||
}
|
||||
|
|
@ -136,9 +174,18 @@ struct SoraApp: App {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AppInfo: NSObject {
|
||||
@objc func getBundleIdentifier() -> String {
|
||||
return Bundle.main.bundleIdentifier ?? "me.cranci.sulfur"
|
||||
}
|
||||
|
||||
@objc func getDisplayName() -> String {
|
||||
return Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as! String
|
||||
}
|
||||
}
|
||||
|
||||
class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask {
|
||||
return OrientationManager.shared.supportedOrientations()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,32 +33,41 @@ class AniListMutation {
|
|||
return String(data: tokenData, encoding: .utf8)
|
||||
}
|
||||
|
||||
func updateAnimeProgress(animeId: Int, episodeNumber: Int, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
if let sendPushUpdates = UserDefaults.standard.object(forKey: "sendPushUpdates") as? Bool,
|
||||
sendPushUpdates == false {
|
||||
return
|
||||
}
|
||||
|
||||
guard let userToken = getTokenFromKeychain() else {
|
||||
completion(.failure(NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "Access token not found"])))
|
||||
return
|
||||
}
|
||||
|
||||
let query = """
|
||||
mutation ($mediaId: Int, $progress: Int, $status: MediaListStatus) {
|
||||
SaveMediaListEntry (mediaId: $mediaId, progress: $progress, status: $status) {
|
||||
id
|
||||
progress
|
||||
status
|
||||
func updateAnimeProgress(
|
||||
animeId: Int,
|
||||
episodeNumber: Int,
|
||||
status: String = "CURRENT",
|
||||
completion: @escaping (Result<Void, Error>) -> Void
|
||||
) {
|
||||
if let sendPushUpdates = UserDefaults.standard.object(forKey: "sendPushUpdates") as? Bool,
|
||||
sendPushUpdates == false {
|
||||
return
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
let variables: [String: Any] = [
|
||||
|
||||
guard let userToken = getTokenFromKeychain() else {
|
||||
completion(.failure(NSError(
|
||||
domain: "",
|
||||
code: -1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Access token not found"]
|
||||
)))
|
||||
return
|
||||
}
|
||||
|
||||
let query = """
|
||||
mutation ($mediaId: Int, $progress: Int, $status: MediaListStatus) {
|
||||
SaveMediaListEntry(mediaId: $mediaId, progress: $progress, status: $status) {
|
||||
id
|
||||
progress
|
||||
status
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
let variables: [String: Any] = [
|
||||
"mediaId": animeId,
|
||||
"progress": episodeNumber,
|
||||
"status": "CURRENT"
|
||||
]
|
||||
"status": status
|
||||
]
|
||||
|
||||
let requestBody: [String: Any] = [
|
||||
"query": query,
|
||||
|
|
@ -104,6 +113,52 @@ class AniListMutation {
|
|||
task.resume()
|
||||
}
|
||||
|
||||
func fetchMediaStatus(
|
||||
mediaId: Int,
|
||||
completion: @escaping (Result<String, Error>) -> Void
|
||||
) {
|
||||
guard let token = getTokenFromKeychain() else {
|
||||
completion(.failure(NSError(
|
||||
domain: "", code: -1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Access token not found"]
|
||||
)))
|
||||
return
|
||||
}
|
||||
|
||||
let query = """
|
||||
query ($mediaId: Int) {
|
||||
Media(id: $mediaId) {
|
||||
status
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
let vars = ["mediaId": mediaId]
|
||||
var req = URLRequest(url: URL(string: "https://graphql.anilist.co")!)
|
||||
req.httpMethod = "POST"
|
||||
req.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
req.httpBody = try? JSONSerialization.data(
|
||||
withJSONObject: ["query": query, "variables": vars]
|
||||
)
|
||||
|
||||
URLSession.shared.dataTask(with: req) { data, _, error in
|
||||
if let e = error { return completion(.failure(e)) }
|
||||
guard
|
||||
let d = data,
|
||||
let json = try? JSONSerialization.jsonObject(with: d) as? [String: Any],
|
||||
let md = (json["data"] as? [String: Any])?["Media"] as? [String: Any],
|
||||
let status = md["status"] as? String
|
||||
else {
|
||||
return completion(.failure(NSError(
|
||||
domain: "", code: -2,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Invalid response"]
|
||||
)))
|
||||
}
|
||||
completion(.success(status))
|
||||
}.resume()
|
||||
}
|
||||
|
||||
func fetchMalID(animeId: Int, completion: @escaping (Result<Int, Error>) -> Void) {
|
||||
let query = """
|
||||
query ($id: Int) {
|
||||
|
|
@ -148,3 +203,10 @@ class AniListMutation {
|
|||
let data: DataField
|
||||
}
|
||||
}
|
||||
|
||||
struct EpisodeMetadataInfo: Codable, Equatable {
|
||||
let title: [String: String]
|
||||
let imageUrl: String
|
||||
let anilistId: Int
|
||||
let episodeNumber: Int
|
||||
}
|
||||
|
|
|
|||
104
Sora/Tracking Services/TMDB/TMDB-FetchID.swift
Normal 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]
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ?? "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -12,11 +12,12 @@ struct ContinueWatchingItem: Codable, Identifiable {
|
|||
let imageUrl: String
|
||||
let episodeNumber: Int
|
||||
let mediaTitle: String
|
||||
let progress: Double
|
||||
var progress: Double
|
||||
let streamUrl: String
|
||||
let fullUrl: String
|
||||
let subtitles: String?
|
||||
let aniListID: Int?
|
||||
let module: ScrapingModule
|
||||
let headers: [String:String]?
|
||||
let totalEpisodes: Int
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,36 +10,110 @@ import Foundation
|
|||
class ContinueWatchingManager {
|
||||
static let shared = ContinueWatchingManager()
|
||||
private let storageKey = "continueWatchingItems"
|
||||
|
||||
private let lastCleanupKey = "lastContinueWatchingCleanup"
|
||||
|
||||
private init() {
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(handleiCloudSync), name: .iCloudSyncDidComplete, object: nil)
|
||||
performCleanupIfNeeded()
|
||||
}
|
||||
|
||||
|
||||
@objc private func handleiCloudSync() {
|
||||
NotificationCenter.default.post(name: .ContinueWatchingDidUpdate, object: nil)
|
||||
}
|
||||
|
||||
|
||||
private func performCleanupIfNeeded() {
|
||||
let lastCleanup = UserDefaults.standard.double(forKey: lastCleanupKey)
|
||||
let currentTime = Date().timeIntervalSince1970
|
||||
|
||||
if currentTime - lastCleanup > 86400 {
|
||||
cleanupOldEpisodes()
|
||||
UserDefaults.standard.set(currentTime, forKey: lastCleanupKey)
|
||||
}
|
||||
}
|
||||
|
||||
private func cleanupOldEpisodes() {
|
||||
var items = fetchItems()
|
||||
var itemsToRemove: Set<UUID> = []
|
||||
|
||||
let groupedItems = Dictionary(grouping: items) { item in
|
||||
let title = item.mediaTitle.replacingOccurrences(of: "Episode \\d+.*$", with: "", options: .regularExpression)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return title
|
||||
}
|
||||
|
||||
for (_, showEpisodes) in groupedItems {
|
||||
let sortedEpisodes = showEpisodes.sorted { $0.episodeNumber < $1.episodeNumber }
|
||||
|
||||
for i in 0..<sortedEpisodes.count - 1 {
|
||||
let currentEpisode = sortedEpisodes[i]
|
||||
let nextEpisode = sortedEpisodes[i + 1]
|
||||
|
||||
if currentEpisode.progress >= 0.8 && nextEpisode.episodeNumber > currentEpisode.episodeNumber {
|
||||
itemsToRemove.insert(currentEpisode.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !itemsToRemove.isEmpty {
|
||||
items.removeAll { itemsToRemove.contains($0.id) }
|
||||
if let data = try? JSONEncoder().encode(items) {
|
||||
UserDefaults.standard.set(data, forKey: storageKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func save(item: ContinueWatchingItem) {
|
||||
if item.progress >= 0.9 {
|
||||
// Use real playback times
|
||||
let lastKey = "lastPlayedTime_\(item.fullUrl)"
|
||||
let totalKey = "totalTime_\(item.fullUrl)"
|
||||
let lastPlayed = UserDefaults.standard.double(forKey: lastKey)
|
||||
let totalTime = UserDefaults.standard.double(forKey: totalKey)
|
||||
|
||||
// Compute up-to-date progress
|
||||
let actualProgress: Double
|
||||
if totalTime > 0 {
|
||||
actualProgress = min(max(lastPlayed / totalTime, 0), 1)
|
||||
} else {
|
||||
actualProgress = item.progress
|
||||
}
|
||||
|
||||
// If watched ≥ 90%, remove it
|
||||
if actualProgress >= 0.9 {
|
||||
remove(item: item)
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise update progress and remove old episodes from the same show
|
||||
var updatedItem = item
|
||||
updatedItem.progress = actualProgress
|
||||
|
||||
var items = fetchItems()
|
||||
|
||||
let showTitle = item.mediaTitle.replacingOccurrences(of: "Episode \\d+.*$", with: "", options: .regularExpression)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
items.removeAll { existingItem in
|
||||
let existingShowTitle = existingItem.mediaTitle.replacingOccurrences(of: "Episode \\d+.*$", with: "", options: .regularExpression)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
return showTitle == existingShowTitle &&
|
||||
existingItem.episodeNumber < item.episodeNumber &&
|
||||
existingItem.progress >= 0.8
|
||||
}
|
||||
|
||||
items.removeAll { existing in
|
||||
existing.fullUrl == item.fullUrl &&
|
||||
existing.episodeNumber == item.episodeNumber &&
|
||||
existing.module.metadata.sourceName == item.module.metadata.sourceName
|
||||
}
|
||||
|
||||
items.append(item)
|
||||
items.append(updatedItem)
|
||||
|
||||
if let data = try? JSONEncoder().encode(items) {
|
||||
UserDefaults.standard.set(data, forKey: storageKey)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func fetchItems() -> [ContinueWatchingItem] {
|
||||
guard
|
||||
let data = UserDefaults.standard.data(forKey: storageKey),
|
||||
|
|
@ -61,7 +135,7 @@ class ContinueWatchingManager {
|
|||
|
||||
return Array(unique)
|
||||
}
|
||||
|
||||
|
||||
func remove(item: ContinueWatchingItem) {
|
||||
var items = fetchItems()
|
||||
items.removeAll { $0.id == item.id }
|
||||
|
|
|
|||
|
|
@ -17,10 +17,8 @@ class DropManager {
|
|||
private init() {}
|
||||
|
||||
func showDrop(title: String, subtitle: String, duration: TimeInterval, icon: UIImage?) {
|
||||
// Add to queue
|
||||
notificationQueue.append((title: title, subtitle: subtitle, duration: duration, icon: icon))
|
||||
|
||||
// Process queue if not already processing
|
||||
if !isProcessingQueue {
|
||||
processQueue()
|
||||
}
|
||||
|
|
@ -33,11 +31,8 @@ class DropManager {
|
|||
}
|
||||
|
||||
isProcessingQueue = true
|
||||
|
||||
// Get the next notification
|
||||
let notification = notificationQueue.removeFirst()
|
||||
|
||||
// Show the notification
|
||||
let drop = Drop(
|
||||
title: notification.title,
|
||||
subtitle: notification.subtitle,
|
||||
|
|
@ -48,7 +43,6 @@ class DropManager {
|
|||
|
||||
Drops.show(drop)
|
||||
|
||||
// Schedule next notification
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + notification.duration) { [weak self] in
|
||||
self?.processQueue()
|
||||
}
|
||||
|
|
@ -69,9 +63,7 @@ class DropManager {
|
|||
showDrop(title: "Info", subtitle: message, duration: duration, icon: icon)
|
||||
}
|
||||
|
||||
// Method for handling download notifications with accurate status determination
|
||||
func downloadStarted(episodeNumber: Int) {
|
||||
// Use the JSController method to accurately determine if download will start immediately
|
||||
let willStartImmediately = JSController.shared.willDownloadStartImmediately()
|
||||
|
||||
let message = willStartImmediately
|
||||
|
|
|
|||
|
|
@ -78,10 +78,12 @@ extension JSContext {
|
|||
}
|
||||
|
||||
func setupFetchV2() {
|
||||
let fetchV2NativeFunction: @convention(block) (String, [String: String]?, String?, String?, ObjCBool,JSValue, JSValue) -> Void = { urlString, headers, method, body, redirect, resolve, reject in
|
||||
let fetchV2NativeFunction: @convention(block) (String, [String: String]?, String?, String?, ObjCBool, JSValue, JSValue) -> Void = { urlString, headers, method, body, redirect, resolve, reject in
|
||||
guard let url = URL(string: urlString) else {
|
||||
Logger.shared.log("Invalid URL", type: "Error")
|
||||
reject.call(withArguments: ["Invalid URL"])
|
||||
DispatchQueue.main.async {
|
||||
reject.call(withArguments: ["Invalid URL"])
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -93,7 +95,9 @@ extension JSContext {
|
|||
|
||||
if httpMethod == "GET", let body = body, !body.isEmpty, body != "null", body != "undefined" {
|
||||
Logger.shared.log("GET request must not have a body", type: "Error")
|
||||
reject.call(withArguments: ["GET request must not have a body"])
|
||||
DispatchQueue.main.async {
|
||||
reject.call(withArguments: ["GET request must not have a body"])
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -101,29 +105,49 @@ extension JSContext {
|
|||
request.httpBody = body.data(using: .utf8)
|
||||
}
|
||||
|
||||
|
||||
if let headers = headers {
|
||||
for (key, value) in headers {
|
||||
request.setValue(value, forHTTPHeaderField: key)
|
||||
}
|
||||
}
|
||||
Logger.shared.log("Redirect value is \(redirect.boolValue)",type:"Error")
|
||||
Logger.shared.log("Redirect value is \(redirect.boolValue)", type: "Error")
|
||||
let task = URLSession.fetchData(allowRedirects: redirect.boolValue).downloadTask(with: request) { tempFileURL, response, error in
|
||||
let callReject: (String) -> Void = { message in
|
||||
DispatchQueue.main.async {
|
||||
reject.call(withArguments: [message])
|
||||
}
|
||||
}
|
||||
let callResolve: ([String: Any]) -> Void = { dict in
|
||||
DispatchQueue.main.async {
|
||||
resolve.call(withArguments: [dict])
|
||||
}
|
||||
}
|
||||
|
||||
if let error = error {
|
||||
Logger.shared.log("Network error in fetchV2NativeFunction: \(error.localizedDescription)", type: "Error")
|
||||
reject.call(withArguments: [error.localizedDescription])
|
||||
callReject(error.localizedDescription)
|
||||
return
|
||||
}
|
||||
|
||||
guard let tempFileURL = tempFileURL else {
|
||||
Logger.shared.log("No data in response", type: "Error")
|
||||
reject.call(withArguments: ["No data"])
|
||||
callReject("No data")
|
||||
return
|
||||
}
|
||||
// initialise return Object
|
||||
|
||||
var safeHeaders: [String: String] = [:]
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
for (key, value) in httpResponse.allHeaderFields {
|
||||
if let keyString = key as? String,
|
||||
let valueString = value as? String {
|
||||
safeHeaders[keyString] = valueString
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var responseDict: [String: Any] = [
|
||||
"status": (response as? HTTPURLResponse)?.statusCode ?? 0,
|
||||
"headers": (response as? HTTPURLResponse)?.allHeaderFields ?? [:],
|
||||
"headers": safeHeaders,
|
||||
"body": ""
|
||||
]
|
||||
|
||||
|
|
@ -132,23 +156,21 @@ extension JSContext {
|
|||
|
||||
if data.count > 10_000_000 {
|
||||
Logger.shared.log("Response exceeds maximum size", type: "Error")
|
||||
reject.call(withArguments: ["Response exceeds maximum size"])
|
||||
callReject("Response exceeds maximum size")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if let text = String(data: data, encoding: .utf8) {
|
||||
|
||||
responseDict["body"] = text
|
||||
resolve.call(withArguments: [responseDict])
|
||||
callResolve(responseDict)
|
||||
} else {
|
||||
// rather than reject -> resolve with empty body as user can utilise reponse headers.
|
||||
Logger.shared.log("Unable to decode data to text", type: "Error")
|
||||
resolve.call(withArguments: [responseDict])
|
||||
callResolve(responseDict)
|
||||
}
|
||||
|
||||
} catch {
|
||||
Logger.shared.log("Error reading downloaded file: \(error.localizedDescription)", type: "Error")
|
||||
reject.call(withArguments: ["Error reading downloaded file"])
|
||||
callReject("Error reading downloaded file")
|
||||
}
|
||||
}
|
||||
task.resume()
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ extension UserDefaults {
|
|||
let data = try NSKeyedArchiver.archivedData(withRootObject: color, requiringSecureCoding: false)
|
||||
set(data, forKey: key)
|
||||
} catch {
|
||||
print("Error archiving color: \(error)")
|
||||
Logger.shared.log("Error archiving color: \(error)", type: "Error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,28 @@
|
|||
//
|
||||
// View.swift
|
||||
// ScrollViewBottomPadding.swift
|
||||
// Sora
|
||||
//
|
||||
// Created by Francesco on 09/02/25.
|
||||
// Created by paul on 29/05/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
extension View {
|
||||
func shimmering() -> some View {
|
||||
self.modifier(Shimmer())
|
||||
struct ScrollViewBottomPadding: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
Color.clear
|
||||
.frame(height: 60)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func shimmering() -> some View {
|
||||
modifier(Shimmer())
|
||||
}
|
||||
|
||||
func scrollViewBottomPadding() -> some View {
|
||||
modifier(ScrollViewBottomPadding())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -38,6 +38,12 @@ extension JSController {
|
|||
print("Subtitle URL: \(subtitle.absoluteString)")
|
||||
}
|
||||
|
||||
// Validate URL
|
||||
guard url.scheme == "http" || url.scheme == "https" else {
|
||||
completionHandler?(false, "Invalid URL scheme")
|
||||
return
|
||||
}
|
||||
|
||||
// Create metadata for the download
|
||||
var metadata: AssetMetadata? = nil
|
||||
if let title = title {
|
||||
|
|
@ -47,7 +53,7 @@ extension JSController {
|
|||
showTitle: showTitle,
|
||||
season: season,
|
||||
episode: episode,
|
||||
showPosterURL: imageURL // Use the correct show poster URL
|
||||
showPosterURL: imageURL
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -57,12 +63,24 @@ extension JSController {
|
|||
// Generate a unique download ID
|
||||
let downloadID = UUID()
|
||||
|
||||
// Get access to the download directory
|
||||
guard let downloadDirectory = getPersistentDownloadDirectory() else {
|
||||
print("MP4 Download: Failed to get download directory")
|
||||
completionHandler?(false, "Failed to create download directory")
|
||||
return
|
||||
}
|
||||
|
||||
// Generate a safe filename for the MP4 file
|
||||
let sanitizedTitle = title?.replacingOccurrences(of: "[^A-Za-z0-9 ._-]", with: "", options: .regularExpression) ?? "download"
|
||||
let filename = "\(sanitizedTitle)_\(downloadID.uuidString.prefix(8)).mp4"
|
||||
let destinationURL = downloadDirectory.appendingPathComponent(filename)
|
||||
|
||||
// Create an active download object
|
||||
let activeDownload = JSActiveDownload(
|
||||
id: downloadID,
|
||||
originalURL: url,
|
||||
task: nil, // We'll set this after creating the task
|
||||
queueStatus: .queued,
|
||||
task: nil,
|
||||
queueStatus: .downloading,
|
||||
type: downloadType,
|
||||
metadata: metadata,
|
||||
title: title,
|
||||
|
|
@ -74,84 +92,78 @@ extension JSController {
|
|||
// Add to active downloads
|
||||
activeDownloads.append(activeDownload)
|
||||
|
||||
// Create a URL session task for downloading the MP4 file
|
||||
// Create request with headers
|
||||
var request = URLRequest(url: url)
|
||||
request.timeoutInterval = 30.0
|
||||
for (key, value) in headers {
|
||||
request.addValue(value, forHTTPHeaderField: key)
|
||||
}
|
||||
|
||||
// Get access to the download directory using the shared instance method
|
||||
guard let downloadDirectory = getPersistentDownloadDirectory() else {
|
||||
print("MP4 Download: Failed to get download directory")
|
||||
completionHandler?(false, "Failed to create download directory")
|
||||
return
|
||||
}
|
||||
|
||||
// Generate a unique filename for the MP4 file
|
||||
let filename = "\(downloadID.uuidString).mp4"
|
||||
let destinationURL = downloadDirectory.appendingPathComponent(filename)
|
||||
|
||||
// Use a session configuration that allows handling SSL issues
|
||||
let sessionConfig = URLSessionConfiguration.default
|
||||
// Set a longer timeout for large files
|
||||
sessionConfig.timeoutIntervalForRequest = 60.0
|
||||
sessionConfig.timeoutIntervalForResource = 600.0
|
||||
|
||||
// Create a URL session that handles SSL certificate validation issues
|
||||
sessionConfig.timeoutIntervalForResource = 1800.0
|
||||
sessionConfig.httpMaximumConnectionsPerHost = 1
|
||||
sessionConfig.allowsCellularAccess = true
|
||||
|
||||
// Create custom session with delegate (self is JSController, which is persistent)
|
||||
let customSession = URLSession(configuration: sessionConfig, delegate: self, delegateQueue: nil)
|
||||
|
||||
// Create the download task with the custom session
|
||||
let downloadTask = customSession.downloadTask(with: request) { (tempURL, response, error) in
|
||||
|
||||
// Create the download task
|
||||
let downloadTask = customSession.downloadTask(with: request) { [weak self] (tempURL, response, error) in
|
||||
guard let self = self else { return }
|
||||
|
||||
DispatchQueue.main.async {
|
||||
defer {
|
||||
// Clean up resources
|
||||
self.cleanupDownloadResources(for: downloadID)
|
||||
}
|
||||
|
||||
// Handle error cases - just remove from active downloads
|
||||
if let error = error {
|
||||
print("MP4 Download Error: \(error.localizedDescription)")
|
||||
|
||||
// Update active download status
|
||||
if let index = self.activeDownloads.firstIndex(where: { $0.id == downloadID }) {
|
||||
self.activeDownloads[index].queueStatus = .queued
|
||||
}
|
||||
|
||||
// Clean up resources
|
||||
self.mp4ProgressObservations?[downloadID] = nil
|
||||
self.mp4CustomSessions?[downloadID] = nil
|
||||
|
||||
// Remove the download after a delay
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) {
|
||||
self.activeDownloads.removeAll { $0.id == downloadID }
|
||||
}
|
||||
|
||||
self.removeActiveDownload(downloadID: downloadID)
|
||||
completionHandler?(false, "Download failed: \(error.localizedDescription)")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate response
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
print("MP4 Download: Invalid response")
|
||||
self.removeActiveDownload(downloadID: downloadID)
|
||||
completionHandler?(false, "Invalid server response")
|
||||
return
|
||||
}
|
||||
|
||||
if httpResponse.statusCode >= 400 {
|
||||
guard (200...299).contains(httpResponse.statusCode) else {
|
||||
print("MP4 Download HTTP Error: \(httpResponse.statusCode)")
|
||||
self.removeActiveDownload(downloadID: downloadID)
|
||||
completionHandler?(false, "Server error: \(httpResponse.statusCode)")
|
||||
return
|
||||
}
|
||||
|
||||
guard let tempURL = tempURL else {
|
||||
print("MP4 Download: No temporary file URL")
|
||||
self.removeActiveDownload(downloadID: downloadID)
|
||||
completionHandler?(false, "Download data not available")
|
||||
return
|
||||
}
|
||||
|
||||
// Move file to final destination
|
||||
do {
|
||||
// Move the temporary file to the permanent location
|
||||
if FileManager.default.fileExists(atPath: destinationURL.path) {
|
||||
try FileManager.default.removeItem(at: destinationURL)
|
||||
}
|
||||
|
||||
try FileManager.default.moveItem(at: tempURL, to: destinationURL)
|
||||
print("MP4 Download: Successfully moved file to \(destinationURL.path)")
|
||||
print("MP4 Download: Successfully saved to \(destinationURL.path)")
|
||||
|
||||
// Create the downloaded asset
|
||||
// Verify file size
|
||||
let fileSize = try FileManager.default.attributesOfItem(atPath: destinationURL.path)[.size] as? Int64 ?? 0
|
||||
guard fileSize > 0 else {
|
||||
throw NSError(domain: "DownloadError", code: -1, userInfo: [NSLocalizedDescriptionKey: "Downloaded file is empty"])
|
||||
}
|
||||
|
||||
// Create downloaded asset
|
||||
let downloadedAsset = DownloadedAsset(
|
||||
name: title ?? url.lastPathComponent,
|
||||
downloadDate: Date(),
|
||||
|
|
@ -162,112 +174,122 @@ extension JSController {
|
|||
subtitleURL: subtitleURL
|
||||
)
|
||||
|
||||
// Add to saved assets
|
||||
// Save asset
|
||||
self.savedAssets.append(downloadedAsset)
|
||||
self.saveAssets()
|
||||
|
||||
// Update active download and remove after a delay
|
||||
if let index = self.activeDownloads.firstIndex(where: { $0.id == downloadID }) {
|
||||
self.activeDownloads[index].progress = 1.0
|
||||
self.activeDownloads[index].queueStatus = .completed
|
||||
}
|
||||
// Update progress to complete and remove after delay
|
||||
self.updateDownloadProgress(downloadID: downloadID, progress: 1.0)
|
||||
|
||||
// Download subtitle if provided
|
||||
if let subtitleURL = subtitleURL {
|
||||
self.downloadSubtitle(subtitleURL: subtitleURL, assetID: downloadedAsset.id.uuidString)
|
||||
}
|
||||
|
||||
// Notify observers - use downloadCompleted since the download finished
|
||||
NotificationCenter.default.post(name: NSNotification.Name("downloadCompleted"), object: nil)
|
||||
|
||||
// Notify completion
|
||||
NotificationCenter.default.post(name: NSNotification.Name("downloadCompleted"), object: downloadedAsset)
|
||||
completionHandler?(true, "Download completed successfully")
|
||||
|
||||
// Clean up resources
|
||||
self.mp4ProgressObservations?[downloadID] = nil
|
||||
self.mp4CustomSessions?[downloadID] = nil
|
||||
|
||||
// Remove the completed download from active list after a delay
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
|
||||
self.activeDownloads.removeAll { $0.id == downloadID }
|
||||
// Remove from active downloads after success
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
|
||||
self.removeActiveDownload(downloadID: downloadID)
|
||||
}
|
||||
|
||||
} catch {
|
||||
print("MP4 Download Error moving file: \(error.localizedDescription)")
|
||||
print("MP4 Download Error saving file: \(error.localizedDescription)")
|
||||
self.removeActiveDownload(downloadID: downloadID)
|
||||
completionHandler?(false, "Error saving download: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set up progress tracking
|
||||
// Set up progress observation
|
||||
setupProgressObservation(for: downloadTask, downloadID: downloadID)
|
||||
|
||||
// Store session reference
|
||||
storeSessionReference(session: customSession, for: downloadID)
|
||||
|
||||
// Start download
|
||||
downloadTask.resume()
|
||||
print("MP4 Download: Task started for \(filename)")
|
||||
|
||||
// Update the task in the active download
|
||||
if let index = activeDownloads.firstIndex(where: { $0.id == downloadID }) {
|
||||
activeDownloads[index].queueStatus = .downloading
|
||||
|
||||
// Store reference to the downloadTask directly - no need to access private properties
|
||||
print("MP4 Download: Task started")
|
||||
// We can't directly store URLSessionDownloadTask in place of AVAssetDownloadTask
|
||||
// Just continue tracking progress separately
|
||||
}
|
||||
|
||||
// Set up progress observation - fix the key path specification
|
||||
let observation = downloadTask.progress.observe(\Progress.fractionCompleted) { progress, _ in
|
||||
// Initial success callback
|
||||
completionHandler?(true, "Download started")
|
||||
}
|
||||
|
||||
// MARK: - Helper Methods
|
||||
|
||||
private func removeActiveDownload(downloadID: UUID) {
|
||||
activeDownloads.removeAll { $0.id == downloadID }
|
||||
}
|
||||
|
||||
private func updateDownloadProgress(downloadID: UUID, progress: Double) {
|
||||
guard let index = activeDownloads.firstIndex(where: { $0.id == downloadID }) else { return }
|
||||
activeDownloads[index].progress = progress
|
||||
}
|
||||
|
||||
private func setupProgressObservation(for task: URLSessionDownloadTask, downloadID: UUID) {
|
||||
let observation = task.progress.observe(\.fractionCompleted) { [weak self] progress, _ in
|
||||
DispatchQueue.main.async {
|
||||
if let index = self.activeDownloads.firstIndex(where: { $0.id == downloadID }) {
|
||||
self.activeDownloads[index].progress = progress.fractionCompleted
|
||||
|
||||
// Notify observers of progress update
|
||||
NotificationCenter.default.post(name: NSNotification.Name("downloadProgressUpdated"), object: nil)
|
||||
}
|
||||
guard let self = self else { return }
|
||||
self.updateDownloadProgress(downloadID: downloadID, progress: progress.fractionCompleted)
|
||||
NotificationCenter.default.post(name: NSNotification.Name("downloadProgressChanged"), object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
// Store the observation somewhere to keep it alive - using nonatomic property from main class
|
||||
if self.mp4ProgressObservations == nil {
|
||||
self.mp4ProgressObservations = [:]
|
||||
if mp4ProgressObservations == nil {
|
||||
mp4ProgressObservations = [:]
|
||||
}
|
||||
self.mp4ProgressObservations?[downloadID] = observation
|
||||
|
||||
// Store the custom session to keep it alive until download is complete
|
||||
if self.mp4CustomSessions == nil {
|
||||
self.mp4CustomSessions = [:]
|
||||
mp4ProgressObservations?[downloadID] = observation
|
||||
}
|
||||
|
||||
private func storeSessionReference(session: URLSession, for downloadID: UUID) {
|
||||
if mp4CustomSessions == nil {
|
||||
mp4CustomSessions = [:]
|
||||
}
|
||||
self.mp4CustomSessions?[downloadID] = customSession
|
||||
|
||||
// Notify that download started successfully
|
||||
completionHandler?(true, "Download started")
|
||||
mp4CustomSessions?[downloadID] = session
|
||||
}
|
||||
|
||||
private func cleanupDownloadResources(for downloadID: UUID) {
|
||||
mp4ProgressObservations?[downloadID] = nil
|
||||
mp4CustomSessions?[downloadID] = nil
|
||||
}
|
||||
}
|
||||
|
||||
// Extension for handling SSL certificate validation for MP4 downloads
|
||||
// MARK: - URLSessionDelegate
|
||||
extension JSController: URLSessionDelegate {
|
||||
func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
|
||||
// Handle SSL/TLS certificate validation
|
||||
if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust {
|
||||
let host = challenge.protectionSpace.host
|
||||
print("MP4 Download: Handling server trust challenge for host: \(host)")
|
||||
|
||||
// Accept the server's certificate for known problematic domains
|
||||
// or for domains in our custom session downloads
|
||||
if host.contains("streamtales.cc") ||
|
||||
host.contains("frembed.xyz") ||
|
||||
host.contains("vidclouds.cc") ||
|
||||
self.mp4CustomSessions?.values.contains(session) == true {
|
||||
|
||||
if let serverTrust = challenge.protectionSpace.serverTrust {
|
||||
// Log detailed info about the trust
|
||||
print("MP4 Download: Accepting certificate for \(host)")
|
||||
|
||||
let credential = URLCredential(trust: serverTrust)
|
||||
completionHandler(.useCredential, credential)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust else {
|
||||
completionHandler(.performDefaultHandling, nil)
|
||||
return
|
||||
}
|
||||
|
||||
// For other authentication challenges, use default handling
|
||||
print("MP4 Download: Using default handling for auth challenge")
|
||||
completionHandler(.performDefaultHandling, nil)
|
||||
let host = challenge.protectionSpace.host
|
||||
print("MP4 Download: Handling server trust challenge for host: \(host)")
|
||||
|
||||
// Define trusted hosts for MP4 downloads
|
||||
let trustedHosts = [
|
||||
"streamtales.cc",
|
||||
"frembed.xyz",
|
||||
"vidclouds.cc"
|
||||
]
|
||||
|
||||
let isTrustedHost = trustedHosts.contains { host.contains($0) }
|
||||
let isCustomSession = mp4CustomSessions?.values.contains(session) == true
|
||||
|
||||
if isTrustedHost || isCustomSession {
|
||||
guard let serverTrust = challenge.protectionSpace.serverTrust else {
|
||||
completionHandler(.performDefaultHandling, nil)
|
||||
return
|
||||
}
|
||||
|
||||
print("MP4 Download: Accepting certificate for \(host)")
|
||||
let credential = URLCredential(trust: serverTrust)
|
||||
completionHandler(.useCredential, credential)
|
||||
} else {
|
||||
print("MP4 Download: Using default handling for \(host)")
|
||||
completionHandler(.performDefaultHandling, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -47,17 +47,10 @@ extension JSController {
|
|||
if let subtitle = subtitleURL {
|
||||
print("Subtitle URL: \(subtitle.absoluteString)")
|
||||
}
|
||||
|
||||
// Check the stream type from the module metadata
|
||||
let streamType = module.metadata.streamType.lowercased()
|
||||
|
||||
// Determine which download method to use based on streamType
|
||||
if streamType == "mp4" || streamType == "direct" || url.absoluteString.contains(".mp4") {
|
||||
print("MP4 URL detected - downloading not supported")
|
||||
completionHandler?(false, "MP4 direct downloads are not supported. Please use HLS streams for downloading.")
|
||||
return
|
||||
} else if streamType == "hls" || streamType == "m3u8" || url.absoluteString.contains(".m3u8") {
|
||||
print("Using HLS download method")
|
||||
if streamType == "hls" || streamType == "m3u8" || url.absoluteString.contains(".m3u8") {
|
||||
Logger.shared.log("Using HLS download method")
|
||||
downloadWithM3U8Support(
|
||||
url: url,
|
||||
headers: headers,
|
||||
|
|
@ -71,22 +64,20 @@ extension JSController {
|
|||
showPosterURL: showPosterURL,
|
||||
completionHandler: completionHandler
|
||||
)
|
||||
} else {
|
||||
// Default to M3U8 method for unknown types, as it has fallback mechanisms
|
||||
print("Using default HLS download method for unknown stream type: \(streamType)")
|
||||
downloadWithM3U8Support(
|
||||
}else {
|
||||
Logger.shared.log("Using MP4 download method")
|
||||
downloadMP4(
|
||||
url: url,
|
||||
headers: headers,
|
||||
title: title,
|
||||
imageURL: imageURL,
|
||||
imageURL: imageURL ?? showPosterURL,
|
||||
isEpisode: isEpisode,
|
||||
showTitle: showTitle,
|
||||
season: season,
|
||||
episode: episode,
|
||||
subtitleURL: subtitleURL,
|
||||
showPosterURL: showPosterURL,
|
||||
completionHandler: completionHandler
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
// Created by Francesco on 30/03/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import JavaScriptCore
|
||||
|
||||
extension JSController {
|
||||
|
|
@ -51,7 +52,7 @@ extension JSController {
|
|||
let episodesResult = fetchEpisodesFunction.call(withArguments: [html]).toArray() as? [[String: String]] {
|
||||
for episodeData in episodesResult {
|
||||
if let num = episodeData["number"], let link = episodeData["href"], let number = Int(num) {
|
||||
episodeLinks.append(EpisodeLink(number: number, href: link))
|
||||
episodeLinks.append(EpisodeLink(number: number, title: "", href: link, duration: nil))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -152,7 +153,9 @@ extension JSController {
|
|||
episodeLinks = array.map { item -> EpisodeLink in
|
||||
EpisodeLink(
|
||||
number: item["number"] as? Int ?? 0,
|
||||
href: item["href"] as? String ?? ""
|
||||
title: "",
|
||||
href: item["href"] as? String ?? "",
|
||||
duration: nil
|
||||
)
|
||||
}
|
||||
} else {
|
||||
|
|
@ -183,3 +186,11 @@ extension JSController {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct EpisodeLink: Identifiable {
|
||||
let id = UUID()
|
||||
let number: Int
|
||||
let title: String
|
||||
let href: String
|
||||
let duration: Int?
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,39 +11,26 @@ import SwiftUI
|
|||
import AVKit
|
||||
import AVFoundation
|
||||
|
||||
// Use ScrapingModule from Modules.swift as Module
|
||||
typealias Module = ScrapingModule
|
||||
|
||||
class JSController: NSObject, ObservableObject {
|
||||
// Shared instance that can be used across the app
|
||||
static let shared = JSController()
|
||||
|
||||
var context: JSContext
|
||||
|
||||
// Downloaded assets storage
|
||||
@Published var savedAssets: [DownloadedAsset] = []
|
||||
@Published var activeDownloads: [JSActiveDownload] = []
|
||||
|
||||
// Tracking map for download tasks
|
||||
var activeDownloadMap: [URLSessionTask: UUID] = [:]
|
||||
|
||||
// Download queue management
|
||||
@Published var downloadQueue: [JSActiveDownload] = []
|
||||
var isProcessingQueue: Bool = false
|
||||
|
||||
var maxConcurrentDownloads: Int {
|
||||
UserDefaults.standard.object(forKey: "maxConcurrentDownloads") as? Int ?? 3
|
||||
}
|
||||
|
||||
// Track downloads that have been cancelled to prevent completion processing
|
||||
var cancelledDownloadIDs: Set<UUID> = []
|
||||
|
||||
// Download session
|
||||
var downloadURLSession: AVAssetDownloadURLSession?
|
||||
|
||||
// For MP4 download progress tracking
|
||||
var mp4ProgressObservations: [UUID: NSKeyValueObservation]?
|
||||
|
||||
// For storing custom URLSessions used for MP4 downloads
|
||||
var mp4CustomSessions: [UUID: URLSession]?
|
||||
|
||||
override init() {
|
||||
|
|
@ -58,9 +45,7 @@ class JSController: NSObject, ObservableObject {
|
|||
setupDownloadSession()
|
||||
}
|
||||
|
||||
// Setup download functionality separately from general context setup
|
||||
private func setupDownloadSession() {
|
||||
// Only initialize download session if it doesn't exist already
|
||||
if downloadURLSession == nil {
|
||||
initializeDownloadSession()
|
||||
setupDownloadFunction()
|
||||
|
|
@ -69,7 +54,6 @@ class JSController: NSObject, ObservableObject {
|
|||
|
||||
func loadScript(_ script: String) {
|
||||
context = JSContext()
|
||||
// Only set up the JavaScript environment without reinitializing the download session
|
||||
context.setupJavaScriptEnvironment()
|
||||
context.evaluateScript(script)
|
||||
if let exception = context.exception {
|
||||
|
|
@ -77,23 +61,15 @@ class JSController: NSObject, ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Download Settings
|
||||
|
||||
/// Updates the maximum number of concurrent downloads and processes the queue if new slots are available
|
||||
func updateMaxConcurrentDownloads(_ newLimit: Int) {
|
||||
print("Updating max concurrent downloads from \(maxConcurrentDownloads) to \(newLimit)")
|
||||
|
||||
// The maxConcurrentDownloads computed property will automatically use the new UserDefaults value
|
||||
// If the new limit is higher and we have queued downloads, process the queue
|
||||
if !downloadQueue.isEmpty && !isProcessingQueue {
|
||||
print("Processing download queue due to increased concurrent limit. Queue has \(downloadQueue.count) items.")
|
||||
|
||||
// Force UI update before processing queue
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.objectWillChange.send()
|
||||
|
||||
// Process the queue with a slight delay to ensure UI is ready
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
||||
self?.processDownloadQueue()
|
||||
}
|
||||
|
|
@ -102,21 +78,6 @@ class JSController: NSObject, ObservableObject {
|
|||
print("No queued downloads to process or queue is already being processed")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stream URL Functions - Convenience methods
|
||||
|
||||
func fetchStreamUrl(episodeUrl: String, module: Module, completion: @escaping ((streams: [String]?, subtitles: [String]?)) -> Void) {
|
||||
// Implementation for the main fetchStreamUrl method
|
||||
}
|
||||
|
||||
func fetchStreamUrlJS(episodeUrl: String, module: Module, completion: @escaping ((streams: [String]?, subtitles: [String]?)) -> Void) {
|
||||
// Implementation for the JS based stream URL fetching
|
||||
}
|
||||
|
||||
func fetchStreamUrlJSSecond(episodeUrl: String, module: Module, completion: @escaping ((streams: [String]?, subtitles: [String]?)) -> Void) {
|
||||
// Implementation for the secondary JS based stream URL fetching
|
||||
}
|
||||
|
||||
// MARK: - Header Management
|
||||
// Header management functions are implemented in JSController-HeaderManager.swift extension file
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -20,8 +20,9 @@ class Logger {
|
|||
private var logs: [LogEntry] = []
|
||||
private let logFileURL: URL
|
||||
private let logFilterViewModel = LogFilterViewModel.shared
|
||||
|
||||
|
||||
private let maxFileSize = 1024 * 512
|
||||
private let maxLogEntries = 1000
|
||||
|
||||
private init() {
|
||||
let documentDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
|
||||
|
|
@ -35,6 +36,11 @@ class Logger {
|
|||
|
||||
queue.async(flags: .barrier) {
|
||||
self.logs.append(entry)
|
||||
|
||||
if self.logs.count > self.maxLogEntries {
|
||||
self.logs.removeFirst(self.logs.count - self.maxLogEntries)
|
||||
}
|
||||
|
||||
self.saveLogToFile(entry)
|
||||
self.debugLog(entry)
|
||||
}
|
||||
|
|
@ -51,6 +57,18 @@ class Logger {
|
|||
return result
|
||||
}
|
||||
|
||||
func getLogsAsync() async -> String {
|
||||
return await withCheckedContinuation { continuation in
|
||||
queue.async {
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.dateFormat = "dd-MM HH:mm:ss"
|
||||
let result = self.logs.map { "[\(dateFormatter.string(from: $0.timestamp))] [\($0.type)] \($0.message)" }
|
||||
.joined(separator: "\n----\n")
|
||||
continuation.resume(returning: result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func clearLogs() {
|
||||
queue.async(flags: .barrier) {
|
||||
self.logs.removeAll()
|
||||
|
|
@ -58,49 +76,73 @@ class Logger {
|
|||
}
|
||||
}
|
||||
|
||||
func clearLogsAsync() async {
|
||||
await withCheckedContinuation { continuation in
|
||||
queue.async(flags: .barrier) {
|
||||
self.logs.removeAll()
|
||||
try? FileManager.default.removeItem(at: self.logFileURL)
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func saveLogToFile(_ log: LogEntry) {
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.dateFormat = "dd-MM HH:mm:ss"
|
||||
|
||||
let logString = "[\(dateFormatter.string(from: log.timestamp))] [\(log.type)] \(log.message)\n---\n"
|
||||
|
||||
if let data = logString.data(using: .utf8) {
|
||||
guard let data = logString.data(using: .utf8) else {
|
||||
print("Failed to encode log string to UTF-8")
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
if FileManager.default.fileExists(atPath: logFileURL.path) {
|
||||
do {
|
||||
let attributes = try FileManager.default.attributesOfItem(atPath: logFileURL.path)
|
||||
let fileSize = attributes[.size] as? UInt64 ?? 0
|
||||
|
||||
if fileSize + UInt64(data.count) > maxFileSize {
|
||||
guard var content = try? String(contentsOf: logFileURL, encoding: .utf8) else { return }
|
||||
|
||||
while (content.data(using: .utf8)?.count ?? 0) + data.count > maxFileSize {
|
||||
if let rangeOfFirstLine = content.range(of: "\n---\n") {
|
||||
content.removeSubrange(content.startIndex...rangeOfFirstLine.upperBound)
|
||||
} else {
|
||||
content = ""
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
content += logString
|
||||
try? content.data(using: .utf8)?.write(to: logFileURL)
|
||||
} else {
|
||||
if let handle = try? FileHandle(forWritingTo: logFileURL) {
|
||||
handle.seekToEndOfFile()
|
||||
handle.write(data)
|
||||
handle.closeFile()
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
print("Error managing log file: \(error)")
|
||||
let attributes = try FileManager.default.attributesOfItem(atPath: logFileURL.path)
|
||||
let fileSize = attributes[.size] as? UInt64 ?? 0
|
||||
|
||||
if fileSize + UInt64(data.count) > maxFileSize {
|
||||
self.truncateLogFile()
|
||||
}
|
||||
|
||||
if let handle = try? FileHandle(forWritingTo: logFileURL) {
|
||||
handle.seekToEndOfFile()
|
||||
handle.write(data)
|
||||
handle.closeFile()
|
||||
}
|
||||
} else {
|
||||
try? data.write(to: logFileURL)
|
||||
try data.write(to: logFileURL)
|
||||
}
|
||||
} catch {
|
||||
print("Error managing log file: \(error)")
|
||||
try? data.write(to: logFileURL)
|
||||
}
|
||||
}
|
||||
|
||||
private func truncateLogFile() {
|
||||
do {
|
||||
guard let content = try? String(contentsOf: logFileURL, encoding: .utf8),
|
||||
!content.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
let entries = content.components(separatedBy: "\n---\n")
|
||||
guard entries.count > 10 else { return }
|
||||
|
||||
let keepCount = entries.count / 2
|
||||
let truncatedEntries = Array(entries.suffix(keepCount))
|
||||
let truncatedContent = truncatedEntries.joined(separator: "\n---\n")
|
||||
|
||||
if let truncatedData = truncatedContent.data(using: .utf8) {
|
||||
try truncatedData.write(to: logFileURL)
|
||||
}
|
||||
} catch {
|
||||
print("Error truncating log file: \(error)")
|
||||
try? FileManager.default.removeItem(at: logFileURL)
|
||||
}
|
||||
}
|
||||
|
||||
/// Prints log messages to the Xcode console only in DEBUG mode
|
||||
private func debugLog(_ entry: LogEntry) {
|
||||
#if DEBUG
|
||||
let dateFormatter = DateFormatter()
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
|||
private var aniListUpdateImpossible: Bool = false
|
||||
private var aniListRetryCount = 0
|
||||
private let aniListMaxRetries = 6
|
||||
private let totalEpisodes: Int
|
||||
|
||||
var player: AVPlayer!
|
||||
var timeObserverToken: Any?
|
||||
|
|
@ -40,6 +41,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
|||
var currentTimeVal: Double = 0.0
|
||||
var duration: Double = 0.0
|
||||
var isVideoLoaded = false
|
||||
var detachedWindow: UIWindow?
|
||||
|
||||
private var isHoldPauseEnabled: Bool {
|
||||
UserDefaults.standard.bool(forKey: "holdForPauseEnabled")
|
||||
|
|
@ -59,6 +61,20 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
|||
return UserDefaults.standard.bool(forKey: "doubleTapSeekEnabled")
|
||||
}
|
||||
|
||||
private var isPipAutoEnabled: Bool {
|
||||
UserDefaults.standard.bool(forKey: "pipAutoEnabled")
|
||||
}
|
||||
|
||||
private var isPipButtonVisible: Bool {
|
||||
if UserDefaults.standard.object(forKey: "pipButtonVisible") == nil {
|
||||
return true
|
||||
}
|
||||
return UserDefaults.standard.bool(forKey: "pipButtonVisible")
|
||||
}
|
||||
private var pipController: AVPictureInPictureController?
|
||||
private var pipButton: UIButton!
|
||||
|
||||
|
||||
var portraitButtonVisibleConstraints: [NSLayoutConstraint] = []
|
||||
var portraitButtonHiddenConstraints: [NSLayoutConstraint] = []
|
||||
var landscapeButtonVisibleConstraints: [NSLayoutConstraint] = []
|
||||
|
|
@ -138,6 +154,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
|||
private var playerItemKVOContext = 0
|
||||
private var loadedTimeRangesObservation: NSKeyValueObservation?
|
||||
private var playerTimeControlStatusObserver: NSKeyValueObservation?
|
||||
private var playerRateObserver: NSKeyValueObservation?
|
||||
|
||||
private var controlsLocked = false
|
||||
private var lockButtonTimer: Timer?
|
||||
|
|
@ -175,6 +192,10 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
|||
private var subtitleDelay: Double = 0.0
|
||||
var currentPlaybackSpeed: Float = 1.0
|
||||
|
||||
private var wasPlayingBeforeBackground = false
|
||||
private var backgroundToken: Any?
|
||||
private var foregroundToken: Any?
|
||||
|
||||
init(module: ScrapingModule,
|
||||
urlString: String,
|
||||
fullUrl: String,
|
||||
|
|
@ -183,6 +204,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
|||
onWatchNext: @escaping () -> Void,
|
||||
subtitlesURL: String?,
|
||||
aniListID: Int,
|
||||
totalEpisodes: Int,
|
||||
episodeImageUrl: String,headers:[String:String]?) {
|
||||
|
||||
self.module = module
|
||||
|
|
@ -195,6 +217,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
|||
self.subtitlesURL = subtitlesURL
|
||||
self.aniListID = aniListID
|
||||
self.headers = headers
|
||||
self.totalEpisodes = totalEpisodes
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
|
||||
|
|
@ -256,6 +279,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
|||
setupAudioSession()
|
||||
updateSkipButtonsVisibility()
|
||||
setupHoldSpeedIndicator()
|
||||
setupPipIfSupported()
|
||||
|
||||
view.bringSubviewToFront(subtitleStackView)
|
||||
|
||||
|
|
@ -286,6 +310,17 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
|||
Logger.shared.log("Error activating audio session: \(error)", type: "Debug")
|
||||
}
|
||||
|
||||
playerRateObserver = player.observe(\.rate, options: [.new, .old]) { [weak self] player, change in
|
||||
guard let self = self else { return }
|
||||
DispatchQueue.main.async {
|
||||
let isActuallyPlaying = player.rate != 0
|
||||
if self.isPlaying != isActuallyPlaying {
|
||||
self.isPlaying = isActuallyPlaying
|
||||
self.playPauseButton.image = UIImage(systemName: isActuallyPlaying ? "pause.fill" : "play.fill")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
volumeViewModel.value = Double(audioSession.outputVolume)
|
||||
|
||||
volumeObserver = audioSession.observe(\.outputVolume, options: [.new]) { [weak self] session, change in
|
||||
|
|
@ -386,6 +421,24 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
|||
player.pause()
|
||||
}
|
||||
|
||||
deinit {
|
||||
playerRateObserver?.invalidate()
|
||||
inactivityTimer?.invalidate()
|
||||
updateTimer?.invalidate()
|
||||
lockButtonTimer?.invalidate()
|
||||
dimButtonTimer?.invalidate()
|
||||
loadedTimeRangesObservation?.invalidate()
|
||||
playerTimeControlStatusObserver?.invalidate()
|
||||
volumeObserver?.invalidate()
|
||||
|
||||
player.replaceCurrentItem(with: nil)
|
||||
player.pause()
|
||||
|
||||
playerViewController = nil
|
||||
sliderHostingController = nil
|
||||
try? AVAudioSession.sharedInstance().setActive(false)
|
||||
}
|
||||
|
||||
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
|
||||
guard context == &playerItemKVOContext else {
|
||||
super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
|
||||
|
|
@ -1048,7 +1101,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
|||
func setupSkipButtons() {
|
||||
let introConfig = UIImage.SymbolConfiguration(pointSize: 14, weight: .bold)
|
||||
let introImage = UIImage(systemName: "forward.frame", withConfiguration: introConfig)
|
||||
skipIntroButton = UIButton(type: .system)
|
||||
skipIntroButton = GradientOverlayButton(type: .system)
|
||||
skipIntroButton.setTitle(" Skip Intro", for: .normal)
|
||||
skipIntroButton.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .bold)
|
||||
skipIntroButton.setImage(introImage, for: .normal)
|
||||
|
|
@ -1080,7 +1133,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
|||
|
||||
let outroConfig = UIImage.SymbolConfiguration(pointSize: 14, weight: .bold)
|
||||
let outroImage = UIImage(systemName: "forward.frame", withConfiguration: outroConfig)
|
||||
skipOutroButton = UIButton(type: .system)
|
||||
skipOutroButton = GradientOverlayButton(type: .system)
|
||||
skipOutroButton.setTitle(" Skip Outro", for: .normal)
|
||||
skipOutroButton.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .bold)
|
||||
skipOutroButton.setImage(outroImage, for: .normal)
|
||||
|
|
@ -1186,6 +1239,53 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
|||
}
|
||||
}
|
||||
|
||||
private func setupPipIfSupported() {
|
||||
guard AVPictureInPictureController.isPictureInPictureSupported() else {
|
||||
return
|
||||
}
|
||||
let pipPlayerLayer = AVPlayerLayer(player: playerViewController.player)
|
||||
pipPlayerLayer.frame = playerViewController.view.layer.bounds
|
||||
pipPlayerLayer.videoGravity = .resizeAspect
|
||||
|
||||
playerViewController.view.layer.insertSublayer(pipPlayerLayer, at: 0)
|
||||
pipController = AVPictureInPictureController(playerLayer: pipPlayerLayer)
|
||||
pipController?.delegate = self
|
||||
|
||||
let config = UIImage.SymbolConfiguration(pointSize: 15, weight: .medium)
|
||||
let Image = UIImage(systemName: "pip", withConfiguration: config)
|
||||
pipButton = UIButton(type: .system)
|
||||
pipButton.setImage(Image, for: .normal)
|
||||
pipButton.tintColor = .white
|
||||
pipButton.addTarget(self, action: #selector(pipButtonTapped(_:)), for: .touchUpInside)
|
||||
|
||||
pipButton.layer.shadowColor = UIColor.black.cgColor
|
||||
pipButton.layer.shadowOffset = CGSize(width: 0, height: 2)
|
||||
pipButton.layer.shadowOpacity = 0.6
|
||||
pipButton.layer.shadowRadius = 4
|
||||
pipButton.layer.masksToBounds = false
|
||||
|
||||
controlsContainerView.addSubview(pipButton)
|
||||
pipButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
// NEW: pin pipButton to the left of lockButton:
|
||||
NSLayoutConstraint.activate([
|
||||
pipButton.centerYAnchor.constraint(equalTo: dimButton.centerYAnchor),
|
||||
pipButton.trailingAnchor.constraint(equalTo: dimButton.leadingAnchor, constant: -8),
|
||||
pipButton.widthAnchor.constraint(equalToConstant: 44),
|
||||
pipButton.heightAnchor.constraint(equalToConstant: 44)
|
||||
])
|
||||
|
||||
pipButton.isHidden = !isPipButtonVisible
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(startPipIfNeeded),
|
||||
name: UIApplication.willResignActiveNotification,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
func setupMenuButton() {
|
||||
let config = UIImage.SymbolConfiguration(pointSize: 15, weight: .bold)
|
||||
let image = UIImage(systemName: "text.bubble", withConfiguration: config)
|
||||
|
|
@ -1280,7 +1380,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
|||
let config = UIImage.SymbolConfiguration(pointSize: 14, weight: .bold)
|
||||
let image = UIImage(systemName: "goforward", withConfiguration: config)
|
||||
|
||||
skip85Button = UIButton(type: .system)
|
||||
skip85Button = GradientOverlayButton(type: .system)
|
||||
skip85Button.setTitle(" Skip 85s", for: .normal)
|
||||
skip85Button.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .bold)
|
||||
skip85Button.setImage(image, for: .normal)
|
||||
|
|
@ -1424,7 +1524,8 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
|||
subtitles: self.subtitlesURL,
|
||||
aniListID: self.aniListID,
|
||||
module: self.module,
|
||||
headers: self.headers
|
||||
headers: self.headers,
|
||||
totalEpisodes: self.totalEpisodes
|
||||
)
|
||||
ContinueWatchingManager.shared.save(item: item)
|
||||
}
|
||||
|
|
@ -1641,6 +1742,24 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
|||
}
|
||||
}
|
||||
|
||||
@objc private func pipButtonTapped(_ sender: UIButton) {
|
||||
guard let pip = pipController else { return }
|
||||
if pip.isPictureInPictureActive {
|
||||
pip.stopPictureInPicture()
|
||||
} else {
|
||||
pip.startPictureInPicture()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func startPipIfNeeded() {
|
||||
guard isPipAutoEnabled,
|
||||
let pip = pipController,
|
||||
!pip.isPictureInPictureActive else {
|
||||
return
|
||||
}
|
||||
pip.startPictureInPicture()
|
||||
}
|
||||
|
||||
@objc private func lockTapped() {
|
||||
controlsLocked.toggle()
|
||||
|
||||
|
|
@ -1681,7 +1800,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
|||
updateSkipButtonsVisibility()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@objc private func skipIntro() {
|
||||
if let range = skipIntervals.op {
|
||||
player.seek(to: range.end)
|
||||
|
|
@ -1697,12 +1816,15 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
|||
}
|
||||
|
||||
@objc func dismissTapped() {
|
||||
dismiss(animated: true, completion: nil)
|
||||
dismiss(animated: true) { [weak self] in
|
||||
self?.detachedWindow = nil
|
||||
}
|
||||
}
|
||||
|
||||
@objc func watchNextTapped() {
|
||||
player.pause()
|
||||
dismiss(animated: true) { [weak self] in
|
||||
self?.detachedWindow = nil
|
||||
self?.onWatchNext()
|
||||
}
|
||||
}
|
||||
|
|
@ -1758,35 +1880,77 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
|||
}
|
||||
|
||||
private func tryAniListUpdate() {
|
||||
let aniListMutation = AniListMutation()
|
||||
aniListMutation.updateAnimeProgress(animeId: self.aniListID, episodeNumber: self.episodeNumber) { [weak self] result in
|
||||
guard !aniListUpdatedSuccessfully else { return }
|
||||
|
||||
guard aniListID > 0 else {
|
||||
Logger.shared.log("AniList ID is invalid, skipping update.", type: "Warning")
|
||||
return
|
||||
}
|
||||
|
||||
let client = AniListMutation()
|
||||
|
||||
client.fetchMediaStatus(mediaId: aniListID) { [weak self] statusResult in
|
||||
guard let self = self else { return }
|
||||
|
||||
let newStatus: String = {
|
||||
switch statusResult {
|
||||
case .success(let mediaStatus):
|
||||
if mediaStatus == "RELEASING" {
|
||||
return "CURRENT"
|
||||
}
|
||||
return (self.episodeNumber == self.totalEpisodes) ? "COMPLETED" : "CURRENT"
|
||||
|
||||
case .failure(let error):
|
||||
Logger.shared.log(
|
||||
"Failed to fetch AniList status: \(error.localizedDescription). " +
|
||||
"Using default CURRENT/COMPLETED logic.",
|
||||
type: "Warning"
|
||||
)
|
||||
return (self.episodeNumber == self.totalEpisodes) ? "COMPLETED" : "CURRENT"
|
||||
}
|
||||
}()
|
||||
|
||||
switch result {
|
||||
case .success:
|
||||
self.aniListUpdatedSuccessfully = true
|
||||
Logger.shared.log("Successfully updated AniList progress for episode \(self.episodeNumber)", type: "General")
|
||||
|
||||
case .failure(let error):
|
||||
let errorString = error.localizedDescription.lowercased()
|
||||
Logger.shared.log("AniList progress update failed: \(errorString)", type: "Error")
|
||||
|
||||
if errorString.contains("access token not found") {
|
||||
Logger.shared.log("AniList update will NOT retry due to missing token.", type: "Error")
|
||||
self.aniListUpdateImpossible = true
|
||||
|
||||
} else {
|
||||
if self.aniListRetryCount < self.aniListMaxRetries {
|
||||
self.aniListRetryCount += 1
|
||||
|
||||
let delaySeconds = 5.0
|
||||
Logger.shared.log("AniList update will retry in \(delaySeconds)s (attempt \(self.aniListRetryCount)).", type: "Debug")
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delaySeconds) {
|
||||
self.tryAniListUpdate()
|
||||
}
|
||||
client.updateAnimeProgress(
|
||||
animeId: self.aniListID,
|
||||
episodeNumber: self.episodeNumber,
|
||||
status: newStatus
|
||||
) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
self.aniListUpdatedSuccessfully = true
|
||||
Logger.shared.log(
|
||||
"AniList progress updated to \(newStatus) for ep \(self.episodeNumber)",
|
||||
type: "General"
|
||||
)
|
||||
|
||||
case .failure(let error):
|
||||
let errorString = error.localizedDescription.lowercased()
|
||||
Logger.shared.log("AniList progress update failed: \(errorString)", type: "Error")
|
||||
|
||||
if errorString.contains("access token not found") {
|
||||
Logger.shared.log("AniList update will NOT retry due to missing token.", type: "Error")
|
||||
self.aniListUpdateImpossible = true
|
||||
|
||||
} else {
|
||||
Logger.shared.log("AniList update reached max retries. No more attempts.", type: "Error")
|
||||
if self.aniListRetryCount < self.aniListMaxRetries {
|
||||
self.aniListRetryCount += 1
|
||||
|
||||
let delaySeconds = 5.0
|
||||
Logger.shared.log(
|
||||
"AniList update will retry in \(delaySeconds)s " +
|
||||
"(attempt \(self.aniListRetryCount)).",
|
||||
type: "Debug"
|
||||
)
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delaySeconds) {
|
||||
self.tryAniListUpdate()
|
||||
}
|
||||
} else {
|
||||
Logger.shared.log(
|
||||
"Reached max retry count (\(self.aniListMaxRetries)). Giving up.",
|
||||
type: "Error"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2323,7 +2487,9 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
|||
switch gesture.state {
|
||||
case .ended:
|
||||
if translation.y > 100 {
|
||||
dismiss(animated: true, completion: nil)
|
||||
dismiss(animated: true) { [weak self] in
|
||||
self?.detachedWindow = nil
|
||||
}
|
||||
}
|
||||
default:
|
||||
break
|
||||
|
|
@ -2407,7 +2573,62 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
|||
}
|
||||
}
|
||||
|
||||
class GradientOverlayButton: UIButton {
|
||||
private let gradientLayer = CAGradientLayer()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
setupGradient()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
setupGradient()
|
||||
}
|
||||
|
||||
private func setupGradient() {
|
||||
gradientLayer.colors = [
|
||||
UIColor.white.withAlphaComponent(0.25).cgColor,
|
||||
UIColor.white.withAlphaComponent(0).cgColor
|
||||
]
|
||||
gradientLayer.startPoint = CGPoint(x: 0.5, y: 0)
|
||||
gradientLayer.endPoint = CGPoint(x: 0.5, y: 1)
|
||||
layer.addSublayer(gradientLayer)
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
gradientLayer.frame = bounds
|
||||
|
||||
let path = UIBezierPath(roundedRect: bounds.insetBy(dx: 0.25, dy: 0.25), cornerRadius: bounds.height / 2)
|
||||
let maskLayer = CAShapeLayer()
|
||||
maskLayer.path = path.cgPath
|
||||
maskLayer.fillColor = nil
|
||||
maskLayer.strokeColor = UIColor.white.cgColor
|
||||
maskLayer.lineWidth = 0.5
|
||||
gradientLayer.mask = maskLayer
|
||||
}
|
||||
}
|
||||
|
||||
extension CustomMediaPlayerViewController: AVPictureInPictureControllerDelegate {
|
||||
func pictureInPictureControllerWillStartPictureInPicture(_ pipController: AVPictureInPictureController) {
|
||||
pipButton.alpha = 0.5
|
||||
}
|
||||
|
||||
func pictureInPictureControllerDidStopPictureInPicture(_ pipController: AVPictureInPictureController) {
|
||||
pipButton.alpha = 1.0
|
||||
}
|
||||
|
||||
func pictureInPictureController(_ pipController: AVPictureInPictureController,
|
||||
failedToStartPictureInPictureWithError error: Error) {
|
||||
|
||||
Logger.shared.log("PiP failed to start: \(error.localizedDescription)", type: "Error")
|
||||
}
|
||||
}
|
||||
|
||||
// yes? Like the plural of the famous american rapper ye? -IBHRAD
|
||||
// low taper fade the meme is massive -cranci
|
||||
// cranci still doesnt have a job -seiike
|
||||
// The mind is the source of good and evil, only you yourself can decide which you will bring yourself. -seiike
|
||||
// guys watch Clannad already - ibro
|
||||
// May the Divine Providence bestow its infinite mercy upon your soul, and may eternal grace find you beyond the shadows of this mortal realm. - paul, 15/11/2005 - 13/05/2023
|
||||
// this dumbass ↑ defo used gpt
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ class NormalPlayer: AVPlayerViewController {
|
|||
private func endHoldSpeed() {
|
||||
player?.rate = originalRate
|
||||
}
|
||||
|
||||
|
||||
func setupAudioSession() {
|
||||
do {
|
||||
let audioSession = AVAudioSession.sharedInstance()
|
||||
|
|
|
|||
|
|
@ -19,10 +19,12 @@ class VideoPlayerViewController: UIViewController {
|
|||
var subtitles: String = ""
|
||||
var aniListID: Int = 0
|
||||
var headers: [String:String]? = nil
|
||||
var totalEpisodes: Int = 0
|
||||
|
||||
var episodeNumber: Int = 0
|
||||
var episodeImageUrl: String = ""
|
||||
var mediaTitle: String = ""
|
||||
var detachedWindow: UIWindow?
|
||||
|
||||
init(module: ScrapingModule) {
|
||||
self.module = module
|
||||
|
|
@ -41,15 +43,11 @@ class VideoPlayerViewController: UIViewController {
|
|||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
if let mydict = headers, !mydict.isEmpty
|
||||
{
|
||||
for (key,value) in mydict
|
||||
{
|
||||
if let mydict = headers, !mydict.isEmpty {
|
||||
for (key,value) in mydict {
|
||||
request.addValue(value, forHTTPHeaderField: key)
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
} else {
|
||||
request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Referer")
|
||||
request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Origin")
|
||||
}
|
||||
|
|
@ -139,7 +137,8 @@ class VideoPlayerViewController: UIViewController {
|
|||
subtitles: self.subtitles,
|
||||
aniListID: self.aniListID,
|
||||
module: self.module,
|
||||
headers: self.headers
|
||||
headers: self.headers,
|
||||
totalEpisodes: self.totalEpisodes
|
||||
)
|
||||
ContinueWatchingManager.shared.save(item: item)
|
||||
}
|
||||
|
|
|
|||
13
Sora/Utils/Models/TabItem.swift
Normal 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
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ private struct ModuleLink: Identifiable {
|
|||
|
||||
struct CommunityLibraryView: View {
|
||||
@EnvironmentObject var moduleManager: ModuleManager
|
||||
@EnvironmentObject var tabBarController: TabBarController
|
||||
|
||||
@AppStorage("lastCommunityURL") private var inputURL: String = ""
|
||||
@State private var webURL: URL?
|
||||
|
|
@ -30,7 +31,6 @@ struct CommunityLibraryView: View {
|
|||
}
|
||||
|
||||
WebView(url: webURL) { linkURL in
|
||||
|
||||
if let comps = URLComponents(url: linkURL, resolvingAgainstBaseURL: false),
|
||||
let m = comps.queryItems?.first(where: { $0.name == "url" })?.value {
|
||||
moduleLinkToAdd = ModuleLink(url: m)
|
||||
|
|
@ -38,7 +38,13 @@ struct CommunityLibraryView: View {
|
|||
}
|
||||
.ignoresSafeArea(edges: .top)
|
||||
}
|
||||
.onAppear(perform: loadURL)
|
||||
.onAppear {
|
||||
loadURL()
|
||||
tabBarController.hideTabBar()
|
||||
}
|
||||
.onDisappear {
|
||||
tabBarController.showTabBar()
|
||||
}
|
||||
.sheet(item: $moduleLinkToAdd) { link in
|
||||
ModuleAdditionSettingsView(moduleUrl: link.url)
|
||||
.environmentObject(moduleManager)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import Kingfisher
|
|||
struct ModuleAdditionSettingsView: View {
|
||||
@Environment(\.presentationMode) var presentationMode
|
||||
@EnvironmentObject var moduleManager: ModuleManager
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
@State private var moduleMetadata: ModuleMetadata?
|
||||
@State private var isLoading = false
|
||||
|
|
@ -115,13 +116,14 @@ struct ModuleAdditionSettingsView: View {
|
|||
Text("Add Module")
|
||||
}
|
||||
.font(.headline)
|
||||
.foregroundColor(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.foregroundColor(colorScheme == .dark ? .black : .white)
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 15)
|
||||
.fill(Color.accentColor)
|
||||
.foregroundColor(colorScheme == .dark ? .white : .black)
|
||||
)
|
||||
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.disabled(isLoading)
|
||||
|
|
@ -131,7 +133,7 @@ struct ModuleAdditionSettingsView: View {
|
|||
self.presentationMode.wrappedValue.dismiss()
|
||||
}) {
|
||||
Text("Cancel")
|
||||
.foregroundColor((Color.accentColor))
|
||||
.foregroundColor(colorScheme == .dark ? Color.white : Color.black)
|
||||
.padding(.top, 10)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
46
Sora/Utils/ProgressiveBlurView/ProgressiveBlurView.swift
Normal 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 = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,27 +8,56 @@
|
|||
import SwiftUI
|
||||
|
||||
struct Shimmer: ViewModifier {
|
||||
@State private var phase: CGFloat = 0
|
||||
@State private var phase: CGFloat = -1
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.overlay(
|
||||
Rectangle()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
gradient: Gradient(colors: [Color.clear, Color.white.opacity(0.4), Color.clear]),
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
.modifier(AnimatedMask(phase: phase)
|
||||
.animation(
|
||||
Animation.linear(duration: 1.2)
|
||||
.repeatForever(autoreverses: false)
|
||||
)
|
||||
)
|
||||
.rotationEffect(.degrees(30))
|
||||
.offset(x: self.phase * 350)
|
||||
)
|
||||
.mask(content)
|
||||
.onAppear {
|
||||
withAnimation(.linear(duration: 1.5).repeatForever(autoreverses: false)) {
|
||||
self.phase = 1
|
||||
}
|
||||
phase = 1.5
|
||||
}
|
||||
}
|
||||
|
||||
struct AnimatedMask: AnimatableModifier {
|
||||
var phase: CGFloat = 0
|
||||
|
||||
var animatableData: CGFloat {
|
||||
get { phase }
|
||||
set { phase = newValue }
|
||||
}
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.overlay(
|
||||
GeometryReader { geo in
|
||||
let width = geo.size.width
|
||||
let shimmerStart = phase - 0.25
|
||||
let shimmerEnd = phase + 0.25
|
||||
Rectangle()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
gradient: Gradient(stops: [
|
||||
.init(color: Color.white.opacity(0.05), location: shimmerStart - 0.15),
|
||||
.init(color: Color.white.opacity(0.25), location: shimmerStart),
|
||||
.init(color: Color.white.opacity(0.85), location: phase),
|
||||
.init(color: Color.white.opacity(0.25), location: shimmerEnd),
|
||||
.init(color: Color.white.opacity(0.05), location: shimmerEnd + 0.15)
|
||||
]),
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
)
|
||||
.blur(radius: 8)
|
||||
.rotationEffect(.degrees(20))
|
||||
.offset(x: -width * 0.7 + width * 2 * phase)
|
||||
}
|
||||
)
|
||||
.mask(content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,19 +11,11 @@ struct HomeSkeletonCell: View {
|
|||
let cellWidth: CGFloat
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(Color.gray.opacity(0.3))
|
||||
.frame(width: cellWidth, height: cellWidth * 1.5)
|
||||
.cornerRadius(10)
|
||||
.shimmering()
|
||||
|
||||
RoundedRectangle(cornerRadius: 5)
|
||||
.fill(Color.gray.opacity(0.3))
|
||||
.frame(width: cellWidth, height: 20)
|
||||
.padding(.top, 4)
|
||||
.shimmering()
|
||||
}
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(Color.gray.opacity(0.3))
|
||||
.frame(width: cellWidth, height: cellWidth * 1.5)
|
||||
.cornerRadius(10)
|
||||
.shimmering()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -31,15 +23,9 @@ struct SearchSkeletonCell: View {
|
|||
let cellWidth: CGFloat
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(Color.gray.opacity(0.3))
|
||||
.frame(width: cellWidth, height: cellWidth * 1.5)
|
||||
.shimmering()
|
||||
RoundedRectangle(cornerRadius: 5)
|
||||
.fill(Color.gray.opacity(0.3))
|
||||
.frame(width: cellWidth, height: 20)
|
||||
.shimmering()
|
||||
}
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(Color.gray.opacity(0.3))
|
||||
.frame(width: cellWidth, height: cellWidth * 1.5)
|
||||
.shimmering()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
267
Sora/Utils/TabBar/TabBar.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
24
Sora/Utils/TabBar/TabBarController.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
44
Sora/Utils/ViewModifiers/DeviceScaleModifier.swift
Normal 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())
|
||||
}
|
||||
}
|
||||
125
Sora/Views/LibraryView/AllBookmarks.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
309
Sora/Views/LibraryView/AllWatching.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
24
Sora/Views/LibraryView/BookmarkComponents/BookmarkLink.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import SwiftUI
|
||||
import Kingfisher
|
||||
import UIKit
|
||||
|
||||
struct LibraryView: View {
|
||||
@EnvironmentObject private var libraryManager: LibraryManager
|
||||
|
|
@ -16,19 +17,23 @@ struct LibraryView: View {
|
|||
@AppStorage("mediaColumnsLandscape") private var mediaColumnsLandscape: Int = 4
|
||||
|
||||
@Environment(\.verticalSizeClass) var verticalSizeClass
|
||||
@Environment(\.horizontalSizeClass) var horizontalSizeClass
|
||||
|
||||
@State private var selectedBookmark: LibraryItem? = nil
|
||||
@State private var isDetailActive: Bool = false
|
||||
|
||||
@State private var continueWatchingItems: [ContinueWatchingItem] = []
|
||||
@State private var isLandscape: Bool = UIDevice.current.orientation.isLandscape
|
||||
@State private var selectedTab: Int = 0
|
||||
|
||||
private let columns = [
|
||||
GridItem(.adaptive(minimum: 150), spacing: 12)
|
||||
]
|
||||
|
||||
private var columnsCount: Int {
|
||||
if UIDevice.current.userInterfaceIdiom == .pad {
|
||||
if UIDevice.current.userInterfaceIdiom == .pad && horizontalSizeClass == .compact {
|
||||
return verticalSizeClass == .compact ? 3 : 2
|
||||
} else if UIDevice.current.userInterfaceIdiom == .pad {
|
||||
let isLandscape = UIScreen.main.bounds.width > UIScreen.main.bounds.height
|
||||
return isLandscape ? mediaColumnsLandscape : mediaColumnsPortrait
|
||||
} else {
|
||||
|
|
@ -49,108 +54,97 @@ struct LibraryView: View {
|
|||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
ScrollView {
|
||||
let columnsCount = determineColumns()
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Continue Watching")
|
||||
.font(.title2)
|
||||
.bold()
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
if continueWatchingItems.isEmpty {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "play.circle")
|
||||
.font(.largeTitle)
|
||||
.foregroundColor(.secondary)
|
||||
Text("No items to continue watching.")
|
||||
.font(.headline)
|
||||
Text("Recently watched content will appear here.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
ContinueWatchingSection(items: $continueWatchingItems, markAsWatched: { item in
|
||||
markContinueWatchingItemAsWatched(item: item)
|
||||
}, removeItem: { item in
|
||||
removeContinueWatchingItem(item: item)
|
||||
})
|
||||
}
|
||||
|
||||
Text("Bookmarks")
|
||||
.font(.title2)
|
||||
.bold()
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
if libraryManager.bookmarks.isEmpty {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "magazine")
|
||||
.font(.largeTitle)
|
||||
.foregroundColor(.secondary)
|
||||
Text("You have no items saved.")
|
||||
.font(.headline)
|
||||
Text("Bookmark items for an easier access later.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: columnsCount), spacing: 12) {
|
||||
ForEach(libraryManager.bookmarks) { item in
|
||||
if let module = moduleManager.modules.first(where: { $0.id.uuidString == item.moduleId }) {
|
||||
Button(action: {
|
||||
selectedBookmark = item
|
||||
isDetailActive = true
|
||||
}) {
|
||||
VStack(alignment: .leading) {
|
||||
ZStack {
|
||||
KFImage(URL(string: item.imageUrl))
|
||||
.placeholder {
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(Color.gray.opacity(0.3))
|
||||
.aspectRatio(2/3, contentMode: .fit)
|
||||
.shimmering()
|
||||
}
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(height: cellWidth * 3 / 2)
|
||||
.frame(maxWidth: cellWidth)
|
||||
.cornerRadius(10)
|
||||
.clipped()
|
||||
.overlay(
|
||||
KFImage(URL(string: module.metadata.iconUrl))
|
||||
.resizable()
|
||||
.frame(width: 24, height: 24)
|
||||
.cornerRadius(4)
|
||||
.padding(4),
|
||||
alignment: .topLeading
|
||||
)
|
||||
}
|
||||
Text(item.title)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.primary)
|
||||
.lineLimit(1)
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
}
|
||||
.contextMenu {
|
||||
Button(role: .destructive, action: {
|
||||
libraryManager.removeBookmark(item: item)
|
||||
}) {
|
||||
Label("Remove from Bookmarks", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
ZStack {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
Text("Library")
|
||||
.font(.largeTitle)
|
||||
.fontWeight(.bold)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 20)
|
||||
HStack {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "play.fill")
|
||||
.font(.subheadline)
|
||||
Text("Continue Watching")
|
||||
.font(.title3)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
NavigationLink(destination: AllWatchingView()) {
|
||||
Text("View All")
|
||||
.font(.subheadline)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.gray.opacity(0.2))
|
||||
.cornerRadius(15)
|
||||
.gradientOutline()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
if continueWatchingItems.isEmpty {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "play.circle")
|
||||
.font(.largeTitle)
|
||||
.foregroundColor(.secondary)
|
||||
Text("No items to continue watching.")
|
||||
.font(.headline)
|
||||
Text("Recently watched content will appear here.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
ContinueWatchingSection(items: $continueWatchingItems, markAsWatched: {
|
||||
item in
|
||||
markContinueWatchingItemAsWatched(item: item)
|
||||
}, removeItem: {
|
||||
item in
|
||||
removeContinueWatchingItem(item: item)
|
||||
})
|
||||
}
|
||||
|
||||
HStack {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "bookmark.fill")
|
||||
.font(.subheadline)
|
||||
Text("Bookmarks")
|
||||
.font(.title3)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
NavigationLink(destination: BookmarksDetailView(bookmarks: $libraryManager.bookmarks)) {
|
||||
Text("View All")
|
||||
.font(.subheadline)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.gray.opacity(0.2))
|
||||
.cornerRadius(15)
|
||||
.gradientOutline()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
|
||||
BookmarksSection(
|
||||
selectedBookmark: $selectedBookmark,
|
||||
isDetailActive: $isDetailActive
|
||||
)
|
||||
|
||||
Spacer().frame(height: 100)
|
||||
|
||||
NavigationLink(
|
||||
destination: Group {
|
||||
if let bookmark = selectedBookmark,
|
||||
let module = moduleManager.modules.first(where: { $0.id.uuidString == bookmark.moduleId }) {
|
||||
let module = moduleManager.modules.first(where: {
|
||||
$0.id.uuidString == bookmark.moduleId
|
||||
}) {
|
||||
MediaInfoView(title: bookmark.title,
|
||||
imageUrl: bookmark.imageUrl,
|
||||
href: bookmark.href,
|
||||
|
|
@ -163,19 +157,14 @@ struct LibraryView: View {
|
|||
) {
|
||||
EmptyView()
|
||||
}
|
||||
.onAppear {
|
||||
updateOrientation()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in
|
||||
updateOrientation()
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
.scrollViewBottomPadding()
|
||||
.deviceScaled()
|
||||
.onAppear {
|
||||
fetchContinueWatching()
|
||||
}
|
||||
.padding(.vertical, 20)
|
||||
}
|
||||
.navigationTitle("Library")
|
||||
.onAppear {
|
||||
fetchContinueWatching()
|
||||
}
|
||||
}
|
||||
.navigationViewStyle(StackNavigationViewStyle())
|
||||
|
|
@ -191,12 +180,16 @@ struct LibraryView: View {
|
|||
UserDefaults.standard.set(99999999.0, forKey: key)
|
||||
UserDefaults.standard.set(99999999.0, forKey: totalKey)
|
||||
ContinueWatchingManager.shared.remove(item: item)
|
||||
continueWatchingItems.removeAll { $0.id == item.id }
|
||||
continueWatchingItems.removeAll {
|
||||
$0.id == item.id
|
||||
}
|
||||
}
|
||||
|
||||
private func removeContinueWatchingItem(item: ContinueWatchingItem) {
|
||||
ContinueWatchingManager.shared.remove(item: item)
|
||||
continueWatchingItems.removeAll { $0.id == item.id }
|
||||
continueWatchingItems.removeAll {
|
||||
$0.id == item.id
|
||||
}
|
||||
}
|
||||
|
||||
private func updateOrientation() {
|
||||
|
|
@ -206,7 +199,10 @@ struct LibraryView: View {
|
|||
}
|
||||
|
||||
private func determineColumns() -> Int {
|
||||
if UIDevice.current.userInterfaceIdiom == .pad {
|
||||
if UIDevice.current.userInterfaceIdiom == .pad && horizontalSizeClass == .compact {
|
||||
return verticalSizeClass == .compact ? 3 : 2
|
||||
} else if UIDevice.current.userInterfaceIdiom == .pad {
|
||||
let isLandscape = UIScreen.main.bounds.width > UIScreen.main.bounds.height
|
||||
return isLandscape ? mediaColumnsLandscape : mediaColumnsPortrait
|
||||
} else {
|
||||
return verticalSizeClass == .compact ? mediaColumnsLandscape : mediaColumnsPortrait
|
||||
|
|
@ -220,20 +216,18 @@ struct ContinueWatchingSection: View {
|
|||
var removeItem: (ContinueWatchingItem) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(Array(items.reversed())) { item in
|
||||
ContinueWatchingCell(item: item, markAsWatched: {
|
||||
markAsWatched(item)
|
||||
}, removeItem: {
|
||||
removeItem(item)
|
||||
})
|
||||
}
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 12) {
|
||||
ForEach(Array(items.reversed().prefix(5))) { item in
|
||||
ContinueWatchingCell(item: item, markAsWatched: {
|
||||
markAsWatched(item)
|
||||
}, removeItem: {
|
||||
removeItem(item)
|
||||
})
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
.frame(height: 190)
|
||||
.padding(.horizontal, 20)
|
||||
.frame(height: 157.03)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -243,7 +237,8 @@ struct ContinueWatchingCell: View {
|
|||
var markAsWatched: () -> Void
|
||||
var removeItem: () -> Void
|
||||
|
||||
@State private var currentProgress: Double = 0.0
|
||||
@State private
|
||||
var currentProgress: Double = 0.0
|
||||
|
||||
var body: some View {
|
||||
Button(action: {
|
||||
|
|
@ -272,9 +267,9 @@ struct ContinueWatchingCell: View {
|
|||
onWatchNext: { },
|
||||
subtitlesURL: item.subtitles,
|
||||
aniListID: item.aniListID ?? 0,
|
||||
totalEpisodes: item.totalEpisodes,
|
||||
episodeImageUrl: item.imageUrl,
|
||||
headers: item.headers ?? nil
|
||||
|
||||
)
|
||||
customMediaPlayer.modalPresentationStyle = .fullScreen
|
||||
|
||||
|
|
@ -284,99 +279,311 @@ struct ContinueWatchingCell: View {
|
|||
}
|
||||
}
|
||||
}) {
|
||||
VStack(alignment: .leading) {
|
||||
ZStack {
|
||||
KFImage(URL(string: item.imageUrl.isEmpty
|
||||
? "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/main/assets/banner2.png"
|
||||
: item.imageUrl))
|
||||
.placeholder {
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(Color.gray.opacity(0.3))
|
||||
.frame(width: 240, height: 135)
|
||||
.shimmering()
|
||||
}
|
||||
.setProcessor(RoundCornerImageProcessor(cornerRadius: 10))
|
||||
.resizable()
|
||||
.aspectRatio(16/9, contentMode: .fill)
|
||||
.frame(width: 240, height: 135)
|
||||
.cornerRadius(10)
|
||||
.clipped()
|
||||
.overlay(
|
||||
Group {
|
||||
if item.streamUrl.hasPrefix("file://") {
|
||||
Image(systemName: "arrow.down.app.fill")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 24, height: 24)
|
||||
.foregroundColor(.white)
|
||||
.background(Color.black.cornerRadius(6)) // black exactly 24×24
|
||||
.padding(4) // add spacing outside the black
|
||||
} else {
|
||||
KFImage(URL(string: item.module.metadata.iconUrl))
|
||||
.resizable()
|
||||
.frame(width: 24, height: 24)
|
||||
.cornerRadius(4)
|
||||
.padding(4)
|
||||
ZStack(alignment: .bottomLeading) {
|
||||
KFImage(URL(string: item.imageUrl.isEmpty ? "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/main/assets/banner2.png" : item.imageUrl))
|
||||
.placeholder {
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(Color.gray.opacity(0.3))
|
||||
.frame(width: 280, height: 157.03)
|
||||
.shimmering()
|
||||
}
|
||||
.resizable()
|
||||
.aspectRatio(16/9, contentMode: .fill)
|
||||
.frame(width: 280, height: 157.03)
|
||||
.cornerRadius(10)
|
||||
.clipped()
|
||||
.overlay(
|
||||
ZStack {
|
||||
ProgressiveBlurView()
|
||||
.cornerRadius(10, corners: [.bottomLeft, .bottomRight])
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Spacer()
|
||||
Text(item.mediaTitle)
|
||||
.font(.headline)
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(1)
|
||||
|
||||
HStack {
|
||||
Text("Episode \(item.episodeNumber)")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.white.opacity(0.9))
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("\(Int(item.progress * 100))% seen")
|
||||
.font(.caption)
|
||||
.foregroundColor(.white.opacity(0.9))
|
||||
}
|
||||
},
|
||||
alignment: .topLeading
|
||||
)
|
||||
}
|
||||
.overlay(
|
||||
ZStack {
|
||||
Rectangle()
|
||||
.fill(Color.black.opacity(0.3))
|
||||
.blur(radius: 3)
|
||||
.frame(height: 30)
|
||||
|
||||
ProgressView(value: currentProgress)
|
||||
.progressViewStyle(LinearProgressViewStyle(tint: .white))
|
||||
.padding(.horizontal, 8)
|
||||
.scaleEffect(x: 1, y: 1.5, anchor: .center)
|
||||
},
|
||||
alignment: .bottom
|
||||
)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text("Episode \(item.episodeNumber)")
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text(item.mediaTitle)
|
||||
.font(.caption)
|
||||
.lineLimit(2)
|
||||
.foregroundColor(.primary)
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
.black.opacity(0.7),
|
||||
.black.opacity(0.0)
|
||||
],
|
||||
startPoint: .bottom,
|
||||
endPoint: .top
|
||||
)
|
||||
.clipped()
|
||||
.cornerRadius(10, corners: [.bottomLeft, .bottomRight])
|
||||
.shadow(color: .black.opacity(0.3), radius: 3, x: 0, y: 1)
|
||||
)
|
||||
},
|
||||
alignment: .bottom
|
||||
)
|
||||
.overlay(
|
||||
ZStack {
|
||||
if item.streamUrl.hasPrefix("file://") {
|
||||
Image(systemName: "arrow.down.app.fill")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 24, height: 24)
|
||||
.foregroundColor(.white)
|
||||
.background(Color.black.cornerRadius(6))
|
||||
.padding(8)
|
||||
} else {
|
||||
Circle()
|
||||
.fill(Color.black.opacity(0.5))
|
||||
.frame(width: 28, height: 28)
|
||||
.overlay(
|
||||
KFImage(URL(string: item.module.metadata.iconUrl))
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: 32, height: 32)
|
||||
.clipShape(Circle())
|
||||
)
|
||||
.padding(8)
|
||||
}
|
||||
},
|
||||
alignment: .topLeading
|
||||
)
|
||||
}
|
||||
.frame(width: 240, height: 170)
|
||||
.frame(width: 280, height: 157.03)
|
||||
}
|
||||
.contextMenu {
|
||||
Button(action: { markAsWatched() }) {
|
||||
Button(action: {
|
||||
markAsWatched()
|
||||
}) {
|
||||
Label("Mark as Watched", systemImage: "checkmark.circle")
|
||||
}
|
||||
Button(role: .destructive, action: { removeItem() }) {
|
||||
Button(role: .destructive, action: {
|
||||
removeItem()
|
||||
}) {
|
||||
Label("Remove Item", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
updateProgress()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
|
||||
updateProgress()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(
|
||||
for: UIApplication.didBecomeActiveNotification)) {
|
||||
_ in
|
||||
updateProgress()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateProgress() {
|
||||
let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(item.fullUrl)")
|
||||
let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(item.fullUrl)")
|
||||
let lastPlayed = UserDefaults.standard.double(forKey: "lastPlayedTime_\(item.fullUrl)")
|
||||
let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(item.fullUrl)")
|
||||
|
||||
let ratio: Double
|
||||
if totalTime > 0 {
|
||||
let ratio = lastPlayedTime / totalTime
|
||||
currentProgress = max(0, min(ratio, 1))
|
||||
ratio = min(max(lastPlayed / totalTime, 0), 1)
|
||||
} else {
|
||||
currentProgress = max(0, min(item.progress, 1))
|
||||
ratio = min(max(item.progress, 0), 1)
|
||||
}
|
||||
currentProgress = ratio
|
||||
|
||||
if ratio >= 0.9 {
|
||||
removeItem()
|
||||
} else {
|
||||
var updated = item
|
||||
updated.progress = ratio
|
||||
ContinueWatchingManager.shared.save(item: updated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
struct RoundedCorner: Shape {
|
||||
var radius: CGFloat = .infinity
|
||||
var corners: UIRectCorner = .allCorners
|
||||
|
||||
func path( in rect: CGRect) -> Path {
|
||||
let path = UIBezierPath(
|
||||
roundedRect: rect,
|
||||
byRoundingCorners: corners,
|
||||
cornerRadii: CGSize(width: radius, height: radius)
|
||||
)
|
||||
return Path(path.cgPath)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View {
|
||||
clipShape(RoundedCorner(radius: radius, corners: corners))
|
||||
}
|
||||
|
||||
func gradientOutline() -> some View {
|
||||
self.background(
|
||||
RoundedRectangle(cornerRadius: 15)
|
||||
.stroke(
|
||||
LinearGradient(
|
||||
gradient: Gradient(stops: [
|
||||
.init(color: Color.accentColor.opacity(0.25), location: 0),
|
||||
.init(color: Color.accentColor.opacity(0), location: 1)
|
||||
]),
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
),
|
||||
lineWidth: 0.5
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct BookmarksSection: View {
|
||||
@EnvironmentObject private var libraryManager: LibraryManager
|
||||
@EnvironmentObject private var moduleManager: ModuleManager
|
||||
|
||||
@Binding var selectedBookmark: LibraryItem?
|
||||
@Binding var isDetailActive: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if libraryManager.bookmarks.isEmpty {
|
||||
EmptyBookmarksView()
|
||||
} else {
|
||||
BookmarksGridView(
|
||||
selectedBookmark: $selectedBookmark,
|
||||
isDetailActive: $isDetailActive
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct EmptyBookmarksView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "magazine")
|
||||
.font(.largeTitle)
|
||||
.foregroundColor(.secondary)
|
||||
Text("You have no items saved.")
|
||||
.font(.headline)
|
||||
Text("Bookmark items for an easier access later.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
struct BookmarksGridView: View {
|
||||
@EnvironmentObject private var libraryManager: LibraryManager
|
||||
@EnvironmentObject private var moduleManager: ModuleManager
|
||||
|
||||
@Binding var selectedBookmark: LibraryItem?
|
||||
@Binding var isDetailActive: Bool
|
||||
|
||||
private var recentBookmarks: [LibraryItem] {
|
||||
Array(libraryManager.bookmarks.prefix(5))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 12) {
|
||||
ForEach(recentBookmarks) { item in
|
||||
BookmarkItemView(
|
||||
item: item,
|
||||
selectedBookmark: $selectedBookmark,
|
||||
isDetailActive: $isDetailActive
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.frame(height: 243)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct BookmarkItemView: View {
|
||||
@EnvironmentObject private var libraryManager: LibraryManager
|
||||
@EnvironmentObject private var moduleManager: ModuleManager
|
||||
|
||||
let item: LibraryItem
|
||||
@Binding var selectedBookmark: LibraryItem?
|
||||
@Binding var isDetailActive: Bool
|
||||
|
||||
var body: some View {
|
||||
if let module = moduleManager.modules.first(where: { $0.id.uuidString == item.moduleId }) {
|
||||
Button(action: {
|
||||
selectedBookmark = item
|
||||
isDetailActive = true
|
||||
}) {
|
||||
ZStack {
|
||||
KFImage(URL(string: item.imageUrl))
|
||||
.placeholder {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color.gray.opacity(0.3))
|
||||
.aspectRatio(2 / 3, contentMode: .fit)
|
||||
.shimmering()
|
||||
}
|
||||
.resizable()
|
||||
.aspectRatio(0.72, contentMode: .fill)
|
||||
.frame(width: 162, height: 243)
|
||||
.cornerRadius(12)
|
||||
.clipped()
|
||||
.overlay(
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.black.opacity(0.5))
|
||||
.frame(width: 28, height: 28)
|
||||
.overlay(
|
||||
KFImage(URL(string: module.metadata.iconUrl))
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: 32, height: 32)
|
||||
.clipShape(Circle())
|
||||
)
|
||||
}
|
||||
.padding(8),
|
||||
alignment: .topLeading
|
||||
)
|
||||
|
||||
VStack {
|
||||
Spacer()
|
||||
Text(item.title)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.lineLimit(2)
|
||||
.foregroundColor(.white)
|
||||
.padding(12)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
.black.opacity(0.7),
|
||||
.black.opacity(0.0)
|
||||
],
|
||||
startPoint: .bottom,
|
||||
endPoint: .top
|
||||
)
|
||||
.shadow(color: .black, radius: 4, x: 0, y: 2)
|
||||
)
|
||||
}
|
||||
}
|
||||
.frame(width: 162, height: 243)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
.contextMenu {
|
||||
Button(role: .destructive, action: {
|
||||
libraryManager.removeBookmark(item: item)
|
||||
}) {
|
||||
Label("Remove from Bookmarks", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
213
Sora/Views/MediaInfoView/AnilistMatchPopupView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -9,12 +9,6 @@ import SwiftUI
|
|||
import Kingfisher
|
||||
import AVFoundation
|
||||
|
||||
struct EpisodeLink: Identifiable {
|
||||
let id = UUID()
|
||||
let number: Int
|
||||
let href: String
|
||||
}
|
||||
|
||||
struct EpisodeCell: View {
|
||||
let episodeIndex: Int
|
||||
let episode: String
|
||||
|
|
@ -50,6 +44,10 @@ struct EpisodeCell: View {
|
|||
@State private var lastLoggedStatus: EpisodeDownloadStatus?
|
||||
@State private var downloadAnimationScale: CGFloat = 1.0
|
||||
|
||||
@State private var swipeOffset: CGFloat = 0
|
||||
@State private var isShowingActions: Bool = false
|
||||
@State private var actionButtonWidth: CGFloat = 60
|
||||
|
||||
@State private var retryAttempts: Int = 0
|
||||
private let maxRetryAttempts: Int = 3
|
||||
private let initialBackoffDelay: TimeInterval = 1.0
|
||||
|
|
@ -71,12 +69,18 @@ struct EpisodeCell: View {
|
|||
}
|
||||
}
|
||||
|
||||
let tmdbID: Int?
|
||||
let seasonNumber: Int?
|
||||
|
||||
init(episodeIndex: Int, episode: String, episodeID: Int, progress: Double,
|
||||
itemID: Int, totalEpisodes: Int? = nil, defaultBannerImage: String = "",
|
||||
module: ScrapingModule, parentTitle: String, showPosterURL: String? = nil,
|
||||
isMultiSelectMode: Bool = false, isSelected: Bool = false,
|
||||
onSelectionChanged: ((Bool) -> Void)? = nil,
|
||||
onTap: @escaping (String) -> Void, onMarkAllPrevious: @escaping () -> Void) {
|
||||
onTap: @escaping (String) -> Void, onMarkAllPrevious: @escaping () -> Void,
|
||||
tmdbID: Int? = nil,
|
||||
seasonNumber: Int? = nil
|
||||
) {
|
||||
self.episodeIndex = episodeIndex
|
||||
self.episode = episode
|
||||
self.episodeID = episodeID
|
||||
|
|
@ -101,28 +105,123 @@ struct EpisodeCell: View {
|
|||
self.onSelectionChanged = onSelectionChanged
|
||||
self.onTap = onTap
|
||||
self.onMarkAllPrevious = onMarkAllPrevious
|
||||
self.tmdbID = tmdbID
|
||||
self.seasonNumber = seasonNumber
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
episodeThumbnail
|
||||
episodeInfo
|
||||
Spacer()
|
||||
CircularProgressBar(progress: currentProgress)
|
||||
.frame(width: 40, height: 40)
|
||||
.padding(.trailing, 8)
|
||||
ZStack {
|
||||
HStack {
|
||||
Spacer()
|
||||
actionButtons
|
||||
}
|
||||
.zIndex(0)
|
||||
|
||||
HStack {
|
||||
episodeThumbnail
|
||||
episodeInfo
|
||||
Spacer()
|
||||
CircularProgressBar(progress: currentProgress)
|
||||
.frame(width: 40, height: 40)
|
||||
.padding(.trailing, 4)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 8)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 15)
|
||||
.fill(Color(UIColor.systemBackground))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 15)
|
||||
.fill(Color.gray.opacity(0.2))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 15)
|
||||
.stroke(
|
||||
LinearGradient(
|
||||
gradient: Gradient(stops: [
|
||||
.init(color: Color.accentColor.opacity(0.25), location: 0),
|
||||
.init(color: Color.accentColor.opacity(0), location: 1)
|
||||
]),
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
),
|
||||
lineWidth: 0.5
|
||||
)
|
||||
)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 15))
|
||||
.offset(x: swipeOffset)
|
||||
.zIndex(1)
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: swipeOffset)
|
||||
.contextMenu {
|
||||
contextMenuContent
|
||||
}
|
||||
.simultaneousGesture(
|
||||
DragGesture()
|
||||
.onChanged { value in
|
||||
let horizontalTranslation = value.translation.width
|
||||
let verticalTranslation = value.translation.height
|
||||
|
||||
let isDefinitelyHorizontalSwipe = abs(horizontalTranslation) > 10 && abs(horizontalTranslation) > abs(verticalTranslation) * 1.5
|
||||
|
||||
if isShowingActions || isDefinitelyHorizontalSwipe {
|
||||
if horizontalTranslation < 0 {
|
||||
let maxSwipe = calculateMaxSwipeDistance()
|
||||
swipeOffset = max(horizontalTranslation, -maxSwipe)
|
||||
} else if isShowingActions {
|
||||
let maxSwipe = calculateMaxSwipeDistance()
|
||||
swipeOffset = max(horizontalTranslation - maxSwipe, -maxSwipe)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onEnded { value in
|
||||
let horizontalTranslation = value.translation.width
|
||||
let verticalTranslation = value.translation.height
|
||||
|
||||
let wasHandlingGesture = abs(horizontalTranslation) > 10 && abs(horizontalTranslation) > abs(verticalTranslation) * 1.5
|
||||
|
||||
if isShowingActions || wasHandlingGesture {
|
||||
let maxSwipe = calculateMaxSwipeDistance()
|
||||
let threshold = maxSwipe * 0.2
|
||||
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
||||
if horizontalTranslation < -threshold && !isShowingActions {
|
||||
swipeOffset = -maxSwipe
|
||||
isShowingActions = true
|
||||
} else if horizontalTranslation > threshold && isShowingActions {
|
||||
swipeOffset = 0
|
||||
isShowingActions = false
|
||||
} else {
|
||||
swipeOffset = isShowingActions ? -maxSwipe : 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.background(isMultiSelectMode && isSelected ? Color.accentColor.opacity(0.1) : Color.clear)
|
||||
.cornerRadius(8)
|
||||
.contextMenu {
|
||||
contextMenuContent
|
||||
.onTapGesture {
|
||||
if isShowingActions {
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
||||
swipeOffset = 0
|
||||
isShowingActions = false
|
||||
}
|
||||
} else if isMultiSelectMode {
|
||||
onSelectionChanged?(!isSelected)
|
||||
} else {
|
||||
let imageUrl = episodeImageUrl.isEmpty ? defaultBannerImage : episodeImageUrl
|
||||
onTap(imageUrl)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
updateProgress()
|
||||
updateDownloadStatus()
|
||||
|
||||
if let type = module.metadata.type?.lowercased(), type == "anime" {
|
||||
if UserDefaults.standard.string(forKey: "metadataProviders") ?? "TMDB" == "TMDB" {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
fetchTMDBEpisodeImage()
|
||||
}
|
||||
} else {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
fetchAnimeEpisodeDetails()
|
||||
}
|
||||
|
|
@ -139,6 +238,12 @@ struct EpisodeCell: View {
|
|||
.onChange(of: progress) { _ in
|
||||
updateProgress()
|
||||
}
|
||||
.onChange(of: itemID) { newID in
|
||||
loadedFromCache = false
|
||||
isLoading = true
|
||||
retryAttempts = maxRetryAttempts
|
||||
fetchEpisodeDetails()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("downloadProgressChanged"))) { _ in
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
updateDownloadStatus()
|
||||
|
|
@ -151,21 +256,8 @@ struct EpisodeCell: View {
|
|||
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("downloadCompleted"))) { _ in
|
||||
updateDownloadStatus()
|
||||
}
|
||||
.onTapGesture {
|
||||
if isMultiSelectMode {
|
||||
onSelectionChanged?(!isSelected)
|
||||
} else {
|
||||
let imageUrl = episodeImageUrl.isEmpty ? defaultBannerImage : episodeImageUrl
|
||||
onTap(imageUrl)
|
||||
}
|
||||
}
|
||||
.alert("Download Episode", isPresented: $showDownloadConfirmation) {
|
||||
Button("Cancel", role: .cancel) {}
|
||||
Button("Download") {
|
||||
downloadEpisode()
|
||||
}
|
||||
} message: {
|
||||
Text("Do you want to download Episode \(episodeID + 1)\(episodeTitle.isEmpty ? "" : ": \(episodeTitle)")?")
|
||||
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("episodeProgressChanged"))) { _ in
|
||||
updateProgress()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -176,7 +268,6 @@ struct EpisodeCell: View {
|
|||
.onFailure { error in
|
||||
Logger.shared.log("Failed to load episode image: \(error)", type: "Error")
|
||||
}
|
||||
.cacheMemoryOnly(!KingfisherCacheManager.shared.isCachingEnabled)
|
||||
.resizable()
|
||||
.aspectRatio(16/9, contentMode: .fill)
|
||||
.frame(width: 100, height: 56)
|
||||
|
|
@ -385,38 +476,136 @@ struct EpisodeCell: View {
|
|||
return
|
||||
}
|
||||
|
||||
if let streams = result.streams, !streams.isEmpty, let url = URL(string: streams[0]) {
|
||||
if let sources = result.sources, !sources.isEmpty {
|
||||
if sources.count > 1 {
|
||||
showDownloadStreamSelectionAlert(streams: sources, downloadID: downloadID, subtitleURL: result.subtitles?.first)
|
||||
return
|
||||
} else if let streamUrl = sources[0]["streamUrl"] as? String, let url = URL(string: streamUrl) {
|
||||
|
||||
let subtitleURLString = sources[0]["subtitle"] as? String
|
||||
let subtitleURL = subtitleURLString.flatMap { URL(string: $0) }
|
||||
if let subtitleURL = subtitleURL {
|
||||
Logger.shared.log("[Download] Found subtitle URL: \(subtitleURL.absoluteString)")
|
||||
}
|
||||
|
||||
startActualDownload(url: url, streamUrl: streamUrl, downloadID: downloadID, subtitleURL: subtitleURL)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if let streams = result.streams, !streams.isEmpty {
|
||||
if streams[0] == "[object Promise]" {
|
||||
print("[Download] Method #\(methodIndex+1) returned a Promise object, trying next method")
|
||||
tryNextDownloadMethod(methodIndex: methodIndex + 1, downloadID: downloadID, softsub: softsub)
|
||||
return
|
||||
}
|
||||
|
||||
print("[Download] Method #\(methodIndex+1) returned valid stream URL: \(streams[0])")
|
||||
|
||||
let subtitleURL = result.subtitles?.first.flatMap { URL(string: $0) }
|
||||
if let subtitleURL = subtitleURL {
|
||||
print("[Download] Found subtitle URL: \(subtitleURL.absoluteString)")
|
||||
if streams.count > 1 {
|
||||
showDownloadStreamSelectionAlert(streams: streams, downloadID: downloadID, subtitleURL: result.subtitles?.first)
|
||||
return
|
||||
} else if let url = URL(string: streams[0]) {
|
||||
let subtitleURL = result.subtitles?.first.flatMap { URL(string: $0) }
|
||||
if let subtitleURL = subtitleURL {
|
||||
Logger.shared.log("[Download] Found subtitle URL: \(subtitleURL.absoluteString)")
|
||||
}
|
||||
|
||||
startActualDownload(url: url, streamUrl: streams[0], downloadID: downloadID, subtitleURL: subtitleURL)
|
||||
return
|
||||
}
|
||||
|
||||
startActualDownload(url: url, streamUrl: streams[0], downloadID: downloadID, subtitleURL: subtitleURL)
|
||||
} else if let sources = result.sources, !sources.isEmpty,
|
||||
let streamUrl = sources[0]["streamUrl"] as? String,
|
||||
let url = URL(string: streamUrl) {
|
||||
|
||||
print("[Download] Method #\(methodIndex+1) returned valid stream URL with headers: \(streamUrl)")
|
||||
|
||||
let subtitleURLString = sources[0]["subtitle"] as? String
|
||||
let subtitleURL = subtitleURLString.flatMap { URL(string: $0) }
|
||||
if let subtitleURL = subtitleURL {
|
||||
print("[Download] Found subtitle URL: \(subtitleURL.absoluteString)")
|
||||
}
|
||||
|
||||
startActualDownload(url: url, streamUrl: streamUrl, downloadID: downloadID, subtitleURL: subtitleURL)
|
||||
} else {
|
||||
print("[Download] Method #\(methodIndex+1) did not return valid streams, trying next method")
|
||||
tryNextDownloadMethod(methodIndex: methodIndex + 1, downloadID: downloadID, softsub: softsub)
|
||||
}
|
||||
|
||||
tryNextDownloadMethod(methodIndex: methodIndex + 1, downloadID: downloadID, softsub: softsub)
|
||||
}
|
||||
|
||||
private func showDownloadStreamSelectionAlert(streams: [Any], downloadID: UUID, subtitleURL: String? = nil) {
|
||||
DispatchQueue.main.async {
|
||||
let alert = UIAlertController(title: "Select Download Server", message: "Choose a server to download from", preferredStyle: .actionSheet)
|
||||
|
||||
var index = 0
|
||||
var streamIndex = 1
|
||||
|
||||
while index < streams.count {
|
||||
var title: String = ""
|
||||
var streamUrl: String = ""
|
||||
|
||||
if let streams = streams as? [String] {
|
||||
if index + 1 < streams.count {
|
||||
if !streams[index].lowercased().contains("http") {
|
||||
title = streams[index]
|
||||
streamUrl = streams[index + 1]
|
||||
index += 2
|
||||
} else {
|
||||
title = "Server \(streamIndex)"
|
||||
streamUrl = streams[index]
|
||||
index += 1
|
||||
}
|
||||
} else {
|
||||
title = "Server \(streamIndex)"
|
||||
streamUrl = streams[index]
|
||||
index += 1
|
||||
}
|
||||
} else if let streams = streams as? [[String: Any]] {
|
||||
if let currTitle = streams[index]["title"] as? String {
|
||||
title = currTitle
|
||||
} else {
|
||||
title = "Server \(streamIndex)"
|
||||
}
|
||||
streamUrl = (streams[index]["streamUrl"] as? String) ?? ""
|
||||
index += 1
|
||||
}
|
||||
|
||||
alert.addAction(UIAlertAction(title: title, style: .default) { _ in
|
||||
guard let url = URL(string: streamUrl) else {
|
||||
DropManager.shared.error("Invalid stream URL selected")
|
||||
self.isDownloading = false
|
||||
return
|
||||
}
|
||||
|
||||
let subtitleURLObj = subtitleURL.flatMap { URL(string: $0) }
|
||||
self.startActualDownload(url: url, streamUrl: streamUrl, downloadID: downloadID, subtitleURL: subtitleURLObj)
|
||||
})
|
||||
|
||||
streamIndex += 1
|
||||
}
|
||||
|
||||
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in
|
||||
self.isDownloading = false
|
||||
})
|
||||
|
||||
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let window = windowScene.windows.first,
|
||||
let rootVC = window.rootViewController {
|
||||
|
||||
if UIDevice.current.userInterfaceIdiom == .pad {
|
||||
if let popover = alert.popoverPresentationController {
|
||||
popover.sourceView = window
|
||||
popover.sourceRect = CGRect(
|
||||
x: UIScreen.main.bounds.width / 2,
|
||||
y: UIScreen.main.bounds.height / 2,
|
||||
width: 0,
|
||||
height: 0
|
||||
)
|
||||
popover.permittedArrowDirections = []
|
||||
}
|
||||
}
|
||||
|
||||
self.findTopViewController(rootVC).present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func findTopViewController(_ controller: UIViewController) -> UIViewController {
|
||||
if let navigationController = controller as? UINavigationController {
|
||||
return findTopViewController(navigationController.visibleViewController!)
|
||||
}
|
||||
if let tabController = controller as? UITabBarController {
|
||||
if let selected = tabController.selectedViewController {
|
||||
return findTopViewController(selected)
|
||||
}
|
||||
}
|
||||
if let presented = controller.presentedViewController {
|
||||
return findTopViewController(presented)
|
||||
}
|
||||
return controller
|
||||
}
|
||||
|
||||
private func startActualDownload(url: URL, streamUrl: String, downloadID: UUID, subtitleURL: URL? = nil) {
|
||||
|
|
@ -523,25 +712,6 @@ struct EpisodeCell: View {
|
|||
}
|
||||
|
||||
private func fetchEpisodeDetails() {
|
||||
if MetadataCacheManager.shared.isCachingEnabled &&
|
||||
(UserDefaults.standard.object(forKey: "fetchEpisodeMetadata") == nil ||
|
||||
UserDefaults.standard.bool(forKey: "fetchEpisodeMetadata")) {
|
||||
|
||||
let cacheKey = "anilist_\(itemID)_episode_\(episodeID + 1)"
|
||||
|
||||
if let cachedData = MetadataCacheManager.shared.getMetadata(forKey: cacheKey),
|
||||
let metadata = EpisodeMetadata.fromData(cachedData) {
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.episodeTitle = metadata.title["en"] ?? ""
|
||||
self.episodeImageUrl = metadata.imageUrl
|
||||
self.isLoading = false
|
||||
self.loadedFromCache = true
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
fetchAnimeEpisodeDetails()
|
||||
}
|
||||
|
||||
|
|
@ -618,22 +788,6 @@ struct EpisodeCell: View {
|
|||
Logger.shared.log("Episode \(episodeKey) missing fields: \(missingFields.joined(separator: ", "))", type: "Warning")
|
||||
}
|
||||
|
||||
if MetadataCacheManager.shared.isCachingEnabled && (!title.isEmpty || !image.isEmpty) {
|
||||
let metadata = EpisodeMetadata(
|
||||
title: title,
|
||||
imageUrl: image,
|
||||
anilistId: self.itemID,
|
||||
episodeNumber: self.episodeID + 1
|
||||
)
|
||||
|
||||
if let metadataData = metadata.toData() {
|
||||
MetadataCacheManager.shared.storeMetadata(
|
||||
metadataData,
|
||||
forKey: metadata.cacheKey
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.isLoading = false
|
||||
self.retryAttempts = 0
|
||||
|
|
@ -678,4 +832,143 @@ struct EpisodeCell: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchTMDBEpisodeImage() {
|
||||
guard let tmdbID = tmdbID, let season = seasonNumber else { return }
|
||||
let episodeNum = episodeID + 1
|
||||
let urlString = "https://api.themoviedb.org/3/tv/\(tmdbID)/season/\(season)/episode/\(episodeNum)?api_key=738b4edd0a156cc126dc4a4b8aea4aca"
|
||||
guard let url = URL(string: urlString) else { return }
|
||||
|
||||
let tmdbImageWidth = UserDefaults.standard.string(forKey: "tmdbImageWidth") ?? "original"
|
||||
|
||||
URLSession.custom.dataTask(with: url) { data, _, error in
|
||||
guard let data = data, error == nil else { return }
|
||||
do {
|
||||
if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
||||
let name = json["name"] as? String ?? ""
|
||||
let stillPath = json["still_path"] as? String
|
||||
let imageUrl: String
|
||||
if let stillPath = stillPath {
|
||||
if tmdbImageWidth == "original" {
|
||||
imageUrl = "https://image.tmdb.org/t/p/original\(stillPath)"
|
||||
} else {
|
||||
imageUrl = "https://image.tmdb.org/t/p/w\(tmdbImageWidth)\(stillPath)"
|
||||
}
|
||||
} else {
|
||||
imageUrl = ""
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
self.episodeTitle = name
|
||||
self.episodeImageUrl = imageUrl
|
||||
self.isLoading = false
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Logger.shared.log("Failed to parse TMDB episode details: \(error.localizedDescription)", type: "Error")
|
||||
DispatchQueue.main.async {
|
||||
self.isLoading = false
|
||||
}
|
||||
}
|
||||
}.resume()
|
||||
}
|
||||
|
||||
private func calculateMaxSwipeDistance() -> CGFloat {
|
||||
var buttonCount = 1
|
||||
|
||||
if progress <= 0.9 { buttonCount += 1 }
|
||||
if progress != 0 { buttonCount += 1 }
|
||||
if episodeIndex > 0 { buttonCount += 1 }
|
||||
|
||||
var swipeDistance = CGFloat(buttonCount) * actionButtonWidth + 16
|
||||
|
||||
if buttonCount == 3 {
|
||||
swipeDistance += 12
|
||||
} else if buttonCount == 4 {
|
||||
swipeDistance += 24
|
||||
}
|
||||
|
||||
return swipeDistance
|
||||
}
|
||||
|
||||
private var actionButtons: some View {
|
||||
HStack(spacing: 8) {
|
||||
Button(action: {
|
||||
closeActionsAndPerform {
|
||||
downloadEpisode()
|
||||
}
|
||||
}) {
|
||||
VStack(spacing: 2) {
|
||||
Image(systemName: "arrow.down.circle")
|
||||
.font(.title3)
|
||||
Text("Download")
|
||||
.font(.caption2)
|
||||
}
|
||||
}
|
||||
.foregroundColor(.blue)
|
||||
.frame(width: actionButtonWidth)
|
||||
|
||||
if progress <= 0.9 {
|
||||
Button(action: {
|
||||
closeActionsAndPerform {
|
||||
markAsWatched()
|
||||
}
|
||||
}) {
|
||||
VStack(spacing: 2) {
|
||||
Image(systemName: "checkmark.circle")
|
||||
.font(.title3)
|
||||
Text("Watched")
|
||||
.font(.caption2)
|
||||
}
|
||||
}
|
||||
.foregroundColor(.green)
|
||||
.frame(width: actionButtonWidth)
|
||||
}
|
||||
|
||||
if progress != 0 {
|
||||
Button(action: {
|
||||
closeActionsAndPerform {
|
||||
resetProgress()
|
||||
}
|
||||
}) {
|
||||
VStack(spacing: 2) {
|
||||
Image(systemName: "arrow.counterclockwise")
|
||||
.font(.title3)
|
||||
Text("Reset")
|
||||
.font(.caption2)
|
||||
}
|
||||
}
|
||||
.foregroundColor(.orange)
|
||||
.frame(width: actionButtonWidth)
|
||||
}
|
||||
|
||||
if episodeIndex > 0 {
|
||||
Button(action: {
|
||||
closeActionsAndPerform {
|
||||
onMarkAllPrevious()
|
||||
}
|
||||
}) {
|
||||
VStack(spacing: 2) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.title3)
|
||||
Text("All Prev")
|
||||
.font(.caption2)
|
||||
}
|
||||
}
|
||||
.foregroundColor(.purple)
|
||||
.frame(width: actionButtonWidth)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
}
|
||||
|
||||
private func closeActionsAndPerform(action: @escaping () -> Void) {
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
||||
swipeOffset = 0
|
||||
isShowingActions = false
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
action()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
72
Sora/Views/SearchView/SearchComponents.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
74
Sora/Views/SearchView/SearchResultsGrid.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
41
Sora/Views/SearchView/SearchStateView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
335
Sora/Views/SearchView/SearchView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
195
Sora/Views/SearchView/SearchViewComponents.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
251
Sora/Views/SettingsSharedComponents.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
246
Sora/Views/SettingsView/Components/SettingsComponents.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
270
Sora/Views/SettingsView/SettingsSubViews/SettingsViewAbout.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -7,310 +7,347 @@
|
|||
|
||||
import SwiftUI
|
||||
|
||||
fileprivate struct SettingsSection<Content: View>: View {
|
||||
let title: String
|
||||
let footer: String?
|
||||
let content: Content
|
||||
|
||||
init(title: String, footer: String? = nil, @ViewBuilder content: () -> Content) {
|
||||
self.title = title
|
||||
self.footer = footer
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title.uppercased())
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.gray)
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
content
|
||||
}
|
||||
.background(.ultraThinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.strokeBorder(
|
||||
LinearGradient(
|
||||
gradient: Gradient(stops: [
|
||||
.init(color: Color.accentColor.opacity(0.3), location: 0),
|
||||
.init(color: Color.accentColor.opacity(0), location: 1)
|
||||
]),
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
),
|
||||
lineWidth: 0.5
|
||||
)
|
||||
)
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
if let footer = footer {
|
||||
Text(footer)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.gray)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate struct SettingsToggleRow: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
@Binding var isOn: Bool
|
||||
var showDivider: Bool = true
|
||||
|
||||
init(icon: String, title: String, isOn: Binding<Bool>, showDivider: Bool = true) {
|
||||
self.icon = icon
|
||||
self.title = title
|
||||
self._isOn = isOn
|
||||
self.showDivider = showDivider
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Image(systemName: icon)
|
||||
.frame(width: 24, height: 24)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Text(title)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Toggle("", isOn: $isOn)
|
||||
.labelsHidden()
|
||||
.tint(.accentColor.opacity(0.5))
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
|
||||
if showDivider {
|
||||
Divider()
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate struct SettingsButtonRow: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let subtitle: String?
|
||||
let action: () -> Void
|
||||
|
||||
init(icon: String, title: String, subtitle: String? = nil, action: @escaping () -> Void) {
|
||||
self.icon = icon
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.action = action
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack {
|
||||
Image(systemName: icon)
|
||||
.frame(width: 24, height: 24)
|
||||
.foregroundStyle(.red)
|
||||
|
||||
Text(title)
|
||||
.foregroundStyle(.red)
|
||||
|
||||
Spacer()
|
||||
|
||||
if let subtitle = subtitle {
|
||||
Text(subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.gray)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsViewData: View {
|
||||
@State private var showEraseAppDataAlert = false
|
||||
@State private var showRemoveDocumentsAlert = false
|
||||
@State private var showSizeAlert = false
|
||||
@State private var showAlert = false
|
||||
@State private var cacheSizeText: String = "Calculating..."
|
||||
@State private var isCalculatingSize: Bool = false
|
||||
@State private var cacheSize: Int64 = 0
|
||||
@State private var documentsSize: Int64 = 0
|
||||
@State private var movPkgSize: Int64 = 0
|
||||
@State private var showRemoveMovPkgAlert = false
|
||||
|
||||
// State bindings for cache settings
|
||||
@State private var isMetadataCachingEnabled: Bool = true
|
||||
@State private var isImageCachingEnabled: Bool = true
|
||||
@State private var isMemoryOnlyMode: Bool = false
|
||||
enum ActiveAlert {
|
||||
case eraseData, removeDocs
|
||||
}
|
||||
|
||||
@State private var activeAlert: ActiveAlert = .eraseData
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
// New section for cache settings
|
||||
Section(header: Text("Cache Settings"), footer: Text("Caching helps reduce network usage and load content faster. You can disable it to save storage space.")) {
|
||||
Toggle("Enable Metadata Caching", isOn: $isMetadataCachingEnabled)
|
||||
.onChange(of: isMetadataCachingEnabled) { newValue in
|
||||
MetadataCacheManager.shared.isCachingEnabled = newValue
|
||||
if !newValue {
|
||||
calculateCacheSize()
|
||||
return ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
SettingsSection(
|
||||
title: "Cache",
|
||||
footer: "Caching helps reduce network usage and load content faster. You can disable it to save storage space."
|
||||
) {
|
||||
HStack {
|
||||
Image(systemName: "folder.badge.gearshape")
|
||||
.frame(width: 24, height: 24)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Text("Current Cache Size")
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
if isCalculatingSize {
|
||||
ProgressView()
|
||||
.scaleEffect(0.7)
|
||||
.padding(.trailing, 5)
|
||||
}
|
||||
|
||||
Text(cacheSizeText)
|
||||
.foregroundStyle(.gray)
|
||||
}
|
||||
|
||||
Toggle("Enable Image Caching", isOn: $isImageCachingEnabled)
|
||||
.onChange(of: isImageCachingEnabled) { newValue in
|
||||
KingfisherCacheManager.shared.isCachingEnabled = newValue
|
||||
if !newValue {
|
||||
calculateCacheSize()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
|
||||
Divider().padding(.horizontal, 16)
|
||||
|
||||
Button(action: clearAllCaches) {
|
||||
Text("Clear All Caches")
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
|
||||
if isMetadataCachingEnabled {
|
||||
Toggle("Memory-Only Mode", isOn: $isMemoryOnlyMode)
|
||||
.onChange(of: isMemoryOnlyMode) { newValue in
|
||||
MetadataCacheManager.shared.isMemoryOnlyMode = newValue
|
||||
if newValue {
|
||||
// Clear disk cache when switching to memory-only
|
||||
MetadataCacheManager.shared.clearAllCache()
|
||||
calculateCacheSize()
|
||||
SettingsSection(
|
||||
title: "App Storage",
|
||||
footer: "The App Data should never be erased if you don't know what that will cause.\nClearing the documents folder will remove all the modules and downloads\n "
|
||||
) {
|
||||
VStack(spacing: 0) {
|
||||
SettingsButtonRow(
|
||||
icon: "doc.text",
|
||||
title: "Remove All Files in Documents",
|
||||
subtitle: formatSize(documentsSize),
|
||||
action: {
|
||||
activeAlert = .removeDocs
|
||||
showAlert = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Current Cache Size")
|
||||
Spacer()
|
||||
if isCalculatingSize {
|
||||
ProgressView()
|
||||
.scaleEffect(0.7)
|
||||
.padding(.trailing, 5)
|
||||
)
|
||||
Divider().padding(.horizontal, 16)
|
||||
|
||||
SettingsButtonRow(
|
||||
icon: "exclamationmark.triangle",
|
||||
title: "Erase all App Data",
|
||||
action: {
|
||||
activeAlert = .eraseData
|
||||
showAlert = true
|
||||
}
|
||||
)
|
||||
}
|
||||
Text(cacheSizeText)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Button(action: clearAllCaches) {
|
||||
Text("Clear All Caches")
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: Text("App storage"), footer: Text("The caches used by Sora are stored images that help load content faster\n\nThe App Data should never be erased if you dont know what that will cause.\n\nClearing the documents folder will remove all the modules and downloads")) {
|
||||
HStack {
|
||||
Button(action: clearCache) {
|
||||
Text("Clear Cache")
|
||||
}
|
||||
Spacer()
|
||||
Text("\(formatSize(cacheSize))")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Button(action: {
|
||||
showRemoveDocumentsAlert = true
|
||||
}) {
|
||||
Text("Remove All Files in Documents")
|
||||
}
|
||||
Spacer()
|
||||
Text("\(formatSize(documentsSize))")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Button(action: {
|
||||
showRemoveMovPkgAlert = true
|
||||
}) {
|
||||
Text("Remove Downloads")
|
||||
}
|
||||
Spacer()
|
||||
Text("\(formatSize(movPkgSize))")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
showEraseAppDataAlert = true
|
||||
}) {
|
||||
Text("Erase all App Data")
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("App Data")
|
||||
.navigationViewStyle(StackNavigationViewStyle())
|
||||
.onAppear {
|
||||
// Initialize state with current values
|
||||
isMetadataCachingEnabled = MetadataCacheManager.shared.isCachingEnabled
|
||||
isImageCachingEnabled = KingfisherCacheManager.shared.isCachingEnabled
|
||||
isMemoryOnlyMode = MetadataCacheManager.shared.isMemoryOnlyMode
|
||||
calculateCacheSize()
|
||||
updateSizes()
|
||||
}
|
||||
.alert(isPresented: $showEraseAppDataAlert) {
|
||||
Alert(
|
||||
title: Text("Erase App Data"),
|
||||
message: Text("Are you sure you want to erase all app data? This action cannot be undone."),
|
||||
primaryButton: .destructive(Text("Erase")) {
|
||||
eraseAppData()
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
.alert(isPresented: $showRemoveDocumentsAlert) {
|
||||
Alert(
|
||||
title: Text("Remove Documents"),
|
||||
message: Text("Are you sure you want to remove all files in the Documents folder? This will remove all modules."),
|
||||
primaryButton: .destructive(Text("Remove")) {
|
||||
removeAllFilesInDocuments()
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
.alert(isPresented: $showRemoveMovPkgAlert) {
|
||||
Alert(
|
||||
title: Text("Remove Downloads"),
|
||||
message: Text("Are you sure you want to remove all Downloads?"),
|
||||
primaryButton: .destructive(Text("Remove")) {
|
||||
removeMovPkgFiles()
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate and update the combined cache size
|
||||
func calculateCacheSize() {
|
||||
isCalculatingSize = true
|
||||
cacheSizeText = "Calculating..."
|
||||
|
||||
// Group all cache size calculations
|
||||
DispatchQueue.global(qos: .background).async {
|
||||
var totalSize: Int64 = 0
|
||||
|
||||
// Get metadata cache size
|
||||
let metadataSize = MetadataCacheManager.shared.getCacheSize()
|
||||
totalSize += metadataSize
|
||||
|
||||
// Get image cache size asynchronously
|
||||
KingfisherCacheManager.shared.calculateCacheSize { imageSize in
|
||||
totalSize += Int64(imageSize)
|
||||
|
||||
// Update the UI on the main thread
|
||||
DispatchQueue.main.async {
|
||||
self.cacheSizeText = KingfisherCacheManager.formatCacheSize(UInt(totalSize))
|
||||
self.isCalculatingSize = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear all caches (both metadata and images)
|
||||
func clearAllCaches() {
|
||||
// Clear metadata cache
|
||||
MetadataCacheManager.shared.clearAllCache()
|
||||
|
||||
// Clear image cache
|
||||
KingfisherCacheManager.shared.clearCache {
|
||||
// Update cache size after clearing
|
||||
calculateCacheSize()
|
||||
}
|
||||
|
||||
Logger.shared.log("All caches cleared", type: "General")
|
||||
}
|
||||
|
||||
func eraseAppData() {
|
||||
if let domain = Bundle.main.bundleIdentifier {
|
||||
UserDefaults.standard.removePersistentDomain(forName: domain)
|
||||
UserDefaults.standard.synchronize()
|
||||
Logger.shared.log("Cleared app data!", type: "General")
|
||||
exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
func clearCache() {
|
||||
let cacheURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
|
||||
|
||||
do {
|
||||
if let cacheURL = cacheURL {
|
||||
let filePaths = try FileManager.default.contentsOfDirectory(at: cacheURL, includingPropertiesForKeys: nil, options: [])
|
||||
for filePath in filePaths {
|
||||
try FileManager.default.removeItem(at: filePath)
|
||||
}
|
||||
Logger.shared.log("Cache cleared successfully!", type: "General")
|
||||
.scrollViewBottomPadding()
|
||||
.navigationTitle("App Data")
|
||||
.onAppear {
|
||||
calculateCacheSize()
|
||||
updateSizes()
|
||||
}
|
||||
} catch {
|
||||
Logger.shared.log("Failed to clear cache.", type: "Error")
|
||||
}
|
||||
}
|
||||
|
||||
func removeAllFilesInDocuments() {
|
||||
let fileManager = FileManager.default
|
||||
if let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first {
|
||||
do {
|
||||
let fileURLs = try fileManager.contentsOfDirectory(at: documentsURL, includingPropertiesForKeys: nil)
|
||||
for fileURL in fileURLs {
|
||||
try fileManager.removeItem(at: fileURL)
|
||||
.alert(isPresented: $showAlert) {
|
||||
switch activeAlert {
|
||||
case .eraseData:
|
||||
return Alert(
|
||||
title: Text("Erase App Data"),
|
||||
message: Text("Are you sure you want to erase all app data? This action cannot be undone."),
|
||||
primaryButton: .destructive(Text("Erase")) {
|
||||
eraseAppData()
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
case .removeDocs:
|
||||
return Alert(
|
||||
title: Text("Remove Documents"),
|
||||
message: Text("Are you sure you want to remove all files in the Documents folder? This will remove all modules."),
|
||||
primaryButton: .destructive(Text("Remove")) {
|
||||
removeAllFilesInDocuments()
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
Logger.shared.log("All files in documents folder removed", type: "General")
|
||||
exit(0)
|
||||
} catch {
|
||||
Logger.shared.log("Error removing files in documents folder: \(error)", type: "Error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func removeMovPkgFiles() {
|
||||
let fileManager = FileManager.default
|
||||
if let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first {
|
||||
do {
|
||||
let fileURLs = try fileManager.contentsOfDirectory(at: documentsURL, includingPropertiesForKeys: nil)
|
||||
for fileURL in fileURLs {
|
||||
if fileURL.pathExtension == "movpkg" {
|
||||
try fileManager.removeItem(at: fileURL)
|
||||
|
||||
func calculateCacheSize() {
|
||||
isCalculatingSize = true
|
||||
cacheSizeText = "Calculating..."
|
||||
|
||||
DispatchQueue.global(qos: .background).async {
|
||||
if let cacheURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first {
|
||||
let size = calculateDirectorySize(for: cacheURL)
|
||||
DispatchQueue.main.async {
|
||||
self.cacheSize = size
|
||||
self.cacheSizeText = formatSize(size)
|
||||
self.isCalculatingSize = false
|
||||
}
|
||||
} else {
|
||||
DispatchQueue.main.async {
|
||||
self.cacheSizeText = "Unknown"
|
||||
self.isCalculatingSize = false
|
||||
}
|
||||
}
|
||||
Logger.shared.log("All Downloads files removed", type: "General")
|
||||
updateSizes()
|
||||
} catch {
|
||||
Logger.shared.log("Error removing Downloads files: \(error)", type: "Error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func calculateDirectorySize(for url: URL) -> Int64 {
|
||||
let fileManager = FileManager.default
|
||||
var totalSize: Int64 = 0
|
||||
|
||||
do {
|
||||
let contents = try fileManager.contentsOfDirectory(at: url, includingPropertiesForKeys: [.fileSizeKey])
|
||||
for url in contents {
|
||||
let resourceValues = try url.resourceValues(forKeys: [.fileSizeKey, .isDirectoryKey])
|
||||
if resourceValues.isDirectory == true {
|
||||
totalSize += calculateDirectorySize(for: url)
|
||||
} else {
|
||||
totalSize += Int64(resourceValues.fileSize ?? 0)
|
||||
func updateSizes() {
|
||||
DispatchQueue.global(qos: .background).async {
|
||||
if let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first {
|
||||
let size = calculateDirectorySize(for: documentsURL)
|
||||
DispatchQueue.main.async {
|
||||
self.documentsSize = size
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Logger.shared.log("Error calculating directory size: \(error)", type: "Error")
|
||||
}
|
||||
|
||||
return totalSize
|
||||
}
|
||||
|
||||
private func formatSize(_ bytes: Int64) -> String {
|
||||
let formatter = ByteCountFormatter()
|
||||
formatter.allowedUnits = [.useBytes, .useKB, .useMB, .useGB]
|
||||
formatter.countStyle = .file
|
||||
return formatter.string(fromByteCount: bytes)
|
||||
}
|
||||
|
||||
private func updateSizes() {
|
||||
if let cacheURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first {
|
||||
cacheSize = calculateDirectorySize(for: cacheURL)
|
||||
func clearAllCaches() {
|
||||
clearCache()
|
||||
}
|
||||
|
||||
if let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first {
|
||||
documentsSize = calculateDirectorySize(for: documentsURL)
|
||||
movPkgSize = calculateMovPkgSize(in: documentsURL)
|
||||
}
|
||||
}
|
||||
|
||||
private func calculateMovPkgSize(in url: URL) -> Int64 {
|
||||
let fileManager = FileManager.default
|
||||
var totalSize: Int64 = 0
|
||||
|
||||
do {
|
||||
let contents = try fileManager.contentsOfDirectory(at: url, includingPropertiesForKeys: [.fileSizeKey])
|
||||
for url in contents where url.pathExtension == "movpkg" {
|
||||
let resourceValues = try url.resourceValues(forKeys: [.fileSizeKey])
|
||||
totalSize += Int64(resourceValues.fileSize ?? 0)
|
||||
func clearCache() {
|
||||
let cacheURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
|
||||
do {
|
||||
if let cacheURL = cacheURL {
|
||||
let filePaths = try FileManager.default.contentsOfDirectory(at: cacheURL, includingPropertiesForKeys: nil, options: [])
|
||||
for filePath in filePaths {
|
||||
try FileManager.default.removeItem(at: filePath)
|
||||
}
|
||||
Logger.shared.log("Cache cleared successfully!", type: "General")
|
||||
calculateCacheSize()
|
||||
updateSizes()
|
||||
}
|
||||
} catch {
|
||||
Logger.shared.log("Failed to clear cache.", type: "Error")
|
||||
}
|
||||
} catch {
|
||||
Logger.shared.log("Error calculating MovPkg size: \(error)", type: "Error")
|
||||
}
|
||||
|
||||
return totalSize
|
||||
func removeAllFilesInDocuments() {
|
||||
let fileManager = FileManager.default
|
||||
if let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first {
|
||||
do {
|
||||
let fileURLs = try fileManager.contentsOfDirectory(at: documentsURL, includingPropertiesForKeys: nil)
|
||||
for fileURL in fileURLs {
|
||||
try fileManager.removeItem(at: fileURL)
|
||||
}
|
||||
Logger.shared.log("All files in documents folder removed", type: "General")
|
||||
exit(0)
|
||||
} catch {
|
||||
Logger.shared.log("Error removing files in documents folder: \(error)", type: "Error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func eraseAppData() {
|
||||
if let domain = Bundle.main.bundleIdentifier {
|
||||
UserDefaults.standard.removePersistentDomain(forName: domain)
|
||||
UserDefaults.standard.synchronize()
|
||||
Logger.shared.log("Cleared app data!", type: "General")
|
||||
exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
func calculateDirectorySize(for url: URL) -> Int64 {
|
||||
let fileManager = FileManager.default
|
||||
var totalSize: Int64 = 0
|
||||
|
||||
do {
|
||||
let contents = try fileManager.contentsOfDirectory(at: url, includingPropertiesForKeys: [.fileSizeKey])
|
||||
for url in contents {
|
||||
let resourceValues = try url.resourceValues(forKeys: [.fileSizeKey, .isDirectoryKey])
|
||||
if resourceValues.isDirectory == true {
|
||||
totalSize += calculateDirectorySize(for: url)
|
||||
} else {
|
||||
totalSize += Int64(resourceValues.fileSize ?? 0)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Logger.shared.log("Error calculating directory size: \(error)", type: "Error")
|
||||
}
|
||||
|
||||
return totalSize
|
||||
}
|
||||
|
||||
func formatSize(_ bytes: Int64) -> String {
|
||||
let formatter = ByteCountFormatter()
|
||||
formatter.allowedUnits = [.useBytes, .useKB, .useMB, .useGB]
|
||||
formatter.countStyle = .file
|
||||
return formatter.string(fromByteCount: bytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,147 @@
|
|||
import SwiftUI
|
||||
import Drops
|
||||
|
||||
fileprivate struct SettingsSection<Content: View>: View {
|
||||
let title: String
|
||||
let footer: String?
|
||||
let content: Content
|
||||
|
||||
init(title: String, footer: String? = nil, @ViewBuilder content: () -> Content) {
|
||||
self.title = title
|
||||
self.footer = footer
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title.uppercased())
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.gray)
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
content
|
||||
}
|
||||
.background(.ultraThinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.strokeBorder(
|
||||
LinearGradient(
|
||||
gradient: Gradient(stops: [
|
||||
.init(color: Color.accentColor.opacity(0.3), location: 0),
|
||||
.init(color: Color.accentColor.opacity(0), location: 1)
|
||||
]),
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
),
|
||||
lineWidth: 0.5
|
||||
)
|
||||
)
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
if let footer = footer {
|
||||
Text(footer)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.gray)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate struct SettingsToggleRow: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
@Binding var isOn: Bool
|
||||
var showDivider: Bool = true
|
||||
|
||||
init(icon: String, title: String, isOn: Binding<Bool>, showDivider: Bool = true) {
|
||||
self.icon = icon
|
||||
self.title = title
|
||||
self._isOn = isOn
|
||||
self.showDivider = showDivider
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Image(systemName: icon)
|
||||
.frame(width: 24, height: 24)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Text(title)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Toggle("", isOn: $isOn)
|
||||
.labelsHidden()
|
||||
.tint(.accentColor.opacity(0.7))
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
|
||||
if showDivider {
|
||||
Divider()
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate struct SettingsPickerRow<T: Hashable>: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let options: [T]
|
||||
let optionToString: (T) -> String
|
||||
@Binding var selection: T
|
||||
var showDivider: Bool = true
|
||||
|
||||
init(icon: String, title: String, options: [T], optionToString: @escaping (T) -> String, selection: Binding<T>, showDivider: Bool = true) {
|
||||
self.icon = icon
|
||||
self.title = title
|
||||
self.options = options
|
||||
self.optionToString = optionToString
|
||||
self._selection = selection
|
||||
self.showDivider = showDivider
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Image(systemName: icon)
|
||||
.frame(width: 24, height: 24)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Text(title)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Menu {
|
||||
ForEach(options, id: \.self) { option in
|
||||
Button(action: { selection = option }) {
|
||||
Text(optionToString(option))
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Text(optionToString(selection))
|
||||
.foregroundStyle(.gray)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
|
||||
if showDivider {
|
||||
Divider()
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsViewDownloads: View {
|
||||
@EnvironmentObject private var jsController: JSController
|
||||
@AppStorage(DownloadQualityPreference.userDefaultsKey)
|
||||
|
|
@ -20,94 +161,168 @@ struct SettingsViewDownloads: View {
|
|||
@State private var isCalculating: Bool = false
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section(header: Text("Download Settings"), footer: Text("Max concurrent downloads controls how many episodes can download simultaneously. Higher values may use more bandwidth and device resources.")) {
|
||||
Picker("Quality", selection: $downloadQuality) {
|
||||
ForEach(DownloadQualityPreference.allCases, id: \.rawValue) { option in
|
||||
Text(option.rawValue)
|
||||
.tag(option.rawValue)
|
||||
}
|
||||
}
|
||||
.onChange(of: downloadQuality) { newValue in
|
||||
print("Download quality preference changed to: \(newValue)")
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Max Concurrent Downloads")
|
||||
Spacer()
|
||||
Stepper("\(maxConcurrentDownloads)", value: $maxConcurrentDownloads, in: 1...10)
|
||||
.onChange(of: maxConcurrentDownloads) { newValue in
|
||||
jsController.updateMaxConcurrentDownloads(newValue)
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
SettingsSection(
|
||||
title: "Download Settings",
|
||||
footer: "Max concurrent downloads controls how many episodes can download simultaneously. Higher values may use more bandwidth and device resources."
|
||||
) {
|
||||
SettingsPickerRow(
|
||||
icon: "4k.tv",
|
||||
title: "Quality",
|
||||
options: DownloadQualityPreference.allCases.map { $0.rawValue },
|
||||
optionToString: { $0 },
|
||||
selection: $downloadQuality
|
||||
)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Image(systemName: "arrow.down.circle")
|
||||
.frame(width: 24, height: 24)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Text("Max Concurrent Downloads")
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Stepper("\(maxConcurrentDownloads)", value: $maxConcurrentDownloads, in: 1...10)
|
||||
.onChange(of: maxConcurrentDownloads) { newValue in
|
||||
jsController.updateMaxConcurrentDownloads(newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Toggle("Allow Cellular Downloads", isOn: $allowCellularDownloads)
|
||||
.tint(.accentColor)
|
||||
}
|
||||
|
||||
Section(header: Text("Quality Information")) {
|
||||
if let preferenceDescription = DownloadQualityPreference(rawValue: downloadQuality)?.description {
|
||||
Text(preferenceDescription)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: Text("Storage Management")) {
|
||||
HStack {
|
||||
Text("Storage Used")
|
||||
Spacer()
|
||||
|
||||
if isCalculating {
|
||||
ProgressView()
|
||||
.scaleEffect(0.7)
|
||||
.padding(.trailing, 5)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
|
||||
Divider()
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
Text(formatFileSize(totalStorageSize))
|
||||
.foregroundColor(.secondary)
|
||||
SettingsToggleRow(
|
||||
icon: "antenna.radiowaves.left.and.right",
|
||||
title: "Allow Cellular Downloads",
|
||||
isOn: $allowCellularDownloads,
|
||||
showDivider: false
|
||||
)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Files Downloaded")
|
||||
Spacer()
|
||||
Text("\(existingDownloadCount) of \(jsController.savedAssets.count)")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
calculateTotalStorage()
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
Text("Refresh Storage Info")
|
||||
SettingsSection(
|
||||
title: "Quality Information"
|
||||
) {
|
||||
if let preferenceDescription = DownloadQualityPreference(rawValue: downloadQuality)?.description {
|
||||
HStack {
|
||||
Text(preferenceDescription)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
showClearConfirmation = true
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "trash")
|
||||
.foregroundColor(.red)
|
||||
Text("Clear All Downloads")
|
||||
.foregroundColor(.red)
|
||||
SettingsSection(
|
||||
title: "Storage Management"
|
||||
) {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Image(systemName: "externaldrive")
|
||||
.frame(width: 24, height: 24)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Text("Storage Used")
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
if isCalculating {
|
||||
ProgressView()
|
||||
.scaleEffect(0.7)
|
||||
.padding(.trailing, 5)
|
||||
}
|
||||
|
||||
Text(formatFileSize(totalStorageSize))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
|
||||
Divider()
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
HStack {
|
||||
Image(systemName: "doc.text")
|
||||
.frame(width: 24, height: 24)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Text("Files Downloaded")
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("\(existingDownloadCount) of \(jsController.savedAssets.count)")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
|
||||
Divider()
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
Button(action: {
|
||||
calculateTotalStorage()
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
.frame(width: 24, height: 24)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Text("Refresh Storage Info")
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
|
||||
Divider()
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
Button(action: {
|
||||
showClearConfirmation = true
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "trash")
|
||||
.frame(width: 24, height: 24)
|
||||
.foregroundStyle(.red)
|
||||
|
||||
Text("Clear All Downloads")
|
||||
.foregroundStyle(.red)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert("Delete All Downloads", isPresented: $showClearConfirmation) {
|
||||
Button("Cancel", role: .cancel) { }
|
||||
Button("Delete All", role: .destructive) {
|
||||
clearAllDownloads(preservePersistentDownloads: false)
|
||||
}
|
||||
Button("Clear Library Only", role: .destructive) {
|
||||
clearAllDownloads(preservePersistentDownloads: true)
|
||||
}
|
||||
} message: {
|
||||
Text("Are you sure you want to delete all downloaded assets? You can choose to clear only the library while preserving the downloaded files for future use.")
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 20)
|
||||
}
|
||||
.navigationTitle("Downloads")
|
||||
.scrollViewBottomPadding()
|
||||
.alert("Delete All Downloads", isPresented: $showClearConfirmation) {
|
||||
Button("Cancel", role: .cancel) { }
|
||||
Button("Delete All", role: .destructive) {
|
||||
clearAllDownloads(preservePersistentDownloads: false)
|
||||
}
|
||||
Button("Clear Library Only", role: .destructive) {
|
||||
clearAllDownloads(preservePersistentDownloads: true)
|
||||
}
|
||||
} message: {
|
||||
Text("Are you sure you want to delete all downloaded assets? You can choose to clear only the library while preserving the downloaded files for future use.")
|
||||
}
|
||||
.onAppear {
|
||||
calculateTotalStorage()
|
||||
jsController.updateMaxConcurrentDownloads(maxConcurrentDownloads)
|
||||
|
|
|
|||
|
|
@ -7,106 +7,310 @@
|
|||
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsViewGeneral: View {
|
||||
@AppStorage("episodeChunkSize") private var episodeChunkSize: Int = 100
|
||||
@AppStorage("refreshModulesOnLaunch") private var refreshModulesOnLaunch: Bool = false
|
||||
@AppStorage("fetchEpisodeMetadata") private var fetchEpisodeMetadata: Bool = true
|
||||
@AppStorage("analyticsEnabled") private var analyticsEnabled: Bool = false
|
||||
@AppStorage("multiThreads") private var multiThreadsEnabled: Bool = false
|
||||
@AppStorage("metadataProviders") private var metadataProviders: String = "AniList"
|
||||
@AppStorage("mediaColumnsPortrait") private var mediaColumnsPortrait: Int = 2
|
||||
@AppStorage("mediaColumnsLandscape") private var mediaColumnsLandscape: Int = 4
|
||||
@AppStorage("episodeSortOrder") private var episodeSortOrder: String = "Ascending"
|
||||
fileprivate struct SettingsSection<Content: View>: View {
|
||||
let title: String
|
||||
let footer: String?
|
||||
let content: Content
|
||||
|
||||
private let metadataProvidersList = ["AniList"]
|
||||
private let sortOrderOptions = ["Ascending", "Descending"]
|
||||
@EnvironmentObject var settings: Settings
|
||||
init(title: String, footer: String? = nil, @ViewBuilder content: () -> Content) {
|
||||
self.title = title
|
||||
self.footer = footer
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section(header: Text("Interface")) {
|
||||
ColorPicker("Accent Color", selection: $settings.accentColor)
|
||||
HStack {
|
||||
Text("Appearance")
|
||||
Picker("Appearance", selection: $settings.selectedAppearance) {
|
||||
Text("System").tag(Appearance.system)
|
||||
Text("Light").tag(Appearance.light)
|
||||
Text("Dark").tag(Appearance.dark)
|
||||
}
|
||||
.pickerStyle(SegmentedPickerStyle())
|
||||
}
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title.uppercased())
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.gray)
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
Section(header: Text("Media View"), footer: Text("The episode range controls how many episodes appear on each page. Episodes are grouped into sets (like 1-25, 26-50, and so on), allowing you to navigate through them more easily.\n\nFor episode metadata it is refering to the episode thumbnail and title, since sometimes it can contain spoilers.")) {
|
||||
|
||||
HStack {
|
||||
Text("Episodes Range")
|
||||
Spacer()
|
||||
Menu {
|
||||
Button(action: { episodeChunkSize = 25 }) { Text("25") }
|
||||
Button(action: { episodeChunkSize = 50 }) { Text("50") }
|
||||
Button(action: { episodeChunkSize = 75 }) { Text("75") }
|
||||
Button(action: { episodeChunkSize = 100 }) { Text("100") }
|
||||
} label: {
|
||||
Text("\(episodeChunkSize)")
|
||||
}
|
||||
}
|
||||
|
||||
Toggle("Fetch Episode metadata", isOn: $fetchEpisodeMetadata)
|
||||
.tint(.accentColor)
|
||||
|
||||
HStack {
|
||||
Text("Metadata Provider")
|
||||
Spacer()
|
||||
Menu(metadataProviders) {
|
||||
ForEach(metadataProvidersList, id: \.self) { provider in
|
||||
Button(action: { metadataProviders = provider }) {
|
||||
Text(provider)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
VStack(spacing: 0) {
|
||||
content
|
||||
}
|
||||
.background(.ultraThinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.strokeBorder(
|
||||
LinearGradient(
|
||||
gradient: Gradient(stops: [
|
||||
.init(color: Color.accentColor.opacity(0.3), location: 0),
|
||||
.init(color: Color.accentColor.opacity(0), location: 1)
|
||||
]),
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
),
|
||||
lineWidth: 0.5
|
||||
)
|
||||
)
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
Section(header: Text("Media Grid Layout"), footer: Text("Adjust the number of media items per row in portrait and landscape modes.")) {
|
||||
HStack {
|
||||
if UIDevice.current.userInterfaceIdiom == .pad {
|
||||
Picker("Portrait Columns", selection: $mediaColumnsPortrait) {
|
||||
ForEach(1..<6) { i in Text("\(i)").tag(i) }
|
||||
}
|
||||
.pickerStyle(MenuPickerStyle())
|
||||
} else {
|
||||
Picker("Portrait Columns", selection: $mediaColumnsPortrait) {
|
||||
ForEach(1..<5) { i in Text("\(i)").tag(i) }
|
||||
}
|
||||
.pickerStyle(MenuPickerStyle())
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
if UIDevice.current.userInterfaceIdiom == .pad {
|
||||
Picker("Landscape Columns", selection: $mediaColumnsLandscape) {
|
||||
ForEach(2..<9) { i in Text("\(i)").tag(i) }
|
||||
}
|
||||
.pickerStyle(MenuPickerStyle())
|
||||
} else {
|
||||
Picker("Landscape Columns", selection: $mediaColumnsLandscape) {
|
||||
ForEach(2..<6) { i in Text("\(i)").tag(i) }
|
||||
}
|
||||
.pickerStyle(MenuPickerStyle())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: Text("Modules"), footer: Text("Note that the modules will be replaced only if there is a different version string inside the JSON file.")) {
|
||||
Toggle("Refresh Modules on Launch", isOn: $refreshModulesOnLaunch)
|
||||
.tint(.accentColor)
|
||||
}
|
||||
|
||||
Section(header: Text("Advanced"), footer: Text("Anonymous data is collected to improve the app. No personal information is collected. This can be disabled at any time.")) {
|
||||
Toggle("Enable Analytics", isOn: $analyticsEnabled)
|
||||
.tint(.accentColor)
|
||||
if let footer = footer {
|
||||
Text(footer)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.gray)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate struct SettingsToggleRow: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
@Binding var isOn: Bool
|
||||
var showDivider: Bool = true
|
||||
|
||||
init(icon: String, title: String, isOn: Binding<Bool>, showDivider: Bool = true) {
|
||||
self.icon = icon
|
||||
self.title = title
|
||||
self._isOn = isOn
|
||||
self.showDivider = showDivider
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Image(systemName: icon)
|
||||
.frame(width: 24, height: 24)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Text(title)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Toggle("", isOn: $isOn)
|
||||
.labelsHidden()
|
||||
.tint(.accentColor.opacity(0.7))
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
|
||||
if showDivider {
|
||||
Divider()
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate struct SettingsPickerRow<T: Hashable>: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let options: [T]
|
||||
let optionToString: (T) -> String
|
||||
@Binding var selection: T
|
||||
var showDivider: Bool = true
|
||||
|
||||
init(icon: String, title: String, options: [T], optionToString: @escaping (T) -> String, selection: Binding<T>, showDivider: Bool = true) {
|
||||
self.icon = icon
|
||||
self.title = title
|
||||
self.options = options
|
||||
self.optionToString = optionToString
|
||||
self._selection = selection
|
||||
self.showDivider = showDivider
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Image(systemName: icon)
|
||||
.frame(width: 24, height: 24)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Text(title)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Menu {
|
||||
ForEach(options, id: \.self) { option in
|
||||
Button(action: { selection = option }) {
|
||||
Text(optionToString(option))
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Text(optionToString(selection))
|
||||
.foregroundStyle(.gray)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
|
||||
if showDivider {
|
||||
Divider()
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsViewGeneral: View {
|
||||
@AppStorage("episodeChunkSize") private var episodeChunkSize: Int = 100
|
||||
@AppStorage("refreshModulesOnLaunch") private var refreshModulesOnLaunch: Bool = true
|
||||
@AppStorage("fetchEpisodeMetadata") private var fetchEpisodeMetadata: Bool = true
|
||||
@AppStorage("analyticsEnabled") private var analyticsEnabled: Bool = false
|
||||
@AppStorage("metadataProviders") private var metadataProviders: String = "TMDB"
|
||||
@AppStorage("tmdbImageWidth") private var TMDBimageWidht: String = "original"
|
||||
@AppStorage("mediaColumnsPortrait") private var mediaColumnsPortrait: Int = 2
|
||||
@AppStorage("mediaColumnsLandscape") private var mediaColumnsLandscape: Int = 4
|
||||
@AppStorage("currentAppIcon") private var currentAppIcon = "Default"
|
||||
|
||||
private let metadataProvidersList = ["AniList", "TMDB"]
|
||||
private let TMDBimageWidhtList = ["300", "500", "780", "1280", "original"]
|
||||
private let sortOrderOptions = ["Ascending", "Descending"]
|
||||
@EnvironmentObject var settings: Settings
|
||||
@State private var showAppIconPicker = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
SettingsSection(title: "Interface") {
|
||||
SettingsPickerRow(
|
||||
icon: "paintbrush",
|
||||
title: "Appearance",
|
||||
options: [Appearance.system, .light, .dark],
|
||||
optionToString: { appearance in
|
||||
switch appearance {
|
||||
case .system: return "System"
|
||||
case .light: return "Light"
|
||||
case .dark: return "Dark"
|
||||
}
|
||||
},
|
||||
selection: $settings.selectedAppearance
|
||||
)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Image(systemName: "app")
|
||||
.frame(width: 24, height: 24)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Text("App Icon")
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: {
|
||||
showAppIconPicker = true
|
||||
}) {
|
||||
Text(currentAppIcon)
|
||||
.foregroundStyle(.gray)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
}
|
||||
|
||||
SettingsSection(
|
||||
title: "Media View",
|
||||
footer: "The episode range controls how many episodes appear on each page. Episodes are grouped into sets (like 1–25, 26–50, 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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,25 +6,117 @@
|
|||
//
|
||||
|
||||
import SwiftUI
|
||||
import Kingfisher
|
||||
|
||||
fileprivate struct SettingsSection<Content: View>: View {
|
||||
let title: String
|
||||
let footer: String?
|
||||
let content: Content
|
||||
|
||||
init(title: String, footer: String? = nil, @ViewBuilder content: () -> Content) {
|
||||
self.title = title
|
||||
self.footer = footer
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title.uppercased())
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.gray)
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
content
|
||||
}
|
||||
.background(.ultraThinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.strokeBorder(
|
||||
LinearGradient(
|
||||
gradient: Gradient(stops: [
|
||||
.init(color: Color.accentColor.opacity(0.3), location: 0),
|
||||
.init(color: Color.accentColor.opacity(0), location: 1)
|
||||
]),
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
),
|
||||
lineWidth: 0.5
|
||||
)
|
||||
)
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
if let footer = footer {
|
||||
Text(footer)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.gray)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
.scrollViewBottomPadding()
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsViewLogger: View {
|
||||
@State private var logs: String = ""
|
||||
@State private var isLoading: Bool = true
|
||||
@State private var showFullLogs: Bool = false
|
||||
@StateObject private var filterViewModel = LogFilterViewModel.shared
|
||||
|
||||
private let displayCharacterLimit = 50_000
|
||||
|
||||
var displayedLogs: String {
|
||||
if showFullLogs || logs.count <= displayCharacterLimit {
|
||||
return logs
|
||||
}
|
||||
return String(logs.suffix(displayCharacterLimit))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
ScrollView {
|
||||
Text(logs)
|
||||
.font(.footnote)
|
||||
.foregroundColor(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
.navigationTitle("Logs")
|
||||
.onAppear {
|
||||
logs = Logger.shared.getLogs()
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
SettingsSection(title: "Logs") {
|
||||
if isLoading {
|
||||
HStack {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
Text("Loading logs...")
|
||||
.font(.footnote)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.vertical, 20)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(displayedLogs)
|
||||
.font(.footnote)
|
||||
.foregroundColor(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.textSelection(.enabled)
|
||||
|
||||
if logs.count > displayCharacterLimit && !showFullLogs {
|
||||
Button(action: {
|
||||
showFullLogs = true
|
||||
}) {
|
||||
Text("Show More (\(logs.count - displayCharacterLimit) more characters)")
|
||||
.font(.footnote)
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 20)
|
||||
}
|
||||
.navigationTitle("Logs")
|
||||
.onAppear {
|
||||
loadLogsAsync()
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
|
|
@ -37,8 +129,7 @@ struct SettingsViewLogger: View {
|
|||
Label("Copy to Clipboard", systemImage: "doc.on.doc")
|
||||
}
|
||||
Button(role: .destructive, action: {
|
||||
Logger.shared.clearLogs()
|
||||
logs = Logger.shared.getLogs()
|
||||
clearLogsAsync()
|
||||
}) {
|
||||
Label("Clear Logs", systemImage: "trash")
|
||||
}
|
||||
|
|
@ -55,4 +146,24 @@ struct SettingsViewLogger: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadLogsAsync() {
|
||||
Task {
|
||||
let loadedLogs = await Logger.shared.getLogsAsync()
|
||||
await MainActor.run {
|
||||
self.logs = loadedLogs
|
||||
self.isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func clearLogsAsync() {
|
||||
Task {
|
||||
await Logger.shared.clearLogsAsync()
|
||||
await MainActor.run {
|
||||
self.logs = ""
|
||||
self.showFullLogs = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,96 @@
|
|||
|
||||
import SwiftUI
|
||||
|
||||
fileprivate struct SettingsSection<Content: View>: View {
|
||||
let title: String
|
||||
let footer: String?
|
||||
let content: Content
|
||||
|
||||
init(title: String, footer: String? = nil, @ViewBuilder content: () -> Content) {
|
||||
self.title = title
|
||||
self.footer = footer
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title.uppercased())
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.gray)
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
content
|
||||
}
|
||||
.background(.ultraThinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.strokeBorder(
|
||||
LinearGradient(
|
||||
gradient: Gradient(stops: [
|
||||
.init(color: Color.accentColor.opacity(0.3), location: 0),
|
||||
.init(color: Color.accentColor.opacity(0), location: 1)
|
||||
]),
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
),
|
||||
lineWidth: 0.5
|
||||
)
|
||||
)
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
if let footer = footer {
|
||||
Text(footer)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.gray)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate struct SettingsToggleRow: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
@Binding var isOn: Bool
|
||||
var showDivider: Bool = true
|
||||
|
||||
init(icon: String, title: String, isOn: Binding<Bool>, showDivider: Bool = true) {
|
||||
self.icon = icon
|
||||
self.title = title
|
||||
self._isOn = isOn
|
||||
self.showDivider = showDivider
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Image(systemName: icon)
|
||||
.frame(width: 24, height: 24)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Text(title)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Toggle("", isOn: $isOn)
|
||||
.labelsHidden()
|
||||
.tint(.accentColor.opacity(0.7))
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
|
||||
if showDivider {
|
||||
Divider()
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LogFilter: Identifiable, Hashable {
|
||||
let id = UUID()
|
||||
let type: String
|
||||
|
|
@ -74,14 +164,33 @@ class LogFilterViewModel: ObservableObject {
|
|||
struct SettingsViewLoggerFilter: View {
|
||||
@ObservedObject var viewModel = LogFilterViewModel.shared
|
||||
|
||||
private func iconForFilter(_ type: String) -> String {
|
||||
switch type {
|
||||
case "General": return "gear"
|
||||
case "Stream": return "play.circle"
|
||||
case "Error": return "exclamationmark.triangle"
|
||||
case "Debug": return "ladybug"
|
||||
case "Download": return "arrow.down.circle"
|
||||
case "HTMLStrings": return "text.alignleft"
|
||||
default: return "circle"
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
ForEach($viewModel.filters) { $filter in
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Toggle(filter.type, isOn: $filter.isEnabled)
|
||||
.tint(.accentColor)
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
SettingsSection(title: "Log Types") {
|
||||
ForEach($viewModel.filters) { $filter in
|
||||
SettingsToggleRow(
|
||||
icon: iconForFilter(filter.type),
|
||||
title: filter.type,
|
||||
isOn: $filter.isEnabled,
|
||||
showDivider: viewModel.filters.last?.id != filter.id
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 20)
|
||||
}
|
||||
.navigationTitle("Log Filters")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,137 @@
|
|||
import SwiftUI
|
||||
import Kingfisher
|
||||
|
||||
fileprivate struct SettingsSection<Content: View>: View {
|
||||
let title: String
|
||||
let footer: String?
|
||||
let content: Content
|
||||
|
||||
init(title: String, footer: String? = nil, @ViewBuilder content: () -> Content) {
|
||||
self.title = title
|
||||
self.footer = footer
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title.uppercased())
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.gray)
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
content
|
||||
}
|
||||
.background(.ultraThinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.strokeBorder(
|
||||
LinearGradient(
|
||||
gradient: Gradient(stops: [
|
||||
.init(color: Color.accentColor.opacity(0.3), location: 0),
|
||||
.init(color: Color.accentColor.opacity(0), location: 1)
|
||||
]),
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
),
|
||||
lineWidth: 0.5
|
||||
)
|
||||
)
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
if let footer = footer {
|
||||
Text(footer)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.gray)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate struct ModuleListItemView: View {
|
||||
let module: Module
|
||||
let selectedModuleId: String?
|
||||
let onDelete: () -> Void
|
||||
let onSelect: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
KFImage(URL(string: module.metadata.iconUrl))
|
||||
.resizable()
|
||||
.frame(width: 40, height: 40)
|
||||
.clipShape(Circle())
|
||||
.padding(.trailing, 10)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(alignment: .bottom, spacing: 4) {
|
||||
Text(module.metadata.sourceName)
|
||||
.font(.headline)
|
||||
.foregroundStyle(.primary)
|
||||
Text("v\(module.metadata.version)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.gray)
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Text(module.metadata.author.name)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.gray)
|
||||
|
||||
Text("•")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.gray)
|
||||
|
||||
Text(module.metadata.language)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.gray)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if module.id.uuidString == selectedModuleId {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(Color.accentColor)
|
||||
.frame(width: 20, height: 20)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture(perform: onSelect)
|
||||
.contextMenu {
|
||||
Button(action: {
|
||||
UIPasteboard.general.string = module.metadataUrl
|
||||
DropManager.shared.showDrop(title: "Copied to Clipboard", subtitle: "", duration: 1.0, icon: UIImage(systemName: "doc.on.clipboard.fill"))
|
||||
}) {
|
||||
Label("Copy URL", systemImage: "doc.on.doc")
|
||||
}
|
||||
Button(role: .destructive) {
|
||||
if selectedModuleId != module.id.uuidString {
|
||||
onDelete()
|
||||
}
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
.disabled(selectedModuleId == module.id.uuidString)
|
||||
}
|
||||
.swipeActions {
|
||||
if selectedModuleId != module.id.uuidString {
|
||||
Button(role: .destructive) {
|
||||
onDelete()
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsViewModule: View {
|
||||
@AppStorage("selectedModuleId") private var selectedModuleId: String?
|
||||
@EnvironmentObject var moduleManager: ModuleManager
|
||||
|
|
@ -21,142 +152,101 @@ struct SettingsViewModule: View {
|
|||
@State private var showLibrary = false
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Form {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
if moduleManager.modules.isEmpty {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "plus.app")
|
||||
.font(.largeTitle)
|
||||
.foregroundColor(.secondary)
|
||||
Text("No Modules")
|
||||
.font(.headline)
|
||||
SettingsSection(title: "Modules") {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "plus.app")
|
||||
.font(.largeTitle)
|
||||
.foregroundColor(.secondary)
|
||||
Text("No Modules")
|
||||
.font(.headline)
|
||||
|
||||
if didReceiveDefaultPageLink {
|
||||
NavigationLink(destination: CommunityLibraryView()
|
||||
.environmentObject(moduleManager)) {
|
||||
Text("Check out some community modules here!")
|
||||
if didReceiveDefaultPageLink {
|
||||
NavigationLink(destination: CommunityLibraryView()
|
||||
.environmentObject(moduleManager)) {
|
||||
Text("Check out some community modules here!")
|
||||
.font(.caption)
|
||||
.foregroundColor(.accentColor)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
} else {
|
||||
Text("Click the plus button to add a module!")
|
||||
.font(.caption)
|
||||
.foregroundColor(.accentColor)
|
||||
.foregroundColor(.secondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
} else {
|
||||
Text("Click the plus button to add a module!")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.padding(.vertical, 24)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
ForEach(moduleManager.modules) { module in
|
||||
HStack {
|
||||
KFImage(URL(string: module.metadata.iconUrl))
|
||||
.resizable()
|
||||
.frame(width: 50, height: 50)
|
||||
.clipShape(Circle())
|
||||
.padding(.trailing, 10)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
HStack(alignment: .bottom, spacing: 4) {
|
||||
Text(module.metadata.sourceName)
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
Text("v\(module.metadata.version)")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Text("Author: \(module.metadata.author.name)")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
Text("Language: \(module.metadata.language)")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if module.id.uuidString == selectedModuleId {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.accentColor)
|
||||
.frame(width: 25, height: 25)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
selectedModuleId = module.id.uuidString
|
||||
}
|
||||
.contextMenu {
|
||||
Button(action: {
|
||||
UIPasteboard.general.string = module.metadataUrl
|
||||
DropManager.shared.showDrop(title: "Copied to Clipboard", subtitle: "", duration: 1.0, icon: UIImage(systemName: "doc.on.clipboard.fill"))
|
||||
}) {
|
||||
Label("Copy URL", systemImage: "doc.on.doc")
|
||||
}
|
||||
Button(role: .destructive) {
|
||||
if selectedModuleId != module.id.uuidString {
|
||||
SettingsSection(title: "Installed Modules") {
|
||||
ForEach(moduleManager.modules) { module in
|
||||
ModuleListItemView(
|
||||
module: module,
|
||||
selectedModuleId: selectedModuleId,
|
||||
onDelete: {
|
||||
moduleManager.deleteModule(module)
|
||||
DropManager.shared.showDrop(title: "Module Removed", subtitle: "", duration: 1.0, icon: UIImage(systemName: "trash"))
|
||||
},
|
||||
onSelect: {
|
||||
selectedModuleId = module.id.uuidString
|
||||
}
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
.disabled(selectedModuleId == module.id.uuidString)
|
||||
}
|
||||
.swipeActions {
|
||||
if selectedModuleId != module.id.uuidString {
|
||||
Button(role: .destructive) {
|
||||
moduleManager.deleteModule(module)
|
||||
DropManager.shared.showDrop(title: "Module Removed", subtitle: "", duration: 1.0, icon: UIImage(systemName: "trash"))
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
)
|
||||
|
||||
if module.id != moduleManager.modules.last?.id {
|
||||
Divider()
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Modules")
|
||||
.navigationBarItems(trailing:
|
||||
HStack(spacing: 16) {
|
||||
if didReceiveDefaultPageLink && !moduleManager.modules.isEmpty {
|
||||
Button(action: {
|
||||
showLibrary = true
|
||||
}) {
|
||||
Image(systemName: "books.vertical.fill")
|
||||
.resizable()
|
||||
.frame(width: 20, height: 20)
|
||||
.padding(5)
|
||||
}
|
||||
.accessibilityLabel("Open Community Library")
|
||||
}
|
||||
|
||||
.padding(.vertical, 20)
|
||||
}
|
||||
.scrollViewBottomPadding()
|
||||
.navigationTitle("Modules")
|
||||
.navigationBarItems(trailing:
|
||||
HStack(spacing: 16) {
|
||||
if didReceiveDefaultPageLink && !moduleManager.modules.isEmpty {
|
||||
Button(action: {
|
||||
showAddModuleAlert()
|
||||
showLibrary = true
|
||||
}) {
|
||||
Image(systemName: "plus")
|
||||
Image(systemName: "books.vertical.fill")
|
||||
.resizable()
|
||||
.frame(width: 20, height: 20)
|
||||
.padding(5)
|
||||
}
|
||||
.accessibilityLabel("Add Module")
|
||||
.accessibilityLabel("Open Community Library")
|
||||
}
|
||||
)
|
||||
.background(
|
||||
NavigationLink(
|
||||
destination: CommunityLibraryView()
|
||||
.environmentObject(moduleManager),
|
||||
isActive: $showLibrary
|
||||
) { EmptyView() }
|
||||
)
|
||||
.refreshable {
|
||||
isRefreshing = true
|
||||
refreshTask?.cancel()
|
||||
refreshTask = Task {
|
||||
await moduleManager.refreshModules()
|
||||
isRefreshing = false
|
||||
|
||||
Button(action: {
|
||||
showAddModuleAlert()
|
||||
}) {
|
||||
Image(systemName: "plus")
|
||||
.resizable()
|
||||
.frame(width: 20, height: 20)
|
||||
.padding(5)
|
||||
}
|
||||
.accessibilityLabel("Add Module")
|
||||
}
|
||||
)
|
||||
.background(
|
||||
NavigationLink(
|
||||
destination: CommunityLibraryView()
|
||||
.environmentObject(moduleManager),
|
||||
isActive: $showLibrary
|
||||
) { EmptyView() }
|
||||
)
|
||||
.refreshable {
|
||||
isRefreshing = true
|
||||
refreshTask?.cancel()
|
||||
refreshTask = Task {
|
||||
await moduleManager.refreshModules()
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,191 @@
|
|||
|
||||
import SwiftUI
|
||||
|
||||
fileprivate struct SettingsSection<Content: View>: View {
|
||||
let title: String
|
||||
let footer: String?
|
||||
let content: Content
|
||||
|
||||
init(title: String, footer: String? = nil, @ViewBuilder content: () -> Content) {
|
||||
self.title = title
|
||||
self.footer = footer
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title.uppercased())
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.gray)
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
content
|
||||
}
|
||||
.background(.ultraThinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.strokeBorder(
|
||||
LinearGradient(
|
||||
gradient: Gradient(stops: [
|
||||
.init(color: Color.accentColor.opacity(0.3), location: 0),
|
||||
.init(color: Color.accentColor.opacity(0), location: 1)
|
||||
]),
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
),
|
||||
lineWidth: 0.5
|
||||
)
|
||||
)
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
if let footer = footer {
|
||||
Text(footer)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.gray)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate struct SettingsToggleRow: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
@Binding var isOn: Bool
|
||||
var showDivider: Bool = true
|
||||
|
||||
init(icon: String, title: String, isOn: Binding<Bool>, showDivider: Bool = true) {
|
||||
self.icon = icon
|
||||
self.title = title
|
||||
self._isOn = isOn
|
||||
self.showDivider = showDivider
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Image(systemName: icon)
|
||||
.frame(width: 24, height: 24)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Text(title)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Toggle("", isOn: $isOn)
|
||||
.labelsHidden()
|
||||
.tint(.accentColor.opacity(0.7))
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
|
||||
if showDivider {
|
||||
Divider()
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate struct SettingsPickerRow<T: Hashable>: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let options: [T]
|
||||
let optionToString: (T) -> String
|
||||
@Binding var selection: T
|
||||
var showDivider: Bool = true
|
||||
|
||||
init(icon: String, title: String, options: [T], optionToString: @escaping (T) -> String, selection: Binding<T>, showDivider: Bool = true) {
|
||||
self.icon = icon
|
||||
self.title = title
|
||||
self.options = options
|
||||
self.optionToString = optionToString
|
||||
self._selection = selection
|
||||
self.showDivider = showDivider
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Image(systemName: icon)
|
||||
.frame(width: 24, height: 24)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Text(title)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Menu {
|
||||
ForEach(options, id: \.self) { option in
|
||||
Button(action: { selection = option }) {
|
||||
Text(optionToString(option))
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Text(optionToString(selection))
|
||||
.foregroundStyle(.gray)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
|
||||
if showDivider {
|
||||
Divider()
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate struct SettingsStepperRow: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
@Binding var value: Double
|
||||
let range: ClosedRange<Double>
|
||||
let step: Double
|
||||
var formatter: (Double) -> String = { "\(Int($0))" }
|
||||
var showDivider: Bool = true
|
||||
|
||||
init(icon: String, title: String, value: Binding<Double>, range: ClosedRange<Double>, step: Double, formatter: @escaping (Double) -> String = { "\(Int($0))" }, showDivider: Bool = true) {
|
||||
self.icon = icon
|
||||
self.title = title
|
||||
self._value = value
|
||||
self.range = range
|
||||
self.step = step
|
||||
self.formatter = formatter
|
||||
self.showDivider = showDivider
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Image(systemName: icon)
|
||||
.frame(width: 24, height: 24)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Text(title)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Stepper(formatter(value), value: $value, in: range, step: step)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
|
||||
if showDivider {
|
||||
Divider()
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsViewPlayer: View {
|
||||
@AppStorage("externalPlayer") private var externalPlayer: String = "Sora"
|
||||
@AppStorage("alwaysLandscape") private var isAlwaysLandscape = false
|
||||
|
|
@ -18,107 +203,134 @@ struct SettingsViewPlayer: View {
|
|||
@AppStorage("skip85Visible") private var skip85Visible: Bool = true
|
||||
@AppStorage("doubleTapSeekEnabled") private var doubleTapSeekEnabled: Bool = false
|
||||
@AppStorage("skipIntroOutroVisible") private var skipIntroOutroVisible: Bool = true
|
||||
@AppStorage("pipButtonVisible") private var pipButtonVisible: Bool = true
|
||||
|
||||
private let mediaPlayers = ["Default", "Sora", "VLC", "OutPlayer", "Infuse", "nPlayer", "SenPlayer", "IINA"]
|
||||
private let mediaPlayers = ["Default", "Sora", "VLC", "OutPlayer", "Infuse", "nPlayer", "SenPlayer", "IINA", "TracyPlayer"]
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section(header: Text("Media Player"), footer: Text("Some features are limited to the Sora and Default player, such as ForceLandscape, holdSpeed and custom time skip increments.")) {
|
||||
HStack {
|
||||
Text("Media Player")
|
||||
Spacer()
|
||||
Menu(externalPlayer) {
|
||||
Menu("In-App Players") {
|
||||
ForEach(mediaPlayers.prefix(2), id: \.self) { player in
|
||||
Button(action: {
|
||||
externalPlayer = player
|
||||
}) {
|
||||
Text(player)
|
||||
}
|
||||
}
|
||||
}
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
SettingsSection(
|
||||
title: "Media Player",
|
||||
footer: "Some features are limited to the Sora and Default player, such as ForceLandscape, holdSpeed and custom time skip increments."
|
||||
) {
|
||||
SettingsPickerRow(
|
||||
icon: "play.circle",
|
||||
title: "Media Player",
|
||||
options: mediaPlayers,
|
||||
optionToString: { $0 },
|
||||
selection: $externalPlayer
|
||||
)
|
||||
|
||||
Menu("External Players") {
|
||||
ForEach(mediaPlayers.dropFirst(2), id: \.self) { player in
|
||||
Button(action: {
|
||||
externalPlayer = player
|
||||
}) {
|
||||
Text(player)
|
||||
SettingsToggleRow(
|
||||
icon: "rotate.right",
|
||||
title: "Force Landscape",
|
||||
isOn: $isAlwaysLandscape
|
||||
)
|
||||
|
||||
SettingsToggleRow(
|
||||
icon: "hand.tap",
|
||||
title: "Two Finger Hold for Pause",
|
||||
isOn: $holdForPauseEnabled,
|
||||
showDivider: true
|
||||
)
|
||||
|
||||
SettingsToggleRow(
|
||||
icon: "pip",
|
||||
title: "Show PiP Button",
|
||||
isOn: $pipButtonVisible,
|
||||
showDivider: false
|
||||
)
|
||||
}
|
||||
|
||||
SettingsSection(title: "Speed Settings") {
|
||||
SettingsToggleRow(
|
||||
icon: "speedometer",
|
||||
title: "Remember Playback speed",
|
||||
isOn: $isRememberPlaySpeed
|
||||
)
|
||||
|
||||
SettingsStepperRow(
|
||||
icon: "forward.fill",
|
||||
title: "Hold Speed",
|
||||
value: $holdSpeedPlayer,
|
||||
range: 0.25...2.5,
|
||||
step: 0.25,
|
||||
formatter: { String(format: "%.2f", $0) },
|
||||
showDivider: false
|
||||
)
|
||||
}
|
||||
|
||||
SettingsSection(title: "Progress bar Marker Color") {
|
||||
ColorPicker("Segments Color", selection: Binding(
|
||||
get: {
|
||||
if let data = UserDefaults.standard.data(forKey: "segmentsColorData"),
|
||||
let uiColor = try? NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(data) as? UIColor {
|
||||
return Color(uiColor)
|
||||
}
|
||||
return .yellow
|
||||
},
|
||||
set: { newColor in
|
||||
let uiColor = UIColor(newColor)
|
||||
if let data = try? NSKeyedArchiver.archivedData(
|
||||
withRootObject: uiColor,
|
||||
requiringSecureCoding: false
|
||||
) {
|
||||
UserDefaults.standard.set(data, forKey: "segmentsColorData")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Toggle("Force Landscape", isOn: $isAlwaysLandscape)
|
||||
.tint(.accentColor)
|
||||
|
||||
Toggle("Two Finger Hold for Pause",isOn: $holdForPauseEnabled)
|
||||
.tint(.accentColor)
|
||||
}
|
||||
|
||||
Section(header: Text("Speed Settings")) {
|
||||
Toggle("Remember Playback speed", isOn: $isRememberPlaySpeed)
|
||||
.tint(.accentColor)
|
||||
|
||||
HStack {
|
||||
Text("Hold Speed:")
|
||||
Spacer()
|
||||
Stepper(
|
||||
value: $holdSpeedPlayer,
|
||||
in: 0.25...2.5,
|
||||
step: 0.25
|
||||
) {
|
||||
Text(String(format: "%.2f", holdSpeedPlayer))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: Text("Progress bar Marker Color")) {
|
||||
ColorPicker("Segments Color", selection: Binding(
|
||||
get: {
|
||||
if let data = UserDefaults.standard.data(forKey: "segmentsColorData"),
|
||||
let uiColor = try? NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(data) as? UIColor {
|
||||
return Color(uiColor)
|
||||
}
|
||||
return .yellow
|
||||
},
|
||||
set: { newColor in
|
||||
let uiColor = UIColor(newColor)
|
||||
if let data = try? NSKeyedArchiver.archivedData(
|
||||
withRootObject: uiColor,
|
||||
requiringSecureCoding: false
|
||||
) {
|
||||
UserDefaults.standard.set(data, forKey: "segmentsColorData")
|
||||
}
|
||||
}
|
||||
))
|
||||
}
|
||||
|
||||
Section(header: Text("Skip Settings"), footer : Text("Double tapping the screen on it's sides will skip with the short tap setting.")) {
|
||||
HStack {
|
||||
Text("Tap Skip:")
|
||||
Spacer()
|
||||
Stepper("\(Int(skipIncrement))s", value: $skipIncrement, in: 5...300, step: 5)
|
||||
))
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Long press Skip:")
|
||||
Spacer()
|
||||
Stepper("\(Int(skipIncrementHold))s", value: $skipIncrementHold, in: 5...300, step: 5)
|
||||
SettingsSection(
|
||||
title: "Skip Settings",
|
||||
footer: "Double tapping the screen on it's sides will skip with the short tap setting."
|
||||
) {
|
||||
SettingsStepperRow(
|
||||
icon: "goforward",
|
||||
title: "Tap Skip",
|
||||
value: $skipIncrement,
|
||||
range: 5...300,
|
||||
step: 5,
|
||||
formatter: { "\(Int($0))s" }
|
||||
)
|
||||
|
||||
SettingsStepperRow(
|
||||
icon: "goforward.plus",
|
||||
title: "Long press Skip",
|
||||
value: $skipIncrementHold,
|
||||
range: 5...300,
|
||||
step: 5,
|
||||
formatter: { "\(Int($0))s" }
|
||||
)
|
||||
|
||||
SettingsToggleRow(
|
||||
icon: "hand.tap.fill",
|
||||
title: "Double Tap to Seek",
|
||||
isOn: $doubleTapSeekEnabled
|
||||
)
|
||||
|
||||
SettingsToggleRow(
|
||||
icon: "forward.end",
|
||||
title: "Show Skip 85s Button",
|
||||
isOn: $skip85Visible
|
||||
)
|
||||
|
||||
SettingsToggleRow(
|
||||
icon: "forward.frame",
|
||||
title: "Show Skip Intro / Outro Buttons",
|
||||
isOn: $skipIntroOutroVisible,
|
||||
showDivider: false
|
||||
)
|
||||
}
|
||||
|
||||
Toggle("Double Tap to Seek", isOn: $doubleTapSeekEnabled)
|
||||
.tint(.accentColor)
|
||||
|
||||
Toggle("Show Skip 85s Button", isOn: $skip85Visible)
|
||||
.tint(.accentColor)
|
||||
|
||||
Toggle("Show Skip Intro / Outro Buttons", isOn: $skipIntroOutroVisible)
|
||||
.tint(.accentColor)
|
||||
SubtitleSettingsSection()
|
||||
}
|
||||
SubtitleSettingsSection()
|
||||
.padding(.vertical, 20)
|
||||
}
|
||||
.scrollViewBottomPadding()
|
||||
.navigationTitle("Player")
|
||||
}
|
||||
}
|
||||
|
|
@ -128,97 +340,78 @@ struct SubtitleSettingsSection: View {
|
|||
@State private var fontSize: Double = SubtitleSettingsManager.shared.settings.fontSize
|
||||
@State private var shadowRadius: Double = SubtitleSettingsManager.shared.settings.shadowRadius
|
||||
@State private var backgroundEnabled: Bool = SubtitleSettingsManager.shared.settings.backgroundEnabled
|
||||
@State private var bottomPadding: CGFloat = SubtitleSettingsManager.shared.settings.bottomPadding
|
||||
@State private var bottomPadding: Double = Double(SubtitleSettingsManager.shared.settings.bottomPadding)
|
||||
@State private var subtitleDelay: Double = SubtitleSettingsManager.shared.settings.subtitleDelay
|
||||
|
||||
private let colors = ["white", "yellow", "green", "blue", "red", "purple"]
|
||||
private let shadowOptions = [0, 1, 3, 6]
|
||||
|
||||
var body: some View {
|
||||
Section(header: Text("Subtitle Settings")) {
|
||||
HStack {
|
||||
Text("Subtitle Color")
|
||||
Spacer()
|
||||
Menu(foregroundColor) {
|
||||
ForEach(colors, id: \.self) { color in
|
||||
Button(action: {
|
||||
foregroundColor = color
|
||||
SubtitleSettingsManager.shared.update { settings in
|
||||
settings.foregroundColor = color
|
||||
}
|
||||
}) {
|
||||
Text(color.capitalized)
|
||||
}
|
||||
}
|
||||
SettingsSection(title: "Subtitle Settings") {
|
||||
SettingsPickerRow(
|
||||
icon: "paintbrush",
|
||||
title: "Subtitle Color",
|
||||
options: colors,
|
||||
optionToString: { $0.capitalized },
|
||||
selection: $foregroundColor
|
||||
)
|
||||
.onChange(of: foregroundColor) { newValue in
|
||||
SubtitleSettingsManager.shared.update { settings in
|
||||
settings.foregroundColor = newValue
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Shadow")
|
||||
Spacer()
|
||||
Menu("\(Int(shadowRadius))") {
|
||||
ForEach(shadowOptions, id: \.self) { option in
|
||||
Button(action: {
|
||||
shadowRadius = Double(option)
|
||||
SubtitleSettingsManager.shared.update { settings in
|
||||
settings.shadowRadius = Double(option)
|
||||
}
|
||||
}) {
|
||||
Text("\(option)")
|
||||
}
|
||||
}
|
||||
SettingsPickerRow(
|
||||
icon: "shadow",
|
||||
title: "Shadow",
|
||||
options: shadowOptions,
|
||||
optionToString: { "\($0)" },
|
||||
selection: Binding(
|
||||
get: { Int(shadowRadius) },
|
||||
set: { shadowRadius = Double($0) }
|
||||
)
|
||||
)
|
||||
.onChange(of: shadowRadius) { newValue in
|
||||
SubtitleSettingsManager.shared.update { settings in
|
||||
settings.shadowRadius = newValue
|
||||
}
|
||||
}
|
||||
|
||||
Toggle("Background Enabled", isOn: $backgroundEnabled)
|
||||
.tint(.accentColor)
|
||||
.onChange(of: backgroundEnabled) { newValue in
|
||||
SubtitleSettingsManager.shared.update { settings in
|
||||
settings.backgroundEnabled = newValue
|
||||
}
|
||||
SettingsToggleRow(
|
||||
icon: "rectangle.fill",
|
||||
title: "Background Enabled",
|
||||
isOn: $backgroundEnabled
|
||||
)
|
||||
.onChange(of: backgroundEnabled) { newValue in
|
||||
SubtitleSettingsManager.shared.update { settings in
|
||||
settings.backgroundEnabled = newValue
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Font Size:")
|
||||
Spacer()
|
||||
Stepper("\(Int(fontSize))", value: $fontSize, in: 12...36, step: 1)
|
||||
.onChange(of: fontSize) { newValue in
|
||||
SubtitleSettingsManager.shared.update { settings in
|
||||
settings.fontSize = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Bottom Padding:")
|
||||
Spacer()
|
||||
Stepper("\(Int(bottomPadding))", value: $bottomPadding, in: 0...50, step: 1)
|
||||
.onChange(of: bottomPadding) { newValue in
|
||||
SubtitleSettingsManager.shared.update { settings in
|
||||
settings.bottomPadding = newValue
|
||||
}
|
||||
}
|
||||
SettingsStepperRow(
|
||||
icon: "textformat.size",
|
||||
title: "Font Size",
|
||||
value: $fontSize,
|
||||
range: 12...36,
|
||||
step: 1
|
||||
)
|
||||
.onChange(of: fontSize) { newValue in
|
||||
SubtitleSettingsManager.shared.update { settings in
|
||||
settings.fontSize = newValue
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text("Subtitle Delay: \(String(format: "%.1fs", subtitleDelay))")
|
||||
.padding(.bottom, 1)
|
||||
|
||||
HStack {
|
||||
Text("-10s")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Slider(value: $subtitleDelay, in: -10...10, step: 0.1)
|
||||
.onChange(of: subtitleDelay) { newValue in
|
||||
SubtitleSettingsManager.shared.update { settings in
|
||||
settings.subtitleDelay = newValue
|
||||
}
|
||||
}
|
||||
|
||||
Text("+10s")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.secondary)
|
||||
SettingsStepperRow(
|
||||
icon: "arrow.up.and.down",
|
||||
title: "Bottom Padding",
|
||||
value: $bottomPadding,
|
||||
range: 0...50,
|
||||
step: 1,
|
||||
showDivider: false
|
||||
)
|
||||
.onChange(of: bottomPadding) { newValue in
|
||||
SubtitleSettingsManager.shared.update { settings in
|
||||
settings.bottomPadding = CGFloat(newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,97 @@ import SwiftUI
|
|||
import Security
|
||||
import Kingfisher
|
||||
|
||||
fileprivate struct SettingsSection<Content: View>: View {
|
||||
let title: String
|
||||
let footer: String?
|
||||
let content: Content
|
||||
|
||||
init(title: String, footer: String? = nil, @ViewBuilder content: () -> Content) {
|
||||
self.title = title
|
||||
self.footer = footer
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title.uppercased())
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.gray)
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
content
|
||||
}
|
||||
.background(.ultraThinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.strokeBorder(
|
||||
LinearGradient(
|
||||
gradient: Gradient(stops: [
|
||||
.init(color: Color.accentColor.opacity(0.3), location: 0),
|
||||
.init(color: Color.accentColor.opacity(0), location: 1)
|
||||
]),
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
),
|
||||
lineWidth: 0.5
|
||||
)
|
||||
)
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
if let footer = footer {
|
||||
Text(footer)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.gray)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate struct SettingsToggleRow: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
@Binding var isOn: Bool
|
||||
var showDivider: Bool = true
|
||||
|
||||
init(icon: String, title: String, isOn: Binding<Bool>, showDivider: Bool = true) {
|
||||
self.icon = icon
|
||||
self.title = title
|
||||
self._isOn = isOn
|
||||
self.showDivider = showDivider
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Image(systemName: icon)
|
||||
.frame(width: 24, height: 24)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Text(title)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Toggle("", isOn: $isOn)
|
||||
.labelsHidden()
|
||||
.tint(.accentColor.opacity(0.7))
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.frame(height: 48)
|
||||
|
||||
if showDivider {
|
||||
Divider()
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsViewTrackers: View {
|
||||
@AppStorage("sendPushUpdates") private var isSendPushUpdates = true
|
||||
@State private var anilistStatus: String = "You are not logged in"
|
||||
|
|
@ -24,101 +115,188 @@ struct SettingsViewTrackers: View {
|
|||
@State private var isTraktLoading: Bool = false
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section(header: Text("AniList")) {
|
||||
HStack() {
|
||||
KFImage(URL(string: "https://raw.githubusercontent.com/cranci1/Ryu/2f10226aa087154974a70c1ec78aa83a47daced9/Ryu/Assets.xcassets/Listing/Anilist.imageset/anilist.png"))
|
||||
.placeholder {
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(Color.gray.opacity(0.3))
|
||||
.frame(width: 80, height: 80)
|
||||
.shimmering()
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
SettingsSection(title: "AniList") {
|
||||
VStack(spacing: 0) {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
KFImage(URL(string: "https://raw.githubusercontent.com/cranci1/Ryu/2f10226aa087154974a70c1ec78aa83a47daced9/Ryu/Assets.xcassets/Listing/Anilist.imageset/anilist.png"))
|
||||
.placeholder {
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(Color.gray.opacity(0.3))
|
||||
.frame(width: 60, height: 60)
|
||||
.shimmering()
|
||||
}
|
||||
.resizable()
|
||||
.frame(width: 60, height: 60)
|
||||
.clipShape(Rectangle())
|
||||
.cornerRadius(10)
|
||||
.padding(.trailing, 10)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("AniList.co")
|
||||
.font(.title3)
|
||||
.fontWeight(.semibold)
|
||||
|
||||
Group {
|
||||
if isAnilistLoading {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
.frame(height: 18)
|
||||
} else if isAnilistLoggedIn {
|
||||
HStack(spacing: 0) {
|
||||
Text("Logged in as ")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.gray)
|
||||
Text(anilistUsername)
|
||||
.font(.footnote)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(profileColor)
|
||||
}
|
||||
.frame(height: 18)
|
||||
} else {
|
||||
Text(anilistStatus)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.gray)
|
||||
.frame(height: 18)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 60, alignment: .center)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.resizable()
|
||||
.frame(width: 80, height: 80)
|
||||
.clipShape(Rectangle())
|
||||
.cornerRadius(10)
|
||||
Text("AniList.co")
|
||||
.font(.title2)
|
||||
}
|
||||
|
||||
if isAnilistLoading {
|
||||
ProgressView()
|
||||
} else {
|
||||
if isAnilistLoggedIn {
|
||||
HStack(spacing: 0) {
|
||||
Text("Logged in as ")
|
||||
Text(anilistUsername)
|
||||
.foregroundColor(profileColor)
|
||||
.font(.body)
|
||||
.fontWeight(.semibold)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.frame(height: 84)
|
||||
|
||||
if isAnilistLoggedIn {
|
||||
Divider()
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
SettingsToggleRow(
|
||||
icon: "arrow.triangle.2.circlepath",
|
||||
title: "Sync anime progress",
|
||||
isOn: $isSendPushUpdates,
|
||||
showDivider: false
|
||||
)
|
||||
}
|
||||
|
||||
Divider()
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
Button(action: {
|
||||
if isAnilistLoggedIn {
|
||||
logoutAniList()
|
||||
} else {
|
||||
loginAniList()
|
||||
}
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: isAnilistLoggedIn ? "rectangle.portrait.and.arrow.right" : "person.badge.key")
|
||||
.frame(width: 24, height: 24)
|
||||
.foregroundStyle(isAnilistLoggedIn ? .red : .accentColor)
|
||||
|
||||
Text(isAnilistLoggedIn ? "Log Out from AniList" : "Log In with AniList")
|
||||
.foregroundStyle(isAnilistLoggedIn ? .red : .accentColor)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.frame(height: 48)
|
||||
}
|
||||
} else {
|
||||
Text(anilistStatus)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
|
||||
if isAnilistLoggedIn {
|
||||
Toggle("Sync anime progress", isOn: $isSendPushUpdates)
|
||||
.tint(.accentColor)
|
||||
}
|
||||
|
||||
Button(isAnilistLoggedIn ? "Log Out from AniList" : "Log In with AniList") {
|
||||
if isAnilistLoggedIn {
|
||||
logoutAniList()
|
||||
} else {
|
||||
loginAniList()
|
||||
SettingsSection(title: "Trakt") {
|
||||
VStack(spacing: 0) {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
KFImage(URL(string: "https://static-00.iconduck.com/assets.00/trakt-icon-2048x2048-2633ksxg.png"))
|
||||
.placeholder {
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(Color.gray.opacity(0.3))
|
||||
.frame(width: 60, height: 60)
|
||||
.shimmering()
|
||||
}
|
||||
.resizable()
|
||||
.frame(width: 60, height: 60)
|
||||
.clipShape(Rectangle())
|
||||
.cornerRadius(10)
|
||||
.padding(.trailing, 10)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Trakt.tv")
|
||||
.font(.title3)
|
||||
.fontWeight(.semibold)
|
||||
|
||||
Group {
|
||||
if isTraktLoading {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
.frame(height: 18)
|
||||
} else if isTraktLoggedIn {
|
||||
HStack(spacing: 0) {
|
||||
Text("Logged in as ")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.gray)
|
||||
Text(traktUsername)
|
||||
.font(.footnote)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
.frame(height: 18)
|
||||
} else {
|
||||
Text(traktStatus)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.gray)
|
||||
.frame(height: 18)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 60, alignment: .center)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.frame(height: 84)
|
||||
|
||||
Divider()
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
Button(action: {
|
||||
if isTraktLoggedIn {
|
||||
logoutTrakt()
|
||||
} else {
|
||||
loginTrakt()
|
||||
}
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: isTraktLoggedIn ? "rectangle.portrait.and.arrow.right" : "person.badge.key")
|
||||
.frame(width: 24, height: 24)
|
||||
.foregroundStyle(isTraktLoggedIn ? .red : .accentColor)
|
||||
|
||||
Text(isTraktLoggedIn ? "Log Out from Trakt" : "Log In with Trakt")
|
||||
.foregroundStyle(isTraktLoggedIn ? .red : .accentColor)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.frame(height: 48)
|
||||
}
|
||||
}
|
||||
}
|
||||
.font(.body)
|
||||
|
||||
SettingsSection(
|
||||
title: "Info",
|
||||
footer: "Sora and cranci1 are not affiliated with AniList nor Trakt in any way.\n\nAlso note that progresses update may not be 100% accurate."
|
||||
) {}
|
||||
}
|
||||
|
||||
Section(header: Text("Trakt")) {
|
||||
HStack() {
|
||||
KFImage(URL(string: "https://static-00.iconduck.com/assets.00/trakt-icon-2048x2048-2633ksxg.png"))
|
||||
.placeholder {
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(Color.gray.opacity(0.3))
|
||||
.frame(width: 80, height: 80)
|
||||
.shimmering()
|
||||
}
|
||||
.resizable()
|
||||
.frame(width: 80, height: 80)
|
||||
.clipShape(Rectangle())
|
||||
.cornerRadius(10)
|
||||
Text("Trakt.tv")
|
||||
.font(.title2)
|
||||
}
|
||||
|
||||
if isTraktLoading {
|
||||
ProgressView()
|
||||
} else {
|
||||
if isTraktLoggedIn {
|
||||
HStack(spacing: 0) {
|
||||
Text("Logged in as ")
|
||||
Text(traktUsername)
|
||||
.font(.body)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
} else {
|
||||
Text(traktStatus)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
|
||||
Button(isTraktLoggedIn ? "Log Out from Trakt" : "Log In with Trakt") {
|
||||
if isTraktLoggedIn {
|
||||
logoutTrakt()
|
||||
} else {
|
||||
loginTrakt()
|
||||
}
|
||||
}
|
||||
.font(.body)
|
||||
}
|
||||
|
||||
Section(footer: Text("Sora and cranci1 are not affiliated with AniList nor Trakt in any way.\n\nAlso note that progresses update may not be 100% accurate.")) {}
|
||||
.padding(.vertical, 20)
|
||||
}
|
||||
.scrollViewBottomPadding()
|
||||
.navigationTitle("Trackers")
|
||||
.onAppear {
|
||||
updateAniListStatus()
|
||||
|
|
@ -244,8 +422,8 @@ struct SettingsViewTrackers: View {
|
|||
guard status == errSecSuccess,
|
||||
let tokenData = item as? Data,
|
||||
let token = String(data: tokenData, encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,96 +7,237 @@
|
|||
|
||||
import SwiftUI
|
||||
|
||||
fileprivate struct SettingsNavigationRow: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let isExternal: Bool
|
||||
let textColor: Color
|
||||
|
||||
init(icon: String, title: String, isExternal: Bool = false, textColor: Color = .primary) {
|
||||
self.icon = icon
|
||||
self.title = title
|
||||
self.isExternal = isExternal
|
||||
self.textColor = textColor
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Image(systemName: icon)
|
||||
.frame(width: 24, height: 24)
|
||||
.foregroundStyle(textColor)
|
||||
|
||||
Text(title)
|
||||
.foregroundStyle(textColor)
|
||||
|
||||
Spacer()
|
||||
|
||||
if isExternal {
|
||||
Image(systemName: "safari")
|
||||
.foregroundStyle(.gray)
|
||||
} else {
|
||||
Image(systemName: "chevron.right")
|
||||
.foregroundStyle(.gray)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
}
|
||||
struct SettingsView: View {
|
||||
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "ALPHA"
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@StateObject var settings = Settings()
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
Form {
|
||||
Section(header: Text("Main")) {
|
||||
NavigationLink(destination: SettingsViewGeneral()) {
|
||||
Text("General Preferences")
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
Text("Settings")
|
||||
.font(.largeTitle)
|
||||
.fontWeight(.bold)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 16)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("MAIN")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.gray)
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
NavigationLink(destination: SettingsViewGeneral()) {
|
||||
SettingsNavigationRow(icon: "gearshape", title: "General Preferences")
|
||||
}
|
||||
Divider().padding(.horizontal, 16)
|
||||
|
||||
NavigationLink(destination: SettingsViewPlayer()) {
|
||||
SettingsNavigationRow(icon: "play.circle", title: "Video Player")
|
||||
}
|
||||
Divider().padding(.horizontal, 16)
|
||||
|
||||
NavigationLink(destination: SettingsViewDownloads()) {
|
||||
SettingsNavigationRow(icon: "arrow.down.circle", title: "Download")
|
||||
}
|
||||
Divider().padding(.horizontal, 16)
|
||||
|
||||
NavigationLink(destination: SettingsViewModule()) {
|
||||
SettingsNavigationRow(icon: "cube", title: "Modules")
|
||||
}
|
||||
Divider().padding(.horizontal, 16)
|
||||
|
||||
NavigationLink(destination: SettingsViewTrackers()) {
|
||||
SettingsNavigationRow(icon: "square.stack.3d.up", title: "Trackers")
|
||||
}
|
||||
}
|
||||
.background(.ultraThinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.strokeBorder(
|
||||
LinearGradient(
|
||||
gradient: Gradient(stops: [
|
||||
.init(color: Color.accentColor.opacity(0.3), location: 0),
|
||||
.init(color: Color.accentColor.opacity(0), location: 1)
|
||||
]),
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
),
|
||||
lineWidth: 0.5
|
||||
)
|
||||
)
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
NavigationLink(destination: SettingsViewPlayer()) {
|
||||
Text("Media Player")
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("DATA/LOGS")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.gray)
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
NavigationLink(destination: SettingsViewData()) {
|
||||
SettingsNavigationRow(icon: "folder", title: "Data")
|
||||
}
|
||||
Divider().padding(.horizontal, 16)
|
||||
|
||||
NavigationLink(destination: SettingsViewLogger()) {
|
||||
SettingsNavigationRow(icon: "doc.text", title: "Logs")
|
||||
}
|
||||
}
|
||||
.background(.ultraThinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.strokeBorder(
|
||||
LinearGradient(
|
||||
gradient: Gradient(stops: [
|
||||
.init(color: Color.accentColor.opacity(0.3), location: 0),
|
||||
.init(color: Color.accentColor.opacity(0), location: 1)
|
||||
]),
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
),
|
||||
lineWidth: 0.5
|
||||
)
|
||||
)
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
NavigationLink(destination: SettingsViewDownloads().environmentObject(JSController.shared)) {
|
||||
Text("Downloads")
|
||||
}
|
||||
NavigationLink(destination: SettingsViewModule()) {
|
||||
Text("Modules")
|
||||
}
|
||||
NavigationLink(destination: SettingsViewTrackers()) {
|
||||
Text("Trackers")
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("INFOS")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.gray)
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
NavigationLink(destination: SettingsViewAbout()) {
|
||||
SettingsNavigationRow(icon: "info.circle", title: "About Sora")
|
||||
}
|
||||
Divider().padding(.horizontal, 16)
|
||||
|
||||
Link(destination: URL(string: "https://github.com/cranci1/Sora")!) {
|
||||
SettingsNavigationRow(
|
||||
icon: "chevron.left.forwardslash.chevron.right",
|
||||
title: "Sora GitHub Repository",
|
||||
isExternal: true,
|
||||
textColor: .gray
|
||||
)
|
||||
}
|
||||
Divider().padding(.horizontal, 16)
|
||||
|
||||
Link(destination: URL(string: "https://discord.gg/x7hppDWFDZ")!) {
|
||||
SettingsNavigationRow(
|
||||
icon: "bubble.left.and.bubble.right",
|
||||
title: "Join the Discord",
|
||||
isExternal: true,
|
||||
textColor: .gray
|
||||
)
|
||||
}
|
||||
Divider().padding(.horizontal, 16)
|
||||
|
||||
Link(destination: URL(string: "https://github.com/cranci1/Sora/issues")!) {
|
||||
SettingsNavigationRow(
|
||||
icon: "exclamationmark.circle",
|
||||
title: "Report an Issue",
|
||||
isExternal: true,
|
||||
textColor: .gray
|
||||
)
|
||||
}
|
||||
Divider().padding(.horizontal, 16)
|
||||
|
||||
Link(destination: URL(string: "https://github.com/cranci1/Sora/blob/dev/LICENSE")!) {
|
||||
SettingsNavigationRow(
|
||||
icon: "doc.text",
|
||||
title: "License (GPLv3.0)",
|
||||
isExternal: true,
|
||||
textColor: .gray
|
||||
)
|
||||
}
|
||||
}
|
||||
.background(.ultraThinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.strokeBorder(
|
||||
LinearGradient(
|
||||
gradient: Gradient(stops: [
|
||||
.init(color: Color.accentColor.opacity(0.3), location: 0),
|
||||
.init(color: Color.accentColor.opacity(0), location: 1)
|
||||
]),
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
),
|
||||
lineWidth: 0.5
|
||||
)
|
||||
)
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
|
||||
Text("Running Sora \(version) - cranci1")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.gray)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
Section(header: Text("Info")) {
|
||||
NavigationLink(destination: SettingsViewData()) {
|
||||
Text("Data")
|
||||
}
|
||||
NavigationLink(destination: SettingsViewLogger()) {
|
||||
Text("Logs")
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: Text("Info")) {
|
||||
Button(action: {
|
||||
if let url = URL(string: "https://github.com/cranci1/Sora") {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}) {
|
||||
HStack {
|
||||
Text("Sora github repo")
|
||||
.foregroundColor(.primary)
|
||||
Spacer()
|
||||
Image(systemName: "safari")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
Button(action: {
|
||||
if let url = URL(string: "https://discord.gg/x7hppDWFDZ") {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}) {
|
||||
HStack {
|
||||
Text("Join the Discord")
|
||||
.foregroundColor(.primary)
|
||||
Spacer()
|
||||
Image(systemName: "safari")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
Button(action: {
|
||||
if let url = URL(string: "https://github.com/cranci1/Sora/issues") {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}) {
|
||||
HStack {
|
||||
Text("Report an issue")
|
||||
.foregroundColor(.primary)
|
||||
Spacer()
|
||||
Image(systemName: "safari")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
Button(action: {
|
||||
if let url = URL(string: "https://github.com/cranci1/Sora/blob/dev/LICENSE") {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}) {
|
||||
HStack {
|
||||
Text("Licensed under GPLv3.0")
|
||||
.foregroundColor(.primary)
|
||||
Spacer()
|
||||
Image(systemName: "safari")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
Section(footer: Text("Running Sora 0.3.0 - cranci1")) {}
|
||||
.scrollViewBottomPadding()
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
.deviceScaled()
|
||||
}
|
||||
.navigationViewStyle(StackNavigationViewStyle())
|
||||
.navigationBarHidden(true)
|
||||
.onChange(of: colorScheme) { newScheme in
|
||||
if settings.selectedAppearance == .system {
|
||||
settings.updateAccentColor(currentColorScheme: newScheme)
|
||||
}
|
||||
}
|
||||
.onChange(of: settings.selectedAppearance) { _ in
|
||||
settings.updateAccentColor(currentColorScheme: colorScheme)
|
||||
}
|
||||
.onAppear {
|
||||
settings.updateAccentColor(currentColorScheme: colorScheme)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -109,7 +250,6 @@ enum Appearance: String, CaseIterable, Identifiable {
|
|||
class Settings: ObservableObject {
|
||||
@Published var accentColor: Color {
|
||||
didSet {
|
||||
saveAccentColor(accentColor)
|
||||
}
|
||||
}
|
||||
@Published var selectedAppearance: Appearance {
|
||||
|
|
@ -120,12 +260,7 @@ class Settings: ObservableObject {
|
|||
}
|
||||
|
||||
init() {
|
||||
if let colorData = UserDefaults.standard.data(forKey: "accentColor"),
|
||||
let uiColor = try? NSKeyedUnarchiver.unarchivedObject(ofClass: UIColor.self, from: colorData) {
|
||||
self.accentColor = Color(uiColor)
|
||||
} else {
|
||||
self.accentColor = .accentColor
|
||||
}
|
||||
self.accentColor = .primary
|
||||
if let appearanceRawValue = UserDefaults.standard.string(forKey: "selectedAppearance"),
|
||||
let appearance = Appearance(rawValue: appearanceRawValue) {
|
||||
self.selectedAppearance = appearance
|
||||
|
|
@ -135,13 +270,20 @@ class Settings: ObservableObject {
|
|||
updateAppearance()
|
||||
}
|
||||
|
||||
private func saveAccentColor(_ color: Color) {
|
||||
let uiColor = UIColor(color)
|
||||
do {
|
||||
let colorData = try NSKeyedArchiver.archivedData(withRootObject: uiColor, requiringSecureCoding: false)
|
||||
UserDefaults.standard.set(colorData, forKey: "accentColor")
|
||||
} catch {
|
||||
Logger.shared.log("Failed to save accent color: \(error.localizedDescription)")
|
||||
func updateAccentColor(currentColorScheme: ColorScheme? = nil) {
|
||||
switch selectedAppearance {
|
||||
case .system:
|
||||
if let scheme = currentColorScheme {
|
||||
accentColor = scheme == .dark ? .white : .black
|
||||
} else {
|
||||
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let window = windowScene.windows.first else { return }
|
||||
accentColor = window.traitCollection.userInterfaceStyle == .dark ? .white : .black
|
||||
}
|
||||
case .light:
|
||||
accentColor = .black
|
||||
case .dark:
|
||||
accentColor = .white
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,21 @@
|
|||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
0402DA0E2DE7AA01003BB42C /* TabBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0402DA0D2DE7A9FC003BB42C /* TabBarController.swift */; };
|
||||
0402DA132DE7B5EC003BB42C /* SearchStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0402DA112DE7B5EC003BB42C /* SearchStateView.swift */; };
|
||||
0402DA142DE7B5EC003BB42C /* SearchResultsGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0402DA102DE7B5EC003BB42C /* SearchResultsGrid.swift */; };
|
||||
0402DA152DE7B5EC003BB42C /* SearchComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0402DA0F2DE7B5EC003BB42C /* SearchComponents.swift */; };
|
||||
0402DA172DE7B7B8003BB42C /* SearchViewComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0402DA162DE7B7B8003BB42C /* SearchViewComponents.swift */; };
|
||||
0457C5972DE7712A000AFBD9 /* DeviceScaleModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0457C5942DE7712A000AFBD9 /* DeviceScaleModifier.swift */; };
|
||||
0457C59D2DE78267000AFBD9 /* BookmarkGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0457C59A2DE78267000AFBD9 /* BookmarkGridView.swift */; };
|
||||
0457C59E2DE78267000AFBD9 /* BookmarkLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0457C59B2DE78267000AFBD9 /* BookmarkLink.swift */; };
|
||||
0457C59F2DE78267000AFBD9 /* BookmarkGridItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0457C5992DE78267000AFBD9 /* BookmarkGridItemView.swift */; };
|
||||
0457C5A12DE78385000AFBD9 /* BookmarksDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0457C5A02DE78385000AFBD9 /* BookmarksDetailView.swift */; };
|
||||
04CD76DB2DE20F2200733536 /* AllWatching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04CD76DA2DE20F2200733536 /* AllWatching.swift */; };
|
||||
04F08EDC2DE10BF3006B29D9 /* ProgressiveBlurView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F08EDB2DE10BEC006B29D9 /* ProgressiveBlurView.swift */; };
|
||||
04F08EDF2DE10C1D006B29D9 /* TabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F08EDE2DE10C1A006B29D9 /* TabBar.swift */; };
|
||||
04F08EE22DE10C40006B29D9 /* TabItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F08EE12DE10C27006B29D9 /* TabItem.swift */; };
|
||||
04F08EE42DE10D6F006B29D9 /* AllBookmarks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F08EE32DE10D6B006B29D9 /* AllBookmarks.swift */; };
|
||||
130C6BFA2D53AB1F00DC1432 /* SettingsViewData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 130C6BF92D53AB1F00DC1432 /* SettingsViewData.swift */; };
|
||||
13103E8B2D58E028000F0673 /* View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13103E8A2D58E028000F0673 /* View.swift */; };
|
||||
13103E8E2D58E04A000F0673 /* SkeletonCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13103E8D2D58E04A000F0673 /* SkeletonCell.swift */; };
|
||||
|
|
@ -17,6 +32,7 @@
|
|||
132AF1212D99951700A0140B /* JSController-Streams.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132AF1202D99951700A0140B /* JSController-Streams.swift */; };
|
||||
132AF1232D9995C300A0140B /* JSController-Details.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132AF1222D9995C300A0140B /* JSController-Details.swift */; };
|
||||
132AF1252D9995F900A0140B /* JSController-Search.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132AF1242D9995F900A0140B /* JSController-Search.swift */; };
|
||||
132FC5B32DE31DAE009A80F7 /* SettingsViewAlternateAppIconPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132FC5B22DE31DAD009A80F7 /* SettingsViewAlternateAppIconPicker.swift */; };
|
||||
133D7C6E2D2BE2500075467E /* SoraApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133D7C6D2D2BE2500075467E /* SoraApp.swift */; };
|
||||
133D7C702D2BE2500075467E /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133D7C6F2D2BE2500075467E /* ContentView.swift */; };
|
||||
133D7C722D2BE2520075467E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 133D7C712D2BE2520075467E /* Assets.xcassets */; };
|
||||
|
|
@ -39,6 +55,8 @@
|
|||
136BBE802DB1038000906B5E /* Notification+Name.swift in Sources */ = {isa = PBXBuildFile; fileRef = 136BBE7F2DB1038000906B5E /* Notification+Name.swift */; };
|
||||
138AA1B82D2D66FD0021F9DF /* EpisodeCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138AA1B62D2D66FD0021F9DF /* EpisodeCell.swift */; };
|
||||
138AA1B92D2D66FD0021F9DF /* CircularProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138AA1B72D2D66FD0021F9DF /* CircularProgressBar.swift */; };
|
||||
138FE1D02DECA00D00936D81 /* TMDB-FetchID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138FE1CF2DECA00D00936D81 /* TMDB-FetchID.swift */; };
|
||||
1398FB3F2DE4E161004D3F5F /* SettingsViewAbout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1398FB3E2DE4E161004D3F5F /* SettingsViewAbout.swift */; };
|
||||
139935662D468C450065CEFF /* ModuleManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 139935652D468C450065CEFF /* ModuleManager.swift */; };
|
||||
1399FAD42D3AB38C00E97C31 /* SettingsViewLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1399FAD32D3AB38C00E97C31 /* SettingsViewLogger.swift */; };
|
||||
1399FAD62D3AB3DB00E97C31 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1399FAD52D3AB3DB00E97C31 /* Logger.swift */; };
|
||||
|
|
@ -63,12 +81,10 @@
|
|||
13EA2BD62D32D97400C1EBD7 /* Double+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EA2BD32D32D97400C1EBD7 /* Double+Extension.swift */; };
|
||||
13EA2BD92D32D98400C1EBD7 /* NormalPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EA2BD82D32D98400C1EBD7 /* NormalPlayer.swift */; };
|
||||
1E26E9E72DA9577900B9DC02 /* VolumeSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E26E9E62DA9577900B9DC02 /* VolumeSlider.swift */; };
|
||||
1E47859B2DEBC5960095BF2F /* AnilistMatchPopupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E47859A2DEBC5960095BF2F /* AnilistMatchPopupView.swift */; };
|
||||
1E9FF1D32D403E49008AC100 /* SettingsViewLoggerFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */; };
|
||||
1EAC7A322D888BC50083984D /* MusicProgressSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EAC7A312D888BC50083984D /* MusicProgressSlider.swift */; };
|
||||
1EF5C3A92DB988E40032BF07 /* CommunityLib.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EF5C3A82DB988D70032BF07 /* CommunityLib.swift */; };
|
||||
7205AEDB2DCCEF9500943F3F /* EpisodeMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7205AED72DCCEF9500943F3F /* EpisodeMetadata.swift */; };
|
||||
7205AEDC2DCCEF9500943F3F /* MetadataCacheManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7205AED92DCCEF9500943F3F /* MetadataCacheManager.swift */; };
|
||||
7205AEDD2DCCEF9500943F3F /* KingfisherManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7205AED82DCCEF9500943F3F /* KingfisherManager.swift */; };
|
||||
7222485F2DCBAA2C00CABE2D /* DownloadModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7222485D2DCBAA2C00CABE2D /* DownloadModels.swift */; };
|
||||
722248602DCBAA2C00CABE2D /* M3U8StreamExtractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7222485E2DCBAA2C00CABE2D /* M3U8StreamExtractor.swift */; };
|
||||
722248632DCBAA4700CABE2D /* JSController-HeaderManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 722248612DCBAA4700CABE2D /* JSController-HeaderManager.swift */; };
|
||||
|
|
@ -78,13 +94,25 @@
|
|||
72443C7F2DC8038300A61321 /* SettingsViewDownloads.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72443C7E2DC8038300A61321 /* SettingsViewDownloads.swift */; };
|
||||
727220712DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7272206F2DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift */; };
|
||||
727220722DD642B100C2A4A2 /* JSController+MP4Download.swift in Sources */ = {isa = PBXBuildFile; fileRef = 727220702DD642B100C2A4A2 /* JSController+MP4Download.swift */; };
|
||||
72AC3A012DD4DAEB00C60B96 /* ImagePrefetchManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72AC39FE2DD4DAEA00C60B96 /* ImagePrefetchManager.swift */; };
|
||||
72AC3A022DD4DAEB00C60B96 /* EpisodeMetadataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72AC39FD2DD4DAEA00C60B96 /* EpisodeMetadataManager.swift */; };
|
||||
72AC3A032DD4DAEB00C60B96 /* PerformanceMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72AC39FF2DD4DAEA00C60B96 /* PerformanceMonitor.swift */; };
|
||||
73D164D52D8B5B470011A360 /* JavaScriptCore+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
0402DA0D2DE7A9FC003BB42C /* TabBarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarController.swift; sourceTree = "<group>"; };
|
||||
0402DA0F2DE7B5EC003BB42C /* SearchComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchComponents.swift; sourceTree = "<group>"; };
|
||||
0402DA102DE7B5EC003BB42C /* SearchResultsGrid.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsGrid.swift; sourceTree = "<group>"; };
|
||||
0402DA112DE7B5EC003BB42C /* SearchStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchStateView.swift; sourceTree = "<group>"; };
|
||||
0402DA162DE7B7B8003BB42C /* SearchViewComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewComponents.swift; sourceTree = "<group>"; };
|
||||
0457C5942DE7712A000AFBD9 /* DeviceScaleModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceScaleModifier.swift; sourceTree = "<group>"; };
|
||||
0457C5992DE78267000AFBD9 /* BookmarkGridItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkGridItemView.swift; sourceTree = "<group>"; };
|
||||
0457C59A2DE78267000AFBD9 /* BookmarkGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkGridView.swift; sourceTree = "<group>"; };
|
||||
0457C59B2DE78267000AFBD9 /* BookmarkLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkLink.swift; sourceTree = "<group>"; };
|
||||
0457C5A02DE78385000AFBD9 /* BookmarksDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksDetailView.swift; sourceTree = "<group>"; };
|
||||
04CD76DA2DE20F2200733536 /* AllWatching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllWatching.swift; sourceTree = "<group>"; };
|
||||
04F08EDB2DE10BEC006B29D9 /* ProgressiveBlurView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressiveBlurView.swift; sourceTree = "<group>"; };
|
||||
04F08EDE2DE10C1A006B29D9 /* TabBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBar.swift; sourceTree = "<group>"; };
|
||||
04F08EE12DE10C27006B29D9 /* TabItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabItem.swift; sourceTree = "<group>"; };
|
||||
04F08EE32DE10D6B006B29D9 /* AllBookmarks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllBookmarks.swift; sourceTree = "<group>"; };
|
||||
130C6BF82D53A4C200DC1432 /* Sora.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Sora.entitlements; sourceTree = "<group>"; };
|
||||
130C6BF92D53AB1F00DC1432 /* SettingsViewData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewData.swift; sourceTree = "<group>"; };
|
||||
13103E8A2D58E028000F0673 /* View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = View.swift; sourceTree = "<group>"; };
|
||||
|
|
@ -96,6 +124,7 @@
|
|||
132AF1202D99951700A0140B /* JSController-Streams.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-Streams.swift"; sourceTree = "<group>"; };
|
||||
132AF1222D9995C300A0140B /* JSController-Details.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-Details.swift"; sourceTree = "<group>"; };
|
||||
132AF1242D9995F900A0140B /* JSController-Search.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-Search.swift"; sourceTree = "<group>"; };
|
||||
132FC5B22DE31DAD009A80F7 /* SettingsViewAlternateAppIconPicker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewAlternateAppIconPicker.swift; sourceTree = "<group>"; };
|
||||
133D7C6A2D2BE2500075467E /* Sulfur.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Sulfur.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
133D7C6D2D2BE2500075467E /* SoraApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoraApp.swift; sourceTree = "<group>"; };
|
||||
133D7C6F2D2BE2500075467E /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||
|
|
@ -116,6 +145,8 @@
|
|||
136BBE7F2DB1038000906B5E /* Notification+Name.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Notification+Name.swift"; sourceTree = "<group>"; };
|
||||
138AA1B62D2D66FD0021F9DF /* EpisodeCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EpisodeCell.swift; sourceTree = "<group>"; };
|
||||
138AA1B72D2D66FD0021F9DF /* CircularProgressBar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CircularProgressBar.swift; sourceTree = "<group>"; };
|
||||
138FE1CF2DECA00D00936D81 /* TMDB-FetchID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TMDB-FetchID.swift"; sourceTree = "<group>"; };
|
||||
1398FB3E2DE4E161004D3F5F /* SettingsViewAbout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewAbout.swift; sourceTree = "<group>"; };
|
||||
139935652D468C450065CEFF /* ModuleManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModuleManager.swift; sourceTree = "<group>"; };
|
||||
1399FAD32D3AB38C00E97C31 /* SettingsViewLogger.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewLogger.swift; sourceTree = "<group>"; };
|
||||
1399FAD52D3AB3DB00E97C31 /* Logger.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = "<group>"; };
|
||||
|
|
@ -141,12 +172,10 @@
|
|||
13EA2BD32D32D97400C1EBD7 /* Double+Extension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Double+Extension.swift"; sourceTree = "<group>"; };
|
||||
13EA2BD82D32D98400C1EBD7 /* NormalPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NormalPlayer.swift; sourceTree = "<group>"; };
|
||||
1E26E9E62DA9577900B9DC02 /* VolumeSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeSlider.swift; sourceTree = "<group>"; };
|
||||
1E47859A2DEBC5960095BF2F /* AnilistMatchPopupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnilistMatchPopupView.swift; sourceTree = "<group>"; };
|
||||
1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewLoggerFilter.swift; sourceTree = "<group>"; };
|
||||
1EAC7A312D888BC50083984D /* MusicProgressSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicProgressSlider.swift; sourceTree = "<group>"; };
|
||||
1EF5C3A82DB988D70032BF07 /* CommunityLib.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityLib.swift; sourceTree = "<group>"; };
|
||||
7205AED72DCCEF9500943F3F /* EpisodeMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeMetadata.swift; sourceTree = "<group>"; };
|
||||
7205AED82DCCEF9500943F3F /* KingfisherManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KingfisherManager.swift; sourceTree = "<group>"; };
|
||||
7205AED92DCCEF9500943F3F /* MetadataCacheManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetadataCacheManager.swift; sourceTree = "<group>"; };
|
||||
7222485D2DCBAA2C00CABE2D /* DownloadModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadModels.swift; sourceTree = "<group>"; };
|
||||
7222485E2DCBAA2C00CABE2D /* M3U8StreamExtractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = M3U8StreamExtractor.swift; sourceTree = "<group>"; };
|
||||
722248612DCBAA4700CABE2D /* JSController-HeaderManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-HeaderManager.swift"; sourceTree = "<group>"; };
|
||||
|
|
@ -156,9 +185,6 @@
|
|||
72443C7E2DC8038300A61321 /* SettingsViewDownloads.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewDownloads.swift; sourceTree = "<group>"; };
|
||||
7272206F2DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-StreamTypeDownload.swift"; sourceTree = "<group>"; };
|
||||
727220702DD642B100C2A4A2 /* JSController+MP4Download.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController+MP4Download.swift"; sourceTree = "<group>"; };
|
||||
72AC39FD2DD4DAEA00C60B96 /* EpisodeMetadataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeMetadataManager.swift; sourceTree = "<group>"; };
|
||||
72AC39FE2DD4DAEA00C60B96 /* ImagePrefetchManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePrefetchManager.swift; sourceTree = "<group>"; };
|
||||
72AC39FF2DD4DAEA00C60B96 /* PerformanceMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerformanceMonitor.swift; sourceTree = "<group>"; };
|
||||
73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JavaScriptCore+Extensions.swift"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
|
|
@ -176,9 +202,66 @@
|
|||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
0402DA122DE7B5EC003BB42C /* SearchView */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
133D7C7C2D2BE2630075467E /* SearchView.swift */,
|
||||
0402DA162DE7B7B8003BB42C /* SearchViewComponents.swift */,
|
||||
0402DA0F2DE7B5EC003BB42C /* SearchComponents.swift */,
|
||||
0402DA102DE7B5EC003BB42C /* SearchResultsGrid.swift */,
|
||||
0402DA112DE7B5EC003BB42C /* SearchStateView.swift */,
|
||||
);
|
||||
path = SearchView;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
0457C5962DE7712A000AFBD9 /* ViewModifiers */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
0457C5942DE7712A000AFBD9 /* DeviceScaleModifier.swift */,
|
||||
);
|
||||
path = ViewModifiers;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
0457C59C2DE78267000AFBD9 /* BookmarkComponents */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
0457C5A02DE78385000AFBD9 /* BookmarksDetailView.swift */,
|
||||
0457C5992DE78267000AFBD9 /* BookmarkGridItemView.swift */,
|
||||
0457C59A2DE78267000AFBD9 /* BookmarkGridView.swift */,
|
||||
0457C59B2DE78267000AFBD9 /* BookmarkLink.swift */,
|
||||
);
|
||||
path = BookmarkComponents;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
04F08EDA2DE10BE3006B29D9 /* ProgressiveBlurView */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
04F08EDB2DE10BEC006B29D9 /* ProgressiveBlurView.swift */,
|
||||
);
|
||||
path = ProgressiveBlurView;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
04F08EDD2DE10C05006B29D9 /* TabBar */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
0402DA0D2DE7A9FC003BB42C /* TabBarController.swift */,
|
||||
04F08EDE2DE10C1A006B29D9 /* TabBar.swift */,
|
||||
);
|
||||
path = TabBar;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
04F08EE02DE10C22006B29D9 /* Models */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
04F08EE12DE10C27006B29D9 /* TabItem.swift */,
|
||||
);
|
||||
path = Models;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
13103E802D589D6C000F0673 /* Tracking Services */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
138FE1CE2DEC9FFA00936D81 /* TMDB */,
|
||||
13E62FBF2DABC3A20007E259 /* Trakt */,
|
||||
13103E812D589D77000F0673 /* AniList */,
|
||||
);
|
||||
|
|
@ -197,8 +280,8 @@
|
|||
13103E8C2D58E037000F0673 /* SkeletonCells */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
13103E8D2D58E04A000F0673 /* SkeletonCell.swift */,
|
||||
13B7F4C02D58FFDD0045714A /* Shimmer.swift */,
|
||||
13103E8D2D58E04A000F0673 /* SkeletonCell.swift */,
|
||||
);
|
||||
path = SkeletonCells;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -230,7 +313,6 @@
|
|||
133D7C6C2D2BE2500075467E /* Sora */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
72AC3A002DD4DAEA00C60B96 /* Managers */,
|
||||
130C6BF82D53A4C200DC1432 /* Sora.entitlements */,
|
||||
13DC0C412D2EC9BA00D0F966 /* Info.plist */,
|
||||
13103E802D589D6C000F0673 /* Tracking Services */,
|
||||
|
|
@ -256,10 +338,10 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
72443C7C2DC8036500A61321 /* DownloadView.swift */,
|
||||
0402DA122DE7B5EC003BB42C /* SearchView */,
|
||||
133D7C7F2D2BE2630075467E /* MediaInfoView */,
|
||||
1399FAD22D3AB34F00E97C31 /* SettingsView */,
|
||||
133F55B92D33B53E00E08EEA /* LibraryView */,
|
||||
133D7C7C2D2BE2630075467E /* SearchView.swift */,
|
||||
);
|
||||
path = Views;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -268,6 +350,7 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
138AA1B52D2D66EC0021F9DF /* EpisodeCell */,
|
||||
1E47859A2DEBC5960095BF2F /* AnilistMatchPopupView.swift */,
|
||||
133D7C802D2BE2630075467E /* MediaInfoView.swift */,
|
||||
);
|
||||
path = MediaInfoView;
|
||||
|
|
@ -276,6 +359,7 @@
|
|||
133D7C832D2BE2630075467E /* SettingsSubViews */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
132FC5B22DE31DAD009A80F7 /* SettingsViewAlternateAppIconPicker.swift */,
|
||||
1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */,
|
||||
1399FAD32D3AB38C00E97C31 /* SettingsViewLogger.swift */,
|
||||
133D7C842D2BE2630075467E /* SettingsViewModule.swift */,
|
||||
|
|
@ -284,6 +368,7 @@
|
|||
130C6BF92D53AB1F00DC1432 /* SettingsViewData.swift */,
|
||||
13DB46912D900BCE008CBC03 /* SettingsViewTrackers.swift */,
|
||||
72443C7E2DC8038300A61321 /* SettingsViewDownloads.swift */,
|
||||
1398FB3E2DE4E161004D3F5F /* SettingsViewAbout.swift */,
|
||||
);
|
||||
path = SettingsSubViews;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -291,7 +376,10 @@
|
|||
133D7C852D2BE2640075467E /* Utils */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
7205AEDA2DCCEF9500943F3F /* Cache */,
|
||||
0457C5962DE7712A000AFBD9 /* ViewModifiers */,
|
||||
04F08EE02DE10C22006B29D9 /* Models */,
|
||||
04F08EDD2DE10C05006B29D9 /* TabBar */,
|
||||
04F08EDA2DE10BE3006B29D9 /* ProgressiveBlurView */,
|
||||
13D842532D45266900EBBFA6 /* Drops */,
|
||||
1399FAD12D3AB33D00E97C31 /* Logger */,
|
||||
133D7C882D2BE2640075467E /* Modules */,
|
||||
|
|
@ -336,11 +424,7 @@
|
|||
133D7C8A2D2BE2640075467E /* JSLoader */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
7272206F2DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift */,
|
||||
727220702DD642B100C2A4A2 /* JSController+MP4Download.swift */,
|
||||
722248652DCBC13E00CABE2D /* JSController-Downloads.swift */,
|
||||
722248612DCBAA4700CABE2D /* JSController-HeaderManager.swift */,
|
||||
722248622DCBAA4700CABE2D /* JSController+M3U8Download.swift */,
|
||||
134A387B2DE4B5B90041B687 /* Downloads */,
|
||||
133D7C8B2D2BE2640075467E /* JSController.swift */,
|
||||
132AF1202D99951700A0140B /* JSController-Streams.swift */,
|
||||
132AF1222D9995C300A0140B /* JSController-Details.swift */,
|
||||
|
|
@ -352,12 +436,27 @@
|
|||
133F55B92D33B53E00E08EEA /* LibraryView */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
0457C59C2DE78267000AFBD9 /* BookmarkComponents */,
|
||||
04CD76DA2DE20F2200733536 /* AllWatching.swift */,
|
||||
04F08EE32DE10D6B006B29D9 /* AllBookmarks.swift */,
|
||||
133F55BA2D33B55100E08EEA /* LibraryManager.swift */,
|
||||
133D7C7E2D2BE2630075467E /* LibraryView.swift */,
|
||||
);
|
||||
path = LibraryView;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
134A387B2DE4B5B90041B687 /* Downloads */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
7272206F2DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift */,
|
||||
727220702DD642B100C2A4A2 /* JSController+MP4Download.swift */,
|
||||
722248652DCBC13E00CABE2D /* JSController-Downloads.swift */,
|
||||
722248612DCBAA4700CABE2D /* JSController-HeaderManager.swift */,
|
||||
722248622DCBAA4700CABE2D /* JSController+M3U8Download.swift */,
|
||||
);
|
||||
path = Downloads;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
1384DCDF2D89BE870094797A /* Helpers */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
|
@ -376,6 +475,14 @@
|
|||
path = EpisodeCell;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
138FE1CE2DEC9FFA00936D81 /* TMDB */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
138FE1CF2DECA00D00936D81 /* TMDB-FetchID.swift */,
|
||||
);
|
||||
path = TMDB;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
1399FAD12D3AB33D00E97C31 /* Logger */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
|
@ -483,16 +590,6 @@
|
|||
path = Components;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
7205AEDA2DCCEF9500943F3F /* Cache */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
7205AED72DCCEF9500943F3F /* EpisodeMetadata.swift */,
|
||||
7205AED82DCCEF9500943F3F /* KingfisherManager.swift */,
|
||||
7205AED92DCCEF9500943F3F /* MetadataCacheManager.swift */,
|
||||
);
|
||||
path = Cache;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
72443C832DC8046500A61321 /* DownloadUtils */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
|
@ -503,16 +600,6 @@
|
|||
path = DownloadUtils;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
72AC3A002DD4DAEA00C60B96 /* Managers */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
72AC39FD2DD4DAEA00C60B96 /* EpisodeMetadataManager.swift */,
|
||||
72AC39FE2DD4DAEA00C60B96 /* ImagePrefetchManager.swift */,
|
||||
72AC39FF2DD4DAEA00C60B96 /* PerformanceMonitor.swift */,
|
||||
);
|
||||
path = Managers;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
|
|
@ -604,16 +691,20 @@
|
|||
132AF1252D9995F900A0140B /* JSController-Search.swift in Sources */,
|
||||
1EF5C3A92DB988E40032BF07 /* CommunityLib.swift in Sources */,
|
||||
13CBEFDA2D5F7D1200D011EE /* String.swift in Sources */,
|
||||
7205AEDB2DCCEF9500943F3F /* EpisodeMetadata.swift in Sources */,
|
||||
7205AEDC2DCCEF9500943F3F /* MetadataCacheManager.swift in Sources */,
|
||||
7205AEDD2DCCEF9500943F3F /* KingfisherManager.swift in Sources */,
|
||||
04F08EDC2DE10BF3006B29D9 /* ProgressiveBlurView.swift in Sources */,
|
||||
1E26E9E72DA9577900B9DC02 /* VolumeSlider.swift in Sources */,
|
||||
136BBE802DB1038000906B5E /* Notification+Name.swift in Sources */,
|
||||
13DB46902D900A38008CBC03 /* URL.swift in Sources */,
|
||||
04F08EE42DE10D6F006B29D9 /* AllBookmarks.swift in Sources */,
|
||||
138FE1D02DECA00D00936D81 /* TMDB-FetchID.swift in Sources */,
|
||||
130C6BFA2D53AB1F00DC1432 /* SettingsViewData.swift in Sources */,
|
||||
0402DA172DE7B7B8003BB42C /* SearchViewComponents.swift in Sources */,
|
||||
1E9FF1D32D403E49008AC100 /* SettingsViewLoggerFilter.swift in Sources */,
|
||||
0402DA0E2DE7AA01003BB42C /* TabBarController.swift in Sources */,
|
||||
1E47859B2DEBC5960095BF2F /* AnilistMatchPopupView.swift in Sources */,
|
||||
13EA2BD92D32D98400C1EBD7 /* NormalPlayer.swift in Sources */,
|
||||
133D7C932D2BE2640075467E /* Modules.swift in Sources */,
|
||||
0457C5972DE7712A000AFBD9 /* DeviceScaleModifier.swift in Sources */,
|
||||
13DB7CC32D7D99C0004371D3 /* SubtitleSettingsManager.swift in Sources */,
|
||||
133D7C702D2BE2500075467E /* ContentView.swift in Sources */,
|
||||
13D99CF72D4E73C300250A86 /* ModuleAdditionSettingsView.swift in Sources */,
|
||||
|
|
@ -621,8 +712,10 @@
|
|||
13CBA0882D60F19C00EFE70A /* VTTSubtitlesLoader.swift in Sources */,
|
||||
13EA2BD62D32D97400C1EBD7 /* Double+Extension.swift in Sources */,
|
||||
133D7C8F2D2BE2640075467E /* MediaInfoView.swift in Sources */,
|
||||
04F08EE22DE10C40006B29D9 /* TabItem.swift in Sources */,
|
||||
132AF1212D99951700A0140B /* JSController-Streams.swift in Sources */,
|
||||
131845F92D47C62D00CA7A54 /* SettingsViewGeneral.swift in Sources */,
|
||||
04CD76DB2DE20F2200733536 /* AllWatching.swift in Sources */,
|
||||
13103E8E2D58E04A000F0673 /* SkeletonCell.swift in Sources */,
|
||||
13D842552D45267500EBBFA6 /* DropManager.swift in Sources */,
|
||||
13103E8B2D58E028000F0673 /* View.swift in Sources */,
|
||||
|
|
@ -637,10 +730,12 @@
|
|||
722248642DCBAA4700CABE2D /* JSController+M3U8Download.swift in Sources */,
|
||||
133D7C942D2BE2640075467E /* JSController.swift in Sources */,
|
||||
133D7C922D2BE2640075467E /* URLSession.swift in Sources */,
|
||||
0457C5A12DE78385000AFBD9 /* BookmarksDetailView.swift in Sources */,
|
||||
133D7C912D2BE2640075467E /* SettingsViewModule.swift in Sources */,
|
||||
13E62FC22DABC5830007E259 /* Trakt-Login.swift in Sources */,
|
||||
133F55BB2D33B55100E08EEA /* LibraryManager.swift in Sources */,
|
||||
13E62FC42DABC58C0007E259 /* Trakt-Token.swift in Sources */,
|
||||
1398FB3F2DE4E161004D3F5F /* SettingsViewAbout.swift in Sources */,
|
||||
133D7C8E2D2BE2640075467E /* LibraryView.swift in Sources */,
|
||||
13E62FC72DABFE900007E259 /* TraktPushUpdates.swift in Sources */,
|
||||
133D7C6E2D2BE2500075467E /* SoraApp.swift in Sources */,
|
||||
|
|
@ -652,16 +747,21 @@
|
|||
13EA2BD52D32D97400C1EBD7 /* CustomPlayer.swift in Sources */,
|
||||
727220712DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift in Sources */,
|
||||
727220722DD642B100C2A4A2 /* JSController+MP4Download.swift in Sources */,
|
||||
132FC5B32DE31DAE009A80F7 /* SettingsViewAlternateAppIconPicker.swift in Sources */,
|
||||
0402DA132DE7B5EC003BB42C /* SearchStateView.swift in Sources */,
|
||||
0402DA142DE7B5EC003BB42C /* SearchResultsGrid.swift in Sources */,
|
||||
0402DA152DE7B5EC003BB42C /* SearchComponents.swift in Sources */,
|
||||
1399FAD42D3AB38C00E97C31 /* SettingsViewLogger.swift in Sources */,
|
||||
72AC3A012DD4DAEB00C60B96 /* ImagePrefetchManager.swift in Sources */,
|
||||
72AC3A022DD4DAEB00C60B96 /* EpisodeMetadataManager.swift in Sources */,
|
||||
72AC3A032DD4DAEB00C60B96 /* PerformanceMonitor.swift in Sources */,
|
||||
04F08EDF2DE10C1D006B29D9 /* TabBar.swift in Sources */,
|
||||
72443C7F2DC8038300A61321 /* SettingsViewDownloads.swift in Sources */,
|
||||
13DB46922D900BCE008CBC03 /* SettingsViewTrackers.swift in Sources */,
|
||||
7222485F2DCBAA2C00CABE2D /* DownloadModels.swift in Sources */,
|
||||
722248602DCBAA2C00CABE2D /* M3U8StreamExtractor.swift in Sources */,
|
||||
13C0E5EA2D5F85EA00E7F619 /* ContinueWatchingManager.swift in Sources */,
|
||||
13637B8A2DE0EA1100BDA2FC /* UserDefaults.swift in Sources */,
|
||||
0457C59D2DE78267000AFBD9 /* BookmarkGridView.swift in Sources */,
|
||||
0457C59E2DE78267000AFBD9 /* BookmarkLink.swift in Sources */,
|
||||
0457C59F2DE78267000AFBD9 /* BookmarkGridItemView.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
|
@ -793,8 +893,10 @@
|
|||
133D7C792D2BE2520075467E /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = "AppIcon_Original AppIcon_Pixel";
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon_Default;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
|
||||
CODE_SIGN_ENTITLEMENTS = Sora/Sora.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
|
|
@ -820,12 +922,12 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.3.0;
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = me.cranci.sulfur;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SUPPORTS_MACCATALYST = YES;
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2,6";
|
||||
|
|
@ -835,8 +937,10 @@
|
|||
133D7C7A2D2BE2520075467E /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = "AppIcon_Original AppIcon_Pixel";
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon_Default;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
|
||||
CODE_SIGN_ENTITLEMENTS = Sora/Sora.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
|
|
@ -862,12 +966,12 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.3.0;
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = me.cranci.sulfur;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SUPPORTS_MACCATALYST = YES;
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2,6";
|
||||
|
|
|
|||