mirror of
https://github.com/cranci1/Sora.git
synced 2026-01-11 20:10:24 +00:00
* First tvOS commit Barely usable on tvOS: - changed documentDirectory to cachesDirectory because is the only writeable folder - changed default player on default because custom has too many issues to fix on tvOS - commented a lot of lines because that property or function does not exist on tvOS - to add modules copy the link from Iphone * add conditional compilation Only searchview need a separation of a class due to compilation timeout issue * Fix incompatibility on tvOS --------- Co-authored-by: K <kimiko88@users.noreply.github.com>
376 lines
17 KiB
Swift
376 lines
17 KiB
Swift
//
|
|
// 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
|
|
|
|
@State private var selectedBookmark: LibraryItem? = nil
|
|
@State private var isDetailActive: Bool = false
|
|
|
|
@State private var continueWatchingItems: [ContinueWatchingItem] = []
|
|
#if !os(tvOS)
|
|
@State private var isLandscape: Bool = UIDevice.current.orientation.isLandscape
|
|
#endif
|
|
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 {
|
|
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()
|
|
}
|
|
#if !os(tvOS)
|
|
.onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in
|
|
updateOrientation()
|
|
}
|
|
#endif
|
|
}
|
|
}
|
|
.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 {
|
|
#if !os(tvOS)
|
|
isLandscape = UIDevice.current.orientation.isLandscape
|
|
#endif
|
|
}
|
|
}
|
|
|
|
private func determineColumns() -> Int {
|
|
if UIDevice.current.userInterfaceIdiom == .pad {
|
|
#if !os(tvOS)
|
|
return isLandscape ? mediaColumnsLandscape : mediaColumnsPortrait
|
|
#elseif os(tvOS)
|
|
return mediaColumnsLandscape
|
|
#endif
|
|
} 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 {
|
|
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,
|
|
episodeImageUrl: item.imageUrl
|
|
)
|
|
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(
|
|
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() {
|
|
let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(item.fullUrl)")
|
|
let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(item.fullUrl)")
|
|
|
|
if totalTime > 0 {
|
|
let ratio = lastPlayedTime / totalTime
|
|
// Clamp ratio between 0 and 1:
|
|
currentProgress = max(0, min(ratio, 1))
|
|
} else {
|
|
currentProgress = max(0, min(item.progress, 1))
|
|
}
|
|
}
|
|
}
|