Bookmark collection system + migration system (Video in discord) (#207)
Some checks failed
Build and Release / Build IPA (push) Has been cancelled
Build and Release / Build Mac Catalyst (push) Has been cancelled

This commit is contained in:
50/50 2025-06-19 08:32:10 +02:00 committed by GitHub
parent 14ebc82fc6
commit 82eec0688f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 839 additions and 440 deletions

View file

@ -246,7 +246,6 @@ struct AllWatchingView: View {
.onAppear {
loadContinueWatchingItems()
// Enable swipe back gesture
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first,
let navigationController = window.rootViewController?.children.first as? UINavigationController {

View file

@ -0,0 +1,96 @@
//
// BookmarkCell.swift
// Sora
//
// Created by paul on 18/06/25.
//
import SwiftUI
import NukeUI
struct BookmarkCell: View {
let bookmark: LibraryItem
@EnvironmentObject private var moduleManager: ModuleManager
@EnvironmentObject private var libraryManager: LibraryManager
var body: some View {
if let module = moduleManager.modules.first(where: { $0.id.uuidString == bookmark.moduleId }) {
ZStack {
LazyImage(url: URL(string: bookmark.imageUrl)) { state in
if let uiImage = state.imageContainer?.image {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(0.72, contentMode: .fill)
.frame(width: 162, height: 243)
.cornerRadius(12)
.clipped()
} else {
RoundedRectangle(cornerRadius: 12)
.fill(Color.gray.opacity(0.3))
.frame(width: 162, height: 243)
}
}
.overlay(
ZStack {
Circle()
.fill(Color.black.opacity(0.5))
.frame(width: 28, height: 28)
.overlay(
LazyImage(url: URL(string: 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
)
VStack {
Spacer()
Text(bookmark.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))
.padding(4)
.contextMenu {
Button(role: .destructive, action: {
// Find which collection contains this bookmark
for collection in libraryManager.collections {
if collection.bookmarks.contains(where: { $0.id == bookmark.id }) {
libraryManager.removeBookmarkFromCollection(bookmarkId: bookmark.id, collectionId: collection.id)
break
}
}
}) {
Label("Remove from Bookmarks", systemImage: "trash")
}
}
}
}
}

View file

@ -0,0 +1,86 @@
//
// BookmarkCollectionGridCell.swift
// Sora
//
// Created by paul on 18/06/25.
//
import SwiftUI
import NukeUI
struct BookmarkCollectionGridCell: View {
let collection: BookmarkCollection
let width: CGFloat
let height: CGFloat
private var recentBookmarks: [LibraryItem] {
Array(collection.bookmarks.prefix(4))
}
var body: some View {
let gap: CGFloat = 2
let cellWidth = (width - gap) / 2
let cellHeight = (height - gap) / 2
VStack(alignment: .leading, spacing: 8) {
ZStack {
if recentBookmarks.isEmpty {
RoundedRectangle(cornerRadius: 12)
.fill(Color.gray.opacity(0.3))
.frame(width: width, height: height)
.overlay(
Image(systemName: "folder.fill")
.resizable()
.scaledToFit()
.frame(width: width/3)
.foregroundColor(.gray.opacity(0.5))
)
} else {
LazyVGrid(
columns: [
GridItem(.flexible(), spacing: gap),
GridItem(.flexible(), spacing: gap)
],
spacing: gap
) {
ForEach(0..<4) { index in
if index < recentBookmarks.count {
LazyImage(url: URL(string: recentBookmarks[index].imageUrl)) { state in
if let image = state.imageContainer?.image {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: cellWidth, height: cellHeight)
.clipped()
} else {
Rectangle()
.fill(Color.gray.opacity(0.3))
.frame(width: cellWidth, height: cellHeight)
}
}
} else {
Rectangle()
.fill(Color.gray.opacity(0.3))
.frame(width: cellWidth, height: cellHeight)
}
}
}
.frame(width: width, height: height)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
VStack(alignment: .leading, spacing: 4) {
Text(collection.name)
.font(.headline)
.lineLimit(1)
.truncationMode(.tail)
.layoutPriority(1)
.frame(maxWidth: .infinity, alignment: .leading)
Text("\(collection.bookmarks.count) items")
.font(.caption)
.foregroundColor(.secondary)
}
.padding(.horizontal, 4)
}
}
}

View file

@ -1,53 +1,81 @@
//
// MediaInfoView.swift
// BookmarkGridItemView.swift
// Sora
//
// Created by paul on 28/05/25.
//
import SwiftUI
import NukeUI
struct BookmarkGridItemView: View {
let bookmark: LibraryItem
let moduleManager: ModuleManager
let isSelecting: Bool
@Binding var selectedBookmarks: Set<LibraryItem.ID>
var isSelected: Bool {
selectedBookmarks.contains(bookmark.id)
}
let item: LibraryItem
let module: Module
var body: some View {
Group {
if let module = moduleManager.modules.first(where: { $0.id.uuidString == bookmark.moduleId }) {
if isSelecting {
Button(action: {
if isSelected {
selectedBookmarks.remove(bookmark.id)
} else {
selectedBookmarks.insert(bookmark.id)
}
}) {
ZStack(alignment: .topTrailing) {
BookmarkCell(bookmark: bookmark)
if isSelected {
Image(systemName: "checkmark.circle.fill")
.resizable()
.frame(width: 32, height: 32)
.foregroundColor(.accentColor)
.background(Color.white.clipShape(Circle()).opacity(0.8))
.offset(x: -8, y: 8)
}
}
}
ZStack {
LazyImage(url: URL(string: item.imageUrl)) { state in
if let uiImage = state.imageContainer?.image {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(0.72, contentMode: .fill)
.frame(width: 162, height: 243)
.cornerRadius(12)
.clipped()
} else {
BookmarkLink(
bookmark: bookmark,
module: module
)
RoundedRectangle(cornerRadius: 12)
.fill(Color.gray.opacity(0.3))
.aspectRatio(2/3, contentMode: .fit)
.redacted(reason: .placeholder)
}
}
.overlay(
ZStack {
Circle()
.fill(Color.black.opacity(0.5))
.frame(width: 28, height: 28)
.overlay(
LazyImage(url: URL(string: 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
)
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, height: 243)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}

View file

@ -8,29 +8,62 @@
import SwiftUI
struct BookmarkGridView: View {
@EnvironmentObject private var libraryManager: LibraryManager
@EnvironmentObject private var moduleManager: ModuleManager
let bookmarks: [LibraryItem]
let moduleManager: ModuleManager
let isSelecting: Bool
@Binding var selectedBookmarks: Set<LibraryItem.ID>
private let columns = [
GridItem(.adaptive(minimum: 150))
GridItem(.adaptive(minimum: 150), spacing: 16)
]
var body: some View {
ScrollView(showsIndicators: false) {
LazyVGrid(columns: columns, spacing: 16) {
ForEach(bookmarks) { bookmark in
BookmarkGridItemView(
bookmark: bookmark,
moduleManager: moduleManager,
isSelecting: isSelecting,
selectedBookmarks: $selectedBookmarks
)
LazyVGrid(columns: columns, spacing: 16) {
ForEach(bookmarks) { bookmark in
if let module = moduleManager.modules.first(where: { $0.id.uuidString == bookmark.moduleId }) {
if isSelecting {
Button(action: {
if selectedBookmarks.contains(bookmark.id) {
selectedBookmarks.remove(bookmark.id)
} else {
selectedBookmarks.insert(bookmark.id)
}
}) {
NavigationLink(destination: MediaInfoView(
title: bookmark.title,
imageUrl: bookmark.imageUrl,
href: bookmark.href,
module: module
)) {
BookmarkGridItemView(item: bookmark, module: module)
.overlay(
selectedBookmarks.contains(bookmark.id) ?
Image(systemName: "checkmark.circle.fill")
.resizable()
.frame(width: 32, height: 32)
.foregroundColor(.accentColor)
.background(Color.white.clipShape(Circle()))
.padding(8)
: nil,
alignment: .topTrailing
)
}
}
} else {
NavigationLink(destination: MediaInfoView(
title: bookmark.title,
imageUrl: bookmark.imageUrl,
href: bookmark.href,
module: module
)) {
BookmarkGridItemView(item: bookmark, module: module)
}
}
}
}
.padding()
.scrollViewBottomPadding()
}
.padding()
}
}

View file

@ -1,5 +1,5 @@
//
// MediaInfoView.swift
// BookmarkLink.swift
// Sora
//
// Created by paul on 28/05/25.

View file

@ -1,5 +1,5 @@
//
// MediaInfoView.swift
// BookmarksDetailView.swift
// Sora
//
// Created by paul on 28/05/25.
@ -13,35 +13,34 @@ struct BookmarksDetailView: View {
@EnvironmentObject private var libraryManager: LibraryManager
@EnvironmentObject private var moduleManager: ModuleManager
@Binding var bookmarks: [LibraryItem]
@State private var sortOption: SortOption = .dateAdded
@State private var sortOption: SortOption = .dateCreated
@State private var searchText: String = ""
@State private var isSearchActive: Bool = false
@State private var isSelecting: Bool = false
@State private var selectedBookmarks: Set<LibraryItem.ID> = []
@State private var selectedCollections: Set<UUID> = []
@State private var isShowingCreateCollection: Bool = false
@State private var newCollectionName: String = ""
@State private var isShowingRenamePrompt: Bool = false
@State private var collectionToRename: BookmarkCollection? = nil
@State private var renameCollectionName: String = ""
enum SortOption: String, CaseIterable {
case dateAdded = "Date Added"
case title = "Title"
case source = "Source"
case dateCreated = "Date Created"
case name = "Name"
case itemCount = "Item Count"
}
var filteredAndSortedBookmarks: [LibraryItem] {
let filtered = searchText.isEmpty ? bookmarks : bookmarks.filter { item in
item.title.localizedCaseInsensitiveContains(searchText) ||
item.moduleName.localizedCaseInsensitiveContains(searchText)
var filteredAndSortedCollections: [BookmarkCollection] {
let filtered = searchText.isEmpty ? libraryManager.collections : libraryManager.collections.filter { collection in
collection.name.localizedCaseInsensitiveContains(searchText)
}
switch sortOption {
case .dateAdded:
return filtered
case .title:
return filtered.sorted { $0.title.lowercased() < $1.title.lowercased() }
case .source:
return filtered.sorted { item1, item2 in
let module1 = moduleManager.modules.first { $0.id.uuidString == item1.moduleId }
let module2 = moduleManager.modules.first { $0.id.uuidString == item2.moduleId }
return (module1?.metadata.sourceName ?? "") < (module2?.metadata.sourceName ?? "")
}
case .dateCreated:
return filtered.sorted { $0.dateCreated > $1.dateCreated }
case .name:
return filtered.sorted { $0.name.lowercased() < $1.name.lowercased() }
case .itemCount:
return filtered.sorted { $0.bookmarks.count > $1.bookmarks.count }
}
}
@ -54,12 +53,15 @@ struct BookmarksDetailView: View {
.foregroundColor(.primary)
}
Button(action: { dismiss() }) {
Text("All Bookmarks")
Text("Collections")
.font(.title3)
.fontWeight(.bold)
.foregroundColor(.primary)
.lineLimit(1)
.truncationMode(.tail)
.layoutPriority(1)
.frame(maxWidth: .infinity, alignment: .leading)
}
Spacer()
HStack(spacing: 16) {
Button(action: {
withAnimation(.easeInOut(duration: 0.3)) {
@ -112,14 +114,11 @@ struct BookmarksDetailView: View {
}
Button(action: {
if isSelecting {
// If trash icon tapped
if !selectedBookmarks.isEmpty {
for id in selectedBookmarks {
if let item = bookmarks.first(where: { $0.id == id }) {
libraryManager.removeBookmark(item: item)
}
if !selectedCollections.isEmpty {
for id in selectedCollections {
libraryManager.deleteCollection(id: id)
}
selectedBookmarks.removeAll()
selectedCollections.removeAll()
}
isSelecting = false
} else {
@ -139,10 +138,28 @@ struct BookmarksDetailView: View {
)
.circularGradientOutline()
}
Button(action: {
isShowingCreateCollection = true
}) {
Image(systemName: "folder.badge.plus")
.resizable()
.scaledToFit()
.frame(width: 18, height: 18)
.foregroundColor(.accentColor)
.padding(10)
.background(
Circle()
.fill(Color.gray.opacity(0.2))
.shadow(color: .accentColor.opacity(0.2), radius: 2)
)
.circularGradientOutline()
}
}
.layoutPriority(0)
}
.padding(.horizontal)
.padding(.top)
if isSearchActive {
HStack(spacing: 12) {
HStack(spacing: 12) {
@ -151,7 +168,7 @@ struct BookmarksDetailView: View {
.scaledToFit()
.frame(width: 18, height: 18)
.foregroundColor(.secondary)
TextField("Search bookmarks...", text: $searchText)
TextField("Search collections...", text: $searchText)
.textFieldStyle(PlainTextFieldStyle())
.foregroundColor(.primary)
if !searchText.isEmpty {
@ -192,15 +209,115 @@ struct BookmarksDetailView: View {
removal: .move(edge: .top).combined(with: .opacity)
))
}
BookmarksDetailGrid(
bookmarks: filteredAndSortedBookmarks,
moduleManager: moduleManager,
isSelecting: isSelecting,
selectedBookmarks: $selectedBookmarks
)
if filteredAndSortedCollections.isEmpty {
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, maxHeight: .infinity)
} else {
ScrollView(showsIndicators: false) {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 162), spacing: 16)], spacing: 16) {
ForEach(filteredAndSortedCollections) { collection in
if isSelecting {
Button(action: {
if selectedCollections.contains(collection.id) {
selectedCollections.remove(collection.id)
} else {
selectedCollections.insert(collection.id)
}
}) {
BookmarkCollectionGridCell(collection: collection, width: 162, height: 162)
.overlay(
selectedCollections.contains(collection.id) ?
ZStack {
Circle()
.fill(Color.white)
.frame(width: 32, height: 32)
Image(systemName: "checkmark")
.resizable()
.scaledToFit()
.frame(width: 18, height: 18)
.foregroundColor(.black)
}
.padding(8)
: nil,
alignment: .topTrailing
)
}
.contextMenu {
Button("Rename") {
collectionToRename = collection
renameCollectionName = collection.name
isShowingRenamePrompt = true
}
Button(role: .destructive) {
libraryManager.deleteCollection(id: collection.id)
} label: {
Label("Delete", systemImage: "trash")
}
}
} else {
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)
.padding(.top)
.scrollViewBottomPadding()
}
}
}
.navigationBarBackButtonHidden(true)
.navigationBarTitleDisplayMode(.inline)
.alert("Create Collection", isPresented: $isShowingCreateCollection) {
TextField("Collection Name", text: $newCollectionName)
Button("Cancel", role: .cancel) {
newCollectionName = ""
}
Button("Create") {
if !newCollectionName.isEmpty {
libraryManager.createCollection(name: newCollectionName)
newCollectionName = ""
}
}
}
.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() }
.onAppear {
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first,
@ -285,12 +402,17 @@ private struct BookmarksDetailGridCell: View {
ZStack(alignment: .topTrailing) {
BookmarkCell(bookmark: bookmark)
if isSelected {
Image(systemName: "checkmark.circle.fill")
.resizable()
.frame(width: 32, height: 32)
.foregroundColor(.black)
.background(Color.white.clipShape(Circle()).opacity(0.8))
.offset(x: -8, y: 8)
ZStack {
Circle()
.fill(Color.white)
.frame(width: 32, height: 32)
Image(systemName: "checkmark")
.resizable()
.scaledToFit()
.frame(width: 18, height: 18)
.foregroundColor(.accentColor)
}
.offset(x: -8, y: 8)
}
}
}

View file

@ -1,78 +1,68 @@
//
// AllBookmarks.swift
// Sulfur
// CollectionDetailView.swift
// Sora
//
// Created by paul on 29/04/2025.
// Created by paul on 18/06/25.
//
import UIKit
import NukeUI
import SwiftUI
import NukeUI
extension View {
func circularGradientOutlineTwo() -> some View {
self.background(
Circle()
.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 AllBookmarks: View {
@EnvironmentObject var libraryManager: LibraryManager
@EnvironmentObject var moduleManager: ModuleManager
struct CollectionDetailView: View {
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var libraryManager: LibraryManager
@EnvironmentObject private var moduleManager: ModuleManager
let collection: BookmarkCollection
@State private var sortOption: SortOption = .dateAdded
@State private var searchText: String = ""
@State private var isSearchActive: Bool = false
@State private var sortOption: SortOption = .title
@State private var isSelecting: Bool = false
@State private var selectedBookmarks: Set<LibraryItem.ID> = []
enum SortOption: String, CaseIterable {
case title = "Title"
case dateAdded = "Date Added"
case title = "Title"
case source = "Source"
}
var filteredAndSortedBookmarks: [LibraryItem] {
let filtered = searchText.isEmpty ? libraryManager.bookmarks : libraryManager.bookmarks.filter { item in
private var filteredAndSortedBookmarks: [LibraryItem] {
let filtered = searchText.isEmpty ? collection.bookmarks : collection.bookmarks.filter { item in
item.title.localizedCaseInsensitiveContains(searchText) ||
item.moduleName.localizedCaseInsensitiveContains(searchText)
}
switch sortOption {
case .title:
return filtered.sorted { $0.title.lowercased() < $1.title.lowercased() }
case .dateAdded:
return filtered
case .title:
return filtered.sorted { $0.title.lowercased() < $1.title.lowercased() }
case .source:
return filtered.sorted { $0.moduleName < $1.moduleName }
return filtered.sorted { item1, item2 in
let module1 = moduleManager.modules.first { $0.id.uuidString == item1.moduleId }
let module2 = moduleManager.modules.first { $0.id.uuidString == item2.moduleId }
return (module1?.metadata.sourceName ?? "") < (module2?.metadata.sourceName ?? "")
}
}
}
var body: some View {
VStack(alignment: .leading) {
HStack {
Button(action: { }) {
HStack(spacing: 8) {
Button(action: { dismiss() }) {
Image(systemName: "chevron.left")
.font(.system(size: 24))
.foregroundColor(.primary)
}
Button(action: { }) {
Text("All Bookmarks")
Button(action: { dismiss() }) {
Text(collection.name)
.font(.title3)
.fontWeight(.bold)
.foregroundColor(.primary)
.lineLimit(1)
.truncationMode(.tail)
.layoutPriority(1)
.frame(maxWidth: .infinity, alignment: .leading)
}
Spacer()
HStack(spacing: 16) {
Button(action: {
withAnimation(.easeInOut(duration: 0.3)) {
@ -93,7 +83,7 @@ struct AllBookmarks: View {
.fill(Color.gray.opacity(0.2))
.shadow(color: .accentColor.opacity(0.2), radius: 2)
)
.circularGradientOutlineTwo()
.circularGradientOutline()
}
Menu {
ForEach(SortOption.allCases, id: \.self) { option in
@ -121,14 +111,14 @@ struct AllBookmarks: View {
.fill(Color.gray.opacity(0.2))
.shadow(color: .accentColor.opacity(0.2), radius: 2)
)
.circularGradientOutlineTwo()
.circularGradientOutline()
}
Button(action: {
if isSelecting {
if !selectedBookmarks.isEmpty {
for id in selectedBookmarks {
if let item = libraryManager.bookmarks.first(where: { $0.id == id }) {
libraryManager.removeBookmark(item: item)
if let item = collection.bookmarks.first(where: { $0.id == id }) {
libraryManager.removeBookmarkFromCollection(bookmarkId: id, collectionId: collection.id)
}
}
selectedBookmarks.removeAll()
@ -149,12 +139,14 @@ struct AllBookmarks: View {
.fill(Color.gray.opacity(0.2))
.shadow(color: .accentColor.opacity(0.2), radius: 2)
)
.circularGradientOutlineTwo()
.circularGradientOutline()
}
}
.layoutPriority(0)
}
.padding(.horizontal)
.padding(.top)
if isSearchActive {
HStack(spacing: 12) {
HStack(spacing: 12) {
@ -204,122 +196,92 @@ struct AllBookmarks: View {
removal: .move(edge: .top).combined(with: .opacity)
))
}
BookmarkGridView(
bookmarks: filteredAndSortedBookmarks,
moduleManager: moduleManager,
isSelecting: isSelecting,
selectedBookmarks: $selectedBookmarks
)
.withGridPadding()
Spacer()
if filteredAndSortedBookmarks.isEmpty {
VStack(spacing: 8) {
Image(systemName: "bookmark")
.font(.largeTitle)
.foregroundColor(.secondary)
Text("No Bookmarks")
.font(.headline)
Text("Add bookmarks to this collection")
.font(.caption)
.foregroundColor(.secondary)
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ScrollView(showsIndicators: false) {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 150))], spacing: 16) {
ForEach(filteredAndSortedBookmarks) { bookmark in
if let module = moduleManager.modules.first(where: { $0.id.uuidString == bookmark.moduleId }) {
if isSelecting {
Button(action: {
if selectedBookmarks.contains(bookmark.id) {
selectedBookmarks.remove(bookmark.id)
} else {
selectedBookmarks.insert(bookmark.id)
}
}) {
BookmarkGridItemView(item: bookmark, module: module)
.overlay(
selectedBookmarks.contains(bookmark.id) ?
ZStack {
Circle()
.fill(Color.white)
.frame(width: 32, height: 32)
Image(systemName: "checkmark")
.resizable()
.scaledToFit()
.frame(width: 18, height: 18)
.foregroundColor(.black)
}
.padding(8)
: nil,
alignment: .topTrailing
)
}
.contextMenu {
Button(role: .destructive) {
libraryManager.removeBookmarkFromCollection(bookmarkId: bookmark.id, collectionId: collection.id)
} label: {
Label("Delete", systemImage: "trash")
}
}
} else {
NavigationLink(destination: MediaInfoView(
title: bookmark.title,
imageUrl: bookmark.imageUrl,
href: bookmark.href,
module: module
)) {
BookmarkGridItemView(item: bookmark, module: module)
}
.contextMenu {
Button(role: .destructive) {
libraryManager.removeBookmarkFromCollection(bookmarkId: bookmark.id, collectionId: collection.id)
} label: {
Label("Delete", systemImage: "trash")
}
}
}
}
}
}
.padding()
.scrollViewBottomPadding()
}
}
}
.navigationBarBackButtonHidden(true)
.navigationBarTitleDisplayMode(.inline)
.onAppear(perform: setupNavigationController)
}
private func setupNavigationController() {
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first,
let navigationController = window.rootViewController?.children.first as? UINavigationController {
navigationController.interactivePopGestureRecognizer?.isEnabled = true
navigationController.interactivePopGestureRecognizer?.delegate = nil
}
}
}
struct BookmarkCell: View {
let bookmark: LibraryItem
@EnvironmentObject private var moduleManager: ModuleManager
@EnvironmentObject private var libraryManager: LibraryManager
var body: some View {
if let module = moduleManager.modules.first(where: { $0.id.uuidString == bookmark.moduleId }) {
ZStack {
LazyImage(url: URL(string: bookmark.imageUrl)) { state in
if let uiImage = state.imageContainer?.image {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(0.72, contentMode: .fill)
.frame(width: 162, height: 243)
.cornerRadius(12)
.clipped()
} else {
RoundedRectangle(cornerRadius: 12)
.fill(Color.gray.opacity(0.3))
.frame(width: 162, height: 243)
}
}
.overlay(
ZStack {
Circle()
.fill(Color.black.opacity(0.5))
.frame(width: 28, height: 28)
.overlay(
LazyImage(url: URL(string: 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
)
VStack {
Spacer()
Text(bookmark.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))
.padding(4)
.contextMenu {
Button(role: .destructive, action: {
libraryManager.removeBookmark(item: bookmark)
}) {
Label("Remove from Bookmarks", systemImage: "trash")
}
.onAppear {
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first,
let navigationController = window.rootViewController?.children.first as? UINavigationController {
navigationController.interactivePopGestureRecognizer?.isEnabled = true
navigationController.interactivePopGestureRecognizer?.delegate = nil
}
}
}
}
private extension View {
func withNavigationBarModifiers() -> some View {
self
.navigationBarBackButtonHidden(true)
.navigationBarTitleDisplayMode(.inline)
}
func withGridPadding() -> some View {
self
.padding(.top)
.padding()
.scrollViewBottomPadding()
}
}
}

View file

@ -0,0 +1,73 @@
//
// LibraryManager.swift
// Sora
//
// Created by paul on 18/06/25.
//
import SwiftUI
struct CollectionPickerView: View {
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var libraryManager: LibraryManager
let bookmark: LibraryItem
@State private var newCollectionName: String = ""
@State private var isShowingNewCollectionField: Bool = false
var body: some View {
NavigationView {
List {
if isShowingNewCollectionField {
Section {
HStack {
TextField("Collection name", text: $newCollectionName)
Button("Create") {
if !newCollectionName.isEmpty {
libraryManager.createCollection(name: newCollectionName)
if let newCollection = libraryManager.collections.first(where: { $0.name == newCollectionName }) {
libraryManager.addBookmarkToCollection(bookmark: bookmark, collectionId: newCollection.id)
}
dismiss()
}
}
.disabled(newCollectionName.isEmpty)
}
}
}
Section {
ForEach(libraryManager.collections) { collection in
Button(action: {
libraryManager.addBookmarkToCollection(bookmark: bookmark, collectionId: collection.id)
dismiss()
}) {
HStack {
Image(systemName: "folder")
Text(collection.name)
Spacer()
Text("\(collection.bookmarks.count)")
.foregroundColor(.secondary)
}
}
}
}
}
.navigationTitle("Add to Collection")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: {
isShowingNewCollectionField.toggle()
}) {
Image(systemName: "folder.badge.plus")
}
}
}
}
}
}

View file

@ -6,6 +6,21 @@
//
import Foundation
import SwiftUI
struct BookmarkCollection: Codable, Identifiable {
let id: UUID
let name: String
var bookmarks: [LibraryItem]
let dateCreated: Date
init(name: String, bookmarks: [LibraryItem] = []) {
self.id = UUID()
self.name = name
self.bookmarks = bookmarks
self.dateCreated = Date()
}
}
struct LibraryItem: Codable, Identifiable {
let id: UUID
@ -28,62 +43,134 @@ struct LibraryItem: Codable, Identifiable {
}
class LibraryManager: ObservableObject {
@Published var bookmarks: [LibraryItem] = []
private let bookmarksKey = "bookmarkedItems"
@Published var collections: [BookmarkCollection] = []
@Published var isShowingCollectionPicker: Bool = false
@Published var bookmarkToAdd: LibraryItem?
private let collectionsKey = "bookmarkCollections"
private let oldBookmarksKey = "bookmarkedItems"
init() {
loadBookmarks()
migrateOldBookmarks()
loadCollections()
NotificationCenter.default.addObserver(self, selector: #selector(handleiCloudSync), name: .iCloudSyncDidComplete, object: nil)
}
@objc private func handleiCloudSync() {
DispatchQueue.main.async {
self.loadBookmarks()
self.loadCollections()
}
}
func removeBookmark(item: LibraryItem) {
if let index = bookmarks.firstIndex(where: { $0.id == item.id }) {
bookmarks.remove(at: index)
Logger.shared.log("Removed series \(item.id) from bookmarks.",type: "Debug")
saveBookmarks()
}
}
private func loadBookmarks() {
guard let data = UserDefaults.standard.data(forKey: bookmarksKey) else {
Logger.shared.log("No bookmarks data found in UserDefaults.", type: "Debug")
private func migrateOldBookmarks() {
guard let data = UserDefaults.standard.data(forKey: oldBookmarksKey) else {
return
}
do {
bookmarks = try JSONDecoder().decode([LibraryItem].self, from: data)
let oldBookmarks = try JSONDecoder().decode([LibraryItem].self, from: data)
if !oldBookmarks.isEmpty {
// Check if "Old Bookmarks" collection already exists
if let existingIndex = collections.firstIndex(where: { $0.name == "Old Bookmarks" }) {
// Add new bookmarks to existing collection, avoiding duplicates
for bookmark in oldBookmarks {
if !collections[existingIndex].bookmarks.contains(where: { $0.href == bookmark.href }) {
collections[existingIndex].bookmarks.insert(bookmark, at: 0)
}
}
} else {
// Create new "Old Bookmarks" collection
let oldCollection = BookmarkCollection(name: "Old Bookmarks", bookmarks: oldBookmarks)
collections.append(oldCollection)
}
saveCollections()
}
UserDefaults.standard.removeObject(forKey: oldBookmarksKey)
} catch {
Logger.shared.log("Failed to decode bookmarks: \(error.localizedDescription)", type: "Error")
Logger.shared.log("Failed to migrate old bookmarks: \(error)", type: "Error")
}
}
private func saveBookmarks() {
private func loadCollections() {
guard let data = UserDefaults.standard.data(forKey: collectionsKey) else {
Logger.shared.log("No collections data found in UserDefaults.", type: "Debug")
return
}
do {
let encoded = try JSONEncoder().encode(bookmarks)
UserDefaults.standard.set(encoded, forKey: bookmarksKey)
collections = try JSONDecoder().decode([BookmarkCollection].self, from: data)
} catch {
Logger.shared.log("Failed to save bookmarks: \(error)", type: "Error")
Logger.shared.log("Failed to decode collections: \(error.localizedDescription)", type: "Error")
}
}
private func saveCollections() {
do {
let encoded = try JSONEncoder().encode(collections)
UserDefaults.standard.set(encoded, forKey: collectionsKey)
} catch {
Logger.shared.log("Failed to save collections: \(error)", type: "Error")
}
}
func createCollection(name: String) {
let newCollection = BookmarkCollection(name: name)
collections.append(newCollection)
saveCollections()
}
func deleteCollection(id: UUID) {
collections.removeAll { $0.id == id }
saveCollections()
}
func addBookmarkToCollection(bookmark: LibraryItem, collectionId: UUID) {
if let index = collections.firstIndex(where: { $0.id == collectionId }) {
if !collections[index].bookmarks.contains(where: { $0.href == bookmark.href }) {
collections[index].bookmarks.insert(bookmark, at: 0)
saveCollections()
}
}
}
func removeBookmarkFromCollection(bookmarkId: UUID, collectionId: UUID) {
if let collectionIndex = collections.firstIndex(where: { $0.id == collectionId }) {
collections[collectionIndex].bookmarks.removeAll { $0.id == bookmarkId }
saveCollections()
}
}
func isBookmarked(href: String, moduleName: String) -> Bool {
bookmarks.contains { $0.href == href }
for collection in collections {
if collection.bookmarks.contains(where: { $0.href == href }) {
return true
}
}
return false
}
func toggleBookmark(title: String, imageUrl: String, href: String, moduleId: String, moduleName: String) {
if let index = bookmarks.firstIndex(where: { $0.href == href }) {
bookmarks.remove(at: index)
} else {
let bookmark = LibraryItem(title: title, imageUrl: imageUrl, href: href, moduleId: moduleId, moduleName: moduleName)
bookmarks.insert(bookmark, at: 0)
for (collectionIndex, collection) in collections.enumerated() {
if let bookmarkIndex = collection.bookmarks.firstIndex(where: { $0.href == href }) {
collections[collectionIndex].bookmarks.remove(at: bookmarkIndex)
saveCollections()
return
}
}
let bookmark = LibraryItem(title: title, imageUrl: imageUrl, href: href, moduleId: moduleId, moduleName: moduleName)
bookmarkToAdd = bookmark
isShowingCollectionPicker = true
}
func renameCollection(id: UUID, newName: String) {
if let index = collections.firstIndex(where: { $0.id == id }) {
var updated = collections[index]
updated = BookmarkCollection(name: newName, bookmarks: updated.bookmarks)
collections[index] = BookmarkCollection(name: newName, bookmarks: updated.bookmarks)
saveCollections()
}
saveBookmarks()
}
}

View file

@ -20,9 +20,6 @@ struct LibraryView: View {
@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
@State private var selectedTab: Int = 0
@ -111,16 +108,16 @@ struct LibraryView: View {
HStack {
HStack(spacing: 4) {
Image(systemName: "bookmark.fill")
Image(systemName: "folder.fill")
.font(.subheadline)
Text("Bookmarks")
Text("Collections")
.font(.title3)
.fontWeight(.semibold)
}
Spacer()
NavigationLink(destination: BookmarksDetailView(bookmarks: $libraryManager.bookmarks)) {
NavigationLink(destination: BookmarksDetailView()) {
Text("View All")
.font(.subheadline)
.padding(.horizontal, 12)
@ -132,32 +129,9 @@ struct LibraryView: View {
}
.padding(.horizontal, 20)
BookmarksSection(
selectedBookmark: $selectedBookmark,
isDetailActive: $isDetailActive
)
BookmarksSection()
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)
}
@ -462,33 +436,66 @@ extension View {
struct BookmarksSection: View {
@EnvironmentObject private var libraryManager: LibraryManager
@EnvironmentObject private var moduleManager: ModuleManager
@Binding var selectedBookmark: LibraryItem?
@Binding var isDetailActive: Bool
@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.bookmarks.isEmpty {
if libraryManager.collections.isEmpty {
EmptyBookmarksView()
} else {
BookmarksGridView(
selectedBookmark: $selectedBookmark,
isDetailActive: $isDetailActive
)
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: "magazine")
Image(systemName: "folder")
.font(.largeTitle)
.foregroundColor(.secondary)
Text("You have no items saved.")
Text("No Collections")
.font(.headline)
Text("Bookmark items for an easier access later.")
Text("Create a collection to organize your bookmarks")
.font(.caption)
.foregroundColor(.secondary)
}
@ -496,120 +503,3 @@ struct EmptyBookmarksView: View {
.frame(maxWidth: .infinity)
}
}
struct BookmarksGridView: View {
@EnvironmentObject private var libraryManager: LibraryManager
@EnvironmentObject private var moduleManager: ModuleManager
@Binding var selectedBookmark: LibraryItem?
@Binding var isDetailActive: Bool
private var recentBookmarks: [LibraryItem] {
Array(libraryManager.bookmarks.prefix(5))
}
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(recentBookmarks) { item in
BookmarkItemView(
item: item,
selectedBookmark: $selectedBookmark,
isDetailActive: $isDetailActive
)
}
}
.padding(.horizontal, 20)
.frame(height: 243)
}
}
}
struct BookmarkItemView: View {
@EnvironmentObject private var libraryManager: LibraryManager
@EnvironmentObject private var moduleManager: ModuleManager
let item: LibraryItem
@Binding var selectedBookmark: LibraryItem?
@Binding var isDetailActive: Bool
var body: some View {
if let module = moduleManager.modules.first(where: { $0.id.uuidString == item.moduleId }) {
Button(action: {
selectedBookmark = item
isDetailActive = true
}) {
ZStack {
LazyImage(url: URL(string: item.imageUrl)) { state in
if let uiImage = state.imageContainer?.image {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(0.72, contentMode: .fill)
.frame(width: 162, height: 243)
.cornerRadius(12)
.clipped()
} else {
RoundedRectangle(cornerRadius: 12)
.fill(Color.gray.opacity(0.3))
.aspectRatio(2/3, contentMode: .fit)
.redacted(reason: .placeholder)
}
}
.overlay(
ZStack {
Circle()
.fill(Color.black.opacity(0.5))
.frame(width: 28, height: 28)
.overlay(
LazyImage(url: URL(string: 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
)
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, height: 243)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.contextMenu {
Button(role: .destructive, action: {
libraryManager.removeBookmark(item: item)
}) {
Label("Remove from Bookmarks", systemImage: "trash")
}
}
}
}
}

View file

@ -197,6 +197,11 @@ struct MediaInfoView: View {
navigationOverlay
}
.sheet(isPresented: $libraryManager.isShowingCollectionPicker) {
if let bookmark = libraryManager.bookmarkToAdd {
CollectionPickerView(bookmark: bookmark)
}
}
}
@ViewBuilder

View file

@ -127,5 +127,10 @@ struct SearchResultsGrid: View {
}
}
}
.sheet(isPresented: $libraryManager.isShowingCollectionPicker) {
if let bookmark = libraryManager.bookmarkToAdd {
CollectionPickerView(bookmark: bookmark)
}
}
}
}

View file

@ -29,12 +29,15 @@
0488FA9A2DFDF380007575E1 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 0488FA982DFDF380007575E1 /* Localizable.strings */; };
0488FA9E2DFDF3BB007575E1 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 0488FA9C2DFDF3BB007575E1 /* Localizable.strings */; };
04A1B73C2DFF39EB0064688A /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 04A1B73A2DFF39EB0064688A /* Localizable.strings */; };
04AD070F2E035D6E00EB74C1 /* BookmarkCollectionGridCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04AD070D2E035D6E00EB74C1 /* BookmarkCollectionGridCell.swift */; };
04AD07102E035D6E00EB74C1 /* CollectionDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04AD070E2E035D6E00EB74C1 /* CollectionDetailView.swift */; };
04AD07122E0360CD00EB74C1 /* CollectionPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04AD07112E0360CD00EB74C1 /* CollectionPickerView.swift */; };
04AD07162E03704700EB74C1 /* BookmarkCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04AD07152E03704700EB74C1 /* BookmarkCell.swift */; };
04CD76DB2DE20F2200733536 /* AllWatching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04CD76DA2DE20F2200733536 /* AllWatching.swift */; };
04EAC39A2DF9E0DB00BBD483 /* SplashScreenView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04EAC3992DF9E0DB00BBD483 /* SplashScreenView.swift */; };
04F08EDC2DE10BF3006B29D9 /* ProgressiveBlurView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F08EDB2DE10BEC006B29D9 /* ProgressiveBlurView.swift */; };
04F08EDF2DE10C1D006B29D9 /* TabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F08EDE2DE10C1A006B29D9 /* TabBar.swift */; };
04F08EE22DE10C40006B29D9 /* TabItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F08EE12DE10C27006B29D9 /* TabItem.swift */; };
04F08EE42DE10D6F006B29D9 /* AllBookmarks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F08EE32DE10D6B006B29D9 /* AllBookmarks.swift */; };
130326B62DF979AC00AEF610 /* WebAuthenticationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 130326B52DF979AC00AEF610 /* WebAuthenticationManager.swift */; };
130C6BFA2D53AB1F00DC1432 /* SettingsViewData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 130C6BF92D53AB1F00DC1432 /* SettingsViewData.swift */; };
13103E8B2D58E028000F0673 /* View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13103E8A2D58E028000F0673 /* View.swift */; };
@ -137,12 +140,15 @@
0488FA992DFDF380007575E1 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = Localizable.strings; sourceTree = "<group>"; };
0488FA9D2DFDF3BB007575E1 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = Localizable.strings; sourceTree = "<group>"; };
04A1B7392DFF39EB0064688A /* nn */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nn; path = Localizable.strings; sourceTree = "<group>"; };
04AD070D2E035D6E00EB74C1 /* BookmarkCollectionGridCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkCollectionGridCell.swift; sourceTree = "<group>"; };
04AD070E2E035D6E00EB74C1 /* CollectionDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionDetailView.swift; sourceTree = "<group>"; };
04AD07112E0360CD00EB74C1 /* CollectionPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionPickerView.swift; sourceTree = "<group>"; };
04AD07152E03704700EB74C1 /* BookmarkCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkCell.swift; sourceTree = "<group>"; };
04CD76DA2DE20F2200733536 /* AllWatching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllWatching.swift; sourceTree = "<group>"; };
04EAC3992DF9E0DB00BBD483 /* SplashScreenView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashScreenView.swift; sourceTree = "<group>"; };
04F08EDB2DE10BEC006B29D9 /* ProgressiveBlurView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressiveBlurView.swift; sourceTree = "<group>"; };
04F08EDE2DE10C1A006B29D9 /* TabBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBar.swift; sourceTree = "<group>"; };
04F08EE12DE10C27006B29D9 /* TabItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabItem.swift; sourceTree = "<group>"; };
04F08EE32DE10D6B006B29D9 /* AllBookmarks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllBookmarks.swift; sourceTree = "<group>"; };
130326B52DF979AC00AEF610 /* WebAuthenticationManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebAuthenticationManager.swift; sourceTree = "<group>"; };
130C6BF82D53A4C200DC1432 /* Sora.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Sora.entitlements; sourceTree = "<group>"; };
130C6BF92D53AB1F00DC1432 /* SettingsViewData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewData.swift; sourceTree = "<group>"; };
@ -322,6 +328,10 @@
0457C59C2DE78267000AFBD9 /* BookmarkComponents */ = {
isa = PBXGroup;
children = (
04AD07152E03704700EB74C1 /* BookmarkCell.swift */,
04AD07112E0360CD00EB74C1 /* CollectionPickerView.swift */,
04AD070D2E035D6E00EB74C1 /* BookmarkCollectionGridCell.swift */,
04AD070E2E035D6E00EB74C1 /* CollectionDetailView.swift */,
0457C5A02DE78385000AFBD9 /* BookmarksDetailView.swift */,
0457C5992DE78267000AFBD9 /* BookmarkGridItemView.swift */,
0457C59A2DE78267000AFBD9 /* BookmarkGridView.swift */,
@ -604,7 +614,6 @@
children = (
0457C59C2DE78267000AFBD9 /* BookmarkComponents */,
04CD76DA2DE20F2200733536 /* AllWatching.swift */,
04F08EE32DE10D6B006B29D9 /* AllBookmarks.swift */,
133F55BA2D33B55100E08EEA /* LibraryManager.swift */,
133D7C7E2D2BE2630075467E /* LibraryView.swift */,
);
@ -855,6 +864,7 @@
sv,
bos,
bs,
cs,
);
mainGroup = 133D7C612D2BE2500075467E;
packageReferences = (
@ -919,8 +929,8 @@
136BBE802DB1038000906B5E /* Notification+Name.swift in Sources */,
13DB46902D900A38008CBC03 /* URL.swift in Sources */,
130326B62DF979AC00AEF610 /* WebAuthenticationManager.swift in Sources */,
04F08EE42DE10D6F006B29D9 /* AllBookmarks.swift in Sources */,
138FE1D02DECA00D00936D81 /* TMDB-FetchID.swift in Sources */,
04AD07162E03704700EB74C1 /* BookmarkCell.swift in Sources */,
130C6BFA2D53AB1F00DC1432 /* SettingsViewData.swift in Sources */,
0402DA172DE7B7B8003BB42C /* SearchViewComponents.swift in Sources */,
1E9FF1D32D403E49008AC100 /* SettingsViewLoggerFilter.swift in Sources */,
@ -934,6 +944,8 @@
13D99CF72D4E73C300250A86 /* ModuleAdditionSettingsView.swift in Sources */,
13C0E5EC2D5F85F800E7F619 /* ContinueWatchingItem.swift in Sources */,
13CBA0882D60F19C00EFE70A /* VTTSubtitlesLoader.swift in Sources */,
04AD070F2E035D6E00EB74C1 /* BookmarkCollectionGridCell.swift in Sources */,
04AD07102E035D6E00EB74C1 /* CollectionDetailView.swift in Sources */,
13EA2BD62D32D97400C1EBD7 /* Double+Extension.swift in Sources */,
133D7C8F2D2BE2640075467E /* MediaInfoView.swift in Sources */,
04F08EE22DE10C40006B29D9 /* TabItem.swift in Sources */,
@ -949,6 +961,7 @@
138AA1B82D2D66FD0021F9DF /* EpisodeCell.swift in Sources */,
722248662DCBC13E00CABE2D /* JSController-Downloads.swift in Sources */,
133D7C8C2D2BE2640075467E /* SearchView.swift in Sources */,
04AD07122E0360CD00EB74C1 /* CollectionPickerView.swift in Sources */,
133CF6A62DFEBE9000BD13F9 /* VideoWatchingActivity.swift in Sources */,
13DB468E2D90093A008CBC03 /* Anilist-Token.swift in Sources */,
1EAC7A322D888BC50083984D /* MusicProgressSlider.swift in Sources */,