diff --git a/Sora/Assets.xcassets/AppIcon.appiconset/Contents.json b/Sora/Assets.xcassets/AppIcon_Default.appiconset/Contents.json
similarity index 100%
rename from Sora/Assets.xcassets/AppIcon.appiconset/Contents.json
rename to Sora/Assets.xcassets/AppIcon_Default.appiconset/Contents.json
diff --git a/Sora/Assets.xcassets/AppIcon.appiconset/darkmode.png b/Sora/Assets.xcassets/AppIcon_Default.appiconset/darkmode.png
similarity index 100%
rename from Sora/Assets.xcassets/AppIcon.appiconset/darkmode.png
rename to Sora/Assets.xcassets/AppIcon_Default.appiconset/darkmode.png
diff --git a/Sora/Assets.xcassets/AppIcon.appiconset/lightmode.png b/Sora/Assets.xcassets/AppIcon_Default.appiconset/lightmode.png
similarity index 100%
rename from Sora/Assets.xcassets/AppIcon.appiconset/lightmode.png
rename to Sora/Assets.xcassets/AppIcon_Default.appiconset/lightmode.png
diff --git a/Sora/Assets.xcassets/AppIcon.appiconset/tinting.png b/Sora/Assets.xcassets/AppIcon_Default.appiconset/tinting.png
similarity index 100%
rename from Sora/Assets.xcassets/AppIcon.appiconset/tinting.png
rename to Sora/Assets.xcassets/AppIcon_Default.appiconset/tinting.png
diff --git a/Sora/Assets.xcassets/AppIcon_Default_Preview.imageset/Contents.json b/Sora/Assets.xcassets/AppIcon_Default_Preview.imageset/Contents.json
new file mode 100644
index 0000000..b85e601
--- /dev/null
+++ b/Sora/Assets.xcassets/AppIcon_Default_Preview.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "preview.png",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Sora/Assets.xcassets/AppIcon_Default_Preview.imageset/preview.png b/Sora/Assets.xcassets/AppIcon_Default_Preview.imageset/preview.png
new file mode 100644
index 0000000..2f2be91
Binary files /dev/null and b/Sora/Assets.xcassets/AppIcon_Default_Preview.imageset/preview.png differ
diff --git a/Sora/Assets.xcassets/AppIcon_Original.appiconset/Contents.json b/Sora/Assets.xcassets/AppIcon_Original.appiconset/Contents.json
new file mode 100644
index 0000000..adc0a20
--- /dev/null
+++ b/Sora/Assets.xcassets/AppIcon_Original.appiconset/Contents.json
@@ -0,0 +1,14 @@
+{
+ "images" : [
+ {
+ "filename" : "original.png",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Sora/Assets.xcassets/AppIcon_Original.appiconset/original.png b/Sora/Assets.xcassets/AppIcon_Original.appiconset/original.png
new file mode 100644
index 0000000..c45e62a
Binary files /dev/null and b/Sora/Assets.xcassets/AppIcon_Original.appiconset/original.png differ
diff --git a/Sora/Assets.xcassets/AppIcon_Original_Preview.imageset/Contents.json b/Sora/Assets.xcassets/AppIcon_Original_Preview.imageset/Contents.json
new file mode 100644
index 0000000..b85e601
--- /dev/null
+++ b/Sora/Assets.xcassets/AppIcon_Original_Preview.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "preview.png",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Sora/Assets.xcassets/AppIcon_Original_Preview.imageset/preview.png b/Sora/Assets.xcassets/AppIcon_Original_Preview.imageset/preview.png
new file mode 100644
index 0000000..460e487
Binary files /dev/null and b/Sora/Assets.xcassets/AppIcon_Original_Preview.imageset/preview.png differ
diff --git a/Sora/Assets.xcassets/AppIcon_Pixel.appiconset/Contents.json b/Sora/Assets.xcassets/AppIcon_Pixel.appiconset/Contents.json
new file mode 100644
index 0000000..c7a15f9
--- /dev/null
+++ b/Sora/Assets.xcassets/AppIcon_Pixel.appiconset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "images" : [
+ {
+ "filename" : "lightmode.png",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "filename" : "darkmode.png",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "tinted"
+ }
+ ],
+ "filename" : "tinting.png",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Sora/Assets.xcassets/AppIcon_Pixel.appiconset/darkmode.png b/Sora/Assets.xcassets/AppIcon_Pixel.appiconset/darkmode.png
new file mode 100755
index 0000000..a84929a
Binary files /dev/null and b/Sora/Assets.xcassets/AppIcon_Pixel.appiconset/darkmode.png differ
diff --git a/Sora/Assets.xcassets/AppIcon_Pixel.appiconset/lightmode.png b/Sora/Assets.xcassets/AppIcon_Pixel.appiconset/lightmode.png
new file mode 100755
index 0000000..7df252d
Binary files /dev/null and b/Sora/Assets.xcassets/AppIcon_Pixel.appiconset/lightmode.png differ
diff --git a/Sora/Assets.xcassets/AppIcon_Pixel.appiconset/tinting.png b/Sora/Assets.xcassets/AppIcon_Pixel.appiconset/tinting.png
new file mode 100755
index 0000000..0adc458
Binary files /dev/null and b/Sora/Assets.xcassets/AppIcon_Pixel.appiconset/tinting.png differ
diff --git a/Sora/Assets.xcassets/AppIcon_Pixel_Preview.imageset/Contents.json b/Sora/Assets.xcassets/AppIcon_Pixel_Preview.imageset/Contents.json
new file mode 100644
index 0000000..b85e601
--- /dev/null
+++ b/Sora/Assets.xcassets/AppIcon_Pixel_Preview.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "preview.png",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Sora/Assets.xcassets/AppIcon_Pixel_Preview.imageset/preview.png b/Sora/Assets.xcassets/AppIcon_Pixel_Preview.imageset/preview.png
new file mode 100644
index 0000000..1a5a0c8
Binary files /dev/null and b/Sora/Assets.xcassets/AppIcon_Pixel_Preview.imageset/preview.png differ
diff --git a/Sora/Assets.xcassets/AppIcon_Pride.appiconset/Contents.json b/Sora/Assets.xcassets/AppIcon_Pride.appiconset/Contents.json
new file mode 100644
index 0000000..c7a15f9
--- /dev/null
+++ b/Sora/Assets.xcassets/AppIcon_Pride.appiconset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "images" : [
+ {
+ "filename" : "lightmode.png",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "filename" : "darkmode.png",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "tinted"
+ }
+ ],
+ "filename" : "tinting.png",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Sora/Assets.xcassets/AppIcon_Pride.appiconset/darkmode.png b/Sora/Assets.xcassets/AppIcon_Pride.appiconset/darkmode.png
new file mode 100755
index 0000000..106c655
Binary files /dev/null and b/Sora/Assets.xcassets/AppIcon_Pride.appiconset/darkmode.png differ
diff --git a/Sora/Assets.xcassets/AppIcon_Pride.appiconset/lightmode.png b/Sora/Assets.xcassets/AppIcon_Pride.appiconset/lightmode.png
new file mode 100755
index 0000000..451a2e8
Binary files /dev/null and b/Sora/Assets.xcassets/AppIcon_Pride.appiconset/lightmode.png differ
diff --git a/Sora/Assets.xcassets/AppIcon_Pride.appiconset/tinting.png b/Sora/Assets.xcassets/AppIcon_Pride.appiconset/tinting.png
new file mode 100755
index 0000000..d911fdb
Binary files /dev/null and b/Sora/Assets.xcassets/AppIcon_Pride.appiconset/tinting.png differ
diff --git a/Sora/Assets.xcassets/AppIcon_Pride_Preview.imageset/Contents.json b/Sora/Assets.xcassets/AppIcon_Pride_Preview.imageset/Contents.json
new file mode 100644
index 0000000..b85e601
--- /dev/null
+++ b/Sora/Assets.xcassets/AppIcon_Pride_Preview.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "preview.png",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Sora/Assets.xcassets/AppIcon_Pride_Preview.imageset/preview.png b/Sora/Assets.xcassets/AppIcon_Pride_Preview.imageset/preview.png
new file mode 100644
index 0000000..7a6110b
Binary files /dev/null and b/Sora/Assets.xcassets/AppIcon_Pride_Preview.imageset/preview.png differ
diff --git a/Sora/Info.plist b/Sora/Info.plist
index cf823a6..df1d517 100644
--- a/Sora/Info.plist
+++ b/Sora/Info.plist
@@ -2,8 +2,6 @@
- NSCameraUsageDescription
- Sora may requires access to your device's camera.
BGTaskSchedulerPermittedIdentifiers
$(PRODUCT_BUNDLE_IDENTIFIER)
@@ -38,7 +36,5 @@
audio
processing
- UISupportsDocumentBrowser
-
diff --git a/Sora/Sora.entitlements b/Sora/Sora.entitlements
index d90dbc3..da83a05 100644
--- a/Sora/Sora.entitlements
+++ b/Sora/Sora.entitlements
@@ -4,7 +4,7 @@
com.apple.developer.icloud-container-identifiers
- iCloud.me.cranci.sora.icloud
+ iCloud.de.devsforge.sulfur.fork
com.apple.developer.icloud-services
@@ -12,7 +12,7 @@
com.apple.developer.ubiquity-container-identifiers
- iCloud.me.cranci.sora.icloud
+ iCloud.de.devsforge.sulfur.fork
com.apple.developer.ubiquity-kvstore-identifier
$(TeamIdentifierPrefix)$(CFBundleIdentifier)
diff --git a/Sora/SoraApp.swift b/Sora/SoraApp.swift
index 241f442..4fc513d 100644
--- a/Sora/SoraApp.swift
+++ b/Sora/SoraApp.swift
@@ -11,11 +11,11 @@ import SwiftUI
struct SoraApp: App {
@StateObject private var settings = Settings()
@StateObject private var moduleManager = ModuleManager()
- @StateObject private var librarykManager = LibraryManager()
-
+ @StateObject private var profileStore = ProfileStore()
+ @StateObject private var libraryManager = LibraryManager()
+ @StateObject private var continueWatchingManager = ContinueWatchingManager()
+
init() {
- _ = iCloudSyncManager.shared
-
TraktToken.checkAuthenticationStatus { isAuthenticated in
if isAuthenticated {
Logger.shared.log("Trakt authentication is valid")
@@ -27,12 +27,21 @@ struct SoraApp: App {
var body: some Scene {
WindowGroup {
- ContentView()
+ RootView()
.environmentObject(moduleManager)
.environmentObject(settings)
- .environmentObject(librarykManager)
+ .environmentObject(libraryManager)
+ .environmentObject(continueWatchingManager)
+ .environmentObject(profileStore)
.accentColor(settings.accentColor)
.onAppear {
+ // pass initial profile value to other manager
+ let suite = self.profileStore.getUserDefaultsSuite()
+ self.libraryManager.userDefaultsSuite = suite
+ self.continueWatchingManager.userDefaultsSuite = suite
+
+ _ = iCloudSyncManager.shared
+
settings.updateAppearance()
iCloudSyncManager.shared.syncModulesFromiCloud()
Task {
@@ -48,6 +57,12 @@ struct SoraApp: App {
handleURL(url)
}
}
+ .onChange(of: profileStore.currentProfile) { _ in
+ // pass changed suite value to other manager
+ let suite = self.profileStore.getUserDefaultsSuite()
+ libraryManager.updateProfileSuite(suite)
+ continueWatchingManager.updateProfileSuite(suite)
+ }
}
}
diff --git a/Sora/Utils/ContinueWatching/ContinueWatchingManager.swift b/Sora/Utils/ContinueWatching/ContinueWatchingManager.swift
index 99a7adb..4b66a3d 100644
--- a/Sora/Utils/ContinueWatching/ContinueWatchingManager.swift
+++ b/Sora/Utils/ContinueWatching/ContinueWatchingManager.swift
@@ -5,16 +5,22 @@
// Created by Francesco on 14/02/25.
//
-import Foundation
+import SwiftUI
-class ContinueWatchingManager {
- static let shared = ContinueWatchingManager()
+class ContinueWatchingManager: ObservableObject {
+ var userDefaultsSuite = UserDefaults.standard
+ @Published var items: [ContinueWatchingItem] = []
private let storageKey = "continueWatchingItems"
-
- private init() {
+
+ init() {
NotificationCenter.default.addObserver(self, selector: #selector(handleiCloudSync), name: .iCloudSyncDidComplete, object: nil)
}
-
+
+ public func updateProfileSuite(_ newSuite: UserDefaults) {
+ userDefaultsSuite = newSuite
+ loadItems()
+ }
+
@objc private func handleiCloudSync() {
NotificationCenter.default.post(name: .ContinueWatchingDidUpdate, object: nil)
}
@@ -24,31 +30,33 @@ class ContinueWatchingManager {
remove(item: item)
return
}
-
- var items = fetchItems()
- if let index = items.firstIndex(where: { $0.streamUrl == item.streamUrl && $0.episodeNumber == item.episodeNumber }) {
+
+ if let index = items.firstIndex(where: {
+ $0.streamUrl == item.streamUrl &&
+ $0.episodeNumber == item.episodeNumber }) {
items[index] = item
} else {
items.append(item)
}
+
if let data = try? JSONEncoder().encode(items) {
- UserDefaults.standard.set(data, forKey: storageKey)
+ userDefaultsSuite.set(data, forKey: storageKey)
}
}
- func fetchItems() -> [ContinueWatchingItem] {
- if let data = UserDefaults.standard.data(forKey: storageKey),
- let items = try? JSONDecoder().decode([ContinueWatchingItem].self, from: data) {
- return items
+ func loadItems() {
+ if let data = userDefaultsSuite.data(forKey: storageKey),
+ let parsedItems = try? JSONDecoder().decode([ContinueWatchingItem].self, from: data) {
+ items = parsedItems
+ } else {
+ items = []
}
- return []
}
func remove(item: ContinueWatchingItem) {
- var items = fetchItems()
items.removeAll { $0.id == item.id }
if let data = try? JSONEncoder().encode(items) {
- UserDefaults.standard.set(data, forKey: storageKey)
+ userDefaultsSuite.set(data, forKey: storageKey)
}
}
}
diff --git a/Sora/Utils/Extensions/UIKit.swift b/Sora/Utils/Extensions/UIKit.swift
new file mode 100644
index 0000000..e54169d
--- /dev/null
+++ b/Sora/Utils/Extensions/UIKit.swift
@@ -0,0 +1,21 @@
+//
+// UIApplication.swift
+// Sulfur
+//
+// Created by Dominic on 21.04.25.
+//
+
+import UIKit
+
+extension UIApplication {
+
+ func dismissKeyboard(_ force: Bool) {
+ if #unavailable(iOS 15) {
+ windows.first?.endEditing(force)
+ } else {
+ guard let windowScene = connectedScenes.first as? UIWindowScene else { return }
+ windowScene.windows.first?.endEditing(force)
+ }
+ }
+
+}
diff --git a/Sora/Utils/Extensions/URLSession.swift b/Sora/Utils/Extensions/URLSession.swift
index efa4fcb..b19ee75 100644
--- a/Sora/Utils/Extensions/URLSession.swift
+++ b/Sora/Utils/Extensions/URLSession.swift
@@ -63,6 +63,7 @@ extension URLSession {
configuration.httpAdditionalHeaders = ["User-Agent": randomUserAgent]
return URLSession(configuration: configuration)
}()
+
// return url session that redirects based on input
static func fetchData(allowRedirects:Bool) -> URLSession
{
diff --git a/Sora/Utils/Extensions/View.swift b/Sora/Utils/Extensions/View.swift
index 7c9d375..46db1cb 100644
--- a/Sora/Utils/Extensions/View.swift
+++ b/Sora/Utils/Extensions/View.swift
@@ -9,6 +9,6 @@ import SwiftUI
extension View {
func shimmering() -> some View {
- self.modifier(Shimmer())
+ self.modifier(ShimmeringEffect())
}
}
diff --git a/Sora/Utils/MediaPlayer/VideoPlayer.swift b/Sora/Utils/MediaPlayer/VideoPlayer.swift
index 122c466..ebd2e6c 100644
--- a/Sora/Utils/MediaPlayer/VideoPlayer.swift
+++ b/Sora/Utils/MediaPlayer/VideoPlayer.swift
@@ -10,7 +10,8 @@ import AVKit
class VideoPlayerViewController: UIViewController {
let module: ScrapingModule
-
+ let continueWatchingManager: ContinueWatchingManager
+
var player: AVPlayer?
var playerViewController: NormalPlayer?
var timeObserverToken: Any?
@@ -23,8 +24,9 @@ class VideoPlayerViewController: UIViewController {
var episodeImageUrl: String = ""
var mediaTitle: String = ""
- init(module: ScrapingModule) {
+ init(module: ScrapingModule, continueWatchingManager: ContinueWatchingManager) {
self.module = module
+ self.continueWatchingManager = continueWatchingManager
super.init(nibName: nil, bundle: nil)
}
@@ -42,7 +44,7 @@ class VideoPlayerViewController: UIViewController {
var request = URLRequest(url: url)
request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Referer")
request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Origin")
- request.addValue("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36", forHTTPHeaderField: "User-Agent")
+ request.addValue(URLSession.randomUserAgent, forHTTPHeaderField: "User-Agent")
let asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": request.allHTTPHeaderFields ?? [:]])
let playerItem = AVPlayerItem(asset: asset)
@@ -129,7 +131,7 @@ class VideoPlayerViewController: UIViewController {
aniListID: self.aniListID,
module: self.module
)
- ContinueWatchingManager.shared.save(item: item)
+ continueWatchingManager.save(item: item)
}
let remainingPercentage = (duration - currentTime) / duration
diff --git a/Sora/Utils/ProfileStore/Profile.swift b/Sora/Utils/ProfileStore/Profile.swift
new file mode 100644
index 0000000..4043b48
--- /dev/null
+++ b/Sora/Utils/ProfileStore/Profile.swift
@@ -0,0 +1,14 @@
+//
+// Profile.swift
+// Sulfur
+//
+// Created by Dominic on 21.04.25.
+//
+
+import Foundation
+
+struct Profile: Identifiable, Equatable, Codable {
+ var id: UUID = UUID()
+ var name: String
+ var emoji: String
+}
diff --git a/Sora/Utils/ProfileStore/ProfileButtonStyle.swift b/Sora/Utils/ProfileStore/ProfileButtonStyle.swift
new file mode 100644
index 0000000..3cfb216
--- /dev/null
+++ b/Sora/Utils/ProfileStore/ProfileButtonStyle.swift
@@ -0,0 +1,14 @@
+//
+// ProfileButtonStyle.swift
+// Sulfur
+//
+// Created by Dominic on 21.04.25.
+//
+
+import SwiftUI
+
+struct ProfileButtonStyle: ButtonStyle {
+ func makeBody(configuration: Self.Configuration) -> some View {
+ configuration.label
+ }
+}
diff --git a/Sora/Utils/ProfileStore/ProfileStore.swift b/Sora/Utils/ProfileStore/ProfileStore.swift
new file mode 100644
index 0000000..af19f63
--- /dev/null
+++ b/Sora/Utils/ProfileStore/ProfileStore.swift
@@ -0,0 +1,96 @@
+//
+// ProfileStore.swift
+// Sulfur
+//
+// Created by Dominic on 21.04.25.
+//
+
+import SwiftUI
+
+class ProfileStore: ObservableObject {
+ @AppStorage("profilesData") private var profilesData: Data = Data()
+ @AppStorage("currentProfileID") private var currentProfileID: String = ""
+
+ @Published public var profiles: [Profile] = []
+ @Published public var currentProfile: Profile!
+
+ public init() {
+ profiles = (try? JSONDecoder().decode([Profile].self, from: profilesData)) ?? []
+
+ if profiles.isEmpty {
+
+ // load default value
+ let defaultProfile = Profile(name: "Default User", emoji: "๐ค")
+ profiles = [defaultProfile]
+
+ saveProfiles()
+ setCurrentProfile(defaultProfile)
+ } else {
+
+ // load current profile
+ if let uuid = UUID(uuidString: currentProfileID),
+ let match = profiles.first(where: { $0.id == uuid }) {
+ currentProfile = match
+ } else if let firstProfile = profiles.first {
+ currentProfile = firstProfile
+ } else {
+ fatalError("profiles Array is not empty, but no profile was found")
+ }
+ }
+ }
+
+ public func getUserDefaultsSuite() -> UserDefaults {
+ guard let suite = UserDefaults(suiteName: currentProfile.id.uuidString) else {
+ fatalError("This can only fail if suiteName == app bundle id ...")
+ }
+
+ Logger.shared.log("loaded UserDefaults suite for \(currentProfile.name) (\(currentProfile.id.uuidString))", type: "Profile")
+
+ return suite
+ }
+
+ private func saveProfiles() {
+ profilesData = (try? JSONEncoder().encode(profiles)) ?? Data()
+ }
+
+ public func setCurrentProfile(_ profile: Profile) {
+ currentProfile = profile
+ currentProfileID = profile.id.uuidString
+ }
+
+ public func addProfile(name: String, emoji: String) {
+ let newProfile = Profile(name: name, emoji: emoji)
+ profiles.append(newProfile)
+
+ saveProfiles()
+ setCurrentProfile(newProfile)
+ }
+
+ public func editCurrentProfile(name: String, emoji: String) {
+ guard let index = profiles.firstIndex(where: { $0.id == currentProfile.id }) else { return }
+ profiles[index].name = name
+ profiles[index].emoji = emoji
+
+ saveProfiles()
+ setCurrentProfile(profiles[index])
+ }
+
+ public func deleteCurrentProfile() {
+ if (profiles.count == 1) { return }
+
+ if let suite = UserDefaults(suiteName: currentProfile.id.uuidString) {
+ for key in suite.dictionaryRepresentation().keys {
+ suite.removeObject(forKey: key)
+ }
+ }
+
+ profiles.removeAll { $0.id == currentProfile.id }
+
+ if let firstProfile = profiles.first {
+ saveProfiles()
+ setCurrentProfile(firstProfile)
+ } else {
+ fatalError("There should still be one Profile left")
+ }
+ }
+}
diff --git a/Sora/Utils/SkeletonCells/Shimmer.swift b/Sora/Utils/SkeletonCells/Shimmer.swift
index e7a2984..7d506ab 100644
--- a/Sora/Utils/SkeletonCells/Shimmer.swift
+++ b/Sora/Utils/SkeletonCells/Shimmer.swift
@@ -7,9 +7,29 @@
import SwiftUI
-struct Shimmer: ViewModifier {
+enum ShimmerType: String, CaseIterable, Identifiable {
+ case shimmer, pulse, none
+ var id: String { self.rawValue }
+}
+
+struct ShimmeringEffect: ViewModifier {
+ @EnvironmentObject var settings: Settings
+
+ func body(content: Content) -> some View {
+ switch settings.shimmerType {
+ case .pulse:
+ return AnyView(content.modifier(ShimmerPulse()))
+ case .shimmer:
+ return AnyView(content.modifier(ShimmerDefault()))
+ default:
+ return AnyView(content.modifier(ShimmerNone()))
+ }
+ }
+}
+
+struct ShimmerDefault: ViewModifier {
@State private var phase: CGFloat = 0
-
+
func body(content: Content) -> some View {
content
.overlay(
@@ -32,3 +52,41 @@ struct Shimmer: ViewModifier {
}
}
}
+
+struct ShimmerPulse: ViewModifier {
+ @Environment(\.colorScheme) var colorScheme
+ @State private var opacity: Double = 0.3
+
+ func body(content: Content) -> some View {
+ content
+ .overlay(
+ (colorScheme == .light ?
+ Color.black.opacity(opacity) :
+ Color.white.opacity(opacity)
+ )
+ .blendMode(.overlay)
+ )
+ .mask(content)
+ .onAppear {
+ withAnimation(.easeInOut(duration: 0.8).repeatForever(autoreverses: true)) {
+ self.opacity = 0.8
+ }
+ }
+ }
+}
+
+struct ShimmerNone: ViewModifier {
+ @Environment(\.colorScheme) var colorScheme
+
+ func body(content: Content) -> some View {
+ content
+ .overlay(
+ (colorScheme == .light ?
+ Color.black.opacity(0.3) :
+ Color.white.opacity(0.3)
+ )
+ .blendMode(.overlay)
+ )
+ .mask(content)
+ }
+}
diff --git a/Sora/Utils/iCloudSyncManager/iCloudSyncManager.swift b/Sora/Utils/iCloudSyncManager/iCloudSyncManager.swift
index 5a7e768..21f08b5 100644
--- a/Sora/Utils/iCloudSyncManager/iCloudSyncManager.swift
+++ b/Sora/Utils/iCloudSyncManager/iCloudSyncManager.swift
@@ -7,6 +7,21 @@
import UIKit
+// TODO: sync all profile user default suits
+/*
+ profileStore.profiles.forEach { profile in
+ let suite = UserDefaults(suiteName: profile.id.uuidString)
+ ...
+ }
+ */
+
+// TODO: add migration for legacy app users without profiles
+/*
+ add all bookmarks and continue watching items to the first profile ?!
+ */
+
+// TODO: update "clear data" feature
+// TODO: tests
class iCloudSyncManager {
static let shared = iCloudSyncManager()
@@ -30,8 +45,11 @@ class iCloudSyncManager {
"analyticsEnabled",
"refreshModulesOnLaunch",
"fetchEpisodeMetadata",
+ "hideEmptySections",
"multiThreads",
- "metadataProviders"
+ "metadataProviders",
+ "profilesData",
+ "currentProfileID"
]
private let modulesFileName = "modules.json"
diff --git a/Sora/Views/DownloadView.swift b/Sora/Views/DownloadView/DownloadView.swift
similarity index 100%
rename from Sora/Views/DownloadView.swift
rename to Sora/Views/DownloadView/DownloadView.swift
diff --git a/Sora/Views/ExploreView/ExploreView.swift b/Sora/Views/ExploreView/ExploreView.swift
new file mode 100644
index 0000000..147bdf6
--- /dev/null
+++ b/Sora/Views/ExploreView/ExploreView.swift
@@ -0,0 +1,196 @@
+//
+// LibraryView.swift
+// Sora
+//
+// Created by Francesco on 05/01/25.
+//
+
+import SwiftUI
+import Kingfisher
+
+struct ExploreView: View {
+ @EnvironmentObject private var moduleManager: ModuleManager
+ @EnvironmentObject private var profileStore: ProfileStore
+
+ @AppStorage("selectedModuleId") private var selectedModuleId: String?
+ @AppStorage("hideEmptySections") private var hideEmptySections: Bool?
+ @AppStorage("mediaColumnsPortrait") private var mediaColumnsPortrait: Int = 2
+ @AppStorage("mediaColumnsLandscape") private var mediaColumnsLandscape: Int = 4
+
+ @Environment(\.verticalSizeClass) var verticalSizeClass
+ @State private var isLandscape: Bool = UIDevice.current.orientation.isLandscape
+ @State private var showProfileSettings = false
+
+ private var selectedModule: ScrapingModule? {
+ guard let id = selectedModuleId else { return nil }
+ return moduleManager.modules.first { $0.id.uuidString == id }
+ }
+
+ private let columns = [
+ GridItem(.adaptive(minimum: 150), spacing: 12)
+ ]
+
+ private var columnsCount: Int {
+ if UIDevice.current.userInterfaceIdiom == .pad {
+ let isLandscape = UIScreen.main.bounds.width > UIScreen.main.bounds.height
+ return isLandscape ? mediaColumnsLandscape : mediaColumnsPortrait
+ } else {
+ return verticalSizeClass == .compact ? mediaColumnsLandscape : mediaColumnsPortrait
+ }
+ }
+
+ private var cellWidth: CGFloat {
+ let keyWindow = UIApplication.shared.connectedScenes
+ .compactMap { ($0 as? UIWindowScene)?.windows.first(where: { $0.isKeyWindow }) }
+ .first
+ let safeAreaInsets = keyWindow?.safeAreaInsets ?? .zero
+ let safeWidth = UIScreen.main.bounds.width - safeAreaInsets.left - safeAreaInsets.right
+ let totalSpacing: CGFloat = 16 * CGFloat(columnsCount + 1)
+ let availableWidth = safeWidth - totalSpacing
+ return availableWidth / CGFloat(columnsCount)
+ }
+
+ var body: some View {
+ NavigationView {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 12) {
+ //TODO: add explore content views
+ }
+ .padding(.vertical, 20)
+ }
+ .navigationTitle("Explore")
+ .toolbar {
+ ToolbarItem(placement: .navigationBarLeading) {
+ Menu {
+ ForEach(profileStore.profiles) { profile in
+ Button {
+ profileStore.setCurrentProfile(profile)
+ } label: {
+ if profile == profileStore.currentProfile {
+ Label("\(profile.emoji) \(profile.name)", systemImage: "checkmark")
+ } else {
+ Text("\(profile.emoji) \(profile.name)")
+ }
+ }
+ }
+
+ Divider()
+
+ Button {
+ showProfileSettings = true
+ } label: {
+ Label("Edit Profiles", systemImage: "slider.horizontal.3")
+ }
+
+ } label: {
+ Circle()
+ .fill(Color.secondary.opacity(0.3))
+ .frame(width: 32, height: 32)
+ .overlay(
+ Text(profileStore.currentProfile.emoji)
+ .font(.system(size: 20))
+ .foregroundStyle(.primary)
+ )
+ }
+ }
+ ToolbarItem(placement: .navigationBarTrailing) {
+ Menu {
+ 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()
+ }
+ }
+
+ NavigationLink(
+ destination: SettingsViewProfile(),
+ isActive: $showProfileSettings,
+ label: { EmptyView() }
+ )
+ .hidden()
+ }
+ .navigationViewStyle(StackNavigationViewStyle())
+ .onAppear {
+ updateOrientation()
+ //TODO: fetch explore content
+ }
+ .onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in
+ updateOrientation()
+ }
+ }
+
+ private func updateOrientation() {
+ DispatchQueue.main.async {
+ isLandscape = UIDevice.current.orientation.isLandscape
+ }
+ }
+
+ private func cleanLanguageName(_ language: String?) -> String {
+ guard let language = language else { return "Unknown" }
+
+ let cleaned = language.replacingOccurrences(
+ of: "\\s*\\([^\\)]*\\)",
+ with: "",
+ options: .regularExpression
+ ).trimmingCharacters(in: .whitespaces)
+
+ return cleaned.isEmpty ? "Unknown" : cleaned
+ }
+
+ private func getModulesByLanguage() -> [String: [ScrapingModule]] {
+ var result = [String: [ScrapingModule]]()
+
+ for module in moduleManager.modules {
+ let language = cleanLanguageName(module.metadata.language)
+ if result[language] == nil {
+ result[language] = [module]
+ } else {
+ result[language]?.append(module)
+ }
+ }
+
+ return result
+ }
+
+ private func getModuleLanguageGroups() -> [String] {
+ return getModulesByLanguage().keys.sorted()
+ }
+
+ private func getModulesForLanguage(_ language: String) -> [ScrapingModule] {
+ return getModulesByLanguage()[language] ?? []
+ }
+}
diff --git a/Sora/Views/LibraryView/LibraryManager.swift b/Sora/Views/LibraryView/LibraryManager.swift
index 6e8e3d3..6b37888 100644
--- a/Sora/Views/LibraryView/LibraryManager.swift
+++ b/Sora/Views/LibraryView/LibraryManager.swift
@@ -5,7 +5,7 @@
// Created by Francesco on 12/01/25.
//
-import Foundation
+import SwiftUI
struct LibraryItem: Codable, Identifiable {
let id: UUID
@@ -28,15 +28,19 @@ struct LibraryItem: Codable, Identifiable {
}
class LibraryManager: ObservableObject {
+ var userDefaultsSuite = UserDefaults.standard
@Published var bookmarks: [LibraryItem] = []
private let bookmarksKey = "bookmarkedItems"
init() {
- loadBookmarks()
-
NotificationCenter.default.addObserver(self, selector: #selector(handleiCloudSync), name: .iCloudSyncDidComplete, object: nil)
}
-
+
+ public func updateProfileSuite(_ newSuite: UserDefaults) {
+ userDefaultsSuite = newSuite
+ loadBookmarks()
+ }
+
@objc private func handleiCloudSync() {
DispatchQueue.main.async {
self.loadBookmarks()
@@ -46,28 +50,31 @@ class LibraryManager: ObservableObject {
func removeBookmark(item: LibraryItem) {
if let index = bookmarks.firstIndex(where: { $0.id == item.id }) {
bookmarks.remove(at: index)
+
Logger.shared.log("Removed series \(item.id) from bookmarks.",type: "Debug")
saveBookmarks()
}
}
private func loadBookmarks() {
- guard let data = UserDefaults.standard.data(forKey: bookmarksKey) else {
+ guard let data = userDefaultsSuite.data(forKey: bookmarksKey) else {
Logger.shared.log("No bookmarks data found in UserDefaults.", type: "Debug")
+ bookmarks = []
return
}
-
+
do {
bookmarks = try JSONDecoder().decode([LibraryItem].self, from: data)
} catch {
Logger.shared.log("Failed to decode bookmarks: \(error.localizedDescription)", type: "Error")
+ bookmarks = []
}
}
private func saveBookmarks() {
do {
let encoded = try JSONEncoder().encode(bookmarks)
- UserDefaults.standard.set(encoded, forKey: bookmarksKey)
+ userDefaultsSuite.set(encoded, forKey: bookmarksKey)
} catch {
Logger.shared.log("Failed to encode bookmarks: \(error.localizedDescription)", type: "Error")
}
diff --git a/Sora/Views/LibraryView/LibraryView.swift b/Sora/Views/LibraryView/LibraryView.swift
index 99eb462..931ad35 100644
--- a/Sora/Views/LibraryView/LibraryView.swift
+++ b/Sora/Views/LibraryView/LibraryView.swift
@@ -10,16 +10,19 @@ import Kingfisher
struct LibraryView: View {
@EnvironmentObject private var libraryManager: LibraryManager
+ @EnvironmentObject private var continueWatchingManager: ContinueWatchingManager
@EnvironmentObject private var moduleManager: ModuleManager
-
+ @EnvironmentObject private var profileStore: ProfileStore
+
+ @AppStorage("hideEmptySections") private var hideEmptySections: Bool?
@AppStorage("mediaColumnsPortrait") private var mediaColumnsPortrait: Int = 2
@AppStorage("mediaColumnsLandscape") private var mediaColumnsLandscape: Int = 4
@Environment(\.verticalSizeClass) var verticalSizeClass
-
- @State private var continueWatchingItems: [ContinueWatchingItem] = []
+
@State private var isLandscape: Bool = UIDevice.current.orientation.isLandscape
-
+ @State private var showProfileSettings = false
+
private let columns = [
GridItem(.adaptive(minimum: 150), spacing: 12)
]
@@ -50,12 +53,15 @@ struct LibraryView: View {
let columnsCount = determineColumns()
VStack(alignment: .leading, spacing: 12) {
- Text("Continue Watching")
- .font(.title2)
- .bold()
- .padding(.horizontal, 20)
-
- if continueWatchingItems.isEmpty {
+
+ if hideEmptySections != true || !continueWatchingManager.items.isEmpty {
+ Text("Continue Watching")
+ .font(.title2)
+ .bold()
+ .padding(.horizontal, 20)
+ }
+
+ if !(hideEmptySections ?? false) && continueWatchingManager.items.isEmpty {
VStack(spacing: 8) {
Image(systemName: "play.circle")
.font(.largeTitle)
@@ -69,19 +75,21 @@ struct LibraryView: View {
.padding()
.frame(maxWidth: .infinity)
} else {
- ContinueWatchingSection(items: $continueWatchingItems, markAsWatched: { item in
+ ContinueWatchingSection(items: $continueWatchingManager.items, markAsWatched: { item in
markContinueWatchingItemAsWatched(item: item)
}, removeItem: { item in
removeContinueWatchingItem(item: item)
})
}
-
- Text("Bookmarks")
- .font(.title2)
- .bold()
- .padding(.horizontal, 20)
-
- if libraryManager.bookmarks.isEmpty {
+
+ if hideEmptySections != true || !libraryManager.bookmarks.isEmpty {
+ Text("Bookmarks")
+ .font(.title2)
+ .bold()
+ .padding(.horizontal, 20)
+ }
+
+ if !(hideEmptySections ?? false) && libraryManager.bookmarks.isEmpty {
VStack(spacing: 8) {
Image(systemName: "magazine")
.font(.largeTitle)
@@ -141,26 +149,62 @@ struct LibraryView: View {
}
}
.padding(.horizontal, 20)
- .onAppear {
- updateOrientation()
- }
- .onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in
- updateOrientation()
- }
}
}
.padding(.vertical, 20)
+
+ NavigationLink(
+ destination: SettingsViewProfile(),
+ isActive: $showProfileSettings,
+ label: { EmptyView() }
+ )
+ .hidden()
}
.navigationTitle("Library")
- .onAppear {
- fetchContinueWatching()
+ .toolbar {
+ ToolbarItem(placement: .navigationBarLeading) {
+ Menu {
+ ForEach(profileStore.profiles) { profile in
+ Button {
+ profileStore.setCurrentProfile(profile)
+ } label: {
+ if profile == profileStore.currentProfile {
+ Label("\(profile.emoji) \(profile.name)", systemImage: "checkmark")
+ } else {
+ Text("\(profile.emoji) \(profile.name)")
+ }
+ }
+ }
+
+ Divider()
+
+ Button {
+ showProfileSettings = true
+ } label: {
+ Label("Edit Profiles", systemImage: "slider.horizontal.3")
+ }
+
+ } label: {
+ Circle()
+ .fill(Color.secondary.opacity(0.3))
+ .frame(width: 32, height: 32)
+ .overlay(
+ Text(profileStore.currentProfile.emoji)
+ .font(.system(size: 20))
+ .foregroundStyle(.primary)
+ )
+ }
+ }
}
}
.navigationViewStyle(StackNavigationViewStyle())
- }
-
- private func fetchContinueWatching() {
- continueWatchingItems = ContinueWatchingManager.shared.fetchItems()
+ .onAppear {
+ updateOrientation()
+ continueWatchingManager.loadItems()
+ }
+ .onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in
+ updateOrientation()
+ }
}
private func markContinueWatchingItemAsWatched(item: ContinueWatchingItem) {
@@ -168,13 +212,11 @@ struct LibraryView: View {
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 }
+ continueWatchingManager.remove(item: item)
}
private func removeContinueWatchingItem(item: ContinueWatchingItem) {
- ContinueWatchingManager.shared.remove(item: item)
- continueWatchingItems.removeAll { $0.id == item.id }
+ continueWatchingManager.remove(item: item)
}
private func updateOrientation() {
@@ -217,6 +259,8 @@ struct ContinueWatchingSection: View {
}
struct ContinueWatchingCell: View {
+ @EnvironmentObject private var continueWatchingManager: ContinueWatchingManager
+
let item: ContinueWatchingItem
var markAsWatched: () -> Void
var removeItem: () -> Void
@@ -226,7 +270,7 @@ struct ContinueWatchingCell: View {
var body: some View {
Button(action: {
if UserDefaults.standard.string(forKey: "externalPlayer") == "Default" {
- let videoPlayerViewController = VideoPlayerViewController(module: item.module)
+ let videoPlayerViewController = VideoPlayerViewController(module: item.module, continueWatchingManager: continueWatchingManager)
videoPlayerViewController.streamUrl = item.streamUrl
videoPlayerViewController.fullUrl = item.fullUrl
videoPlayerViewController.episodeImageUrl = item.imageUrl
@@ -243,6 +287,7 @@ struct ContinueWatchingCell: View {
} else {
let customMediaPlayer = CustomMediaPlayerViewController(
module: item.module,
+ continueWatchingManager: continueWatchingManager,
urlString: item.streamUrl,
fullUrl: item.fullUrl,
title: item.mediaTitle,
diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift
index 902228e..76ef8b5 100644
--- a/Sora/Views/MediaInfoView/MediaInfoView.swift
+++ b/Sora/Views/MediaInfoView/MediaInfoView.swift
@@ -48,302 +48,230 @@ struct MediaInfoView: View {
@StateObject private var jsController = JSController()
@EnvironmentObject var moduleManager: ModuleManager
@EnvironmentObject private var libraryManager: LibraryManager
-
+ @EnvironmentObject private var continueWatchingManager: ContinueWatchingManager
+
@State private var selectedRange: Range = 0..<100
@State private var showSettingsMenu = false
@State private var customAniListID: Int?
- @State private var showStreamLoadingView: Bool = false
- @State private var currentStreamTitle: String = ""
-
- @State private var activeFetchID: UUID? = nil
- @Environment(\.dismiss) private var dismiss
private var isGroupedBySeasons: Bool {
return groupedEpisodes().count > 1
}
var body: some View {
- ZStack {
- Group {
- if isLoading {
- ProgressView()
- .padding()
- } else {
- ScrollView {
- VStack(alignment: .leading, spacing: 16) {
- 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()
+ Group {
+ if isLoading {
+ ProgressView()
+ .padding()
+ } else {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 16) {
+ 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)
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text(title)
+ .font(.system(size: 17))
+ .fontWeight(.bold)
+ .onLongPressGesture {
+ UIPasteboard.general.string = title
+ DropManager.shared.showDrop(title: "Copied to Clipboard", subtitle: "", duration: 1.0, icon: UIImage(systemName: "doc.on.clipboard.fill"))
}
- .resizable()
- .aspectRatio(contentMode: .fill)
- .frame(width: 150, height: 225)
- .clipped()
- .cornerRadius(10)
- VStack(alignment: .leading, spacing: 4) {
- Text(title)
- .font(.system(size: 17))
- .fontWeight(.bold)
- .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)
- }
- .padding(4)
- }
- }
-
+ 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) {
- Button(action: {
- openSafariViewController(with: href)
- }) {
- HStack(spacing: 4) {
- Text(module.metadata.sourceName)
- .font(.system(size: 13))
- .foregroundColor(.primary)
-
- Image(systemName: "safari")
- .resizable()
- .frame(width: 20, height: 20)
- .foregroundColor(.primary)
- }
- .padding(4)
- .background(Capsule().fill(Color.accentColor.opacity(0.4)))
+ HStack(spacing: 4) {
+ Image(systemName: "calendar")
+ .resizable()
+ .frame(width: 15, height: 15)
+ .foregroundColor(.secondary)
+
+ Text(airdate)
+ .font(.system(size: 12))
+ .foregroundColor(.secondary)
}
-
- Menu {
- Button(action: {
- showCustomIDAlert()
- }) {
- Label("Set Custom AniList ID", systemImage: "number")
- }
+ .padding(4)
+ }
+ }
+
+ HStack(alignment: .center, spacing: 12) {
+ Button(action: {
+ openSafariViewController(with: href)
+ }) {
+ HStack(spacing: 4) {
+ Text(module.metadata.sourceName)
+ .font(.system(size: 13))
+ .foregroundColor(.primary)
- if let customID = customAniListID {
- Button(action: {
- customAniListID = nil
- itemID = nil
- fetchItemID(byTitle: cleanTitle(title)) { result in
- switch result {
- case .success(let id):
- itemID = id
- case .failure(let error):
- Logger.shared.log("Failed to fetch AniList ID: \(error)")
- }
- }
- }) {
- Label("Reset AniList ID", systemImage: "arrow.clockwise")
- }
- }
-
- if let id = itemID ?? customAniListID {
- Button(action: {
- if let url = URL(string: "https://anilist.co/anime/\(id)") {
- openSafariViewController(with: url.absoluteString)
- }
- }) {
- Label("Open in AniList", systemImage: "link")
- }
- }
-
- Divider()
-
- Button(action: {
- Logger.shared.log("Debug Info:\nTitle: \(title)\nHref: \(href)\nModule: \(module.metadata.sourceName)\nAniList ID: \(itemID ?? -1)\nCustom ID: \(customAniListID ?? -1)", type: "Debug")
- DropManager.shared.showDrop(title: "Debug Info Logged", subtitle: "", duration: 1.0, icon: UIImage(systemName: "terminal"))
- }) {
- Label("Log Debug Info", systemImage: "terminal")
- }
- } label: {
- Image(systemName: "ellipsis.circle")
+ Image(systemName: "safari")
.resizable()
.frame(width: 20, height: 20)
.foregroundColor(.primary)
}
- }
- }
- }
-
- if !synopsis.isEmpty {
- 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))
- }
+ .padding(4)
+ .background(Capsule().fill(Color.accentColor.opacity(0.4)))
}
- Text(synopsis)
- .lineLimit(showFullSynopsis ? nil : 4)
- .font(.system(size: 14))
- }
- }
-
- HStack {
- Button(action: {
- playFirstUnwatchedEpisode()
- }) {
- HStack {
- Image(systemName: "play.fill")
- .foregroundColor(.primary)
- Text(startWatchingText)
- .font(.headline)
+ Menu {
+ Button(action: {
+ showCustomIDAlert()
+ }) {
+ Label("Set Custom AniList ID", systemImage: "number")
+ }
+
+ if let _ = customAniListID {
+ Button(action: {
+ customAniListID = nil
+ itemID = nil
+ fetchItemID(byTitle: cleanTitle(title)) { result in
+ switch result {
+ case .success(let id):
+ itemID = id
+ case .failure(let error):
+ Logger.shared.log("Failed to fetch AniList ID: \(error)")
+ }
+ }
+ }) {
+ Label("Reset AniList ID", systemImage: "arrow.clockwise")
+ }
+ }
+
+ if let id = itemID ?? customAniListID {
+ Button(action: {
+ if let url = URL(string: "https://anilist.co/anime/\(id)") {
+ openSafariViewController(with: url.absoluteString)
+ }
+ }) {
+ Label("Open in AniList", systemImage: "link")
+ }
+ }
+
+ Divider()
+
+ Button(action: {
+ Logger.shared.log("Debug Info:\nTitle: \(title)\nHref: \(href)\nModule: \(module.metadata.sourceName)\nAniList ID: \(itemID ?? -1)\nCustom ID: \(customAniListID ?? -1)", type: "Debug")
+ DropManager.shared.showDrop(title: "Debug Info Logged", subtitle: "", duration: 1.0, icon: UIImage(systemName: "terminal"))
+ }) {
+ Label("Log Debug Info", systemImage: "terminal")
+ }
+ } label: {
+ Image(systemName: "ellipsis.circle")
+ .resizable()
+ .frame(width: 20, height: 20)
.foregroundColor(.primary)
}
- .padding()
- .frame(maxWidth: .infinity)
- .background(Color.accentColor)
- .cornerRadius(10)
- }
- .disabled(isFetchingEpisode)
- .id(buttonRefreshTrigger)
-
- Button(action: {
- libraryManager.toggleBookmark(
- title: title,
- imageUrl: imageUrl,
- href: href,
- moduleId: module.id.uuidString,
- moduleName: module.metadata.sourceName
- )
- }) {
- Image(systemName: libraryManager.isBookmarked(href: href, moduleName: module.metadata.sourceName) ? "bookmark.fill" : "bookmark")
- .resizable()
- .frame(width: 20, height: 27)
- .foregroundColor(Color.accentColor)
}
}
-
- if !episodeLinks.isEmpty {
- VStack(alignment: .leading, spacing: 10) {
- HStack {
- Text("Episodes")
- .font(.system(size: 18))
- .fontWeight(.bold)
-
- Spacer()
-
- if !isGroupedBySeasons, episodeLinks.count > episodeChunkSize {
+ }
+
+ if !synopsis.isEmpty {
+ 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))
+ }
+ }
+
+ Button(action: {
+ playFirstUnwatchedEpisode()
+ }) {
+ HStack {
+ Image(systemName: "play.fill")
+ .foregroundColor(.primary)
+ Text(startWatchingText)
+ .font(.headline)
+ .foregroundColor(.primary)
+ }
+ .padding()
+ .frame(maxWidth: .infinity)
+ .background(Color.accentColor)
+ .cornerRadius(10)
+ }
+ .disabled(isFetchingEpisode)
+ .id(buttonRefreshTrigger)
+
+ if !episodeLinks.isEmpty {
+ VStack(alignment: .leading, spacing: 10) {
+ HStack {
+ Text("Episodes")
+ .font(.system(size: 18))
+ .fontWeight(.bold)
+
+ Spacer()
+
+ if !isGroupedBySeasons, episodeLinks.count > episodeChunkSize {
+ Menu {
+ ForEach(generateRanges(), id: \.self) { range in
+ Button(action: { selectedRange = range }) {
+ Text("\(range.lowerBound + 1)-\(range.upperBound)")
+ }
+ }
+ } label: {
+ Text("\(selectedRange.lowerBound + 1)-\(selectedRange.upperBound)")
+ .font(.system(size: 14))
+ .foregroundColor(.accentColor)
+ }
+ } else if isGroupedBySeasons {
+ let seasons = groupedEpisodes()
+ if seasons.count > 1 {
Menu {
- ForEach(generateRanges(), id: \.self) { range in
- Button(action: { selectedRange = range }) {
- Text("\(range.lowerBound + 1)-\(range.upperBound)")
+ ForEach(0.. 1 {
- Menu {
- ForEach(0.. 0 ? lastPlayedTime / totalTime : 0
-
- EpisodeCell(
- episodeIndex: selectedSeason,
- episode: ep.href,
- episodeID: ep.number - 1,
- progress: progress,
- itemID: itemID ?? 0,
- onTap: { imageUrl in
- if !isFetchingEpisode {
- selectedEpisodeNumber = ep.number
- selectedEpisodeImage = imageUrl
- fetchStream(href: ep.href)
- AnalyticsManager.shared.sendEvent(
- event: "watch",
- additionalData: ["title": title, "episode": ep.number]
- )
- }
- },
- onMarkAllPrevious: {
- let userDefaults = UserDefaults.standard
- var updates = [String: Double]()
-
- for ep2 in seasons[selectedSeason] where ep2.number < ep.number {
- let href = ep2.href
- updates["lastPlayedTime_\(href)"] = 99999999.0
- updates["totalTime_\(href)"] = 99999999.0
- }
-
- for (key, value) in updates {
- userDefaults.set(value, forKey: key)
- }
-
- userDefaults.synchronize()
-
- refreshTrigger.toggle()
- Logger.shared.log("Marked episodes watched within season \(selectedSeason + 1) of \"\(title)\".", type: "General")
- }
- )
- .id(refreshTrigger)
- .disabled(isFetchingEpisode)
- }
- } else {
- Text("No episodes available")
- }
- } else {
- ForEach(episodeLinks.indices.filter { selectedRange.contains($0) }, id: \.self) { i in
- let ep = episodeLinks[i]
+ }
+ if isGroupedBySeasons {
+ let seasons = groupedEpisodes()
+ if !seasons.isEmpty, selectedSeason < seasons.count {
+ ForEach(seasons[selectedSeason]) { ep in
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
EpisodeCell(
- episodeIndex: i,
+ episodeIndex: selectedSeason,
episode: ep.href,
episodeID: ep.number - 1,
progress: progress,
@@ -363,148 +291,159 @@ struct MediaInfoView: View {
let userDefaults = UserDefaults.standard
var updates = [String: Double]()
- for idx in 0.. 0 ? lastPlayedTime / totalTime : 0
+
+ EpisodeCell(
+ episodeIndex: i,
+ episode: ep.href,
+ episodeID: ep.number - 1,
+ progress: progress,
+ itemID: itemID ?? 0,
+ onTap: { imageUrl in
+ if !isFetchingEpisode {
+ selectedEpisodeNumber = ep.number
+ selectedEpisodeImage = imageUrl
+ fetchStream(href: ep.href)
+ AnalyticsManager.shared.sendEvent(
+ event: "watch",
+ additionalData: ["title": title, "episode": ep.number]
+ )
+ }
+ },
+ onMarkAllPrevious: {
+ let userDefaults = UserDefaults.standard
+ var updates = [String: Double]()
+
+ for idx in 0.. 1 {
self.showStreamSelectionAlert(streams: streams, fullURL: href, subtitles: result.subtitles?.first)
@@ -666,8 +600,6 @@ struct MediaInfoView: View {
}
} else if module.metadata.streamAsyncJS == true {
jsController.fetchStreamUrlJSSecond(episodeUrl: href, softsub: true, module: module) { result in
- guard self.activeFetchID == fetchID else { return }
-
if let streams = result.streams, !streams.isEmpty {
if streams.count > 1 {
self.showStreamSelectionAlert(streams: streams, fullURL: href, subtitles: result.subtitles?.first)
@@ -683,8 +615,6 @@ struct MediaInfoView: View {
}
} else {
jsController.fetchStreamUrl(episodeUrl: href, softsub: true, module: module) { result in
- guard self.activeFetchID == fetchID else { return }
-
if let streams = result.streams, !streams.isEmpty {
if streams.count > 1 {
self.showStreamSelectionAlert(streams: streams, fullURL: href, subtitles: result.subtitles?.first)
@@ -702,8 +632,6 @@ struct MediaInfoView: View {
} else {
if module.metadata.asyncJS == true {
jsController.fetchStreamUrlJS(episodeUrl: href, module: module) { result in
- guard self.activeFetchID == fetchID else { return }
-
if let streams = result.streams, !streams.isEmpty {
if streams.count > 1 {
self.showStreamSelectionAlert(streams: streams, fullURL: href, subtitles: result.subtitles?.first)
@@ -719,8 +647,6 @@ struct MediaInfoView: View {
}
} else if module.metadata.streamAsyncJS == true {
jsController.fetchStreamUrlJSSecond(episodeUrl: href, module: module) { result in
- guard self.activeFetchID == fetchID else { return }
-
if let streams = result.streams, !streams.isEmpty {
if streams.count > 1 {
self.showStreamSelectionAlert(streams: streams, fullURL: href, subtitles: result.subtitles?.first)
@@ -736,8 +662,6 @@ struct MediaInfoView: View {
}
} else {
jsController.fetchStreamUrl(episodeUrl: href, module: module) { result in
- guard self.activeFetchID == fetchID else { return }
-
if let streams = result.streams, !streams.isEmpty {
if streams.count > 1 {
self.showStreamSelectionAlert(streams: streams, fullURL: href, subtitles: result.subtitles?.first)
@@ -764,8 +688,6 @@ struct MediaInfoView: View {
}
func handleStreamFailure(error: Error? = nil) {
- self.isFetchingEpisode = false
- self.showStreamLoadingView = false
if let error = error {
Logger.shared.log("Error loading module: \(error)", type: "Error")
AnalyticsManager.shared.sendEvent(event: "error", additionalData: ["error": error, "message": "Failed to fetch stream"])
@@ -777,8 +699,6 @@ struct MediaInfoView: View {
}
func showStreamSelectionAlert(streams: [String], fullURL: String, subtitles: String? = nil) {
- self.isFetchingEpisode = false
- self.showStreamLoadingView = false
DispatchQueue.main.async {
let alert = UIAlertController(title: "Select Server", message: "Choose a server to play from", preferredStyle: .actionSheet)
@@ -841,8 +761,6 @@ struct MediaInfoView: View {
}
func playStream(url: String, fullURL: String, subtitles: String? = nil) {
- self.isFetchingEpisode = false
- self.showStreamLoadingView = false
DispatchQueue.main.async {
let externalPlayer = UserDefaults.standard.string(forKey: "externalPlayer") ?? "Sora"
var scheme: String?
@@ -857,7 +775,7 @@ struct MediaInfoView: View {
case "nPlayer":
scheme = "nplayer-\(url)"
case "Default":
- let videoPlayerViewController = VideoPlayerViewController(module: module)
+ let videoPlayerViewController = VideoPlayerViewController(module: module, continueWatchingManager: continueWatchingManager)
videoPlayerViewController.streamUrl = url
videoPlayerViewController.fullUrl = fullURL
videoPlayerViewController.episodeNumber = selectedEpisodeNumber
@@ -888,6 +806,7 @@ struct MediaInfoView: View {
let customMediaPlayer = CustomMediaPlayerViewController(
module: module,
+ continueWatchingManager: continueWatchingManager,
urlString: url.absoluteString,
fullUrl: fullURL,
title: title,
diff --git a/Sora/ContentView.swift b/Sora/Views/RootView/RootView.swift
similarity index 78%
rename from Sora/ContentView.swift
rename to Sora/Views/RootView/RootView.swift
index afbd4ea..1570fa6 100644
--- a/Sora/ContentView.swift
+++ b/Sora/Views/RootView/RootView.swift
@@ -6,11 +6,14 @@
//
import SwiftUI
-import Kingfisher
-struct ContentView: View {
+struct RootView: View {
var body: some View {
TabView {
+ ExploreView()
+ .tabItem {
+ Label("Explore", systemImage: "star")
+ }
LibraryView()
.tabItem {
Label("Library", systemImage: "books.vertical")
diff --git a/Sora/Views/SearchView.swift b/Sora/Views/SearchView/SearchView.swift
similarity index 88%
rename from Sora/Views/SearchView.swift
rename to Sora/Views/SearchView/SearchView.swift
index 856e2a4..be75e6d 100644
--- a/Sora/Views/SearchView.swift
+++ b/Sora/Views/SearchView/SearchView.swift
@@ -17,12 +17,14 @@ struct SearchItem: Identifiable {
struct SearchView: View {
+ @AppStorage("hideEmptySections") private var hideEmptySections: Bool?
@AppStorage("selectedModuleId") private var selectedModuleId: String?
@AppStorage("mediaColumnsPortrait") private var mediaColumnsPortrait: Int = 2
@AppStorage("mediaColumnsLandscape") private var mediaColumnsLandscape: Int = 4
@StateObject private var jsController = JSController()
- @EnvironmentObject var moduleManager: ModuleManager
+ @EnvironmentObject private var moduleManager: ModuleManager
+ @EnvironmentObject private var profileStore: ProfileStore
@Environment(\.verticalSizeClass) var verticalSizeClass
@State private var searchItems: [SearchItem] = []
@@ -32,7 +34,8 @@ struct SearchView: View {
@State private var hasNoResults = false
@State private var isLandscape: Bool = UIDevice.current.orientation.isLandscape
@State private var isModuleSelectorPresented = false
-
+ @State private var showProfileSettings = false
+
private var selectedModule: ScrapingModule? {
guard let id = selectedModuleId else { return nil }
return moduleManager.modules.first { $0.id.uuidString == id }
@@ -88,7 +91,7 @@ struct SearchView: View {
}
}
- if selectedModule == nil {
+ if !(hideEmptySections ?? false) && selectedModule == nil {
VStack(spacing: 8) {
Image(systemName: "questionmark.app")
.font(.largeTitle)
@@ -102,7 +105,6 @@ struct SearchView: View {
.padding()
.frame(maxWidth: .infinity)
.background(Color(.systemBackground))
- .shadow(color: Color.black.opacity(0.1), radius: 2, y: 1)
}
if !searchText.isEmpty {
@@ -160,10 +162,50 @@ struct SearchView: View {
}
}
}
+
+ NavigationLink(
+ destination: SettingsViewProfile(),
+ isActive: $showProfileSettings,
+ label: { EmptyView() }
+ )
+ .hidden()
}
.navigationTitle("Search")
.navigationBarTitleDisplayMode(.large)
.toolbar {
+ ToolbarItem(placement: .navigationBarLeading) {
+ Menu {
+ ForEach(profileStore.profiles) { profile in
+ Button {
+ profileStore.setCurrentProfile(profile)
+ } label: {
+ if profile == profileStore.currentProfile {
+ Label("\(profile.emoji) \(profile.name)", systemImage: "checkmark")
+ } else {
+ Text("\(profile.emoji) \(profile.name)")
+ }
+ }
+ }
+
+ Divider()
+
+ Button {
+ showProfileSettings = true
+ } label: {
+ Label("Edit Profiles", systemImage: "slider.horizontal.3")
+ }
+
+ } label: {
+ Circle()
+ .fill(Color.secondary.opacity(0.3))
+ .frame(width: 32, height: 32)
+ .overlay(
+ Text(profileStore.currentProfile.emoji)
+ .font(.system(size: 20))
+ .foregroundStyle(.primary)
+ )
+ }
+ }
ToolbarItem(placement: .navigationBarTrailing) {
Menu {
ForEach(getModuleLanguageGroups(), id: \.self) { language in
diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewAlternateAppIconPicker.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewAlternateAppIconPicker.swift
new file mode 100644
index 0000000..2b329ca
--- /dev/null
+++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewAlternateAppIconPicker.swift
@@ -0,0 +1,69 @@
+//
+// SettingsViewAlternateAppIconPicker.swift
+// Sulfur
+//
+// Created by Dominic on 20.04.25.
+//
+
+import SwiftUI
+
+struct SettingsViewAlternateAppIconPicker: View {
+ @Binding var isPresented: Bool
+ @AppStorage("currentAppIcon") private var currentAppIcon: String = "Default"
+
+ let icons: [(name: String, icon: String)] = [
+ ("Default", "Default"),
+ ("Original", "Original"),
+ ("Pixel", "Pixel"),
+ ("Pride", "Pride")
+ ]
+
+ var body: some View {
+ VStack {
+ Text("Select an App Icon")
+ .font(.headline)
+ .padding()
+
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 20) {
+ ForEach(icons, id: \.name) { icon in
+ VStack {
+ Image("AppIcon_\(icon.icon)_Preview", bundle: .main)
+ .resizable()
+ .scaledToFit()
+ .frame(width: 60, height: 60)
+ .cornerRadius(10)
+ .padding()
+ .background(
+ currentAppIcon == icon.name ? Color.accentColor.opacity(0.3) : Color.clear
+ )
+ .cornerRadius(10)
+
+ Text(icon.name)
+ .font(.caption)
+ .foregroundColor(currentAppIcon == icon.name ? .accentColor : .primary)
+ }
+ .onTapGesture {
+ currentAppIcon = icon.name
+ setAppIcon(named: icon.icon)
+ }
+ }
+ }
+ .padding()
+ }
+
+ Spacer()
+ }
+ }
+
+ private func setAppIcon(named iconName: String) {
+ if UIApplication.shared.supportsAlternateIcons {
+ UIApplication.shared.setAlternateIconName(iconName == "Default" ? nil : "AppIcon_\(iconName)", completionHandler: { error in
+ isPresented = false
+ if let error = error {
+ print("Failed to set alternate icon: \(error.localizedDescription)")
+ }
+ })
+ }
+ }
+}
diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift
index ed21a08..50ac9a8 100644
--- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift
+++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift
@@ -16,23 +16,60 @@ struct SettingsViewGeneral: View {
@AppStorage("metadataProviders") private var metadataProviders: String = "AniList"
@AppStorage("mediaColumnsPortrait") private var mediaColumnsPortrait: Int = 2
@AppStorage("mediaColumnsLandscape") private var mediaColumnsLandscape: Int = 4
-
+ @AppStorage("hideEmptySections") private var hideEmptySections: Bool = false
+ @AppStorage("currentAppIcon") private var currentAppIcon: String = "Default"
+
private let metadataProvidersList = ["AniList"]
@EnvironmentObject var settings: Settings
-
+ @State var showAppIconPicker: Bool = 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)
+ Spacer()
+ Menu {
+ ForEach(Appearance.allCases) { appearance in
+ Button {
+ settings.selectedAppearance = appearance
+ } label: {
+ Label(appearance.rawValue.capitalized, systemImage: settings.selectedAppearance == appearance ? "checkmark" : "")
+ }
+ }
+ } label: {
+ Text(settings.selectedAppearance.rawValue.capitalized)
}
- .pickerStyle(SegmentedPickerStyle())
}
+ HStack {
+ Text("App Icon")
+ Spacer()
+ Button(action: {
+ showAppIconPicker.toggle()
+ }) {
+ Text(currentAppIcon.isEmpty ? "Default" : currentAppIcon)
+ .font(.body)
+ .foregroundColor(.accentColor)
+ }
+ }
+ HStack {
+ Text("Loading Animation")
+ Spacer()
+ Menu {
+ ForEach(ShimmerType.allCases) { shimmerType in
+ Button {
+ settings.shimmerType = shimmerType
+ } label: {
+ Label(shimmerType.rawValue.capitalized, systemImage: settings.shimmerType == shimmerType ? "checkmark" : "")
+ }
+ }
+ } label: {
+ Text(settings.shimmerType.rawValue.capitalized)
+ }
+ }
+ Toggle("Hide Empty Sections", isOn: $hideEmptySections)
+ .tint(.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.")) {
@@ -103,5 +140,13 @@ struct SettingsViewGeneral: View {
}
}
.navigationTitle("General")
+ .sheet(isPresented: $showAppIconPicker) {
+ if #available(iOS 16.0, *) {
+ SettingsViewAlternateAppIconPicker(isPresented: $showAppIconPicker)
+ .presentationDetents([.height(200)])
+ } else {
+ SettingsViewAlternateAppIconPicker(isPresented: $showAppIconPicker)
+ }
+ }
}
}
diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewModule.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewModule.swift
index 1f52970..dfb1bc0 100644
--- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewModule.swift
+++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewModule.swift
@@ -9,9 +9,12 @@ import SwiftUI
import Kingfisher
struct SettingsViewModule: View {
- @AppStorage("selectedModuleId") private var selectedModuleId: String?
@EnvironmentObject var moduleManager: ModuleManager
-
+ @EnvironmentObject var settings: Settings
+
+ @AppStorage("selectedModuleId") private var selectedModuleId: String?
+ @AppStorage("hideEmptySections") private var hideEmptySections: Bool?
+
@State private var errorMessage: String?
@State private var isLoading = false
@State private var isRefreshing = false
@@ -21,7 +24,7 @@ struct SettingsViewModule: View {
var body: some View {
VStack {
Form {
- if moduleManager.modules.isEmpty {
+ if !(hideEmptySections ?? false) && moduleManager.modules.isEmpty {
VStack(spacing: 8) {
Image(systemName: "plus.app")
.font(.largeTitle)
@@ -65,7 +68,7 @@ struct SettingsViewModule: View {
Spacer()
if module.id.uuidString == selectedModuleId {
- Image(systemName: "checkmark.circle.fill")
+ Image(systemName: "checkmark")
.foregroundColor(.accentColor)
.frame(width: 25, height: 25)
}
@@ -150,7 +153,7 @@ struct SettingsViewModule: View {
message: "We found some text in your clipboard. Would you like to use it as the module URL?",
preferredStyle: .alert
)
-
+
clipboardAlert.addAction(UIAlertAction(title: "Use Clipboard", style: .default, handler: { _ in
self.displayModuleView(url: pasteboardString)
}))
@@ -161,6 +164,7 @@ struct SettingsViewModule: View {
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let rootViewController = windowScene.windows.first?.rootViewController {
+ windowScene.windows.first?.tintColor = UIColor(settings.accentColor)
rootViewController.present(clipboardAlert, animated: true, completion: nil)
}
@@ -189,6 +193,7 @@ struct SettingsViewModule: View {
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let rootViewController = windowScene.windows.first?.rootViewController {
+ windowScene.windows.first?.tintColor = UIColor(settings.accentColor)
rootViewController.present(alert, animated: true, completion: nil)
}
}
@@ -201,6 +206,7 @@ struct SettingsViewModule: View {
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first {
+ window.tintColor = UIColor(settings.accentColor)
window.rootViewController?.present(hostingController, animated: true, completion: nil)
}
}
diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewProfile.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewProfile.swift
new file mode 100644
index 0000000..8947bb3
--- /dev/null
+++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewProfile.swift
@@ -0,0 +1,137 @@
+//
+// ProfileView.swift
+// Sulfur
+//
+// Created by Dominic on 20.04.25.
+//
+
+import SwiftUI
+
+struct ProfileCell: View {
+ let profile: Profile
+ var isSelected: Bool = false
+
+ var body: some View {
+ HStack(spacing: 10) {
+ Circle()
+ .fill(Color.secondary.opacity(0.3))
+ .frame(width: 50, height: 50)
+ .overlay(
+ Text(profile.emoji)
+ .font(.system(size: 28))
+ .foregroundStyle(.primary)
+ )
+
+ Text(profile.name)
+ .font(.headline)
+ .foregroundColor(.primary)
+
+ Spacer()
+
+ if isSelected {
+ Image(systemName: "checkmark")
+ .foregroundColor(.accentColor)
+ }
+ }
+ .padding(.vertical, 4)
+ }
+}
+
+struct SettingsViewProfile: View {
+ @EnvironmentObject var profileStore: ProfileStore
+
+ @State private var showDeleteAlert = false
+
+ var body: some View {
+ Form {
+ Section(header: Text("Select Profile")) {
+ ForEach(profileStore.profiles) { profile in
+ Button {
+ profileStore.setCurrentProfile(profile)
+ } label: {
+ ProfileCell(profile: profile,
+ isSelected: profile.id == profileStore.currentProfile.id
+ )
+ }
+ }
+ }
+
+ Section(header: Text("Edit Selected Profile")) {
+ HStack {
+ Text("Avatar")
+ TextField("Avatar", text: Binding(
+ get: { profileStore.currentProfile.emoji },
+ set: { newValue in
+
+ // handle multi unicode emojis like "๐จโ๐ฉโ๐งโ๐ฆ" or "๐งโโ๏ธ"
+ let emoji = String(newValue
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ .prefix(2)
+ )
+
+ profileStore.editCurrentProfile(name: profileStore.currentProfile.name, emoji: emoji)
+ }
+ ))
+ .lineLimit(1)
+ .multilineTextAlignment(.trailing)
+ .frame(maxWidth: .infinity)
+ }
+
+ HStack {
+ Text("Name")
+ TextField("Name", text: Binding(
+ get: { profileStore.currentProfile.name },
+ set: { newValue in
+ profileStore.editCurrentProfile(name: newValue, emoji: profileStore.currentProfile.emoji)
+ }
+ ))
+ .lineLimit(1)
+ .multilineTextAlignment(.trailing)
+ .frame(maxWidth: .infinity)
+ }
+
+ if profileStore.profiles.count > 1 {
+ Button(action: {
+ showDeleteAlert = true
+ }) {
+ Text("Delete Selected Profile")
+ .fontWeight(.semibold)
+ .foregroundColor(.red)
+ }
+ }
+ }
+ }
+ .navigationTitle("Profiles")
+ .alert(isPresented: $showDeleteAlert) {
+ Alert(
+ title: Text("Delete Profile"),
+ message: Text("Are you sure you want to delete this profile? This action cannot be undone."),
+ primaryButton: .destructive(Text("Delete")) {
+ profileStore.deleteCurrentProfile()
+ },
+ secondaryButton: .cancel()
+ )
+ }
+ .toolbar {
+ ToolbarItemGroup(placement: .keyboard) {
+ Spacer()
+ Button {
+ UIApplication.shared.dismissKeyboard(true)
+ } label: {
+ Text("Done")
+ .foregroundColor(.accentColor)
+ .fontWeight(.semibold)
+ }
+ }
+
+ ToolbarItem(placement: .topBarTrailing) {
+ Button {
+ profileStore.addProfile(name: "New Profile", emoji: "๐งโโ๏ธ")
+ } label: {
+ Image(systemName: "plus")
+ .foregroundColor(.accentColor)
+ }
+ }
+ }
+ }
+}
diff --git a/Sora/Views/SettingsView/SettingsView.swift b/Sora/Views/SettingsView/SettingsView.swift
index e156260..0c97689 100644
--- a/Sora/Views/SettingsView/SettingsView.swift
+++ b/Sora/Views/SettingsView/SettingsView.swift
@@ -8,9 +8,17 @@
import SwiftUI
struct SettingsView: View {
+ @EnvironmentObject var profileStore: ProfileStore
+
var body: some View {
NavigationView {
Form {
+ Section {
+ NavigationLink(destination: SettingsViewProfile()) {
+ ProfileCell(profile: profileStore.currentProfile)
+ }
+ }
+
Section(header: Text("Main")) {
NavigationLink(destination: SettingsViewGeneral()) {
Text("General Preferences")
@@ -26,7 +34,7 @@ struct SettingsView: View {
}
}
- Section(header: Text("Info")) {
+ Section(header: Text("Diagnostics & Storage")) {
NavigationLink(destination: SettingsViewData()) {
Text("Data")
}
@@ -88,6 +96,19 @@ struct SettingsView: View {
.foregroundColor(.secondary)
}
}
+ Button(action: {
+ if let url = URL(string: "https://github.com/cranci1/Sora/graphs/contributors") {
+ UIApplication.shared.open(url)
+ }
+ }) {
+ HStack {
+ Text("Contributors")
+ .foregroundColor(.primary)
+ Spacer()
+ Image(systemName: "safari")
+ .foregroundColor(.secondary)
+ }
+ }
}
Section(footer: Text("Running Sora 0.2.2 - cranci1")) {}
}
@@ -104,11 +125,17 @@ enum Appearance: String, CaseIterable, Identifiable {
}
class Settings: ObservableObject {
+ @Published var shimmerType: ShimmerType {
+ didSet {
+ UserDefaults.standard.set(shimmerType.rawValue, forKey: "shimmerType")
+ }
+ }
@Published var accentColor: Color {
didSet {
saveAccentColor(accentColor)
}
}
+
@Published var selectedAppearance: Appearance {
didSet {
UserDefaults.standard.set(selectedAppearance.rawValue, forKey: "selectedAppearance")
@@ -117,21 +144,38 @@ class Settings: ObservableObject {
}
init() {
+ if let shimmerRawValue = UserDefaults.standard.string(forKey: "shimmerType"),
+ let shimmer = ShimmerType(rawValue: shimmerRawValue) {
+ self.shimmerType = shimmer
+ } else {
+ self.shimmerType = .shimmer
+ }
+
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
}
+
+
if let appearanceRawValue = UserDefaults.standard.string(forKey: "selectedAppearance"),
let appearance = Appearance(rawValue: appearanceRawValue) {
self.selectedAppearance = appearance
} else {
self.selectedAppearance = .system
}
+
+ applyColorToUIKit(accentColor)
updateAppearance()
}
-
+
+ private func applyColorToUIKit(_ color: Color) {
+ let tempStepper = UIStepper()
+ UIStepper.appearance().setDecrementImage(tempStepper.decrementImage(for: .normal), for: .normal)
+ UIStepper.appearance().setIncrementImage(tempStepper.incrementImage(for: .normal), for: .normal)
+ }
+
private func saveAccentColor(_ color: Color) {
let uiColor = UIColor(color)
do {
diff --git a/Sulfur.xcodeproj/project.pbxproj b/Sulfur.xcodeproj/project.pbxproj
index 8e19b85..3cbaf52 100644
--- a/Sulfur.xcodeproj/project.pbxproj
+++ b/Sulfur.xcodeproj/project.pbxproj
@@ -3,10 +3,17 @@
archiveVersion = 1;
classes = {
};
- objectVersion = 55;
+ objectVersion = 70;
objects = {
/* Begin PBXBuildFile section */
+ 12D1E2692DB45CBE00EFCDAB /* SettingsViewAlternateAppIconPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12D1E2682DB45CB500EFCDAB /* SettingsViewAlternateAppIconPicker.swift */; };
+ 12D1E26B2DB4BA9E00EFCDAB /* SettingsViewProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12D1E26A2DB4BA9D00EFCDAB /* SettingsViewProfile.swift */; };
+ 12D1E26E2DB66F9800EFCDAB /* ProfileStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12D1E26D2DB66F9600EFCDAB /* ProfileStore.swift */; };
+ 12D1E2702DB6702700EFCDAB /* Profile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12D1E26F2DB6702500EFCDAB /* Profile.swift */; };
+ 12D1E2722DB6771100EFCDAB /* ProfileButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12D1E2712DB6770D00EFCDAB /* ProfileButtonStyle.swift */; };
+ 12D1E2742DB67EB200EFCDAB /* UIKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12D1E2732DB67EA900EFCDAB /* UIKit.swift */; };
+ 12D1E27A2DB69D7F00EFCDAB /* ExploreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12D1E2792DB69D7F00EFCDAB /* ExploreView.swift */; };
130217CC2D81C55E0011EFF5 /* DownloadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 130217CB2D81C55E0011EFF5 /* DownloadView.swift */; };
130C6BFA2D53AB1F00DC1432 /* SettingsViewData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 130C6BF92D53AB1F00DC1432 /* SettingsViewData.swift */; };
13103E8B2D58E028000F0673 /* View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13103E8A2D58E028000F0673 /* View.swift */; };
@@ -21,9 +28,8 @@
132E35202D959E1D0007800E /* FFmpeg-iOS-Lame in Frameworks */ = {isa = PBXBuildFile; productRef = 132E351F2D959E1D0007800E /* FFmpeg-iOS-Lame */; };
132E35232D959E410007800E /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 132E35222D959E410007800E /* Kingfisher */; };
133D7C6E2D2BE2500075467E /* SoraApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133D7C6D2D2BE2500075467E /* SoraApp.swift */; };
- 133D7C702D2BE2500075467E /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133D7C6F2D2BE2500075467E /* ContentView.swift */; };
+ 133D7C702D2BE2500075467E /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133D7C6F2D2BE2500075467E /* RootView.swift */; };
133D7C722D2BE2520075467E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 133D7C712D2BE2520075467E /* Assets.xcassets */; };
- 133D7C752D2BE2520075467E /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 133D7C742D2BE2520075467E /* Preview Assets.xcassets */; };
133D7C8C2D2BE2640075467E /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133D7C7C2D2BE2630075467E /* SearchView.swift */; };
133D7C8E2D2BE2640075467E /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133D7C7E2D2BE2630075467E /* LibraryView.swift */; };
133D7C8F2D2BE2640075467E /* MediaInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133D7C802D2BE2630075467E /* MediaInfoView.swift */; };
@@ -71,6 +77,14 @@
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
+ 120764652DB6F6E0003621E9 /* SulfurTV.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SulfurTV.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ 12D1E2682DB45CB500EFCDAB /* SettingsViewAlternateAppIconPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewAlternateAppIconPicker.swift; sourceTree = ""; };
+ 12D1E26A2DB4BA9D00EFCDAB /* SettingsViewProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewProfile.swift; sourceTree = ""; };
+ 12D1E26D2DB66F9600EFCDAB /* ProfileStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileStore.swift; sourceTree = ""; };
+ 12D1E26F2DB6702500EFCDAB /* Profile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Profile.swift; sourceTree = ""; };
+ 12D1E2712DB6770D00EFCDAB /* ProfileButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileButtonStyle.swift; sourceTree = ""; };
+ 12D1E2732DB67EA900EFCDAB /* UIKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIKit.swift; sourceTree = ""; };
+ 12D1E2792DB69D7F00EFCDAB /* ExploreView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExploreView.swift; sourceTree = ""; };
130217CB2D81C55E0011EFF5 /* DownloadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadView.swift; sourceTree = ""; };
130C6BF82D53A4C200DC1432 /* Sora.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Sora.entitlements; sourceTree = ""; };
130C6BF92D53AB1F00DC1432 /* SettingsViewData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewData.swift; sourceTree = ""; };
@@ -84,9 +98,8 @@
132AF1242D9995F900A0140B /* JSController-Search.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-Search.swift"; sourceTree = ""; };
133D7C6A2D2BE2500075467E /* Sulfur.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Sulfur.app; sourceTree = BUILT_PRODUCTS_DIR; };
133D7C6D2D2BE2500075467E /* SoraApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoraApp.swift; sourceTree = ""; };
- 133D7C6F2D2BE2500075467E /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; };
+ 133D7C6F2D2BE2500075467E /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; };
133D7C712D2BE2520075467E /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
- 133D7C742D2BE2520075467E /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; };
133D7C7C2D2BE2630075467E /* SearchView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = ""; };
133D7C7E2D2BE2630075467E /* LibraryView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = ""; };
133D7C802D2BE2630075467E /* MediaInfoView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaInfoView.swift; sourceTree = ""; };
@@ -119,7 +132,7 @@
13DB46912D900BCE008CBC03 /* SettingsViewTrackers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewTrackers.swift; sourceTree = ""; };
13DB7CC22D7D99C0004371D3 /* SubtitleSettingsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubtitleSettingsManager.swift; sourceTree = ""; };
13DB7CEB2D7DED5D004371D3 /* DownloadManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadManager.swift; sourceTree = ""; };
- 13DC0C412D2EC9BA00D0F966 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; };
+ 13DC0C412D2EC9BA00D0F966 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
13DC0C452D302C7500D0F966 /* VideoPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayer.swift; sourceTree = ""; };
13E62FC12DABC5830007E259 /* Trakt-Login.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Trakt-Login.swift"; sourceTree = ""; };
13E62FC32DABC58C0007E259 /* Trakt-Token.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Trakt-Token.swift"; sourceTree = ""; };
@@ -133,7 +146,18 @@
73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JavaScriptCore+Extensions.swift"; sourceTree = ""; };
/* End PBXFileReference section */
+/* Begin PBXFileSystemSynchronizedRootGroup section */
+ 120764662DB6F6E0003621E9 /* SulfurTV */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = SulfurTV; sourceTree = ""; };
+/* End PBXFileSystemSynchronizedRootGroup section */
+
/* Begin PBXFrameworksBuildPhase section */
+ 120764622DB6F6E0003621E9 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
133D7C672D2BE2500075467E /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
@@ -148,6 +172,48 @@
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
+ 1207649E2DB6FA7F003621E9 /* SearchView */ = {
+ isa = PBXGroup;
+ children = (
+ 133D7C7C2D2BE2630075467E /* SearchView.swift */,
+ );
+ path = SearchView;
+ sourceTree = "";
+ };
+ 1207649F2DB6FA88003621E9 /* DownloadView */ = {
+ isa = PBXGroup;
+ children = (
+ 130217CB2D81C55E0011EFF5 /* DownloadView.swift */,
+ );
+ path = DownloadView;
+ sourceTree = "";
+ };
+ 120764A02DB6FA9D003621E9 /* RootView */ = {
+ isa = PBXGroup;
+ children = (
+ 133D7C6F2D2BE2500075467E /* RootView.swift */,
+ );
+ path = RootView;
+ sourceTree = "";
+ };
+ 12D1E26C2DB66F8B00EFCDAB /* ProfileStore */ = {
+ isa = PBXGroup;
+ children = (
+ 12D1E2712DB6770D00EFCDAB /* ProfileButtonStyle.swift */,
+ 12D1E26F2DB6702500EFCDAB /* Profile.swift */,
+ 12D1E26D2DB66F9600EFCDAB /* ProfileStore.swift */,
+ );
+ path = ProfileStore;
+ sourceTree = "";
+ };
+ 12D1E2782DB69D7900EFCDAB /* ExploreView */ = {
+ isa = PBXGroup;
+ children = (
+ 12D1E2792DB69D7F00EFCDAB /* ExploreView.swift */,
+ );
+ path = ExploreView;
+ sourceTree = "";
+ };
13103E802D589D6C000F0673 /* Tracking Services */ = {
isa = PBXGroup;
children = (
@@ -187,6 +253,7 @@
isa = PBXGroup;
children = (
133D7C6C2D2BE2500075467E /* Sora */,
+ 120764662DB6F6E0003621E9 /* SulfurTV */,
133D7C6B2D2BE2500075467E /* Products */,
);
sourceTree = "";
@@ -195,6 +262,7 @@
isa = PBXGroup;
children = (
133D7C6A2D2BE2500075467E /* Sulfur.app */,
+ 120764652DB6F6E0003621E9 /* SulfurTV.app */,
);
name = Products;
sourceTree = "";
@@ -202,35 +270,27 @@
133D7C6C2D2BE2500075467E /* Sora */ = {
isa = PBXGroup;
children = (
- 130C6BF82D53A4C200DC1432 /* Sora.entitlements */,
13DC0C412D2EC9BA00D0F966 /* Info.plist */,
+ 133D7C712D2BE2520075467E /* Assets.xcassets */,
+ 130C6BF82D53A4C200DC1432 /* Sora.entitlements */,
+ 133D7C6D2D2BE2500075467E /* SoraApp.swift */,
13103E802D589D6C000F0673 /* Tracking Services */,
133D7C852D2BE2640075467E /* Utils */,
133D7C7B2D2BE2630075467E /* Views */,
- 133D7C6D2D2BE2500075467E /* SoraApp.swift */,
- 133D7C6F2D2BE2500075467E /* ContentView.swift */,
- 133D7C712D2BE2520075467E /* Assets.xcassets */,
- 133D7C732D2BE2520075467E /* Preview Content */,
);
path = Sora;
sourceTree = "";
};
- 133D7C732D2BE2520075467E /* Preview Content */ = {
- isa = PBXGroup;
- children = (
- 133D7C742D2BE2520075467E /* Preview Assets.xcassets */,
- );
- path = "Preview Content";
- sourceTree = "";
- };
133D7C7B2D2BE2630075467E /* Views */ = {
isa = PBXGroup;
children = (
+ 120764A02DB6FA9D003621E9 /* RootView */,
+ 1207649F2DB6FA88003621E9 /* DownloadView */,
+ 1207649E2DB6FA7F003621E9 /* SearchView */,
+ 12D1E2782DB69D7900EFCDAB /* ExploreView */,
133D7C7F2D2BE2630075467E /* MediaInfoView */,
1399FAD22D3AB34F00E97C31 /* SettingsView */,
133F55B92D33B53E00E08EEA /* LibraryView */,
- 133D7C7C2D2BE2630075467E /* SearchView.swift */,
- 130217CB2D81C55E0011EFF5 /* DownloadView.swift */,
);
path = Views;
sourceTree = "";
@@ -247,6 +307,7 @@
133D7C832D2BE2630075467E /* SettingsSubViews */ = {
isa = PBXGroup;
children = (
+ 12D1E2682DB45CB500EFCDAB /* SettingsViewAlternateAppIconPicker.swift */,
1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */,
1399FAD32D3AB38C00E97C31 /* SettingsViewLogger.swift */,
133D7C842D2BE2630075467E /* SettingsViewModule.swift */,
@@ -254,6 +315,7 @@
135CCBE12D4D1138008B9C0E /* SettingsViewPlayer.swift */,
130C6BF92D53AB1F00DC1432 /* SettingsViewData.swift */,
13DB46912D900BCE008CBC03 /* SettingsViewTrackers.swift */,
+ 12D1E26A2DB4BA9D00EFCDAB /* SettingsViewProfile.swift */,
);
path = SettingsSubViews;
sourceTree = "";
@@ -261,6 +323,7 @@
133D7C852D2BE2640075467E /* Utils */ = {
isa = PBXGroup;
children = (
+ 12D1E26C2DB66F8B00EFCDAB /* ProfileStore */,
136BBE7C2DB102BE00906B5E /* iCloudSyncManager */,
13DB7CEA2D7DED50004371D3 /* DownloadManager */,
13C0E5E82D5F85DD00E7F619 /* ContinueWatching */,
@@ -279,6 +342,7 @@
133D7C862D2BE2640075467E /* Extensions */ = {
isa = PBXGroup;
children = (
+ 12D1E2732DB67EA900EFCDAB /* UIKit.swift */,
73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */,
136BBE7F2DB1038000906B5E /* Notification+Name.swift */,
1327FBA82D758DEA00FC6689 /* UIDevice+Model.swift */,
@@ -465,6 +529,28 @@
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
+ 120764642DB6F6E0003621E9 /* SulfurTV */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 1207646F2DB6F6E1003621E9 /* Build configuration list for PBXNativeTarget "SulfurTV" */;
+ buildPhases = (
+ 120764612DB6F6E0003621E9 /* Sources */,
+ 120764622DB6F6E0003621E9 /* Frameworks */,
+ 120764632DB6F6E0003621E9 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ fileSystemSynchronizedGroups = (
+ 120764662DB6F6E0003621E9 /* SulfurTV */,
+ );
+ name = SulfurTV;
+ packageProductDependencies = (
+ );
+ productName = SulfurTV;
+ productReference = 120764652DB6F6E0003621E9 /* SulfurTV.app */;
+ productType = "com.apple.product-type.application";
+ };
133D7C692D2BE2500075467E /* Sulfur */ = {
isa = PBXNativeTarget;
buildConfigurationList = 133D7C782D2BE2520075467E /* Build configuration list for PBXNativeTarget "Sulfur" */;
@@ -495,9 +581,12 @@
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
- LastSwiftUpdateCheck = 1320;
- LastUpgradeCheck = 1320;
+ LastSwiftUpdateCheck = 1630;
+ LastUpgradeCheck = 1630;
TargetAttributes = {
+ 120764642DB6F6E0003621E9 = {
+ CreatedOnToolsVersion = 16.3;
+ };
133D7C692D2BE2500075467E = {
CreatedOnToolsVersion = 13.2.1;
};
@@ -522,16 +611,23 @@
projectRoot = "";
targets = (
133D7C692D2BE2500075467E /* Sulfur */,
+ 120764642DB6F6E0003621E9 /* SulfurTV */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
+ 120764632DB6F6E0003621E9 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
133D7C682D2BE2500075467E /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
- 133D7C752D2BE2520075467E /* Preview Assets.xcassets in Resources */,
133D7C722D2BE2520075467E /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -539,6 +635,13 @@
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
+ 120764612DB6F6E0003621E9 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
133D7C662D2BE2500075467E /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
@@ -550,6 +653,7 @@
1399FAD62D3AB3DB00E97C31 /* Logger.swift in Sources */,
13B7F4C12D58FFDD0045714A /* Shimmer.swift in Sources */,
139935662D468C450065CEFF /* ModuleManager.swift in Sources */,
+ 12D1E2692DB45CBE00EFCDAB /* SettingsViewAlternateAppIconPicker.swift in Sources */,
133D7C902D2BE2640075467E /* SettingsView.swift in Sources */,
132AF1252D9995F900A0140B /* JSController-Search.swift in Sources */,
13CBEFDA2D5F7D1200D011EE /* String.swift in Sources */,
@@ -558,11 +662,13 @@
136BBE802DB1038000906B5E /* Notification+Name.swift in Sources */,
13DB46902D900A38008CBC03 /* URL.swift in Sources */,
130C6BFA2D53AB1F00DC1432 /* SettingsViewData.swift in Sources */,
+ 12D1E26B2DB4BA9E00EFCDAB /* SettingsViewProfile.swift in Sources */,
1E9FF1D32D403E49008AC100 /* SettingsViewLoggerFilter.swift in Sources */,
13EA2BD92D32D98400C1EBD7 /* NormalPlayer.swift in Sources */,
+ 12D1E2742DB67EB200EFCDAB /* UIKit.swift in Sources */,
133D7C932D2BE2640075467E /* Modules.swift in Sources */,
13DB7CC32D7D99C0004371D3 /* SubtitleSettingsManager.swift in Sources */,
- 133D7C702D2BE2500075467E /* ContentView.swift in Sources */,
+ 133D7C702D2BE2500075467E /* RootView.swift in Sources */,
13DB7CEC2D7DED5D004371D3 /* DownloadManager.swift in Sources */,
13D99CF72D4E73C300250A86 /* ModuleAdditionSettingsView.swift in Sources */,
13C0E5EC2D5F85F800E7F619 /* ContinueWatchingItem.swift in Sources */,
@@ -572,6 +678,7 @@
132AF1212D99951700A0140B /* JSController-Streams.swift in Sources */,
131845F92D47C62D00CA7A54 /* SettingsViewGeneral.swift in Sources */,
13103E8E2D58E04A000F0673 /* SkeletonCell.swift in Sources */,
+ 12D1E26E2DB66F9800EFCDAB /* ProfileStore.swift in Sources */,
13D842552D45267500EBBFA6 /* DropManager.swift in Sources */,
13103E8B2D58E028000F0673 /* View.swift in Sources */,
1327FBA92D758DEA00FC6689 /* UIDevice+Model.swift in Sources */,
@@ -585,10 +692,13 @@
13E62FC22DABC5830007E259 /* Trakt-Login.swift in Sources */,
130217CC2D81C55E0011EFF5 /* DownloadView.swift in Sources */,
133F55BB2D33B55100E08EEA /* LibraryManager.swift in Sources */,
+ 12D1E2722DB6771100EFCDAB /* ProfileButtonStyle.swift in Sources */,
13E62FC42DABC58C0007E259 /* Trakt-Token.swift in Sources */,
133D7C8E2D2BE2640075467E /* LibraryView.swift in Sources */,
13E62FC72DABFE900007E259 /* TraktPushUpdates.swift in Sources */,
+ 12D1E27A2DB69D7F00EFCDAB /* ExploreView.swift in Sources */,
133D7C6E2D2BE2500075467E /* SoraApp.swift in Sources */,
+ 12D1E2702DB6702700EFCDAB /* Profile.swift in Sources */,
138AA1B92D2D66FD0021F9DF /* CircularProgressBar.swift in Sources */,
13DB468D2D90093A008CBC03 /* Anilist-Login.swift in Sources */,
73D164D52D8B5B470011A360 /* JavaScriptCore+Extensions.swift in Sources */,
@@ -604,10 +714,72 @@
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
+ 1207646D2DB6F6E1003621E9 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = AXLC3PQNC8;
+ ENABLE_PREVIEWS = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+ INFOPLIST_KEY_UIUserInterfaceStyle = Automatic;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = de.devsforge.tvos.SulfurTV;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = appletvos;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = 3;
+ TVOS_DEPLOYMENT_TARGET = 18.4;
+ };
+ name = Debug;
+ };
+ 1207646E2DB6F6E1003621E9 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = AXLC3PQNC8;
+ ENABLE_PREVIEWS = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+ INFOPLIST_KEY_UIUserInterfaceStyle = Automatic;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = de.devsforge.tvos.SulfurTV;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = appletvos;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = 3;
+ TVOS_DEPLOYMENT_TARGET = 18.4;
+ };
+ name = Release;
+ };
133D7C762D2BE2520075467E /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
@@ -639,8 +811,10 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
+ DEVELOPMENT_TEAM = AXLC3PQNC8;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
@@ -670,6 +844,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
@@ -701,8 +876,10 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ DEVELOPMENT_TEAM = AXLC3PQNC8;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
@@ -725,15 +902,15 @@
133D7C792D2BE2520075467E /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
- ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = "AppIcon_Pride AppIcon_Original AppIcon_Pixel";
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon_Default;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
CODE_SIGN_ENTITLEMENTS = Sora/Sora.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
- DEVELOPMENT_ASSET_PATHS = "\"Sora/Preview Content\"";
- DEVELOPMENT_TEAM = 399LMK6Q2Y;
"ENABLE_HARDENED_RUNTIME[sdk=macosx*]" = NO;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -754,7 +931,7 @@
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.2.2;
- PRODUCT_BUNDLE_IDENTIFIER = me.cranci.sulfur;
+ PRODUCT_BUNDLE_IDENTIFIER = de.devsforge.sulfur.fork;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = "";
@@ -768,15 +945,15 @@
133D7C7A2D2BE2520075467E /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
- ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = "AppIcon_Pride AppIcon_Original AppIcon_Pixel";
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon_Default;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
CODE_SIGN_ENTITLEMENTS = Sora/Sora.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
- DEVELOPMENT_ASSET_PATHS = "\"Sora/Preview Content\"";
- DEVELOPMENT_TEAM = 399LMK6Q2Y;
"ENABLE_HARDENED_RUNTIME[sdk=macosx*]" = NO;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -797,7 +974,7 @@
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.2.2;
- PRODUCT_BUNDLE_IDENTIFIER = me.cranci.sulfur;
+ PRODUCT_BUNDLE_IDENTIFIER = de.devsforge.sulfur.fork;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = "";
@@ -811,6 +988,15 @@
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
+ 1207646F2DB6F6E1003621E9 /* Build configuration list for PBXNativeTarget "SulfurTV" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 1207646D2DB6F6E1003621E9 /* Debug */,
+ 1207646E2DB6F6E1003621E9 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
133D7C652D2BE2500075467E /* Build configuration list for PBXProject "Sulfur" */ = {
isa = XCConfigurationList;
buildConfigurations = (
diff --git a/SulfurTV/Assets.xcassets/AccentColor.colorset/Contents.json b/SulfurTV/Assets.xcassets/AccentColor.colorset/Contents.json
new file mode 100644
index 0000000..bb57667
--- /dev/null
+++ b/SulfurTV/Assets.xcassets/AccentColor.colorset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "platform" : "universal",
+ "reference" : "systemMintColor"
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Contents.json b/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Contents.json
new file mode 100644
index 0000000..cec685a
--- /dev/null
+++ b/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "large.png",
+ "idiom" : "tv"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/large.png b/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/large.png
new file mode 100644
index 0000000..f1f3206
Binary files /dev/null and b/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/large.png differ
diff --git a/Sora/Preview Content/Preview Assets.xcassets/Contents.json b/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Contents.json
similarity index 100%
rename from Sora/Preview Content/Preview Assets.xcassets/Contents.json
rename to SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Contents.json
diff --git a/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Contents.json b/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Contents.json
new file mode 100644
index 0000000..de59d88
--- /dev/null
+++ b/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Contents.json
@@ -0,0 +1,17 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "layers" : [
+ {
+ "filename" : "Front.imagestacklayer"
+ },
+ {
+ "filename" : "Middle.imagestacklayer"
+ },
+ {
+ "filename" : "Back.imagestacklayer"
+ }
+ ]
+}
diff --git a/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Contents.json b/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Contents.json
new file mode 100644
index 0000000..cec685a
--- /dev/null
+++ b/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "large.png",
+ "idiom" : "tv"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/large.png b/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/large.png
new file mode 100644
index 0000000..95b562a
Binary files /dev/null and b/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/large.png differ
diff --git a/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Contents.json b/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json b/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json
new file mode 100644
index 0000000..2e00335
--- /dev/null
+++ b/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json
@@ -0,0 +1,11 @@
+{
+ "images" : [
+ {
+ "idiom" : "tv"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Contents.json b/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Contents.json b/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Contents.json
new file mode 100644
index 0000000..91050f5
--- /dev/null
+++ b/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Contents.json
@@ -0,0 +1,18 @@
+{
+ "images" : [
+ {
+ "filename" : "small.png",
+ "idiom" : "tv",
+ "scale" : "1x"
+ },
+ {
+ "filename" : "large.png",
+ "idiom" : "tv",
+ "scale" : "2x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/large.png b/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/large.png
new file mode 100644
index 0000000..c050a91
Binary files /dev/null and b/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/large.png differ
diff --git a/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/small.png b/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/small.png
new file mode 100644
index 0000000..017666f
Binary files /dev/null and b/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/small.png differ
diff --git a/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Contents.json b/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Contents.json b/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Contents.json
new file mode 100644
index 0000000..de59d88
--- /dev/null
+++ b/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Contents.json
@@ -0,0 +1,17 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "layers" : [
+ {
+ "filename" : "Front.imagestacklayer"
+ },
+ {
+ "filename" : "Middle.imagestacklayer"
+ },
+ {
+ "filename" : "Back.imagestacklayer"
+ }
+ ]
+}
diff --git a/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Contents.json b/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Contents.json
new file mode 100644
index 0000000..91050f5
--- /dev/null
+++ b/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Contents.json
@@ -0,0 +1,18 @@
+{
+ "images" : [
+ {
+ "filename" : "small.png",
+ "idiom" : "tv",
+ "scale" : "1x"
+ },
+ {
+ "filename" : "large.png",
+ "idiom" : "tv",
+ "scale" : "2x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/large.png b/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/large.png
new file mode 100644
index 0000000..fcf3e9e
Binary files /dev/null and b/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/large.png differ
diff --git a/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/small.png b/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/small.png
new file mode 100644
index 0000000..02f486f
Binary files /dev/null and b/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/small.png differ
diff --git a/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Contents.json b/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json b/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json
new file mode 100644
index 0000000..795cce1
--- /dev/null
+++ b/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json
@@ -0,0 +1,16 @@
+{
+ "images" : [
+ {
+ "idiom" : "tv",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "tv",
+ "scale" : "2x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Contents.json b/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Contents.json b/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Contents.json
new file mode 100644
index 0000000..f47ba43
--- /dev/null
+++ b/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Contents.json
@@ -0,0 +1,32 @@
+{
+ "assets" : [
+ {
+ "filename" : "App Icon - App Store.imagestack",
+ "idiom" : "tv",
+ "role" : "primary-app-icon",
+ "size" : "1280x768"
+ },
+ {
+ "filename" : "App Icon.imagestack",
+ "idiom" : "tv",
+ "role" : "primary-app-icon",
+ "size" : "400x240"
+ },
+ {
+ "filename" : "Top Shelf Image Wide.imageset",
+ "idiom" : "tv",
+ "role" : "top-shelf-image-wide",
+ "size" : "2320x720"
+ },
+ {
+ "filename" : "Top Shelf Image.imageset",
+ "idiom" : "tv",
+ "role" : "top-shelf-image",
+ "size" : "1920x720"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Contents.json b/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Contents.json
new file mode 100644
index 0000000..91050f5
--- /dev/null
+++ b/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Contents.json
@@ -0,0 +1,18 @@
+{
+ "images" : [
+ {
+ "filename" : "small.png",
+ "idiom" : "tv",
+ "scale" : "1x"
+ },
+ {
+ "filename" : "large.png",
+ "idiom" : "tv",
+ "scale" : "2x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/large.png b/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/large.png
new file mode 100644
index 0000000..754c8b1
Binary files /dev/null and b/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/large.png differ
diff --git a/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/small.png b/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/small.png
new file mode 100644
index 0000000..368683e
Binary files /dev/null and b/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/small.png differ
diff --git a/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Contents.json b/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Contents.json
new file mode 100644
index 0000000..684d25c
--- /dev/null
+++ b/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Contents.json
@@ -0,0 +1,18 @@
+{
+ "images" : [
+ {
+ "filename" : "small_2.png",
+ "idiom" : "tv",
+ "scale" : "1x"
+ },
+ {
+ "filename" : "large_2.png",
+ "idiom" : "tv",
+ "scale" : "2x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/large_2.png b/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/large_2.png
new file mode 100644
index 0000000..ebcd8fa
Binary files /dev/null and b/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/large_2.png differ
diff --git a/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/small_2.png b/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/small_2.png
new file mode 100644
index 0000000..357a4c9
Binary files /dev/null and b/SulfurTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/small_2.png differ
diff --git a/SulfurTV/Assets.xcassets/Contents.json b/SulfurTV/Assets.xcassets/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/SulfurTV/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/SulfurTV/SulfurTVApp.swift b/SulfurTV/SulfurTVApp.swift
new file mode 100644
index 0000000..0083ccf
--- /dev/null
+++ b/SulfurTV/SulfurTVApp.swift
@@ -0,0 +1,17 @@
+//
+// SulfurTVApp.swift
+// SulfurTV
+//
+// Created by Dominic on 21.04.25.
+//
+
+import SwiftUI
+
+@main
+struct SulfurTVApp: App {
+ var body: some Scene {
+ WindowGroup {
+ RootView()
+ }
+ }
+}
diff --git a/SulfurTV/Views/ExploreView/ExploreView.swift b/SulfurTV/Views/ExploreView/ExploreView.swift
new file mode 100644
index 0000000..05f760c
--- /dev/null
+++ b/SulfurTV/Views/ExploreView/ExploreView.swift
@@ -0,0 +1,14 @@
+//
+// ExploreView.swift
+// Sulfur
+//
+// Created by Dominic on 22.04.25.
+//
+
+import SwiftUI
+
+struct ExploreView: View {
+ var body: some View {
+ Text("Explore View")
+ }
+}
diff --git a/SulfurTV/Views/LibraryView/LibraryView.swift b/SulfurTV/Views/LibraryView/LibraryView.swift
new file mode 100644
index 0000000..df1d84e
--- /dev/null
+++ b/SulfurTV/Views/LibraryView/LibraryView.swift
@@ -0,0 +1,14 @@
+//
+// ExploreView.swift
+// Sulfur
+//
+// Created by Dominic on 22.04.25.
+//
+
+import SwiftUI
+
+struct LibraryView: View {
+ var body: some View {
+ Text("Library View")
+ }
+}
diff --git a/SulfurTV/Views/RootView/RootView.swift b/SulfurTV/Views/RootView/RootView.swift
new file mode 100644
index 0000000..734daf4
--- /dev/null
+++ b/SulfurTV/Views/RootView/RootView.swift
@@ -0,0 +1,31 @@
+//
+// ContentView.swift
+// SulfurTV
+//
+// Created by Dominic on 21.04.25.
+//
+
+import SwiftUI
+
+struct RootView: View {
+ var body: some View {
+ TabView {
+ ExploreView()
+ .tabItem {
+ Label("Explore", systemImage: "star.fill")
+ }
+ LibraryView()
+ .tabItem {
+ Label("Library", systemImage: "books.vertical.fill")
+ }
+ SearchView()
+ .tabItem {
+ Label("Search", systemImage: "magnifyingglass")
+ }
+ SettingsView()
+ .tabItem {
+ Label("Settings", systemImage: "gear")
+ }
+ }
+ }
+}
diff --git a/SulfurTV/Views/SearchView/SearchView.swift b/SulfurTV/Views/SearchView/SearchView.swift
new file mode 100644
index 0000000..9b5b0a1
--- /dev/null
+++ b/SulfurTV/Views/SearchView/SearchView.swift
@@ -0,0 +1,14 @@
+//
+// ExploreView.swift
+// Sulfur
+//
+// Created by Dominic on 22.04.25.
+//
+
+import SwiftUI
+
+struct SearchView: View {
+ var body: some View {
+ Text("Search View")
+ }
+}
diff --git a/SulfurTV/Views/SettingsView/SettingsView.swift b/SulfurTV/Views/SettingsView/SettingsView.swift
new file mode 100644
index 0000000..82cd915
--- /dev/null
+++ b/SulfurTV/Views/SettingsView/SettingsView.swift
@@ -0,0 +1,42 @@
+//
+// ExploreView.swift
+// Sulfur
+//
+// Created by Dominic on 22.04.25.
+//
+
+import SwiftUI
+
+struct SettingsView: View {
+ @FocusState private var focusedSetting: Int?
+ private let screenWidth = UIScreen.main.bounds.width
+
+ var body: some View {
+ HStack(spacing: 0) {
+ VStack {
+ Group {
+ RoundedRectangle(cornerRadius: 90, style: .circular)
+ .fill(.gray.opacity(0.3))
+ .frame(width: UIScreen.main.bounds.width * 0.3, height: UIScreen.main.bounds.width * 0.3)
+ .shadow(radius: 12)
+ }
+ }
+ .frame(width: screenWidth / 2.0)
+
+ VStack {
+ ForEach(1..<7) { index in
+ Button(action: {
+ print("Selected Index: \(index)")
+ }) {
+ Text("Random Setting \(index)")
+ .frame(maxWidth: screenWidth / 2.5)
+ .scaleEffect(focusedSetting == index ? 1.0 : 0.85)
+ .animation(.easeInOut(duration: 0.2), value: focusedSetting == index)
+ }
+ .focused($focusedSetting, equals: index)
+ }
+ }
+ .frame(width: screenWidth / 2.0)
+ }
+ }
+}