Sora/Sora/Views/LibraryView/LibraryView.swift

407 lines
18 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// LibraryView.swift
// Sora
//
// Created by Francesco on 05/01/25.
//
import SwiftUI
import Kingfisher
struct LibraryView: View {
@EnvironmentObject private var libraryManager: LibraryManager
@EnvironmentObject private var moduleManager: ModuleManager
@AppStorage("mediaColumnsPortrait") private var mediaColumnsPortrait: Int = 2
@AppStorage("mediaColumnsLandscape") private var mediaColumnsLandscape: Int = 4
@Environment(\.verticalSizeClass) var verticalSizeClass
@Environment(\.horizontalSizeClass) var horizontalSizeClass
@State private var selectedBookmark: LibraryItem? = nil
@State private var isDetailActive: Bool = false
@State private var continueWatchingItems: [ContinueWatchingItem] = []
@State private var isLandscape: Bool = UIDevice.current.orientation.isLandscape
private let columns = [
GridItem(.adaptive(minimum: 150), spacing: 12)
]
private var columnsCount: Int {
// Stage Manager Detection
if UIDevice.current.userInterfaceIdiom == .pad && horizontalSizeClass == .compact {
return verticalSizeClass == .compact ? 3 : 2
} else if UIDevice.current.userInterfaceIdiom == .pad {
// Normal iPad layout
let isLandscape = UIScreen.main.bounds.width > UIScreen.main.bounds.height
return isLandscape ? mediaColumnsLandscape : mediaColumnsPortrait
} else {
// iPhone layout
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 {
let columnsCount = determineColumns()
VStack(alignment: .leading, spacing: 12) {
Text("Continue Watching")
.font(.title2)
.bold()
.padding(.horizontal, 20)
if continueWatchingItems.isEmpty {
VStack(spacing: 8) {
Image(systemName: "play.circle")
.font(.largeTitle)
.foregroundColor(.secondary)
Text("No items to continue watching.")
.font(.headline)
Text("Recently watched content will appear here.")
.font(.caption)
.foregroundColor(.secondary)
}
.padding()
.frame(maxWidth: .infinity)
} else {
ContinueWatchingSection(items: $continueWatchingItems, markAsWatched: { item in
markContinueWatchingItemAsWatched(item: item)
}, removeItem: { item in
removeContinueWatchingItem(item: item)
})
}
Text("Bookmarks")
.font(.title2)
.bold()
.padding(.horizontal, 20)
if libraryManager.bookmarks.isEmpty {
VStack(spacing: 8) {
Image(systemName: "magazine")
.font(.largeTitle)
.foregroundColor(.secondary)
Text("You have no items saved.")
.font(.headline)
Text("Bookmark items for an easier access later.")
.font(.caption)
.foregroundColor(.secondary)
}
.padding()
.frame(maxWidth: .infinity)
} else {
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: columnsCount), spacing: 12) {
ForEach(libraryManager.bookmarks) { item in
if let module = moduleManager.modules.first(where: { $0.id.uuidString == item.moduleId }) {
Button(action: {
selectedBookmark = item
isDetailActive = true
}) {
VStack(alignment: .leading) {
ZStack {
KFImage(URL(string: item.imageUrl))
.placeholder {
RoundedRectangle(cornerRadius: 10)
.fill(Color.gray.opacity(0.3))
.aspectRatio(2/3, contentMode: .fit)
.shimmering()
}
.resizable()
.aspectRatio(contentMode: .fill)
.frame(height: cellWidth * 3 / 2)
.frame(maxWidth: cellWidth)
.cornerRadius(10)
.clipped()
.overlay(
KFImage(URL(string: module.metadata.iconUrl))
.resizable()
.frame(width: 24, height: 24)
.cornerRadius(4)
.padding(4),
alignment: .topLeading
)
}
Text(item.title)
.font(.subheadline)
.foregroundColor(.primary)
.lineLimit(1)
.multilineTextAlignment(.leading)
}
}
.contextMenu {
Button(role: .destructive, action: {
libraryManager.removeBookmark(item: item)
}) {
Label("Remove from Bookmarks", systemImage: "trash")
}
}
}
}
}
.padding(.horizontal, 20)
NavigationLink(
destination: Group {
if let bookmark = selectedBookmark,
let module = moduleManager.modules.first(where: { $0.id.uuidString == bookmark.moduleId }) {
MediaInfoView(title: bookmark.title,
imageUrl: bookmark.imageUrl,
href: bookmark.href,
module: module)
} else {
Text("No Data Available")
}
},
isActive: $isDetailActive
) {
EmptyView()
}
.onAppear {
updateOrientation()
}
.onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in
updateOrientation()
}
}
}
.padding(.vertical, 20)
}
.navigationTitle("Library")
.onAppear {
fetchContinueWatching()
}
}
.navigationViewStyle(StackNavigationViewStyle())
}
private func fetchContinueWatching() {
continueWatchingItems = ContinueWatchingManager.shared.fetchItems()
}
private func markContinueWatchingItemAsWatched(item: ContinueWatchingItem) {
let key = "lastPlayedTime_\(item.fullUrl)"
let totalKey = "totalTime_\(item.fullUrl)"
UserDefaults.standard.set(99999999.0, forKey: key)
UserDefaults.standard.set(99999999.0, forKey: totalKey)
ContinueWatchingManager.shared.remove(item: item)
continueWatchingItems.removeAll { $0.id == item.id }
}
private func removeContinueWatchingItem(item: ContinueWatchingItem) {
ContinueWatchingManager.shared.remove(item: item)
continueWatchingItems.removeAll { $0.id == item.id }
}
private func updateOrientation() {
DispatchQueue.main.async {
isLandscape = UIDevice.current.orientation.isLandscape
}
}
private func determineColumns() -> Int {
// Stage Manager Detection
if UIDevice.current.userInterfaceIdiom == .pad && horizontalSizeClass == .compact {
return verticalSizeClass == .compact ? 3 : 2
} else if UIDevice.current.userInterfaceIdiom == .pad {
// Normal iPad layout
let isLandscape = UIScreen.main.bounds.width > UIScreen.main.bounds.height
return isLandscape ? mediaColumnsLandscape : mediaColumnsPortrait
} else {
// iPhone layout
return verticalSizeClass == .compact ? mediaColumnsLandscape : mediaColumnsPortrait
}
}
}
struct ContinueWatchingSection: View {
@Binding var items: [ContinueWatchingItem]
var markAsWatched: (ContinueWatchingItem) -> Void
var removeItem: (ContinueWatchingItem) -> Void
var body: some View {
VStack(alignment: .leading) {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(Array(items.reversed())) { item in
ContinueWatchingCell(item: item, markAsWatched: {
markAsWatched(item)
}, removeItem: {
removeItem(item)
})
}
}
.padding(.horizontal, 20)
}
.frame(height: 190)
}
}
}
struct ContinueWatchingCell: View {
let item: ContinueWatchingItem
var markAsWatched: () -> Void
var removeItem: () -> Void
@State private var currentProgress: Double = 0.0
var body: some View {
Button(action: {
if UserDefaults.standard.string(forKey: "externalPlayer") == "Default" {
let videoPlayerViewController = VideoPlayerViewController(module: item.module)
videoPlayerViewController.streamUrl = item.streamUrl
videoPlayerViewController.fullUrl = item.fullUrl
videoPlayerViewController.episodeImageUrl = item.imageUrl
videoPlayerViewController.episodeNumber = item.episodeNumber
videoPlayerViewController.mediaTitle = item.mediaTitle
videoPlayerViewController.subtitles = item.subtitles ?? ""
videoPlayerViewController.aniListID = item.aniListID ?? 0
videoPlayerViewController.modalPresentationStyle = .fullScreen
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let rootVC = windowScene.windows.first?.rootViewController {
findTopViewController.findViewController(rootVC).present(videoPlayerViewController, animated: true, completion: nil)
}
} else {
let customMediaPlayer = CustomMediaPlayerViewController(
module: item.module,
urlString: item.streamUrl,
fullUrl: item.fullUrl,
title: item.mediaTitle,
episodeNumber: item.episodeNumber,
onWatchNext: { },
subtitlesURL: item.subtitles,
aniListID: item.aniListID ?? 0,
totalEpisodes: item.totalEpisodes,
episodeImageUrl: item.imageUrl,
headers: item.headers ?? nil
)
customMediaPlayer.modalPresentationStyle = .fullScreen
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let rootVC = windowScene.windows.first?.rootViewController {
findTopViewController.findViewController(rootVC).present(customMediaPlayer, animated: true, completion: nil)
}
}
}) {
VStack(alignment: .leading) {
ZStack {
KFImage(URL(string: item.imageUrl.isEmpty
? "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/main/assets/banner2.png"
: item.imageUrl))
.placeholder {
RoundedRectangle(cornerRadius: 10)
.fill(Color.gray.opacity(0.3))
.frame(width: 240, height: 135)
.shimmering()
}
.setProcessor(RoundCornerImageProcessor(cornerRadius: 10))
.resizable()
.aspectRatio(16/9, contentMode: .fill)
.frame(width: 240, height: 135)
.cornerRadius(10)
.clipped()
.overlay(
Group {
if item.streamUrl.hasPrefix("file://") {
Image(systemName: "arrow.down.app.fill")
.resizable()
.scaledToFit()
.frame(width: 24, height: 24)
.foregroundColor(.white)
.background(Color.black.cornerRadius(6)) // black exactly 24×24
.padding(4) // add spacing outside the black
} else {
KFImage(URL(string: item.module.metadata.iconUrl))
.resizable()
.frame(width: 24, height: 24)
.cornerRadius(4)
.padding(4)
}
},
alignment: .topLeading
)
}
.overlay(
ZStack {
Rectangle()
.fill(Color.black.opacity(0.3))
.blur(radius: 3)
.frame(height: 30)
ProgressView(value: currentProgress)
.progressViewStyle(LinearProgressViewStyle(tint: .white))
.padding(.horizontal, 8)
.scaleEffect(x: 1, y: 1.5, anchor: .center)
},
alignment: .bottom
)
VStack(alignment: .leading) {
Text("Episode \(item.episodeNumber)")
.font(.caption)
.lineLimit(1)
.foregroundColor(.secondary)
Text(item.mediaTitle)
.font(.caption)
.lineLimit(2)
.foregroundColor(.primary)
.multilineTextAlignment(.leading)
}
}
.frame(width: 240, height: 170)
}
.contextMenu {
Button(action: { markAsWatched() }) {
Label("Mark as Watched", systemImage: "checkmark.circle")
}
Button(role: .destructive, action: { removeItem() }) {
Label("Remove Item", systemImage: "trash")
}
}
.onAppear {
updateProgress()
}
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
updateProgress()
}
}
private func updateProgress() {
// grab the true playback times
let lastPlayed = UserDefaults.standard.double(forKey: "lastPlayedTime_\(item.fullUrl)")
let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(item.fullUrl)")
// compute a clean 01 ratio
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 {
// >90% watched? drop it immediately
removeItem()
} else {
// otherwise persist the latest progress
var updated = item
updated.progress = ratio
ContinueWatchingManager.shared.save(item: updated)
}
}
}