From 82eec0688f49281c01903834795671dd1d9c3ecb Mon Sep 17 00:00:00 2001 From: 50/50 <80717571+50n50@users.noreply.github.com> Date: Thu, 19 Jun 2025 08:32:10 +0200 Subject: [PATCH 01/45] Bookmark collection system + migration system (Video in discord) (#207) --- Sora/Views/LibraryView/AllWatching.swift | 1 - .../BookmarkComponents/BookmarkCell.swift | 96 ++++++ .../BookmarkCollectionGridCell.swift | 86 ++++++ .../BookmarkGridItemView.swift | 98 ++++--- .../BookmarkComponents/BookmarkGridView.swift | 59 +++- .../BookmarkComponents/BookmarkLink.swift | 2 +- .../BookmarksDetailView.swift | 208 ++++++++++--- .../CollectionDetailView.swift} | 276 ++++++++---------- .../CollectionPickerView.swift | 73 +++++ Sora/Views/LibraryView/LibraryManager.swift | 143 +++++++-- Sora/Views/LibraryView/LibraryView.swift | 206 +++---------- Sora/Views/MediaInfoView/MediaInfoView.swift | 5 + Sora/Views/SearchView/SearchResultsGrid.swift | 5 + Sulfur.xcodeproj/project.pbxproj | 21 +- 14 files changed, 839 insertions(+), 440 deletions(-) create mode 100644 Sora/Views/LibraryView/BookmarkComponents/BookmarkCell.swift create mode 100644 Sora/Views/LibraryView/BookmarkComponents/BookmarkCollectionGridCell.swift rename Sora/Views/LibraryView/{AllBookmarks.swift => BookmarkComponents/CollectionDetailView.swift} (55%) create mode 100644 Sora/Views/LibraryView/BookmarkComponents/CollectionPickerView.swift diff --git a/Sora/Views/LibraryView/AllWatching.swift b/Sora/Views/LibraryView/AllWatching.swift index c60a711..1852cdc 100644 --- a/Sora/Views/LibraryView/AllWatching.swift +++ b/Sora/Views/LibraryView/AllWatching.swift @@ -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 { diff --git a/Sora/Views/LibraryView/BookmarkComponents/BookmarkCell.swift b/Sora/Views/LibraryView/BookmarkComponents/BookmarkCell.swift new file mode 100644 index 0000000..7e746cd --- /dev/null +++ b/Sora/Views/LibraryView/BookmarkComponents/BookmarkCell.swift @@ -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") + } + } + } + } +} \ No newline at end of file diff --git a/Sora/Views/LibraryView/BookmarkComponents/BookmarkCollectionGridCell.swift b/Sora/Views/LibraryView/BookmarkComponents/BookmarkCollectionGridCell.swift new file mode 100644 index 0000000..9b67c31 --- /dev/null +++ b/Sora/Views/LibraryView/BookmarkComponents/BookmarkCollectionGridCell.swift @@ -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) + } + } +} \ No newline at end of file diff --git a/Sora/Views/LibraryView/BookmarkComponents/BookmarkGridItemView.swift b/Sora/Views/LibraryView/BookmarkComponents/BookmarkGridItemView.swift index d6085f1..e833918 100644 --- a/Sora/Views/LibraryView/BookmarkComponents/BookmarkGridItemView.swift +++ b/Sora/Views/LibraryView/BookmarkComponents/BookmarkGridItemView.swift @@ -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 - - 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)) } } diff --git a/Sora/Views/LibraryView/BookmarkComponents/BookmarkGridView.swift b/Sora/Views/LibraryView/BookmarkComponents/BookmarkGridView.swift index 3d792a7..19e8b96 100644 --- a/Sora/Views/LibraryView/BookmarkComponents/BookmarkGridView.swift +++ b/Sora/Views/LibraryView/BookmarkComponents/BookmarkGridView.swift @@ -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 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() } } diff --git a/Sora/Views/LibraryView/BookmarkComponents/BookmarkLink.swift b/Sora/Views/LibraryView/BookmarkComponents/BookmarkLink.swift index 4a597cb..8b8bcf8 100644 --- a/Sora/Views/LibraryView/BookmarkComponents/BookmarkLink.swift +++ b/Sora/Views/LibraryView/BookmarkComponents/BookmarkLink.swift @@ -1,5 +1,5 @@ // -// MediaInfoView.swift +// BookmarkLink.swift // Sora // // Created by paul on 28/05/25. diff --git a/Sora/Views/LibraryView/BookmarkComponents/BookmarksDetailView.swift b/Sora/Views/LibraryView/BookmarkComponents/BookmarksDetailView.swift index 07985f2..dabb25a 100644 --- a/Sora/Views/LibraryView/BookmarkComponents/BookmarksDetailView.swift +++ b/Sora/Views/LibraryView/BookmarkComponents/BookmarksDetailView.swift @@ -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 = [] + @State private var selectedCollections: Set = [] + @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) } } } diff --git a/Sora/Views/LibraryView/AllBookmarks.swift b/Sora/Views/LibraryView/BookmarkComponents/CollectionDetailView.swift similarity index 55% rename from Sora/Views/LibraryView/AllBookmarks.swift rename to Sora/Views/LibraryView/BookmarkComponents/CollectionDetailView.swift index cb4fde4..620c05c 100644 --- a/Sora/Views/LibraryView/AllBookmarks.swift +++ b/Sora/Views/LibraryView/BookmarkComponents/CollectionDetailView.swift @@ -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 = [] 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() - } -} +} \ No newline at end of file diff --git a/Sora/Views/LibraryView/BookmarkComponents/CollectionPickerView.swift b/Sora/Views/LibraryView/BookmarkComponents/CollectionPickerView.swift new file mode 100644 index 0000000..3874d06 --- /dev/null +++ b/Sora/Views/LibraryView/BookmarkComponents/CollectionPickerView.swift @@ -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") + } + } + } + } + } +} \ No newline at end of file diff --git a/Sora/Views/LibraryView/LibraryManager.swift b/Sora/Views/LibraryView/LibraryManager.swift index 4f18ef2..510fda9 100644 --- a/Sora/Views/LibraryView/LibraryManager.swift +++ b/Sora/Views/LibraryView/LibraryManager.swift @@ -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() } } diff --git a/Sora/Views/LibraryView/LibraryView.swift b/Sora/Views/LibraryView/LibraryView.swift index bad055c..1f0844e 100644 --- a/Sora/Views/LibraryView/LibraryView.swift +++ b/Sora/Views/LibraryView/LibraryView.swift @@ -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") - } - } - } - } -} diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index e665c3f..23ded14 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -197,6 +197,11 @@ struct MediaInfoView: View { navigationOverlay } + .sheet(isPresented: $libraryManager.isShowingCollectionPicker) { + if let bookmark = libraryManager.bookmarkToAdd { + CollectionPickerView(bookmark: bookmark) + } + } } @ViewBuilder diff --git a/Sora/Views/SearchView/SearchResultsGrid.swift b/Sora/Views/SearchView/SearchResultsGrid.swift index ada75b4..28b02be 100644 --- a/Sora/Views/SearchView/SearchResultsGrid.swift +++ b/Sora/Views/SearchView/SearchResultsGrid.swift @@ -127,5 +127,10 @@ struct SearchResultsGrid: View { } } } + .sheet(isPresented: $libraryManager.isShowingCollectionPicker) { + if let bookmark = libraryManager.bookmarkToAdd { + CollectionPickerView(bookmark: bookmark) + } + } } } diff --git a/Sulfur.xcodeproj/project.pbxproj b/Sulfur.xcodeproj/project.pbxproj index 7a8529b..6f8acfb 100644 --- a/Sulfur.xcodeproj/project.pbxproj +++ b/Sulfur.xcodeproj/project.pbxproj @@ -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 = ""; }; 0488FA9D2DFDF3BB007575E1 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = Localizable.strings; sourceTree = ""; }; 04A1B7392DFF39EB0064688A /* nn */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nn; path = Localizable.strings; sourceTree = ""; }; + 04AD070D2E035D6E00EB74C1 /* BookmarkCollectionGridCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkCollectionGridCell.swift; sourceTree = ""; }; + 04AD070E2E035D6E00EB74C1 /* CollectionDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionDetailView.swift; sourceTree = ""; }; + 04AD07112E0360CD00EB74C1 /* CollectionPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionPickerView.swift; sourceTree = ""; }; + 04AD07152E03704700EB74C1 /* BookmarkCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkCell.swift; sourceTree = ""; }; 04CD76DA2DE20F2200733536 /* AllWatching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllWatching.swift; sourceTree = ""; }; 04EAC3992DF9E0DB00BBD483 /* SplashScreenView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashScreenView.swift; sourceTree = ""; }; 04F08EDB2DE10BEC006B29D9 /* ProgressiveBlurView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressiveBlurView.swift; sourceTree = ""; }; 04F08EDE2DE10C1A006B29D9 /* TabBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBar.swift; sourceTree = ""; }; 04F08EE12DE10C27006B29D9 /* TabItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabItem.swift; sourceTree = ""; }; - 04F08EE32DE10D6B006B29D9 /* AllBookmarks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllBookmarks.swift; sourceTree = ""; }; 130326B52DF979AC00AEF610 /* WebAuthenticationManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebAuthenticationManager.swift; sourceTree = ""; }; 130C6BF82D53A4C200DC1432 /* Sora.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Sora.entitlements; sourceTree = ""; }; 130C6BF92D53AB1F00DC1432 /* SettingsViewData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewData.swift; sourceTree = ""; }; @@ -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 */, From 78df4df73b8ebbe49caf68bd83c18393595429f4 Mon Sep 17 00:00:00 2001 From: undeaD_D <8116188+undeaDD@users.noreply.github.com> Date: Mon, 23 Jun 2025 21:03:37 +0200 Subject: [PATCH 02/45] Fix simulator crash & add conditional support for iOS 26+ native TabView (#209) * add ability to use native tabbar on ios 26 or later * add tabview lcalization * fix library / home tab label and localization * add missing localization & german translation --- Sora/ContentView.swift | 79 +++++++++++-------- .../Localization/de.lproj/Localizable.strings | 14 +++- .../Localization/en.lproj/Localizable.strings | 10 +++ .../Utils/DownloadUtils/DownloadManager.swift | 17 ++-- .../Downloads/JSController-Downloads.swift | 41 +++++----- Sora/Utils/TabBar/TabBar.swift | 29 ++----- .../SettingsViewGeneral.swift | 18 ++++- 7 files changed, 121 insertions(+), 87 deletions(-) diff --git a/Sora/ContentView.swift b/Sora/ContentView.swift index 8238bd1..4cde703 100644 --- a/Sora/ContentView.swift +++ b/Sora/ContentView.swift @@ -16,48 +16,57 @@ struct ContentView_Previews: PreviewProvider { } struct ContentView: View { + @AppStorage("useNativeTabBar") private var useNativeTabBar: Bool = false @StateObject private var tabBarController = TabBarController() @State var selectedTab: Int = 0 @State var lastTab: Int = 0 @State private var searchQuery: String = "" let tabs: [TabItem] = [ - TabItem(icon: "square.stack", title: ""), - TabItem(icon: "arrow.down.circle", title: ""), - TabItem(icon: "gearshape", title: ""), - TabItem(icon: "magnifyingglass", title: "") + TabItem(icon: "square.stack", title: NSLocalizedString("LibraryTab", comment: "")), + TabItem(icon: "arrow.down.circle", title: NSLocalizedString("DownloadsTab", comment: "")), + TabItem(icon: "gearshape", title: NSLocalizedString("SettingsTab", comment: "")), + TabItem(icon: "magnifyingglass", title: NSLocalizedString("SearchTab", comment: "")) ] - - var body: some View { - ZStack(alignment: .bottom) { - switch selectedTab { - case 0: - LibraryView() - .environmentObject(tabBarController) - case 1: - DownloadView() - .environmentObject(tabBarController) - case 2: - SettingsView() - .environmentObject(tabBarController) - case 3: - SearchView(searchQuery: $searchQuery) - .environmentObject(tabBarController) - default: - LibraryView() - .environmentObject(tabBarController) - } - - TabBar( - tabs: tabs, - selectedTab: $selectedTab, - lastTab: $lastTab, - searchQuery: $searchQuery, - controller: tabBarController - ) + + private func tabView(for index: Int) -> some View { + switch index { + case 1: return AnyView(DownloadView()) + case 2: return AnyView(SettingsView()) + case 3: return AnyView(SearchView(searchQuery: $searchQuery)) + default: return AnyView(LibraryView()) + } + } + + var body: some View { + if #available(iOS 26, *), useNativeTabBar == true { + TabView { + ForEach(Array(tabs.enumerated()), id: \.offset) { index, item in + Tab(item.title, systemImage: item.icon, role: index == 3 ? .search : nil) { + tabView(for: index) + } + } + } + .searchable(text: $searchQuery) + .environmentObject(tabBarController) + } else { + ZStack(alignment: .bottom) { + Group { + tabView(for: selectedTab) + } + .environmentObject(tabBarController) + + TabBar( + tabs: tabs, + selectedTab: $selectedTab, + lastTab: $lastTab, + searchQuery: $searchQuery, + controller: tabBarController + ) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + .ignoresSafeArea(.keyboard, edges: .bottom) + .padding(.bottom, -20) } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) - .ignoresSafeArea(.keyboard, edges: .bottom) - .padding(.bottom, -20) } } diff --git a/Sora/Localization/de.lproj/Localizable.strings b/Sora/Localization/de.lproj/Localizable.strings index 948d5c3..98aed9b 100644 --- a/Sora/Localization/de.lproj/Localizable.strings +++ b/Sora/Localization/de.lproj/Localizable.strings @@ -49,6 +49,9 @@ "Copied to Clipboard" = "In die Zwischenablage kopiert"; "Copy to Clipboard" = "In die Zwischenablage kopieren"; "Copy URL" = "URL kopieren"; +"Collections" = "Sammlungen"; +"No Collections" = "Keine Sammlungen vorhanden"; +"Create a collection to organize your bookmarks" = "Erstelle eine Sammlung für mehr Organisation"; /* Episodes */ "%lld Episodes" = "%lld Folgen"; @@ -84,7 +87,7 @@ "Download Episode" = "Folge herunterladen"; "Download Summary" = "Download-Übersicht"; "Download This Episode" = "Diese Folge herunterladen"; -"Downloaded" = "Heruntergeladen"; +"Downloaded" = "Geladen"; "Downloaded Shows" = "Heruntergeladene Serien"; "Downloading" = "Lädt herunter"; "Downloads" = "Downloads"; @@ -115,6 +118,7 @@ "General events and activities." = "Allgemeine Aktivitäten."; "General Preferences" = "Allgemeine Einstellungen"; "Hide Splash Screen" = "Startbildschirm ausblenden"; +"Use Native Tab Bar" = "System Bar verwenden"; "HLS video downloading." = "HLS Video-Downloads."; "Hold Speed" = "Geschwindigkeit halten"; @@ -383,8 +387,14 @@ "Library cleared successfully" = "Bibliothek erfolgreich geleert"; "All downloads deleted successfully" = "Alle Downloads erfolgreich gelöscht"; +/* TabView */ +"LibraryTab" = "Bibliothek"; +"DownloadsTab" = "Downloads"; +"SettingsTab" = "Einstellungen"; +"SearchTab" = "Suchen"; + /* New additions */ "Recent searches" = "Letzte Suchanfragen"; "me frfr" = "Ich, ohne Witz"; "Data" = "Daten"; -"Maximum Quality Available" = "Maximal verfügbare Qualität"; \ No newline at end of file +"Maximum Quality Available" = "Maximal verfügbare Qualität"; diff --git a/Sora/Localization/en.lproj/Localizable.strings b/Sora/Localization/en.lproj/Localizable.strings index 2e83aae..4f55cbb 100644 --- a/Sora/Localization/en.lproj/Localizable.strings +++ b/Sora/Localization/en.lproj/Localizable.strings @@ -49,6 +49,9 @@ "Copied to Clipboard" = "Copied to Clipboard"; "Copy to Clipboard" = "Copy to Clipboard"; "Copy URL" = "Copy URL"; +"Collections" = "Collections"; +"No Collections" = "No Collections"; +"Create a collection to organize your bookmarks" = "Create a collection to organize your bookmarks"; /* Episodes */ "%lld Episodes" = "%lld Episodes"; @@ -115,6 +118,7 @@ "General events and activities." = "General events and activities."; "General Preferences" = "General Preferences"; "Hide Splash Screen" = "Hide Splash Screen"; +"Use Native Tab Bar" = "Use Native Tabs"; "HLS video downloading." = "HLS video downloading."; "Hold Speed" = "Hold Speed"; @@ -384,6 +388,12 @@ "Library cleared successfully" = "Library cleared successfully"; "All downloads deleted successfully" = "All downloads deleted successfully"; +/* TabView */ +"LibraryTab" = "Library"; +"DownloadsTab" = "Downloads"; +"SettingsTab" = "Settings"; +"SearchTab" = "Search"; + /* New additions */ "Recent searches" = "Recent searches"; "me frfr" = "me frfr"; diff --git a/Sora/Utils/DownloadUtils/DownloadManager.swift b/Sora/Utils/DownloadUtils/DownloadManager.swift index 3c09ea9..2adab56 100644 --- a/Sora/Utils/DownloadUtils/DownloadManager.swift +++ b/Sora/Utils/DownloadUtils/DownloadManager.swift @@ -23,12 +23,17 @@ class DownloadManager: NSObject, ObservableObject { } private func initializeDownloadSession() { - let configuration = URLSessionConfiguration.background(withIdentifier: "hls-downloader") - assetDownloadURLSession = AVAssetDownloadURLSession( - configuration: configuration, - assetDownloadDelegate: self, - delegateQueue: .main - ) + #if targetEnvironment(simulator) + Logger.shared.log("Download Sessions are not available on Simulator", type: "Error") + #else + let configuration = URLSessionConfiguration.background(withIdentifier: "hls-downloader") + + assetDownloadURLSession = AVAssetDownloadURLSession( + configuration: configuration, + assetDownloadDelegate: self, + delegateQueue: .main + ) + #endif } func downloadAsset(from url: URL) { diff --git a/Sora/Utils/JSLoader/Downloads/JSController-Downloads.swift b/Sora/Utils/JSLoader/Downloads/JSController-Downloads.swift index be358fe..42a37b6 100644 --- a/Sora/Utils/JSLoader/Downloads/JSController-Downloads.swift +++ b/Sora/Utils/JSLoader/Downloads/JSController-Downloads.swift @@ -43,24 +43,29 @@ extension JSController { private static var progressUpdateTimer: Timer? func initializeDownloadSession() { - // Create a unique identifier for the background session - let sessionIdentifier = "hls-downloader-\(UUID().uuidString)" - - let configuration = URLSessionConfiguration.background(withIdentifier: sessionIdentifier) - - // Configure session - configuration.allowsCellularAccess = true - configuration.shouldUseExtendedBackgroundIdleMode = true - configuration.waitsForConnectivity = true - - // Create session with configuration - downloadURLSession = AVAssetDownloadURLSession( - configuration: configuration, - assetDownloadDelegate: self, - delegateQueue: .main - ) - - print("Download session initialized with ID: \(sessionIdentifier)") + #if targetEnvironment(simulator) + Logger.shared.log("Download Sessions are not available on Simulator", type: "Error") + #else + // Create a unique identifier for the background session + let sessionIdentifier = "hls-downloader-\(UUID().uuidString)" + + let configuration = URLSessionConfiguration.background(withIdentifier: sessionIdentifier) + + // Configure session + configuration.allowsCellularAccess = true + configuration.shouldUseExtendedBackgroundIdleMode = true + configuration.waitsForConnectivity = true + + // Create session with configuration + downloadURLSession = AVAssetDownloadURLSession( + configuration: configuration, + assetDownloadDelegate: self, + delegateQueue: .main + ) + + print("Download session initialized with ID: \(sessionIdentifier)") + #endif + loadSavedAssets() } diff --git a/Sora/Utils/TabBar/TabBar.swift b/Sora/Utils/TabBar/TabBar.swift index a646e58..148f1c5 100644 --- a/Sora/Utils/TabBar/TabBar.swift +++ b/Sora/Utils/TabBar/TabBar.swift @@ -227,33 +227,14 @@ struct TabBar: View { } } }) { - if tab.title.isEmpty { - Image(systemName: tab.icon + (selectedTab == index ? ".fill" : "")) - .frame(width: 28, height: 28) - .matchedGeometryEffect(id: tab.icon, in: animation) - .foregroundStyle(selectedTab == index ? .black : .gray) - .padding(.vertical, 8) - .padding(.horizontal, 10) - .frame(width: 70) - .opacity(selectedTab == index ? 1 : 0.5) - } else { - VStack { - Image(systemName: tab.icon + (selectedTab == index ? ".fill" : "")) - .frame(width: 36, height: 18) - .matchedGeometryEffect(id: tab.icon, in: animation) - .foregroundStyle(selectedTab == index ? .black : .gray) - - Text(tab.title) - .font(.caption) - .frame(width: 60) - .lineLimit(1) - .truncationMode(.tail) - } + Image(systemName: tab.icon + (selectedTab == index ? ".fill" : "")) + .frame(width: 28, height: 28) + .matchedGeometryEffect(id: tab.icon, in: animation) + .foregroundStyle(selectedTab == index ? .black : .gray) .padding(.vertical, 8) .padding(.horizontal, 10) - .frame(width: 80) + .frame(width: 70) .opacity(selectedTab == index ? 1 : 0.5) - } } .background( selectedTab == index ? diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift index 68b2f96..cb7bdcb 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift @@ -154,6 +154,7 @@ struct SettingsViewGeneral: View { @AppStorage("fetchEpisodeMetadata") private var fetchEpisodeMetadata: Bool = true @AppStorage("analyticsEnabled") private var analyticsEnabled: Bool = false @AppStorage("hideSplashScreen") private var hideSplashScreenEnable: Bool = false + @AppStorage("useNativeTabBar") private var useNativeTabBar: Bool = false @AppStorage("metadataProvidersOrder") private var metadataProvidersOrderData: Data = { try! JSONEncoder().encode(["TMDB","AniList"]) }() @@ -171,7 +172,11 @@ struct SettingsViewGeneral: View { private let metadataProvidersList = ["TMDB", "AniList"] @EnvironmentObject var settings: Settings @State private var showRestartAlert = false - + + private let isiOS26OrLater: Bool = { + if #available(iOS 26, *) { return true } else { return false } + }() + var body: some View { ScrollView(showsIndicators: false) { VStack(spacing: 24) { @@ -194,8 +199,17 @@ struct SettingsViewGeneral: View { icon: "wand.and.rays.inverse", title: NSLocalizedString("Hide Splash Screen", comment: ""), isOn: $hideSplashScreenEnable, - showDivider: false + showDivider: isiOS26OrLater ) + + if isiOS26OrLater { + SettingsToggleRow( + icon: "inset.filled.bottomthird.rectangle", + title: NSLocalizedString("Use Native Tab Bar", comment: ""), + isOn: $useNativeTabBar, + showDivider: false + ) + } } SettingsSection(title: NSLocalizedString("Language", comment: "")) { From 22ba348959b3f5687f5eb53b3c552e5473a2a16d Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Mon, 23 Jun 2025 21:14:58 +0200 Subject: [PATCH 03/45] fixed build issue maybe --- Sora/ContentView.swift | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Sora/ContentView.swift b/Sora/ContentView.swift index 4cde703..67697f4 100644 --- a/Sora/ContentView.swift +++ b/Sora/ContentView.swift @@ -37,14 +37,15 @@ struct ContentView: View { default: return AnyView(LibraryView()) } } - + var body: some View { if #available(iOS 26, *), useNativeTabBar == true { TabView { ForEach(Array(tabs.enumerated()), id: \.offset) { index, item in - Tab(item.title, systemImage: item.icon, role: index == 3 ? .search : nil) { - tabView(for: index) - } + tabView(for: index) + .tabItem { + Label(item.title, systemImage: item.icon) + } } } .searchable(text: $searchQuery) From 55dfa9cbf494f2cb3c74b556217959fdbcd37acc Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Tue, 24 Jun 2025 10:52:59 +0200 Subject: [PATCH 04/45] Improve fetchV2 header handling in JSContext extension Updated the fetchV2 native function to accept headers as Any type and safely convert them to [String: String]. Added error logging for invalid header formats and non-string header values to improve robustness. --- .../Extensions/JavaScriptCore+Extensions.swift | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/Sora/Utils/Extensions/JavaScriptCore+Extensions.swift b/Sora/Utils/Extensions/JavaScriptCore+Extensions.swift index beb18ec..0f6bd60 100644 --- a/Sora/Utils/Extensions/JavaScriptCore+Extensions.swift +++ b/Sora/Utils/Extensions/JavaScriptCore+Extensions.swift @@ -76,7 +76,7 @@ extension JSContext { } func setupFetchV2() { - let fetchV2NativeFunction: @convention(block) (String, [String: String]?, String?, String?, ObjCBool, String?, JSValue, JSValue) -> Void = { urlString, headers, method, body, redirect, encoding, resolve, reject in + let fetchV2NativeFunction: @convention(block) (String, Any?, String?, String?, ObjCBool, String?, JSValue, JSValue) -> Void = { urlString, headersAny, method, body, redirect, encoding, resolve, reject in guard let url = URL(string: urlString) else { Logger.shared.log("Invalid URL", type: "Error") DispatchQueue.main.async { @@ -85,6 +85,21 @@ extension JSContext { return } + var headers: [String: String]? = nil + if let headersDict = headersAny as? [String: Any] { + var safeHeaders: [String: String] = [:] + for (key, value) in headersDict { + if let valueStr = value as? String { + safeHeaders[key] = valueStr + } else { + Logger.shared.log("Header value is not a String: \(key): \(value)", type: "Error") + } + } + headers = safeHeaders + } else if headersAny != nil { + Logger.shared.log("Headers argument is not a dictionary", type: "Error") + } + let httpMethod = method ?? "GET" var request = URLRequest(url: url) request.httpMethod = httpMethod From e348ed243f21887f7f9556b7a1c1be48818b2fd0 Mon Sep 17 00:00:00 2001 From: 50/50 <80717571+50n50@users.noreply.github.com> Date: Tue, 24 Jun 2025 14:47:08 +0200 Subject: [PATCH 05/45] Check discord (#208) * Quick Czech fix * Bookmark collection system + migration system (Video in discord)) * Check discord * Fix mediainfoview * Title always expanded * Reader header enhancements * Fix tab bar gradient * MORE/LESS below synopsis instead of next to it (less wasted space)) * Font + Weight + Size buttons for reader (with correct UI)) * Change icon * Theming and auto scroll * fucking cool shit * added new theme for reader * Fixed reader header * Added italian * made italian usable * changed credits * finally fucking italian works * Fix novel details * Fix loading issue * made chapter cells less tall * Fix current label --- Sora/Info.plist | 19 + Sora/Localizable.xcstrings | 3907 ----------------- .../Localization/en.lproj/Localizable.strings | 2 +- .../Localization/it.lproj/Localizable.strings | 330 ++ .../Utils/JSLoader/JSController-Details.swift | 41 +- Sora/Utils/JSLoader/JSController-Novel.swift | 187 + Sora/Utils/JSLoader/JSController.swift | 29 + Sora/Utils/Modules/Modules.swift | 1 + Sora/Utils/TabBar/TabBar.swift | 4 +- Sora/Views/DownloadView.swift | 9 +- .../BookmarkGridItemView.swift | 13 +- .../CollectionDetailView.swift | 2 + Sora/Views/LibraryView/LibraryView.swift | 2 + .../ChapterCell/ChapterCell.swift | 78 + Sora/Views/MediaInfoView/MediaInfoView.swift | 412 +- Sora/Views/ReaderView/ReaderView.swift | 697 +++ Sora/Views/SearchView/SearchView.swift | 2 + .../SettingsSubViews/SettingsViewAbout.swift | 4 +- .../SettingsViewGeneral.swift | 2 + Sora/Views/SettingsView/SettingsView.swift | 5 +- Sulfur.xcodeproj/project.pbxproj | 50 +- 21 files changed, 1783 insertions(+), 4013 deletions(-) delete mode 100644 Sora/Localizable.xcstrings create mode 100644 Sora/Localization/it.lproj/Localizable.strings create mode 100644 Sora/Utils/JSLoader/JSController-Novel.swift create mode 100644 Sora/Views/MediaInfoView/ChapterCell/ChapterCell.swift create mode 100644 Sora/Views/ReaderView/ReaderView.swift diff --git a/Sora/Info.plist b/Sora/Info.plist index ba6c434..dc64431 100644 --- a/Sora/Info.plist +++ b/Sora/Info.plist @@ -6,6 +6,25 @@ $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleDevelopmentRegion + en + CFBundleLocalizations + + en + ar + bos + cs + nl + fr + de + it + kk + nn + ru + sk + es + sv + CFBundleURLTypes diff --git a/Sora/Localizable.xcstrings b/Sora/Localizable.xcstrings deleted file mode 100644 index e15ac25..0000000 --- a/Sora/Localizable.xcstrings +++ /dev/null @@ -1,3907 +0,0 @@ -{ - "sourceLanguage" : "en", - "strings" : { - "" : { - - }, - "%lld" : { - - }, - "%lld Episodes" : { - "localizations" : { - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld Afleveringen" - } - } - } - }, - "%lld of %lld" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "%1$lld of %2$lld" - } - } - } - }, - "%lld-%lld" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "%1$lld-%2$lld" - } - } - } - }, - "%lld%%" : { - - }, - "%lld%% seen" : { - "localizations" : { - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld%% gezien" - } - } - } - }, - "•" : { - - }, - "About" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "About" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Over" - } - } - } - }, - "About Sora" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "About Sora" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Over Sora" - } - } - } - }, - "Active" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Active" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Actief" - } - } - } - }, - "Active Downloads" : { - - }, - "Actively downloading media can be tracked from here." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Actively downloading media can be tracked from here." - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Actief downloaden van media kan hier worden gevolgd." - } - } - } - }, - "Add Module" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Add Module" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Module Toevoegen" - } - } - } - }, - "Adjust the number of media items per row in portrait and landscape modes." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Adjust the number of media items per row in portrait and landscape modes." - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Pas het aantal media-items per rij aan in staande en liggende modus." - } - } - } - }, - "Advanced" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Advanced" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Geavanceerd" - } - } - } - }, - "AKA Sulfur" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "AKA Sulfur" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "AKA Sulfur" - } - } - } - }, - "All Bookmarks" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "All Bookmarks" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Alle Bladwijzers" - } - } - } - }, - "All Prev" : { - "localizations" : { - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Alle vorige" - } - } - } - }, - "All Watching" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "All Watching" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Alles Wat Ik Kijk" - } - } - } - }, - "Also known as Sulfur" : { - - }, - "AniList" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "AniList" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "AniList" - } - } - } - }, - "AniList ID" : { - - }, - "AniList Match" : { - - }, - "AniList.co" : { - - }, - "Anonymous data is collected to improve the app. No personal information is collected. This can be disabled at any time." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Anonymous data is collected to improve the app. No personal information is collected. This can be disabled at any time." - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Anonieme gegevens worden verzameld om de app te verbeteren. Er worden geen persoonlijke gegevens verzameld. Dit kan op elk moment worden uitgeschakeld." - } - } - } - }, - "App Data" : { - - }, - "App Info" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "App Info" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "App Info" - } - } - } - }, - "App Language" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "App Language" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "App Taal" - } - } - } - }, - "App Storage" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "App Storage" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "App Opslag" - } - } - } - }, - "Appearance" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Appearance" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Uiterlijk" - } - } - } - }, - "Are you sure you want to clear all cached data? This will help free up storage space." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Are you sure you want to clear all cached data? This will help free up storage space." - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Weet je zeker dat je alle gecachte gegevens wilt wissen? Dit helpt opslagruimte vrij te maken." - } - } - } - }, - "Are you sure you want to delete '%@'?" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Are you sure you want to delete '%@'?" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Weet je zeker dat je '%@' wilt verwijderen?" - } - } - } - }, - "Are you sure you want to delete all %d episodes in '%@'?" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Are you sure you want to delete all %1$d episodes in '%2$@'?" - } - } - } - }, - "Are you sure you want to delete all %lld episodes in '%@'?" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Are you sure you want to delete all %1$lld episodes in '%2$@'?" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Weet je zeker dat je alle %1$lld afleveringen in '%2$@' wilt verwijderen?" - } - } - } - }, - "Are you sure you want to delete all downloaded assets? You can choose to clear only the library while preserving the downloaded files for future use." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Are you sure you want to delete all downloaded assets? You can choose to clear only the library while preserving the downloaded files for future use." - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Weet je zeker dat je alle gedownloade bestanden wilt verwijderen? Je kunt ervoor kiezen om alleen de bibliotheek te wissen terwijl je de gedownloade bestanden voor later gebruik bewaart." - } - } - } - }, - "Are you sure you want to erase all app data? This action cannot be undone." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Are you sure you want to erase all app data? This action cannot be undone." - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Weet je zeker dat je alle app-gegevens wilt wissen? Deze actie kan niet ongedaan worden gemaakt." - } - } - } - }, - "Are you sure you want to remove all downloaded media files (.mov, .mp4, .pkg)? This action cannot be undone." : { - "localizations" : { - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Weet je zeker dat je alle gedownloade mediabestanden (.mov, .mp4, .pkg) wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.\n" - } - } - } - }, - "Are you sure you want to remove all files in the Documents folder? This will remove all modules." : { - "localizations" : { - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Weet je zeker dat je alle bestanden in de map Documenten wilt verwijderen? Dit zal alle modules verwijderen.\n" - } - } - } - }, - "Author" : { - "localizations" : { - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Auteur\n" - } - } - } - }, - "Background Enabled" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Background Enabled" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Achtergrond Ingeschakeld" - } - } - } - }, - "Bookmark items for an easier access later." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Bookmark items for an easier access later." - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Bladwijzer items voor eenvoudigere toegang later." - } - } - } - }, - "Bookmarks" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Bookmarks" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Bladwijzers" - } - } - } - }, - "Bottom Padding" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Bottom Padding" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Onderste Padding" - } - } - } - }, - "Cancel" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Cancel" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Annuleren" - } - } - } - }, - "Cellular Quality" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Cellular Quality" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Mobiele Kwaliteit" - } - } - } - }, - "Check out some community modules here!" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Check out some community modules here!" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Bekijk hier enkele community modules!" - } - } - } - }, - "Choose preferred video resolution for WiFi and cellular connections. Higher resolutions use more data but provide better quality. If the exact quality isn't available, the closest option will be selected automatically.\n\nNote: Not all video sources and players support quality selection. This feature works best with HLS streams using the Sora player." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Choose preferred video resolution for WiFi and cellular connections. Higher resolutions use more data but provide better quality. If the exact quality isn't available, the closest option will be selected automatically.\n\nNote: Not all video sources and players support quality selection. This feature works best with HLS streams using the Sora player." - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Kies de gewenste videoresolutie voor WiFi en mobiele verbindingen. Hogere resoluties gebruiken meer data maar bieden betere kwaliteit. Als de exacte kwaliteit niet beschikbaar is, wordt automatisch de dichtstbijzijnde optie geselecteerd.\n\nLet op: Niet alle videobronnen en spelers ondersteunen kwaliteitsselectie. Deze functie werkt het beste met HLS-streams met de Sora-speler." - } - } - } - }, - "Clear" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Clear" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Wissen" - } - } - } - }, - "Clear All Downloads" : { - "localizations" : { - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wis Alle Downloads" - } - } - } - }, - "Clear Cache" : { - "localizations" : { - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wis Cache" - } - } - } - }, - "Clear Library Only" : { - "localizations" : { - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Alleen bibliotheek wissen\n" - } - } - } - }, - "Clear Logs" : { - "localizations" : { - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wis Logs" - } - } - } - }, - "Click the plus button to add a module!" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Click the plus button to add a module!" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Klik op de plus-knop om een module toe te voegen!" - } - } - } - }, - "Continue Watching" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Continue Watching" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Verder Kijken" - } - } - } - }, - "Continue Watching Episode %d" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Continue Watching Episode %d" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Verder Kijken Aflevering %d" - } - } - } - }, - "Contributors" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Contributors" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Bijdragers" - } - } - } - }, - "Copied to Clipboard" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Copied to Clipboard" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Gekopieerd naar Klembord" - } - } - } - }, - "Copy to Clipboard" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Copy to Clipboard" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Kopiëren naar Klembord" - } - } - } - }, - "Copy URL" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Copy URL" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "URL Kopiëren" - } - } - } - }, - "cranci1" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "cranci1" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "cranci1" - } - } - } - }, - "Dark" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Dark" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Donker" - } - } - } - }, - "DATA & LOGS" : { - - }, - "Debug" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Debug" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Debug" - } - } - } - }, - "Debugging and troubleshooting." : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Debugging and troubleshooting." - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Debuggen en probleemoplossing." - } - } - } - }, - "Delete" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Delete" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Verwijderen" - } - } - } - }, - "Delete All" : { - "localizations" : { - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Alles Wissen" - } - } - } - }, - "Delete All Downloads" : { - "localizations" : { - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Alle Downloads Wissen" - } - } - } - }, - "Delete All Episodes" : { - "localizations" : { - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Alle Afleveringen Wissen" - } - } - } - }, - "Delete Download" : { - "localizations" : { - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Downloads Wissen" - } - } - } - }, - "Delete Episode" : { - "localizations" : { - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Afleveringen Wissen" - } - } - } - }, - "Double Tap to Seek" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Double Tap to Seek" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Dubbel Tikken om te Zoeken" - } - } - } - }, - "Double tapping the screen on it's sides will skip with the short tap setting." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Double tapping the screen on it's sides will skip with the short tap setting." - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Dubbel tikken op de zijkanten van het scherm zal overslaan met de korte tik instelling." - } - } - } - }, - "Download" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Download" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Downloaden" - } - } - } - }, - "Download Episode" : { - "extractionState" : "stale", - "localizations" : { - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Aflevering Downloaden" - } - } - } - }, - "Download Summary" : { - - }, - "Download This Episode" : { - - }, - "Downloaded" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Downloaded" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Gedownload" - } - } - } - }, - "Downloaded Shows" : { - - }, - "Downloading" : { - - }, - "Downloads" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Downloads" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Downloads" - } - } - } - }, - "Enable Analytics" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Enable Analytics" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Analytics Inschakelen" - } - } - } - }, - "Enable Subtitles" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Enable Subtitles" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Ondertiteling Inschakelen" - } - } - } - }, - "Enter the AniList ID for this media" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Enter the AniList ID for this media" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Voer de AniList ID in voor deze media" - } - } - } - }, - "Enter the AniList ID for this series" : { - - }, - "Episode %lld" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Episode %lld" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Aflevering %lld" - } - } - } - }, - "Episodes" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Episodes" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Afleveringen" - } - } - } - }, - "Episodes might not be available yet or there could be an issue with the source." : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Episodes might not be available yet or there could be an issue with the source." - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Afleveringen zijn mogelijk nog niet beschikbaar of er is een probleem met de bron." - } - } - } - }, - "Episodes Range" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Episodes Range" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Afleveringen Bereik" - } - } - } - }, - "Erase" : { - "localizations" : { - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Verwijden" - } - } - } - }, - "Erase all App Data" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Erase all App Data" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wis Alle App Data" - } - } - } - }, - "Erase App Data" : { - "localizations" : { - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Verwijder App Data" - } - } - } - }, - "Error" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Error" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Fout" - } - } - } - }, - "Error Fetching Results" : { - - }, - "Errors and critical issues." : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Errors and critical issues." - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Fouten en kritieke problemen." - } - } - } - }, - "Failed to load contributors" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Failed to load contributors" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Laden van bijdragers mislukt" - } - } - } - }, - "Fetch Episode metadata" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Fetch Episode metadata" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Haal Aflevering Metadata op" - } - } - } - }, - "Files Downloaded" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Files Downloaded" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Gedownloade Bestanden" - } - } - } - }, - "Font Size" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Font Size" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Lettergrootte" - } - } - } - }, - "Force Landscape" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Force Landscape" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Forceer Landschap" - } - } - } - }, - "General" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "General" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Algemeen" - } - } - } - }, - "General events and activities." : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "General events and activities." - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Algemene gebeurtenissen en activiteiten." - } - } - } - }, - "General Preferences" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "General Preferences" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Algemene Voorkeuren" - } - } - } - }, - "Hide Splash Screen" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Hide Splash Screen" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Splash Screen Verbergen" - } - } - } - }, - "HLS video downloading." : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "HLS video downloading." - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "HLS video downloaden." - } - } - } - }, - "Hold Speed" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Hold Speed" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Vasthouden Snelheid" - } - } - } - }, - "Info" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Info" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Info" - } - } - } - }, - "INFOS" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "INFOS" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "INFO" - } - } - } - }, - "Installed Modules" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Installed Modules" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Geïnstalleerde Modules" - } - } - } - }, - "Interface" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Interface" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Interface" - } - } - } - }, - "Join the Discord" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Join the Discord" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Word lid van de Discord" - } - } - } - }, - "Landscape Columns" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Landscape Columns" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Liggende Kolommen" - } - } - } - }, - "Language" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Language" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Taal" - } - } - } - }, - "LESS" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "LESS" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "MINDER" - } - } - } - }, - "Library" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Library" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Bibliotheek" - } - } - } - }, - "License (GPLv3.0)" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "License (GPLv3.0)" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Licentie (GPLv3.0)" - } - } - } - }, - "Light" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Light" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Licht" - } - } - } - }, - "Loading Episode %lld..." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Loading Episode %lld..." - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Aflevering %lld laden..." - } - } - } - }, - "Loading logs..." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Loading logs..." - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Logboeken laden..." - } - } - } - }, - "Loading module information..." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Loading module information..." - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Module-informatie laden..." - } - } - } - }, - "Loading Stream" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Loading Stream" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Stream Laden" - } - } - } - }, - "Log Debug Info" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Log Debug Info" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Debug Info Loggen" - } - } - } - }, - "Log Filters" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Log Filters" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Log Filters" - } - } - } - }, - "Log In with AniList" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Log In with AniList" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Inloggen met AniList" - } - } - } - }, - "Log In with Trakt" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Log In with Trakt" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Inloggen met Trakt" - } - } - } - }, - "Log Out from AniList" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Log Out from AniList" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Uitloggen van AniList" - } - } - } - }, - "Log Out from Trakt" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Log Out from Trakt" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Uitloggen van Trakt" - } - } - } - }, - "Log Types" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Log Types" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Logboek Types" - } - } - } - }, - "Logged in as" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Logged in as" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ingelogd als" - } - } - } - }, - "Logged in as " : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Logged in as " - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Ingelogd als " - } - } - } - }, - "Logs" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Logs" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Logboeken" - } - } - } - }, - "Long press Skip" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Long press Skip" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Lang Drukken Overslaan" - } - } - } - }, - "MAIN" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Main Settings" - } - } - } - }, - "Main Developer" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Main Developer" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Hoofdontwikkelaar" - } - } - } - }, - "MAIN SETTINGS" : { - - }, - "Mark All Previous Watched" : { - "extractionState" : "stale", - "localizations" : { - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Markeer alles als gezien\n" - } - } - } - }, - "Mark as Watched" : { - "localizations" : { - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Markeer als gezien" - } - } - } - }, - "Mark Episode as Watched" : { - - }, - "Mark Previous Episodes as Watched" : { - - }, - "Mark watched" : { - "localizations" : { - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Markeer als gezien" - } - } - } - }, - "Match with AniList" : { - - }, - "Match with TMDB" : { - - }, - "Matched ID: %lld" : { - - }, - "Matched with: %@" : { - "extractionState" : "stale", - "localizations" : { - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Match met: %@" - } - } - } - }, - "Max Concurrent Downloads" : { - "localizations" : { - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Maximaal gelijktijdige downloads\n" - } - } - } - }, - "me frfr" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "me frfr" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "me frfr" - } - } - } - }, - "Media Grid Layout" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Media Grid Layout" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Media Raster Layout" - } - } - } - }, - "Media Player" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Media Player" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Media Speler" - } - } - } - }, - "Media View" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Media View" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Mediaweergave" - } - } - } - }, - "Metadata Provider" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Metadata Provider" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Metadata Provider" - } - } - } - }, - "Metadata Providers Order" : { - - }, - "Module Removed" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Module Removed" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Module Verwijderd" - } - } - } - }, - "Modules" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Modules" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Modules" - } - } - } - }, - "MODULES" : { - - }, - "MORE" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "MORE" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "MEER" - } - } - } - }, - "No Active Downloads" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "No Active Downloads" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Geen Actieve Downloads" - } - } - } - }, - "No AniList matches found" : { - - }, - "No Data Available" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "No Data Available" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Geen Gegevens Beschikbaar" - } - } - } - }, - "No Downloads" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "No Downloads" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Geen Downloads" - } - } - } - }, - "No episodes available" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "No episodes available" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Geen afleveringen beschikbaar" - } - } - } - }, - "No Episodes Available" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "No Episodes Available" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Geen Afleveringen Beschikbaar" - } - } - } - }, - "No items to continue watching." : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "No items to continue watching." - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Geen items om verder te kijken." - } - } - } - }, - "No matches found" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "No matches found" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Geen overeenkomsten gevonden" - } - } - } - }, - "No Module Selected" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "No Module Selected" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Geen Module Geselecteerd" - } - } - } - }, - "No Modules" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "No Modules" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Geen Modules" - } - } - } - }, - "No Results Found" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "No Results Found" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Geen Resultaten Gevonden" - } - } - } - }, - "No Search Results Found" : { - - }, - "Note that the modules will be replaced only if there is a different version string inside the JSON file." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Note that the modules will be replaced only if there is a different version string inside the JSON file." - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Let op: de modules worden alleen vervangen als er een andere versiestring in het JSON-bestand staat." - } - } - } - }, - "Nothing to Continue Watching" : { - - }, - "OK" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "OK" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "OK" - } - } - } - }, - "Open Community Library" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Open Community Library" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Open Community Bibliotheek" - } - } - } - }, - "Open in AniList" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Open in AniList" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Openen in AniList" - } - } - } - }, - "Original Poster" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Original Poster" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Originele Poster" - } - } - } - }, - "Paused" : { - - }, - "Play" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Play" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Afspelen" - } - } - } - }, - "Player" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Player" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Speler" - } - } - } - }, - "Please restart the app to apply the language change." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Please restart the app to apply the language change." - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Herstart de app om de taalwijziging toe te passen." - } - } - } - }, - "Please select a module from settings" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Please select a module from settings" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Selecteer een module uit de instellingen" - } - } - } - }, - "Portrait Columns" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Portrait Columns" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Staande Kolommen" - } - } - } - }, - "Progress bar Marker Color" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Progress bar Marker Color" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Voortgangsbalk Markeerkleur" - } - } - } - }, - "Provider: %@" : { - - }, - "Queue" : { - - }, - "Queued" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Queued" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "In Wachtrij" - } - } - } - }, - "Recently watched content will appear here." : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Recently watched content will appear here." - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Recent bekeken inhoud verschijnt hier." - } - } - } - }, - "Refresh Modules on Launch" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Refresh Modules on Launch" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Ververs Modules bij Opstarten" - } - } - } - }, - "Refresh Storage Info" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Refresh Storage Info" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Opslaginformatie Vernieuwen" - } - } - } - }, - "Remember Playback speed" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Remember Playback speed" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Onthoud Afspeelsnelheid" - } - } - } - }, - "Remove" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Remove" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Verwijderen" - } - } - } - }, - "Remove All Cache" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Remove All Cache" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Verwijder Alle Cache" - } - } - } - }, - "Remove All Documents" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Remove All Documents" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Verwijder Alle Documenten" - } - } - } - }, - "Remove Documents" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Remove Documents" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Documenten Verwijderen" - } - } - } - }, - "Remove Downloaded Media" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Remove Downloaded Media" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Gedownloade Media Verwijderen" - } - } - } - }, - "Remove Downloads" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Remove Downloads" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Verwijder Downloads" - } - } - } - }, - "Remove from Bookmarks" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Remove from Bookmarks" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Verwijderen uit Bladwijzers" - } - } - } - }, - "Remove Item" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Remove Item" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Item Verwijderen" - } - } - } - }, - "Report an Issue" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Report an Issue" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Rapporteer een Probleem" - } - } - } - }, - "Reset" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Reset" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Resetten" - } - } - } - }, - "Reset AniList ID" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Reset AniList ID" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "AniList ID Resetten" - } - } - } - }, - "Reset Episode Progress" : { - - }, - "Reset progress" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Reset progress" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Voortgang resetten" - } - } - } - }, - "Reset Progress" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Reset Progress" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Voortgang Resetten" - } - } - } - }, - "Restart Required" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Restart Required" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Herstart Vereist" - } - } - } - }, - "Running Sora %@ - cranci1" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Running Sora %@ - cranci1" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Sora %@ draait - cranci1" - } - } - } - }, - "Save" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Save" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Opslaan" - } - } - } - }, - "Search" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Search" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Zoeken" - } - } - } - }, - "Search downloads" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Search downloads" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Downloads zoeken" - } - } - } - }, - "Search for something..." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Search for something..." - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Zoek naar iets..." - } - } - } - }, - "Search..." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Search..." - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Zoeken..." - } - } - } - }, - "Season %d" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Season %d" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Seizoen %d" - } - } - } - }, - "Season %lld" : { - - }, - "Segments Color" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Segments Color" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Segmenten Kleur" - } - } - } - }, - "Select Module" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Select Module" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Module Selecteren" - } - } - } - }, - "Set Custom AniList ID" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Set Custom AniList ID" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Aangepaste AniList ID Instellen" - } - } - } - }, - "Settings" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Settings" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Instellingen" - } - } - } - }, - "Shadow" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Shadow" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Schaduw" - } - } - } - }, - "Show More (%lld more characters)" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Show More (%lld more characters)" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Meer Tonen (%lld meer tekens)" - } - } - } - }, - "Show PiP Button" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Show PiP Button" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Toon PiP Knop" - } - } - } - }, - "Show Skip 85s Button" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Show Skip 85s Button" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Toon Overslaan 85s Knop" - } - } - } - }, - "Show Skip Intro / Outro Buttons" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Show Skip Intro / Outro Buttons" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Toon Overslaan Intro / Outro Knoppen" - } - } - } - }, - "Shows" : { - - }, - "Size (%@)" : { - - }, - "Skip Settings" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Skip Settings" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Overslaan Instellingen" - } - } - } - }, - "Some features are limited to the Sora and Default player, such as ForceLandscape, holdSpeed and custom time skip increments." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Some features are limited to the Sora and Default player, such as ForceLandscape, holdSpeed and custom time skip increments." - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Sommige functies zijn beperkt tot de Sora en Standaard speler, zoals ForceLandscape, holdSpeed en aangepaste tijd overslaan stappen." - } - } - } - }, - "Sora" : { - - }, - "Sora %@ by cranci1" : { - - }, - "Sora and cranci1 are not affiliated with AniList or Trakt in any way.\n\nAlso note that progress updates may not be 100% accurate." : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sora and cranci1 are not affiliated with AniList or Trakt in any way.\n\nAlso note that progress updates may not be 100% accurate." - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sora en cranci1 zijn op geen enkele manier verbonden met AniList of Trakt.\n\nHoud er ook rekening mee dat voortgangsupdates mogelijk niet 100% nauwkeurig zijn." - } - } - } - }, - "Sora GitHub Repository" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sora GitHub Repository" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sora GitHub Repository" - } - } - } - }, - "Sora/Sulfur will always remain free with no ADs!" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Sora/Sulfur will always remain free with no ADs!" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Sora/Sulfur blijft altijd gratis zonder advertenties!" - } - } - } - }, - "Sort" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Sort" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Sorteren" - } - } - } - }, - "Speed Settings" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Speed Settings" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Snelheidsinstellingen" - } - } - } - }, - "Start Watching" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Start Watching" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Start met Kijken" - } - } - } - }, - "Start Watching Episode %d" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Start Watching Episode %d" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Start met Kijken Aflevering %d" - } - } - } - }, - "Storage Used" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Storage Used" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Gebruikte Opslag" - } - } - } - }, - "Stream" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Stream" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Stream" - } - } - } - }, - "Streaming and video playback." : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Streaming and video playback." - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Streaming en video afspelen." - } - } - } - }, - "Subtitle Color" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Subtitle Color" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Ondertitelingskleur" - } - } - } - }, - "Subtitle Settings" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Subtitle Settings" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Ondertitelingsinstellingen" - } - } - } - }, - "Sync anime progress" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sync anime progress" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Synchroniseer anime voortgang" - } - } - } - }, - "Sync TV shows progress" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sync TV shows progress" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Synchroniseer TV series voortgang" - } - } - } - }, - "System" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "System" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Systeem" - } - } - } - }, - "Tap a title to override the current match." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Tap a title to override the current match." - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Tik op een titel om de huidige match te overschrijven." - } - } - } - }, - "Tap Skip" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Tap Skip" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Tik Overslaan" - } - } - } - }, - "Tap to manage your modules" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Tap to manage your modules" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Tik om je modules te beheren" - } - } - } - }, - "Tap to select a module" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Tap to select a module" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Tik om een module te selecteren" - } - } - } - }, - "The app cache helps the app load images faster.\n\nClearing the Documents folder will delete all downloaded modules.\n\nDo not erase App Data unless you understand the consequences — it may cause the app to malfunction." : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "The app cache helps the app load images faster.\n\nClearing the Documents folder will delete all downloaded modules.\n\nDo not erase App Data unless you understand the consequences — it may cause the app to malfunction." - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "De app cache helpt de app om afbeeldingen sneller te laden.\n\nHet wissen van de Documents map zal alle gedownloade modules verwijderen.\n\nWis de App Data niet tenzij je de gevolgen begrijpt — het kan ervoor zorgen dat de app niet meer goed werkt." - } - } - } - }, - "The episode range controls how many episodes appear on each page. Episodes are grouped into sets (like 1–25, 26–50, and so on), allowing you to navigate through them more easily.\n\nFor episode metadata, it refers to the episode thumbnail and title, since sometimes it can contain spoilers." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "The episode range controls how many episodes appear on each page. Episodes are grouped into sets (like 1–25, 26–50, and so on), allowing you to navigate through them more easily.\n\nFor episode metadata, it refers to the episode thumbnail and title, since sometimes it can contain spoilers." - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Het afleveringen bereik bepaalt hoeveel afleveringen er op elke pagina verschijnen. Afleveringen worden gegroepeerd in sets (zoals 1-25, 26-50, enzovoort), waardoor je er gemakkelijker doorheen kunt navigeren.\n\nVoor aflevering metadata verwijst dit naar de aflevering miniatuur en titel, aangezien deze soms spoilers kunnen bevatten." - } - } - } - }, - "The module provided only a single episode, this is most likely a movie, so we decided to make separate screens for these cases." : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "The module provided only a single episode, this is most likely a movie, so we decided to make separate screens for these cases." - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "De module heeft slechts één aflevering geleverd, dit is waarschijnlijk een film, daarom hebben we aparte schermen gemaakt voor deze gevallen." - } - } - } - }, - "Thumbnails Width" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Thumbnails Width" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Miniatuur Breedte" - } - } - } - }, - "TMDB Match" : { - - }, - "Trackers" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Trackers" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Trackers" - } - } - } - }, - "Trakt" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trakt" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trakt" - } - } - } - }, - "Trakt.tv" : { - - }, - "Try different keywords" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Try different keywords" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Probeer andere zoekwoorden" - } - } - } - }, - "Try different search terms" : { - - }, - "Two Finger Hold for Pause" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Two Finger Hold for Pause" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Twee Vingers Vasthouden voor Pauze" - } - } - } - }, - "Unable to fetch matches. Please try again later." : { - - }, - "Use TMDB Poster Image" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Use TMDB Poster Image" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "TMDB Poster Afbeelding Gebruiken" - } - } - } - }, - "v%@" : { - - }, - "Video Player" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Video Player" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Videospeler" - } - } - } - }, - "Video Quality Preferences" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Video Quality Preferences" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Video Kwaliteit Voorkeuren" - } - } - } - }, - "View All" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "View All" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Alles Bekijken" - } - } - } - }, - "Watched" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Watched" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Bekeken" - } - } - } - }, - "Why am I not seeing any episodes?" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Why am I not seeing any episodes?" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Waarom zie ik geen afleveringen?" - } - } - } - }, - "WiFi Quality" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "WiFi Quality" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "WiFi Kwaliteit" - } - } - } - }, - "You are not logged in" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "You are not logged in" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Je bent niet ingelogd" - } - } - } - }, - "You have no items saved." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "You have no items saved." - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Je hebt geen items opgeslagen." - } - } - } - }, - "Your downloaded episodes will appear here" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Your downloaded episodes will appear here" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Je gedownloade afleveringen verschijnen hier" - } - } - } - }, - "Your recently watched content will appear here" : { - - }, - "Download Settings" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Download Settings" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Download Instellingen" - } - } - } - }, - "Max concurrent downloads controls how many episodes can download simultaneously. Higher values may use more bandwidth and device resources." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Max concurrent downloads controls how many episodes can download simultaneously. Higher values may use more bandwidth and device resources." - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Maximum gelijktijdige downloads bepaalt hoeveel afleveringen tegelijk kunnen worden gedownload. Hogere waarden kunnen meer bandbreedte en apparaatbronnen gebruiken." - } - } - } - }, - "Quality" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Quality" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Kwaliteit" - } - } - } - }, - "Max Concurrent Downloads" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Max Concurrent Downloads" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Maximum Gelijktijdige Downloads" - } - } - } - }, - "Allow Cellular Downloads" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Allow Cellular Downloads" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Downloads via Mobiel Netwerk Toestaan" - } - } - } - }, - "Quality Information" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Quality Information" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Kwaliteitsinformatie" - } - } - } - }, - "Storage Management" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Storage Management" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Opslagbeheer" - } - } - } - }, - "Storage Used" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Storage Used" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Gebruikte Opslag" - } - } - } - }, - "Files Downloaded" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Files Downloaded" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Gedownloade Bestanden" - } - } - } - }, - "Refresh Storage Info" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Refresh Storage Info" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Opslaginformatie Vernieuwen" - } - } - } - }, - "Clear All Downloads" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Clear All Downloads" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Alle Downloads Wissen" - } - } - } - }, - "Delete All Downloads" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Delete All Downloads" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Alle Downloads Verwijderen" - } - } - } - }, - "Are you sure you want to delete all downloaded assets? You can choose to clear only the library while preserving the downloaded files for future use." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Are you sure you want to delete all downloaded assets? You can choose to clear only the library while preserving the downloaded files for future use." - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Weet je zeker dat je alle gedownloade bestanden wilt verwijderen? Je kunt ervoor kiezen om alleen de bibliotheek te wissen terwijl je de gedownloade bestanden voor later gebruik bewaart." - } - } - } - }, - "Clear Library Only" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Clear Library Only" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Alleen Bibliotheek Wissen" - } - } - } - }, - "Library cleared successfully" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Library cleared successfully" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Bibliotheek succesvol gewist" - } - } - } - }, - "All downloads deleted successfully" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "All downloads deleted successfully" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Alle downloads succesvol verwijderd" - } - } - } - }, - "Downloads" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Downloads" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Downloads" - } - } - } - } - }, - "version" : "1.0" -} \ No newline at end of file diff --git a/Sora/Localization/en.lproj/Localizable.strings b/Sora/Localization/en.lproj/Localizable.strings index 4f55cbb..303ef28 100644 --- a/Sora/Localization/en.lproj/Localizable.strings +++ b/Sora/Localization/en.lproj/Localizable.strings @@ -339,7 +339,6 @@ "The app cache helps the app load images faster.\n\nClearing the Documents folder will delete all downloaded modules.\n\nDo not erase App Data unless you understand the consequences — it may cause the app to malfunction." = "The app cache helps the app load images faster.\n\nClearing the Documents folder will delete all downloaded modules.\n\nDo not erase App Data unless you understand the consequences — it may cause the app to malfunction."; "The episode range controls how many episodes appear on each page. Episodes are grouped into sets (like 1–25, 26–50, and so on), allowing you to navigate through them more easily.\n\nFor episode metadata, it refers to the episode thumbnail and title, since sometimes it can contain spoilers." = "The episode range controls how many episodes appear on each page. Episodes are grouped into sets (like 1–25, 26–50, and so on), allowing you to navigate through them more easily.\n\nFor episode metadata, it refers to the episode thumbnail and title, since sometimes it can contain spoilers."; "The module provided only a single episode, this is most likely a movie, so we decided to make separate screens for these cases." = "The module provided only a single episode, this is most likely a movie, so we decided to make separate screens for these cases."; - /* Interface */ "Thumbnails Width" = "Thumbnails Width"; "TMDB Match" = "TMDB Match"; @@ -398,3 +397,4 @@ "Recent searches" = "Recent searches"; "me frfr" = "me frfr"; "Data" = "Data"; + diff --git a/Sora/Localization/it.lproj/Localizable.strings b/Sora/Localization/it.lproj/Localizable.strings new file mode 100644 index 0000000..df2e8d0 --- /dev/null +++ b/Sora/Localization/it.lproj/Localizable.strings @@ -0,0 +1,330 @@ +/* General */ +"About" = "informazioni"; +"About Sora" = "Informazioni su Sora"; +"Active" = "Attivi"; +"Active Downloads" = "Download Attivi"; +"Actively downloading media can be tracked from here." = "I Download Attivi si possono trovare qui"; +"Add Module" = "Aggiungi Modulo"; +"Adjust the number of media items per row in portrait and landscape modes." = "Imposta il numero dei media per riga in vista orizzontale e in vista verticale"; +"Advanced" = "Avanzate"; +"AKA Sulfur" = "Aka Sulfur"; +"All Bookmarks" = "Tutti i Preferiti"; +"All Watching" = "Tutti i Guardati"; +"Also known as Sulfur" = "Conosciuta anche come Sulfur"; +"AniList" = "AniList"; +"AniList ID" = "AniList ID"; +"AniList Match" = "AniList Match"; +"AniList.co" = "AniList.co"; +"Anonymous data is collected to improve the app. No personal information is collected. This can be disabled at any time." = "Vengono raccolti dati anonimi per il miglioramento dell'App, Nessun Dato Personale viene Registrato, Questa opzione può essere disabilitata in ogni momento"; +"App Info" = "Informazioni App"; +"App Language" = "Lingua App"; +"App Storage" = "Memoria Utilizzata dall'app"; +"Appearance" = "Aspetto"; +/* Alerts and Actions */ +"Are you sure you want to clear all cached data? This will help free up storage space." = "Sei sicuro di voler rimuovere tutti i dati presenti nella cache? Questo aiuterà a liberare spazio"; +"Are you sure you want to delete '%@'?" = "Sei sicuro di volere rimuovere '%@'?"; +"Are you sure you want to delete all %1$d episodes in '%2$@'?" = "Sei sicuro di voler eliminare tutti %1$d episodi in '%2$@'?"; +"Are you sure you want to delete all downloaded assets? You can choose to clear only the library while preserving the downloaded files for future use." = "Siete sicuri di voler eliminare tutte le risorse scaricate? È possibile scegliere di cancellare solo la libreria, conservando i file scaricati per un uso futuro."; +"Are you sure you want to erase all app data? This action cannot be undone." = "Sei sicuro di voler eliminare tutti i dati dell'app, questa azione non può essere cancellata"; +/* Features */ +"Background Enabled" = "Background Abilitato"; +"Bookmark items for an easier access later." = "Imposta Preferiti per un accesso Più veloce dopo"; +"Bookmarks" = "Preferiti"; +"Bottom Padding" = "Distanza Sottotitoli"; +"Cancel" = "Cancella"; +"Cellular Quality" = "Qualità Dati Cellulare"; +"Check out some community modules here!" = "Cerca i moduli della community qui!"; +"Choose preferred video resolution for WiFi and cellular connections. Higher resolutions use more data but provide better quality. If the exact quality isn't available, the closest option will be selected automatically.\n\nNote: Not all video sources and players support quality selection. This feature works best with HLS streams using the Sora player." = "Seleziona la Risoluzione video preferita per WiFi e Dati Mobili. Una Risoluzione più alta consuma più dati ma fornisce una qualità migliore. Se la stessa qualità non é disponibile la soluzione più vicina verrà scelta automaticamente \n\nNota:Non tutte le fonti e player supportano la scelta della qualità video.Questa Feature funziona meglio usando il Player integrato di Sora"; +"Clear" = "Pulisci"; +"Clear All Downloads" = "Pulisci Tutti i Download"; +"Clear Cache" = "Pulisci Cache"; +"Clear Library Only" = "Pulisci Solo La Libreria"; +"Clear Logs" = "Pulisci Log"; +"Click the plus button to add a module!" = "Premi il più per aggiungere un modulo"; +"Continue Watching" = "Continua a Guardare"; +"Continue Watching Episode %d" = "Continua a guardare %d"; +"Contributors" = "Collaboratori"; +"Copied to Clipboard" = "Copiato Negli Appunti"; +"Copy to Clipboard" = "Copia negli Appunti"; +"Copy URL" = "Copia URL"; +/* Episodes */ +"%lld Episodes" = "%lld Episodi"; +"%lld of %lld" = "%lld di %lld"; +"%lld-%lld" = "%lld-%lld"; +"%lld%% seen" = "%lld%% visti"; +"Episode %lld" = "Episodio %lld"; +"Episodes" = "Episodi"; +"Episodes might not be available yet or there could be an issue with the source." = "Gli episodi potrebbero non essere ancora disponibili o potrebbe esserci un errore con la fonte"; +"Episodes Range" = "Range Episodi"; +/* System */ +"cranci1" = "cranc1"; +"Dark" = "Dark"; +"DATA & LOGS" = "DATA & LOGS"; +"Debug" = "Debug"; +"Debugging and troubleshooting." = "Debugging e Risoluzione Problemi"; +/* Actions */ +"Delete" = "Elimina"; +"Delete All" = "Elimina Tutto"; +"Delete All Downloads" = "Elimina Tutti I Download"; +"Delete All Episodes" = "Elimina Tutti Gli Episodi"; +"Delete Download" = "Elimina Download"; +"Delete Episode" = "Elimina Episodio"; +/* Player */ +"Double Tap to Seek" = "Doppio Tap Per (Seek)"; +"Double tapping the screen on it's sides will skip with the short tap setting." = "Se l'opzione per saltare é abilitata, con questo premendo nei lati due volte é possibile skippare"; +/* Downloads */ +"Download" = "Scarica"; +"Download Episode" = "Scarica Episodio"; +"Download Summary" = "Scarica Riassunto"; +"Download This Episode" = "Scarica questo Episodio"; +"Downloaded" = "Scaricato"; +"Downloaded Shows" = "Serie Scaricate"; +"Downloading" = "Download in Corso"; +"Downloads" = "Scaricati"; +/* Settings */ +"Enable Analytics" = "Attiva Analytics"; +"Enable Subtitles" = "Attiva Sottotitoli"; +/* Data Management */ +"Erase" = "Cancella"; +"Erase all App Data" = "Cancella Tutti i Dati dell'App"; +"Erase App Data" = "Cancella Dati App"; +/* Errors */ +"Error" = "Errore"; +"Error Fetching Results" = "Errore nella ricerca dei risultati"; +"Errors and critical issues." = "Errori e Problemi Critici"; +"Failed to load contributors" = "impossibile caricare i collaboratori"; +/* Features */ +"Fetch Episode metadata" = "Cercando i Metadata"; +"Files Downloaded" = "File Scaricati"; +"Font Size" = "Dimensione Carattere"; +/* Interface */ +"Force Landscape" = "Forza Vista Orizzontale"; +"General" = "Generali"; +"General events and activities." = "Eventi e Attività Generali"; +"General Preferences" = "Preferenze Generali"; +"Hide Splash Screen" = "Nascondi Splash Screen"; +"HLS video downloading." = "Scaricamento Video HLS"; +"Hold Speed" = "Mantieni per Velocizzare"; +/* Info */ +"Info" = "Info"; +"INFOS" = "INFORMAZIONI"; +"Installed Modules" = "Moduli Installati"; +"Interface" = "Interfaccia"; +/* Social */ +"Join the Discord" = "Entra Nel Nostro Discord!"; +/* Layout */ +"Landscape Columns" = "Colonne in Vista Orizzontale"; +"Language" = "Lingua"; +"LESS" = "Mostra Meno"; +/* Library */ +"Library" = "Libreria"; +"License (GPLv3.0)" = "Licenza (GPLv3.0)"; +"Light" = "Tema Chiaro"; +/* Loading States */ +"Loading Episode %lld..." = "Caricando Gli Episodi %lld..."; +"Loading logs..." = "Caricando i Logs"; +"Loading module information..." = "Caricando le Informazioni del modulo"; +"Loading Stream" = "Caricando lo Stream"; +/* Logging */ +"Log Debug Info" = "Info Log Debug"; +"Log Filters" = "Filtri Log"; +"Log In with AniList" = "Accedi a AniList"; +"Log In with Trakt" = "Accedi a Trakt"; +"Log Out from AniList" = "Esci da AniList"; +"Log Out from Trakt" = "Esci da Trakt"; +"Log Types" = "Tipo Log"; +"Logged in as" = "Accesso Effettuato come"; +"Logged in as " = "Accesso Effettuato come"; +/* Logs and Settings */ +"Logs" = "Logs"; +"Long press Skip" = "Tener premuto per Skippare"; +"MAIN" = "PRINCIPALE"; +"Main Developer" = "Sviluppatore Principale"; +"MAIN SETTINGS" = "Impostazioni principali"; +/* Media Actions */ +"Mark All Previous Watched" = "Segna Tutti I Precedenti Come visti"; +"Mark as Watched" = "Segna Come Visto"; +"Mark Episode as Watched" = "Segna Episodio come Visto"; +"Mark Previous Episodes as Watched" = "Segna episodi precedenti come visti"; +"Mark watched" = "Segna Come Completato"; +"Match with AniList" = "Match with AniList"; +"Match with TMDB" = "Match with TMDB"; +"Matched ID: %lld" = "Matched ID: %lld"; +"Matched with: %@" = "Matched with:%@"; +"Max Concurrent Downloads" = "Download massimi in Contemporanea"; +/* Media Interface */ +"Media Grid Layout" = "Layout della Griglia dei Media"; +"Media Player" = "Media Player"; +"Media View" = "Vista media"; +"Metadata Provider" = "Provider Metadata"; +"Metadata Providers Order" = "Ordine dei Provider Metadata"; +"Module Removed" = "Modulo Rimosso"; +"Modules" = "Moduli"; +/* Headers */ +"MODULES" = "MODULI"; +"MORE" = "PIÙ"; +/* Status Messages */ +"No Active Downloads" = "Nessun Download Attivo"; +"No AniList matches found" = "Nessun"; +"No Data Available" = "Nessun Dato Disponibile"; +"No Downloads" = "Nessun Download"; +"No episodes available" = "Nessun Episodio Disponibile"; +"No Episodes Available" = "Nessun Episodio Disponibile"; +"No items to continue watching." = "Nessun Contenuto da continuare a guardare"; +"No matches found" = "Nessun Elemento Trovato"; +"No Module Selected" = "Nessun Modulo Selezionato"; +"No Modules" = "Nessun Modulo"; +"No Results Found" = "Nessun Risultato Trovato"; +"No Search Results Found" = "Nessun risultato di ricerca trovato"; +"Nothing to Continue Watching" = "Nulla da Continuare a Guardare"; +/* Notes and Messages */ +"Note that the modules will be replaced only if there is a different version string inside the JSON file." = "Si noti che i moduli saranno sostituiti solo se c'è una stringa di versione diversa all'interno del file JSON"; +/* Actions */ +"OK" = "OK"; +"Open Community Library" = "Apri Libreria Community"; +/* External Services */ +"Open in AniList" = "Apri in AniList"; +"Original Poster" = "Poster Originale"; +/* Playback */ +"Paused" = "Pausa"; +"Play" = "Play"; +"Player" = "Player"; +/* System Messages */ +"Please restart the app to apply the language change." = "Riavvia l'app per effettuare i cambiamenti"; +"Please select a module from settings" = "Seleziona un Modulo dalle impostazioni"; +/* Interface */ +"Portrait Columns" = "Colonne in Vista Verticale"; +"Progress bar Marker Color" = "Colore Barra di Progresso"; +"Provider: %@" = "Provider: %@"; +/* Queue */ +"Queue" = "Coda"; +"Queued" = "In Coda"; +/* Content */ +"Recently watched content will appear here." = "I Contenuti Precedentemente Visti Appariranno qui"; +/* Settings */ +"Refresh Modules on Launch" = "Aggiorna i Moduli al Lancio"; +"Refresh Storage Info" = "Aggiorna informazioni storage"; +"Remember Playback speed" = "Ricorda Velocità Playback"; +/* Actions */ +"Remove" = "Rimuovi"; +"Remove All Cache" = "Pulisci Cache"; +/* File Management */ +"Remove All Documents" = "Rimuovi Tutti i Documenti"; +"Remove Documents" = "Rimuovi Documenti"; +"Remove Downloaded Media" = "Rimuovi Media Scaricati"; +"Remove Downloads" = "Rimuovi Scaricati"; +"Remove from Bookmarks" = "Rimuovi dai Preferiti"; +"Remove Item" = "Rimuovi Elemento"; +/* Support */ +"Report an Issue" = "Segnala un Problema"; +/* Reset Options */ +"Reset" = "Reimposta"; +"Reset AniList ID" = "Reimposta AniList ID"; +"Reset Episode Progress" = "Reimposta"; +"Reset progress" = "Reimposta Progressi"; +"Reset Progress" = "Reimposta Progressi"; +/* System */ +"Restart Required" = "Riavvio Richiesto"; +"Running Sora %@ - cranci1" = "Running Sora %@ - cranc1"; +/* Actions */ +"Save" = "Salva"; +"Search" = "Cerca"; +/* Search */ +"Search downloads" = "Cerca Download"; +"Search for something..." = "Cerca Qualcosa..."; +"Search..." = "Cerca..."; +/* Content */ +"Season %d" = "Stagione %d"; +"Season %lld" = "Stagione %lld"; +"Segments Color" = "Colore Segmenti"; +/* Modules */ +"Select Module" = "Seleziona Modulo"; +"Set Custom AniList ID" = "Imposta Custom AniList ID"; +/* Interface */ +"Settings" = "Impostazioni"; +"Shadow" = "Ombra"; +"Show More (%lld more characters)" = "Mostra Di Più (%lld più Caratteri)"; +"Show PiP Button" = "Mostra Bottone PiP"; +"Show Skip 85s Button" = "Mostra Skip 85s"; +"Show Skip Intro / Outro Buttons" = "Mostra Bottoni Skip Intro/Outro"; +"Shows" = "Serie"; +"Size (%@)" = "Dimensione (%@)"; +"Skip Settings" = "Salta Impostazioni"; +/* Player Features */ +"Some features are limited to the Sora and Default player, such as ForceLandscape, holdSpeed and custom time skip increments." = "Qualche Features sono limitate a Sora e al Default Player, Come Forza Visualizzazione Orizzontale, Mantieni per Velocizzare e Skip di Tempo Custom"; +/* App Info */ +"Sora" = "Sora"; +"Sora %@ by cranci1" = "Sora %@ by cranci1"; +"Sora and cranci1 are not affiliated with AniList or Trakt in any way. Also note that progress updates may not be 100% accurate." = "Sora e Cranc1 non sono affiliati in nessun modo con AniList o Trakt."; +"Sora GitHub Repository" = "Repository GitHub di Sora"; +"Sora/Sulfur will always remain free with no ADs!" = "Sora/Sulfur rimarrà sempre gratis e senza pubblicità"; +/* Interface */ +"Sort" = "Filtra"; +"Speed Settings" = "Impostazioni velocità"; +/* Playback */ +"Start Watching" = "Inizia a Guardare"; +"Start Watching Episode %d" = "Guarda Episodio %d"; +"Storage Used" = "Memoria Usata"; +"Stream" = "Stream"; +"Streaming and video playback." = "Streaming e VideoPlayBack"; +/* Subtitles */ +"Subtitle Color" = "Colore Sottotitoli"; +"Subtitle Settings" = "Impostazioni Sottotitoli"; +/* Sync */ +"Sync anime progress" = "Sincronizza Progressi Anime"; +"Sync TV shows progress" = "Sincronizza Progressi Serie Tv"; +/* System */ +"System" = "Sistema"; +/* Instructions */ +"Tap a title to override the current match." = "Premi per Sovrascrivere il"; +"Tap Skip" = "Premi per Saltare"; +"Tap to manage your modules" = "Premi Per Gestire i Tuoi Moduli"; +"Tap to select a module" = "Premi per Selezionare un Modulo"; +/* App Information */ +"The app cache helps the app load images faster. Clearing the Documents folder will delete all downloaded modules. Do not erase App Data unless you understand the consequences — it may cause the app to malfunction." = "La Cache dell'App serve a caricare le immagini più velocemente, pulire la cartella dei documenti eliminerà tutti i moduli scaricati. Non cancellare i Dati dell'App senza aver capito le conseguenze- potrebbe causare malfunzionamenti all'app"; +"The episode range controls how many episodes appear on each page. Episodes are grouped into sets (like 1–25, 26–50, and so on), allowing you to navigate through them more easily. For episode metadata, it refers to the episode thumbnail and title, since sometimes it can contain spoilers." = "Il range degli episodi controlla quanti episodi appaiono in ogni pagina.Gli episodi sono raggruppati in set (tipo 1-25,26-50 e così via), permettendo di accedere ad essi più facilmente"; +"The module provided only a single episode, this is most likely a movie, so we decided to make separate screens for these cases." = ""; +/* Interface */ +"Thumbnails Width" = "Grandezza Miniature"; +"TMDB Match" = "TMDB Match"; +"Trackers" = "Trackers"; +"Trakt" = "Trakt"; +"Trakt.tv" = "Trakt.tv"; +/* Search */ +"Try different keywords" = "Prova diverse Parole Chiave"; +"Try different search terms" = "Prova diversi Termini di Ricerca"; +/* Player Controls */ +"Two Finger Hold for Pause" = "Mantieni con Due Dita Per Mettere in Pausa"; +"Unable to fetch matches. Please try again later." = "Impossibile trovare i match. Riprovare più tardi."; +"Use TMDB Poster Image" = "Usa i poster di IMDB"; +/* Version */ +"v%@" = "v%@"; +"Video Player" = "Video Player"; +/* Video Settings */ +"Video Quality Preferences" = "Preferenze qualità video"; +"View All" = "Vedi Tutti"; +"Watched" = "Guardati"; +"Why am I not seeing any episodes?" = "Perché non sto vedendo nessun episodio?"; +"WiFi Quality" = "Qualità WiFi"; +/* User Status */ +"You are not logged in" = "Non sei Loggato"; +"You have no items saved." = "Non hai Elementi Salvati"; +"Your downloaded episodes will appear here" = "I Tuoi Download appariranno Qui"; +"Your recently watched content will appear here" = "I Contenuti Guardati Recentemente Appariranno Qui"; +/* Download Settings */ +"Download Settings" = "Impostazioni Download"; +"Max concurrent downloads controls how many episodes can download simultaneously. Higher values may use more bandwidth and device resources." = "Il valore massimo di download contemporanei controlla il numero di episodi che possono essere scaricati simultaneamente. Valori più alti possono utilizzare più larghezza di banda e risorse del dispositivo."; +"Quality" = "Qualità"; +"Max Concurrent Downloads" = "Download Massimi in Contemporanea"; +"Allow Cellular Downloads" = "Permetti il download con Connessione Dati"; +"Quality Information" = "Informazioni Qualità"; +/* Storage */ +"Storage Management" = "Gestione Memoria"; +"Storage Used" = "Memoria Usata"; +"Library cleared successfully" = "Libreria Pulita con Successo"; +"All downloads deleted successfully" = "Tutti i Download Sono Stati Eliminati Correttamente"; +/* New additions */ +"Recent searches" = "Ricerche Recenti"; +"me frfr" = "me frfr"; +"Data" = "Dati"; +"Maximum Quality Available" = "Qualità Massima Disponibile"; diff --git a/Sora/Utils/JSLoader/JSController-Details.swift b/Sora/Utils/JSLoader/JSController-Details.swift index 51c0b8d..9c79f0f 100644 --- a/Sora/Utils/JSLoader/JSController-Details.swift +++ b/Sora/Utils/JSLoader/JSController-Details.swift @@ -65,24 +65,25 @@ extension JSController { func fetchDetailsJS(url: String, completion: @escaping ([MediaItem], [EpisodeLink]) -> Void) { guard let url = URL(string: url) else { + Logger.shared.log("Invalid URL in fetchDetailsJS: \(url)", type: "Error") completion([], []) return } if let exception = context.exception { - Logger.shared.log("JavaScript exception: \(exception)",type: "Error") + Logger.shared.log("JavaScript exception: \(exception)", type: "Error") completion([], []) return } guard let extractDetailsFunction = context.objectForKeyedSubscript("extractDetails") else { - Logger.shared.log("No JavaScript function extractDetails found",type: "Error") + Logger.shared.log("No JavaScript function extractDetails found", type: "Error") completion([], []) return } guard let extractEpisodesFunction = context.objectForKeyedSubscript("extractEpisodes") else { - Logger.shared.log("No JavaScript function extractEpisodes found",type: "Error") + Logger.shared.log("No JavaScript function extractEpisodes found", type: "Error") completion([], []) return } @@ -95,13 +96,13 @@ extension JSController { dispatchGroup.enter() let promiseValueDetails = extractDetailsFunction.call(withArguments: [url.absoluteString]) guard let promiseDetails = promiseValueDetails else { - Logger.shared.log("extractDetails did not return a Promise",type: "Error") + Logger.shared.log("extractDetails did not return a Promise", type: "Error") + dispatchGroup.leave() completion([], []) return } let thenBlockDetails: @convention(block) (JSValue) -> Void = { result in - Logger.shared.log(result.toString(),type: "Debug") if let jsonOfDetails = result.toString(), let dataDetails = jsonOfDetails.data(using: .utf8) { do { @@ -114,19 +115,19 @@ extension JSController { ) } } else { - Logger.shared.log("Failed to parse JSON of extractDetails",type: "Error") + Logger.shared.log("Failed to parse JSON of extractDetails", type: "Error") } } catch { - Logger.shared.log("JSON parsing error of extract details: \(error)",type: "Error") + Logger.shared.log("JSON parsing error of extract details: \(error)", type: "Error") } } else { - Logger.shared.log("Result is not a string of extractDetails",type: "Error") + Logger.shared.log("Result is not a string of extractDetails", type: "Error") } dispatchGroup.leave() } let catchBlockDetails: @convention(block) (JSValue) -> Void = { error in - Logger.shared.log("Promise rejected of extractDetails: \(String(describing: error.toString()))",type: "Error") + Logger.shared.log("Promise rejected of extractDetails: \(String(describing: error.toString()))", type: "Error") dispatchGroup.leave() } @@ -138,14 +139,23 @@ extension JSController { dispatchGroup.enter() let promiseValueEpisodes = extractEpisodesFunction.call(withArguments: [url.absoluteString]) + + let timeoutWorkItem = DispatchWorkItem { + Logger.shared.log("Timeout for extractEpisodes", type: "Warning") + dispatchGroup.leave() + } + DispatchQueue.main.asyncAfter(deadline: .now() + 5.0, execute: timeoutWorkItem) + guard let promiseEpisodes = promiseValueEpisodes else { - Logger.shared.log("extractEpisodes did not return a Promise",type: "Error") + Logger.shared.log("extractEpisodes did not return a Promise", type: "Error") + timeoutWorkItem.cancel() + dispatchGroup.leave() completion([], []) return } let thenBlockEpisodes: @convention(block) (JSValue) -> Void = { result in - Logger.shared.log(result.toString(),type: "Debug") + timeoutWorkItem.cancel() if let jsonOfEpisodes = result.toString(), let dataEpisodes = jsonOfEpisodes.data(using: .utf8) { do { @@ -159,19 +169,20 @@ extension JSController { ) } } else { - Logger.shared.log("Failed to parse JSON of extractEpisodes",type: "Error") + Logger.shared.log("Failed to parse JSON of extractEpisodes", type: "Error") } } catch { - Logger.shared.log("JSON parsing error of extractEpisodes: \(error)",type: "Error") + Logger.shared.log("JSON parsing error of extractEpisodes: \(error)", type: "Error") } } else { - Logger.shared.log("Result is not a string of extractEpisodes",type: "Error") + Logger.shared.log("Result is not a string of extractEpisodes", type: "Error") } dispatchGroup.leave() } let catchBlockEpisodes: @convention(block) (JSValue) -> Void = { error in - Logger.shared.log("Promise rejected of extractEpisodes: \(String(describing: error.toString()))",type: "Error") + timeoutWorkItem.cancel() + Logger.shared.log("Promise rejected of extractEpisodes: \(String(describing: error.toString()))", type: "Error") dispatchGroup.leave() } diff --git a/Sora/Utils/JSLoader/JSController-Novel.swift b/Sora/Utils/JSLoader/JSController-Novel.swift new file mode 100644 index 0000000..6b01c11 --- /dev/null +++ b/Sora/Utils/JSLoader/JSController-Novel.swift @@ -0,0 +1,187 @@ +// +// JSController-Novel.swift +// Sora +// +// Created by paul on 20/06/25. +// + +import Foundation +import JavaScriptCore + +enum JSError: Error { + case moduleNotFound + case invalidResponse + case emptyContent + case redirectError + case jsException(String) + + var localizedDescription: String { + switch self { + case .moduleNotFound: + return "Module not found" + case .invalidResponse: + return "Invalid response from server" + case .emptyContent: + return "No content received" + case .redirectError: + return "Redirect error occurred" + case .jsException(let message): + return "JavaScript error: \(message)" + } + } +} + +extension JSController { + @MainActor + func extractChapters(moduleId: String, href: String) async throws -> [[String: Any]] { + guard let module = ModuleManager().modules.first(where: { $0.id.uuidString == moduleId }) else { + throw JSError.moduleNotFound + } + + return await withCheckedContinuation { (continuation: CheckedContinuation<[[String: Any]], Never>) in + DispatchQueue.main.async { [weak self] in + guard let self = self else { + continuation.resume(returning: []) + return + } + guard let extractChaptersFunction = self.context.objectForKeyedSubscript("extractChapters") else { + Logger.shared.log("extractChapters: function not found", type: "Error") + continuation.resume(returning: []) + return + } + let result = extractChaptersFunction.call(withArguments: [href]) + if result?.isUndefined == true || result == nil { + Logger.shared.log("extractChapters: result is undefined or nil", type: "Error") + continuation.resume(returning: []) + return + } + if let result = result, result.hasProperty("then") { + let group = DispatchGroup() + group.enter() + var chaptersArr: [[String: Any]] = [] + let thenBlock: @convention(block) (JSValue) -> Void = { jsValue in + Logger.shared.log("extractChapters thenBlock: \(jsValue)", type: "Debug") + if let arr = jsValue.toArray() as? [[String: Any]] { + Logger.shared.log("extractChapters: parsed as array, count = \(arr.count)", type: "Debug") + chaptersArr = arr + } else if let jsonString = jsValue.toString(), let data = jsonString.data(using: .utf8) { + do { + if let arr = try JSONSerialization.jsonObject(with: data) as? [[String: Any]] { + Logger.shared.log("extractChapters: parsed as JSON string, count = \(arr.count)", type: "Debug") + chaptersArr = arr + } else { + Logger.shared.log("extractChapters: JSON string did not parse to array", type: "Error") + } + } catch { + Logger.shared.log("JSON parsing error of extractChapters: \(error)", type: "Error") + } + } else { + Logger.shared.log("extractChapters: could not parse result", type: "Error") + } + group.leave() + } + let catchBlock: @convention(block) (JSValue) -> Void = { jsValue in + Logger.shared.log("extractChapters catchBlock: \(jsValue)", type: "Error") + group.leave() + } + result.invokeMethod("then", withArguments: [thenBlock]) + result.invokeMethod("catch", withArguments: [catchBlock]) + group.notify(queue: .main) { + continuation.resume(returning: chaptersArr) + } + } else { + if let arr = result?.toArray() as? [[String: Any]] { + Logger.shared.log("extractChapters: direct array, count = \(arr.count)", type: "Debug") + continuation.resume(returning: arr) + } else if let jsonString = result?.toString(), let data = jsonString.data(using: .utf8) { + do { + if let arr = try JSONSerialization.jsonObject(with: data) as? [[String: Any]] { + Logger.shared.log("extractChapters: direct JSON string, count = \(arr.count)", type: "Debug") + continuation.resume(returning: arr) + } else { + Logger.shared.log("extractChapters: direct JSON string did not parse to array", type: "Error") + continuation.resume(returning: []) + } + } catch { + Logger.shared.log("JSON parsing error of extractChapters: \(error)", type: "Error") + continuation.resume(returning: []) + } + } else { + Logger.shared.log("extractChapters: could not parse direct result", type: "Error") + continuation.resume(returning: []) + } + } + } + } + } + + @MainActor + func extractText(moduleId: String, href: String) async throws -> String { + guard let module = ModuleManager().modules.first(where: { $0.id.uuidString == moduleId }) else { + throw JSError.moduleNotFound + } + + return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + DispatchQueue.main.async { [weak self] in + guard let self = self else { + continuation.resume(throwing: JSError.invalidResponse) + return + } + + let function = self.context.objectForKeyedSubscript("extractText") + let result = function?.call(withArguments: [href]) + + if let exception = self.context.exception { + Logger.shared.log("Error extracting text: \(exception)", type: "Error") + } + + if let result = result, result.hasProperty("then") { + let group = DispatchGroup() + group.enter() + var extractedText = "" + var extractError: Error? = nil + + let thenBlock: @convention(block) (JSValue) -> Void = { jsValue in + Logger.shared.log("extractText thenBlock: received value", type: "Debug") + if let text = jsValue.toString(), !text.isEmpty { + Logger.shared.log("extractText: successfully extracted text", type: "Debug") + extractedText = text + } else { + extractError = JSError.emptyContent + } + group.leave() + } + + let catchBlock: @convention(block) (JSValue) -> Void = { jsValue in + Logger.shared.log("extractText catchBlock: \(jsValue)", type: "Error") + if extractedText.isEmpty { + extractError = JSError.jsException(jsValue.toString() ?? "Unknown error") + } + group.leave() + } + + result.invokeMethod("then", withArguments: [thenBlock]) + result.invokeMethod("catch", withArguments: [catchBlock]) + + group.notify(queue: .main) { + if !extractedText.isEmpty { + continuation.resume(returning: extractedText) + } else if let error = extractError { + continuation.resume(throwing: error) + } else { + continuation.resume(throwing: JSError.emptyContent) + } + } + } else { + if let text = result?.toString(), !text.isEmpty { + Logger.shared.log("extractText: direct string result", type: "Debug") + continuation.resume(returning: text) + } else { + Logger.shared.log("extractText: could not parse direct result", type: "Error") + continuation.resume(throwing: JSError.emptyContent) + } + } + } + } + } +} \ No newline at end of file diff --git a/Sora/Utils/JSLoader/JSController.swift b/Sora/Utils/JSLoader/JSController.swift index 163eae9..4cc7f60 100644 --- a/Sora/Utils/JSLoader/JSController.swift +++ b/Sora/Utils/JSLoader/JSController.swift @@ -42,6 +42,35 @@ class JSController: NSObject, ObservableObject { func setupContext() { context.setupJavaScriptEnvironment() + // Inject async Promise bridge for extractChapters with debug logging + let asyncChaptersHelper = """ + function extractChaptersWithCallback(href, callback) { + try { + console.log('[JS] extractChaptersWithCallback called with href:', href); + var result = extractChapters(href); + if (result && typeof result.then === 'function') { + result.then(function(arr) { + console.log('[JS] extractChaptersWithCallback Promise resolved, arr.length:', arr && arr.length); + callback(arr); + }).catch(function(e) { + console.log('[JS] extractChaptersWithCallback Promise rejected:', e); + callback([]); + }); + } else { + console.log('[JS] extractChaptersWithCallback result is not a Promise:', result); + callback(result); + } + } catch (e) { + console.log('[JS] extractChaptersWithCallback threw:', e); + callback([]); + } + } + """ + context.evaluateScript(asyncChaptersHelper) + // Print JS exceptions to Xcode console + context.exceptionHandler = { context, exception in + print("[JS Exception]", exception?.toString() ?? "unknown") + } setupDownloadSession() } diff --git a/Sora/Utils/Modules/Modules.swift b/Sora/Utils/Modules/Modules.swift index 7debdbb..f52f0e2 100644 --- a/Sora/Utils/Modules/Modules.swift +++ b/Sora/Utils/Modules/Modules.swift @@ -24,6 +24,7 @@ struct ModuleMetadata: Codable, Hashable { let multiStream: Bool? let multiSubs: Bool? let type: String? + let novel: Bool? struct Author: Codable, Hashable { let name: String diff --git a/Sora/Utils/TabBar/TabBar.swift b/Sora/Utils/TabBar/TabBar.swift index 148f1c5..8b93d24 100644 --- a/Sora/Utils/TabBar/TabBar.swift +++ b/Sora/Utils/TabBar/TabBar.swift @@ -90,7 +90,7 @@ struct TabBar: View { .stroke( LinearGradient( gradient: Gradient(stops: [ - .init(color: Color.accentColor.opacity(gradientOpacity), location: 0), + .init(color: Color.accentColor.opacity(0.25), location: 0), .init(color: Color.accentColor.opacity(0), location: 1) ]), startPoint: .top, @@ -162,7 +162,7 @@ struct TabBar: View { .strokeBorder( LinearGradient( gradient: Gradient(stops: [ - .init(color: Color.accentColor.opacity(gradientOpacity), location: 0), + .init(color: Color.accentColor.opacity(0.25), location: 0), .init(color: Color.accentColor.opacity(0), location: 1) ]), startPoint: .top, diff --git a/Sora/Views/DownloadView.swift b/Sora/Views/DownloadView.swift index c997f4f..5e36154 100644 --- a/Sora/Views/DownloadView.swift +++ b/Sora/Views/DownloadView.swift @@ -11,6 +11,7 @@ import SwiftUI struct DownloadView: View { @EnvironmentObject var jsController: JSController + @EnvironmentObject var tabBarController: TabBarController @State private var searchText = "" @State private var selectedTab = 0 @State private var sortOption: SortOption = .newest @@ -70,6 +71,9 @@ struct DownloadView: View { Text(String(format: NSLocalizedString("Are you sure you want to delete '%@'?", comment: ""), asset.episodeDisplayName)) } } + .onAppear { + tabBarController.showTabBar() + } } .deviceScaled() .navigationViewStyle(StackNavigationViewStyle()) @@ -232,7 +236,8 @@ struct DownloadView: View { softsub: nil, multiStream: nil, multiSubs: nil, - type: nil + type: nil, + novel: false ) let dummyModule = ScrapingModule( @@ -241,7 +246,6 @@ struct DownloadView: View { metadataUrl: "" ) - // Always use CustomMediaPlayerViewController for consistency let customPlayer = CustomMediaPlayerViewController( module: dummyModule, urlString: asset.localURL.absoluteString, @@ -1026,7 +1030,6 @@ struct EnhancedShowEpisodesView: View { } .onAppear { tabBarController.hideTabBar() - // Enable swipe-to-go-back gesture if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let window = windowScene.windows.first, let navigationController = window.rootViewController?.children.first as? UINavigationController { diff --git a/Sora/Views/LibraryView/BookmarkComponents/BookmarkGridItemView.swift b/Sora/Views/LibraryView/BookmarkComponents/BookmarkGridItemView.swift index e833918..3c49eba 100644 --- a/Sora/Views/LibraryView/BookmarkComponents/BookmarkGridItemView.swift +++ b/Sora/Views/LibraryView/BookmarkComponents/BookmarkGridItemView.swift @@ -12,6 +12,10 @@ struct BookmarkGridItemView: View { let item: LibraryItem let module: Module + var isNovel: Bool { + module.metadata.novel ?? false + } + var body: some View { ZStack { LazyImage(url: URL(string: item.imageUrl)) { state in @@ -30,7 +34,7 @@ struct BookmarkGridItemView: View { } } .overlay( - ZStack { + ZStack(alignment: .bottomTrailing) { Circle() .fill(Color.black.opacity(0.5)) .frame(width: 28, height: 28) @@ -49,6 +53,13 @@ struct BookmarkGridItemView: View { } } ) + // Book/TV icon overlay, bottom right of module icon + Image(systemName: isNovel ? "book.fill" : "tv.fill") + .resizable() + .scaledToFit() + .frame(width: 14, height: 14) + .foregroundColor(.accentColor) + .offset(x: 6, y: 6) } .padding(8), alignment: .topLeading diff --git a/Sora/Views/LibraryView/BookmarkComponents/CollectionDetailView.swift b/Sora/Views/LibraryView/BookmarkComponents/CollectionDetailView.swift index 620c05c..c256090 100644 --- a/Sora/Views/LibraryView/BookmarkComponents/CollectionDetailView.swift +++ b/Sora/Views/LibraryView/BookmarkComponents/CollectionDetailView.swift @@ -12,6 +12,7 @@ struct CollectionDetailView: View { @Environment(\.dismiss) private var dismiss @EnvironmentObject private var libraryManager: LibraryManager @EnvironmentObject private var moduleManager: ModuleManager + @EnvironmentObject private var tabBarController: TabBarController let collection: BookmarkCollection @State private var sortOption: SortOption = .dateAdded @@ -282,6 +283,7 @@ struct CollectionDetailView: View { navigationController.interactivePopGestureRecognizer?.isEnabled = true navigationController.interactivePopGestureRecognizer?.delegate = nil } + tabBarController.showTabBar() } } } \ No newline at end of file diff --git a/Sora/Views/LibraryView/LibraryView.swift b/Sora/Views/LibraryView/LibraryView.swift index 1f0844e..d5a84b1 100644 --- a/Sora/Views/LibraryView/LibraryView.swift +++ b/Sora/Views/LibraryView/LibraryView.swift @@ -12,6 +12,7 @@ import SwiftUI struct LibraryView: View { @EnvironmentObject private var libraryManager: LibraryManager @EnvironmentObject private var moduleManager: ModuleManager + @EnvironmentObject var tabBarController: TabBarController @Environment(\.scenePhase) private var scenePhase @AppStorage("mediaColumnsPortrait") private var mediaColumnsPortrait: Int = 2 @@ -139,6 +140,7 @@ struct LibraryView: View { .deviceScaled() .onAppear { fetchContinueWatching() + tabBarController.showTabBar() } .onChange(of: scenePhase) { newPhase in if newPhase == .active { diff --git a/Sora/Views/MediaInfoView/ChapterCell/ChapterCell.swift b/Sora/Views/MediaInfoView/ChapterCell/ChapterCell.swift new file mode 100644 index 0000000..ccfa006 --- /dev/null +++ b/Sora/Views/MediaInfoView/ChapterCell/ChapterCell.swift @@ -0,0 +1,78 @@ +// +// ChapterCell.swift +// Sora +// +// Created by paul on 20/06/25. +// + +import SwiftUI + +struct ChapterCell: View { + let chapterNumber: String + let chapterTitle: String + let isCurrentChapter: Bool + + var body: some View { + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: 3) { + HStack(alignment: .center, spacing: 6) { + Text("Chapter \(chapterNumber)") + .font(.system(size: 17, weight: .semibold)) + .foregroundColor(.primary) + .lineLimit(1) + + if isCurrentChapter { + Text("Current") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.blue) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Color.blue.opacity(0.18)) + ) + } + Spacer(minLength: 0) + } + Text(chapterTitle) + .font(.system(size: 15)) + .foregroundColor(.secondary) + .lineLimit(2) + } + Spacer() + } + .padding(.vertical, 10) + .padding(.horizontal, 14) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(Color(UIColor.systemBackground)) + .overlay( + RoundedRectangle(cornerRadius: 16) + .fill(Color.accentColor.opacity(0.08)) + ) + ) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke( + LinearGradient( + gradient: Gradient(stops: [ + .init(color: Color.accentColor.opacity(0.35), location: 0), + .init(color: Color.accentColor.opacity(0), location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ), + lineWidth: 1.2 + ) + ) + .clipShape(RoundedRectangle(cornerRadius: 16)) + } +} + +#Preview { + ChapterCell( + chapterNumber: "1", + chapterTitle: "Chapter 1: The Beginning", + isCurrentChapter: true + ) +} \ No newline at end of file diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index 23ded14..471bece 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -28,6 +28,7 @@ struct MediaInfoView: View { @State private var synopsis: String = "" @State private var airdate: String = "" @State private var episodeLinks: [EpisodeLink] = [] + @State private var chapters: [[String: Any]] = [] @State private var itemID: Int? @State private var tmdbID: Int? @State private var tmdbType: TMDBFetcher.MediaType? = nil @@ -119,29 +120,37 @@ struct MediaInfoView: View { return isCompactLayout ? 20 : 16 } - private var startWatchingText: String { - let indices = finishedAndUnfinishedIndices() - let finished = indices.finished - let unfinished = indices.unfinished - - if episodeLinks.count == 1 { - if let _ = unfinished { - return NSLocalizedString("Continue Watching", comment: "") + private var startActionText: String { + if module.metadata.novel == true { + let lastReadChapter = UserDefaults.standard.string(forKey: "lastReadChapter") + if let lastRead = lastReadChapter, chapters.contains(where: { $0["href"] as! String == lastRead }) { + return NSLocalizedString("Continue Reading", comment: "") } + return NSLocalizedString("Start Reading", comment: "") + } else { + let indices = finishedAndUnfinishedIndices() + let finished = indices.finished + let unfinished = indices.unfinished + + if episodeLinks.count == 1 { + if let _ = unfinished { + return NSLocalizedString("Continue Watching", comment: "") + } + return NSLocalizedString("Start Watching", comment: "") + } + + if let finishedIndex = finished, finishedIndex < episodeLinks.count - 1 { + let nextEp = episodeLinks[finishedIndex + 1] + return String(format: NSLocalizedString("Start Watching Episode %d", comment: ""), nextEp.number) + } + + if let unfinishedIndex = unfinished { + let currentEp = episodeLinks[unfinishedIndex] + return String(format: NSLocalizedString("Continue Watching Episode %d", comment: ""), currentEp.number) + } + return NSLocalizedString("Start Watching", comment: "") } - - if let finishedIndex = finished, finishedIndex < episodeLinks.count - 1 { - let nextEp = episodeLinks[finishedIndex + 1] - return String(format: NSLocalizedString("Start Watching Episode %d", comment: ""), nextEp.number) - } - - if let unfinishedIndex = unfinished { - let currentEp = episodeLinks[unfinishedIndex] - return String(format: NSLocalizedString("Continue Watching Episode %d", comment: ""), currentEp.number) - } - - return NSLocalizedString("Start Watching", comment: "") } private var singleEpisodeWatchText: String { @@ -154,6 +163,14 @@ struct MediaInfoView: View { return NSLocalizedString("Mark watched", comment: "") } + @State private var selectedChapterRange: Range = { + let size = UserDefaults.standard.integer(forKey: "chapterChunkSize") + let chunk = size == 0 ? 100 : size + return 0.. chapterChunkSize { + Menu { + ForEach(generateChapterRanges(), id: \..self) { range in + Button(action: { selectedChapterRange = range }) { + Text("\(range.lowerBound + 1)-\(range.upperBound)") + } + } + } label: { + Text("\(selectedChapterRange.lowerBound + 1)-\(selectedChapterRange.upperBound)") + .font(.system(size: 14)) + .foregroundColor(.accentColor) + } + } + HStack(spacing: 4) { + sourceButton + menuButton + } + } + LazyVStack(spacing: 15) { + ForEach(chapters.indices.filter { selectedChapterRange.contains($0) }, id: \..self) { i in + let chapter = chapters[i] + if let href = chapter["href"] as? String, + let number = chapter["number"] as? Int, + let title = chapter["title"] as? String { + NavigationLink( + destination: ReaderView( + moduleId: module.id.uuidString, + chapterHref: href, + chapterTitle: title + ) + ) { + ChapterCell( + chapterNumber: String(number), + chapterTitle: title, + isCurrentChapter: UserDefaults.standard.string(forKey: "lastReadChapter") == href + ) + } + .buttonStyle(PlainButtonStyle()) + } + } + } + } + } + + @ViewBuilder + private var noContentSection: some View { VStack(spacing: 8) { - Image(systemName: "tv.slash") + Image(systemName: module.metadata.novel == true ? "book.slash" : "tv.slash") .font(.system(size: 48)) .foregroundColor(.secondary) - Text(NSLocalizedString("No Episodes Available", comment: "")) + Text(module.metadata.novel == true ? NSLocalizedString("No Chapters Available", comment: "") : NSLocalizedString("No Episodes Available", comment: "")) .font(.title2) .fontWeight(.semibold) .foregroundColor(.primary) - Text(NSLocalizedString("Episodes might not be available yet or there could be an issue with the source.", comment: "")) + Text(module.metadata.novel == true ? NSLocalizedString("Chapters might not be available yet or there could be an issue with the source.", comment: "") : NSLocalizedString("Episodes might not be available yet or there could be an issue with the source.", comment: "")) .font(.body) - .lineLimit(0) .foregroundColor(.secondary) .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) .padding(.horizontal) } .padding(.vertical, 50) @@ -676,7 +790,7 @@ struct MediaInfoView: View { .sheet(isPresented: $isMatchingPresented) { AnilistMatchPopupView(seriesTitle: title) { id, matched in handleAniListMatch(selectedID: id) - matchedTitle = matched // ← now in scope + matchedTitle = matched fetchMetadataIDIfNeeded() } } @@ -684,7 +798,7 @@ struct MediaInfoView: View { TMDBMatchPopupView(seriesTitle: title) { id, type, matched in tmdbID = id tmdbType = type - matchedTitle = matched // ← now in scope + matchedTitle = matched fetchMetadataIDIfNeeded() } } @@ -755,37 +869,105 @@ struct MediaInfoView: View { } private func setupInitialData() async { - guard !hasFetched else { return } - - let savedCustomID = UserDefaults.standard.integer(forKey: "custom_anilist_id_\(href)") - if savedCustomID != 0 { customAniListID = savedCustomID } - - if let savedPoster = UserDefaults.standard.string(forKey: "tmdbPosterURL_\(href)") { - imageUrl = savedPoster + do { + Logger.shared.log("setupInitialData: module.metadata.novel = \(String(describing: module.metadata.novel))", type: "Debug") + if module.metadata.novel == true { + DispatchQueue.main.async { + DropManager.shared.showDrop( + title: "Fetching Data", + subtitle: "Please wait while fetching.", + duration: 0.5, + icon: UIImage(systemName: "arrow.triangle.2.circlepath") + ) + } + let jsContent = try? moduleManager.getModuleContent(module) + if let jsContent = jsContent { + jsController.loadScript(jsContent) + } + + await withTaskGroup(of: Void.self) { group in + var chaptersLoaded = false + var detailsLoaded = false + let timeout: TimeInterval = 8.0 + let start = Date() + + group.addTask { + let fetchedChapters = try? await JSController.shared.extractChapters(moduleId: module.id.uuidString, href: href) + DispatchQueue.main.async { + if let fetchedChapters = fetchedChapters { + Logger.shared.log("setupInitialData: fetchedChapters count = \(fetchedChapters.count)", type: "Debug") + Logger.shared.log("setupInitialData: fetchedChapters = \(fetchedChapters)", type: "Debug") + self.chapters = fetchedChapters + } + chaptersLoaded = true + } + } + group.addTask { + await withCheckedContinuation { continuation in + self.fetchDetails() + var checkDetails: (() -> Void)? + checkDetails = { + if !(self.synopsis.isEmpty && self.aliases.isEmpty && self.airdate.isEmpty) { + detailsLoaded = true + continuation.resume() + } else if Date().timeIntervalSince(start) > timeout { + detailsLoaded = true + continuation.resume() + } else { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + checkDetails?() + } + } + } + checkDetails?() + } + } + group.addTask { + try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) + DispatchQueue.main.async { + chaptersLoaded = true + detailsLoaded = true + } + } + while !(chaptersLoaded && detailsLoaded) { + try? await Task.sleep(nanoseconds: 100_000_000) + } + } + DispatchQueue.main.async { + self.hasFetched = true + self.isLoading = false + } + } else { + let savedCustomID = UserDefaults.standard.integer(forKey: "custom_anilist_id_\(href)") + if savedCustomID != 0 { customAniListID = savedCustomID } + if let savedPoster = UserDefaults.standard.string(forKey: "tmdbPosterURL_\(href)") { + imageUrl = savedPoster + } + DropManager.shared.showDrop( + title: "Fetching Data", + subtitle: "Please wait while fetching.", + duration: 0.5, + icon: UIImage(systemName: "arrow.triangle.2.circlepath") + ) + fetchDetails() + if savedCustomID != 0 { + itemID = savedCustomID + activeProvider = "AniList" + UserDefaults.standard.set("AniList", forKey: "metadataProviders") + } else { + fetchMetadataIDIfNeeded() + } + hasFetched = true + AnalyticsManager.shared.sendEvent( + event: "MediaInfoView", + additionalData: ["title": title] + ) + } + } catch { + isError = true + isLoading = false + Logger.shared.log("Error loading media info: \(error)", type: "Error") } - - DropManager.shared.showDrop( - title: "Fetching Data", - subtitle: "Please wait while fetching.", - duration: 0.5, - icon: UIImage(systemName: "arrow.triangle.2.circlepath") - ) - - fetchDetails() - - if savedCustomID != 0 { - itemID = savedCustomID - activeProvider = "AniList" - UserDefaults.standard.set("AniList", forKey: "metadataProviders") - } else { - fetchMetadataIDIfNeeded() - } - - hasFetched = true - AnalyticsManager.shared.sendEvent( - event: "MediaInfoView", - additionalData: ["title": title] - ) } private func cancelCurrentFetch() { @@ -1169,6 +1351,7 @@ struct MediaInfoView: View { func fetchDetails() { + Logger.shared.log("fetchDetails: called", type: "Debug") DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { Task { do { @@ -1176,27 +1359,85 @@ struct MediaInfoView: View { jsController.loadScript(jsContent) if module.metadata.asyncJS == true { jsController.fetchDetailsJS(url: href) { items, episodes in - if let item = items.first { + Logger.shared.log("fetchDetails: items = \(items)", type: "Debug") + Logger.shared.log("fetchDetails: episodes = \(episodes)", type: "Debug") + + if let mediaItems = items as? [MediaItem], let item = mediaItems.first { self.synopsis = item.description self.aliases = item.aliases self.airdate = item.airdate + } else if let str = items as? String { + if let data = str.data(using: .utf8), + let arr = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]], + let dict = arr.first { + self.synopsis = dict["description"] as? String ?? "" + self.aliases = dict["aliases"] as? String ?? "" + self.airdate = dict["airdate"] as? String ?? "" + } + } else if let dict = items as? [String: Any] { + self.synopsis = dict["description"] as? String ?? "" + self.aliases = dict["aliases"] as? String ?? "" + self.airdate = dict["airdate"] as? String ?? "" + } else if let arr = items as? [[String: Any]], let dict = arr.first { + self.synopsis = dict["description"] as? String ?? "" + self.aliases = dict["aliases"] as? String ?? "" + self.airdate = dict["airdate"] as? String ?? "" + } else { + Logger.shared.log("Failed to process items of type: \(type(of: items))", type: "Error") + } + + if self.module.metadata.novel ?? false { + Logger.shared.log("fetchDetails: (novel) chapters count = \(self.chapters.count)", type: "Debug") + self.isLoading = false + self.isRefetching = false + } else { + Logger.shared.log("fetchDetails: (episodes) episodes count = \(episodes.count)", type: "Debug") + self.episodeLinks = episodes + self.restoreSelectionState() + self.isLoading = false + self.isRefetching = false } - self.episodeLinks = episodes - self.restoreSelectionState() - self.isLoading = false - self.isRefetching = false } } else { jsController.fetchDetails(url: href) { items, episodes in - if let item = items.first { + Logger.shared.log("fetchDetails: items = \(items)", type: "Debug") + Logger.shared.log("fetchDetails: episodes = \(episodes)", type: "Debug") + + if let mediaItems = items as? [MediaItem], let item = mediaItems.first { self.synopsis = item.description self.aliases = item.aliases self.airdate = item.airdate + } else if let str = items as? String { + if let data = str.data(using: .utf8), + let arr = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]], + let dict = arr.first { + self.synopsis = dict["description"] as? String ?? "" + self.aliases = dict["aliases"] as? String ?? "" + self.airdate = dict["airdate"] as? String ?? "" + } + } else if let dict = items as? [String: Any] { + self.synopsis = dict["description"] as? String ?? "" + self.aliases = dict["aliases"] as? String ?? "" + self.airdate = dict["airdate"] as? String ?? "" + } else if let arr = items as? [[String: Any]], let dict = arr.first { + self.synopsis = dict["description"] as? String ?? "" + self.aliases = dict["aliases"] as? String ?? "" + self.airdate = dict["airdate"] as? String ?? "" + } else { + Logger.shared.log("Failed to process items of type: \(type(of: items))", type: "Error") + } + + if self.module.metadata.novel ?? false { + Logger.shared.log("fetchDetails: (novel) chapters count = \(self.chapters.count)", type: "Debug") + self.isLoading = false + self.isRefetching = false + } else { + Logger.shared.log("fetchDetails: (episodes) episodes count = \(episodes.count)", type: "Debug") + self.episodeLinks = episodes + self.restoreSelectionState() + self.isLoading = false + self.isRefetching = false } - self.episodeLinks = episodes - self.restoreSelectionState() - self.isLoading = false - self.isRefetching = false } } } catch { @@ -1941,4 +2182,15 @@ struct MediaInfoView: View { findTopViewController.findViewController(rootVC).present(alert, animated: true) } } + + private func generateChapterRanges() -> [Range] { + let chunkSize = chapterChunkSize + let totalChapters = chapters.count + var ranges: [Range] = [] + for i in stride(from: 0, to: totalChapters, by: chunkSize) { + let end = min(i + chunkSize, totalChapters) + ranges.append(i.. CGFloat? { + if let value = object(forKey: defaultName) as? NSNumber { + return CGFloat(value.doubleValue) + } + return nil + } + + func set(_ value: CGFloat, forKey defaultName: String) { + set(NSNumber(value: Double(value)), forKey: defaultName) + } +} + +struct ReaderView: View { + let moduleId: String + let chapterHref: String + let chapterTitle: String + + @State private var htmlContent: String = "" + @State private var isLoading: Bool = true + @State private var error: Error? + @State private var isHeaderVisible: Bool = true + @State private var fontSize: CGFloat = 16 + @State private var selectedFont: String = "-apple-system" + @State private var fontWeight: String = "normal" + @State private var isAutoScrolling: Bool = false + @State private var autoScrollSpeed: Double = 1.0 + @State private var autoScrollTimer: Timer? + @State private var selectedColorPreset: Int = 0 + @State private var isSettingsExpanded: Bool = false + @State private var textAlignment: String = "left" + @State private var lineSpacing: CGFloat = 1.6 + @State private var margin: CGFloat = 4 + @Environment(\.dismiss) private var dismiss + @EnvironmentObject var tabBarController: TabBarController + + private let fontOptions = [ + ("-apple-system", "System"), + ("Georgia", "Georgia"), + ("Times New Roman", "Times"), + ("Helvetica", "Helvetica"), + ("Charter", "Charter"), + ("New York", "New York") + ] + private let weightOptions = [ + ("300", "Light"), + ("normal", "Regular"), + ("600", "Semibold"), + ("bold", "Bold") + ] + + private let alignmentOptions = [ + ("left", "Left", "text.alignleft"), + ("center", "Center", "text.aligncenter"), + ("right", "Right", "text.alignright"), + ("justify", "Justify", "text.justify") + ] + + private let colorPresets = [ + (name: "Pure", background: "#ffffff", text: "#000000"), + (name: "Warm", background: "#f9f1e4", text: "#4f321c"), + (name: "Slate", background: "#49494d", text: "#d7d7d8"), + (name: "Off-Black", background: "#121212", text: "#EAEAEA"), + (name: "Dark", background: "#000000", text: "#ffffff") + ] + + private var currentTheme: (background: Color, text: Color) { + let preset = colorPresets[selectedColorPreset] + return ( + background: Color(hex: preset.background), + text: Color(hex: preset.text) + ) + } + + init(moduleId: String, chapterHref: String, chapterTitle: String) { + self.moduleId = moduleId + self.chapterHref = chapterHref + self.chapterTitle = chapterTitle + + _fontSize = State(initialValue: UserDefaults.standard.cgFloat(forKey: "readerFontSize") ?? 16) + _selectedFont = State(initialValue: UserDefaults.standard.string(forKey: "readerFontFamily") ?? "-apple-system") + _fontWeight = State(initialValue: UserDefaults.standard.string(forKey: "readerFontWeight") ?? "normal") + _selectedColorPreset = State(initialValue: UserDefaults.standard.integer(forKey: "readerColorPreset")) + _textAlignment = State(initialValue: UserDefaults.standard.string(forKey: "readerTextAlignment") ?? "left") + _lineSpacing = State(initialValue: UserDefaults.standard.cgFloat(forKey: "readerLineSpacing") ?? 1.6) + _margin = State(initialValue: UserDefaults.standard.cgFloat(forKey: "readerMargin") ?? 4) + } + + var body: some View { + ZStack(alignment: .bottom) { + currentTheme.background.ignoresSafeArea() + + if isLoading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: currentTheme.text)) + .onDisappear { + stopAutoScroll() + } + } else if let error = error { + VStack { + Text("Error loading chapter") + .font(.headline) + .foregroundColor(currentTheme.text) + Text(error.localizedDescription) + .font(.subheadline) + .foregroundColor(currentTheme.text.opacity(0.7)) + } + } else { + ZStack { + Color.clear + .contentShape(Rectangle()) + .onTapGesture { + withAnimation(.easeInOut(duration: 0.6)) { + isHeaderVisible.toggle() + if !isHeaderVisible { + isSettingsExpanded = false + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + HTMLView( + htmlContent: htmlContent, + fontSize: fontSize, + fontFamily: selectedFont, + fontWeight: fontWeight, + textAlignment: textAlignment, + lineSpacing: lineSpacing, + margin: margin, + isAutoScrolling: $isAutoScrolling, + autoScrollSpeed: autoScrollSpeed, + colorPreset: colorPresets[selectedColorPreset] + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.horizontal) + .simultaneousGesture(TapGesture().onEnded { + withAnimation(.easeInOut(duration: 0.6)) { + isHeaderVisible.toggle() + if !isHeaderVisible { + isSettingsExpanded = false + } + } + }) + } + .padding(.top, isHeaderVisible ? 0 : (UIApplication.shared.windows.first?.safeAreaInsets.top ?? 0)) + } + + headerView + .opacity(isHeaderVisible ? 1 : 0) + .offset(y: isHeaderVisible ? 0 : -100) + .allowsHitTesting(isHeaderVisible) + .animation(.easeInOut(duration: 0.6), value: isHeaderVisible) + .zIndex(1) + + if isHeaderVisible { + footerView + .transition(.move(edge: .bottom)) + .zIndex(2) + } + } + .navigationBarHidden(true) + .navigationBarBackButtonHidden(true) + .ignoresSafeArea() + .onAppear { + tabBarController.hideTabBar() + UserDefaults.standard.set(chapterHref, forKey: "lastReadChapter") + } + .task { + do { + let content = try await JSController.shared.extractText(moduleId: moduleId, href: chapterHref) + if !content.isEmpty { + htmlContent = content + isLoading = false + } else { + throw JSError.invalidResponse + } + } catch { + self.error = error + isLoading = false + } + } + } + + private func stopAutoScroll() { + autoScrollTimer?.invalidate() + autoScrollTimer = nil + isAutoScrolling = false + } + + private var headerView: some View { + VStack { + ZStack(alignment: .top) { + // Base header content + HStack { + Button(action: { + dismiss() + }) { + Image(systemName: "chevron.left") + .font(.system(size: 20, weight: .bold)) + .foregroundColor(currentTheme.text) + .padding(12) + .background(currentTheme.background.opacity(0.8)) + .clipShape(Circle()) + .circularGradientOutline() + } + .padding(.leading) + + Text(chapterTitle) + .font(.headline) + .foregroundColor(currentTheme.text) + .lineLimit(1) + .truncationMode(.tail) + + Spacer() + + Color.clear + .frame(width: 44, height: 44) + .padding(.trailing) + } + .opacity(isHeaderVisible ? 1 : 0) + .offset(y: isHeaderVisible ? 0 : -100) + .animation(.easeInOut(duration: 0.6), value: isHeaderVisible) + .contentShape(Rectangle()) + .onTapGesture { + withAnimation(.easeInOut(duration: 0.6)) { + isHeaderVisible = false + isSettingsExpanded = false + } + } + + HStack { + Spacer() + ZStack(alignment: .topTrailing) { + Button(action: { + withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { + isSettingsExpanded.toggle() + } + }) { + Image(systemName: "ellipsis") + .font(.system(size: 18, weight: .bold)) + .foregroundColor(currentTheme.text) + .padding(12) + .background(currentTheme.background.opacity(0.8)) + .clipShape(Circle()) + .circularGradientOutline() + .rotationEffect(.degrees(isSettingsExpanded ? 90 : 0)) + } + .opacity(isHeaderVisible ? 1 : 0) + .offset(y: isHeaderVisible ? 0 : -100) + .animation(.easeInOut(duration: 0.6), value: isHeaderVisible) + + if isSettingsExpanded { + VStack(spacing: 8) { + Menu { + VStack { + Text("Font Size: \(Int(fontSize))pt") + .font(.headline) + .padding(.bottom, 8) + + Slider(value: Binding( + get: { fontSize }, + set: { newValue in + fontSize = newValue + UserDefaults.standard.set(newValue, forKey: "readerFontSize") + } + ), in: 12...32, step: 1) { + Text("Font Size") + } + .padding(.horizontal) + } + .padding() + } label: { + settingsButtonLabel(icon: "textformat.size") + } + + Menu { + ForEach(fontOptions, id: \.0) { font in + Button(action: { + selectedFont = font.0 + UserDefaults.standard.set(font.0, forKey: "readerFontFamily") + }) { + HStack { + Text(font.1) + .font(.custom(font.0, size: 16)) + Spacer() + if selectedFont == font.0 { + Image(systemName: "checkmark") + .foregroundColor(.blue) + } + } + } + } + } label: { + settingsButtonLabel(icon: "textformat.characters") + } + + Menu { + ForEach(weightOptions, id: \.0) { weight in + Button(action: { + fontWeight = weight.0 + UserDefaults.standard.set(weight.0, forKey: "readerFontWeight") + }) { + HStack { + Text(weight.1) + .fontWeight(weight.0 == "300" ? .light : + weight.0 == "normal" ? .regular : + weight.0 == "600" ? .semibold : .bold) + Spacer() + if fontWeight == weight.0 { + Image(systemName: "checkmark") + .foregroundColor(.blue) + } + } + } + } + } label: { + settingsButtonLabel(icon: "bold") + } + + Menu { + ForEach(0.. some View { + Image(systemName: icon) + .font(.system(size: 16, weight: .bold)) + .foregroundColor(currentTheme.text) + .padding(10) + .background(currentTheme.background.opacity(0.8)) + .clipShape(Circle()) + .circularGradientOutline() + } +} + +struct ColorPreviewCircle: View { + let backgroundColor: String + let textColor: String + + var body: some View { + ZStack { + Circle() + .fill( + LinearGradient( + colors: [ + Color(hex: backgroundColor), + Color(hex: textColor) + ], + startPoint: .leading, + endPoint: .trailing + ) + ) + } + } +} + +struct HTMLView: UIViewRepresentable { + let htmlContent: String + let fontSize: CGFloat + let fontFamily: String + let fontWeight: String + let textAlignment: String + let lineSpacing: CGFloat + let margin: CGFloat + @Binding var isAutoScrolling: Bool + let autoScrollSpeed: Double + let colorPreset: (name: String, background: String, text: String) + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject { + var parent: HTMLView + var scrollTimer: Timer? + var lastHtmlContent: String = "" + var lastFontSize: CGFloat = 0 + var lastFontFamily: String = "" + var lastFontWeight: String = "" + var lastTextAlignment: String = "" + var lastLineSpacing: CGFloat = 0 + var lastMargin: CGFloat = 0 + var lastColorPreset: String = "" + + init(_ parent: HTMLView) { + self.parent = parent + } + + func startAutoScroll(webView: WKWebView) { + stopAutoScroll() + + scrollTimer = Timer.scheduledTimer(withTimeInterval: 0.016, repeats: true) { _ in // 60fps for smoother scrolling + let scrollAmount = self.parent.autoScrollSpeed * 0.5 // Reduced increment for smoother scrolling + + webView.evaluateJavaScript("window.scrollBy(0, \(scrollAmount));") { _, error in + if let error = error { + print("Scroll error: \(error)") + } + } + + webView.evaluateJavaScript("(window.pageYOffset + window.innerHeight) >= document.body.scrollHeight") { result, _ in + if let isAtBottom = result as? Bool, isAtBottom { + DispatchQueue.main.async { + self.parent.isAutoScrolling = false + } + } + } + } + } + + func stopAutoScroll() { + scrollTimer?.invalidate() + scrollTimer = nil + } + } + + func makeUIView(context: Context) -> WKWebView { + let webView = WKWebView() + webView.backgroundColor = .clear + webView.isOpaque = false + webView.scrollView.backgroundColor = .clear + + webView.scrollView.showsHorizontalScrollIndicator = false + webView.scrollView.bounces = false + webView.scrollView.alwaysBounceHorizontal = false + webView.scrollView.contentInsetAdjustmentBehavior = .never + + return webView + } + + func updateUIView(_ webView: WKWebView, context: Context) { + let coordinator = context.coordinator + + if isAutoScrolling { + coordinator.startAutoScroll(webView: webView) + } else { + coordinator.stopAutoScroll() + } + + guard !htmlContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return + } + + let contentChanged = coordinator.lastHtmlContent != htmlContent + let fontSizeChanged = coordinator.lastFontSize != fontSize + let fontFamilyChanged = coordinator.lastFontFamily != fontFamily + let fontWeightChanged = coordinator.lastFontWeight != fontWeight + let alignmentChanged = coordinator.lastTextAlignment != textAlignment + let lineSpacingChanged = coordinator.lastLineSpacing != lineSpacing + let marginChanged = coordinator.lastMargin != margin + let colorChanged = coordinator.lastColorPreset != colorPreset.name + + if contentChanged || fontSizeChanged || fontFamilyChanged || fontWeightChanged || + alignmentChanged || lineSpacingChanged || marginChanged || colorChanged { + let htmlTemplate = """ + + + + + + + + \(htmlContent) + + + """ + + Logger.shared.log("Loading HTML content into WebView", type: "Debug") + webView.loadHTMLString(htmlTemplate, baseURL: nil) + + coordinator.lastHtmlContent = htmlContent + coordinator.lastFontSize = fontSize + coordinator.lastFontFamily = fontFamily + coordinator.lastFontWeight = fontWeight + coordinator.lastTextAlignment = textAlignment + coordinator.lastLineSpacing = lineSpacing + coordinator.lastMargin = margin + coordinator.lastColorPreset = colorPreset.name + } + } +} diff --git a/Sora/Views/SearchView/SearchView.swift b/Sora/Views/SearchView/SearchView.swift index a3cbfca..2668c62 100644 --- a/Sora/Views/SearchView/SearchView.swift +++ b/Sora/Views/SearchView/SearchView.swift @@ -23,6 +23,7 @@ struct SearchView: View { @StateObject private var jsController = JSController.shared @EnvironmentObject var moduleManager: ModuleManager + @EnvironmentObject var tabBarController: TabBarController @Environment(\.verticalSizeClass) var verticalSizeClass @Binding public var searchQuery: String @@ -141,6 +142,7 @@ struct SearchView: View { if !searchQuery.isEmpty { performSearch() } + tabBarController.showTabBar() } .onChange(of: selectedModuleId) { _ in if !searchQuery.isEmpty { diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewAbout.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewAbout.swift index 5bc1242..94375e6 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewAbout.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewAbout.swift @@ -288,8 +288,8 @@ struct TranslatorsView: View { ), Translator( id: 3, - login: "cranci", - avatarUrl: "https://github.com/50n50/assets/blob/main/pfps/28ac8bfaa250788579af747d8fb7f827_webp.png?raw=true", + login: "simplymox", + avatarUrl: "https://github.com/50n50/assets/blob/main/pfps/9131174855bd67fc445206e888505a6a_webp.png?raw=true", language: "Italian" ), Translator( diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift index cb7bdcb..47cd59b 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift @@ -224,6 +224,7 @@ struct SettingsViewGeneral: View { "Dutch", "French", "German", + "Italian", "Kazakh", "Norsk", "Russian", @@ -246,6 +247,7 @@ struct SettingsViewGeneral: View { case "Norsk": return "Norsk" case "Kazakh": return "Қазақша" case "Swedish": return "Svenska" + case "Italian": return "Italiano" default: return lang } }, diff --git a/Sora/Views/SettingsView/SettingsView.swift b/Sora/Views/SettingsView/SettingsView.swift index 9a7c6c4..c36c2d1 100644 --- a/Sora/Views/SettingsView/SettingsView.swift +++ b/Sora/Views/SettingsView/SettingsView.swift @@ -131,6 +131,7 @@ struct SettingsView: View { @Environment(\.colorScheme) var colorScheme @StateObject var settings = Settings() @EnvironmentObject var moduleManager: ModuleManager + @EnvironmentObject var tabBarController: TabBarController @State private var isNavigationActive = false var body: some View { @@ -144,7 +145,6 @@ struct SettingsView: View { .padding(.horizontal, 20) .padding(.top, 16) - // Modules Section at the top VStack(alignment: .leading, spacing: 4) { Text("MODULES") .font(.footnote) @@ -330,6 +330,7 @@ struct SettingsView: View { } .onAppear { settings.updateAccentColor(currentColorScheme: colorScheme) + tabBarController.showTabBar() } } } @@ -427,6 +428,8 @@ class Settings: ObservableObject { languageCode = "kk" case "Swedish": languageCode = "sv" + case "Italian": + languageCode = "it" default: languageCode = "en" } diff --git a/Sulfur.xcodeproj/project.pbxproj b/Sulfur.xcodeproj/project.pbxproj index 6f8acfb..102fb60 100644 --- a/Sulfur.xcodeproj/project.pbxproj +++ b/Sulfur.xcodeproj/project.pbxproj @@ -19,6 +19,9 @@ 0410697F2E00ABE900A157BB /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 0410697C2E00ABE900A157BB /* Localizable.strings */; }; 041069832E00C71000A157BB /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 041069812E00C71000A157BB /* Localizable.strings */; }; 041261042E00D14F00D05B47 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 041261022E00D14F00D05B47 /* Localizable.strings */; }; + 04536F712E04BA3B00A11248 /* JSController-Novel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04536F702E04BA3B00A11248 /* JSController-Novel.swift */; }; + 04536F742E04BA5600A11248 /* ReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04536F722E04BA5600A11248 /* ReaderView.swift */; }; + 04536F772E04BA6900A11248 /* ChapterCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04536F752E04BA6900A11248 /* ChapterCell.swift */; }; 0457C5972DE7712A000AFBD9 /* DeviceScaleModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0457C5942DE7712A000AFBD9 /* DeviceScaleModifier.swift */; }; 0457C59D2DE78267000AFBD9 /* BookmarkGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0457C59A2DE78267000AFBD9 /* BookmarkGridView.swift */; }; 0457C59E2DE78267000AFBD9 /* BookmarkLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0457C59B2DE78267000AFBD9 /* BookmarkLink.swift */; }; @@ -34,6 +37,7 @@ 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 */; }; + 04E00C9F2E09F5920056124A /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 04E00C9D2E09F5920056124A /* Localizable.strings */; }; 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 */; }; @@ -130,6 +134,9 @@ 041069802E00C71000A157BB /* kk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = kk; path = Localizable.strings; sourceTree = ""; }; 041261012E00D14F00D05B47 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = Localizable.strings; sourceTree = ""; }; 0452339E2E02149C002EA23C /* bos */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bos; path = Localizable.strings; sourceTree = ""; }; + 04536F702E04BA3B00A11248 /* JSController-Novel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-Novel.swift"; sourceTree = ""; }; + 04536F722E04BA5600A11248 /* ReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderView.swift; sourceTree = ""; }; + 04536F752E04BA6900A11248 /* ChapterCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterCell.swift; sourceTree = ""; }; 0457C5942DE7712A000AFBD9 /* DeviceScaleModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceScaleModifier.swift; sourceTree = ""; }; 0457C5992DE78267000AFBD9 /* BookmarkGridItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkGridItemView.swift; sourceTree = ""; }; 0457C59A2DE78267000AFBD9 /* BookmarkGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkGridView.swift; sourceTree = ""; }; @@ -145,6 +152,7 @@ 04AD07112E0360CD00EB74C1 /* CollectionPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionPickerView.swift; sourceTree = ""; }; 04AD07152E03704700EB74C1 /* BookmarkCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkCell.swift; sourceTree = ""; }; 04CD76DA2DE20F2200733536 /* AllWatching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllWatching.swift; sourceTree = ""; }; + 04E00C9E2E09F5920056124A /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = Localizable.strings; sourceTree = ""; }; 04EAC3992DF9E0DB00BBD483 /* SplashScreenView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashScreenView.swift; sourceTree = ""; }; 04F08EDB2DE10BEC006B29D9 /* ProgressiveBlurView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressiveBlurView.swift; sourceTree = ""; }; 04F08EDE2DE10C1A006B29D9 /* TabBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBar.swift; sourceTree = ""; }; @@ -317,6 +325,22 @@ path = bos.lproj; sourceTree = ""; }; + 04536F732E04BA5600A11248 /* ReaderView */ = { + isa = PBXGroup; + children = ( + 04536F722E04BA5600A11248 /* ReaderView.swift */, + ); + path = ReaderView; + sourceTree = ""; + }; + 04536F762E04BA6900A11248 /* ChapterCell */ = { + isa = PBXGroup; + children = ( + 04536F752E04BA6900A11248 /* ChapterCell.swift */, + ); + path = ChapterCell; + sourceTree = ""; + }; 0457C5962DE7712A000AFBD9 /* ViewModifiers */ = { isa = PBXGroup; children = ( @@ -380,6 +404,14 @@ path = nn.lproj; sourceTree = ""; }; + 04E00C9A2E09E96B0056124A /* it.lproj */ = { + isa = PBXGroup; + children = ( + 04E00C9D2E09F5920056124A /* Localizable.strings */, + ); + path = it.lproj; + sourceTree = ""; + }; 04F08EDA2DE10BE3006B29D9 /* ProgressiveBlurView */ = { isa = PBXGroup; children = ( @@ -514,6 +546,7 @@ 133D7C7B2D2BE2630075467E /* Views */ = { isa = PBXGroup; children = ( + 04536F732E04BA5600A11248 /* ReaderView */, 04EAC3992DF9E0DB00BBD483 /* SplashScreenView.swift */, 72443C7C2DC8036500A61321 /* DownloadView.swift */, 0402DA122DE7B5EC003BB42C /* SearchView */, @@ -527,6 +560,7 @@ 133D7C7F2D2BE2630075467E /* MediaInfoView */ = { isa = PBXGroup; children = ( + 04536F762E04BA6900A11248 /* ChapterCell */, 1E0435F02DFCB86800FF6808 /* CustomMatching */, 138AA1B52D2D66EC0021F9DF /* EpisodeCell */, 133D7C802D2BE2630075467E /* MediaInfoView.swift */, @@ -600,6 +634,7 @@ 133D7C8A2D2BE2640075467E /* JSLoader */ = { isa = PBXGroup; children = ( + 04536F702E04BA3B00A11248 /* JSController-Novel.swift */, 134A387B2DE4B5B90041B687 /* Downloads */, 133D7C8B2D2BE2640075467E /* JSController.swift */, 132AF1202D99951700A0140B /* JSController-Streams.swift */, @@ -634,6 +669,7 @@ 13530BE02E00028E0048B7DE /* Localization */ = { isa = PBXGroup; children = ( + 04E00C9A2E09E96B0056124A /* it.lproj */, 0452339C2E021491002EA23C /* bos.lproj */, 041261032E00D14F00D05B47 /* sv.lproj */, 041069822E00C71000A157BB /* kk.lproj */, @@ -854,7 +890,6 @@ nl, fr, ar, - cz, es, ru, nn, @@ -865,6 +900,7 @@ bos, bs, cs, + it, ); mainGroup = 133D7C612D2BE2500075467E; packageReferences = ( @@ -897,6 +933,7 @@ 0409FE8C2DFF2886000DB00C /* Localizable.strings in Resources */, 04A1B73C2DFF39EB0064688A /* Localizable.strings in Resources */, 133D7C752D2BE2520075467E /* Preview Assets.xcassets in Resources */, + 04E00C9F2E09F5920056124A /* Localizable.strings in Resources */, 041069832E00C71000A157BB /* Localizable.strings in Resources */, 133D7C722D2BE2520075467E /* Assets.xcassets in Resources */, 0488FA952DFDE724007575E1 /* Localizable.strings in Resources */, @@ -912,16 +949,19 @@ buildActionMask = 2147483647; files = ( 135CCBE22D4D1138008B9C0E /* SettingsViewPlayer.swift in Sources */, + 04536F742E04BA5600A11248 /* ReaderView.swift in Sources */, 131270172DC13A010093AA9C /* DownloadManager.swift in Sources */, 1EDA48F42DFAC374002A4EC3 /* TMDBMatchPopupView.swift in Sources */, 1327FBA72D758CEA00FC6689 /* Analytics.swift in Sources */, 13DC0C462D302C7500D0F966 /* VideoPlayer.swift in Sources */, 1359ED142D76F49900C13034 /* finTopView.swift in Sources */, 1399FAD62D3AB3DB00E97C31 /* Logger.swift in Sources */, + 04536F772E04BA6900A11248 /* ChapterCell.swift in Sources */, 13B7F4C12D58FFDD0045714A /* Shimmer.swift in Sources */, 139935662D468C450065CEFF /* ModuleManager.swift in Sources */, 133D7C902D2BE2640075467E /* SettingsView.swift in Sources */, 132AF1252D9995F900A0140B /* JSController-Search.swift in Sources */, + 04536F712E04BA3B00A11248 /* JSController-Novel.swift in Sources */, 1EF5C3A92DB988E40032BF07 /* CommunityLib.swift in Sources */, 13CBEFDA2D5F7D1200D011EE /* String.swift in Sources */, 04F08EDC2DE10BF3006B29D9 /* ProgressiveBlurView.swift in Sources */, @@ -1109,6 +1149,14 @@ name = Localizable.strings; sourceTree = ""; }; + 04E00C9D2E09F5920056124A /* Localizable.strings */ = { + isa = PBXVariantGroup; + children = ( + 04E00C9E2E09F5920056124A /* it */, + ); + name = Localizable.strings; + sourceTree = ""; + }; /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ From fc1cff477b0d1382ff9983aa429e807f8575ea89 Mon Sep 17 00:00:00 2001 From: 50/50 <80717571+50n50@users.noreply.github.com> Date: Tue, 24 Jun 2025 21:09:31 +0200 Subject: [PATCH 06/45] Build fix (will flush commits after this) (#210) * Quick Czech fix * Bookmark collection system + migration system (Video in discord)) * Check discord * Fix mediainfoview * Title always expanded * Reader header enhancements * Fix tab bar gradient * MORE/LESS below synopsis instead of next to it (less wasted space)) * Font + Weight + Size buttons for reader (with correct UI)) * Change icon * Theming and auto scroll * fucking cool shit * added new theme for reader * Fixed reader header * Added italian * made italian usable * changed credits * finally fucking italian works * Fix novel details * Fix loading issue * made chapter cells less tall * Fix current label * Create ios.yml * Delete .github/workflows/ios.yml * Hopefully fixed building error --- Sora/Views/MediaInfoView/MediaInfoView.swift | 37 +++++++++++--------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index 471bece..59812a2 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -893,7 +893,7 @@ struct MediaInfoView: View { group.addTask { let fetchedChapters = try? await JSController.shared.extractChapters(moduleId: module.id.uuidString, href: href) - DispatchQueue.main.async { + await MainActor.run { if let fetchedChapters = fetchedChapters { Logger.shared.log("setupInitialData: fetchedChapters count = \(fetchedChapters.count)", type: "Debug") Logger.shared.log("setupInitialData: fetchedChapters = \(fetchedChapters)", type: "Debug") @@ -903,33 +903,38 @@ struct MediaInfoView: View { } } group.addTask { - await withCheckedContinuation { continuation in + await MainActor.run { self.fetchDetails() - var checkDetails: (() -> Void)? - checkDetails = { - if !(self.synopsis.isEmpty && self.aliases.isEmpty && self.airdate.isEmpty) { - detailsLoaded = true - continuation.resume() - } else if Date().timeIntervalSince(start) > timeout { - detailsLoaded = true - continuation.resume() - } else { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - checkDetails?() + } + await withCheckedContinuation { (continuation: CheckedContinuation) in + func checkDetails() { + Task { @MainActor in + if !(self.synopsis.isEmpty && self.aliases.isEmpty && self.airdate.isEmpty) { + detailsLoaded = true + continuation.resume() + } else if Date().timeIntervalSince(start) > timeout { + detailsLoaded = true + continuation.resume() + } else { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + checkDetails() + } } } } - checkDetails?() + checkDetails() } } group.addTask { try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) - DispatchQueue.main.async { + await MainActor.run { chaptersLoaded = true detailsLoaded = true } } - while !(chaptersLoaded && detailsLoaded) { + while true { + let loaded = await MainActor.run { chaptersLoaded && detailsLoaded } + if loaded { break } try? await Task.sleep(nanoseconds: 100_000_000) } } From 44c5b59601ae9826d2206bcc64b66e2c79897a38 Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Wed, 25 Jun 2025 10:33:42 +0200 Subject: [PATCH 07/45] added percentage switcher --- .../CustomPlayer/CustomPlayer.swift | 7 +++-- .../MediaUtils/NormalPlayer/VideoPlayer.swift | 3 +- .../Analytics/Analytics.swift | 0 .../DownloadManager/DownloadManager.swift | 0 .../DownloadUtils/DownloadManager.swift | 0 .../DownloadUtils/DownloadModels.swift | 0 .../DownloadUtils/M3U8StreamExtractor.swift | 0 .../Drops/DropManager.swift | 0 .../JavaScriptCore+Extensions.swift | 0 .../Extensions/Notification+Name.swift | 0 .../Extensions/String.swift | 0 .../Extensions/UIDevice+Model.swift | 0 .../Extensions/URL.swift | 0 .../Extensions/URLSession.swift | 0 .../Extensions/UserDefaults.swift | 0 .../Extensions/View.swift | 0 .../Extensions/finTopView.swift | 0 .../Downloads/JSController+Downloader.swift | 0 .../Downloads/JSController-Downloads.swift | 0 .../JSController-HeaderManager.swift | 0 .../JSController-StreamTypeDownload.swift | 0 .../JSLoader/JSController-Details.swift | 0 .../JSLoader/JSController-Novel.swift | 0 .../JSLoader/JSController-Search.swift | 0 .../JSLoader/JSController-Streams.swift | 0 .../JSLoader/JSController.swift | 0 .../Logger/Logger.swift | 0 .../Models/TabItem.swift | 0 .../Modules/CommunityLib.swift | 0 .../Modules/ModuleAdditionSettingsView.swift | 0 .../Modules/ModuleManager.swift | 0 .../Modules/Modules.swift | 0 .../ProgressiveBlurView.swift | 0 .../SkeletonCells/Shimmer.swift | 0 .../SkeletonCells/SkeletonCell.swift | 0 .../TabBar/TabBar.swift | 0 .../TabBar/TabBarController.swift | 0 .../ViewModifiers/DeviceScaleModifier.swift | 0 .../WebAuthenticationManager.swift | 0 .../SettingsSubViews/SettingsViewPlayer.swift | 11 ++++++- .../SettingsViewTrackers.swift | 4 +-- Sulfur.xcodeproj/project.pbxproj | 31 +++++++++---------- 42 files changed, 33 insertions(+), 23 deletions(-) rename Sora/{Utils => Utlis & Misc}/Analytics/Analytics.swift (100%) rename Sora/{Utils => Utlis & Misc}/DownloadManager/DownloadManager.swift (100%) rename Sora/{Utils => Utlis & Misc}/DownloadUtils/DownloadManager.swift (100%) rename Sora/{Utils => Utlis & Misc}/DownloadUtils/DownloadModels.swift (100%) rename Sora/{Utils => Utlis & Misc}/DownloadUtils/M3U8StreamExtractor.swift (100%) rename Sora/{Utils => Utlis & Misc}/Drops/DropManager.swift (100%) rename Sora/{Utils => Utlis & Misc}/Extensions/JavaScriptCore+Extensions.swift (100%) rename Sora/{Utils => Utlis & Misc}/Extensions/Notification+Name.swift (100%) rename Sora/{Utils => Utlis & Misc}/Extensions/String.swift (100%) rename Sora/{Utils => Utlis & Misc}/Extensions/UIDevice+Model.swift (100%) rename Sora/{Utils => Utlis & Misc}/Extensions/URL.swift (100%) rename Sora/{Utils => Utlis & Misc}/Extensions/URLSession.swift (100%) rename Sora/{Utils => Utlis & Misc}/Extensions/UserDefaults.swift (100%) rename Sora/{Utils => Utlis & Misc}/Extensions/View.swift (100%) rename Sora/{Utils => Utlis & Misc}/Extensions/finTopView.swift (100%) rename Sora/{Utils => Utlis & Misc}/JSLoader/Downloads/JSController+Downloader.swift (100%) rename Sora/{Utils => Utlis & Misc}/JSLoader/Downloads/JSController-Downloads.swift (100%) rename Sora/{Utils => Utlis & Misc}/JSLoader/Downloads/JSController-HeaderManager.swift (100%) rename Sora/{Utils => Utlis & Misc}/JSLoader/Downloads/JSController-StreamTypeDownload.swift (100%) rename Sora/{Utils => Utlis & Misc}/JSLoader/JSController-Details.swift (100%) rename Sora/{Utils => Utlis & Misc}/JSLoader/JSController-Novel.swift (100%) rename Sora/{Utils => Utlis & Misc}/JSLoader/JSController-Search.swift (100%) rename Sora/{Utils => Utlis & Misc}/JSLoader/JSController-Streams.swift (100%) rename Sora/{Utils => Utlis & Misc}/JSLoader/JSController.swift (100%) rename Sora/{Utils => Utlis & Misc}/Logger/Logger.swift (100%) rename Sora/{Utils => Utlis & Misc}/Models/TabItem.swift (100%) rename Sora/{Utils => Utlis & Misc}/Modules/CommunityLib.swift (100%) rename Sora/{Utils => Utlis & Misc}/Modules/ModuleAdditionSettingsView.swift (100%) rename Sora/{Utils => Utlis & Misc}/Modules/ModuleManager.swift (100%) rename Sora/{Utils => Utlis & Misc}/Modules/Modules.swift (100%) rename Sora/{Utils => Utlis & Misc}/ProgressiveBlurView/ProgressiveBlurView.swift (100%) rename Sora/{Utils => Utlis & Misc}/SkeletonCells/Shimmer.swift (100%) rename Sora/{Utils => Utlis & Misc}/SkeletonCells/SkeletonCell.swift (100%) rename Sora/{Utils => Utlis & Misc}/TabBar/TabBar.swift (100%) rename Sora/{Utils => Utlis & Misc}/TabBar/TabBarController.swift (100%) rename Sora/{Utils => Utlis & Misc}/ViewModifiers/DeviceScaleModifier.swift (100%) rename Sora/{Utils => Utlis & Misc}/WebAuthentication/WebAuthenticationManager.swift (100%) diff --git a/Sora/MediaUtils/CustomPlayer/CustomPlayer.swift b/Sora/MediaUtils/CustomPlayer/CustomPlayer.swift index 21d86bb..9e1bc7d 100644 --- a/Sora/MediaUtils/CustomPlayer/CustomPlayer.swift +++ b/Sora/MediaUtils/CustomPlayer/CustomPlayer.swift @@ -1642,9 +1642,10 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele ContinueWatchingManager.shared.save(item: item) } - let remainingPercentage = (self.duration - self.currentTimeVal) / self.duration - - if remainingPercentage < 0.1 { + let remainingPercentage = (duration - currentTime) / duration + let threshold = (100.0 - (UserDefaults.standard.double(forKey: "remainingTimePercentage"))) / 100.0 ?? 0.1 + + if remainingPercentage < threshold { if self.aniListID != 0 && !self.aniListUpdatedSuccessfully && !self.aniListUpdateImpossible { self.tryAniListUpdate() } diff --git a/Sora/MediaUtils/NormalPlayer/VideoPlayer.swift b/Sora/MediaUtils/NormalPlayer/VideoPlayer.swift index 0bbb821..599b8ca 100644 --- a/Sora/MediaUtils/NormalPlayer/VideoPlayer.swift +++ b/Sora/MediaUtils/NormalPlayer/VideoPlayer.swift @@ -320,8 +320,9 @@ class VideoPlayerViewController: UIViewController { } let remainingPercentage = (duration - currentTime) / duration + let threshold = (100.0 - (UserDefaults.standard.double(forKey: "remainingTimePercentage"))) / 100.0 ?? 0.1 - if remainingPercentage < 0.1 { + if remainingPercentage < threshold { if self.aniListID != 0 && !self.aniListUpdateSent { self.sendAniListUpdate() } diff --git a/Sora/Utils/Analytics/Analytics.swift b/Sora/Utlis & Misc/Analytics/Analytics.swift similarity index 100% rename from Sora/Utils/Analytics/Analytics.swift rename to Sora/Utlis & Misc/Analytics/Analytics.swift diff --git a/Sora/Utils/DownloadManager/DownloadManager.swift b/Sora/Utlis & Misc/DownloadManager/DownloadManager.swift similarity index 100% rename from Sora/Utils/DownloadManager/DownloadManager.swift rename to Sora/Utlis & Misc/DownloadManager/DownloadManager.swift diff --git a/Sora/Utils/DownloadUtils/DownloadManager.swift b/Sora/Utlis & Misc/DownloadUtils/DownloadManager.swift similarity index 100% rename from Sora/Utils/DownloadUtils/DownloadManager.swift rename to Sora/Utlis & Misc/DownloadUtils/DownloadManager.swift diff --git a/Sora/Utils/DownloadUtils/DownloadModels.swift b/Sora/Utlis & Misc/DownloadUtils/DownloadModels.swift similarity index 100% rename from Sora/Utils/DownloadUtils/DownloadModels.swift rename to Sora/Utlis & Misc/DownloadUtils/DownloadModels.swift diff --git a/Sora/Utils/DownloadUtils/M3U8StreamExtractor.swift b/Sora/Utlis & Misc/DownloadUtils/M3U8StreamExtractor.swift similarity index 100% rename from Sora/Utils/DownloadUtils/M3U8StreamExtractor.swift rename to Sora/Utlis & Misc/DownloadUtils/M3U8StreamExtractor.swift diff --git a/Sora/Utils/Drops/DropManager.swift b/Sora/Utlis & Misc/Drops/DropManager.swift similarity index 100% rename from Sora/Utils/Drops/DropManager.swift rename to Sora/Utlis & Misc/Drops/DropManager.swift diff --git a/Sora/Utils/Extensions/JavaScriptCore+Extensions.swift b/Sora/Utlis & Misc/Extensions/JavaScriptCore+Extensions.swift similarity index 100% rename from Sora/Utils/Extensions/JavaScriptCore+Extensions.swift rename to Sora/Utlis & Misc/Extensions/JavaScriptCore+Extensions.swift diff --git a/Sora/Utils/Extensions/Notification+Name.swift b/Sora/Utlis & Misc/Extensions/Notification+Name.swift similarity index 100% rename from Sora/Utils/Extensions/Notification+Name.swift rename to Sora/Utlis & Misc/Extensions/Notification+Name.swift diff --git a/Sora/Utils/Extensions/String.swift b/Sora/Utlis & Misc/Extensions/String.swift similarity index 100% rename from Sora/Utils/Extensions/String.swift rename to Sora/Utlis & Misc/Extensions/String.swift diff --git a/Sora/Utils/Extensions/UIDevice+Model.swift b/Sora/Utlis & Misc/Extensions/UIDevice+Model.swift similarity index 100% rename from Sora/Utils/Extensions/UIDevice+Model.swift rename to Sora/Utlis & Misc/Extensions/UIDevice+Model.swift diff --git a/Sora/Utils/Extensions/URL.swift b/Sora/Utlis & Misc/Extensions/URL.swift similarity index 100% rename from Sora/Utils/Extensions/URL.swift rename to Sora/Utlis & Misc/Extensions/URL.swift diff --git a/Sora/Utils/Extensions/URLSession.swift b/Sora/Utlis & Misc/Extensions/URLSession.swift similarity index 100% rename from Sora/Utils/Extensions/URLSession.swift rename to Sora/Utlis & Misc/Extensions/URLSession.swift diff --git a/Sora/Utils/Extensions/UserDefaults.swift b/Sora/Utlis & Misc/Extensions/UserDefaults.swift similarity index 100% rename from Sora/Utils/Extensions/UserDefaults.swift rename to Sora/Utlis & Misc/Extensions/UserDefaults.swift diff --git a/Sora/Utils/Extensions/View.swift b/Sora/Utlis & Misc/Extensions/View.swift similarity index 100% rename from Sora/Utils/Extensions/View.swift rename to Sora/Utlis & Misc/Extensions/View.swift diff --git a/Sora/Utils/Extensions/finTopView.swift b/Sora/Utlis & Misc/Extensions/finTopView.swift similarity index 100% rename from Sora/Utils/Extensions/finTopView.swift rename to Sora/Utlis & Misc/Extensions/finTopView.swift diff --git a/Sora/Utils/JSLoader/Downloads/JSController+Downloader.swift b/Sora/Utlis & Misc/JSLoader/Downloads/JSController+Downloader.swift similarity index 100% rename from Sora/Utils/JSLoader/Downloads/JSController+Downloader.swift rename to Sora/Utlis & Misc/JSLoader/Downloads/JSController+Downloader.swift diff --git a/Sora/Utils/JSLoader/Downloads/JSController-Downloads.swift b/Sora/Utlis & Misc/JSLoader/Downloads/JSController-Downloads.swift similarity index 100% rename from Sora/Utils/JSLoader/Downloads/JSController-Downloads.swift rename to Sora/Utlis & Misc/JSLoader/Downloads/JSController-Downloads.swift diff --git a/Sora/Utils/JSLoader/Downloads/JSController-HeaderManager.swift b/Sora/Utlis & Misc/JSLoader/Downloads/JSController-HeaderManager.swift similarity index 100% rename from Sora/Utils/JSLoader/Downloads/JSController-HeaderManager.swift rename to Sora/Utlis & Misc/JSLoader/Downloads/JSController-HeaderManager.swift diff --git a/Sora/Utils/JSLoader/Downloads/JSController-StreamTypeDownload.swift b/Sora/Utlis & Misc/JSLoader/Downloads/JSController-StreamTypeDownload.swift similarity index 100% rename from Sora/Utils/JSLoader/Downloads/JSController-StreamTypeDownload.swift rename to Sora/Utlis & Misc/JSLoader/Downloads/JSController-StreamTypeDownload.swift diff --git a/Sora/Utils/JSLoader/JSController-Details.swift b/Sora/Utlis & Misc/JSLoader/JSController-Details.swift similarity index 100% rename from Sora/Utils/JSLoader/JSController-Details.swift rename to Sora/Utlis & Misc/JSLoader/JSController-Details.swift diff --git a/Sora/Utils/JSLoader/JSController-Novel.swift b/Sora/Utlis & Misc/JSLoader/JSController-Novel.swift similarity index 100% rename from Sora/Utils/JSLoader/JSController-Novel.swift rename to Sora/Utlis & Misc/JSLoader/JSController-Novel.swift diff --git a/Sora/Utils/JSLoader/JSController-Search.swift b/Sora/Utlis & Misc/JSLoader/JSController-Search.swift similarity index 100% rename from Sora/Utils/JSLoader/JSController-Search.swift rename to Sora/Utlis & Misc/JSLoader/JSController-Search.swift diff --git a/Sora/Utils/JSLoader/JSController-Streams.swift b/Sora/Utlis & Misc/JSLoader/JSController-Streams.swift similarity index 100% rename from Sora/Utils/JSLoader/JSController-Streams.swift rename to Sora/Utlis & Misc/JSLoader/JSController-Streams.swift diff --git a/Sora/Utils/JSLoader/JSController.swift b/Sora/Utlis & Misc/JSLoader/JSController.swift similarity index 100% rename from Sora/Utils/JSLoader/JSController.swift rename to Sora/Utlis & Misc/JSLoader/JSController.swift diff --git a/Sora/Utils/Logger/Logger.swift b/Sora/Utlis & Misc/Logger/Logger.swift similarity index 100% rename from Sora/Utils/Logger/Logger.swift rename to Sora/Utlis & Misc/Logger/Logger.swift diff --git a/Sora/Utils/Models/TabItem.swift b/Sora/Utlis & Misc/Models/TabItem.swift similarity index 100% rename from Sora/Utils/Models/TabItem.swift rename to Sora/Utlis & Misc/Models/TabItem.swift diff --git a/Sora/Utils/Modules/CommunityLib.swift b/Sora/Utlis & Misc/Modules/CommunityLib.swift similarity index 100% rename from Sora/Utils/Modules/CommunityLib.swift rename to Sora/Utlis & Misc/Modules/CommunityLib.swift diff --git a/Sora/Utils/Modules/ModuleAdditionSettingsView.swift b/Sora/Utlis & Misc/Modules/ModuleAdditionSettingsView.swift similarity index 100% rename from Sora/Utils/Modules/ModuleAdditionSettingsView.swift rename to Sora/Utlis & Misc/Modules/ModuleAdditionSettingsView.swift diff --git a/Sora/Utils/Modules/ModuleManager.swift b/Sora/Utlis & Misc/Modules/ModuleManager.swift similarity index 100% rename from Sora/Utils/Modules/ModuleManager.swift rename to Sora/Utlis & Misc/Modules/ModuleManager.swift diff --git a/Sora/Utils/Modules/Modules.swift b/Sora/Utlis & Misc/Modules/Modules.swift similarity index 100% rename from Sora/Utils/Modules/Modules.swift rename to Sora/Utlis & Misc/Modules/Modules.swift diff --git a/Sora/Utils/ProgressiveBlurView/ProgressiveBlurView.swift b/Sora/Utlis & Misc/ProgressiveBlurView/ProgressiveBlurView.swift similarity index 100% rename from Sora/Utils/ProgressiveBlurView/ProgressiveBlurView.swift rename to Sora/Utlis & Misc/ProgressiveBlurView/ProgressiveBlurView.swift diff --git a/Sora/Utils/SkeletonCells/Shimmer.swift b/Sora/Utlis & Misc/SkeletonCells/Shimmer.swift similarity index 100% rename from Sora/Utils/SkeletonCells/Shimmer.swift rename to Sora/Utlis & Misc/SkeletonCells/Shimmer.swift diff --git a/Sora/Utils/SkeletonCells/SkeletonCell.swift b/Sora/Utlis & Misc/SkeletonCells/SkeletonCell.swift similarity index 100% rename from Sora/Utils/SkeletonCells/SkeletonCell.swift rename to Sora/Utlis & Misc/SkeletonCells/SkeletonCell.swift diff --git a/Sora/Utils/TabBar/TabBar.swift b/Sora/Utlis & Misc/TabBar/TabBar.swift similarity index 100% rename from Sora/Utils/TabBar/TabBar.swift rename to Sora/Utlis & Misc/TabBar/TabBar.swift diff --git a/Sora/Utils/TabBar/TabBarController.swift b/Sora/Utlis & Misc/TabBar/TabBarController.swift similarity index 100% rename from Sora/Utils/TabBar/TabBarController.swift rename to Sora/Utlis & Misc/TabBar/TabBarController.swift diff --git a/Sora/Utils/ViewModifiers/DeviceScaleModifier.swift b/Sora/Utlis & Misc/ViewModifiers/DeviceScaleModifier.swift similarity index 100% rename from Sora/Utils/ViewModifiers/DeviceScaleModifier.swift rename to Sora/Utlis & Misc/ViewModifiers/DeviceScaleModifier.swift diff --git a/Sora/Utils/WebAuthentication/WebAuthenticationManager.swift b/Sora/Utlis & Misc/WebAuthentication/WebAuthenticationManager.swift similarity index 100% rename from Sora/Utils/WebAuthentication/WebAuthenticationManager.swift rename to Sora/Utlis & Misc/WebAuthentication/WebAuthenticationManager.swift diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift index 6aba3dc..48ea05d 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift @@ -199,6 +199,7 @@ struct SettingsViewPlayer: View { @AppStorage("holdSpeedPlayer") private var holdSpeedPlayer: Double = 2.0 @AppStorage("skipIncrement") private var skipIncrement: Double = 10.0 @AppStorage("skipIncrementHold") private var skipIncrementHold: Double = 30.0 + @AppStorage("remainingTimePercentage") private var remainingTimePercentage: Double = 90.0 @AppStorage("holdForPauseEnabled") private var holdForPauseEnabled = false @AppStorage("skip85Visible") private var skip85Visible: Bool = true @AppStorage("doubleTapSeekEnabled") private var doubleTapSeekEnabled: Bool = false @@ -216,7 +217,7 @@ struct SettingsViewPlayer: View { VStack(spacing: 24) { SettingsSection( title: NSLocalizedString("Media Player", comment: ""), - footer: NSLocalizedString("Some features are limited to the Sora and Default player, such as ForceLandscape, holdSpeed and custom time skip increments.", comment: "") + footer: NSLocalizedString("Some features are limited to the Sora and Default player, such as ForceLandscape, holdSpeed and custom time skip increments.\n\nThe completion percentage setting determines at what point before the end of a video the app will mark it as completed on AniList and Trakt.", comment: "") ) { SettingsPickerRow( icon: "play.circle", @@ -245,6 +246,14 @@ struct SettingsViewPlayer: View { isOn: $pipButtonVisible, showDivider: false ) + + SettingsPickerRow( + icon: "timer", + title: NSLocalizedString("Completion Percentage", comment: ""), + options: [60.0, 70.0, 80.0, 90.0, 95.0, 100.0], + optionToString: { "\(Int($0))%" }, + selection: $remainingTimePercentage + ) } SettingsSection(title: NSLocalizedString("Speed Settings", comment: "")) { diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewTrackers.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewTrackers.swift index 65c489e..6edc64a 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewTrackers.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewTrackers.swift @@ -213,7 +213,7 @@ struct SettingsViewTrackers: View { SettingsSection(title: NSLocalizedString("Trakt", comment: "")) { VStack(spacing: 0) { HStack(alignment: .center, spacing: 10) { - LazyImage(url: URL(string: "https://static-00.iconduck.com/assets.00/trakt-icon-2048x2048-2633ksxg.png")) { state in + LazyImage(url: URL(string: "https://cdn.iconscout.com/icon/free/png-512/free-trakt-logo-icon-download-in-svg-png-gif-file-formats--technology-social-media-company-vol-7-pack-logos-icons-2945267.png?f=webp&w=512")) { state in if let uiImage = state.imageContainer?.image { Image(uiImage: uiImage) .resizable() @@ -304,7 +304,7 @@ struct SettingsViewTrackers: View { } SettingsSection( - title: NSLocalizedString("Info", comment: ""), + title: "", footer: NSLocalizedString("Sora and cranci1 are not affiliated with AniList or Trakt in any way.\n\nAlso note that progress updates may not be 100% accurate.", comment: "") ) {} } diff --git a/Sulfur.xcodeproj/project.pbxproj b/Sulfur.xcodeproj/project.pbxproj index 102fb60..f43777b 100644 --- a/Sulfur.xcodeproj/project.pbxproj +++ b/Sulfur.xcodeproj/project.pbxproj @@ -42,7 +42,6 @@ 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 */; }; - 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 */; }; 13103E8E2D58E04A000F0673 /* SkeletonCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13103E8D2D58E04A000F0673 /* SkeletonCell.swift */; }; @@ -78,6 +77,7 @@ 136BBE802DB1038000906B5E /* Notification+Name.swift in Sources */ = {isa = PBXBuildFile; fileRef = 136BBE7F2DB1038000906B5E /* Notification+Name.swift */; }; 138AA1B82D2D66FD0021F9DF /* EpisodeCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138AA1B62D2D66FD0021F9DF /* EpisodeCell.swift */; }; 138AA1B92D2D66FD0021F9DF /* CircularProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138AA1B72D2D66FD0021F9DF /* CircularProgressBar.swift */; }; + 138B66A02E0BEA52009BE8D9 /* WebAuthenticationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138B669F2E0BEA52009BE8D9 /* WebAuthenticationManager.swift */; }; 138FE1D02DECA00D00936D81 /* TMDB-FetchID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138FE1CF2DECA00D00936D81 /* TMDB-FetchID.swift */; }; 1398FB3F2DE4E161004D3F5F /* SettingsViewAbout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1398FB3E2DE4E161004D3F5F /* SettingsViewAbout.swift */; }; 139935662D468C450065CEFF /* ModuleManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 139935652D468C450065CEFF /* ModuleManager.swift */; }; @@ -157,7 +157,6 @@ 04F08EDB2DE10BEC006B29D9 /* ProgressiveBlurView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressiveBlurView.swift; sourceTree = ""; }; 04F08EDE2DE10C1A006B29D9 /* TabBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBar.swift; sourceTree = ""; }; 04F08EE12DE10C27006B29D9 /* TabItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabItem.swift; sourceTree = ""; }; - 130326B52DF979AC00AEF610 /* WebAuthenticationManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebAuthenticationManager.swift; sourceTree = ""; }; 130C6BF82D53A4C200DC1432 /* Sora.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Sora.entitlements; sourceTree = ""; }; 130C6BF92D53AB1F00DC1432 /* SettingsViewData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewData.swift; sourceTree = ""; }; 13103E8A2D58E028000F0673 /* View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = View.swift; sourceTree = ""; }; @@ -190,6 +189,7 @@ 136BBE7F2DB1038000906B5E /* Notification+Name.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Notification+Name.swift"; sourceTree = ""; }; 138AA1B62D2D66FD0021F9DF /* EpisodeCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EpisodeCell.swift; sourceTree = ""; }; 138AA1B72D2D66FD0021F9DF /* CircularProgressBar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CircularProgressBar.swift; sourceTree = ""; }; + 138B669F2E0BEA52009BE8D9 /* WebAuthenticationManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebAuthenticationManager.swift; sourceTree = ""; }; 138FE1CF2DECA00D00936D81 /* TMDB-FetchID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TMDB-FetchID.swift"; sourceTree = ""; }; 1398FB3E2DE4E161004D3F5F /* SettingsViewAbout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewAbout.swift; sourceTree = ""; }; 139935652D468C450065CEFF /* ModuleManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModuleManager.swift; sourceTree = ""; }; @@ -437,15 +437,6 @@ path = Models; sourceTree = ""; }; - 130326B42DF979A300AEF610 /* WebAuthentication */ = { - isa = PBXGroup; - children = ( - 130326B52DF979AC00AEF610 /* WebAuthenticationManager.swift */, - ); - name = WebAuthentication; - path = Sora/Utils/WebAuthentication; - sourceTree = SOURCE_ROOT; - }; 13103E802D589D6C000F0673 /* Tracking & Metadata */ = { isa = PBXGroup; children = ( @@ -522,9 +513,9 @@ children = ( 13103E802D589D6C000F0673 /* Tracking & Metadata */, 13530BE02E00028E0048B7DE /* Localization */, + 133D7C852D2BE2640075467E /* Utlis & Misc */, 133CF6A92DFEBEAB00BD13F9 /* MediaUtils */, 133D7C7B2D2BE2630075467E /* Views */, - 133D7C852D2BE2640075467E /* Utils */, 13DC0C412D2EC9BA00D0F966 /* Info.plist */, 133D7C6D2D2BE2500075467E /* SoraApp.swift */, 133D7C712D2BE2520075467E /* Assets.xcassets */, @@ -584,11 +575,11 @@ path = SettingsSubViews; sourceTree = ""; }; - 133D7C852D2BE2640075467E /* Utils */ = { + 133D7C852D2BE2640075467E /* Utlis & Misc */ = { isa = PBXGroup; children = ( 04F08EDA2DE10BE3006B29D9 /* ProgressiveBlurView */, - 130326B42DF979A300AEF610 /* WebAuthentication */, + 138B669E2E0BEA52009BE8D9 /* WebAuthentication */, 0457C5962DE7712A000AFBD9 /* ViewModifiers */, 04F08EE02DE10C22006B29D9 /* Models */, 04F08EDD2DE10C05006B29D9 /* TabBar */, @@ -601,7 +592,7 @@ 13103E8C2D58E037000F0673 /* SkeletonCells */, 72443C832DC8046500A61321 /* DownloadUtils */, ); - path = Utils; + path = "Utlis & Misc"; sourceTree = ""; }; 133D7C862D2BE2640075467E /* Extensions */ = { @@ -705,6 +696,14 @@ path = EpisodeCell; sourceTree = ""; }; + 138B669E2E0BEA52009BE8D9 /* WebAuthentication */ = { + isa = PBXGroup; + children = ( + 138B669F2E0BEA52009BE8D9 /* WebAuthenticationManager.swift */, + ); + path = WebAuthentication; + sourceTree = ""; + }; 138FE1CE2DEC9FFA00936D81 /* TMDB */ = { isa = PBXGroup; children = ( @@ -968,7 +967,6 @@ 1E26E9E72DA9577900B9DC02 /* VolumeSlider.swift in Sources */, 136BBE802DB1038000906B5E /* Notification+Name.swift in Sources */, 13DB46902D900A38008CBC03 /* URL.swift in Sources */, - 130326B62DF979AC00AEF610 /* WebAuthenticationManager.swift in Sources */, 138FE1D02DECA00D00936D81 /* TMDB-FetchID.swift in Sources */, 04AD07162E03704700EB74C1 /* BookmarkCell.swift in Sources */, 130C6BFA2D53AB1F00DC1432 /* SettingsViewData.swift in Sources */, @@ -978,6 +976,7 @@ 1E47859B2DEBC5960095BF2F /* AnilistMatchPopupView.swift in Sources */, 13EA2BD92D32D98400C1EBD7 /* NormalPlayer.swift in Sources */, 133D7C932D2BE2640075467E /* Modules.swift in Sources */, + 138B66A02E0BEA52009BE8D9 /* WebAuthenticationManager.swift in Sources */, 0457C5972DE7712A000AFBD9 /* DeviceScaleModifier.swift in Sources */, 13DB7CC32D7D99C0004371D3 /* SubtitleSettingsManager.swift in Sources */, 133D7C702D2BE2500075467E /* ContentView.swift in Sources */, From 4ce60d957d3004d78390d18068425aa2cc504cab Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Wed, 25 Jun 2025 10:37:09 +0200 Subject: [PATCH 08/45] fixed nil case + error --- Sora/MediaUtils/CustomPlayer/CustomPlayer.swift | 5 +++-- Sora/MediaUtils/NormalPlayer/VideoPlayer.swift | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Sora/MediaUtils/CustomPlayer/CustomPlayer.swift b/Sora/MediaUtils/CustomPlayer/CustomPlayer.swift index 9e1bc7d..d64eab4 100644 --- a/Sora/MediaUtils/CustomPlayer/CustomPlayer.swift +++ b/Sora/MediaUtils/CustomPlayer/CustomPlayer.swift @@ -1642,8 +1642,9 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele ContinueWatchingManager.shared.save(item: item) } - let remainingPercentage = (duration - currentTime) / duration - let threshold = (100.0 - (UserDefaults.standard.double(forKey: "remainingTimePercentage"))) / 100.0 ?? 0.1 + let remainingPercentage = (self.duration - self.currentTimeVal) / self.duration + let remainingTimePercentage = UserDefaults.standard.object(forKey: "remainingTimePercentage") != nil ? UserDefaults.standard.double(forKey: "remainingTimePercentage") : 90.0 + let threshold = (100.0 - remainingTimePercentage) / 100.0 if remainingPercentage < threshold { if self.aniListID != 0 && !self.aniListUpdatedSuccessfully && !self.aniListUpdateImpossible { diff --git a/Sora/MediaUtils/NormalPlayer/VideoPlayer.swift b/Sora/MediaUtils/NormalPlayer/VideoPlayer.swift index 599b8ca..6ffabd0 100644 --- a/Sora/MediaUtils/NormalPlayer/VideoPlayer.swift +++ b/Sora/MediaUtils/NormalPlayer/VideoPlayer.swift @@ -320,7 +320,8 @@ class VideoPlayerViewController: UIViewController { } let remainingPercentage = (duration - currentTime) / duration - let threshold = (100.0 - (UserDefaults.standard.double(forKey: "remainingTimePercentage"))) / 100.0 ?? 0.1 + let remainingTimePercentage = UserDefaults.standard.object(forKey: "remainingTimePercentage") != nil ? UserDefaults.standard.double(forKey: "remainingTimePercentage") : 90.0 + let threshold = (100.0 - remainingTimePercentage) / 100.0 if remainingPercentage < threshold { if self.aniListID != 0 && !self.aniListUpdateSent { From 9b29b40ff3bc29a1579b37b8e76c807df986c3e3 Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Wed, 25 Jun 2025 10:44:01 +0200 Subject: [PATCH 09/45] Circular Progress fixes + Continue watching fixes --- .../ContinueWatchingManager.swift | 110 +++++++++--------- .../EpisodeCell/CircularProgressBar.swift | 5 +- 2 files changed, 60 insertions(+), 55 deletions(-) diff --git a/Sora/MediaUtils/ContinueWatching/ContinueWatchingManager.swift b/Sora/MediaUtils/ContinueWatching/ContinueWatchingManager.swift index 828e31f..5208456 100644 --- a/Sora/MediaUtils/ContinueWatching/ContinueWatchingManager.swift +++ b/Sora/MediaUtils/ContinueWatching/ContinueWatchingManager.swift @@ -11,49 +11,51 @@ class ContinueWatchingManager { static let shared = ContinueWatchingManager() private let storageKey = "continueWatchingItems" private let lastCleanupKey = "lastContinueWatchingCleanup" - + private init() { NotificationCenter.default.addObserver(self, selector: #selector(handleiCloudSync), name: .iCloudSyncDidComplete, object: nil) performCleanupIfNeeded() } - + @objc private func handleiCloudSync() { NotificationCenter.default.post(name: .ContinueWatchingDidUpdate, object: nil) } - + private func performCleanupIfNeeded() { let lastCleanup = UserDefaults.standard.double(forKey: lastCleanupKey) let currentTime = Date().timeIntervalSince1970 - + if currentTime - lastCleanup > 86400 { cleanupOldEpisodes() UserDefaults.standard.set(currentTime, forKey: lastCleanupKey) } } - + private func cleanupOldEpisodes() { var items = fetchItems() var itemsToRemove: Set = [] - + let groupedItems = Dictionary(grouping: items) { item in let title = item.mediaTitle.replacingOccurrences(of: "Episode \\d+.*$", with: "", options: .regularExpression) .trimmingCharacters(in: .whitespacesAndNewlines) return title } - + for (_, showEpisodes) in groupedItems { let sortedEpisodes = showEpisodes.sorted { $0.episodeNumber < $1.episodeNumber } - + for i in 0..= 0.8 && nextEpisode.episodeNumber > currentEpisode.episodeNumber { + + let remainingTimePercentage = UserDefaults.standard.object(forKey: "remainingTimePercentage") != nil ? UserDefaults.standard.double(forKey: "remainingTimePercentage") : 90.0 + let threshold = (100.0 - remainingTimePercentage) / 100.0 + if currentEpisode.progress >= threshold && nextEpisode.episodeNumber > currentEpisode.episodeNumber { itemsToRemove.insert(currentEpisode.id) } } } - + if !itemsToRemove.isEmpty { items.removeAll { itemsToRemove.contains($0.id) } if let data = try? JSONEncoder().encode(items) { @@ -61,59 +63,59 @@ class ContinueWatchingManager { } } } - + func save(item: ContinueWatchingItem) { - // Use real playback times let lastKey = "lastPlayedTime_\(item.fullUrl)" let totalKey = "totalTime_\(item.fullUrl)" let lastPlayed = UserDefaults.standard.double(forKey: lastKey) let totalTime = UserDefaults.standard.double(forKey: totalKey) - - // Compute up-to-date progress + let actualProgress: Double if totalTime > 0 { actualProgress = min(max(lastPlayed / totalTime, 0), 1) } else { actualProgress = item.progress - } - - // If watched ≥ 90%, remove it - if actualProgress >= 0.9 { - remove(item: item) - return - } - - // Otherwise update progress and remove old episodes from the same show - var updatedItem = item - updatedItem.progress = actualProgress - - var items = fetchItems() - - let showTitle = item.mediaTitle.replacingOccurrences(of: "Episode \\d+.*$", with: "", options: .regularExpression) - .trimmingCharacters(in: .whitespacesAndNewlines) - - items.removeAll { existingItem in - let existingShowTitle = existingItem.mediaTitle.replacingOccurrences(of: "Episode \\d+.*$", with: "", options: .regularExpression) + + let remainingTimePercentage = UserDefaults.standard.object(forKey: "remainingTimePercentage") != nil ? UserDefaults.standard.double(forKey: "remainingTimePercentage") : 90.0 + let threshold = (100.0 - remainingTimePercentage) / 100.0 + if actualProgress >= threshold { + remove(item: item) + return + } + + var updatedItem = item + updatedItem.progress = actualProgress + + var items = fetchItems() + + let showTitle = item.mediaTitle.replacingOccurrences(of: "Episode \\d+.*$", with: "", options: .regularExpression) .trimmingCharacters(in: .whitespacesAndNewlines) - - return showTitle == existingShowTitle && - existingItem.episodeNumber < item.episodeNumber && - existingItem.progress >= 0.8 - } - - items.removeAll { existing in - existing.fullUrl == item.fullUrl && - existing.episodeNumber == item.episodeNumber && - existing.module.metadata.sourceName == item.module.metadata.sourceName - } - - items.append(updatedItem) - - if let data = try? JSONEncoder().encode(items) { - UserDefaults.standard.set(data, forKey: storageKey) + + items.removeAll { existingItem in + let existingShowTitle = existingItem.mediaTitle.replacingOccurrences(of: "Episode \\d+.*$", with: "", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + + let remainingTimePercentage = UserDefaults.standard.object(forKey: "remainingTimePercentage") != nil ? UserDefaults.standard.double(forKey: "remainingTimePercentage") : 90.0 + let threshold = (100.0 - remainingTimePercentage) / 100.0 + return showTitle == existingShowTitle && + existingItem.episodeNumber < item.episodeNumber && + existingItem.progress >= threshold + } + + items.removeAll { existing in + existing.fullUrl == item.fullUrl && + existing.episodeNumber == item.episodeNumber && + existing.module.metadata.sourceName == item.module.metadata.sourceName + } + + items.append(updatedItem) + + if let data = try? JSONEncoder().encode(items) { + UserDefaults.standard.set(data, forKey: storageKey) + } } } - + func fetchItems() -> [ContinueWatchingItem] { guard let data = UserDefaults.standard.data(forKey: storageKey), @@ -121,7 +123,7 @@ class ContinueWatchingManager { else { return [] } - + var seen = Set() let unique = raw.reversed().filter { item in let key = "\(item.fullUrl)|\(item.module.metadata.sourceName)|\(item.episodeNumber)" @@ -132,10 +134,10 @@ class ContinueWatchingManager { return true } }.reversed() - + return Array(unique) } - + func remove(item: ContinueWatchingItem) { var items = fetchItems() items.removeAll { $0.id == item.id } diff --git a/Sora/Views/MediaInfoView/EpisodeCell/CircularProgressBar.swift b/Sora/Views/MediaInfoView/EpisodeCell/CircularProgressBar.swift index 974d1ae..c58cc87 100644 --- a/Sora/Views/MediaInfoView/EpisodeCell/CircularProgressBar.swift +++ b/Sora/Views/MediaInfoView/EpisodeCell/CircularProgressBar.swift @@ -24,7 +24,10 @@ struct CircularProgressBar: View { .rotationEffect(Angle(degrees: 270.0)) .animation(.linear, value: progress) - if progress >= 0.9 { + let remainingTimePercentage = UserDefaults.standard.object(forKey: "remainingTimePercentage") != nil ? UserDefaults.standard.double(forKey: "remainingTimePercentage") : 90.0 + let threshold = (100.0 - remainingTimePercentage) / 100.0 + + if progress >= threshold { Image(systemName: "checkmark") .font(.system(size: 12)) } else { From b64f44c341a6d2a6681799b6886b215e7cf07708 Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Wed, 25 Jun 2025 10:45:38 +0200 Subject: [PATCH 10/45] made players use <= for better results --- Sora/MediaUtils/CustomPlayer/CustomPlayer.swift | 2 +- Sora/MediaUtils/NormalPlayer/VideoPlayer.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sora/MediaUtils/CustomPlayer/CustomPlayer.swift b/Sora/MediaUtils/CustomPlayer/CustomPlayer.swift index d64eab4..ad80f74 100644 --- a/Sora/MediaUtils/CustomPlayer/CustomPlayer.swift +++ b/Sora/MediaUtils/CustomPlayer/CustomPlayer.swift @@ -1646,7 +1646,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele let remainingTimePercentage = UserDefaults.standard.object(forKey: "remainingTimePercentage") != nil ? UserDefaults.standard.double(forKey: "remainingTimePercentage") : 90.0 let threshold = (100.0 - remainingTimePercentage) / 100.0 - if remainingPercentage < threshold { + if remainingPercentage <= threshold { if self.aniListID != 0 && !self.aniListUpdatedSuccessfully && !self.aniListUpdateImpossible { self.tryAniListUpdate() } diff --git a/Sora/MediaUtils/NormalPlayer/VideoPlayer.swift b/Sora/MediaUtils/NormalPlayer/VideoPlayer.swift index 6ffabd0..d6241c6 100644 --- a/Sora/MediaUtils/NormalPlayer/VideoPlayer.swift +++ b/Sora/MediaUtils/NormalPlayer/VideoPlayer.swift @@ -323,7 +323,7 @@ class VideoPlayerViewController: UIViewController { let remainingTimePercentage = UserDefaults.standard.object(forKey: "remainingTimePercentage") != nil ? UserDefaults.standard.double(forKey: "remainingTimePercentage") : 90.0 let threshold = (100.0 - remainingTimePercentage) / 100.0 - if remainingPercentage < threshold { + if remainingPercentage <= threshold { if self.aniListID != 0 && !self.aniListUpdateSent { self.sendAniListUpdate() } From 54c75da75607de2f92beef9aa4198b6ecbac194d Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Wed, 25 Jun 2025 10:49:46 +0200 Subject: [PATCH 11/45] test label --- .../Utlis & Misc/Extensions/MarqueeText.swift | 61 +++++++++++++++++++ Sora/Views/SearchView/SearchResultsGrid.swift | 5 +- Sulfur.xcodeproj/project.pbxproj | 4 ++ 3 files changed, 67 insertions(+), 3 deletions(-) create mode 100644 Sora/Utlis & Misc/Extensions/MarqueeText.swift diff --git a/Sora/Utlis & Misc/Extensions/MarqueeText.swift b/Sora/Utlis & Misc/Extensions/MarqueeText.swift new file mode 100644 index 0000000..6eae135 --- /dev/null +++ b/Sora/Utlis & Misc/Extensions/MarqueeText.swift @@ -0,0 +1,61 @@ +// +// MarqueeText.swift +// Sulfur +// +// Created by Francesco on 25/06/25. +// + +import SwiftUI + +struct MarqueeText: View { + let text: String + let font: Font + let color: Color + + @State private var animate = false + @State private var textSize: CGSize = .zero + @State private var containerWidth: CGFloat = 0 + + init(_ text: String, font: Font = .body, color: Color = .white) { + self.text = text + self.font = font + self.color = color + } + + var body: some View { + GeometryReader { geometry in + if textSize.width > geometry.size.width { + ScrollView(.horizontal, showsIndicators: false) { + Text(text) + .font(font) + .foregroundColor(color) + .lineLimit(1) + .offset(x: animate ? -textSize.width - 20 : geometry.size.width) + .onAppear { + containerWidth = geometry.size.width + withAnimation(Animation.linear(duration: Double(textSize.width + containerWidth) / 30.0) + .repeatForever(autoreverses: false)) { + animate = true + } + } + } + } else { + Text(text) + .font(font) + .foregroundColor(color) + .lineLimit(1) + } + } + .background( + Text(text) + .font(font) + .lineLimit(1) + .hidden() + .background(GeometryReader { geometry in + Color.clear.onAppear { + textSize = geometry.size + } + }) + ) + } +} diff --git a/Sora/Views/SearchView/SearchResultsGrid.swift b/Sora/Views/SearchView/SearchResultsGrid.swift index 28b02be..3239b6c 100644 --- a/Sora/Views/SearchView/SearchResultsGrid.swift +++ b/Sora/Views/SearchView/SearchResultsGrid.swift @@ -57,9 +57,8 @@ struct SearchResultsGrid: View { VStack { Spacer() HStack { - Text(item.title) - .lineLimit(2) - .foregroundColor(.white) + MarqueeText(item.title) + .frame(height: 20) .multilineTextAlignment(.leading) Spacer() } diff --git a/Sulfur.xcodeproj/project.pbxproj b/Sulfur.xcodeproj/project.pbxproj index f43777b..d4c3b01 100644 --- a/Sulfur.xcodeproj/project.pbxproj +++ b/Sulfur.xcodeproj/project.pbxproj @@ -78,6 +78,7 @@ 138AA1B82D2D66FD0021F9DF /* EpisodeCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138AA1B62D2D66FD0021F9DF /* EpisodeCell.swift */; }; 138AA1B92D2D66FD0021F9DF /* CircularProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138AA1B72D2D66FD0021F9DF /* CircularProgressBar.swift */; }; 138B66A02E0BEA52009BE8D9 /* WebAuthenticationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138B669F2E0BEA52009BE8D9 /* WebAuthenticationManager.swift */; }; + 138B66A42E0BEF64009BE8D9 /* MarqueeText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138B66A32E0BEF64009BE8D9 /* MarqueeText.swift */; }; 138FE1D02DECA00D00936D81 /* TMDB-FetchID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138FE1CF2DECA00D00936D81 /* TMDB-FetchID.swift */; }; 1398FB3F2DE4E161004D3F5F /* SettingsViewAbout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1398FB3E2DE4E161004D3F5F /* SettingsViewAbout.swift */; }; 139935662D468C450065CEFF /* ModuleManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 139935652D468C450065CEFF /* ModuleManager.swift */; }; @@ -190,6 +191,7 @@ 138AA1B62D2D66FD0021F9DF /* EpisodeCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EpisodeCell.swift; sourceTree = ""; }; 138AA1B72D2D66FD0021F9DF /* CircularProgressBar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CircularProgressBar.swift; sourceTree = ""; }; 138B669F2E0BEA52009BE8D9 /* WebAuthenticationManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebAuthenticationManager.swift; sourceTree = ""; }; + 138B66A32E0BEF64009BE8D9 /* MarqueeText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarqueeText.swift; sourceTree = ""; }; 138FE1CF2DECA00D00936D81 /* TMDB-FetchID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TMDB-FetchID.swift"; sourceTree = ""; }; 1398FB3E2DE4E161004D3F5F /* SettingsViewAbout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewAbout.swift; sourceTree = ""; }; 139935652D468C450065CEFF /* ModuleManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModuleManager.swift; sourceTree = ""; }; @@ -602,6 +604,7 @@ 136BBE7F2DB1038000906B5E /* Notification+Name.swift */, 1327FBA82D758DEA00FC6689 /* UIDevice+Model.swift */, 13637B892DE0EA1100BDA2FC /* UserDefaults.swift */, + 138B66A32E0BEF64009BE8D9 /* MarqueeText.swift */, 133D7C872D2BE2640075467E /* URLSession.swift */, 1359ED132D76F49900C13034 /* finTopView.swift */, 13CBEFD92D5F7D1200D011EE /* String.swift */, @@ -972,6 +975,7 @@ 130C6BFA2D53AB1F00DC1432 /* SettingsViewData.swift in Sources */, 0402DA172DE7B7B8003BB42C /* SearchViewComponents.swift in Sources */, 1E9FF1D32D403E49008AC100 /* SettingsViewLoggerFilter.swift in Sources */, + 138B66A42E0BEF64009BE8D9 /* MarqueeText.swift in Sources */, 0402DA0E2DE7AA01003BB42C /* TabBarController.swift in Sources */, 1E47859B2DEBC5960095BF2F /* AnilistMatchPopupView.swift in Sources */, 13EA2BD92D32D98400C1EBD7 /* NormalPlayer.swift in Sources */, From 9bafb790cfa2bb46a290ab54c3057e34bff3f48d Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Wed, 25 Jun 2025 10:55:47 +0200 Subject: [PATCH 12/45] Revert "test label" This reverts commit 54c75da75607de2f92beef9aa4198b6ecbac194d. --- .../Utlis & Misc/Extensions/MarqueeText.swift | 61 ------------------- Sora/Views/SearchView/SearchResultsGrid.swift | 5 +- Sulfur.xcodeproj/project.pbxproj | 4 -- 3 files changed, 3 insertions(+), 67 deletions(-) delete mode 100644 Sora/Utlis & Misc/Extensions/MarqueeText.swift diff --git a/Sora/Utlis & Misc/Extensions/MarqueeText.swift b/Sora/Utlis & Misc/Extensions/MarqueeText.swift deleted file mode 100644 index 6eae135..0000000 --- a/Sora/Utlis & Misc/Extensions/MarqueeText.swift +++ /dev/null @@ -1,61 +0,0 @@ -// -// MarqueeText.swift -// Sulfur -// -// Created by Francesco on 25/06/25. -// - -import SwiftUI - -struct MarqueeText: View { - let text: String - let font: Font - let color: Color - - @State private var animate = false - @State private var textSize: CGSize = .zero - @State private var containerWidth: CGFloat = 0 - - init(_ text: String, font: Font = .body, color: Color = .white) { - self.text = text - self.font = font - self.color = color - } - - var body: some View { - GeometryReader { geometry in - if textSize.width > geometry.size.width { - ScrollView(.horizontal, showsIndicators: false) { - Text(text) - .font(font) - .foregroundColor(color) - .lineLimit(1) - .offset(x: animate ? -textSize.width - 20 : geometry.size.width) - .onAppear { - containerWidth = geometry.size.width - withAnimation(Animation.linear(duration: Double(textSize.width + containerWidth) / 30.0) - .repeatForever(autoreverses: false)) { - animate = true - } - } - } - } else { - Text(text) - .font(font) - .foregroundColor(color) - .lineLimit(1) - } - } - .background( - Text(text) - .font(font) - .lineLimit(1) - .hidden() - .background(GeometryReader { geometry in - Color.clear.onAppear { - textSize = geometry.size - } - }) - ) - } -} diff --git a/Sora/Views/SearchView/SearchResultsGrid.swift b/Sora/Views/SearchView/SearchResultsGrid.swift index 3239b6c..28b02be 100644 --- a/Sora/Views/SearchView/SearchResultsGrid.swift +++ b/Sora/Views/SearchView/SearchResultsGrid.swift @@ -57,8 +57,9 @@ struct SearchResultsGrid: View { VStack { Spacer() HStack { - MarqueeText(item.title) - .frame(height: 20) + Text(item.title) + .lineLimit(2) + .foregroundColor(.white) .multilineTextAlignment(.leading) Spacer() } diff --git a/Sulfur.xcodeproj/project.pbxproj b/Sulfur.xcodeproj/project.pbxproj index d4c3b01..f43777b 100644 --- a/Sulfur.xcodeproj/project.pbxproj +++ b/Sulfur.xcodeproj/project.pbxproj @@ -78,7 +78,6 @@ 138AA1B82D2D66FD0021F9DF /* EpisodeCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138AA1B62D2D66FD0021F9DF /* EpisodeCell.swift */; }; 138AA1B92D2D66FD0021F9DF /* CircularProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138AA1B72D2D66FD0021F9DF /* CircularProgressBar.swift */; }; 138B66A02E0BEA52009BE8D9 /* WebAuthenticationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138B669F2E0BEA52009BE8D9 /* WebAuthenticationManager.swift */; }; - 138B66A42E0BEF64009BE8D9 /* MarqueeText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138B66A32E0BEF64009BE8D9 /* MarqueeText.swift */; }; 138FE1D02DECA00D00936D81 /* TMDB-FetchID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138FE1CF2DECA00D00936D81 /* TMDB-FetchID.swift */; }; 1398FB3F2DE4E161004D3F5F /* SettingsViewAbout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1398FB3E2DE4E161004D3F5F /* SettingsViewAbout.swift */; }; 139935662D468C450065CEFF /* ModuleManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 139935652D468C450065CEFF /* ModuleManager.swift */; }; @@ -191,7 +190,6 @@ 138AA1B62D2D66FD0021F9DF /* EpisodeCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EpisodeCell.swift; sourceTree = ""; }; 138AA1B72D2D66FD0021F9DF /* CircularProgressBar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CircularProgressBar.swift; sourceTree = ""; }; 138B669F2E0BEA52009BE8D9 /* WebAuthenticationManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebAuthenticationManager.swift; sourceTree = ""; }; - 138B66A32E0BEF64009BE8D9 /* MarqueeText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarqueeText.swift; sourceTree = ""; }; 138FE1CF2DECA00D00936D81 /* TMDB-FetchID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TMDB-FetchID.swift"; sourceTree = ""; }; 1398FB3E2DE4E161004D3F5F /* SettingsViewAbout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewAbout.swift; sourceTree = ""; }; 139935652D468C450065CEFF /* ModuleManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModuleManager.swift; sourceTree = ""; }; @@ -604,7 +602,6 @@ 136BBE7F2DB1038000906B5E /* Notification+Name.swift */, 1327FBA82D758DEA00FC6689 /* UIDevice+Model.swift */, 13637B892DE0EA1100BDA2FC /* UserDefaults.swift */, - 138B66A32E0BEF64009BE8D9 /* MarqueeText.swift */, 133D7C872D2BE2640075467E /* URLSession.swift */, 1359ED132D76F49900C13034 /* finTopView.swift */, 13CBEFD92D5F7D1200D011EE /* String.swift */, @@ -975,7 +972,6 @@ 130C6BFA2D53AB1F00DC1432 /* SettingsViewData.swift in Sources */, 0402DA172DE7B7B8003BB42C /* SearchViewComponents.swift in Sources */, 1E9FF1D32D403E49008AC100 /* SettingsViewLoggerFilter.swift in Sources */, - 138B66A42E0BEF64009BE8D9 /* MarqueeText.swift in Sources */, 0402DA0E2DE7AA01003BB42C /* TabBarController.swift in Sources */, 1E47859B2DEBC5960095BF2F /* AnilistMatchPopupView.swift in Sources */, 13EA2BD92D32D98400C1EBD7 /* NormalPlayer.swift in Sources */, From 4b4bb0ad7b55c841421eabdbb745aaf91be610d1 Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Wed, 25 Jun 2025 10:58:16 +0200 Subject: [PATCH 13/45] fixed UI --- Sora/Utlis & Misc/JSLoader/JSController.swift | 12 +++--------- .../SettingsSubViews/SettingsViewPlayer.swift | 5 +++-- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/Sora/Utlis & Misc/JSLoader/JSController.swift b/Sora/Utlis & Misc/JSLoader/JSController.swift index 4cc7f60..5024428 100644 --- a/Sora/Utlis & Misc/JSLoader/JSController.swift +++ b/Sora/Utlis & Misc/JSLoader/JSController.swift @@ -5,11 +5,11 @@ // Created by Francesco on 05/01/25. // -import JavaScriptCore -import Foundation -import SwiftUI import AVKit +import SwiftUI +import Foundation import AVFoundation +import JavaScriptCore typealias Module = ScrapingModule @@ -42,7 +42,6 @@ class JSController: NSObject, ObservableObject { func setupContext() { context.setupJavaScriptEnvironment() - // Inject async Promise bridge for extractChapters with debug logging let asyncChaptersHelper = """ function extractChaptersWithCallback(href, callback) { try { @@ -67,7 +66,6 @@ class JSController: NSObject, ObservableObject { } """ context.evaluateScript(asyncChaptersHelper) - // Print JS exceptions to Xcode console context.exceptionHandler = { context, exception in print("[JS Exception]", exception?.toString() ?? "unknown") } @@ -101,10 +99,6 @@ class JSController: NSObject, ObservableObject { self?.processDownloadQueue() } } - } else { - Logger.shared.log("No queued downloads to process or queue is already being processed") } } } - - diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift index 48ea05d..ccbca67 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift @@ -244,7 +244,7 @@ struct SettingsViewPlayer: View { icon: "pip", title: NSLocalizedString("Show PiP Button", comment: ""), isOn: $pipButtonVisible, - showDivider: false + showDivider: true ) SettingsPickerRow( @@ -252,7 +252,8 @@ struct SettingsViewPlayer: View { title: NSLocalizedString("Completion Percentage", comment: ""), options: [60.0, 70.0, 80.0, 90.0, 95.0, 100.0], optionToString: { "\(Int($0))%" }, - selection: $remainingTimePercentage + selection: $remainingTimePercentage, + showDivider: false ) } From 0eef3dd918c2a683dce4afa39b2b9bb84633e74c Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Wed, 25 Jun 2025 11:23:56 +0200 Subject: [PATCH 14/45] added whats new maybe + UI fixes --- Sora/ContentView.swift | 78 ++++++++++------- Sora/Views/DownloadView.swift | 10 +-- Sora/Views/MediaInfoView/MediaInfoView.swift | 1 + Sora/Views/WhatsNewView.swift | 86 +++++++++++++++++++ Sulfur.xcodeproj/project.pbxproj | 27 +++++- .../xcshareddata/swiftpm/Package.resolved | 14 ++- 6 files changed, 174 insertions(+), 42 deletions(-) create mode 100644 Sora/Views/WhatsNewView.swift diff --git a/Sora/ContentView.swift b/Sora/ContentView.swift index 67697f4..7a885e9 100644 --- a/Sora/ContentView.swift +++ b/Sora/ContentView.swift @@ -4,7 +4,9 @@ // // Created by Francesco on 06/01/25. // + import SwiftUI +import SlideOverCard struct ContentView_Previews: PreviewProvider { static var previews: some View { @@ -17,10 +19,12 @@ struct ContentView_Previews: PreviewProvider { struct ContentView: View { @AppStorage("useNativeTabBar") private var useNativeTabBar: Bool = false + @AppStorage("lastVersionPrompt") private var lastVersionPrompt: String = "" @StateObject private var tabBarController = TabBarController() @State var selectedTab: Int = 0 @State var lastTab: Int = 0 @State private var searchQuery: String = "" + @State private var showWhatsNew: Bool = false let tabs: [TabItem] = [ TabItem(icon: "square.stack", title: NSLocalizedString("LibraryTab", comment: "")), @@ -28,46 +32,58 @@ struct ContentView: View { TabItem(icon: "gearshape", title: NSLocalizedString("SettingsTab", comment: "")), TabItem(icon: "magnifyingglass", title: NSLocalizedString("SearchTab", comment: "")) ] - + + private let currentVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0.0" + private func tabView(for index: Int) -> some View { switch index { - case 1: return AnyView(DownloadView()) - case 2: return AnyView(SettingsView()) - case 3: return AnyView(SearchView(searchQuery: $searchQuery)) - default: return AnyView(LibraryView()) + case 1: return AnyView(DownloadView()) + case 2: return AnyView(SettingsView()) + case 3: return AnyView(SearchView(searchQuery: $searchQuery)) + default: return AnyView(LibraryView()) } } var body: some View { - if #available(iOS 26, *), useNativeTabBar == true { - TabView { - ForEach(Array(tabs.enumerated()), id: \.offset) { index, item in - tabView(for: index) - .tabItem { - Label(item.title, systemImage: item.icon) - } - } - } - .searchable(text: $searchQuery) - .environmentObject(tabBarController) - } else { - ZStack(alignment: .bottom) { - Group { - tabView(for: selectedTab) + Group { + if #available(iOS 26, *), useNativeTabBar == true { + TabView { + ForEach(Array(tabs.enumerated()), id: \.offset) { index, item in + tabView(for: index) + .tabItem { + Label(item.title, systemImage: item.icon) + } + } } + .searchable(text: $searchQuery) .environmentObject(tabBarController) - - TabBar( - tabs: tabs, - selectedTab: $selectedTab, - lastTab: $lastTab, - searchQuery: $searchQuery, - controller: tabBarController - ) + } else { + ZStack(alignment: .bottom) { + Group { + tabView(for: selectedTab) + } + .environmentObject(tabBarController) + + TabBar( + tabs: tabs, + selectedTab: $selectedTab, + lastTab: $lastTab, + searchQuery: $searchQuery, + controller: tabBarController + ) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + .ignoresSafeArea(.keyboard, edges: .bottom) + .padding(.bottom, -20) } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) - .ignoresSafeArea(.keyboard, edges: .bottom) - .padding(.bottom, -20) + } + .onAppear { + if lastVersionPrompt != currentVersion { + showWhatsNew = true + } + } + .slideOverCard(isPresented: $showWhatsNew, options: SOCOptions()) { + WhatsNewView(isPresented: $showWhatsNew) } } } diff --git a/Sora/Views/DownloadView.swift b/Sora/Views/DownloadView.swift index 5e36154..5320577 100644 --- a/Sora/Views/DownloadView.swift +++ b/Sora/Views/DownloadView.swift @@ -141,8 +141,8 @@ struct DownloadView: View { private var emptyActiveDownloadsView: some View { VStack(spacing: 20) { Image(systemName: "arrow.down.circle") - .font(.system(size: 64, weight: .ultraLight)) - .foregroundStyle(.tertiary) + .font(.largeTitle) + .foregroundStyle(.secondary) VStack(spacing: 8) { Text(NSLocalizedString("No Active Downloads", comment: "")) @@ -162,9 +162,9 @@ struct DownloadView: View { private var emptyDownloadsView: some View { VStack(spacing: 20) { - Image(systemName: "arrow.down.circle") - .font(.system(size: 64, weight: .ultraLight)) - .foregroundStyle(.tertiary) + Image(systemName: "arrow.down.circle") + .font(.largeTitle) + .foregroundStyle(.secondary) VStack(spacing: 8) { Text(NSLocalizedString("No Downloads", comment: "")) diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index 59812a2..911b3c7 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -196,6 +196,7 @@ struct MediaInfoView: View { UserDefaults.standard.set(newValue.lowerBound, forKey: selectedChapterRangeKey) } .onDisappear { + tabBarController.showTabBar() currentFetchTask?.cancel() activeFetchID = nil } diff --git a/Sora/Views/WhatsNewView.swift b/Sora/Views/WhatsNewView.swift new file mode 100644 index 0000000..c585a13 --- /dev/null +++ b/Sora/Views/WhatsNewView.swift @@ -0,0 +1,86 @@ +// +// WhatsNewView.swift +// Sora +// +// Created by Francesco on 25/06/25. +// + +import SwiftUI +import SlideOverCard + +struct WhatsNewView: View { + @AppStorage("lastVersionPrompt") private var lastVersionPrompt: String = "" + @Binding var isPresented: Bool + + private let currentVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0.0" + private let whatsNewItems = [ + WhatsNewItem(title: "Brand new UI", description: "Enjoy this brand new look of Sora", icon: "sparkles"), + WhatsNewItem(title: "TMDB Metadata", description: "Various UI improvements and animations across the app", icon: "bolt.fill"), + WhatsNewItem(title: "Download Support", description: "For both mp4 and HLS with Multi server support", icon: "tray.and.arrow.down.fill") + ] + + var body: some View { + VStack(alignment: .center, spacing: 25) { + HStack { + Text("What's New in Sora") + .font(.system(size: 28, weight: .bold)) + Text("Version \(currentVersion)") + .foregroundColor(.gray) + } + + ScrollView { + VStack(spacing: 20) { + ForEach(whatsNewItems) { item in + WhatsNewItemView(item: item) + } + } + .padding(.horizontal) + } + + VStack(spacing: 0) { + Button("Continue", action: { + lastVersionPrompt = currentVersion + isPresented = false + }).buttonStyle(SOCActionButton()) + + Button("Release Notes", action: { + if let url = URL(string: "https://github.com/cranci1/Sora/releases/tag/1.0.0") { + UIApplication.shared.open(url) + } + }).buttonStyle(SOCEmptyButton()) + } + } + .frame(height: 480) + } +} + +struct WhatsNewItem: Identifiable { + let id = UUID() + let title: String + let description: String + let icon: String +} + +struct WhatsNewItemView: View { + let item: WhatsNewItem + + var body: some View { + HStack(spacing: 16) { + Image(systemName: item.icon) + .font(.system(size: 24)) + .foregroundColor(.blue) + .frame(width: 32) + + VStack(alignment: .leading, spacing: 4) { + Text(item.title) + .font(.headline) + Text(item.description) + .font(.subheadline) + .foregroundColor(.gray) + .fixedSize(horizontal: false, vertical: true) + } + Spacer() + } + .padding(.vertical, 8) + } +} diff --git a/Sulfur.xcodeproj/project.pbxproj b/Sulfur.xcodeproj/project.pbxproj index f43777b..d4f0736 100644 --- a/Sulfur.xcodeproj/project.pbxproj +++ b/Sulfur.xcodeproj/project.pbxproj @@ -78,6 +78,8 @@ 138AA1B82D2D66FD0021F9DF /* EpisodeCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138AA1B62D2D66FD0021F9DF /* EpisodeCell.swift */; }; 138AA1B92D2D66FD0021F9DF /* CircularProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138AA1B72D2D66FD0021F9DF /* CircularProgressBar.swift */; }; 138B66A02E0BEA52009BE8D9 /* WebAuthenticationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138B669F2E0BEA52009BE8D9 /* WebAuthenticationManager.swift */; }; + 138B66A82E0BF22E009BE8D9 /* SlideOverCard in Frameworks */ = {isa = PBXBuildFile; productRef = 138B66A72E0BF22E009BE8D9 /* SlideOverCard */; }; + 138B66AC2E0BF560009BE8D9 /* WhatsNewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138B66AB2E0BF560009BE8D9 /* WhatsNewView.swift */; }; 138FE1D02DECA00D00936D81 /* TMDB-FetchID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138FE1CF2DECA00D00936D81 /* TMDB-FetchID.swift */; }; 1398FB3F2DE4E161004D3F5F /* SettingsViewAbout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1398FB3E2DE4E161004D3F5F /* SettingsViewAbout.swift */; }; 139935662D468C450065CEFF /* ModuleManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 139935652D468C450065CEFF /* ModuleManager.swift */; }; @@ -190,6 +192,7 @@ 138AA1B62D2D66FD0021F9DF /* EpisodeCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EpisodeCell.swift; sourceTree = ""; }; 138AA1B72D2D66FD0021F9DF /* CircularProgressBar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CircularProgressBar.swift; sourceTree = ""; }; 138B669F2E0BEA52009BE8D9 /* WebAuthenticationManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebAuthenticationManager.swift; sourceTree = ""; }; + 138B66AB2E0BF560009BE8D9 /* WhatsNewView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = WhatsNewView.swift; path = Sora/Views/WhatsNewView.swift; sourceTree = SOURCE_ROOT; }; 138FE1CF2DECA00D00936D81 /* TMDB-FetchID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TMDB-FetchID.swift"; sourceTree = ""; }; 1398FB3E2DE4E161004D3F5F /* SettingsViewAbout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewAbout.swift; sourceTree = ""; }; 139935652D468C450065CEFF /* ModuleManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModuleManager.swift; sourceTree = ""; }; @@ -242,6 +245,7 @@ 13637B902DE0ECD200BDA2FC /* Drops in Frameworks */, 13530BDF2E0002790048B7DE /* SoraCore in Frameworks */, 13637B932DE0ECDB00BDA2FC /* MarqueeLabel in Frameworks */, + 138B66A82E0BF22E009BE8D9 /* SlideOverCard in Frameworks */, 13367ECE2DF70698009CB33F /* NukeUI in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -519,8 +523,8 @@ 13DC0C412D2EC9BA00D0F966 /* Info.plist */, 133D7C6D2D2BE2500075467E /* SoraApp.swift */, 133D7C712D2BE2520075467E /* Assets.xcassets */, - 133D7C6F2D2BE2500075467E /* ContentView.swift */, 130C6BF82D53A4C200DC1432 /* Sora.entitlements */, + 133D7C6F2D2BE2500075467E /* ContentView.swift */, 133D7C732D2BE2520075467E /* Preview Content */, ); path = Sora; @@ -537,9 +541,10 @@ 133D7C7B2D2BE2630075467E /* Views */ = { isa = PBXGroup; children = ( - 04536F732E04BA5600A11248 /* ReaderView */, 04EAC3992DF9E0DB00BBD483 /* SplashScreenView.swift */, + 138B66AB2E0BF560009BE8D9 /* WhatsNewView.swift */, 72443C7C2DC8036500A61321 /* DownloadView.swift */, + 04536F732E04BA5600A11248 /* ReaderView */, 0402DA122DE7B5EC003BB42C /* SearchView */, 133D7C7F2D2BE2630075467E /* MediaInfoView */, 1399FAD22D3AB34F00E97C31 /* SettingsView */, @@ -551,8 +556,8 @@ 133D7C7F2D2BE2630075467E /* MediaInfoView */ = { isa = PBXGroup; children = ( - 04536F762E04BA6900A11248 /* ChapterCell */, 1E0435F02DFCB86800FF6808 /* CustomMatching */, + 04536F762E04BA6900A11248 /* ChapterCell */, 138AA1B52D2D66EC0021F9DF /* EpisodeCell */, 133D7C802D2BE2630075467E /* MediaInfoView.swift */, ); @@ -859,6 +864,7 @@ 13367ECB2DF70698009CB33F /* Nuke */, 13367ECD2DF70698009CB33F /* NukeUI */, 13530BDE2E0002790048B7DE /* SoraCore */, + 138B66A72E0BF22E009BE8D9 /* SlideOverCard */, ); productName = Sora; productReference = 133D7C6A2D2BE2500075467E /* Sulfur.app */; @@ -907,6 +913,7 @@ 13637B912DE0ECDB00BDA2FC /* XCRemoteSwiftPackageReference "MarqueeLabel" */, 13367ECA2DF70698009CB33F /* XCRemoteSwiftPackageReference "Nuke" */, 13530BDD2E0002790048B7DE /* XCRemoteSwiftPackageReference "SoraCore" */, + 138B66A62E0BF22E009BE8D9 /* XCRemoteSwiftPackageReference "SlideOverCard" */, ); productRefGroup = 133D7C6B2D2BE2500075467E /* Products */; projectDirPath = ""; @@ -991,6 +998,7 @@ 04EAC39A2DF9E0DB00BBD483 /* SplashScreenView.swift in Sources */, 132AF1212D99951700A0140B /* JSController-Streams.swift in Sources */, 131845F92D47C62D00CA7A54 /* SettingsViewGeneral.swift in Sources */, + 138B66AC2E0BF560009BE8D9 /* WhatsNewView.swift in Sources */, 04CD76DB2DE20F2200733536 /* AllWatching.swift in Sources */, 13103E8E2D58E04A000F0673 /* SkeletonCell.swift in Sources */, 13D842552D45267500EBBFA6 /* DropManager.swift in Sources */, @@ -1435,6 +1443,14 @@ kind = branch; }; }; + 138B66A62E0BF22E009BE8D9 /* XCRemoteSwiftPackageReference "SlideOverCard" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/akwasiio/SlideOverCard"; + requirement = { + branch = main; + kind = branch; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -1463,6 +1479,11 @@ package = 13637B912DE0ECDB00BDA2FC /* XCRemoteSwiftPackageReference "MarqueeLabel" */; productName = MarqueeLabel; }; + 138B66A72E0BF22E009BE8D9 /* SlideOverCard */ = { + isa = XCSwiftPackageProductDependency; + package = 138B66A62E0BF22E009BE8D9 /* XCRemoteSwiftPackageReference "SlideOverCard" */; + productName = SlideOverCard; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 133D7C622D2BE2500075467E /* Project object */; diff --git a/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 4bae672..b3d9a06 100644 --- a/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,4 @@ { - "originHash" : "07beed18a1a0b5e52eea618e423e9ca1c37c24c4d3d4ec31d68c1664db0f0596", "pins" : [ { "identity" : "drops", @@ -28,15 +27,24 @@ "revision" : "c7ba4833b1b38f09e9708858aeaf91babc69f65c" } }, + { + "identity" : "slideovercard", + "kind" : "remoteSourceControl", + "location" : "https://github.com/akwasiio/SlideOverCard", + "state" : { + "branch" : "main", + "revision" : "5773fcbbe583ce09a2e0314d6e72494e945c4ab6" + } + }, { "identity" : "soracore", "kind" : "remoteSourceControl", "location" : "https://github.com/cranci1/SoraCore", "state" : { "branch" : "main", - "revision" : "957207dded41b1db9fbfdabde81ffb2e72e71b31" + "revision" : "543fe1c8c1d421201aeb10e7d2438a91c90c8ac5" } } ], - "version" : 3 + "version" : 2 } From 5d3939e61fafaa6dbb6862227833b54ba69ee522 Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Wed, 25 Jun 2025 11:30:33 +0200 Subject: [PATCH 15/45] =?UTF-8?q?nvm=20=F0=9F=98=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sora/ContentView.swift | 67 ++++++--------- Sora/Views/WhatsNewView.swift | 86 ------------------- Sulfur.xcodeproj/project.pbxproj | 21 ----- .../xcshareddata/swiftpm/Package.resolved | 9 -- 4 files changed, 26 insertions(+), 157 deletions(-) delete mode 100644 Sora/Views/WhatsNewView.swift diff --git a/Sora/ContentView.swift b/Sora/ContentView.swift index 7a885e9..be49a33 100644 --- a/Sora/ContentView.swift +++ b/Sora/ContentView.swift @@ -6,7 +6,6 @@ // import SwiftUI -import SlideOverCard struct ContentView_Previews: PreviewProvider { static var previews: some View { @@ -19,12 +18,10 @@ struct ContentView_Previews: PreviewProvider { struct ContentView: View { @AppStorage("useNativeTabBar") private var useNativeTabBar: Bool = false - @AppStorage("lastVersionPrompt") private var lastVersionPrompt: String = "" @StateObject private var tabBarController = TabBarController() @State var selectedTab: Int = 0 @State var lastTab: Int = 0 @State private var searchQuery: String = "" - @State private var showWhatsNew: Bool = false let tabs: [TabItem] = [ TabItem(icon: "square.stack", title: NSLocalizedString("LibraryTab", comment: "")), @@ -33,8 +30,6 @@ struct ContentView: View { TabItem(icon: "magnifyingglass", title: NSLocalizedString("SearchTab", comment: "")) ] - private let currentVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0.0" - private func tabView(for index: Int) -> some View { switch index { case 1: return AnyView(DownloadView()) @@ -45,45 +40,35 @@ struct ContentView: View { } var body: some View { - Group { - if #available(iOS 26, *), useNativeTabBar == true { - TabView { - ForEach(Array(tabs.enumerated()), id: \.offset) { index, item in - tabView(for: index) - .tabItem { - Label(item.title, systemImage: item.icon) - } - } + if #available(iOS 26, *), useNativeTabBar == true { + TabView { + ForEach(Array(tabs.enumerated()), id: \.offset) { index, item in + tabView(for: index) + .tabItem { + Label(item.title, systemImage: item.icon) + } + } + } + .searchable(text: $searchQuery) + .environmentObject(tabBarController) + } else { + ZStack(alignment: .bottom) { + Group { + tabView(for: selectedTab) } - .searchable(text: $searchQuery) .environmentObject(tabBarController) - } else { - ZStack(alignment: .bottom) { - Group { - tabView(for: selectedTab) - } - .environmentObject(tabBarController) - - TabBar( - tabs: tabs, - selectedTab: $selectedTab, - lastTab: $lastTab, - searchQuery: $searchQuery, - controller: tabBarController - ) - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) - .ignoresSafeArea(.keyboard, edges: .bottom) - .padding(.bottom, -20) + + TabBar( + tabs: tabs, + selectedTab: $selectedTab, + lastTab: $lastTab, + searchQuery: $searchQuery, + controller: tabBarController + ) } - } - .onAppear { - if lastVersionPrompt != currentVersion { - showWhatsNew = true - } - } - .slideOverCard(isPresented: $showWhatsNew, options: SOCOptions()) { - WhatsNewView(isPresented: $showWhatsNew) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + .ignoresSafeArea(.keyboard, edges: .bottom) + .padding(.bottom, -20) } } } diff --git a/Sora/Views/WhatsNewView.swift b/Sora/Views/WhatsNewView.swift deleted file mode 100644 index c585a13..0000000 --- a/Sora/Views/WhatsNewView.swift +++ /dev/null @@ -1,86 +0,0 @@ -// -// WhatsNewView.swift -// Sora -// -// Created by Francesco on 25/06/25. -// - -import SwiftUI -import SlideOverCard - -struct WhatsNewView: View { - @AppStorage("lastVersionPrompt") private var lastVersionPrompt: String = "" - @Binding var isPresented: Bool - - private let currentVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0.0" - private let whatsNewItems = [ - WhatsNewItem(title: "Brand new UI", description: "Enjoy this brand new look of Sora", icon: "sparkles"), - WhatsNewItem(title: "TMDB Metadata", description: "Various UI improvements and animations across the app", icon: "bolt.fill"), - WhatsNewItem(title: "Download Support", description: "For both mp4 and HLS with Multi server support", icon: "tray.and.arrow.down.fill") - ] - - var body: some View { - VStack(alignment: .center, spacing: 25) { - HStack { - Text("What's New in Sora") - .font(.system(size: 28, weight: .bold)) - Text("Version \(currentVersion)") - .foregroundColor(.gray) - } - - ScrollView { - VStack(spacing: 20) { - ForEach(whatsNewItems) { item in - WhatsNewItemView(item: item) - } - } - .padding(.horizontal) - } - - VStack(spacing: 0) { - Button("Continue", action: { - lastVersionPrompt = currentVersion - isPresented = false - }).buttonStyle(SOCActionButton()) - - Button("Release Notes", action: { - if let url = URL(string: "https://github.com/cranci1/Sora/releases/tag/1.0.0") { - UIApplication.shared.open(url) - } - }).buttonStyle(SOCEmptyButton()) - } - } - .frame(height: 480) - } -} - -struct WhatsNewItem: Identifiable { - let id = UUID() - let title: String - let description: String - let icon: String -} - -struct WhatsNewItemView: View { - let item: WhatsNewItem - - var body: some View { - HStack(spacing: 16) { - Image(systemName: item.icon) - .font(.system(size: 24)) - .foregroundColor(.blue) - .frame(width: 32) - - VStack(alignment: .leading, spacing: 4) { - Text(item.title) - .font(.headline) - Text(item.description) - .font(.subheadline) - .foregroundColor(.gray) - .fixedSize(horizontal: false, vertical: true) - } - Spacer() - } - .padding(.vertical, 8) - } -} diff --git a/Sulfur.xcodeproj/project.pbxproj b/Sulfur.xcodeproj/project.pbxproj index d4f0736..1c31c4b 100644 --- a/Sulfur.xcodeproj/project.pbxproj +++ b/Sulfur.xcodeproj/project.pbxproj @@ -78,8 +78,6 @@ 138AA1B82D2D66FD0021F9DF /* EpisodeCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138AA1B62D2D66FD0021F9DF /* EpisodeCell.swift */; }; 138AA1B92D2D66FD0021F9DF /* CircularProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138AA1B72D2D66FD0021F9DF /* CircularProgressBar.swift */; }; 138B66A02E0BEA52009BE8D9 /* WebAuthenticationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138B669F2E0BEA52009BE8D9 /* WebAuthenticationManager.swift */; }; - 138B66A82E0BF22E009BE8D9 /* SlideOverCard in Frameworks */ = {isa = PBXBuildFile; productRef = 138B66A72E0BF22E009BE8D9 /* SlideOverCard */; }; - 138B66AC2E0BF560009BE8D9 /* WhatsNewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138B66AB2E0BF560009BE8D9 /* WhatsNewView.swift */; }; 138FE1D02DECA00D00936D81 /* TMDB-FetchID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138FE1CF2DECA00D00936D81 /* TMDB-FetchID.swift */; }; 1398FB3F2DE4E161004D3F5F /* SettingsViewAbout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1398FB3E2DE4E161004D3F5F /* SettingsViewAbout.swift */; }; 139935662D468C450065CEFF /* ModuleManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 139935652D468C450065CEFF /* ModuleManager.swift */; }; @@ -192,7 +190,6 @@ 138AA1B62D2D66FD0021F9DF /* EpisodeCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EpisodeCell.swift; sourceTree = ""; }; 138AA1B72D2D66FD0021F9DF /* CircularProgressBar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CircularProgressBar.swift; sourceTree = ""; }; 138B669F2E0BEA52009BE8D9 /* WebAuthenticationManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebAuthenticationManager.swift; sourceTree = ""; }; - 138B66AB2E0BF560009BE8D9 /* WhatsNewView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = WhatsNewView.swift; path = Sora/Views/WhatsNewView.swift; sourceTree = SOURCE_ROOT; }; 138FE1CF2DECA00D00936D81 /* TMDB-FetchID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TMDB-FetchID.swift"; sourceTree = ""; }; 1398FB3E2DE4E161004D3F5F /* SettingsViewAbout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewAbout.swift; sourceTree = ""; }; 139935652D468C450065CEFF /* ModuleManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModuleManager.swift; sourceTree = ""; }; @@ -245,7 +242,6 @@ 13637B902DE0ECD200BDA2FC /* Drops in Frameworks */, 13530BDF2E0002790048B7DE /* SoraCore in Frameworks */, 13637B932DE0ECDB00BDA2FC /* MarqueeLabel in Frameworks */, - 138B66A82E0BF22E009BE8D9 /* SlideOverCard in Frameworks */, 13367ECE2DF70698009CB33F /* NukeUI in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -542,7 +538,6 @@ isa = PBXGroup; children = ( 04EAC3992DF9E0DB00BBD483 /* SplashScreenView.swift */, - 138B66AB2E0BF560009BE8D9 /* WhatsNewView.swift */, 72443C7C2DC8036500A61321 /* DownloadView.swift */, 04536F732E04BA5600A11248 /* ReaderView */, 0402DA122DE7B5EC003BB42C /* SearchView */, @@ -864,7 +859,6 @@ 13367ECB2DF70698009CB33F /* Nuke */, 13367ECD2DF70698009CB33F /* NukeUI */, 13530BDE2E0002790048B7DE /* SoraCore */, - 138B66A72E0BF22E009BE8D9 /* SlideOverCard */, ); productName = Sora; productReference = 133D7C6A2D2BE2500075467E /* Sulfur.app */; @@ -913,7 +907,6 @@ 13637B912DE0ECDB00BDA2FC /* XCRemoteSwiftPackageReference "MarqueeLabel" */, 13367ECA2DF70698009CB33F /* XCRemoteSwiftPackageReference "Nuke" */, 13530BDD2E0002790048B7DE /* XCRemoteSwiftPackageReference "SoraCore" */, - 138B66A62E0BF22E009BE8D9 /* XCRemoteSwiftPackageReference "SlideOverCard" */, ); productRefGroup = 133D7C6B2D2BE2500075467E /* Products */; projectDirPath = ""; @@ -998,7 +991,6 @@ 04EAC39A2DF9E0DB00BBD483 /* SplashScreenView.swift in Sources */, 132AF1212D99951700A0140B /* JSController-Streams.swift in Sources */, 131845F92D47C62D00CA7A54 /* SettingsViewGeneral.swift in Sources */, - 138B66AC2E0BF560009BE8D9 /* WhatsNewView.swift in Sources */, 04CD76DB2DE20F2200733536 /* AllWatching.swift in Sources */, 13103E8E2D58E04A000F0673 /* SkeletonCell.swift in Sources */, 13D842552D45267500EBBFA6 /* DropManager.swift in Sources */, @@ -1443,14 +1435,6 @@ kind = branch; }; }; - 138B66A62E0BF22E009BE8D9 /* XCRemoteSwiftPackageReference "SlideOverCard" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/akwasiio/SlideOverCard"; - requirement = { - branch = main; - kind = branch; - }; - }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -1479,11 +1463,6 @@ package = 13637B912DE0ECDB00BDA2FC /* XCRemoteSwiftPackageReference "MarqueeLabel" */; productName = MarqueeLabel; }; - 138B66A72E0BF22E009BE8D9 /* SlideOverCard */ = { - isa = XCSwiftPackageProductDependency; - package = 138B66A62E0BF22E009BE8D9 /* XCRemoteSwiftPackageReference "SlideOverCard" */; - productName = SlideOverCard; - }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 133D7C622D2BE2500075467E /* Project object */; diff --git a/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index b3d9a06..36de013 100644 --- a/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -27,15 +27,6 @@ "revision" : "c7ba4833b1b38f09e9708858aeaf91babc69f65c" } }, - { - "identity" : "slideovercard", - "kind" : "remoteSourceControl", - "location" : "https://github.com/akwasiio/SlideOverCard", - "state" : { - "branch" : "main", - "revision" : "5773fcbbe583ce09a2e0314d6e72494e945c4ab6" - } - }, { "identity" : "soracore", "kind" : "remoteSourceControl", From 92343aee2b666bc792fc6b1b623f74cf86b903f9 Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Mon, 30 Jun 2025 10:03:39 +0200 Subject: [PATCH 16/45] reverted continue watching --- .../ContinueWatchingManager.swift | 76 +++++++++---------- 1 file changed, 37 insertions(+), 39 deletions(-) diff --git a/Sora/MediaUtils/ContinueWatching/ContinueWatchingManager.swift b/Sora/MediaUtils/ContinueWatching/ContinueWatchingManager.swift index 5208456..203cf23 100644 --- a/Sora/MediaUtils/ContinueWatching/ContinueWatchingManager.swift +++ b/Sora/MediaUtils/ContinueWatching/ContinueWatchingManager.swift @@ -48,9 +48,7 @@ class ContinueWatchingManager { let currentEpisode = sortedEpisodes[i] let nextEpisode = sortedEpisodes[i + 1] - let remainingTimePercentage = UserDefaults.standard.object(forKey: "remainingTimePercentage") != nil ? UserDefaults.standard.double(forKey: "remainingTimePercentage") : 90.0 - let threshold = (100.0 - remainingTimePercentage) / 100.0 - if currentEpisode.progress >= threshold && nextEpisode.episodeNumber > currentEpisode.episodeNumber { + if currentEpisode.progress >= 0.8 && nextEpisode.episodeNumber > currentEpisode.episodeNumber { itemsToRemove.insert(currentEpisode.id) } } @@ -65,54 +63,54 @@ class ContinueWatchingManager { } func save(item: ContinueWatchingItem) { + // Use real playback times let lastKey = "lastPlayedTime_\(item.fullUrl)" let totalKey = "totalTime_\(item.fullUrl)" let lastPlayed = UserDefaults.standard.double(forKey: lastKey) let totalTime = UserDefaults.standard.double(forKey: totalKey) + // Compute up-to-date progress let actualProgress: Double if totalTime > 0 { actualProgress = min(max(lastPlayed / totalTime, 0), 1) } else { actualProgress = item.progress - - let remainingTimePercentage = UserDefaults.standard.object(forKey: "remainingTimePercentage") != nil ? UserDefaults.standard.double(forKey: "remainingTimePercentage") : 90.0 - let threshold = (100.0 - remainingTimePercentage) / 100.0 - if actualProgress >= threshold { - remove(item: item) - return - } - - var updatedItem = item - updatedItem.progress = actualProgress - - var items = fetchItems() - - let showTitle = item.mediaTitle.replacingOccurrences(of: "Episode \\d+.*$", with: "", options: .regularExpression) + } + + // If watched ≥ 90%, remove it + if actualProgress >= 0.9 { + remove(item: item) + return + } + + // Otherwise update progress and remove old episodes from the same show + var updatedItem = item + updatedItem.progress = actualProgress + + var items = fetchItems() + + let showTitle = item.mediaTitle.replacingOccurrences(of: "Episode \\d+.*$", with: "", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + + items.removeAll { existingItem in + let existingShowTitle = existingItem.mediaTitle.replacingOccurrences(of: "Episode \\d+.*$", with: "", options: .regularExpression) .trimmingCharacters(in: .whitespacesAndNewlines) - items.removeAll { existingItem in - let existingShowTitle = existingItem.mediaTitle.replacingOccurrences(of: "Episode \\d+.*$", with: "", options: .regularExpression) - .trimmingCharacters(in: .whitespacesAndNewlines) - - let remainingTimePercentage = UserDefaults.standard.object(forKey: "remainingTimePercentage") != nil ? UserDefaults.standard.double(forKey: "remainingTimePercentage") : 90.0 - let threshold = (100.0 - remainingTimePercentage) / 100.0 - return showTitle == existingShowTitle && - existingItem.episodeNumber < item.episodeNumber && - existingItem.progress >= threshold - } - - items.removeAll { existing in - existing.fullUrl == item.fullUrl && - existing.episodeNumber == item.episodeNumber && - existing.module.metadata.sourceName == item.module.metadata.sourceName - } - - items.append(updatedItem) - - if let data = try? JSONEncoder().encode(items) { - UserDefaults.standard.set(data, forKey: storageKey) - } + return showTitle == existingShowTitle && + existingItem.episodeNumber < item.episodeNumber && + existingItem.progress >= 0.8 + } + + items.removeAll { existing in + existing.fullUrl == item.fullUrl && + existing.episodeNumber == item.episodeNumber && + existing.module.metadata.sourceName == item.module.metadata.sourceName + } + + items.append(updatedItem) + + if let data = try? JSONEncoder().encode(items) { + UserDefaults.standard.set(data, forKey: storageKey) } } From 0dac0566dd2cbf4dde70652e7155fc2b0f21cdf6 Mon Sep 17 00:00:00 2001 From: 50/50 <80717571+50n50@users.noreply.github.com> Date: Mon, 30 Jun 2025 10:04:11 +0200 Subject: [PATCH 17/45] Minor bug fixes (#211) * Modified season selector location * Increase back button opacity * Fix drop when exiting reader * Fix tab bar appearing in reader * Next chapter button * removed old commentary * Fix collection image not updating after module removal * Fix next chapter button size * Modified small bookmark type indicator * Align season selector * Continue reading, not fully done yet tho * fixed continue reading issues + added some stuff * correct resetting * pretty continue reading cells :3 * Test building * Fixed continue reading by caching * inshallah only build issue * Fixed chapter number for continue reading * Fix tab bar not appearing in search * Added github and discord icon * fix next chapter button * disable locking for dim * two finger tap to pause * 4 hours to fix this, from 8 pm till now * fix that bichass dim button * Fix downloadview * more tab bar fixes * smoother search bar * time till done indicator * someone stop me * fix bounce scroll * Fixed most of the localizations * back up system (experimental) * fuck main actor * fix reader crash when no network --------- Co-authored-by: cranci <100066266+cranci1@users.noreply.github.com> --- .../Discord Icon.imageset/Contents.json | 21 + .../Discord Icon.imageset/Discord Icon.png | Bin 0 -> 16214 bytes .../Github Icon.imageset/Contents.json | 21 + .../Github Icon.imageset/Github Icon.png | Bin 0 -> 14726 bytes Sora/ContentView.swift | 70 +- .../Localization/ar.lproj/Localizable.strings | 102 ++ .../bos.lproj/Localizable.strings | 106 +- .../Localization/cs.lproj/Localizable.strings | 105 +- .../Localization/de.lproj/Localizable.strings | 99 ++ .../Localization/en.lproj/Localizable.strings | 63 +- .../Localization/es.lproj/Localizable.strings | 99 ++ .../Localization/fr.lproj/Localizable.strings | 104 ++ .../Localization/it.lproj/Localizable.strings | 95 ++ .../Localization/kk.lproj/Localizable.strings | 104 +- .../Localization/nl.lproj/Localizable.strings | 100 +- .../Localization/nn.lproj/Localizable.strings | 106 +- .../Localization/ru.lproj/Localizable.strings | 106 +- .../Localization/sk.lproj/Localizable.strings | 102 ++ .../Localization/sv.lproj/Localizable.strings | 105 +- .../ContinueReadingItem.swift | 48 + .../ContinueReadingManager.swift | 275 +++++ .../CustomPlayer/CustomPlayer.swift | 205 +++- .../Extensions/Notification+Name.swift | 9 + Sora/Utlis & Misc/Extensions/View.swift | 1 + .../JSLoader/JSController-Novel.swift | 155 ++- Sora/Utlis & Misc/Modules/CommunityLib.swift | 6 +- Sora/Utlis & Misc/Modules/ModuleManager.swift | 2 + Sora/Utlis & Misc/TabBar/TabBar.swift | 43 +- .../TabBar/TabBarController.swift | 24 - Sora/Views/DownloadView.swift | 12 +- Sora/Views/LibraryView/AllReading.swift | 448 +++++++ Sora/Views/LibraryView/AllWatching.swift | 2 +- .../BookmarkGridItemView.swift | 20 +- .../BookmarksDetailView.swift | 42 +- .../CollectionDetailView.swift | 49 +- .../CollectionPickerView.swift | 8 +- .../LibraryView/ContinueReadingSection.swift | 181 +++ Sora/Views/LibraryView/LibraryManager.swift | 30 +- Sora/Views/LibraryView/LibraryView.swift | 270 +++-- .../ChapterCell/ChapterCell.swift | 65 +- Sora/Views/MediaInfoView/MediaInfoView.swift | 342 ++++-- Sora/Views/ReaderView/ReaderView.swift | 1029 +++++++++++++---- Sora/Views/SearchView/SearchResultsGrid.swift | 9 +- Sora/Views/SearchView/SearchView.swift | 50 +- .../SettingsSubViews/SettingsViewAbout.swift | 12 +- .../SettingsSubViews/SettingsViewBackup.swift | 261 +++++ .../SettingsViewGeneral.swift | 134 ++- Sora/Views/SettingsView/SettingsView.swift | 65 +- Sulfur.xcodeproj/project.pbxproj | 24 +- 49 files changed, 4719 insertions(+), 610 deletions(-) create mode 100644 Sora/Assets.xcassets/Discord Icon.imageset/Contents.json create mode 100644 Sora/Assets.xcassets/Discord Icon.imageset/Discord Icon.png create mode 100644 Sora/Assets.xcassets/Github Icon.imageset/Contents.json create mode 100644 Sora/Assets.xcassets/Github Icon.imageset/Github Icon.png create mode 100644 Sora/MediaUtils/ContinueWatching/ContinueReadingItem.swift create mode 100644 Sora/MediaUtils/ContinueWatching/ContinueReadingManager.swift delete mode 100644 Sora/Utlis & Misc/TabBar/TabBarController.swift create mode 100644 Sora/Views/LibraryView/AllReading.swift create mode 100644 Sora/Views/LibraryView/ContinueReadingSection.swift create mode 100644 Sora/Views/SettingsView/SettingsSubViews/SettingsViewBackup.swift diff --git a/Sora/Assets.xcassets/Discord Icon.imageset/Contents.json b/Sora/Assets.xcassets/Discord Icon.imageset/Contents.json new file mode 100644 index 0000000..a6e53a9 --- /dev/null +++ b/Sora/Assets.xcassets/Discord Icon.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "Discord Icon.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sora/Assets.xcassets/Discord Icon.imageset/Discord Icon.png b/Sora/Assets.xcassets/Discord Icon.imageset/Discord Icon.png new file mode 100644 index 0000000000000000000000000000000000000000..3847685bc23cd7b9f12913f3634c27a2fd467fcd GIT binary patch literal 16214 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 ShKL~W_tDt#WA=|$X#NWYaj2^R literal 0 HcmV?d00001 diff --git a/Sora/Assets.xcassets/Github Icon.imageset/Contents.json b/Sora/Assets.xcassets/Github Icon.imageset/Contents.json new file mode 100644 index 0000000..87fc200 --- /dev/null +++ b/Sora/Assets.xcassets/Github Icon.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "Github Icon.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sora/Assets.xcassets/Github Icon.imageset/Github Icon.png b/Sora/Assets.xcassets/Github Icon.imageset/Github Icon.png new file mode 100644 index 0000000000000000000000000000000000000000..bb23c56ffca8e0bf380d09603693b05ab54c7003 GIT binary patch literal 14726 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 literal 0 HcmV?d00001 diff --git a/Sora/ContentView.swift b/Sora/ContentView.swift index be49a33..e10f765 100644 --- a/Sora/ContentView.swift +++ b/Sora/ContentView.swift @@ -18,10 +18,13 @@ struct ContentView_Previews: PreviewProvider { struct ContentView: View { @AppStorage("useNativeTabBar") private var useNativeTabBar: Bool = false - @StateObject private var tabBarController = TabBarController() @State var selectedTab: Int = 0 @State var lastTab: Int = 0 @State private var searchQuery: String = "" + @State private var shouldShowTabBar: Bool = true + @State private var tabBarOffset: CGFloat = 0 + @State private var tabBarVisible: Bool = true + @State private var lastHideTime: Date = Date() let tabs: [TabItem] = [ TabItem(icon: "square.stack", title: NSLocalizedString("LibraryTab", comment: "")), @@ -50,25 +53,72 @@ struct ContentView: View { } } .searchable(text: $searchQuery) - .environmentObject(tabBarController) } else { ZStack(alignment: .bottom) { Group { tabView(for: selectedTab) } - .environmentObject(tabBarController) + .onPreferenceChange(TabBarVisibilityKey.self) { shouldShowTabBar = $0 } - TabBar( - tabs: tabs, - selectedTab: $selectedTab, - lastTab: $lastTab, - searchQuery: $searchQuery, - controller: tabBarController - ) + if shouldShowTabBar { + TabBar( + tabs: tabs, + selectedTab: $selectedTab + ) + .opacity(shouldShowTabBar && tabBarVisible ? 1 : 0) + .offset(y: tabBarVisible ? 0 : 120) + .animation(.spring(response: 0.15, dampingFraction: 0.7), value: tabBarVisible) + } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) .ignoresSafeArea(.keyboard, edges: .bottom) .padding(.bottom, -20) + .onAppear { + setupNotificationObservers() + } + .onDisappear { + removeNotificationObservers() + } } } + + private func setupNotificationObservers() { + NotificationCenter.default.addObserver( + forName: .hideTabBar, + object: nil, + queue: .main + ) { _ in + lastHideTime = Date() + tabBarVisible = false + Logger.shared.log("Tab bar hidden", type: "Debug") + } + + NotificationCenter.default.addObserver( + forName: .showTabBar, + object: nil, + queue: .main + ) { _ in + let timeSinceHide = Date().timeIntervalSince(lastHideTime) + if timeSinceHide > 0.2 { + tabBarVisible = true + Logger.shared.log("Tab bar shown after \(timeSinceHide) seconds", type: "Debug") + } else { + Logger.shared.log("Tab bar show request ignored, only \(timeSinceHide) seconds since hide", type: "Debug") + } + } + } + + private func removeNotificationObservers() { + NotificationCenter.default.removeObserver(self, name: .hideTabBar, object: nil) + NotificationCenter.default.removeObserver(self, name: .showTabBar, object: nil) + } } + +struct TabBarVisibilityKey: PreferenceKey { + static var defaultValue: Bool = true + static func reduce(value: inout Bool, nextValue: () -> Bool) { + value = nextValue() + } +} + + diff --git a/Sora/Localization/ar.lproj/Localizable.strings b/Sora/Localization/ar.lproj/Localizable.strings index 1f3036a..d1fbb6e 100644 --- a/Sora/Localization/ar.lproj/Localizable.strings +++ b/Sora/Localization/ar.lproj/Localizable.strings @@ -388,3 +388,105 @@ "me frfr" = "me frfr"; "Data" = "البيانات"; "Maximum Quality Available" = "أعلى جودة متاحة"; + +/* New additions */ +"DownloadCountFormat" = "%d من %d"; +"Error loading chapter" = "حدث خطأ أثناء تحميل الفصل"; +"Font Size: %dpt" = "حجم الخط: %d نقطة"; +"Line Spacing: %.1f" = "تباعد الأسطر: %.1f"; +"Line Spacing" = "تباعد الأسطر"; +"Margin: %dpx" = "الهامش: %d بكسل"; +"Margin" = "الهامش"; +"Auto Scroll Speed" = "سرعة التمرير التلقائي"; +"Speed" = "السرعة"; +"Speed: %.1fx" = "السرعة: %.1fx"; +"Matched %@: %@" = "%@: %@ متطابق"; +"Enter the AniList ID for this series" = "أدخل معرف AniList لهذه السلسلة"; + +/* New additions */ +"Create Collection" = "إنشاء مجموعة"; +"Collection Name" = "اسم المجموعة"; +"Rename Collection" = "إعادة تسمية المجموعة"; +"Rename" = "إعادة تسمية"; +"All Reading" = "كل القراءة"; +"Recently Added" = "أضيفت مؤخراً"; +"Novel Title" = "عنوان الرواية"; +"Read Progress" = "تقدم القراءة"; +"Date Created" = "تاريخ الإنشاء"; +"Name" = "الاسم"; +"Item Count" = "عدد العناصر"; +"Date Added" = "تاريخ الإضافة"; +"Title" = "العنوان"; +"Source" = "المصدر"; +"Search reading..." = "ابحث في القراءة..."; +"Search collections..." = "ابحث في المجموعات..."; +"Search bookmarks..." = "ابحث في الإشارات المرجعية..."; +"%d items" = "%d عناصر"; +"Fetching Data" = "جاري جلب البيانات"; +"Please wait while fetching." = "يرجى الانتظار أثناء الجلب."; +"Start Reading" = "ابدأ القراءة"; +"Chapters" = "الفصول"; +"Completed" = "مكتمل"; +"Drag to reorder" = "اسحب لإعادة الترتيب"; +"Drag to reorder sections" = "اسحب لإعادة ترتيب الأقسام"; +"Library View" = "عرض المكتبة"; +"Customize the sections shown in your library. You can reorder sections or disable them completely." = "خصص الأقسام المعروضة في مكتبتك. يمكنك إعادة ترتيب الأقسام أو تعطيلها بالكامل."; +"Library Sections Order" = "ترتيب أقسام المكتبة"; +"Completion Percentage" = "نسبة الإكمال"; +"Some features are limited to the Sora and Default player, such as ForceLandscape, holdSpeed and custom time skip increments.\n\nThe completion percentage setting determines at what point before the end of a video the app will mark it as completed on AniList and Trakt." = "بعض الميزات محدودة في مشغل Sora والمشغل الافتراضي فقط، مثل الوضع الأفقي الإجباري، سرعة التثبيت، وزيادات تخطي الوقت المخصصة.\n\nإعداد نسبة الإكمال يحدد عند أي نقطة قبل نهاية الفيديو سيتم اعتبار العمل مكتمل في AniList وTrakt."; +"The app cache helps the app load images faster.\n\nClearing the Documents folder will delete all downloaded modules.\n\nErasing the App Data will clears all your settings and data of the app." = "ذاكرة التخزين المؤقت تساعد التطبيق على تحميل الصور بشكل أسرع.\n\nمسح مجلد المستندات سيحذف جميع الوحدات التي تم تنزيلها.\n\nمسح بيانات التطبيق سيحذف جميع إعداداتك وبياناتك."; +"Translators" = "المترجمون"; +"Paste URL" = "الصق الرابط"; + +/* New additions */ +"Series Title" = "عنوان السلسلة"; +"Content Source" = "مصدر المحتوى"; +"Watch Progress" = "تقدم المشاهدة"; +"Nothing to Continue Reading" = "لا شيء لمتابعة القراءة"; +"Your recently read novels will appear here" = "ستظهر الروايات التي قرأتها مؤخرًا هنا"; +"No Bookmarks" = "لا توجد إشارات مرجعية"; +"Add bookmarks to this collection" = "أضف إشارات مرجعية إلى هذه المجموعة"; +"items" = "عناصر"; +"All Watching" = "كل المشاهدة"; +"No Reading History" = "لا يوجد سجل قراءة"; +"Books you're reading will appear here" = "ستظهر الكتب التي تقرأها هنا"; +"Create Collection" = "إنشاء مجموعة"; +"Collection Name" = "اسم المجموعة"; +"Rename Collection" = "إعادة تسمية المجموعة"; +"Rename" = "إعادة تسمية"; +"Novel Title" = "عنوان الرواية"; +"Read Progress" = "تقدم القراءة"; +"Date Created" = "تاريخ الإنشاء"; +"Name" = "الاسم"; +"Item Count" = "عدد العناصر"; +"Date Added" = "تاريخ الإضافة"; +"Title" = "العنوان"; +"Source" = "المصدر"; +"Search reading..." = "ابحث في القراءة..."; +"Search collections..." = "ابحث في المجموعات..."; +"Search bookmarks..." = "ابحث في الإشارات المرجعية..."; +"%d items" = "%d عناصر"; +"Fetching Data" = "جاري جلب البيانات"; +"Please wait while fetching." = "يرجى الانتظار أثناء الجلب."; +"Start Reading" = "ابدأ القراءة"; +"Chapters" = "الفصول"; +"Completed" = "مكتمل"; +"Drag to reorder" = "اسحب لإعادة الترتيب"; +"Drag to reorder sections" = "اسحب لإعادة ترتيب الأقسام"; +"Library View" = "عرض المكتبة"; +"Customize the sections shown in your library. You can reorder sections or disable them completely." = "خصص الأقسام المعروضة في مكتبتك. يمكنك إعادة ترتيب الأقسام أو تعطيلها بالكامل."; +"Library Sections Order" = "ترتيب أقسام المكتبة"; +"Completion Percentage" = "نسبة الإكمال"; +"Translators" = "المترجمون"; +"Paste URL" = "الصق الرابط"; + +/* New additions */ +"Collections" = "المجموعات"; +"Continue Reading" = "متابعة القراءة"; + +/* New additions */ +"Backup & Restore" = "النسخ الاحتياطي والاستعادة"; +"Export Backup" = "تصدير النسخة الاحتياطية"; +"Import Backup" = "استيراد النسخة الاحتياطية"; +"Notice: This feature is still experimental. Please double-check your data after import/export." = "تنبيه: هذه الميزة لا تزال تجريبية. يرجى التحقق من بياناتك بعد التصدير/الاستيراد."; +"Backup" = "نسخة احتياطية"; diff --git a/Sora/Localization/bos.lproj/Localizable.strings b/Sora/Localization/bos.lproj/Localizable.strings index 7840131..f2ec2ea 100644 --- a/Sora/Localization/bos.lproj/Localizable.strings +++ b/Sora/Localization/bos.lproj/Localizable.strings @@ -41,7 +41,7 @@ "Clear All Downloads" = "Obriši sva preuzimanja"; "Clear Cache" = "Obriši keš"; "Clear Library Only" = "Obriši samo biblioteku"; -"Clear Logs" = "Obriši logове"; +"Clear Logs" = "Obriši logove"; "Click the plus button to add a module!" = "Kliknite plus dugme da dodate modul!"; "Continue Watching" = "Nastavi gledanje"; "Continue Watching Episode %d" = "Nastavi gledanje epizode %d"; @@ -404,3 +404,107 @@ Za metapodatke epizode, odnosi se na sličicu i naslov epizode, jer ponekad mogu "me frfr" = "ja stvarno"; "Data" = "Podaci"; "Maximum Quality Available" = "Maksimalna dostupna kvaliteta"; + +/* Additional translations */ +"DownloadCountFormat" = "%d od %d"; +"Error loading chapter" = "Greška pri učitavanju poglavlja"; +"Font Size: %dpt" = "Veličina fonta: %dpt"; +"Line Spacing: %.1f" = "Razmak između redova: %.1f"; +"Line Spacing" = "Razmak između redova"; +"Margin: %dpx" = "Margina: %dpx"; +"Margin" = "Margina"; +"Auto Scroll Speed" = "Brzina automatskog pomicanja"; +"Speed" = "Brzina"; +"Speed: %.1fx" = "Brzina: %.1fx"; +"Matched %@: %@" = "Poklapanje %@: %@"; +"Enter the AniList ID for this series" = "Unesite AniList ID za ovu seriju"; + +/* Added missing localizations */ +"Create Collection" = "Kreiraj kolekciju"; +"Collection Name" = "Naziv kolekcije"; +"Rename Collection" = "Preimenuj kolekciju"; +"Rename" = "Preimenuj"; +"All Reading" = "Sva čitanja"; +"Recently Added" = "Nedavno dodano"; +"Novel Title" = "Naslov romana"; +"Read Progress" = "Napredak čitanja"; +"Date Created" = "Datum kreiranja"; +"Name" = "Naziv"; +"Item Count" = "Broj stavki"; +"Date Added" = "Datum dodavanja"; +"Title" = "Naslov"; +"Source" = "Izvor"; +"Search reading..." = "Pretraži čitanje..."; +"Search collections..." = "Pretraži kolekcije..."; +"Search bookmarks..." = "Pretraži oznake..."; +"%d items" = "%d stavki"; +"Fetching Data" = "Preuzimanje podataka"; +"Please wait while fetching." = "Molimo sačekajte dok se preuzima."; +"Start Reading" = "Započni čitanje"; +"Chapters" = "Poglavlja"; +"Completed" = "Završeno"; +"Drag to reorder" = "Povuci za promjenu redoslijeda"; +"Drag to reorder sections" = "Povuci za promjenu redoslijeda sekcija"; +"Library View" = "Prikaz biblioteke"; +"Customize the sections shown in your library. You can reorder sections or disable them completely." = "Prilagodite sekcije prikazane u vašoj biblioteci. Možete ih preurediti ili potpuno onemogućiti."; +"Library Sections Order" = "Redoslijed sekcija biblioteke"; +"Completion Percentage" = "Procenat završetka"; +"Some features are limited to the Sora and Default player, such as ForceLandscape, holdSpeed and custom time skip increments.\n\nThe completion percentage setting determines at what point before the end of a video the app will mark it as completed on AniList and Trakt." = "Neke funkcije su ograničene na Sora i zadani player, kao što su prisilni pejzaž, držanje brzine i prilagođeni intervali preskakanja.\n\nPostavka procenta završetka određuje u kojoj tački prije kraja videa će aplikacija označiti kao završeno na AniList i Trakt."; +"The app cache helps the app load images faster.\n\nClearing the Documents folder will delete all downloaded modules.\n\nErasing the App Data will clears all your settings and data of the app." = "Keš aplikacije pomaže bržem učitavanju slika.\n\nBrisanje Documents foldera će ukloniti sve preuzete module.\n\nBrisanje podataka aplikacije briše sve vaše postavke i podatke."; +"Translators" = "Prevoditelji"; +"Paste URL" = "Zalijepi URL"; + +/* New additions */ +"Series Title" = "Naslov serije"; +"Content Source" = "Izvor sadržaja"; +"Watch Progress" = "Napredak gledanja"; +"Recent searches" = "Nedavne pretrage"; +"All Reading" = "Sve što čitam"; +"Nothing to Continue Reading" = "Nema ništa za nastaviti čitanje"; +"Your recently read novels will appear here" = "Vaši nedavno pročitani romani će se pojaviti ovdje"; +"No Bookmarks" = "Nema zabilješki"; +"Add bookmarks to this collection" = "Dodajte zabilješke u ovu kolekciju"; +"items" = "stavke"; +"All Watching" = "Sve što gledam"; +"No Reading History" = "Nema historije čitanja"; +"Books you're reading will appear here" = "Knjige koje čitate će se pojaviti ovdje"; +"Create Collection" = "Kreiraj kolekciju"; +"Collection Name" = "Naziv kolekcije"; +"Rename Collection" = "Preimenuj kolekciju"; +"Rename" = "Preimenuj"; +"Novel Title" = "Naslov romana"; +"Read Progress" = "Napredak čitanja"; +"Date Created" = "Datum kreiranja"; +"Name" = "Ime"; +"Item Count" = "Broj stavki"; +"Date Added" = "Datum dodavanja"; +"Title" = "Naslov"; +"Source" = "Izvor"; +"Search reading..." = "Pretraži čitanje..."; +"Search collections..." = "Pretraži kolekcije..."; +"Search bookmarks..." = "Pretraži zabilješke..."; +"%d items" = "%d stavki"; +"Fetching Data" = "Dohvatanje podataka"; +"Please wait while fetching." = "Molimo sačekajte dok se podaci dohvaćaju."; +"Start Reading" = "Započni čitanje"; +"Chapters" = "Poglavlja"; +"Completed" = "Završeno"; +"Drag to reorder" = "Povucite za promjenu redoslijeda"; +"Drag to reorder sections" = "Povucite za promjenu redoslijeda sekcija"; +"Library View" = "Prikaz biblioteke"; +"Customize the sections shown in your library. You can reorder sections or disable them completely." = "Prilagodite sekcije prikazane u vašoj biblioteci. Možete promijeniti redoslijed ili ih potpuno onemogućiti."; +"Library Sections Order" = "Redoslijed sekcija biblioteke"; +"Completion Percentage" = "Procenat završetka"; +"Translators" = "Prevodioci"; +"Paste URL" = "Zalijepi URL"; + +/* New additions */ +"Collections" = "Kolekcije"; +"Continue Reading" = "Nastavi čitanje"; + +/* Backup & Restore */ +"Backup & Restore" = "Sigurnosna kopija i vraćanje"; +"Export Backup" = "Izvezi sigurnosnu kopiju"; +"Import Backup" = "Uvezi sigurnosnu kopiju"; +"Notice: This feature is still experimental. Please double-check your data after import/export." = "Napomena: Ova funkcija je još uvijek eksperimentalna. Molimo provjerite svoje podatke nakon izvoza/uvoza."; +"Backup" = "Sigurnosna kopija"; diff --git a/Sora/Localization/cs.lproj/Localizable.strings b/Sora/Localization/cs.lproj/Localizable.strings index 12a1591..8f7b19e 100644 --- a/Sora/Localization/cs.lproj/Localizable.strings +++ b/Sora/Localization/cs.lproj/Localizable.strings @@ -405,4 +405,107 @@ Metadata epizody se týkají náhledu a názvu epizody, které mohou někdy obsa "Data" = "Data"; /* New string */ -"Maximum Quality Available" = "Maximální dostupná kvalita"; \ No newline at end of file +"Maximum Quality Available" = "Maximální dostupná kvalita"; + +/* Additional translations */ +"DownloadCountFormat" = "%d z %d"; +"Error loading chapter" = "Chyba při načítání kapitoly"; +"Font Size: %dpt" = "Velikost písma: %dpt"; +"Line Spacing: %.1f" = "Řádkování: %.1f"; +"Line Spacing" = "Řádkování"; +"Margin: %dpx" = "Okraj: %dpx"; +"Margin" = "Okraj"; +"Auto Scroll Speed" = "Rychlost automatického posunu"; +"Speed" = "Rychlost"; +"Speed: %.1fx" = "Rychlost: %.1fx"; +"Matched %@: %@" = "Shoda %@: %@"; +"Enter the AniList ID for this series" = "Zadejte AniList ID pro tuto sérii"; + +/* Added missing localizations */ +"Create Collection" = "Vytvořit kolekci"; +"Collection Name" = "Název kolekce"; +"Rename Collection" = "Přejmenovat kolekci"; +"Rename" = "Přejmenovat"; +"All Reading" = "Všechny knihy"; +"Recently Added" = "Nedávno přidáno"; +"Novel Title" = "Název románu"; +"Read Progress" = "Postup čtení"; +"Date Created" = "Datum vytvoření"; +"Name" = "Název"; +"Item Count" = "Počet položek"; +"Date Added" = "Datum přidání"; +"Title" = "Titul"; +"Source" = "Zdroj"; +"Search reading..." = "Hledat v knihách..."; +"Search collections..." = "Hledat v kolekcích..."; +"Search bookmarks..." = "Hledat v záložkách..."; +"%d items" = "%d položek"; +"Fetching Data" = "Načítání dat"; +"Please wait while fetching." = "Počkejte prosím během načítání."; +"Start Reading" = "Začít číst"; +"Chapters" = "Kapitoly"; +"Completed" = "Dokončeno"; +"Drag to reorder" = "Přetáhněte pro změnu pořadí"; +"Drag to reorder sections" = "Přetáhněte pro změnu pořadí sekcí"; +"Library View" = "Zobrazení knihovny"; +"Customize the sections shown in your library. You can reorder sections or disable them completely." = "Přizpůsobte sekce zobrazené ve vaší knihovně. Můžete je přeuspořádat nebo zcela vypnout."; +"Library Sections Order" = "Pořadí sekcí knihovny"; +"Completion Percentage" = "Procento dokončení"; +"Some features are limited to the Sora and Default player, such as ForceLandscape, holdSpeed and custom time skip increments.\n\nThe completion percentage setting determines at what point before the end of a video the app will mark it as completed on AniList and Trakt." = "Některé funkce jsou omezeny na Sora a výchozí přehrávač, například vynucená krajina, podržení rychlosti a vlastní intervaly přeskočení.\n\nNastavení procenta dokončení určuje, v jakém bodě před koncem videa bude aplikace označovat jako dokončené na AniList a Trakt."; +"The app cache helps the app load images faster.\n\nClearing the Documents folder will delete all downloaded modules.\n\nErasing the App Data will clears all your settings and data of the app." = "Mezipaměť aplikace pomáhá rychlejšímu načítání obrázků.\n\nVymazání složky Documents odstraní všechny stažené moduly.\n\nVymazání dat aplikace smaže všechna vaše nastavení a data."; +"Translators" = "Překladatelé"; +"Paste URL" = "Vložit URL"; + +/* New localizations */ +"Series Title" = "Název série"; +"Content Source" = "Zdroj obsahu"; +"Watch Progress" = "Průběh sledování"; +"All Reading" = "Vše ke čtení"; +"Nothing to Continue Reading" = "Nic k pokračování ve čtení"; +"Your recently read novels will appear here" = "Vaše nedávno čtené romány se zobrazí zde"; +"No Bookmarks" = "Žádné záložky"; +"Add bookmarks to this collection" = "Přidejte záložky do této kolekce"; +"items" = "položky"; +"All Watching" = "Vše ke sledování"; +"No Reading History" = "Žádná historie čtení"; +"Books you're reading will appear here" = "Knihy, které čtete, se zobrazí zde"; +"Create Collection" = "Vytvořit kolekci"; +"Collection Name" = "Název kolekce"; +"Rename Collection" = "Přejmenovat kolekci"; +"Rename" = "Přejmenovat"; +"Novel Title" = "Název románu"; +"Read Progress" = "Průběh čtení"; +"Date Created" = "Datum vytvoření"; +"Name" = "Jméno"; +"Item Count" = "Počet položek"; +"Date Added" = "Datum přidání"; +"Title" = "Název"; +"Source" = "Zdroj"; +"Search reading..." = "Hledat ve čtení..."; +"Search collections..." = "Hledat v kolekcích..."; +"Search bookmarks..." = "Hledat v záložkách..."; +"%d items" = "%d položek"; +"Fetching Data" = "Načítání dat"; +"Please wait while fetching." = "Počkejte prosím, načítají se data."; +"Start Reading" = "Začít číst"; +"Chapters" = "Kapitoly"; +"Completed" = "Dokončeno"; +"Drag to reorder" = "Přetáhněte pro změnu pořadí"; +"Drag to reorder sections" = "Přetáhněte pro změnu pořadí sekcí"; +"Library View" = "Zobrazení knihovny"; +"Customize the sections shown in your library. You can reorder sections or disable them completely." = "Přizpůsobte sekce zobrazené ve vaší knihovně. Můžete změnit jejich pořadí nebo je úplně vypnout."; +"Library Sections Order" = "Pořadí sekcí knihovny"; +"Completion Percentage" = "Procento dokončení"; +"Translators" = "Překladatelé"; +"Paste URL" = "Vložit URL"; + +/* New localizations */ +"Collections" = "Kolekce"; +"Continue Reading" = "Pokračovat ve čtení"; + +/* Backup & Restore */ +"Backup & Restore" = "Zálohování a obnovení"; +"Export Backup" = "Exportovat zálohu"; +"Import Backup" = "Importovat zálohu"; +"Notice: This feature is still experimental. Please double-check your data after import/export." = "Upozornění: Tato funkce je stále experimentální. Po exportu/importu si prosím zkontrolujte svá data."; +"Backup" = "Záloha"; \ No newline at end of file diff --git a/Sora/Localization/de.lproj/Localizable.strings b/Sora/Localization/de.lproj/Localizable.strings index 98aed9b..0103581 100644 --- a/Sora/Localization/de.lproj/Localizable.strings +++ b/Sora/Localization/de.lproj/Localizable.strings @@ -398,3 +398,102 @@ "me frfr" = "Ich, ohne Witz"; "Data" = "Daten"; "Maximum Quality Available" = "Maximal verfügbare Qualität"; + +"DownloadCountFormat" = "%d von %d"; +"Error loading chapter" = "Fehler beim Laden des Kapitels"; +"Font Size: %dpt" = "Schriftgröße: %dpt"; +"Line Spacing: %.1f" = "Zeilenabstand: %.1f"; +"Line Spacing" = "Zeilenabstand"; +"Margin: %dpx" = "Rand: %dpx"; +"Margin" = "Rand"; +"Auto Scroll Speed" = "Automatische Scroll-Geschwindigkeit"; +"Speed" = "Geschwindigkeit"; +"Speed: %.1fx" = "Geschwindigkeit: %.1fx"; +"Matched %@: %@" = "Abgeglichen %@: %@"; +"Enter the AniList ID for this series" = "Geben Sie die AniList-ID für diese Serie ein"; + +/* Added missing localizations */ +"Create Collection" = "Sammlung erstellen"; +"Collection Name" = "Sammlungsname"; +"Rename Collection" = "Sammlung umbenennen"; +"Rename" = "Umbenennen"; +"All Reading" = "Alles Lesen"; +"Recently Added" = "Kürzlich hinzugefügt"; +"Novel Title" = "Roman Titel"; +"Read Progress" = "Lesefortschritt"; +"Date Created" = "Erstellungsdatum"; +"Name" = "Name"; +"Item Count" = "Anzahl der Elemente"; +"Date Added" = "Hinzugefügt am"; +"Title" = "Titel"; +"Source" = "Quelle"; +"Search reading..." = "Lesen durchsuchen..."; +"Search collections..." = "Sammlungen durchsuchen..."; +"Search bookmarks..." = "Lesezeichen durchsuchen..."; +"%d items" = "%d Elemente"; +"Fetching Data" = "Daten werden abgerufen"; +"Please wait while fetching." = "Bitte warten Sie während des Abrufs."; +"Start Reading" = "Lesen starten"; +"Chapters" = "Kapitel"; +"Completed" = "Abgeschlossen"; +"Drag to reorder" = "Ziehen zum Neuordnen"; +"Drag to reorder sections" = "Ziehen zum Neuordnen der Abschnitte"; +"Library View" = "Bibliotheksansicht"; +"Customize the sections shown in your library. You can reorder sections or disable them completely." = "Passen Sie die in Ihrer Bibliothek angezeigten Abschnitte an. Sie können Abschnitte neu anordnen oder vollständig deaktivieren."; +"Library Sections Order" = "Reihenfolge der Bibliotheksabschnitte"; +"Completion Percentage" = "Abschlussprozentsatz"; +"Some features are limited to the Sora and Default player, such as ForceLandscape, holdSpeed and custom time skip increments.\n\nThe completion percentage setting determines at what point before the end of a video the app will mark it as completed on AniList and Trakt." = "Einige Funktionen sind nur im Sora- und Standard-Player verfügbar, wie z.B. erzwungene Querformatansicht, Haltegeschwindigkeit und benutzerdefinierte Zeitsprünge.\n\nDie Einstellung des Abschlussprozentsatzes bestimmt, ab welchem Punkt vor dem Ende eines Videos die App es als abgeschlossen auf AniList und Trakt markiert."; +"The app cache helps the app load images faster.\n\nClearing the Documents folder will delete all downloaded modules.\n\nErasing the App Data will clears all your settings and data of the app." = "Der App-Cache hilft, Bilder schneller zu laden.\n\nDas Löschen des Dokumente-Ordners entfernt alle heruntergeladenen Module.\n\nDas Löschen der App-Daten entfernt alle Ihre Einstellungen und Daten."; +"Translators" = "Übersetzer"; +"Paste URL" = "URL einfügen"; + +/* Added missing localizations */ +"Series Title" = "Serientitel"; +"Content Source" = "Inhaltsquelle"; +"Watch Progress" = "Fortschritt ansehen"; +"All Reading" = "Alles Lesen"; +"Nothing to Continue Reading" = "Nichts zum Weiterlesen"; +"Your recently read novels will appear here" = "Ihre zuletzt gelesenen Romane erscheinen hier"; +"No Bookmarks" = "Keine Lesezeichen"; +"Add bookmarks to this collection" = "Fügen Sie dieser Sammlung Lesezeichen hinzu"; +"items" = "Elemente"; +"All Watching" = "Alles Ansehen"; +"No Reading History" = "Kein Leseverlauf"; +"Books you're reading will appear here" = "Bücher, die Sie lesen, erscheinen hier"; +"Create Collection" = "Sammlung erstellen"; +"Collection Name" = "Sammlungsname"; +"Rename Collection" = "Sammlung umbenennen"; +"Rename" = "Umbenennen"; +"Novel Title" = "Roman Titel"; +"Read Progress" = "Lesefortschritt"; +"Date Created" = "Erstellungsdatum"; +"Name" = "Name"; +"Item Count" = "Anzahl der Elemente"; +"Date Added" = "Hinzugefügt am"; +"Title" = "Titel"; +"Source" = "Quelle"; +"Search reading..." = "Lesen durchsuchen..."; +"Search collections..." = "Sammlungen durchsuchen..."; +"Search bookmarks..." = "Lesezeichen durchsuchen..."; +"%d items" = "%d Elemente"; +"Fetching Data" = "Daten werden abgerufen"; +"Please wait while fetching." = "Bitte warten Sie während des Abrufs."; +"Start Reading" = "Lesen starten"; +"Chapters" = "Kapitel"; +"Completed" = "Abgeschlossen"; +"Drag to reorder" = "Ziehen zum Neuordnen"; +"Drag to reorder sections" = "Ziehen zum Neuordnen der Abschnitte"; +"Library View" = "Bibliotheksansicht"; +"Customize the sections shown in your library. You can reorder sections or disable them completely." = "Passen Sie die in Ihrer Bibliothek angezeigten Abschnitte an. Sie können Abschnitte neu anordnen oder vollständig deaktivieren."; +"Library Sections Order" = "Reihenfolge der Bibliotheksabschnitte"; +"Completion Percentage" = "Abschlussprozentsatz"; +"Translators" = "Übersetzer"; +"Paste URL" = "URL einfügen"; + +"Continue Reading" = "Weiterlesen"; + +"Backup & Restore" = "Sichern & Wiederherstellen"; +"Export Backup" = "Backup exportieren"; +"Import Backup" = "Backup importieren"; +"Notice: This feature is still experimental. Please double-check your data after import/export." = "Hinweis: Diese Funktion ist noch experimentell. Bitte überprüfe deine Daten nach dem Export/Import."; +"Backup" = "Backup"; diff --git a/Sora/Localization/en.lproj/Localizable.strings b/Sora/Localization/en.lproj/Localizable.strings index 303ef28..06d2550 100644 --- a/Sora/Localization/en.lproj/Localizable.strings +++ b/Sora/Localization/en.lproj/Localizable.strings @@ -396,5 +396,66 @@ /* New additions */ "Recent searches" = "Recent searches"; "me frfr" = "me frfr"; -"Data" = "Data"; +"Data" = "Data"; +"All Reading" = "All Reading"; +"No Reading History" = "No Reading History"; +"Books you're reading will appear here" = "Books you're reading will appear here"; +"All Watching" = "All Watching"; +"Continue Reading" = "Continue Reading"; +"Nothing to Continue Reading" = "Nothing to Continue Reading"; +"Your recently read novels will appear here" = "Your recently read novels will appear here"; +"No Bookmarks" = "No Bookmarks"; +"Add bookmarks to this collection" = "Add bookmarks to this collection"; +"items" = "items"; +"Chapter %d" = "Chapter %d"; +"Episode %d" = "Episode %d"; +"%d%%" = "%d%%"; +"%d%% seen" = "%d%% seen"; +"DownloadCountFormat" = "%d of %d"; +"Error loading chapter" = "Error loading chapter"; +"Font Size: %dpt" = "Font Size: %dpt"; +"Line Spacing: %.1f" = "Line Spacing: %.1f"; +"Line Spacing" = "Line Spacing"; +"Margin: %dpx" = "Margin: %dpx"; +"Margin" = "Margin"; +"Auto Scroll Speed" = "Auto Scroll Speed"; +"Speed" = "Speed"; +"Speed: %.1fx" = "Speed: %.1fx"; +"Matched %@: %@" = "Matched %@: %@"; +"Enter the AniList ID for this series" = "Enter the AniList ID for this series"; + +/* New additions */ +"Create Collection" = "Create Collection"; +"Collection Name" = "Collection Name"; +"Rename Collection" = "Rename Collection"; +"Rename" = "Rename"; +"All Reading" = "All Reading"; +"Recently Added" = "Recently Added"; +"Novel Title" = "Novel Title"; +"Read Progress" = "Read Progress"; +"Date Created" = "Date Created"; +"Name" = "Name"; +"Item Count" = "Item Count"; +"Date Added" = "Date Added"; +"Title" = "Title"; +"Source" = "Source"; +"Search reading..." = "Search reading..."; +"Search collections..." = "Search collections..."; +"Search bookmarks..." = "Search bookmarks..."; +"%d items" = "%d items"; +"Fetching Data" = "Fetching Data"; +"Please wait while fetching." = "Please wait while fetching."; +"Start Reading" = "Start Reading"; +"Chapters" = "Chapters"; +"Completed" = "Completed"; +"Drag to reorder" = "Drag to reorder"; +"Drag to reorder sections" = "Drag to reorder sections"; +"Library View" = "Library View"; +"Customize the sections shown in your library. You can reorder sections or disable them completely." = "Customize the sections shown in your library. You can reorder sections or disable them completely."; +"Library Sections Order" = "Library Sections Order"; +"Completion Percentage" = "Completion Percentage"; +"Some features are limited to the Sora and Default player, such as ForceLandscape, holdSpeed and custom time skip increments.\n\nThe completion percentage setting determines at what point before the end of a video the app will mark it as completed on AniList and Trakt." = "Some features are limited to the Sora and Default player, such as ForceLandscape, holdSpeed and custom time skip increments.\n\nThe completion percentage setting determines at what point before the end of a video the app will mark it as completed on AniList and Trakt."; +"The app cache helps the app load images faster.\n\nClearing the Documents folder will delete all downloaded modules.\n\nErasing the App Data will clears all your settings and data of the app." = "The app cache helps the app load images faster.\n\nClearing the Documents folder will delete all downloaded modules.\n\nErasing the App Data will clears all your settings and data of the app."; +"Translators" = "Translators"; +"Paste URL" = "Paste URL"; diff --git a/Sora/Localization/es.lproj/Localizable.strings b/Sora/Localization/es.lproj/Localizable.strings index d4f1f09..ac10a76 100644 --- a/Sora/Localization/es.lproj/Localizable.strings +++ b/Sora/Localization/es.lproj/Localizable.strings @@ -404,4 +404,103 @@ Para los metadatos del episodio, se refiere a la miniatura y el título del epis "me frfr" = "yo frfr"; "Data" = "Datos"; "Maximum Quality Available" = "Calidad máxima disponible"; +"DownloadCountFormat" = "%d de %d"; +"Error loading chapter" = "Error al cargar el capítulo"; +"Font Size: %dpt" = "Tamaño de fuente: %dpt"; +"Line Spacing: %.1f" = "Espaciado de línea: %.1f"; +"Line Spacing" = "Espaciado de línea"; +"Margin: %dpx" = "Margen: %dpx"; +"Margin" = "Margen"; +"Auto Scroll Speed" = "Velocidad de desplazamiento automático"; +"Speed" = "Velocidad"; +"Speed: %.1fx" = "Velocidad: %.1fx"; +"Matched %@: %@" = "Coincidencia %@: %@"; +"Enter the AniList ID for this series" = "Introduce el ID de AniList para esta serie"; + +/* Added missing localizations */ +"Create Collection" = "Crear colección"; +"Collection Name" = "Nombre de la colección"; +"Rename Collection" = "Renombrar colección"; +"Rename" = "Renombrar"; +"All Reading" = "Todas las lecturas"; +"Recently Added" = "Añadido recientemente"; +"Novel Title" = "Título de la novela"; +"Read Progress" = "Progreso de lectura"; +"Date Created" = "Fecha de creación"; +"Name" = "Nombre"; +"Item Count" = "Cantidad de elementos"; +"Date Added" = "Fecha de añadido"; +"Title" = "Título"; +"Source" = "Fuente"; +"Search reading..." = "Buscar en lecturas..."; +"Search collections..." = "Buscar en colecciones..."; +"Search bookmarks..." = "Buscar en marcadores..."; +"%d items" = "%d elementos"; +"Fetching Data" = "Obteniendo datos"; +"Please wait while fetching." = "Por favor, espere mientras se obtienen los datos."; +"Start Reading" = "Comenzar a leer"; +"Chapters" = "Capítulos"; +"Completed" = "Completado"; +"Drag to reorder" = "Arrastrar para reordenar"; +"Drag to reorder sections" = "Arrastrar para reordenar secciones"; +"Library View" = "Vista de biblioteca"; +"Customize the sections shown in your library. You can reorder sections or disable them completely." = "Personaliza las secciones que se muestran en tu biblioteca. Puedes reordenarlas o desactivarlas completamente."; +"Library Sections Order" = "Orden de secciones de la biblioteca"; +"Completion Percentage" = "Porcentaje de finalización"; +"Some features are limited to the Sora and Default player, such as ForceLandscape, holdSpeed and custom time skip increments.\n\nThe completion percentage setting determines at what point before the end of a video the app will mark it as completed on AniList and Trakt." = "Algunas funciones están limitadas al reproductor Sora y al predeterminado, como el modo horizontal forzado, la velocidad de retención y los saltos de tiempo personalizados.\n\nEl ajuste del porcentaje de finalización determina en qué punto antes del final de un vídeo la app lo marcará como completado en AniList y Trakt."; +"The app cache helps the app load images faster.\n\nClearing the Documents folder will delete all downloaded modules.\n\nErasing the App Data will clears all your settings and data of the app." = "La caché de la app ayuda a cargar imágenes más rápido.\n\nBorrar la carpeta Documentos eliminará todos los módulos descargados.\n\nBorrar los datos de la app eliminará todos tus ajustes y datos."; +"Translators" = "Traductores"; +"Paste URL" = "Pegar URL"; + +/* Added missing localizations */ +"Series Title" = "Título de la serie"; +"Content Source" = "Fuente de contenido"; +"Watch Progress" = "Progreso de visualización"; +"All Reading" = "Todo lo que lees"; +"Nothing to Continue Reading" = "Nada para continuar leyendo"; +"Your recently read novels will appear here" = "Tus novelas leídas recientemente aparecerán aquí"; +"No Bookmarks" = "Sin marcadores"; +"Add bookmarks to this collection" = "Agrega marcadores a esta colección"; +"items" = "elementos"; +"All Watching" = "Todo lo que ves"; +"No Reading History" = "Sin historial de lectura"; +"Books you're reading will appear here" = "Los libros que estás leyendo aparecerán aquí"; +"Create Collection" = "Crear colección"; +"Collection Name" = "Nombre de la colección"; +"Rename Collection" = "Renombrar colección"; +"Rename" = "Renombrar"; +"Novel Title" = "Título de la novela"; +"Read Progress" = "Progreso de lectura"; +"Date Created" = "Fecha de creación"; +"Name" = "Nombre"; +"Item Count" = "Cantidad de elementos"; +"Date Added" = "Fecha de agregado"; +"Title" = "Título"; +"Source" = "Fuente"; +"Search reading..." = "Buscar en lecturas..."; +"Search collections..." = "Buscar en colecciones..."; +"Search bookmarks..." = "Buscar en marcadores..."; +"%d items" = "%d elementos"; +"Fetching Data" = "Obteniendo datos"; +"Please wait while fetching." = "Por favor, espera mientras se obtienen los datos."; +"Start Reading" = "Comenzar a leer"; +"Chapters" = "Capítulos"; +"Completed" = "Completado"; +"Drag to reorder" = "Arrastra para reordenar"; +"Drag to reorder sections" = "Arrastra para reordenar secciones"; +"Library View" = "Vista de biblioteca"; +"Customize the sections shown in your library. You can reorder sections or disable them completely." = "Personaliza las secciones que se muestran en tu biblioteca. Puedes reordenar secciones o deshabilitarlas completamente."; +"Library Sections Order" = "Orden de secciones de la biblioteca"; +"Completion Percentage" = "Porcentaje de finalización"; +"Translators" = "Traductores"; +"Paste URL" = "Pegar URL"; + +"Collections" = "Colecciones"; +"Continue Reading" = "Continuar leyendo"; + +"Backup & Restore" = "Copia de seguridad y restaurar"; +"Export Backup" = "Exportar copia de seguridad"; +"Import Backup" = "Importar copia de seguridad"; +"Notice: This feature is still experimental. Please double-check your data after import/export." = "Aviso: Esta función aún es experimental. Por favor, verifica tus datos después de exportar/importar."; +"Backup" = "Copia de seguridad"; diff --git a/Sora/Localization/fr.lproj/Localizable.strings b/Sora/Localization/fr.lproj/Localizable.strings index 6fe1728..27a6959 100644 --- a/Sora/Localization/fr.lproj/Localizable.strings +++ b/Sora/Localization/fr.lproj/Localizable.strings @@ -388,3 +388,107 @@ "me frfr" = "moi frfr"; "Data" = "Données"; "Maximum Quality Available" = "Qualité maximale disponible"; + +/* Additional translations */ +"DownloadCountFormat" = "%d sur %d"; +"Error loading chapter" = "Erreur lors du chargement du chapitre"; +"Font Size: %dpt" = "Taille de police : %dpt"; +"Line Spacing: %.1f" = "Interligne : %.1f"; +"Line Spacing" = "Interligne"; +"Margin: %dpx" = "Marge : %dpx"; +"Margin" = "Marge"; +"Auto Scroll Speed" = "Vitesse de défilement automatique"; +"Speed" = "Vitesse"; +"Speed: %.1fx" = "Vitesse : %.1fx"; +"Matched %@: %@" = "Correspondance %@ : %@"; +"Enter the AniList ID for this series" = "Entrez l'ID AniList pour cette série"; + +/* Added missing localizations */ +"Create Collection" = "Créer une collection"; +"Collection Name" = "Nom de la collection"; +"Rename Collection" = "Renommer la collection"; +"Rename" = "Renommer"; +"All Reading" = "Toutes les lectures"; +"Recently Added" = "Ajouté récemment"; +"Novel Title" = "Titre du roman"; +"Read Progress" = "Progression de la lecture"; +"Date Created" = "Date de création"; +"Name" = "Nom"; +"Item Count" = "Nombre d'éléments"; +"Date Added" = "Date d'ajout"; +"Title" = "Titre"; +"Source" = "Source"; +"Search reading..." = "Rechercher dans les lectures..."; +"Search collections..." = "Rechercher dans les collections..."; +"Search bookmarks..." = "Rechercher dans les favoris..."; +"%d items" = "%d éléments"; +"Fetching Data" = "Récupération des données"; +"Please wait while fetching." = "Veuillez patienter pendant la récupération."; +"Start Reading" = "Commencer la lecture"; +"Chapters" = "Chapitres"; +"Completed" = "Terminé"; +"Drag to reorder" = "Glisser pour réorganiser"; +"Drag to reorder sections" = "Glisser pour réorganiser les sections"; +"Library View" = "Vue de la bibliothèque"; +"Customize the sections shown in your library. You can reorder sections or disable them completely." = "Personnalisez les sections affichées dans votre bibliothèque. Vous pouvez réorganiser ou désactiver complètement les sections."; +"Library Sections Order" = "Ordre des sections de la bibliothèque"; +"Completion Percentage" = "Pourcentage d'achèvement"; +"Some features are limited to the Sora and Default player, such as ForceLandscape, holdSpeed and custom time skip increments.\n\nThe completion percentage setting determines at what point before the end of a video the app will mark it as completed on AniList and Trakt." = "Certaines fonctionnalités sont limitées au lecteur Sora et au lecteur par défaut, comme le mode paysage forcé, la vitesse de maintien et les sauts de temps personnalisés.\n\nLe réglage du pourcentage d'achèvement détermine à quel moment avant la fin d'une vidéo l'application la marquera comme terminée sur AniList et Trakt."; +"The app cache helps the app load images faster.\n\nClearing the Documents folder will delete all downloaded modules.\n\nErasing the App Data will clears all your settings and data of the app." = "Le cache de l'application aide à charger les images plus rapidement.\n\nVider le dossier Documents supprimera tous les modules téléchargés.\n\nEffacer les données de l'application supprimera tous vos paramètres et données."; +"Translators" = "Traducteurs"; +"Paste URL" = "Coller l'URL"; + +/* New additions */ +"Series Title" = "Titre de la série"; +"Content Source" = "Source du contenu"; +"Watch Progress" = "Progression de visionnage"; +"Recent searches" = "Recherches récentes"; +"All Reading" = "Tout ce que vous lisez"; +"Nothing to Continue Reading" = "Rien à continuer à lire"; +"Your recently read novels will appear here" = "Vos romans récemment lus apparaîtront ici"; +"No Bookmarks" = "Aucun favori"; +"Add bookmarks to this collection" = "Ajoutez des favoris à cette collection"; +"items" = "éléments"; +"All Watching" = "Tout ce que vous regardez"; +"No Reading History" = "Aucun historique de lecture"; +"Books you're reading will appear here" = "Les livres que vous lisez apparaîtront ici"; +"Create Collection" = "Créer une collection"; +"Collection Name" = "Nom de la collection"; +"Rename Collection" = "Renommer la collection"; +"Rename" = "Renommer"; +"Novel Title" = "Titre du roman"; +"Read Progress" = "Progression de lecture"; +"Date Created" = "Date de création"; +"Name" = "Nom"; +"Item Count" = "Nombre d'éléments"; +"Date Added" = "Date d'ajout"; +"Title" = "Titre"; +"Source" = "Source"; +"Search reading..." = "Rechercher dans les lectures..."; +"Search collections..." = "Rechercher dans les collections..."; +"Search bookmarks..." = "Rechercher dans les favoris..."; +"%d items" = "%d éléments"; +"Fetching Data" = "Récupération des données"; +"Please wait while fetching." = "Veuillez patienter pendant la récupération."; +"Start Reading" = "Commencer la lecture"; +"Chapters" = "Chapitres"; +"Completed" = "Terminé"; +"Drag to reorder" = "Faites glisser pour réorganiser"; +"Drag to reorder sections" = "Faites glisser pour réorganiser les sections"; +"Library View" = "Vue de la bibliothèque"; +"Customize the sections shown in your library. You can reorder sections or disable them completely." = "Personnalisez les sections affichées dans votre bibliothèque. Vous pouvez réorganiser les sections ou les désactiver complètement."; +"Library Sections Order" = "Ordre des sections de la bibliothèque"; +"Completion Percentage" = "Pourcentage d'achèvement"; +"Translators" = "Traducteurs"; +"Paste URL" = "Coller l'URL"; + +/* New additions */ +"Collections" = "Collections"; +"Continue Reading" = "Continuer la lecture"; + +/* Backup & Restore */ +"Backup & Restore" = "Sauvegarde & Restauration"; +"Export Backup" = "Exporter la sauvegarde"; +"Import Backup" = "Importer la sauvegarde"; +"Notice: This feature is still experimental. Please double-check your data after import/export." = "Remarque : Cette fonctionnalité est encore expérimentale. Veuillez vérifier vos données après l'export/import."; +"Backup" = "Sauvegarde"; diff --git a/Sora/Localization/it.lproj/Localizable.strings b/Sora/Localization/it.lproj/Localizable.strings index df2e8d0..4a90535 100644 --- a/Sora/Localization/it.lproj/Localizable.strings +++ b/Sora/Localization/it.lproj/Localizable.strings @@ -328,3 +328,98 @@ "me frfr" = "me frfr"; "Data" = "Dati"; "Maximum Quality Available" = "Qualità Massima Disponibile"; +"DownloadCountFormat" = "%d di %d"; +"Error loading chapter" = "Errore nel caricamento del capitolo"; +"Font Size: %dpt" = "Dimensione carattere: %dpt"; +"Line Spacing: %.1f" = "Interlinea: %.1f"; +"Line Spacing" = "Interlinea"; +"Margin: %dpx" = "Margine: %dpx"; +"Margin" = "Margine"; +"Auto Scroll Speed" = "Velocità di scorrimento automatico"; +"Speed" = "Velocità"; +"Speed: %.1fx" = "Velocità: %.1fx"; +"Matched %@: %@" = "Corrispondenza %@: %@"; +"Enter the AniList ID for this series" = "Inserisci l'ID AniList per questa serie"; +/* Added missing localizations */ +"Create Collection" = "Crea raccolta"; +"Collection Name" = "Nome raccolta"; +"Rename Collection" = "Rinomina raccolta"; +"Rename" = "Rinomina"; +"All Reading" = "Tutte le letture"; +"Recently Added" = "Aggiunti di recente"; +"Novel Title" = "Titolo del romanzo"; +"Read Progress" = "Progresso lettura"; +"Date Created" = "Data di creazione"; +"Name" = "Nome"; +"Item Count" = "Numero di elementi"; +"Date Added" = "Data di aggiunta"; +"Title" = "Titolo"; +"Source" = "Fonte"; +"Search reading..." = "Cerca nelle letture..."; +"Search collections..." = "Cerca nelle raccolte..."; +"Search bookmarks..." = "Cerca nei segnalibri..."; +"%d items" = "%d elementi"; +"Fetching Data" = "Recupero dati"; +"Please wait while fetching." = "Attendere durante il recupero."; +"Start Reading" = "Inizia a leggere"; +"Chapters" = "Capitoli"; +"Completed" = "Completato"; +"Drag to reorder" = "Trascina per riordinare"; +"Drag to reorder sections" = "Trascina per riordinare le sezioni"; +"Library View" = "Vista libreria"; +"Customize the sections shown in your library. You can reorder sections or disable them completely." = "Personalizza le sezioni mostrate nella tua libreria. Puoi riordinare le sezioni o disabilitarle completamente."; +"Library Sections Order" = "Ordine delle sezioni della libreria"; +"Completion Percentage" = "Percentuale di completamento"; +"Some features are limited to the Sora and Default player, such as ForceLandscape, holdSpeed and custom time skip increments.\n\nThe completion percentage setting determines at what point before the end of a video the app will mark it as completed on AniList and Trakt." = "Alcune funzioni sono limitate al player Sora e a quello predefinito, come la forzatura del paesaggio, la velocità di mantenimento e gli intervalli di salto personalizzati.\n\nL'impostazione della percentuale di completamento determina in quale punto prima della fine di un video l'app lo segnerà come completato su AniList e Trakt."; +"The app cache helps the app load images faster.\n\nClearing the Documents folder will delete all downloaded modules.\n\nErasing the App Data will clears all your settings and data of the app." = "La cache dell'app aiuta a caricare le immagini più velocemente.\n\nCancellare la cartella Documenti eliminerà tutti i moduli scaricati.\n\nCancellare i dati dell'app eliminerà tutte le tue impostazioni e dati."; +"Translators" = "Traduttori"; +"Paste URL" = "Incolla URL"; +"Series Title" = "Titolo della serie"; +"Content Source" = "Fonte del contenuto"; +"Watch Progress" = "Progresso di visione"; +"Recent searches" = "Ricerche recenti"; +"All Reading" = "Tutto ciò che leggi"; +"Nothing to Continue Reading" = "Niente da continuare a leggere"; +"Your recently read novels will appear here" = "I tuoi romanzi letti di recente appariranno qui"; +"No Bookmarks" = "Nessun segnalibro"; +"Add bookmarks to this collection" = "Aggiungi segnalibri a questa raccolta"; +"items" = "elementi"; +"All Watching" = "Tutto ciò che guardi"; +"No Reading History" = "Nessuna cronologia di lettura"; +"Books you're reading will appear here" = "I libri che stai leggendo appariranno qui"; +"Create Collection" = "Crea raccolta"; +"Collection Name" = "Nome raccolta"; +"Rename Collection" = "Rinomina raccolta"; +"Rename" = "Rinomina"; +"Novel Title" = "Titolo del romanzo"; +"Read Progress" = "Progresso di lettura"; +"Date Created" = "Data di creazione"; +"Name" = "Nome"; +"Item Count" = "Numero di elementi"; +"Date Added" = "Data di aggiunta"; +"Title" = "Titolo"; +"Source" = "Fonte"; +"Search reading..." = "Cerca nelle letture..."; +"Search collections..." = "Cerca nelle raccolte..."; +"Search bookmarks..." = "Cerca nei segnalibri..."; +"%d items" = "%d elementi"; +"Fetching Data" = "Recupero dati"; +"Please wait while fetching." = "Attendere durante il recupero."; +"Start Reading" = "Inizia a leggere"; +"Chapters" = "Capitoli"; +"Completed" = "Completato"; +"Drag to reorder" = "Trascina per riordinare"; +"Drag to reorder sections" = "Trascina per riordinare le sezioni"; +"Library View" = "Vista libreria"; +"Customize the sections shown in your library. You can reorder sections or disable them completely." = "Personalizza le sezioni mostrate nella tua libreria. Puoi riordinare le sezioni o disabilitarle completamente."; +"Library Sections Order" = "Ordine delle sezioni della libreria"; +"Completion Percentage" = "Percentuale di completamento"; +"Translators" = "Traduttori"; +"Paste URL" = "Incolla URL"; +"Collections" = "Raccolte"; +"Continue Reading" = "Continua a leggere"; +"Backup & Restore" = "Backup e ripristino"; +"Export Backup" = "Esporta backup"; +"Import Backup" = "Importa backup"; +"Notice: This feature is still experimental. Please double-check your data after import/export." = "Nota: Questa funzione è ancora sperimentale. Si prega di ricontrollare i dati dopo esportazione/importazione."; +"Backup" = "Backup"; diff --git a/Sora/Localization/kk.lproj/Localizable.strings b/Sora/Localization/kk.lproj/Localizable.strings index ed8a7f2..ba6afce 100644 --- a/Sora/Localization/kk.lproj/Localizable.strings +++ b/Sora/Localization/kk.lproj/Localizable.strings @@ -397,10 +397,110 @@ For episode metadata, it refers to the episode thumbnail and title, since someti "Storage Management" = "Қойма басқаруы"; "Storage Used" = "Пайдаланылған қойма"; "Library cleared successfully" = "Кітапхана сәтті тазартылды"; -"All downloads deleted successfully" = "Барлық жүктеулер сәтті жойылды"; +"All downloads deleted successfully" = "Барлық жүктеулер сәтті жойылды"; /* New additions */ "Recent searches" = "Соңғы іздеулер"; "me frfr" = "мен шынында"; "Data" = "Деректер"; -"Maximum Quality Available" = "Қолжетімді максималды сапа"; \ No newline at end of file +"Maximum Quality Available" = "Қолжетімді максималды сапа"; +"DownloadCountFormat" = "%d / %d"; +"Error loading chapter" = "Тарауды жүктеу қатесі"; +"Font Size: %dpt" = "Қаріп өлшемі: %dpt"; +"Line Spacing: %.1f" = "Жоларалық қашықтық: %.1f"; +"Line Spacing" = "Жоларалық қашықтық"; +"Margin: %dpx" = "Шеткі өріс: %dpx"; +"Margin" = "Шеткі өріс"; +"Auto Scroll Speed" = "Автоматты айналдыру жылдамдығы"; +"Speed" = "Жылдамдық"; +"Speed: %.1fx" = "Жылдамдық: %.1fx"; +"Matched %@: %@" = "Сәйкестік %@: %@"; +"Enter the AniList ID for this series" = "Осы серия үшін AniList ID енгізіңіз"; + +/* Added missing localizations */ +"Create Collection" = "Жинақ құру"; +"Collection Name" = "Жинақ атауы"; +"Rename Collection" = "Жинақты қайта атау"; +"Rename" = "Қайта атау"; +"All Reading" = "Барлық оқу"; +"Recently Added" = "Жуырда қосылған"; +"Novel Title" = "Роман атауы"; +"Read Progress" = "Оқу барысы"; +"Date Created" = "Құрылған күні"; +"Name" = "Атауы"; +"Item Count" = "Элементтер саны"; +"Date Added" = "Қосылған күні"; +"Title" = "Тақырып"; +"Source" = "Дереккөз"; +"Search reading..." = "Оқуды іздеу..."; +"Search collections..." = "Жинақтарды іздеу..."; +"Search bookmarks..." = "Бетбелгілерді іздеу..."; +"%d items" = "%d элемент"; +"Fetching Data" = "Деректерді алу"; +"Please wait while fetching." = "Алу барысында күтіңіз."; +"Start Reading" = "Оқуды бастау"; +"Chapters" = "Тараулар"; +"Completed" = "Аяқталды"; +"Drag to reorder" = "Ретін өзгерту үшін сүйреңіз"; +"Drag to reorder sections" = "Бөлімдердің ретін өзгерту үшін сүйреңіз"; +"Library View" = "Кітапхана көрінісі"; +"Customize the sections shown in your library. You can reorder sections or disable them completely." = "Кітапханаңызда көрсетілетін бөлімдерді баптаңыз. Бөлімдерді қайта реттеуге немесе толықтай өшіруге болады."; +"Library Sections Order" = "Кітапхана бөлімдерінің реті"; +"Completion Percentage" = "Аяқталу пайызы"; +"Some features are limited to the Sora and Default player, such as ForceLandscape, holdSpeed and custom time skip increments.\n\nThe completion percentage setting determines at what point before the end of a video the app will mark it as completed on AniList and Trakt." = "Кейбір функциялар тек Sora және әдепкі ойнатқышта ғана қолжетімді, мысалы, ландшафтты мәжбүрлеу, жылдамдықты ұстау және уақытты өткізіп жіберу.\n\nАяқталу пайызы параметрі бейне соңына дейін қай жерде аяқталған деп белгіленетінін анықтайды (AniList және Trakt)."; +"The app cache helps the app load images faster.\n\nClearing the Documents folder will delete all downloaded modules.\n\nErasing the App Data will clears all your settings and data of the app." = "Қолданба кэші суреттерді жылдам жүктеуге көмектеседі.\n\nDocuments қалтасын тазалау барлық жүктелген модульдерді өшіреді.\n\nҚолданба деректерін өшіру барлық баптауларыңызды және деректеріңізді өшіреді."; +"Translators" = "Аудармашылар"; +"Paste URL" = "URL қою"; + +/* Added missing localizations */ +"Series Title" = "Серия атауы"; +"Content Source" = "Мазмұн көзі"; +"Watch Progress" = "Көру барысы"; +"Nothing to Continue Reading" = "Оқуды жалғастыруға ештеңе жоқ"; +"Your recently read novels will appear here" = "Соңғы оқылған романдар осында көрсетіледі"; +"No Bookmarks" = "Бетбелгілер жоқ"; +"Add bookmarks to this collection" = "Бұл жинаққа бетбелгілерді қосыңыз"; +"items" = "элементтер"; +"All Watching" = "Барлық көру"; +"No Reading History" = "Оқу тарихы жоқ"; +"Books you're reading will appear here" = "Сіз оқып жатқан кітаптар осында көрсетіледі"; +"Create Collection" = "Жинақ құру"; +"Collection Name" = "Жинақ атауы"; +"Rename Collection" = "Жинақты қайта атау"; +"Rename" = "Қайта атау"; +"Novel Title" = "Роман атауы"; +"Read Progress" = "Оқу барысы"; +"Date Created" = "Жасалған күні"; +"Name" = "Аты"; +"Item Count" = "Элементтер саны"; +"Date Added" = "Қосылған күні"; +"Title" = "Атауы"; +"Source" = "Дереккөз"; +"Search reading..." = "Оқуды іздеу..."; +"Search collections..." = "Жинақтарды іздеу..."; +"Search bookmarks..." = "Бетбелгілерді іздеу..."; +"%d items" = "%d элемент"; +"Fetching Data" = "Деректерді алу"; +"Please wait while fetching." = "Алу кезінде күтіңіз."; +"Start Reading" = "Оқуды бастау"; +"Chapters" = "Тараулар"; +"Completed" = "Аяқталды"; +"Drag to reorder" = "Қайта реттеу үшін сүйреңіз"; +"Drag to reorder sections" = "Бөлімдерді қайта реттеу үшін сүйреңіз"; +"Library View" = "Кітапхана көрінісі"; +"Customize the sections shown in your library. You can reorder sections or disable them completely." = "Кітапханаңызда көрсетілетін бөлімдерді реттеңіз. Бөлімдерді қайта реттеуге немесе толықтай өшіруге болады."; +"Library Sections Order" = "Кітапхана бөлімдерінің реті"; +"Completion Percentage" = "Аяқталу пайызы"; +"Translators" = "Аудармашылар"; +"Paste URL" = "URL қою"; + +/* Added missing localizations */ +"Collections" = "Жинақтар"; +"Continue Reading" = "Оқуды жалғастыру"; + +/* Backup & Restore */ +"Backup & Restore" = "Сақтық көшірме және қалпына келтіру"; +"Export Backup" = "Сақтық көшірмені экспорттау"; +"Import Backup" = "Сақтық көшірмені импорттау"; +"Notice: This feature is still experimental. Please double-check your data after import/export." = "Ескерту: Бұл мүмкіндік әлі де тәжірибелік. Экспорт/импорттан кейін деректеріңізді тексеріңіз."; +"Backup" = "Сақтық көшірме"; \ No newline at end of file diff --git a/Sora/Localization/nl.lproj/Localizable.strings b/Sora/Localization/nl.lproj/Localizable.strings index 8b61971..3cc1545 100644 --- a/Sora/Localization/nl.lproj/Localizable.strings +++ b/Sora/Localization/nl.lproj/Localizable.strings @@ -387,4 +387,102 @@ "Recent searches" = "Recente zoekopdrachten"; "me frfr" = "ik frfr"; "Data" = "Gegevens"; -"Maximum Quality Available" = "Maximale beschikbare kwaliteit"; +"Maximum Quality Available" = "Maximale beschikbare kwaliteit"; +"DownloadCountFormat" = "%d van %d"; +"Error loading chapter" = "Fout bij het laden van hoofdstuk"; +"Font Size: %dpt" = "Lettergrootte: %dpt"; +"Line Spacing: %.1f" = "Regelafstand: %.1f"; +"Line Spacing" = "Regelafstand"; +"Margin: %dpx" = "Marge: %dpx"; +"Margin" = "Marge"; +"Auto Scroll Speed" = "Automatische scrollsnelheid"; +"Speed" = "Snelheid"; +"Speed: %.1fx" = "Snelheid: %.1fx"; +"Matched %@: %@" = "Overeenkomst %@: %@"; +"Enter the AniList ID for this series" = "Voer de AniList-ID voor deze serie in"; + +/* Added missing localizations */ +"Create Collection" = "Collectie aanmaken"; +"Collection Name" = "Collectienaam"; +"Rename Collection" = "Collectie hernoemen"; +"Rename" = "Hernoemen"; +"All Reading" = "Alles wat je leest"; +"Recently Added" = "Recent toegevoegd"; +"Novel Title" = "Roman titel"; +"Read Progress" = "Leesvoortgang"; +"Date Created" = "Aanmaakdatum"; +"Name" = "Naam"; +"Item Count" = "Aantal items"; +"Date Added" = "Datum toegevoegd"; +"Title" = "Titel"; +"Source" = "Bron"; +"Search reading..." = "Zoek in lezen..."; +"Search collections..." = "Zoek in collecties..."; +"Search bookmarks..." = "Zoek in bladwijzers..."; +"%d items" = "%d items"; +"Fetching Data" = "Gegevens ophalen"; +"Please wait while fetching." = "Even geduld tijdens het ophalen."; +"Start Reading" = "Begin met lezen"; +"Chapters" = "Hoofdstukken"; +"Completed" = "Voltooid"; +"Drag to reorder" = "Sleep om te herschikken"; +"Drag to reorder sections" = "Sleep om secties te herschikken"; +"Library View" = "Bibliotheekweergave"; +"Customize the sections shown in your library. You can reorder sections or disable them completely." = "Pas de secties aan die in je bibliotheek worden weergegeven. Je kunt secties herschikken of volledig uitschakelen."; +"Library Sections Order" = "Volgorde van bibliotheeksecties"; +"Completion Percentage" = "Voltooiingspercentage"; +"Translators" = "Vertalers"; +"Paste URL" = "URL plakken"; + +/* Added missing localizations */ +"Series Title" = "Serietitel"; +"Content Source" = "Inhoudsbron"; +"Watch Progress" = "Kijkvoortgang"; +"Nothing to Continue Reading" = "Niets om verder te lezen"; +"Your recently read novels will appear here" = "Je recent gelezen romans verschijnen hier"; +"No Bookmarks" = "Geen bladwijzers"; +"Add bookmarks to this collection" = "Voeg bladwijzers toe aan deze collectie"; +"items" = "items"; +"All Watching" = "Alles wat je kijkt"; +"No Reading History" = "Geen leeshistorie"; +"Books you're reading will appear here" = "Boeken die je leest verschijnen hier"; +"Create Collection" = "Collectie aanmaken"; +"Collection Name" = "Collectienaam"; +"Rename Collection" = "Collectie hernoemen"; +"Rename" = "Hernoemen"; +"Novel Title" = "Roman titel"; +"Read Progress" = "Leesvoortgang"; +"Date Created" = "Aanmaakdatum"; +"Name" = "Naam"; +"Item Count" = "Aantal items"; +"Date Added" = "Datum toegevoegd"; +"Title" = "Titel"; +"Source" = "Bron"; +"Search reading..." = "Zoek in lezen..."; +"Search collections..." = "Zoek in collecties..."; +"Search bookmarks..." = "Zoek in bladwijzers..."; +"%d items" = "%d items"; +"Fetching Data" = "Gegevens ophalen"; +"Please wait while fetching." = "Even geduld tijdens het ophalen."; +"Start Reading" = "Begin met lezen"; +"Chapters" = "Hoofdstukken"; +"Completed" = "Voltooid"; +"Drag to reorder" = "Sleep om te herschikken"; +"Drag to reorder sections" = "Sleep om secties te herschikken"; +"Library View" = "Bibliotheekweergave"; +"Customize the sections shown in your library. You can reorder sections or disable them completely." = "Pas de secties aan die in je bibliotheek worden weergegeven. Je kunt secties herschikken of volledig uitschakelen."; +"Library Sections Order" = "Volgorde van bibliotheeksecties"; +"Completion Percentage" = "Voltooiingspercentage"; +"Translators" = "Vertalers"; +"Paste URL" = "URL plakken"; + +/* Added missing localizations */ +"Collections" = "Collecties"; +"Continue Reading" = "Doorgaan met lezen"; + +/* Backup & Restore */ +"Backup & Restore" = "Back-up & Herstellen"; +"Export Backup" = "Back-up exporteren"; +"Import Backup" = "Back-up importeren"; +"Notice: This feature is still experimental. Please double-check your data after import/export." = "Let op: Deze functie is nog experimenteel. Controleer je gegevens na export/import."; +"Backup" = "Back-up"; diff --git a/Sora/Localization/nn.lproj/Localizable.strings b/Sora/Localization/nn.lproj/Localizable.strings index 4876fd0..91add59 100644 --- a/Sora/Localization/nn.lproj/Localizable.strings +++ b/Sora/Localization/nn.lproj/Localizable.strings @@ -381,4 +381,108 @@ "Storage Management" = "Lagringsadministrasjon"; "Storage Used" = "Brukt Lagring"; "Library cleared successfully" = "Bibliotek tømt"; -"All downloads deleted successfully" = "Alle nedlastinger slettet"; \ No newline at end of file +"All downloads deleted successfully" = "Alle nedlastinger slettet"; + +/* New keys from English localization */ +"DownloadCountFormat" = "%d av %d"; +"Error loading chapter" = "Feil ved lasting av kapittel"; +"Font Size: %dpt" = "Skriftstorleik: %dpt"; +"Line Spacing: %.1f" = "Linjeavstand: %.1f"; +"Line Spacing" = "Linjeavstand"; +"Margin: %dpx" = "Marg: %dpx"; +"Margin" = "Marg"; +"Auto Scroll Speed" = "Fart på automatisk rulling"; +"Speed" = "Fart"; +"Speed: %.1fx" = "Fart: %.1fx"; +"Matched %@: %@" = "Treff %@: %@"; +"Enter the AniList ID for this series" = "Skriv inn AniList-ID for denne serien"; + +/* Added missing localizations */ +"Create Collection" = "Opprett samling"; +"Collection Name" = "Samlingens navn"; +"Rename Collection" = "Gi nytt navn til samling"; +"Rename" = "Gi nytt navn"; +"All Reading" = "All lesing"; +"Recently Added" = "Nylig lagt til"; +"Novel Title" = "Roman tittel"; +"Read Progress" = "Lesefremgang"; +"Date Created" = "Opprettelsesdato"; +"Name" = "Navn"; +"Item Count" = "Antall elementer"; +"Date Added" = "Dato lagt til"; +"Title" = "Tittel"; +"Source" = "Kilde"; +"Search reading..." = "Søk i lesing..."; +"Search collections..." = "Søk i samlinger..."; +"Search bookmarks..." = "Søk i bokmerker..."; +"%d items" = "%d elementer"; +"Fetching Data" = "Henter data"; +"Please wait while fetching." = "Vennligst vent mens det hentes."; +"Start Reading" = "Start lesing"; +"Chapters" = "Kapitler"; +"Completed" = "Fullført"; +"Drag to reorder" = "Dra for å endre rekkefølge"; +"Drag to reorder sections" = "Dra for å endre rekkefølge på seksjoner"; +"Library View" = "Bibliotekvisning"; +"Customize the sections shown in your library. You can reorder sections or disable them completely." = "Tilpass seksjonene som vises i biblioteket ditt. Du kan endre rekkefølge eller deaktivere seksjoner helt."; +"Library Sections Order" = "Rekkefølge på bibliotekseksjoner"; +"Completion Percentage" = "Fullføringsprosent"; +"Some features are limited to the Sora and Default player, such as ForceLandscape, holdSpeed and custom time skip increments.\n\nThe completion percentage setting determines at what point before the end of a video the app will mark it as completed on AniList and Trakt." = "Noen funksjoner er begrenset til Sora- og standardspilleren, som tvunget landskap, holdhastighet og tilpassede tidshopp.\n\nFullføringsprosenten bestemmer når før slutten av en video appen markerer den som fullført på AniList og Trakt."; +"The app cache helps the app load images faster.\n\nClearing the Documents folder will delete all downloaded modules.\n\nErasing the App Data will clears all your settings and data of the app." = "Appens cache hjelper med å laste inn bilder raskere.\n\nÅ tømme Dokumenter-mappen sletter alle nedlastede moduler.\n\nÅ slette appdata sletter alle innstillinger og data."; +"Translators" = "Oversettere"; +"Paste URL" = "Lim inn URL"; + +/* Added missing localizations */ +"Series Title" = "Serietittel"; +"Content Source" = "Innhaldskjelde"; +"Watch Progress" = "Framdrift for vising"; +"Recent searches" = "Nylege søk"; +"All Reading" = "Alt du les"; +"Nothing to Continue Reading" = "Ingenting å fortsetje å lese"; +"Your recently read novels will appear here" = "Dine nyleg lesne romanar vil visast her"; +"No Bookmarks" = "Ingen bokmerke"; +"Add bookmarks to this collection" = "Legg til bokmerke i denne samlinga"; +"items" = "element"; +"All Watching" = "Alt du ser på"; +"No Reading History" = "Ingen leseloggar"; +"Books you're reading will appear here" = "Bøker du les vil visast her"; +"Create Collection" = "Opprett samling"; +"Collection Name" = "Samlingnamn"; +"Rename Collection" = "Endre namn på samling"; +"Rename" = "Endre namn"; +"Novel Title" = "Roman tittel"; +"Read Progress" = "Leseframdrift"; +"Date Created" = "Oppretta dato"; +"Name" = "Namn"; +"Item Count" = "Tal på element"; +"Date Added" = "Dato lagt til"; +"Title" = "Tittel"; +"Source" = "Kjelde"; +"Search reading..." = "Søk i lesing..."; +"Search collections..." = "Søk i samlingar..."; +"Search bookmarks..." = "Søk i bokmerke..."; +"%d items" = "%d element"; +"Fetching Data" = "Hentar data"; +"Please wait while fetching." = "Vent medan data vert henta."; +"Start Reading" = "Start lesing"; +"Chapters" = "Kapittel"; +"Completed" = "Fullført"; +"Drag to reorder" = "Dra for å endre rekkefølgje"; +"Drag to reorder sections" = "Dra for å endre rekkefølgje på seksjonar"; +"Library View" = "Bibliotekvising"; +"Customize the sections shown in your library. You can reorder sections or disable them completely." = "Tilpass seksjonane som vert viste i biblioteket ditt. Du kan endre rekkefølgje eller slå dei heilt av."; +"Library Sections Order" = "Rekkefølgje på bibliotekseksjonar"; +"Completion Percentage" = "Fullføringsprosent"; +"Translators" = "Omsetjarar"; +"Paste URL" = "Lim inn URL"; + +/* Added missing localizations */ +"Collections" = "Samlingar"; +"Continue Reading" = "Hald fram med å lese"; + +/* Backup & Restore */ +"Backup & Restore" = "Sikkerhetskopi og gjenoppretting"; +"Export Backup" = "Eksporter sikkerhetskopi"; +"Import Backup" = "Importer sikkerhetskopi"; +"Notice: This feature is still experimental. Please double-check your data after import/export." = "Merk: Denne funksjonen er fortsatt eksperimentell. Vennligst dobbeltsjekk dataene dine etter eksport/import."; +"Backup" = "Sikkerhetskopi"; \ No newline at end of file diff --git a/Sora/Localization/ru.lproj/Localizable.strings b/Sora/Localization/ru.lproj/Localizable.strings index 178e7c0..2bfa200 100644 --- a/Sora/Localization/ru.lproj/Localizable.strings +++ b/Sora/Localization/ru.lproj/Localizable.strings @@ -405,4 +405,108 @@ For episode metadata, it refers to the episode thumbnail and title, since someti "Data" = "Данные"; /* New string */ -"Maximum Quality Available" = "Максимальное доступное качество"; \ No newline at end of file +"Maximum Quality Available" = "Максимальное доступное качество"; + +/* Additional translations */ +"DownloadCountFormat" = "%d из %d"; +"Error loading chapter" = "Ошибка загрузки главы"; +"Font Size: %dpt" = "Размер шрифта: %dpt"; +"Line Spacing: %.1f" = "Межстрочный интервал: %.1f"; +"Line Spacing" = "Межстрочный интервал"; +"Margin: %dpx" = "Поле: %dpx"; +"Margin" = "Поле"; +"Auto Scroll Speed" = "Скорость автопрокрутки"; +"Speed" = "Скорость"; +"Speed: %.1fx" = "Скорость: %.1fx"; +"Matched %@: %@" = "Совпадение %@: %@"; +"Enter the AniList ID for this series" = "Введите AniList ID для этой серии"; + +/* Added missing localizations */ +"Create Collection" = "Создать коллекцию"; +"Collection Name" = "Название коллекции"; +"Rename Collection" = "Переименовать коллекцию"; +"Rename" = "Переименовать"; +"All Reading" = "Все чтения"; +"Recently Added" = "Недавно добавленные"; +"Novel Title" = "Название романа"; +"Read Progress" = "Прогресс чтения"; +"Date Created" = "Дата создания"; +"Name" = "Имя"; +"Item Count" = "Количество элементов"; +"Date Added" = "Дата добавления"; +"Title" = "Заголовок"; +"Source" = "Источник"; +"Search reading..." = "Поиск по чтению..."; +"Search collections..." = "Поиск по коллекциям..."; +"Search bookmarks..." = "Поиск по закладкам..."; +"%d items" = "%d элементов"; +"Fetching Data" = "Получение данных"; +"Please wait while fetching." = "Пожалуйста, подождите, идет получение данных."; +"Start Reading" = "Начать чтение"; +"Chapters" = "Главы"; +"Completed" = "Завершено"; +"Drag to reorder" = "Перетащите для изменения порядка"; +"Drag to reorder sections" = "Перетащите для изменения порядка разделов"; +"Library View" = "Вид библиотеки"; +"Customize the sections shown in your library. You can reorder sections or disable them completely." = "Настройте разделы, отображаемые в вашей библиотеке. Вы можете изменить порядок разделов или полностью их отключить."; +"Library Sections Order" = "Порядок разделов библиотеки"; +"Completion Percentage" = "Процент завершения"; +"Some features are limited to the Sora and Default player, such as ForceLandscape, holdSpeed and custom time skip increments.\n\nThe completion percentage setting determines at what point before the end of a video the app will mark it as completed on AniList and Trakt." = "Некоторые функции доступны только в Sora и стандартном плеере, такие как принудительный ландшафт, удержание скорости и пользовательские интервалы пропуска.\n\nНастройка процента завершения определяет, в какой момент до конца видео приложение отметит его как завершенное на AniList и Trakt."; +"The app cache helps the app load images faster.\n\nClearing the Documents folder will delete all downloaded modules.\n\nErasing the App Data will clears all your settings and data of the app." = "Кэш приложения помогает быстрее загружать изображения.\n\nОчистка папки Documents удалит все загруженные модули.\n\nСтирание данных приложения удалит все ваши настройки и данные."; +"Translators" = "Переводчики"; +"Paste URL" = "Вставить URL"; + +/* Added missing localizations */ +"Series Title" = "Название серии"; +"Content Source" = "Источник контента"; +"Watch Progress" = "Прогресс просмотра"; +"Recent searches" = "Недавние поиски"; +"All Reading" = "Всё для чтения"; +"Nothing to Continue Reading" = "Нечего продолжать читать"; +"Your recently read novels will appear here" = "Ваши недавно прочитанные романы появятся здесь"; +"No Bookmarks" = "Нет закладок"; +"Add bookmarks to this collection" = "Добавьте закладки в эту коллекцию"; +"items" = "элементы"; +"All Watching" = "Всё для просмотра"; +"No Reading History" = "Нет истории чтения"; +"Books you're reading will appear here" = "Книги, которые вы читаете, появятся здесь"; +"Create Collection" = "Создать коллекцию"; +"Collection Name" = "Название коллекции"; +"Rename Collection" = "Переименовать коллекцию"; +"Rename" = "Переименовать"; +"Novel Title" = "Название романа"; +"Read Progress" = "Прогресс чтения"; +"Date Created" = "Дата создания"; +"Name" = "Имя"; +"Item Count" = "Количество элементов"; +"Date Added" = "Дата добавления"; +"Title" = "Заголовок"; +"Source" = "Источник"; +"Search reading..." = "Поиск по чтению..."; +"Search collections..." = "Поиск по коллекциям..."; +"Search bookmarks..." = "Поиск по закладкам..."; +"%d items" = "%d элементов"; +"Fetching Data" = "Получение данных"; +"Please wait while fetching." = "Пожалуйста, подождите, идет получение данных."; +"Start Reading" = "Начать чтение"; +"Chapters" = "Главы"; +"Completed" = "Завершено"; +"Drag to reorder" = "Перетащите для изменения порядка"; +"Drag to reorder sections" = "Перетащите для изменения порядка разделов"; +"Library View" = "Вид библиотеки"; +"Customize the sections shown in your library. You can reorder sections or disable them completely." = "Настройте разделы, отображаемые в вашей библиотеке. Вы можете изменить порядок разделов или полностью их отключить."; +"Library Sections Order" = "Порядок разделов библиотеки"; +"Completion Percentage" = "Процент завершения"; +"Translators" = "Переводчики"; +"Paste URL" = "Вставить URL"; + +/* Added missing localizations */ +"Collections" = "Коллекции"; +"Continue Reading" = "Продолжить чтение"; + +/* Backup & Restore */ +"Backup & Restore" = "Резервное копирование и восстановление"; +"Export Backup" = "Экспорт резервной копии"; +"Import Backup" = "Импорт резервной копии"; +"Notice: This feature is still experimental. Please double-check your data after import/export." = "Внимание: Эта функция все еще экспериментальная. Пожалуйста, проверьте свои данные после экспорта/импорта."; +"Backup" = "Резервная копия"; \ No newline at end of file diff --git a/Sora/Localization/sk.lproj/Localizable.strings b/Sora/Localization/sk.lproj/Localizable.strings index b517219..e0addd2 100644 --- a/Sora/Localization/sk.lproj/Localizable.strings +++ b/Sora/Localization/sk.lproj/Localizable.strings @@ -404,3 +404,105 @@ For episode metadata, it refers to the episode thumbnail and title, since someti "me frfr" = "me frfr"; "Data" = "Dáta"; "Maximum Quality Available" = "Maximálna dostupná kvalita"; + +/* New additions */ +"DownloadCountFormat" = "%d z %d"; +"Error loading chapter" = "Chyba pri načítaní kapitoly"; +"Font Size: %dpt" = "Veľkosť písma: %dpt"; +"Line Spacing: %.1f" = "Riadkovanie: %.1f"; +"Line Spacing" = "Riadkovanie"; +"Margin: %dpx" = "Okraj: %dpx"; +"Margin" = "Okraj"; +"Auto Scroll Speed" = "Rýchlosť automatického posúvania"; +"Speed" = "Rýchlosť"; +"Speed: %.1fx" = "Rýchlosť: %.1fx"; +"Matched %@: %@" = "Zhoda %@: %@"; +"Enter the AniList ID for this series" = "Zadajte AniList ID pre túto sériu"; + +/* Added missing localizations */ +"Create Collection" = "Vytvoriť kolekciu"; +"Collection Name" = "Názov kolekcie"; +"Rename Collection" = "Premenovať kolekciu"; +"Rename" = "Premenovať"; +"All Reading" = "Všetko na čítanie"; +"Recently Added" = "Nedávno pridané"; +"Novel Title" = "Názov románu"; +"Read Progress" = "Priebeh čítania"; +"Date Created" = "Dátum vytvorenia"; +"Name" = "Meno"; +"Item Count" = "Počet položiek"; +"Date Added" = "Dátum pridania"; +"Title" = "Názov"; +"Source" = "Zdroj"; +"Search reading..." = "Hľadať v čítaní..."; +"Search collections..." = "Hľadať v kolekciách..."; +"Search bookmarks..." = "Hľadať v záložkách..."; +"%d items" = "%d položiek"; +"Fetching Data" = "Načítavanie údajov"; +"Please wait while fetching." = "Počkajte, kým sa údaje načítajú."; +"Start Reading" = "Začať čítať"; +"Chapters" = "Kapitoly"; +"Completed" = "Dokončené"; +"Drag to reorder" = "Potiahnite na zmenu poradia"; +"Drag to reorder sections" = "Potiahnite na zmenu poradia sekcií"; +"Library View" = "Zobrazenie knižnice"; +"Customize the sections shown in your library. You can reorder sections or disable them completely." = "Prispôsobte sekcie zobrazené vo vašej knižnici. Môžete zmeniť ich poradie alebo ich úplne vypnúť."; +"Library Sections Order" = "Poradie sekcií knižnice"; +"Completion Percentage" = "Percento dokončenia"; +"Some features are limited to the Sora and Default player, such as ForceLandscape, holdSpeed and custom time skip increments.\n\nThe completion percentage setting determines at what point before the end of a video the app will mark it as completed on AniList and Trakt." = "Niektoré funkcie sú obmedzené na Sora a predvolený prehrávač, ako je vynútená krajina, podržanie rýchlosti a vlastné intervaly preskočenia.\n\nNastavenie percenta dokončenia určuje, v ktorom bode pred koncom videa aplikácia označí ako dokončené na AniList a Trakt."; +"The app cache helps the app load images faster.\n\nClearing the Documents folder will delete all downloaded modules.\n\nErasing the App Data will clears all your settings and data of the app." = "Vyrovnávacia pamäť aplikácie pomáha rýchlejšiemu načítaniu obrázkov.\n\nVymazanie priečinka Documents odstráni všetky stiahnuté moduly.\n\nVymazanie údajov aplikácie odstráni všetky vaše nastavenia a údaje."; +"Translators" = "Prekladatelia"; +"Paste URL" = "Vložiť URL"; + +/* New additions */ +"Series Title" = "Názov série"; +"Content Source" = "Zdroj obsahu"; +"Watch Progress" = "Priebeh sledovania"; +"Nothing to Continue Reading" = "Nič na pokračovanie v čítaní"; +"Your recently read novels will appear here" = "Vaše nedávno čítané romány sa zobrazia tu"; +"No Bookmarks" = "Žiadne záložky"; +"Add bookmarks to this collection" = "Pridajte záložky do tejto kolekcie"; +"items" = "položky"; +"All Watching" = "Všetko na sledovanie"; +"No Reading History" = "Žiadna história čítania"; +"Books you're reading will appear here" = "Knihy, ktoré čítate, sa zobrazia tu"; +"Create Collection" = "Vytvoriť kolekciu"; +"Collection Name" = "Názov kolekcie"; +"Rename Collection" = "Premenovať kolekciu"; +"Rename" = "Premenovať"; +"Novel Title" = "Názov románu"; +"Read Progress" = "Priebeh čítania"; +"Date Created" = "Dátum vytvorenia"; +"Name" = "Meno"; +"Item Count" = "Počet položiek"; +"Date Added" = "Dátum pridania"; +"Title" = "Názov"; +"Source" = "Zdroj"; +"Search reading..." = "Hľadať v čítaní..."; +"Search collections..." = "Hľadať v kolekciách..."; +"Search bookmarks..." = "Hľadať v záložkách..."; +"%d items" = "%d položiek"; +"Fetching Data" = "Načítavanie údajov"; +"Please wait while fetching." = "Počkajte, kým sa údaje načítajú."; +"Start Reading" = "Začať čítať"; +"Chapters" = "Kapitoly"; +"Completed" = "Dokončené"; +"Drag to reorder" = "Potiahnite na zmenu poradia"; +"Drag to reorder sections" = "Potiahnite na zmenu poradia sekcií"; +"Library View" = "Zobrazenie knižnice"; +"Customize the sections shown in your library. You can reorder sections or disable them completely." = "Prispôsobte sekcie zobrazené vo vašej knižnici. Môžete zmeniť ich poradie alebo ich úplne vypnúť."; +"Library Sections Order" = "Poradie sekcií knižnice"; +"Completion Percentage" = "Percento dokončenia"; +"Translators" = "Prekladatelia"; +"Paste URL" = "Vložiť URL"; + +/* New additions */ +"Collections" = "Kolekcie"; +"Continue Reading" = "Pokračovať v čítaní"; + +/* Backup & Restore */ +"Backup & Restore" = "Záloha a obnovenie"; +"Export Backup" = "Exportovať zálohu"; +"Import Backup" = "Importovať zálohu"; +"Notice: This feature is still experimental. Please double-check your data after import/export." = "Upozornenie: Táto funkcia je stále experimentálna. Po exporte/importe si prosím skontrolujte svoje údaje."; +"Backup" = "Záloha"; diff --git a/Sora/Localization/sv.lproj/Localizable.strings b/Sora/Localization/sv.lproj/Localizable.strings index 5667118..a9b6bbe 100644 --- a/Sora/Localization/sv.lproj/Localizable.strings +++ b/Sora/Localization/sv.lproj/Localizable.strings @@ -381,4 +381,107 @@ "Storage Management" = "Lagringshantering"; "Storage Used" = "Använt Lagringsutrymme"; "Library cleared successfully" = "Biblioteket rensat"; -"All downloads deleted successfully" = "Alla nedladdningar borttagna"; \ No newline at end of file +"All downloads deleted successfully" = "Alla nedladdningar borttagna"; + +/* New keys from English localization */ +"DownloadCountFormat" = "%d av %d"; +"Error loading chapter" = "Fel vid inläsning av kapitel"; +"Font Size: %dpt" = "Teckenstorlek: %dpt"; +"Line Spacing: %.1f" = "Radavstånd: %.1f"; +"Line Spacing" = "Radavstånd"; +"Margin: %dpx" = "Marginal: %dpx"; +"Margin" = "Marginal"; +"Auto Scroll Speed" = "Automatisk rullningshastighet"; +"Speed" = "Hastighet"; +"Speed: %.1fx" = "Hastighet: %.1fx"; +"Matched %@: %@" = "Matchning %@: %@"; +"Enter the AniList ID for this series" = "Ange AniList-ID för denna serie"; + +/* Added missing localizations */ +"Create Collection" = "Skapa samling"; +"Collection Name" = "Samlingens namn"; +"Rename Collection" = "Byt namn på samling"; +"Rename" = "Byt namn"; +"All Reading" = "All läsning"; +"Recently Added" = "Nyligen tillagda"; +"Novel Title" = "Romanens titel"; +"Read Progress" = "Läsningsframsteg"; +"Date Created" = "Skapad datum"; +"Name" = "Namn"; +"Item Count" = "Antal objekt"; +"Date Added" = "Datum tillagt"; +"Title" = "Titel"; +"Source" = "Källa"; +"Search reading..." = "Sök i läsning..."; +"Search collections..." = "Sök i samlingar..."; +"Search bookmarks..." = "Sök i bokmärken..."; +"%d items" = "%d objekt"; +"Fetching Data" = "Hämtar data"; +"Please wait while fetching." = "Vänligen vänta under hämtning."; +"Start Reading" = "Börja läsa"; +"Chapters" = "Kapitel"; +"Completed" = "Slutförd"; +"Drag to reorder" = "Dra för att ändra ordning"; +"Drag to reorder sections" = "Dra för att ändra ordning på sektioner"; +"Library View" = "Biblioteksvisning"; +"Customize the sections shown in your library. You can reorder sections or disable them completely." = "Anpassa sektionerna som visas i ditt bibliotek. Du kan ändra ordning eller inaktivera sektioner helt."; +"Library Sections Order" = "Ordning på bibliotekets sektioner"; +"Completion Percentage" = "Slutförandeprocent"; +"Some features are limited to the Sora and Default player, such as ForceLandscape, holdSpeed and custom time skip increments.\n\nThe completion percentage setting determines at what point before the end of a video the app will mark it as completed on AniList and Trakt." = "Vissa funktioner är begränsade till Sora- och standardspelaren, såsom tvingat landskap, hållhastighet och anpassade tidshopp.\n\nInställningen för slutförandeprocent avgör vid vilken punkt före slutet av en video appen markerar den som slutförd på AniList och Trakt."; +"The app cache helps the app load images faster.\n\nClearing the Documents folder will delete all downloaded modules.\n\nErasing the App Data will clears all your settings and data of the app." = "Appens cache hjälper till att ladda bilder snabbare.\n\nAtt rensa mappen Dokument tar bort alla nedladdade moduler.\n\nAtt radera appdata tar bort alla dina inställningar och data."; +"Translators" = "Översättare"; +"Paste URL" = "Klistra in URL"; + +/* Added missing localizations */ +"Series Title" = "Serietitel"; +"Content Source" = "Innehållskälla"; +"Watch Progress" = "Tittarframsteg"; +"Recent searches" = "Senaste sökningar"; +"Nothing to Continue Reading" = "Inget att fortsätta läsa"; +"Your recently read novels will appear here" = "Dina nyligen lästa romaner visas här"; +"No Bookmarks" = "Inga bokmärken"; +"Add bookmarks to this collection" = "Lägg till bokmärken i denna samling"; +"items" = "objekt"; +"All Watching" = "Allt du tittar på"; +"No Reading History" = "Ingen läshistorik"; +"Books you're reading will appear here" = "Böcker du läser visas här"; +"Create Collection" = "Skapa samling"; +"Collection Name" = "Samlingens namn"; +"Rename Collection" = "Byt namn på samling"; +"Rename" = "Byt namn"; +"Novel Title" = "Roman titel"; +"Read Progress" = "Läsframsteg"; +"Date Created" = "Skapad datum"; +"Name" = "Namn"; +"Item Count" = "Antal objekt"; +"Date Added" = "Datum tillagt"; +"Title" = "Titel"; +"Source" = "Källa"; +"Search reading..." = "Sök i läsning..."; +"Search collections..." = "Sök i samlingar..."; +"Search bookmarks..." = "Sök i bokmärken..."; +"%d items" = "%d objekt"; +"Fetching Data" = "Hämtar data"; +"Please wait while fetching." = "Vänligen vänta medan data hämtas."; +"Start Reading" = "Börja läsa"; +"Chapters" = "Kapitel"; +"Completed" = "Slutförd"; +"Drag to reorder" = "Dra för att ändra ordning"; +"Drag to reorder sections" = "Dra för att ändra ordning på sektioner"; +"Library View" = "Biblioteksvy"; +"Customize the sections shown in your library. You can reorder sections or disable them completely." = "Anpassa sektionerna som visas i ditt bibliotek. Du kan ändra ordning eller inaktivera dem helt."; +"Library Sections Order" = "Bibliotekssektioners ordning"; +"Completion Percentage" = "Slutförandeprocent"; +"Translators" = "Översättare"; +"Paste URL" = "Klistra in URL"; + +/* Added missing localizations */ +"Collections" = "Samlingar"; +"Continue Reading" = "Fortsätt läsa"; + +/* Backup & Restore */ +"Backup & Restore" = "Säkerhetskopiera & Återställ"; +"Export Backup" = "Exportera säkerhetskopia"; +"Import Backup" = "Importera säkerhetskopia"; +"Notice: This feature is still experimental. Please double-check your data after import/export." = "Observera: Denna funktion är fortfarande experimentell. Kontrollera dina data efter export/import."; +"Backup" = "Säkerhetskopia"; \ No newline at end of file diff --git a/Sora/MediaUtils/ContinueWatching/ContinueReadingItem.swift b/Sora/MediaUtils/ContinueWatching/ContinueReadingItem.swift new file mode 100644 index 0000000..ecca9ba --- /dev/null +++ b/Sora/MediaUtils/ContinueWatching/ContinueReadingItem.swift @@ -0,0 +1,48 @@ +// +// ContinueReadingItem.swift +// Sora +// +// Created by paul on 26/06/25. +// + +import Foundation + +struct ContinueReadingItem: Identifiable, Codable { + let id: UUID + let mediaTitle: String + let chapterTitle: String + let chapterNumber: Int + let imageUrl: String + let href: String + let moduleId: String + let progress: Double + let totalChapters: Int + let lastReadDate: Date + let cachedHtml: String? + + init( + id: UUID = UUID(), + mediaTitle: String, + chapterTitle: String, + chapterNumber: Int, + imageUrl: String, + href: String, + moduleId: String, + progress: Double = 0.0, + totalChapters: Int = 0, + lastReadDate: Date = Date(), + cachedHtml: String? = nil + ) { + self.id = id + self.mediaTitle = mediaTitle + self.chapterTitle = chapterTitle + self.chapterNumber = chapterNumber + self.imageUrl = imageUrl + self.href = href + self.moduleId = moduleId + self.progress = progress + self.totalChapters = totalChapters + self.lastReadDate = lastReadDate + self.cachedHtml = cachedHtml + } +} \ No newline at end of file diff --git a/Sora/MediaUtils/ContinueWatching/ContinueReadingManager.swift b/Sora/MediaUtils/ContinueWatching/ContinueReadingManager.swift new file mode 100644 index 0000000..c76c236 --- /dev/null +++ b/Sora/MediaUtils/ContinueWatching/ContinueReadingManager.swift @@ -0,0 +1,275 @@ +// +// ContinueReadingManager.swift +// Sora +// +// Created by paul on 26/06/25. +// + +import Foundation + +class ContinueReadingManager { + static let shared = ContinueReadingManager() + + private let userDefaults = UserDefaults.standard + private let continueReadingKey = "continueReadingItems" + + private init() {} + + func extractTitleFromURL(_ url: String) -> String? { + guard let url = URL(string: url) else { return nil } + + let pathComponents = url.pathComponents + + for (index, component) in pathComponents.enumerated() { + if component == "book" || component == "novel" { + if index + 1 < pathComponents.count { + let bookTitle = pathComponents[index + 1] + .replacingOccurrences(of: "-", with: " ") + .replacingOccurrences(of: "_", with: " ") + .capitalized + + if !bookTitle.isEmpty { + return bookTitle + } + } + } + } + + return nil + } + + func fetchItems() -> [ContinueReadingItem] { + guard let data = userDefaults.data(forKey: continueReadingKey) else { + Logger.shared.log("No continue reading items found in UserDefaults", type: "Debug") + return [] + } + + do { + let items = try JSONDecoder().decode([ContinueReadingItem].self, from: data) + Logger.shared.log("Fetched \(items.count) continue reading items", type: "Debug") + + for (index, item) in items.enumerated() { + Logger.shared.log("Item \(index): \(item.mediaTitle), Image URL: \(item.imageUrl)", type: "Debug") + } + + return items.sorted(by: { $0.lastReadDate > $1.lastReadDate }) + } catch { + Logger.shared.log("Error decoding continue reading items: \(error)", type: "Error") + return [] + } + } + + func save(item: ContinueReadingItem, htmlContent: String? = nil) { + var items = fetchItems() + + items.removeAll { $0.href == item.href } + + if item.progress >= 0.98 { + userDefaults.set(item.progress, forKey: "readingProgress_\(item.href)") + + do { + let data = try JSONEncoder().encode(items) + userDefaults.set(data, forKey: continueReadingKey) + } catch { + Logger.shared.log("Error encoding continue reading items: \(error)", type: "Error") + } + return + } + + var updatedItem = item + if item.mediaTitle.contains("-") && item.mediaTitle.count >= 30 || item.mediaTitle.contains("Unknown") { + if let betterTitle = extractTitleFromURL(item.href) { + updatedItem = ContinueReadingItem( + id: item.id, + mediaTitle: betterTitle, + chapterTitle: item.chapterTitle, + chapterNumber: item.chapterNumber, + imageUrl: item.imageUrl, + href: item.href, + moduleId: item.moduleId, + progress: item.progress, + totalChapters: item.totalChapters, + lastReadDate: item.lastReadDate, + cachedHtml: htmlContent ?? item.cachedHtml + ) + } + } + + Logger.shared.log("Incoming item image URL: \(updatedItem.imageUrl)", type: "Debug") + + if updatedItem.imageUrl.isEmpty { + let defaultImageUrl = "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/main/assets/novel_cover.jpg" + updatedItem = ContinueReadingItem( + id: updatedItem.id, + mediaTitle: updatedItem.mediaTitle, + chapterTitle: updatedItem.chapterTitle, + chapterNumber: updatedItem.chapterNumber, + imageUrl: defaultImageUrl, + href: updatedItem.href, + moduleId: updatedItem.moduleId, + progress: updatedItem.progress, + totalChapters: updatedItem.totalChapters, + lastReadDate: updatedItem.lastReadDate, + cachedHtml: htmlContent ?? updatedItem.cachedHtml + ) + Logger.shared.log("Using default image URL: \(defaultImageUrl)", type: "Debug") + } + + if !updatedItem.imageUrl.isEmpty { + if URL(string: updatedItem.imageUrl) == nil { + Logger.shared.log("Invalid image URL format: \(updatedItem.imageUrl)", type: "Warning") + + if let encodedUrl = updatedItem.imageUrl.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), + let _ = URL(string: encodedUrl) { + updatedItem = ContinueReadingItem( + id: updatedItem.id, + mediaTitle: updatedItem.mediaTitle, + chapterTitle: updatedItem.chapterTitle, + chapterNumber: updatedItem.chapterNumber, + imageUrl: encodedUrl, + href: updatedItem.href, + moduleId: updatedItem.moduleId, + progress: updatedItem.progress, + totalChapters: updatedItem.totalChapters, + lastReadDate: updatedItem.lastReadDate, + cachedHtml: htmlContent ?? updatedItem.cachedHtml + ) + Logger.shared.log("Fixed image URL with encoding: \(encodedUrl)", type: "Debug") + } else { + let defaultImageUrl = "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/main/assets/novel_cover.jpg" + updatedItem = ContinueReadingItem( + id: updatedItem.id, + mediaTitle: updatedItem.mediaTitle, + chapterTitle: updatedItem.chapterTitle, + chapterNumber: updatedItem.chapterNumber, + imageUrl: defaultImageUrl, + href: updatedItem.href, + moduleId: updatedItem.moduleId, + progress: updatedItem.progress, + totalChapters: updatedItem.totalChapters, + lastReadDate: updatedItem.lastReadDate, + cachedHtml: htmlContent ?? updatedItem.cachedHtml + ) + Logger.shared.log("Using default image URL after encoding failed: \(defaultImageUrl)", type: "Debug") + } + } + } + + Logger.shared.log("Saving item with image URL: \(updatedItem.imageUrl)", type: "Debug") + + items.append(updatedItem) + + if items.count > 20 { + items = Array(items.sorted(by: { $0.lastReadDate > $1.lastReadDate }).prefix(20)) + } + + do { + let data = try JSONEncoder().encode(items) + userDefaults.set(data, forKey: continueReadingKey) + Logger.shared.log("Successfully saved continue reading item", type: "Debug") + } catch { + Logger.shared.log("Error encoding continue reading items: \(error)", type: "Error") + } + } + + func remove(item: ContinueReadingItem) { + var items = fetchItems() + items.removeAll { $0.id == item.id } + + do { + let data = try JSONEncoder().encode(items) + userDefaults.set(data, forKey: continueReadingKey) + } catch { + Logger.shared.log("Error encoding continue reading items: \(error)", type: "Error") + } + } + + func updateProgress(for href: String, progress: Double, htmlContent: String? = nil) { + var items = fetchItems() + if let index = items.firstIndex(where: { $0.href == href }) { + let updatedItem = items[index] + + if progress >= 0.98 { + let cachedHtml = htmlContent ?? updatedItem.cachedHtml + + if let html = cachedHtml, !html.isEmpty && !html.contains("undefined") && html.count > 50 { + let completedChapterKey = "completedChapterHtml_\(href)" + UserDefaults.standard.set(html, forKey: completedChapterKey) + Logger.shared.log("Saved HTML content for completed chapter \(href)", type: "Debug") + } + + items.remove(at: index) + userDefaults.set(progress, forKey: "readingProgress_\(href)") + + do { + let data = try JSONEncoder().encode(items) + userDefaults.set(data, forKey: continueReadingKey) + } catch { + Logger.shared.log("Error encoding continue reading items: \(error)", type: "Error") + } + return + } + + var mediaTitle = updatedItem.mediaTitle + if mediaTitle.contains("-") && mediaTitle.count >= 30 || mediaTitle.contains("Unknown") { + if let betterTitle = extractTitleFromURL(href) { + mediaTitle = betterTitle + } + } + + let newItem = ContinueReadingItem( + id: updatedItem.id, + mediaTitle: mediaTitle, + chapterTitle: updatedItem.chapterTitle, + chapterNumber: updatedItem.chapterNumber, + imageUrl: updatedItem.imageUrl, + href: updatedItem.href, + moduleId: updatedItem.moduleId, + progress: progress, + totalChapters: updatedItem.totalChapters, + lastReadDate: Date(), + cachedHtml: htmlContent ?? updatedItem.cachedHtml + ) + + Logger.shared.log("Updating item with image URL: \(newItem.imageUrl)", type: "Debug") + + items[index] = newItem + + do { + let data = try JSONEncoder().encode(items) + userDefaults.set(data, forKey: continueReadingKey) + } catch { + Logger.shared.log("Error encoding continue reading items: \(error)", type: "Error") + } + } + } + + func isChapterCompleted(href: String) -> Bool { + let progress = UserDefaults.standard.double(forKey: "readingProgress_\(href)") + if progress >= 0.98 { + return true + } + + let items = fetchItems() + if let item = items.first(where: { $0.href == href }) { + return item.progress >= 0.98 + } + + return false + } + + func getCachedHtml(for href: String) -> String? { + let completedChapterKey = "completedChapterHtml_\(href)" + if let completedHtml = UserDefaults.standard.string(forKey: completedChapterKey), + !completedHtml.isEmpty && !completedHtml.contains("undefined") && completedHtml.count > 50 { + Logger.shared.log("Using cached HTML for completed chapter \(href)", type: "Debug") + return completedHtml + } + + let items = fetchItems() + if let item = items.first(where: { $0.href == href }) { + return item.cachedHtml + } + return nil + } +} \ No newline at end of file diff --git a/Sora/MediaUtils/CustomPlayer/CustomPlayer.swift b/Sora/MediaUtils/CustomPlayer/CustomPlayer.swift index ad80f74..f5b1053 100644 --- a/Sora/MediaUtils/CustomPlayer/CustomPlayer.swift +++ b/Sora/MediaUtils/CustomPlayer/CustomPlayer.swift @@ -184,7 +184,10 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele volumeSliderHostingView, pipButton, airplayButton, - timeBatteryContainer + timeBatteryContainer, + endTimeIcon, + endTimeLabel, + endTimeSeparator ].compactMap { $0 } private var originalHiddenStates: [UIView: Bool] = [:] @@ -207,6 +210,10 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele private var timeLabel: UILabel? private var batteryLabel: UILabel? private var timeUpdateTimer: Timer? + private var endTimeLabel: UILabel? + private var endTimeIcon: UIImageView? + private var endTimeSeparator: UIView? + private var isEndTimeVisible: Bool = false init(module: ScrapingModule, urlString: String, @@ -332,10 +339,6 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele self?.checkForHLSStream() } - if isHoldPauseEnabled { - holdForPause() - } - do { try audioSession.setActive(true) } catch { @@ -394,6 +397,11 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele if let slider = hiddenVolumeView.subviews.first(where: { $0 is UISlider }) as? UISlider { systemVolumeSlider = slider } + + let twoFingerTapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTwoFingerTapPause(_:))) + twoFingerTapGesture.numberOfTouchesRequired = 2 + twoFingerTapGesture.delegate = self + view.addGestureRecognizer(twoFingerTapGesture) } override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { @@ -461,6 +469,11 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele NotificationCenter.default.removeObserver(self) UIDevice.current.isBatteryMonitoringEnabled = false + // Clean up end time related resources + endTimeIcon?.removeFromSuperview() + endTimeLabel?.removeFromSuperview() + endTimeSeparator?.removeFromSuperview() + inactivityTimer?.invalidate() inactivityTimer = nil @@ -473,7 +486,6 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele player?.removeTimeObserver(token) } - // Remove observer from player item if it exists if let currentItem = player?.currentItem { currentItem.removeObserver(self, forKeyPath: "status", context: &playerItemKVOContext) } @@ -548,7 +560,6 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele } } } else if keyPath == "loadedTimeRanges" { - // Handle loaded time ranges if needed } } @@ -593,13 +604,14 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele let tapGesture = UITapGestureRecognizer(target: self, action: #selector(toggleControls)) view.addGestureRecognizer(tapGesture) - } - - func setupControls() { - controlsContainerView = UIView() + + controlsContainerView = PassthroughView() controlsContainerView.backgroundColor = UIColor.black.withAlphaComponent(0.0) view.addSubview(controlsContainerView) controlsContainerView.translatesAutoresizingMaskIntoConstraints = false + } + + func setupControls() { NSLayoutConstraint.activate([ controlsContainerView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), controlsContainerView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), @@ -610,6 +622,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele blackCoverView = UIView() blackCoverView.backgroundColor = UIColor.black.withAlphaComponent(0.4) blackCoverView.translatesAutoresizingMaskIntoConstraints = false + blackCoverView.isUserInteractionEnabled = false controlsContainerView.insertSubview(blackCoverView, at: 0) NSLayoutConstraint.activate([ blackCoverView.topAnchor.constraint(equalTo: view.topAnchor), @@ -765,11 +778,10 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele ]) } - func holdForPause() { - let holdForPauseGesture = UILongPressGestureRecognizer(target: self, action: #selector(handleHoldForPause(_:))) - holdForPauseGesture.minimumPressDuration = 1 - holdForPauseGesture.numberOfTouchesRequired = 2 - view.addGestureRecognizer(holdForPauseGesture) + @objc private func handleTwoFingerTapPause(_ gesture: UITapGestureRecognizer) { + if gesture.state == .ended { + togglePlayPause() + } } func addInvisibleControlOverlays() { @@ -1589,6 +1601,9 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele self.sliderViewModel.sliderValue = max(0, min(self.currentTimeVal, self.duration)) } + // Update end time when current time changes + self.updateEndTime() + self.updateSkipButtonsVisibility() UserDefaults.standard.set(self.currentTimeVal, forKey: "lastPlayedTime_\(self.fullUrl)") @@ -1746,6 +1761,8 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele return } + isControlsVisible.toggle() + if isDimmed { dimButton.isHidden = false dimButton.alpha = 1.0 @@ -1755,12 +1772,8 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele self?.dimButton.alpha = 0 } } - - updateSkipButtonsVisibility() - return } - isControlsVisible.toggle() UIView.animate(withDuration: 0.2) { let alpha: CGFloat = self.isControlsVisible ? 1.0 : 0.0 self.controlsContainerView.alpha = alpha @@ -1947,31 +1960,12 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) } - @objc private func handleHoldForPause(_ gesture: UILongPressGestureRecognizer) { - guard isHoldPauseEnabled else { return } - - if gesture.state == .began { - togglePlayPause() - } - } - @objc private func dimTapped() { isDimmed.toggle() - isControlsVisible = !isDimmed dimButtonTimer?.invalidate() - UIView.animate(withDuration: 0.25) { self.blackCoverView.alpha = self.isDimmed ? 1.0 : 0.4 - for v in self.controlsToHide { v.alpha = self.isDimmed ? 0 : 1 } - self.dimButton.alpha = self.isDimmed ? 0 : 1 - self.lockButton.alpha = self.isDimmed ? 0 : 1 - - self.subtitleBottomToSafeAreaConstraint?.isActive = !self.isControlsVisible - self.subtitleBottomToSliderConstraint?.isActive = self.isControlsVisible - - self.view.layoutIfNeeded() } - dimButtonToSlider.isActive = !isDimmed dimButtonToRight.isActive = isDimmed } @@ -2131,7 +2125,6 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele private func parseM3U8(url: URL, completion: @escaping () -> Void) { - // For local file URLs, use a simple data task without custom headers if url.scheme == "file" { URLSession.shared.dataTask(with: url) { [weak self] data, response, error in self?.processM3U8Data(data: data, url: url, completion: completion) @@ -2139,7 +2132,6 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele return } - // For remote URLs, add HTTP headers var request = URLRequest(url: url) if let mydict = headers, !mydict.isEmpty { for (key,value) in mydict { @@ -2243,12 +2235,10 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele let asset: AVURLAsset - // Check if this is a local file URL if url.scheme == "file" { - // For local files, don't add HTTP headers Logger.shared.log("Switching to local file: \(url.absoluteString)", type: "Debug") - // Check if file exists + if FileManager.default.fileExists(atPath: url.path) { Logger.shared.log("Local file exists for quality switch: \(url.path)", type: "Debug") } else { @@ -2257,7 +2247,6 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele asset = AVURLAsset(url: url) } else { - // For remote URLs, add HTTP headers Logger.shared.log("Switching to remote URL: \(url.absoluteString)", type: "Debug") var request = URLRequest(url: url) if let mydict = headers, !mydict.isEmpty { @@ -2275,10 +2264,8 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele let playerItem = AVPlayerItem(asset: asset) - // Add observer for the new player item playerItem.addObserver(self, forKeyPath: "status", options: [.new], context: &playerItemKVOContext) - // Remove observer from old item if it exists if let currentItem = player.currentItem { currentItem.removeObserver(self, forKeyPath: "status", context: &playerItemKVOContext) } @@ -2839,6 +2826,36 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele controlsContainerView.addSubview(container) self.timeBatteryContainer = container + // Add tap gesture to toggle end time visibility + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(toggleEndTimeVisibility)) + container.addGestureRecognizer(tapGesture) + container.isUserInteractionEnabled = true + + // Add end time components (initially hidden) + let endTimeIcon = UIImageView(image: UIImage(systemName: "timer")) + endTimeIcon.translatesAutoresizingMaskIntoConstraints = false + endTimeIcon.tintColor = .white + endTimeIcon.contentMode = .scaleAspectFit + endTimeIcon.alpha = 0 + container.addSubview(endTimeIcon) + self.endTimeIcon = endTimeIcon + + let endTimeLabel = UILabel() + endTimeLabel.translatesAutoresizingMaskIntoConstraints = false + endTimeLabel.textColor = .white + endTimeLabel.font = .systemFont(ofSize: 12, weight: .medium) + endTimeLabel.textAlignment = .center + endTimeLabel.alpha = 0 + container.addSubview(endTimeLabel) + self.endTimeLabel = endTimeLabel + + let endTimeSeparator = UIView() + endTimeSeparator.translatesAutoresizingMaskIntoConstraints = false + endTimeSeparator.backgroundColor = .white.withAlphaComponent(0.5) + endTimeSeparator.alpha = 0 + container.addSubview(endTimeSeparator) + self.endTimeSeparator = endTimeSeparator + let timeLabel = UILabel() timeLabel.translatesAutoresizingMaskIntoConstraints = false timeLabel.textColor = .white @@ -2860,12 +2877,28 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele container.addSubview(batteryLabel) self.batteryLabel = batteryLabel + let centerXConstraint = container.centerXAnchor.constraint(equalTo: controlsContainerView.centerXAnchor) + centerXConstraint.isActive = true + NSLayoutConstraint.activate([ - container.centerXAnchor.constraint(equalTo: controlsContainerView.centerXAnchor), container.topAnchor.constraint(equalTo: sliderHostingController?.view.bottomAnchor ?? controlsContainerView.bottomAnchor, constant: 2), container.heightAnchor.constraint(equalToConstant: 20), - timeLabel.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 12), + endTimeIcon.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 12), + endTimeIcon.centerYAnchor.constraint(equalTo: container.centerYAnchor), + endTimeIcon.widthAnchor.constraint(equalToConstant: 12), + endTimeIcon.heightAnchor.constraint(equalToConstant: 12), + + endTimeLabel.leadingAnchor.constraint(equalTo: endTimeIcon.trailingAnchor, constant: 4), + endTimeLabel.centerYAnchor.constraint(equalTo: container.centerYAnchor), + endTimeLabel.widthAnchor.constraint(equalToConstant: 50), + + endTimeSeparator.leadingAnchor.constraint(equalTo: endTimeLabel.trailingAnchor, constant: 8), + endTimeSeparator.centerYAnchor.constraint(equalTo: container.centerYAnchor), + endTimeSeparator.widthAnchor.constraint(equalToConstant: 1), + endTimeSeparator.heightAnchor.constraint(equalToConstant: 12), + + timeLabel.leadingAnchor.constraint(equalTo: endTimeSeparator.trailingAnchor, constant: 8), timeLabel.centerYAnchor.constraint(equalTo: container.centerYAnchor), timeLabel.widthAnchor.constraint(equalToConstant: 50), @@ -2880,9 +2913,14 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele batteryLabel.widthAnchor.constraint(equalToConstant: 50) ]) + isEndTimeVisible = UserDefaults.standard.bool(forKey: "showEndTime") + updateEndTimeVisibility(animated: false) + updateTime() + updateEndTime() timeUpdateTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in self?.updateTime() + self?.updateEndTime() } UIDevice.current.isBatteryMonitoringEnabled = true @@ -2890,12 +2928,62 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele NotificationCenter.default.addObserver(self, selector: #selector(batteryLevelDidChange), name: UIDevice.batteryLevelDidChangeNotification, object: nil) } + @objc private func toggleEndTimeVisibility() { + isEndTimeVisible.toggle() + UserDefaults.standard.set(isEndTimeVisible, forKey: "showEndTime") + updateEndTimeVisibility(animated: true) + } + + private func updateEndTimeVisibility(animated: Bool) { + let alpha: CGFloat = isEndTimeVisible ? 1.0 : 0.0 + let offset: CGFloat = isEndTimeVisible ? 0 : -37 + + if animated { + UIView.animate(withDuration: 0.3) { + self.endTimeIcon?.alpha = alpha + self.endTimeSeparator?.alpha = alpha + self.endTimeLabel?.alpha = alpha + + if let container = self.timeBatteryContainer { + container.transform = CGAffineTransform(translationX: offset, y: 0) + } + } + } else { + self.endTimeIcon?.alpha = alpha + self.endTimeSeparator?.alpha = alpha + self.endTimeLabel?.alpha = alpha + + // 调整容器位置以保持居中 + if let container = self.timeBatteryContainer { + container.transform = CGAffineTransform(translationX: offset, y: 0) + } + } + } + private func updateTime() { let formatter = DateFormatter() formatter.dateFormat = "HH:mm" timeLabel?.text = formatter.string(from: Date()) } + private func updateEndTime() { + guard let player = player, duration > 0 else { return } + + let currentSeconds = CMTimeGetSeconds(player.currentTime()) + let remainingSeconds = duration - currentSeconds + + if remainingSeconds <= 0 { + endTimeLabel?.text = "--:--" + return + } + + // Calculate end time by adding remaining seconds to current time + let endTime = Date().addingTimeInterval(remainingSeconds) + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm" + endTimeLabel?.text = formatter.string(from: endTime) + } + @objc private func batteryLevelDidChange() { updateBatteryLevel() } @@ -2962,9 +3050,28 @@ extension CustomMediaPlayerViewController: AVPictureInPictureControllerDelegate } } +extension CustomMediaPlayerViewController { + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { + return true + } +} + // yes? Like the plural of the famous american rapper ye? -IBHRAD // low taper fade the meme is massive -cranci // The mind is the source of good and evil, only you yourself can decide which you will bring yourself. -seiike // guys watch Clannad already - ibro // May the Divine Providence bestow its infinite mercy upon your soul, and may eternal grace find you beyond the shadows of this mortal realm. - paul, 15/11/2005 - 13/05/2023 // this dumbass ↑ defo used gpt, ong he did bro +// A view that passes through touches to views behind it unless the touch hits a subview +// fuck yall stories, continue below this code + +class PassthroughView: UIView { + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + for subview in subviews { + if !subview.isHidden && subview.alpha > 0 && subview.isUserInteractionEnabled && subview.point(inside: convert(point, to: subview), with: event) { + return true + } + } + return false + } +} diff --git a/Sora/Utlis & Misc/Extensions/Notification+Name.swift b/Sora/Utlis & Misc/Extensions/Notification+Name.swift index 2e87c5c..2f82dad 100644 --- a/Sora/Utlis & Misc/Extensions/Notification+Name.swift +++ b/Sora/Utlis & Misc/Extensions/Notification+Name.swift @@ -6,6 +6,7 @@ // import Foundation +import UIKit extension Notification.Name { static let iCloudSyncDidComplete = Notification.Name("iCloudSyncDidComplete") @@ -13,4 +14,12 @@ extension Notification.Name { static let ContinueWatchingDidUpdate = Notification.Name("ContinueWatchingDidUpdate") static let DownloadManagerStatusUpdate = Notification.Name("DownloadManagerStatusUpdate") static let modulesSyncDidComplete = Notification.Name("modulesSyncDidComplete") + static let moduleRemoved = Notification.Name("moduleRemoved") + static let didReceiveNewModule = Notification.Name("didReceiveNewModule") + static let didUpdateModules = Notification.Name("didUpdateModules") + static let didUpdateDownloads = Notification.Name("didUpdateDownloads") + static let didUpdateBookmarks = Notification.Name("didUpdateBookmarks") + static let hideTabBar = Notification.Name("hideTabBar") + static let showTabBar = Notification.Name("showTabBar") + static let searchQueryChanged = Notification.Name("searchQueryChanged") } diff --git a/Sora/Utlis & Misc/Extensions/View.swift b/Sora/Utlis & Misc/Extensions/View.swift index 8aece77..78e49f6 100644 --- a/Sora/Utlis & Misc/Extensions/View.swift +++ b/Sora/Utlis & Misc/Extensions/View.swift @@ -6,6 +6,7 @@ // import SwiftUI +import UIKit struct ScrollViewBottomPadding: ViewModifier { func body(content: Content) -> some View { diff --git a/Sora/Utlis & Misc/JSLoader/JSController-Novel.swift b/Sora/Utlis & Misc/JSLoader/JSController-Novel.swift index 6b01c11..ab446fc 100644 --- a/Sora/Utlis & Misc/JSLoader/JSController-Novel.swift +++ b/Sora/Utlis & Misc/JSLoader/JSController-Novel.swift @@ -34,7 +34,7 @@ enum JSError: Error { extension JSController { @MainActor func extractChapters(moduleId: String, href: String) async throws -> [[String: Any]] { - guard let module = ModuleManager().modules.first(where: { $0.id.uuidString == moduleId }) else { + guard ModuleManager().modules.first(where: { $0.id.uuidString == moduleId }) != nil else { throw JSError.moduleNotFound } @@ -122,17 +122,59 @@ extension JSController { } return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - DispatchQueue.main.async { [weak self] in + let workItem = DispatchWorkItem { [weak self] in guard let self = self else { continuation.resume(throwing: JSError.invalidResponse) return } - let function = self.context.objectForKeyedSubscript("extractText") - let result = function?.call(withArguments: [href]) + if self.context.objectForKeyedSubscript("extractText") == nil { + Logger.shared.log("extractText function not found, attempting to load module script", type: "Debug") + do { + let moduleContent = try ModuleManager().getModuleContent(module) + self.loadScript(moduleContent) + Logger.shared.log("Successfully loaded module script", type: "Debug") + } catch { + Logger.shared.log("Failed to load module script: \(error)", type: "Error") + } + } + + guard let function = self.context.objectForKeyedSubscript("extractText") else { + Logger.shared.log("extractText function not available after loading module script", type: "Error") + + let task = Task { + return try await self.fetchContentDirectly(from: href) + } + + Task { + do { + let content = try await task.value + continuation.resume(returning: content) + } catch { + continuation.resume(throwing: JSError.invalidResponse) + } + } + return + } + + let result = function.call(withArguments: [href]) if let exception = self.context.exception { Logger.shared.log("Error extracting text: \(exception)", type: "Error") + + let task = Task { + return try await self.fetchContentDirectly(from: href) + } + + Task { + do { + let content = try await task.value + continuation.resume(returning: content) + } catch { + continuation.resume(throwing: JSError.jsException(exception.toString() ?? "Unknown JS error")) + } + } + return } if let result = result, result.hasProperty("then") { @@ -163,25 +205,116 @@ extension JSController { result.invokeMethod("then", withArguments: [thenBlock]) result.invokeMethod("catch", withArguments: [catchBlock]) - group.notify(queue: .main) { + let notifyWorkItem = DispatchWorkItem { if !extractedText.isEmpty { continuation.resume(returning: extractedText) - } else if let error = extractError { - continuation.resume(throwing: error) + } else if extractError != nil { + let fetchTask = Task { + return try await self.fetchContentDirectly(from: href) + } + + Task { + do { + let content = try await fetchTask.value + continuation.resume(returning: content) + } catch { + continuation.resume(throwing: error) + } + } } else { - continuation.resume(throwing: JSError.emptyContent) + let fetchTask = Task { + return try await self.fetchContentDirectly(from: href) + } + + Task { + do { + let content = try await fetchTask.value + continuation.resume(returning: content) + } catch _ { + continuation.resume(throwing: JSError.emptyContent) + } + } } } + + group.notify(queue: .main, work: notifyWorkItem) } else { if let text = result?.toString(), !text.isEmpty { Logger.shared.log("extractText: direct string result", type: "Debug") continuation.resume(returning: text) } else { - Logger.shared.log("extractText: could not parse direct result", type: "Error") - continuation.resume(throwing: JSError.emptyContent) + Logger.shared.log("extractText: could not parse direct result, trying direct fetch", type: "Error") + let task = Task { + return try await self.fetchContentDirectly(from: href) + } + + Task { + do { + let content = try await task.value + continuation.resume(returning: content) + } catch { + continuation.resume(throwing: JSError.emptyContent) + } + } } } } + + DispatchQueue.main.async(execute: workItem) } } -} \ No newline at end of file + + private func fetchContentDirectly(from url: String) async throws -> String { + guard let url = URL(string: url) else { + throw JSError.invalidResponse + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.addValue("Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Mobile/15E148 Safari/604.1", forHTTPHeaderField: "User-Agent") + + Logger.shared.log("Attempting direct fetch from: \(url.absoluteString)", type: "Debug") + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + (200...299).contains(httpResponse.statusCode) else { + Logger.shared.log("Direct fetch failed with status code: \((response as? HTTPURLResponse)?.statusCode ?? -1)", type: "Error") + throw JSError.invalidResponse + } + + guard let htmlString = String(data: data, encoding: .utf8) else { + Logger.shared.log("Failed to decode response data", type: "Error") + throw JSError.invalidResponse + } + + var content = "" + + if let contentRange = htmlString.range(of: "", options: .caseInsensitive) { + let startIndex = contentRange.lowerBound + let endIndex = endRange.upperBound + content = String(htmlString[startIndex..", options: .caseInsensitive, range: contentRange.upperBound..", options: .caseInsensitive, range: contentRange.upperBound..", options: .caseInsensitive) { + let startIndex = bodyRange.lowerBound + let endIndex = endBodyRange.upperBound + content = String(htmlString[startIndex.. String { diff --git a/Sora/Utlis & Misc/TabBar/TabBar.swift b/Sora/Utlis & Misc/TabBar/TabBar.swift index 8b93d24..52acb8d 100644 --- a/Sora/Utlis & Misc/TabBar/TabBar.swift +++ b/Sora/Utlis & Misc/TabBar/TabBar.swift @@ -6,6 +6,7 @@ // import SwiftUI +import Combine extension Color { @@ -36,15 +37,14 @@ extension Color { struct TabBar: View { - let tabs: [TabItem] + var tabs: [TabItem] @Binding var selectedTab: Int - @Binding var lastTab: Int - @State var showSearch: Bool = false - @State var searchLocked: Bool = false - @FocusState var keyboardFocus: Bool - @State var keyboardHidden: Bool = true - @Binding var searchQuery: String - @ObservedObject var controller: TabBarController + @State private var lastTab: Int = 0 + @State private var showSearch: Bool = false + @State private var searchQuery: String = "" + @FocusState private var keyboardFocus: Bool + @State private var keyboardHidden: Bool = true + @State private var searchLocked: Bool = false @State private var keyboardHeight: CGFloat = 0 @@ -57,15 +57,6 @@ struct TabBar: View { @Namespace private var animation - - func slideDown() { - controller.hideTabBar() - } - - func slideUp() { - controller.showTabBar() - } - var body: some View { HStack { if showSearch && keyboardHidden { @@ -124,6 +115,14 @@ struct TabBar: View { keyboardHidden = !newValue } } + .onChange(of: searchQuery) { newValue in + // 发送通知,传递搜索查询 + NotificationCenter.default.post( + name: .searchQueryChanged, + object: nil, + userInfo: ["searchQuery": newValue] + ) + } .onDisappear { keyboardFocus = false } @@ -180,12 +179,10 @@ struct TabBar: View { .padding(.horizontal, -20) .padding(.bottom, -100) .padding(.top, -10) - .opacity(controller.isHidden ? 0 : 1) // Animate opacity - .animation(.easeInOut(duration: 0.15), value: controller.isHidden) } - .offset(y: controller.isHidden ? 120 : (keyboardFocus ? -keyboardHeight + 36 : 0)) + .offset(y: keyboardFocus ? -keyboardHeight + 40 : 0) .animation(.spring(response: 0.3, dampingFraction: 0.8), value: keyboardHeight) - .animation(.easeInOut(duration: 0.15), value: controller.isHidden) + .animation(.spring(response: 0.3, dampingFraction: 0.8), value: keyboardFocus) .onChange(of: keyboardHeight) { newValue in withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { } @@ -201,6 +198,10 @@ struct TabBar: View { keyboardHeight = 0 } } + .onDisappear { + NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillShowNotification, object: nil) + NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillHideNotification, object: nil) + } } @ViewBuilder diff --git a/Sora/Utlis & Misc/TabBar/TabBarController.swift b/Sora/Utlis & Misc/TabBar/TabBarController.swift deleted file mode 100644 index cf4d95c..0000000 --- a/Sora/Utlis & Misc/TabBar/TabBarController.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// TabBarController.swift -// Sulfur -// -// Created by Mac on 28/05/2025. -// - -import SwiftUI - -class TabBarController: ObservableObject { - @Published var isHidden = false - - func hideTabBar() { - withAnimation(.easeInOut(duration: 0.15)) { - isHidden = true - } - } - - func showTabBar() { - withAnimation(.easeInOut(duration: 0.10)) { - isHidden = false - } - } -} diff --git a/Sora/Views/DownloadView.swift b/Sora/Views/DownloadView.swift index 5320577..e6e5d31 100644 --- a/Sora/Views/DownloadView.swift +++ b/Sora/Views/DownloadView.swift @@ -11,7 +11,6 @@ import SwiftUI struct DownloadView: View { @EnvironmentObject var jsController: JSController - @EnvironmentObject var tabBarController: TabBarController @State private var searchText = "" @State private var selectedTab = 0 @State private var sortOption: SortOption = .newest @@ -71,9 +70,6 @@ struct DownloadView: View { Text(String(format: NSLocalizedString("Are you sure you want to delete '%@'?", comment: ""), asset.episodeDisplayName)) } } - .onAppear { - tabBarController.showTabBar() - } } .deviceScaled() .navigationViewStyle(StackNavigationViewStyle()) @@ -989,7 +985,6 @@ struct EnhancedShowEpisodesView: View { @State private var showDeleteAllAlert = false @State private var assetToDelete: DownloadedAsset? @EnvironmentObject var jsController: JSController - @EnvironmentObject var tabBarController: TabBarController @Environment(\.colorScheme) private var colorScheme @Environment(\.dismiss) private var dismiss @@ -1029,16 +1024,18 @@ struct EnhancedShowEpisodesView: View { navigationOverlay } .onAppear { - tabBarController.hideTabBar() 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 } + + NotificationCenter.default.post(name: .hideTabBar, object: nil) } .onDisappear { - tabBarController.showTabBar() + NotificationCenter.default.post(name: .showTabBar, object: nil) + UIScrollView.appearance().bounces = true } .navigationBarBackButtonHidden(true) } @@ -1048,7 +1045,6 @@ struct EnhancedShowEpisodesView: View { VStack { HStack { Button(action: { - tabBarController.showTabBar() dismiss() }) { Image(systemName: "chevron.left") diff --git a/Sora/Views/LibraryView/AllReading.swift b/Sora/Views/LibraryView/AllReading.swift new file mode 100644 index 0000000..9310acb --- /dev/null +++ b/Sora/Views/LibraryView/AllReading.swift @@ -0,0 +1,448 @@ +// +// AllReading.swift +// Sora +// +// Created by paul on 26/06/25. +// + +import SwiftUI +import NukeUI + +struct AllReadingView: View { + @Environment(\.dismiss) private var dismiss + + + @State private var continueReadingItems: [ContinueReadingItem] = [] + @State private var isRefreshing: Bool = false + @State private var sortOption: SortOption = .dateAdded + @State private var searchText: String = "" + @State private var isSearchActive: Bool = false + @State private var isSelecting: Bool = false + @State private var selectedItems: Set = [] + @Environment(\.scenePhase) private var scenePhase + + enum SortOption: String, CaseIterable { + case dateAdded = "Recently Added" + case title = "Novel Title" + case progress = "Read Progress" + } + + var filteredAndSortedItems: [ContinueReadingItem] { + let filtered = searchText.isEmpty ? continueReadingItems : continueReadingItems.filter { item in + item.mediaTitle.localizedCaseInsensitiveContains(searchText) + } + switch sortOption { + case .dateAdded: + return filtered.sorted { $0.lastReadDate > $1.lastReadDate } + case .title: + return filtered.sorted { $0.mediaTitle.lowercased() < $1.mediaTitle.lowercased() } + case .progress: + return filtered.sorted { $0.progress > $1.progress } + } + } + + var body: some View { + VStack(alignment: .leading) { + HStack { + Button(action: { + dismiss() + }) { + Image(systemName: "chevron.left") + .font(.system(size: 24)) + .foregroundColor(.primary) + } + + Button(action: { + dismiss() + }) { + Text(LocalizedStringKey("All Reading")) + .font(.title3) + .fontWeight(.bold) + .foregroundColor(.primary) + } + + Spacer() + + HStack(spacing: 16) { + Button(action: { + withAnimation(.easeInOut(duration: 0.3)) { + isSearchActive.toggle() + } + if !isSearchActive { + searchText = "" + } + }) { + Image(systemName: isSearchActive ? "xmark.circle.fill" : "magnifyingglass") + .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() + } + Menu { + ForEach(SortOption.allCases, id: \.self) { option in + Button { + sortOption = option + } label: { + HStack { + Text(NSLocalizedString(option.rawValue, comment: "")) + if option == sortOption { + Image(systemName: "checkmark") + .foregroundColor(.accentColor) + } + } + } + } + } label: { + Image(systemName: "line.3.horizontal.decrease.circle") + .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() + } + Button(action: { + if isSelecting { + if !selectedItems.isEmpty { + for id in selectedItems { + if let item = continueReadingItems.first(where: { $0.id == id }) { + ContinueReadingManager.shared.remove(item: item) + } + } + selectedItems.removeAll() + fetchContinueReading() + } + isSelecting = false + } else { + isSelecting = true + } + }) { + Image(systemName: isSelecting ? "trash" : "checkmark.circle") + .resizable() + .scaledToFit() + .frame(width: 18, height: 18) + .foregroundColor(isSelecting ? .red : .accentColor) + .padding(10) + .background( + Circle() + .fill(Color.gray.opacity(0.2)) + .shadow(color: .accentColor.opacity(0.2), radius: 2) + ) + .circularGradientOutline() + } + } + } + .padding(.horizontal) + .padding(.top) + + if isSearchActive { + HStack(spacing: 12) { + HStack(spacing: 12) { + Image(systemName: "magnifyingglass") + .resizable() + .scaledToFit() + .frame(width: 18, height: 18) + .foregroundColor(.secondary) + TextField(LocalizedStringKey("Search reading..."), text: $searchText) + .textFieldStyle(PlainTextFieldStyle()) + .foregroundColor(.primary) + if !searchText.isEmpty { + Button(action: { + searchText = "" + }) { + Image(systemName: "xmark.circle.fill") + .resizable() + .scaledToFit() + .frame(width: 18, height: 18) + .foregroundColor(.secondary) + } + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(.ultraThinMaterial) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .strokeBorder( + 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: 1.5 + ) + ) + } + .padding(.horizontal) + .padding(.bottom, 8) + .transition(.asymmetric( + insertion: .move(edge: .top).combined(with: .opacity), + removal: .move(edge: .top).combined(with: .opacity) + )) + } + + ScrollView { + LazyVStack(spacing: 12) { + if filteredAndSortedItems.isEmpty { + emptyStateView + } else { + ForEach(filteredAndSortedItems) { item in + FullWidthContinueReadingCell( + item: item, + markAsRead: { + markContinueReadingItemAsRead(item: item) + }, + removeItem: { + removeContinueReadingItem(item: item) + }, + isSelecting: isSelecting, + selectedItems: $selectedItems + ) + } + } + } + .padding(.top) + .padding(.horizontal) + } + .scrollViewBottomPadding() + } + .navigationBarBackButtonHidden(true) + .navigationBarTitleDisplayMode(.inline) + .onAppear { + fetchContinueReading() + + NotificationCenter.default.post(name: .showTabBar, object: nil) + } + .onChange(of: scenePhase) { newPhase in + if newPhase == .active { + fetchContinueReading() + } + } + .refreshable { + isRefreshing = true + fetchContinueReading() + isRefreshing = false + } + } + + private var emptyStateView: some View { + VStack(spacing: 16) { + Image(systemName: "book.closed") + .font(.system(size: 50)) + .foregroundColor(.gray) + + Text("No Reading History") + .font(.title2) + .fontWeight(.bold) + + Text("Books you're reading will appear here") + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 60) + } + + private func fetchContinueReading() { + continueReadingItems = ContinueReadingManager.shared.fetchItems() + + for (index, item) in continueReadingItems.enumerated() { + print("Reading item \(index): Title: \(item.mediaTitle), Image URL: \(item.imageUrl)") + } + } + + private func markContinueReadingItemAsRead(item: ContinueReadingItem) { + UserDefaults.standard.set(1.0, forKey: "readingProgress_\(item.href)") + ContinueReadingManager.shared.updateProgress(for: item.href, progress: 1.0) + fetchContinueReading() + } + + private func removeContinueReadingItem(item: ContinueReadingItem) { + ContinueReadingManager.shared.remove(item: item) + fetchContinueReading() + } +} + +struct FullWidthContinueReadingCell: View { + let item: ContinueReadingItem + var markAsRead: () -> Void + var removeItem: () -> Void + var isSelecting: Bool + var selectedItems: Binding> + + var isSelected: Bool { + selectedItems.wrappedValue.contains(item.id) + } + + private var imageURL: URL { + print("Processing image URL: \(item.imageUrl)") + + if !item.imageUrl.isEmpty { + if let url = URL(string: item.imageUrl) { + print("Valid URL: \(url)") + return url + } + + if let encodedUrlString = item.imageUrl.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), + let url = URL(string: encodedUrlString) { + print("Using encoded URL: \(encodedUrlString)") + return url + } + } + + print("Using fallback URL") + return URL(string: "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/main/assets/banner2.png")! + } + + @MainActor + var body: some View { + Group { + if isSelecting { + Button(action: { + if isSelected { + selectedItems.wrappedValue.remove(item.id) + } else { + selectedItems.wrappedValue.insert(item.id) + } + }) { + ZStack(alignment: .topTrailing) { + cellContent + 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) + } + } + } + } else { + NavigationLink(destination: ReaderView( + moduleId: item.moduleId, + chapterHref: item.href, + chapterTitle: item.chapterTitle, + mediaTitle: item.mediaTitle, + chapterNumber: item.chapterNumber + )) { + cellContent + } + .simultaneousGesture(TapGesture().onEnded { + UserDefaults.standard.set(true, forKey: "navigatingToReaderView") + }) + } + } + .contextMenu { + Button(action: { markAsRead() }) { + Label("Mark as Read", systemImage: "checkmark.circle") + } + Button(role: .destructive, action: { removeItem() }) { + Label("Remove from Continue Reading", systemImage: "trash") + } + } + } + + @MainActor + private var cellContent: some View { + GeometryReader { geometry in + ZStack { + LazyImage(url: imageURL) { state in + if let image = state.imageContainer?.image { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: geometry.size.width, height: 157.03) + .blur(radius: 3) + .opacity(0.7) + } else { + Rectangle() + .fill(Color.gray.opacity(0.3)) + .frame(width: geometry.size.width, height: 157.03) + } + } + .onAppear { + print("Background image loading: \(imageURL)") + } + + Rectangle() + .fill(LinearGradient( + gradient: Gradient(colors: [Color.black.opacity(0.7), Color.black.opacity(0.4)]), + startPoint: .leading, + endPoint: .trailing + )) + .frame(width: geometry.size.width, height: 157.03) + + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: 4) { + Text("\(Int(item.progress * 100))%") + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(.white) + .padding(.vertical, 4) + .padding(.horizontal, 8) + .background(Color.black.opacity(0.6)) + .cornerRadius(4) + + Spacer() + + VStack(alignment: .leading, spacing: 4) { + Text("Chapter \(item.chapterNumber)") + .font(.caption) + .foregroundColor(.white.opacity(0.9)) + + Text(item.mediaTitle) + .font(.headline) + .fontWeight(.bold) + .foregroundColor(.white) + .lineLimit(2) + } + } + .padding(12) + .frame(width: geometry.size.width * 0.6, alignment: .leading) + + LazyImage(url: imageURL) { state in + if let image = state.imageContainer?.image { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: geometry.size.width * 0.4, height: 157.03) + .clipped() + } else { + Rectangle() + .fill(Color.gray.opacity(0.3)) + .frame(width: geometry.size.width * 0.4, height: 157.03) + } + } + .onAppear { + print("Right image loading: \(imageURL)") + } + .frame(width: geometry.size.width * 0.4, height: 157.03) + } + } + .frame(height: 157.03) + .cornerRadius(10) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Color.gray.opacity(0.3), lineWidth: 0.5) + ) + } + .frame(height: 157.03) + } +} diff --git a/Sora/Views/LibraryView/AllWatching.swift b/Sora/Views/LibraryView/AllWatching.swift index 1852cdc..3a94fcb 100644 --- a/Sora/Views/LibraryView/AllWatching.swift +++ b/Sora/Views/LibraryView/AllWatching.swift @@ -113,7 +113,7 @@ struct AllWatchingView: View { sortOption = option } label: { HStack { - Text(option.rawValue) + Text(NSLocalizedString(option.rawValue, comment: "")) if option == sortOption { Image(systemName: "checkmark") .foregroundColor(.accentColor) diff --git a/Sora/Views/LibraryView/BookmarkComponents/BookmarkGridItemView.swift b/Sora/Views/LibraryView/BookmarkComponents/BookmarkGridItemView.swift index 3c49eba..a100542 100644 --- a/Sora/Views/LibraryView/BookmarkComponents/BookmarkGridItemView.swift +++ b/Sora/Views/LibraryView/BookmarkComponents/BookmarkGridItemView.swift @@ -53,13 +53,19 @@ struct BookmarkGridItemView: View { } } ) - // Book/TV icon overlay, bottom right of module icon - Image(systemName: isNovel ? "book.fill" : "tv.fill") - .resizable() - .scaledToFit() - .frame(width: 14, height: 14) - .foregroundColor(.accentColor) - .offset(x: 6, y: 6) + ZStack { + Circle() + .fill(Color.gray.opacity(0.8)) + .shadow(color: .accentColor.opacity(0.2), radius: 2) + .frame(width: 20, height: 20) + Image(systemName: isNovel ? "book.fill" : "tv.fill") + .resizable() + .scaledToFit() + .frame(width: 10, height: 10) + .foregroundColor(.white) + } + .circularGradientOutline() + .offset(x: 6, y: 6) } .padding(8), alignment: .topLeading diff --git a/Sora/Views/LibraryView/BookmarkComponents/BookmarksDetailView.swift b/Sora/Views/LibraryView/BookmarkComponents/BookmarksDetailView.swift index dabb25a..afa28d0 100644 --- a/Sora/Views/LibraryView/BookmarkComponents/BookmarksDetailView.swift +++ b/Sora/Views/LibraryView/BookmarkComponents/BookmarksDetailView.swift @@ -53,7 +53,7 @@ struct BookmarksDetailView: View { .foregroundColor(.primary) } Button(action: { dismiss() }) { - Text("Collections") + Text(LocalizedStringKey("Collections")) .font(.title3) .fontWeight(.bold) .foregroundColor(.primary) @@ -90,7 +90,7 @@ struct BookmarksDetailView: View { sortOption = option } label: { HStack { - Text(option.rawValue) + Text(NSLocalizedString(option.rawValue, comment: "")) if option == sortOption { Image(systemName: "checkmark") .foregroundColor(.accentColor) @@ -168,7 +168,7 @@ struct BookmarksDetailView: View { .scaledToFit() .frame(width: 18, height: 18) .foregroundColor(.secondary) - TextField("Search collections...", text: $searchText) + TextField(LocalizedStringKey("Search collections..."), text: $searchText) .textFieldStyle(PlainTextFieldStyle()) .foregroundColor(.primary) if !searchText.isEmpty { @@ -215,9 +215,9 @@ struct BookmarksDetailView: View { Image(systemName: "folder") .font(.largeTitle) .foregroundColor(.secondary) - Text("No Collections") + Text(LocalizedStringKey("No Collections")) .font(.headline) - Text("Create a collection to organize your bookmarks") + Text(LocalizedStringKey("Create a collection to organize your bookmarks")) .font(.caption) .foregroundColor(.secondary) } @@ -254,7 +254,7 @@ struct BookmarksDetailView: View { ) } .contextMenu { - Button("Rename") { + Button(LocalizedStringKey("Rename")) { collectionToRename = collection renameCollectionName = collection.name isShowingRenamePrompt = true @@ -262,7 +262,7 @@ struct BookmarksDetailView: View { Button(role: .destructive) { libraryManager.deleteCollection(id: collection.id) } label: { - Label("Delete", systemImage: "trash") + Label(LocalizedStringKey("Delete"), systemImage: "trash") } } } else { @@ -270,7 +270,7 @@ struct BookmarksDetailView: View { BookmarkCollectionGridCell(collection: collection, width: 162, height: 162) } .contextMenu { - Button("Rename") { + Button(LocalizedStringKey("Rename")) { collectionToRename = collection renameCollectionName = collection.name isShowingRenamePrompt = true @@ -278,7 +278,7 @@ struct BookmarksDetailView: View { Button(role: .destructive) { libraryManager.deleteCollection(id: collection.id) } label: { - Label("Delete", systemImage: "trash") + Label(LocalizedStringKey("Delete"), systemImage: "trash") } } } @@ -292,30 +292,30 @@ struct BookmarksDetailView: View { } .navigationBarBackButtonHidden(true) .navigationBarTitleDisplayMode(.inline) - .alert("Create Collection", isPresented: $isShowingCreateCollection) { - TextField("Collection Name", text: $newCollectionName) - Button("Cancel", role: .cancel) { + .alert(LocalizedStringKey("Create Collection"), isPresented: $isShowingCreateCollection) { + TextField(LocalizedStringKey("Collection Name"), text: $newCollectionName) + Button(LocalizedStringKey("Cancel"), role: .cancel) { newCollectionName = "" } - Button("Create") { + Button(LocalizedStringKey("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) { + .alert(LocalizedStringKey("Rename Collection"), isPresented: $isShowingRenamePrompt, presenting: collectionToRename) { collection in + TextField(LocalizedStringKey("Collection Name"), text: $renameCollectionName) + Button(LocalizedStringKey("Cancel"), role: .cancel) { collectionToRename = nil renameCollectionName = "" } - Button("Rename") { - if !renameCollectionName.isEmpty { + Button(LocalizedStringKey("Rename")) { + if let collection = collectionToRename, !renameCollectionName.isEmpty { libraryManager.renameCollection(id: collection.id, newName: renameCollectionName) + collectionToRename = nil + renameCollectionName = "" } - collectionToRename = nil - renameCollectionName = "" } } message: { _ in EmptyView() } .onAppear { @@ -338,7 +338,7 @@ private struct SortMenu: View { sortOption = option } label: { HStack { - Text(option.rawValue) + Text(NSLocalizedString(option.rawValue, comment: "")) if option == sortOption { Image(systemName: "checkmark") .foregroundColor(.accentColor) diff --git a/Sora/Views/LibraryView/BookmarkComponents/CollectionDetailView.swift b/Sora/Views/LibraryView/BookmarkComponents/CollectionDetailView.swift index c256090..d37afca 100644 --- a/Sora/Views/LibraryView/BookmarkComponents/CollectionDetailView.swift +++ b/Sora/Views/LibraryView/BookmarkComponents/CollectionDetailView.swift @@ -12,7 +12,7 @@ struct CollectionDetailView: View { @Environment(\.dismiss) private var dismiss @EnvironmentObject private var libraryManager: LibraryManager @EnvironmentObject private var moduleManager: ModuleManager - @EnvironmentObject private var tabBarController: TabBarController + let collection: BookmarkCollection @State private var sortOption: SortOption = .dateAdded @@ -20,6 +20,7 @@ struct CollectionDetailView: View { @State private var isSearchActive: Bool = false @State private var isSelecting: Bool = false @State private var selectedBookmarks: Set = [] + @State private var isActive: Bool = false enum SortOption: String, CaseIterable { case dateAdded = "Date Added" @@ -28,10 +29,15 @@ struct CollectionDetailView: View { } private var filteredAndSortedBookmarks: [LibraryItem] { - let filtered = searchText.isEmpty ? collection.bookmarks : collection.bookmarks.filter { item in + let validBookmarks = collection.bookmarks.filter { bookmark in + moduleManager.modules.contains { $0.id.uuidString == bookmark.moduleId } + } + + let filtered = searchText.isEmpty ? validBookmarks : validBookmarks.filter { item in item.title.localizedCaseInsensitiveContains(searchText) || item.moduleName.localizedCaseInsensitiveContains(searchText) } + switch sortOption { case .dateAdded: return filtered @@ -92,7 +98,7 @@ struct CollectionDetailView: View { sortOption = option } label: { HStack { - Text(option.rawValue) + Text(NSLocalizedString(option.rawValue, comment: "")) if option == sortOption { Image(systemName: "checkmark") .foregroundColor(.accentColor) @@ -118,7 +124,7 @@ struct CollectionDetailView: View { if isSelecting { if !selectedBookmarks.isEmpty { for id in selectedBookmarks { - if let item = collection.bookmarks.first(where: { $0.id == id }) { + if collection.bookmarks.contains(where: { $0.id == id }) { libraryManager.removeBookmarkFromCollection(bookmarkId: id, collectionId: collection.id) } } @@ -156,7 +162,7 @@ struct CollectionDetailView: View { .scaledToFit() .frame(width: 18, height: 18) .foregroundColor(.secondary) - TextField("Search bookmarks...", text: $searchText) + TextField(LocalizedStringKey("Search bookmarks..."), text: $searchText) .textFieldStyle(PlainTextFieldStyle()) .foregroundColor(.primary) if !searchText.isEmpty { @@ -258,6 +264,7 @@ struct CollectionDetailView: View { )) { BookmarkGridItemView(item: bookmark, module: module) } + .isDetailLink(true) .contextMenu { Button(role: .destructive) { libraryManager.removeBookmarkFromCollection(bookmarkId: bookmark.id, collectionId: collection.id) @@ -277,13 +284,41 @@ struct CollectionDetailView: View { .navigationBarBackButtonHidden(true) .navigationBarTitleDisplayMode(.inline) .onAppear { + isActive = true 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 } - tabBarController.showTabBar() + + let isMediaInfoActive = UserDefaults.standard.bool(forKey: "isMediaInfoActive") + let isReaderActive = UserDefaults.standard.bool(forKey: "isReaderActive") + if !isMediaInfoActive && !isReaderActive { + NotificationCenter.default.post(name: .showTabBar, object: nil) + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) { + let isMediaInfoActive = UserDefaults.standard.bool(forKey: "isMediaInfoActive") + let isReaderActive = UserDefaults.standard.bool(forKey: "isReaderActive") + if !isMediaInfoActive && !isReaderActive { + NotificationCenter.default.post(name: .showTabBar, object: nil) + } + } + } + .onReceive(Timer.publish(every: 0.1, on: .main, in: .common).autoconnect()) { _ in + let isMediaInfoActive = UserDefaults.standard.bool(forKey: "isMediaInfoActive") + let isReaderActive = UserDefaults.standard.bool(forKey: "isReaderActive") + if isActive && !isMediaInfoActive && !isReaderActive { + NotificationCenter.default.post(name: .showTabBar, object: nil) + } + } + .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in + isActive = true + let isMediaInfoActive = UserDefaults.standard.bool(forKey: "isMediaInfoActive") + let isReaderActive = UserDefaults.standard.bool(forKey: "isReaderActive") + if !isMediaInfoActive && !isReaderActive { + NotificationCenter.default.post(name: .showTabBar, object: nil) + } } } -} \ No newline at end of file +} diff --git a/Sora/Views/LibraryView/BookmarkComponents/CollectionPickerView.swift b/Sora/Views/LibraryView/BookmarkComponents/CollectionPickerView.swift index 3874d06..9978bd5 100644 --- a/Sora/Views/LibraryView/BookmarkComponents/CollectionPickerView.swift +++ b/Sora/Views/LibraryView/BookmarkComponents/CollectionPickerView.swift @@ -20,8 +20,8 @@ struct CollectionPickerView: View { if isShowingNewCollectionField { Section { HStack { - TextField("Collection name", text: $newCollectionName) - Button("Create") { + TextField(LocalizedStringKey("Collection name"), text: $newCollectionName) + Button(LocalizedStringKey("Create")) { if !newCollectionName.isEmpty { libraryManager.createCollection(name: newCollectionName) if let newCollection = libraryManager.collections.first(where: { $0.name == newCollectionName }) { @@ -52,11 +52,11 @@ struct CollectionPickerView: View { } } } - .navigationTitle("Add to Collection") + .navigationTitle(LocalizedStringKey("Add to Collection")) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarLeading) { - Button("Cancel") { + Button(LocalizedStringKey("Cancel")) { dismiss() } } diff --git a/Sora/Views/LibraryView/ContinueReadingSection.swift b/Sora/Views/LibraryView/ContinueReadingSection.swift new file mode 100644 index 0000000..abba180 --- /dev/null +++ b/Sora/Views/LibraryView/ContinueReadingSection.swift @@ -0,0 +1,181 @@ +// +// ContinueReadingSection.swift +// Sora +// +// Created by paul on 26/06/25. +// + +import SwiftUI +import NukeUI + +struct ContinueReadingSection: View { + @Binding var items: [ContinueReadingItem] + var markAsRead: (ContinueReadingItem) -> Void + var removeItem: (ContinueReadingItem) -> Void + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(Array(items.prefix(5))) { item in + ContinueReadingCell(item: item, markAsRead: { + markAsRead(item) + }, removeItem: { + removeItem(item) + }) + } + } + .padding(.horizontal, 20) + .frame(height: 157.03) + } + } +} + +struct ContinueReadingCell: View { + let item: ContinueReadingItem + var markAsRead: () -> Void + var removeItem: () -> Void + + @Environment(\.colorScheme) private var colorScheme + @State private var imageLoadError: Bool = false + + private var imageURL: URL { + print("Processing image URL in ContinueReadingCell: \(item.imageUrl)") + + if !item.imageUrl.isEmpty { + if let url = URL(string: item.imageUrl) { + print("Valid direct URL: \(url)") + return url + } + + if let encodedUrlString = item.imageUrl.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), + let url = URL(string: encodedUrlString) { + print("Using encoded URL: \(encodedUrlString)") + return url + } + + if item.imageUrl.hasPrefix("http://") { + let httpsUrl = "https://" + item.imageUrl.dropFirst(7) + if let url = URL(string: httpsUrl) { + print("Using https URL: \(httpsUrl)") + return url + } + } + } + + print("Using fallback URL") + return URL(string: "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/main/assets/banner2.png")! + } + + var body: some View { + NavigationLink(destination: ReaderView( + moduleId: item.moduleId, + chapterHref: item.href, + chapterTitle: item.chapterTitle, + chapters: [], + mediaTitle: item.mediaTitle, + chapterNumber: item.chapterNumber + )) { + ZStack { + LazyImage(url: imageURL) { state in + if let image = state.imageContainer?.image { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 280, height: 157.03) + .blur(radius: 3) + .opacity(0.7) + } else { + Rectangle() + .fill(Color.gray.opacity(0.3)) + .frame(width: 280, height: 157.03) + } + } + .onAppear { + print("Background image loading: \(imageURL)") + } + + Rectangle() + .fill(LinearGradient( + gradient: Gradient(colors: [Color.black.opacity(0.7), Color.black.opacity(0.4)]), + startPoint: .leading, + endPoint: .trailing + )) + .frame(width: 280, height: 157.03) + + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: 4) { + Text("\(Int(item.progress * 100))%") + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(.white) + .padding(.vertical, 4) + .padding(.horizontal, 8) + .background(Color.black.opacity(0.6)) + .cornerRadius(4) + + Spacer() + + VStack(alignment: .leading, spacing: 4) { + Text("Chapter \(item.chapterNumber)") + .font(.caption) + .foregroundColor(.white.opacity(0.9)) + + Text(item.mediaTitle) + .font(.headline) + .fontWeight(.bold) + .foregroundColor(.white) + .lineLimit(2) + } + } + .padding(12) + .frame(width: 170, alignment: .leading) + + LazyImage(url: imageURL) { state in + if let image = state.imageContainer?.image { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 110, height: 157.03) + .clipped() + } else { + Rectangle() + .fill(Color.gray.opacity(0.3)) + .frame(width: 110, height: 157.03) + } + } + .onAppear { + print("Right image loading: \(imageURL)") + } + .onDisappear { + print("Right image disappeared") + } + .frame(width: 110, height: 157.03) + } + } + .frame(width: 280, height: 157.03) + .cornerRadius(10) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Color.gray.opacity(0.3), lineWidth: 0.5) + ) + } + .contextMenu { + Button(action: { + markAsRead() + }) { + Label("Mark as Read", systemImage: "checkmark.circle") + } + Button(role: .destructive, action: { + removeItem() + }) { + Label("Remove Item", systemImage: "trash") + } + } + .onAppear { + print("ContinueReadingCell appeared for: \(item.mediaTitle)") + print("Image URL: \(item.imageUrl)") + print("Chapter: \(item.chapterNumber)") + print("Progress: \(item.progress)") + } + } +} diff --git a/Sora/Views/LibraryView/LibraryManager.swift b/Sora/Views/LibraryView/LibraryManager.swift index 510fda9..d025c6d 100644 --- a/Sora/Views/LibraryView/LibraryManager.swift +++ b/Sora/Views/LibraryView/LibraryManager.swift @@ -7,6 +7,8 @@ import Foundation import SwiftUI +import NukeUI +import Nuke struct BookmarkCollection: Codable, Identifiable { let id: UUID @@ -55,6 +57,7 @@ class LibraryManager: ObservableObject { loadCollections() NotificationCenter.default.addObserver(self, selector: #selector(handleiCloudSync), name: .iCloudSyncDidComplete, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(handleModuleRemoval), name: .moduleRemoved, object: nil) } @objc private func handleiCloudSync() { @@ -63,6 +66,30 @@ class LibraryManager: ObservableObject { } } + @objc private func handleModuleRemoval(_ notification: Notification) { + if let moduleId = notification.object as? String { + cleanupBookmarksForModule(moduleId: moduleId) + } + } + + private func cleanupBookmarksForModule(moduleId: String) { + var didChange = false + + for (collectionIndex, collection) in collections.enumerated() { + let originalCount = collection.bookmarks.count + collections[collectionIndex].bookmarks.removeAll { $0.moduleId == moduleId } + + if collections[collectionIndex].bookmarks.count != originalCount { + didChange = true + } + } + + if didChange { + ImagePipeline.shared.cache.removeAll() + saveCollections() + } + } + private func migrateOldBookmarks() { guard let data = UserDefaults.standard.data(forKey: oldBookmarksKey) else { return @@ -71,16 +98,13 @@ class LibraryManager: ObservableObject { do { 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) } diff --git a/Sora/Views/LibraryView/LibraryView.swift b/Sora/Views/LibraryView/LibraryView.swift index d5a84b1..7b370df 100644 --- a/Sora/Views/LibraryView/LibraryView.swift +++ b/Sora/Views/LibraryView/LibraryView.swift @@ -12,19 +12,34 @@ import SwiftUI struct LibraryView: View { @EnvironmentObject private var libraryManager: LibraryManager @EnvironmentObject private var moduleManager: ModuleManager - @EnvironmentObject var tabBarController: TabBarController + @Environment(\.scenePhase) private var scenePhase @AppStorage("mediaColumnsPortrait") private var mediaColumnsPortrait: Int = 2 @AppStorage("mediaColumnsLandscape") private var mediaColumnsLandscape: Int = 4 + @AppStorage("librarySectionsOrderData") private var librarySectionsOrderData: Data = { + try! JSONEncoder().encode(["continueWatching", "continueReading", "collections"]) + }() + @AppStorage("disabledLibrarySectionsData") private var disabledLibrarySectionsData: Data = { + try! JSONEncoder().encode([String]()) + }() @Environment(\.verticalSizeClass) var verticalSizeClass @Environment(\.horizontalSizeClass) var horizontalSizeClass @State private var continueWatchingItems: [ContinueWatchingItem] = [] + @State private var continueReadingItems: [ContinueReadingItem] = [] @State private var isLandscape: Bool = UIDevice.current.orientation.isLandscape @State private var selectedTab: Int = 0 + private var librarySectionsOrder: [String] { + (try? JSONDecoder().decode([String].self, from: librarySectionsOrderData)) ?? ["continueWatching", "continueReading", "collections"] + } + + private var disabledLibrarySections: [String] { + (try? JSONDecoder().decode([String].self, from: disabledLibrarySectionsData)) ?? [] + } + private let columns = [ GridItem(.adaptive(minimum: 150), spacing: 12) ] @@ -56,81 +71,26 @@ struct LibraryView: View { ZStack { ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 20) { - Text("Library") + Text(LocalizedStringKey("Library")) .font(.largeTitle) .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() + + ForEach(librarySectionsOrder, id: \.self) { section in + if !disabledLibrarySections.contains(section) { + switch section { + case "continueWatching": + continueWatchingSection + case "continueReading": + continueReadingSection + case "collections": + collectionsSection + default: + EmptyView() + } } } - .padding(.horizontal, 20) - - if continueWatchingItems.isEmpty { - VStack(spacing: 8) { - Image(systemName: "play.circle") - .font(.largeTitle) - .foregroundColor(.secondary) - Text("Nothing to Continue Watching") - .font(.headline) - Text("Your 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: "folder.fill") - .font(.subheadline) - Text("Collections") - .font(.title3) - .fontWeight(.semibold) - } - - Spacer() - - NavigationLink(destination: BookmarksDetailView()) { - Text("View All") - .font(.subheadline) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(Color.gray.opacity(0.2)) - .cornerRadius(15) - .gradientOutline() - } - } - .padding(.horizontal, 20) - - BookmarksSection() Spacer().frame(height: 100) } @@ -140,16 +100,160 @@ struct LibraryView: View { .deviceScaled() .onAppear { fetchContinueWatching() - tabBarController.showTabBar() + fetchContinueReading() + + NotificationCenter.default.post(name: .showTabBar, object: nil) } .onChange(of: scenePhase) { newPhase in if newPhase == .active { fetchContinueWatching() + fetchContinueReading() } } } } .navigationViewStyle(StackNavigationViewStyle()) + + } + + // MARK: - Section Views + + private var continueWatchingSection: some View { + VStack(alignment: .leading, spacing: 0) { + HStack { + HStack(spacing: 4) { + Image(systemName: "play.fill") + .font(.subheadline) + Text(LocalizedStringKey("Continue Watching")) + .font(.title3) + .fontWeight(.semibold) + } + + Spacer() + + NavigationLink(destination: AllWatchingView()) { + Text(LocalizedStringKey("View All")) + .font(.subheadline) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.gray.opacity(0.2)) + .cornerRadius(15) + .gradientOutline() + } + } + .padding(.horizontal, 20) + .padding(.bottom, 10) + + if continueWatchingItems.isEmpty { + VStack(spacing: 8) { + Image(systemName: "play.circle") + .font(.largeTitle) + .foregroundColor(.secondary) + Text(LocalizedStringKey("Nothing to Continue Watching")) + .font(.headline) + Text(LocalizedStringKey("Your 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) + }) + } + + Spacer().frame(height: 20) + } + } + + private var continueReadingSection: some View { + VStack(alignment: .leading, spacing: 0) { + HStack { + HStack(spacing: 4) { + Image(systemName: "book.fill") + .font(.subheadline) + Text(LocalizedStringKey("Continue Reading")) + .font(.title3) + .fontWeight(.semibold) + } + + Spacer() + + NavigationLink(destination: AllReadingView()) { + Text(LocalizedStringKey("View All")) + .font(.subheadline) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.gray.opacity(0.2)) + .cornerRadius(15) + .gradientOutline() + } + } + .padding(.horizontal, 20) + .padding(.bottom, 10) + + if continueReadingItems.isEmpty { + VStack(spacing: 8) { + Image(systemName: "book.closed") + .font(.largeTitle) + .foregroundColor(.secondary) + Text(LocalizedStringKey("Nothing to Continue Reading")) + .font(.headline) + Text(LocalizedStringKey("Your recently read novels will appear here")) + .font(.caption) + .foregroundColor(.secondary) + } + .padding() + .frame(maxWidth: .infinity) + } else { + ContinueReadingSection(items: $continueReadingItems, markAsRead: { + item in + markContinueReadingItemAsRead(item: item) + }, removeItem: { + item in + removeContinueReadingItem(item: item) + }) + } + + Spacer().frame(height: 20) + } + } + + private var collectionsSection: some View { + VStack(alignment: .leading, spacing: 0) { + HStack { + HStack(spacing: 4) { + Image(systemName: "folder.fill") + .font(.subheadline) + Text(LocalizedStringKey("Collections")) + .font(.title3) + .fontWeight(.semibold) + } + + Spacer() + + NavigationLink(destination: BookmarksDetailView()) { + Text(LocalizedStringKey("View All")) + .font(.subheadline) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.gray.opacity(0.2)) + .cornerRadius(15) + .gradientOutline() + } + } + .padding(.horizontal, 20) + .padding(.bottom, 10) + + BookmarksSection() + + Spacer().frame(height: 20) + } } private func fetchContinueWatching() { @@ -174,6 +278,30 @@ struct LibraryView: View { } } + private func fetchContinueReading() { + continueReadingItems = ContinueReadingManager.shared.fetchItems() + Logger.shared.log("Fetched \(continueReadingItems.count) continue reading items", type: "Debug") + + if !continueReadingItems.isEmpty { + for (index, item) in continueReadingItems.enumerated() { + Logger.shared.log("Reading item \(index): \(item.mediaTitle), chapter \(item.chapterNumber), progress \(item.progress)", type: "Debug") + } + } + } + + private func markContinueReadingItemAsRead(item: ContinueReadingItem) { + UserDefaults.standard.set(1.0, forKey: "readingProgress_\(item.href)") + ContinueReadingManager.shared.updateProgress(for: item.href, progress: 1.0) + fetchContinueReading() + } + + private func removeContinueReadingItem(item: ContinueReadingItem) { + ContinueReadingManager.shared.remove(item: item) + continueReadingItems.removeAll { + $0.id == item.id + } + } + private func updateOrientation() { DispatchQueue.main.async { isLandscape = UIDevice.current.orientation.isLandscape diff --git a/Sora/Views/MediaInfoView/ChapterCell/ChapterCell.swift b/Sora/Views/MediaInfoView/ChapterCell/ChapterCell.swift index ccfa006..ec2dc9f 100644 --- a/Sora/Views/MediaInfoView/ChapterCell/ChapterCell.swift +++ b/Sora/Views/MediaInfoView/ChapterCell/ChapterCell.swift @@ -11,6 +11,28 @@ struct ChapterCell: View { let chapterNumber: String let chapterTitle: String let isCurrentChapter: Bool + var progress: Double = 0.0 + var href: String = "" + + private var progressText: String { + if progress >= 0.98 { + return "Completed" + } else if progress > 0 { + return "\(Int(progress * 100))%" + } else { + return "New" + } + } + + private var progressColor: Color { + if progress >= 0.98 { + return .green + } else if progress > 0 { + return .blue + } else { + return .secondary + } + } var body: some View { HStack(alignment: .center, spacing: 12) { @@ -21,15 +43,15 @@ struct ChapterCell: View { .foregroundColor(.primary) .lineLimit(1) - if isCurrentChapter { - Text("Current") + if progress > 0 { + Text(progressText) .font(.system(size: 12, weight: .medium)) - .foregroundColor(.blue) + .foregroundColor(progressColor) .padding(.horizontal, 8) .padding(.vertical, 3) .background( RoundedRectangle(cornerRadius: 6) - .fill(Color.blue.opacity(0.18)) + .fill(progressColor.opacity(0.18)) ) } Spacer(minLength: 0) @@ -38,6 +60,13 @@ struct ChapterCell: View { .font(.system(size: 15)) .foregroundColor(.secondary) .lineLimit(2) + + if progress > 0 && progress < 0.98 { + ProgressView(value: progress) + .progressViewStyle(LinearProgressViewStyle()) + .frame(height: 3) + .padding(.top, 4) + } } Spacer() } @@ -70,9 +99,27 @@ struct ChapterCell: View { } #Preview { - ChapterCell( - chapterNumber: "1", - chapterTitle: "Chapter 1: The Beginning", - isCurrentChapter: true - ) + VStack(spacing: 16) { + ChapterCell( + chapterNumber: "1", + chapterTitle: "Chapter 1: The Beginning", + isCurrentChapter: false, + progress: 0.0 + ) + + ChapterCell( + chapterNumber: "2", + chapterTitle: "Chapter 2: The Journey", + isCurrentChapter: false, + progress: 0.45 + ) + + ChapterCell( + chapterNumber: "3", + chapterTitle: "Chapter 3: The Conclusion", + isCurrentChapter: false, + progress: 1.0 + ) + } + .padding() } \ No newline at end of file diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index 911b3c7..50be738 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -82,7 +82,7 @@ struct MediaInfoView: View { @ObservedObject private var jsController = JSController.shared @EnvironmentObject var moduleManager: ModuleManager @EnvironmentObject private var libraryManager: LibraryManager - @EnvironmentObject var tabBarController: TabBarController + @ObservedObject private var navigator = ChapterNavigator.shared @Environment(\.dismiss) private var dismiss @Environment(\.colorScheme) private var colorScheme @@ -185,6 +185,16 @@ struct MediaInfoView: View { .ignoresSafeArea(.container, edges: .top) .onAppear { setupViewOnAppear() + NotificationCenter.default.post(name: .hideTabBar, object: nil) + UserDefaults.standard.set(true, forKey: "isMediaInfoActive") + // swipe back + /* + 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 = false + } + */ } .onChange(of: selectedRange) { newValue in UserDefaults.standard.set(newValue.lowerBound, forKey: selectedRangeKey) @@ -196,9 +206,10 @@ struct MediaInfoView: View { UserDefaults.standard.set(newValue.lowerBound, forKey: selectedChapterRangeKey) } .onDisappear { - tabBarController.showTabBar() currentFetchTask?.cancel() activeFetchID = nil + UserDefaults.standard.set(false, forKey: "isMediaInfoActive") + UIScrollView.appearance().bounces = true } .task { await setupInitialData() @@ -229,14 +240,13 @@ struct MediaInfoView: View { VStack { HStack { Button(action: { - tabBarController.showTabBar() dismiss() }) { Image(systemName: "chevron.left") .font(.system(size: 24)) .foregroundColor(.primary) .padding(12) - .background(Color.gray.opacity(0.2)) + .background(Color(.systemBackground).opacity(0.8)) .clipShape(Circle()) .circularGradientOutline() } @@ -305,7 +315,7 @@ struct MediaInfoView: View { if !aliases.isEmpty && !(module.metadata.novel ?? false) { Text(aliases) - .font(.system(size: 14)) + .font(.system(size: 14, weight: .bold)) .foregroundColor(.secondary) .padding(.top, 4) } @@ -354,7 +364,7 @@ struct MediaInfoView: View { Image(systemName: "calendar") .foregroundColor(.accentColor) Text(airdate) - .font(.system(size: 14)) + .font(.system(size: 14, weight: .bold)) .foregroundColor(.accentColor) Spacer() } @@ -390,7 +400,7 @@ struct MediaInfoView: View { private var synopsisSection: some View { VStack(alignment: .leading, spacing: 2) { Text(synopsis) - .font(.system(size: 16)) + .font(.system(size: 16, weight: .bold)) .foregroundColor(.secondary) .lineLimit(showFullSynopsis ? nil : 3) .animation(nil, value: showFullSynopsis) @@ -418,7 +428,7 @@ struct MediaInfoView: View { Image(systemName: "play.fill") .foregroundColor(colorScheme == .dark ? .black : .white) Text(startActionText) - .font(.system(size: 16, weight: .medium)) + .font(.system(size: 16, weight: .bold)) .foregroundColor(colorScheme == .dark ? .black : .white) } .frame(maxWidth: .infinity) @@ -530,10 +540,69 @@ struct MediaInfoView: View { if episodeLinks.count != 1 { VStack(alignment: .leading, spacing: 16) { episodesSectionHeader + if isGroupedBySeasons || episodeLinks.count > episodeChunkSize { + HStack(spacing: 8) { + if isGroupedBySeasons { + seasonSelectorStyled + } + Spacer() + if episodeLinks.count > episodeChunkSize { + rangeSelectorStyled + .padding(.trailing, 4) + } + } + .padding(.top, -8) + } episodeListSection } } } + + @ViewBuilder + private var seasonSelectorStyled: some View { + let seasons = groupedEpisodes() + if seasons.count > 1 { + Menu { + ForEach(0.. episodeChunkSize { - rangeSelectionMenu - } else if isGroupedBySeasons { - seasonSelectionMenu - } - } - } - - @ViewBuilder - private var rangeSelectionMenu: some View { - Menu { - ForEach(generateRanges(), id: \.self) { range in - Button(action: { selectedRange = range }) { - Text("\(range.lowerBound + 1)-\(range.upperBound)") - } - } - } label: { - Text("\(selectedRange.lowerBound + 1)-\(selectedRange.upperBound)") - .font(.system(size: 14)) - .foregroundColor(.accentColor) - } - } - - @ViewBuilder - private var seasonSelectionMenu: some View { - let seasons = groupedEpisodes() - if seasons.count > 1 { - Menu { - ForEach(0.. chapterChunkSize { - Menu { - ForEach(generateChapterRanges(), id: \..self) { range in - Button(action: { selectedChapterRange = range }) { - Text("\(range.lowerBound + 1)-\(range.upperBound)") - } - } - } label: { - Text("\(selectedChapterRange.lowerBound + 1)-\(selectedChapterRange.upperBound)") - .font(.system(size: 14)) - .foregroundColor(.accentColor) - } - } HStack(spacing: 4) { sourceButton menuButton } } - LazyVStack(spacing: 15) { - ForEach(chapters.indices.filter { selectedChapterRange.contains($0) }, id: \..self) { i in - let chapter = chapters[i] + if chapters.count > chapterChunkSize { + HStack { + Spacer() + chapterRangeSelectorStyled + } + .padding(.bottom, 0) + } + LazyVStack(spacing: 15) { + ForEach(chapters.indices.filter { selectedChapterRange.contains($0) }, id: \..self) { i in + let chapter = chapters[i] + let _ = refreshTrigger if let href = chapter["href"] as? String, let number = chapter["number"] as? Int, let title = chapter["title"] as? String { @@ -722,21 +737,83 @@ struct MediaInfoView: View { destination: ReaderView( moduleId: module.id.uuidString, chapterHref: href, - chapterTitle: title + chapterTitle: title, + chapters: chapters, + mediaTitle: self.title, + chapterNumber: number ) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + ChapterNavigator.shared.currentChapter = nil + } + } ) { ChapterCell( chapterNumber: String(number), chapterTitle: title, - isCurrentChapter: UserDefaults.standard.string(forKey: "lastReadChapter") == href + isCurrentChapter: false, + progress: UserDefaults.standard.double(forKey: "readingProgress_\(href)"), + href: href ) } + .simultaneousGesture(TapGesture().onEnded { + UserDefaults.standard.set(true, forKey: "navigatingToReaderView") + ChapterNavigator.shared.currentChapter = ( + moduleId: module.id.uuidString, + href: href, + title: title, + chapters: chapters, + mediaTitle: self.title, + chapterNumber: number + ) + }) + .contextMenu { + Button(action: { + markChapterAsRead(href: href, number: number) + }) { + Label("Mark as Read", systemImage: "checkmark.circle") + } + + Button(action: { + resetChapterProgress(href: href) + }) { + Label("Reset Progress", systemImage: "arrow.counterclockwise") + } + + Button(action: { + markAllPreviousChaptersAsRead(currentNumber: number) + }) { + Label("Mark Previous as Read", systemImage: "checkmark.circle.badge.plus") + } + } .buttonStyle(PlainButtonStyle()) } } } } } + + @ViewBuilder + private var chapterRangeSelectorStyled: some View { + Menu { + ForEach(generateChapterRanges(), id: \..self) { range in + Button(action: { selectedChapterRange = range }) { + Text("\(range.lowerBound + 1)-\(range.upperBound)") + } + } + } label: { + HStack(spacing: 4) { + Text("\(selectedChapterRange.lowerBound + 1)-\(selectedChapterRange.upperBound)") + .font(.system(size: 15, weight: .bold)) + .foregroundColor(.accentColor) + Image(systemName: "chevron.down") + .font(.system(size: 13, weight: .bold)) + .foregroundColor(.accentColor) + } + .padding(.vertical, 2) + .padding(.horizontal, 4) + } + } @ViewBuilder private var noContentSection: some View { @@ -859,7 +936,6 @@ struct MediaInfoView: View { private func setupViewOnAppear() { buttonRefreshTrigger.toggle() - tabBarController.hideTabBar() if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let window = windowScene.windows.first, @@ -872,14 +948,22 @@ struct MediaInfoView: View { private func setupInitialData() async { do { Logger.shared.log("setupInitialData: module.metadata.novel = \(String(describing: module.metadata.novel))", type: "Debug") + + UserDefaults.standard.set(imageUrl, forKey: "mediaInfoImageUrl_\(module.id.uuidString)") + Logger.shared.log("Saved MediaInfoView image URL: \(imageUrl) for module \(module.id.uuidString)", type: "Debug") + + + if module.metadata.novel == true { - DispatchQueue.main.async { - DropManager.shared.showDrop( - title: "Fetching Data", - subtitle: "Please wait while fetching.", - duration: 0.5, - icon: UIImage(systemName: "arrow.triangle.2.circlepath") - ) + if !hasFetched { + DispatchQueue.main.async { + DropManager.shared.showDrop( + title: "Fetching Data", + subtitle: "Please wait while fetching.", + duration: 0.5, + icon: UIImage(systemName: "arrow.triangle.2.circlepath") + ) + } } let jsContent = try? moduleManager.getModuleContent(module) if let jsContent = jsContent { @@ -949,12 +1033,14 @@ struct MediaInfoView: View { if let savedPoster = UserDefaults.standard.string(forKey: "tmdbPosterURL_\(href)") { imageUrl = savedPoster } - DropManager.shared.showDrop( - title: "Fetching Data", - subtitle: "Please wait while fetching.", - duration: 0.5, - icon: UIImage(systemName: "arrow.triangle.2.circlepath") - ) + if !hasFetched { + DropManager.shared.showDrop( + title: "Fetching Data", + subtitle: "Please wait while fetching.", + duration: 0.5, + icon: UIImage(systemName: "arrow.triangle.2.circlepath") + ) + } fetchDetails() if savedCustomID != 0 { itemID = savedCustomID @@ -969,10 +1055,10 @@ struct MediaInfoView: View { additionalData: ["title": title] ) } - } catch { + } catch let loadError { isError = true isLoading = false - Logger.shared.log("Error loading media info: \(error)", type: "Error") + Logger.shared.log("Error loading media info: \(loadError)", type: "Error") } } @@ -2199,4 +2285,74 @@ struct MediaInfoView: View { } return ranges } + + private func markChapterAsRead(href: String, number: Int) { + UserDefaults.standard.set(1.0, forKey: "readingProgress_\(href)") + + UserDefaults.standard.set(1.0, forKey: "scrollPosition_\(href)") + + ContinueReadingManager.shared.updateProgress(for: href, progress: 1.0) + + DropManager.shared.showDrop( + title: "Chapter \(number) Marked as Read", + subtitle: "", + duration: 1.0, + icon: UIImage(systemName: "checkmark.circle.fill") + ) + refreshTrigger.toggle() + } + + private func resetChapterProgress(href: String) { + UserDefaults.standard.set(0.0, forKey: "readingProgress_\(href)") + + UserDefaults.standard.removeObject(forKey: "scrollPosition_\(href)") + + ContinueReadingManager.shared.updateProgress(for: href, progress: 0.0) + + DropManager.shared.showDrop( + title: "Progress Reset", + subtitle: "", + duration: 1.0, + icon: UIImage(systemName: "arrow.counterclockwise") + ) + refreshTrigger.toggle() + } + + private func markAllPreviousChaptersAsRead(currentNumber: Int) { + let userDefaults = UserDefaults.standard + var markedCount = 0 + + for chapter in chapters { + if let number = chapter["number"] as? Int, + let href = chapter["href"] as? String { + if number < currentNumber { + userDefaults.set(1.0, forKey: "readingProgress_\(href)") + + userDefaults.set(1.0, forKey: "scrollPosition_\(href)") + + ContinueReadingManager.shared.updateProgress(for: href, progress: 1.0) + markedCount += 1 + } + } + } + + userDefaults.synchronize() + + DropManager.shared.showDrop( + title: "Marked \(markedCount) Chapters as Read", + subtitle: "", + duration: 1.0, + icon: UIImage(systemName: "checkmark.circle.fill") + ) + + refreshTrigger.toggle() + } + + private func simultaneousGesture(for item: NavigationLink) -> some View { + item.simultaneousGesture(TapGesture().onEnded { + UserDefaults.standard.set(true, forKey: "navigatingToReaderView") + }) + } } + + diff --git a/Sora/Views/ReaderView/ReaderView.swift b/Sora/Views/ReaderView/ReaderView.swift index 03d1e75..cc7397a 100644 --- a/Sora/Views/ReaderView/ReaderView.swift +++ b/Sora/Views/ReaderView/ReaderView.swift @@ -8,6 +8,11 @@ import SwiftUI import WebKit +class ChapterNavigator: ObservableObject { + static let shared = ChapterNavigator() + @Published var currentChapter: (moduleId: String, href: String, title: String, chapters: [[String: Any]], mediaTitle: String, chapterNumber: Int)? = nil +} + extension UserDefaults { func cgFloat(forKey defaultName: String) -> CGFloat? { if let value = object(forKey: defaultName) as? NSNumber { @@ -25,6 +30,9 @@ struct ReaderView: View { let moduleId: String let chapterHref: String let chapterTitle: String + let chapters: [[String: Any]] + let mediaTitle: String + let chapterNumber: Int @State private var htmlContent: String = "" @State private var isLoading: Bool = true @@ -41,8 +49,11 @@ struct ReaderView: View { @State private var textAlignment: String = "left" @State private var lineSpacing: CGFloat = 1.6 @State private var margin: CGFloat = 4 + @State private var readingProgress: Double = 0.0 + @State private var lastProgressUpdate: Date = Date() @Environment(\.dismiss) private var dismiss - @EnvironmentObject var tabBarController: TabBarController + + @StateObject private var navigator = ChapterNavigator.shared private let fontOptions = [ ("-apple-system", "System"), @@ -82,10 +93,13 @@ struct ReaderView: View { ) } - init(moduleId: String, chapterHref: String, chapterTitle: String) { + init(moduleId: String, chapterHref: String, chapterTitle: String, chapters: [[String: Any]] = [], mediaTitle: String = "Unknown Novel", chapterNumber: Int = 1) { self.moduleId = moduleId self.chapterHref = chapterHref self.chapterTitle = chapterTitle + self.chapters = chapters + self.mediaTitle = mediaTitle + self.chapterNumber = chapterNumber _fontSize = State(initialValue: UserDefaults.standard.cgFloat(forKey: "readerFontSize") ?? 16) _selectedFont = State(initialValue: UserDefaults.standard.string(forKey: "readerFontFamily") ?? "-apple-system") @@ -96,6 +110,20 @@ struct ReaderView: View { _margin = State(initialValue: UserDefaults.standard.cgFloat(forKey: "readerMargin") ?? 4) } + private func ensureModuleLoaded() { + if let module = ModuleManager().modules.first(where: { $0.id.uuidString == moduleId }) { + do { + let moduleContent = try ModuleManager().getModuleContent(module) + JSController.shared.loadScript(moduleContent) + Logger.shared.log("Loaded script for module \(moduleId)", type: "Debug") + } catch { + Logger.shared.log("Failed to load module script: \(error)", type: "Error") + } + } + } + + + var body: some View { ZStack(alignment: .bottom) { currentTheme.background.ignoresSafeArea() @@ -129,18 +157,28 @@ struct ReaderView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) - HTMLView( - htmlContent: htmlContent, - fontSize: fontSize, - fontFamily: selectedFont, - fontWeight: fontWeight, - textAlignment: textAlignment, - lineSpacing: lineSpacing, - margin: margin, - isAutoScrolling: $isAutoScrolling, - autoScrollSpeed: autoScrollSpeed, - colorPreset: colorPresets[selectedColorPreset] - ) + HTMLView( + htmlContent: htmlContent, + fontSize: fontSize, + fontFamily: selectedFont, + fontWeight: fontWeight, + textAlignment: textAlignment, + lineSpacing: lineSpacing, + margin: margin, + isAutoScrolling: $isAutoScrolling, + autoScrollSpeed: autoScrollSpeed, + colorPreset: colorPresets[selectedColorPreset], + chapterHref: chapterHref, + onProgressChanged: { progress in + self.readingProgress = progress + + if Date().timeIntervalSince(self.lastProgressUpdate) > 2.0 { + self.updateReadingProgress(progress: progress) + self.lastProgressUpdate = Date() + Logger.shared.log("Progress updated to \(progress)", type: "Debug") + } + } + ) .frame(maxWidth: .infinity, maxHeight: .infinity) .padding(.horizontal) .simultaneousGesture(TapGesture().onEnded { @@ -152,7 +190,7 @@ struct ReaderView: View { } }) } - .padding(.top, isHeaderVisible ? 0 : (UIApplication.shared.windows.first?.safeAreaInsets.top ?? 0)) + .padding(.top, isHeaderVisible ? 0 : (UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first?.windows.first?.safeAreaInsets.top ?? 0)) } headerView @@ -172,21 +210,161 @@ struct ReaderView: View { .navigationBarBackButtonHidden(true) .ignoresSafeArea() .onAppear { - tabBarController.hideTabBar() + UserDefaults.standard.set(false, forKey: "navigatingToReaderView") UserDefaults.standard.set(chapterHref, forKey: "lastReadChapter") + saveReadingProgress() + 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 = false + } + + NotificationCenter.default.post(name: .hideTabBar, object: nil) + UserDefaults.standard.set(true, forKey: "isReaderActive") } + .onDisappear { + 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 + } + + if navigator.currentChapter != nil && navigator.currentChapter?.href != chapterHref { + UserDefaults.standard.set(true, forKey: "navigatingToReaderView") + } + + if let next = navigator.currentChapter, + next.href != chapterHref { + UserDefaults.standard.set(true, forKey: "navigatingToReaderView") + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first, + let rootVC = window.rootViewController { + let nextReader = ReaderView( + moduleId: next.moduleId, + chapterHref: next.href, + chapterTitle: next.title, + chapters: next.chapters, + mediaTitle: next.mediaTitle, + chapterNumber: next.chapterNumber + ) + + + let hostingController = UIHostingController(rootView: nextReader) + hostingController.modalPresentationStyle = .fullScreen + hostingController.modalTransitionStyle = .crossDissolve + + findTopViewController.findViewController(rootVC).present(hostingController, animated: true) + } + } + } else { + if !htmlContent.isEmpty { + let validHtmlContent = (!htmlContent.isEmpty && + !htmlContent.contains("undefined") && + htmlContent.count > 50) ? htmlContent : nil + + if validHtmlContent == nil { + Logger.shared.log("Not caching HTML content on disappear as it appears invalid", type: "Warning") + } else { + let item = ContinueReadingItem( + mediaTitle: mediaTitle, + chapterTitle: chapterTitle, + chapterNumber: chapterNumber, + imageUrl: UserDefaults.standard.string(forKey: "novelImageUrl_\(moduleId)_\(mediaTitle)") ?? "", + href: chapterHref, + moduleId: moduleId, + progress: readingProgress, + totalChapters: chapters.count, + lastReadDate: Date(), + cachedHtml: validHtmlContent + ) + ContinueReadingManager.shared.save(item: item, htmlContent: validHtmlContent) + Logger.shared.log("Saved HTML content on view disappear for \(chapterHref)", type: "Debug") + } + } + } + UserDefaults.standard.set(false, forKey: "isReaderActive") + } + .task { do { - let content = try await JSController.shared.extractText(moduleId: moduleId, href: chapterHref) - if !content.isEmpty { - htmlContent = content + ensureModuleLoaded() + let isOffline = !(NetworkMonitor.shared.isConnected) + if let cachedContent = ContinueReadingManager.shared.getCachedHtml(for: chapterHref), + !cachedContent.isEmpty && + !cachedContent.contains("undefined") && + cachedContent.count > 50 { + Logger.shared.log("Using cached HTML content for \(chapterHref)", type: "Debug") + htmlContent = cachedContent isLoading = false + } else if isOffline { + let offlineError = NSError(domain: "Sora", code: -1009, userInfo: [NSLocalizedDescriptionKey: "No network connection."]) + self.error = offlineError + isLoading = false + return } else { - throw JSError.invalidResponse + Logger.shared.log("Fetching HTML content from network for \(chapterHref)", type: "Debug") + + var content = "" + var attempts = 0 + var lastError: Error? = nil + + while attempts < 3 && (content.isEmpty || content.contains("undefined") || content.count < 50) { + do { + attempts += 1 + content = try await JSController.shared.extractText(moduleId: moduleId, href: chapterHref) + + if content.isEmpty || content.contains("undefined") || content.count < 50 { + Logger.shared.log("Received invalid content on attempt \(attempts), retrying...", type: "Warning") + try await Task.sleep(nanoseconds: 500_000_000) + } + } catch { + lastError = error + Logger.shared.log("Error fetching content on attempt \(attempts): \(error.localizedDescription)", type: "Error") + try await Task.sleep(nanoseconds: 500_000_000) + } + } + + if !content.isEmpty && !content.contains("undefined") && content.count >= 50 { + htmlContent = content + isLoading = false + + if let cachedContent = ContinueReadingManager.shared.getCachedHtml(for: chapterHref), + cachedContent.isEmpty || cachedContent.contains("undefined") || cachedContent.count < 50 { + let item = ContinueReadingItem( + mediaTitle: mediaTitle, + chapterTitle: chapterTitle, + chapterNumber: chapterNumber, + imageUrl: UserDefaults.standard.string(forKey: "novelImageUrl_\(moduleId)_\(mediaTitle)") ?? "", + href: chapterHref, + moduleId: moduleId, + progress: readingProgress, + totalChapters: chapters.count, + lastReadDate: Date(), + cachedHtml: content + ) + ContinueReadingManager.shared.save(item: item, htmlContent: content) + } + } else if let lastError = lastError { + throw lastError + } else { + throw JSError.emptyContent + } } } catch { self.error = error isLoading = false + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + DropManager.shared.showDrop( + title: "Error Loading Content", + subtitle: error.localizedDescription, + duration: 2.0, + icon: UIImage(systemName: "exclamationmark.triangle") + ) + } } } } @@ -200,7 +378,6 @@ struct ReaderView: View { private var headerView: some View { VStack { ZStack(alignment: .top) { - // Base header content HStack { Button(action: { dismiss() @@ -240,205 +417,221 @@ struct ReaderView: View { HStack { Spacer() - ZStack(alignment: .topTrailing) { - Button(action: { - withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { - isSettingsExpanded.toggle() - } - }) { - Image(systemName: "ellipsis") - .font(.system(size: 18, weight: .bold)) - .foregroundColor(currentTheme.text) - .padding(12) - .background(currentTheme.background.opacity(0.8)) - .clipShape(Circle()) - .circularGradientOutline() - .rotationEffect(.degrees(isSettingsExpanded ? 90 : 0)) - } - .opacity(isHeaderVisible ? 1 : 0) - .offset(y: isHeaderVisible ? 0 : -100) - .animation(.easeInOut(duration: 0.6), value: isHeaderVisible) - - if isSettingsExpanded { - VStack(spacing: 8) { - Menu { - VStack { - Text("Font Size: \(Int(fontSize))pt") - .font(.headline) - .padding(.bottom, 8) - - Slider(value: Binding( - get: { fontSize }, - set: { newValue in - fontSize = newValue - UserDefaults.standard.set(newValue, forKey: "readerFontSize") - } - ), in: 12...32, step: 1) { - Text("Font Size") - } - .padding(.horizontal) - } - .padding() - } label: { - settingsButtonLabel(icon: "textformat.size") - } - - Menu { - ForEach(fontOptions, id: \.0) { font in - Button(action: { - selectedFont = font.0 - UserDefaults.standard.set(font.0, forKey: "readerFontFamily") - }) { - HStack { - Text(font.1) - .font(.custom(font.0, size: 16)) - Spacer() - if selectedFont == font.0 { - Image(systemName: "checkmark") - .foregroundColor(.blue) - } - } - } - } - } label: { - settingsButtonLabel(icon: "textformat.characters") - } - - Menu { - ForEach(weightOptions, id: \.0) { weight in - Button(action: { - fontWeight = weight.0 - UserDefaults.standard.set(weight.0, forKey: "readerFontWeight") - }) { - HStack { - Text(weight.1) - .fontWeight(weight.0 == "300" ? .light : - weight.0 == "normal" ? .regular : - weight.0 == "600" ? .semibold : .bold) - Spacer() - if fontWeight == weight.0 { - Image(systemName: "checkmark") - .foregroundColor(.blue) - } - } - } - } - } label: { - settingsButtonLabel(icon: "bold") - } - - Menu { - ForEach(0.. 50) ? htmlContent : nil + + if validHtmlContent == nil && !htmlContent.isEmpty { + Logger.shared.log("Not caching HTML content as it appears invalid", type: "Warning") + } + + let item = ContinueReadingItem( + mediaTitle: novelTitle, + chapterTitle: chapterTitle, + chapterNumber: currentChapterNumber, + imageUrl: imageUrl, + href: chapterHref, + moduleId: moduleId, + progress: progress, + totalChapters: chapters.count, + lastReadDate: Date(), + cachedHtml: validHtmlContent + ) + + ContinueReadingManager.shared.save(item: item, htmlContent: validHtmlContent) + } + + private func updateReadingProgress(progress: Double) { + let roundedProgress = progress >= 0.95 ? 1.0 : progress + + UserDefaults.standard.set(roundedProgress, forKey: "readingProgress_\(chapterHref)") + + var novelTitle = self.mediaTitle + var currentChapterNumber = 1 + var imageUrl = "" + + if let savedImageUrl = UserDefaults.standard.string(forKey: "mediaInfoImageUrl_\(moduleId)") { + imageUrl = savedImageUrl + } else if let savedImageUrl = UserDefaults.standard.string(forKey: "novelImageUrl_\(moduleId)_\(novelTitle)") { + imageUrl = savedImageUrl + } + + if imageUrl.isEmpty { + for chapter in chapters { + for key in ["imageUrl", "coverUrl", "cover", "image", "thumbnail", "posterUrl", "poster"] { + if let url = chapter[key] as? String, !url.isEmpty { + imageUrl = url + break + } + } + + if !imageUrl.isEmpty { + break + } + } + } + + if imageUrl.isEmpty, let currentChapter = chapters.first(where: { $0["href"] as? String == chapterHref }) { + for key in ["imageUrl", "coverUrl", "cover", "image", "thumbnail", "posterUrl", "poster"] { + if let url = currentChapter[key] as? String, !url.isEmpty { + imageUrl = url + break + } + } + } + + if imageUrl.isEmpty { + imageUrl = "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/main/assets/novel_cover.jpg" + } + + if let currentIndex = chapters.firstIndex(where: { $0["href"] as? String == chapterHref }) { + currentChapterNumber = chapters[currentIndex]["number"] as? Int ?? currentIndex + 1 + } + + Logger.shared.log("Updating reading progress: \(roundedProgress) for \(chapterHref), title: \(novelTitle), image: \(imageUrl)", type: "Debug") + + let validHtmlContent = (!htmlContent.isEmpty && + !htmlContent.contains("undefined") && + htmlContent.count > 50) ? htmlContent : nil + + if validHtmlContent == nil && !htmlContent.isEmpty { + Logger.shared.log("Not caching HTML content as it appears invalid", type: "Warning") + } + + let isCompleted = roundedProgress >= 0.98 + + if isCompleted && readingProgress < 0.98 { + DropManager.shared.showDrop( + title: NSLocalizedString("Chapter Completed", comment: ""), + subtitle: "", + duration: 0.5, + icon: UIImage(systemName: "checkmark.circle") + ) + Logger.shared.log("Chapter marked as completed", type: "Debug") + + ContinueReadingManager.shared.updateProgress(for: chapterHref, progress: roundedProgress, htmlContent: validHtmlContent) + } else { + let item = ContinueReadingItem( + mediaTitle: novelTitle, + chapterTitle: chapterTitle, + chapterNumber: currentChapterNumber, + imageUrl: imageUrl, + href: chapterHref, + moduleId: moduleId, + progress: roundedProgress, + totalChapters: chapters.count, + lastReadDate: Date(), + cachedHtml: validHtmlContent + ) + + ContinueReadingManager.shared.save(item: item, htmlContent: validHtmlContent) + } + } } struct ColorPreviewCircle: View { @@ -543,12 +990,19 @@ struct HTMLView: UIViewRepresentable { @Binding var isAutoScrolling: Bool let autoScrollSpeed: Double let colorPreset: (name: String, background: String, text: String) + let chapterHref: String? + + var onProgressChanged: ((Double) -> Void)? = nil func makeCoordinator() -> Coordinator { Coordinator(self) } - class Coordinator: NSObject { + static func dismantleUIView(_ uiView: WKWebView, coordinator: Coordinator) { + coordinator.stopProgressTracking() + } + + class Coordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler { var parent: HTMLView var scrollTimer: Timer? var lastHtmlContent: String = "" @@ -559,16 +1013,43 @@ struct HTMLView: UIViewRepresentable { var lastLineSpacing: CGFloat = 0 var lastMargin: CGFloat = 0 var lastColorPreset: String = "" + var progressUpdateTimer: Timer? + weak var webView: WKWebView? + var savedScrollPosition: Double? init(_ parent: HTMLView) { self.parent = parent } + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + if let href = parent.chapterHref { + let savedPosition = UserDefaults.standard.double(forKey: "scrollPosition_\(href)") + if savedPosition > 0.01 { + let script = "window.scrollTo(0, document.documentElement.scrollHeight * \(savedPosition));" + webView.evaluateJavaScript(script, completionHandler: { _, error in + if let error = error { + Logger.shared.log("Error restoring scroll position after navigation: \(error)", type: "Error") + } else { + Logger.shared.log("Restored scroll position to \(savedPosition) after navigation", type: "Debug") + } + }) + } + } + + startProgressTracking(webView: webView) + } + + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + if message.name == "scrollHandler", let webView = self.webView { + updateReadingProgress(webView: webView) + } + } + func startAutoScroll(webView: WKWebView) { stopAutoScroll() - scrollTimer = Timer.scheduledTimer(withTimeInterval: 0.016, repeats: true) { _ in // 60fps for smoother scrolling - let scrollAmount = self.parent.autoScrollSpeed * 0.5 // Reduced increment for smoother scrolling + scrollTimer = Timer.scheduledTimer(withTimeInterval: 0.016, repeats: true) { _ in + let scrollAmount = self.parent.autoScrollSpeed * 0.5 webView.evaluateJavaScript("window.scrollBy(0, \(scrollAmount));") { _, error in if let error = error { @@ -590,6 +1071,91 @@ struct HTMLView: UIViewRepresentable { scrollTimer?.invalidate() scrollTimer = nil } + + func startProgressTracking(webView: WKWebView) { + stopProgressTracking() + + updateReadingProgress(webView: webView) + + progressUpdateTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self, weak webView] _ in + guard let strongSelf = self, let webView = webView, webView.window != nil else { + self?.stopProgressTracking() + return + } + strongSelf.updateReadingProgress(webView: webView) + } + + let script = """ + document.addEventListener('scroll', function() { + window.webkit.messageHandlers.scrollHandler.postMessage('scroll'); + }, { passive: true }); + """ + + let userScript = WKUserScript(source: script, injectionTime: .atDocumentEnd, forMainFrameOnly: true) + webView.configuration.userContentController.addUserScript(userScript) + + webView.configuration.userContentController.add(self, name: "scrollHandler") + } + + func stopProgressTracking() { + progressUpdateTimer?.invalidate() + progressUpdateTimer = nil + + if let webView = self.webView { + webView.configuration.userContentController.removeAllUserScripts() + webView.configuration.userContentController.removeScriptMessageHandler(forName: "scrollHandler") + } + } + + func updateReadingProgress(webView: WKWebView) { + guard webView.window != nil else { + stopProgressTracking() + return + } + + let script = """ + (function() { + var scrollHeight = document.documentElement.scrollHeight; + var scrollTop = window.pageYOffset || document.documentElement.scrollTop; + var clientHeight = document.documentElement.clientHeight; + + var rawProgress = scrollHeight > 0 ? (scrollTop + clientHeight) / scrollHeight : 0; + + var progress = rawProgress > 0.95 ? 1.0 : rawProgress; + + return { + scrollHeight: scrollHeight, + scrollTop: scrollTop, + clientHeight: clientHeight, + progress: progress, + isAtBottom: (scrollTop + clientHeight >= scrollHeight - 10), + scrollPosition: scrollTop / scrollHeight + }; + })(); + """ + + webView.evaluateJavaScript(script) { [weak self] result, error in + guard let self = self, let dict = result as? [String: Any], + let progress = dict["progress"] as? Double else { + return + } + + if let scrollPosition = dict["scrollPosition"] as? Double { + self.savedScrollPosition = scrollPosition + + if let href = self.parent.chapterHref { + UserDefaults.standard.set(scrollPosition, forKey: "scrollPosition_\(href)") + } + } + + if let isAtBottom = dict["isAtBottom"] as? Bool, isAtBottom { + Logger.shared.log("Reader at bottom of page, setting progress to 100%", type: "Debug") + self.parent.onProgressChanged?(1.0) + } else { + self.parent.onProgressChanged?(progress) + } + } + } } func makeUIView(context: Context) -> WKWebView { @@ -602,6 +1168,9 @@ struct HTMLView: UIViewRepresentable { webView.scrollView.bounces = false webView.scrollView.alwaysBounceHorizontal = false webView.scrollView.contentInsetAdjustmentBehavior = .never + webView.navigationDelegate = context.coordinator + + context.coordinator.webView = webView return webView } @@ -615,6 +1184,12 @@ struct HTMLView: UIViewRepresentable { coordinator.stopAutoScroll() } + if webView.window != nil { + coordinator.startProgressTracking(webView: webView) + } else { + coordinator.stopProgressTracking() + } + guard !htmlContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } @@ -684,6 +1259,22 @@ struct HTMLView: UIViewRepresentable { Logger.shared.log("Loading HTML content into WebView", type: "Debug") webView.loadHTMLString(htmlTemplate, baseURL: nil) + if let href = chapterHref { + let savedPosition = UserDefaults.standard.double(forKey: "scrollPosition_\(href)") + if savedPosition > 0.01 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + let script = "window.scrollTo(0, document.documentElement.scrollHeight * \(savedPosition));" + webView.evaluateJavaScript(script, completionHandler: { _, error in + if let error = error { + Logger.shared.log("Error restoring scroll position: \(error)", type: "Error") + } else { + Logger.shared.log("Restored scroll position to \(savedPosition)", type: "Debug") + } + }) + } + } + } + coordinator.lastHtmlContent = htmlContent coordinator.lastFontSize = fontSize coordinator.lastFontFamily = fontFamily diff --git a/Sora/Views/SearchView/SearchResultsGrid.swift b/Sora/Views/SearchView/SearchResultsGrid.swift index 28b02be..b7c39fb 100644 --- a/Sora/Views/SearchView/SearchResultsGrid.swift +++ b/Sora/Views/SearchView/SearchResultsGrid.swift @@ -14,6 +14,7 @@ struct SearchResultsGrid: View { @Environment(\.verticalSizeClass) var verticalSizeClass @EnvironmentObject private var libraryManager: LibraryManager @EnvironmentObject private var moduleManager: ModuleManager + @State private var showBookmarkToast: Bool = false @State private var toastMessage: String = "" @@ -35,7 +36,12 @@ struct SearchResultsGrid: View { ZStack { LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: columnsCount), spacing: 12) { ForEach(items) { item in - NavigationLink(destination: MediaInfoView(title: item.title, imageUrl: item.imageUrl, href: item.href, module: selectedModule)) { + NavigationLink(destination: + MediaInfoView(title: item.title, imageUrl: item.imageUrl, href: item.href, module: selectedModule) + .onDisappear { + + } + ) { ZStack { LazyImage(url: URL(string: item.imageUrl)) { state in if let uiImage = state.imageContainer?.image { @@ -81,6 +87,7 @@ struct SearchResultsGrid: View { .clipShape(RoundedRectangle(cornerRadius: 12)) .padding(4) } + .isDetailLink(true) .id(item.href) .contextMenu { Button(action: { diff --git a/Sora/Views/SearchView/SearchView.swift b/Sora/Views/SearchView/SearchView.swift index 2668c62..5a468e8 100644 --- a/Sora/Views/SearchView/SearchView.swift +++ b/Sora/Views/SearchView/SearchView.swift @@ -23,7 +23,7 @@ struct SearchView: View { @StateObject private var jsController = JSController.shared @EnvironmentObject var moduleManager: ModuleManager - @EnvironmentObject var tabBarController: TabBarController + @Environment(\.verticalSizeClass) var verticalSizeClass @Binding public var searchQuery: String @@ -38,6 +38,7 @@ struct SearchView: View { @State private var isSearchFieldFocused = false @State private var saveDebounceTimer: Timer? @State private var searchDebounceTimer: Timer? + @State private var isActive: Bool = false init(searchQuery: Binding) { self._searchQuery = searchQuery @@ -75,7 +76,7 @@ struct SearchView: View { private var mainContent: some View { VStack(alignment: .leading) { HStack { - Text("Search") + Text(LocalizedStringKey("Search")) .font(.largeTitle) .fontWeight(.bold) @@ -138,11 +139,51 @@ struct SearchView: View { } } .onAppear { + isActive = true loadSearchHistory() if !searchQuery.isEmpty { performSearch() } - tabBarController.showTabBar() + let isMediaInfoActive = UserDefaults.standard.bool(forKey: "isMediaInfoActive") + let isReaderActive = UserDefaults.standard.bool(forKey: "isReaderActive") + if !isMediaInfoActive && !isReaderActive { + NotificationCenter.default.post(name: .showTabBar, object: nil) + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) { + let isMediaInfoActive = UserDefaults.standard.bool(forKey: "isMediaInfoActive") + let isReaderActive = UserDefaults.standard.bool(forKey: "isReaderActive") + if !isMediaInfoActive && !isReaderActive { + NotificationCenter.default.post(name: .showTabBar, object: nil) + } + } + + NotificationCenter.default.addObserver( + forName: .searchQueryChanged, + object: nil, + queue: .main + ) { notification in + if let query = notification.userInfo?["searchQuery"] as? String { + searchQuery = query + } + } + } + .onDisappear { + NotificationCenter.default.removeObserver(self, name: .searchQueryChanged, object: nil) + } + .onReceive(Timer.publish(every: 0.1, on: .main, in: .common).autoconnect()) { _ in + let isMediaInfoActive = UserDefaults.standard.bool(forKey: "isMediaInfoActive") + let isReaderActive = UserDefaults.standard.bool(forKey: "isReaderActive") + if isActive && !isMediaInfoActive && !isReaderActive { + NotificationCenter.default.post(name: .showTabBar, object: nil) + } + } + .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in + isActive = true + let isMediaInfoActive = UserDefaults.standard.bool(forKey: "isMediaInfoActive") + let isReaderActive = UserDefaults.standard.bool(forKey: "isReaderActive") + if !isMediaInfoActive && !isReaderActive { + NotificationCenter.default.post(name: .showTabBar, object: nil) + } } .onChange(of: selectedModuleId) { _ in if !searchQuery.isEmpty { @@ -302,6 +343,7 @@ struct SearchView: View { } } + struct SearchBar: View { @State private var debounceTimer: Timer? @Binding var text: String @@ -310,7 +352,7 @@ struct SearchBar: View { var body: some View { HStack { - TextField("Search...", text: $text, onEditingChanged: { isEditing in + TextField(LocalizedStringKey("Search..."), text: $text, onEditingChanged: { isEditing in isFocused = isEditing }, onCommit: onSearchButtonClicked) .padding(7) diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewAbout.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewAbout.swift index 94375e6..78dada0 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewAbout.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewAbout.swift @@ -78,10 +78,10 @@ struct SettingsViewAbout: View { } VStack(alignment: .leading, spacing: 8) { - Text("Sora") + Text(LocalizedStringKey("Sora")) .font(.title) .bold() - Text("Also known as Sulfur") + Text(LocalizedStringKey("Also known as Sulfur")) .font(.caption) .foregroundColor(.secondary) } @@ -111,10 +111,10 @@ struct SettingsViewAbout: View { } VStack(alignment: .leading) { - Text("cranci1") + Text(LocalizedStringKey("cranci1")) .font(.headline) .foregroundColor(.indigo) - Text("me frfr") + Text(LocalizedStringKey("me frfr")) .font(.subheadline) .foregroundColor(.secondary) } @@ -137,7 +137,7 @@ struct SettingsViewAbout: View { } .padding(.vertical, 20) } - .navigationTitle("About") + .navigationTitle(LocalizedStringKey("About")) .scrollViewBottomPadding() } } @@ -157,7 +157,7 @@ struct ContributorsView: View { } .padding(.vertical, 12) } else if error != nil { - Text("Failed to load contributors") + Text(LocalizedStringKey("Failed to load contributors")) .foregroundColor(.secondary) .padding(.vertical, 12) } else { diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewBackup.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewBackup.swift new file mode 100644 index 0000000..982aa50 --- /dev/null +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewBackup.swift @@ -0,0 +1,261 @@ +// +// SettingsViewBackup.swift +// Sora +// +// Created by paul on 29/06/25. +// + +import SwiftUI +import Foundation +import UniformTypeIdentifiers + +fileprivate struct SettingsSection: View { + let title: String + let footer: String? + let content: Content + + init(title: String, footer: String? = nil, @ViewBuilder content: () -> Content) { + self.title = title + self.footer = footer + self.content = content() + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(title.uppercased()) + .font(.footnote) + .foregroundStyle(.gray) + .padding(.horizontal, 20) + + VStack(spacing: 0) { + content + } + .background(.ultraThinMaterial) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .strokeBorder( + LinearGradient( + gradient: Gradient(stops: [ + .init(color: Color.accentColor.opacity(0.3), location: 0), + .init(color: Color.accentColor.opacity(0), location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ), + lineWidth: 0.5 + ) + ) + .padding(.horizontal, 20) + + if let footer = footer { + Text(footer) + .font(.footnote) + .foregroundStyle(.gray) + .padding(.horizontal, 20) + .padding(.top, 4) + } + } + } +} + +fileprivate struct SettingsActionRow: View { + let icon: String + let title: String + let action: () -> Void + var showDivider: Bool = true + var color: Color = .accentColor + + var body: some View { + Button(action: action) { + HStack { + Image(systemName: icon) + .frame(width: 24, height: 24) + .foregroundStyle(color) + Text(title) + .foregroundStyle(color) + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + .buttonStyle(PlainButtonStyle()) + .background(Color.clear) + .contentShape(Rectangle()) + .overlay( + VStack { + if showDivider { + Divider().padding(.leading, 56) + } + }, alignment: .bottom + ) + } +} + +struct SettingsViewBackup: View { + @State private var showExporter = false + @State private var showImporter = false + @State private var exportURL: URL? + @State private var showAlert = false + @State private var alertMessage = "" + @State private var exportData: Data? = nil + + var body: some View { + ScrollView(showsIndicators: false) { + VStack(spacing: 24) { + SettingsSection( + title: NSLocalizedString("Backup & Restore", comment: "Settings section title for backup and restore"), + footer: NSLocalizedString("Notice: This feature is still experimental. Please double-check your data after import/export.", comment: "Footer notice for experimental backup/restore feature") + ) { + SettingsActionRow( + icon: "arrow.up.doc", + title: NSLocalizedString("Export Backup", comment: "Export backup button title"), + action: { + exportData = generateBackupData() + showExporter = true + }, + showDivider: true + ) + SettingsActionRow( + icon: "arrow.down.doc", + title: NSLocalizedString("Import Backup", comment: "Import backup button title"), + action: { + showImporter = true + }, + showDivider: false + ) + } + } + .padding(.vertical, 20) + } + .navigationTitle(NSLocalizedString("Backup & Restore", comment: "Navigation title for backup and restore view")) + .fileExporter( + isPresented: $showExporter, + document: BackupDocument(data: exportData ?? Data()), + contentType: .json, + defaultFilename: exportFilename() + ) { result in + switch result { + case .success(let url): + alertMessage = "Exported to \(url.lastPathComponent)" + showAlert = true + case .failure(let error): + alertMessage = "Export failed: \(error.localizedDescription)" + showAlert = true + } + } + .fileImporter( + isPresented: $showImporter, + allowedContentTypes: [.json] + ) { result in + switch result { + case .success(let url): + var success = false + if url.startAccessingSecurityScopedResource() { + defer { url.stopAccessingSecurityScopedResource() } + do { + let data = try Data(contentsOf: url) + try restoreBackupData(data) + alertMessage = "Import successful!" + success = true + } catch { + alertMessage = "Import failed: \(error.localizedDescription)" + } + } + if !success { + alertMessage = "Import failed: Could not access file." + } + showAlert = true + case .failure(let error): + alertMessage = "Import failed: \(error.localizedDescription)" + showAlert = true + } + } + .alert(isPresented: $showAlert) { + Alert(title: Text(NSLocalizedString("Backup", comment: "Alert title for backup actions")), message: Text(alertMessage), dismissButton: .default(Text("OK"))) + } + } + + @MainActor + private func generateBackupData() -> Data? { + let continueWatching = ContinueWatchingManager.shared.fetchItems() + let continueReading = ContinueReadingManager.shared.fetchItems() + let collections = (try? JSONDecoder().decode([BookmarkCollection].self, from: UserDefaults.standard.data(forKey: "bookmarkCollections") ?? Data())) ?? [] + let searchHistory = UserDefaults.standard.stringArray(forKey: "searchHistory") ?? [] + let modules = ModuleManager().modules + + let backup: [String: Any] = [ + "continueWatching": continueWatching.map { try? $0.toDictionary() }, + "continueReading": continueReading.map { try? $0.toDictionary() }, + "collections": collections.map { try? $0.toDictionary() }, + "searchHistory": searchHistory, + "modules": modules.map { try? $0.toDictionary() } + ] + + return try? JSONSerialization.data(withJSONObject: backup, options: .prettyPrinted) + } + + private func restoreBackupData(_ data: Data) throws { + guard let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else { + throw NSError(domain: "restoreBackupData", code: 0, userInfo: [NSLocalizedDescriptionKey: "Invalid backup format"]) + } + if let cwArr = json["continueWatching"] as? NSArray { + let cwData = try JSONSerialization.data(withJSONObject: cwArr, options: []) + UserDefaults.standard.set(cwData, forKey: "continueWatchingItems") + } + if let crArr = json["continueReading"] as? NSArray { + let crData = try JSONSerialization.data(withJSONObject: crArr, options: []) + UserDefaults.standard.set(crData, forKey: "continueReadingItems") + } + if let colArr = json["collections"] as? NSArray { + let colData = try JSONSerialization.data(withJSONObject: colArr, options: []) + UserDefaults.standard.set(colData, forKey: "bookmarkCollections") + } + if let shArr = json["searchHistory"] as? [String] { + UserDefaults.standard.set(shArr, forKey: "searchHistory") + } + if let modArr = json["modules"] as? NSArray { + let modData = try JSONSerialization.data(withJSONObject: modArr, options: []) + let fileManager = FileManager.default + let docs = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0] + let modulesURL = docs.appendingPathComponent("modules.json") + try modData.write(to: modulesURL) + } + UserDefaults.standard.synchronize() + } + + + private func exportFilename() -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd_HH-mm-ss" + let dateString = formatter.string(from: Date()) + return "SoraBackup_\(dateString).json" + } +} + + +extension Encodable { + func toDictionary() throws -> [String: Any] { + let data = try JSONEncoder().encode(self) + let json = try JSONSerialization.jsonObject(with: data, options: []) + guard let dict = json as? [String: Any] else { + throw NSError(domain: "toDictionary", code: 0, userInfo: nil) + } + return dict + } +} + +struct BackupDocument: FileDocument { + static var readableContentTypes: [UTType] { [.json] } + var data: Data + + init(data: Data) { + self.data = data + } + init(configuration: ReadConfiguration) throws { + self.data = configuration.file.regularFileContents ?? Data() + } + func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { + return .init(regularFileWithContents: data) + } +} diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift index 47cd59b..77f378f 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift @@ -155,18 +155,35 @@ struct SettingsViewGeneral: View { @AppStorage("analyticsEnabled") private var analyticsEnabled: Bool = false @AppStorage("hideSplashScreen") private var hideSplashScreenEnable: Bool = false @AppStorage("useNativeTabBar") private var useNativeTabBar: Bool = false - @AppStorage("metadataProvidersOrder") private var metadataProvidersOrderData: Data = { + @AppStorage("metadataProvidersOrderData") private var metadataProvidersOrderData: Data = { try! JSONEncoder().encode(["TMDB","AniList"]) }() @AppStorage("tmdbImageWidth") private var TMDBimageWidht: String = "original" @AppStorage("mediaColumnsPortrait") private var mediaColumnsPortrait: Int = 2 @AppStorage("mediaColumnsLandscape") private var mediaColumnsLandscape: Int = 4 @AppStorage("metadataProviders") private var metadataProviders: String = "TMDB" + @AppStorage("librarySectionsOrderData") private var librarySectionsOrderData: Data = { + try! JSONEncoder().encode(["continueWatching", "continueReading", "collections"]) + }() + @AppStorage("disabledLibrarySectionsData") private var disabledLibrarySectionsData: Data = { + try! JSONEncoder().encode([String]()) + }() private var metadataProvidersOrder: [String] { get { (try? JSONDecoder().decode([String].self, from: metadataProvidersOrderData)) ?? ["AniList","TMDB"] } set { metadataProvidersOrderData = try! JSONEncoder().encode(newValue) } } + + private var librarySectionsOrder: [String] { + get { (try? JSONDecoder().decode([String].self, from: librarySectionsOrderData)) ?? ["continueWatching", "continueReading", "collections"] } + set { librarySectionsOrderData = try! JSONEncoder().encode(newValue) } + } + + private var disabledLibrarySections: [String] { + get { (try? JSONDecoder().decode([String].self, from: disabledLibrarySectionsData)) ?? [] } + set { disabledLibrarySectionsData = try! JSONEncoder().encode(newValue) } + } + private let TMDBimageWidhtList = ["300", "500", "780", "1280", "original"] private let sortOrderOptions = ["Ascending", "Descending"] private let metadataProvidersList = ["TMDB", "AniList"] @@ -317,9 +334,15 @@ struct SettingsViewGeneral: View { } } .listStyle(.plain) - .frame(height: CGFloat(metadataProvidersOrder.count * 48)) + .frame(height: CGFloat(metadataProvidersOrder.count * 65)) .background(Color.clear) .padding(.bottom, 8) + + Text(NSLocalizedString("Drag to reorder", comment: "")) + .font(.caption) + .foregroundStyle(.gray) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.bottom, 8) } .environment(\.editMode, .constant(.active)) } @@ -369,6 +392,70 @@ struct SettingsViewGeneral: View { showDivider: false ) } + + SettingsSection( + title: NSLocalizedString("Library View", comment: ""), + footer: NSLocalizedString("Customize the sections shown in your library. You can reorder sections or disable them completely.", comment: "") + ) { + VStack(spacing: 0) { + HStack { + Image(systemName: "arrow.up.arrow.down") + .frame(width: 24, height: 24) + .foregroundStyle(.primary) + + Text(NSLocalizedString("Library Sections Order", comment: "")) + .foregroundStyle(.primary) + + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + + List { + ForEach(Array(librarySectionsOrder.enumerated()), id: \.element) { index, section in + HStack { + Text("\(index + 1)") + .frame(width: 24, height: 24) + .foregroundStyle(.gray) + + Image(systemName: sectionIcon(for: section)) + .frame(width: 24, height: 24) + + Text(sectionName(for: section)) + .foregroundStyle(.primary) + + Spacer() + + Toggle("", isOn: toggleBinding(for: section)) + .labelsHidden() + .tint(.accentColor.opacity(0.7)) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .listRowBackground(Color.clear) + .listRowSeparator(.visible) + .listRowSeparatorTint(.gray.opacity(0.3)) + .listRowInsets(EdgeInsets()) + } + .onMove { from, to in + var arr = librarySectionsOrder + arr.move(fromOffsets: from, toOffset: to) + librarySectionsOrderData = try! JSONEncoder().encode(arr) + } + } + .listStyle(.plain) + .frame(height: CGFloat(librarySectionsOrder.count * 70)) + .background(Color.clear) + .padding(.bottom, 8) + + Text(NSLocalizedString("Drag to reorder sections", comment: "")) + .font(.caption) + .foregroundStyle(.gray) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.bottom, 8) + } + .environment(\.editMode, .constant(.active)) + } } .padding(.vertical, 20) } @@ -382,4 +469,47 @@ struct SettingsViewGeneral: View { ) } } + + private func sectionName(for section: String) -> String { + switch section { + case "continueWatching": + return NSLocalizedString("Continue Watching", comment: "") + case "continueReading": + return NSLocalizedString("Continue Reading", comment: "") + case "collections": + return NSLocalizedString("Collections", comment: "") + default: + return section + } + } + + private func sectionIcon(for section: String) -> String { + switch section { + case "continueWatching": + return "play.fill" + case "continueReading": + return "book.fill" + case "collections": + return "folder.fill" + default: + return "questionmark" + } + } + + private func toggleBinding(for section: String) -> Binding { + return Binding( + get: { !self.disabledLibrarySections.contains(section) }, + set: { isEnabled in + var sections = self.disabledLibrarySections + if isEnabled { + sections.removeAll { $0 == section } + } else { + if !sections.contains(section) { + sections.append(section) + } + } + self.disabledLibrarySectionsData = try! JSONEncoder().encode(sections) + } + ) + } } diff --git a/Sora/Views/SettingsView/SettingsView.swift b/Sora/Views/SettingsView/SettingsView.swift index c36c2d1..490c622 100644 --- a/Sora/Views/SettingsView/SettingsView.swift +++ b/Sora/Views/SettingsView/SettingsView.swift @@ -131,7 +131,7 @@ struct SettingsView: View { @Environment(\.colorScheme) var colorScheme @StateObject var settings = Settings() @EnvironmentObject var moduleManager: ModuleManager - @EnvironmentObject var tabBarController: TabBarController + @State private var isNavigationActive = false var body: some View { @@ -177,11 +177,6 @@ struct SettingsView: View { NavigationLink(destination: SettingsViewDownloads().navigationBarBackButtonHidden(false)) { SettingsNavigationRow(icon: "arrow.down.circle", titleKey: "Downloads") } - Divider().padding(.horizontal, 16) - - NavigationLink(destination: SettingsViewTrackers().navigationBarBackButtonHidden(false)) { - SettingsNavigationRow(icon: "square.stack.3d.up", titleKey: "Trackers") - } } .background(.ultraThinMaterial) .clipShape(RoundedRectangle(cornerRadius: 12)) @@ -217,6 +212,11 @@ struct SettingsView: View { NavigationLink(destination: SettingsViewLogger().navigationBarBackButtonHidden(false)) { SettingsNavigationRow(icon: "doc.text", titleKey: "Logs") } + Divider().padding(.horizontal, 16) + + NavigationLink(destination: SettingsViewBackup().navigationBarBackButtonHidden(false)) { + SettingsNavigationRow(icon: "arrow.triangle.2.circlepath", titleKey: NSLocalizedString("Backup & Restore", comment: "Settings navigation row for backup and restore")) + } } .background(.ultraThinMaterial) .clipShape(RoundedRectangle(cornerRadius: 12)) @@ -250,28 +250,52 @@ struct SettingsView: View { Divider().padding(.horizontal, 16) Link(destination: URL(string: "https://github.com/cranci1/Sora")!) { - SettingsNavigationRow( - icon: "chevron.left.forwardslash.chevron.right", - titleKey: "Sora GitHub Repository", - isExternal: true, - textColor: .gray - ) + HStack { + Image("Github Icon") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 20, height: 20) + .padding(.leading, 2) + .padding(.trailing, 4) + + Text(NSLocalizedString("Sora GitHub Repository", comment: "")) + .foregroundStyle(.gray) + + Spacer() + + Image(systemName: "safari") + .foregroundStyle(.gray) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) } Divider().padding(.horizontal, 16) Link(destination: URL(string: "https://discord.gg/x7hppDWFDZ")!) { - SettingsNavigationRow( - icon: "bubble.left.and.bubble.right", - titleKey: "Join the Discord", - isExternal: true, - textColor: .gray - ) + HStack { + Image("Discord Icon") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 20, height: 20) + .padding(.leading, 2) + .padding(.trailing, 4) + + Text(NSLocalizedString("Join the Discord", comment: "")) + .foregroundStyle(.gray) + + Spacer() + + Image(systemName: "safari") + .foregroundStyle(.gray) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) } Divider().padding(.horizontal, 16) Link(destination: URL(string: "https://github.com/cranci1/Sora/issues")!) { SettingsNavigationRow( - icon: "exclamationmark.circle", + icon: "exclamationmark.circle.fill", titleKey: "Report an Issue", isExternal: true, textColor: .gray @@ -281,7 +305,7 @@ struct SettingsView: View { Link(destination: URL(string: "https://github.com/cranci1/Sora/blob/dev/LICENSE")!) { SettingsNavigationRow( - icon: "doc.text", + icon: "doc.text.fill", titleKey: "License (GPLv3.0)", isExternal: true, textColor: .gray @@ -330,7 +354,6 @@ struct SettingsView: View { } .onAppear { settings.updateAccentColor(currentColorScheme: colorScheme) - tabBarController.showTabBar() } } } diff --git a/Sulfur.xcodeproj/project.pbxproj b/Sulfur.xcodeproj/project.pbxproj index 1c31c4b..071d8ba 100644 --- a/Sulfur.xcodeproj/project.pbxproj +++ b/Sulfur.xcodeproj/project.pbxproj @@ -7,7 +7,6 @@ objects = { /* Begin PBXBuildFile section */ - 0402DA0E2DE7AA01003BB42C /* TabBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0402DA0D2DE7A9FC003BB42C /* TabBarController.swift */; }; 0402DA132DE7B5EC003BB42C /* SearchStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0402DA112DE7B5EC003BB42C /* SearchStateView.swift */; }; 0402DA142DE7B5EC003BB42C /* SearchResultsGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0402DA102DE7B5EC003BB42C /* SearchResultsGrid.swift */; }; 0402DA152DE7B5EC003BB42C /* SearchComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0402DA0F2DE7B5EC003BB42C /* SearchComponents.swift */; }; @@ -19,6 +18,7 @@ 0410697F2E00ABE900A157BB /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 0410697C2E00ABE900A157BB /* Localizable.strings */; }; 041069832E00C71000A157BB /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 041069812E00C71000A157BB /* Localizable.strings */; }; 041261042E00D14F00D05B47 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 041261022E00D14F00D05B47 /* Localizable.strings */; }; + 041E9D722E11D71F0025F150 /* SettingsViewBackup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 041E9D712E11D71F0025F150 /* SettingsViewBackup.swift */; }; 04536F712E04BA3B00A11248 /* JSController-Novel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04536F702E04BA3B00A11248 /* JSController-Novel.swift */; }; 04536F742E04BA5600A11248 /* ReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04536F722E04BA5600A11248 /* ReaderView.swift */; }; 04536F772E04BA6900A11248 /* ChapterCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04536F752E04BA6900A11248 /* ChapterCell.swift */; }; @@ -27,6 +27,10 @@ 0457C59E2DE78267000AFBD9 /* BookmarkLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0457C59B2DE78267000AFBD9 /* BookmarkLink.swift */; }; 0457C59F2DE78267000AFBD9 /* BookmarkGridItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0457C5992DE78267000AFBD9 /* BookmarkGridItemView.swift */; }; 0457C5A12DE78385000AFBD9 /* BookmarksDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0457C5A02DE78385000AFBD9 /* BookmarksDetailView.swift */; }; + 047F170A2E0C93E10081B5FB /* AllReading.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047F17082E0C93E10081B5FB /* AllReading.swift */; }; + 047F170B2E0C93E10081B5FB /* ContinueReadingSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047F17092E0C93E10081B5FB /* ContinueReadingSection.swift */; }; + 047F170E2E0C93F30081B5FB /* ContinueReadingItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047F170C2E0C93F30081B5FB /* ContinueReadingItem.swift */; }; + 047F170F2E0C93F30081B5FB /* ContinueReadingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047F170D2E0C93F30081B5FB /* ContinueReadingManager.swift */; }; 0488FA952DFDE724007575E1 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 0488FA902DFDE724007575E1 /* Localizable.strings */; }; 0488FA962DFDE724007575E1 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 0488FA932DFDE724007575E1 /* Localizable.strings */; }; 0488FA9A2DFDF380007575E1 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 0488FA982DFDF380007575E1 /* Localizable.strings */; }; @@ -121,7 +125,6 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ - 0402DA0D2DE7A9FC003BB42C /* TabBarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarController.swift; sourceTree = ""; }; 0402DA0F2DE7B5EC003BB42C /* SearchComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchComponents.swift; sourceTree = ""; }; 0402DA102DE7B5EC003BB42C /* SearchResultsGrid.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsGrid.swift; sourceTree = ""; }; 0402DA112DE7B5EC003BB42C /* SearchStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchStateView.swift; sourceTree = ""; }; @@ -133,6 +136,7 @@ 0410697B2E00ABE900A157BB /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = Localizable.strings; sourceTree = ""; }; 041069802E00C71000A157BB /* kk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = kk; path = Localizable.strings; sourceTree = ""; }; 041261012E00D14F00D05B47 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = Localizable.strings; sourceTree = ""; }; + 041E9D712E11D71F0025F150 /* SettingsViewBackup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewBackup.swift; sourceTree = ""; }; 0452339E2E02149C002EA23C /* bos */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bos; path = Localizable.strings; sourceTree = ""; }; 04536F702E04BA3B00A11248 /* JSController-Novel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-Novel.swift"; sourceTree = ""; }; 04536F722E04BA5600A11248 /* ReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderView.swift; sourceTree = ""; }; @@ -142,6 +146,10 @@ 0457C59A2DE78267000AFBD9 /* BookmarkGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkGridView.swift; sourceTree = ""; }; 0457C59B2DE78267000AFBD9 /* BookmarkLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkLink.swift; sourceTree = ""; }; 0457C5A02DE78385000AFBD9 /* BookmarksDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksDetailView.swift; sourceTree = ""; }; + 047F17082E0C93E10081B5FB /* AllReading.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllReading.swift; sourceTree = ""; }; + 047F17092E0C93E10081B5FB /* ContinueReadingSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContinueReadingSection.swift; sourceTree = ""; }; + 047F170C2E0C93F30081B5FB /* ContinueReadingItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContinueReadingItem.swift; sourceTree = ""; }; + 047F170D2E0C93F30081B5FB /* ContinueReadingManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContinueReadingManager.swift; sourceTree = ""; }; 0488FA8F2DFDE724007575E1 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = Localizable.strings; sourceTree = ""; }; 0488FA922DFDE724007575E1 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = Localizable.strings; sourceTree = ""; }; 0488FA992DFDF380007575E1 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = Localizable.strings; sourceTree = ""; }; @@ -423,7 +431,6 @@ 04F08EDD2DE10C05006B29D9 /* TabBar */ = { isa = PBXGroup; children = ( - 0402DA0D2DE7A9FC003BB42C /* TabBarController.swift */, 04F08EDE2DE10C1A006B29D9 /* TabBar.swift */, ); path = TabBar; @@ -562,6 +569,7 @@ 133D7C832D2BE2630075467E /* SettingsSubViews */ = { isa = PBXGroup; children = ( + 041E9D712E11D71F0025F150 /* SettingsViewBackup.swift */, 1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */, 1399FAD32D3AB38C00E97C31 /* SettingsViewLogger.swift */, 133D7C842D2BE2630075467E /* SettingsViewModule.swift */, @@ -638,6 +646,8 @@ 133F55B92D33B53E00E08EEA /* LibraryView */ = { isa = PBXGroup; children = ( + 047F17082E0C93E10081B5FB /* AllReading.swift */, + 047F17092E0C93E10081B5FB /* ContinueReadingSection.swift */, 0457C59C2DE78267000AFBD9 /* BookmarkComponents */, 04CD76DA2DE20F2200733536 /* AllWatching.swift */, 133F55BA2D33B55100E08EEA /* LibraryManager.swift */, @@ -740,6 +750,8 @@ 13C0E5E82D5F85DD00E7F619 /* ContinueWatching */ = { isa = PBXGroup; children = ( + 047F170C2E0C93F30081B5FB /* ContinueReadingItem.swift */, + 047F170D2E0C93F30081B5FB /* ContinueReadingManager.swift */, 13C0E5E92D5F85EA00E7F619 /* ContinueWatchingManager.swift */, 13C0E5EB2D5F85F800E7F619 /* ContinueWatchingItem.swift */, ); @@ -947,6 +959,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 041E9D722E11D71F0025F150 /* SettingsViewBackup.swift in Sources */, 135CCBE22D4D1138008B9C0E /* SettingsViewPlayer.swift in Sources */, 04536F742E04BA5600A11248 /* ReaderView.swift in Sources */, 131270172DC13A010093AA9C /* DownloadManager.swift in Sources */, @@ -966,13 +979,14 @@ 04F08EDC2DE10BF3006B29D9 /* ProgressiveBlurView.swift in Sources */, 1E26E9E72DA9577900B9DC02 /* VolumeSlider.swift in Sources */, 136BBE802DB1038000906B5E /* Notification+Name.swift in Sources */, + 047F170E2E0C93F30081B5FB /* ContinueReadingItem.swift in Sources */, + 047F170F2E0C93F30081B5FB /* ContinueReadingManager.swift in Sources */, 13DB46902D900A38008CBC03 /* URL.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 */, - 0402DA0E2DE7AA01003BB42C /* TabBarController.swift in Sources */, 1E47859B2DEBC5960095BF2F /* AnilistMatchPopupView.swift in Sources */, 13EA2BD92D32D98400C1EBD7 /* NormalPlayer.swift in Sources */, 133D7C932D2BE2640075467E /* Modules.swift in Sources */, @@ -992,6 +1006,8 @@ 132AF1212D99951700A0140B /* JSController-Streams.swift in Sources */, 131845F92D47C62D00CA7A54 /* SettingsViewGeneral.swift in Sources */, 04CD76DB2DE20F2200733536 /* AllWatching.swift in Sources */, + 047F170A2E0C93E10081B5FB /* AllReading.swift in Sources */, + 047F170B2E0C93E10081B5FB /* ContinueReadingSection.swift in Sources */, 13103E8E2D58E04A000F0673 /* SkeletonCell.swift in Sources */, 13D842552D45267500EBBFA6 /* DropManager.swift in Sources */, 13103E8B2D58E028000F0673 /* View.swift in Sources */, From f03f4c0b8adea229b8a45fa869f8c4cc3c265aef Mon Sep 17 00:00:00 2001 From: realdoomsboygaming <105606471+realdoomsboygaming@users.noreply.github.com> Date: Mon, 30 Jun 2025 15:21:51 -0500 Subject: [PATCH 18/45] Fix One Pace (#212) --- .../Downloads/JSController-Downloads.swift | 41 +++++++++++-------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/Sora/Utlis & Misc/JSLoader/Downloads/JSController-Downloads.swift b/Sora/Utlis & Misc/JSLoader/Downloads/JSController-Downloads.swift index 42a37b6..705d8c2 100644 --- a/Sora/Utlis & Misc/JSLoader/Downloads/JSController-Downloads.swift +++ b/Sora/Utlis & Misc/JSLoader/Downloads/JSController-Downloads.swift @@ -1197,7 +1197,7 @@ extension JSController: AVAssetDownloadDelegate { let download = activeDownloads[downloadIndex] // Move the downloaded file to Application Support directory for persistence - guard let persistentURL = moveToApplicationSupportDirectory(from: location, filename: download.title ?? download.originalURL.lastPathComponent) else { + guard let persistentURL = moveToApplicationSupportDirectory(from: location, filename: download.title ?? download.originalURL.lastPathComponent, originalURL: download.originalURL) else { print("Failed to move downloaded file to persistent storage") return } @@ -1245,8 +1245,9 @@ extension JSController: AVAssetDownloadDelegate { /// - Parameters: /// - location: The original location from the download task /// - filename: Name to use for the file + /// - originalURL: The original download URL to determine proper file extension /// - Returns: URL to the new persistent location or nil if move failed - private func moveToApplicationSupportDirectory(from location: URL, filename: String) -> URL? { + private func moveToApplicationSupportDirectory(from location: URL, filename: String, originalURL: URL) -> URL? { let fileManager = FileManager.default // Get Application Support directory @@ -1269,23 +1270,31 @@ extension JSController: AVAssetDownloadDelegate { let safeFilename = filename.replacingOccurrences(of: "/", with: "-") .replacingOccurrences(of: ":", with: "-") - // Determine file extension based on the source location + // Determine file extension based on the original download URL, not the downloaded file let fileExtension: String - if location.pathExtension.isEmpty { - // If no extension from the source, check if it's likely an HLS download (which becomes .movpkg) - // or preserve original URL extension - if safeFilename.contains(".m3u8") || safeFilename.contains("hls") { - fileExtension = "movpkg" - print("Using .movpkg extension for HLS download: \(safeFilename)") - } else { - fileExtension = "mp4" // Default for direct video downloads - print("Using .mp4 extension for direct video download: \(safeFilename)") - } + + // Check the original URL to determine if this was an HLS stream or direct MP4 + let originalURLString = originalURL.absoluteString.lowercased() + let originalPathExtension = originalURL.pathExtension.lowercased() + + if originalURLString.contains(".m3u8") || originalURLString.contains("/hls/") || originalURLString.contains("m3u8") { + // This was an HLS stream, keep as .movpkg + fileExtension = "movpkg" + print("Using .movpkg extension for HLS download: \(safeFilename)") + } else if originalPathExtension == "mp4" || originalURLString.contains(".mp4") || originalURLString.contains("download") { + // This was a direct MP4 download, use .mp4 extension regardless of what AVAssetDownloadTask created + fileExtension = "mp4" + print("Using .mp4 extension for direct MP4 download: \(safeFilename)") } else { - // Use the extension from the downloaded file + // Fallback: check the downloaded file extension let sourceExtension = location.pathExtension.lowercased() - fileExtension = (sourceExtension == "movpkg") ? "movpkg" : "mp4" - print("Using extension from source file: \(sourceExtension) -> \(fileExtension)") + if sourceExtension == "movpkg" && originalURLString.contains("m3u8") { + fileExtension = "movpkg" + print("Using .movpkg extension for HLS stream: \(safeFilename)") + } else { + fileExtension = "mp4" + print("Using .mp4 extension as fallback: \(safeFilename)") + } } print("Final destination will be: \(safeFilename)-\(uniqueID).\(fileExtension)") From 81393601e1a23552b821a9cd9476ac126d3daba7 Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Tue, 1 Jul 2025 14:43:49 +0200 Subject: [PATCH 19/45] images reduction (-22kb) --- .../Discord Icon.imageset/Discord Icon.png | Bin 16214 -> 3722 bytes .../Github Icon.imageset/Github Icon.png | Bin 14726 -> 3545 bytes Sora/Models/EpisodeLink.swift | 7 - .../DownloadManager/DownloadManager.swift | 223 ------------------ 4 files changed, 230 deletions(-) delete mode 100644 Sora/Models/EpisodeLink.swift delete mode 100644 Sora/Utlis & Misc/DownloadManager/DownloadManager.swift diff --git a/Sora/Assets.xcassets/Discord Icon.imageset/Discord Icon.png b/Sora/Assets.xcassets/Discord Icon.imageset/Discord Icon.png index 3847685bc23cd7b9f12913f3634c27a2fd467fcd..72d3df415c9eaa2d90a3b140930475707136d856 100644 GIT binary patch literal 3722 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 ShKL~W_tDt#WA=|$X#NWYaj2^R diff --git a/Sora/Assets.xcassets/Github Icon.imageset/Github Icon.png b/Sora/Assets.xcassets/Github Icon.imageset/Github Icon.png index bb23c56ffca8e0bf380d09603693b05ab54c7003..13e93d7e8a19f9b5d505da59ede0d5aa6893bcb2 100644 GIT binary patch literal 3545 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 diff --git a/Sora/Models/EpisodeLink.swift b/Sora/Models/EpisodeLink.swift deleted file mode 100644 index b0aae5f..0000000 --- a/Sora/Models/EpisodeLink.swift +++ /dev/null @@ -1,7 +0,0 @@ -struct EpisodeLink: Identifiable { - let id = UUID() - let number: Int - let title: String - let href: String - let duration: Int? -} \ No newline at end of file diff --git a/Sora/Utlis & Misc/DownloadManager/DownloadManager.swift b/Sora/Utlis & Misc/DownloadManager/DownloadManager.swift deleted file mode 100644 index b9f5b15..0000000 --- a/Sora/Utlis & Misc/DownloadManager/DownloadManager.swift +++ /dev/null @@ -1,223 +0,0 @@ -// -// DownloadManager.swift -// Sulfur -// -// Created by Francesco on 29/04/25. -// - -import SwiftUI -import AVKit -import Foundation -import AVFoundation - -struct DownloadedAsset: Identifiable, Codable { - let id: UUID - var name: String - let downloadDate: Date - let originalURL: URL - let localURL: URL - var fileSize: Int64? - let module: ScrapingModule - - init(id: UUID = UUID(), name: String, downloadDate: Date, originalURL: URL, localURL: URL, module: ScrapingModule) { - self.id = id - self.name = name - self.downloadDate = downloadDate - self.originalURL = originalURL - self.localURL = localURL - self.module = module - self.fileSize = getFileSize() - } - - func getFileSize() -> Int64? { - do { - let values = try localURL.resourceValues(forKeys: [.fileSizeKey]) - return Int64(values.fileSize ?? 0) - } catch { - return nil - } - } -} - -class DownloadManager: NSObject, ObservableObject { - @Published var activeDownloads: [ActiveDownload] = [] - @Published var savedAssets: [DownloadedAsset] = [] - - private var assetDownloadURLSession: AVAssetDownloadURLSession! - private var activeDownloadTasks: [URLSessionTask: (URL, ScrapingModule)] = [:] - - override init() { - super.init() - initializeDownloadSession() - loadSavedAssets() - reconcileFileSystemAssets() - } - - private func initializeDownloadSession() { - let configuration = URLSessionConfiguration.background(withIdentifier: "downloader-\(UUID().uuidString)") - assetDownloadURLSession = AVAssetDownloadURLSession( - configuration: configuration, - assetDownloadDelegate: self, - delegateQueue: .main - ) - } - - func downloadAsset(from url: URL, module: ScrapingModule, headers: [String: String]? = nil) { - var urlRequest = URLRequest(url: url) - - - if let headers = headers { - for (key, value) in headers { - urlRequest.addValue(value, forHTTPHeaderField: key) - } - } else { - urlRequest.addValue(module.metadata.baseUrl, forHTTPHeaderField: "Origin") - urlRequest.addValue(module.metadata.baseUrl, forHTTPHeaderField: "Referer") - } - - urlRequest.addValue("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", forHTTPHeaderField: "User-Agent") - - let asset = AVURLAsset(url: urlRequest.url!, options: ["AVURLAssetHTTPHeaderFieldsKey": urlRequest.allHTTPHeaderFields ?? [:]]) - - let task = assetDownloadURLSession.makeAssetDownloadTask( - asset: asset, - assetTitle: url.lastPathComponent, - assetArtworkData: nil, - options: [AVAssetDownloadTaskMinimumRequiredMediaBitrateKey: 2_000_000] - ) - - let download = ActiveDownload( - id: UUID(), - originalURL: url, - progress: 0, - task: task! - ) - - activeDownloads.append(download) - activeDownloadTasks[task!] = (url, module) - task?.resume() - } - - func deleteAsset(_ asset: DownloadedAsset) { - do { - try FileManager.default.removeItem(at: asset.localURL) - savedAssets.removeAll { $0.id == asset.id } - saveAssets() - } catch { - Logger.shared.log("Error deleting asset: \(error)") - } - } - - func renameAsset(_ asset: DownloadedAsset, newName: String) { - guard let index = savedAssets.firstIndex(where: { $0.id == asset.id }) else { return } - savedAssets[index].name = newName - saveAssets() - } - - private func saveAssets() { - do { - let data = try JSONEncoder().encode(savedAssets) - UserDefaults.standard.set(data, forKey: "savedAssets") - } catch { - Logger.shared.log("Error saving assets: \(error)") - } - } - - private func loadSavedAssets() { - guard let data = UserDefaults.standard.data(forKey: "savedAssets") else { return } - do { - savedAssets = try JSONDecoder().decode([DownloadedAsset].self, from: data) - } catch { - Logger.shared.log("Error loading saved assets: \(error)") - } - } - - private func reconcileFileSystemAssets() { - guard let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return } - - do { - let fileURLs = try FileManager.default.contentsOfDirectory( - at: documents, - includingPropertiesForKeys: [.creationDateKey, .fileSizeKey], - options: .skipsHiddenFiles - ) - } catch { - Logger.shared.log("Error reconciling files: \(error)") - } - } -} - -extension DownloadManager: AVAssetDownloadDelegate { - func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didFinishDownloadingTo location: URL) { - guard let (originalURL, module) = activeDownloadTasks[assetDownloadTask] else { return } - - let newAsset = DownloadedAsset( - name: originalURL.lastPathComponent, - downloadDate: Date(), - originalURL: originalURL, - localURL: location, - module: module - ) - - savedAssets.append(newAsset) - saveAssets() - cleanupDownloadTask(assetDownloadTask) - } - - func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { - guard let error = error else { return } - Logger.shared.log("Download error: \(error.localizedDescription)") - cleanupDownloadTask(task) - } - - func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didLoad timeRange: CMTimeRange, totalTimeRangesLoaded loadedTimeRanges: [NSValue], timeRangeExpectedToLoad: CMTimeRange) { - guard let (originalURL, _) = activeDownloadTasks[assetDownloadTask], let downloadIndex = activeDownloads.firstIndex(where: { $0.originalURL == originalURL }) else { return } - - let progress = loadedTimeRanges - .map { $0.timeRangeValue.duration.seconds / timeRangeExpectedToLoad.duration.seconds } - .reduce(0, +) - - activeDownloads[downloadIndex].progress = progress - } - - private func cleanupDownloadTask(_ task: URLSessionTask) { - activeDownloadTasks.removeValue(forKey: task) - activeDownloads.removeAll { $0.task == task } - } -} - -struct DownloadProgressView: View { - let download: ActiveDownload - - var body: some View { - VStack(alignment: .leading) { - Text(download.originalURL.lastPathComponent) - .font(.subheadline) - ProgressView(value: download.progress) - .progressViewStyle(LinearProgressViewStyle()) - Text("\(Int(download.progress * 100))%") - .font(.caption) - } - } -} - -struct AssetRowView: View { - let asset: DownloadedAsset - - var body: some View { - VStack(alignment: .leading) { - Text(asset.name) - .font(.headline) - Text("\(asset.fileSize ?? 0) bytes • \(asset.downloadDate.formatted())") - .font(.caption) - .foregroundColor(.secondary) - } - } -} - -struct ActiveDownload: Identifiable { - let id: UUID - let originalURL: URL - var progress: Double - let task: URLSessionTask -} From ad323efe3415f67e3f020a77fa3fdfc8e3c2fe43 Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Tue, 1 Jul 2025 17:46:34 +0200 Subject: [PATCH 20/45] oh hell nah i aint changing the Package.resolved --- .../SettingsView/SettingsSubViews/SettingsViewAbout.swift | 5 ----- .../SettingsView/SettingsSubViews/SettingsViewBackup.swift | 2 +- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewAbout.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewAbout.swift index 78dada0..a5c234f 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewAbout.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewAbout.swift @@ -191,11 +191,6 @@ struct ContributorsView: View { id: 71751652, login: "qooode", avatarUrl: "https://avatars.githubusercontent.com/u/71751652?v=4" - ), - Contributor( - id: 8116188, - login: "undeaDD", - avatarUrl: "https://avatars.githubusercontent.com/u/8116188?v=4" ) ] } diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewBackup.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewBackup.swift index 982aa50..982aa8f 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewBackup.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewBackup.swift @@ -105,7 +105,7 @@ struct SettingsViewBackup: View { VStack(spacing: 24) { SettingsSection( title: NSLocalizedString("Backup & Restore", comment: "Settings section title for backup and restore"), - footer: NSLocalizedString("Notice: This feature is still experimental. Please double-check your data after import/export.", comment: "Footer notice for experimental backup/restore feature") + footer: NSLocalizedString("Notice: This feature is still experimental. Please double-check your data after import/export. \nAlso note that when importing a backup your current data will be overwritten, it is not possible to merge yet.", comment: "Footer notice for experimental backup/restore feature") ) { SettingsActionRow( icon: "arrow.up.doc", From 1b6c62a204156d3a02661be307853e6a380ab0cc Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Tue, 1 Jul 2025 17:56:00 +0200 Subject: [PATCH 21/45] better safe handling? --- .../JavaScriptCore+Extensions.swift | 143 ++++++++++++------ .../SettingsViewGeneral.swift | 4 - Sulfur.xcodeproj/project.pbxproj | 2 +- 3 files changed, 95 insertions(+), 54 deletions(-) diff --git a/Sora/Utlis & Misc/Extensions/JavaScriptCore+Extensions.swift b/Sora/Utlis & Misc/Extensions/JavaScriptCore+Extensions.swift index 0f6bd60..3c11ef5 100644 --- a/Sora/Utlis & Misc/Extensions/JavaScriptCore+Extensions.swift +++ b/Sora/Utlis & Misc/Extensions/JavaScriptCore+Extensions.swift @@ -86,18 +86,48 @@ extension JSContext { } var headers: [String: String]? = nil - if let headersDict = headersAny as? [String: Any] { - var safeHeaders: [String: String] = [:] - for (key, value) in headersDict { - if let valueStr = value as? String { - safeHeaders[key] = valueStr - } else { - Logger.shared.log("Header value is not a String: \(key): \(value)", type: "Error") + + if let headersAny = headersAny { + if headersAny is NSNull { + headers = nil + } else if let headersDict = headersAny as? [String: Any] { + var safeHeaders: [String: String] = [:] + for (key, value) in headersDict { + let stringValue: String + if let str = value as? String { + stringValue = str + } else if let num = value as? NSNumber { + stringValue = num.stringValue + } else if value is NSNull { + continue + } else { + stringValue = String(describing: value) + } + safeHeaders[key] = stringValue } + headers = safeHeaders.isEmpty ? nil : safeHeaders + } else if let headersDict = headersAny as? [AnyHashable: Any] { + var safeHeaders: [String: String] = [:] + for (key, value) in headersDict { + let stringKey = String(describing: key) + + let stringValue: String + if let str = value as? String { + stringValue = str + } else if let num = value as? NSNumber { + stringValue = num.stringValue + } else if value is NSNull { + continue + } else { + stringValue = String(describing: value) + } + safeHeaders[stringKey] = stringValue + } + headers = safeHeaders.isEmpty ? nil : safeHeaders + } else { + Logger.shared.log("Headers argument is not a dictionary, type: \(type(of: headersAny))", type: "Warning") + headers = nil } - headers = safeHeaders - } else if headersAny != nil { - Logger.shared.log("Headers argument is not a dictionary", type: "Error") } let httpMethod = method ?? "GET" @@ -132,7 +162,9 @@ extension JSContext { let textEncoding = getEncoding(from: encoding) - if httpMethod == "GET", let body = body, !body.isEmpty, body != "null", body != "undefined" { + let bodyIsEmpty = body == nil || (body)?.isEmpty == true || body == "null" || body == "undefined" + + if httpMethod == "GET" && !bodyIsEmpty { Logger.shared.log("GET request must not have a body", type: "Error") DispatchQueue.main.async { reject.call(withArguments: ["GET request must not have a body"]) @@ -140,8 +172,13 @@ extension JSContext { return } - if httpMethod != "GET", let body = body, !body.isEmpty, body != "null", body != "undefined" { - request.httpBody = body.data(using: .utf8) + if httpMethod != "GET" && !bodyIsEmpty { + if let bodyString = body { + request.httpBody = bodyString.data(using: .utf8) + } else { + let bodyString = String(describing: body!) + request.httpBody = bodyString.data(using: .utf8) + } } if let headers = headers { @@ -149,7 +186,8 @@ extension JSContext { request.setValue(value, forHTTPHeaderField: key) } } - Logger.shared.log("Redirect value is \(redirect.boolValue)", type: "Error") + + Logger.shared.log("Redirect value is \(redirect.boolValue)", type: "Debug") let session = URLSession.fetchData(allowRedirects: redirect.boolValue) let task = session.downloadTask(with: request) { tempFileURL, response, error in @@ -181,8 +219,13 @@ extension JSContext { var safeHeaders: [String: String] = [:] if let httpResponse = response as? HTTPURLResponse { for (key, value) in httpResponse.allHeaderFields { - if let keyString = key as? String, - let valueString = value as? String { + if let keyString = key as? String { + let valueString: String + if let str = value as? String { + valueString = str + } else { + valueString = String(describing: value) + } safeHeaders[keyString] = valueString } } @@ -225,43 +268,45 @@ extension JSContext { task.resume() } - self.setObject(fetchV2NativeFunction, forKeyedSubscript: "fetchV2Native" as NSString) let fetchv2Definition = """ - function fetchv2(url, headers = {}, method = "GET", body = null, redirect = true, encoding ) { - - - var processedBody = null; - if(method != "GET") - { - processedBody = (body && (typeof body === 'object')) ? JSON.stringify(body) : (body || null) - } - - var finalEncoding = encoding || "utf-8"; - - return new Promise(function(resolve, reject) { - fetchV2Native(url, headers, method, processedBody, redirect, finalEncoding, function(rawText) { - const responseObj = { - headers: rawText.headers, - status: rawText.status, - _data: rawText.body, - text: function() { - return Promise.resolve(this._data); - }, - json: function() { - try { - return Promise.resolve(JSON.parse(this._data)); - } catch (e) { - return Promise.reject("JSON parse error: " + e.message); - } - } - }; - resolve(responseObj); - }, reject); - }); - } + function fetchv2(url, headers = {}, method = "GET", body = null, redirect = true, encoding) { + + var processedBody = null; + if(method != "GET") { + processedBody = (body && (typeof body === 'object')) ? JSON.stringify(body) : (body || null) + } + + var finalEncoding = encoding || "utf-8"; + + // Ensure headers is an object and not null/undefined + var processedHeaders = {}; + if (headers && typeof headers === 'object' && !Array.isArray(headers)) { + processedHeaders = headers; + } + return new Promise(function(resolve, reject) { + fetchV2Native(url, processedHeaders, method, processedBody, redirect, finalEncoding, function(rawText) { + const responseObj = { + headers: rawText.headers, + status: rawText.status, + _data: rawText.body, + text: function() { + return Promise.resolve(this._data); + }, + json: function() { + try { + return Promise.resolve(JSON.parse(this._data)); + } catch (e) { + return Promise.reject("JSON parse error: " + e.message); + } + } + }; + resolve(responseObj); + }, reject); + }); + } """ self.evaluateScript(fetchv2Definition) } diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift index 77f378f..d487e4d 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift @@ -336,13 +336,11 @@ struct SettingsViewGeneral: View { .listStyle(.plain) .frame(height: CGFloat(metadataProvidersOrder.count * 65)) .background(Color.clear) - .padding(.bottom, 8) Text(NSLocalizedString("Drag to reorder", comment: "")) .font(.caption) .foregroundStyle(.gray) .frame(maxWidth: .infinity, alignment: .center) - .padding(.bottom, 8) } .environment(\.editMode, .constant(.active)) } @@ -446,13 +444,11 @@ struct SettingsViewGeneral: View { .listStyle(.plain) .frame(height: CGFloat(librarySectionsOrder.count * 70)) .background(Color.clear) - .padding(.bottom, 8) Text(NSLocalizedString("Drag to reorder sections", comment: "")) .font(.caption) .foregroundStyle(.gray) .frame(maxWidth: .infinity, alignment: .center) - .padding(.bottom, 8) } .environment(\.editMode, .constant(.active)) } diff --git a/Sulfur.xcodeproj/project.pbxproj b/Sulfur.xcodeproj/project.pbxproj index 071d8ba..eb72bea 100644 --- a/Sulfur.xcodeproj/project.pbxproj +++ b/Sulfur.xcodeproj/project.pbxproj @@ -633,8 +633,8 @@ 133D7C8A2D2BE2640075467E /* JSLoader */ = { isa = PBXGroup; children = ( - 04536F702E04BA3B00A11248 /* JSController-Novel.swift */, 134A387B2DE4B5B90041B687 /* Downloads */, + 04536F702E04BA3B00A11248 /* JSController-Novel.swift */, 133D7C8B2D2BE2640075467E /* JSController.swift */, 132AF1202D99951700A0140B /* JSController-Streams.swift */, 132AF1222D9995C300A0140B /* JSController-Details.swift */, From f5e0dc7cc77cfbc31e13ce4263044e0b88cf560f Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Tue, 1 Jul 2025 17:58:38 +0200 Subject: [PATCH 22/45] added back trackers view --- Sora/Views/SettingsView/SettingsView.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Sora/Views/SettingsView/SettingsView.swift b/Sora/Views/SettingsView/SettingsView.swift index 490c622..2c72061 100644 --- a/Sora/Views/SettingsView/SettingsView.swift +++ b/Sora/Views/SettingsView/SettingsView.swift @@ -177,6 +177,11 @@ struct SettingsView: View { NavigationLink(destination: SettingsViewDownloads().navigationBarBackButtonHidden(false)) { SettingsNavigationRow(icon: "arrow.down.circle", titleKey: "Downloads") } + Divider().padding(.horizontal, 16) + + NavigationLink(destination: SettingsViewTrackers().navigationBarBackButtonHidden(false)) { + SettingsNavigationRow(icon: "square.3.stack.3d", titleKey: "Trackers") + } } .background(.ultraThinMaterial) .clipShape(RoundedRectangle(cornerRadius: 12)) From 95d6d1ac1477e72b89db9c49a4ff021a172089cf Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Tue, 1 Jul 2025 18:05:13 +0200 Subject: [PATCH 23/45] removed non used files --- Sora/Views/SettingsSharedComponents.swift | 251 ------------------ .../Components/SettingsComponents.swift | 246 ----------------- 2 files changed, 497 deletions(-) delete mode 100644 Sora/Views/SettingsSharedComponents.swift delete mode 100644 Sora/Views/SettingsView/Components/SettingsComponents.swift diff --git a/Sora/Views/SettingsSharedComponents.swift b/Sora/Views/SettingsSharedComponents.swift deleted file mode 100644 index 75d3b62..0000000 --- a/Sora/Views/SettingsSharedComponents.swift +++ /dev/null @@ -1,251 +0,0 @@ -// -// SettingsSharedComponents.swift -// Sora -// - -import SwiftUI - -// MARK: - Settings Section -struct SettingsSection: View { - let title: String - let footer: String? - let content: Content - - init(title: String, footer: String? = nil, @ViewBuilder content: () -> Content) { - self.title = title - self.footer = footer - self.content = content() - } - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - Text(title.uppercased()) - .font(.footnote) - .foregroundStyle(.gray) - .padding(.horizontal, 20) - - VStack(spacing: 0) { - content - } - .background(.ultraThinMaterial) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .overlay( - RoundedRectangle(cornerRadius: 12) - .strokeBorder( - LinearGradient( - gradient: Gradient(stops: [ - .init(color: Color.accentColor.opacity(0.3), location: 0), - .init(color: Color.accentColor.opacity(0), location: 1) - ]), - startPoint: .top, - endPoint: .bottom - ), - lineWidth: 0.5 - ) - ) - .padding(.horizontal, 20) - - if let footer = footer { - Text(footer) - .font(.footnote) - .foregroundStyle(.gray) - .padding(.horizontal, 20) - .padding(.top, 4) - } - } - } -} - -// MARK: - Settings Row -struct SettingsRow: View { - let icon: String - let title: String - var value: String? = nil - var isExternal: Bool = false - var textColor: Color = .primary - var showDivider: Bool = true - - init(icon: String, title: String, value: String? = nil, isExternal: Bool = false, textColor: Color = .primary, showDivider: Bool = true) { - self.icon = icon - self.title = title - self.value = value - self.isExternal = isExternal - self.textColor = textColor - self.showDivider = showDivider - } - - var body: some View { - VStack(spacing: 0) { - HStack { - Image(systemName: icon) - .frame(width: 24, height: 24) - .foregroundStyle(textColor) - - Text(title) - .foregroundStyle(textColor) - - Spacer() - - if let value = value { - Text(value) - .foregroundStyle(.gray) - } - - if isExternal { - Image(systemName: "arrow.up.forward") - .foregroundStyle(.gray) - .font(.footnote) - } else { - Image(systemName: "chevron.right") - .foregroundStyle(.gray) - .font(.footnote) - } - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - - if showDivider { - Divider() - .padding(.horizontal, 16) - } - } - } -} - -// MARK: - Settings Toggle Row -struct SettingsToggleRow: View { - let icon: String - let title: String - @Binding var isOn: Bool - var showDivider: Bool = true - - init(icon: String, title: String, isOn: Binding, showDivider: Bool = true) { - self.icon = icon - self.title = title - self._isOn = isOn - self.showDivider = showDivider - } - - var body: some View { - VStack(spacing: 0) { - HStack { - Image(systemName: icon) - .frame(width: 24, height: 24) - .foregroundStyle(.primary) - - Text(title) - .foregroundStyle(.primary) - - Spacer() - - Toggle("", isOn: $isOn) - .labelsHidden() - .tint(.accentColor) - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - - if showDivider { - Divider() - .padding(.horizontal, 16) - } - } - } -} - -// MARK: - Settings Picker Row -struct SettingsPickerRow: View { - let icon: String - let title: String - let options: [T] - let optionToString: (T) -> String - @Binding var selection: T - var showDivider: Bool = true - - init(icon: String, title: String, options: [T], optionToString: @escaping (T) -> String, selection: Binding, showDivider: Bool = true) { - self.icon = icon - self.title = title - self.options = options - self.optionToString = optionToString - self._selection = selection - self.showDivider = showDivider - } - - var body: some View { - VStack(spacing: 0) { - HStack { - Image(systemName: icon) - .frame(width: 24, height: 24) - .foregroundStyle(.primary) - - Text(title) - .foregroundStyle(.primary) - - Spacer() - - Menu { - ForEach(options, id: \.self) { option in - Button(action: { selection = option }) { - Text(optionToString(option)) - } - } - } label: { - Text(optionToString(selection)) - .foregroundStyle(.gray) - } - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - - if showDivider { - Divider() - .padding(.horizontal, 16) - } - } - } -} - -// MARK: - Settings Stepper Row -struct SettingsStepperRow: View { - let icon: String - let title: String - @Binding var value: Double - let range: ClosedRange - let step: Double - var formatter: (Double) -> String = { "\(Int($0))" } - var showDivider: Bool = true - - init(icon: String, title: String, value: Binding, range: ClosedRange, step: Double, formatter: @escaping (Double) -> String = { "\(Int($0))" }, showDivider: Bool = true) { - self.icon = icon - self.title = title - self._value = value - self.range = range - self.step = step - self.formatter = formatter - self.showDivider = showDivider - } - - var body: some View { - VStack(spacing: 0) { - HStack { - Image(systemName: icon) - .frame(width: 24, height: 24) - .foregroundStyle(.primary) - - Text(title) - .foregroundStyle(.primary) - - Spacer() - - Stepper(formatter(value), value: $value, in: range, step: step) - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - - if showDivider { - Divider() - .padding(.horizontal, 16) - } - } - } -} \ No newline at end of file diff --git a/Sora/Views/SettingsView/Components/SettingsComponents.swift b/Sora/Views/SettingsView/Components/SettingsComponents.swift deleted file mode 100644 index 10c8acc..0000000 --- a/Sora/Views/SettingsView/Components/SettingsComponents.swift +++ /dev/null @@ -1,246 +0,0 @@ -// -// SettingsComponents.swift -// Sora -// - -import SwiftUI - -internal struct SettingsSection: View { - internal let title: String - internal let footer: String? - internal let content: Content - - internal init(title: String, footer: String? = nil, @ViewBuilder content: () -> Content) { - self.title = title - self.footer = footer - self.content = content() - } - - internal var body: some View { - VStack(alignment: .leading, spacing: 4) { - Text(title.uppercased()) - .font(.footnote) - .foregroundStyle(.gray) - .padding(.horizontal, 20) - - VStack(spacing: 0) { - content - } - .background(.ultraThinMaterial) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .overlay( - RoundedRectangle(cornerRadius: 12) - .strokeBorder( - LinearGradient( - gradient: Gradient(stops: [ - .init(color: Color.accentColor.opacity(0.3), location: 0), - .init(color: Color.accentColor.opacity(0), location: 1) - ]), - startPoint: .top, - endPoint: .bottom - ), - lineWidth: 0.5 - ) - ) - .padding(.horizontal, 20) - - if let footer = footer { - Text(footer) - .font(.footnote) - .foregroundStyle(.gray) - .padding(.horizontal, 20) - .padding(.top, 4) - } - } - } -} - -internal struct SettingsRow: View { - internal let icon: String - internal let title: String - internal var value: String? = nil - internal var isExternal: Bool = false - internal var textColor: Color = .primary - internal var showDivider: Bool = true - - internal init(icon: String, title: String, value: String? = nil, isExternal: Bool = false, textColor: Color = .primary, showDivider: Bool = true) { - self.icon = icon - self.title = title - self.value = value - self.isExternal = isExternal - self.textColor = textColor - self.showDivider = showDivider - } - - internal var body: some View { - VStack(spacing: 0) { - HStack { - Image(systemName: icon) - .frame(width: 24, height: 24) - .foregroundStyle(textColor) - - Text(title) - .foregroundStyle(textColor) - - Spacer() - - if let value = value { - Text(value) - .foregroundStyle(.gray) - } - - if isExternal { - Image(systemName: "arrow.up.forward") - .foregroundStyle(.gray) - .font(.footnote) - } else { - Image(systemName: "chevron.right") - .foregroundStyle(.gray) - .font(.footnote) - } - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - - if showDivider { - Divider() - .padding(.horizontal, 16) - } - } - } -} - -internal struct SettingsToggleRow: View { - internal let icon: String - internal let title: String - @Binding internal var isOn: Bool - internal var showDivider: Bool = true - - internal init(icon: String, title: String, isOn: Binding, showDivider: Bool = true) { - self.icon = icon - self.title = title - self._isOn = isOn - self.showDivider = showDivider - } - - internal var body: some View { - VStack(spacing: 0) { - HStack { - Image(systemName: icon) - .frame(width: 24, height: 24) - .foregroundStyle(.primary) - - Text(title) - .foregroundStyle(.primary) - - Spacer() - - Toggle("", isOn: $isOn) - .labelsHidden() - .tint(.accentColor) - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - - if showDivider { - Divider() - .padding(.horizontal, 16) - } - } - } -} - -internal struct SettingsPickerRow: View { - internal let icon: String - internal let title: String - internal let options: [T] - internal let optionToString: (T) -> String - @Binding internal var selection: T - internal var showDivider: Bool = true - - internal init(icon: String, title: String, options: [T], optionToString: @escaping (T) -> String, selection: Binding, showDivider: Bool = true) { - self.icon = icon - self.title = title - self.options = options - self.optionToString = optionToString - self._selection = selection - self.showDivider = showDivider - } - - internal var body: some View { - VStack(spacing: 0) { - HStack { - Image(systemName: icon) - .frame(width: 24, height: 24) - .foregroundStyle(.primary) - - Text(title) - .foregroundStyle(.primary) - - Spacer() - - Menu { - ForEach(options, id: \.self) { option in - Button(action: { selection = option }) { - Text(optionToString(option)) - } - } - } label: { - Text(optionToString(selection)) - .foregroundStyle(.gray) - } - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - - if showDivider { - Divider() - .padding(.horizontal, 16) - } - } - } -} - -internal struct SettingsStepperRow: View { - internal let icon: String - internal let title: String - @Binding internal var value: Double - internal let range: ClosedRange - internal let step: Double - internal var formatter: (Double) -> String = { "\(Int($0))" } - internal var showDivider: Bool = true - - internal init(icon: String, title: String, value: Binding, range: ClosedRange, step: Double, formatter: @escaping (Double) -> String = { "\(Int($0))" }, showDivider: Bool = true) { - self.icon = icon - self.title = title - self._value = value - self.range = range - self.step = step - self.formatter = formatter - self.showDivider = showDivider - } - - internal var body: some View { - VStack(spacing: 0) { - HStack { - Image(systemName: icon) - .frame(width: 24, height: 24) - .foregroundStyle(.primary) - - Text(title) - .foregroundStyle(.primary) - - Spacer() - - Stepper(formatter(value), value: $value, in: range, step: step) - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - - if showDivider { - Divider() - .padding(.horizontal, 16) - } - } - } -} \ No newline at end of file From d707858ad79b8204c67c5dadb8cb2474a5d22834 Mon Sep 17 00:00:00 2001 From: realdoomsboygaming <105606471+realdoomsboygaming@users.noreply.github.com> Date: Tue, 1 Jul 2025 11:08:14 -0500 Subject: [PATCH 24/45] Add autoplay feature and playback end handling (#214) - Introduced a new setting for "Autoplay Next" in the player settings. - Implemented autoplay functionality in the CustomMediaPlayerViewController to automatically start the next episode when playback ends. - Added notification observers for handling playback completion and managing the autoplay behavior. Co-authored-by: cranci <100066266+cranci1@users.noreply.github.com> --- .../CustomPlayer/CustomPlayer.swift | 28 +++++++++++++++++++ .../SettingsSubViews/SettingsViewPlayer.swift | 8 ++++++ 2 files changed, 36 insertions(+) diff --git a/Sora/MediaUtils/CustomPlayer/CustomPlayer.swift b/Sora/MediaUtils/CustomPlayer/CustomPlayer.swift index f5b1053..623cb50 100644 --- a/Sora/MediaUtils/CustomPlayer/CustomPlayer.swift +++ b/Sora/MediaUtils/CustomPlayer/CustomPlayer.swift @@ -77,6 +77,13 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele } return UserDefaults.standard.bool(forKey: "pipButtonVisible") } + + private var isAutoplayEnabled: Bool { + if UserDefaults.standard.object(forKey: "autoplayNext") == nil { + return true + } + return UserDefaults.standard.bool(forKey: "autoplayNext") + } private var pipController: AVPictureInPictureController? private var pipButton: UIButton! @@ -402,6 +409,13 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele twoFingerTapGesture.numberOfTouchesRequired = 2 twoFingerTapGesture.delegate = self view.addGestureRecognizer(twoFingerTapGesture) + + NotificationCenter.default.addObserver( + self, + selector: #selector(handlePlaybackEnded), + name: .AVPlayerItemDidPlayToEndTime, + object: player.currentItem + ) } override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { @@ -466,6 +480,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele timeUpdateTimer.invalidate() } + NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: nil) NotificationCenter.default.removeObserver(self) UIDevice.current.isBatteryMonitoringEnabled = false @@ -2997,6 +3012,19 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele batteryLabel?.text = "N/A" } } + + @objc private func handlePlaybackEnded() { + guard isAutoplayEnabled else { return } + + Logger.shared.log("Playback ended, autoplay enabled - starting next episode", type: "Debug") + + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in + self?.player.pause() + self?.dismiss(animated: true) { [weak self] in + self?.onWatchNext() + } + } + } } class GradientOverlayButton: UIButton { diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift index ccbca67..f34e5a3 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift @@ -205,6 +205,7 @@ struct SettingsViewPlayer: View { @AppStorage("doubleTapSeekEnabled") private var doubleTapSeekEnabled: Bool = false @AppStorage("skipIntroOutroVisible") private var skipIntroOutroVisible: Bool = true @AppStorage("pipButtonVisible") private var pipButtonVisible: Bool = true + @AppStorage("autoplayNext") private var autoplayNext: Bool = true @AppStorage("videoQualityWiFi") private var wifiQuality: String = VideoQualityPreference.defaultWiFiPreference.rawValue @AppStorage("videoQualityCellular") private var cellularQuality: String = VideoQualityPreference.defaultCellularPreference.rawValue @@ -247,6 +248,13 @@ struct SettingsViewPlayer: View { showDivider: true ) + SettingsToggleRow( + icon: "play.circle.fill", + title: NSLocalizedString("Autoplay Next", comment: ""), + isOn: $autoplayNext, + showDivider: true + ) + SettingsPickerRow( icon: "timer", title: NSLocalizedString("Completion Percentage", comment: ""), From 09b1d9b0b13ab840391a89767fad606f0bfbfe48 Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Tue, 1 Jul 2025 18:11:16 +0200 Subject: [PATCH 25/45] test crash fix + layout fix --- .../JSLoader/JSController-Details.swift | 146 ++++++++++++------ .../JSLoader/JSController-Novel.swift | 94 +++++++---- .../SettingsViewGeneral.swift | 2 + Sulfur.xcodeproj/project.pbxproj | 2 +- 4 files changed, 167 insertions(+), 77 deletions(-) diff --git a/Sora/Utlis & Misc/JSLoader/JSController-Details.swift b/Sora/Utlis & Misc/JSLoader/JSController-Details.swift index 9c79f0f..ea2b8ae 100644 --- a/Sora/Utlis & Misc/JSLoader/JSController-Details.swift +++ b/Sora/Utlis & Misc/JSLoader/JSController-Details.swift @@ -9,7 +9,6 @@ import Foundation import JavaScriptCore extension JSController { - func fetchDetails(url: String, completion: @escaping ([MediaItem], [EpisodeLink]) -> Void) { guard let url = URL(string: url) else { completion([], []) @@ -94,41 +93,64 @@ extension JSController { let dispatchGroup = DispatchGroup() dispatchGroup.enter() + var hasLeftDetailsGroup = false + let detailsGroupQueue = DispatchQueue(label: "details.group") + let promiseValueDetails = extractDetailsFunction.call(withArguments: [url.absoluteString]) guard let promiseDetails = promiseValueDetails else { Logger.shared.log("extractDetails did not return a Promise", type: "Error") - dispatchGroup.leave() + detailsGroupQueue.sync { + guard !hasLeftDetailsGroup else { return } + hasLeftDetailsGroup = true + dispatchGroup.leave() + } completion([], []) return } let thenBlockDetails: @convention(block) (JSValue) -> Void = { result in - if let jsonOfDetails = result.toString(), - let dataDetails = jsonOfDetails.data(using: .utf8) { - do { - if let array = try JSONSerialization.jsonObject(with: dataDetails, options: []) as? [[String: Any]] { - resultItems = array.map { item -> MediaItem in - MediaItem( - description: item["description"] as? String ?? "", - aliases: item["aliases"] as? String ?? "", - airdate: item["airdate"] as? String ?? "" - ) - } - } else { - Logger.shared.log("Failed to parse JSON of extractDetails", type: "Error") - } - } catch { - Logger.shared.log("JSON parsing error of extract details: \(error)", type: "Error") + detailsGroupQueue.sync { + guard !hasLeftDetailsGroup else { + Logger.shared.log("extractDetails: thenBlock called but group already left", type: "Debug") + return } - } else { - Logger.shared.log("Result is not a string of extractDetails", type: "Error") + hasLeftDetailsGroup = true + + if let jsonOfDetails = result.toString(), + let dataDetails = jsonOfDetails.data(using: .utf8) { + do { + if let array = try JSONSerialization.jsonObject(with: dataDetails, options: []) as? [[String: Any]] { + resultItems = array.map { item -> MediaItem in + MediaItem( + description: item["description"] as? String ?? "", + aliases: item["aliases"] as? String ?? "", + airdate: item["airdate"] as? String ?? "" + ) + } + } else { + Logger.shared.log("Failed to parse JSON of extractDetails", type: "Error") + } + } catch { + Logger.shared.log("JSON parsing error of extract details: \(error)", type: "Error") + } + } else { + Logger.shared.log("Result is not a string of extractDetails", type: "Error") + } + dispatchGroup.leave() } - dispatchGroup.leave() } let catchBlockDetails: @convention(block) (JSValue) -> Void = { error in - Logger.shared.log("Promise rejected of extractDetails: \(String(describing: error.toString()))", type: "Error") - dispatchGroup.leave() + detailsGroupQueue.sync { + guard !hasLeftDetailsGroup else { + Logger.shared.log("extractDetails: catchBlock called but group already left", type: "Debug") + return + } + hasLeftDetailsGroup = true + + Logger.shared.log("Promise rejected of extractDetails: \(String(describing: error.toString()))", type: "Error") + dispatchGroup.leave() + } } let thenFunctionDetails = JSValue(object: thenBlockDetails, in: context) @@ -140,50 +162,80 @@ extension JSController { dispatchGroup.enter() let promiseValueEpisodes = extractEpisodesFunction.call(withArguments: [url.absoluteString]) + var hasLeftEpisodesGroup = false + let episodesGroupQueue = DispatchQueue(label: "episodes.group") + let timeoutWorkItem = DispatchWorkItem { Logger.shared.log("Timeout for extractEpisodes", type: "Warning") - dispatchGroup.leave() + episodesGroupQueue.sync { + guard !hasLeftEpisodesGroup else { + Logger.shared.log("extractEpisodes: timeout called but group already left", type: "Debug") + return + } + hasLeftEpisodesGroup = true + dispatchGroup.leave() + } } DispatchQueue.main.asyncAfter(deadline: .now() + 5.0, execute: timeoutWorkItem) guard let promiseEpisodes = promiseValueEpisodes else { Logger.shared.log("extractEpisodes did not return a Promise", type: "Error") timeoutWorkItem.cancel() - dispatchGroup.leave() + episodesGroupQueue.sync { + guard !hasLeftEpisodesGroup else { return } + hasLeftEpisodesGroup = true + dispatchGroup.leave() + } completion([], []) return } let thenBlockEpisodes: @convention(block) (JSValue) -> Void = { result in timeoutWorkItem.cancel() - if let jsonOfEpisodes = result.toString(), - let dataEpisodes = jsonOfEpisodes.data(using: .utf8) { - do { - if let array = try JSONSerialization.jsonObject(with: dataEpisodes, options: []) as? [[String: Any]] { - episodeLinks = array.map { item -> EpisodeLink in - EpisodeLink( - number: item["number"] as? Int ?? 0, - title: "", - href: item["href"] as? String ?? "", - duration: nil - ) - } - } else { - Logger.shared.log("Failed to parse JSON of extractEpisodes", type: "Error") - } - } catch { - Logger.shared.log("JSON parsing error of extractEpisodes: \(error)", type: "Error") + episodesGroupQueue.sync { + guard !hasLeftEpisodesGroup else { + Logger.shared.log("extractEpisodes: thenBlock called but group already left", type: "Debug") + return } - } else { - Logger.shared.log("Result is not a string of extractEpisodes", type: "Error") + hasLeftEpisodesGroup = true + + if let jsonOfEpisodes = result.toString(), + let dataEpisodes = jsonOfEpisodes.data(using: .utf8) { + do { + if let array = try JSONSerialization.jsonObject(with: dataEpisodes, options: []) as? [[String: Any]] { + episodeLinks = array.map { item -> EpisodeLink in + EpisodeLink( + number: item["number"] as? Int ?? 0, + title: "", + href: item["href"] as? String ?? "", + duration: nil + ) + } + } else { + Logger.shared.log("Failed to parse JSON of extractEpisodes", type: "Error") + } + } catch { + Logger.shared.log("JSON parsing error of extractEpisodes: \(error)", type: "Error") + } + } else { + Logger.shared.log("Result is not a string of extractEpisodes", type: "Error") + } + dispatchGroup.leave() } - dispatchGroup.leave() } let catchBlockEpisodes: @convention(block) (JSValue) -> Void = { error in timeoutWorkItem.cancel() - Logger.shared.log("Promise rejected of extractEpisodes: \(String(describing: error.toString()))", type: "Error") - dispatchGroup.leave() + episodesGroupQueue.sync { + guard !hasLeftEpisodesGroup else { + Logger.shared.log("extractEpisodes: catchBlock called but group already left", type: "Debug") + return + } + hasLeftEpisodesGroup = true + + Logger.shared.log("Promise rejected of extractEpisodes: \(String(describing: error.toString()))", type: "Error") + dispatchGroup.leave() + } } let thenFunctionEpisodes = JSValue(object: thenBlockEpisodes, in: context) diff --git a/Sora/Utlis & Misc/JSLoader/JSController-Novel.swift b/Sora/Utlis & Misc/JSLoader/JSController-Novel.swift index ab446fc..12d04c4 100644 --- a/Sora/Utlis & Misc/JSLoader/JSController-Novel.swift +++ b/Sora/Utlis & Misc/JSLoader/JSController-Novel.swift @@ -59,30 +59,48 @@ extension JSController { let group = DispatchGroup() group.enter() var chaptersArr: [[String: Any]] = [] + var hasLeftGroup = false + let groupQueue = DispatchQueue(label: "extractChapters.group") + let thenBlock: @convention(block) (JSValue) -> Void = { jsValue in Logger.shared.log("extractChapters thenBlock: \(jsValue)", type: "Debug") - if let arr = jsValue.toArray() as? [[String: Any]] { - Logger.shared.log("extractChapters: parsed as array, count = \(arr.count)", type: "Debug") - chaptersArr = arr - } else if let jsonString = jsValue.toString(), let data = jsonString.data(using: .utf8) { - do { - if let arr = try JSONSerialization.jsonObject(with: data) as? [[String: Any]] { - Logger.shared.log("extractChapters: parsed as JSON string, count = \(arr.count)", type: "Debug") - chaptersArr = arr - } else { - Logger.shared.log("extractChapters: JSON string did not parse to array", type: "Error") - } - } catch { - Logger.shared.log("JSON parsing error of extractChapters: \(error)", type: "Error") + groupQueue.sync { + guard !hasLeftGroup else { + Logger.shared.log("extractChapters: thenBlock called but group already left", type: "Debug") + return } - } else { - Logger.shared.log("extractChapters: could not parse result", type: "Error") + hasLeftGroup = true + + if let arr = jsValue.toArray() as? [[String: Any]] { + Logger.shared.log("extractChapters: parsed as array, count = \(arr.count)", type: "Debug") + chaptersArr = arr + } else if let jsonString = jsValue.toString(), let data = jsonString.data(using: .utf8) { + do { + if let arr = try JSONSerialization.jsonObject(with: data) as? [[String: Any]] { + Logger.shared.log("extractChapters: parsed as JSON string, count = \(arr.count)", type: "Debug") + chaptersArr = arr + } else { + Logger.shared.log("extractChapters: JSON string did not parse to array", type: "Error") + } + } catch { + Logger.shared.log("JSON parsing error of extractChapters: \(error)", type: "Error") + } + } else { + Logger.shared.log("extractChapters: could not parse result", type: "Error") + } + group.leave() } - group.leave() } let catchBlock: @convention(block) (JSValue) -> Void = { jsValue in Logger.shared.log("extractChapters catchBlock: \(jsValue)", type: "Error") - group.leave() + groupQueue.sync { + guard !hasLeftGroup else { + Logger.shared.log("extractChapters: catchBlock called but group already left", type: "Debug") + return + } + hasLeftGroup = true + group.leave() + } } result.invokeMethod("then", withArguments: [thenBlock]) result.invokeMethod("catch", withArguments: [catchBlock]) @@ -182,24 +200,42 @@ extension JSController { group.enter() var extractedText = "" var extractError: Error? = nil + var hasLeftGroup = false + let groupQueue = DispatchQueue(label: "extractText.group") let thenBlock: @convention(block) (JSValue) -> Void = { jsValue in Logger.shared.log("extractText thenBlock: received value", type: "Debug") - if let text = jsValue.toString(), !text.isEmpty { - Logger.shared.log("extractText: successfully extracted text", type: "Debug") - extractedText = text - } else { - extractError = JSError.emptyContent + groupQueue.sync { + guard !hasLeftGroup else { + Logger.shared.log("extractText: thenBlock called but group already left", type: "Debug") + return + } + hasLeftGroup = true + + if let text = jsValue.toString(), !text.isEmpty { + Logger.shared.log("extractText: successfully extracted text", type: "Debug") + extractedText = text + } else { + extractError = JSError.emptyContent + } + group.leave() } - group.leave() } let catchBlock: @convention(block) (JSValue) -> Void = { jsValue in Logger.shared.log("extractText catchBlock: \(jsValue)", type: "Error") - if extractedText.isEmpty { - extractError = JSError.jsException(jsValue.toString() ?? "Unknown error") + groupQueue.sync { + guard !hasLeftGroup else { + Logger.shared.log("extractText: catchBlock called but group already left", type: "Debug") + return + } + hasLeftGroup = true + + if extractedText.isEmpty { + extractError = JSError.jsException(jsValue.toString() ?? "Unknown error") + } + group.leave() } - group.leave() } result.invokeMethod("then", withArguments: [thenBlock]) @@ -277,8 +313,8 @@ extension JSController { let (data, response) = try await URLSession.shared.data(for: request) - guard let httpResponse = response as? HTTPURLResponse, - (200...299).contains(httpResponse.statusCode) else { + guard let httpResponse = response as? HTTPURLResponse, + (200...299).contains(httpResponse.statusCode) else { Logger.shared.log("Direct fetch failed with status code: \((response as? HTTPURLResponse)?.statusCode ?? -1)", type: "Error") throw JSError.invalidResponse } @@ -317,4 +353,4 @@ extension JSController { Logger.shared.log("Direct fetch successful, content length: \(content.count)", type: "Debug") return content } -} +} diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift index d487e4d..ee3085f 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift @@ -341,6 +341,8 @@ struct SettingsViewGeneral: View { .font(.caption) .foregroundStyle(.gray) .frame(maxWidth: .infinity, alignment: .center) + .padding(.top, -6) + .padding(.bottom, 8) } .environment(\.editMode, .constant(.active)) } diff --git a/Sulfur.xcodeproj/project.pbxproj b/Sulfur.xcodeproj/project.pbxproj index eb72bea..c9bf7ef 100644 --- a/Sulfur.xcodeproj/project.pbxproj +++ b/Sulfur.xcodeproj/project.pbxproj @@ -634,8 +634,8 @@ isa = PBXGroup; children = ( 134A387B2DE4B5B90041B687 /* Downloads */, - 04536F702E04BA3B00A11248 /* JSController-Novel.swift */, 133D7C8B2D2BE2640075467E /* JSController.swift */, + 04536F702E04BA3B00A11248 /* JSController-Novel.swift */, 132AF1202D99951700A0140B /* JSController-Streams.swift */, 132AF1222D9995C300A0140B /* JSController-Details.swift */, 132AF1242D9995F900A0140B /* JSController-Search.swift */, From 52e7101472786eb99fded67943814dada328bbe4 Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Tue, 1 Jul 2025 18:21:49 +0200 Subject: [PATCH 26/45] nice --- .../SettingsView/SettingsSubViews/SettingsViewGeneral.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift index ee3085f..6751cda 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift @@ -451,6 +451,8 @@ struct SettingsViewGeneral: View { .font(.caption) .foregroundStyle(.gray) .frame(maxWidth: .infinity, alignment: .center) + .padding(.top, -6) + .padding(.bottom, 8) } .environment(\.editMode, .constant(.active)) } From 3974fc7003657d88fd89819be0d3f04bc97feaa7 Mon Sep 17 00:00:00 2001 From: realdoomsboygaming <105606471+realdoomsboygaming@users.noreply.github.com> Date: Wed, 2 Jul 2025 10:00:30 -0500 Subject: [PATCH 27/45] Add downloaded indicator to EpisodeCell (#215) --- Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift index c074a62..bdbfa69 100644 --- a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift +++ b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift @@ -149,6 +149,10 @@ private extension EpisodeCell { episodeThumbnail episodeInfo Spacer() + if case .downloaded = downloadStatus { + downloadedIndicator + .padding(.trailing, 8) + } CircularProgressBar(progress: currentProgress) .frame(width: 40, height: 40) .padding(.trailing, 4) @@ -228,6 +232,12 @@ private extension EpisodeCell { } } + var downloadedIndicator: some View { + Image(systemName: "folder.fill") + .foregroundColor(.accentColor) + .font(.system(size: 18)) + } + var contextMenuContent: some View { Group { if progress <= 0.9 { From 0a411e8421d4cb1a2e00132a7dff9d5af8e9e28a Mon Sep 17 00:00:00 2001 From: cranci <100066266+cranci1@users.noreply.github.com> Date: Wed, 2 Jul 2025 17:01:26 +0200 Subject: [PATCH 28/45] better download icon --- Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift index bdbfa69..0bca941 100644 --- a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift +++ b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift @@ -233,7 +233,7 @@ private extension EpisodeCell { } var downloadedIndicator: some View { - Image(systemName: "folder.fill") + Image(systemName: "externaldrive.fill.badge.checkmark") .foregroundColor(.accentColor) .font(.system(size: 18)) } From 4aaf5ab518301e41f9b78cec58d0c21eb73c1de8 Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Sat, 5 Jul 2025 16:04:17 +0200 Subject: [PATCH 29/45] removed paul tabs --- Sora/ContentView.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Sora/ContentView.swift b/Sora/ContentView.swift index e10f765..ac124f0 100644 --- a/Sora/ContentView.swift +++ b/Sora/ContentView.swift @@ -120,5 +120,3 @@ struct TabBarVisibilityKey: PreferenceKey { value = nextValue() } } - - From 508dcd4a42cc454e83160bff3dbba8e582629e60 Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Sat, 5 Jul 2025 16:29:55 +0200 Subject: [PATCH 30/45] fixed TMDB not being primary choice when nil (Seiike fault ofc) --- Sora/Views/MediaInfoView/MediaInfoView.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index 50be738..cb88432 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -89,11 +89,11 @@ struct MediaInfoView: View { @Environment(\.verticalSizeClass) private var verticalSizeClass @AppStorage("metadataProvidersOrder") private var metadataProvidersOrderData: Data = { - try! JSONEncoder().encode(["AniList","TMDB"]) + try! JSONEncoder().encode(["TMDB","AniList"]) }() private var metadataProvidersOrder: [String] { - get { (try? JSONDecoder().decode([String].self, from: metadataProvidersOrderData)) ?? ["AniList","TMDB"] } + get { (try? JSONDecoder().decode([String].self, from: metadataProvidersOrderData)) ?? ["TMDB","AniList"] } set { metadataProvidersOrderData = try! JSONEncoder().encode(newValue) } } @@ -1590,7 +1590,7 @@ struct MediaInfoView: View { func checkCompletion() { guard aniListCompleted && tmdbCompleted else { return } - let primaryProvider = order.first ?? "AniList" + let primaryProvider = order.first ?? "TMDB" if primaryProvider == "AniList" && aniListSuccess { activeProvider = "AniList" From 5d0c3fd97791370b6970e13bda9c88035d857b56 Mon Sep 17 00:00:00 2001 From: ibro <54913038+xibrox@users.noreply.github.com> Date: Sun, 6 Jul 2025 15:53:07 +0200 Subject: [PATCH 31/45] Fixed chapters not loading (#216) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * hey * mediainfoview fixed chapters not loading * added back that * ts annoying 🥀 --- Sora/Views/MediaInfoView/MediaInfoView.swift | 24 +++++--------------- 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index cb88432..da95922 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -209,7 +209,7 @@ struct MediaInfoView: View { currentFetchTask?.cancel() activeFetchID = nil UserDefaults.standard.set(false, forKey: "isMediaInfoActive") - UIScrollView.appearance().bounces = true + UIScrollView.appearance().bounces = true } .task { await setupInitialData() @@ -239,7 +239,7 @@ struct MediaInfoView: View { private var navigationOverlay: some View { VStack { HStack { - Button(action: { + Button(action: { dismiss() }) { Image(systemName: "chevron.left") @@ -729,7 +729,7 @@ struct MediaInfoView: View { LazyVStack(spacing: 15) { ForEach(chapters.indices.filter { selectedChapterRange.contains($0) }, id: \..self) { i in let chapter = chapters[i] - let _ = refreshTrigger + let _ = refreshTrigger if let href = chapter["href"] as? String, let number = chapter["number"] as? Int, let title = chapter["title"] as? String { @@ -751,7 +751,7 @@ struct MediaInfoView: View { ChapterCell( chapterNumber: String(number), chapterTitle: title, - isCurrentChapter: false, + isCurrentChapter: false, progress: UserDefaults.standard.double(forKey: "readingProgress_\(href)"), href: href ) @@ -868,7 +868,7 @@ struct MediaInfoView: View { .sheet(isPresented: $isMatchingPresented) { AnilistMatchPopupView(seriesTitle: title) { id, matched in handleAniListMatch(selectedID: id) - matchedTitle = matched + matchedTitle = matched fetchMetadataIDIfNeeded() } } @@ -876,7 +876,7 @@ struct MediaInfoView: View { TMDBMatchPopupView(seriesTitle: title) { id, type, matched in tmdbID = id tmdbType = type - matchedTitle = matched + matchedTitle = matched fetchMetadataIDIfNeeded() } } @@ -973,8 +973,6 @@ struct MediaInfoView: View { await withTaskGroup(of: Void.self) { group in var chaptersLoaded = false var detailsLoaded = false - let timeout: TimeInterval = 8.0 - let start = Date() group.addTask { let fetchedChapters = try? await JSController.shared.extractChapters(moduleId: module.id.uuidString, href: href) @@ -997,9 +995,6 @@ struct MediaInfoView: View { if !(self.synopsis.isEmpty && self.aliases.isEmpty && self.airdate.isEmpty) { detailsLoaded = true continuation.resume() - } else if Date().timeIntervalSince(start) > timeout { - detailsLoaded = true - continuation.resume() } else { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { checkDetails() @@ -1010,13 +1005,6 @@ struct MediaInfoView: View { checkDetails() } } - group.addTask { - try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) - await MainActor.run { - chaptersLoaded = true - detailsLoaded = true - } - } while true { let loaded = await MainActor.run { chaptersLoaded && detailsLoaded } if loaded { break } From b9523c259f202a8a949d33bdc0a26ad75b3ed4af Mon Sep 17 00:00:00 2001 From: cranci <100066266+cranci1@users.noreply.github.com> Date: Sun, 6 Jul 2025 16:06:49 +0200 Subject: [PATCH 32/45] Update README.md --- README.md | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/README.md b/README.md index 3d358db..3710815 100644 --- a/README.md +++ b/README.md @@ -35,17 +35,7 @@ ## Installation -You can download Sora on the App Store for stable updates or on Testflight for more updates but maybe some instability. (Testflight is recommended): - - - Build and Release IPA - - - - Build and Release IPA - - -Additionally, you can install the app using Xcode or using the .ipa file, which you can find in the [Releases](https://github.com/cranci1/Sora/releases) tab or the [nightly](https://nightly.link/cranci1/Sora/workflows/build/dev/Sulfur-IPA.zip) build page. +You can download Sora using Xcode or using the .ipa file, which you can find in the [Releases](https://github.com/cranci1/Sora/releases) tab or the [Nightly](https://nightly.link/cranci1/Sora/workflows/build/dev/Sulfur-IPA.zip) build page. ## Frequently Asked Questions From 501a3da48f56eab12cf64e7d2018fe52911e9f2f Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Mon, 7 Jul 2025 10:15:36 +0200 Subject: [PATCH 33/45] fixed recent search issues --- .../Extensions/Notification+Name.swift | 1 + Sora/Utlis & Misc/TabBar/TabBar.swift | 11 +++++++-- Sora/Views/SearchView/SearchView.swift | 6 +++++ .../SettingsSubViews/SettingsViewPlayer.swift | 23 ------------------- 4 files changed, 16 insertions(+), 25 deletions(-) diff --git a/Sora/Utlis & Misc/Extensions/Notification+Name.swift b/Sora/Utlis & Misc/Extensions/Notification+Name.swift index 2f82dad..33b3d4d 100644 --- a/Sora/Utlis & Misc/Extensions/Notification+Name.swift +++ b/Sora/Utlis & Misc/Extensions/Notification+Name.swift @@ -22,4 +22,5 @@ extension Notification.Name { static let hideTabBar = Notification.Name("hideTabBar") static let showTabBar = Notification.Name("showTabBar") static let searchQueryChanged = Notification.Name("searchQueryChanged") + static let tabBarSearchQueryUpdated = Notification.Name("tabBarSearchQueryUpdated") } diff --git a/Sora/Utlis & Misc/TabBar/TabBar.swift b/Sora/Utlis & Misc/TabBar/TabBar.swift index 52acb8d..0b383c0 100644 --- a/Sora/Utlis & Misc/TabBar/TabBar.swift +++ b/Sora/Utlis & Misc/TabBar/TabBar.swift @@ -24,7 +24,7 @@ extension Color { default: (r, g, b, a) = (1, 1, 1, 1) } - + self.init( .sRGB, red: Double(r) / 255, @@ -180,7 +180,7 @@ struct TabBar: View { .padding(.bottom, -100) .padding(.top, -10) } - .offset(y: keyboardFocus ? -keyboardHeight + 40 : 0) + .offset(y: keyboardFocus ? -keyboardHeight + 40 : 0) .animation(.spring(response: 0.3, dampingFraction: 0.8), value: keyboardHeight) .animation(.spring(response: 0.3, dampingFraction: 0.8), value: keyboardFocus) .onChange(of: keyboardHeight) { newValue in @@ -197,10 +197,17 @@ struct TabBar: View { NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillHideNotification, object: nil, queue: .main) { _ in keyboardHeight = 0 } + + NotificationCenter.default.addObserver(forName: .tabBarSearchQueryUpdated, object: nil, queue: .main) { notification in + if let query = notification.userInfo?["searchQuery"] as? String { + searchQuery = query + } + } } .onDisappear { NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillShowNotification, object: nil) NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillHideNotification, object: nil) + NotificationCenter.default.removeObserver(self, name: .tabBarSearchQueryUpdated, object: nil) } } diff --git a/Sora/Views/SearchView/SearchView.swift b/Sora/Views/SearchView/SearchView.swift index 5a468e8..e251581 100644 --- a/Sora/Views/SearchView/SearchView.swift +++ b/Sora/Views/SearchView/SearchView.swift @@ -108,6 +108,12 @@ struct SearchView: View { cellWidth: cellWidth, onHistoryItemSelected: { query in searchQuery = query + searchDebounceTimer?.invalidate() + + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + NotificationCenter.default.post(name: .tabBarSearchQueryUpdated, object: nil, userInfo: ["searchQuery": query]) + + performSearch() }, onHistoryItemDeleted: { index in removeFromHistory(at: index) diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift index f34e5a3..6b13746 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift @@ -304,29 +304,6 @@ struct SettingsViewPlayer: View { ) } - SettingsSection(title: NSLocalizedString("Progress bar Marker Color", comment: "")) { - ColorPicker(NSLocalizedString("Segments Color", comment: ""), selection: Binding( - get: { - if let data = UserDefaults.standard.data(forKey: "segmentsColorData"), - let uiColor = try? NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(data) as? UIColor { - return Color(uiColor) - } - return .yellow - }, - set: { newColor in - let uiColor = UIColor(newColor) - if let data = try? NSKeyedArchiver.archivedData( - withRootObject: uiColor, - requiringSecureCoding: false - ) { - UserDefaults.standard.set(data, forKey: "segmentsColorData") - } - } - )) - .padding(.horizontal, 16) - .padding(.vertical, 12) - } - SettingsSection( title: NSLocalizedString("Skip Settings", comment: ""), footer: NSLocalizedString("Double tapping the screen on it's sides will skip with the short tap setting.", comment: "") From bca07c9c46258d481accfc88a7b18c5ac166abeb Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Mon, 7 Jul 2025 10:17:10 +0200 Subject: [PATCH 34/45] fixed logger issue --- Sora/SoraApp.swift | 4 ++-- Sora/Tracking & Metadata/Trakt/Auth/Trakt-Token.swift | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Sora/SoraApp.swift b/Sora/SoraApp.swift index 95e8cd4..bca9e78 100644 --- a/Sora/SoraApp.swift +++ b/Sora/SoraApp.swift @@ -23,9 +23,9 @@ struct SoraApp: App { TraktToken.checkAuthenticationStatus { isAuthenticated in if isAuthenticated { - Logger.shared.log("Trakt authentication is valid") + Logger.shared.log("Trakt authentication is valid", type: "Debug") } else { - Logger.shared.log("Trakt authentication required", type: "Error") + Logger.shared.log("Trakt authentication required", type: "Debug") } } } diff --git a/Sora/Tracking & Metadata/Trakt/Auth/Trakt-Token.swift b/Sora/Tracking & Metadata/Trakt/Auth/Trakt-Token.swift index 6e726cf..daddc6a 100644 --- a/Sora/Tracking & Metadata/Trakt/Auth/Trakt-Token.swift +++ b/Sora/Tracking & Metadata/Trakt/Auth/Trakt-Token.swift @@ -159,8 +159,8 @@ class TraktToken { guard status == errSecSuccess, let tokenData = result as? Data, let token = String(data: tokenData, encoding: .utf8) else { - return nil - } + return nil + } return token } @@ -179,8 +179,8 @@ class TraktToken { guard status == errSecSuccess, let tokenData = result as? Data, let token = String(data: tokenData, encoding: .utf8) else { - return nil - } + return nil + } return token } From 5607ec70ff204913ecf5335910e400226c7b804a Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Mon, 7 Jul 2025 10:23:25 +0200 Subject: [PATCH 35/45] =?UTF-8?q?what=20was=20this=20monster=20?= =?UTF-8?q?=F0=9F=98=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sora/Views/MediaInfoView/MediaInfoView.swift | 188 ++++++++----------- 1 file changed, 78 insertions(+), 110 deletions(-) diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index da95922..25cb00b 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -189,12 +189,12 @@ struct MediaInfoView: View { UserDefaults.standard.set(true, forKey: "isMediaInfoActive") // swipe back /* - 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 = false - } - */ + 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 = false + } + */ } .onChange(of: selectedRange) { newValue in UserDefaults.standard.set(newValue.lowerBound, forKey: selectedRangeKey) @@ -404,7 +404,7 @@ struct MediaInfoView: View { .foregroundColor(.secondary) .lineLimit(showFullSynopsis ? nil : 3) .animation(nil, value: showFullSynopsis) - + HStack { Spacer() Text(showFullSynopsis ? NSLocalizedString("LESS", comment: "") : NSLocalizedString("MORE", comment: "")) @@ -487,7 +487,7 @@ struct MediaInfoView: View { .cornerRadius(15) .gradientOutline() } - + Button(action: { openSafariViewController(with: href) }) { Image(systemName: "safari") .resizable() @@ -557,7 +557,7 @@ struct MediaInfoView: View { } } } - + @ViewBuilder private var seasonSelectorStyled: some View { let seasons = groupedEpisodes() @@ -581,7 +581,7 @@ struct MediaInfoView: View { } } } - + @ViewBuilder private var rangeSelectorStyled: some View { Menu { @@ -719,17 +719,17 @@ struct MediaInfoView: View { menuButton } } - if chapters.count > chapterChunkSize { - HStack { - Spacer() - chapterRangeSelectorStyled - } - .padding(.bottom, 0) + if chapters.count > chapterChunkSize { + HStack { + Spacer() + chapterRangeSelectorStyled } - LazyVStack(spacing: 15) { - ForEach(chapters.indices.filter { selectedChapterRange.contains($0) }, id: \..self) { i in - let chapter = chapters[i] - let _ = refreshTrigger + .padding(.bottom, 0) + } + LazyVStack(spacing: 15) { + ForEach(chapters.indices.filter { selectedChapterRange.contains($0) }, id: \..self) { i in + let chapter = chapters[i] + let _ = refreshTrigger if let href = chapter["href"] as? String, let number = chapter["number"] as? Int, let title = chapter["title"] as? String { @@ -792,7 +792,7 @@ struct MediaInfoView: View { } } } - + @ViewBuilder private var chapterRangeSelectorStyled: some View { Menu { @@ -952,7 +952,7 @@ struct MediaInfoView: View { UserDefaults.standard.set(imageUrl, forKey: "mediaInfoImageUrl_\(module.id.uuidString)") Logger.shared.log("Saved MediaInfoView image URL: \(imageUrl) for module \(module.id.uuidString)", type: "Debug") - + if module.metadata.novel == true { if !hasFetched { @@ -969,11 +969,11 @@ struct MediaInfoView: View { if let jsContent = jsContent { jsController.loadScript(jsContent) } - + await withTaskGroup(of: Void.self) { group in var chaptersLoaded = false var detailsLoaded = false - + group.addTask { let fetchedChapters = try? await JSController.shared.extractChapters(moduleId: module.id.uuidString, href: href) await MainActor.run { @@ -1429,7 +1429,6 @@ struct MediaInfoView: View { } } - func fetchDetails() { Logger.shared.log("fetchDetails: called", type: "Debug") DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { @@ -1437,88 +1436,15 @@ struct MediaInfoView: View { do { let jsContent = try moduleManager.getModuleContent(module) jsController.loadScript(jsContent) + + let completion: (Any?, [EpisodeLink]) -> Void = { items, episodes in + self.handleFetchDetailsResponse(items: items, episodes: episodes) + } + if module.metadata.asyncJS == true { - jsController.fetchDetailsJS(url: href) { items, episodes in - Logger.shared.log("fetchDetails: items = \(items)", type: "Debug") - Logger.shared.log("fetchDetails: episodes = \(episodes)", type: "Debug") - - if let mediaItems = items as? [MediaItem], let item = mediaItems.first { - self.synopsis = item.description - self.aliases = item.aliases - self.airdate = item.airdate - } else if let str = items as? String { - if let data = str.data(using: .utf8), - let arr = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]], - let dict = arr.first { - self.synopsis = dict["description"] as? String ?? "" - self.aliases = dict["aliases"] as? String ?? "" - self.airdate = dict["airdate"] as? String ?? "" - } - } else if let dict = items as? [String: Any] { - self.synopsis = dict["description"] as? String ?? "" - self.aliases = dict["aliases"] as? String ?? "" - self.airdate = dict["airdate"] as? String ?? "" - } else if let arr = items as? [[String: Any]], let dict = arr.first { - self.synopsis = dict["description"] as? String ?? "" - self.aliases = dict["aliases"] as? String ?? "" - self.airdate = dict["airdate"] as? String ?? "" - } else { - Logger.shared.log("Failed to process items of type: \(type(of: items))", type: "Error") - } - - if self.module.metadata.novel ?? false { - Logger.shared.log("fetchDetails: (novel) chapters count = \(self.chapters.count)", type: "Debug") - self.isLoading = false - self.isRefetching = false - } else { - Logger.shared.log("fetchDetails: (episodes) episodes count = \(episodes.count)", type: "Debug") - self.episodeLinks = episodes - self.restoreSelectionState() - self.isLoading = false - self.isRefetching = false - } - } + jsController.fetchDetailsJS(url: href, completion: completion) } else { - jsController.fetchDetails(url: href) { items, episodes in - Logger.shared.log("fetchDetails: items = \(items)", type: "Debug") - Logger.shared.log("fetchDetails: episodes = \(episodes)", type: "Debug") - - if let mediaItems = items as? [MediaItem], let item = mediaItems.first { - self.synopsis = item.description - self.aliases = item.aliases - self.airdate = item.airdate - } else if let str = items as? String { - if let data = str.data(using: .utf8), - let arr = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]], - let dict = arr.first { - self.synopsis = dict["description"] as? String ?? "" - self.aliases = dict["aliases"] as? String ?? "" - self.airdate = dict["airdate"] as? String ?? "" - } - } else if let dict = items as? [String: Any] { - self.synopsis = dict["description"] as? String ?? "" - self.aliases = dict["aliases"] as? String ?? "" - self.airdate = dict["airdate"] as? String ?? "" - } else if let arr = items as? [[String: Any]], let dict = arr.first { - self.synopsis = dict["description"] as? String ?? "" - self.aliases = dict["aliases"] as? String ?? "" - self.airdate = dict["airdate"] as? String ?? "" - } else { - Logger.shared.log("Failed to process items of type: \(type(of: items))", type: "Error") - } - - if self.module.metadata.novel ?? false { - Logger.shared.log("fetchDetails: (novel) chapters count = \(self.chapters.count)", type: "Debug") - self.isLoading = false - self.isRefetching = false - } else { - Logger.shared.log("fetchDetails: (episodes) episodes count = \(episodes.count)", type: "Debug") - self.episodeLinks = episodes - self.restoreSelectionState() - self.isLoading = false - self.isRefetching = false - } - } + jsController.fetchDetails(url: href, completion: completion) } } catch { Logger.shared.log("Error loading module: \(error)", type: "Error") @@ -1529,6 +1455,53 @@ struct MediaInfoView: View { } } + private func handleFetchDetailsResponse(items: Any?, episodes: [EpisodeLink]) { + Logger.shared.log("fetchDetails: items = \(items)", type: "Debug") + Logger.shared.log("fetchDetails: episodes = \(episodes)", type: "Debug") + + processItemsResponse(items) + + if module.metadata.novel ?? false { + Logger.shared.log("fetchDetails: (novel) chapters count = \(chapters.count)", type: "Debug") + } else { + Logger.shared.log("fetchDetails: (episodes) episodes count = \(episodes.count)", type: "Debug") + episodeLinks = episodes + restoreSelectionState() + } + + isLoading = false + isRefetching = false + } + + private func processItemsResponse(_ items: Any?) { + if let mediaItems = items as? [MediaItem], let item = mediaItems.first { + synopsis = item.description + aliases = item.aliases + airdate = item.airdate + } else if let str = items as? String { + parseStringResponse(str) + } else if let dict = items as? [String: Any] { + extractMetadataFromDict(dict) + } else if let arr = items as? [[String: Any]], let dict = arr.first { + extractMetadataFromDict(dict) + } else { + Logger.shared.log("Failed to process items of type: \(type(of: items))", type: "Error") + } + } + + private func parseStringResponse(_ str: String) { + guard let data = str.data(using: .utf8), + let arr = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]], + let dict = arr.first else { return } + extractMetadataFromDict(dict) + } + + private func extractMetadataFromDict(_ dict: [String: Any]) { + synopsis = dict["description"] as? String ?? "" + aliases = dict["aliases"] as? String ?? "" + airdate = dict["airdate"] as? String ?? "" + } + private func fetchAniListPosterImageAndSet() { guard let listID = itemID, listID > 0 else { return } AniListMutation().fetchCoverImage(animeId: listID) { result in @@ -1709,7 +1682,6 @@ struct MediaInfoView: View { }.resume() } - func fetchStream(href: String) { let fetchID = UUID() activeFetchID = fetchID @@ -1954,7 +1926,6 @@ struct MediaInfoView: View { } } - private func downloadSingleEpisodeDirectly(episode: EpisodeLink) { if isSingleEpisodeDownloading { return } @@ -2240,7 +2211,6 @@ struct MediaInfoView: View { }.resume() } - private func presentAlert(_ alert: UIAlertController) { if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let window = windowScene.windows.first, @@ -2342,5 +2312,3 @@ struct MediaInfoView: View { }) } } - - From f6f9bcaa8fd7ebf5e89a4a5a138e0416b587387c Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Mon, 7 Jul 2025 10:27:25 +0200 Subject: [PATCH 36/45] Fixed episode chunk alignment --- Sora/Views/MediaInfoView/MediaInfoView.swift | 43 +++++++++++--------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index 25cb00b..f4890eb 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -540,19 +540,6 @@ struct MediaInfoView: View { if episodeLinks.count != 1 { VStack(alignment: .leading, spacing: 16) { episodesSectionHeader - if isGroupedBySeasons || episodeLinks.count > episodeChunkSize { - HStack(spacing: 8) { - if isGroupedBySeasons { - seasonSelectorStyled - } - Spacer() - if episodeLinks.count > episodeChunkSize { - rangeSelectorStyled - .padding(.trailing, 4) - } - } - .padding(.top, -8) - } episodeListSection } } @@ -614,6 +601,20 @@ struct MediaInfoView: View { Spacer() HStack(spacing: 4) { + if isGroupedBySeasons || episodeLinks.count > episodeChunkSize { + HStack(spacing: 8) { + if isGroupedBySeasons { + seasonSelectorStyled + } + Spacer() + if episodeLinks.count > episodeChunkSize { + rangeSelectorStyled + .padding(.trailing, 4) + } + } + .padding(.top, -8) + } + sourceButton menuButton } @@ -715,17 +716,19 @@ struct MediaInfoView: View { .foregroundColor(.primary) Spacer() HStack(spacing: 4) { + if chapters.count > chapterChunkSize { + HStack { + Spacer() + chapterRangeSelectorStyled + } + .padding(.bottom, 0) + } + sourceButton menuButton } } - if chapters.count > chapterChunkSize { - HStack { - Spacer() - chapterRangeSelectorStyled - } - .padding(.bottom, 0) - } + LazyVStack(spacing: 15) { ForEach(chapters.indices.filter { selectedChapterRange.contains($0) }, id: \..self) { i in let chapter = chapters[i] From 112770024c716feba7ca18f44127d4e01cff1c3a Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Mon, 7 Jul 2025 10:30:14 +0200 Subject: [PATCH 37/45] made default chunk size to 50 --- Sora/Views/MediaInfoView/MediaInfoView.swift | 10 +++++----- .../SettingsSubViews/SettingsViewGeneral.swift | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index f4890eb..5052ff3 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -47,7 +47,7 @@ struct MediaInfoView: View { @State private var selectedSeason: Int = 0 @State private var selectedRange: Range = { let size = UserDefaults.standard.integer(forKey: "episodeChunkSize") - let chunk = size == 0 ? 100 : size + let chunk = size == 0 ? 50 : size return 0.. = { - let size = UserDefaults.standard.integer(forKey: "chapterChunkSize") - let chunk = size == 0 ? 100 : size + let size = UserDefaults.standard.integer(forKey: "episodeChunkSize") + let chunk = size == 0 ? 50 : size return 0..: View { } struct SettingsViewGeneral: View { - @AppStorage("episodeChunkSize") private var episodeChunkSize: Int = 100 + @AppStorage("episodeChunkSize") private var episodeChunkSize: Int = 50 @AppStorage("refreshModulesOnLaunch") private var refreshModulesOnLaunch: Bool = true @AppStorage("fetchEpisodeMetadata") private var fetchEpisodeMetadata: Bool = true @AppStorage("analyticsEnabled") private var analyticsEnabled: Bool = false From e9da8de67e18335368446364bbcee36053216a28 Mon Sep 17 00:00:00 2001 From: 50/50 <80717571+50n50@users.noreply.github.com> Date: Tue, 8 Jul 2025 09:51:34 +0200 Subject: [PATCH 38/45] title (#218) --- Sora/ContentView.swift | 5 +- Sora/Info.plist | 1 + .../Localization/mn.lproj/Localizable.strings | 467 +++++ .../Components/MusicProgressSlider.swift | 48 +- .../Components/VolumeSlider.swift | 46 +- .../CustomPlayer/CustomPlayer.swift | 1508 +++++++++++------ Sora/Utlis & Misc/TabBar/TabBar.swift | 178 +- .../SettingsSubViews/SettingsViewAbout.swift | 6 + .../SettingsViewGeneral.swift | 17 +- .../SettingsSubViews/SettingsViewModule.swift | 53 + Sora/Views/SettingsView/SettingsView.swift | 2 + Sulfur.xcodeproj/project.pbxproj | 19 + 12 files changed, 1777 insertions(+), 573 deletions(-) create mode 100644 Sora/Localization/mn.lproj/Localizable.strings diff --git a/Sora/ContentView.swift b/Sora/ContentView.swift index ac124f0..563f32d 100644 --- a/Sora/ContentView.swift +++ b/Sora/ContentView.swift @@ -55,8 +55,11 @@ struct ContentView: View { .searchable(text: $searchQuery) } else { ZStack(alignment: .bottom) { - Group { + ZStack { tabView(for: selectedTab) + .id(selectedTab) + .transition(.opacity) + .animation(.easeInOut(duration: 0.3), value: selectedTab) } .onPreferenceChange(TabBarVisibilityKey.self) { shouldShowTabBar = $0 } diff --git a/Sora/Info.plist b/Sora/Info.plist index dc64431..ebd099a 100644 --- a/Sora/Info.plist +++ b/Sora/Info.plist @@ -19,6 +19,7 @@ de it kk + mn nn ru sk diff --git a/Sora/Localization/mn.lproj/Localizable.strings b/Sora/Localization/mn.lproj/Localizable.strings new file mode 100644 index 0000000..c5e820a --- /dev/null +++ b/Sora/Localization/mn.lproj/Localizable.strings @@ -0,0 +1,467 @@ +/* General */ +"About" = "Бидний тухайд"; +"About Sora" = "Sora аппын тухай"; +"Active" = "Идэвхтэй"; +"Active Downloads" = "Татаж байна"; +"Actively downloading media can be tracked from here." = "Татаж байгаа үзвэрүүдийг эндээс харж болно"; +"Add Module" = "Модул нэмэх"; +"Adjust the number of media items per row in portrait and landscape modes." = "Хэвтээ болон босоо загварын нэг мөрөн харуулах үзвэрийн тоо"; +"Advanced" = "Нарийн тохиргоо"; +"AKA Sulfur" = "өөрөөр Sulfur"; +"All Bookmarks" = "Бүх хадгалсан үзвэрүүд"; +"All Watching" = "Үзэж буй үзвэрүүд"; +"Also known as Sulfur" = "өөрөөр Sulfur гэж нэрлэдэг"; +"AniList" = "AniList"; +"AniList ID" = "AniList ХД"; +"AniList Match" = "AniList тохирол"; +"AniList.co" = "AniList.co"; +"Anonymous data is collected to improve the app. No personal information is collected. This can be disabled at any time." = "Аппыг сайжруулах зорилгоор мэдээллийг нууцалж цуглуулдаг. Таны хувийн мэдээллийг цуглуулдаггүй болно. Мөн хүссэн үедээ мэдээлэл цуглуулахыг цуцалж болно."; +"App Info" = "Аппын мэдээлэл "; +"App Language" = "Хэл"; +"App Storage" = "Багтаамж"; +"Appearance" = "Харагдах байдал"; + +/* Alerts and Actions */ +"Are you sure you want to clear all cached data? This will help free up storage space." = "Та хадгалагдсан өгөгдлийн устгахдаа итгэлтэй байна уу? Устгасан тохиолдолд багтаамж чөлөөлөгдөнө."; +"Are you sure you want to delete '%@'?" = "Та '%@' үзвэрийг устгахдаа итгэлтэй байна уу?"; +"Are you sure you want to delete all %1$d episodes in '%2$@'?" = "Та '%2$@' үзвэрийн %1$d ангиудыг устгахдаа итгэлтэй байна уу?"; +"Are you sure you want to delete all downloaded assets? You can choose to clear only the library while preserving the downloaded files for future use." = "Та бүх татаж авсан үзвэрийг устгахдаа итгэлтэй байна уу? Зөвхөн сангаа цэврэлсэнээр, татаж авсан үзвэрүүдээ устгахгүй байж болно."; +"Are you sure you want to erase all app data? This action cannot be undone." = "Та аппын бүх өгөгдлийг утгахдаа итгэлтэй байна уу? Энэ үйлдлийг буцаах боломжгүй."; + +/* Features */ +"Background Enabled" = "Аппыг идэвхгүй үед татах"; +"Bookmark items for an easier access later." = "Үзвэрийг хадгалсанаар дараа нь олоход хялбар болно"; +"Bookmarks" = "Хадгалсан үзвэр"; +"Bottom Padding" = "Доод зай"; +"Cancel" = "Цуцлах"; +"Cellular Quality" = "Утасны дата бичлэгийн чанар"; +"Check out some community modules here!" = "Илүү олон модулиудыг эндээс олоорой!"; +"Choose preferred video resolution for WiFi and cellular connections. Higher resolutions use more data but provide better quality. If the exact quality isn't available, the closest option will be selected automatically.\n\nNote: Not all video sources and players support quality selection. This feature works best with HLS streams using the Sora player." = "Өөрийн WiFi болон утасны датанд тааруулж бичлэгийн чанарыг сонгоорой. Өндөр чанартай бичлэг нь илүү их дата ашиглана. Хэрэв таны сонгосон бичлэгийн чанар байхгүй бол хамгийн ойролцоо чанарыг сонгож тоглуулна.\n\nТэмдэглэл: Бүх үзвэрүүд болон бичлэг тоглуулагч нь чанар сонгох үйлдэлгүй байдаг. Бичлэгийн чанар сонгох үйлдлийг HLS төрлийн үзвэрийг Sora тоглуулагч ашиглан үзэж байгаа тохиолдолд ашиглахад хамгийн тохиромжтой байдаг."; +"Clear" = "Устгах"; +"Clear All Downloads" = "Бүх таталтыг устгах"; +"Clear Cache" = "Кэш цэвэрлэх"; +"Clear Library Only" = "Зөвхөн санг цэвэрлэх"; +"Clear Logs" = "Лог цэвэрлэх"; +"Click the plus button to add a module!" = "Нэмэх тэмдэг дээр дарж шинэ модуль нэмнэ үү!"; +"Continue Watching" = "Үргэлжлүүлж үзэх"; +"Continue Watching Episode %d" = "%d ангийг үргэлжлүүлж үзэх"; +"Contributors" = "Хувь нэмэр оруулсан"; +"Copied to Clipboard" = "Хуулсан"; +"Copy to Clipboard" = "Хуулсан"; +"Copy URL" = "Холбоосыг хуулах"; + +/* Episodes */ +"%lld Episodes" = "%lld анги"; +"%lld of %lld" = "%lld-ийн %lld"; +"%lld-%lld" = "%lld-%lld"; +"%lld%% seen" = "%lld%% үзсэн"; +"Episode %lld" = "%lld-р анги"; +"Episodes" = "Ангиуд"; +"Episodes might not be available yet or there could be an issue with the source." = " "; +"Episodes Range" = " "; + +/* System */ +"cranci1" = "cranci1"; +"Dark" = "Хар"; +"DATA & LOGS" = "Өгөгдөл ба лог"; +"Debug" = "Алдаа илрүүлэх"; +"Debugging and troubleshooting." = "Алдааг илрүүлэх ба асуудал олох"; + +/* Actions */ +"Delete" = "Устгах"; +"Delete All" = "Бүгдийг устгах"; +"Delete All Downloads" = "Бүх таталтыг устгах"; +"Delete All Episodes" = "Бүх ангийг устгах"; +"Delete Download" = "Таталт устгах"; +"Delete Episode" = "Анги устгах"; + +/* Player */ +"Double Tap to Seek" = "Хоёр дарж гүйлгэх"; +"Double tapping the screen on it's sides will skip with the short tap setting." = "Дэлгэцийн хоёр талд хоёр удаа хурдан дарвал богино хугацаагаар бичлэгийг гүйлгэнэ."; + +/* Downloads */ +"Download" = "Татах"; +"Download Episode" = "Анги татах"; +"Download Summary" = "Таталтын түүх"; +"Download This Episode" = "Энэ ангийг татах"; +"Downloaded" = "Татсан"; +"Downloaded Shows" = "Татсан үзвэрүүд"; +"Downloading" = "Татаж байна"; +"Downloads" = "Таталтууд"; + +/* Settings */ +"Enable Analytics" = "Аналитик ажилуулах"; +"Enable Subtitles" = "Хадмал харуулах"; + +/* Data Management */ +"Erase" = "Арилгах"; +"Erase all App Data" = "Аппын бүх өгөгдлийг арилгах"; +"Erase App Data" = "Аппын өгөгдлийг арилгах"; + +/* Errors */ +"Error" = "Алдаа"; +"Error Fetching Results" = "Илэрцийг олоход гарсан алдаа"; +"Errors and critical issues." = "Алдаанууд болон ноцтой асуудлууд"; +"Failed to load contributors" = "Контрибуторуудыг ачааллаж чадсангүй"; + +/* Features */ +"Fetch Episode metadata" = "Ангийн мета мэдээллийг татах"; +"Files Downloaded" = "Татаж авсан файлууд"; +"Font Size" = "Үсгийн хэмжээ"; + +/* Interface */ +"Force Landscape" = "Байнга хэвтээ байлгах"; +"General" = "Ерөнхий"; +"General events and activities." = "Ерөнхий эвэнт ба үйл ажиллагаанууд"; +"General Preferences" = "Ерөнхий тохиргоо"; +"Hide Splash Screen" = "Эхлэлийн дэлгэцийг нуух"; +"HLS video downloading." = "HLS бичлэг таталт"; +"Hold Speed" = "Дарах хурд"; + +/* Info */ +"Info" = "Мэдээлэл"; +"INFOS" = "МЭДЭЭЛЛҮҮД"; +"Installed Modules" = "Суулгасан модулиуд"; +"Interface" = "Харилцах хэсэг"; + +/* Social */ +"Join the Discord" = "Дискорд сувагт нэгдэх"; + +/* Layout */ +"Landscape Columns" = "Хэвтээ багана"; +"Language" = "Хэл"; +"LESS" = "БАГАСГАХ"; + +/* Library */ +"Library" = "Сан"; +"License (GPLv3.0)" = "Лиценз (GPLх3.0)"; +"Light" = "Цагаан"; + +/* Loading States */ +"Loading Episode %lld..." = "%lld-р ангийг ачаалж байна..."; +"Loading logs..." = "Логийг ачаалж байна..."; +"Loading module information..." = "Модулийн мэдээллийг ачаалж байна..."; +"Loading Stream" = "Үзвэрийг ачаалж байна"; + +/* Logging */ +"Log Debug Info" = "Дибаг мэдээллийг бичих"; +"Log Filters" = "Лог шүүлтүүрүүд"; +"Log In with AniList" = "AniList-ээр нэвтрэх"; +"Log In with Trakt" = "Trakt-аар нэвтрэх"; +"Log Out from AniList" = "AniList-ээс гарах"; +"Log Out from Trakt" = "Trakt-аас гарах"; +"Log Types" = "Логийн төрлүүд"; +"Logged in as" = "Нэвтэрсэн байна"; +"Logged in as " = " нэвтэрсэн байна"; + +/* Logs and Settings */ +"Logs" = "Логууд"; +"Long press Skip" = "Удаан дарж алгасах"; +"MAIN" = "ҮНДСЭН"; +"Main Developer" = "Үндсэн Хөгжүүлэгч"; +"MAIN SETTINGS" = "ҮНДСЭН ТОХИРГООНУУД"; + +/* Media Actions */ +"Mark All Previous Watched" = "Өмнөх бүгдийг үзсэнээр тэмдэглэх"; +"Mark as Watched" = "Үзсэнээр тэмдэглэх"; +"Mark Episode as Watched" = "Ангийг үзсэнээр тэмдэглэх"; +"Mark Previous Episodes as Watched" = "Өмнөх бүх ангийг үзсэнээр тэмдэглэх"; +"Mark watched" = "Үзсэнийг тэмдэглэх"; +"Match with AniList" = "Anilist-тэй тааруулах"; +"Match with TMDB" = "TMDB-тэй тааруулах"; +"Matched ID: %lld" = "Тааруулсан ХД: %lld"; +"Matched with: %@" = "Тааруулсан: %@"; +"Max Concurrent Downloads" = "Зэрэг татах дээд хэмжээ"; + +/* Media Interface */ +"Media Grid Layout" = "Медиа грид байршил"; +"Media Player" = "Медиа тоглуулагч"; +"Media View" = "Медиа харагдац"; +"Metadata Provider" = "Нэмэлт мэдээлэл нийлүүлэгч"; +"Metadata Providers Order" = "Нэмэлт мэдээлэл нийлүүлэгчид"; +"Module Removed" = "Модуль устсан"; +"Modules" = "Модулиуд"; + +/* Headers */ +"MODULES" = "МОДУЛИУД"; +"MORE" = "ИЛҮҮ"; + +/* Status Messages */ +"No Active Downloads" = "Идэвхтэй таталт байхгүй байна"; +"No AniList matches found" = "Anilist дээр олдсонгүй"; +"No Data Available" = "Мэдээлэл байхгүй байна"; +"No Downloads" = "Таталт байхгүй байна"; +"No episodes available" = "Анги олдсонгүй"; +"No Episodes Available" = "Анги Олдсонгүй"; +"No items to continue watching." = "Үргэлүүлж үзэх зүйл байхгүй"; +"No matches found" = "Илэрц олдсонгүй"; +"No Module Selected" = "Модуль сонгоогүй байна"; +"No Modules" = "Модуль байгүй"; +"No Results Found" = "Хайлт олдсонгүй"; +"No Search Results Found" = "Хайлтын Үр Дүн Олдсонгүй"; +"Nothing to Continue Watching" = "Үргэлжлүүлж Үзэх Зүйл Байхгүй"; + +/* Notes and Messages */ +"Note that the modules will be replaced only if there is a different version string inside the JSON file." = "Модулийн JSON файл доторх хувилбарийн нэр өөрчлөгдсөн тохиолдолд л модуль шинэчлэгдэнэ."; + +/* Actions */ +"OK" = "ЗА"; +"Open Community Library" = "Нийтлэг Санг Нээх"; + +/* External Services */ +"Open in AniList" = "Anilist дотор нээх"; +"Original Poster" = "Жинхэнэ Постлогч"; + +/* Playback */ +"Paused" = "Зогсоосон"; +"Play" = "Тоглуулах"; +"Player" = "Тоглуулагч"; + +/* System Messages */ +"Please restart the app to apply the language change." = "Аппаас гарч дахин орсноор хэл солигдоно"; +"Please select a module from settings" = "Тохиргооны хэсгээс модуль сонгоно уу"; + +/* Interface */ +"Portrait Columns" = "Босоо Баганууд"; +"Progress bar Marker Color" = "Явцын зурвасын тэмдэглэгээний өнгө"; +"Provider: %@" = "Нийлүүлэгч: %@"; + +/* Queue */ +"Queue" = "Дараалал"; +"Queued" = "Хүлээлтэд орсон"; + +/* Content */ +"Recently watched content will appear here." = "Сүүлд үзсэн үзвэрүүд энд харагдана"; + +/* Settings */ +"Refresh Modules on Launch" = "Апп нээгдэх болгонд модуль шинэчлэх"; +"Refresh Storage Info" = "Багтаамжийн мэдээллийг шинэчлэх"; +"Remember Playback speed" = "Тоглуулах хурдыг сануулах"; + +/* Actions */ +"Remove" = "Устгах"; +"Remove All Cache" = "Бүх Кэшийг Устгах"; + +/* File Management */ +"Remove All Documents" = "Бүх мэдээлийг устгах"; +"Remove Documents" = "Мэдээллийг Устгах"; +"Remove Downloaded Media" = "Татаж авсан үзвэрийг устгах"; +"Remove Downloads" = "Таталтуудыг Устгах"; +"Remove from Bookmarks" = "Хадгалахаа болих"; +"Remove Item" = "Анги Устгах"; + +/* Support */ +"Report an Issue" = "Алдаа мэдээлэх"; + +/* Reset Options */ +"Reset" = "Анхны төлөвт оруулах"; +"Reset AniList ID" = "AniList ХД анхны төлөвт оруулах"; +"Reset Episode Progress" = "Эхнээс нь үзэх"; +"Reset progress" = "Анхны төлөвт оруулах явц"; +"Reset Progress" = "Явцыг ахний төлөвт оруулах"; + +/* System */ +"Restart Required" = "Дахин ачааллах шаардлагатай"; +"Running Sora %@ - cranci1" = "Sora %@ ачаалж байна - cranci1"; + +/* Actions */ +"Save" = "Хадгалах"; +"Search" = "Хайа"; + +/* Search */ +"Search downloads" = "Татсан үзвэр хайх"; +"Search for something..." = "Үзвэр хайх..."; +"Search..." = "Хайх..."; + +/* Content */ +"Season %d" = "%d-р Улирал"; +"Season %lld" = "%lld-р Улирал"; +"Segments Color" = "Ерөнхий өнгө"; + +/* Modules */ +"Select Module" = "Модуль сонгох"; +"Set Custom AniList ID" = "AniList ХД харуулах"; + +/* Interface */ +"Settings" = "Тохиргоо"; +"Shadow" = "Сүүдэр"; +"Show More (%lld more characters)" = "Илүү харуулах (%lld тэмдэгт харагдана)"; +"Show PiP Button" = "PiP товч харуулах"; +"Show Skip 85s Button" = "85с алгасах товч харуулах"; +"Show Skip Intro / Outro Buttons" = "Эхлэл/Төгсгөлийн дууг алгасах точ хөруулах"; +"Shows" = "Харуулах"; +"Size (%@)" = "Хэмжээ (%d)"; +"Skip Settings" = "Алгасах тохиргоо"; + +/* Player Features */ +"Some features are limited to the Sora and Default player, such as ForceLandscape, holdSpeed and custom time skip increments." = "Зарим үйлдлүүд нь зөвхөн Sora болон Үндсэн тоглуулагч дээр ажилладаг, тухайлбал Үргэлж хэвтээ байдлаар үзэх, удаан дарж хурд удирдах болон алгасах хугацаа нь тохиргоо"; + +/* App Info */ +"Sora" = "Sora"; +"Sora %@ by cranci1" = "Sora %@ by cranci1"; +"Sora and cranci1 are not affiliated with AniList or Trakt in any way. + +Also note that progress updates may not be 100% accurate." = " +Sora ба cranci1 нь AniList эсвэл Trakt-тэй ямар ч хамааралгүй болно. + +Мөн явцын шинэчлэлтүүд 100% үнэн зөв байж чадахгүй гэдгийг анхаарна уу."; +"Sora GitHub Repository" = "Sora GitHub хуудас"; +"Sora/Sulfur will always remain free with no ADs!" = "Sora/Sulfur нь үргэлж үнэ төлбөргүй, зар сурталчилгаагүй байх болно!"; + +/* Interface */ +"Sort" = "Эрэмблэх"; +"Speed Settings" = "Тоглуулах хурд"; + +/* Playback */ +"Start Watching" = "Үзэх"; +"Start Watching Episode %d" = "%d ангийг үзэх"; +"Storage Used" = "Ашигласан багтаамж"; +"Stream" = "Үзвэр"; +"Streaming and video playback." = "Үзвэр ба бичлэг"; + +/* Subtitles */ +"Subtitle Color" = "Хадмалын өнгө"; +"Subtitle Settings" = "Хадмалын тохиргоо"; + +/* Sync */ +"Sync anime progress" = "Аниме үзсэн ангиудыг тэмдэглэх"; +"Sync TV shows progress" = "Цувралын үзсэн ангиудыг тэмдэглэх"; + +/* System */ +"System" = "Систем"; + +/* Instructions */ +"Tap a title to override the current match." = "Нэр дээр дарж одоогийн хайлтыг солино уу"; +"Tap Skip" = "Энд дарж гүйлгэнэ үү"; +"Tap to manage your modules" = "Энд дарж модуль солино уу"; +"Tap to select a module" = "Энд дарж модуль сонгоно уу"; + +/* App Information */ +"The app cache helps the app load images faster. Clearing the Documents folder will delete all downloaded modules. Do not erase App Data unless you understand the consequences — it may cause the app to malfunction." = " Аппын кэш нь зургуудийг илүү хурдан ачаалахад тусалдаг. Documents хавтасыг устгавал бүх татсан үзвэрүүдийг утгана. Гарах үр дагаврыг нь ойлголгүйгээр Апп датаг бүү устга - Энэ нь дараа нь аппыг буруу ажиллахад нөлөөлөх боломжтой"; +"The episode range controls how many episodes appear on each page. Episodes are grouped into sets (like 1–25, 26–50, and so on), allowing you to navigate through them more easily. For episode metadata, it refers to the episode thumbnail and title, since sometimes it can contain spoilers." = " Ангийн хязгаар нь нэг хуудсанд хэдэн харагдахыг тохируулдаг. Ингэснээр ангиуд нь багцлагдаж (Жишээ нь 1-25, 26-50 гэх мэт), үзэх ангиа сонгоход илүү хялбар болгоно. Ангийн мета өгөгдөл нь тухайн ангийн харагдац зураг болон нэрийг хадгалдаг тул зарим тохиолдолд спойлер агуулдаг."; + + + +"The module provided only a single episode, this is most likely a movie, so we decided to make separate screens for these cases." = "Модуль зөвхөн нэг анги агуулсан байсан тул, кино байх боломжтой гэж үзээд тусгай дэлгэц хийхээр шийдсэн."; + +/* Interface */ +"Thumbnails Width" = "Үзвэрийн зургийн урт"; +"TMDB Match" = "ТМДБ тохирол"; +"Trackers" = "Тракерууд"; +"Trakt" = "Trakt"; +"Trakt.tv" = "Trakt.tv"; + +/* Search */ +"Try different keywords" = "Өөр үгээр хайж үзнэ үү"; +"Try different search terms" = "Өөр төрлөөр хайж үзнэ үү"; + +/* Player Controls */ +"Two Finger Hold for Pause" = "Хоёр хуруугаар дарж бичлэгийг зогсоох"; +"Unable to fetch matches. Please try again later." = "Илэрц олж чадсангүй. Дараа дахин оролдоно уу?"; +"Use TMDB Poster Image" = "ТМДБ нүүр зураг ашиглах"; + +/* Version */ +"v%@" = "х%@"; +"Video Player" = "Бичлэг тоглуулагч"; + +/* Video Settings */ +"Video Quality Preferences" = "Бичлэгийн чанарын тохиргоо"; +"View All" = "Бүгдийг харах"; +"Watched" = "Үзсэн"; +"Why am I not seeing any episodes?" = "Яагаад нэг ч анги байхгүй байна?"; +"WiFi Quality" = "WiFi чанар"; + +/* User Status */ +"You are not logged in" = "Та нэвтрээгүй байна"; +"You have no items saved." = "Танд хадгалсан үзвэр байхгүй байна"; +"Your downloaded episodes will appear here" = "Таны татсан үзвэрийн ангиуд энд харагдана"; +"Your recently watched content will appear here" = "Таны сүүлд үзсэн үзвэрүүд энд харагдана"; + +/* Download Settings */ +"Download Settings" = "Таталтын тохиргоо"; +"Max concurrent downloads controls how many episodes can download simultaneously. Higher values may use more bandwidth and device resources." = "Зэрэг таталт хийх хязгаар нь зэрэг татах ангийн тоо юм. Өндөр байх тусам илүү их дата болон утасны нөөцийг ашиглана."; +"Quality" = "Чанар"; +"Max Concurrent Downloads" = "Зэрэг таталт хийх хязгаар"; +"Allow Cellular Downloads" = "Утасны датагаар татах"; +"Quality Information" = "Чанарын мэдээлэл"; + +/* Storage */ +"Storage Management" = "Багтаамж удирдах"; +"Storage Used" = "Ашигласан багтаамж"; +"Library cleared successfully" = "Санг амжилттай цэвэрлэлээ"; +"All downloads deleted successfully" = "Бүх татсан үзвэрүүдийг амжилттай устлаа"; + +/* New additions */ +"Recent searches" = "Сүүлд хайсан"; +"me frfr" = "me frfr"; +"Data" = "Мэдээлэл"; +"Maximum Quality Available" = "Хамгийн өндөр чанартай"; +"All Reading" = "Бүх унших зүйл"; +"No Reading History" = "Унших түүх байхгүй"; +"Books you're reading will appear here" = "Таны уншиж байгаа номууд энд харагдана"; +"All Watching" = "Бүх үзэх зүйл"; +"Continue Reading" = "Унших үргэлжлүүлэх"; +"Nothing to Continue Reading" = "Үргэлжлүүлж унших зүйл байхгүй"; +"Your recently read novels will appear here" = "Таны саяхан уншсан зохиолууд энд харагдана"; +"No Bookmarks" = "Хадгалсан зүйл байхгүй"; +"Add bookmarks to this collection" = "Энэ цуглуулгад хадгалсан зүйл нэмэх"; +"items" = "зүйл"; +"Chapter %d" = "Бүлэг %d"; +"Episode %d" = "Анги %d"; +"%d%%" = "%d%%"; +"%d%% seen" = "%d%% үзсэн"; +"DownloadCountFormat" = "Татаж авсан: %d"; +"Error loading chapter" = "Бүлэг ачаалахад алдаа гарлаа"; +"Font Size: %dpt" = "Фонтын хэмжээ: %dpt"; +"Line Spacing: %.1f" = "Мөр хоорондын зай: %.1f"; +"Line Spacing" = "Мөр хоорондын зай"; +"Margin: %dpx" = "Захын зай: %dpx"; +"Margin" = "Захын зай"; +"Auto Scroll Speed" = "Автомат гүйлгэх хурд"; +"Speed" = "Хурд"; +"Speed: %.1fx" = "Хурд: %.1fx"; +"Matched %@: %@" = "Таарсан %@: %@"; +"Enter the AniList ID for this series" = "Энэ цувралын AniList ID-г оруулна уу"; + +/* New additions */ +"Create Collection" = "Цуглуулга үүсгэх"; +"Collection Name" = "Цуглуулгын нэр"; +"Rename Collection" = "Цуглуулгын нэр солих"; +"Rename" = "Нэр солих"; +"All Reading" = "Бүх унших зүйл"; +"Recently Added" = "Саяхан нэмэгдсэн"; +"Novel Title" = "Зохиолын гарчиг"; +"Read Progress" = "Уншсан явц"; +"Date Created" = "Үүсгэсэн огноо"; +"Name" = "Нэр"; +"Item Count" = "Зүйлийн тоо"; +"Date Added" = "Нэмсэн огноо"; +"Title" = "Гарчиг"; +"Source" = "Эх сурвалж"; +"Search reading..." = "Унж байгаа зүйл хайх..."; +"Search collections..." = "Цуглуулга хайх..."; +"Search bookmarks..." = "Хадгалсан зүйл хайх..."; +"%d items" = "%d зүйл"; +"Fetching Data" = "Өгөгдөл татаж байна"; +"Please wait while fetching." = "Татаж байна, хүлээнэ үү."; +"Start Reading" = "Унж эхлэх"; +"Chapters" = "Бүлгүүд"; +"Completed" = "Дууссан"; +"Drag to reorder" = "Дарааллаар байрлуулахын тулд чирнэ үү"; +"Drag to reorder sections" = "Хэсгүүдийг дарааллаар байрлуулахын тулд чирнэ үү"; +"Library View" = "Сангийн харагдац"; +"Customize the sections shown in your library. You can reorder sections or disable them completely." = "Сангийн харагдах хэсгүүдийг тохируулна уу. Хэсгүүдийг дахин эрэмбэлж эсвэл бүрэн идэвхгүй болгож болно."; +"Library Sections Order" = "Сангийн хэсгүүдийн дараалал"; +"Completion Percentage" = "Дуусгах хувь"; +"Some features are limited to the Sora and Default player, such as ForceLandscape, holdSpeed and custom time skip increments.\n\nThe completion percentage setting determines at what point before the end of a video the app will mark it as completed on AniList and Trakt." = "Зарим үйлдлүүд нь зөвхөн Sora болон Үндсэн тоглуулагч дээр ажилладаг, тухайлбал Үргэлж хэвтээ байдлаар үзэх, удаан дарж хурд удирдах болон алгасах хугацаа нь тохиргоо\n\nДуусгах хувь нь бичлэгийн төгсгөлөөс хэдэн хувийн өмнө AniList болон Trakt дээр үзсэнээр тэмдэглэхээ тодорхойлно."; +"The app cache helps the app load images faster.\n\nClearing the Documents folder will delete all downloaded modules.\n\nErasing the App Data will clears all your settings and data of the app." = "Аппын кэш нь зургуудийг илүү хурдан ачаалахад тусалдаг.\n\nDocuments хавтасыг устгавал бүх татсан үзвэрүүдийг утгана.\n\nАпп датаг устгавал аппын бүх тохиргоо болон өгөгдөл устана."; +"Translators" = "Орчуулагчид"; +"Paste URL" = "Холбоосыг буулгах"; + +/* Added missing localizations */ +"Series Title" = "Цувралын гарчиг"; +"Content Source" = "Агуулгын эх сурвалж"; +"Watch Progress" = "Үзсэн явц"; +"Recent searches" = "Саяхны хайлт"; +"Collections" = "Цуглуулгууд"; +"Continue Reading" = "Унших үргэлжлүүлэх"; \ No newline at end of file diff --git a/Sora/MediaUtils/CustomPlayer/Components/MusicProgressSlider.swift b/Sora/MediaUtils/CustomPlayer/Components/MusicProgressSlider.swift index 5c97e1d..5a25b02 100644 --- a/Sora/MediaUtils/CustomPlayer/Components/MusicProgressSlider.swift +++ b/Sora/MediaUtils/CustomPlayer/Components/MusicProgressSlider.swift @@ -33,32 +33,8 @@ struct MusicProgressSlider: View { VStack(spacing: 8) { ZStack(alignment: .center) { ZStack(alignment: .center) { - // Intro Segments - ForEach(introSegments, id: \.self) { segment in - HStack(spacing: 0) { - Spacer() - .frame(width: bounds.size.width * CGFloat(segment.lowerBound)) - Rectangle() - .fill(introColor.opacity(0.5)) - .frame(width: bounds.size.width * CGFloat(segment.upperBound - segment.lowerBound)) - Spacer() - } - } - - // Outro Segments - ForEach(outroSegments, id: \.self) { segment in - HStack(spacing: 0) { - Spacer() - .frame(width: bounds.size.width * CGFloat(segment.lowerBound)) - Rectangle() - .fill(outroColor.opacity(0.5)) - .frame(width: bounds.size.width * CGFloat(segment.upperBound - segment.lowerBound)) - Spacer() - } - } - Capsule() - .fill(emptyColor) + .fill(.ultraThinMaterial) } .clipShape(Capsule()) @@ -77,6 +53,28 @@ struct MusicProgressSlider: View { Spacer(minLength: 0) } }) + + ForEach(introSegments, id: \.self) { segment in + HStack(spacing: 0) { + Spacer() + .frame(width: bounds.size.width * CGFloat(segment.lowerBound)) + Rectangle() + .fill(introColor.opacity(0.5)) + .frame(width: bounds.size.width * CGFloat(segment.upperBound - segment.lowerBound)) + Spacer() + } + } + + ForEach(outroSegments, id: \.self) { segment in + HStack(spacing: 0) { + Spacer() + .frame(width: bounds.size.width * CGFloat(segment.lowerBound)) + Rectangle() + .fill(outroColor.opacity(0.5)) + .frame(width: bounds.size.width * CGFloat(segment.upperBound - segment.lowerBound)) + Spacer() + } + } } HStack { diff --git a/Sora/MediaUtils/CustomPlayer/Components/VolumeSlider.swift b/Sora/MediaUtils/CustomPlayer/Components/VolumeSlider.swift index 15437c6..443a0db 100644 --- a/Sora/MediaUtils/CustomPlayer/Components/VolumeSlider.swift +++ b/Sora/MediaUtils/CustomPlayer/Components/VolumeSlider.swift @@ -21,6 +21,7 @@ struct VolumeSlider: View { @State private var localTempProgress: T = 0 @State private var lastVolumeValue: T = 0 @GestureState private var isActive: Bool = false + @State private var isAtEnd: Bool = false var body: some View { GeometryReader { bounds in @@ -51,8 +52,9 @@ struct VolumeSlider: View { handleIconTap() } } - .frame(width: isActive ? bounds.size.width * 1.02 : bounds.size.width, alignment: .center) + .frame(width: getStretchWidth(bounds: bounds), alignment: .center) .animation(animation, value: isActive) + .animation(animation, value: isAtEnd) } .frame(width: bounds.size.width, height: bounds.size.height) .gesture( @@ -61,16 +63,26 @@ struct VolumeSlider: View { .onChanged { gesture in let delta = gesture.translation.width / bounds.size.width localTempProgress = T(delta) + + let totalProgress = localRealProgress + localTempProgress + if totalProgress <= 0.0 || totalProgress >= 1.0 { + isAtEnd = true + } else { + isAtEnd = false + } + value = sliderValueInRange() } .onEnded { _ in localRealProgress = max(min(localRealProgress + localTempProgress, 1), 0) localTempProgress = 0 + isAtEnd = false } ) .onChange(of: isActive) { newValue in if !newValue { value = sliderValueInRange() + isAtEnd = false } onEditingChanged(newValue) } @@ -91,7 +103,7 @@ struct VolumeSlider: View { } } } - .frame(height: isActive ? height * 1.25 : height) + .frame(height: getStretchHeight()) } private var getIconName: String { @@ -133,9 +145,12 @@ struct VolumeSlider: View { } private var animation: Animation { - isActive - ? .spring() - : .spring(response: 0.5, dampingFraction: 0.5, blendDuration: 0.6) + .interpolatingSpring( + mass: 1.0, + stiffness: 100, + damping: 15, + initialVelocity: 0.0 + ) } private func progress(for val: T) -> T { @@ -150,4 +165,25 @@ struct VolumeSlider: View { + inRange.lowerBound return max(min(rawVal, inRange.upperBound), inRange.lowerBound) } + + private func getStretchWidth(bounds: GeometryProxy) -> CGFloat { + let baseWidth = bounds.size.width + if isAtEnd { + return baseWidth * 1.08 + } else if isActive { + return baseWidth * 1.04 + } else { + return baseWidth + } + } + + private func getStretchHeight() -> CGFloat { + if isAtEnd { + return height * 1.35 + } else if isActive { + return height * 1.25 + } else { + return height + } + } } diff --git a/Sora/MediaUtils/CustomPlayer/CustomPlayer.swift b/Sora/MediaUtils/CustomPlayer/CustomPlayer.swift index 623cb50..a91d66d 100644 --- a/Sora/MediaUtils/CustomPlayer/CustomPlayer.swift +++ b/Sora/MediaUtils/CustomPlayer/CustomPlayer.swift @@ -92,7 +92,6 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele var landscapeButtonVisibleConstraints: [NSLayoutConstraint] = [] var landscapeButtonHiddenConstraints: [NSLayoutConstraint] = [] var currentMarqueeConstraints: [NSLayoutConstraint] = [] - private var currentMenuButtonTrailing: NSLayoutConstraint! var subtitleForegroundColor: String = "white" var subtitleBackgroundEnabled: Bool = true @@ -107,7 +106,6 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele } } - var marqueeLabel: MarqueeLabel! var playerViewController: AVPlayerViewController! var controlsContainerView: UIView! var playPauseButton: UIImageView! @@ -124,6 +122,9 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele var qualityButton: UIButton! var holdSpeedIndicator: UIButton! private var lockButton: UIButton! + private var controlButtonsContainer: GradientBlurButton! + + private var unlockButton: UIButton! var isHLSStream: Bool = false var qualities: [(String, String)] = [] @@ -176,26 +177,41 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele private var dimButtonToRight: NSLayoutConstraint! private var dimButtonTimer: Timer? - private lazy var controlsToHide: [UIView] = [ - dismissButton, - playPauseButton, - backwardButton, - forwardButton, - sliderHostingController?.view, - skip85Button, - marqueeLabel, - menuButton, - qualityButton, - speedButton, - watchNextButton, - volumeSliderHostingView, - pipButton, - airplayButton, - timeBatteryContainer, - endTimeIcon, - endTimeLabel, - endTimeSeparator - ].compactMap { $0 } + private var controlsToHide: [UIView] { + var views = [ + dismissButton, + playPauseButton, + backwardButton, + forwardButton, + sliderHostingController?.view, + skip85Button, + controlButtonsContainer, + volumeSliderHostingView, + pipButton, + airplayButton, + timeBatteryContainer, + endTimeIcon, + endTimeLabel, + endTimeSeparator, + lockButton, + dimButton, + titleStackView, + titleLabel, + episodeNumberLabel, + controlsContainerView + ].compactMap { $0 }.filter { $0.superview != nil } + + if let airplayParent = airplayButton?.superview { + views.append(airplayParent) + } + + views.append(contentsOf: view.subviews.filter { + $0 is UIVisualEffectView || + ($0.layer.cornerRadius > 0 && $0 != dismissButton && $0 != lockButton && $0 != dimButton && $0 != pipButton && $0 != holdSpeedIndicator && $0 != volumeSliderHostingView) + }) + + return views + } private var originalHiddenStates: [UIView: Bool] = [:] @@ -222,6 +238,18 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele private var endTimeSeparator: UIView? private var isEndTimeVisible: Bool = false + private var titleStackAboveSkipButtonConstraints: [NSLayoutConstraint] = [] + private var titleStackAboveSliderConstraints: [NSLayoutConstraint] = [] + + var episodeNumberLabel: UILabel! + var titleLabel: MarqueeLabel! + var titleStackView: UIStackView! + + private var controlButtonsContainerBottomConstraint: NSLayoutConstraint? + + private var isMenuOpen = false + private var menuProtectionTimer: Timer? + init(module: ScrapingModule, urlString: String, fullUrl: String, @@ -300,6 +328,9 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele super.viewDidLoad() view.backgroundColor = .black + skipOutroDismissedInSession = false + skipIntroDismissedInSession = false + setupHoldGesture() loadSubtitleSettings() setupPlayerViewController() @@ -320,11 +351,27 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele addTimeObserver() startUpdateTimer() setupLockButton() + setupUnlockButton() setupAudioSession() updateSkipButtonsVisibility() setupHoldSpeedIndicator() setupPipIfSupported() setupTimeBatteryIndicator() + setupTopRowLayout() + updateSkipButtonsVisibility() + + isControlsVisible = true + for control in controlsToHide { + control.alpha = 1.0 + } + + if let volumeSlider = volumeSliderHostingView { + volumeSlider.alpha = 1.0 + volumeSlider.isHidden = false + view.bringSubviewToFront(volumeSlider) + } + + setupControlButtonsContainer() view.bringSubviewToFront(subtitleStackView) subtitleStackView.isHidden = !SubtitleSettingsManager.shared.settings.enabled @@ -340,7 +387,9 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele } } - controlsToHide.forEach { originalHiddenStates[$0] = $0.isHidden } + for control in controlsToHide { + originalHiddenStates[control] = control.isHidden + } DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in self?.checkForHLSStream() @@ -416,6 +465,57 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele name: .AVPlayerItemDidPlayToEndTime, object: player.currentItem ) + + + } + + private func setupTopRowLayout() { + if let old = view.subviews.first(where: { $0 is GradientBlurButton && $0 != controlButtonsContainer && $0 != skip85Button }) { + old.removeFromSuperview() + } + + let capsuleContainer = GradientBlurButton(type: .custom) + capsuleContainer.translatesAutoresizingMaskIntoConstraints = false + capsuleContainer.backgroundColor = .clear + capsuleContainer.layer.cornerRadius = 21 + capsuleContainer.clipsToBounds = true + view.addSubview(capsuleContainer) + capsuleContainer.alpha = isControlsVisible ? 1.0 : 0.0 + + let buttons: [UIView] = [airplayButton, pipButton, lockButton, dimButton] + for btn in buttons { + btn.removeFromSuperview() + capsuleContainer.addSubview(btn) + } + + NSLayoutConstraint.activate([ + capsuleContainer.leadingAnchor.constraint(equalTo: dismissButton.superview!.trailingAnchor, constant: 12), + capsuleContainer.centerYAnchor.constraint(equalTo: dismissButton.superview!.centerYAnchor), + capsuleContainer.heightAnchor.constraint(equalToConstant: 42) + ]) + + for (index, btn) in buttons.enumerated() { + NSLayoutConstraint.activate([ + btn.centerYAnchor.constraint(equalTo: capsuleContainer.centerYAnchor), + btn.widthAnchor.constraint(equalToConstant: 40), + btn.heightAnchor.constraint(equalToConstant: 40) + ]) + if index == 0 { + btn.leadingAnchor.constraint(equalTo: capsuleContainer.leadingAnchor, constant: 20).isActive = true + } else { + btn.leadingAnchor.constraint(equalTo: buttons[index - 1].trailingAnchor, constant: 18).isActive = true + } + if index == buttons.count - 1 { + btn.trailingAnchor.constraint(equalTo: capsuleContainer.trailingAnchor, constant: -10).isActive = true + } + } + + + view.bringSubviewToFront(skip85Button) + + if let volumeSlider = volumeSliderHostingView { + view.bringSubviewToFront(volumeSlider) + } } override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { @@ -427,20 +527,23 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() + if let pipController = pipController { + pipController.playerLayer.frame = view.bounds + } - guard let marqueeLabel = marqueeLabel else { + guard let episodeNumberLabel = episodeNumberLabel else { return } - let availableWidth = marqueeLabel.frame.width - let textWidth = marqueeLabel.intrinsicContentSize.width + let availableWidth = episodeNumberLabel.frame.width + let textWidth = episodeNumberLabel.intrinsicContentSize.width if textWidth > availableWidth { - marqueeLabel.lineBreakMode = .byTruncatingTail + episodeNumberLabel.lineBreakMode = .byTruncatingTail } else { - marqueeLabel.lineBreakMode = .byClipping + episodeNumberLabel.lineBreakMode = .byClipping } - updateMenuButtonConstraints() + updateMarqueeConstraintsForBottom() } override func viewDidAppear(_ animated: Bool) { @@ -480,11 +583,13 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele timeUpdateTimer.invalidate() } + menuProtectionTimer?.invalidate() + menuProtectionTimer = nil + NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: nil) NotificationCenter.default.removeObserver(self) UIDevice.current.isBatteryMonitoringEnabled = false - // Clean up end time related resources endTimeIcon?.removeFromSuperview() endTimeLabel?.removeFromSuperview() endTimeSeparator?.removeFromSuperview() @@ -528,7 +633,9 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele volumeSliderHostingView?.removeFromSuperview() hiddenVolumeView.removeFromSuperview() subtitleStackView?.removeFromSuperview() - marqueeLabel?.removeFromSuperview() + episodeNumberLabel?.removeFromSuperview() + titleLabel?.removeFromSuperview() + titleStackView?.removeFromSuperview() controlsContainerView?.removeFromSuperview() blackCoverView?.removeFromSuperview() skipIntroButton?.removeFromSuperview() @@ -585,8 +692,6 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele self.qualityButton.isHidden = false self.qualityButton.menu = self.qualitySelectionMenu() - self.updateMenuButtonConstraints() - UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseInOut) { self.view.layoutIfNeeded() } @@ -607,7 +712,9 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele playerViewController.player = player playerViewController.showsPlaybackControls = false addChild(playerViewController) - view.addSubview(playerViewController.view) + if playerViewController.view.superview == nil { + view.addSubview(playerViewController.view) + } playerViewController.view.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ playerViewController.view.topAnchor.constraint(equalTo: view.topAnchor), @@ -638,7 +745,8 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele blackCoverView.backgroundColor = UIColor.black.withAlphaComponent(0.4) blackCoverView.translatesAutoresizingMaskIntoConstraints = false blackCoverView.isUserInteractionEnabled = false - controlsContainerView.insertSubview(blackCoverView, at: 0) + blackCoverView.alpha = 0.0 + view.insertSubview(blackCoverView, belowSubview: controlsContainerView) NSLayoutConstraint.activate([ blackCoverView.topAnchor.constraint(equalTo: view.topAnchor), blackCoverView.bottomAnchor.constraint(equalTo: view.bottomAnchor), @@ -646,17 +754,23 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele blackCoverView.trailingAnchor.constraint(equalTo: view.trailingAnchor) ]) + let backwardCircle = createCircularBlurBackground(size: 60) + controlsContainerView.addSubview(backwardCircle) + backwardCircle.translatesAutoresizingMaskIntoConstraints = false + + let playPauseCircle = createCircularBlurBackground(size: 80) + controlsContainerView.addSubview(playPauseCircle) + playPauseCircle.translatesAutoresizingMaskIntoConstraints = false + + let forwardCircle = createCircularBlurBackground(size: 60) + controlsContainerView.addSubview(forwardCircle) + forwardCircle.translatesAutoresizingMaskIntoConstraints = false + backwardButton = UIImageView(image: UIImage(systemName: "gobackward")) backwardButton.tintColor = .white backwardButton.contentMode = .scaleAspectFit backwardButton.isUserInteractionEnabled = true - backwardButton.layer.shadowColor = UIColor.black.cgColor - backwardButton.layer.shadowOffset = CGSize(width: 0, height: 2) - backwardButton.layer.shadowOpacity = 0.6 - backwardButton.layer.shadowRadius = 4 - backwardButton.layer.masksToBounds = false - let backwardTap = UITapGestureRecognizer(target: self, action: #selector(seekBackward)) backwardTap.numberOfTapsRequired = 1 backwardButton.addGestureRecognizer(backwardTap) @@ -674,12 +788,6 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele playPauseButton.contentMode = .scaleAspectFit playPauseButton.isUserInteractionEnabled = true - playPauseButton.layer.shadowColor = UIColor.black.cgColor - playPauseButton.layer.shadowOffset = CGSize(width: 0, height: 2) - playPauseButton.layer.shadowOpacity = 0.6 - playPauseButton.layer.shadowRadius = 4 - playPauseButton.layer.masksToBounds = false - let playPauseTap = UITapGestureRecognizer(target: self, action: #selector(togglePlayPause)) playPauseTap.delaysTouchesBegan = false playPauseTap.delegate = self @@ -695,12 +803,6 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele forwardButton.contentMode = .scaleAspectFit forwardButton.isUserInteractionEnabled = true - forwardButton.layer.shadowColor = UIColor.black.cgColor - forwardButton.layer.shadowOffset = CGSize(width: 0, height: 2) - forwardButton.layer.shadowOpacity = 0.6 - forwardButton.layer.shadowRadius = 4 - forwardButton.layer.masksToBounds = false - let forwardTap = UITapGestureRecognizer(target: self, action: #selector(seekForward)) forwardTap.numberOfTapsRequired = 1 forwardButton.addGestureRecognizer(forwardTap) @@ -723,7 +825,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele ), inRange: 0...(duration > 0 ? duration : 1.0), activeFillColor: .white, - fillColor: .white.opacity(0.6), + fillColor: .white, textColor: .white.opacity(0.7), emptyColor: .white.opacity(0.3), height: 33, @@ -776,23 +878,55 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele ]) NSLayoutConstraint.activate([ - playPauseButton.centerXAnchor.constraint(equalTo: controlsContainerView.centerXAnchor), - playPauseButton.centerYAnchor.constraint(equalTo: controlsContainerView.centerYAnchor), + playPauseCircle.centerXAnchor.constraint(equalTo: controlsContainerView.centerXAnchor), + playPauseCircle.centerYAnchor.constraint(equalTo: controlsContainerView.centerYAnchor), + playPauseCircle.widthAnchor.constraint(equalToConstant: 80), + playPauseCircle.heightAnchor.constraint(equalToConstant: 80), + + backwardCircle.centerYAnchor.constraint(equalTo: playPauseCircle.centerYAnchor), + backwardCircle.trailingAnchor.constraint(equalTo: playPauseCircle.leadingAnchor, constant: -50), + backwardCircle.widthAnchor.constraint(equalToConstant: 60), + backwardCircle.heightAnchor.constraint(equalToConstant: 60), + + forwardCircle.centerYAnchor.constraint(equalTo: playPauseCircle.centerYAnchor), + forwardCircle.leadingAnchor.constraint(equalTo: playPauseCircle.trailingAnchor, constant: 50), + forwardCircle.widthAnchor.constraint(equalToConstant: 60), + forwardCircle.heightAnchor.constraint(equalToConstant: 60) + ]) + + NSLayoutConstraint.activate([ + playPauseButton.centerXAnchor.constraint(equalTo: playPauseCircle.centerXAnchor), + playPauseButton.centerYAnchor.constraint(equalTo: playPauseCircle.centerYAnchor), playPauseButton.widthAnchor.constraint(equalToConstant: 50), playPauseButton.heightAnchor.constraint(equalToConstant: 50), - backwardButton.centerYAnchor.constraint(equalTo: playPauseButton.centerYAnchor), - backwardButton.trailingAnchor.constraint(equalTo: playPauseButton.leadingAnchor, constant: -50), - backwardButton.widthAnchor.constraint(equalToConstant: 40), - backwardButton.heightAnchor.constraint(equalToConstant: 40), + backwardButton.centerXAnchor.constraint(equalTo: backwardCircle.centerXAnchor), + backwardButton.centerYAnchor.constraint(equalTo: backwardCircle.centerYAnchor), + backwardButton.widthAnchor.constraint(equalToConstant: 35), + backwardButton.heightAnchor.constraint(equalToConstant: 35), - forwardButton.centerYAnchor.constraint(equalTo: playPauseButton.centerYAnchor), - forwardButton.leadingAnchor.constraint(equalTo: playPauseButton.trailingAnchor, constant: 50), - forwardButton.widthAnchor.constraint(equalToConstant: 40), - forwardButton.heightAnchor.constraint(equalToConstant: 40) + forwardButton.centerXAnchor.constraint(equalTo: forwardCircle.centerXAnchor), + forwardButton.centerYAnchor.constraint(equalTo: forwardCircle.centerYAnchor), + forwardButton.widthAnchor.constraint(equalToConstant: 35), + forwardButton.heightAnchor.constraint(equalToConstant: 35) ]) } + private func createCircularBlurBackground(size: CGFloat) -> UIView { + let blurEffect = UIBlurEffect(style: .systemUltraThinMaterial) + let blurView = UIVisualEffectView(effect: blurEffect) + blurView.translatesAutoresizingMaskIntoConstraints = false + blurView.layer.cornerRadius = size / 2 + blurView.clipsToBounds = true + + NSLayoutConstraint.activate([ + blurView.widthAnchor.constraint(equalToConstant: size), + blurView.heightAnchor.constraint(equalToConstant: size) + ]) + + return blurView + } + @objc private func handleTwoFingerTapPause(_ gesture: UITapGestureRecognizer) { if gesture.state == .ended { togglePlayPause() @@ -974,53 +1108,71 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele let config = UIImage.SymbolConfiguration(pointSize: 15, weight: .bold) let image = UIImage(systemName: "xmark", withConfiguration: config) + let dismissCircle = createCircularBlurBackground(size: 42) + view.addSubview(dismissCircle) + dismissCircle.translatesAutoresizingMaskIntoConstraints = false + dismissButton = UIButton(type: .system) dismissButton.setImage(image, for: .normal) dismissButton.tintColor = .white dismissButton.addTarget(self, action: #selector(dismissTapped), for: .touchUpInside) - controlsContainerView.addSubview(dismissButton) + + if let blurView = dismissCircle as? UIVisualEffectView { + blurView.contentView.addSubview(dismissButton) + } else { + dismissCircle.addSubview(dismissButton) + } + dismissButton.translatesAutoresizingMaskIntoConstraints = false - dismissButton.layer.shadowColor = UIColor.black.cgColor - dismissButton.layer.shadowOffset = CGSize(width: 0, height: 2) - dismissButton.layer.shadowOpacity = 0.6 - dismissButton.layer.shadowRadius = 4 - dismissButton.layer.masksToBounds = false - NSLayoutConstraint.activate([ - dismissButton.leadingAnchor.constraint(equalTo: controlsContainerView.leadingAnchor, constant: 16), - dismissButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20), + dismissCircle.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 16), + dismissCircle.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20), + dismissCircle.widthAnchor.constraint(equalToConstant: 42), + dismissCircle.heightAnchor.constraint(equalToConstant: 42), + + dismissButton.centerXAnchor.constraint(equalTo: dismissCircle.centerXAnchor), + dismissButton.centerYAnchor.constraint(equalTo: dismissCircle.centerYAnchor), dismissButton.widthAnchor.constraint(equalToConstant: 40), dismissButton.heightAnchor.constraint(equalToConstant: 40) ]) } func setupMarqueeLabel() { - marqueeLabel = MarqueeLabel() - marqueeLabel.text = "\(titleText) • Ep \(episodeNumber)" - marqueeLabel.type = .continuous - marqueeLabel.textColor = .white - marqueeLabel.font = UIFont.systemFont(ofSize: 14, weight: .heavy) + episodeNumberLabel = UILabel() + episodeNumberLabel.text = "Episode \(episodeNumber)" + episodeNumberLabel.textColor = UIColor(white: 1.0, alpha: 0.6) + episodeNumberLabel.font = UIFont.systemFont(ofSize: 14, weight: .semibold) + episodeNumberLabel.textAlignment = .left + episodeNumberLabel.setContentHuggingPriority(.required, for: .vertical) - marqueeLabel.speed = .rate(35) - marqueeLabel.fadeLength = 10.0 - marqueeLabel.leadingBuffer = 1.0 - marqueeLabel.trailingBuffer = 16.0 - marqueeLabel.animationDelay = 2.5 + titleLabel = MarqueeLabel() + titleLabel.text = titleText + titleLabel.type = .continuous + titleLabel.textColor = .white + titleLabel.font = UIFont.systemFont(ofSize: 20, weight: .heavy) + titleLabel.speed = .rate(35) + titleLabel.fadeLength = 10.0 + titleLabel.leadingBuffer = 1.0 + titleLabel.trailingBuffer = 16.0 + titleLabel.animationDelay = 2.5 + titleLabel.layer.shadowColor = UIColor.black.cgColor + titleLabel.layer.shadowOffset = CGSize(width: 0, height: 2) + titleLabel.layer.shadowOpacity = 0.6 + titleLabel.layer.shadowRadius = 4 + titleLabel.layer.masksToBounds = false + titleLabel.lineBreakMode = .byTruncatingTail + titleLabel.textAlignment = .left + titleLabel.setContentHuggingPriority(.defaultLow, for: .vertical) - marqueeLabel.layer.shadowColor = UIColor.black.cgColor - marqueeLabel.layer.shadowOffset = CGSize(width: 0, height: 2) - marqueeLabel.layer.shadowOpacity = 0.6 - marqueeLabel.layer.shadowRadius = 4 - marqueeLabel.layer.masksToBounds = false - - marqueeLabel.lineBreakMode = .byTruncatingTail - marqueeLabel.textAlignment = .left - - controlsContainerView.addSubview(marqueeLabel) - marqueeLabel.translatesAutoresizingMaskIntoConstraints = false - - updateMarqueeConstraints() + titleStackView = UIStackView(arrangedSubviews: [episodeNumberLabel, titleLabel]) + titleStackView.axis = .vertical + titleStackView.alignment = .leading + titleStackView.spacing = 0 + titleStackView.clipsToBounds = false + titleStackView.isLayoutMarginsRelativeArrangement = true + controlsContainerView.addSubview(titleStackView) + titleStackView.translatesAutoresizingMaskIntoConstraints = false } func volumeSlider() { @@ -1034,18 +1186,41 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele hostingController.view.backgroundColor = UIColor.clear hostingController.view.translatesAutoresizingMaskIntoConstraints = false - controlsContainerView.addSubview(hostingController.view) + let volumeCapsule = GradientBlurButton(type: .custom) + volumeCapsule.translatesAutoresizingMaskIntoConstraints = false + volumeCapsule.backgroundColor = .white + volumeCapsule.layer.cornerRadius = 21 + volumeCapsule.clipsToBounds = true + controlsContainerView.addSubview(volumeCapsule) + + if let blurView = volumeCapsule as? UIVisualEffectView { + blurView.contentView.addSubview(hostingController.view) + } else { + volumeCapsule.addSubview(hostingController.view) + } addChild(hostingController) hostingController.didMove(toParent: self) self.volumeSliderHostingView = hostingController.view NSLayoutConstraint.activate([ - hostingController.view.centerYAnchor.constraint(equalTo: dismissButton.centerYAnchor), - hostingController.view.trailingAnchor.constraint(equalTo: controlsContainerView.trailingAnchor, constant: -16), - hostingController.view.widthAnchor.constraint(equalToConstant: 160), + volumeCapsule.centerYAnchor.constraint(equalTo: dismissButton.centerYAnchor), + volumeCapsule.trailingAnchor.constraint(equalTo: controlsContainerView.trailingAnchor, constant: -16), + volumeCapsule.heightAnchor.constraint(equalToConstant: 42), + volumeCapsule.widthAnchor.constraint(equalToConstant: 200), + + hostingController.view.centerYAnchor.constraint(equalTo: volumeCapsule.centerYAnchor), + hostingController.view.leadingAnchor.constraint(equalTo: volumeCapsule.leadingAnchor, constant: 20), + hostingController.view.trailingAnchor.constraint(equalTo: volumeCapsule.trailingAnchor, constant: -20), hostingController.view.heightAnchor.constraint(equalToConstant: 30) ]) + + self.volumeSliderHostingView = volumeCapsule + + volumeCapsule.alpha = 1.0 + volumeCapsule.isHidden = false + hostingController.view.alpha = 1.0 + hostingController.view.isHidden = false } private func setupHoldSpeedIndicator() { @@ -1061,75 +1236,147 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele holdSpeedIndicator.setTitle(" \(speed)", for: .normal) holdSpeedIndicator.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .bold) holdSpeedIndicator.setImage(image, for: .normal) - - holdSpeedIndicator.backgroundColor = UIColor(red: 51/255.0, green: 51/255.0, blue: 51/255.0, alpha: 0.8) + holdSpeedIndicator.contentEdgeInsets = UIEdgeInsets(top: 6, left: 10, bottom: 6, right: 10) holdSpeedIndicator.tintColor = .white holdSpeedIndicator.setTitleColor(.white, for: .normal) - holdSpeedIndicator.layer.cornerRadius = 21 - holdSpeedIndicator.alpha = 0 + holdSpeedIndicator.alpha = 0.0 - holdSpeedIndicator.layer.shadowColor = UIColor.black.cgColor - holdSpeedIndicator.layer.shadowOffset = CGSize(width: 0, height: 2) - holdSpeedIndicator.layer.shadowOpacity = 0.6 - holdSpeedIndicator.layer.shadowRadius = 4 - holdSpeedIndicator.layer.masksToBounds = false + let blurEffect = UIBlurEffect(style: .systemUltraThinMaterial) + let blurView = UIVisualEffectView(effect: blurEffect) + blurView.translatesAutoresizingMaskIntoConstraints = false + blurView.layer.cornerRadius = 21 + blurView.clipsToBounds = true view.addSubview(holdSpeedIndicator) holdSpeedIndicator.translatesAutoresizingMaskIntoConstraints = false + holdSpeedIndicator.insertSubview(blurView, at: 0) + NSLayoutConstraint.activate([ holdSpeedIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor), holdSpeedIndicator.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20), holdSpeedIndicator.heightAnchor.constraint(equalToConstant: 40), - holdSpeedIndicator.widthAnchor.constraint(greaterThanOrEqualToConstant: 85) + holdSpeedIndicator.widthAnchor.constraint(greaterThanOrEqualToConstant: 85), + + blurView.leadingAnchor.constraint(equalTo: holdSpeedIndicator.leadingAnchor), + blurView.trailingAnchor.constraint(equalTo: holdSpeedIndicator.trailingAnchor), + blurView.topAnchor.constraint(equalTo: holdSpeedIndicator.topAnchor), + blurView.bottomAnchor.constraint(equalTo: holdSpeedIndicator.bottomAnchor) ]) holdSpeedIndicator.isUserInteractionEnabled = false + holdSpeedIndicator.layer.cornerRadius = 21 + holdSpeedIndicator.clipsToBounds = true + + holdSpeedIndicator.bringSubviewToFront(holdSpeedIndicator.imageView!) + holdSpeedIndicator.bringSubviewToFront(holdSpeedIndicator.titleLabel!) + + holdSpeedIndicator.imageView?.contentMode = .scaleAspectFit + holdSpeedIndicator.titleLabel?.textAlignment = .center } - private func updateSkipButtonsVisibility() { + func updateSkipButtonsVisibility() { + if !isControlsVisible { return } let t = currentTimeVal - let controlsShowing = isControlsVisible - func handle(_ button: UIButton, range: CMTimeRange?) { - guard let r = range else { button.isHidden = true; return } - - let inInterval = t >= r.start.seconds && t <= r.end.seconds - let target = controlsShowing ? 0.0 : skipButtonBaseAlpha - - if inInterval { - if button.isHidden { - button.alpha = 0 - } - button.isHidden = false + let skipIntroAvailable = skipIntervals.op != nil && + t >= skipIntervals.op!.start.seconds && + t <= skipIntervals.op!.end.seconds && + !skipIntroDismissedInSession + + let skipOutroAvailable = skipIntervals.ed != nil && + t >= skipIntervals.ed!.start.seconds && + t <= skipIntervals.ed!.end.seconds && + !skipOutroDismissedInSession + + let shouldShowSkip85 = isSkip85Visible && !skipIntroAvailable + + if skipIntroAvailable { + skipIntroButton.setTitle(" Skip Intro", for: .normal) + skipIntroButton.setImage(UIImage(systemName: "forward.frame"), for: .normal) + UIView.animate(withDuration: 0.2) { + self.skipIntroButton.alpha = 1.0 + } + } else { + UIView.animate(withDuration: 0.2) { + self.skipIntroButton.alpha = 0.0 + } + } + + if shouldShowSkip85 { + skip85Button.setTitle(" Skip 85s", for: .normal) + skip85Button.setImage(UIImage(systemName: "goforward"), for: .normal) + skip85Button.isHidden = false + UIView.animate(withDuration: 0.2) { + self.skip85Button.alpha = 1.0 + } + } else { + UIView.animate(withDuration: 0.2) { + self.skip85Button.alpha = 0.0 + } completion: { _ in + self.skip85Button.isHidden = true + } + } + + view.bringSubviewToFront(skip85Button) + + if skipOutroAvailable { + if skipOutroButton.superview == nil { + controlsContainerView.addSubview(skipOutroButton) - UIView.animate(withDuration: 0.25) { - button.alpha = target - } - return + NSLayoutConstraint.activate([ + skipOutroButton.trailingAnchor.constraint(equalTo: sliderHostingController!.view.trailingAnchor), + skipOutroButton.bottomAnchor.constraint(equalTo: sliderHostingController!.view.topAnchor, constant: -5), + skipOutroButton.heightAnchor.constraint(equalToConstant: 40), + skipOutroButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 104) + ]) + + view.setNeedsLayout() + view.layoutIfNeeded() } + + UIView.animate(withDuration: 0.2) { + self.skipOutroButton.alpha = 1.0 + } + } else { + removeSkipOutroButton() + } + + if !isMenuOpen { + updateControlButtonsContainerPosition() + updateMarqueeConstraintsForBottom() - guard !button.isHidden else { return } - UIView.animate(withDuration: 0.15, animations: { - button.alpha = 0 - }) { _ in - button.isHidden = true + UIView.animate(withDuration: 0.25) { + self.view.layoutIfNeeded() } } - handle(skipIntroButton, range: skipIntervals.op) - handle(skipOutroButton, range: skipIntervals.ed) + let hasVisibleButtons = [watchNextButton, speedButton, qualityButton, menuButton].contains { button in + guard let button = button else { return false } + return !button.isHidden + } - if skipIntroDismissedInSession { - skipIntroButton.isHidden = true - } else { - handle(skipIntroButton, range: skipIntervals.op) + if !isMenuOpen && (hasVisibleButtons || controlButtonsContainer.superview != nil) { + self.setupControlButtonsContainer() } - if skipOutroDismissedInSession { - skipOutroButton.isHidden = true + } + + private func updateControlButtonsContainerPosition() { + guard controlButtonsContainer.superview != nil else { return } + + controlButtonsContainerBottomConstraint?.isActive = false + + let skipOutroActuallyVisible = skipOutroButton.superview != nil && skipOutroButton.alpha > 0.1 + + if !skipOutroActuallyVisible { + controlButtonsContainerBottomConstraint = controlButtonsContainer.bottomAnchor.constraint( + equalTo: sliderHostingController!.view.topAnchor, constant: -27) } else { - handle(skipOutroButton, range: skipIntervals.ed) + controlButtonsContainerBottomConstraint = controlButtonsContainer.bottomAnchor.constraint( + equalTo: skipOutroButton.topAnchor, constant: -5) } + + controlButtonsContainerBottomConstraint?.isActive = true } private func updateSegments() { @@ -1168,7 +1415,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele ), inRange: 0...validDuration, activeFillColor: .white, - fillColor: .white.opacity(0.6), + fillColor: .white, textColor: .white.opacity(0.7), emptyColor: .white.opacity(0.3), height: 33, @@ -1230,333 +1477,72 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele func setupSkipButtons() { let introConfig = UIImage.SymbolConfiguration(pointSize: 14, weight: .bold) let introImage = UIImage(systemName: "forward.frame", withConfiguration: introConfig) - skipIntroButton = GradientOverlayButton(type: .system) + skipIntroButton = GradientBlurButton(type: .system) skipIntroButton.setTitle(" Skip Intro", for: .normal) skipIntroButton.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .bold) skipIntroButton.setImage(introImage, for: .normal) - - skipIntroButton.backgroundColor = UIColor(red: 51/255.0, green: 51/255.0, blue: 51/255.0, alpha: 0.8) skipIntroButton.contentEdgeInsets = UIEdgeInsets(top: 6, left: 10, bottom: 6, right: 10) skipIntroButton.tintColor = .white skipIntroButton.setTitleColor(.white, for: .normal) skipIntroButton.layer.cornerRadius = 21 - skipIntroButton.alpha = skipButtonBaseAlpha - - skipIntroButton.layer.shadowColor = UIColor.black.cgColor - skipIntroButton.layer.shadowOffset = CGSize(width: 0, height: 2) - skipIntroButton.layer.shadowOpacity = 0.6 - skipIntroButton.layer.shadowRadius = 4 - skipIntroButton.layer.masksToBounds = false - + skipIntroButton.clipsToBounds = true + skipIntroButton.alpha = 0.0 skipIntroButton.addTarget(self, action: #selector(skipIntro), for: .touchUpInside) - - view.addSubview(skipIntroButton) + controlsContainerView.addSubview(skipIntroButton) skipIntroButton.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - skipIntroButton.trailingAnchor.constraint(equalTo: sliderHostingController!.view.trailingAnchor), - skipIntroButton.bottomAnchor.constraint(equalTo: sliderHostingController!.view.topAnchor, constant: -5), + skipIntroButton.leadingAnchor.constraint(equalTo: controlsContainerView.leadingAnchor, constant: 18), + skipIntroButton.bottomAnchor.constraint(equalTo: sliderHostingController!.view.topAnchor, constant: -12), skipIntroButton.heightAnchor.constraint(equalToConstant: 40), skipIntroButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 104) ]) let outroConfig = UIImage.SymbolConfiguration(pointSize: 14, weight: .bold) let outroImage = UIImage(systemName: "forward.frame", withConfiguration: outroConfig) - skipOutroButton = GradientOverlayButton(type: .system) + skipOutroButton = GradientBlurButton(type: .system) skipOutroButton.setTitle(" Skip Outro", for: .normal) skipOutroButton.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .bold) skipOutroButton.setImage(outroImage, for: .normal) - - skipOutroButton.backgroundColor = UIColor(red: 51/255.0, green: 51/255.0, blue: 51/255.0, alpha: 0.8) skipOutroButton.contentEdgeInsets = UIEdgeInsets(top: 6, left: 10, bottom: 6, right: 10) skipOutroButton.tintColor = .white skipOutroButton.setTitleColor(.white, for: .normal) skipOutroButton.layer.cornerRadius = 21 - skipOutroButton.alpha = skipButtonBaseAlpha - - skipOutroButton.layer.shadowColor = UIColor.black.cgColor - skipOutroButton.layer.shadowOffset = CGSize(width: 0, height: 2) - skipOutroButton.layer.shadowOpacity = 0.6 - skipOutroButton.layer.shadowRadius = 4 - skipOutroButton.layer.masksToBounds = false - + skipOutroButton.clipsToBounds = true + skipOutroButton.alpha = 0.0 skipOutroButton.addTarget(self, action: #selector(skipOutro), for: .touchUpInside) - - view.addSubview(skipOutroButton) skipOutroButton.translatesAutoresizingMaskIntoConstraints = false - - NSLayoutConstraint.activate([ - skipOutroButton.trailingAnchor.constraint(equalTo: sliderHostingController!.view.trailingAnchor), - skipOutroButton.bottomAnchor.constraint(equalTo: sliderHostingController!.view.topAnchor, constant: -5), - skipOutroButton.heightAnchor.constraint(equalToConstant: 40), - skipOutroButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 104) - ]) - } - - private func setupDimButton() { - let cfg = UIImage.SymbolConfiguration(pointSize: 24, weight: .regular) - dimButton = UIButton(type: .system) - dimButton.setImage(UIImage(systemName: "moon.fill", withConfiguration: cfg), for: .normal) - dimButton.tintColor = .white - dimButton.addTarget(self, action: #selector(dimTapped), for: .touchUpInside) - controlsContainerView.addSubview(dimButton) - dimButton.translatesAutoresizingMaskIntoConstraints = false - - dimButton.layer.shadowColor = UIColor.black.cgColor - dimButton.layer.shadowOffset = CGSize(width: 0, height: 2) - dimButton.layer.shadowOpacity = 0.6 - dimButton.layer.shadowRadius = 4 - dimButton.layer.masksToBounds = false - - NSLayoutConstraint.activate([ - dimButton.topAnchor.constraint(equalTo: volumeSliderHostingView!.bottomAnchor, constant: 15), - dimButton.trailingAnchor.constraint(equalTo: volumeSliderHostingView!.trailingAnchor), - dimButton.widthAnchor.constraint(equalToConstant: 24), - dimButton.heightAnchor.constraint(equalToConstant: 24) - ]) - - dimButtonToSlider = dimButton.trailingAnchor.constraint(equalTo: volumeSliderHostingView!.trailingAnchor) - dimButtonToRight = dimButton.trailingAnchor.constraint(equalTo: controlsContainerView.trailingAnchor, constant: -16) - dimButtonToSlider.isActive = true - } - private func setupLockButton() { - let cfg = UIImage.SymbolConfiguration(pointSize: 24, weight: .regular) - lockButton = UIButton(type: .system) - lockButton.setImage( - UIImage(systemName: "lock.open.fill", withConfiguration: cfg), - for: .normal - ) - lockButton.tintColor = .white - lockButton.layer.shadowColor = UIColor.black.cgColor - lockButton.layer.shadowOffset = CGSize(width: 0, height: 2) - lockButton.layer.shadowOpacity = 0.6 - lockButton.layer.shadowRadius = 4 - lockButton.layer.masksToBounds = false - - lockButton.addTarget(self, action: #selector(lockTapped), for: .touchUpInside) - - view.addSubview(lockButton) - lockButton.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - lockButton.topAnchor.constraint(equalTo: volumeSliderHostingView!.bottomAnchor, constant: 60), - lockButton.trailingAnchor.constraint(equalTo: volumeSliderHostingView!.trailingAnchor), - lockButton.widthAnchor.constraint(equalToConstant: 24), - lockButton.heightAnchor.constraint(equalToConstant: 24), - ]) - } - - func updateMarqueeConstraints() { - UIView.performWithoutAnimation { - NSLayoutConstraint.deactivate(currentMarqueeConstraints) - - let leftSpacing: CGFloat = 2 - let rightSpacing: CGFloat = 6 - let trailingAnchor: NSLayoutXAxisAnchor = (volumeSliderHostingView?.isHidden == false) - ? volumeSliderHostingView!.leadingAnchor - : view.safeAreaLayoutGuide.trailingAnchor - - currentMarqueeConstraints = [ - marqueeLabel.leadingAnchor.constraint( - equalTo: dismissButton.trailingAnchor, constant: leftSpacing), - marqueeLabel.trailingAnchor.constraint( - equalTo: trailingAnchor, constant: -rightSpacing - 10), - marqueeLabel.centerYAnchor.constraint(equalTo: dismissButton.centerYAnchor) - ] - NSLayoutConstraint.activate(currentMarqueeConstraints) - view.layoutIfNeeded() - } - } - - private func setupPipIfSupported() { - airplayButton = AVRoutePickerView(frame: .zero) - airplayButton.translatesAutoresizingMaskIntoConstraints = false - airplayButton.activeTintColor = .white - airplayButton.tintColor = .white - airplayButton.backgroundColor = .clear - airplayButton.prioritizesVideoDevices = true - airplayButton.setContentHuggingPriority(.required, for: .horizontal) - airplayButton.setContentCompressionResistancePriority(.required, for: .horizontal) - controlsContainerView.addSubview(airplayButton) - - airplayButton.layer.shadowColor = UIColor.black.cgColor - airplayButton.layer.shadowOffset = CGSize(width: 0, height: 2) - airplayButton.layer.shadowOpacity = 0.6 - airplayButton.layer.shadowRadius = 4 - airplayButton.layer.masksToBounds = false - - guard AVPictureInPictureController.isPictureInPictureSupported() else { - return - } - let pipPlayerLayer = AVPlayerLayer(player: playerViewController.player) - pipPlayerLayer.frame = playerViewController.view.layer.bounds - pipPlayerLayer.videoGravity = .resizeAspect - - playerViewController.view.layer.insertSublayer(pipPlayerLayer, at: 0) - pipController = AVPictureInPictureController(playerLayer: pipPlayerLayer) - pipController?.delegate = self - - let config = UIImage.SymbolConfiguration(pointSize: 15, weight: .medium) - let Image = UIImage(systemName: "pip", withConfiguration: config) - pipButton = UIButton(type: .system) - pipButton.setImage(Image, for: .normal) - pipButton.tintColor = .white - pipButton.addTarget(self, action: #selector(pipButtonTapped(_:)), for: .touchUpInside) - - pipButton.layer.shadowColor = UIColor.black.cgColor - pipButton.layer.shadowOffset = CGSize(width: 0, height: 2) - pipButton.layer.shadowOpacity = 0.6 - pipButton.layer.shadowRadius = 4 - pipButton.layer.masksToBounds = false - - controlsContainerView.addSubview(pipButton) - pipButton.translatesAutoresizingMaskIntoConstraints = false - - NSLayoutConstraint.activate([ - pipButton.centerYAnchor.constraint(equalTo: dimButton.centerYAnchor), - pipButton.trailingAnchor.constraint(equalTo: dimButton.leadingAnchor, constant: -8), - pipButton.widthAnchor.constraint(equalToConstant: 44), - pipButton.heightAnchor.constraint(equalToConstant: 44), - airplayButton.centerYAnchor.constraint(equalTo: pipButton.centerYAnchor), - airplayButton.trailingAnchor.constraint(equalTo: pipButton.leadingAnchor, constant: -4), - airplayButton.widthAnchor.constraint(equalToConstant: 44), - airplayButton.heightAnchor.constraint(equalToConstant: 44) - ]) - - pipButton.isHidden = !isPipButtonVisible - - NotificationCenter.default.addObserver(self, selector: #selector(startPipIfNeeded), name: UIApplication.willResignActiveNotification, object: nil) - } - - func setupMenuButton() { - let config = UIImage.SymbolConfiguration(pointSize: 15, weight: .bold) - let image = UIImage(systemName: "text.bubble", withConfiguration: config) - - menuButton = UIButton(type: .system) - menuButton.setImage(image, for: .normal) - menuButton.tintColor = .white - - if let subtitlesURL = subtitlesURL, !subtitlesURL.isEmpty { - menuButton.showsMenuAsPrimaryAction = true - menuButton.menu = buildOptionsMenu() - } else { - menuButton.isHidden = true - } - - menuButton.layer.shadowColor = UIColor.black.cgColor - menuButton.layer.shadowOffset = CGSize(width: 0, height: 2) - menuButton.layer.shadowOpacity = 0.6 - menuButton.layer.shadowRadius = 4 - menuButton.layer.masksToBounds = false - - controlsContainerView.addSubview(menuButton) - menuButton.translatesAutoresizingMaskIntoConstraints = false - - NSLayoutConstraint.activate([ - menuButton.topAnchor.constraint(equalTo: qualityButton.topAnchor), - menuButton.widthAnchor.constraint(equalToConstant: 40), - menuButton.heightAnchor.constraint(equalToConstant: 40), - ]) - - currentMenuButtonTrailing = menuButton.trailingAnchor.constraint(equalTo: qualityButton.leadingAnchor, constant: -6) - } - - func setupSpeedButton() { - let config = UIImage.SymbolConfiguration(pointSize: 15, weight: .bold) - let image = UIImage(systemName: "speedometer", withConfiguration: config) - - speedButton = UIButton(type: .system) - speedButton.setImage(image, for: .normal) - speedButton.tintColor = .white - speedButton.showsMenuAsPrimaryAction = true - speedButton.menu = speedChangerMenu() - - speedButton.layer.shadowColor = UIColor.black.cgColor - speedButton.layer.shadowOffset = CGSize(width: 0, height: 2) - speedButton.layer.shadowOpacity = 0.6 - speedButton.layer.shadowRadius = 4 - speedButton.layer.masksToBounds = false - - controlsContainerView.addSubview(speedButton) - speedButton.translatesAutoresizingMaskIntoConstraints = false - - NSLayoutConstraint.activate([ - speedButton.topAnchor.constraint(equalTo: watchNextButton.topAnchor), - speedButton.trailingAnchor.constraint(equalTo: watchNextButton.leadingAnchor, constant: 18), - speedButton.widthAnchor.constraint(equalToConstant: 40), - speedButton.heightAnchor.constraint(equalToConstant: 40) - ]) - } - - func setupWatchNextButton() { - let config = UIImage.SymbolConfiguration(pointSize: 15, weight: .bold) - let image = UIImage(systemName: "forward.end", withConfiguration: config) - - watchNextButton = UIButton(type: .system) - watchNextButton.setImage(image, for: .normal) - watchNextButton.backgroundColor = .clear - watchNextButton.tintColor = .white - watchNextButton.setTitleColor(.white, for: .normal) - - watchNextButton.layer.shadowColor = UIColor.black.cgColor - watchNextButton.layer.shadowOffset = CGSize(width: 0, height: 2) - watchNextButton.layer.shadowOpacity = 0.6 - watchNextButton.layer.shadowRadius = 4 - watchNextButton.layer.masksToBounds = false - - watchNextButton.addTarget(self, action: #selector(watchNextTapped), for: .touchUpInside) - - controlsContainerView.addSubview(watchNextButton) - watchNextButton.translatesAutoresizingMaskIntoConstraints = false - - NSLayoutConstraint.activate([ - watchNextButton.trailingAnchor.constraint(equalTo: sliderHostingController!.view.trailingAnchor, constant: 20), - watchNextButton.bottomAnchor.constraint(equalTo: sliderHostingController!.view.topAnchor, constant: -5), - watchNextButton.heightAnchor.constraint(equalToConstant: 40), - watchNextButton.widthAnchor.constraint(equalToConstant: 80) - ]) } func setupSkip85Button() { let config = UIImage.SymbolConfiguration(pointSize: 14, weight: .bold) let image = UIImage(systemName: "goforward", withConfiguration: config) - - skip85Button = GradientOverlayButton(type: .system) + skip85Button = GradientBlurButton(type: .system) skip85Button.setTitle(" Skip 85s", for: .normal) skip85Button.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .bold) skip85Button.setImage(image, for: .normal) - - skip85Button.backgroundColor = UIColor(red: 51/255.0, green: 51/255.0, blue: 51/255.0, alpha: 0.8) skip85Button.contentEdgeInsets = UIEdgeInsets(top: 6, left: 10, bottom: 6, right: 10) skip85Button.tintColor = .white skip85Button.setTitleColor(.white, for: .normal) skip85Button.layer.cornerRadius = 21 - skip85Button.alpha = 0.7 - - skip85Button.layer.shadowColor = UIColor.black.cgColor - skip85Button.layer.shadowOffset = CGSize(width: 0, height: 2) - skip85Button.layer.shadowOpacity = 0.6 - skip85Button.layer.shadowRadius = 4 - skip85Button.layer.masksToBounds = false - + skip85Button.clipsToBounds = true + skip85Button.alpha = 0.0 skip85Button.addTarget(self, action: #selector(skip85Tapped), for: .touchUpInside) - - view.addSubview(skip85Button) + controlsContainerView.addSubview(skip85Button) skip85Button.translatesAutoresizingMaskIntoConstraints = false - - NSLayoutConstraint.activate([ - skip85Button.leadingAnchor.constraint(equalTo: sliderHostingController!.view.leadingAnchor), - skip85Button.bottomAnchor.constraint(equalTo: sliderHostingController!.view.topAnchor, constant: -5), + + let skip85Constraints = [ + skip85Button.leadingAnchor.constraint(equalTo: controlsContainerView.leadingAnchor, constant: 18), + skip85Button.bottomAnchor.constraint(equalTo: sliderHostingController!.view.topAnchor, constant: -12), skip85Button.heightAnchor.constraint(equalToConstant: 40), skip85Button.widthAnchor.constraint(greaterThanOrEqualToConstant: 97) - ]) - - skip85Button.isHidden = !isSkip85Visible + ] + NSLayoutConstraint.activate(skip85Constraints) } private func setupQualityButton() { let config = UIImage.SymbolConfiguration(pointSize: 15, weight: .bold) - let image = UIImage(systemName: "4k.tv", withConfiguration: config) + let image = UIImage(systemName: "tv", withConfiguration: config) qualityButton = UIButton(type: .system) qualityButton.setImage(image, for: .normal) @@ -1565,21 +1551,107 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele qualityButton.menu = qualitySelectionMenu() qualityButton.isHidden = true - qualityButton.layer.shadowColor = UIColor.black.cgColor - qualityButton.layer.shadowOffset = CGSize(width: 0, height: 2) - qualityButton.layer.shadowOpacity = 0.6 - qualityButton.layer.shadowRadius = 4 - qualityButton.layer.masksToBounds = false + qualityButton.addTarget(self, action: #selector(protectMenuFromRecreation), for: .touchDown) - controlsContainerView.addSubview(qualityButton) qualityButton.translatesAutoresizingMaskIntoConstraints = false + } + + private func setupControlButtonsContainer() { + controlButtonsContainer?.removeFromSuperview() + + controlButtonsContainer = GradientBlurButton(type: .custom) + controlButtonsContainer.isUserInteractionEnabled = true + controlButtonsContainer.translatesAutoresizingMaskIntoConstraints = false + controlButtonsContainer.backgroundColor = .clear + controlButtonsContainer.layer.cornerRadius = 21 + controlButtonsContainer.clipsToBounds = true + controlsContainerView.addSubview(controlButtonsContainer) + + Logger.shared.log("Setting up control buttons container - isHLSStream: \(isHLSStream)", type: "Debug") + + if isHLSStream { + Logger.shared.log("HLS stream detected, showing quality button", type: "Debug") + qualityButton.isHidden = false + qualityButton.menu = qualitySelectionMenu() + } else { + Logger.shared.log("Not an HLS stream, quality button will be hidden", type: "Debug") + qualityButton.isHidden = true + } + + let visibleButtons = [watchNextButton, speedButton, qualityButton, menuButton].compactMap { button -> UIButton? in + guard let button = button else { + Logger.shared.log("Button is nil", type: "Debug") + return nil + } + + if button == qualityButton { + Logger.shared.log("Quality button state - isHidden: \(button.isHidden), isHLSStream: \(isHLSStream)", type: "Debug") + } + + if button.isHidden { + if button == qualityButton { + Logger.shared.log("Quality button is hidden, skipping", type: "Debug") + } + return nil + } + + controlButtonsContainer.addSubview(button) + button.layer.shadowOpacity = 0 + + if button == qualityButton { + Logger.shared.log("Quality button added to container", type: "Debug") + } + + return button + } + + Logger.shared.log("Visible buttons count: \(visibleButtons.count)", type: "Debug") + Logger.shared.log("Visible buttons: \(visibleButtons.map { $0 == watchNextButton ? "watchNext" : $0 == speedButton ? "speed" : $0 == qualityButton ? "quality" : "menu" })", type: "Debug") + + if visibleButtons.isEmpty { + Logger.shared.log("No visible buttons, removing container from view hierarchy", type: "Debug") + controlButtonsContainer.removeFromSuperview() + return + } + + controlButtonsContainer.alpha = 1.0 + + for (index, button) in visibleButtons.enumerated() { + NSLayoutConstraint.activate([ + button.centerYAnchor.constraint(equalTo: controlButtonsContainer.centerYAnchor), + button.heightAnchor.constraint(equalToConstant: 40), + button.widthAnchor.constraint(equalToConstant: 40) + ]) + + if index == 0 { + button.trailingAnchor.constraint(equalTo: controlButtonsContainer.trailingAnchor, constant: -10).isActive = true + } else { + button.trailingAnchor.constraint(equalTo: visibleButtons[index - 1].leadingAnchor, constant: -6).isActive = true + } + + if index == visibleButtons.count - 1 { + controlButtonsContainer.leadingAnchor.constraint(equalTo: button.leadingAnchor, constant: -10).isActive = true + } + } NSLayoutConstraint.activate([ - qualityButton.topAnchor.constraint(equalTo: speedButton.topAnchor), - qualityButton.trailingAnchor.constraint(equalTo: speedButton.leadingAnchor, constant: -6), - qualityButton.widthAnchor.constraint(equalToConstant: 40), - qualityButton.heightAnchor.constraint(equalToConstant: 40) + controlButtonsContainer.trailingAnchor.constraint(equalTo: sliderHostingController!.view.trailingAnchor), + controlButtonsContainer.heightAnchor.constraint(equalToConstant: 42) ]) + + let skipOutroActuallyVisible = skipOutroButton.superview != nil && skipOutroButton.alpha > 0.1 + + if skipOutroActuallyVisible { + controlButtonsContainerBottomConstraint = controlButtonsContainer.bottomAnchor.constraint( + equalTo: skipOutroButton.topAnchor, constant: -5) + } else { + controlButtonsContainerBottomConstraint = controlButtonsContainer.bottomAnchor.constraint( + equalTo: sliderHostingController!.view.topAnchor, constant: -12) + } + + controlButtonsContainerBottomConstraint?.isActive = true + + Logger.shared.log("Control buttons container setup complete", type: "Debug") } func updateSubtitleLabelAppearance() { @@ -1616,7 +1688,6 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele self.sliderViewModel.sliderValue = max(0, min(self.currentTimeVal, self.duration)) } - // Update end time when current time changes self.updateEndTime() self.updateSkipButtonsVisibility() @@ -1695,7 +1766,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele ), inRange: 0...(self.duration > 0 ? self.duration : 1.0), activeFillColor: .white, - fillColor: .white.opacity(0.6), + fillColor: .white, textColor: .white.opacity(0.7), emptyColor: .white.opacity(0.3), height: 33, @@ -1744,35 +1815,20 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele } } - func updateMenuButtonConstraints() { - currentMenuButtonTrailing.isActive = false - - let anchor: NSLayoutXAxisAnchor - if (!qualityButton.isHidden) { - anchor = qualityButton.leadingAnchor - } else if (!speedButton.isHidden) { - anchor = speedButton.leadingAnchor - } else { - anchor = controlsContainerView.trailingAnchor - } - - currentMenuButtonTrailing = menuButton.trailingAnchor.constraint(equalTo: anchor, constant: -6) - currentMenuButtonTrailing.isActive = true - } + @objc func toggleControls() { if controlsLocked { - lockButton.alpha = 1.0 + unlockButton.alpha = 1.0 lockButtonTimer?.invalidate() lockButtonTimer = Timer.scheduledTimer( withTimeInterval: 3.0, repeats: false ) { [weak self] _ in UIView.animate(withDuration: 0.3) { - self?.lockButton.alpha = 0 + self?.unlockButton.alpha = 0 } } - updateSkipButtonsVisibility() return } @@ -1789,16 +1845,40 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele } } + let holdSpeedAlpha = holdSpeedIndicator?.alpha ?? 0 + UIView.animate(withDuration: 0.2) { let alpha: CGFloat = self.isControlsVisible ? 1.0 : 0.0 - self.controlsContainerView.alpha = alpha - self.skip85Button.alpha = alpha - self.lockButton.alpha = alpha + + for control in self.controlsToHide { + control.alpha = alpha + } + + self.dismissButton.alpha = self.isControlsVisible ? 1.0 : 0.0 + + if let holdSpeed = self.holdSpeedIndicator { + holdSpeed.alpha = holdSpeedAlpha + } + + if self.isControlsVisible { + self.skip85Button.isHidden = false + self.skipIntroButton.alpha = 0.0 + self.skipOutroButton.alpha = 0.0 + } + self.subtitleBottomToSafeAreaConstraint?.isActive = !self.isControlsVisible self.subtitleBottomToSliderConstraint?.isActive = self.isControlsVisible self.view.layoutIfNeeded() } - updateSkipButtonsVisibility() + + if isControlsVisible { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.setupControlButtonsContainer() + self.updateSkipButtonsVisibility() + } + } else { + updateSkipButtonsVisibility() + } } @objc func seekBackwardLongPress(_ gesture: UILongPressGestureRecognizer) { @@ -1842,7 +1922,8 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele let finalSkip = skipValue > 0 ? skipValue : 10 currentTimeVal = min(currentTimeVal + finalSkip, duration) player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) { [weak self] finished in - guard self != nil else { return } } + guard self != nil else { return } + } animateButtonRotation(forwardButton) } @@ -1855,6 +1936,8 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele seekForward() showSkipFeedback(direction: "forward") } + + skipOutroDismissedInSession = false } @objc func handleSwipeDown(_ gesture: UISwipeGestureRecognizer) { @@ -1867,17 +1950,6 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele player.pause() isPlaying = false playPauseButton.image = UIImage(systemName: "play.fill") - - DispatchQueue.main.async { - if !self.isControlsVisible { - self.isControlsVisible = true - UIView.animate(withDuration: 0.1, animations: { - self.controlsContainerView.alpha = 1.0 - self.skip85Button.alpha = 0.8 - }) - self.updateSkipButtonsVisibility() - } - } } else { player.play() player.rate = currentPlaybackSpeed @@ -1887,11 +1959,18 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele } @objc private func pipButtonTapped(_ sender: UIButton) { - guard let pip = pipController else { return } + Logger.shared.log("PiP button tapped", type: "Debug") + guard let pip = pipController else { + Logger.shared.log("PiP controller is nil", type: "Error") + return + } + Logger.shared.log("PiP controller found, isActive: \(pip.isPictureInPictureActive)", type: "Debug") if pip.isPictureInPictureActive { pip.stopPictureInPicture() + Logger.shared.log("Stopping PiP", type: "Debug") } else { pip.startPictureInPicture() + Logger.shared.log("Starting PiP", type: "Debug") } } @@ -1910,37 +1989,57 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele isControlsVisible = !controlsLocked lockButtonTimer?.invalidate() + let holdSpeedAlpha = holdSpeedIndicator?.alpha ?? 0 + if controlsLocked { UIView.animate(withDuration: 0.25) { self.controlsContainerView.alpha = 0 - self.dimButton.alpha = 0 + self.dimButton.alpha = 0 for v in self.controlsToHide { v.alpha = 0 } self.skipIntroButton.alpha = 0 self.skipOutroButton.alpha = 0 - self.skip85Button.alpha = 0 + self.skip85Button.alpha = 0 self.lockButton.alpha = 0 + self.unlockButton.alpha = 1 + + if let holdSpeed = self.holdSpeedIndicator { + holdSpeed.alpha = holdSpeedAlpha + } self.subtitleBottomToSafeAreaConstraint?.isActive = true - self.subtitleBottomToSliderConstraint?.isActive = false + self.subtitleBottomToSliderConstraint?.isActive = false self.view.layoutIfNeeded() } - lockButton.setImage(UIImage(systemName: "lock.fill"), for: .normal) + lockButton.setImage(UIImage(systemName: "lock"), for: .normal) + lockButtonTimer = Timer.scheduledTimer( + withTimeInterval: 3.0, + repeats: false + ) { [weak self] _ in + UIView.animate(withDuration: 0.3) { + self?.unlockButton.alpha = 0 + } + } } else { UIView.animate(withDuration: 0.25) { self.controlsContainerView.alpha = 1 - self.dimButton.alpha = 1 + self.dimButton.alpha = 1 for v in self.controlsToHide { v.alpha = 1 } + self.unlockButton.alpha = 0 + + if let holdSpeed = self.holdSpeedIndicator { + holdSpeed.alpha = holdSpeedAlpha + } self.subtitleBottomToSafeAreaConstraint?.isActive = false - self.subtitleBottomToSliderConstraint?.isActive = true + self.subtitleBottomToSliderConstraint?.isActive = true self.view.layoutIfNeeded() } - lockButton.setImage(UIImage(systemName: "lock.open.fill"), for: .normal) + lockButton.setImage(UIImage(systemName: "lock.open"), for: .normal) updateSkipButtonsVisibility() } } @@ -1948,14 +2047,47 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele @objc private func skipIntro() { if let range = skipIntervals.op { player.seek(to: range.end) - skipIntroButton.isHidden = true + skipIntroDismissedInSession = true + hideSkipIntroButton() + } + } + + private func hideSkipIntroButton() { + UIView.animate(withDuration: 0.2) { + self.skipIntroButton.alpha = 0.0 + } completion: { _ in + self.updateSkipButtonsVisibility() + self.view.setNeedsLayout() + self.view.layoutIfNeeded() } } @objc private func skipOutro() { if let range = skipIntervals.ed { player.seek(to: range.end) - skipOutroButton.isHidden = true + skipOutroDismissedInSession = true + removeSkipOutroButton() + } + } + + private func removeSkipOutroButton() { + if skipOutroButton.superview != nil { + UIView.animate(withDuration: 0.2, animations: { + self.skipOutroButton.alpha = 0.0 + }, completion: { _ in + self.view.setNeedsLayout() + self.view.layoutIfNeeded() + + self.updateControlButtonsContainerPosition() + + let hasVisibleButtons = [self.watchNextButton, self.speedButton, self.qualityButton, self.menuButton].contains { button in + guard let button = button else { return false } + return !button.isHidden + } + if hasVisibleButtons || self.controlButtonsContainer.superview != nil { + self.setupControlButtonsContainer() + } + }) } } @@ -1979,10 +2111,8 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele isDimmed.toggle() dimButtonTimer?.invalidate() UIView.animate(withDuration: 0.25) { - self.blackCoverView.alpha = self.isDimmed ? 1.0 : 0.4 + self.blackCoverView.alpha = self.isDimmed ? 1.0 : 0.0 } - dimButtonToSlider.isActive = !isDimmed - dimButtonToRight.isActive = isDimmed } func speedChangerMenu() -> UIMenu { @@ -2343,9 +2473,10 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele private func checkForHLSStream() { guard let url = URL(string: streamURL) else { return } - let streamType = module.metadata.streamType.lowercased() + Logger.shared.log("Checking for HLS stream: \(url.absoluteString)", type: "Debug") if url.absoluteString.contains(".m3u8") || url.absoluteString.contains(".m3u") { + Logger.shared.log("HLS stream detected", type: "Debug") isHLSStream = true baseM3U8URL = url currentQualityURL = url @@ -2380,17 +2511,22 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele } } - self.qualityButton.isHidden = false - self.qualityButton.menu = self.qualitySelectionMenu() - self.updateMenuButtonConstraints() - UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseInOut) { - self.view.layoutIfNeeded() + DispatchQueue.main.async { + Logger.shared.log("Showing quality button on main thread", type: "Debug") + self.qualityButton.isHidden = false + self.qualityButton.menu = self.qualitySelectionMenu() + + self.setupControlButtonsContainer() + + UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseInOut) { + self.view.layoutIfNeeded() + } } } } else { + Logger.shared.log("Not an HLS stream", type: "Debug") isHLSStream = false qualityButton.isHidden = true - updateMenuButtonConstraints() Logger.shared.log("Quality Selection: Non-HLS stream detected, quality selection unavailable", type: "General") } } @@ -2714,7 +2850,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele player.rate = speed UIView.animate(withDuration: 0.1) { - self.holdSpeedIndicator.alpha = 0.8 + self.holdSpeedIndicator.alpha = 1.0 } } @@ -2761,12 +2897,11 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele ), inRange: 0...1, activeFillColor: .white, - fillColor: .white.opacity(0.6), + fillColor: .white, emptyColor: .white.opacity(0.3), height: 10, onEditingChanged: { _ in } ) - .shadow(color: Color.black.opacity(0.6), radius: 4, x: 0, y: 2) } } @@ -2841,12 +2976,10 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele controlsContainerView.addSubview(container) self.timeBatteryContainer = container - // Add tap gesture to toggle end time visibility let tapGesture = UITapGestureRecognizer(target: self, action: #selector(toggleEndTimeVisibility)) container.addGestureRecognizer(tapGesture) container.isUserInteractionEnabled = true - // Add end time components (initially hidden) let endTimeIcon = UIImageView(image: UIImage(systemName: "timer")) endTimeIcon.translatesAutoresizingMaskIntoConstraints = false endTimeIcon.tintColor = .white @@ -2968,7 +3101,6 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele self.endTimeSeparator?.alpha = alpha self.endTimeLabel?.alpha = alpha - // 调整容器位置以保持居中 if let container = self.timeBatteryContainer { container.transform = CGAffineTransform(translationX: offset, y: 0) } @@ -2986,14 +3118,23 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele let currentSeconds = CMTimeGetSeconds(player.currentTime()) let remainingSeconds = duration - currentSeconds + let playbackSpeed = player.rate if remainingSeconds <= 0 { endTimeLabel?.text = "--:--" return } - // Calculate end time by adding remaining seconds to current time - let endTime = Date().addingTimeInterval(remainingSeconds) + if playbackSpeed == 0 { + let endTime = Date().addingTimeInterval(remainingSeconds) + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm" + endTimeLabel?.text = formatter.string(from: endTime) + return + } + + let realTimeRemaining = remainingSeconds / Double(playbackSpeed) + let endTime = Date().addingTimeInterval(realTimeRemaining) let formatter = DateFormatter() formatter.dateFormat = "HH:mm" endTimeLabel?.text = formatter.string(from: endTime) @@ -3025,6 +3166,266 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele } } } + + + + @objc private func protectMenuFromRecreation() { + isMenuOpen = true + } + + func updateMarqueeConstraintsForBottom() { + NSLayoutConstraint.deactivate(titleStackAboveSkipButtonConstraints) + NSLayoutConstraint.deactivate(titleStackAboveSliderConstraints) + + let skipIntroVisible = !(skipIntroButton?.isHidden ?? true) && (skipIntroButton?.alpha ?? 0) > 0.1 + let skip85Visible = !(skip85Button?.isHidden ?? true) && (skip85Button?.alpha ?? 0) > 0.1 + let skipOutroVisible = skipOutroButton.superview != nil && !skipOutroButton.isHidden && skipOutroButton.alpha > 0.1 + + if skipIntroVisible && skipIntroButton?.superview != nil && titleStackView.superview != nil { + titleStackAboveSkipButtonConstraints = [ + titleStackView.leadingAnchor.constraint(equalTo: controlsContainerView.leadingAnchor, constant: 18), + titleStackView.bottomAnchor.constraint(equalTo: skipIntroButton.topAnchor, constant: -4), + titleStackView.widthAnchor.constraint(lessThanOrEqualTo: controlsContainerView.widthAnchor, multiplier: 0.7) + ] + NSLayoutConstraint.activate(titleStackAboveSkipButtonConstraints) + } else if skip85Visible && skip85Button?.superview != nil && titleStackView.superview != nil { + titleStackAboveSkipButtonConstraints = [ + titleStackView.leadingAnchor.constraint(equalTo: controlsContainerView.leadingAnchor, constant: 18), + titleStackView.bottomAnchor.constraint(equalTo: skip85Button.topAnchor, constant: -4), + titleStackView.widthAnchor.constraint(lessThanOrEqualTo: controlsContainerView.widthAnchor, multiplier: 0.7) + ] + NSLayoutConstraint.activate(titleStackAboveSkipButtonConstraints) + } else if let sliderView = sliderHostingController?.view, titleStackView.superview != nil { + titleStackAboveSliderConstraints = [ + titleStackView.leadingAnchor.constraint(equalTo: controlsContainerView.leadingAnchor, constant: 18), + titleStackView.bottomAnchor.constraint(equalTo: sliderView.topAnchor, constant: -4), + titleStackView.widthAnchor.constraint(lessThanOrEqualTo: controlsContainerView.widthAnchor, multiplier: 0.7) + ] + NSLayoutConstraint.activate(titleStackAboveSliderConstraints) + } + + view.layoutIfNeeded() + } + + func setupWatchNextButton() { + let config = UIImage.SymbolConfiguration(pointSize: 15, weight: .bold) + let image = UIImage(systemName: "forward.end", withConfiguration: config) + + watchNextButton = UIButton(type: .system) + watchNextButton.setImage(image, for: .normal) + watchNextButton.backgroundColor = .clear + watchNextButton.tintColor = .white + watchNextButton.setTitleColor(.white, for: .normal) + watchNextButton.addTarget(self, action: #selector(watchNextTapped), for: .touchUpInside) + watchNextButton.translatesAutoresizingMaskIntoConstraints = false + } + + private func setupDimButton() { + let cfg = UIImage.SymbolConfiguration(pointSize: 15, weight: .medium) + dimButton = UIButton(type: .system) + dimButton.setImage(UIImage(systemName: "moon", withConfiguration: cfg), for: .normal) + dimButton.tintColor = .white + dimButton.addTarget(self, action: #selector(dimTapped), for: .touchUpInside) + view.addSubview(dimButton) + dimButton.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + dimButton.centerYAnchor.constraint(equalTo: dismissButton.centerYAnchor), + dimButton.widthAnchor.constraint(equalToConstant: 24), + dimButton.heightAnchor.constraint(equalToConstant: 24) + ]) + + dimButtonToSlider = dimButton.trailingAnchor.constraint(equalTo: volumeSliderHostingView!.trailingAnchor) + dimButtonToRight = dimButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16) + } + + func setupSpeedButton() { + let config = UIImage.SymbolConfiguration(pointSize: 15, weight: .bold) + let image = UIImage(systemName: "speedometer", withConfiguration: config) + + speedButton = UIButton(type: .system) + speedButton.setImage(image, for: .normal) + speedButton.tintColor = .white + speedButton.showsMenuAsPrimaryAction = true + speedButton.menu = speedChangerMenu() + + speedButton.addTarget(self, action: #selector(protectMenuFromRecreation), for: .touchDown) + + speedButton.translatesAutoresizingMaskIntoConstraints = false + } + + func setupMenuButton() { + let config = UIImage.SymbolConfiguration(pointSize: 15, weight: .bold) + let image = UIImage(systemName: "captions.bubble", withConfiguration: config) + + menuButton = UIButton(type: .system) + menuButton.setImage(image, for: .normal) + menuButton.tintColor = .white + menuButton.showsMenuAsPrimaryAction = true + + if let subtitlesURL = subtitlesURL, !subtitlesURL.isEmpty { + menuButton.menu = buildOptionsMenu() + } else { + menuButton.isHidden = true + } + + menuButton.addTarget(self, action: #selector(protectMenuFromRecreation), for: .touchDown) + + menuButton.translatesAutoresizingMaskIntoConstraints = false + } + + private func setupLockButton() { + let cfg = UIImage.SymbolConfiguration(pointSize: 15, weight: .medium) + lockButton = UIButton(type: .system) + lockButton.setImage( + UIImage(systemName: "lock.open", withConfiguration: cfg), + for: .normal + ) + lockButton.tintColor = .white + + lockButton.addTarget(self, action: #selector(lockTapped), for: .touchUpInside) + + view.addSubview(lockButton) + lockButton.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + lockButton.centerYAnchor.constraint(equalTo: dismissButton.centerYAnchor), + lockButton.widthAnchor.constraint(equalToConstant: 24), + lockButton.heightAnchor.constraint(equalToConstant: 24), + ]) + } + + private func setupUnlockButton() { + let cfg = UIImage.SymbolConfiguration(pointSize: 40, weight: .medium) + unlockButton = UIButton(type: .system) + unlockButton.setImage( + UIImage(systemName: "lock", withConfiguration: cfg), + for: .normal + ) + unlockButton.tintColor = .white + unlockButton.alpha = 0 + unlockButton.layer.shadowColor = UIColor.black.cgColor + unlockButton.layer.shadowOffset = CGSize(width: 0, height: 2) + unlockButton.layer.shadowOpacity = 0.6 + unlockButton.layer.shadowRadius = 4 + unlockButton.layer.masksToBounds = false + + unlockButton.addTarget(self, action: #selector(lockTapped), for: .touchUpInside) + + view.addSubview(unlockButton) + unlockButton.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + unlockButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), + unlockButton.centerYAnchor.constraint(equalTo: view.centerYAnchor), + unlockButton.widthAnchor.constraint(equalToConstant: 80), + unlockButton.heightAnchor.constraint(equalToConstant: 80) + ]) + } + + private func setupPipIfSupported() { + airplayButton = AVRoutePickerView(frame: .zero) + airplayButton.translatesAutoresizingMaskIntoConstraints = false + airplayButton.activeTintColor = .white + airplayButton.tintColor = .white + airplayButton.backgroundColor = .clear + airplayButton.prioritizesVideoDevices = true + airplayButton.setContentHuggingPriority(.required, for: .horizontal) + airplayButton.setContentCompressionResistancePriority(.required, for: .horizontal) + + let airplayContainer = UIView() + airplayContainer.translatesAutoresizingMaskIntoConstraints = false + airplayContainer.backgroundColor = .clear + view.addSubview(airplayContainer) + airplayContainer.addSubview(airplayButton) + + NSLayoutConstraint.activate([ + airplayContainer.widthAnchor.constraint(equalToConstant: 24), + airplayContainer.heightAnchor.constraint(equalToConstant: 24), + airplayButton.centerXAnchor.constraint(equalTo: airplayContainer.centerXAnchor), + airplayButton.centerYAnchor.constraint(equalTo: airplayContainer.centerYAnchor), + airplayButton.widthAnchor.constraint(equalToConstant: 24), + airplayButton.heightAnchor.constraint(equalToConstant: 24) + ]) + + for subview in airplayButton.subviews { + subview.contentMode = .scaleAspectFit + if let button = subview as? UIButton { + button.imageEdgeInsets = .zero + button.contentEdgeInsets = .zero + } + } + + guard AVPictureInPictureController.isPictureInPictureSupported() else { + return + } + + playerViewController.allowsPictureInPicturePlayback = true + + let playerLayerContainer = UIView() + playerLayerContainer.translatesAutoresizingMaskIntoConstraints = false + view.insertSubview(playerLayerContainer, at: 0) + + NSLayoutConstraint.activate([ + playerLayerContainer.topAnchor.constraint(equalTo: view.topAnchor), + playerLayerContainer.bottomAnchor.constraint(equalTo: view.bottomAnchor), + playerLayerContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor), + playerLayerContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + + let playerLayer = AVPlayerLayer(player: player) + playerLayer.frame = playerLayerContainer.bounds + playerLayer.videoGravity = .resizeAspect + playerLayerContainer.layer.addSublayer(playerLayer) + + pipController = AVPictureInPictureController(playerLayer: playerLayer) + pipController?.delegate = self + + let config = UIImage.SymbolConfiguration(pointSize: 15, weight: .medium) + let Image = UIImage(systemName: "pip", withConfiguration: config) + pipButton = UIButton(type: .system) + pipButton.setImage(Image, for: .normal) + pipButton.tintColor = .white + pipButton.addTarget(self, action: #selector(pipButtonTapped(_:)), for: .touchUpInside) + + controlsContainerView.addSubview(pipButton) + pipButton.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + pipButton.centerYAnchor.constraint(equalTo: dimButton.centerYAnchor), + pipButton.trailingAnchor.constraint(equalTo: dimButton.leadingAnchor, constant: -8), + pipButton.widthAnchor.constraint(equalToConstant: 40), + pipButton.heightAnchor.constraint(equalToConstant: 40), + airplayButton.centerYAnchor.constraint(equalTo: pipButton.centerYAnchor), + airplayButton.trailingAnchor.constraint(equalTo: pipButton.leadingAnchor, constant: -4), + airplayButton.widthAnchor.constraint(equalToConstant: 24), + airplayButton.heightAnchor.constraint(equalToConstant: 24) + ]) + + pipButton.isHidden = !isPipButtonVisible + + NotificationCenter.default.addObserver(self, selector: #selector(startPipIfNeeded), name: UIApplication.willResignActiveNotification, object: nil) + } + + func updateMarqueeConstraints() { + UIView.performWithoutAnimation { + NSLayoutConstraint.deactivate(currentMarqueeConstraints) + + let leftSpacing: CGFloat = 2 + let rightSpacing: CGFloat = 6 + let trailingAnchor: NSLayoutXAxisAnchor = (volumeSliderHostingView?.isHidden == false) + ? volumeSliderHostingView!.leadingAnchor + : view.safeAreaLayoutGuide.trailingAnchor + + currentMarqueeConstraints = [ + episodeNumberLabel.leadingAnchor.constraint( + equalTo: dismissButton.trailingAnchor, constant: leftSpacing), + episodeNumberLabel.trailingAnchor.constraint( + equalTo: trailingAnchor, constant: -rightSpacing - 10), + episodeNumberLabel.centerYAnchor.constraint(equalTo: dismissButton.centerYAnchor) + ] + NSLayoutConstraint.activate(currentMarqueeConstraints) + view.layoutIfNeeded() + } + } } class GradientOverlayButton: UIButton { @@ -3103,3 +3504,116 @@ class PassthroughView: UIView { return false } } + +class GradientBlurButton: UIButton { + private var gradientLayer: CAGradientLayer? + private var borderMask: CAShapeLayer? + private var blurView: UIVisualEffectView? + private var storedTitle: String? + private var storedImage: UIImage? + + override var isHidden: Bool { + didSet { + updateVisualState() + } + } + + override var alpha: CGFloat { + didSet { + updateVisualState() + } + } + + private func updateVisualState() { + let shouldBeVisible = !isHidden && alpha > 0.1 + + storedTitle = self.title(for: .normal) + storedImage = self.image(for: .normal) + + cleanupVisualEffects() + + if shouldBeVisible { + setupBlurAndGradient() + } else { + backgroundColor = .clear + } + + if let title = storedTitle, !title.isEmpty { + setTitle(title, for: .normal) + } + if let image = storedImage { + setImage(image, for: .normal) + } + } + + private func cleanupVisualEffects() { + blurView?.removeFromSuperview() + blurView = nil + + backgroundColor = .clear + layer.borderWidth = 0 + layer.shadowOpacity = 0 + layer.cornerRadius = 0 + clipsToBounds = false + } + + override func willMove(toSuperview newSuperview: UIView?) { + super.willMove(toSuperview: newSuperview) + if newSuperview == nil { + cleanupVisualEffects() + } else if newSuperview != nil && !isHidden && alpha > 0.1 { + setupBlurAndGradient() + } else if newSuperview != nil { + cleanupVisualEffects() + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + if !isHidden && alpha > 0.1 { + setupBlurAndGradient() + } + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + if !isHidden && alpha > 0.1 { + setupBlurAndGradient() + } + } + + private func setupBlurAndGradient() { + let blur = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterial)) + blur.isUserInteractionEnabled = false + blur.layer.cornerRadius = 21 + blur.clipsToBounds = true + blur.translatesAutoresizingMaskIntoConstraints = false + insertSubview(blur, at: 0) + NSLayoutConstraint.activate([ + blur.leadingAnchor.constraint(equalTo: leadingAnchor), + blur.trailingAnchor.constraint(equalTo: trailingAnchor), + blur.topAnchor.constraint(equalTo: topAnchor), + blur.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + self.blurView = blur + + clipsToBounds = true + layer.cornerRadius = 21 + } + + override func layoutSubviews() { + super.layoutSubviews() + + if let imageView = self.imageView { + bringSubviewToFront(imageView) + } + if let titleLabel = self.titleLabel { + bringSubviewToFront(titleLabel) + } + } + + override func removeFromSuperview() { + cleanupVisualEffects() + super.removeFromSuperview() + } +} diff --git a/Sora/Utlis & Misc/TabBar/TabBar.swift b/Sora/Utlis & Misc/TabBar/TabBar.swift index 0b383c0..01b6ae0 100644 --- a/Sora/Utlis & Misc/TabBar/TabBar.swift +++ b/Sora/Utlis & Misc/TabBar/TabBar.swift @@ -45,9 +45,18 @@ struct TabBar: View { @FocusState private var keyboardFocus: Bool @State private var keyboardHidden: Bool = true @State private var searchLocked: Bool = false - @State private var keyboardHeight: CGFloat = 0 + @GestureState private var isHolding: Bool = false + @State private var dragOffset: CGFloat = 0 + @State private var isDragging: Bool = false + @State private var dragTargetIndex: Int? = nil + @State private var jellyScale: CGFloat = 1.0 + @State private var lastDragTranslation: CGFloat = 0 + @State private var previousDragOffset: CGFloat = 0 + @State private var lastUpdateTime: Date = Date() + @State private var capsuleOffset: CGFloat = 0 + private var gradientOpacity: CGFloat { let accentColor = UIColor(Color.accentColor) var white: CGFloat = 0 @@ -57,6 +66,8 @@ struct TabBar: View { @Namespace private var animation + private let tabWidth: CGFloat = 70 + var body: some View { HStack { if showSearch && keyboardHidden { @@ -95,7 +106,6 @@ struct TabBar: View { } .disabled(!keyboardHidden || searchLocked) } - HStack { if showSearch { HStack { @@ -116,7 +126,6 @@ struct TabBar: View { } } .onChange(of: searchQuery) { newValue in - // 发送通知,传递搜索查询 NotificationCenter.default.post( name: .searchQueryChanged, object: nil, @@ -142,10 +151,101 @@ struct TabBar: View { .frame(height: 24) .padding(8) } else { - ForEach(0.. 0.01 + Capsule() + .fill(.white) + .shadow(color: .black.opacity(0.2), radius: 6) + .frame(width: tabWidth, height: 44) + .scaleEffect(x: isActuallyMoving ? jellyScale : 1.0, y: isActuallyMoving ? (2.0 - jellyScale) : 1.0, anchor: .center) + .scaleEffect(isDragging || isHolding ? 1.15 : 1.0) + .offset(x: capsuleOffset) + .zIndex(1) + let capsuleIndex: Int = isDragging ? Int(round(dragOffset / tabWidth)) : selectedTab + HStack(spacing: 0) { + ForEach(0.. 0 { + velocity = (dragOffset - previousDragOffset) / CGFloat(dt) + } + let absVelocity = abs(velocity) + let scaleX = min(1.0 + min(absVelocity / 1200, 0.18), 1.18) + withAnimation(.interpolatingSpring(stiffness: 200, damping: 18)) { + jellyScale = scaleX + } + previousDragOffset = dragOffset + lastUpdateTime = now + } + } + .onEnded { value in + if isDragging && selectedTab == index { + previousDragOffset = 0 + lastUpdateTime = Date() + lastDragTranslation = 0 + let totalWidth = tabWidth * CGFloat(tabs.count) + let startX = CGFloat(selectedTab) * tabWidth + let newOffset = startX + value.translation.width + let target = dragTargetIndex(selectedTab: selectedTab, dragOffset: newOffset, tabCount: tabs.count, tabWidth: tabWidth) + withAnimation(.interpolatingSpring(stiffness: 110, damping: 19)) { + selectedTab = target + jellyScale = 1.0 + capsuleOffset = CGFloat(target) * tabWidth + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.32) { + dragOffset = 0 + isDragging = false + dragTargetIndex = nil + capsuleOffset = CGFloat(selectedTab) * tabWidth + } + if target == tabs.count - 1 { + searchLocked = true + withAnimation(.bouncy(duration: 0.3)) { + lastTab = index + showSearch = true + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + searchLocked = false + } + } + } + } + ) + } else { + tabButton(for: tab, index: index, scale: shouldEnlarge ? 1.35 : 1.0, isActive: isActive, isSelected: isSelected) + .frame(width: tabWidth, height: 44) + .contentShape(Rectangle()) + } + } + } + .zIndex(2) + .animation(.spring(response: 0.25, dampingFraction: 0.7), value: isDragging) } } } @@ -188,6 +288,7 @@ struct TabBar: View { } } .onAppear { + capsuleOffset = CGFloat(selectedTab) * tabWidth NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillShowNotification, object: nil, queue: .main) { notification in if let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect { keyboardHeight = keyboardFrame.height @@ -209,48 +310,61 @@ struct TabBar: View { NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillHideNotification, object: nil) NotificationCenter.default.removeObserver(self, name: .tabBarSearchQueryUpdated, object: nil) } + .onChange(of: selectedTab) { newValue in + if !isDragging { + withAnimation(.interpolatingSpring(stiffness: 320, damping: 22)) { + capsuleOffset = CGFloat(newValue) * tabWidth + } + } + } } @ViewBuilder - private func tabButton(for tab: TabItem, index: Int) -> some View { - Button(action: { + private func tabButton(for tab: TabItem, index: Int, scale: CGFloat = 1.0, isActive: Bool, isSelected: Bool) -> some View { + let icon = Image(systemName: tab.icon + (isActive ? ".fill" : "")) + .frame(width: 28, height: 28) + .matchedGeometryEffect(id: tab.icon, in: animation) + .foregroundStyle(isActive ? .black : .gray) + .padding(.vertical, 8) + .padding(.horizontal, 10) + .frame(width: tabWidth) + .opacity(isActive ? 1 : 0.5) + .scaleEffect(scale) + return icon + .contentShape(Rectangle()) + .simultaneousGesture( + TapGesture() + .onEnded { + if isDragging || isHolding { return } if index == tabs.count - 1 { searchLocked = true - withAnimation(.bouncy(duration: 0.3)) { lastTab = selectedTab selectedTab = index showSearch = true } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { searchLocked = false } } else { - if !searchLocked { + if (!searchLocked) { withAnimation(.bouncy(duration: 0.3)) { lastTab = selectedTab selectedTab = index + } + } + } } - } - } - }) { - Image(systemName: tab.icon + (selectedTab == index ? ".fill" : "")) - .frame(width: 28, height: 28) - .matchedGeometryEffect(id: tab.icon, in: animation) - .foregroundStyle(selectedTab == index ? .black : .gray) - .padding(.vertical, 8) - .padding(.horizontal, 10) - .frame(width: 70) - .opacity(selectedTab == index ? 1 : 0.5) - } - .background( - selectedTab == index ? - Capsule() - .fill(.white) - .shadow(color: .black.opacity(0.2), radius: 6) - .matchedGeometryEffect(id: "background_capsule", in: animation) - : nil - ) + ) + } + + private func enlargedTabIndex(selectedTab: Int, dragOffset: CGFloat, tabCount: Int, tabWidth: CGFloat) -> Int { + let index = Int(round(dragOffset / tabWidth)) + return min(max(index, 0), tabCount - 1) + } + + private func dragTargetIndex(selectedTab: Int, dragOffset: CGFloat, tabCount: Int, tabWidth: CGFloat) -> Int { + let index = Int(round(dragOffset / tabWidth)) + return min(max(index, 0), tabCount - 1) } } diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewAbout.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewAbout.swift index a5c234f..b2a91f6 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewAbout.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewAbout.swift @@ -322,6 +322,12 @@ struct TranslatorsView: View { login: "Cufiy", avatarUrl: "https://github.com/50n50/assets/blob/main/pfps/y1wwm0ed_png.png?raw=true", language: "German" + ), + Translator( + id: 10, + login: "yoshi1780", + avatarUrl: "https://github.com/50n50/assets/blob/main/pfps/262d7c1a61ff49355ddb74c76c7c5c7f_webp.png?raw=true", + language: "Mongolian" ) ] diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift index 234f706..7b43cf8 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift @@ -149,8 +149,7 @@ fileprivate struct SettingsPickerRow: View { } struct SettingsViewGeneral: View { - @AppStorage("episodeChunkSize") private var episodeChunkSize: Int = 50 - @AppStorage("refreshModulesOnLaunch") private var refreshModulesOnLaunch: Bool = true + @AppStorage("episodeChunkSize") private var episodeChunkSize: Int = 100 @AppStorage("fetchEpisodeMetadata") private var fetchEpisodeMetadata: Bool = true @AppStorage("analyticsEnabled") private var analyticsEnabled: Bool = false @AppStorage("hideSplashScreen") private var hideSplashScreenEnable: Bool = false @@ -243,6 +242,7 @@ struct SettingsViewGeneral: View { "German", "Italian", "Kazakh", + "Mongolian", "Norsk", "Russian", "Slovak", @@ -263,6 +263,7 @@ struct SettingsViewGeneral: View { case "Russian": return "Русский" case "Norsk": return "Norsk" case "Kazakh": return "Қазақша" + case "Mongolian": return "Монгол" case "Swedish": return "Svenska" case "Italian": return "Italiano" default: return lang @@ -369,17 +370,7 @@ struct SettingsViewGeneral: View { ) } - SettingsSection( - title: NSLocalizedString("Modules", comment: ""), - footer: NSLocalizedString("Note that the modules will be replaced only if there is a different version string inside the JSON file.", comment: "") - ) { - SettingsToggleRow( - icon: "arrow.clockwise", - title: NSLocalizedString("Refresh Modules on Launch", comment: ""), - isOn: $refreshModulesOnLaunch, - showDivider: false - ) - } + SettingsSection( title: NSLocalizedString("Advanced", comment: ""), diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewModule.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewModule.swift index b8ac26b..66026b0 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewModule.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewModule.swift @@ -58,6 +58,46 @@ fileprivate struct SettingsSection: View { } } +fileprivate struct SettingsToggleRow: View { + let icon: String + let title: String + @Binding var isOn: Bool + var showDivider: Bool = true + + init(icon: String, title: String, isOn: Binding, showDivider: Bool = true) { + self.icon = icon + self.title = title + self._isOn = isOn + self.showDivider = showDivider + } + + var body: some View { + VStack(spacing: 0) { + HStack { + Image(systemName: icon) + .frame(width: 24, height: 24) + .foregroundStyle(.primary) + + Text(title) + .foregroundStyle(.primary) + + Spacer() + + Toggle("", isOn: $isOn) + .labelsHidden() + .tint(.accentColor.opacity(0.7)) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + + if showDivider { + Divider() + .padding(.horizontal, 16) + } + } + } +} + fileprivate struct ModuleListItemView: View { let module: Module let selectedModuleId: String? @@ -151,6 +191,7 @@ struct SettingsViewModule: View { @AppStorage("selectedModuleId") private var selectedModuleId: String? @EnvironmentObject var moduleManager: ModuleManager @AppStorage("didReceiveDefaultPageLink") private var didReceiveDefaultPageLink: Bool = false + @AppStorage("refreshModulesOnLaunch") private var refreshModulesOnLaunch: Bool = true @State private var errorMessage: String? @State private var isLoading = false @@ -212,6 +253,18 @@ struct SettingsViewModule: View { } } } + + SettingsSection( + title: NSLocalizedString("Module Settings", comment: ""), + footer: NSLocalizedString("Note that the modules will be replaced only if there is a different version string inside the JSON file.", comment: "") + ) { + SettingsToggleRow( + icon: "arrow.clockwise", + title: NSLocalizedString("Refresh Modules on Launch", comment: ""), + isOn: $refreshModulesOnLaunch, + showDivider: false + ) + } } .padding(.vertical, 20) } diff --git a/Sora/Views/SettingsView/SettingsView.swift b/Sora/Views/SettingsView/SettingsView.swift index 2c72061..0e2f290 100644 --- a/Sora/Views/SettingsView/SettingsView.swift +++ b/Sora/Views/SettingsView/SettingsView.swift @@ -454,6 +454,8 @@ class Settings: ObservableObject { languageCode = "nn" case "Kazakh": languageCode = "kk" + case "Mongolian": + languageCode = "mn" case "Swedish": languageCode = "sv" case "Italian": diff --git a/Sulfur.xcodeproj/project.pbxproj b/Sulfur.xcodeproj/project.pbxproj index c9bf7ef..4f956e7 100644 --- a/Sulfur.xcodeproj/project.pbxproj +++ b/Sulfur.xcodeproj/project.pbxproj @@ -165,6 +165,7 @@ 04F08EDB2DE10BEC006B29D9 /* ProgressiveBlurView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressiveBlurView.swift; sourceTree = ""; }; 04F08EDE2DE10C1A006B29D9 /* TabBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBar.swift; sourceTree = ""; }; 04F08EE12DE10C27006B29D9 /* TabItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabItem.swift; sourceTree = ""; }; + 04F8DF9C2E1B2822006248D8 /* mn */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = mn; path = Localizable.strings; sourceTree = ""; }; 130C6BF82D53A4C200DC1432 /* Sora.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Sora.entitlements; sourceTree = ""; }; 130C6BF92D53AB1F00DC1432 /* SettingsViewData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewData.swift; sourceTree = ""; }; 13103E8A2D58E028000F0673 /* View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = View.swift; sourceTree = ""; }; @@ -444,6 +445,14 @@ path = Models; sourceTree = ""; }; + 04F8DF9A2E1B2814006248D8 /* mn.lproj */ = { + isa = PBXGroup; + children = ( + 04F8DF9B2E1B2822006248D8 /* Localizable.strings */, + ); + path = mn.lproj; + sourceTree = ""; + }; 13103E802D589D6C000F0673 /* Tracking & Metadata */ = { isa = PBXGroup; children = ( @@ -670,6 +679,7 @@ 13530BE02E00028E0048B7DE /* Localization */ = { isa = PBXGroup; children = ( + 04F8DF9A2E1B2814006248D8 /* mn.lproj */, 04E00C9A2E09E96B0056124A /* it.lproj */, 0452339C2E021491002EA23C /* bos.lproj */, 041261032E00D14F00D05B47 /* sv.lproj */, @@ -912,6 +922,7 @@ bs, cs, it, + mn, ); mainGroup = 133D7C612D2BE2500075467E; packageReferences = ( @@ -1172,6 +1183,14 @@ name = Localizable.strings; sourceTree = ""; }; + 04F8DF9B2E1B2822006248D8 /* Localizable.strings */ = { + isa = PBXVariantGroup; + children = ( + 04F8DF9C2E1B2822006248D8 /* mn */, + ); + name = Localizable.strings; + sourceTree = ""; + }; /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ From 681c43ec69d44072106dc871fe2aff578337dbfe Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Tue, 8 Jul 2025 10:10:08 +0200 Subject: [PATCH 39/45] ok starting player refactor --- Sora/Views/SettingsView/SettingsView.swift | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/Sora/Views/SettingsView/SettingsView.swift b/Sora/Views/SettingsView/SettingsView.swift index 0e2f290..f6c6d15 100644 --- a/Sora/Views/SettingsView/SettingsView.swift +++ b/Sora/Views/SettingsView/SettingsView.swift @@ -127,11 +127,10 @@ fileprivate struct ModulePreviewRow: View { } struct SettingsView: View { - let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "ALPHA" @Environment(\.colorScheme) var colorScheme @StateObject var settings = Settings() @EnvironmentObject var moduleManager: ModuleManager - + @State private var isNavigationActive = false var body: some View { @@ -253,7 +252,7 @@ struct SettingsView: View { SettingsNavigationRow(icon: "info.circle", titleKey: "About Sora") } Divider().padding(.horizontal, 16) - + Link(destination: URL(string: "https://github.com/cranci1/Sora")!) { HStack { Image("Github Icon") @@ -275,7 +274,7 @@ struct SettingsView: View { .padding(.vertical, 12) } Divider().padding(.horizontal, 16) - + Link(destination: URL(string: "https://discord.gg/x7hppDWFDZ")!) { HStack { Image("Discord Icon") @@ -297,7 +296,7 @@ struct SettingsView: View { .padding(.vertical, 12) } Divider().padding(.horizontal, 16) - + Link(destination: URL(string: "https://github.com/cranci1/Sora/issues")!) { SettingsNavigationRow( icon: "exclamationmark.circle.fill", @@ -307,7 +306,7 @@ struct SettingsView: View { ) } Divider().padding(.horizontal, 16) - + Link(destination: URL(string: "https://github.com/cranci1/Sora/blob/dev/LICENSE")!) { SettingsNavigationRow( icon: "doc.text.fill", @@ -335,8 +334,8 @@ struct SettingsView: View { ) .padding(.horizontal, 20) } - - Text("Sora \(version) by cranci1") + + Text("Sora 1.0.1 by cranci1") .font(.footnote) .foregroundStyle(.gray) .frame(maxWidth: .infinity, alignment: .center) From 982fe468c8ba663fb6e454d196ac2ea8c2213df0 Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Tue, 8 Jul 2025 10:11:06 +0200 Subject: [PATCH 40/45] fixed build yaml file too --- .github/workflows/build.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9086c7a..5eca7ea 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -3,6 +3,10 @@ on: push: branches: - dev + pull_request: + branches: + - dev + jobs: build-ios: name: Build IPA From 94735cd23d641160b87927583cf9acf7d902b6a8 Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Tue, 8 Jul 2025 10:25:41 +0200 Subject: [PATCH 41/45] buttons fixes --- .../CustomPlayer/CustomPlayer.swift | 39 ++++++++----------- 1 file changed, 16 insertions(+), 23 deletions(-) diff --git a/Sora/MediaUtils/CustomPlayer/CustomPlayer.swift b/Sora/MediaUtils/CustomPlayer/CustomPlayer.swift index a91d66d..0aab003 100644 --- a/Sora/MediaUtils/CustomPlayer/CustomPlayer.swift +++ b/Sora/MediaUtils/CustomPlayer/CustomPlayer.swift @@ -177,6 +177,8 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele private var dimButtonToRight: NSLayoutConstraint! private var dimButtonTimer: Timer? + let cfg = UIImage.SymbolConfiguration(pointSize: 15, weight: .medium) + private var controlsToHide: [UIView] { var views = [ dismissButton, @@ -1105,8 +1107,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele } func setupDismissButton() { - let config = UIImage.SymbolConfiguration(pointSize: 15, weight: .bold) - let image = UIImage(systemName: "xmark", withConfiguration: config) + let image = UIImage(systemName: "xmark", withConfiguration: cfg) let dismissCircle = createCircularBlurBackground(size: 42) view.addSubview(dismissCircle) @@ -1534,15 +1535,13 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele skip85Button.leadingAnchor.constraint(equalTo: controlsContainerView.leadingAnchor, constant: 18), skip85Button.bottomAnchor.constraint(equalTo: sliderHostingController!.view.topAnchor, constant: -12), skip85Button.heightAnchor.constraint(equalToConstant: 40), - skip85Button.widthAnchor.constraint(greaterThanOrEqualToConstant: 97) + skip85Button.widthAnchor.constraint(greaterThanOrEqualToConstant: 104) ] NSLayoutConstraint.activate(skip85Constraints) } - private func setupQualityButton() { - let config = UIImage.SymbolConfiguration(pointSize: 15, weight: .bold) - let image = UIImage(systemName: "tv", withConfiguration: config) + let image = UIImage(systemName: "tv", withConfiguration: cfg) qualityButton = UIButton(type: .system) qualityButton.setImage(image, for: .normal) @@ -1815,8 +1814,6 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele } } - - @objc func toggleControls() { if controlsLocked { unlockButton.alpha = 1.0 @@ -2112,6 +2109,8 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele dimButtonTimer?.invalidate() UIView.animate(withDuration: 0.25) { self.blackCoverView.alpha = self.isDimmed ? 1.0 : 0.0 + let iconName = self.isDimmed ? "moon.fill" : "moon" + self.dimButton.setImage(UIImage(systemName: iconName, withConfiguration: self.cfg), for: .normal) } } @@ -3208,8 +3207,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele } func setupWatchNextButton() { - let config = UIImage.SymbolConfiguration(pointSize: 15, weight: .bold) - let image = UIImage(systemName: "forward.end", withConfiguration: config) + let image = UIImage(systemName: "forward.end", withConfiguration: cfg) watchNextButton = UIButton(type: .system) watchNextButton.setImage(image, for: .normal) @@ -3221,7 +3219,6 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele } private func setupDimButton() { - let cfg = UIImage.SymbolConfiguration(pointSize: 15, weight: .medium) dimButton = UIButton(type: .system) dimButton.setImage(UIImage(systemName: "moon", withConfiguration: cfg), for: .normal) dimButton.tintColor = .white @@ -3240,8 +3237,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele } func setupSpeedButton() { - let config = UIImage.SymbolConfiguration(pointSize: 15, weight: .bold) - let image = UIImage(systemName: "speedometer", withConfiguration: config) + let image = UIImage(systemName: "speedometer", withConfiguration: cfg) speedButton = UIButton(type: .system) speedButton.setImage(image, for: .normal) @@ -3255,8 +3251,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele } func setupMenuButton() { - let config = UIImage.SymbolConfiguration(pointSize: 15, weight: .bold) - let image = UIImage(systemName: "captions.bubble", withConfiguration: config) + let image = UIImage(systemName: "captions.bubble", withConfiguration: cfg) menuButton = UIButton(type: .system) menuButton.setImage(image, for: .normal) @@ -3275,7 +3270,6 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele } private func setupLockButton() { - let cfg = UIImage.SymbolConfiguration(pointSize: 15, weight: .medium) lockButton = UIButton(type: .system) lockButton.setImage( UIImage(systemName: "lock.open", withConfiguration: cfg), @@ -3295,10 +3289,10 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele } private func setupUnlockButton() { - let cfg = UIImage.SymbolConfiguration(pointSize: 40, weight: .medium) + let config = UIImage.SymbolConfiguration(pointSize: 30, weight: .medium) unlockButton = UIButton(type: .system) unlockButton.setImage( - UIImage(systemName: "lock", withConfiguration: cfg), + UIImage(systemName: "lock", withConfiguration: config), for: .normal ) unlockButton.tintColor = .white @@ -3315,9 +3309,9 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele unlockButton.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ unlockButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), - unlockButton.centerYAnchor.constraint(equalTo: view.centerYAnchor), - unlockButton.widthAnchor.constraint(equalToConstant: 80), - unlockButton.heightAnchor.constraint(equalToConstant: 80) + unlockButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), + unlockButton.widthAnchor.constraint(equalToConstant: 60), + unlockButton.heightAnchor.constraint(equalToConstant: 60) ]) } @@ -3379,8 +3373,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele pipController = AVPictureInPictureController(playerLayer: playerLayer) pipController?.delegate = self - let config = UIImage.SymbolConfiguration(pointSize: 15, weight: .medium) - let Image = UIImage(systemName: "pip", withConfiguration: config) + let Image = UIImage(systemName: "pip", withConfiguration: cfg) pipButton = UIButton(type: .system) pipButton.setImage(Image, for: .normal) pipButton.tintColor = .white From 27b42125679029d0cf75a665352bdb17e8437077 Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Tue, 8 Jul 2025 10:51:51 +0200 Subject: [PATCH 42/45] kinda fixed the title maybe? --- .../CustomPlayer/CustomPlayer.swift | 79 +++++++++++-------- 1 file changed, 45 insertions(+), 34 deletions(-) diff --git a/Sora/MediaUtils/CustomPlayer/CustomPlayer.swift b/Sora/MediaUtils/CustomPlayer/CustomPlayer.swift index 0aab003..9079884 100644 --- a/Sora/MediaUtils/CustomPlayer/CustomPlayer.swift +++ b/Sora/MediaUtils/CustomPlayer/CustomPlayer.swift @@ -208,7 +208,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele } views.append(contentsOf: view.subviews.filter { - $0 is UIVisualEffectView || + $0 is UIVisualEffectView || ($0.layer.cornerRadius > 0 && $0 != dismissButton && $0 != lockButton && $0 != dimButton && $0 != pipButton && $0 != holdSpeedIndicator && $0 != volumeSliderHostingView) }) @@ -468,14 +468,14 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele object: player.currentItem ) - + } private func setupTopRowLayout() { if let old = view.subviews.first(where: { $0 is GradientBlurButton && $0 != controlButtonsContainer && $0 != skip85Button }) { old.removeFromSuperview() } - + let capsuleContainer = GradientBlurButton(type: .custom) capsuleContainer.translatesAutoresizingMaskIntoConstraints = false capsuleContainer.backgroundColor = .clear @@ -483,19 +483,19 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele capsuleContainer.clipsToBounds = true view.addSubview(capsuleContainer) capsuleContainer.alpha = isControlsVisible ? 1.0 : 0.0 - + let buttons: [UIView] = [airplayButton, pipButton, lockButton, dimButton] for btn in buttons { btn.removeFromSuperview() capsuleContainer.addSubview(btn) } - + NSLayoutConstraint.activate([ capsuleContainer.leadingAnchor.constraint(equalTo: dismissButton.superview!.trailingAnchor, constant: 12), capsuleContainer.centerYAnchor.constraint(equalTo: dismissButton.superview!.centerYAnchor), capsuleContainer.heightAnchor.constraint(equalToConstant: 42) ]) - + for (index, btn) in buttons.enumerated() { NSLayoutConstraint.activate([ btn.centerYAnchor.constraint(equalTo: capsuleContainer.centerYAnchor), @@ -511,8 +511,8 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele btn.trailingAnchor.constraint(equalTo: capsuleContainer.trailingAnchor, constant: -10).isActive = true } } - - + + view.bringSubviewToFront(skip85Button) if let volumeSlider = volumeSliderHostingView { @@ -524,6 +524,8 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele super.viewWillTransition(to: size, with: coordinator) coordinator.animate(alongsideTransition: { _ in self.updateMarqueeConstraints() + }, completion: { _ in + self.view.layoutIfNeeded() }) } @@ -1280,15 +1282,15 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele if !isControlsVisible { return } let t = currentTimeVal - let skipIntroAvailable = skipIntervals.op != nil && - t >= skipIntervals.op!.start.seconds && - t <= skipIntervals.op!.end.seconds && - !skipIntroDismissedInSession + let skipIntroAvailable = skipIntervals.op != nil && + t >= skipIntervals.op!.start.seconds && + t <= skipIntervals.op!.end.seconds && + !skipIntroDismissedInSession - let skipOutroAvailable = skipIntervals.ed != nil && - t >= skipIntervals.ed!.start.seconds && - t <= skipIntervals.ed!.end.seconds && - !skipOutroDismissedInSession + let skipOutroAvailable = skipIntervals.ed != nil && + t >= skipIntervals.ed!.start.seconds && + t <= skipIntervals.ed!.end.seconds && + !skipOutroDismissedInSession let shouldShowSkip85 = isSkip85Visible && !skipIntroAvailable @@ -1335,7 +1337,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele view.setNeedsLayout() view.layoutIfNeeded() } - + UIView.animate(withDuration: 0.2) { self.skipOutroButton.alpha = 1.0 } @@ -1530,7 +1532,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele skip85Button.addTarget(self, action: #selector(skip85Tapped), for: .touchUpInside) controlsContainerView.addSubview(skip85Button) skip85Button.translatesAutoresizingMaskIntoConstraints = false - + let skip85Constraints = [ skip85Button.leadingAnchor.constraint(equalTo: controlsContainerView.leadingAnchor, constant: 18), skip85Button.bottomAnchor.constraint(equalTo: sliderHostingController!.view.topAnchor, constant: -12), @@ -1554,7 +1556,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele qualityButton.translatesAutoresizingMaskIntoConstraints = false } - + private func setupControlButtonsContainer() { controlButtonsContainer?.removeFromSuperview() @@ -1578,20 +1580,20 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele } let visibleButtons = [watchNextButton, speedButton, qualityButton, menuButton].compactMap { button -> UIButton? in - guard let button = button else { + guard let button = button else { Logger.shared.log("Button is nil", type: "Debug") - return nil + return nil } if button == qualityButton { Logger.shared.log("Quality button state - isHidden: \(button.isHidden), isHLSStream: \(isHLSStream)", type: "Debug") } - if button.isHidden { + if button.isHidden { if button == qualityButton { Logger.shared.log("Quality button is hidden, skipping", type: "Debug") } - return nil + return nil } controlButtonsContainer.addSubview(button) @@ -1745,7 +1747,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele let remainingPercentage = (self.duration - self.currentTimeVal) / self.duration let remainingTimePercentage = UserDefaults.standard.object(forKey: "remainingTimePercentage") != nil ? UserDefaults.standard.double(forKey: "remainingTimePercentage") : 90.0 let threshold = (100.0 - remainingTimePercentage) / 100.0 - + if remainingPercentage <= threshold { if self.aniListID != 0 && !self.aniListUpdatedSuccessfully && !self.aniListUpdateImpossible { self.tryAniListUpdate() @@ -1957,9 +1959,9 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele @objc private func pipButtonTapped(_ sender: UIButton) { Logger.shared.log("PiP button tapped", type: "Debug") - guard let pip = pipController else { + guard let pip = pipController else { Logger.shared.log("PiP controller is nil", type: "Error") - return + return } Logger.shared.log("PiP controller found, isActive: \(pip.isPictureInPictureActive)", type: "Debug") if pip.isPictureInPictureActive { @@ -2382,7 +2384,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele if url.scheme == "file" { Logger.shared.log("Switching to local file: \(url.absoluteString)", type: "Debug") - + if FileManager.default.fileExists(atPath: url.path) { Logger.shared.log("Local file exists for quality switch: \(url.path)", type: "Debug") } else { @@ -3083,7 +3085,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele private func updateEndTimeVisibility(animated: Bool) { let alpha: CGFloat = isEndTimeVisible ? 1.0 : 0.0 - let offset: CGFloat = isEndTimeVisible ? 0 : -37 + let offset: CGFloat = isEndTimeVisible ? 0 : -37 if animated { UIView.animate(withDuration: 0.3) { @@ -3166,7 +3168,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele } } - + @objc private func protectMenuFromRecreation() { isMenuOpen = true @@ -3180,25 +3182,28 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele let skip85Visible = !(skip85Button?.isHidden ?? true) && (skip85Button?.alpha ?? 0) > 0.1 let skipOutroVisible = skipOutroButton.superview != nil && !skipOutroButton.isHidden && skipOutroButton.alpha > 0.1 + let isLandscape = view.bounds.width > view.bounds.height + let widthMultiplier: CGFloat = isLandscape ? 0.5 : 0.7 + if skipIntroVisible && skipIntroButton?.superview != nil && titleStackView.superview != nil { titleStackAboveSkipButtonConstraints = [ titleStackView.leadingAnchor.constraint(equalTo: controlsContainerView.leadingAnchor, constant: 18), titleStackView.bottomAnchor.constraint(equalTo: skipIntroButton.topAnchor, constant: -4), - titleStackView.widthAnchor.constraint(lessThanOrEqualTo: controlsContainerView.widthAnchor, multiplier: 0.7) + titleStackView.widthAnchor.constraint(lessThanOrEqualTo: controlsContainerView.widthAnchor, multiplier: widthMultiplier) ] NSLayoutConstraint.activate(titleStackAboveSkipButtonConstraints) } else if skip85Visible && skip85Button?.superview != nil && titleStackView.superview != nil { titleStackAboveSkipButtonConstraints = [ titleStackView.leadingAnchor.constraint(equalTo: controlsContainerView.leadingAnchor, constant: 18), titleStackView.bottomAnchor.constraint(equalTo: skip85Button.topAnchor, constant: -4), - titleStackView.widthAnchor.constraint(lessThanOrEqualTo: controlsContainerView.widthAnchor, multiplier: 0.7) + titleStackView.widthAnchor.constraint(lessThanOrEqualTo: controlsContainerView.widthAnchor, multiplier: widthMultiplier) ] NSLayoutConstraint.activate(titleStackAboveSkipButtonConstraints) } else if let sliderView = sliderHostingController?.view, titleStackView.superview != nil { titleStackAboveSliderConstraints = [ titleStackView.leadingAnchor.constraint(equalTo: controlsContainerView.leadingAnchor, constant: 18), titleStackView.bottomAnchor.constraint(equalTo: sliderView.topAnchor, constant: -4), - titleStackView.widthAnchor.constraint(lessThanOrEqualTo: controlsContainerView.widthAnchor, multiplier: 0.7) + titleStackView.widthAnchor.constraint(lessThanOrEqualTo: controlsContainerView.widthAnchor, multiplier: widthMultiplier) ] NSLayoutConstraint.activate(titleStackAboveSliderConstraints) } @@ -3347,11 +3352,11 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele button.contentEdgeInsets = .zero } } - + guard AVPictureInPictureController.isPictureInPictureSupported() else { return } - + playerViewController.allowsPictureInPicturePlayback = true let playerLayerContainer = UIView() @@ -3416,7 +3421,13 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele episodeNumberLabel.centerYAnchor.constraint(equalTo: dismissButton.centerYAnchor) ] NSLayoutConstraint.activate(currentMarqueeConstraints) + updateMarqueeConstraintsForBottom() + view.layoutIfNeeded() + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.titleLabel?.restartLabel() + } } } } From b67b44a069ea26ca5e811035248b9f9082354c87 Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Tue, 8 Jul 2025 10:56:47 +0200 Subject: [PATCH 43/45] yeah looks a bit better tbh no? --- .../CustomPlayer/CustomPlayer.swift | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/Sora/MediaUtils/CustomPlayer/CustomPlayer.swift b/Sora/MediaUtils/CustomPlayer/CustomPlayer.swift index 9079884..c18a115 100644 --- a/Sora/MediaUtils/CustomPlayer/CustomPlayer.swift +++ b/Sora/MediaUtils/CustomPlayer/CustomPlayer.swift @@ -917,12 +917,31 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele } private func createCircularBlurBackground(size: CGFloat) -> UIView { - let blurEffect = UIBlurEffect(style: .systemUltraThinMaterial) + let blurEffect = UIBlurEffect(style: .systemMaterial) let blurView = UIVisualEffectView(effect: blurEffect) blurView.translatesAutoresizingMaskIntoConstraints = false blurView.layer.cornerRadius = size / 2 blurView.clipsToBounds = true + let vibrancyEffect = UIVibrancyEffect(blurEffect: blurEffect) + let vibrancyView = UIVisualEffectView(effect: vibrancyEffect) + vibrancyView.frame = blurView.bounds + vibrancyView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + blurView.contentView.addSubview(vibrancyView) + + let tintView = UIView() + tintView.backgroundColor = UIColor.white.withAlphaComponent(0.08) + tintView.layer.cornerRadius = size / 2 + tintView.clipsToBounds = true + tintView.translatesAutoresizingMaskIntoConstraints = false + vibrancyView.contentView.addSubview(tintView) + NSLayoutConstraint.activate([ + tintView.leadingAnchor.constraint(equalTo: vibrancyView.contentView.leadingAnchor), + tintView.trailingAnchor.constraint(equalTo: vibrancyView.contentView.trailingAnchor), + tintView.topAnchor.constraint(equalTo: vibrancyView.contentView.topAnchor), + tintView.bottomAnchor.constraint(equalTo: vibrancyView.contentView.bottomAnchor) + ]) + NSLayoutConstraint.activate([ blurView.widthAnchor.constraint(equalToConstant: size), blurView.heightAnchor.constraint(equalToConstant: size) From 19fe680a869f9794235cc986325af8f5324dcd9c Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Fri, 11 Jul 2025 10:03:33 +0200 Subject: [PATCH 44/45] maybe test --- .../SettingsSubViews/SettingsViewBackup.swift | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewBackup.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewBackup.swift index 982aa8f..622ce67 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewBackup.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewBackup.swift @@ -125,6 +125,30 @@ struct SettingsViewBackup: View { showDivider: false ) } + SettingsSection( + title: NSLocalizedString("Backup Informations", comment: "Settings section title for backup informations"), + footer: "" + ) { + VStack { + HStack { + Image(systemName: "") + .frame(width: 24, height: 24) + Text("title") + } + + Spacer() + + HStack { + Image(systemName: "") + .frame(width: 24, height: 24) + Text("title") + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(Color.clear) + .contentShape(Rectangle()) + } } .padding(.vertical, 20) } From ac9d4f9e39f77f4c910cd0756968cfe30d8f2cf2 Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Fri, 11 Jul 2025 10:11:01 +0200 Subject: [PATCH 45/45] Revert "maybe test" This reverts commit 19fe680a869f9794235cc986325af8f5324dcd9c. --- .../SettingsSubViews/SettingsViewBackup.swift | 24 ------------------- 1 file changed, 24 deletions(-) diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewBackup.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewBackup.swift index 622ce67..982aa8f 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewBackup.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewBackup.swift @@ -125,30 +125,6 @@ struct SettingsViewBackup: View { showDivider: false ) } - SettingsSection( - title: NSLocalizedString("Backup Informations", comment: "Settings section title for backup informations"), - footer: "" - ) { - VStack { - HStack { - Image(systemName: "") - .frame(width: 24, height: 24) - Text("title") - } - - Spacer() - - HStack { - Image(systemName: "") - .frame(width: 24, height: 24) - Text("title") - } - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - .background(Color.clear) - .contentShape(Rectangle()) - } } .padding(.vertical, 20) }