mirror of
https://github.com/cranci1/Sora.git
synced 2026-01-11 20:10:24 +00:00
green fn (#140)
* Minor changes * more minor changes * MORE MINOR CHANGES * Fixed one singular bug * Update SettingsViewGeneral.swift * fuck conflicts * fuck you and your credits * buh buh * Update SettingsViewAbout.swift * What's that? What's a properly working code without unnecessary issues? * Type shit maybe? * Create ios.yml * smol * type shi shi * end * Fixed system theme bug * (hopefully) fixed sliding items in search + recent searches spam * Fixed open keyboard in media view * Fixed searchviewdata + fixed toggle color being too light * fixed episode slider sensitivity * new recent searches, not fully done but wtv WHO CARES 💯 * Add module screen fix * Delete .github/workflows/ios.yml * UI modifications * Scroll to close keyboard * Text change * Downloadview transition * Search cards text fixed * Reduced header spacing + moved down a to match library * Text change * Small tab bar tweak for search view * added gradient to player buttons * reduced summary size * IBH special 💯 * Fixed different height text? * Removed seperator * Some more fixes start watching button
This commit is contained in:
parent
38df5c71e8
commit
f3ef58db11
47 changed files with 6476 additions and 2019 deletions
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,4 +11,4 @@
|
|||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -35,4 +35,4 @@
|
|||
"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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
104
Sora/Info.plist
104
Sora/Info.plist
|
|
@ -6,6 +6,110 @@
|
|||
<array>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
</array>
|
||||
<key>CFBundleIcons</key>
|
||||
<dict>
|
||||
<key>CFBundleAlternateIcons</key>
|
||||
<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>
|
||||
<key>AppIcon_CiroGold</key>
|
||||
<dict>
|
||||
<key>CFBundleIconFiles</key>
|
||||
<array>
|
||||
<string>AppIcon_CiroGold</string>
|
||||
</array>
|
||||
</dict>
|
||||
<key>AppIcon_CiroChrome</key>
|
||||
<dict>
|
||||
<key>CFBundleIconFiles</key>
|
||||
<array>
|
||||
<string>AppIcon_CiroChrome</string>
|
||||
</array>
|
||||
</dict>
|
||||
<key>AppIcon_CiroGoldTwo</key>
|
||||
<dict>
|
||||
<key>CFBundleIconFiles</key>
|
||||
<array>
|
||||
<string>AppIcon_CiroGoldTwo</string>
|
||||
</array>
|
||||
</dict>
|
||||
<key>AppIcon_CiroGoldThree</key>
|
||||
<dict>
|
||||
<key>CFBundleIconFiles</key>
|
||||
<array>
|
||||
<string>AppIcon_CiroGoldThree</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>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
|
|
|
|||
7
Sora/Models/EpisodeLink.swift
Normal file
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?
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -142,3 +183,9 @@ class AppInfo: NSObject {
|
|||
return Bundle.main.bundleIdentifier ?? "me.cranci.sulfur"
|
||||
}
|
||||
}
|
||||
|
||||
class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask {
|
||||
return OrientationManager.shared.supportedOrientations()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,21 +10,64 @@ 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) {
|
||||
// Read the real playback times
|
||||
let lastKey = "lastPlayedTime_\(item.fullUrl)"
|
||||
// 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)
|
||||
let totalTime = UserDefaults.standard.double(forKey: totalKey)
|
||||
|
||||
// Compute up-to-date progress
|
||||
let actualProgress: Double
|
||||
|
|
@ -40,16 +83,30 @@ class ContinueWatchingManager {
|
|||
return
|
||||
}
|
||||
|
||||
// Otherwise update progress and re-save
|
||||
// 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(updatedItem)
|
||||
|
||||
if let data = try? JSONEncoder().encode(items) {
|
||||
|
|
@ -57,7 +114,6 @@ class ContinueWatchingManager {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
func fetchItems() -> [ContinueWatchingItem] {
|
||||
guard
|
||||
let data = UserDefaults.standard.data(forKey: storageKey),
|
||||
|
|
@ -79,7 +135,7 @@ class ContinueWatchingManager {
|
|||
|
||||
return Array(unique)
|
||||
}
|
||||
|
||||
|
||||
func remove(item: ContinueWatchingItem) {
|
||||
var items = fetchItems()
|
||||
items.removeAll { $0.id == item.id }
|
||||
|
|
|
|||
28
Sora/Utils/Extensions/ScrollViewBottomPadding.swift
Normal file
28
Sora/Utils/Extensions/ScrollViewBottomPadding.swift
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
//
|
||||
// ScrollViewBottomPadding.swift
|
||||
// Sora
|
||||
//
|
||||
// Created by paul on 29/05/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ScrollViewBottomPadding: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
Color.clear
|
||||
.frame(height: 60)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func shimmering() -> some View {
|
||||
self.modifier(Shimmer())
|
||||
}
|
||||
|
||||
func scrollViewBottomPadding() -> some View {
|
||||
modifier(ScrollViewBottomPadding())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
//
|
||||
// View.swift
|
||||
// Sora
|
||||
//
|
||||
// Created by Francesco on 09/02/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
extension View {
|
||||
func shimmering() -> some View {
|
||||
self.modifier(Shimmer())
|
||||
}
|
||||
}
|
||||
|
|
@ -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?
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,3 +79,5 @@ class JSController: NSObject, ObservableObject {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -64,39 +64,62 @@ class Logger {
|
|||
|
||||
let logString = "[\(dateFormatter.string(from: log.timestamp))] [\(log.type)] \(log.message)\n---\n"
|
||||
|
||||
if let data = logString.data(using: .utf8) {
|
||||
if FileManager.default.fileExists(atPath: logFileURL.path) {
|
||||
do {
|
||||
let attributes = try FileManager.default.attributesOfItem(atPath: logFileURL.path)
|
||||
let fileSize = attributes[.size] as? UInt64 ?? 0
|
||||
guard let data = logString.data(using: .utf8) else {
|
||||
print("Failed to encode log string to UTF-8")
|
||||
return
|
||||
}
|
||||
|
||||
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 }
|
||||
|
||||
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
|
||||
}
|
||||
// Ensure content is not empty and contains valid UTF-8
|
||||
guard !content.isEmpty else {
|
||||
try? data.write(to: logFileURL)
|
||||
return
|
||||
}
|
||||
|
||||
// Remove old entries until we have space
|
||||
while (content.data(using: .utf8)?.count ?? 0) + data.count > maxFileSize {
|
||||
if let rangeOfFirstLine = content.range(of: "\n---\n") {
|
||||
// Ensure we don't try to remove beyond the string's bounds
|
||||
let endIndex = min(rangeOfFirstLine.upperBound, content.endIndex)
|
||||
content.removeSubrange(content.startIndex..<endIndex)
|
||||
} else {
|
||||
// If we can't find a separator, clear the content
|
||||
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()
|
||||
// Safety check to prevent infinite loops
|
||||
if content.isEmpty {
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
print("Error managing log file: \(error)")
|
||||
|
||||
// Append new log entry
|
||||
content += logString
|
||||
|
||||
// Write back to file
|
||||
if let finalData = content.data(using: .utf8) {
|
||||
try? finalData.write(to: logFileURL)
|
||||
}
|
||||
} else {
|
||||
if let handle = try? FileHandle(forWritingTo: logFileURL) {
|
||||
handle.seekToEndOfFile()
|
||||
handle.write(data)
|
||||
handle.closeFile()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try? data.write(to: logFileURL)
|
||||
} catch {
|
||||
print("Error managing log file: \(error)")
|
||||
}
|
||||
} else {
|
||||
try? data.write(to: logFileURL)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1051,7 +1051,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)
|
||||
|
|
@ -1083,7 +1083,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)
|
||||
|
|
@ -1283,7 +1283,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)
|
||||
|
|
@ -2453,7 +2453,45 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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
|
||||
13
Sora/Utils/Models/TabItem.swift
Normal file
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
|
||||
}
|
||||
|
|
@ -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
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 = []
|
||||
}
|
||||
}
|
||||
}
|
||||
250
Sora/Utils/TabBar/TabBar.swift
Normal file
250
Sora/Utils/TabBar/TabBar.swift
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
//
|
||||
// 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
|
||||
@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: "x.circle")
|
||||
.font(.system(size: 30))
|
||||
.foregroundStyle(.gray)
|
||||
.frame(width: 24, height: 24)
|
||||
.matchedGeometryEffect(id: "x.circle", 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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
.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 {
|
||||
// Move the blur background here and animate it
|
||||
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 {
|
||||
withAnimation(.bouncy(duration: 0.3)) {
|
||||
lastTab = selectedTab
|
||||
selectedTab = index
|
||||
showSearch = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
||||
keyboardFocus = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
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(maxWidth: .infinity)
|
||||
.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(maxWidth: .infinity)
|
||||
.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
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
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())
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
125
Sora/Views/LibraryView/AllBookmarks.swift
Normal file
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
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
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,27 +7,29 @@
|
|||
|
||||
import SwiftUI
|
||||
import Kingfisher
|
||||
import UIKit
|
||||
|
||||
struct LibraryView: View {
|
||||
@EnvironmentObject private var libraryManager: LibraryManager
|
||||
@EnvironmentObject private var moduleManager: ModuleManager
|
||||
|
||||
|
||||
@AppStorage("mediaColumnsPortrait") private var mediaColumnsPortrait: Int = 2
|
||||
@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 {
|
||||
// Stage Manager Detection
|
||||
if UIDevice.current.userInterfaceIdiom == .pad && horizontalSizeClass == .compact {
|
||||
|
|
@ -41,7 +43,7 @@ struct LibraryView: View {
|
|||
return verticalSizeClass == .compact ? mediaColumnsLandscape : mediaColumnsPortrait
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private var cellWidth: CGFloat {
|
||||
let keyWindow = UIApplication.shared.connectedScenes
|
||||
.compactMap { ($0 as? UIWindowScene)?.windows.first(where: { $0.isKeyWindow }) }
|
||||
|
|
@ -52,165 +54,153 @@ struct LibraryView: View {
|
|||
let availableWidth = safeWidth - totalSpacing
|
||||
return availableWidth / CGFloat(columnsCount)
|
||||
}
|
||||
|
||||
|
||||
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")
|
||||
ZStack {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
Text("Library")
|
||||
.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")
|
||||
}
|
||||
}
|
||||
.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)
|
||||
NavigationLink(
|
||||
destination: Group {
|
||||
if let bookmark = selectedBookmark,
|
||||
let module = moduleManager.modules.first(where: { $0.id.uuidString == bookmark.moduleId }) {
|
||||
MediaInfoView(title: bookmark.title,
|
||||
imageUrl: bookmark.imageUrl,
|
||||
href: bookmark.href,
|
||||
module: module)
|
||||
} else {
|
||||
Text("No Data Available")
|
||||
.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)
|
||||
}
|
||||
},
|
||||
isActive: $isDetailActive
|
||||
) {
|
||||
EmptyView()
|
||||
|
||||
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
|
||||
}) {
|
||||
MediaInfoView(title: bookmark.title,
|
||||
imageUrl: bookmark.imageUrl,
|
||||
href: bookmark.href,
|
||||
module: module)
|
||||
} else {
|
||||
Text("No Data Available")
|
||||
}
|
||||
},
|
||||
isActive: $isDetailActive
|
||||
) {
|
||||
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())
|
||||
}
|
||||
|
||||
|
||||
private func fetchContinueWatching() {
|
||||
continueWatchingItems = ContinueWatchingManager.shared.fetchItems()
|
||||
}
|
||||
|
||||
|
||||
private func markContinueWatchingItemAsWatched(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)
|
||||
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() {
|
||||
DispatchQueue.main.async {
|
||||
isLandscape = UIDevice.current.orientation.isLandscape
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func determineColumns() -> Int {
|
||||
// Stage Manager Detection
|
||||
if UIDevice.current.userInterfaceIdiom == .pad && horizontalSizeClass == .compact {
|
||||
|
|
@ -227,25 +217,24 @@ struct LibraryView: View {
|
|||
}
|
||||
|
||||
struct ContinueWatchingSection: View {
|
||||
@Binding var items: [ContinueWatchingItem]
|
||||
@Binding
|
||||
var items: [ContinueWatchingItem]
|
||||
var markAsWatched: (ContinueWatchingItem) -> Void
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -254,132 +243,154 @@ struct ContinueWatchingCell: View {
|
|||
let item: ContinueWatchingItem
|
||||
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: {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}) {
|
||||
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()
|
||||
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)
|
||||
}
|
||||
.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)
|
||||
}
|
||||
},
|
||||
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)
|
||||
}) {
|
||||
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(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))
|
||||
}
|
||||
}
|
||||
.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: 280, height: 157.03)
|
||||
}
|
||||
.contextMenu {
|
||||
Button(action: {
|
||||
markAsWatched()
|
||||
}) {
|
||||
Label("Mark as Watched", systemImage: "checkmark.circle")
|
||||
}
|
||||
Button(role: .destructive, action: {
|
||||
removeItem()
|
||||
}) {
|
||||
Label("Remove Item", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
.frame(width: 240, height: 170)
|
||||
}
|
||||
.contextMenu {
|
||||
Button(action: { markAsWatched() }) {
|
||||
Label("Mark as Watched", systemImage: "checkmark.circle")
|
||||
.onAppear {
|
||||
updateProgress()
|
||||
}
|
||||
Button(role: .destructive, action: { removeItem() }) {
|
||||
Label("Remove Item", systemImage: "trash")
|
||||
.onReceive(NotificationCenter.default.publisher(
|
||||
for: UIApplication.didBecomeActiveNotification)) {
|
||||
_ in
|
||||
updateProgress()
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
updateProgress()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
|
||||
updateProgress()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func updateProgress() {
|
||||
// grab the true playback times
|
||||
let lastPlayed = UserDefaults.standard.double(forKey: "lastPlayedTime_\(item.fullUrl)")
|
||||
|
|
@ -405,3 +416,188 @@ struct ContinueWatchingCell: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
.contextMenu {
|
||||
Button(role: .destructive, action: {
|
||||
libraryManager.removeBookmark(item: item)
|
||||
}) {
|
||||
Label("Remove from Bookmarks", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -104,19 +102,111 @@ struct EpisodeCell: View {
|
|||
}
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
episodeThumbnail
|
||||
episodeInfo
|
||||
Spacer()
|
||||
CircularProgressBar(progress: currentProgress)
|
||||
.frame(width: 40, height: 40)
|
||||
.padding(.trailing, 4)
|
||||
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(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()
|
||||
|
|
@ -153,22 +243,6 @@ 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)")?")
|
||||
}
|
||||
}
|
||||
|
||||
private var episodeThumbnail: some View {
|
||||
|
|
@ -680,4 +754,102 @@ struct EpisodeCell: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ struct MediaInfoView: View {
|
|||
@StateObject private var jsController = JSController.shared
|
||||
@EnvironmentObject var moduleManager: ModuleManager
|
||||
@EnvironmentObject private var libraryManager: LibraryManager
|
||||
@EnvironmentObject var tabBarController: TabBarController
|
||||
|
||||
@State private var selectedRange: Range<Int> = 0..<100
|
||||
@State private var showSettingsMenu = false
|
||||
|
|
@ -97,7 +98,43 @@ struct MediaInfoView: View {
|
|||
}
|
||||
|
||||
var body: some View {
|
||||
bodyContent
|
||||
ZStack {
|
||||
bodyContent
|
||||
.navigationBarHidden(true)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarTitle("")
|
||||
.navigationViewStyle(StackNavigationViewStyle())
|
||||
.ignoresSafeArea(.container, edges: .top)
|
||||
.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
|
||||
}
|
||||
}
|
||||
|
||||
VStack {
|
||||
HStack {
|
||||
Button(action: {
|
||||
dismiss()
|
||||
}) {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: 24))
|
||||
.foregroundColor(.primary)
|
||||
.padding(12)
|
||||
.background(Color.gray.opacity(0.2))
|
||||
.clipShape(Circle())
|
||||
.circularGradientOutline()
|
||||
}
|
||||
.padding(.top, 8)
|
||||
.padding(.leading, 16)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
|
|
@ -113,6 +150,8 @@ struct MediaInfoView: View {
|
|||
.onAppear {
|
||||
buttonRefreshTrigger.toggle()
|
||||
|
||||
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
||||
|
||||
if !hasFetched {
|
||||
DropManager.shared.showDrop(title: "Fetching Data", subtitle: "Please wait while fetching.", duration: 0.5, icon: UIImage(systemName: "arrow.triangle.2.circlepath"))
|
||||
fetchDetails()
|
||||
|
|
@ -138,6 +177,10 @@ struct MediaInfoView: View {
|
|||
hasFetched = true
|
||||
AnalyticsManager.shared.sendEvent(event: "search", additionalData: ["title": title])
|
||||
}
|
||||
tabBarController.hideTabBar()
|
||||
}
|
||||
.onDisappear(){
|
||||
tabBarController.showTabBar()
|
||||
}
|
||||
.alert("Loading Stream", isPresented: $showLoadingAlert) {
|
||||
Button("Cancel", role: .cancel) {
|
||||
|
|
@ -162,91 +205,252 @@ struct MediaInfoView: View {
|
|||
@ViewBuilder
|
||||
private var mainScrollView: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
mediaHeaderSection
|
||||
|
||||
if !synopsis.isEmpty {
|
||||
synopsisSection
|
||||
}
|
||||
|
||||
playAndBookmarkSection
|
||||
|
||||
if !episodeLinks.isEmpty {
|
||||
episodesSection
|
||||
} else {
|
||||
noEpisodesSection
|
||||
ZStack(alignment: .top) {
|
||||
KFImage(URL(string: imageUrl))
|
||||
.placeholder {
|
||||
Rectangle()
|
||||
.fill(Color.gray.opacity(0.3))
|
||||
.shimmering()
|
||||
}
|
||||
.setProcessor(ImageUpscaler.lanczosProcessor(scale: 3, sharpeningIntensity: 1, sharpeningRadius: 1))
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: UIScreen.main.bounds.width, height: 600)
|
||||
.clipped()
|
||||
|
||||
KFImage(URL(string: imageUrl))
|
||||
.placeholder { EmptyView() }
|
||||
.setProcessor(ImageUpscaler.lanczosProcessor(scale: 3, sharpeningIntensity: 1, sharpeningRadius: 1))
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: UIScreen.main.bounds.width, height: 600)
|
||||
.clipped()
|
||||
.blur(radius: 30)
|
||||
.mask(
|
||||
LinearGradient(
|
||||
gradient: Gradient(stops: [
|
||||
.init(color: .clear, location: 0.0),
|
||||
.init(color: .clear, location: 0.6),
|
||||
.init(color: .black, location: 0.8),
|
||||
.init(color: .black, location: 1.0)
|
||||
]),
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
.overlay(
|
||||
LinearGradient(
|
||||
gradient: Gradient(stops: [
|
||||
.init(color: .clear, location: 0.0),
|
||||
.init(color: .clear, location: 0.7),
|
||||
.init(color: (colorScheme == .dark ? Color.black : Color.white).opacity(0.9), location: 1.0)
|
||||
]),
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
Rectangle()
|
||||
.fill(Color.clear)
|
||||
.frame(height: 400)
|
||||
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
headerSection
|
||||
if !episodeLinks.isEmpty {
|
||||
episodesSection
|
||||
} else {
|
||||
noEpisodesSection
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(
|
||||
LinearGradient(
|
||||
gradient: Gradient(stops: [
|
||||
.init(color: (colorScheme == .dark ? Color.black : Color.white).opacity(0.0), location: 0.0),
|
||||
.init(color: (colorScheme == .dark ? Color.black : Color.white).opacity(0.5), location: 0.2),
|
||||
.init(color: (colorScheme == .dark ? Color.black : Color.white).opacity(0.8), location: 0.5),
|
||||
.init(color: (colorScheme == .dark ? Color.black : Color.white), location: 1.0)
|
||||
]),
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 0))
|
||||
.shadow(
|
||||
color: (colorScheme == .dark ? Color.black : Color.white).opacity(1),
|
||||
radius: 10,
|
||||
x: 0,
|
||||
y: 10
|
||||
)
|
||||
)
|
||||
}
|
||||
.deviceScaled()
|
||||
}
|
||||
.padding()
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarTitle("")
|
||||
.navigationViewStyle(StackNavigationViewStyle())
|
||||
}
|
||||
.onAppear {
|
||||
UIScrollView.appearance().bounces = false
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarTitle("")
|
||||
.navigationViewStyle(StackNavigationViewStyle())
|
||||
.ignoresSafeArea(.container, edges: .top)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var mediaHeaderSection: some View {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
KFImage(URL(string: imageUrl))
|
||||
.placeholder {
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(Color.gray.opacity(0.3))
|
||||
.frame(width: 150, height: 225)
|
||||
.shimmering()
|
||||
}
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 150, height: 225)
|
||||
.clipped()
|
||||
.cornerRadius(10)
|
||||
|
||||
mediaInfoSection
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var mediaInfoSection: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
private var headerSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(title)
|
||||
.font(.system(size: 17))
|
||||
.fontWeight(.bold)
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.foregroundColor(.primary)
|
||||
.lineLimit(3)
|
||||
.onLongPressGesture {
|
||||
UIPasteboard.general.string = title
|
||||
DropManager.shared.showDrop(title: "Copied to Clipboard", subtitle: "", duration: 1.0, icon: UIImage(systemName: "doc.on.clipboard.fill"))
|
||||
}
|
||||
|
||||
if !aliases.isEmpty && aliases != title && aliases != "N/A" && aliases != "No Data" {
|
||||
Text(aliases)
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if !airdate.isEmpty && airdate != "N/A" && airdate != "No Data" {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "calendar")
|
||||
.resizable()
|
||||
.frame(width: 15, height: 15)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text(airdate)
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.secondary)
|
||||
if !synopsis.isEmpty {
|
||||
HStack(alignment: .bottom) {
|
||||
Text(synopsis)
|
||||
.font(.system(size: 16))
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(showFullSynopsis ? nil : 3)
|
||||
|
||||
Text(showFullSynopsis ? "LESS" : "MORE")
|
||||
.font(.system(size: 16, weight: .bold))
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
.onTapGesture {
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
showFullSynopsis.toggle()
|
||||
}
|
||||
.padding(4)
|
||||
}
|
||||
}
|
||||
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
playAndBookmarkSection
|
||||
|
||||
// Metadata row
|
||||
HStack(spacing: 16) {
|
||||
sourceButton
|
||||
|
||||
if !airdate.isEmpty && airdate != "N/A" && airdate != "No Data" {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "calendar")
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text(airdate)
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
menuButton
|
||||
}
|
||||
|
||||
// Single episode action buttons
|
||||
if episodeLinks.count == 1 {
|
||||
VStack(spacing: 12) {
|
||||
HStack(spacing: 12) {
|
||||
Button(action: {
|
||||
if let ep = episodeLinks.first {
|
||||
let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(ep.href)")
|
||||
let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(ep.href)")
|
||||
let progress = totalTime > 0 ? lastPlayedTime / totalTime : 0
|
||||
|
||||
if progress <= 0.9 {
|
||||
UserDefaults.standard.set(99999999.0, forKey: "lastPlayedTime_\(ep.href)")
|
||||
UserDefaults.standard.set(99999999.0, forKey: "totalTime_\(ep.href)")
|
||||
DropManager.shared.showDrop(title: "Marked as Watched", subtitle: "", duration: 1.0, icon: UIImage(systemName: "checkmark.circle.fill"))
|
||||
}
|
||||
}
|
||||
}) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "checkmark.circle")
|
||||
.foregroundColor(.primary)
|
||||
Text("Mark as Watched")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.gray.opacity(0.2))
|
||||
.cornerRadius(15)
|
||||
.gradientOutline()
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
if let ep = episodeLinks.first {
|
||||
let downloadStatus = jsController.isEpisodeDownloadedOrInProgress(
|
||||
showTitle: title,
|
||||
episodeNumber: ep.number,
|
||||
season: 1
|
||||
)
|
||||
|
||||
if downloadStatus == .notDownloaded {
|
||||
selectedEpisodeNumber = ep.number
|
||||
fetchStream(href: ep.href)
|
||||
DropManager.shared.showDrop(title: "Starting Download", subtitle: "", duration: 1.0, icon: UIImage(systemName: "arrow.down.circle"))
|
||||
} else {
|
||||
DropManager.shared.showDrop(title: "Already Downloaded", subtitle: "", duration: 1.0, icon: UIImage(systemName: "checkmark.circle"))
|
||||
}
|
||||
}
|
||||
}) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "arrow.down.circle")
|
||||
.foregroundColor(.primary)
|
||||
Text("Download")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.gray.opacity(0.2))
|
||||
.cornerRadius(15)
|
||||
.gradientOutline()
|
||||
}
|
||||
}
|
||||
Text("Why am I not seeing any episodes?")
|
||||
.font(.caption)
|
||||
.bold()
|
||||
.foregroundColor(.gray)
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineLimit(nil)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.top, 4)
|
||||
.padding(.leading, 2.8)
|
||||
Text("The module provided only a single episode, this is most likely a movie, so we decided to make separate screens for these cases.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.gray)
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineLimit(nil)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var contentSection: some View {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
playAndBookmarkSection
|
||||
|
||||
if !episodeLinks.isEmpty {
|
||||
episodesSection
|
||||
} else {
|
||||
noEpisodesSection
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 20)
|
||||
.background(
|
||||
Rectangle()
|
||||
.fill(colorScheme == .dark ? Color.black : Color.white)
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var sourceButton: some View {
|
||||
Button(action: {
|
||||
|
|
@ -254,16 +458,20 @@ struct MediaInfoView: View {
|
|||
}) {
|
||||
HStack(spacing: 4) {
|
||||
Text(module.metadata.sourceName)
|
||||
.font(.system(size: 13))
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(.primary)
|
||||
.lineLimit(1)
|
||||
|
||||
Image(systemName: "safari")
|
||||
.resizable()
|
||||
.frame(width: 20, height: 20)
|
||||
.frame(width: 14, height: 14)
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
.padding(4)
|
||||
.background(Capsule().fill(Color.accentColor.opacity(0.4)))
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.gray.opacity(0.2))
|
||||
.cornerRadius(15)
|
||||
.gradientOutline()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -312,54 +520,37 @@ struct MediaInfoView: View {
|
|||
Label("Log Debug Info", systemImage: "terminal")
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
Image(systemName: "ellipsis")
|
||||
.resizable()
|
||||
.frame(width: 20, height: 20)
|
||||
.frame(width: 16, height: 4)
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var synopsisSection: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(alignment: .center) {
|
||||
Text("Synopsis")
|
||||
.font(.system(size: 18))
|
||||
.fontWeight(.bold)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: {
|
||||
showFullSynopsis.toggle()
|
||||
}) {
|
||||
Text(showFullSynopsis ? "Less" : "More")
|
||||
.font(.system(size: 14))
|
||||
}
|
||||
}
|
||||
|
||||
Text(synopsis)
|
||||
.lineLimit(showFullSynopsis ? nil : 4)
|
||||
.font(.system(size: 14))
|
||||
.padding(12)
|
||||
.background(Color.gray.opacity(0.2))
|
||||
.clipShape(Circle())
|
||||
.circularGradientOutline()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var playAndBookmarkSection: some View {
|
||||
HStack {
|
||||
HStack(spacing: 12) {
|
||||
Button(action: {
|
||||
playFirstUnwatchedEpisode()
|
||||
}) {
|
||||
HStack {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "play.fill")
|
||||
.foregroundColor(.primary)
|
||||
.foregroundColor(colorScheme == .dark ? .black : .white)
|
||||
Text(startWatchingText)
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundColor(colorScheme == .dark ? .black : .white)
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color.accentColor)
|
||||
.cornerRadius(10)
|
||||
.padding(.vertical, 12)
|
||||
.padding(.horizontal, 20)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 25)
|
||||
.fill(Color.accentColor)
|
||||
)
|
||||
}
|
||||
.disabled(isFetchingEpisode)
|
||||
|
||||
|
|
@ -374,25 +565,43 @@ struct MediaInfoView: View {
|
|||
}) {
|
||||
Image(systemName: libraryManager.isBookmarked(href: href, moduleName: module.metadata.sourceName) ? "bookmark.fill" : "bookmark")
|
||||
.resizable()
|
||||
.frame(width: 20, height: 27)
|
||||
.foregroundColor(Color.accentColor)
|
||||
.frame(width: 16, height: 22)
|
||||
.foregroundColor(.primary)
|
||||
.padding(12)
|
||||
.background(Color.gray.opacity(0.2))
|
||||
.clipShape(Circle())
|
||||
.circularGradientOutline()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var episodesSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
Text("Episodes")
|
||||
.font(.system(size: 18))
|
||||
.fontWeight(.bold)
|
||||
if episodeLinks.count == 1 {
|
||||
// Don't show episodes list for single-episode media
|
||||
EmptyView()
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack {
|
||||
Text("Episodes")
|
||||
.font(.system(size: 22, weight: .bold))
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Group {
|
||||
if !isGroupedBySeasons && episodeLinks.count <= episodeChunkSize {
|
||||
Text("All episodes already shown")
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
episodeNavigationSection
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
episodeNavigationSection
|
||||
episodeListSection
|
||||
}
|
||||
|
||||
episodeListSection
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -540,6 +749,8 @@ struct MediaInfoView: View {
|
|||
episodeID: ep.number - 1,
|
||||
progress: progress,
|
||||
itemID: itemID ?? 0,
|
||||
totalEpisodes: episodeLinks.count,
|
||||
defaultBannerImage: getBannerImageBasedOnAppearance(),
|
||||
module: module,
|
||||
parentTitle: title,
|
||||
showPosterURL: imageUrl,
|
||||
|
|
@ -606,34 +817,23 @@ struct MediaInfoView: View {
|
|||
|
||||
@ViewBuilder
|
||||
private var noEpisodesSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Episodes")
|
||||
.font(.system(size: 18))
|
||||
.fontWeight(.bold)
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "tv.slash")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text("No Episodes Available")
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Text("Episodes might not be available yet or there could be an issue with the source.")
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
VStack(spacing: 8) {
|
||||
if isRefetching {
|
||||
ProgressView()
|
||||
.padding()
|
||||
} else {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.largeTitle)
|
||||
.foregroundColor(.secondary)
|
||||
HStack(spacing: 2) {
|
||||
Text("No episodes Found:")
|
||||
.foregroundColor(.secondary)
|
||||
Button(action: {
|
||||
isRefetching = true
|
||||
fetchDetails()
|
||||
}) {
|
||||
Text("Retry")
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 40)
|
||||
}
|
||||
|
||||
private var startWatchingText: String {
|
||||
|
|
@ -641,6 +841,13 @@ struct MediaInfoView: View {
|
|||
let finished = indices.finished
|
||||
let unfinished = indices.unfinished
|
||||
|
||||
if episodeLinks.count == 1 {
|
||||
if let unfinishedIndex = unfinished {
|
||||
return "Continue Watching"
|
||||
}
|
||||
return "Start Watching"
|
||||
}
|
||||
|
||||
if let finishedIndex = finished, finishedIndex < episodeLinks.count - 1 {
|
||||
let nextEp = episodeLinks[finishedIndex + 1]
|
||||
return "Start Watching Episode \(nextEp.number)"
|
||||
|
|
@ -785,8 +992,8 @@ struct MediaInfoView: View {
|
|||
self.showLoadingAlert = false
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
guard self.activeFetchID == fetchID else {
|
||||
return
|
||||
guard self.activeFetchID == fetchID else {
|
||||
return
|
||||
}
|
||||
|
||||
if let sources = result.sources, !sources.isEmpty {
|
||||
|
|
|
|||
|
|
@ -8,14 +8,15 @@
|
|||
import SwiftUI
|
||||
import Kingfisher
|
||||
|
||||
struct SearchItem: Identifiable {
|
||||
let id = UUID()
|
||||
let title: String
|
||||
let imageUrl: String
|
||||
let href: String
|
||||
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
|
||||
|
|
@ -25,15 +26,23 @@ struct SearchView: View {
|
|||
@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 searchText = ""
|
||||
@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?
|
||||
|
||||
private let columns = [GridItem(.adaptive(minimum: 150))]
|
||||
|
||||
init(searchQuery: Binding<String>) {
|
||||
self._searchQuery = searchQuery
|
||||
}
|
||||
|
||||
private var selectedModule: ScrapingModule? {
|
||||
guard let id = selectedModuleId else { return nil }
|
||||
|
|
@ -62,228 +71,65 @@ struct SearchView: View {
|
|||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
ScrollView {
|
||||
let columnsCount = determineColumns()
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
SearchBar(text: $searchText, isFocused: $isSearchFieldFocused, onSearchButtonClicked: performSearch)
|
||||
.padding(.leading)
|
||||
.padding(.trailing, searchText.isEmpty ? 16 : 0)
|
||||
.disabled(selectedModule == nil)
|
||||
.padding(.top)
|
||||
|
||||
if !searchText.isEmpty {
|
||||
Button("Cancel") {
|
||||
searchText = ""
|
||||
isSearchFieldFocused = false
|
||||
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
||||
}
|
||||
.padding(.trailing)
|
||||
.padding(.top)
|
||||
}
|
||||
}
|
||||
VStack(alignment: .leading) {
|
||||
HStack {
|
||||
Text("Search")
|
||||
.font(.largeTitle)
|
||||
.fontWeight(.bold)
|
||||
|
||||
if isSearchFieldFocused && !searchHistory.isEmpty && searchText.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack {
|
||||
Text("Recent Searches")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
Button("Clear") {
|
||||
clearSearchHistory()
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 8)
|
||||
|
||||
ForEach(Array(searchHistory.enumerated()), id: \.offset) { index, searchTerm in
|
||||
Button(action: {
|
||||
searchText = searchTerm
|
||||
isSearchFieldFocused = false
|
||||
performSearch()
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "clock")
|
||||
.foregroundColor(.secondary)
|
||||
.font(.caption)
|
||||
Text(searchTerm)
|
||||
.foregroundColor(.primary)
|
||||
Spacer()
|
||||
Button(action: {
|
||||
removeFromHistory(at: index)
|
||||
}) {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundColor(.secondary)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
|
||||
if index < searchHistory.count - 1 {
|
||||
Divider()
|
||||
.padding(.leading, 40)
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(8)
|
||||
.shadow(color: Color.black.opacity(0.1), radius: 2, y: 1)
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
Spacer()
|
||||
|
||||
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)
|
||||
ModuleSelectorMenu(
|
||||
selectedModule: selectedModule,
|
||||
moduleGroups: getModuleLanguageGroups(),
|
||||
modulesByLanguage: getModulesByLanguage(),
|
||||
selectedModuleId: selectedModuleId,
|
||||
onModuleSelected: { moduleId in
|
||||
selectedModuleId = moduleId
|
||||
}
|
||||
.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()
|
||||
.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(StackNavigationViewStyle())
|
||||
.onAppear {
|
||||
loadSearchHistory()
|
||||
if !searchQuery.isEmpty {
|
||||
performSearch()
|
||||
}
|
||||
}
|
||||
.onChange(of: selectedModuleId) { _ in
|
||||
if !searchText.isEmpty {
|
||||
if !searchQuery.isEmpty {
|
||||
performSearch()
|
||||
}
|
||||
}
|
||||
|
|
@ -295,52 +141,77 @@ struct SearchView: View {
|
|||
moduleManager.selectedModuleChanged = false
|
||||
}
|
||||
}
|
||||
.onChange(of: searchText) { newValue in
|
||||
.onChange(of: searchQuery) { newValue in
|
||||
if newValue.isEmpty {
|
||||
saveDebounceTimer?.invalidate()
|
||||
searchItems = []
|
||||
hasNoResults = false
|
||||
isSearching = false
|
||||
} else {
|
||||
performSearch()
|
||||
|
||||
saveDebounceTimer?.invalidate()
|
||||
saveDebounceTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { _ in
|
||||
self.addToSearchHistory(newValue)}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func lockOrientation() {
|
||||
OrientationManager.shared.lockOrientation()
|
||||
}
|
||||
|
||||
private func unlockOrientation(after delay: TimeInterval = 1.0) {
|
||||
OrientationManager.shared.unlockOrientation(after: delay)
|
||||
}
|
||||
|
||||
private func performSearch() {
|
||||
Logger.shared.log("Searching for: \(searchText)", type: "General")
|
||||
guard !searchText.isEmpty, let module = selectedModule else {
|
||||
Logger.shared.log("Searching for: \(searchQuery)", type: "General")
|
||||
guard !searchQuery.isEmpty, let module = selectedModule else {
|
||||
searchItems = []
|
||||
hasNoResults = false
|
||||
return
|
||||
}
|
||||
|
||||
addToSearchHistory(searchText)
|
||||
isSearchFieldFocused = false
|
||||
|
||||
isSearching = true
|
||||
hasNoResults = false
|
||||
searchItems = []
|
||||
|
||||
lockOrientation()
|
||||
|
||||
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
|
||||
jsController.fetchJsSearchResults(keyword: searchQuery, module: module) { items in
|
||||
DispatchQueue.main.async {
|
||||
searchItems = items
|
||||
hasNoResults = items.isEmpty
|
||||
isSearching = false
|
||||
unlockOrientation(after: 3.0)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
jsController.fetchSearchResults(keyword: searchText, module: module) { items in
|
||||
searchItems = items
|
||||
hasNoResults = items.isEmpty
|
||||
isSearching = false
|
||||
jsController.fetchSearchResults(keyword: searchQuery, module: module) { items in
|
||||
DispatchQueue.main.async {
|
||||
searchItems = items
|
||||
hasNoResults = items.isEmpty
|
||||
isSearching = false
|
||||
unlockOrientation(after: 3.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Logger.shared.log("Error loading module: \(error)", type: "Error")
|
||||
isSearching = false
|
||||
hasNoResults = true
|
||||
DispatchQueue.main.async {
|
||||
isSearching = false
|
||||
hasNoResults = true
|
||||
unlockOrientation(after: 3.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -444,10 +315,10 @@ struct SearchBar: View {
|
|||
.padding(.horizontal, 25)
|
||||
.background(Color(.systemGray6))
|
||||
.cornerRadius(8)
|
||||
.onChange(of: text){newValue in
|
||||
.onChange(of: text) { newValue in
|
||||
debounceTimer?.invalidate()
|
||||
if !newValue.isEmpty {
|
||||
debounceTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in
|
||||
debounceTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: false) { _ in
|
||||
onSearchButtonClicked()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
79
Sora/Views/SearchView/SearchComponents.swift
Normal file
79
Sora/Views/SearchView/SearchComponents.swift
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
//
|
||||
// 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 {
|
||||
// Clock Icon
|
||||
Image(systemName: "clock")
|
||||
.frame(width: 24, height: 24)
|
||||
.foregroundStyle(Color.primary)
|
||||
|
||||
// Search Text
|
||||
Text(text)
|
||||
.foregroundStyle(Color.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Delete Button
|
||||
Button(action: onDelete) {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(Color.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture(perform: onTap)
|
||||
|
||||
// Divider if needed
|
||||
if showDivider {
|
||||
Divider()
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
}
|
||||
60
Sora/Views/SearchView/SearchResultsGrid.swift
Normal file
60
Sora/Views/SearchView/SearchResultsGrid.swift
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
//
|
||||
// SearchResultsGrid.swift
|
||||
// Sora
|
||||
//
|
||||
// Created by paul on 28/05/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Kingfisher
|
||||
|
||||
struct SearchResultsGrid: View {
|
||||
let items: [SearchItem]
|
||||
let columns: [GridItem]
|
||||
let selectedModule: ScrapingModule
|
||||
|
||||
var body: some View {
|
||||
LazyVGrid(columns: columns, spacing: 16) {
|
||||
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: 162, height: 243)
|
||||
.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: 162)
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.padding(4)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.top)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
41
Sora/Views/SearchView/SearchStateView.swift
Normal file
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
191
Sora/Views/SearchView/SearchViewComponents.swift
Normal file
191
Sora/Views/SearchView/SearchViewComponents.swift
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
//
|
||||
// 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
|
||||
)
|
||||
}
|
||||
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
|
||||
)
|
||||
} else {
|
||||
SearchStateView(
|
||||
isSearching: isSearching,
|
||||
hasNoResults: hasNoResults,
|
||||
columnsCount: columnsCount,
|
||||
cellWidth: cellWidth
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
251
Sora/Views/SettingsSharedComponents.swift
Normal file
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
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,70 +8,130 @@
|
|||
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 {
|
||||
Form {
|
||||
Section(footer: Text("Sora/Sulfur will always remain free with no ADs!")) {
|
||||
HStack {
|
||||
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(spacing: 8) {
|
||||
Text("Sora")
|
||||
.font(.title)
|
||||
.bold()
|
||||
Text("AKA Sulfur")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.listRowInsets(EdgeInsets())
|
||||
.padding()
|
||||
}
|
||||
|
||||
Section("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"))
|
||||
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: 40, height: 40)
|
||||
.clipShape(Circle())
|
||||
.frame(width: 100, height: 100)
|
||||
.cornerRadius(20)
|
||||
.shadow(radius: 5)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text("cranci1")
|
||||
.font(.headline)
|
||||
.foregroundColor(.indigo)
|
||||
Text("me frfr")
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Sora")
|
||||
.font(.title)
|
||||
.bold()
|
||||
Text("AKA Sulfur")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text("Version \(version)")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "safari")
|
||||
.foregroundColor(.indigo)
|
||||
}
|
||||
.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()
|
||||
}
|
||||
}
|
||||
|
||||
Section("Contributors") {
|
||||
ContributorsView()
|
||||
}
|
||||
.padding(.vertical, 20)
|
||||
}
|
||||
.navigationTitle("About")
|
||||
.scrollViewBottomPadding()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -88,12 +148,18 @@ struct ContributorsView: View {
|
|||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -152,12 +218,14 @@ struct ContributorView: View {
|
|||
|
||||
Text(contributor.login)
|
||||
.font(.headline)
|
||||
.foregroundColor(.accentColor)
|
||||
.foregroundColor(contributor.login == "IBH-RAD" ? Color(hexTwo: "#41127b") : .accentColor)
|
||||
|
||||
Spacer()
|
||||
Image(systemName: "safari")
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -173,3 +241,29 @@ struct Contributor: Identifiable, Decodable {
|
|||
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: // RGB (12-bit)
|
||||
(a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
|
||||
case 6: // RGB (24-bit)
|
||||
(a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
|
||||
case 8: // ARGB (32-bit)
|
||||
(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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,134 @@
|
|||
|
||||
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
|
||||
|
|
@ -17,302 +145,328 @@ struct SettingsViewData: View {
|
|||
@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, removeMovPkg }
|
||||
|
||||
@State private var showAlert = false
|
||||
|
||||
enum ActiveAlert {
|
||||
case eraseData, removeDocs, removeMovPkg
|
||||
}
|
||||
|
||||
@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)
|
||||
return ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
SettingsSection(
|
||||
title: "Cache Settings",
|
||||
footer: "Caching helps reduce network usage and load content faster. You can disable it to save storage space."
|
||||
) {
|
||||
SettingsToggleRow(
|
||||
icon: "doc.text",
|
||||
title: "Enable Metadata Caching",
|
||||
isOn: $isMetadataCachingEnabled
|
||||
)
|
||||
.onChange(of: isMetadataCachingEnabled) { newValue in
|
||||
MetadataCacheManager.shared.isCachingEnabled = newValue
|
||||
if !newValue {
|
||||
calculateCacheSize()
|
||||
}
|
||||
}
|
||||
|
||||
Toggle("Enable Image Caching", isOn: $isImageCachingEnabled)
|
||||
|
||||
SettingsToggleRow(
|
||||
icon: "photo",
|
||||
title: "Enable Image Caching",
|
||||
isOn: $isImageCachingEnabled
|
||||
)
|
||||
.onChange(of: isImageCachingEnabled) { newValue in
|
||||
KingfisherCacheManager.shared.isCachingEnabled = newValue
|
||||
if !newValue {
|
||||
calculateCacheSize()
|
||||
}
|
||||
}
|
||||
|
||||
if isMetadataCachingEnabled {
|
||||
Toggle("Memory-Only Mode", isOn: $isMemoryOnlyMode)
|
||||
|
||||
if isMetadataCachingEnabled {
|
||||
SettingsToggleRow(
|
||||
icon: "memorychip",
|
||||
title: "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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Current Metadata Cache Size")
|
||||
Spacer()
|
||||
if isCalculatingSize {
|
||||
ProgressView()
|
||||
.scaleEffect(0.7)
|
||||
.padding(.trailing, 5)
|
||||
}
|
||||
Text(cacheSizeText)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
VStack {
|
||||
Spacer()
|
||||
Spacer()
|
||||
Text("Current Metadata Cache Size")
|
||||
Spacer()
|
||||
|
||||
if isCalculatingSize {
|
||||
ProgressView()
|
||||
.scaleEffect(0.7)
|
||||
.padding(.trailing, 5)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
|
||||
Button(action: clearAllCaches) {
|
||||
Text("Clear All Caches")
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
|
||||
Button(action: clearAllCaches) {
|
||||
Text("Clear All Metadata Caches")
|
||||
.foregroundColor(.red)
|
||||
SettingsSection(
|
||||
title: "App Storage",
|
||||
footer: "The caches used by Sora are stored images that help load content faster\nThe 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: "trash",
|
||||
title: "Clear Cache",
|
||||
subtitle: formatSize(cacheSize),
|
||||
action: clearCache
|
||||
)
|
||||
Divider().padding(.horizontal, 16)
|
||||
|
||||
SettingsButtonRow(
|
||||
icon: "doc.text",
|
||||
title: "Remove All Files in Documents",
|
||||
subtitle: formatSize(documentsSize),
|
||||
action: {
|
||||
activeAlert = .removeDocs
|
||||
showAlert = true
|
||||
}
|
||||
)
|
||||
Divider().padding(.horizontal, 16)
|
||||
|
||||
SettingsButtonRow(
|
||||
icon: "arrow.down.circle",
|
||||
title: "Remove Downloads",
|
||||
subtitle: formatSize(movPkgSize),
|
||||
action: {
|
||||
showRemoveMovPkgAlert = true
|
||||
}
|
||||
)
|
||||
Divider().padding(.horizontal, 16)
|
||||
|
||||
SettingsButtonRow(
|
||||
icon: "exclamationmark.triangle",
|
||||
title: "Erase all App Data",
|
||||
action: {
|
||||
activeAlert = .eraseData
|
||||
showAlert = true
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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: {
|
||||
activeAlert = .removeDocs
|
||||
showAlert = 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: {
|
||||
activeAlert = .eraseData
|
||||
showAlert = 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: $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()
|
||||
)
|
||||
case .removeMovPkg:
|
||||
return 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")
|
||||
.navigationViewStyle(StackNavigationViewStyle())
|
||||
.onAppear {
|
||||
isMetadataCachingEnabled = MetadataCacheManager.shared.isCachingEnabled
|
||||
isImageCachingEnabled = KingfisherCacheManager.shared.isCachingEnabled
|
||||
isMemoryOnlyMode = MetadataCacheManager.shared.isMemoryOnlyMode
|
||||
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()
|
||||
)
|
||||
case .removeMovPkg:
|
||||
return Alert(
|
||||
title: Text("Remove Downloads"),
|
||||
message: Text("Are you sure you want to remove all Downloads?"),
|
||||
primaryButton: .destructive(Text("Remove")) {
|
||||
removeMovPkgFiles()
|
||||
},
|
||||
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 {
|
||||
var totalSize: Int64 = 0
|
||||
let metadataSize = MetadataCacheManager.shared.getCacheSize()
|
||||
totalSize += metadataSize
|
||||
|
||||
KingfisherCacheManager.shared.calculateCacheSize { imageSize in
|
||||
totalSize += Int64(imageSize)
|
||||
DispatchQueue.main.async {
|
||||
self.cacheSizeText = KingfisherCacheManager.formatCacheSize(UInt(totalSize))
|
||||
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 clearAllCaches() {
|
||||
MetadataCacheManager.shared.clearAllCache()
|
||||
KingfisherCacheManager.shared.clearCache {
|
||||
calculateCacheSize()
|
||||
}
|
||||
Logger.shared.log("All caches cleared", type: "General")
|
||||
}
|
||||
|
||||
func eraseAppData() {
|
||||
if let domain = Bundle.main.bundleIdentifier {
|
||||
UserDefaults.standard.removePersistentDomain(forName: domain)
|
||||
UserDefaults.standard.synchronize()
|
||||
Logger.shared.log("Cleared app data!", type: "General")
|
||||
exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
func clearCache() {
|
||||
let cacheURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
|
||||
do {
|
||||
if let cacheURL = cacheURL {
|
||||
let filePaths = try FileManager.default.contentsOfDirectory(at: cacheURL, includingPropertiesForKeys: nil, options: [])
|
||||
for filePath in filePaths {
|
||||
try FileManager.default.removeItem(at: filePath)
|
||||
}
|
||||
Logger.shared.log("Cache cleared successfully!", type: "General")
|
||||
calculateCacheSize()
|
||||
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)
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
} 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)
|
||||
}
|
||||
|
||||
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 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 where fileURL.pathExtension == "movpkg" {
|
||||
try fileManager.removeItem(at: fileURL)
|
||||
}
|
||||
Logger.shared.log("All Downloads files removed", type: "General")
|
||||
updateSizes()
|
||||
} catch {
|
||||
Logger.shared.log("Error removing Downloads files: $error)", type: "Error")
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Logger.shared.log("Error calculating MovPkg size: \(error)", type: "Error")
|
||||
}
|
||||
|
||||
return totalSize
|
||||
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) ?? "\(bytes) bytes"
|
||||
}
|
||||
|
||||
func updateSizes() {
|
||||
if let cacheURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first {
|
||||
cacheSize = calculateDirectorySize(for: cacheURL)
|
||||
}
|
||||
if let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first {
|
||||
documentsSize = calculateDirectorySize(for: documentsURL)
|
||||
movPkgSize = calculateMovPkgSize(in: documentsURL)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
} catch {
|
||||
Logger.shared.log("Error calculating MovPkg size: $error)", type: "Error")
|
||||
}
|
||||
return totalSize
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,147 @@
|
|||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsViewGeneral: View {
|
||||
@AppStorage("episodeChunkSize") private var episodeChunkSize: Int = 100
|
||||
@AppStorage("refreshModulesOnLaunch") private var refreshModulesOnLaunch: Bool = false
|
||||
|
|
@ -25,110 +166,131 @@ struct SettingsViewGeneral: View {
|
|||
@State private var showAppIconPicker = false
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("App Icon")
|
||||
Spacer()
|
||||
Button(action: {
|
||||
showAppIconPicker.toggle()
|
||||
}) {
|
||||
Text(currentAppIcon.isEmpty ? "Default" : currentAppIcon)
|
||||
.font(.body)
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
SettingsPickerRow(
|
||||
icon: "server.rack",
|
||||
title: "Metadata Provider",
|
||||
options: metadataProvidersList,
|
||||
optionToString: { $0 },
|
||||
selection: $metadataProviders,
|
||||
showDivider: false
|
||||
)
|
||||
}
|
||||
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())
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
.padding(.vertical, 20)
|
||||
}
|
||||
.navigationTitle("General")
|
||||
.scrollViewBottomPadding()
|
||||
.sheet(isPresented: $showAppIconPicker) {
|
||||
if #available(iOS 16.0, *) {
|
||||
SettingsViewAlternateAppIconPicker(isPresented: $showAppIconPicker)
|
||||
.presentationDetents([.height(200)])
|
||||
} else {
|
||||
SettingsViewAlternateAppIconPicker(isPresented: $showAppIconPicker)
|
||||
}
|
||||
SettingsViewAlternateAppIconPicker(isPresented: $showAppIconPicker)
|
||||
.presentationDetents([.height(200)])
|
||||
} else {
|
||||
SettingsViewAlternateAppIconPicker(isPresented: $showAppIconPicker)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,25 +6,81 @@
|
|||
//
|
||||
|
||||
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 = ""
|
||||
@StateObject private var filterViewModel = LogFilterViewModel.shared
|
||||
|
||||
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") {
|
||||
Text(logs)
|
||||
.font(.footnote)
|
||||
.foregroundColor(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 20)
|
||||
}
|
||||
.navigationTitle("Logs")
|
||||
.onAppear {
|
||||
logs = Logger.shared.getLogs()
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -20,105 +205,126 @@ struct SettingsViewPlayer: View {
|
|||
@AppStorage("skipIntroOutroVisible") private var skipIntroOutroVisible: Bool = true
|
||||
|
||||
private let mediaPlayers = ["Default", "Sora", "VLC", "OutPlayer", "Infuse", "nPlayer", "SenPlayer", "IINA"]
|
||||
private let inAppPlayers = ["Default", "Sora"]
|
||||
private let externalPlayers = ["VLC", "OutPlayer", "Infuse", "nPlayer", "SenPlayer", "IINA"]
|
||||
|
||||
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: 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 +334,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,96 @@ 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)
|
||||
|
||||
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 +114,172 @@ 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 {
|
||||
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)
|
||||
|
||||
if isAnilistLoading {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
} else if isAnilistLoggedIn {
|
||||
HStack(spacing: 0) {
|
||||
Text("Logged in as ")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.gray)
|
||||
Text(anilistUsername)
|
||||
.font(.footnote)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(profileColor)
|
||||
}
|
||||
} else {
|
||||
Text(anilistStatus)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.gray)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
}
|
||||
} 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 {
|
||||
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)
|
||||
|
||||
if isTraktLoading {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
} else if isTraktLoggedIn {
|
||||
HStack(spacing: 0) {
|
||||
Text("Logged in as ")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.gray)
|
||||
Text(traktUsername)
|
||||
.font(.footnote)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
} else {
|
||||
Text(traktStatus)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.gray)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
.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()
|
||||
|
|
|
|||
|
|
@ -7,101 +7,238 @@
|
|||
|
||||
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)
|
||||
|
||||
// MAIN SECTION
|
||||
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: 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")
|
||||
|
||||
// DATA/LOGS SECTION
|
||||
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")
|
||||
|
||||
// INFOS SECTION
|
||||
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")) {
|
||||
NavigationLink(destination: SettingsViewAbout()) {
|
||||
Text("About Sora")
|
||||
}
|
||||
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 \(version) - cranci1")) {}
|
||||
.scrollViewBottomPadding()
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
.deviceScaled()
|
||||
}
|
||||
.navigationViewStyle(StackNavigationViewStyle())
|
||||
.navigationBarHidden(true)
|
||||
.onChange(of: colorScheme) { newScheme in
|
||||
// Always update accent color when system color scheme changes
|
||||
if settings.selectedAppearance == .system {
|
||||
settings.updateAccentColor(currentColorScheme: newScheme)
|
||||
}
|
||||
}
|
||||
.onChange(of: settings.selectedAppearance) { _ in
|
||||
// Update accent color when appearance setting changes
|
||||
settings.updateAccentColor(currentColorScheme: colorScheme)
|
||||
}
|
||||
.onAppear {
|
||||
// Ensure accent color is correct when view appears
|
||||
settings.updateAccentColor(currentColorScheme: colorScheme)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -114,7 +251,6 @@ enum Appearance: String, CaseIterable, Identifiable {
|
|||
class Settings: ObservableObject {
|
||||
@Published var accentColor: Color {
|
||||
didSet {
|
||||
saveAccentColor(accentColor)
|
||||
}
|
||||
}
|
||||
@Published var selectedAppearance: Appearance {
|
||||
|
|
@ -125,12 +261,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
|
||||
|
|
@ -140,13 +271,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,9 +7,24 @@
|
|||
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 */; };
|
||||
130A810B2DE5F32400614732 /* WatchTogether.swift in Sources */ = {isa = PBXBuildFile; fileRef = 130A810A2DE5F32400614732 /* WatchTogether.swift */; };
|
||||
130C6BFA2D53AB1F00DC1432 /* SettingsViewData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 130C6BF92D53AB1F00DC1432 /* SettingsViewData.swift */; };
|
||||
13103E8B2D58E028000F0673 /* View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13103E8A2D58E028000F0673 /* View.swift */; };
|
||||
13103E8B2D58E028000F0673 /* ScrollViewBottomPadding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13103E8A2D58E028000F0673 /* ScrollViewBottomPadding.swift */; };
|
||||
13103E8E2D58E04A000F0673 /* SkeletonCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13103E8D2D58E04A000F0673 /* SkeletonCell.swift */; };
|
||||
131270172DC13A010093AA9C /* DownloadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 131270162DC13A010093AA9C /* DownloadManager.swift */; };
|
||||
131845F92D47C62D00CA7A54 /* SettingsViewGeneral.swift in Sources */ = {isa = PBXBuildFile; fileRef = 131845F82D47C62D00CA7A54 /* SettingsViewGeneral.swift */; };
|
||||
|
|
@ -89,10 +104,25 @@
|
|||
/* 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>"; };
|
||||
130A810A2DE5F32400614732 /* WatchTogether.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = WatchTogether.swift; path = Sora/Utils/MediaPlayer/WatchTogether.swift; sourceTree = SOURCE_ROOT; };
|
||||
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>"; };
|
||||
13103E8A2D58E028000F0673 /* ScrollViewBottomPadding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollViewBottomPadding.swift; sourceTree = "<group>"; };
|
||||
13103E8D2D58E04A000F0673 /* SkeletonCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SkeletonCell.swift; sourceTree = "<group>"; };
|
||||
131270162DC13A010093AA9C /* DownloadManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadManager.swift; sourceTree = "<group>"; };
|
||||
131845F82D47C62D00CA7A54 /* SettingsViewGeneral.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewGeneral.swift; sourceTree = "<group>"; };
|
||||
|
|
@ -184,6 +214,61 @@
|
|||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
0402DA122DE7B5EC003BB42C /* SearchView */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
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 = (
|
||||
|
|
@ -264,6 +349,7 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
72443C7C2DC8036500A61321 /* DownloadView.swift */,
|
||||
0402DA122DE7B5EC003BB42C /* SearchView */,
|
||||
133D7C7F2D2BE2630075467E /* MediaInfoView */,
|
||||
1399FAD22D3AB34F00E97C31 /* SettingsView */,
|
||||
133F55B92D33B53E00E08EEA /* LibraryView */,
|
||||
|
|
@ -301,6 +387,10 @@
|
|||
133D7C852D2BE2640075467E /* Utils */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
0457C5962DE7712A000AFBD9 /* ViewModifiers */,
|
||||
04F08EE02DE10C22006B29D9 /* Models */,
|
||||
04F08EDD2DE10C05006B29D9 /* TabBar */,
|
||||
04F08EDA2DE10BE3006B29D9 /* ProgressiveBlurView */,
|
||||
7205AEDA2DCCEF9500943F3F /* Cache */,
|
||||
13D842532D45266900EBBFA6 /* Drops */,
|
||||
1399FAD12D3AB33D00E97C31 /* Logger */,
|
||||
|
|
@ -326,7 +416,7 @@
|
|||
133D7C872D2BE2640075467E /* URLSession.swift */,
|
||||
1359ED132D76F49900C13034 /* finTopView.swift */,
|
||||
13CBEFD92D5F7D1200D011EE /* String.swift */,
|
||||
13103E8A2D58E028000F0673 /* View.swift */,
|
||||
13103E8A2D58E028000F0673 /* ScrollViewBottomPadding.swift */,
|
||||
13DB468F2D900A38008CBC03 /* URL.swift */,
|
||||
);
|
||||
path = Extensions;
|
||||
|
|
@ -358,6 +448,9 @@
|
|||
133F55B92D33B53E00E08EEA /* LibraryView */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
0457C59C2DE78267000AFBD9 /* BookmarkComponents */,
|
||||
04CD76DA2DE20F2200733536 /* AllWatching.swift */,
|
||||
04F08EE32DE10D6B006B29D9 /* AllBookmarks.swift */,
|
||||
133F55BA2D33B55100E08EEA /* LibraryManager.swift */,
|
||||
133D7C7E2D2BE2630075467E /* LibraryView.swift */,
|
||||
);
|
||||
|
|
@ -626,15 +719,20 @@
|
|||
1EF5C3A92DB988E40032BF07 /* CommunityLib.swift in Sources */,
|
||||
13CBEFDA2D5F7D1200D011EE /* String.swift in Sources */,
|
||||
7205AEDB2DCCEF9500943F3F /* EpisodeMetadata.swift in Sources */,
|
||||
04F08EDC2DE10BF3006B29D9 /* ProgressiveBlurView.swift in Sources */,
|
||||
7205AEDC2DCCEF9500943F3F /* MetadataCacheManager.swift in Sources */,
|
||||
7205AEDD2DCCEF9500943F3F /* KingfisherManager.swift in Sources */,
|
||||
1E26E9E72DA9577900B9DC02 /* VolumeSlider.swift in Sources */,
|
||||
136BBE802DB1038000906B5E /* Notification+Name.swift in Sources */,
|
||||
13DB46902D900A38008CBC03 /* URL.swift in Sources */,
|
||||
04F08EE42DE10D6F006B29D9 /* AllBookmarks.swift in Sources */,
|
||||
130C6BFA2D53AB1F00DC1432 /* SettingsViewData.swift in Sources */,
|
||||
0402DA172DE7B7B8003BB42C /* SearchViewComponents.swift in Sources */,
|
||||
1E9FF1D32D403E49008AC100 /* SettingsViewLoggerFilter.swift in Sources */,
|
||||
0402DA0E2DE7AA01003BB42C /* TabBarController.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 */,
|
||||
|
|
@ -642,11 +740,13 @@
|
|||
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 */,
|
||||
13103E8B2D58E028000F0673 /* ScrollViewBottomPadding.swift in Sources */,
|
||||
72443C7D2DC8036500A61321 /* DownloadView.swift in Sources */,
|
||||
1327FBA92D758DEA00FC6689 /* UIDevice+Model.swift in Sources */,
|
||||
138AA1B82D2D66FD0021F9DF /* EpisodeCell.swift in Sources */,
|
||||
|
|
@ -658,6 +758,7 @@
|
|||
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 */,
|
||||
|
|
@ -676,7 +777,11 @@
|
|||
727220722DD642B100C2A4A2 /* JSController+MP4Download.swift in Sources */,
|
||||
132FC5B32DE31DAE009A80F7 /* SettingsViewAlternateAppIconPicker.swift in Sources */,
|
||||
1EA64DCD2DE5030100AC14BC /* ImageUpscaler.swift in Sources */,
|
||||
0402DA132DE7B5EC003BB42C /* SearchStateView.swift in Sources */,
|
||||
0402DA142DE7B5EC003BB42C /* SearchResultsGrid.swift in Sources */,
|
||||
0402DA152DE7B5EC003BB42C /* SearchComponents.swift in Sources */,
|
||||
1399FAD42D3AB38C00E97C31 /* SettingsViewLogger.swift in Sources */,
|
||||
04F08EDF2DE10C1D006B29D9 /* TabBar.swift in Sources */,
|
||||
72AC3A012DD4DAEB00C60B96 /* ImagePrefetchManager.swift in Sources */,
|
||||
72AC3A022DD4DAEB00C60B96 /* EpisodeMetadataManager.swift in Sources */,
|
||||
72AC3A032DD4DAEB00C60B96 /* PerformanceMonitor.swift in Sources */,
|
||||
|
|
@ -686,6 +791,9 @@
|
|||
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;
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue