diff --git a/Sora/Assets.xcassets/AccentColor.colorset/Contents.json b/Sora/Assets.xcassets/AccentColor.colorset/Contents.json
index bb57667..0425637 100644
--- a/Sora/Assets.xcassets/AccentColor.colorset/Contents.json
+++ b/Sora/Assets.xcassets/AccentColor.colorset/Contents.json
@@ -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"
}
diff --git a/Sora/Assets.xcassets/AppIcons/AppIcon_Original.appiconset/Contents.json b/Sora/Assets.xcassets/AppIcons/AppIcon_Original.appiconset/Contents.json
index adc0a20..147bdb2 100644
--- a/Sora/Assets.xcassets/AppIcons/AppIcon_Original.appiconset/Contents.json
+++ b/Sora/Assets.xcassets/AppIcons/AppIcon_Original.appiconset/Contents.json
@@ -11,4 +11,4 @@
"author" : "xcode",
"version" : 1
}
-}
+}
\ No newline at end of file
diff --git a/Sora/Assets.xcassets/AppIcons/AppIcon_Pixel.appiconset/Contents.json b/Sora/Assets.xcassets/AppIcons/AppIcon_Pixel.appiconset/Contents.json
index c7a15f9..d5592ce 100644
--- a/Sora/Assets.xcassets/AppIcons/AppIcon_Pixel.appiconset/Contents.json
+++ b/Sora/Assets.xcassets/AppIcons/AppIcon_Pixel.appiconset/Contents.json
@@ -35,4 +35,4 @@
"author" : "xcode",
"version" : 1
}
-}
+}
\ No newline at end of file
diff --git a/Sora/ContentView.swift b/Sora/ContentView.swift
index 8fa65bb..8238bd1 100644
--- a/Sora/ContentView.swift
+++ b/Sora/ContentView.swift
@@ -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)
}
}
diff --git a/Sora/Info.plist b/Sora/Info.plist
index 8758a4e..726f0ec 100644
--- a/Sora/Info.plist
+++ b/Sora/Info.plist
@@ -6,6 +6,110 @@
$(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleIcons
+
+ CFBundleAlternateIcons
+
+ AppIcon_Original
+
+ CFBundleIconFiles
+
+ AppIcon_Original
+
+
+ AppIcon_Pixel
+
+ CFBundleIconFiles
+
+ AppIcon_Pixel
+
+
+ AppIcon_SoraAlt
+
+ CFBundleIconFiles
+
+ AppIcon_SoraAlt
+
+
+ AppIcon_CiroGold
+
+ CFBundleIconFiles
+
+ AppIcon_CiroGold
+
+
+ AppIcon_CiroChrome
+
+ CFBundleIconFiles
+
+ AppIcon_CiroChrome
+
+
+ AppIcon_CiroGoldTwo
+
+ CFBundleIconFiles
+
+ AppIcon_CiroGoldTwo
+
+
+ AppIcon_CiroGoldThree
+
+ CFBundleIconFiles
+
+ AppIcon_CiroGoldThree
+
+
+ AppIcon_CiroPink
+
+ CFBundleIconFiles
+
+ AppIcon_CiroPink
+
+
+ AppIcon_CiroPurple
+
+ CFBundleIconFiles
+
+ AppIcon_CiroPurple
+
+
+ AppIcon_CiroRed
+
+ CFBundleIconFiles
+
+ AppIcon_CiroRed
+
+
+ AppIcon_CiroRoseGold
+
+ CFBundleIconFiles
+
+ AppIcon_CiroRoseGold
+
+
+ AppIcon_CiroSilver
+
+ CFBundleIconFiles
+
+ AppIcon_CiroSilver
+
+
+ AppIcon_CiroSilverTwo
+
+ CFBundleIconFiles
+
+ AppIcon_CiroSilverTwo
+
+
+ AppIcon_CiroYellow
+
+ CFBundleIconFiles
+
+ AppIcon_CiroYellow
+
+
+
+
CFBundleURLTypes
diff --git a/Sora/Models/EpisodeLink.swift b/Sora/Models/EpisodeLink.swift
new file mode 100644
index 0000000..b0aae5f
--- /dev/null
+++ b/Sora/Models/EpisodeLink.swift
@@ -0,0 +1,7 @@
+struct EpisodeLink: Identifiable {
+ let id = UUID()
+ let number: Int
+ let title: String
+ let href: String
+ let duration: Int?
+}
\ No newline at end of file
diff --git a/Sora/SoraApp.swift b/Sora/SoraApp.swift
index 37ba4c1..c87ed45 100644
--- a/Sora/SoraApp.swift
+++ b/Sora/SoraApp.swift
@@ -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()
+ }
+}
diff --git a/Sora/Utils/ContinueWatching/ContinueWatchingManager.swift b/Sora/Utils/ContinueWatching/ContinueWatchingManager.swift
index bfda215..828e31f 100644
--- a/Sora/Utils/ContinueWatching/ContinueWatchingManager.swift
+++ b/Sora/Utils/ContinueWatching/ContinueWatchingManager.swift
@@ -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 = []
+
+ 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..= 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 }
diff --git a/Sora/Utils/Extensions/ScrollViewBottomPadding.swift b/Sora/Utils/Extensions/ScrollViewBottomPadding.swift
new file mode 100644
index 0000000..f5d6965
--- /dev/null
+++ b/Sora/Utils/Extensions/ScrollViewBottomPadding.swift
@@ -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())
+ }
+}
diff --git a/Sora/Utils/Extensions/View.swift b/Sora/Utils/Extensions/View.swift
deleted file mode 100644
index 7c9d375..0000000
--- a/Sora/Utils/Extensions/View.swift
+++ /dev/null
@@ -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())
- }
-}
diff --git a/Sora/Utils/JSLoader/JSController-Details.swift b/Sora/Utils/JSLoader/JSController-Details.swift
index 8ba89b9..51c0b8d 100644
--- a/Sora/Utils/JSLoader/JSController-Details.swift
+++ b/Sora/Utils/JSLoader/JSController-Details.swift
@@ -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?
+}
diff --git a/Sora/Utils/JSLoader/JSController.swift b/Sora/Utils/JSLoader/JSController.swift
index c50eb0d..d0d9d2e 100644
--- a/Sora/Utils/JSLoader/JSController.swift
+++ b/Sora/Utils/JSLoader/JSController.swift
@@ -79,3 +79,5 @@ class JSController: NSObject, ObservableObject {
}
}
}
+
+
diff --git a/Sora/Utils/Logger/Logger.swift b/Sora/Utils/Logger/Logger.swift
index 7749132..8781fc1 100644
--- a/Sora/Utils/Logger/Logger.swift
+++ b/Sora/Utils/Logger/Logger.swift
@@ -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.. 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 = []
+ }
+ }
+}
diff --git a/Sora/Utils/TabBar/TabBar.swift b/Sora/Utils/TabBar/TabBar.swift
new file mode 100644
index 0000000..b009a20
--- /dev/null
+++ b/Sora/Utils/TabBar/TabBar.swift
@@ -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.. 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
+ )
+ }
+}
diff --git a/Sora/Utils/TabBar/TabBarController.swift b/Sora/Utils/TabBar/TabBarController.swift
new file mode 100644
index 0000000..cf4d95c
--- /dev/null
+++ b/Sora/Utils/TabBar/TabBarController.swift
@@ -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
+ }
+ }
+}
diff --git a/Sora/Utils/ViewModifiers/DeviceScaleModifier.swift b/Sora/Utils/ViewModifiers/DeviceScaleModifier.swift
new file mode 100644
index 0000000..97b6969
--- /dev/null
+++ b/Sora/Utils/ViewModifiers/DeviceScaleModifier.swift
@@ -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())
+ }
+}
diff --git a/Sora/Views/DownloadView.swift b/Sora/Views/DownloadView.swift
index f956e6e..556f62f 100644
--- a/Sora/Views/DownloadView.swift
+++ b/Sora/Views/DownloadView.swift
@@ -16,6 +16,7 @@ struct DownloadView: View {
@State private var sortOption: SortOption = .newest
@State private var showDeleteAlert = false
@State private var assetToDelete: DownloadedAsset?
+ @State private var isSearchActive = false
enum SortOption: String, CaseIterable, Identifiable {
case newest = "Newest"
@@ -23,38 +24,39 @@ struct DownloadView: View {
case title = "Title"
var id: String { self.rawValue }
+
+ var systemImage: String {
+ switch self {
+ case .newest: return "calendar.badge.clock"
+ case .oldest: return "calendar"
+ case .title: return "textformat.abc"
+ }
+ }
}
var body: some View {
NavigationView {
VStack(spacing: 0) {
- Picker("Download Status", selection: $selectedTab) {
- Text("Active").tag(0)
- Text("Downloaded").tag(1)
- }
- .pickerStyle(SegmentedPickerStyle())
- .padding(.horizontal)
- .padding(.top, 8)
+ Spacer()
+ .frame(height: 20)
+ CustomDownloadHeader(
+ selectedTab: $selectedTab,
+ searchText: $searchText,
+ isSearchActive: $isSearchActive,
+ sortOption: $sortOption,
+ showSortMenu: selectedTab == 1 && !jsController.savedAssets.isEmpty
+ )
if selectedTab == 0 {
activeDownloadsView
+ .transition(.opacity)
} else {
downloadedContentView
+ .transition(.opacity)
}
}
- .navigationTitle("Downloads")
- .toolbar {
- if selectedTab == 1 && !jsController.savedAssets.isEmpty {
- Menu {
- Button("Sort by Newest") { sortOption = .newest }
- Button("Sort by Oldest") { sortOption = .oldest }
- Button("Sort by Title") { sortOption = .title }
- } label: {
- Image(systemName: "arrow.up.arrow.down")
- }
- }
- }
- .searchable(text: $searchText, prompt: "Search downloads")
+ .animation(.easeInOut(duration: 0.2), value: selectedTab)
+ .navigationBarHidden(true)
.alert("Delete Download", isPresented: $showDeleteAlert) {
Button("Delete", role: .destructive) {
if let asset = assetToDelete {
@@ -68,6 +70,7 @@ struct DownloadView: View {
}
}
}
+ .deviceScaled()
}
private var activeDownloadsView: some View {
@@ -76,18 +79,25 @@ struct DownloadView: View {
emptyActiveDownloadsView
} else {
ScrollView {
- LazyVStack(spacing: 12) {
- ForEach(jsController.downloadQueue) { download in
- ActiveDownloadCard(download: download)
- .padding(.horizontal)
+ VStack(spacing: 20) {
+ if !jsController.downloadQueue.isEmpty {
+ DownloadSectionView(
+ title: "Queue",
+ icon: "clock.fill",
+ downloads: jsController.downloadQueue
+ )
}
- ForEach(jsController.activeDownloads) { download in
- ActiveDownloadCard(download: download)
- .padding(.horizontal)
+ if !jsController.activeDownloads.isEmpty {
+ DownloadSectionView(
+ title: "Active Downloads",
+ icon: "arrow.down.circle.fill",
+ downloads: jsController.activeDownloads
+ )
}
}
- .padding(.vertical)
+ .padding(.top, 20)
+ .scrollViewBottomPadding()
}
}
}
@@ -99,64 +109,71 @@ struct DownloadView: View {
emptyDownloadsView
} else {
ScrollView {
- LazyVStack(spacing: 12) {
- ForEach(groupedAssets, id: \.title) { group in
- DownloadGroupCard(
- group: group,
- onDelete: { asset in
- assetToDelete = asset
- showDeleteAlert = true
- },
- onPlay: playAsset
- )
- .padding(.horizontal)
- }
+ VStack(spacing: 20) {
+ DownloadSummaryCard(
+ totalShows: groupedAssets.count,
+ totalEpisodes: filteredAndSortedAssets.count,
+ totalSize: filteredAndSortedAssets.reduce(0) { $0 + $1.fileSize }
+ )
+
+ DownloadedSection(
+ groups: groupedAssets,
+ onDelete: { asset in
+ assetToDelete = asset
+ showDeleteAlert = true
+ },
+ onPlay: playAsset
+ )
}
- .padding(.vertical)
+ .padding(.top, 20)
+ .scrollViewBottomPadding()
}
}
}
}
private var emptyActiveDownloadsView: some View {
- VStack {
+ VStack(spacing: 20) {
Image(systemName: "arrow.down.circle")
- .font(.system(size: 60))
- .foregroundColor(.gray)
- .padding(4)
+ .font(.system(size: 64, weight: .ultraLight))
+ .foregroundStyle(.tertiary)
- Text("No Active Downloads")
- .font(.title2)
- .foregroundColor(.gray)
- .padding(3)
-
- Text("Download episodes from the episode list")
- .font(.subheadline)
- .foregroundColor(.gray)
- .multilineTextAlignment(.center)
-
+ VStack(spacing: 8) {
+ Text("No Active Downloads")
+ .font(.title2)
+ .fontWeight(.medium)
+ .foregroundStyle(.primary)
+
+ Text("Actively downloading media can be tracked from here.")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ }
}
- .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
+ .padding(.horizontal, 40)
}
private var emptyDownloadsView: some View {
- VStack {
+ VStack(spacing: 20) {
Image(systemName: "arrow.down.circle")
- .font(.system(size: 60))
- .foregroundColor(.gray)
- .padding(4)
+ .font(.system(size: 64, weight: .ultraLight))
+ .foregroundStyle(.tertiary)
- Text("No Downloads")
- .font(.title2)
- .foregroundColor(.gray)
- .padding(3)
-
- Text("Your downloaded episodes will appear here")
- .font(.subheadline)
- .foregroundColor(.gray)
- .multilineTextAlignment(.center)
+ VStack(spacing: 8) {
+ Text("No Downloads")
+ .font(.title2)
+ .fontWeight(.medium)
+ .foregroundStyle(.primary)
+
+ Text("Your downloaded episodes will appear here")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ }
}
- .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
+ .padding(.horizontal, 40)
}
private var filteredAndSortedAssets: [DownloadedAsset] {
@@ -259,6 +276,228 @@ struct DownloadView: View {
}
}
+struct CustomDownloadHeader: View {
+ @Binding var selectedTab: Int
+ @Binding var searchText: String
+ @Binding var isSearchActive: Bool
+ @Binding var sortOption: DownloadView.SortOption
+ let showSortMenu: Bool
+
+ var body: some View {
+ VStack(spacing: 0) {
+ HStack {
+ Text("Downloads")
+ .font(.largeTitle)
+ .fontWeight(.bold)
+ .foregroundStyle(.primary)
+
+ Spacer()
+
+ HStack(spacing: 16) {
+ Button(action: {
+ withAnimation(.easeInOut(duration: 0.3)) {
+ isSearchActive.toggle()
+ }
+ if !isSearchActive {
+ searchText = ""
+ }
+ }) {
+ Image(systemName: isSearchActive ? "xmark.circle.fill" : "magnifyingglass")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 24, height: 24)
+ .foregroundColor(.accentColor)
+ .padding(6)
+ .background(
+ Circle()
+ .fill(Color.gray.opacity(0.2))
+ .shadow(color: .accentColor.opacity(0.2), radius: 2)
+ )
+ .overlay(
+ 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
+ )
+ .frame(width: 32, height: 32)
+ )
+ }
+
+ if showSortMenu {
+ Menu {
+ ForEach(DownloadView.SortOption.allCases) { option in
+ Button(action: { sortOption = option }) {
+ HStack {
+ Image(systemName: option.systemImage)
+ Text(option.rawValue)
+ if sortOption == option {
+ Spacer()
+ Image(systemName: "checkmark")
+ }
+ }
+ }
+ }
+ } label: {
+ Image(systemName: "arrow.up.arrow.down")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 24, height: 24)
+ .foregroundColor(.accentColor)
+ .padding(6)
+ .background(
+ Circle()
+ .fill(Color.gray.opacity(0.2))
+ .shadow(color: .accentColor.opacity(0.2), radius: 2)
+ )
+ .overlay(
+ 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
+ )
+ )
+ }
+ }
+ }
+ }
+ .padding(.horizontal, 20)
+ .padding(.top, 2)
+ .padding(.bottom, isSearchActive ? 12 : 8)
+
+ if isSearchActive {
+ HStack(spacing: 12) {
+ HStack(spacing: 12) {
+ Image(systemName: "magnifyingglass")
+ .foregroundColor(.secondary)
+ .font(.body)
+
+ TextField("Search downloads", text: $searchText)
+ .textFieldStyle(PlainTextFieldStyle())
+ .foregroundColor(.primary)
+
+ if !searchText.isEmpty {
+ Button(action: {
+ searchText = ""
+ }) {
+ Image(systemName: "xmark.circle.fill")
+ .foregroundColor(.secondary)
+ .font(.body)
+ }
+ }
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 12)
+ .background(.ultraThinMaterial)
+ .clipShape(RoundedRectangle(cornerRadius: 12))
+ .overlay(
+ RoundedRectangle(cornerRadius: 12)
+ .strokeBorder(
+ 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: 1.5
+ )
+ )
+ }
+ .padding(.horizontal, 20)
+ .padding(.bottom, 16)
+ .transition(.asymmetric(
+ insertion: .move(edge: .top).combined(with: .opacity),
+ removal: .move(edge: .top).combined(with: .opacity)
+ ))
+ }
+
+ VStack(spacing: 0) {
+ HStack(spacing: 0) {
+ TabButton(
+ title: "Active",
+ icon: "arrow.down.circle",
+ isSelected: selectedTab == 0,
+ action: { selectedTab = 0 }
+ )
+
+ TabButton(
+ title: "Downloaded",
+ icon: "checkmark.circle",
+ isSelected: selectedTab == 1,
+ action: { selectedTab = 1 }
+ )
+ }
+ .padding(.horizontal, 20)
+ .padding(.vertical, 16)
+ }
+ }
+ }
+}
+
+struct TabButton: View {
+ let title: String
+ let icon: String
+ let isSelected: Bool
+ let action: () -> Void
+
+ var body: some View {
+ Button(action: action) {
+ HStack(spacing: 8) {
+ Image(systemName: icon)
+ .font(.body)
+ .foregroundColor(isSelected ? .accentColor : .secondary)
+ Text(title)
+ .font(.body)
+ .fontWeight(isSelected ? .semibold : .regular)
+ .foregroundColor(isSelected ? .accentColor : .secondary)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 12)
+ .padding(.horizontal, 16)
+ .background(
+ RoundedRectangle(cornerRadius: 12)
+ .fill(isSelected ? Color.accentColor.opacity(0.1) : Color.clear)
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: 12)
+ .strokeBorder(
+ isSelected
+ ? AnyShapeStyle(
+ 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
+ )
+ )
+ : AnyShapeStyle(Color.clear),
+ lineWidth: 1.5
+ )
+ )
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(PlainButtonStyle())
+ .animation(.easeInOut(duration: 0.2), value: isSelected)
+ }
+}
+
struct SimpleDownloadGroup {
let title: String
let assets: [DownloadedAsset]
@@ -270,7 +509,222 @@ struct SimpleDownloadGroup {
}
}
-struct ActiveDownloadCard: View {
+struct DownloadSectionView: View {
+ let title: String
+ let icon: String
+ let downloads: [JSActiveDownload]
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ HStack {
+ Image(systemName: icon)
+ .foregroundColor(.accentColor)
+ Text(title.uppercased())
+ .font(.footnote)
+ .fontWeight(.medium)
+ .foregroundColor(.secondary)
+ }
+ .padding(.horizontal, 20)
+
+ VStack(spacing: 8) {
+ ForEach(downloads) { download in
+ EnhancedActiveDownloadCard(download: download)
+ }
+ }
+ .background(.ultraThinMaterial)
+ .clipShape(RoundedRectangle(cornerRadius: 16))
+ .overlay(
+ RoundedRectangle(cornerRadius: 16)
+ .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)
+ }
+ }
+}
+
+struct DownloadSummaryCard: View {
+ let totalShows: Int
+ let totalEpisodes: Int
+ let totalSize: Int64
+
+ var body: some View {
+ HStack {
+ Image(systemName: "chart.bar.fill")
+ .foregroundColor(.accentColor)
+ Text("Download Summary".uppercased())
+ .font(.footnote)
+ .fontWeight(.medium)
+ .foregroundColor(.secondary)
+ Spacer()
+ }
+ .padding(.horizontal, 20)
+ .padding(.bottom, -6)
+
+ VStack(alignment: .leading, spacing: 12) {
+ HStack(spacing: 20) {
+ SummaryItem(
+ title: "Shows",
+ value: "\(totalShows)",
+ icon: "tv.fill"
+ )
+
+ Divider().frame(height: 32)
+
+ SummaryItem(
+ title: "Episodes",
+ value: "\(totalEpisodes)",
+ icon: "play.rectangle.fill"
+ )
+
+ Divider().frame(height: 32)
+
+ let formattedSize = formatFileSize(totalSize)
+ let components = formattedSize.split(separator: " ")
+ let sizeValue = components.first.map(String.init) ?? formattedSize
+ let sizeUnit = components.dropFirst().first.map(String.init) ?? ""
+
+ SummaryItem(
+ title: "Size (\(sizeUnit))",
+ value: sizeValue,
+ icon: "internaldrive.fill"
+ )
+ }
+ }
+ .padding(.horizontal, 20)
+ .padding(.vertical, 12)
+ .background(.ultraThinMaterial)
+ .clipShape(RoundedRectangle(cornerRadius: 16))
+ .overlay(
+ RoundedRectangle(cornerRadius: 16)
+ .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)
+ }
+
+ private func formatFileSize(_ size: Int64) -> String {
+ let formatter = ByteCountFormatter()
+ formatter.allowedUnits = [.useKB, .useMB, .useGB]
+ formatter.countStyle = .file
+ return formatter.string(fromByteCount: size)
+ }
+}
+
+
+ private func formatFileSize(_ size: Int64) -> String {
+ let formatter = ByteCountFormatter()
+ formatter.allowedUnits = [.useKB, .useMB, .useGB]
+ formatter.countStyle = .file
+ return formatter.string(fromByteCount: size)
+ }
+
+ private func formatFileSizeWithUnit(_ size: Int64) -> String {
+ let formatter = ByteCountFormatter()
+ formatter.allowedUnits = [.useKB, .useMB, .useGB]
+ formatter.countStyle = .file
+
+ let formattedString = formatter.string(fromByteCount: size)
+ let components = formattedString.components(separatedBy: " ")
+ if components.count == 2 {
+ return "Size (\(components[1]))"
+ }
+ return "Size"
+ }
+
+
+struct SummaryItem: View {
+ let title: String
+ let value: String
+ let icon: String
+
+ var body: some View {
+ VStack(spacing: 8) {
+ Image(systemName: icon)
+ .font(.title2)
+ .foregroundColor(.accentColor)
+
+ if !value.isEmpty {
+ Text(value)
+ .font(.title3)
+ .fontWeight(.semibold)
+ .foregroundStyle(.primary)
+ }
+
+ Text(title)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ .frame(maxWidth: .infinity)
+ }
+}
+
+struct DownloadedSection: View {
+ let groups: [SimpleDownloadGroup]
+ let onDelete: (DownloadedAsset) -> Void
+ let onPlay: (DownloadedAsset) -> Void
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ HStack {
+ Image(systemName: "folder.fill")
+ .foregroundColor(.accentColor)
+ Text("Downloaded Shows".uppercased())
+ .font(.footnote)
+ .fontWeight(.medium)
+ .foregroundColor(.secondary)
+ }
+ .padding(.horizontal, 20)
+
+ VStack(spacing: 8) {
+ ForEach(groups, id: \.title) { group in
+ EnhancedDownloadGroupCard(
+ group: group,
+ onDelete: onDelete,
+ onPlay: onPlay
+ )
+ }
+ }
+ .background(.ultraThinMaterial)
+ .clipShape(RoundedRectangle(cornerRadius: 16))
+ .overlay(
+ RoundedRectangle(cornerRadius: 16)
+ .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)
+ }
+ }
+}
+
+struct EnhancedActiveDownloadCard: View {
let download: JSActiveDownload
@State private var currentProgress: Double
@State private var taskState: URLSessionTask.State
@@ -282,98 +736,133 @@ struct ActiveDownloadCard: View {
}
var body: some View {
- HStack(spacing: 12) {
- if let imageURL = download.imageURL {
- KFImage(imageURL)
- .placeholder {
- Rectangle()
- .fill(Color.gray.opacity(0.3))
- }
- .resizable()
- .aspectRatio(contentMode: .fill)
- .frame(width: 60, height: 60)
- .cornerRadius(8)
- } else {
- Rectangle()
- .fill(Color.gray.opacity(0.3))
- .frame(width: 60, height: 60)
- .cornerRadius(8)
- }
-
- VStack(alignment: .leading, spacing: 6) {
- Text(download.title ?? download.originalURL.lastPathComponent)
- .font(.headline)
- .lineLimit(1)
-
- VStack(alignment: .leading, spacing: 4) {
- if download.queueStatus == .queued {
- ProgressView()
- .progressViewStyle(LinearProgressViewStyle())
- .tint(.orange)
+ VStack(spacing: 0) {
+ HStack(spacing: 16) {
+ // Thumbnail
+ Group {
+ if let imageURL = download.imageURL {
+ KFImage(imageURL)
+ .placeholder {
+ Rectangle()
+ .fill(.tertiary)
+ }
+ .resizable()
+ .aspectRatio(contentMode: .fill)
} else {
- ProgressView(value: currentProgress)
- .progressViewStyle(LinearProgressViewStyle())
- .tint(currentProgress >= 1.0 ? .green : .blue)
+ Rectangle()
+ .fill(.tertiary)
+ .overlay(
+ Image(systemName: "photo")
+ .foregroundStyle(.secondary)
+ )
}
+ }
+ .frame(width: 64, height: 64)
+ .clipShape(RoundedRectangle(cornerRadius: 8))
+
+ // Content
+ VStack(alignment: .leading, spacing: 8) {
+ Text(download.title ?? download.originalURL.lastPathComponent)
+ .font(.headline)
+ .fontWeight(.medium)
+ .lineLimit(2)
+ .foregroundStyle(.primary)
- HStack {
- if download.queueStatus == .queued {
- Text("Queued")
- .font(.caption)
- .foregroundColor(.orange)
- } else {
- Text("\(Int(currentProgress * 100))%")
- .font(.caption)
- .foregroundColor(.secondary)
+ // Progress Section
+ VStack(spacing: 6) {
+ HStack {
+ if download.queueStatus == .queued {
+ Text("Queued")
+ .font(.caption)
+ .fontWeight(.medium)
+ .foregroundStyle(.orange)
+ } else {
+ Text("\(Int(currentProgress * 100))%")
+ .font(.caption)
+ .fontWeight(.medium)
+ .foregroundStyle(.secondary)
+ }
+
+ Spacer()
+
+ HStack(spacing: 4) {
+ Circle()
+ .fill(statusColor)
+ .frame(width: 6, height: 6)
+
+ Text(statusText)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
}
- Spacer()
+ if download.queueStatus == .queued {
+ ProgressView()
+ .progressViewStyle(LinearProgressViewStyle(tint: .orange))
+ .scaleEffect(y: 0.8)
+ } else {
+ ProgressView(value: currentProgress)
+ .progressViewStyle(LinearProgressViewStyle(tint: currentProgress >= 1.0 ? .green : .accentColor))
+ .scaleEffect(y: 0.8)
+ }
+ }
+ }
+
+ // Controls
+ HStack(spacing: 12) {
+ if download.queueStatus == .queued {
+ Button(action: cancelDownload) {
+ Image(systemName: "xmark.circle.fill")
+ .font(.title3)
+ .foregroundStyle(.red)
+ }
+ } else {
+ Button(action: toggleDownload) {
+ Image(systemName: taskState == .running ? "pause.circle.fill" : "play.circle.fill")
+ .font(.title3)
+ .foregroundStyle(taskState == .running ? .orange : .accentColor)
+ }
- if taskState == .running {
- Text("Downloading")
- .font(.caption)
- .foregroundColor(.blue)
- } else if taskState == .suspended {
- Text("Paused")
- .font(.caption)
- .foregroundColor(.orange)
+ Button(action: cancelDownload) {
+ Image(systemName: "xmark.circle.fill")
+ .font(.title3)
+ .foregroundStyle(.red)
}
}
}
}
+ .padding(16)
- Spacer()
-
- HStack(spacing: 8) {
- if download.queueStatus == .queued {
- Button(action: cancelDownload) {
- Image(systemName: "xmark.circle.fill")
- .foregroundColor(.red)
- .font(.title2)
- }
- } else {
- Button(action: toggleDownload) {
- Image(systemName: taskState == .running ? "pause.circle.fill" : "play.circle.fill")
- .foregroundColor(taskState == .running ? .orange : .blue)
- .font(.title2)
- }
-
- Button(action: cancelDownload) {
- Image(systemName: "xmark.circle.fill")
- .foregroundColor(.red)
- .font(.title2)
- }
- }
+ if download != download { // Not last item (placeholder condition)
+ Divider()
+ .padding(.horizontal, 16)
}
}
- .padding()
- .background(Color(UIColor.secondarySystemBackground))
- .cornerRadius(12)
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("downloadProgressChanged"))) { _ in
updateProgress()
}
}
+ private var statusColor: Color {
+ if download.queueStatus == .queued {
+ return .orange
+ } else if taskState == .running {
+ return .green
+ } else {
+ return .orange
+ }
+ }
+
+ private var statusText: String {
+ if download.queueStatus == .queued {
+ return "Queued"
+ } else if taskState == .running {
+ return "Downloading"
+ } else {
+ return "Paused"
+ }
+ }
+
private func updateProgress() {
if let currentDownload = JSController.shared.activeDownloads.first(where: { $0.id == download.id }) {
withAnimation(.easeInOut(duration: 0.1)) {
@@ -404,53 +893,65 @@ struct ActiveDownloadCard: View {
}
}
-struct DownloadGroupCard: View {
+struct EnhancedDownloadGroupCard: View {
let group: SimpleDownloadGroup
let onDelete: (DownloadedAsset) -> Void
let onPlay: (DownloadedAsset) -> Void
var body: some View {
- NavigationLink(destination: ShowEpisodesView(group: group, onDelete: onDelete, onPlay: onPlay)) {
- HStack(spacing: 12) {
- if let posterURL = group.posterURL {
- KFImage(posterURL)
- .placeholder {
+ NavigationLink(destination: EnhancedShowEpisodesView(group: group, onDelete: onDelete, onPlay: onPlay)) {
+ VStack(spacing: 0) {
+ HStack(spacing: 16) {
+ // Poster
+ Group {
+ if let posterURL = group.posterURL {
+ KFImage(posterURL)
+ .placeholder {
+ Rectangle()
+ .fill(.tertiary)
+ }
+ .resizable()
+ .aspectRatio(contentMode: .fill)
+ } else {
Rectangle()
- .fill(Color.gray.opacity(0.3))
+ .fill(.tertiary)
+ .overlay(
+ Image(systemName: "tv")
+ .foregroundStyle(.secondary)
+ )
}
- .resizable()
- .aspectRatio(contentMode: .fill)
- .frame(width: 50, height: 75)
- .cornerRadius(6)
- } else {
- Rectangle()
- .fill(Color.gray.opacity(0.3))
- .frame(width: 50, height: 75)
- .cornerRadius(6)
- }
-
- VStack(alignment: .leading, spacing: 4) {
- Text(group.title)
- .font(.headline)
- .lineLimit(2)
+ }
+ .frame(width: 56, height: 84)
+ .clipShape(RoundedRectangle(cornerRadius: 8))
- Text("\(group.assetCount) Episodes")
- .font(.subheadline)
- .foregroundColor(.secondary)
+ // Content
+ VStack(alignment: .leading, spacing: 8) {
+ Text(group.title)
+ .font(.headline)
+ .fontWeight(.medium)
+ .lineLimit(2)
+ .foregroundStyle(.primary)
+
+ HStack(spacing: 16) {
+ Label("\(group.assetCount)", systemImage: "play.rectangle")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+
+ Label(formatFileSize(group.totalFileSize), systemImage: "internaldrive")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ }
- Text(formatFileSize(group.totalFileSize))
+ Spacer()
+
+ Image(systemName: "chevron.right")
.font(.caption)
- .foregroundColor(.secondary)
+ .foregroundStyle(.tertiary)
}
-
- Spacer()
-
- Image(systemName: "chevron.right")
- .foregroundColor(.gray)
- .font(.caption)
+ .padding(16)
+ .contentShape(Rectangle())
}
- .contentShape(Rectangle())
- .background(Color.clear)
}
.buttonStyle(PlainButtonStyle())
}
@@ -463,88 +964,7 @@ struct DownloadGroupCard: View {
}
}
-struct EpisodeRow: View {
- let asset: DownloadedAsset
- let onDelete: () -> Void
- let onPlay: () -> Void
-
- var body: some View {
- HStack(spacing: 12) {
- if let backdropURL = asset.metadata?.backdropURL {
- KFImage(backdropURL)
- .placeholder {
- Rectangle()
- .fill(Color.gray.opacity(0.3))
- }
- .resizable()
- .aspectRatio(contentMode: .fill)
- .frame(width: 60, height: 40)
- .cornerRadius(6)
- } else {
- Rectangle()
- .fill(Color.gray.opacity(0.3))
- .frame(width: 60, height: 40)
- .cornerRadius(6)
- }
-
- VStack(alignment: .leading, spacing: 2) {
- Text("Episode \(asset.metadata?.episode ?? 1)")
- .font(.subheadline)
- .lineLimit(1)
-
- let base = "Episode \(asset.metadata?.episode ?? 1)"
- if asset.episodeDisplayName != base {
- Text(asset.episodeDisplayName)
- .font(.caption)
- .foregroundColor(.secondary)
- .lineLimit(1)
- }
-
- HStack(spacing: 4) {
- Text(asset.downloadDate.formatted(date: .abbreviated, time: .omitted))
- .font(.caption)
- .foregroundColor(.secondary)
-
- if asset.localSubtitleURL != nil {
- Image(systemName: "captions.bubble")
- .foregroundColor(.blue)
- .font(.caption)
- }
- if !asset.fileExists {
- Image(systemName: "exclamationmark.triangle")
- .foregroundColor(.orange)
- .font(.caption)
- }
- }
- }
-
- Spacer()
-
- Button(action: onPlay) {
- Image(systemName: "play.circle.fill")
- .foregroundColor(asset.fileExists ? .blue : .gray)
- .font(.title3)
- }
- .disabled(!asset.fileExists)
- }
- .padding(.horizontal, 12)
- .padding(.vertical, 8)
- .background(Color(UIColor.tertiarySystemBackground))
- .cornerRadius(8)
- .contextMenu {
- Button(action: onPlay) {
- Label("Play", systemImage: "play.fill")
- }
- .disabled(!asset.fileExists)
-
- Button(role: .destructive, action: onDelete) {
- Label("Delete", systemImage: "trash")
- }
- }
- }
-}
-
-struct ShowEpisodesView: View {
+struct EnhancedShowEpisodesView: View {
let group: SimpleDownloadGroup
let onDelete: (DownloadedAsset) -> Void
let onPlay: (DownloadedAsset) -> Void
@@ -553,8 +973,8 @@ struct ShowEpisodesView: View {
@State private var assetToDelete: DownloadedAsset?
@EnvironmentObject var jsController: JSController
- @State private var episodeSortOption: EpisodeSortOption = .downloadDate
-
+ @State private var episodeSortOption: EpisodeSortOption = .episodeOrder
+
enum EpisodeSortOption: String, CaseIterable, Identifiable {
case downloadDate = "Download Date"
case episodeOrder = "Episode Order"
@@ -582,115 +1002,147 @@ struct ShowEpisodesView: View {
var body: some View {
ScrollView {
- VStack(spacing: 20) {
- HStack(alignment: .top, spacing: 16) {
- if let posterURL = group.posterURL {
- KFImage(posterURL)
- .placeholder {
+ VStack(spacing: 24) {
+ // Header Section
+ VStack(spacing: 20) {
+ HStack(alignment: .top, spacing: 20) {
+ Group {
+ if let posterURL = group.posterURL {
+ KFImage(posterURL)
+ .placeholder {
+ Rectangle()
+ .fill(.tertiary)
+ }
+ .resizable()
+ .aspectRatio(contentMode: .fill)
+ } else {
Rectangle()
- .fill(Color.gray.opacity(0.3))
+ .fill(.tertiary)
+ .overlay(
+ Image(systemName: "tv")
+ .font(.largeTitle)
+ .foregroundColor(.secondary)
+ )
}
- .resizable()
- .aspectRatio(contentMode: .fill)
- .frame(width: 120, height: 180)
- .cornerRadius(10)
- } else {
- Rectangle()
- .fill(Color.gray.opacity(0.3))
- .frame(width: 120, height: 180)
- .cornerRadius(10)
+ }
+ .frame(width: 120, height: 180)
+ .clipShape(RoundedRectangle(cornerRadius: 12))
+ .shadow(color: .black.opacity(0.1), radius: 8, x: 0, y: 4)
+
+ VStack(alignment: .leading, spacing: 12) {
+ Text(group.title)
+ .font(.title2)
+ .fontWeight(.bold)
+ .lineLimit(3)
+ .foregroundColor(.primary)
+
+ VStack(alignment: .leading, spacing: 8) {
+ HStack {
+ Image(systemName: "play.rectangle.fill")
+ .foregroundColor(.accentColor)
+ Text("\(group.assetCount) Episodes")
+ .font(.headline)
+ .foregroundColor(.secondary)
+ }
+
+ HStack {
+ Image(systemName: "internaldrive.fill")
+ .foregroundColor(.accentColor)
+ Text(formatFileSize(group.totalFileSize))
+ .font(.subheadline)
+ .foregroundColor(.secondary)
+ }
+ }
+
+ Spacer()
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
}
-
- VStack(alignment: .leading, spacing: 8) {
- Text(group.title)
- .font(.title2)
- .fontWeight(.bold)
- .lineLimit(3)
-
- Text("\(group.assetCount) Episodes")
- .font(.headline)
- .foregroundColor(.secondary)
-
- Text(formatFileSize(group.totalFileSize))
- .font(.subheadline)
- .foregroundColor(.secondary)
-
- Spacer()
- }
- .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal, 20)
}
- .padding(.horizontal)
+ .padding(.top, 20)
- VStack(alignment: .leading, spacing: 16) {
- HStack {
- Text("Episodes")
- .font(.title3)
- .fontWeight(.bold)
-
- Spacer()
-
- Menu {
- ForEach(EpisodeSortOption.allCases) { option in
- Button(action: {
- episodeSortOption = option
- }) {
- HStack {
- Text(option.rawValue)
- if episodeSortOption == option {
- Image(systemName: "checkmark")
+ // Episodes Section
+ VStack(spacing: 16) {
+ // Section Header
+ VStack(spacing: 12) {
+ HStack {
+ Image(systemName: "list.bullet.rectangle")
+ .foregroundColor(.accentColor)
+ Text("Episodes".uppercased())
+ .font(.footnote)
+ .fontWeight(.medium)
+ .foregroundColor(.secondary)
+
+ Spacer()
+
+ Menu {
+ ForEach(EpisodeSortOption.allCases) { option in
+ Button(action: {
+ episodeSortOption = option
+ }) {
+ HStack {
+ Image(systemName: option.systemImage)
+ Text(option.rawValue)
+ if episodeSortOption == option {
+ Spacer()
+ Image(systemName: "checkmark")
+ }
}
}
}
- }
- } label: {
- HStack(spacing: 4) {
- Image(systemName: episodeSortOption.systemImage)
- Text("Sort")
- }
- .font(.subheadline)
- .foregroundColor(.blue)
- }
-
- Button(action: {
- showDeleteAllAlert = true
- }) {
- Label("Delete All", systemImage: "trash")
- .foregroundColor(.red)
+ } label: {
+ HStack(spacing: 4) {
+ Image(systemName: episodeSortOption.systemImage)
+ Text("Sort")
+ }
.font(.subheadline)
+ .foregroundColor(.accentColor)
+ }
+
+ Button(action: {
+ showDeleteAllAlert = true
+ }) {
+ HStack(spacing: 4) {
+ Image(systemName: "trash")
+ Text("Delete All")
+ }
+ .font(.subheadline)
+ .foregroundColor(.red)
+ }
}
+ .padding(.horizontal, 20)
}
- .padding(.horizontal)
+ // Episodes List
if group.assets.isEmpty {
Text("No episodes available")
- .foregroundColor(.gray)
+ .foregroundColor(.secondary)
.italic()
- .padding()
- .frame(maxWidth: .infinity, alignment: .center)
+ .padding(40)
} else {
- LazyVStack(spacing: 8) {
- ForEach(sortedEpisodes) { asset in
- DetailedEpisodeRow(asset: asset)
- .padding(.horizontal)
- .background(Color(UIColor.secondarySystemBackground))
- .cornerRadius(10)
- .padding(.horizontal)
- .contextMenu {
- Button(action: { onPlay(asset) }) {
- Label("Play", systemImage: "play.fill")
- }
- .disabled(!asset.fileExists)
-
- Button(role: .destructive, action: {
- assetToDelete = asset
- showDeleteAlert = true
- }) {
- Label("Delete", systemImage: "trash")
- }
+ VStack(spacing: 8) {
+ ForEach(Array(sortedEpisodes.enumerated()), id: \.element.id) { index, asset in
+ EnhancedEpisodeRow(
+ asset: asset,
+ showDivider: index < sortedEpisodes.count - 1
+ )
+ .contextMenu {
+ Button(action: { onPlay(asset) }) {
+ Label("Play", systemImage: "play.fill")
}
- .onTapGesture {
- onPlay(asset)
+ .disabled(!asset.fileExists)
+
+ Button(role: .destructive, action: {
+ assetToDelete = asset
+ showDeleteAlert = true
+ }) {
+ Label("Delete", systemImage: "trash")
}
+ }
+ .onTapGesture {
+ onPlay(asset)
+ }
}
}
}
@@ -736,63 +1188,79 @@ struct ShowEpisodesView: View {
}
}
-struct DetailedEpisodeRow: View {
+struct EnhancedEpisodeRow: View {
let asset: DownloadedAsset
+ let showDivider: Bool
var body: some View {
- HStack(spacing: 12) {
- if let backdropURL = asset.metadata?.backdropURL ?? asset.metadata?.posterURL {
- KFImage(backdropURL)
- .placeholder {
+ VStack(spacing: 0) {
+ HStack(spacing: 16) {
+ // Thumbnail
+ Group {
+ if let backdropURL = asset.metadata?.backdropURL ?? asset.metadata?.posterURL {
+ KFImage(backdropURL)
+ .placeholder {
+ Rectangle()
+ .fill(.tertiary)
+ }
+ .resizable()
+ .aspectRatio(contentMode: .fill)
+ } else {
Rectangle()
- .fill(Color.gray.opacity(0.3))
+ .fill(.tertiary)
+ .overlay(
+ Image(systemName: "photo")
+ .foregroundColor(.secondary)
+ )
}
- .resizable()
- .aspectRatio(contentMode: .fill)
- .frame(width: 100, height: 60)
- .cornerRadius(8)
- } else {
- Rectangle()
- .fill(Color.gray.opacity(0.3))
- .frame(width: 100, height: 60)
- .cornerRadius(8)
- }
-
- VStack(alignment: .leading, spacing: 4) {
- Text(asset.episodeDisplayName)
- .font(.headline)
- .lineLimit(2)
+ }
+ .frame(width: 100, height: 60)
+ .clipShape(RoundedRectangle(cornerRadius: 8))
- Text(formatFileSize(asset.fileSize))
- .font(.caption)
- .foregroundColor(.secondary)
-
- HStack(spacing: 6) {
- Text(asset.downloadDate.formatted(date: .abbreviated, time: .shortened))
+ // Content
+ VStack(alignment: .leading, spacing: 4) {
+ Text(asset.episodeDisplayName)
+ .font(.headline)
+ .lineLimit(2)
+ .foregroundColor(.primary)
+
+ Text(formatFileSize(asset.fileSize))
.font(.caption)
.foregroundColor(.secondary)
- if asset.localSubtitleURL != nil {
- Image(systemName: "captions.bubble")
- .foregroundColor(.blue)
- .font(.caption)
- }
-
- if !asset.fileExists {
- Image(systemName: "exclamationmark.triangle")
- .foregroundColor(.orange)
+ HStack(spacing: 6) {
+ Text(asset.downloadDate.formatted(date: .abbreviated, time: .shortened))
.font(.caption)
+ .foregroundColor(.secondary)
+
+ if asset.localSubtitleURL != nil {
+ Image(systemName: "captions.bubble")
+ .foregroundColor(.blue)
+ .font(.caption)
+ }
+
+ if !asset.fileExists {
+ Image(systemName: "exclamationmark.triangle")
+ .foregroundColor(.orange)
+ .font(.caption)
+ }
}
}
+
+ Spacer()
+
+ Image(systemName: "play.circle.fill")
+ .foregroundColor(asset.fileExists ? .blue : .gray)
+ .font(.title2)
}
+ .padding(.vertical, 8)
+ .padding(.horizontal, 16)
- Spacer()
-
- Image(systemName: "play.circle.fill")
- .foregroundColor(asset.fileExists ? .blue : .gray)
- .font(.title2)
+ if showDivider {
+ Divider()
+ .padding(.horizontal, 16)
+ }
}
- .padding(.vertical, 8)
}
private func formatFileSize(_ size: Int64) -> String {
@@ -803,3 +1271,27 @@ struct DetailedEpisodeRow: View {
}
}
+struct SearchableStyleModifier: ViewModifier {
+ func body(content: Content) -> some View {
+ content
+ .searchable(text: .constant(""), prompt: "")
+ .background(
+ 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
+ )
+ )
+ )
+ }
+}
diff --git a/Sora/Views/LibraryView/AllBookmarks.swift b/Sora/Views/LibraryView/AllBookmarks.swift
new file mode 100644
index 0000000..b06ec95
--- /dev/null
+++ b/Sora/Views/LibraryView/AllBookmarks.swift
@@ -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()
+ }
+}
diff --git a/Sora/Views/LibraryView/AllWatching.swift b/Sora/Views/LibraryView/AllWatching.swift
new file mode 100644
index 0000000..8c0fefa
--- /dev/null
+++ b/Sora/Views/LibraryView/AllWatching.swift
@@ -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))
+ }
+ }
+}
diff --git a/Sora/Views/LibraryView/BookmarkComponents/BookmarkGridItemView.swift b/Sora/Views/LibraryView/BookmarkComponents/BookmarkGridItemView.swift
new file mode 100644
index 0000000..35a4747
--- /dev/null
+++ b/Sora/Views/LibraryView/BookmarkComponents/BookmarkGridItemView.swift
@@ -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
+ )
+ }
+ }
+ }
+}
+
diff --git a/Sora/Views/LibraryView/BookmarkComponents/BookmarkGridView.swift b/Sora/Views/LibraryView/BookmarkComponents/BookmarkGridView.swift
new file mode 100644
index 0000000..12289c6
--- /dev/null
+++ b/Sora/Views/LibraryView/BookmarkComponents/BookmarkGridView.swift
@@ -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()
+ }
+ }
+}
diff --git a/Sora/Views/LibraryView/BookmarkComponents/BookmarkLink.swift b/Sora/Views/LibraryView/BookmarkComponents/BookmarkLink.swift
new file mode 100644
index 0000000..4a597cb
--- /dev/null
+++ b/Sora/Views/LibraryView/BookmarkComponents/BookmarkLink.swift
@@ -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)
+ }
+ }
+}
diff --git a/Sora/Views/LibraryView/BookmarkComponents/BookmarksDetailView.swift b/Sora/Views/LibraryView/BookmarkComponents/BookmarksDetailView.swift
new file mode 100644
index 0000000..90eccf4
--- /dev/null
+++ b/Sora/Views/LibraryView/BookmarkComponents/BookmarksDetailView.swift
@@ -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)
+ }
+ }
+ }
+}
diff --git a/Sora/Views/LibraryView/LibraryView.swift b/Sora/Views/LibraryView/LibraryView.swift
index dee4e62..77289b7 100644
--- a/Sora/Views/LibraryView/LibraryView.swift
+++ b/Sora/Views/LibraryView/LibraryView.swift
@@ -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")
+ }
+ }
+ }
+ }
+}
diff --git a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift
index f87051f..daab631 100644
--- a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift
+++ b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift
@@ -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()
+ }
+ }
}
diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift
index b57ae3a..01d75a9 100644
--- a/Sora/Views/MediaInfoView/MediaInfoView.swift
+++ b/Sora/Views/MediaInfoView/MediaInfoView.swift
@@ -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 = 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 {
diff --git a/Sora/Views/SearchView.swift b/Sora/Views/SearchView.swift
index 4677ee4..28dc71f 100644
--- a/Sora/Views/SearchView.swift
+++ b/Sora/Views/SearchView.swift
@@ -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) {
+ 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..: 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)
+ }
+ }
+}
diff --git a/Sora/Views/SearchView/SearchResultsGrid.swift b/Sora/Views/SearchView/SearchResultsGrid.swift
new file mode 100644
index 0000000..5a2394b
--- /dev/null
+++ b/Sora/Views/SearchView/SearchResultsGrid.swift
@@ -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()
+ }
+}
diff --git a/Sora/Views/SearchView/SearchStateView.swift b/Sora/Views/SearchView/SearchStateView.swift
new file mode 100644
index 0000000..f174398
--- /dev/null
+++ b/Sora/Views/SearchView/SearchStateView.swift
@@ -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.. 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
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/Sora/Views/SettingsSharedComponents.swift b/Sora/Views/SettingsSharedComponents.swift
new file mode 100644
index 0000000..75d3b62
--- /dev/null
+++ b/Sora/Views/SettingsSharedComponents.swift
@@ -0,0 +1,251 @@
+//
+// SettingsSharedComponents.swift
+// Sora
+//
+
+import SwiftUI
+
+// MARK: - Settings Section
+struct SettingsSection: 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, 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: 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, 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
+ let step: Double
+ var formatter: (Double) -> String = { "\(Int($0))" }
+ var showDivider: Bool = true
+
+ init(icon: String, title: String, value: Binding, range: ClosedRange, 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)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Sora/Views/SettingsView/Components/SettingsComponents.swift b/Sora/Views/SettingsView/Components/SettingsComponents.swift
new file mode 100644
index 0000000..10c8acc
--- /dev/null
+++ b/Sora/Views/SettingsView/Components/SettingsComponents.swift
@@ -0,0 +1,246 @@
+//
+// SettingsComponents.swift
+// Sora
+//
+
+import SwiftUI
+
+internal struct SettingsSection: 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, 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: 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, 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
+ internal let step: Double
+ internal var formatter: (Double) -> String = { "\(Int($0))" }
+ internal var showDivider: Bool = true
+
+ internal init(icon: String, title: String, value: Binding, range: ClosedRange, 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)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewAbout.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewAbout.swift
index 8919e6b..4395c5b 100644
--- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewAbout.swift
+++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewAbout.swift
@@ -8,70 +8,130 @@
import SwiftUI
import Kingfisher
+fileprivate struct SettingsSection: 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
+ )
+ }
+}
diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewData.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewData.swift
index 977deae..fd088d4 100644
--- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewData.swift
+++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewData.swift
@@ -7,6 +7,134 @@
import SwiftUI
+fileprivate struct SettingsSection: 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, 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
+ }
}
}
diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift
index c48949d..5562947 100644
--- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift
+++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift
@@ -7,6 +7,147 @@
import SwiftUI
+fileprivate struct SettingsSection: 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, 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: 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, 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)
+ }
}
}
}
diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewLogger.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewLogger.swift
index 0d5c9c5..3f3d8ab 100644
--- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewLogger.swift
+++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewLogger.swift
@@ -6,25 +6,81 @@
//
import SwiftUI
+import Kingfisher
+
+fileprivate struct SettingsSection: 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) {
diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewLoggerFilter.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewLoggerFilter.swift
index 6769d81..6588afc 100644
--- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewLoggerFilter.swift
+++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewLoggerFilter.swift
@@ -7,6 +7,96 @@
import SwiftUI
+fileprivate struct SettingsSection: 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, 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")
}
diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewModule.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewModule.swift
index ee4abf4..6aeafd2 100644
--- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewModule.swift
+++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewModule.swift
@@ -8,6 +8,137 @@
import SwiftUI
import Kingfisher
+fileprivate struct SettingsSection: 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 {
diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift
index 7bce329..25a9526 100644
--- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift
+++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift
@@ -7,6 +7,191 @@
import SwiftUI
+fileprivate struct SettingsSection: 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, 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: 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, 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
+ let step: Double
+ var formatter: (Double) -> String = { "\(Int($0))" }
+ var showDivider: Bool = true
+
+ init(icon: String, title: String, value: Binding, range: ClosedRange, 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)
}
}
}
diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewTrackers.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewTrackers.swift
index ae8977c..0f3338b 100644
--- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewTrackers.swift
+++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewTrackers.swift
@@ -9,6 +9,96 @@ import SwiftUI
import Security
import Kingfisher
+fileprivate struct SettingsSection: 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, 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()
diff --git a/Sora/Views/SettingsView/SettingsView.swift b/Sora/Views/SettingsView/SettingsView.swift
index 51350a8..cebb12e 100644
--- a/Sora/Views/SettingsView/SettingsView.swift
+++ b/Sora/Views/SettingsView/SettingsView.swift
@@ -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
}
}
diff --git a/Sulfur.xcodeproj/project.pbxproj b/Sulfur.xcodeproj/project.pbxproj
index 2811b4d..7668b43 100644
--- a/Sulfur.xcodeproj/project.pbxproj
+++ b/Sulfur.xcodeproj/project.pbxproj
@@ -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 = ""; };
+ 0402DA0F2DE7B5EC003BB42C /* SearchComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchComponents.swift; sourceTree = ""; };
+ 0402DA102DE7B5EC003BB42C /* SearchResultsGrid.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsGrid.swift; sourceTree = ""; };
+ 0402DA112DE7B5EC003BB42C /* SearchStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchStateView.swift; sourceTree = ""; };
+ 0402DA162DE7B7B8003BB42C /* SearchViewComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewComponents.swift; sourceTree = ""; };
+ 0457C5942DE7712A000AFBD9 /* DeviceScaleModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceScaleModifier.swift; sourceTree = ""; };
+ 0457C5992DE78267000AFBD9 /* BookmarkGridItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkGridItemView.swift; sourceTree = ""; };
+ 0457C59A2DE78267000AFBD9 /* BookmarkGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkGridView.swift; sourceTree = ""; };
+ 0457C59B2DE78267000AFBD9 /* BookmarkLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkLink.swift; sourceTree = ""; };
+ 0457C5A02DE78385000AFBD9 /* BookmarksDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksDetailView.swift; sourceTree = ""; };
+ 04CD76DA2DE20F2200733536 /* AllWatching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllWatching.swift; sourceTree = ""; };
+ 04F08EDB2DE10BEC006B29D9 /* ProgressiveBlurView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressiveBlurView.swift; sourceTree = ""; };
+ 04F08EDE2DE10C1A006B29D9 /* TabBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBar.swift; sourceTree = ""; };
+ 04F08EE12DE10C27006B29D9 /* TabItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabItem.swift; sourceTree = ""; };
+ 04F08EE32DE10D6B006B29D9 /* AllBookmarks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllBookmarks.swift; sourceTree = ""; };
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 = ""; };
130C6BF92D53AB1F00DC1432 /* SettingsViewData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewData.swift; sourceTree = ""; };
- 13103E8A2D58E028000F0673 /* View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = View.swift; sourceTree = ""; };
+ 13103E8A2D58E028000F0673 /* ScrollViewBottomPadding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollViewBottomPadding.swift; sourceTree = ""; };
13103E8D2D58E04A000F0673 /* SkeletonCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SkeletonCell.swift; sourceTree = ""; };
131270162DC13A010093AA9C /* DownloadManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadManager.swift; sourceTree = ""; };
131845F82D47C62D00CA7A54 /* SettingsViewGeneral.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewGeneral.swift; sourceTree = ""; };
@@ -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 = "";
+ };
+ 0457C5962DE7712A000AFBD9 /* ViewModifiers */ = {
+ isa = PBXGroup;
+ children = (
+ 0457C5942DE7712A000AFBD9 /* DeviceScaleModifier.swift */,
+ );
+ path = ViewModifiers;
+ sourceTree = "";
+ };
+ 0457C59C2DE78267000AFBD9 /* BookmarkComponents */ = {
+ isa = PBXGroup;
+ children = (
+ 0457C5A02DE78385000AFBD9 /* BookmarksDetailView.swift */,
+ 0457C5992DE78267000AFBD9 /* BookmarkGridItemView.swift */,
+ 0457C59A2DE78267000AFBD9 /* BookmarkGridView.swift */,
+ 0457C59B2DE78267000AFBD9 /* BookmarkLink.swift */,
+ );
+ path = BookmarkComponents;
+ sourceTree = "";
+ };
+ 04F08EDA2DE10BE3006B29D9 /* ProgressiveBlurView */ = {
+ isa = PBXGroup;
+ children = (
+ 04F08EDB2DE10BEC006B29D9 /* ProgressiveBlurView.swift */,
+ );
+ path = ProgressiveBlurView;
+ sourceTree = "";
+ };
+ 04F08EDD2DE10C05006B29D9 /* TabBar */ = {
+ isa = PBXGroup;
+ children = (
+ 0402DA0D2DE7A9FC003BB42C /* TabBarController.swift */,
+ 04F08EDE2DE10C1A006B29D9 /* TabBar.swift */,
+ );
+ path = TabBar;
+ sourceTree = "";
+ };
+ 04F08EE02DE10C22006B29D9 /* Models */ = {
+ isa = PBXGroup;
+ children = (
+ 04F08EE12DE10C27006B29D9 /* TabItem.swift */,
+ );
+ path = Models;
+ sourceTree = "";
+ };
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;
};