Move Development from Fork into main Repository (#100)

* add contributor link, add hide empty sections toggle, cleanup warnings, tests

* fix darkmode label color

* use primary and secondary colors ( for consistency with rest of codebase )

* add basic profile views / ui

* add current profile view to important places ( navigationbar leading )

* reorder contributors row, update url

* merge upstream into fork

* add new icons, cleanup, tests.

* close app icon sheet automatically on completion

* add profilestore ( persistence, enviromentobject ), finalize profile settings view, cleanup, tests

* add profilestore ( persistence, enviromentobject ), finalize profile settings view, cleanup, tests

* add dismiss keyboard extension, dismiss keyboard on tap outside ( profile settings view )

* fix icon transparency issue, add profile data to icloud sync

* remove weird empty view ( search ) shadow, fix dismiss keyboard,  align system appearance to other rows ( style ), cleanup, tests

* fancy profile switch manu ( navigationbar )

* add explore view ( basic library and search view copy )

* fix uikit alerts not using the correct accentColor

* apply custom accentColor to stepper components

* style consistency ( icons, colors ), change duplicate section title ( "Info" ), hide more empty sections conditionally, cleanup

* fix missing section headers

* fix copy paste error ^^'

* add empty explore view placeholder, add new shimmer effect ( configurable via settings ), cleanup

* convert ContinueWatchingManager() singleton to dependency injected enviroment object to match similar manager structures

* fix spelling, inject profile into library and continueWatching Managers, fix iCloudSync premature execution, remove profile from explore view ( wont be needed )

* add update profile function to library and continuewatching managers ( to reload the media items ), change media fetching style of continuewatchingmanager to better match librarymanager, update libraryview to use the new continuewatchingmanager fetch style

* fix state desync on insertion / removal / profile change

* switched from filtering by profile ids to seperated data storage via user default suites with different ids.

* update todo markers

* fix bookmarks not getting overwritten on empty userdefaults load

* add the profile button back to the explore view ( you might wanna change profile quickly ), add todos

* moved some views into folders, renamed contentview to rootview, moved bookmark button into navigationbar, used randomUseragents everywhere, add tvos target, add tvos images and basic tabview, started work on settings view design

* add new shimmer type, swap two settings rows, add detailed instructions to some todos

* Squashed commit of the following:

commit 5d076e0cf7
Author: cranci <100066266+cranci1@users.noreply.github.com>
Date:   Tue Apr 22 15:03:25 2025 +0200

    Aniskip logic and basic buttons (#96) (#97)

    * Aniskip logic and basic buttons

    * good fuckin enough for now

    * im callin good enough

    * bug fix

    * its something

    * hallelujah

    * Update SearchView.swift

    * made subs go up the progress bar if it is showing

    ---------

    Co-authored-by: ibro <54913038+xibrox@users.noreply.github.com>
    Co-authored-by: Seiike <122684677+Seeike@users.noreply.github.com>

commit 0ad4659d2c
Author: Seiike <122684677+Seeike@users.noreply.github.com>
Date:   Sun Apr 20 19:50:15 2025 +0200

    hello 👋  (#95)

    * bug fix dimming

    * improved the fetchEpisodeMetadata logic

commit 83cf7b0e9f
Merge: d28a55a 68e8196
Author: cranci <100066266+cranci1@users.noreply.github.com>
Date:   Sun Apr 20 08:53:08 2025 +0200

    Implementation of loading modal and dim mode (#93)

---------

Co-authored-by: Dominic Drees <dominic.drees@atino.de>
Co-authored-by: Francesco <100066266+cranci1@users.noreply.github.com>
This commit is contained in:
undeaD_D 2025-04-22 16:49:57 +02:00 committed by GitHub
parent 5d076e0cf7
commit 2127b3b9e6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
83 changed files with 1977 additions and 525 deletions

View file

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 109 KiB

View file

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 117 KiB

View file

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 104 KiB

View file

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "preview.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

View file

@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "original.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

View file

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "preview.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

View file

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "preview.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

View file

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "preview.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

View file

@ -2,8 +2,6 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSCameraUsageDescription</key>
<string>Sora may requires access to your device&apos;s camera.</string>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
@ -38,7 +36,5 @@
<string>audio</string>
<string>processing</string>
</array>
<key>UISupportsDocumentBrowser</key>
<true/>
</dict>
</plist>

View file

@ -4,7 +4,7 @@
<dict>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.me.cranci.sora.icloud</string>
<string>iCloud.de.devsforge.sulfur.fork</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
@ -12,7 +12,7 @@
</array>
<key>com.apple.developer.ubiquity-container-identifiers</key>
<array>
<string>iCloud.me.cranci.sora.icloud</string>
<string>iCloud.de.devsforge.sulfur.fork</string>
</array>
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>

View file

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

View file

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

View file

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

View file

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

View file

@ -9,6 +9,6 @@ import SwiftUI
extension View {
func shimmering() -> some View {
self.modifier(Shimmer())
self.modifier(ShimmeringEffect())
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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] ?? []
}
}

View file

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

View file

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

View file

@ -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<Int> = 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..<seasons.count, id: \.self) { index in
Button(action: { selectedSeason = index }) {
Text("Season \(index + 1)")
}
}
} label: {
Text("\(selectedRange.lowerBound + 1)-\(selectedRange.upperBound)")
Text("Season \(selectedSeason + 1)")
.font(.system(size: 14))
.foregroundColor(.accentColor)
}
} else if isGroupedBySeasons {
let seasons = groupedEpisodes()
if seasons.count > 1 {
Menu {
ForEach(0..<seasons.count, id: \.self) { index in
Button(action: { selectedSeason = index }) {
Text("Season \(index + 1)")
}
}
} label: {
Text("Season \(selectedSeason + 1)")
.font(.system(size: 14))
.foregroundColor(.accentColor)
}
}
}
}
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: 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..<i {
if idx < episodeLinks.count {
let href = episodeLinks[idx].href
updates["lastPlayedTime_\(href)"] = 1000.0
updates["totalTime_\(href)"] = 1000.0
}
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 \(ep.number - 1) episodes watched within series \"\(title)\".", type: "General")
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]
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,
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..<i {
if idx < episodeLinks.count {
let href = episodeLinks[idx].href
updates["lastPlayedTime_\(href)"] = 1000.0
updates["totalTime_\(href)"] = 1000.0
}
}
for (key, value) in updates {
userDefaults.set(value, forKey: key)
}
refreshTrigger.toggle()
Logger.shared.log("Marked \(ep.number - 1) episodes watched within series \"\(title)\".", type: "General")
}
)
.id(refreshTrigger)
.disabled(isFetchingEpisode)
}
}
}
} else {
VStack(alignment: .leading, spacing: 10) {
Text("Episodes")
.font(.system(size: 18))
.fontWeight(.bold)
}
VStack(spacing: 8) {
if isRefetching {
ProgressView()
.padding()
} else {
Image(systemName: "exclamationmark.triangle")
.font(.largeTitle)
}
} else {
VStack(alignment: .leading, spacing: 10) {
Text("Episodes")
.font(.system(size: 18))
.fontWeight(.bold)
}
VStack(spacing: 8) {
if isRefetching {
ProgressView()
.padding()
} else {
Image(systemName: "exclamationmark.triangle")
.font(.largeTitle)
.foregroundColor(.secondary)
HStack(spacing: 2) {
Text("No episodes Found:")
.foregroundColor(.secondary)
HStack(spacing: 2) {
Text("No episodes Found:")
.foregroundColor(.secondary)
Button(action: {
isRefetching = true
fetchDetails()
}) {
Text("Retry")
.foregroundColor(.accentColor)
}
Button(action: {
isRefetching = true
fetchDetails()
}) {
Text("Retry")
.foregroundColor(.accentColor)
}
}
}
.padding()
.frame(maxWidth: .infinity)
}
.padding()
.frame(maxWidth: .infinity)
}
.padding()
.navigationBarTitleDisplayMode(.inline)
.navigationBarTitle("")
.navigationViewStyle(StackNavigationViewStyle())
}
}
}
.onAppear {
buttonRefreshTrigger.toggle()
if !hasFetched {
DropManager.shared.showDrop(title: "Fetching Data", subtitle: "Please wait while fetching.", duration: 0.5, icon: UIImage(systemName: "arrow.triangle.2.circlepath"))
fetchDetails()
if let savedID = UserDefaults.standard.object(forKey: "custom_anilist_id_\(href)") as? Int {
customAniListID = savedID
itemID = savedID
Logger.shared.log("Using custom AniList ID: \(savedID)", type: "Debug")
} else {
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)")
AnalyticsManager.shared.sendEvent(event: "error", additionalData: ["error": error, "message": "Failed to fetch AniList ID"])
.padding()
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
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()
.foregroundColor(Color.accentColor)
}
}
}
hasFetched = true
AnalyticsManager.shared.sendEvent(event: "search", additionalData: ["title": title])
.navigationBarTitleDisplayMode(.inline)
.navigationBarTitle("")
.navigationViewStyle(StackNavigationViewStyle())
}
selectedRange = 0..<episodeChunkSize
}
}
.onAppear {
buttonRefreshTrigger.toggle()
if showStreamLoadingView {
VStack(spacing: 16) {
Text("Loading \(currentStreamTitle)")
.font(.headline)
.foregroundColor(.primary)
Button("Cancel") {
withAnimation(.spring(response: 0.4, dampingFraction: 0.6)) {
activeFetchID = nil
isFetchingEpisode = false
showStreamLoadingView = false
if !hasFetched {
DropManager.shared.showDrop(title: "Fetching Data", subtitle: "Please wait while fetching.", duration: 0.5, icon: UIImage(systemName: "arrow.triangle.2.circlepath"))
fetchDetails()
if let savedID = UserDefaults.standard.object(forKey: "custom_anilist_id_\(href)") as? Int {
customAniListID = savedID
itemID = savedID
Logger.shared.log("Using custom AniList ID: \(savedID)", type: "Debug")
} else {
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)")
AnalyticsManager.shared.sendEvent(event: "error", additionalData: ["error": error, "message": "Failed to fetch AniList ID"])
}
}
.font(.subheadline)
.padding(.vertical, 8)
.padding(.horizontal, 24)
.background(
// Hex #FF705E
Color(red: 1.0, green: 112/255.0, blue: 94/255.0)
)
.foregroundColor(.white)
.cornerRadius(8)
}
.padding(.top, 20)
.padding(.bottom, 16)
.padding(.horizontal, 40)
.background(.ultraThinMaterial)
.cornerRadius(16)
.shadow(color: Color.black.opacity(0.3), radius: 12, x: 0, y: 8)
.frame(maxWidth: 300)
.transition(.scale.combined(with: .opacity))
.animation(.spring(response: 0.4, dampingFraction: 0.6), value: showStreamLoadingView)
hasFetched = true
AnalyticsManager.shared.sendEvent(event: "search", additionalData: ["title": title])
}
}
.navigationBarBackButtonHidden(true)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button(action: {
activeFetchID = nil
isFetchingEpisode = false
showStreamLoadingView = false
dismiss()
}) {
HStack {
Image(systemName: "chevron.left")
Text("Search")
}
}
}
}
.onDisappear {
activeFetchID = nil
isFetchingEpisode = false
showStreamLoadingView = false
selectedRange = 0..<episodeChunkSize
}
}
@ -635,10 +574,7 @@ struct MediaInfoView: View {
}
func fetchStream(href: String) {
let fetchID = UUID()
activeFetchID = fetchID
currentStreamTitle = "Episode \(selectedEpisodeNumber)"
showStreamLoadingView = true
DropManager.shared.showDrop(title: "Fetching Stream", subtitle: "", duration: 0.5, icon: UIImage(systemName: "arrow.triangle.2.circlepath"))
isFetchingEpisode = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
Task {
@ -649,8 +585,6 @@ struct MediaInfoView: View {
if module.metadata.softsub == true {
if module.metadata.asyncJS == true {
jsController.fetchStreamUrlJS(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)
@ -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,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 = "<group>"; };
12D1E26A2DB4BA9D00EFCDAB /* SettingsViewProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewProfile.swift; sourceTree = "<group>"; };
12D1E26D2DB66F9600EFCDAB /* ProfileStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileStore.swift; sourceTree = "<group>"; };
12D1E26F2DB6702500EFCDAB /* Profile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Profile.swift; sourceTree = "<group>"; };
12D1E2712DB6770D00EFCDAB /* ProfileButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileButtonStyle.swift; sourceTree = "<group>"; };
12D1E2732DB67EA900EFCDAB /* UIKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIKit.swift; sourceTree = "<group>"; };
12D1E2792DB69D7F00EFCDAB /* ExploreView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExploreView.swift; sourceTree = "<group>"; };
130217CB2D81C55E0011EFF5 /* DownloadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadView.swift; sourceTree = "<group>"; };
130C6BF82D53A4C200DC1432 /* Sora.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Sora.entitlements; sourceTree = "<group>"; };
130C6BF92D53AB1F00DC1432 /* SettingsViewData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewData.swift; sourceTree = "<group>"; };
@ -84,9 +98,8 @@
132AF1242D9995F900A0140B /* JSController-Search.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-Search.swift"; sourceTree = "<group>"; };
133D7C6A2D2BE2500075467E /* Sulfur.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Sulfur.app; sourceTree = BUILT_PRODUCTS_DIR; };
133D7C6D2D2BE2500075467E /* SoraApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoraApp.swift; sourceTree = "<group>"; };
133D7C6F2D2BE2500075467E /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
133D7C6F2D2BE2500075467E /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; };
133D7C712D2BE2520075467E /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
133D7C742D2BE2520075467E /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
133D7C7C2D2BE2630075467E /* SearchView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = "<group>"; };
133D7C7E2D2BE2630075467E /* LibraryView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = "<group>"; };
133D7C802D2BE2630075467E /* MediaInfoView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaInfoView.swift; sourceTree = "<group>"; };
@ -119,7 +132,7 @@
13DB46912D900BCE008CBC03 /* SettingsViewTrackers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewTrackers.swift; sourceTree = "<group>"; };
13DB7CC22D7D99C0004371D3 /* SubtitleSettingsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubtitleSettingsManager.swift; sourceTree = "<group>"; };
13DB7CEB2D7DED5D004371D3 /* DownloadManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadManager.swift; sourceTree = "<group>"; };
13DC0C412D2EC9BA00D0F966 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
13DC0C412D2EC9BA00D0F966 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
13DC0C452D302C7500D0F966 /* VideoPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayer.swift; sourceTree = "<group>"; };
13E62FC12DABC5830007E259 /* Trakt-Login.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Trakt-Login.swift"; sourceTree = "<group>"; };
13E62FC32DABC58C0007E259 /* Trakt-Token.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Trakt-Token.swift"; sourceTree = "<group>"; };
@ -133,7 +146,18 @@
73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JavaScriptCore+Extensions.swift"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
120764662DB6F6E0003621E9 /* SulfurTV */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = SulfurTV; sourceTree = "<group>"; };
/* 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 = "<group>";
};
1207649F2DB6FA88003621E9 /* DownloadView */ = {
isa = PBXGroup;
children = (
130217CB2D81C55E0011EFF5 /* DownloadView.swift */,
);
path = DownloadView;
sourceTree = "<group>";
};
120764A02DB6FA9D003621E9 /* RootView */ = {
isa = PBXGroup;
children = (
133D7C6F2D2BE2500075467E /* RootView.swift */,
);
path = RootView;
sourceTree = "<group>";
};
12D1E26C2DB66F8B00EFCDAB /* ProfileStore */ = {
isa = PBXGroup;
children = (
12D1E2712DB6770D00EFCDAB /* ProfileButtonStyle.swift */,
12D1E26F2DB6702500EFCDAB /* Profile.swift */,
12D1E26D2DB66F9600EFCDAB /* ProfileStore.swift */,
);
path = ProfileStore;
sourceTree = "<group>";
};
12D1E2782DB69D7900EFCDAB /* ExploreView */ = {
isa = PBXGroup;
children = (
12D1E2792DB69D7F00EFCDAB /* ExploreView.swift */,
);
path = ExploreView;
sourceTree = "<group>";
};
13103E802D589D6C000F0673 /* Tracking Services */ = {
isa = PBXGroup;
children = (
@ -187,6 +253,7 @@
isa = PBXGroup;
children = (
133D7C6C2D2BE2500075467E /* Sora */,
120764662DB6F6E0003621E9 /* SulfurTV */,
133D7C6B2D2BE2500075467E /* Products */,
);
sourceTree = "<group>";
@ -195,6 +262,7 @@
isa = PBXGroup;
children = (
133D7C6A2D2BE2500075467E /* Sulfur.app */,
120764652DB6F6E0003621E9 /* SulfurTV.app */,
);
name = Products;
sourceTree = "<group>";
@ -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 = "<group>";
};
133D7C732D2BE2520075467E /* Preview Content */ = {
isa = PBXGroup;
children = (
133D7C742D2BE2520075467E /* Preview Assets.xcassets */,
);
path = "Preview Content";
sourceTree = "<group>";
};
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 = "<group>";
@ -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 = "<group>";
@ -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 = (

View file

@ -0,0 +1,15 @@
{
"colors" : [
{
"color" : {
"platform" : "universal",
"reference" : "systemMintColor"
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "large.png",
"idiom" : "tv"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,17 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"layers" : [
{
"filename" : "Front.imagestacklayer"
},
{
"filename" : "Middle.imagestacklayer"
},
{
"filename" : "Back.imagestacklayer"
}
]
}

View file

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "large.png",
"idiom" : "tv"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,11 @@
{
"images" : [
{
"idiom" : "tv"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,18 @@
{
"images" : [
{
"filename" : "small.png",
"idiom" : "tv",
"scale" : "1x"
},
{
"filename" : "large.png",
"idiom" : "tv",
"scale" : "2x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,17 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"layers" : [
{
"filename" : "Front.imagestacklayer"
},
{
"filename" : "Middle.imagestacklayer"
},
{
"filename" : "Back.imagestacklayer"
}
]
}

View file

@ -0,0 +1,18 @@
{
"images" : [
{
"filename" : "small.png",
"idiom" : "tv",
"scale" : "1x"
},
{
"filename" : "large.png",
"idiom" : "tv",
"scale" : "2x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,16 @@
{
"images" : [
{
"idiom" : "tv",
"scale" : "1x"
},
{
"idiom" : "tv",
"scale" : "2x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

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

View file

@ -0,0 +1,18 @@
{
"images" : [
{
"filename" : "small.png",
"idiom" : "tv",
"scale" : "1x"
},
{
"filename" : "large.png",
"idiom" : "tv",
"scale" : "2x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 795 KiB

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 651 KiB

View file

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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