Sora/Sora/Views/MediaInfoView/MediaInfoView.swift
Francesco 519744616e test
2025-06-03 15:07:58 +02:00

1984 lines
84 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// MediaInfoView.swift
// Sora
//
// Created by Francesco on 05/01/25.
//
import SwiftUI
import Kingfisher
import SafariServices
private let tmdbFetcher = TMDBFetcher()
struct MediaItem: Identifiable {
let id = UUID()
let description: String
let aliases: String
let airdate: String
}
struct MediaInfoView: View {
let title: String
@State var imageUrl: String
let href: String
let module: ScrapingModule
@State var aliases: String = ""
@State var synopsis: String = ""
@State var airdate: String = ""
@State var episodeLinks: [EpisodeLink] = []
@State var itemID: Int?
@State var tmdbID: Int?
@State var isLoading: Bool = true
@State var showFullSynopsis: Bool = false
@State var hasFetched: Bool = false
@State var isRefetching: Bool = true
@State var isFetchingEpisode: Bool = false
@State private var refreshTrigger: Bool = false
@State private var buttonRefreshTrigger: Bool = false
@State private var selectedEpisodeNumber: Int = 0
@State private var selectedEpisodeImage: String = ""
@State private var selectedSeason: Int = 0
@AppStorage("externalPlayer") private var externalPlayer: String = "Default"
@AppStorage("episodeChunkSize") private var episodeChunkSize: Int = 100
@State private var isModuleSelectorPresented = false
@State private var isError = false
@State private var isMatchingPresented = false
@State private var matchedTitle: String? = nil
@StateObject private var jsController = JSController.shared
@EnvironmentObject var moduleManager: ModuleManager
@EnvironmentObject private var libraryManager: LibraryManager
@EnvironmentObject var tabBarController: TabBarController
@State private var selectedRange: Range<Int> = 0..<100
@State private var showSettingsMenu = false
@State private var customAniListID: Int?
@State private var showStreamLoadingView: Bool = false
@State private var currentStreamTitle: String = ""
@State private var activeFetchID: UUID? = nil
@Environment(\.dismiss) private var dismiss
@State private var showLoadingAlert: Bool = false
@Environment(\.colorScheme) private var colorScheme
@Environment(\.verticalSizeClass) private var verticalSizeClass
@AppStorage("selectedAppearance") private var selectedAppearance: Appearance = .system
@State private var isMultiSelectMode: Bool = false
@State private var selectedEpisodes: Set<Int> = []
@State private var showRangeInput: Bool = false
@State private var isBulkDownloading: Bool = false
@State private var bulkDownloadProgress: String = ""
@State private var tmdbType: TMDBFetcher.MediaType? = nil
private var isGroupedBySeasons: Bool {
return groupedEpisodes().count > 1
}
private var isCompactLayout: Bool {
return verticalSizeClass == .compact
}
private var useIconOnlyButtons: Bool {
if UIDevice.current.userInterfaceIdiom == .pad {
return false
}
return verticalSizeClass == .regular
}
private var multiselectButtonSpacing: CGFloat {
return isCompactLayout ? 16 : 12
}
private var multiselectPadding: CGFloat {
return isCompactLayout ? 20 : 16
}
private var watchingProgress: Double {
guard !episodeLinks.isEmpty else { return 0 }
var watchedCount = 0
for ep in episodeLinks {
let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(ep.href)")
let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(ep.href)")
if totalTime > 0 && (totalTime - lastPlayedTime) / totalTime <= 0.1 {
watchedCount += 1
}
}
return Double(watchedCount) / Double(episodeLinks.count)
}
private var latestProgressEpisode: (progress: Double, title: String)? {
for ep in episodeLinks.reversed() {
let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(ep.href)")
let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(ep.href)")
if totalTime > 0 {
let progress = lastPlayedTime / totalTime
return (progress, "Episode \(ep.number)")
}
}
return nil
}
private var continueWatchingText: String {
for ep in episodeLinks {
let last = UserDefaults.standard.double(forKey: "lastPlayedTime_\(ep.href)")
let total = UserDefaults.standard.double(forKey: "totalTime_\(ep.href)")
let progress = total > 0 ? last / total : 0
if progress > 0 && progress < 0.9 {
return "Continue Watching Episode \(ep.number)"
}
}
for ep in episodeLinks {
let last = UserDefaults.standard.double(forKey: "lastPlayedTime_\(ep.href)")
let total = UserDefaults.standard.double(forKey: "totalTime_\(ep.href)")
let progress = total > 0 ? last / total : 0
if progress < 0.9 {
return "Start Watching Episode \(ep.number)"
}
}
return "Start Watching"
}
var body: some View {
ZStack {
bodyContent
.navigationBarHidden(true)
.ignoresSafeArea(.container, edges: .top)
VStack {
HStack {
Button(action: {
dismiss()
}) {
Image(systemName: "chevron.left")
.font(.system(size: 24))
.foregroundColor(.primary)
.padding(12)
.background(Color.gray.opacity(0.2))
.clipShape(Circle())
.circularGradientOutline()
}
.padding(.top, 8)
.padding(.leading, 16)
Spacer()
}
Spacer()
}
}
}
@ViewBuilder
private var bodyContent: some View {
Group {
if isLoading {
ProgressView()
.padding()
} else {
mainScrollView
}
}
.onAppear {
buttonRefreshTrigger.toggle()
let savedID = UserDefaults.standard.integer(forKey: "custom_anilist_id_\(href)")
if savedID != 0 { customAniListID = savedID }
if let savedPoster = UserDefaults.standard.string(forKey: "tmdbPosterURL_\(href)") {
self.imageUrl = savedPoster
}
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
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 let savedID = UserDefaults.standard.object(forKey: "custom_anilist_id_\(href)") as? Int {
customAniListID = savedID
itemID = savedID
Logger.shared.log("Using custom AniList ID: \(savedID)", type: "Debug")
} else {
fetchMetadataIDIfNeeded()
}
selectedRange = 0..<episodeChunkSize
hasFetched = true
AnalyticsManager.shared.sendEvent(event: "search", additionalData: ["title": title])
}
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
}
}
.onDisappear(){
tabBarController.showTabBar()
}
.alert("Loading Stream", isPresented: $showLoadingAlert) {
Button("Cancel", role: .cancel) {
activeFetchID = nil
isFetchingEpisode = false
showStreamLoadingView = false
}
} message: {
HStack {
Text("Loading Episode \(selectedEpisodeNumber)...")
ProgressView()
.padding(.top, 8)
}
}
.onDisappear {
activeFetchID = nil
isFetchingEpisode = false
showStreamLoadingView = false
}
}
@ViewBuilder
private var mainScrollView: some View {
ScrollView {
ZStack(alignment: .top) {
KFImage(URL(string: imageUrl))
.placeholder {
Rectangle()
.fill(Color.gray.opacity(0.3))
.shimmering()
}
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: UIScreen.main.bounds.width, height: 700)
.clipped()
KFImage(URL(string: imageUrl))
.placeholder { EmptyView() }
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: UIScreen.main.bounds.width, height: 700)
.clipped()
.blur(radius: 30)
.mask(
LinearGradient(
gradient: Gradient(stops: [
.init(color: .clear, location: 0.0),
.init(color: .clear, location: 0.6),
.init(color: .black, location: 0.8),
.init(color: .black, location: 1.0)
]),
startPoint: .top,
endPoint: .bottom
)
)
.overlay(
VStack(spacing: 0) {
Spacer()
LinearGradient(
gradient: Gradient(stops: [
.init(color: (colorScheme == .dark ? Color.black : Color.white).opacity(0.0), location: 0.0),
.init(color: (colorScheme == .dark ? Color.black : Color.white).opacity(0.5), location: 0.5),
.init(color: (colorScheme == .dark ? Color.black : Color.white).opacity(1.0), location: 1.0)
]),
startPoint: .top,
endPoint: .bottom
)
.frame(height: 150)
}
)
VStack(spacing: 0) {
Rectangle()
.fill(Color.clear)
.frame(height: 450)
VStack(alignment: .leading, spacing: 16) {
headerSection
if !episodeLinks.isEmpty {
episodesSection
} else {
noEpisodesSection
}
}
.padding()
.background(
LinearGradient(
gradient: Gradient(stops: [
.init(color: (colorScheme == .dark ? Color.black : Color.white).opacity(0.0), location: 0.0),
.init(color: (colorScheme == .dark ? Color.black : Color.white).opacity(0.3), location: 0.1),
.init(color: (colorScheme == .dark ? Color.black : Color.white).opacity(0.6), location: 0.3),
.init(color: (colorScheme == .dark ? Color.black : Color.white).opacity(0.9), location: 0.7),
.init(color: (colorScheme == .dark ? Color.black : Color.white), location: 1.0)
]),
startPoint: .top,
endPoint: .bottom
)
.clipShape(RoundedRectangle(cornerRadius: 0))
.shadow(color: (colorScheme == .dark ? Color.black : Color.white).opacity(1), radius: 15, x: 0, y: 15)
)
}
.deviceScaled()
}
}
.onAppear {
UIScrollView.appearance().bounces = false
}
.ignoresSafeArea(.container, edges: .top)
}
@ViewBuilder
private var headerSection: some View {
VStack(alignment: .leading, spacing: 8) {
Spacer()
HStack(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()
}
Text(title)
.font(.system(size: 28, weight: .bold))
.foregroundColor(.primary)
.lineLimit(3)
.onLongPressGesture {
UIPasteboard.general.string = title
DropManager.shared.showDrop(title: "Copied to Clipboard", subtitle: "", duration: 1.0, icon: UIImage(systemName: "doc.on.clipboard.fill"))
}
if !synopsis.isEmpty {
HStack(alignment: .bottom) {
Text(synopsis)
.font(.system(size: 16))
.foregroundColor(.secondary)
.lineLimit(showFullSynopsis ? nil : 3)
.animation(nil, value: showFullSynopsis)
Text(showFullSynopsis ? "LESS" : "MORE")
.font(.system(size: 16, weight: .bold))
.foregroundColor(.accentColor)
.animation(.easeInOut(duration: 0.3), value: showFullSynopsis)
}
.onTapGesture {
withAnimation(.easeInOut(duration: 0.3)) {
showFullSynopsis.toggle()
}
}
}
playAndBookmarkSection
if episodeLinks.count == 1 {
VStack(spacing: 12) {
HStack(spacing: 12) {
Button(action: {
if let ep = episodeLinks.first {
let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(ep.href)")
let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(ep.href)")
let progress = totalTime > 0 ? lastPlayedTime / totalTime : 0
if progress <= 0.9 {
UserDefaults.standard.set(99999999.0, forKey: "lastPlayedTime_\(ep.href)")
UserDefaults.standard.set(99999999.0, forKey: "totalTime_\(ep.href)")
DropManager.shared.showDrop(title: "Marked as Watched", subtitle: "", duration: 1.0, icon: UIImage(systemName: "checkmark.circle.fill"))
} else {
UserDefaults.standard.set(0.0, forKey: "lastPlayedTime_\(ep.href)")
UserDefaults.standard.set(0.0, forKey: "totalTime_\(ep.href)")
DropManager.shared.showDrop(title: "Progress Reset", subtitle: "", duration: 1.0, icon: UIImage(systemName: "arrow.counterclockwise"))
}
}
}) {
HStack(spacing: 4) {
Image(systemName: {
if let ep = episodeLinks.first {
let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(ep.href)")
let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(ep.href)")
let progress = totalTime > 0 ? lastPlayedTime / totalTime : 0
return progress <= 0.9 ? "checkmark.circle" : "arrow.counterclockwise"
}
return "checkmark.circle"
}())
.foregroundColor(.primary)
Text({
if let ep = episodeLinks.first {
let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(ep.href)")
let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(ep.href)")
let progress = totalTime > 0 ? lastPlayedTime / totalTime : 0
return progress <= 0.9 ? "Mark watched" : "Reset progress"
}
return "Mark watched"
}())
.font(.system(size: 14, weight: .medium))
.foregroundColor(.primary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
.background(Color.gray.opacity(0.2))
.cornerRadius(15)
.gradientOutline()
}
Button(action: {
if let ep = episodeLinks.first {
let downloadStatus = jsController.isEpisodeDownloadedOrInProgress(
showTitle: title,
episodeNumber: ep.number,
season: 1
)
if downloadStatus == .notDownloaded {
selectedEpisodeNumber = ep.number
fetchStream(href: ep.href)
DropManager.shared.showDrop(title: "Starting Download", subtitle: "", duration: 1.0, icon: UIImage(systemName: "arrow.down.circle"))
} else {
DropManager.shared.showDrop(title: "Already Downloaded", subtitle: "", duration: 1.0, icon: UIImage(systemName: "checkmark.circle"))
}
}
}) {
HStack(spacing: 4) {
Image(systemName: "arrow.down.circle")
.foregroundColor(.primary)
Text("Download")
.font(.system(size: 14, weight: .medium))
.foregroundColor(.primary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
.background(Color.gray.opacity(0.2))
.cornerRadius(15)
.gradientOutline()
}
menuButton
}
Text("Why am I not seeing any episodes?")
.font(.caption)
.bold()
.foregroundColor(.gray)
.multilineTextAlignment(.leading)
.lineLimit(nil)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 4)
Text("The module provided only a single episode, this is most likely a movie, so we decided to make separate screens for these cases.")
.font(.caption)
.foregroundColor(.gray)
.multilineTextAlignment(.leading)
.lineLimit(nil)
.fixedSize(horizontal: false, vertical: true)
.padding(.top, 4)
}
}
}
}
@ViewBuilder
private var contentSection: some View {
VStack(alignment: .leading, spacing: 20) {
playAndBookmarkSection
if !episodeLinks.isEmpty {
episodesSection
} else {
noEpisodesSection
}
}
.padding(.horizontal, 20)
.padding(.vertical, 20)
.background(
Rectangle()
.fill(colorScheme == .dark ? Color.black : Color.white)
)
}
@ViewBuilder
private var sourceButton: some View {
Button(action: {
openSafariViewController(with: href)
}) {
Image(systemName: "safari")
.resizable()
.frame(width: 16, height: 16)
.foregroundColor(.primary)
.padding(6)
.background(Color.gray.opacity(0.2))
.clipShape(Circle())
.circularGradientOutline()
}
}
@ViewBuilder
private var menuButton: some View {
Menu {
if let id = itemID ?? customAniListID {
let labelText = (matchedTitle?.isEmpty == false ? matchedTitle! : "\(id)")
Text("Matched with: \(labelText)")
.font(.caption)
.foregroundColor(.gray)
.padding(.vertical, 4)
}
Divider()
if let _ = customAniListID {
Button(action: {
customAniListID = nil
itemID = nil
matchedTitle = nil
fetchItemID(byTitle: cleanTitle(title)) { result in
switch result {
case .success(let id):
itemID = id
case .failure(let error):
Logger.shared.log("Failed to fetch AniList ID: \(error)")
}
}
}) {
Label("Reset AniList ID", systemImage: "arrow.clockwise")
}
}
if let id = itemID ?? customAniListID {
Button(action: {
if let url = URL(string: "https://anilist.co/anime/\(id)") {
openSafariViewController(with: url.absoluteString)
}
}) {
Label("Open in AniList", systemImage: "link")
}
}
if UserDefaults.standard.string(forKey: "metadataProviders") ?? "TMDB" == "AniList" {
Button(action: {
isMatchingPresented = true
}) {
Label("Match with AniList", systemImage: "magnifyingglass")
}
}
Button(action: {
fetchTMDBPosterImageAndSet()
}) {
Label("Use TMDB Poster Image", systemImage: "photo")
}
Divider()
Button(action: {
Logger.shared.log("""
Debug Info:
Title: \(title)
Href: \(href)
Module: \(module.metadata.sourceName)
AniList ID: \(itemID ?? -1)
Custom ID: \(customAniListID ?? -1)
Matched Title: \(matchedTitle ?? "")
""", type: "Debug")
DropManager.shared.showDrop(
title: "Debug Info Logged",
subtitle: "",
duration: 1.0,
icon: UIImage(systemName: "terminal")
)
}) {
Label("Log Debug Info", systemImage: "terminal")
}
} label: {
Image(systemName: "ellipsis")
.resizable()
.frame(width: 16, height: 4)
.foregroundColor(.primary)
.padding(12)
.background(Color.gray.opacity(0.2))
.clipShape(Circle())
.circularGradientOutline()
}
.sheet(isPresented: $isMatchingPresented) {
AnilistMatchPopupView(seriesTitle: title) { selectedID in
self.customAniListID = selectedID
self.itemID = selectedID
UserDefaults.standard.set(selectedID, forKey: "custom_anilist_id_\(href)")
self.fetchDetails()
isMatchingPresented = false
}
}
}
@ViewBuilder
private var playAndBookmarkSection: some View {
HStack(spacing: 12) {
ZStack(alignment: .leading) {
GeometryReader { geometry in
let width = geometry.size.width
let progress = latestProgressEpisode?.progress ?? 0.0
RoundedRectangle(cornerRadius: 25)
.fill(Color.accentColor.opacity(0.25))
.frame(width: width, height: 48)
if progress < 0.9 {
Capsule()
.fill(Color.accentColor)
.frame(width: max(width * CGFloat(progress), 8), height: 48)
.mask(
HStack {
if progress < 0.05 && progress != 0 {
RoundedRectangle(cornerRadius: 24)
.frame(width: 8)
} else {
RoundedRectangle(cornerRadius: 24)
}
Spacer()
}
)
}
}
.frame(height: 48)
Button(action: {
playFirstUnwatchedEpisode()
}) {
HStack(spacing: 8) {
Image(systemName: "play.fill")
.foregroundColor(colorScheme == .dark ? .black : .white)
Text(continueWatchingText)
.font(.system(size: 16, weight: .medium))
.foregroundColor(colorScheme == .dark ? .black : .white)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.padding(.horizontal, 20)
.background(Color.clear)
.contentShape(RoundedRectangle(cornerRadius: 25))
}
.disabled(isFetchingEpisode)
}
.clipShape(RoundedRectangle(cornerRadius: 25))
.overlay(
RoundedRectangle(cornerRadius: 25)
.stroke(Color.accentColor, lineWidth: 0)
)
Button(action: {
libraryManager.toggleBookmark(
title: title,
imageUrl: imageUrl,
href: href,
moduleId: module.id.uuidString,
moduleName: module.metadata.sourceName
)
}) {
Image(systemName: libraryManager.isBookmarked(href: href, moduleName: module.metadata.sourceName) ? "bookmark.fill" : "bookmark")
.resizable()
.frame(width: 16, height: 22)
.foregroundColor(.primary)
.padding(12)
.background(Color.gray.opacity(0.2))
.clipShape(Circle())
.circularGradientOutline()
}
}
}
@ViewBuilder
private var episodesSection: some View {
if episodeLinks.count == 1 {
EmptyView()
} else {
VStack(alignment: .leading, spacing: 16) {
HStack {
Text("Episodes")
.font(.system(size: 22, weight: .bold))
.foregroundColor(.primary)
Spacer()
Group {
if !isGroupedBySeasons && episodeLinks.count <= episodeChunkSize {
Text("")
.font(.system(size: 14))
.foregroundColor(.secondary)
} else {
episodeNavigationSection
}
}
HStack(spacing: 4) {
sourceButton
menuButton
}
}
episodeListSection
}
}
}
@ViewBuilder
private var episodeNavigationSection: some View {
Group {
if !isGroupedBySeasons, episodeLinks.count > episodeChunkSize {
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)
}
} else if isGroupedBySeasons {
let seasons = groupedEpisodes()
if seasons.count > 1 {
Menu {
ForEach(0..<seasons.count, id: \.self) { index in
Button(action: { selectedSeason = index }) {
Text("Season \(index + 1)")
}
}
} label: {
Text("Season \(selectedSeason + 1)")
.font(.system(size: 14))
.foregroundColor(.accentColor)
}
}
}
}
}
@ViewBuilder
private var episodeListSection: some View {
Group {
if isGroupedBySeasons {
seasonsEpisodeList
} else {
flatEpisodeList
}
}
}
@ViewBuilder
private var flatEpisodeList: some View {
LazyVStack(spacing: 15) {
ForEach(episodeLinks.indices.filter { selectedRange.contains($0) }, id: \.self) { i in
let ep = episodeLinks[i]
let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(ep.href)")
let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(ep.href)")
let progress = totalTime > 0 ? lastPlayedTime / totalTime : 0
let defaultBannerImageValue = getBannerImageBasedOnAppearance()
EpisodeCell(
episodeIndex: i,
episode: ep.href,
episodeID: ep.number - 1,
progress: progress,
itemID: itemID ?? 0,
totalEpisodes: episodeLinks.count,
defaultBannerImage: defaultBannerImageValue,
module: module,
parentTitle: title,
showPosterURL: imageUrl,
isMultiSelectMode: isMultiSelectMode,
isSelected: selectedEpisodes.contains(ep.number),
onSelectionChanged: { isSelected in
if isSelected {
selectedEpisodes.insert(ep.number)
} else {
selectedEpisodes.remove(ep.number)
}
},
onTap: { imageUrl in
episodeTapAction(ep: ep, imageUrl: imageUrl)
},
onMarkAllPrevious: {
markAllPreviousEpisodesInFlatList(ep: ep, index: i)
},
tmdbID: tmdbID,
seasonNumber: 1
)
.disabled(isFetchingEpisode)
}
}
}
@ViewBuilder
private var seasonsEpisodeList: some View {
let seasons = groupedEpisodes()
if !seasons.isEmpty, selectedSeason < seasons.count {
LazyVStack(spacing: 15) {
ForEach(seasons[selectedSeason]) { ep in
let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(ep.href)")
let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(ep.href)")
let progress = totalTime > 0 ? lastPlayedTime / totalTime : 0
let defaultBannerImageValue = getBannerImageBasedOnAppearance()
EpisodeCell(
episodeIndex: selectedSeason,
episode: ep.href,
episodeID: ep.number - 1,
progress: progress,
itemID: itemID ?? 0,
totalEpisodes: episodeLinks.count,
defaultBannerImage: defaultBannerImageValue,
module: module,
parentTitle: title,
showPosterURL: imageUrl,
isMultiSelectMode: isMultiSelectMode,
isSelected: selectedEpisodes.contains(ep.number),
onSelectionChanged: { isSelected in
if isSelected {
selectedEpisodes.insert(ep.number)
} else {
selectedEpisodes.remove(ep.number)
}
},
onTap: { imageUrl in
episodeTapAction(ep: ep, imageUrl: imageUrl)
},
onMarkAllPrevious: {
markAllPreviousEpisodesAsWatched(ep: ep, inSeason: true)
},
tmdbID: tmdbID,
seasonNumber: selectedSeason + 1
)
.disabled(isFetchingEpisode)
}
}
} else {
Text("No episodes available")
}
}
private func getBannerImageBasedOnAppearance() -> String {
let isLightMode = selectedAppearance == .light || (selectedAppearance == .system && colorScheme == .light)
return isLightMode
? "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/dev/assets/banner1.png"
: "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/dev/assets/banner2.png"
}
private func episodeTapAction(ep: EpisodeLink, imageUrl: String) {
if !isFetchingEpisode {
selectedEpisodeNumber = ep.number
selectedEpisodeImage = imageUrl
fetchStream(href: ep.href)
AnalyticsManager.shared.sendEvent(
event: "watch",
additionalData: ["title": title, "episode": ep.number]
)
}
}
private func fetchMetadataIDIfNeeded() {
let provider = UserDefaults.standard.string(forKey: "metadataProviders") ?? "TMDB"
let cleaned = cleanTitle(title)
if provider == "TMDB" {
tmdbID = nil
tmdbFetcher.fetchBestMatchID(for: cleaned) { id, type in
DispatchQueue.main.async {
self.tmdbID = id
self.tmdbType = type
Logger.shared.log("Fetched TMDB ID: \(id ?? -1) (\(type?.rawValue ?? "unknown")) for title: \(cleaned)", type: "Debug")
}
}
} else if provider == "Anilist" {
itemID = nil
fetchItemID(byTitle: cleaned) { result in
switch result {
case .success(let id):
DispatchQueue.main.async {
self.itemID = id
Logger.shared.log("Fetched AniList ID: \(id) for title: \(cleaned)", type: "Debug")
}
case .failure(let error):
Logger.shared.log("Failed to fetch AniList ID: \(error)", type: "Error")
}
}
}
}
private func fetchTMDBPosterImageAndSet() {
guard let tmdbID = tmdbID, let tmdbType = tmdbType else { return }
let apiType = tmdbType.rawValue
let urlString = "https://api.themoviedb.org/3/\(apiType)/\(tmdbID)?api_key=738b4edd0a156cc126dc4a4b8aea4aca"
guard let url = URL(string: urlString) else { return }
let tmdbImageWidth = UserDefaults.standard.string(forKey: "tmdbImageWidth") ?? "original"
URLSession.custom.dataTask(with: url) { data, _, error in
guard let data = data, error == nil else { return }
do {
if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let posterPath = json["poster_path"] as? String {
let imageUrl: String
if tmdbImageWidth == "original" {
imageUrl = "https://image.tmdb.org/t/p/original\(posterPath)"
} else {
imageUrl = "https://image.tmdb.org/t/p/w\(tmdbImageWidth)\(posterPath)"
}
DispatchQueue.main.async {
self.imageUrl = imageUrl
UserDefaults.standard.set(imageUrl, forKey: "tmdbPosterURL_\(self.href)")
}
}
} catch {
Logger.shared.log("Failed to parse TMDB poster: \(error.localizedDescription)", type: "Error")
}
}.resume()
}
private func markAllPreviousEpisodesAsWatched(ep: EpisodeLink, inSeason: Bool) {
let userDefaults = UserDefaults.standard
var updates = [String: Double]()
if inSeason {
let seasons = groupedEpisodes()
for ep2 in seasons[selectedSeason] where ep2.number < ep.number {
let href = ep2.href
updates["lastPlayedTime_\(href)"] = 99999999.0
updates["totalTime_\(href)"] = 99999999.0
}
for (key, value) in updates {
userDefaults.set(value, forKey: key)
}
userDefaults.synchronize()
Logger.shared.log("Marked episodes watched within season \(selectedSeason + 1) of \"\(title)\".", type: "General")
}
}
private func markAllPreviousEpisodesInFlatList(ep: EpisodeLink, index: Int) {
let userDefaults = UserDefaults.standard
var updates = [String: Double]()
for idx in 0..<index {
let href = episodeLinks[idx].href
updates["lastPlayedTime_\(href)"] = 1000.0
updates["totalTime_\(href)"] = 1000.0
}
for (key, value) in updates {
userDefaults.set(value, forKey: key)
}
userDefaults.synchronize()
NotificationCenter.default.post(name: NSNotification.Name("episodeProgressChanged"), object: nil)
Logger.shared.log(
"Marked \(ep.number - 1) episodes watched within series \"\(title)\".",
type: "General"
)
guard let listID = itemID, listID > 0 else { return }
let watchedCount = ep.number - 1
let statusToSend = (watchedCount == episodeLinks.count) ? "COMPLETED" : "CURRENT"
AniListMutation().updateAnimeProgress(
animeId: listID,
episodeNumber: watchedCount,
status: statusToSend
) { result in
switch result {
case .success:
Logger.shared.log(
"AniList bulksync: set progress to \(watchedCount) (\(statusToSend))",
type: "General"
)
case .failure(let error):
Logger.shared.log(
"AniList bulksync failed: \(error.localizedDescription)",
type: "Error"
)
}
}
}
@ViewBuilder
private var noEpisodesSection: some View {
VStack(spacing: 8) {
Image(systemName: "tv.slash")
.font(.system(size: 48))
.foregroundColor(.secondary)
Text("No Episodes Available")
.font(.title2)
.fontWeight(.semibold)
.foregroundColor(.primary)
Text("Episodes might not be available yet or there could be an issue with the source.")
.font(.body)
.lineLimit(0)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
}
.padding(.vertical, 50)
}
private func playFirstUnwatchedEpisode() {
for ep in episodeLinks {
let last = UserDefaults.standard.double(forKey: "lastPlayedTime_\(ep.href)")
let total = UserDefaults.standard.double(forKey: "totalTime_\(ep.href)")
let progress = total > 0 ? last / total : 0
if progress < 0.9 {
selectedEpisodeNumber = ep.number
fetchStream(href: ep.href)
return
}
}
if let first = episodeLinks.first {
selectedEpisodeNumber = first.number
fetchStream(href: first.href)
}
}
private func finishedAndUnfinishedIndices() -> (finished: Int?, unfinished: Int?) {
var finishedIndex: Int? = nil
var firstUnfinishedIndex: Int? = nil
for (index, ep) in episodeLinks.enumerated() {
let keyLast = "lastPlayedTime_\(ep.href)"
let keyTotal = "totalTime_\(ep.href)"
let lastPlayedTime = UserDefaults.standard.double(forKey: keyLast)
let totalTime = UserDefaults.standard.double(forKey: keyTotal)
guard totalTime > 0 else { continue }
let remainingFraction = (totalTime - lastPlayedTime) / totalTime
if remainingFraction <= 0.1 {
finishedIndex = index
} else if firstUnfinishedIndex == nil {
firstUnfinishedIndex = index
}
}
return (finishedIndex, firstUnfinishedIndex)
}
private func generateRanges() -> [Range<Int>] {
let chunkSize = episodeChunkSize
let totalEpisodes = episodeLinks.count
var ranges: [Range<Int>] = []
for i in stride(from: 0, to: totalEpisodes, by: chunkSize) {
let end = min(i + chunkSize, totalEpisodes)
ranges.append(i..<end)
}
return ranges
}
private func groupedEpisodes() -> [[EpisodeLink]] {
guard !episodeLinks.isEmpty else { return [] }
var groups: [[EpisodeLink]] = []
var currentGroup: [EpisodeLink] = [episodeLinks[0]]
for ep in episodeLinks.dropFirst() {
if let last = currentGroup.last, ep.number < last.number {
groups.append(currentGroup)
currentGroup = [ep]
} else {
currentGroup.append(ep)
}
}
groups.append(currentGroup)
return groups
}
func fetchDetails() {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
Task {
do {
let jsContent = try moduleManager.getModuleContent(module)
jsController.loadScript(jsContent)
if module.metadata.asyncJS == true {
jsController.fetchDetailsJS(url: href) { items, episodes in
if let item = items.first {
self.synopsis = item.description
self.aliases = item.aliases
self.airdate = item.airdate
}
self.episodeLinks = episodes
self.isLoading = false
self.isRefetching = false
}
} else {
jsController.fetchDetails(url: href) { items, episodes in
if let item = items.first {
self.synopsis = item.description
self.aliases = item.aliases
self.airdate = item.airdate
}
self.episodeLinks = episodes
self.isLoading = false
self.isRefetching = false
}
}
} catch {
Logger.shared.log("Error loading module: \(error)", type: "Error")
self.isLoading = false
self.isRefetching = false
}
}
}
}
func fetchStream(href: String) {
let fetchID = UUID()
activeFetchID = fetchID
currentStreamTitle = "Episode \(selectedEpisodeNumber)"
showLoadingAlert = true
isFetchingEpisode = true
let completion: ((streams: [String]?, subtitles: [String]?, sources: [[String: Any]]?)) -> Void = { result in
guard self.activeFetchID == fetchID else {
return
}
self.showLoadingAlert = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
guard self.activeFetchID == fetchID else {
return
}
if let sources = result.sources, !sources.isEmpty {
if sources.count > 1 {
self.showStreamSelectionAlert(sources: sources, fullURL: href, subtitles: result.subtitles?.first, fetchID: fetchID)
} else if let streamUrl = sources[0]["streamUrl"] as? String {
let headers = sources[0]["headers"] as? [String: String]
self.playStream(url: streamUrl, fullURL: href, subtitles: result.subtitles?.first, headers: headers, fetchID: fetchID)
} else {
self.handleStreamFailure(error: nil)
}
} else if let streams = result.streams, !streams.isEmpty {
if streams.count > 1 {
self.showStreamSelectionAlert(sources: streams, fullURL: href, subtitles: result.subtitles?.first, fetchID: fetchID)
} else {
self.playStream(url: streams[0], fullURL: href, subtitles: result.subtitles?.first, fetchID: fetchID)
}
} else {
self.handleStreamFailure(error: nil)
}
DispatchQueue.main.async {
self.isFetchingEpisode = false
}
}
}
Task {
do {
let jsContent = try moduleManager.getModuleContent(module)
jsController.loadScript(jsContent)
if module.metadata.asyncJS == true {
jsController.fetchStreamUrlJS(episodeUrl: href, softsub: module.metadata.softsub == true, module: module, completion: completion)
} else if module.metadata.streamAsyncJS == true {
jsController.fetchStreamUrlJSSecond(episodeUrl: href, softsub: module.metadata.softsub == true, module: module, completion: completion)
} else {
jsController.fetchStreamUrl(episodeUrl: href, softsub: module.metadata.softsub == true, module: module, completion: completion)
}
} catch {
self.handleStreamFailure(error: error)
DispatchQueue.main.async {
self.isFetchingEpisode = false
}
}
}
}
private func handleStreamFailure(error: Error?) {
DispatchQueue.main.async {
self.showLoadingAlert = false
if let error = error {
Logger.shared.log("Error loading module: \(error)", type: "Error")
AnalyticsManager.shared.sendEvent(event: "error", additionalData: ["error": error, "message": "Failed to fetch stream"])
}
DropManager.shared.showDrop(title: "Stream not Found", subtitle: "", duration: 0.5, icon: UIImage(systemName: "xmark"))
UINotificationFeedbackGenerator().notificationOccurred(.error)
self.isLoading = false
}
}
func showStreamSelectionAlert(sources: [Any], fullURL: String, subtitles: String? = nil, fetchID: UUID) {
guard self.activeFetchID == fetchID else {
return
}
self.isFetchingEpisode = false
self.showLoadingAlert = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
guard self.activeFetchID == fetchID else {
return
}
let alert = UIAlertController(title: "Select Server", message: "Choose a server to play from", preferredStyle: .actionSheet)
var index = 0
var streamIndex = 1
while index < sources.count {
var title: String = ""
var streamUrl: String = ""
var headers: [String:String]? = nil
if let sources = sources as? [String] {
if index + 1 < sources.count {
if !sources[index].lowercased().contains("http") {
title = sources[index]
streamUrl = sources[index + 1]
index += 2
} else {
title = "Stream \(streamIndex)"
streamUrl = sources[index]
index += 1
}
} else {
title = "Stream \(streamIndex)"
streamUrl = sources[index]
index += 1
}
} else if let sources = sources as? [[String: Any]] {
if let currTitle = sources[index]["title"] as? String {
title = currTitle
streamUrl = (sources[index]["streamUrl"] as? String) ?? ""
} else {
title = "Stream \(streamIndex)"
streamUrl = (sources[index]["streamUrl"] as? String)!
}
headers = sources[index]["headers"] as? [String:String] ?? [:]
index += 1
}
alert.addAction(UIAlertAction(title: title, style: .default) { _ in
guard self.activeFetchID == fetchID else {
return
}
self.playStream(url: streamUrl, fullURL: fullURL, subtitles: subtitles, headers: headers, fetchID: fetchID)
})
streamIndex += 1
}
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first,
let rootVC = window.rootViewController {
if UIDevice.current.userInterfaceIdiom == .pad {
if let popover = alert.popoverPresentationController {
popover.sourceView = window
popover.sourceRect = CGRect(
x: UIScreen.main.bounds.width / 2,
y: UIScreen.main.bounds.height / 2,
width: 0,
height: 0
)
popover.permittedArrowDirections = []
}
}
findTopViewController.findViewController(rootVC).present(alert, animated: true)
}
DispatchQueue.main.async {
self.isFetchingEpisode = false
}
}
}
func playStream(url: String, fullURL: String, subtitles: String? = nil, headers: [String:String]? = nil, fetchID: UUID) {
guard self.activeFetchID == fetchID else {
return
}
self.isFetchingEpisode = false
self.showLoadingAlert = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
guard self.activeFetchID == fetchID else {
return
}
let externalPlayer = UserDefaults.standard.string(forKey: "externalPlayer") ?? "Sora"
var scheme: String?
switch externalPlayer {
case "Infuse":
scheme = "infuse://x-callback-url/play?url=\(url)"
case "VLC":
scheme = "vlc://\(url)"
case "OutPlayer":
scheme = "outplayer://\(url)"
case "nPlayer":
scheme = "nplayer-\(url)"
case "SenPlayer":
scheme = "senplayer://x-callback-url/play?url=\(url)"
case "IINA":
scheme = "iina://weblink?url=\(url)"
case "Default":
let videoPlayerViewController = VideoPlayerViewController(module: module)
videoPlayerViewController.headers = headers
videoPlayerViewController.streamUrl = url
videoPlayerViewController.fullUrl = fullURL
videoPlayerViewController.episodeNumber = selectedEpisodeNumber
videoPlayerViewController.episodeImageUrl = selectedEpisodeImage
videoPlayerViewController.mediaTitle = title
videoPlayerViewController.aniListID = itemID ?? 0
videoPlayerViewController.modalPresentationStyle = .fullScreen
presentPlayerWithDetachedContext(videoPlayerViewController: videoPlayerViewController)
return
default:
break
}
if let scheme = scheme, let url = URL(string: scheme), UIApplication.shared.canOpenURL(url) {
UIApplication.shared.open(url, options: [:], completionHandler: nil)
Logger.shared.log("Opening external app with scheme: \(url)", type: "General")
} else {
guard let url = URL(string: url) else {
Logger.shared.log("Invalid stream URL: \(url)", type: "Error")
DropManager.shared.showDrop(title: "Error", subtitle: "Invalid stream URL", duration: 2.0, icon: UIImage(systemName: "xmark.circle"))
return
}
guard self.activeFetchID == fetchID else {
return
}
let customMediaPlayer = CustomMediaPlayerViewController(
module: module,
urlString: url.absoluteString,
fullUrl: fullURL,
title: title,
episodeNumber: selectedEpisodeNumber,
onWatchNext: {
selectNextEpisode()
},
subtitlesURL: subtitles,
aniListID: itemID ?? 0,
totalEpisodes: episodeLinks.count,
episodeImageUrl: selectedEpisodeImage,
headers: headers ?? nil
)
customMediaPlayer.modalPresentationStyle = .fullScreen
Logger.shared.log("Opening custom media player with url: \(url)")
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let rootVC = windowScene.windows.first?.rootViewController {
rootVC.present(customMediaPlayer, animated: true, completion: nil)
} else {
Logger.shared.log("Failed to find root view controller", type: "Error")
DropManager.shared.showDrop(title: "Error", subtitle: "Failed to present player", duration: 2.0, icon: UIImage(systemName: "xmark.circle"))
}
}
}
}
private func selectNextEpisode() {
guard let currentIndex = episodeLinks.firstIndex(where: { $0.number == selectedEpisodeNumber }),
currentIndex + 1 < episodeLinks.count else {
Logger.shared.log("No more episodes to play", type: "Info")
return
}
let nextEpisode = episodeLinks[currentIndex + 1]
selectedEpisodeNumber = nextEpisode.number
fetchStream(href: nextEpisode.href)
DropManager.shared.showDrop(title: "Fetching Next Episode", subtitle: "", duration: 0.5, icon: UIImage(systemName: "arrow.triangle.2.circlepath"))
}
private func openSafariViewController(with urlString: String) {
guard let url = URL(string: urlString), UIApplication.shared.canOpenURL(url) else {
Logger.shared.log("Unable to open the webpage", type: "Error")
return
}
let safariViewController = SFSafariViewController(url: url)
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let rootVC = windowScene.windows.first?.rootViewController {
rootVC.present(safariViewController, animated: true, completion: nil)
}
}
private func cleanTitle(_ title: String?) -> String {
guard let title = title else { return "Unknown" }
let cleaned = title.replacingOccurrences(
of: "\\s*\\([^\\)]*\\)",
with: "",
options: .regularExpression
).trimmingCharacters(in: .whitespaces)
return cleaned.isEmpty ? "Unknown" : cleaned
}
private func fetchItemID(byTitle title: String, completion: @escaping (Result<Int, Error>) -> Void) {
let query = """
query {
Media(search: "\(title)", type: ANIME) {
id
}
}
"""
guard let url = URL(string: "https://graphql.anilist.co") else {
completion(.failure(NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: "Invalid URL"])))
return
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let parameters: [String: Any] = ["query": query]
request.httpBody = try? JSONSerialization.data(withJSONObject: parameters)
URLSession.custom.dataTask(with: request) { data, _, error in
if let error = error {
completion(.failure(error))
return
}
guard let data = data else {
completion(.failure(NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: "No data received"])))
return
}
do {
if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
let data = json["data"] as? [String: Any],
let media = data["Media"] as? [String: Any],
let id = media["id"] as? Int {
completion(.success(id))
} else {
let error = NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: "Invalid response"])
completion(.failure(error))
}
} catch {
completion(.failure(error))
}
}.resume()
}
private func showCustomIDAlert() {
let alert = UIAlertController(title: "Set Custom AniList ID", message: "Enter the AniList ID for this media", preferredStyle: .alert)
alert.addTextField { textField in
textField.placeholder = "AniList ID"
textField.keyboardType = .numberPad
if let customID = customAniListID {
textField.text = "\(customID)"
}
}
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alert.addAction(UIAlertAction(title: "Save", style: .default) { _ in
if let text = alert.textFields?.first?.text,
let id = Int(text) {
customAniListID = id
itemID = id
UserDefaults.standard.set(id, forKey: "custom_anilist_id_\(href)")
Logger.shared.log("Set custom AniList ID: \(id)", type: "General")
self.fetchDetails()
}
})
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first,
let rootVC = window.rootViewController {
findTopViewController.findViewController(rootVC).present(alert, animated: true)
}
}
private func selectEpisodeRange(start: Int, end: Int) {
selectedEpisodes.removeAll()
for episodeNumber in start...end {
selectedEpisodes.insert(episodeNumber)
}
showRangeInput = false
}
private func selectAllVisibleEpisodes() {
if isGroupedBySeasons {
let seasons = groupedEpisodes()
if !seasons.isEmpty, selectedSeason < seasons.count {
for episode in seasons[selectedSeason] {
selectedEpisodes.insert(episode.number)
}
}
} else {
for i in episodeLinks.indices.filter({ selectedRange.contains($0) }) {
selectedEpisodes.insert(episodeLinks[i].number)
}
}
}
private func startBulkDownload() {
guard !selectedEpisodes.isEmpty else { return }
isBulkDownloading = true
bulkDownloadProgress = "Starting downloads..."
let episodesToDownload = episodeLinks.filter { selectedEpisodes.contains($0.number) }
Task {
await processBulkDownload(episodes: episodesToDownload)
}
}
@MainActor
private func processBulkDownload(episodes: [EpisodeLink]) async {
let totalCount = episodes.count
var completedCount = 0
var successCount = 0
for (index, episode) in episodes.enumerated() {
bulkDownloadProgress = "Downloading episode \(episode.number) (\(index + 1)/\(totalCount))"
let downloadStatus = jsController.isEpisodeDownloadedOrInProgress(
showTitle: title,
episodeNumber: episode.number,
season: 1
)
switch downloadStatus {
case .downloaded:
Logger.shared.log("Episode \(episode.number) already downloaded, skipping", type: "Info")
case .downloading:
Logger.shared.log("Episode \(episode.number) already downloading, skipping", type: "Info")
case .notDownloaded:
let downloadSuccess = await downloadSingleEpisode(episode: episode)
if downloadSuccess {
successCount += 1
}
}
completedCount += 1
try? await Task.sleep(nanoseconds: 500_000_000)
}
isBulkDownloading = false
bulkDownloadProgress = ""
isMultiSelectMode = false
selectedEpisodes.removeAll()
DropManager.shared.showDrop(
title: "Bulk Download Complete",
subtitle: "\(successCount)/\(totalCount) episodes queued for download",
duration: 2.0,
icon: UIImage(systemName: successCount == totalCount ? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
)
}
private func downloadSingleEpisode(episode: EpisodeLink) async -> Bool {
return await withCheckedContinuation { continuation in
Task {
do {
let jsContent = try moduleManager.getModuleContent(module)
jsController.loadScript(jsContent)
self.tryNextDownloadMethodForBulk(
episode: episode,
methodIndex: 0,
softsub: module.metadata.softsub == true,
continuation: continuation
)
} catch {
Logger.shared.log("Error downloading episode \(episode.number): \(error)", type: "Error")
continuation.resume(returning: false)
}
}
}
}
private func tryNextDownloadMethodForBulk(
episode: EpisodeLink,
methodIndex: Int,
softsub: Bool,
continuation: CheckedContinuation<Bool, Never>
) {
print("[Bulk Download] Trying download method #\(methodIndex+1) for Episode \(episode.number)")
switch methodIndex {
case 0:
if module.metadata.asyncJS == true {
jsController.fetchStreamUrlJS(episodeUrl: episode.href, softsub: softsub, module: module) { result in
self.handleBulkDownloadResult(result, episode: episode, methodIndex: methodIndex, softsub: softsub, continuation: continuation)
}
} else {
tryNextDownloadMethodForBulk(episode: episode, methodIndex: methodIndex + 1, softsub: softsub, continuation: continuation)
}
case 1:
if module.metadata.streamAsyncJS == true {
jsController.fetchStreamUrlJSSecond(episodeUrl: episode.href, softsub: softsub, module: module) { result in
self.handleBulkDownloadResult(result, episode: episode, methodIndex: methodIndex, softsub: softsub, continuation: continuation)
}
} else {
tryNextDownloadMethodForBulk(episode: episode, methodIndex: methodIndex + 1, softsub: softsub, continuation: continuation)
}
case 2:
jsController.fetchStreamUrl(episodeUrl: episode.href, softsub: softsub, module: module) { result in
self.handleBulkDownloadResult(result, episode: episode, methodIndex: methodIndex, softsub: softsub, continuation: continuation)
}
default:
Logger.shared.log("Failed to find a valid stream for bulk download after trying all methods", type: "Error")
continuation.resume(returning: false)
}
}
private func handleBulkDownloadResult(_ result: (streams: [String]?, subtitles: [String]?, sources: [[String:Any]]?), episode: EpisodeLink, methodIndex: Int, softsub: Bool, continuation: CheckedContinuation<Bool, Never>) {
// Handle sources with headers first (more complex format)
if let sources = result.sources, !sources.isEmpty {
if sources.count > 1 {
// Multiple sources available - show selection dialog
print("[Bulk Download] Method #\(methodIndex+1) returned \(sources.count) stream sources, showing selection")
showBulkDownloadStreamSelectionAlert(sources: sources, episode: episode, continuation: continuation)
return
} else if let streamUrl = sources[0]["streamUrl"] as? String, let url = URL(string: streamUrl) {
// Single source - proceed directly
print("[Bulk Download] Method #\(methodIndex+1) returned valid stream URL with headers: \(streamUrl)")
let subtitleURLString = sources[0]["subtitle"] as? String
let subtitleURL = subtitleURLString.flatMap { URL(string: $0) }
if let subtitleURL = subtitleURL {
print("[Bulk Download] Found subtitle URL: \(subtitleURL.absoluteString)")
}
startEpisodeDownloadWithProcessedStream(episode: episode, url: url, streamUrl: streamUrl, subtitleURL: subtitleURL)
continuation.resume(returning: true)
return
}
}
// Handle simple streams array
if let streams = result.streams, !streams.isEmpty {
// Check for Promise object
if streams[0] == "[object Promise]" {
print("[Bulk Download] Method #\(methodIndex+1) returned a Promise object, trying next method")
tryNextDownloadMethodForBulk(episode: episode, methodIndex: methodIndex + 1, softsub: softsub, continuation: continuation)
return
}
if streams.count > 1 {
// Multiple streams available - show selection dialog
print("[Bulk Download] Method #\(methodIndex+1) returned \(streams.count) streams, showing selection")
showBulkDownloadStreamSelectionAlert(sources: streams, episode: episode, continuation: continuation)
return
} else if let url = URL(string: streams[0]) {
// Single stream - proceed directly
print("[Bulk Download] Method #\(methodIndex+1) returned valid stream URL: \(streams[0])")
let subtitleURL = result.subtitles?.first.flatMap { URL(string: $0) }
if let subtitleURL = subtitleURL {
print("[Bulk Download] Found subtitle URL: \(subtitleURL.absoluteString)")
}
startEpisodeDownloadWithProcessedStream(episode: episode, url: url, streamUrl: streams[0], subtitleURL: subtitleURL)
continuation.resume(returning: true)
return
}
}
// No valid streams found - try next method
print("[Bulk Download] Method #\(methodIndex+1) did not return valid streams, trying next method")
tryNextDownloadMethodForBulk(episode: episode, methodIndex: methodIndex + 1, softsub: softsub, continuation: continuation)
}
private func showBulkDownloadStreamSelectionAlert(sources: [Any], episode: EpisodeLink, continuation: CheckedContinuation<Bool, Never>) {
DispatchQueue.main.async {
let alert = UIAlertController(title: "Select Download Server", message: "Choose a server to download Episode \(episode.number) from", preferredStyle: .actionSheet)
var index = 0
var streamIndex = 1
while index < sources.count {
var title: String = ""
var streamUrl: String = ""
var headers: [String:String]? = nil
if let sources = sources as? [String] {
// Parse string array format
if index + 1 < sources.count {
if !sources[index].lowercased().contains("http") {
title = sources[index] // First element is title
streamUrl = sources[index + 1] // Second is URL
index += 2
} else {
title = "Server \(streamIndex)"
streamUrl = sources[index]
index += 1
}
} else {
title = "Server \(streamIndex)"
streamUrl = sources[index]
index += 1
}
} else if let sources = sources as? [[String: Any]] {
// Parse object array format
if let currTitle = sources[index]["title"] as? String {
title = currTitle
} else {
title = "Server \(streamIndex)"
}
streamUrl = (sources[index]["streamUrl"] as? String) ?? ""
index += 1
}
alert.addAction(UIAlertAction(title: title, style: .default) { _ in
guard let url = URL(string: streamUrl) else {
DropManager.shared.error("Invalid stream URL selected")
continuation.resume(returning: false)
return
}
// Extract subtitle URL from the sources if available
var subtitleURL: URL? = nil
if let sources = sources as? [[String: Any]],
let subtitleURLString = sources[index-1]["subtitle"] as? String {
subtitleURL = URL(string: subtitleURLString)
}
self.startEpisodeDownloadWithProcessedStream(episode: episode, url: url, streamUrl: streamUrl, subtitleURL: subtitleURL)
continuation.resume(returning: true)
})
streamIndex += 1
}
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in
continuation.resume(returning: false)
})
// Present the alert
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first,
let rootVC = window.rootViewController {
if UIDevice.current.userInterfaceIdiom == .pad {
if let popover = alert.popoverPresentationController {
popover.sourceView = window
popover.sourceRect = CGRect(
x: UIScreen.main.bounds.width / 2,
y: UIScreen.main.bounds.height / 2,
width: 0,
height: 0
)
popover.permittedArrowDirections = []
}
}
findTopViewController.findViewController(rootVC).present(alert, animated: true)
}
}
}
private func startEpisodeDownloadWithProcessedStream(episode: EpisodeLink, url: URL, streamUrl: String, subtitleURL: URL? = nil) {
var headers: [String: String] = [:]
if !module.metadata.baseUrl.isEmpty && !module.metadata.baseUrl.contains("undefined") {
print("Using module baseUrl: \(module.metadata.baseUrl)")
headers = [
"Origin": module.metadata.baseUrl,
"Referer": module.metadata.baseUrl,
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36",
"Accept": "*/*",
"Accept-Language": "en-US,en;q=0.9",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-origin"
]
} else {
if let scheme = url.scheme, let host = url.host {
let baseUrl = scheme + "://" + host
headers = [
"Origin": baseUrl,
"Referer": baseUrl,
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36",
"Accept": "*/*",
"Accept-Language": "en-US,en;q=0.9",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-origin"
]
} else {
headers = [
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36"
]
Logger.shared.log("Warning: Missing URL scheme/host for episode \(episode.number), using minimal headers", type: "Warning")
}
}
print("Bulk download headers: \(headers)")
fetchEpisodeMetadataForDownload(episode: episode) { metadata in
let episodeImageUrl = metadata?.imageUrl ?? ""
let episodeName = metadata?.title["en"] ?? "Episode \(episode.number)"
let fullEpisodeTitle = episodeName
let episodeThumbnailURL: URL?
if !episodeImageUrl.isEmpty {
episodeThumbnailURL = URL(string: episodeImageUrl)
} else {
episodeThumbnailURL = URL(string: self.getBannerImageBasedOnAppearance())
}
let showPosterImageURL = URL(string: self.imageUrl)
print("[Bulk Download] Using episode metadata - Title: '\(fullEpisodeTitle)', Image: '\(episodeImageUrl.isEmpty ? "default banner" : episodeImageUrl)'")
self.jsController.downloadWithStreamTypeSupport(
url: url,
headers: headers,
title: fullEpisodeTitle,
imageURL: episodeThumbnailURL,
module: self.module,
isEpisode: true,
showTitle: self.title,
season: 1,
episode: episode.number,
subtitleURL: subtitleURL,
showPosterURL: showPosterImageURL,
completionHandler: { success, message in
if success {
Logger.shared.log("Queued download for Episode \(episode.number) with metadata", type: "Download")
} else {
Logger.shared.log("Failed to queue download for Episode \(episode.number): \(message)", type: "Error")
}
}
)
}
}
private func fetchEpisodeMetadataForDownload(episode: EpisodeLink, completion: @escaping (EpisodeMetadataInfo?) -> Void) {
guard let anilistId = itemID else {
Logger.shared.log("No AniList ID available for episode metadata", type: "Warning")
completion(nil)
return
}
fetchEpisodeMetadataFromNetwork(anilistId: anilistId, episodeNumber: episode.number, completion: completion)
}
private func fetchEpisodeMetadataFromNetwork(anilistId: Int, episodeNumber: Int, completion: @escaping (EpisodeMetadataInfo?) -> Void) {
guard let url = URL(string: "https://api.ani.zip/mappings?anilist_id=\(anilistId)") else {
Logger.shared.log("Invalid URL for anilistId: \(anilistId)", type: "Error")
completion(nil)
return
}
print("[Bulk Download] Fetching metadata for episode \(episodeNumber) from network")
URLSession.custom.dataTask(with: url) { data, response, error in
if let error = error {
Logger.shared.log("Failed to fetch episode metadata: \(error)", type: "Error")
completion(nil)
return
}
guard let data = data else {
Logger.shared.log("No data received for episode metadata", type: "Error")
completion(nil)
return
}
do {
let jsonObject = try JSONSerialization.jsonObject(with: data, options: [])
guard let json = jsonObject as? [String: Any] else {
Logger.shared.log("Invalid JSON format for episode metadata", type: "Error")
completion(nil)
return
}
guard let episodes = json["episodes"] as? [String: Any] else {
Logger.shared.log("Missing 'episodes' object in metadata response", type: "Error")
completion(nil)
return
}
let episodeKey = "\(episodeNumber)"
guard let episodeDetails = episodes[episodeKey] as? [String: Any] else {
Logger.shared.log("Episode \(episodeKey) not found in metadata response", type: "Warning")
completion(nil)
return
}
var title: [String: String] = [:]
var image: String = ""
if let titleData = episodeDetails["title"] as? [String: String], !titleData.isEmpty {
title = titleData
} else {
title = ["en": "Episode \(episodeNumber)"]
}
if let imageUrl = episodeDetails["image"] as? String, !imageUrl.isEmpty {
image = imageUrl
}
let metadataInfo = EpisodeMetadataInfo(
title: title,
imageUrl: image,
anilistId: anilistId,
episodeNumber: episodeNumber
)
print("[Bulk Download] Fetched metadata for episode \(episodeNumber): title='\(title["en"] ?? "N/A")', hasImage=\(!image.isEmpty)")
completion(metadataInfo)
} catch {
Logger.shared.log("JSON parsing error for episode metadata: \(error.localizedDescription)", type: "Error")
completion(nil)
}
}.resume()
}
private func presentPlayerWithDetachedContext(videoPlayerViewController: VideoPlayerViewController) {
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { return }
let detachedWindow = UIWindow(windowScene: windowScene)
let hostingController = UIViewController()
hostingController.view.backgroundColor = .clear
detachedWindow.rootViewController = hostingController
detachedWindow.backgroundColor = .clear
detachedWindow.windowLevel = .normal + 1
detachedWindow.makeKeyAndVisible()
videoPlayerViewController.detachedWindow = detachedWindow
hostingController.present(videoPlayerViewController, animated: true, completion: nil)
}
}