Check discord (#208)
Some checks are pending
Build and Release / Build IPA (push) Waiting to run
Build and Release / Build Mac Catalyst (push) Waiting to run

* 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
This commit is contained in:
50/50 2025-06-24 14:47:08 +02:00 committed by GitHub
parent 55dfa9cbf4
commit e348ed243f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1783 additions and 4013 deletions

View file

@ -6,6 +6,25 @@
<array>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
</array>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>ar</string>
<string>bos</string>
<string>cs</string>
<string>nl</string>
<string>fr</string>
<string>de</string>
<string>it</string>
<string>kk</string>
<string>nn</string>
<string>ru</string>
<string>sk</string>
<string>es</string>
<string>sv</string>
</array>
<key>CFBundleURLTypes</key>
<array>
<dict>

File diff suppressed because it is too large Load diff

View file

@ -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 125, 2650, 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 125, 2650, 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";

View file

@ -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 125, 2650, 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";

View file

@ -65,6 +65,7 @@ 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
}
@ -96,12 +97,12 @@ extension JSController {
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()
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 {
@ -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")
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 {
@ -171,6 +181,7 @@ extension JSController {
}
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()
}

View file

@ -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<String, Error>) 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)
}
}
}
}
}
}

View file

@ -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()
}

View file

@ -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

View file

@ -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,

View file

@ -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 {

View file

@ -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

View file

@ -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()
}
}
}

View file

@ -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 {

View file

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

View file

@ -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,7 +120,14 @@ struct MediaInfoView: View {
return isCompactLayout ? 20 : 16
}
private var startWatchingText: String {
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
@ -143,6 +151,7 @@ struct MediaInfoView: View {
return NSLocalizedString("Start Watching", comment: "")
}
}
private var singleEpisodeWatchText: String {
if let ep = episodeLinks.first {
@ -154,6 +163,14 @@ struct MediaInfoView: View {
return NSLocalizedString("Mark watched", comment: "")
}
@State private var selectedChapterRange: Range<Int> = {
let size = UserDefaults.standard.integer(forKey: "chapterChunkSize")
let chunk = size == 0 ? 100 : size
return 0..<chunk
}()
@AppStorage("chapterChunkSize") private var chapterChunkSize: Int = 100
private var selectedChapterRangeKey: String { "selectedChapterRangeStart_\(href)" }
var body: some View {
ZStack {
Group {
@ -175,8 +192,10 @@ struct MediaInfoView: View {
.onChange(of: selectedSeason) { newValue in
UserDefaults.standard.set(newValue, forKey: selectedSeasonKey)
}
.onChange(of: selectedChapterRange) { newValue in
UserDefaults.standard.set(newValue.lowerBound, forKey: selectedChapterRangeKey)
}
.onDisappear {
tabBarController.showTabBar()
currentFetchTask?.cancel()
activeFetchID = nil
}
@ -282,10 +301,26 @@ struct MediaInfoView: View {
VStack(alignment: .leading, spacing: 16) {
headerSection
if !aliases.isEmpty && !(module.metadata.novel ?? false) {
Text(aliases)
.font(.system(size: 14))
.foregroundColor(.secondary)
.padding(.top, 4)
}
if module.metadata.novel ?? false {
if !chapters.isEmpty {
chaptersSection
} else {
noContentSection
}
} else {
if !episodeLinks.isEmpty {
episodesSection
} else {
noEpisodesSection
noContentSection
}
}
}
.padding()
@ -327,12 +362,18 @@ struct MediaInfoView: View {
Text(title)
.font(.system(size: 28, weight: .bold))
.foregroundColor(.primary)
.lineLimit(3)
.fixedSize(horizontal: false, vertical: true)
.multilineTextAlignment(.leading)
.lineLimit(nil)
.onLongPressGesture {
copyTitleToClipboard()
}
if !synopsis.isEmpty {
if !synopsis.isEmpty && !(module.metadata.novel ?? false) {
synopsisSection
}
if module.metadata.novel ?? false && !synopsis.isEmpty {
synopsisSection
}
@ -346,18 +387,21 @@ struct MediaInfoView: View {
@ViewBuilder
private var synopsisSection: some View {
HStack(alignment: .bottom) {
VStack(alignment: .leading, spacing: 2) {
Text(synopsis)
.font(.system(size: 16))
.foregroundColor(.secondary)
.lineLimit(showFullSynopsis ? nil : 3)
.animation(nil, value: showFullSynopsis)
HStack {
Spacer()
Text(showFullSynopsis ? NSLocalizedString("LESS", comment: "") : NSLocalizedString("MORE", comment: ""))
.font(.system(size: 16, weight: .bold))
.foregroundColor(.accentColor)
.animation(.easeInOut(duration: 0.3), value: showFullSynopsis)
}
}
.onTapGesture {
withAnimation(.easeInOut(duration: 0.3)) {
showFullSynopsis.toggle()
@ -372,7 +416,7 @@ struct MediaInfoView: View {
HStack(spacing: 8) {
Image(systemName: "play.fill")
.foregroundColor(colorScheme == .dark ? .black : .white)
Text(startWatchingText)
Text(startActionText)
.font(.system(size: 16, weight: .medium))
.foregroundColor(colorScheme == .dark ? .black : .white)
}
@ -481,6 +525,7 @@ struct MediaInfoView: View {
@ViewBuilder
private var episodesSection: some View {
let _ = Logger.shared.log("episodesSection: episodeLinks count = \(episodeLinks.count)", type: "Debug")
if episodeLinks.count != 1 {
VStack(alignment: .leading, spacing: 16) {
episodesSectionHeader
@ -624,22 +669,91 @@ struct MediaInfoView: View {
}
@ViewBuilder
private var noEpisodesSection: some View {
private var chaptersSection: some View {
let _ = Logger.shared.log("chaptersSection: chapters count = \(chapters.count)", type: "Debug")
VStack(alignment: .leading, spacing: 16) {
if !airdate.isEmpty && airdate != "N/A" && airdate != "No Data" {
HStack(spacing: 4) {
Image(systemName: "calendar")
.foregroundColor(.accentColor)
Text(airdate)
.font(.system(size: 14))
.foregroundColor(.accentColor)
Spacer()
}
}
if !aliases.isEmpty {
Text(aliases)
.font(.system(size: 14))
.foregroundColor(.secondary)
}
HStack {
Text(NSLocalizedString("Chapters", comment: ""))
.font(.system(size: 22, weight: .bold))
.foregroundColor(.primary)
Spacer()
if chapters.count > 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,24 +869,87 @@ 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"
@ -780,13 +957,18 @@ struct MediaInfoView: View {
} 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")
}
}
private func cancelCurrentFetch() {
activeFetchID = nil
@ -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,29 +1359,87 @@ 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
}
}
} 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
}
}
}
} catch {
Logger.shared.log("Error loading module: \(error)", type: "Error")
self.isLoading = false
@ -1941,4 +2182,15 @@ struct MediaInfoView: View {
findTopViewController.findViewController(rootVC).present(alert, animated: true)
}
}
private func generateChapterRanges() -> [Range<Int>] {
let chunkSize = chapterChunkSize
let totalChapters = chapters.count
var ranges: [Range<Int>] = []
for i in stride(from: 0, to: totalChapters, by: chunkSize) {
let end = min(i + chunkSize, totalChapters)
ranges.append(i..<end)
}
return ranges
}
}

View file

@ -0,0 +1,697 @@
//
// ReaderView.swift
// Sora
//
// Created by paul on 18/06/25.
//
import SwiftUI
import WebKit
extension UserDefaults {
func cgFloat(forKey defaultName: String) -> 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..<colorPresets.count, id: \.self) { index in
Button(action: {
selectedColorPreset = index
UserDefaults.standard.set(index, forKey: "readerColorPreset")
}) {
Label {
HStack {
Text(colorPresets[index].name)
Spacer()
if selectedColorPreset == index {
Image(systemName: "checkmark")
.foregroundColor(.blue)
}
}
} icon: {
Circle()
.fill(Color(hex: colorPresets[index].background))
.frame(width: 16, height: 16)
.overlay(
Circle()
.stroke(Color(hex: colorPresets[index].text), lineWidth: 1)
)
}
}
}
} label: {
settingsButtonLabel(icon: "paintpalette")
}
Menu {
VStack {
Text("Line Spacing: \(String(format: "%.1f", lineSpacing))")
.font(.headline)
.padding(.bottom, 8)
Slider(value: Binding(
get: { lineSpacing },
set: { newValue in
lineSpacing = newValue
UserDefaults.standard.set(newValue, forKey: "readerLineSpacing")
}
), in: 1.0...3.0, step: 0.1) {
Text("Line Spacing")
}
.padding(.horizontal)
}
.padding()
} label: {
Image(systemName: "arrow.left.and.right.text.vertical")
.font(.system(size: 16, weight: .bold))
.foregroundColor(currentTheme.text)
.padding(10)
.background(currentTheme.background.opacity(0.8))
.clipShape(Circle())
.circularGradientOutline()
.rotationEffect(.degrees(-90))
}
Menu {
VStack {
Text("Margin: \(Int(margin))px")
.font(.headline)
.padding(.bottom, 8)
Slider(value: Binding(
get: { margin },
set: { newValue in
margin = newValue
UserDefaults.standard.set(newValue, forKey: "readerMargin")
}
), in: 0...30, step: 1) {
Text("Margin")
}
.padding(.horizontal)
}
.padding()
} label: {
settingsButtonLabel(icon: "rectangle.inset.filled")
}
Menu {
ForEach(alignmentOptions, id: \.0) { alignment in
Button(action: {
textAlignment = alignment.0
UserDefaults.standard.set(alignment.0, forKey: "readerTextAlignment")
}) {
HStack {
Image(systemName: alignment.2)
Text(alignment.1)
Spacer()
if textAlignment == alignment.0 {
Image(systemName: "checkmark")
.foregroundColor(.blue)
}
}
}
}
} label: {
settingsButtonLabel(icon: "text.alignleft")
}
}
.padding(.top, 50)
.transition(.opacity)
}
}
.padding(.trailing)
}
}
.padding(.top, (UIApplication.shared.windows.first?.safeAreaInsets.top ?? 0))
.padding(.bottom, 30)
.background(ProgressiveBlurView())
Spacer()
}
.ignoresSafeArea()
}
private var footerView: some View {
VStack {
Spacer()
HStack(spacing: 20) {
Spacer()
Button(action: {
isAutoScrolling.toggle()
}) {
Image(systemName: isAutoScrolling ? "pause.fill" : "play.fill")
.font(.system(size: 18, weight: .bold))
.foregroundColor(isAutoScrolling ? .red : currentTheme.text)
.padding(12)
.background(currentTheme.background.opacity(0.8))
.clipShape(Circle())
.circularGradientOutline()
}
.contextMenu {
VStack {
Text("Auto Scroll Speed")
.font(.headline)
.padding(.bottom, 8)
Slider(value: $autoScrollSpeed, in: 0.2...3.0, step: 0.1) {
Text("Speed")
}
.padding(.horizontal)
Text("Speed: \(String(format: "%.1f", autoScrollSpeed))x")
.font(.caption)
.foregroundColor(.secondary)
.padding(.top, 4)
}
.padding()
}
}
.padding(.horizontal, 20)
.padding(.top, 20)
.padding(.bottom, (UIApplication.shared.windows.first?.safeAreaInsets.bottom ?? 0) + 20)
.frame(maxWidth: .infinity)
.background(ProgressiveBlurView())
.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
}
}
}
.ignoresSafeArea()
}
private func settingsButtonLabel(icon: String) -> 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 = """
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<style>
html, body {
font-family: \(fontFamily), system-ui;
font-size: \(fontSize)px;
font-weight: \(fontWeight);
line-height: \(lineSpacing);
text-align: \(textAlignment);
padding: \(margin)px;
margin: 0;
color: \(colorPreset.text);
background-color: \(colorPreset.background);
transition: all 0.3s ease;
overflow-x: hidden;
width: 100%;
max-width: 100%;
word-wrap: break-word;
-webkit-user-select: text;
-webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent;
}
body {
box-sizing: border-box;
}
p, div, span, h1, h2, h3, h4, h5, h6 {
font-size: inherit;
font-family: inherit;
font-weight: inherit;
line-height: inherit;
text-align: inherit;
color: inherit;
max-width: 100%;
word-wrap: break-word;
overflow-wrap: break-word;
}
* {
max-width: 100%;
box-sizing: border-box;
}
</style>
</head>
<body>
\(htmlContent)
</body>
</html>
"""
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
}
}
}

View file

@ -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 {

View file

@ -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(

View file

@ -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
}
},

View file

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

View file

@ -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 = "<group>"; };
041261012E00D14F00D05B47 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = Localizable.strings; sourceTree = "<group>"; };
0452339E2E02149C002EA23C /* bos */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bos; path = Localizable.strings; sourceTree = "<group>"; };
04536F702E04BA3B00A11248 /* JSController-Novel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-Novel.swift"; sourceTree = "<group>"; };
04536F722E04BA5600A11248 /* ReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderView.swift; sourceTree = "<group>"; };
04536F752E04BA6900A11248 /* ChapterCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterCell.swift; sourceTree = "<group>"; };
0457C5942DE7712A000AFBD9 /* DeviceScaleModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceScaleModifier.swift; sourceTree = "<group>"; };
0457C5992DE78267000AFBD9 /* BookmarkGridItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkGridItemView.swift; sourceTree = "<group>"; };
0457C59A2DE78267000AFBD9 /* BookmarkGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkGridView.swift; sourceTree = "<group>"; };
@ -145,6 +152,7 @@
04AD07112E0360CD00EB74C1 /* CollectionPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionPickerView.swift; sourceTree = "<group>"; };
04AD07152E03704700EB74C1 /* BookmarkCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkCell.swift; sourceTree = "<group>"; };
04CD76DA2DE20F2200733536 /* AllWatching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllWatching.swift; sourceTree = "<group>"; };
04E00C9E2E09F5920056124A /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = Localizable.strings; sourceTree = "<group>"; };
04EAC3992DF9E0DB00BBD483 /* SplashScreenView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashScreenView.swift; sourceTree = "<group>"; };
04F08EDB2DE10BEC006B29D9 /* ProgressiveBlurView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressiveBlurView.swift; sourceTree = "<group>"; };
04F08EDE2DE10C1A006B29D9 /* TabBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBar.swift; sourceTree = "<group>"; };
@ -317,6 +325,22 @@
path = bos.lproj;
sourceTree = "<group>";
};
04536F732E04BA5600A11248 /* ReaderView */ = {
isa = PBXGroup;
children = (
04536F722E04BA5600A11248 /* ReaderView.swift */,
);
path = ReaderView;
sourceTree = "<group>";
};
04536F762E04BA6900A11248 /* ChapterCell */ = {
isa = PBXGroup;
children = (
04536F752E04BA6900A11248 /* ChapterCell.swift */,
);
path = ChapterCell;
sourceTree = "<group>";
};
0457C5962DE7712A000AFBD9 /* ViewModifiers */ = {
isa = PBXGroup;
children = (
@ -380,6 +404,14 @@
path = nn.lproj;
sourceTree = "<group>";
};
04E00C9A2E09E96B0056124A /* it.lproj */ = {
isa = PBXGroup;
children = (
04E00C9D2E09F5920056124A /* Localizable.strings */,
);
path = it.lproj;
sourceTree = "<group>";
};
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 = "<group>";
};
04E00C9D2E09F5920056124A /* Localizable.strings */ = {
isa = PBXVariantGroup;
children = (
04E00C9E2E09F5920056124A /* it */,
);
name = Localizable.strings;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */