idk i changed layout constants
Some checks are pending
Build and Release / Build IPA (push) Waiting to run
Build and Release / Build Mac Catalyst (push) Waiting to run

This commit is contained in:
Francesco 2025-06-01 16:41:11 +02:00
parent afe49abcfa
commit fddf940b95

View file

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