* Minor changes

* more minor changes

* MORE MINOR CHANGES

* Fixed one singular bug

* Update SettingsViewGeneral.swift

* fuck conflicts

* fuck you and your credits

* buh buh

* Update SettingsViewAbout.swift

* What's that? What's a properly working code without unnecessary issues?

* Type shit maybe?

* Create ios.yml

* smol

* type shi

shi

* end

* Fixed system theme bug

* (hopefully) fixed sliding items in search + recent searches spam

* Fixed open keyboard in media view

* Fixed searchviewdata + fixed toggle color being too light

* fixed episode slider sensitivity

* new recent searches, not fully done but wtv WHO CARES 💯

* Add module screen fix

* Delete .github/workflows/ios.yml

* UI modifications

* Scroll to close keyboard

* Text change

* Downloadview transition

* Search cards text fixed

* Reduced header spacing + moved down a to match library

* Text change

* Small tab bar tweak for search view

* added gradient to player buttons

* reduced summary size

* IBH special 💯

* Fixed different height text?

* Removed seperator

* Some more fixes

start watching button
This commit is contained in:
50/50 2025-05-31 15:56:13 +02:00 committed by GitHub
parent 38df5c71e8
commit f3ef58db11
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 6476 additions and 2019 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -6,6 +6,110 @@
<array>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
</array>
<key>CFBundleIcons</key>
<dict>
<key>CFBundleAlternateIcons</key>
<dict>
<key>AppIcon_Original</key>
<dict>
<key>CFBundleIconFiles</key>
<array>
<string>AppIcon_Original</string>
</array>
</dict>
<key>AppIcon_Pixel</key>
<dict>
<key>CFBundleIconFiles</key>
<array>
<string>AppIcon_Pixel</string>
</array>
</dict>
<key>AppIcon_SoraAlt</key>
<dict>
<key>CFBundleIconFiles</key>
<array>
<string>AppIcon_SoraAlt</string>
</array>
</dict>
<key>AppIcon_CiroGold</key>
<dict>
<key>CFBundleIconFiles</key>
<array>
<string>AppIcon_CiroGold</string>
</array>
</dict>
<key>AppIcon_CiroChrome</key>
<dict>
<key>CFBundleIconFiles</key>
<array>
<string>AppIcon_CiroChrome</string>
</array>
</dict>
<key>AppIcon_CiroGoldTwo</key>
<dict>
<key>CFBundleIconFiles</key>
<array>
<string>AppIcon_CiroGoldTwo</string>
</array>
</dict>
<key>AppIcon_CiroGoldThree</key>
<dict>
<key>CFBundleIconFiles</key>
<array>
<string>AppIcon_CiroGoldThree</string>
</array>
</dict>
<key>AppIcon_CiroPink</key>
<dict>
<key>CFBundleIconFiles</key>
<array>
<string>AppIcon_CiroPink</string>
</array>
</dict>
<key>AppIcon_CiroPurple</key>
<dict>
<key>CFBundleIconFiles</key>
<array>
<string>AppIcon_CiroPurple</string>
</array>
</dict>
<key>AppIcon_CiroRed</key>
<dict>
<key>CFBundleIconFiles</key>
<array>
<string>AppIcon_CiroRed</string>
</array>
</dict>
<key>AppIcon_CiroRoseGold</key>
<dict>
<key>CFBundleIconFiles</key>
<array>
<string>AppIcon_CiroRoseGold</string>
</array>
</dict>
<key>AppIcon_CiroSilver</key>
<dict>
<key>CFBundleIconFiles</key>
<array>
<string>AppIcon_CiroSilver</string>
</array>
</dict>
<key>AppIcon_CiroSilverTwo</key>
<dict>
<key>CFBundleIconFiles</key>
<array>
<string>AppIcon_CiroSilverTwo</string>
</array>
</dict>
<key>AppIcon_CiroYellow</key>
<dict>
<key>CFBundleIconFiles</key>
<array>
<string>AppIcon_CiroYellow</string>
</array>
</dict>
</dict>
</dict>
<key>CFBundleURLTypes</key>
<array>
<dict>

View file

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

View file

@ -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()
}
}

View file

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

View file

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

View file

@ -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())
}
}

View file

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

View file

@ -79,3 +79,5 @@ class JSController: NSObject, ObservableObject {
}
}
}

View file

@ -64,39 +64,62 @@ class Logger {
let logString = "[\(dateFormatter.string(from: log.timestamp))] [\(log.type)] \(log.message)\n---\n"
if let data = logString.data(using: .utf8) {
if FileManager.default.fileExists(atPath: logFileURL.path) {
do {
let attributes = try FileManager.default.attributesOfItem(atPath: logFileURL.path)
let fileSize = attributes[.size] as? UInt64 ?? 0
guard let data = logString.data(using: .utf8) else {
print("Failed to encode log string to UTF-8")
return
}
if FileManager.default.fileExists(atPath: logFileURL.path) {
do {
let attributes = try FileManager.default.attributesOfItem(atPath: logFileURL.path)
let fileSize = attributes[.size] as? UInt64 ?? 0
if fileSize + UInt64(data.count) > maxFileSize {
guard var content = try? String(contentsOf: logFileURL, encoding: .utf8) else { return }
if fileSize + UInt64(data.count) > maxFileSize {
guard var content = try? String(contentsOf: logFileURL, encoding: .utf8) else { return }
while (content.data(using: .utf8)?.count ?? 0) + data.count > maxFileSize {
if let rangeOfFirstLine = content.range(of: "\n---\n") {
content.removeSubrange(content.startIndex...rangeOfFirstLine.upperBound)
} else {
content = ""
break
}
// Ensure content is not empty and contains valid UTF-8
guard !content.isEmpty else {
try? data.write(to: logFileURL)
return
}
// Remove old entries until we have space
while (content.data(using: .utf8)?.count ?? 0) + data.count > maxFileSize {
if let rangeOfFirstLine = content.range(of: "\n---\n") {
// Ensure we don't try to remove beyond the string's bounds
let endIndex = min(rangeOfFirstLine.upperBound, content.endIndex)
content.removeSubrange(content.startIndex..<endIndex)
} else {
// If we can't find a separator, clear the content
content = ""
break
}
content += logString
try? content.data(using: .utf8)?.write(to: logFileURL)
} else {
if let handle = try? FileHandle(forWritingTo: logFileURL) {
handle.seekToEndOfFile()
handle.write(data)
handle.closeFile()
// Safety check to prevent infinite loops
if content.isEmpty {
break
}
}
} catch {
print("Error managing log file: \(error)")
// Append new log entry
content += logString
// Write back to file
if let finalData = content.data(using: .utf8) {
try? finalData.write(to: logFileURL)
}
} else {
if let handle = try? FileHandle(forWritingTo: logFileURL) {
handle.seekToEndOfFile()
handle.write(data)
handle.closeFile()
}
}
} else {
try? data.write(to: logFileURL)
} catch {
print("Error managing log file: \(error)")
}
} else {
try? data.write(to: logFileURL)
}
}

View file

@ -1051,7 +1051,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
func setupSkipButtons() {
let introConfig = UIImage.SymbolConfiguration(pointSize: 14, weight: .bold)
let introImage = UIImage(systemName: "forward.frame", withConfiguration: introConfig)
skipIntroButton = UIButton(type: .system)
skipIntroButton = GradientOverlayButton(type: .system)
skipIntroButton.setTitle(" Skip Intro", for: .normal)
skipIntroButton.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .bold)
skipIntroButton.setImage(introImage, for: .normal)
@ -1083,7 +1083,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
let outroConfig = UIImage.SymbolConfiguration(pointSize: 14, weight: .bold)
let outroImage = UIImage(systemName: "forward.frame", withConfiguration: outroConfig)
skipOutroButton = UIButton(type: .system)
skipOutroButton = GradientOverlayButton(type: .system)
skipOutroButton.setTitle(" Skip Outro", for: .normal)
skipOutroButton.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .bold)
skipOutroButton.setImage(outroImage, for: .normal)
@ -1283,7 +1283,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
let config = UIImage.SymbolConfiguration(pointSize: 14, weight: .bold)
let image = UIImage(systemName: "goforward", withConfiguration: config)
skip85Button = UIButton(type: .system)
skip85Button = GradientOverlayButton(type: .system)
skip85Button.setTitle(" Skip 85s", for: .normal)
skip85Button.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .bold)
skip85Button.setImage(image, for: .normal)
@ -2453,7 +2453,45 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
}
}
class GradientOverlayButton: UIButton {
private let gradientLayer = CAGradientLayer()
override init(frame: CGRect) {
super.init(frame: frame)
setupGradient()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setupGradient()
}
private func setupGradient() {
gradientLayer.colors = [
UIColor.white.withAlphaComponent(0.25).cgColor,
UIColor.white.withAlphaComponent(0).cgColor
]
gradientLayer.startPoint = CGPoint(x: 0.5, y: 0)
gradientLayer.endPoint = CGPoint(x: 0.5, y: 1)
layer.addSublayer(gradientLayer)
}
override func layoutSubviews() {
super.layoutSubviews()
gradientLayer.frame = bounds
let path = UIBezierPath(roundedRect: bounds.insetBy(dx: 0.25, dy: 0.25), cornerRadius: bounds.height / 2)
let maskLayer = CAShapeLayer()
maskLayer.path = path.cgPath
maskLayer.fillColor = nil
maskLayer.strokeColor = UIColor.white.cgColor
maskLayer.lineWidth = 0.5
gradientLayer.mask = maskLayer
}
}
// yes? Like the plural of the famous american rapper ye? -IBHRAD
// low taper fade the meme is massive -cranci
// cranci still doesnt have a job -seiike
// guys watch Clannad already - ibro
// May the Divine Providence bestow its infinite mercy upon your soul, and may eternal grace find you beyond the shadows of this mortal realm. - paul, 15/11/2005 - 13/05/2023

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -7,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")
}
}
}
}
}

View file

@ -9,12 +9,6 @@ import SwiftUI
import Kingfisher
import AVFoundation
struct EpisodeLink: Identifiable {
let id = UUID()
let number: Int
let href: String
}
struct EpisodeCell: View {
let episodeIndex: Int
let episode: String
@ -50,6 +44,10 @@ struct EpisodeCell: View {
@State private var lastLoggedStatus: EpisodeDownloadStatus?
@State private var downloadAnimationScale: CGFloat = 1.0
@State private var swipeOffset: CGFloat = 0
@State private var isShowingActions: Bool = false
@State private var actionButtonWidth: CGFloat = 60
@State private var retryAttempts: Int = 0
private let maxRetryAttempts: Int = 3
private let initialBackoffDelay: TimeInterval = 1.0
@ -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()
}
}
}

View file

@ -51,6 +51,7 @@ struct MediaInfoView: View {
@StateObject private var jsController = JSController.shared
@EnvironmentObject var moduleManager: ModuleManager
@EnvironmentObject private var libraryManager: LibraryManager
@EnvironmentObject var tabBarController: TabBarController
@State private var selectedRange: Range<Int> = 0..<100
@State private var showSettingsMenu = false
@ -97,7 +98,43 @@ struct MediaInfoView: View {
}
var body: some View {
bodyContent
ZStack {
bodyContent
.navigationBarHidden(true)
.navigationBarTitleDisplayMode(.inline)
.navigationBarTitle("")
.navigationViewStyle(StackNavigationViewStyle())
.ignoresSafeArea(.container, edges: .top)
.onAppear {
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first,
let navigationController = window.rootViewController?.children.first as? UINavigationController {
navigationController.interactivePopGestureRecognizer?.isEnabled = true
navigationController.interactivePopGestureRecognizer?.delegate = nil
}
}
VStack {
HStack {
Button(action: {
dismiss()
}) {
Image(systemName: "chevron.left")
.font(.system(size: 24))
.foregroundColor(.primary)
.padding(12)
.background(Color.gray.opacity(0.2))
.clipShape(Circle())
.circularGradientOutline()
}
.padding(.top, 8)
.padding(.leading, 16)
Spacer()
}
Spacer()
}
}
}
@ViewBuilder
@ -113,6 +150,8 @@ struct MediaInfoView: View {
.onAppear {
buttonRefreshTrigger.toggle()
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
if !hasFetched {
DropManager.shared.showDrop(title: "Fetching Data", subtitle: "Please wait while fetching.", duration: 0.5, icon: UIImage(systemName: "arrow.triangle.2.circlepath"))
fetchDetails()
@ -138,6 +177,10 @@ struct MediaInfoView: View {
hasFetched = true
AnalyticsManager.shared.sendEvent(event: "search", additionalData: ["title": title])
}
tabBarController.hideTabBar()
}
.onDisappear(){
tabBarController.showTabBar()
}
.alert("Loading Stream", isPresented: $showLoadingAlert) {
Button("Cancel", role: .cancel) {
@ -162,91 +205,252 @@ struct MediaInfoView: View {
@ViewBuilder
private var mainScrollView: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
mediaHeaderSection
if !synopsis.isEmpty {
synopsisSection
}
playAndBookmarkSection
if !episodeLinks.isEmpty {
episodesSection
} else {
noEpisodesSection
ZStack(alignment: .top) {
KFImage(URL(string: imageUrl))
.placeholder {
Rectangle()
.fill(Color.gray.opacity(0.3))
.shimmering()
}
.setProcessor(ImageUpscaler.lanczosProcessor(scale: 3, sharpeningIntensity: 1, sharpeningRadius: 1))
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: UIScreen.main.bounds.width, height: 600)
.clipped()
KFImage(URL(string: imageUrl))
.placeholder { EmptyView() }
.setProcessor(ImageUpscaler.lanczosProcessor(scale: 3, sharpeningIntensity: 1, sharpeningRadius: 1))
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: UIScreen.main.bounds.width, height: 600)
.clipped()
.blur(radius: 30)
.mask(
LinearGradient(
gradient: Gradient(stops: [
.init(color: .clear, location: 0.0),
.init(color: .clear, location: 0.6),
.init(color: .black, location: 0.8),
.init(color: .black, location: 1.0)
]),
startPoint: .top,
endPoint: .bottom
)
)
.overlay(
LinearGradient(
gradient: Gradient(stops: [
.init(color: .clear, location: 0.0),
.init(color: .clear, location: 0.7),
.init(color: (colorScheme == .dark ? Color.black : Color.white).opacity(0.9), location: 1.0)
]),
startPoint: .top,
endPoint: .bottom
)
)
VStack(spacing: 0) {
Rectangle()
.fill(Color.clear)
.frame(height: 400)
VStack(alignment: .leading, spacing: 16) {
headerSection
if !episodeLinks.isEmpty {
episodesSection
} else {
noEpisodesSection
}
}
.padding()
.background(
LinearGradient(
gradient: Gradient(stops: [
.init(color: (colorScheme == .dark ? Color.black : Color.white).opacity(0.0), location: 0.0),
.init(color: (colorScheme == .dark ? Color.black : Color.white).opacity(0.5), location: 0.2),
.init(color: (colorScheme == .dark ? Color.black : Color.white).opacity(0.8), location: 0.5),
.init(color: (colorScheme == .dark ? Color.black : Color.white), location: 1.0)
]),
startPoint: .top,
endPoint: .bottom
)
.clipShape(RoundedRectangle(cornerRadius: 0))
.shadow(
color: (colorScheme == .dark ? Color.black : Color.white).opacity(1),
radius: 10,
x: 0,
y: 10
)
)
}
.deviceScaled()
}
.padding()
.navigationBarTitleDisplayMode(.inline)
.navigationBarTitle("")
.navigationViewStyle(StackNavigationViewStyle())
}
.onAppear {
UIScrollView.appearance().bounces = false
}
.navigationBarTitleDisplayMode(.inline)
.navigationBarTitle("")
.navigationViewStyle(StackNavigationViewStyle())
.ignoresSafeArea(.container, edges: .top)
}
@ViewBuilder
private var mediaHeaderSection: some View {
HStack(alignment: .top, spacing: 10) {
KFImage(URL(string: imageUrl))
.placeholder {
RoundedRectangle(cornerRadius: 10)
.fill(Color.gray.opacity(0.3))
.frame(width: 150, height: 225)
.shimmering()
}
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 150, height: 225)
.clipped()
.cornerRadius(10)
mediaInfoSection
}
}
@ViewBuilder
private var mediaInfoSection: some View {
VStack(alignment: .leading, spacing: 4) {
private var headerSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text(title)
.font(.system(size: 17))
.fontWeight(.bold)
.font(.system(size: 28, weight: .bold))
.foregroundColor(.primary)
.lineLimit(3)
.onLongPressGesture {
UIPasteboard.general.string = title
DropManager.shared.showDrop(title: "Copied to Clipboard", subtitle: "", duration: 1.0, icon: UIImage(systemName: "doc.on.clipboard.fill"))
}
if !aliases.isEmpty && aliases != title && aliases != "N/A" && aliases != "No Data" {
Text(aliases)
.font(.system(size: 13))
.foregroundColor(.secondary)
}
Spacer()
if !airdate.isEmpty && airdate != "N/A" && airdate != "No Data" {
HStack(alignment: .center, spacing: 12) {
HStack(spacing: 4) {
Image(systemName: "calendar")
.resizable()
.frame(width: 15, height: 15)
.foregroundColor(.secondary)
Text(airdate)
.font(.system(size: 12))
.foregroundColor(.secondary)
if !synopsis.isEmpty {
HStack(alignment: .bottom) {
Text(synopsis)
.font(.system(size: 16))
.foregroundColor(.secondary)
.lineLimit(showFullSynopsis ? nil : 3)
Text(showFullSynopsis ? "LESS" : "MORE")
.font(.system(size: 16, weight: .bold))
.foregroundColor(.accentColor)
}
.onTapGesture {
withAnimation(.easeInOut(duration: 0.3)) {
showFullSynopsis.toggle()
}
.padding(4)
}
}
HStack(alignment: .center, spacing: 12) {
playAndBookmarkSection
// Metadata row
HStack(spacing: 16) {
sourceButton
if !airdate.isEmpty && airdate != "N/A" && airdate != "No Data" {
HStack(spacing: 4) {
Image(systemName: "calendar")
.foregroundColor(.secondary)
Text(airdate)
.font(.system(size: 14))
.foregroundColor(.secondary)
}
}
Spacer()
menuButton
}
// Single episode action buttons
if episodeLinks.count == 1 {
VStack(spacing: 12) {
HStack(spacing: 12) {
Button(action: {
if let ep = episodeLinks.first {
let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(ep.href)")
let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(ep.href)")
let progress = totalTime > 0 ? lastPlayedTime / totalTime : 0
if progress <= 0.9 {
UserDefaults.standard.set(99999999.0, forKey: "lastPlayedTime_\(ep.href)")
UserDefaults.standard.set(99999999.0, forKey: "totalTime_\(ep.href)")
DropManager.shared.showDrop(title: "Marked as Watched", subtitle: "", duration: 1.0, icon: UIImage(systemName: "checkmark.circle.fill"))
}
}
}) {
HStack(spacing: 4) {
Image(systemName: "checkmark.circle")
.foregroundColor(.primary)
Text("Mark as Watched")
.font(.system(size: 14, weight: .medium))
.foregroundColor(.primary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
.background(Color.gray.opacity(0.2))
.cornerRadius(15)
.gradientOutline()
}
Button(action: {
if let ep = episodeLinks.first {
let downloadStatus = jsController.isEpisodeDownloadedOrInProgress(
showTitle: title,
episodeNumber: ep.number,
season: 1
)
if downloadStatus == .notDownloaded {
selectedEpisodeNumber = ep.number
fetchStream(href: ep.href)
DropManager.shared.showDrop(title: "Starting Download", subtitle: "", duration: 1.0, icon: UIImage(systemName: "arrow.down.circle"))
} else {
DropManager.shared.showDrop(title: "Already Downloaded", subtitle: "", duration: 1.0, icon: UIImage(systemName: "checkmark.circle"))
}
}
}) {
HStack(spacing: 4) {
Image(systemName: "arrow.down.circle")
.foregroundColor(.primary)
Text("Download")
.font(.system(size: 14, weight: .medium))
.foregroundColor(.primary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
.background(Color.gray.opacity(0.2))
.cornerRadius(15)
.gradientOutline()
}
}
Text("Why am I not seeing any episodes?")
.font(.caption)
.bold()
.foregroundColor(.gray)
.multilineTextAlignment(.leading)
.lineLimit(nil)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 4)
.padding(.leading, 2.8)
Text("The module provided only a single episode, this is most likely a movie, so we decided to make separate screens for these cases.")
.font(.caption)
.foregroundColor(.gray)
.multilineTextAlignment(.leading)
.lineLimit(nil)
.fixedSize(horizontal: false, vertical: true)
.padding(.top, 4)
}
}
}
}
@ViewBuilder
private var contentSection: some View {
VStack(alignment: .leading, spacing: 20) {
playAndBookmarkSection
if !episodeLinks.isEmpty {
episodesSection
} else {
noEpisodesSection
}
}
.padding(.horizontal, 20)
.padding(.vertical, 20)
.background(
Rectangle()
.fill(colorScheme == .dark ? Color.black : Color.white)
)
}
@ViewBuilder
private var sourceButton: some View {
Button(action: {
@ -254,16 +458,20 @@ struct MediaInfoView: View {
}) {
HStack(spacing: 4) {
Text(module.metadata.sourceName)
.font(.system(size: 13))
.font(.system(size: 14, weight: .medium))
.foregroundColor(.primary)
.lineLimit(1)
Image(systemName: "safari")
.resizable()
.frame(width: 20, height: 20)
.frame(width: 14, height: 14)
.foregroundColor(.primary)
}
.padding(4)
.background(Capsule().fill(Color.accentColor.opacity(0.4)))
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color.gray.opacity(0.2))
.cornerRadius(15)
.gradientOutline()
}
}
@ -312,54 +520,37 @@ struct MediaInfoView: View {
Label("Log Debug Info", systemImage: "terminal")
}
} label: {
Image(systemName: "ellipsis.circle")
Image(systemName: "ellipsis")
.resizable()
.frame(width: 20, height: 20)
.frame(width: 16, height: 4)
.foregroundColor(.primary)
}
}
@ViewBuilder
private var synopsisSection: some View {
VStack(alignment: .leading, spacing: 2) {
HStack(alignment: .center) {
Text("Synopsis")
.font(.system(size: 18))
.fontWeight(.bold)
Spacer()
Button(action: {
showFullSynopsis.toggle()
}) {
Text(showFullSynopsis ? "Less" : "More")
.font(.system(size: 14))
}
}
Text(synopsis)
.lineLimit(showFullSynopsis ? nil : 4)
.font(.system(size: 14))
.padding(12)
.background(Color.gray.opacity(0.2))
.clipShape(Circle())
.circularGradientOutline()
}
}
@ViewBuilder
private var playAndBookmarkSection: some View {
HStack {
HStack(spacing: 12) {
Button(action: {
playFirstUnwatchedEpisode()
}) {
HStack {
HStack(spacing: 8) {
Image(systemName: "play.fill")
.foregroundColor(.primary)
.foregroundColor(colorScheme == .dark ? .black : .white)
Text(startWatchingText)
.font(.headline)
.foregroundColor(.primary)
.font(.system(size: 16, weight: .medium))
.foregroundColor(colorScheme == .dark ? .black : .white)
}
.padding()
.frame(maxWidth: .infinity)
.background(Color.accentColor)
.cornerRadius(10)
.padding(.vertical, 12)
.padding(.horizontal, 20)
.background(
RoundedRectangle(cornerRadius: 25)
.fill(Color.accentColor)
)
}
.disabled(isFetchingEpisode)
@ -374,25 +565,43 @@ struct MediaInfoView: View {
}) {
Image(systemName: libraryManager.isBookmarked(href: href, moduleName: module.metadata.sourceName) ? "bookmark.fill" : "bookmark")
.resizable()
.frame(width: 20, height: 27)
.foregroundColor(Color.accentColor)
.frame(width: 16, height: 22)
.foregroundColor(.primary)
.padding(12)
.background(Color.gray.opacity(0.2))
.clipShape(Circle())
.circularGradientOutline()
}
}
}
@ViewBuilder
private var episodesSection: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text("Episodes")
.font(.system(size: 18))
.fontWeight(.bold)
if episodeLinks.count == 1 {
// Don't show episodes list for single-episode media
EmptyView()
} else {
VStack(alignment: .leading, spacing: 16) {
HStack {
Text("Episodes")
.font(.system(size: 22, weight: .bold))
.foregroundColor(.primary)
Spacer()
Group {
if !isGroupedBySeasons && episodeLinks.count <= episodeChunkSize {
Text("All episodes already shown")
.font(.system(size: 14))
.foregroundColor(.secondary)
} else {
episodeNavigationSection
}
}
}
Spacer()
episodeNavigationSection
episodeListSection
}
episodeListSection
}
}
@ -540,6 +749,8 @@ struct MediaInfoView: View {
episodeID: ep.number - 1,
progress: progress,
itemID: itemID ?? 0,
totalEpisodes: episodeLinks.count,
defaultBannerImage: getBannerImageBasedOnAppearance(),
module: module,
parentTitle: title,
showPosterURL: imageUrl,
@ -606,34 +817,23 @@ struct MediaInfoView: View {
@ViewBuilder
private var noEpisodesSection: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Episodes")
.font(.system(size: 18))
.fontWeight(.bold)
VStack(spacing: 16) {
Image(systemName: "tv.slash")
.font(.system(size: 48))
.foregroundColor(.secondary)
Text("No Episodes Available")
.font(.title2)
.fontWeight(.semibold)
.foregroundColor(.primary)
Text("Episodes might not be available yet or there could be an issue with the source.")
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
}
VStack(spacing: 8) {
if isRefetching {
ProgressView()
.padding()
} else {
Image(systemName: "exclamationmark.triangle")
.font(.largeTitle)
.foregroundColor(.secondary)
HStack(spacing: 2) {
Text("No episodes Found:")
.foregroundColor(.secondary)
Button(action: {
isRefetching = true
fetchDetails()
}) {
Text("Retry")
.foregroundColor(.accentColor)
}
}
}
}
.padding()
.frame(maxWidth: .infinity)
.padding(.vertical, 40)
}
private var startWatchingText: String {
@ -641,6 +841,13 @@ struct MediaInfoView: View {
let finished = indices.finished
let unfinished = indices.unfinished
if episodeLinks.count == 1 {
if let unfinishedIndex = unfinished {
return "Continue Watching"
}
return "Start Watching"
}
if let finishedIndex = finished, finishedIndex < episodeLinks.count - 1 {
let nextEp = episodeLinks[finishedIndex + 1]
return "Start Watching Episode \(nextEp.number)"
@ -785,8 +992,8 @@ struct MediaInfoView: View {
self.showLoadingAlert = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
guard self.activeFetchID == fetchID else {
return
guard self.activeFetchID == fetchID else {
return
}
if let sources = result.sources, !sources.isEmpty {

View file

@ -8,14 +8,15 @@
import SwiftUI
import Kingfisher
struct SearchItem: Identifiable {
let id = UUID()
let title: String
let imageUrl: String
let href: String
struct ModuleButtonModifier: ViewModifier {
func body(content: Content) -> some View {
content
.buttonStyle(PlainButtonStyle())
.offset(y: 45)
.zIndex(999)
}
}
struct SearchView: View {
@AppStorage("selectedModuleId") private var selectedModuleId: String?
@AppStorage("mediaColumnsPortrait") private var mediaColumnsPortrait: Int = 2
@ -25,15 +26,23 @@ struct SearchView: View {
@EnvironmentObject var moduleManager: ModuleManager
@Environment(\.verticalSizeClass) var verticalSizeClass
@Binding public var searchQuery: String
@State private var searchItems: [SearchItem] = []
@State private var selectedSearchItem: SearchItem?
@State private var isSearching = false
@State private var searchText = ""
@State private var hasNoResults = false
@State private var isLandscape: Bool = UIDevice.current.orientation.isLandscape
@State private var isModuleSelectorPresented = false
@State private var searchHistory: [String] = []
@State private var isSearchFieldFocused = false
@State private var saveDebounceTimer: Timer?
private let columns = [GridItem(.adaptive(minimum: 150))]
init(searchQuery: Binding<String>) {
self._searchQuery = searchQuery
}
private var selectedModule: ScrapingModule? {
guard let id = selectedModuleId else { return nil }
@ -62,228 +71,65 @@ struct SearchView: View {
var body: some View {
NavigationView {
ScrollView {
let columnsCount = determineColumns()
VStack(spacing: 0) {
HStack {
SearchBar(text: $searchText, isFocused: $isSearchFieldFocused, onSearchButtonClicked: performSearch)
.padding(.leading)
.padding(.trailing, searchText.isEmpty ? 16 : 0)
.disabled(selectedModule == nil)
.padding(.top)
if !searchText.isEmpty {
Button("Cancel") {
searchText = ""
isSearchFieldFocused = false
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}
.padding(.trailing)
.padding(.top)
}
}
VStack(alignment: .leading) {
HStack {
Text("Search")
.font(.largeTitle)
.fontWeight(.bold)
if isSearchFieldFocused && !searchHistory.isEmpty && searchText.isEmpty {
VStack(alignment: .leading, spacing: 0) {
HStack {
Text("Recent Searches")
.font(.subheadline)
.foregroundColor(.secondary)
Spacer()
Button("Clear") {
clearSearchHistory()
}
.font(.caption)
.foregroundColor(.accentColor)
}
.padding(.horizontal)
.padding(.top, 8)
ForEach(Array(searchHistory.enumerated()), id: \.offset) { index, searchTerm in
Button(action: {
searchText = searchTerm
isSearchFieldFocused = false
performSearch()
}) {
HStack {
Image(systemName: "clock")
.foregroundColor(.secondary)
.font(.caption)
Text(searchTerm)
.foregroundColor(.primary)
Spacer()
Button(action: {
removeFromHistory(at: index)
}) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.secondary)
.font(.caption)
}
}
.padding(.horizontal)
.padding(.vertical, 8)
}
.buttonStyle(PlainButtonStyle())
if index < searchHistory.count - 1 {
Divider()
.padding(.leading, 40)
}
}
}
.background(Color(.systemBackground))
.cornerRadius(8)
.shadow(color: Color.black.opacity(0.1), radius: 2, y: 1)
.padding(.horizontal)
.padding(.top, 4)
}
Spacer()
if selectedModule == nil {
VStack(spacing: 8) {
Image(systemName: "questionmark.app")
.font(.largeTitle)
.foregroundColor(.secondary)
Text("No Module Selected")
.font(.headline)
Text("Please select a module from settings")
.font(.caption)
.foregroundColor(.secondary)
ModuleSelectorMenu(
selectedModule: selectedModule,
moduleGroups: getModuleLanguageGroups(),
modulesByLanguage: getModulesByLanguage(),
selectedModuleId: selectedModuleId,
onModuleSelected: { moduleId in
selectedModuleId = moduleId
}
.padding()
.frame(maxWidth: .infinity)
.background(Color(.systemBackground))
.shadow(color: Color.black.opacity(0.1), radius: 2, y: 1)
}
if !searchText.isEmpty {
if isSearching {
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 16), count: columnsCount), spacing: 16) {
ForEach(0..<columnsCount*4, id: \.self) { _ in
SearchSkeletonCell(cellWidth: cellWidth)
}
}
.padding(.top)
.padding()
} else if hasNoResults {
VStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.font(.largeTitle)
.foregroundColor(.secondary)
Text("No Results Found")
.font(.headline)
Text("Try different keywords")
.font(.caption)
.foregroundColor(.secondary)
}
.padding()
.frame(maxWidth: .infinity)
.padding(.top)
} else {
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 16), count: columnsCount), spacing: 16) {
ForEach(searchItems) { item in
NavigationLink(destination: MediaInfoView(title: item.title, imageUrl: item.imageUrl, href: item.href, module: selectedModule!)) {
VStack {
KFImage(URL(string: item.imageUrl))
.resizable()
.aspectRatio(contentMode: .fill)
.frame(height: cellWidth * 3 / 2)
.frame(maxWidth: cellWidth)
.cornerRadius(10)
.clipped()
Text(item.title)
.font(.subheadline)
.foregroundColor(Color.primary)
.padding([.leading, .bottom], 8)
.lineLimit(1)
}
}
}
.onAppear {
updateOrientation()
}
.onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in
updateOrientation()
}
}
.padding(.top)
.padding()
}
}
)
}
}
.navigationTitle("Search")
.navigationBarTitleDisplayMode(.large)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Menu {
if getModuleLanguageGroups().count == 1 {
ForEach(moduleManager.modules, id: \.id) { module in
Button {
selectedModuleId = module.id.uuidString
} label: {
HStack {
KFImage(URL(string: module.metadata.iconUrl))
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 20, height: 20)
.cornerRadius(4)
Text(module.metadata.sourceName)
if module.id.uuidString == selectedModuleId {
Image(systemName: "checkmark")
.foregroundColor(.accentColor)
}
}
}
}
} else {
ForEach(getModuleLanguageGroups(), id: \.self) { language in
Menu(language) {
ForEach(getModulesForLanguage(language), id: \.id) { module in
Button {
selectedModuleId = module.id.uuidString
} label: {
HStack {
KFImage(URL(string: module.metadata.iconUrl))
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 20, height: 20)
.cornerRadius(4)
Text(module.metadata.sourceName)
if module.id.uuidString == selectedModuleId {
Image(systemName: "checkmark")
.foregroundColor(.accentColor)
}
}
}
}
}
}
}
} label: {
HStack(spacing: 4) {
if let selectedModule = selectedModule {
Text(selectedModule.metadata.sourceName)
.font(.headline)
.foregroundColor(.secondary)
} else {
Text("Select Module")
.font(.headline)
.foregroundColor(.accentColor)
}
Image(systemName: "chevron.down")
.foregroundColor(.secondary)
}
}
.fixedSize()
.padding(.horizontal, 20)
.padding(.top, 20)
ScrollView {
SearchContent(
selectedModule: selectedModule,
searchQuery: searchQuery,
searchHistory: searchHistory,
searchItems: searchItems,
isSearching: isSearching,
hasNoResults: hasNoResults,
columns: columns,
columnsCount: columnsCount,
cellWidth: cellWidth,
onHistoryItemSelected: { query in
searchQuery = query
},
onHistoryItemDeleted: { index in
removeFromHistory(at: index)
},
onClearHistory: clearSearchHistory
)
}
.scrollViewBottomPadding()
.simultaneousGesture(
DragGesture().onChanged { _ in
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}
)
}
.navigationBarHidden(true)
}
.navigationViewStyle(StackNavigationViewStyle())
.onAppear {
loadSearchHistory()
if !searchQuery.isEmpty {
performSearch()
}
}
.onChange(of: selectedModuleId) { _ in
if !searchText.isEmpty {
if !searchQuery.isEmpty {
performSearch()
}
}
@ -295,52 +141,77 @@ struct SearchView: View {
moduleManager.selectedModuleChanged = false
}
}
.onChange(of: searchText) { newValue in
.onChange(of: searchQuery) { newValue in
if newValue.isEmpty {
saveDebounceTimer?.invalidate()
searchItems = []
hasNoResults = false
isSearching = false
} else {
performSearch()
saveDebounceTimer?.invalidate()
saveDebounceTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { _ in
self.addToSearchHistory(newValue)}
}
}
}
private func lockOrientation() {
OrientationManager.shared.lockOrientation()
}
private func unlockOrientation(after delay: TimeInterval = 1.0) {
OrientationManager.shared.unlockOrientation(after: delay)
}
private func performSearch() {
Logger.shared.log("Searching for: \(searchText)", type: "General")
guard !searchText.isEmpty, let module = selectedModule else {
Logger.shared.log("Searching for: \(searchQuery)", type: "General")
guard !searchQuery.isEmpty, let module = selectedModule else {
searchItems = []
hasNoResults = false
return
}
addToSearchHistory(searchText)
isSearchFieldFocused = false
isSearching = true
hasNoResults = false
searchItems = []
lockOrientation()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
Task {
do {
let jsContent = try moduleManager.getModuleContent(module)
jsController.loadScript(jsContent)
if module.metadata.asyncJS == true {
jsController.fetchJsSearchResults(keyword: searchText, module: module) { items in
searchItems = items
hasNoResults = items.isEmpty
isSearching = false
jsController.fetchJsSearchResults(keyword: searchQuery, module: module) { items in
DispatchQueue.main.async {
searchItems = items
hasNoResults = items.isEmpty
isSearching = false
unlockOrientation(after: 3.0)
}
}
} else {
jsController.fetchSearchResults(keyword: searchText, module: module) { items in
searchItems = items
hasNoResults = items.isEmpty
isSearching = false
jsController.fetchSearchResults(keyword: searchQuery, module: module) { items in
DispatchQueue.main.async {
searchItems = items
hasNoResults = items.isEmpty
isSearching = false
unlockOrientation(after: 3.0)
}
}
}
} catch {
Logger.shared.log("Error loading module: \(error)", type: "Error")
isSearching = false
hasNoResults = true
DispatchQueue.main.async {
isSearching = false
hasNoResults = true
unlockOrientation(after: 3.0)
}
}
}
}
@ -444,10 +315,10 @@ struct SearchBar: View {
.padding(.horizontal, 25)
.background(Color(.systemGray6))
.cornerRadius(8)
.onChange(of: text){newValue in
.onChange(of: text) { newValue in
debounceTimer?.invalidate()
if !newValue.isEmpty {
debounceTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in
debounceTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: false) { _ in
onSearchButtonClicked()
}
}

View file

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

View file

@ -0,0 +1,60 @@
//
// SearchResultsGrid.swift
// Sora
//
// Created by paul on 28/05/25.
//
import SwiftUI
import Kingfisher
struct SearchResultsGrid: View {
let items: [SearchItem]
let columns: [GridItem]
let selectedModule: ScrapingModule
var body: some View {
LazyVGrid(columns: columns, spacing: 16) {
ForEach(items) { item in
NavigationLink(destination: MediaInfoView(title: item.title, imageUrl: item.imageUrl, href: item.href, module: selectedModule)) {
ZStack {
KFImage(URL(string: item.imageUrl))
.resizable()
.aspectRatio(0.72, contentMode: .fill)
.frame(width: 162, height: 243)
.cornerRadius(12)
.clipped()
VStack {
Spacer()
HStack {
Text(item.title)
.lineLimit(2)
.foregroundColor(.white)
.multilineTextAlignment(.leading)
Spacer()
}
.padding(12)
.background(
LinearGradient(
colors: [
.black.opacity(0.7),
.black.opacity(0.0)
],
startPoint: .bottom,
endPoint: .top
)
.shadow(color: .black, radius: 4, x: 0, y: 2)
)
}
.frame(width: 162)
}
.clipShape(RoundedRectangle(cornerRadius: 12))
.padding(4)
}
}
}
.padding(.top)
.padding()
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -8,70 +8,130 @@
import SwiftUI
import Kingfisher
fileprivate struct SettingsSection<Content: View>: View {
let title: String
let footer: String?
let content: Content
init(title: String, footer: String? = nil, @ViewBuilder content: () -> Content) {
self.title = title
self.footer = footer
self.content = content()
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(title.uppercased())
.font(.footnote)
.foregroundStyle(.gray)
.padding(.horizontal, 20)
VStack(spacing: 0) {
content
}
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.strokeBorder(
LinearGradient(
gradient: Gradient(stops: [
.init(color: Color.accentColor.opacity(0.3), location: 0),
.init(color: Color.accentColor.opacity(0), location: 1)
]),
startPoint: .top,
endPoint: .bottom
),
lineWidth: 0.5
)
)
.padding(.horizontal, 20)
if let footer = footer {
Text(footer)
.font(.footnote)
.foregroundStyle(.gray)
.padding(.horizontal, 20)
.padding(.top, 4)
}
}
}
}
struct SettingsViewAbout: View {
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "ALPHA"
var body: some View {
Form {
Section(footer: Text("Sora/Sulfur will always remain free with no ADs!")) {
HStack {
KFImage(URL(string: "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/dev/Sora/Assets.xcassets/AppIcons/AppIcon_Default.appiconset/darkmode.png"))
.placeholder {
ProgressView()
}
.resizable()
.frame(width: 100, height: 100)
.cornerRadius(20)
.shadow(radius: 5)
VStack(spacing: 8) {
Text("Sora")
.font(.title)
.bold()
Text("AKA Sulfur")
.font(.caption)
.foregroundColor(.secondary)
}
}
.listRowInsets(EdgeInsets())
.padding()
}
Section("Main Developer") {
Button(action: {
if let url = URL(string: "https://github.com/cranci1") {
UIApplication.shared.open(url)
}
}) {
HStack {
KFImage(URL(string: "https://avatars.githubusercontent.com/u/100066266?v=4"))
ScrollView {
VStack(spacing: 24) {
SettingsSection(title: "App Info", footer: "Sora/Sulfur will always remain free with no ADs!") {
HStack(alignment: .center, spacing: 16) {
KFImage(URL(string: "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/dev/Sora/Assets.xcassets/AppIcons/AppIcon_Default.appiconset/darkmode.png"))
.placeholder {
ProgressView()
}
.resizable()
.frame(width: 40, height: 40)
.clipShape(Circle())
.frame(width: 100, height: 100)
.cornerRadius(20)
.shadow(radius: 5)
VStack(alignment: .leading) {
Text("cranci1")
.font(.headline)
.foregroundColor(.indigo)
Text("me frfr")
VStack(alignment: .leading, spacing: 8) {
Text("Sora")
.font(.title)
.bold()
Text("AKA Sulfur")
.font(.caption)
.foregroundColor(.secondary)
Text("Version \(version)")
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
Image(systemName: "safari")
.foregroundColor(.indigo)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
SettingsSection(title: "Main Developer") {
Button(action: {
if let url = URL(string: "https://github.com/cranci1") {
UIApplication.shared.open(url)
}
}) {
HStack {
KFImage(URL(string: "https://avatars.githubusercontent.com/u/100066266?v=4"))
.placeholder {
ProgressView()
}
.resizable()
.frame(width: 40, height: 40)
.clipShape(Circle())
VStack(alignment: .leading) {
Text("cranci1")
.font(.headline)
.foregroundColor(.indigo)
Text("me frfr")
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
Image(systemName: "safari")
.foregroundColor(.indigo)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
}
SettingsSection(title: "Contributors") {
ContributorsView()
}
}
Section("Contributors") {
ContributorsView()
}
.padding(.vertical, 20)
}
.navigationTitle("About")
.scrollViewBottomPadding()
}
}
@ -88,12 +148,18 @@ struct ContributorsView: View {
ProgressView()
Spacer()
}
.padding(.vertical, 12)
} else if error != nil {
Text("Failed to load contributors")
.foregroundColor(.secondary)
.padding(.vertical, 12)
} else {
ForEach(filteredContributors) { contributor in
ContributorView(contributor: contributor)
if contributor.id != filteredContributors.last?.id {
Divider()
.padding(.horizontal, 16)
}
}
}
}
@ -152,12 +218,14 @@ struct ContributorView: View {
Text(contributor.login)
.font(.headline)
.foregroundColor(.accentColor)
.foregroundColor(contributor.login == "IBH-RAD" ? Color(hexTwo: "#41127b") : .accentColor)
Spacer()
Image(systemName: "safari")
.foregroundColor(.accentColor)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
}
}
@ -173,3 +241,29 @@ struct Contributor: Identifiable, Decodable {
case avatarUrl = "avatar_url"
}
}
extension Color {
init(hexTwo: String) {
let hexTwo = hexTwo.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
var int: UInt64 = 0
Scanner(string: hexTwo).scanHexInt64(&int)
let a, r, g, b: UInt64
switch hexTwo.count {
case 3: // RGB (12-bit)
(a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
case 6: // RGB (24-bit)
(a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
case 8: // ARGB (32-bit)
(a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
default:
(a, r, g, b) = (1, 1, 1, 0)
}
self.init(
.sRGB,
red: Double(r) / 255,
green: Double(g) / 255,
blue: Double(b) / 255,
opacity: Double(a) / 255
)
}
}

View file

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

View file

@ -7,6 +7,147 @@
import SwiftUI
fileprivate struct SettingsSection<Content: View>: View {
let title: String
let footer: String?
let content: Content
init(title: String, footer: String? = nil, @ViewBuilder content: () -> Content) {
self.title = title
self.footer = footer
self.content = content()
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(title.uppercased())
.font(.footnote)
.foregroundStyle(.gray)
.padding(.horizontal, 20)
VStack(spacing: 0) {
content
}
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.strokeBorder(
LinearGradient(
gradient: Gradient(stops: [
.init(color: Color.accentColor.opacity(0.3), location: 0),
.init(color: Color.accentColor.opacity(0), location: 1)
]),
startPoint: .top,
endPoint: .bottom
),
lineWidth: 0.5
)
)
.padding(.horizontal, 20)
if let footer = footer {
Text(footer)
.font(.footnote)
.foregroundStyle(.gray)
.padding(.horizontal, 20)
.padding(.top, 4)
}
}
}
}
fileprivate struct SettingsToggleRow: View {
let icon: String
let title: String
@Binding var isOn: Bool
var showDivider: Bool = true
init(icon: String, title: String, isOn: Binding<Bool>, showDivider: Bool = true) {
self.icon = icon
self.title = title
self._isOn = isOn
self.showDivider = showDivider
}
var body: some View {
VStack(spacing: 0) {
HStack {
Image(systemName: icon)
.frame(width: 24, height: 24)
.foregroundStyle(.primary)
Text(title)
.foregroundStyle(.primary)
Spacer()
Toggle("", isOn: $isOn)
.labelsHidden()
.tint(.accentColor.opacity(0.7))
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
if showDivider {
Divider()
.padding(.horizontal, 16)
}
}
}
}
fileprivate struct SettingsPickerRow<T: Hashable>: View {
let icon: String
let title: String
let options: [T]
let optionToString: (T) -> String
@Binding var selection: T
var showDivider: Bool = true
init(icon: String, title: String, options: [T], optionToString: @escaping (T) -> String, selection: Binding<T>, showDivider: Bool = true) {
self.icon = icon
self.title = title
self.options = options
self.optionToString = optionToString
self._selection = selection
self.showDivider = showDivider
}
var body: some View {
VStack(spacing: 0) {
HStack {
Image(systemName: icon)
.frame(width: 24, height: 24)
.foregroundStyle(.primary)
Text(title)
.foregroundStyle(.primary)
Spacer()
Menu {
ForEach(options, id: \.self) { option in
Button(action: { selection = option }) {
Text(optionToString(option))
}
}
} label: {
Text(optionToString(selection))
.foregroundStyle(.gray)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
if showDivider {
Divider()
.padding(.horizontal, 16)
}
}
}
}
struct SettingsViewGeneral: View {
@AppStorage("episodeChunkSize") private var episodeChunkSize: Int = 100
@AppStorage("refreshModulesOnLaunch") private var refreshModulesOnLaunch: Bool = false
@ -25,110 +166,131 @@ struct SettingsViewGeneral: View {
@State private var showAppIconPicker = false
var body: some View {
Form {
Section(header: Text("Interface")) {
ColorPicker("Accent Color", selection: $settings.accentColor)
HStack {
Text("Appearance")
Picker("Appearance", selection: $settings.selectedAppearance) {
Text("System").tag(Appearance.system)
Text("Light").tag(Appearance.light)
Text("Dark").tag(Appearance.dark)
}
.pickerStyle(SegmentedPickerStyle())
}
HStack {
Text("App Icon")
Spacer()
Button(action: {
showAppIconPicker.toggle()
}) {
Text(currentAppIcon.isEmpty ? "Default" : currentAppIcon)
.font(.body)
.foregroundColor(.accentColor)
}
}
}
Section(header: Text("Media View"), footer: Text("The episode range controls how many episodes appear on each page. Episodes are grouped into sets (like 1-25, 26-50, and so on), allowing you to navigate through them more easily.\n\nFor episode metadata it is refering to the episode thumbnail and title, since sometimes it can contain spoilers.")) {
HStack {
Text("Episodes Range")
Spacer()
Menu {
Button(action: { episodeChunkSize = 25 }) { Text("25") }
Button(action: { episodeChunkSize = 50 }) { Text("50") }
Button(action: { episodeChunkSize = 75 }) { Text("75") }
Button(action: { episodeChunkSize = 100 }) { Text("100") }
} label: {
Text("\(episodeChunkSize)")
}
}
Toggle("Fetch Episode metadata", isOn: $fetchEpisodeMetadata)
.tint(.accentColor)
HStack {
Text("Metadata Provider")
Spacer()
Menu(metadataProviders) {
ForEach(metadataProvidersList, id: \.self) { provider in
Button(action: { metadataProviders = provider }) {
Text(provider)
ScrollView {
VStack(spacing: 24) {
SettingsSection(title: "Interface") {
SettingsPickerRow(
icon: "paintbrush",
title: "Appearance",
options: [Appearance.system, .light, .dark],
optionToString: { appearance in
switch appearance {
case .system: return "System"
case .light: return "Light"
case .dark: return "Dark"
}
},
selection: $settings.selectedAppearance
)
VStack(spacing: 0) {
HStack {
Image(systemName: "app")
.frame(width: 24, height: 24)
.foregroundStyle(.primary)
Text("App Icon")
.foregroundStyle(.primary)
Spacer()
Button(action: {
showAppIconPicker = true
}) {
Text(currentAppIcon)
.foregroundStyle(.gray)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
}
}
Section(header: Text("Media Grid Layout"), footer: Text("Adjust the number of media items per row in portrait and landscape modes.")) {
HStack {
if UIDevice.current.userInterfaceIdiom == .pad {
Picker("Portrait Columns", selection: $mediaColumnsPortrait) {
ForEach(1..<6) { i in Text("\(i)").tag(i) }
}
.pickerStyle(MenuPickerStyle())
} else {
Picker("Portrait Columns", selection: $mediaColumnsPortrait) {
ForEach(1..<5) { i in Text("\(i)").tag(i) }
}
.pickerStyle(MenuPickerStyle())
}
SettingsSection(
title: "Media View",
footer: "The episode range controls how many episodes appear on each page. Episodes are grouped into sets (like 125, 2650, and so on), allowing you to navigate through them more easily.\n\nFor episode metadata, it refers to the episode thumbnail and title, since sometimes it can contain spoilers."
) {
SettingsPickerRow(
icon: "list.number",
title: "Episodes Range",
options: [25, 50, 75, 100],
optionToString: { "\($0)" },
selection: $episodeChunkSize
)
SettingsToggleRow(
icon: "info.circle",
title: "Fetch Episode metadata",
isOn: $fetchEpisodeMetadata
)
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)
}
}
}
}

View file

@ -6,25 +6,81 @@
//
import SwiftUI
import Kingfisher
fileprivate struct SettingsSection<Content: View>: View {
let title: String
let footer: String?
let content: Content
init(title: String, footer: String? = nil, @ViewBuilder content: () -> Content) {
self.title = title
self.footer = footer
self.content = content()
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(title.uppercased())
.font(.footnote)
.foregroundStyle(.gray)
.padding(.horizontal, 20)
VStack(spacing: 0) {
content
}
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.strokeBorder(
LinearGradient(
gradient: Gradient(stops: [
.init(color: Color.accentColor.opacity(0.3), location: 0),
.init(color: Color.accentColor.opacity(0), location: 1)
]),
startPoint: .top,
endPoint: .bottom
),
lineWidth: 0.5
)
)
.padding(.horizontal, 20)
if let footer = footer {
Text(footer)
.font(.footnote)
.foregroundStyle(.gray)
.padding(.horizontal, 20)
.padding(.top, 4)
}
}
.scrollViewBottomPadding()
}
}
struct SettingsViewLogger: View {
@State private var logs: String = ""
@StateObject private var filterViewModel = LogFilterViewModel.shared
var body: some View {
VStack {
ScrollView {
Text(logs)
.font(.footnote)
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.textSelection(.enabled)
}
.navigationTitle("Logs")
.onAppear {
logs = Logger.shared.getLogs()
ScrollView {
VStack(spacing: 24) {
SettingsSection(title: "Logs") {
Text(logs)
.font(.footnote)
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
.padding(.vertical, 12)
.textSelection(.enabled)
}
}
.padding(.vertical, 20)
}
.navigationTitle("Logs")
.onAppear {
logs = Logger.shared.getLogs()
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {

View file

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

View file

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

View file

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

View file

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

View file

@ -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
}
}

View file

@ -7,9 +7,24 @@
objects = {
/* Begin PBXBuildFile section */
0402DA0E2DE7AA01003BB42C /* TabBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0402DA0D2DE7A9FC003BB42C /* TabBarController.swift */; };
0402DA132DE7B5EC003BB42C /* SearchStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0402DA112DE7B5EC003BB42C /* SearchStateView.swift */; };
0402DA142DE7B5EC003BB42C /* SearchResultsGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0402DA102DE7B5EC003BB42C /* SearchResultsGrid.swift */; };
0402DA152DE7B5EC003BB42C /* SearchComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0402DA0F2DE7B5EC003BB42C /* SearchComponents.swift */; };
0402DA172DE7B7B8003BB42C /* SearchViewComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0402DA162DE7B7B8003BB42C /* SearchViewComponents.swift */; };
0457C5972DE7712A000AFBD9 /* DeviceScaleModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0457C5942DE7712A000AFBD9 /* DeviceScaleModifier.swift */; };
0457C59D2DE78267000AFBD9 /* BookmarkGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0457C59A2DE78267000AFBD9 /* BookmarkGridView.swift */; };
0457C59E2DE78267000AFBD9 /* BookmarkLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0457C59B2DE78267000AFBD9 /* BookmarkLink.swift */; };
0457C59F2DE78267000AFBD9 /* BookmarkGridItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0457C5992DE78267000AFBD9 /* BookmarkGridItemView.swift */; };
0457C5A12DE78385000AFBD9 /* BookmarksDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0457C5A02DE78385000AFBD9 /* BookmarksDetailView.swift */; };
04CD76DB2DE20F2200733536 /* AllWatching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04CD76DA2DE20F2200733536 /* AllWatching.swift */; };
04F08EDC2DE10BF3006B29D9 /* ProgressiveBlurView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F08EDB2DE10BEC006B29D9 /* ProgressiveBlurView.swift */; };
04F08EDF2DE10C1D006B29D9 /* TabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F08EDE2DE10C1A006B29D9 /* TabBar.swift */; };
04F08EE22DE10C40006B29D9 /* TabItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F08EE12DE10C27006B29D9 /* TabItem.swift */; };
04F08EE42DE10D6F006B29D9 /* AllBookmarks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F08EE32DE10D6B006B29D9 /* AllBookmarks.swift */; };
130A810B2DE5F32400614732 /* WatchTogether.swift in Sources */ = {isa = PBXBuildFile; fileRef = 130A810A2DE5F32400614732 /* WatchTogether.swift */; };
130C6BFA2D53AB1F00DC1432 /* SettingsViewData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 130C6BF92D53AB1F00DC1432 /* SettingsViewData.swift */; };
13103E8B2D58E028000F0673 /* View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13103E8A2D58E028000F0673 /* View.swift */; };
13103E8B2D58E028000F0673 /* ScrollViewBottomPadding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13103E8A2D58E028000F0673 /* ScrollViewBottomPadding.swift */; };
13103E8E2D58E04A000F0673 /* SkeletonCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13103E8D2D58E04A000F0673 /* SkeletonCell.swift */; };
131270172DC13A010093AA9C /* DownloadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 131270162DC13A010093AA9C /* DownloadManager.swift */; };
131845F92D47C62D00CA7A54 /* SettingsViewGeneral.swift in Sources */ = {isa = PBXBuildFile; fileRef = 131845F82D47C62D00CA7A54 /* SettingsViewGeneral.swift */; };
@ -89,10 +104,25 @@
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
0402DA0D2DE7A9FC003BB42C /* TabBarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarController.swift; sourceTree = "<group>"; };
0402DA0F2DE7B5EC003BB42C /* SearchComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchComponents.swift; sourceTree = "<group>"; };
0402DA102DE7B5EC003BB42C /* SearchResultsGrid.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsGrid.swift; sourceTree = "<group>"; };
0402DA112DE7B5EC003BB42C /* SearchStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchStateView.swift; sourceTree = "<group>"; };
0402DA162DE7B7B8003BB42C /* SearchViewComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewComponents.swift; sourceTree = "<group>"; };
0457C5942DE7712A000AFBD9 /* DeviceScaleModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceScaleModifier.swift; sourceTree = "<group>"; };
0457C5992DE78267000AFBD9 /* BookmarkGridItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkGridItemView.swift; sourceTree = "<group>"; };
0457C59A2DE78267000AFBD9 /* BookmarkGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkGridView.swift; sourceTree = "<group>"; };
0457C59B2DE78267000AFBD9 /* BookmarkLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkLink.swift; sourceTree = "<group>"; };
0457C5A02DE78385000AFBD9 /* BookmarksDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksDetailView.swift; sourceTree = "<group>"; };
04CD76DA2DE20F2200733536 /* AllWatching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllWatching.swift; sourceTree = "<group>"; };
04F08EDB2DE10BEC006B29D9 /* ProgressiveBlurView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressiveBlurView.swift; sourceTree = "<group>"; };
04F08EDE2DE10C1A006B29D9 /* TabBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBar.swift; sourceTree = "<group>"; };
04F08EE12DE10C27006B29D9 /* TabItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabItem.swift; sourceTree = "<group>"; };
04F08EE32DE10D6B006B29D9 /* AllBookmarks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllBookmarks.swift; sourceTree = "<group>"; };
130A810A2DE5F32400614732 /* WatchTogether.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = WatchTogether.swift; path = Sora/Utils/MediaPlayer/WatchTogether.swift; sourceTree = SOURCE_ROOT; };
130C6BF82D53A4C200DC1432 /* Sora.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Sora.entitlements; sourceTree = "<group>"; };
130C6BF92D53AB1F00DC1432 /* SettingsViewData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewData.swift; sourceTree = "<group>"; };
13103E8A2D58E028000F0673 /* View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = View.swift; sourceTree = "<group>"; };
13103E8A2D58E028000F0673 /* ScrollViewBottomPadding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollViewBottomPadding.swift; sourceTree = "<group>"; };
13103E8D2D58E04A000F0673 /* SkeletonCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SkeletonCell.swift; sourceTree = "<group>"; };
131270162DC13A010093AA9C /* DownloadManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadManager.swift; sourceTree = "<group>"; };
131845F82D47C62D00CA7A54 /* SettingsViewGeneral.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewGeneral.swift; sourceTree = "<group>"; };
@ -184,6 +214,61 @@
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
0402DA122DE7B5EC003BB42C /* SearchView */ = {
isa = PBXGroup;
children = (
0402DA162DE7B7B8003BB42C /* SearchViewComponents.swift */,
0402DA0F2DE7B5EC003BB42C /* SearchComponents.swift */,
0402DA102DE7B5EC003BB42C /* SearchResultsGrid.swift */,
0402DA112DE7B5EC003BB42C /* SearchStateView.swift */,
);
path = SearchView;
sourceTree = "<group>";
};
0457C5962DE7712A000AFBD9 /* ViewModifiers */ = {
isa = PBXGroup;
children = (
0457C5942DE7712A000AFBD9 /* DeviceScaleModifier.swift */,
);
path = ViewModifiers;
sourceTree = "<group>";
};
0457C59C2DE78267000AFBD9 /* BookmarkComponents */ = {
isa = PBXGroup;
children = (
0457C5A02DE78385000AFBD9 /* BookmarksDetailView.swift */,
0457C5992DE78267000AFBD9 /* BookmarkGridItemView.swift */,
0457C59A2DE78267000AFBD9 /* BookmarkGridView.swift */,
0457C59B2DE78267000AFBD9 /* BookmarkLink.swift */,
);
path = BookmarkComponents;
sourceTree = "<group>";
};
04F08EDA2DE10BE3006B29D9 /* ProgressiveBlurView */ = {
isa = PBXGroup;
children = (
04F08EDB2DE10BEC006B29D9 /* ProgressiveBlurView.swift */,
);
path = ProgressiveBlurView;
sourceTree = "<group>";
};
04F08EDD2DE10C05006B29D9 /* TabBar */ = {
isa = PBXGroup;
children = (
0402DA0D2DE7A9FC003BB42C /* TabBarController.swift */,
04F08EDE2DE10C1A006B29D9 /* TabBar.swift */,
);
path = TabBar;
sourceTree = "<group>";
};
04F08EE02DE10C22006B29D9 /* Models */ = {
isa = PBXGroup;
children = (
04F08EE12DE10C27006B29D9 /* TabItem.swift */,
);
path = Models;
sourceTree = "<group>";
};
13103E802D589D6C000F0673 /* Tracking Services */ = {
isa = PBXGroup;
children = (
@ -264,6 +349,7 @@
isa = PBXGroup;
children = (
72443C7C2DC8036500A61321 /* DownloadView.swift */,
0402DA122DE7B5EC003BB42C /* SearchView */,
133D7C7F2D2BE2630075467E /* MediaInfoView */,
1399FAD22D3AB34F00E97C31 /* SettingsView */,
133F55B92D33B53E00E08EEA /* LibraryView */,
@ -301,6 +387,10 @@
133D7C852D2BE2640075467E /* Utils */ = {
isa = PBXGroup;
children = (
0457C5962DE7712A000AFBD9 /* ViewModifiers */,
04F08EE02DE10C22006B29D9 /* Models */,
04F08EDD2DE10C05006B29D9 /* TabBar */,
04F08EDA2DE10BE3006B29D9 /* ProgressiveBlurView */,
7205AEDA2DCCEF9500943F3F /* Cache */,
13D842532D45266900EBBFA6 /* Drops */,
1399FAD12D3AB33D00E97C31 /* Logger */,
@ -326,7 +416,7 @@
133D7C872D2BE2640075467E /* URLSession.swift */,
1359ED132D76F49900C13034 /* finTopView.swift */,
13CBEFD92D5F7D1200D011EE /* String.swift */,
13103E8A2D58E028000F0673 /* View.swift */,
13103E8A2D58E028000F0673 /* ScrollViewBottomPadding.swift */,
13DB468F2D900A38008CBC03 /* URL.swift */,
);
path = Extensions;
@ -358,6 +448,9 @@
133F55B92D33B53E00E08EEA /* LibraryView */ = {
isa = PBXGroup;
children = (
0457C59C2DE78267000AFBD9 /* BookmarkComponents */,
04CD76DA2DE20F2200733536 /* AllWatching.swift */,
04F08EE32DE10D6B006B29D9 /* AllBookmarks.swift */,
133F55BA2D33B55100E08EEA /* LibraryManager.swift */,
133D7C7E2D2BE2630075467E /* LibraryView.swift */,
);
@ -626,15 +719,20 @@
1EF5C3A92DB988E40032BF07 /* CommunityLib.swift in Sources */,
13CBEFDA2D5F7D1200D011EE /* String.swift in Sources */,
7205AEDB2DCCEF9500943F3F /* EpisodeMetadata.swift in Sources */,
04F08EDC2DE10BF3006B29D9 /* ProgressiveBlurView.swift in Sources */,
7205AEDC2DCCEF9500943F3F /* MetadataCacheManager.swift in Sources */,
7205AEDD2DCCEF9500943F3F /* KingfisherManager.swift in Sources */,
1E26E9E72DA9577900B9DC02 /* VolumeSlider.swift in Sources */,
136BBE802DB1038000906B5E /* Notification+Name.swift in Sources */,
13DB46902D900A38008CBC03 /* URL.swift in Sources */,
04F08EE42DE10D6F006B29D9 /* AllBookmarks.swift in Sources */,
130C6BFA2D53AB1F00DC1432 /* SettingsViewData.swift in Sources */,
0402DA172DE7B7B8003BB42C /* SearchViewComponents.swift in Sources */,
1E9FF1D32D403E49008AC100 /* SettingsViewLoggerFilter.swift in Sources */,
0402DA0E2DE7AA01003BB42C /* TabBarController.swift in Sources */,
13EA2BD92D32D98400C1EBD7 /* NormalPlayer.swift in Sources */,
133D7C932D2BE2640075467E /* Modules.swift in Sources */,
0457C5972DE7712A000AFBD9 /* DeviceScaleModifier.swift in Sources */,
13DB7CC32D7D99C0004371D3 /* SubtitleSettingsManager.swift in Sources */,
133D7C702D2BE2500075467E /* ContentView.swift in Sources */,
13D99CF72D4E73C300250A86 /* ModuleAdditionSettingsView.swift in Sources */,
@ -642,11 +740,13 @@
13CBA0882D60F19C00EFE70A /* VTTSubtitlesLoader.swift in Sources */,
13EA2BD62D32D97400C1EBD7 /* Double+Extension.swift in Sources */,
133D7C8F2D2BE2640075467E /* MediaInfoView.swift in Sources */,
04F08EE22DE10C40006B29D9 /* TabItem.swift in Sources */,
132AF1212D99951700A0140B /* JSController-Streams.swift in Sources */,
131845F92D47C62D00CA7A54 /* SettingsViewGeneral.swift in Sources */,
04CD76DB2DE20F2200733536 /* AllWatching.swift in Sources */,
13103E8E2D58E04A000F0673 /* SkeletonCell.swift in Sources */,
13D842552D45267500EBBFA6 /* DropManager.swift in Sources */,
13103E8B2D58E028000F0673 /* View.swift in Sources */,
13103E8B2D58E028000F0673 /* ScrollViewBottomPadding.swift in Sources */,
72443C7D2DC8036500A61321 /* DownloadView.swift in Sources */,
1327FBA92D758DEA00FC6689 /* UIDevice+Model.swift in Sources */,
138AA1B82D2D66FD0021F9DF /* EpisodeCell.swift in Sources */,
@ -658,6 +758,7 @@
722248642DCBAA4700CABE2D /* JSController+M3U8Download.swift in Sources */,
133D7C942D2BE2640075467E /* JSController.swift in Sources */,
133D7C922D2BE2640075467E /* URLSession.swift in Sources */,
0457C5A12DE78385000AFBD9 /* BookmarksDetailView.swift in Sources */,
133D7C912D2BE2640075467E /* SettingsViewModule.swift in Sources */,
13E62FC22DABC5830007E259 /* Trakt-Login.swift in Sources */,
133F55BB2D33B55100E08EEA /* LibraryManager.swift in Sources */,
@ -676,7 +777,11 @@
727220722DD642B100C2A4A2 /* JSController+MP4Download.swift in Sources */,
132FC5B32DE31DAE009A80F7 /* SettingsViewAlternateAppIconPicker.swift in Sources */,
1EA64DCD2DE5030100AC14BC /* ImageUpscaler.swift in Sources */,
0402DA132DE7B5EC003BB42C /* SearchStateView.swift in Sources */,
0402DA142DE7B5EC003BB42C /* SearchResultsGrid.swift in Sources */,
0402DA152DE7B5EC003BB42C /* SearchComponents.swift in Sources */,
1399FAD42D3AB38C00E97C31 /* SettingsViewLogger.swift in Sources */,
04F08EDF2DE10C1D006B29D9 /* TabBar.swift in Sources */,
72AC3A012DD4DAEB00C60B96 /* ImagePrefetchManager.swift in Sources */,
72AC3A022DD4DAEB00C60B96 /* EpisodeMetadataManager.swift in Sources */,
72AC3A032DD4DAEB00C60B96 /* PerformanceMonitor.swift in Sources */,
@ -686,6 +791,9 @@
722248602DCBAA2C00CABE2D /* M3U8StreamExtractor.swift in Sources */,
13C0E5EA2D5F85EA00E7F619 /* ContinueWatchingManager.swift in Sources */,
13637B8A2DE0EA1100BDA2FC /* UserDefaults.swift in Sources */,
0457C59D2DE78267000AFBD9 /* BookmarkGridView.swift in Sources */,
0457C59E2DE78267000AFBD9 /* BookmarkLink.swift in Sources */,
0457C59F2DE78267000AFBD9 /* BookmarkGridItemView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};