mirror of
https://github.com/cranci1/Sora.git
synced 2026-05-12 21:10:46 +00:00
idk i changed layout constants
This commit is contained in:
parent
afe49abcfa
commit
fddf940b95
1 changed files with 353 additions and 367 deletions
|
|
@ -12,38 +12,35 @@ import UIKit
|
||||||
struct LibraryView: View {
|
struct LibraryView: View {
|
||||||
@EnvironmentObject private var libraryManager: LibraryManager
|
@EnvironmentObject private var libraryManager: LibraryManager
|
||||||
@EnvironmentObject private var moduleManager: ModuleManager
|
@EnvironmentObject private var moduleManager: ModuleManager
|
||||||
|
|
||||||
@AppStorage("mediaColumnsPortrait") private var mediaColumnsPortrait: Int = 2
|
@AppStorage("mediaColumnsPortrait") private var mediaColumnsPortrait: Int = 2
|
||||||
@AppStorage("mediaColumnsLandscape") private var mediaColumnsLandscape: Int = 4
|
@AppStorage("mediaColumnsLandscape") private var mediaColumnsLandscape: Int = 4
|
||||||
|
|
||||||
@Environment(\.verticalSizeClass) var verticalSizeClass
|
@Environment(\.verticalSizeClass) var verticalSizeClass
|
||||||
@Environment(\.horizontalSizeClass) var horizontalSizeClass
|
@Environment(\.horizontalSizeClass) var horizontalSizeClass
|
||||||
|
|
||||||
@State private var selectedBookmark: LibraryItem? = nil
|
@State private var selectedBookmark: LibraryItem? = nil
|
||||||
@State private var isDetailActive: Bool = false
|
@State private var isDetailActive: Bool = false
|
||||||
|
|
||||||
@State private var continueWatchingItems: [ContinueWatchingItem] = []
|
@State private var continueWatchingItems: [ContinueWatchingItem] = []
|
||||||
@State private var isLandscape: Bool = UIDevice.current.orientation.isLandscape
|
@State private var isLandscape: Bool = UIDevice.current.orientation.isLandscape
|
||||||
@State private var selectedTab: Int = 0
|
@State private var selectedTab: Int = 0
|
||||||
|
|
||||||
private let columns = [
|
private let columns = [
|
||||||
GridItem(.adaptive(minimum: 150), spacing: 12)
|
GridItem(.adaptive(minimum: 150), spacing: 12)
|
||||||
]
|
]
|
||||||
|
|
||||||
private var columnsCount: Int {
|
private var columnsCount: Int {
|
||||||
// Stage Manager Detection
|
|
||||||
if UIDevice.current.userInterfaceIdiom == .pad && horizontalSizeClass == .compact {
|
if UIDevice.current.userInterfaceIdiom == .pad && horizontalSizeClass == .compact {
|
||||||
return verticalSizeClass == .compact ? 3 : 2
|
return verticalSizeClass == .compact ? 3 : 2
|
||||||
} else if UIDevice.current.userInterfaceIdiom == .pad {
|
} else if UIDevice.current.userInterfaceIdiom == .pad {
|
||||||
// Normal iPad layout
|
|
||||||
let isLandscape = UIScreen.main.bounds.width > UIScreen.main.bounds.height
|
let isLandscape = UIScreen.main.bounds.width > UIScreen.main.bounds.height
|
||||||
return isLandscape ? mediaColumnsLandscape : mediaColumnsPortrait
|
return isLandscape ? mediaColumnsLandscape : mediaColumnsPortrait
|
||||||
} else {
|
} else {
|
||||||
// iPhone layout
|
|
||||||
return verticalSizeClass == .compact ? mediaColumnsLandscape : mediaColumnsPortrait
|
return verticalSizeClass == .compact ? mediaColumnsLandscape : mediaColumnsPortrait
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var cellWidth: CGFloat {
|
private var cellWidth: CGFloat {
|
||||||
let keyWindow = UIApplication.shared.connectedScenes
|
let keyWindow = UIApplication.shared.connectedScenes
|
||||||
.compactMap { ($0 as? UIWindowScene)?.windows.first(where: { $0.isKeyWindow }) }
|
.compactMap { ($0 as? UIWindowScene)?.windows.first(where: { $0.isKeyWindow }) }
|
||||||
|
|
@ -54,114 +51,114 @@ struct LibraryView: View {
|
||||||
let availableWidth = safeWidth - totalSpacing
|
let availableWidth = safeWidth - totalSpacing
|
||||||
return availableWidth / CGFloat(columnsCount)
|
return availableWidth / CGFloat(columnsCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
NavigationView {
|
||||||
ZStack {
|
ZStack {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 20) {
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
Text("Library")
|
Text("Library")
|
||||||
.font(.largeTitle)
|
.font(.largeTitle)
|
||||||
.fontWeight(.bold)
|
.fontWeight(.bold)
|
||||||
.padding(.horizontal, 20)
|
|
||||||
.padding(.top, 20)
|
|
||||||
HStack {
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
Image(systemName: "play.fill")
|
|
||||||
.font(.subheadline)
|
|
||||||
Text("Continue Watching")
|
|
||||||
.font(.title3)
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
NavigationLink(destination: AllWatchingView()) {
|
|
||||||
Text("View All")
|
|
||||||
.font(.subheadline)
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.vertical, 6)
|
|
||||||
.background(Color.gray.opacity(0.2))
|
|
||||||
.cornerRadius(15)
|
|
||||||
.gradientOutline()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.top, 20)
|
||||||
if continueWatchingItems.isEmpty {
|
HStack {
|
||||||
VStack(spacing: 8) {
|
HStack(spacing: 4) {
|
||||||
Image(systemName: "play.circle")
|
Image(systemName: "play.fill")
|
||||||
.font(.largeTitle)
|
.font(.subheadline)
|
||||||
.foregroundColor(.secondary)
|
Text("Continue Watching")
|
||||||
Text("No items to continue watching.")
|
.font(.title3)
|
||||||
.font(.headline)
|
.fontWeight(.semibold)
|
||||||
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)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
HStack {
|
Spacer()
|
||||||
HStack(spacing: 4) {
|
|
||||||
Image(systemName: "bookmark.fill")
|
NavigationLink(destination: AllWatchingView()) {
|
||||||
.font(.subheadline)
|
Text("View All")
|
||||||
Text("Bookmarks")
|
.font(.subheadline)
|
||||||
.font(.title3)
|
.padding(.horizontal, 12)
|
||||||
.fontWeight(.semibold)
|
.padding(.vertical, 6)
|
||||||
}
|
.background(Color.gray.opacity(0.2))
|
||||||
|
.cornerRadius(15)
|
||||||
Spacer()
|
.gradientOutline()
|
||||||
|
|
||||||
NavigationLink(destination: BookmarksDetailView(bookmarks: $libraryManager.bookmarks)) {
|
|
||||||
Text("View All")
|
|
||||||
.font(.subheadline)
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.vertical, 6)
|
|
||||||
.background(Color.gray.opacity(0.2))
|
|
||||||
.cornerRadius(15)
|
|
||||||
.gradientOutline()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 20)
|
|
||||||
|
|
||||||
|
|
||||||
BookmarksSection(
|
|
||||||
selectedBookmark: $selectedBookmark,
|
|
||||||
isDetailActive: $isDetailActive
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer().frame(height: 100)
|
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.bottom, 20)
|
.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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "bookmark.fill")
|
||||||
|
.font(.subheadline)
|
||||||
|
Text("Bookmarks")
|
||||||
|
.font(.title3)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
NavigationLink(destination: BookmarksDetailView(bookmarks: $libraryManager.bookmarks)) {
|
||||||
|
Text("View All")
|
||||||
|
.font(.subheadline)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(Color.gray.opacity(0.2))
|
||||||
|
.cornerRadius(15)
|
||||||
|
.gradientOutline()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
|
||||||
|
|
||||||
|
BookmarksSection(
|
||||||
|
selectedBookmark: $selectedBookmark,
|
||||||
|
isDetailActive: $isDetailActive
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer().frame(height: 100)
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.bottom, 20)
|
||||||
}
|
}
|
||||||
.scrollViewBottomPadding()
|
.scrollViewBottomPadding()
|
||||||
.deviceScaled()
|
.deviceScaled()
|
||||||
|
|
@ -172,11 +169,11 @@ struct LibraryView: View {
|
||||||
}
|
}
|
||||||
.navigationViewStyle(StackNavigationViewStyle())
|
.navigationViewStyle(StackNavigationViewStyle())
|
||||||
}
|
}
|
||||||
|
|
||||||
private func fetchContinueWatching() {
|
private func fetchContinueWatching() {
|
||||||
continueWatchingItems = ContinueWatchingManager.shared.fetchItems()
|
continueWatchingItems = ContinueWatchingManager.shared.fetchItems()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func markContinueWatchingItemAsWatched(item: ContinueWatchingItem) {
|
private func markContinueWatchingItemAsWatched(item: ContinueWatchingItem) {
|
||||||
let key = "lastPlayedTime_\(item.fullUrl)"
|
let key = "lastPlayedTime_\(item.fullUrl)"
|
||||||
let totalKey = "totalTime_\(item.fullUrl)"
|
let totalKey = "totalTime_\(item.fullUrl)"
|
||||||
|
|
@ -187,54 +184,50 @@ struct LibraryView: View {
|
||||||
$0.id == item.id
|
$0.id == item.id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func removeContinueWatchingItem(item: ContinueWatchingItem) {
|
private func removeContinueWatchingItem(item: ContinueWatchingItem) {
|
||||||
ContinueWatchingManager.shared.remove(item: item)
|
ContinueWatchingManager.shared.remove(item: item)
|
||||||
continueWatchingItems.removeAll {
|
continueWatchingItems.removeAll {
|
||||||
$0.id == item.id
|
$0.id == item.id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateOrientation() {
|
private func updateOrientation() {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
isLandscape = UIDevice.current.orientation.isLandscape
|
isLandscape = UIDevice.current.orientation.isLandscape
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func determineColumns() -> Int {
|
private func determineColumns() -> Int {
|
||||||
// Stage Manager Detection
|
|
||||||
if UIDevice.current.userInterfaceIdiom == .pad && horizontalSizeClass == .compact {
|
if UIDevice.current.userInterfaceIdiom == .pad && horizontalSizeClass == .compact {
|
||||||
return verticalSizeClass == .compact ? 3 : 2
|
return verticalSizeClass == .compact ? 3 : 2
|
||||||
} else if UIDevice.current.userInterfaceIdiom == .pad {
|
} else if UIDevice.current.userInterfaceIdiom == .pad {
|
||||||
// Normal iPad layout
|
|
||||||
let isLandscape = UIScreen.main.bounds.width > UIScreen.main.bounds.height
|
let isLandscape = UIScreen.main.bounds.width > UIScreen.main.bounds.height
|
||||||
return isLandscape ? mediaColumnsLandscape : mediaColumnsPortrait
|
return isLandscape ? mediaColumnsLandscape : mediaColumnsPortrait
|
||||||
} else {
|
} else {
|
||||||
// iPhone layout
|
|
||||||
return verticalSizeClass == .compact ? mediaColumnsLandscape : mediaColumnsPortrait
|
return verticalSizeClass == .compact ? mediaColumnsLandscape : mediaColumnsPortrait
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ContinueWatchingSection: View {
|
struct ContinueWatchingSection: View {
|
||||||
@Binding
|
@Binding var items: [ContinueWatchingItem]
|
||||||
var items: [ContinueWatchingItem]
|
|
||||||
var markAsWatched: (ContinueWatchingItem) -> Void
|
var markAsWatched: (ContinueWatchingItem) -> Void
|
||||||
var removeItem: (ContinueWatchingItem) -> Void
|
var removeItem: (ContinueWatchingItem) -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
ForEach(Array(items.reversed().prefix(5))) {
|
ForEach(Array(items.reversed().prefix(5))) { item in
|
||||||
item in
|
ContinueWatchingCell(item: item, markAsWatched: {
|
||||||
ContinueWatchingCell(item: item, markAsWatched: {
|
markAsWatched(item)
|
||||||
markAsWatched(item)
|
}, removeItem: {
|
||||||
}, removeItem: {
|
removeItem(item)
|
||||||
removeItem(item)
|
})
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.frame(height: 157.03)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -243,160 +236,158 @@ struct ContinueWatchingCell: View {
|
||||||
let item: ContinueWatchingItem
|
let item: ContinueWatchingItem
|
||||||
var markAsWatched: () -> Void
|
var markAsWatched: () -> Void
|
||||||
var removeItem: () -> Void
|
var removeItem: () -> Void
|
||||||
|
|
||||||
@State private
|
@State private
|
||||||
var currentProgress: Double = 0.0
|
var currentProgress: Double = 0.0
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
if UserDefaults.standard.string(forKey: "externalPlayer") == "Default" {
|
if UserDefaults.standard.string(forKey: "externalPlayer") == "Default" {
|
||||||
let videoPlayerViewController = VideoPlayerViewController(module: item.module)
|
let videoPlayerViewController = VideoPlayerViewController(module: item.module)
|
||||||
videoPlayerViewController.streamUrl = item.streamUrl
|
videoPlayerViewController.streamUrl = item.streamUrl
|
||||||
videoPlayerViewController.fullUrl = item.fullUrl
|
videoPlayerViewController.fullUrl = item.fullUrl
|
||||||
videoPlayerViewController.episodeImageUrl = item.imageUrl
|
videoPlayerViewController.episodeImageUrl = item.imageUrl
|
||||||
videoPlayerViewController.episodeNumber = item.episodeNumber
|
videoPlayerViewController.episodeNumber = item.episodeNumber
|
||||||
videoPlayerViewController.mediaTitle = item.mediaTitle
|
videoPlayerViewController.mediaTitle = item.mediaTitle
|
||||||
videoPlayerViewController.subtitles = item.subtitles ?? ""
|
videoPlayerViewController.subtitles = item.subtitles ?? ""
|
||||||
videoPlayerViewController.aniListID = item.aniListID ?? 0
|
videoPlayerViewController.aniListID = item.aniListID ?? 0
|
||||||
videoPlayerViewController.modalPresentationStyle = .fullScreen
|
videoPlayerViewController.modalPresentationStyle = .fullScreen
|
||||||
|
|
||||||
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||||
let rootVC = windowScene.windows.first?.rootViewController {
|
let rootVC = windowScene.windows.first?.rootViewController {
|
||||||
findTopViewController.findViewController(rootVC).present(videoPlayerViewController, animated: true, completion: nil)
|
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}) {
|
} else {
|
||||||
ZStack(alignment: .bottomLeading) {
|
let customMediaPlayer = CustomMediaPlayerViewController(
|
||||||
KFImage(URL(string: item.imageUrl.isEmpty ? "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/main/assets/banner2.png" : item.imageUrl))
|
module: item.module,
|
||||||
.placeholder {
|
urlString: item.streamUrl,
|
||||||
RoundedRectangle(cornerRadius: 10)
|
fullUrl: item.fullUrl,
|
||||||
.fill(Color.gray.opacity(0.3))
|
title: item.mediaTitle,
|
||||||
.frame(width: 280, height: 157.03)
|
episodeNumber: item.episodeNumber,
|
||||||
.shimmering()
|
onWatchNext: { },
|
||||||
}
|
subtitlesURL: item.subtitles,
|
||||||
.resizable()
|
aniListID: item.aniListID ?? 0,
|
||||||
.aspectRatio(contentMode: .fill)
|
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) {
|
||||||
|
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: 280, height: 157.03)
|
.frame(width: 280, height: 157.03)
|
||||||
.cornerRadius(10)
|
.shimmering()
|
||||||
.clipped()
|
|
||||||
.overlay(
|
|
||||||
ZStack {
|
|
||||||
ProgressiveBlurView()
|
|
||||||
.cornerRadius(10, corners: [.bottomLeft, .bottomRight])
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
Spacer()
|
|
||||||
Text(item.mediaTitle)
|
|
||||||
.font(.headline)
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.lineLimit(1)
|
|
||||||
|
|
||||||
HStack {
|
|
||||||
Text("Episode \(item.episodeNumber)")
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundColor(.white.opacity(0.9))
|
|
||||||
|
|
||||||
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(
|
|
||||||
KFImage(URL(string: item.module.metadata.iconUrl))
|
|
||||||
.resizable()
|
|
||||||
.scaledToFill()
|
|
||||||
.frame(width: 32, height: 32)
|
|
||||||
.clipShape(Circle())
|
|
||||||
)
|
|
||||||
.padding(8)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
alignment: .topLeading
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(16/9, contentMode: .fill)
|
||||||
.frame(width: 280, height: 157.03)
|
.frame(width: 280, height: 157.03)
|
||||||
|
.cornerRadius(10)
|
||||||
|
.clipped()
|
||||||
|
.overlay(
|
||||||
|
ZStack {
|
||||||
|
ProgressiveBlurView()
|
||||||
|
.cornerRadius(10, corners: [.bottomLeft, .bottomRight])
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Spacer()
|
||||||
|
Text(item.mediaTitle)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text("Episode \(item.episodeNumber)")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.white.opacity(0.9))
|
||||||
|
|
||||||
|
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(
|
||||||
|
KFImage(URL(string: item.module.metadata.iconUrl))
|
||||||
|
.resizable()
|
||||||
|
.scaledToFill()
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
.clipShape(Circle())
|
||||||
|
)
|
||||||
|
.padding(8)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
alignment: .topLeading
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.contextMenu {
|
.frame(width: 280, height: 157.03)
|
||||||
Button(action: {
|
}
|
||||||
markAsWatched()
|
.contextMenu {
|
||||||
}) {
|
Button(action: {
|
||||||
Label("Mark as Watched", systemImage: "checkmark.circle")
|
markAsWatched()
|
||||||
}
|
}) {
|
||||||
Button(role: .destructive, action: {
|
Label("Mark as Watched", systemImage: "checkmark.circle")
|
||||||
removeItem()
|
|
||||||
}) {
|
|
||||||
Label("Remove Item", systemImage: "trash")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.onAppear {
|
Button(role: .destructive, action: {
|
||||||
|
removeItem()
|
||||||
|
}) {
|
||||||
|
Label("Remove Item", systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
updateProgress()
|
||||||
|
}
|
||||||
|
.onReceive(NotificationCenter.default.publisher(
|
||||||
|
for: UIApplication.didBecomeActiveNotification)) {
|
||||||
|
_ in
|
||||||
updateProgress()
|
updateProgress()
|
||||||
}
|
}
|
||||||
.onReceive(NotificationCenter.default.publisher(
|
|
||||||
for: UIApplication.didBecomeActiveNotification)) {
|
|
||||||
_ in
|
|
||||||
updateProgress()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateProgress() {
|
private func updateProgress() {
|
||||||
// grab the true playback times
|
|
||||||
let lastPlayed = UserDefaults.standard.double(forKey: "lastPlayedTime_\(item.fullUrl)")
|
let lastPlayed = UserDefaults.standard.double(forKey: "lastPlayedTime_\(item.fullUrl)")
|
||||||
let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(item.fullUrl)")
|
let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(item.fullUrl)")
|
||||||
|
|
||||||
// compute a clean 0…1 ratio
|
|
||||||
let ratio: Double
|
let ratio: Double
|
||||||
if totalTime > 0 {
|
if totalTime > 0 {
|
||||||
ratio = min(max(lastPlayed / totalTime, 0), 1)
|
ratio = min(max(lastPlayed / totalTime, 0), 1)
|
||||||
|
|
@ -406,10 +397,8 @@ struct ContinueWatchingCell: View {
|
||||||
currentProgress = ratio
|
currentProgress = ratio
|
||||||
|
|
||||||
if ratio >= 0.9 {
|
if ratio >= 0.9 {
|
||||||
// >90% watched? drop it immediately
|
|
||||||
removeItem()
|
removeItem()
|
||||||
} else {
|
} else {
|
||||||
// otherwise persist the latest progress
|
|
||||||
var updated = item
|
var updated = item
|
||||||
updated.progress = ratio
|
updated.progress = ratio
|
||||||
ContinueWatchingManager.shared.save(item: updated)
|
ContinueWatchingManager.shared.save(item: updated)
|
||||||
|
|
@ -421,7 +410,7 @@ struct ContinueWatchingCell: View {
|
||||||
struct RoundedCorner: Shape {
|
struct RoundedCorner: Shape {
|
||||||
var radius: CGFloat = .infinity
|
var radius: CGFloat = .infinity
|
||||||
var corners: UIRectCorner = .allCorners
|
var corners: UIRectCorner = .allCorners
|
||||||
|
|
||||||
func path( in rect: CGRect) -> Path {
|
func path( in rect: CGRect) -> Path {
|
||||||
let path = UIBezierPath(
|
let path = UIBezierPath(
|
||||||
roundedRect: rect,
|
roundedRect: rect,
|
||||||
|
|
@ -436,21 +425,21 @@ extension View {
|
||||||
func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View {
|
func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View {
|
||||||
clipShape(RoundedCorner(radius: radius, corners: corners))
|
clipShape(RoundedCorner(radius: radius, corners: corners))
|
||||||
}
|
}
|
||||||
|
|
||||||
func gradientOutline() -> some View {
|
func gradientOutline() -> some View {
|
||||||
self.background(
|
self.background(
|
||||||
RoundedRectangle(cornerRadius: 15)
|
RoundedRectangle(cornerRadius: 15)
|
||||||
.stroke(
|
.stroke(
|
||||||
LinearGradient(
|
LinearGradient(
|
||||||
gradient: Gradient(stops: [
|
gradient: Gradient(stops: [
|
||||||
.init(color: Color.accentColor.opacity(0.25), location: 0),
|
.init(color: Color.accentColor.opacity(0.25), location: 0),
|
||||||
.init(color: Color.accentColor.opacity(0), location: 1)
|
.init(color: Color.accentColor.opacity(0), location: 1)
|
||||||
]),
|
]),
|
||||||
startPoint: .top,
|
startPoint: .top,
|
||||||
endPoint: .bottom
|
endPoint: .bottom
|
||||||
),
|
),
|
||||||
lineWidth: 0.5
|
lineWidth: 0.5
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -458,10 +447,10 @@ extension View {
|
||||||
struct BookmarksSection: View {
|
struct BookmarksSection: View {
|
||||||
@EnvironmentObject private var libraryManager: LibraryManager
|
@EnvironmentObject private var libraryManager: LibraryManager
|
||||||
@EnvironmentObject private var moduleManager: ModuleManager
|
@EnvironmentObject private var moduleManager: ModuleManager
|
||||||
|
|
||||||
@Binding var selectedBookmark: LibraryItem?
|
@Binding var selectedBookmark: LibraryItem?
|
||||||
@Binding var isDetailActive: Bool
|
@Binding var isDetailActive: Bool
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
if libraryManager.bookmarks.isEmpty {
|
if libraryManager.bookmarks.isEmpty {
|
||||||
|
|
@ -479,45 +468,44 @@ struct BookmarksSection: View {
|
||||||
struct EmptyBookmarksView: View {
|
struct EmptyBookmarksView: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: 8) {
|
||||||
Image(systemName: "magazine")
|
Image(systemName: "magazine")
|
||||||
.font(.largeTitle)
|
.font(.largeTitle)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
Text("You have no items saved.")
|
Text("You have no items saved.")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
Text("Bookmark items for an easier access later.")
|
Text("Bookmark items for an easier access later.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct BookmarksGridView: View {
|
struct BookmarksGridView: View {
|
||||||
@EnvironmentObject private var libraryManager: LibraryManager
|
@EnvironmentObject private var libraryManager: LibraryManager
|
||||||
@EnvironmentObject private var moduleManager: ModuleManager
|
@EnvironmentObject private var moduleManager: ModuleManager
|
||||||
|
|
||||||
@Binding var selectedBookmark: LibraryItem?
|
@Binding var selectedBookmark: LibraryItem?
|
||||||
@Binding var isDetailActive: Bool
|
@Binding var isDetailActive: Bool
|
||||||
|
|
||||||
private
|
private var recentBookmarks: [LibraryItem] {
|
||||||
var recentBookmarks: [LibraryItem] {
|
|
||||||
Array(libraryManager.bookmarks.prefix(5))
|
Array(libraryManager.bookmarks.prefix(5))
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
ForEach(recentBookmarks) {
|
ForEach(recentBookmarks) { item in
|
||||||
item in
|
BookmarkItemView(
|
||||||
BookmarkItemView(
|
item: item,
|
||||||
item: item,
|
selectedBookmark: $selectedBookmark,
|
||||||
selectedBookmark: $selectedBookmark,
|
isDetailActive: $isDetailActive
|
||||||
isDetailActive: $isDetailActive
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.frame(height: 243)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -525,79 +513,77 @@ struct BookmarksGridView: View {
|
||||||
struct BookmarkItemView: View {
|
struct BookmarkItemView: View {
|
||||||
@EnvironmentObject private var libraryManager: LibraryManager
|
@EnvironmentObject private var libraryManager: LibraryManager
|
||||||
@EnvironmentObject private var moduleManager: ModuleManager
|
@EnvironmentObject private var moduleManager: ModuleManager
|
||||||
|
|
||||||
let item: LibraryItem
|
let item: LibraryItem
|
||||||
@Binding var selectedBookmark: LibraryItem?
|
@Binding var selectedBookmark: LibraryItem?
|
||||||
@Binding var isDetailActive: Bool
|
@Binding var isDetailActive: Bool
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if let module = moduleManager.modules.first(where: {
|
if let module = moduleManager.modules.first(where: { $0.id.uuidString == item.moduleId }) {
|
||||||
$0.id.uuidString == item.moduleId
|
|
||||||
}) {
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
selectedBookmark = item
|
selectedBookmark = item
|
||||||
isDetailActive = true
|
isDetailActive = true
|
||||||
}) {
|
}) {
|
||||||
ZStack {
|
ZStack {
|
||||||
KFImage(URL(string: item.imageUrl))
|
KFImage(URL(string: item.imageUrl))
|
||||||
.placeholder {
|
.placeholder {
|
||||||
RoundedRectangle(cornerRadius: 12)
|
RoundedRectangle(cornerRadius: 12)
|
||||||
.fill(Color.gray.opacity(0.3))
|
.fill(Color.gray.opacity(0.3))
|
||||||
.aspectRatio(2 / 3, contentMode: .fit)
|
.aspectRatio(2 / 3, contentMode: .fit)
|
||||||
.shimmering()
|
.shimmering()
|
||||||
}
|
}
|
||||||
.resizable()
|
.resizable()
|
||||||
.aspectRatio(0.72, contentMode: .fill)
|
.aspectRatio(0.72, contentMode: .fill)
|
||||||
.frame(width: 162, height: 243)
|
.frame(width: 162, height: 243)
|
||||||
.cornerRadius(12)
|
.cornerRadius(12)
|
||||||
.clipped()
|
.clipped()
|
||||||
.overlay(
|
.overlay(
|
||||||
ZStack {
|
ZStack {
|
||||||
Circle()
|
Circle()
|
||||||
.fill(Color.black.opacity(0.5))
|
.fill(Color.black.opacity(0.5))
|
||||||
.frame(width: 28, height: 28)
|
.frame(width: 28, height: 28)
|
||||||
.overlay(
|
.overlay(
|
||||||
KFImage(URL(string: module.metadata.iconUrl))
|
KFImage(URL(string: module.metadata.iconUrl))
|
||||||
.resizable()
|
.resizable()
|
||||||
.scaledToFill()
|
.scaledToFill()
|
||||||
.frame(width: 32, height: 32)
|
.frame(width: 32, height: 32)
|
||||||
.clipShape(Circle())
|
.clipShape(Circle())
|
||||||
)
|
|
||||||
}
|
|
||||||
.padding(8),
|
|
||||||
alignment: .topLeading
|
|
||||||
)
|
|
||||||
|
|
||||||
VStack {
|
|
||||||
Spacer()
|
|
||||||
Text(item.title)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.lineLimit(2)
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.padding(12)
|
|
||||||
.background(
|
|
||||||
LinearGradient(
|
|
||||||
colors: [
|
|
||||||
.black.opacity(0.7),
|
|
||||||
.black.opacity(0.0)
|
|
||||||
],
|
|
||||||
startPoint: .bottom,
|
|
||||||
endPoint: .top
|
|
||||||
)
|
)
|
||||||
.shadow(color: .black, radius: 4, x: 0, y: 2)
|
}
|
||||||
|
.padding(8),
|
||||||
|
alignment: .topLeading
|
||||||
|
)
|
||||||
|
|
||||||
|
VStack {
|
||||||
|
Spacer()
|
||||||
|
Text(item.title)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.lineLimit(2)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding(12)
|
||||||
|
.background(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [
|
||||||
|
.black.opacity(0.7),
|
||||||
|
.black.opacity(0.0)
|
||||||
|
],
|
||||||
|
startPoint: .bottom,
|
||||||
|
endPoint: .top
|
||||||
)
|
)
|
||||||
}
|
.shadow(color: .black, radius: 4, x: 0, y: 2)
|
||||||
.frame(width: 162)
|
)
|
||||||
}
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
||||||
}
|
|
||||||
.contextMenu {
|
|
||||||
Button(role: .destructive, action: {
|
|
||||||
libraryManager.removeBookmark(item: item)
|
|
||||||
}) {
|
|
||||||
Label("Remove from Bookmarks", systemImage: "trash")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.frame(width: 162, height: 243)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
.contextMenu {
|
||||||
|
Button(role: .destructive, action: {
|
||||||
|
libraryManager.removeBookmark(item: item)
|
||||||
|
}) {
|
||||||
|
Label("Remove from Bookmarks", systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue