// // LibraryView.swift // Sora // // Created by Francesco on 05/01/25. // import UIKit import NukeUI import SwiftUI struct LibraryView: View { @EnvironmentObject private var libraryManager: LibraryManager @EnvironmentObject private var moduleManager: ModuleManager @Environment(\.scenePhase) private var scenePhase @AppStorage("mediaColumnsPortrait") private var mediaColumnsPortrait: Int = 2 @AppStorage("mediaColumnsLandscape") private var mediaColumnsLandscape: Int = 4 @AppStorage("librarySectionsOrderData") private var librarySectionsOrderData: Data = { try! JSONEncoder().encode(["continueWatching", "continueReading", "collections"]) }() @AppStorage("disabledLibrarySectionsData") private var disabledLibrarySectionsData: Data = { try! JSONEncoder().encode([String]()) }() @Environment(\.verticalSizeClass) var verticalSizeClass @Environment(\.horizontalSizeClass) var horizontalSizeClass @State private var continueWatchingItems: [ContinueWatchingItem] = [] @State private var continueReadingItems: [ContinueReadingItem] = [] @State private var isLandscape: Bool = UIDevice.current.orientation.isLandscape @State private var selectedTab: Int = 0 @State private var isActive: Bool = false private var librarySectionsOrder: [String] { (try? JSONDecoder().decode([String].self, from: librarySectionsOrderData)) ?? ["continueWatching", "continueReading", "collections"] } private var disabledLibrarySections: [String] { (try? JSONDecoder().decode([String].self, from: disabledLibrarySectionsData)) ?? [] } private let columns = [ GridItem(.adaptive(minimum: 150), spacing: 12) ] private var columnsCount: Int { if UIDevice.current.userInterfaceIdiom == .pad && horizontalSizeClass == .compact { return verticalSizeClass == .compact ? 3 : 2 } else 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 { ZStack { ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 20) { Text(LocalizedStringKey("Library")) .font(.largeTitle) .fontWeight(.bold) .padding(.horizontal, 20) .padding(.top, 20) ForEach(librarySectionsOrder, id: \.self) { section in if !disabledLibrarySections.contains(section) { switch section { case "continueWatching": continueWatchingSection case "continueReading": continueReadingSection case "collections": collectionsSection default: EmptyView() } } } Spacer().frame(height: 100) } .padding(.bottom, 20) } .scrollViewBottomPadding() .deviceScaled() .onAppear { isActive = true fetchContinueWatching() fetchContinueReading() let isMediaInfoActive = UserDefaults.standard.bool(forKey: "isMediaInfoActive") let isReaderActive = UserDefaults.standard.bool(forKey: "isReaderActive") if !isMediaInfoActive && !isReaderActive { NotificationCenter.default.post(name: .showTabBar, object: nil) } DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) { let isMediaInfoActive = UserDefaults.standard.bool(forKey: "isMediaInfoActive") let isReaderActive = UserDefaults.standard.bool(forKey: "isReaderActive") if !isMediaInfoActive && !isReaderActive { NotificationCenter.default.post(name: .showTabBar, object: nil) } } } .onDisappear { isActive = false } .onChange(of: scenePhase) { newPhase in if newPhase == .active { fetchContinueWatching() fetchContinueReading() let isMediaInfoActive = UserDefaults.standard.bool(forKey: "isMediaInfoActive") let isReaderActive = UserDefaults.standard.bool(forKey: "isReaderActive") if !isMediaInfoActive && !isReaderActive { NotificationCenter.default.post(name: .showTabBar, object: nil) } } } .onReceive(Timer.publish(every: 0.1, on: .main, in: .common).autoconnect()) { _ in let isMediaInfoActive = UserDefaults.standard.bool(forKey: "isMediaInfoActive") let isReaderActive = UserDefaults.standard.bool(forKey: "isReaderActive") if isActive && !isMediaInfoActive && !isReaderActive { NotificationCenter.default.post(name: .showTabBar, object: nil) } } .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in isActive = true let isMediaInfoActive = UserDefaults.standard.bool(forKey: "isMediaInfoActive") let isReaderActive = UserDefaults.standard.bool(forKey: "isReaderActive") if !isMediaInfoActive && !isReaderActive { NotificationCenter.default.post(name: .showTabBar, object: nil) } } } } .navigationViewStyle(StackNavigationViewStyle()) } // MARK: - Section Views private var continueWatchingSection: some View { VStack(alignment: .leading, spacing: 0) { HStack { HStack(spacing: 4) { Image(systemName: "play.fill") .font(.subheadline) Text(LocalizedStringKey("Continue Watching")) .font(.title3) .fontWeight(.semibold) } Spacer() NavigationLink(destination: AllWatchingView()) { Text(LocalizedStringKey("View All")) .font(.subheadline) .padding(.horizontal, 12) .padding(.vertical, 6) .background(Color.gray.opacity(0.2)) .cornerRadius(15) .gradientOutline() } } .padding(.horizontal, 20) .padding(.bottom, 10) if continueWatchingItems.isEmpty { VStack(spacing: 8) { Image(systemName: "play.circle") .font(.largeTitle) .foregroundColor(.secondary) Text(LocalizedStringKey("Nothing to Continue Watching")) .font(.headline) Text(LocalizedStringKey("Your recently watched content will appear here")) .font(.caption) .foregroundColor(.secondary) } .padding() .frame(maxWidth: .infinity) } else { ContinueWatchingSection(items: $continueWatchingItems, markAsWatched: { item in markContinueWatchingItemAsWatched(item: item) }, removeItem: { item in removeContinueWatchingItem(item: item) }) } Spacer().frame(height: 20) } } private var continueReadingSection: some View { VStack(alignment: .leading, spacing: 0) { HStack { HStack(spacing: 4) { Image(systemName: "book.fill") .font(.subheadline) Text(LocalizedStringKey("Continue Reading")) .font(.title3) .fontWeight(.semibold) } Spacer() NavigationLink(destination: AllReadingView()) { Text(LocalizedStringKey("View All")) .font(.subheadline) .padding(.horizontal, 12) .padding(.vertical, 6) .background(Color.gray.opacity(0.2)) .cornerRadius(15) .gradientOutline() } } .padding(.horizontal, 20) .padding(.bottom, 10) if continueReadingItems.isEmpty { VStack(spacing: 8) { Image(systemName: "book.closed") .font(.largeTitle) .foregroundColor(.secondary) Text(LocalizedStringKey("Nothing to Continue Reading")) .font(.headline) Text(LocalizedStringKey("Your recently read novels will appear here")) .font(.caption) .foregroundColor(.secondary) } .padding() .frame(maxWidth: .infinity) } else { ContinueReadingSection(items: $continueReadingItems, markAsRead: { item in markContinueReadingItemAsRead(item: item) }, removeItem: { item in removeContinueReadingItem(item: item) }) } Spacer().frame(height: 20) } } private var collectionsSection: some View { VStack(alignment: .leading, spacing: 0) { HStack { HStack(spacing: 4) { Image(systemName: "folder.fill") .font(.subheadline) Text(LocalizedStringKey("Collections")) .font(.title3) .fontWeight(.semibold) } Spacer() NavigationLink(destination: BookmarksDetailView()) { Text(LocalizedStringKey("View All")) .font(.subheadline) .padding(.horizontal, 12) .padding(.vertical, 6) .background(Color.gray.opacity(0.2)) .cornerRadius(15) .gradientOutline() } } .padding(.horizontal, 20) .padding(.bottom, 10) BookmarksSection() Spacer().frame(height: 20) } } private func fetchContinueWatching() { continueWatchingItems = ContinueWatchingManager.shared.fetchItems() } private func markContinueWatchingItemAsWatched(item: ContinueWatchingItem) { let key = "lastPlayedTime_\(item.fullUrl)" let totalKey = "totalTime_\(item.fullUrl)" UserDefaults.standard.set(99999999.0, forKey: key) UserDefaults.standard.set(99999999.0, forKey: totalKey) ContinueWatchingManager.shared.remove(item: item) continueWatchingItems.removeAll { $0.id == item.id } } private func removeContinueWatchingItem(item: ContinueWatchingItem) { ContinueWatchingManager.shared.remove(item: item) continueWatchingItems.removeAll { $0.id == item.id } } private func fetchContinueReading() { continueReadingItems = ContinueReadingManager.shared.fetchItems() Logger.shared.log("Fetched \(continueReadingItems.count) continue reading items", type: "Debug") if !continueReadingItems.isEmpty { for (index, item) in continueReadingItems.enumerated() { Logger.shared.log("Reading item \(index): \(item.mediaTitle), chapter \(item.chapterNumber), progress \(item.progress)", type: "Debug") } } } private func markContinueReadingItemAsRead(item: ContinueReadingItem) { UserDefaults.standard.set(1.0, forKey: "readingProgress_\(item.href)") ContinueReadingManager.shared.updateProgress(for: item.href, progress: 1.0) fetchContinueReading() } private func removeContinueReadingItem(item: ContinueReadingItem) { ContinueReadingManager.shared.remove(item: item) continueReadingItems.removeAll { $0.id == item.id } } private func updateOrientation() { DispatchQueue.main.async { isLandscape = UIDevice.current.orientation.isLandscape } } private func determineColumns() -> Int { if UIDevice.current.userInterfaceIdiom == .pad && horizontalSizeClass == .compact { return verticalSizeClass == .compact ? 3 : 2 } else 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 } } } struct ContinueWatchingSection: View { @Binding var items: [ContinueWatchingItem] var markAsWatched: (ContinueWatchingItem) -> Void var removeItem: (ContinueWatchingItem) -> Void var body: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 12) { ForEach(Array(items.reversed().prefix(5))) { item in ContinueWatchingCell(item: item, markAsWatched: { markAsWatched(item) }, removeItem: { removeItem(item) }) } } .padding(.horizontal, 20) .frame(height: 157.03) } } } struct ContinueWatchingCell: View { let item: ContinueWatchingItem var markAsWatched: () -> Void var removeItem: () -> Void @State private var currentProgress: Double = 0.0 @Environment(\.scenePhase) private var scenePhase var body: some View { Button(action: { if UserDefaults.standard.string(forKey: "externalPlayer") == "Default" { let videoPlayerViewController = VideoPlayerViewController(module: item.module) videoPlayerViewController.streamUrl = item.streamUrl videoPlayerViewController.fullUrl = item.fullUrl videoPlayerViewController.episodeImageUrl = item.imageUrl videoPlayerViewController.episodeNumber = item.episodeNumber videoPlayerViewController.mediaTitle = item.mediaTitle videoPlayerViewController.subtitles = item.subtitles ?? "" videoPlayerViewController.aniListID = item.aniListID ?? 0 videoPlayerViewController.modalPresentationStyle = .fullScreen if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let rootVC = windowScene.windows.first?.rootViewController { findTopViewController.findViewController(rootVC).present(videoPlayerViewController, animated: true, completion: nil) } } else { let customMediaPlayer = CustomMediaPlayerViewController( module: item.module, urlString: item.streamUrl, fullUrl: item.fullUrl, title: item.mediaTitle, episodeNumber: item.episodeNumber, episodeTitle: item.episodeTitle ?? "", seasonNumber: item.seasonNumber ?? 1, onWatchNext: { }, subtitlesURL: item.subtitles, aniListID: item.aniListID ?? 0, totalEpisodes: item.totalEpisodes, episodeImageUrl: item.imageUrl, headers: item.headers ?? nil ) customMediaPlayer.modalPresentationStyle = .fullScreen if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let rootVC = windowScene.windows.first?.rootViewController { findTopViewController.findViewController(rootVC).present(customMediaPlayer, animated: true, completion: nil) } } }) { ZStack(alignment: .bottomLeading) { LazyImage(url: URL(string: item.imageUrl.isEmpty ? "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/main/assets/banner2.png" : item.imageUrl)) { state in if let uiImage = state.imageContainer?.image { Image(uiImage: uiImage) .resizable() .aspectRatio(16/9, contentMode: .fill) .frame(width: 280, height: 157.03) .cornerRadius(10) .clipped() } else { RoundedRectangle(cornerRadius: 10) .fill(Color.gray.opacity(0.3)) .frame(width: 280, height: 157.03) .redacted(reason: .placeholder) } } .overlay( ZStack { VStack(alignment: .leading, spacing: 4) { Spacer() Text(item.mediaTitle) .font(.headline) .foregroundColor(.white) .lineLimit(1) HStack { Text(episodeLabel(for: item)) .font(.subheadline) .foregroundColor(.white.opacity(0.9)) .lineLimit(2) .truncationMode(.tail) Spacer() Text("\(Int(item.progress * 100))% seen") .font(.caption) .foregroundColor(.white.opacity(0.9)) } } .padding(10) .background( LinearGradient( colors: [ .black.opacity(0.7), .black.opacity(0.0) ], startPoint: .bottom, endPoint: .top ) .clipped() .cornerRadius(10, corners: [.bottomLeft, .bottomRight]) .shadow(color: .black.opacity(0.3), radius: 3, x: 0, y: 1) ) }, alignment: .bottom ) .overlay( ZStack { if item.streamUrl.hasPrefix("file://") { Image(systemName: "arrow.down.app.fill") .resizable() .scaledToFit() .frame(width: 24, height: 24) .foregroundColor(.white) .background(Color.black.cornerRadius(6)) .padding(8) } else { Circle() .fill(Color.black.opacity(0.5)) .frame(width: 28, height: 28) .overlay( LazyImage(url: URL(string: item.module.metadata.iconUrl)) { state in if let uiImage = state.imageContainer?.image { Image(uiImage: uiImage) .resizable() .scaledToFill() .frame(width: 32, height: 32) .clipShape(Circle()) } else { Circle() .fill(Color.gray.opacity(0.3)) .frame(width: 32, height: 32) } } ) .padding(8) } }, alignment: .topLeading ) } .frame(width: 280, height: 157.03) } .contextMenu { Button(action: { markAsWatched() }) { Label("Mark as Watched", systemImage: "checkmark.circle") } Button(role: .destructive, action: { removeItem() }) { Label("Remove Item", systemImage: "trash") } } .onAppear { updateProgress() } .onChange(of: scenePhase) { newPhase in if newPhase == .active { updateProgress() } } } private func updateProgress() { let lastPlayed = UserDefaults.standard.double(forKey: "lastPlayedTime_\(item.fullUrl)") let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(item.fullUrl)") let ratio: Double if totalTime > 0 { ratio = min(max(lastPlayed / totalTime, 0), 1) } else { ratio = min(max(item.progress, 0), 1) } currentProgress = ratio if ratio >= 0.9 { removeItem() } else { var updated = item updated.progress = ratio ContinueWatchingManager.shared.save(item: updated) } } } private func episodeLabel(for item: ContinueWatchingItem) -> String { let hasTitle = !(item.episodeTitle?.isEmpty ?? true) let isSingleSeason = (item.seasonNumber ?? 1) <= 1 let episodePart = "E\(item.episodeNumber)" let seasonPart = isSingleSeason ? "" : "S\(item.seasonNumber ?? 1)" let colon = hasTitle ? ":" : "" let title = item.episodeTitle ?? "" let main = [seasonPart, episodePart].filter { !$0.isEmpty }.joined() return hasTitle ? "\(main)\(colon) \(title)" : main } struct RoundedCorner: Shape { var radius: CGFloat = .infinity var corners: UIRectCorner = .allCorners func path( in rect: CGRect) -> Path { let path = UIBezierPath( roundedRect: rect, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius) ) return Path(path.cgPath) } } extension View { func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View { clipShape(RoundedCorner(radius: radius, corners: corners)) } func gradientOutline() -> some View { self.background( RoundedRectangle(cornerRadius: 15) .stroke( LinearGradient( gradient: Gradient(stops: [ .init(color: Color.accentColor.opacity(0.25), location: 0), .init(color: Color.accentColor.opacity(0), location: 1) ]), startPoint: .top, endPoint: .bottom ), lineWidth: 0.5 ) ) } } struct BookmarksSection: View { @EnvironmentObject private var libraryManager: LibraryManager @EnvironmentObject private var moduleManager: ModuleManager @State private var isShowingRenamePrompt: Bool = false @State private var collectionToRename: BookmarkCollection? = nil @State private var renameCollectionName: String = "" var body: some View { VStack(alignment: .leading, spacing: 16) { if libraryManager.collections.isEmpty { EmptyBookmarksView() } else { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 12) { ForEach(Array(libraryManager.collections.prefix(5))) { collection in NavigationLink(destination: CollectionDetailView(collection: collection)) { BookmarkCollectionGridCell(collection: collection, width: 162, height: 162) } .contextMenu { Button("Rename") { collectionToRename = collection renameCollectionName = collection.name isShowingRenamePrompt = true } Button(role: .destructive) { libraryManager.deleteCollection(id: collection.id) } label: { Label("Delete", systemImage: "trash") } } } } .padding(.horizontal, 20) .frame(height: 220) } } } .alert("Rename Collection", isPresented: $isShowingRenamePrompt, presenting: collectionToRename) { collection in TextField("Collection Name", text: $renameCollectionName) Button("Cancel", role: .cancel) { collectionToRename = nil renameCollectionName = "" } Button("Rename") { if !renameCollectionName.isEmpty { libraryManager.renameCollection(id: collection.id, newName: renameCollectionName) } collectionToRename = nil renameCollectionName = "" } } message: { _ in EmptyView() } } } struct EmptyBookmarksView: View { var body: some View { VStack(spacing: 8) { Image(systemName: "folder") .font(.largeTitle) .foregroundColor(.secondary) Text("No Collections") .font(.headline) Text("Create a collection to organize your bookmarks") .font(.caption) .foregroundColor(.secondary) } .padding() .frame(maxWidth: .infinity) } }