fixed stuffs idk

This commit is contained in:
Francesco 2025-05-23 19:50:59 +02:00
parent c85b6690da
commit 21a08e4aa3
10 changed files with 293 additions and 539 deletions

View file

@ -8,32 +8,6 @@
import SwiftUI
import UIKit
// Add missing extension for UserDefaults
extension UserDefaults {
func color(forKey key: String) -> UIColor? {
guard let colorData = data(forKey: key) else { return nil }
do {
return try NSKeyedUnarchiver.unarchivedObject(ofClass: UIColor.self, from: colorData)
} catch {
return nil
}
}
func set(_ color: UIColor?, forKey key: String) {
guard let color = color else {
removeObject(forKey: key)
return
}
do {
let data = try NSKeyedArchiver.archivedData(withRootObject: color, requiringSecureCoding: false)
set(data, forKey: key)
} catch {
print("Error archiving color: \(error)")
}
}
}
@main
struct SoraApp: App {
@StateObject private var settings = Settings()
@ -43,7 +17,6 @@ struct SoraApp: App {
@StateObject private var jsController = JSController.shared
init() {
// Initialize caching systems
_ = MetadataCacheManager.shared
_ = KingfisherCacheManager.shared

View file

@ -2,7 +2,7 @@
// UserDefaults.swift
// Sulfur
//
// Created by Francesco on 11/05/25.
// Created by Francesco on 23/05/25.
//
import UIKit

View file

@ -1139,24 +1139,23 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
}
private func setupLockButton() {
// copy dim-button styling
let cfg = UIImage.SymbolConfiguration(pointSize: 24, weight: .regular)
lockButton = UIButton(type: .system)
lockButton.setImage(
UIImage(systemName: "lock.open.fill", withConfiguration: cfg),
for: .normal
)
lockButton.tintColor = .white
lockButton.layer.shadowColor = UIColor.black.cgColor
lockButton.layer.shadowOffset = CGSize(width: 0, height: 2)
lockButton.layer.shadowOpacity = 0.6
lockButton.layer.shadowRadius = 4
lockButton.layer.masksToBounds = false
lockButton.addTarget(self, action: #selector(lockTapped), for: .touchUpInside)
view.addSubview(lockButton)
lockButton.translatesAutoresizingMaskIntoConstraints = false
let cfg = UIImage.SymbolConfiguration(pointSize: 24, weight: .regular)
lockButton = UIButton(type: .system)
lockButton.setImage(
UIImage(systemName: "lock.open.fill", withConfiguration: cfg),
for: .normal
)
lockButton.tintColor = .white
lockButton.layer.shadowColor = UIColor.black.cgColor
lockButton.layer.shadowOffset = CGSize(width: 0, height: 2)
lockButton.layer.shadowOpacity = 0.6
lockButton.layer.shadowRadius = 4
lockButton.layer.masksToBounds = false
lockButton.addTarget(self, action: #selector(lockTapped), for: .touchUpInside)
view.addSubview(lockButton)
lockButton.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
lockButton.topAnchor.constraint(equalTo: volumeSliderHostingView!.bottomAnchor, constant: 60),
lockButton.trailingAnchor.constraint(equalTo: volumeSliderHostingView!.trailingAnchor),
@ -1531,34 +1530,33 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
updateSkipButtonsVisibility()
return
}
if isDimmed {
// show the dim button
dimButton.isHidden = false
dimButton.alpha = 1.0
dimButtonTimer?.invalidate()
dimButtonTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { [weak self] _ in
UIView.animate(withDuration: 0.3) {
self?.dimButton.alpha = 0
}
dimButton.isHidden = false
dimButton.alpha = 1.0
dimButtonTimer?.invalidate()
dimButtonTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { [weak self] _ in
UIView.animate(withDuration: 0.3) {
self?.dimButton.alpha = 0
}
updateSkipButtonsVisibility()
return
}
updateSkipButtonsVisibility()
return
}
isControlsVisible.toggle()
UIView.animate(withDuration: 0.2) {
let alpha: CGFloat = self.isControlsVisible ? 1.0 : 0.0
self.controlsContainerView.alpha = alpha
self.skip85Button.alpha = alpha
self.lockButton.alpha = alpha // Fade lock button with controls
self.subtitleBottomToSafeAreaConstraint?.isActive = !self.isControlsVisible
self.subtitleBottomToSliderConstraint?.isActive = self.isControlsVisible
self.view.layoutIfNeeded()
}
updateSkipButtonsVisibility()
}
UIView.animate(withDuration: 0.2) {
let alpha: CGFloat = self.isControlsVisible ? 1.0 : 0.0
self.controlsContainerView.alpha = alpha
self.skip85Button.alpha = alpha
self.lockButton.alpha = alpha
self.subtitleBottomToSafeAreaConstraint?.isActive = !self.isControlsVisible
self.subtitleBottomToSliderConstraint?.isActive = self.isControlsVisible
self.view.layoutIfNeeded()
}
updateSkipButtonsVisibility()
}
@objc func seekBackwardLongPress(_ gesture: UILongPressGestureRecognizer) {
if gesture.state == .began {
@ -1645,10 +1643,10 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
@objc private func lockTapped() {
controlsLocked.toggle()
isControlsVisible = !controlsLocked
lockButtonTimer?.invalidate()
if controlsLocked {
UIView.animate(withDuration: 0.25) {
self.controlsContainerView.alpha = 0
@ -1658,13 +1656,13 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
self.skipOutroButton.alpha = 0
self.skip85Button.alpha = 0
self.lockButton.alpha = 0
self.subtitleBottomToSafeAreaConstraint?.isActive = true
self.subtitleBottomToSliderConstraint?.isActive = false
self.view.layoutIfNeeded()
}
lockButton.setImage(UIImage(systemName: "lock.fill"), for: .normal)
} else {
@ -1672,13 +1670,13 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
self.controlsContainerView.alpha = 1
self.dimButton.alpha = 1
for v in self.controlsToHide { v.alpha = 1 }
self.subtitleBottomToSafeAreaConstraint?.isActive = false
self.subtitleBottomToSliderConstraint?.isActive = true
self.view.layoutIfNeeded()
}
lockButton.setImage(UIImage(systemName: "lock.open.fill"), for: .normal)
updateSkipButtonsVisibility()
}
@ -1733,14 +1731,14 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
for v in self.controlsToHide { v.alpha = self.isDimmed ? 0 : 1 }
self.dimButton.alpha = self.isDimmed ? 0 : 1
self.lockButton.alpha = self.isDimmed ? 0 : 1
// switch subtitle constraints just like toggleControls()
self.subtitleBottomToSafeAreaConstraint?.isActive = !self.isControlsVisible
self.subtitleBottomToSliderConstraint?.isActive = self.isControlsVisible
self.view.layoutIfNeeded()
}
// slide the dim-icon over
dimButtonToSlider.isActive = !isDimmed
dimButtonToRight.isActive = isDimmed

View file

@ -28,7 +28,6 @@ struct DownloadView: View {
var body: some View {
NavigationView {
VStack(spacing: 0) {
// Tab selector
Picker("Download Status", selection: $selectedTab) {
Text("Active").tag(0)
Text("Downloaded").tag(1)
@ -37,10 +36,9 @@ struct DownloadView: View {
.padding(.horizontal)
.padding(.top, 8)
// Content
if selectedTab == 0 {
activeDownloadsView
} else {
} else {
downloadedContentView
}
}
@ -69,10 +67,9 @@ struct DownloadView: View {
Text("Are you sure you want to delete '\(asset.episodeDisplayName)'?")
}
}
}
}
}
}
// MARK: - Active Downloads View
private var activeDownloadsView: some View {
Group {
if jsController.activeDownloads.isEmpty && jsController.downloadQueue.isEmpty {
@ -80,13 +77,11 @@ struct DownloadView: View {
} else {
ScrollView {
LazyVStack(spacing: 12) {
// Show queued downloads first
ForEach(jsController.downloadQueue) { download in
ActiveDownloadCard(download: download)
.padding(.horizontal)
}
// Then show active downloads
ForEach(jsController.activeDownloads) { download in
ActiveDownloadCard(download: download)
.padding(.horizontal)
@ -98,33 +93,31 @@ struct DownloadView: View {
}
}
// MARK: - Downloaded Content View
private var downloadedContentView: some View {
Group {
if filteredAndSortedAssets.isEmpty {
emptyDownloadsView
} else {
ScrollView {
ScrollView {
LazyVStack(spacing: 12) {
ForEach(groupedAssets, id: \.title) { group in
DownloadGroupCard(
group: group,
group: group,
onDelete: { asset in
assetToDelete = asset
showDeleteAlert = true
},
onPlay: playAsset
)
.padding(.horizontal)
}
}
.padding(.horizontal)
}
}
.padding(.vertical)
}
}
}
}
// MARK: - Empty States
private var emptyActiveDownloadsView: some View {
VStack {
Image(systemName: "arrow.down.circle")
@ -165,14 +158,13 @@ struct DownloadView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
// MARK: - Data Processing
private var filteredAndSortedAssets: [DownloadedAsset] {
let filtered = searchText.isEmpty
? jsController.savedAssets
: jsController.savedAssets.filter { asset in
asset.name.localizedCaseInsensitiveContains(searchText) ||
(asset.metadata?.showTitle?.localizedCaseInsensitiveContains(searchText) ?? false)
}
let filtered = searchText.isEmpty
? jsController.savedAssets
: jsController.savedAssets.filter { asset in
asset.name.localizedCaseInsensitiveContains(searchText) ||
(asset.metadata?.showTitle?.localizedCaseInsensitiveContains(searchText) ?? false)
}
switch sortOption {
case .newest:
@ -198,80 +190,72 @@ struct DownloadView: View {
}.sorted { $0.title < $1.title }
}
// MARK: - Actions
private func playAsset(_ asset: DownloadedAsset) {
// Verify file exists
guard jsController.verifyAssetFileExists(asset) else { return }
// Determine stream type
let streamType = asset.localURL.pathExtension.lowercased() == "mp4" ? "mp4" : "hls"
// Create dummy metadata for player
let dummyMetadata = ModuleMetadata(
sourceName: "",
author: ModuleMetadata.Author(name: "", icon: ""),
iconUrl: "",
version: "",
language: "",
baseUrl: "",
let dummyMetadata = ModuleMetadata(
sourceName: "",
author: ModuleMetadata.Author(name: "", icon: ""),
iconUrl: "",
version: "",
language: "",
baseUrl: "",
streamType: streamType,
quality: "",
searchBaseUrl: "",
scriptUrl: "",
asyncJS: nil,
streamAsyncJS: nil,
softsub: nil,
multiStream: nil,
multiSubs: nil,
type: nil
)
quality: "",
searchBaseUrl: "",
scriptUrl: "",
asyncJS: nil,
streamAsyncJS: nil,
softsub: nil,
multiStream: nil,
multiSubs: nil,
type: nil
)
let dummyModule = ScrapingModule(
metadata: dummyMetadata,
localPath: "",
metadataUrl: ""
)
if streamType == "mp4" {
let playerItem = AVPlayerItem(url: asset.localURL)
let player = AVPlayer(playerItem: playerItem)
let playerController = AVPlayerViewController()
playerController.player = player
let dummyModule = ScrapingModule(
metadata: dummyMetadata,
localPath: "",
metadataUrl: ""
)
// Present player
if streamType == "mp4" {
// Use system player for MP4
let playerItem = AVPlayerItem(url: asset.localURL)
let player = AVPlayer(playerItem: playerItem)
let playerController = AVPlayerViewController()
playerController.player = player
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let rootViewController = windowScene.windows.first?.rootViewController {
rootViewController.present(playerController, animated: true) {
player.play()
}
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let rootViewController = windowScene.windows.first?.rootViewController {
rootViewController.present(playerController, animated: true) {
player.play()
}
} else {
// Use custom player for HLS
let customPlayer = CustomMediaPlayerViewController(
module: dummyModule,
urlString: asset.localURL.absoluteString,
fullUrl: asset.originalURL.absoluteString,
title: asset.name,
episodeNumber: asset.metadata?.episode ?? 0,
onWatchNext: {},
}
} else {
let customPlayer = CustomMediaPlayerViewController(
module: dummyModule,
urlString: asset.localURL.absoluteString,
fullUrl: asset.originalURL.absoluteString,
title: asset.name,
episodeNumber: asset.metadata?.episode ?? 0,
onWatchNext: {},
subtitlesURL: asset.localSubtitleURL?.absoluteString,
aniListID: 0,
episodeImageUrl: asset.metadata?.posterURL?.absoluteString ?? "",
headers: nil
)
customPlayer.modalPresentationStyle = UIModalPresentationStyle.fullScreen
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let rootViewController = windowScene.windows.first?.rootViewController {
rootViewController.present(customPlayer, animated: true)
}
aniListID: 0,
episodeImageUrl: asset.metadata?.posterURL?.absoluteString ?? "",
headers: nil
)
customPlayer.modalPresentationStyle = UIModalPresentationStyle.fullScreen
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let rootViewController = windowScene.windows.first?.rootViewController {
rootViewController.present(customPlayer, animated: true)
}
}
}
// MARK: - Supporting Types
}
struct SimpleDownloadGroup {
let title: String
let assets: [DownloadedAsset]
@ -279,12 +263,10 @@ struct SimpleDownloadGroup {
var assetCount: Int { assets.count }
var totalFileSize: Int64 {
// Simple summation without complex caching to avoid navigation issues
assets.reduce(0) { $0 + $1.fileSize }
}
}
// MARK: - ActiveDownloadCard
struct ActiveDownloadCard: View {
let download: JSActiveDownload
@State private var currentProgress: Double
@ -298,7 +280,6 @@ struct ActiveDownloadCard: View {
var body: some View {
HStack(spacing: 12) {
// Thumbnail
if let imageURL = download.imageURL {
KFImage(imageURL)
.placeholder {
@ -316,13 +297,11 @@ struct ActiveDownloadCard: View {
.cornerRadius(8)
}
// Content
VStack(alignment: .leading, spacing: 6) {
Text(download.title ?? download.originalURL.lastPathComponent)
.font(.headline)
.lineLimit(1)
// Progress
VStack(alignment: .leading, spacing: 4) {
if download.queueStatus == .queued {
ProgressView()
@ -344,47 +323,46 @@ struct ActiveDownloadCard: View {
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
if taskState == .running {
Text("Downloading")
.font(.caption)
.foregroundColor(.blue)
} else if taskState == .suspended {
Text("Paused")
.font(.caption)
.foregroundColor(.orange)
}
Spacer()
if taskState == .running {
Text("Downloading")
.font(.caption)
.foregroundColor(.blue)
} else if taskState == .suspended {
Text("Paused")
.font(.caption)
.foregroundColor(.orange)
}
}
}
}
Spacer()
// Controls
HStack(spacing: 8) {
if download.queueStatus == .queued {
if download.queueStatus == .queued {
Button(action: cancelDownload) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.red)
.font(.title2)
}
} else {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.red)
.font(.title2)
}
} else {
Button(action: toggleDownload) {
Image(systemName: taskState == .running ? "pause.circle.fill" : "play.circle.fill")
.foregroundColor(taskState == .running ? .orange : .blue)
.font(.title2)
}
Image(systemName: taskState == .running ? "pause.circle.fill" : "play.circle.fill")
.foregroundColor(taskState == .running ? .orange : .blue)
.font(.title2)
}
Button(action: cancelDownload) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.red)
.font(.title2)
Image(systemName: "xmark.circle.fill")
.foregroundColor(.red)
.font(.title2)
}
}
}
}
}
.padding()
.background(Color(UIColor.secondarySystemBackground))
.cornerRadius(12)
@ -423,7 +401,6 @@ struct ActiveDownloadCard: View {
}
}
// MARK: - DownloadGroupCard
struct DownloadGroupCard: View {
let group: SimpleDownloadGroup
let onDelete: (DownloadedAsset) -> Void
@ -432,7 +409,6 @@ struct DownloadGroupCard: View {
var body: some View {
NavigationLink(destination: ShowEpisodesView(group: group, onDelete: onDelete, onPlay: onPlay)) {
HStack(spacing: 12) {
// Poster
if let posterURL = group.posterURL {
KFImage(posterURL)
.placeholder {
@ -443,14 +419,13 @@ struct DownloadGroupCard: View {
.aspectRatio(contentMode: .fill)
.frame(width: 50, height: 75)
.cornerRadius(6)
} else {
} else {
Rectangle()
.fill(Color.gray.opacity(0.3))
.frame(width: 50, height: 75)
.cornerRadius(6)
}
// Info
VStack(alignment: .leading, spacing: 4) {
Text(group.title)
.font(.headline)
@ -467,7 +442,6 @@ struct DownloadGroupCard: View {
Spacer()
// Navigation chevron
Image(systemName: "chevron.right")
.foregroundColor(.gray)
.font(.caption)
@ -484,7 +458,6 @@ struct DownloadGroupCard: View {
}
}
// MARK: - EpisodeRow
struct EpisodeRow: View {
let asset: DownloadedAsset
let onDelete: () -> Void
@ -492,7 +465,6 @@ struct EpisodeRow: View {
var body: some View {
HStack(spacing: 12) {
// Thumbnail
if let backdropURL = asset.metadata?.backdropURL {
KFImage(backdropURL)
.placeholder {
@ -510,7 +482,6 @@ struct EpisodeRow: View {
.cornerRadius(6)
}
// Info
VStack(alignment: .leading, spacing: 2) {
Text(asset.episodeDisplayName)
.font(.subheadline)
@ -521,28 +492,27 @@ struct EpisodeRow: View {
.font(.caption)
.foregroundColor(.secondary)
if asset.localSubtitleURL != nil {
Image(systemName: "captions.bubble")
.foregroundColor(.blue)
.font(.caption)
}
if !asset.fileExists {
Image(systemName: "exclamationmark.triangle")
.foregroundColor(.orange)
.font(.caption)
if asset.localSubtitleURL != nil {
Image(systemName: "captions.bubble")
.foregroundColor(.blue)
.font(.caption)
}
if !asset.fileExists {
Image(systemName: "exclamationmark.triangle")
.foregroundColor(.orange)
.font(.caption)
}
}
}
Spacer()
// Play button
Button(action: onPlay) {
Image(systemName: "play.circle.fill")
.foregroundColor(asset.fileExists ? .blue : .gray)
.font(.title3)
}
Image(systemName: "play.circle.fill")
.foregroundColor(asset.fileExists ? .blue : .gray)
.font(.title3)
}
.disabled(!asset.fileExists)
}
.padding(.horizontal, 12)
@ -562,7 +532,6 @@ struct EpisodeRow: View {
}
}
// MARK: - ShowEpisodesView
struct ShowEpisodesView: View {
let group: SimpleDownloadGroup
let onDelete: (DownloadedAsset) -> Void
@ -572,10 +541,8 @@ struct ShowEpisodesView: View {
@State private var assetToDelete: DownloadedAsset?
@EnvironmentObject var jsController: JSController
// Episode sorting state
@State private var episodeSortOption: EpisodeSortOption = .downloadDate
// Episode sort options enum
enum EpisodeSortOption: String, CaseIterable, Identifiable {
case downloadDate = "Download Date"
case episodeOrder = "Episode Order"
@ -592,7 +559,6 @@ struct ShowEpisodesView: View {
}
}
// Computed property for sorted episodes
private var sortedEpisodes: [DownloadedAsset] {
switch episodeSortOption {
case .downloadDate:
@ -605,9 +571,7 @@ struct ShowEpisodesView: View {
var body: some View {
ScrollView {
VStack(spacing: 20) {
// Header with poster and info
HStack(alignment: .top, spacing: 16) {
// Larger poster image
if let posterURL = group.posterURL {
KFImage(posterURL)
.placeholder {
@ -625,7 +589,6 @@ struct ShowEpisodesView: View {
.cornerRadius(10)
}
// Show info
VStack(alignment: .leading, spacing: 8) {
Text(group.title)
.font(.title2)
@ -636,9 +599,9 @@ struct ShowEpisodesView: View {
.font(.headline)
.foregroundColor(.secondary)
Text(formatFileSize(group.totalFileSize))
.font(.subheadline)
.foregroundColor(.secondary)
Text(formatFileSize(group.totalFileSize))
.font(.subheadline)
.foregroundColor(.secondary)
Spacer()
}
@ -646,7 +609,6 @@ struct ShowEpisodesView: View {
}
.padding(.horizontal)
// Episodes section
VStack(alignment: .leading, spacing: 16) {
HStack {
Text("Episodes")
@ -655,7 +617,6 @@ struct ShowEpisodesView: View {
Spacer()
// Sort toggle menu
Menu {
ForEach(EpisodeSortOption.allCases) { option in
Button(action: {
@ -688,7 +649,6 @@ struct ShowEpisodesView: View {
}
.padding(.horizontal)
// Episodes list
if group.assets.isEmpty {
Text("No episodes available")
.foregroundColor(.gray)
@ -699,27 +659,27 @@ struct ShowEpisodesView: View {
LazyVStack(spacing: 8) {
ForEach(sortedEpisodes) { asset in
DetailedEpisodeRow(asset: asset)
.padding(.horizontal)
.background(Color(UIColor.secondarySystemBackground))
.cornerRadius(10)
.padding(.horizontal)
.contextMenu {
.padding(.horizontal)
.background(Color(UIColor.secondarySystemBackground))
.cornerRadius(10)
.padding(.horizontal)
.contextMenu {
Button(action: { onPlay(asset) }) {
Label("Play", systemImage: "play.fill")
}
Label("Play", systemImage: "play.fill")
}
.disabled(!asset.fileExists)
Button(role: .destructive, action: {
Button(role: .destructive, action: {
assetToDelete = asset
showDeleteAlert = true
}) {
Label("Delete", systemImage: "trash")
Label("Delete", systemImage: "trash")
}
}
}
.onTapGesture {
.onTapGesture {
onPlay(asset)
}
}
}
}
}
}
@ -764,13 +724,11 @@ struct ShowEpisodesView: View {
}
}
// MARK: - DetailedEpisodeRow
struct DetailedEpisodeRow: View {
let asset: DownloadedAsset
var body: some View {
HStack(spacing: 12) {
// Episode thumbnail
if let backdropURL = asset.metadata?.backdropURL ?? asset.metadata?.posterURL {
KFImage(backdropURL)
.placeholder {
@ -788,7 +746,6 @@ struct DetailedEpisodeRow: View {
.cornerRadius(8)
}
// Episode info
VStack(alignment: .leading, spacing: 4) {
Text(asset.episodeDisplayName)
.font(.headline)
@ -819,7 +776,6 @@ struct DetailedEpisodeRow: View {
Spacer()
// Play button
Image(systemName: "play.circle.fill")
.foregroundColor(asset.fileExists ? .blue : .gray)
.font(.title2)

View file

@ -25,9 +25,8 @@ struct EpisodeCell: View {
var defaultBannerImage: String
var module: ScrapingModule
var parentTitle: String
var showPosterURL: String? // Add show poster URL for downloads
var showPosterURL: String?
// Multi-select support (Task MD-3)
var isMultiSelectMode: Bool = false
var isSelected: Bool = false
var onSelectionChanged: ((Bool) -> Void)?
@ -51,7 +50,6 @@ struct EpisodeCell: View {
@State private var lastLoggedStatus: EpisodeDownloadStatus?
@State private var downloadAnimationScale: CGFloat = 1.0
// Add retry configuration
@State private var retryAttempts: Int = 0
private let maxRetryAttempts: Int = 3
private let initialBackoffDelay: TimeInterval = 1.0
@ -62,7 +60,6 @@ struct EpisodeCell: View {
@Environment(\.colorScheme) private var colorScheme
@AppStorage("selectedAppearance") private var selectedAppearance: Appearance = .system
// Simple download status for UI updates
private var downloadStatusString: String {
switch downloadStatus {
case .notDownloaded:
@ -87,15 +84,14 @@ struct EpisodeCell: View {
self.itemID = itemID
self.totalEpisodes = totalEpisodes
// Initialize banner image based on appearance
let isLightMode = (UserDefaults.standard.string(forKey: "selectedAppearance") == "light") ||
((UserDefaults.standard.string(forKey: "selectedAppearance") == "system") &&
UITraitCollection.current.userInterfaceStyle == .light)
let isLightMode = (UserDefaults.standard.string(forKey: "selectedAppearance") == "light") ||
((UserDefaults.standard.string(forKey: "selectedAppearance") == "system") &&
UITraitCollection.current.userInterfaceStyle == .light)
let defaultLightBanner = "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/dev/assets/banner1.png"
let defaultDarkBanner = "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/dev/assets/banner2.png"
self.defaultBannerImage = defaultBannerImage.isEmpty ?
(isLightMode ? defaultLightBanner : defaultDarkBanner) : defaultBannerImage
self.defaultBannerImage = defaultBannerImage.isEmpty ?
(isLightMode ? defaultLightBanner : defaultDarkBanner) : defaultBannerImage
self.module = module
self.parentTitle = parentTitle
@ -109,7 +105,6 @@ struct EpisodeCell: View {
var body: some View {
HStack {
// Multi-select checkbox (Task MD-3)
if isMultiSelectMode {
Button(action: {
onSelectionChanged?(!isSelected)
@ -135,30 +130,15 @@ struct EpisodeCell: View {
contextMenuContent
}
.onAppear {
// Stagger operations for better scroll performance
updateProgress()
// Check download status when cell appears (less frequently)
updateDownloadStatus()
// Slightly delay loading episode details to prioritize smooth scrolling
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
fetchEpisodeDetails()
}
// Prefetch next episodes when this one becomes visible
if let totalEpisodes = totalEpisodes, episodeID + 1 < totalEpisodes {
// Prefetch the next 5 episodes when this one appears
let nextEpisodeStart = episodeID + 1
let count = min(5, totalEpisodes - episodeID - 1)
// Also prefetch images for the next few episodes
// Commented out prefetching until ImagePrefetchManager is ready
// ImagePrefetchManager.shared.prefetchEpisodeImages(
// anilistId: itemID,
// startEpisode: nextEpisodeStart,
// count: count
// )
}
}
.onDisappear {
@ -168,7 +148,6 @@ struct EpisodeCell: View {
updateProgress()
}
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("downloadProgressChanged"))) { _ in
// Update download status less frequently to reduce jitter
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
updateDownloadStatus()
updateProgress()
@ -198,17 +177,14 @@ struct EpisodeCell: View {
}
}
// MARK: - View Components
private var episodeThumbnail: some View {
ZStack {
if let url = URL(string: episodeImageUrl.isEmpty ? defaultBannerImage : episodeImageUrl) {
KFImage.optimizedEpisodeThumbnail(url: url)
// Convert back to the regular KFImage since the extension isn't available yet
.setProcessor(DownsamplingImageProcessor(size: CGSize(width: 100, height: 56)))
.memoryCacheExpiration(.seconds(600)) // Increase cache duration to reduce loading
.memoryCacheExpiration(.seconds(600))
.cacheOriginalImage()
.fade(duration: 0.1) // Shorter fade for better performance
.fade(duration: 0.1)
.onFailure { error in
Logger.shared.log("Failed to load episode image: \(error)", type: "Error")
}
@ -217,9 +193,6 @@ struct EpisodeCell: View {
.aspectRatio(16/9, contentMode: .fill)
.frame(width: 100, height: 56)
.cornerRadius(8)
.onAppear {
// Image loading logic if needed
}
} else {
Rectangle()
.fill(Color.gray.opacity(0.3))
@ -299,9 +272,7 @@ struct EpisodeCell: View {
.foregroundColor(.green)
.font(.title3)
.padding(.horizontal, 8)
// Add animation to stand out more
.scaleEffect(1.1)
// Use more straightforward animation
.animation(.default, value: downloadStatusString)
}
@ -354,36 +325,29 @@ struct EpisodeCell: View {
}
private func updateDownloadStatus() {
// Check the current download status with JSController
let newStatus = jsController.isEpisodeDownloadedOrInProgress(
showTitle: parentTitle,
episodeNumber: episodeID + 1
)
// Only update if status actually changed to reduce unnecessary UI updates
if downloadStatus != newStatus {
downloadStatus = newStatus
}
}
private func downloadEpisode() {
// Check the current download status
updateDownloadStatus()
// Don't proceed if the episode is already downloaded or being downloaded
if case .notDownloaded = downloadStatus, !isDownloading {
isDownloading = true
let downloadID = UUID()
// Use the new consolidated download notification
DropManager.shared.downloadStarted(episodeNumber: episodeID + 1)
Task {
do {
let jsContent = try moduleManager.getModuleContent(module)
jsController.loadScript(jsContent)
// Try download methods sequentially instead of in parallel
tryNextDownloadMethod(methodIndex: 0, downloadID: downloadID, softsub: module.metadata.softsub == true)
} catch {
DropManager.shared.error("Failed to start download: \(error.localizedDescription)")
@ -391,7 +355,6 @@ struct EpisodeCell: View {
}
}
} else {
// Handle case where download is already in progress or completed
if case .downloaded = downloadStatus {
DropManager.shared.info("Episode \(episodeID + 1) is already downloaded")
} else if case .downloading = downloadStatus {
@ -400,7 +363,6 @@ struct EpisodeCell: View {
}
}
// Try each download method sequentially
private func tryNextDownloadMethod(methodIndex: Int, downloadID: UUID, softsub: Bool) {
if !isDownloading {
return
@ -410,73 +372,60 @@ struct EpisodeCell: View {
switch methodIndex {
case 0:
// First try fetchStreamUrlJS if asyncJS is true
if module.metadata.asyncJS == true {
jsController.fetchStreamUrlJS(episodeUrl: episode, softsub: softsub, module: module) { result in
self.handleSequentialDownloadResult(result, downloadID: downloadID, methodIndex: methodIndex, softsub: softsub)
}
} else {
// Skip to next method if not applicable
tryNextDownloadMethod(methodIndex: methodIndex + 1, downloadID: downloadID, softsub: softsub)
}
case 1:
// Then try fetchStreamUrlJSSecond if streamAsyncJS is true
if module.metadata.streamAsyncJS == true {
jsController.fetchStreamUrlJSSecond(episodeUrl: episode, softsub: softsub, module: module) { result in
self.handleSequentialDownloadResult(result, downloadID: downloadID, methodIndex: methodIndex, softsub: softsub)
}
} else {
// Skip to next method if not applicable
tryNextDownloadMethod(methodIndex: methodIndex + 1, downloadID: downloadID, softsub: softsub)
}
case 2:
// Finally try fetchStreamUrl (most reliable method)
jsController.fetchStreamUrl(episodeUrl: episode, softsub: softsub, module: module) { result in
self.handleSequentialDownloadResult(result, downloadID: downloadID, methodIndex: methodIndex, softsub: softsub)
}
default:
// We've tried all methods and none worked
DropManager.shared.error("Failed to find a valid stream for download after trying all methods")
isDownloading = false
}
}
// Handle result from sequential download attempts
private func handleSequentialDownloadResult(_ result: (streams: [String]?, subtitles: [String]?, sources: [[String:Any]]?), downloadID: UUID, methodIndex: Int, softsub: Bool) {
// Skip if we're no longer downloading
if !isDownloading {
return
}
// Check if we have valid streams
if let streams = result.streams, !streams.isEmpty, let url = URL(string: streams[0]) {
// Check if it's a Promise object
if streams[0] == "[object Promise]" {
print("[Download] Method #\(methodIndex+1) returned a Promise object, trying next method")
tryNextDownloadMethod(methodIndex: methodIndex + 1, downloadID: downloadID, softsub: softsub)
return
}
// We found a valid stream URL, proceed with download
print("[Download] Method #\(methodIndex+1) returned valid stream URL: \(streams[0])")
// Get subtitle URL if available
let subtitleURL = result.subtitles?.first.flatMap { URL(string: $0) }
if let subtitleURL = subtitleURL {
print("[Download] Found subtitle URL: \(subtitleURL.absoluteString)")
}
startActualDownload(url: url, streamUrl: streams[0], downloadID: downloadID, subtitleURL: subtitleURL)
} else if let sources = result.sources, !sources.isEmpty,
let streamUrl = sources[0]["streamUrl"] as? String,
let url = URL(string: streamUrl) {
} else if let sources = result.sources, !sources.isEmpty,
let streamUrl = sources[0]["streamUrl"] as? String,
let url = URL(string: streamUrl) {
print("[Download] Method #\(methodIndex+1) returned valid stream URL with headers: \(streamUrl)")
// Get subtitle URL if available
let subtitleURLString = sources[0]["subtitle"] as? String
let subtitleURL = subtitleURLString.flatMap { URL(string: $0) }
if let subtitleURL = subtitleURL {
@ -485,22 +434,17 @@ struct EpisodeCell: View {
startActualDownload(url: url, streamUrl: streamUrl, downloadID: downloadID, subtitleURL: subtitleURL)
} else {
// No valid streams from this method, try the next one
print("[Download] Method #\(methodIndex+1) did not return valid streams, trying next method")
tryNextDownloadMethod(methodIndex: methodIndex + 1, downloadID: downloadID, softsub: softsub)
}
}
// Start the actual download process once we have a valid URL
private func startActualDownload(url: URL, streamUrl: String, downloadID: UUID, subtitleURL: URL? = nil) {
// Extract base URL for headers
var headers: [String: String] = [:]
// Always use the module's baseUrl for Origin and Referer
if !module.metadata.baseUrl.isEmpty && !module.metadata.baseUrl.contains("undefined") {
print("Using module baseUrl: \(module.metadata.baseUrl)")
// Create comprehensive headers prioritizing the module's baseUrl
headers = [
"Origin": module.metadata.baseUrl,
"Referer": module.metadata.baseUrl,
@ -512,7 +456,6 @@ struct EpisodeCell: View {
"Sec-Fetch-Site": "same-origin"
]
} else {
// Fallback to using the stream URL's domain if module.baseUrl isn't available
if let scheme = url.scheme, let host = url.host {
let baseUrl = scheme + "://" + host
@ -527,7 +470,6 @@ struct EpisodeCell: View {
"Sec-Fetch-Site": "same-origin"
]
} else {
// Missing URL components
DropManager.shared.error("Invalid stream URL - missing scheme or host")
isDownloading = false
return
@ -536,18 +478,14 @@ struct EpisodeCell: View {
print("Download headers: \(headers)")
// Use episode thumbnail for the individual episode, show poster for grouping
let episodeThumbnailURL = URL(string: episodeImageUrl.isEmpty ? defaultBannerImage : episodeImageUrl)
let showPosterImageURL = URL(string: showPosterURL ?? defaultBannerImage)
// Get the episode title and information
let episodeName = episodeTitle.isEmpty ? "Episode \(episodeID + 1)" : episodeTitle
let fullEpisodeTitle = "Episode \(episodeID + 1): \(episodeName)"
// Extract show title from the parent view
let animeTitle = parentTitle.isEmpty ? "Unknown Anime" : parentTitle
// Use streamType-aware download method instead of M3U8-specific method
jsController.downloadWithStreamTypeSupport(
url: url,
headers: headers,
@ -556,13 +494,12 @@ struct EpisodeCell: View {
module: module,
isEpisode: true,
showTitle: animeTitle,
season: 1, // Default to season 1 if not known
season: 1,
episode: episodeID + 1,
subtitleURL: subtitleURL,
showPosterURL: showPosterImageURL,
completionHandler: { success, message in
if success {
// Log the download for analytics
Logger.shared.log("Started download for Episode \(self.episodeID + 1): \(self.episode)", type: "Download")
AnalyticsManager.shared.sendEvent(
event: "download",
@ -571,8 +508,6 @@ struct EpisodeCell: View {
} else {
DropManager.shared.error(message)
}
// Mark that we've handled this download
self.isDownloading = false
}
)
@ -606,19 +541,15 @@ struct EpisodeCell: View {
}
private func fetchEpisodeDetails() {
// Check if metadata caching is enabled
if MetadataCacheManager.shared.isCachingEnabled &&
(UserDefaults.standard.object(forKey: "fetchEpisodeMetadata") == nil ||
UserDefaults.standard.bool(forKey: "fetchEpisodeMetadata")) {
if MetadataCacheManager.shared.isCachingEnabled &&
(UserDefaults.standard.object(forKey: "fetchEpisodeMetadata") == nil ||
UserDefaults.standard.bool(forKey: "fetchEpisodeMetadata")) {
// Create a cache key using the anilist ID and episode number
let cacheKey = "anilist_\(itemID)_episode_\(episodeID + 1)"
// Try to get from cache first
if let cachedData = MetadataCacheManager.shared.getMetadata(forKey: cacheKey),
let metadata = EpisodeMetadata.fromData(cachedData) {
// Successfully loaded from cache
DispatchQueue.main.async {
self.episodeTitle = metadata.title["en"] ?? ""
self.episodeImageUrl = metadata.imageUrl
@ -629,7 +560,6 @@ struct EpisodeCell: View {
}
}
// Cache miss or caching disabled, fetch from network
fetchAnimeEpisodeDetails()
}
@ -640,7 +570,6 @@ struct EpisodeCell: View {
return
}
// For debugging
if retryAttempts > 0 {
Logger.shared.log("Retrying episode details fetch (attempt \(retryAttempts)/\(maxRetryAttempts))", type: "Debug")
}
@ -664,10 +593,8 @@ struct EpisodeCell: View {
return
}
// Check if episodes object exists
guard let episodes = json["episodes"] as? [String: Any] else {
Logger.shared.log("Missing 'episodes' object in response", type: "Error")
// Still proceed with empty data rather than failing
DispatchQueue.main.async {
self.isLoading = false
self.retryAttempts = 0
@ -675,11 +602,9 @@ struct EpisodeCell: View {
return
}
// Check if this specific episode exists in the response
let episodeKey = "\(episodeID + 1)"
guard let episodeDetails = episodes[episodeKey] as? [String: Any] else {
Logger.shared.log("Episode \(episodeKey) not found in response", type: "Error")
// Still proceed with empty data rather than failing
DispatchQueue.main.async {
self.isLoading = false
self.retryAttempts = 0
@ -687,7 +612,6 @@ struct EpisodeCell: View {
return
}
// Extract available fields, log if they're missing but continue anyway
var title: [String: String] = [:]
var image: String = ""
var missingFields: [String] = []
@ -695,7 +619,6 @@ struct EpisodeCell: View {
if let titleData = episodeDetails["title"] as? [String: String], !titleData.isEmpty {
title = titleData
// Check if we have any non-empty title values
if title.values.allSatisfy({ $0.isEmpty }) {
missingFields.append("title (all values empty)")
}
@ -709,12 +632,10 @@ struct EpisodeCell: View {
missingFields.append("image")
}
// Log missing fields but continue processing
if !missingFields.isEmpty {
Logger.shared.log("Episode \(episodeKey) missing fields: \(missingFields.joined(separator: ", "))", type: "Warning")
}
// Cache whatever metadata we have if caching is enabled
if MetadataCacheManager.shared.isCachingEnabled && (!title.isEmpty || !image.isEmpty) {
let metadata = EpisodeMetadata(
title: title,
@ -731,17 +652,14 @@ struct EpisodeCell: View {
}
}
// Update UI with whatever data we have
DispatchQueue.main.async {
self.isLoading = false
self.retryAttempts = 0 // Reset retry counter on success (even partial)
self.retryAttempts = 0
if UserDefaults.standard.object(forKey: "fetchEpisodeMetadata") == nil
|| UserDefaults.standard.bool(forKey: "fetchEpisodeMetadata") {
// Use whatever title we have, or leave as empty string
self.episodeTitle = title["en"] ?? title.values.first ?? ""
// Use image if available, otherwise leave current value
if !image.isEmpty {
self.episodeImageUrl = image
}
@ -749,7 +667,6 @@ struct EpisodeCell: View {
}
} catch {
Logger.shared.log("JSON parsing error: \(error.localizedDescription)", type: "Error")
// Still continue with empty data rather than failing
DispatchQueue.main.async {
self.isLoading = false
self.retryAttempts = 0
@ -762,22 +679,17 @@ struct EpisodeCell: View {
Logger.shared.log("Episode details fetch error: \(error.localizedDescription)", type: "Error")
DispatchQueue.main.async {
// Check if we should retry
if self.retryAttempts < self.maxRetryAttempts {
// Increment retry counter
self.retryAttempts += 1
// Calculate backoff delay with exponential backoff
let backoffDelay = self.initialBackoffDelay * pow(2.0, Double(self.retryAttempts - 1))
Logger.shared.log("Will retry episode details fetch in \(backoffDelay) seconds", type: "Debug")
// Schedule retry after backoff delay
DispatchQueue.main.asyncAfter(deadline: .now() + backoffDelay) {
self.fetchAnimeEpisodeDetails()
}
} else {
// Max retries reached, give up but still update UI with what we have
Logger.shared.log("Failed to fetch episode details after \(self.maxRetryAttempts) attempts", type: "Error")
self.isLoading = false
self.retryAttempts = 0

View file

@ -67,7 +67,6 @@ struct MediaInfoView: View {
@Environment(\.verticalSizeClass) private var verticalSizeClass
@AppStorage("selectedAppearance") private var selectedAppearance: Appearance = .system
// MARK: - Multi-Download State Management (Task MD-1)
@State private var isMultiSelectMode: Bool = false
@State private var selectedEpisodes: Set<Int> = []
@State private var showRangeInput: Bool = false
@ -78,16 +77,15 @@ struct MediaInfoView: View {
return groupedEpisodes().count > 1
}
// MARK: - Responsive Layout Properties
private var isCompactLayout: Bool {
return verticalSizeClass == .compact
}
private var useIconOnlyButtons: Bool {
if UIDevice.current.userInterfaceIdiom == .pad {
return false // iPad has more space
return false
}
return verticalSizeClass == .regular // Portrait mode on iPhone
return verticalSizeClass == .regular
}
private var multiselectButtonSpacing: CGFloat {
@ -404,7 +402,6 @@ struct MediaInfoView: View {
episodeNavigationSection
}
// Multi-select action bar
if isMultiSelectMode && !selectedEpisodes.isEmpty {
multiSelectActionBar
}
@ -416,11 +413,8 @@ struct MediaInfoView: View {
@ViewBuilder
private var multiSelectControls: some View {
if isMultiSelectMode {
// Responsive multiselect toolbar
if useIconOnlyButtons {
// Compact layout for portrait mode
HStack(spacing: multiselectButtonSpacing) {
// Secondary actions menu
Menu {
Button(action: {
selectedEpisodes.removeAll()
@ -444,7 +438,6 @@ struct MediaInfoView: View {
Spacer()
// Select All button (icon only)
Button(action: {
selectAllVisibleEpisodes()
}) {
@ -456,7 +449,6 @@ struct MediaInfoView: View {
.clipShape(Circle())
}
// Done button
Button(action: {
isMultiSelectMode = false
selectedEpisodes.removeAll()
@ -473,9 +465,7 @@ struct MediaInfoView: View {
.padding(.horizontal, multiselectPadding)
.padding(.vertical, 8)
} else {
// Expanded layout for landscape mode or iPad
HStack(spacing: multiselectButtonSpacing) {
// Clear All button
Button(action: {
selectedEpisodes.removeAll()
}) {
@ -491,7 +481,6 @@ struct MediaInfoView: View {
.cornerRadius(8)
}
// Range button
Button(action: {
showRangeInput = true
}) {
@ -509,7 +498,6 @@ struct MediaInfoView: View {
Spacer()
// Select All button
Button(action: {
selectAllVisibleEpisodes()
}) {
@ -525,7 +513,6 @@ struct MediaInfoView: View {
.cornerRadius(8)
}
// Done button
Button(action: {
isMultiSelectMode = false
selectedEpisodes.removeAll()
@ -545,7 +532,6 @@ struct MediaInfoView: View {
.cornerRadius(12)
}
} else {
// Select button to enter multi-select mode
HStack {
Spacer()
Button(action: {
@ -570,13 +556,11 @@ struct MediaInfoView: View {
@ViewBuilder
private var multiSelectActionBar: some View {
VStack(spacing: 0) {
// Divider
Rectangle()
.fill(Color(UIColor.separator))
.frame(height: 0.5)
HStack(spacing: 12) {
// Selection count
HStack(spacing: 6) {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.accentColor)
@ -588,7 +572,6 @@ struct MediaInfoView: View {
Spacer()
if isBulkDownloading {
// Progress indicator
HStack(spacing: 8) {
ProgressView()
.scaleEffect(0.8)
@ -597,7 +580,6 @@ struct MediaInfoView: View {
.foregroundColor(.secondary)
}
} else {
// Download button
Button(action: {
startBulkDownload()
}) {
@ -705,7 +687,7 @@ struct MediaInfoView: View {
markAllPreviousEpisodesAsWatched(ep: ep, inSeason: true)
}
)
.disabled(isFetchingEpisode)
.disabled(isFetchingEpisode)
}
} else {
Text("No episodes available")
@ -715,8 +697,8 @@ struct MediaInfoView: View {
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"
? "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) {
@ -787,7 +769,7 @@ struct MediaInfoView: View {
markAllPreviousEpisodesInFlatList(ep: ep, index: i)
}
)
.disabled(isFetchingEpisode)
.disabled(isFetchingEpisode)
}
}
@ -850,7 +832,7 @@ struct MediaInfoView: View {
if let finishedIndex = finished, finishedIndex < episodeLinks.count - 1 {
let nextEp = episodeLinks[finishedIndex + 1]
return "Start Watching Episode \(nextEp.number)"
}
}
if let unfinishedIndex = unfinished {
let currentEp = episodeLinks[unfinishedIndex]
@ -870,7 +852,7 @@ struct MediaInfoView: View {
selectedEpisodeNumber = nextEp.number
fetchStream(href: nextEp.href)
return
}
}
if let unfinishedIndex = unfinished {
let ep = episodeLinks[unfinishedIndex]
@ -1322,8 +1304,6 @@ struct MediaInfoView: View {
}
}
// MARK: - Multi-Download Helper Functions (Task MD-1 & MD-4)
private func selectEpisodeRange(start: Int, end: Int) {
selectedEpisodes.removeAll()
for episodeNumber in start...end {
@ -1352,11 +1332,8 @@ struct MediaInfoView: View {
isBulkDownloading = true
bulkDownloadProgress = "Starting downloads..."
// Convert selected episode numbers to EpisodeLink objects
let episodesToDownload = episodeLinks.filter { selectedEpisodes.contains($0.number) }
// Start bulk download process
Task {
await processBulkDownload(episodes: episodesToDownload)
}
@ -1371,7 +1348,6 @@ struct MediaInfoView: View {
for (index, episode) in episodes.enumerated() {
bulkDownloadProgress = "Downloading episode \(episode.number) (\(index + 1)/\(totalCount))"
// Check if episode is already downloaded or queued
let downloadStatus = jsController.isEpisodeDownloadedOrInProgress(
showTitle: title,
episodeNumber: episode.number,
@ -1384,7 +1360,6 @@ struct MediaInfoView: View {
case .downloading:
Logger.shared.log("Episode \(episode.number) already downloading, skipping", type: "Info")
case .notDownloaded:
// Start download for this episode
let downloadSuccess = await downloadSingleEpisode(episode: episode)
if downloadSuccess {
successCount += 1
@ -1393,17 +1368,14 @@ struct MediaInfoView: View {
completedCount += 1
// Small delay between downloads to avoid overwhelming the system
try? await Task.sleep(nanoseconds: 500_000_000) // 500 milliseconds = 500,000,000 nanoseconds
try? await Task.sleep(nanoseconds: 500_000_000)
}
// Update UI and provide feedback
isBulkDownloading = false
bulkDownloadProgress = ""
isMultiSelectMode = false
selectedEpisodes.removeAll()
// Show completion notification
DropManager.shared.showDrop(
title: "Bulk Download Complete",
subtitle: "\(successCount)/\(totalCount) episodes queued for download",
@ -1419,7 +1391,6 @@ struct MediaInfoView: View {
let jsContent = try moduleManager.getModuleContent(module)
jsController.loadScript(jsContent)
// Use the same comprehensive stream fetching approach as manual downloads
self.tryNextDownloadMethodForBulk(
episode: episode,
methodIndex: 0,
@ -1434,7 +1405,6 @@ struct MediaInfoView: View {
}
}
// Replicate the same multi-method approach used in EpisodeCell for bulk downloads
private func tryNextDownloadMethodForBulk(
episode: EpisodeLink,
methodIndex: Int,
@ -1445,61 +1415,44 @@ struct MediaInfoView: View {
switch methodIndex {
case 0:
// First try fetchStreamUrlJS if asyncJS is true
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 {
// Skip to next method if not applicable
tryNextDownloadMethodForBulk(episode: episode, methodIndex: methodIndex + 1, softsub: softsub, continuation: continuation)
}
case 1:
// Then try fetchStreamUrlJSSecond if streamAsyncJS is true
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 {
// Skip to next method if not applicable
tryNextDownloadMethodForBulk(episode: episode, methodIndex: methodIndex + 1, softsub: softsub, continuation: continuation)
}
case 2:
// Finally try fetchStreamUrl (most reliable method)
jsController.fetchStreamUrl(episodeUrl: episode.href, softsub: softsub, module: module) { result in
self.handleBulkDownloadResult(result, episode: episode, methodIndex: methodIndex, softsub: softsub, continuation: continuation)
}
default:
// We've tried all methods and none worked
Logger.shared.log("Failed to find a valid stream for bulk download after trying all methods", type: "Error")
continuation.resume(returning: false)
}
}
// Handle result from sequential download attempts (same logic as EpisodeCell)
private func handleBulkDownloadResult(
_ result: (streams: [String]?, subtitles: [String]?, sources: [[String:Any]]?),
episode: EpisodeLink,
methodIndex: Int,
softsub: Bool,
continuation: CheckedContinuation<Bool, Never>
) {
// Check if we have valid streams
private func handleBulkDownloadResult(_ result: (streams: [String]?, subtitles: [String]?, sources: [[String:Any]]?), episode: EpisodeLink, methodIndex: Int, softsub: Bool, continuation: CheckedContinuation<Bool, Never>) {
if let streams = result.streams, !streams.isEmpty, let url = URL(string: streams[0]) {
// Check if it's a 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
}
// We found a valid stream URL, proceed with download
print("[Bulk Download] Method #\(methodIndex+1) returned valid stream URL: \(streams[0])")
// Get subtitle URL if available
let subtitleURL = result.subtitles?.first.flatMap { URL(string: $0) }
if let subtitleURL = subtitleURL {
print("[Bulk Download] Found subtitle URL: \(subtitleURL.absoluteString)")
@ -1508,13 +1461,12 @@ struct MediaInfoView: View {
startEpisodeDownloadWithProcessedStream(episode: episode, url: url, streamUrl: streams[0], subtitleURL: subtitleURL)
continuation.resume(returning: true)
} else if let sources = result.sources, !sources.isEmpty,
let streamUrl = sources[0]["streamUrl"] as? String,
let url = URL(string: streamUrl) {
} else if let sources = result.sources, !sources.isEmpty,
let streamUrl = sources[0]["streamUrl"] as? String,
let url = URL(string: streamUrl) {
print("[Bulk Download] Method #\(methodIndex+1) returned valid stream URL with headers: \(streamUrl)")
// Get subtitle URL if available
let subtitleURLString = sources[0]["subtitle"] as? String
let subtitleURL = subtitleURLString.flatMap { URL(string: $0) }
if let subtitleURL = subtitleURL {
@ -1525,27 +1477,17 @@ struct MediaInfoView: View {
continuation.resume(returning: true)
} else {
// No valid streams from this method, try the next one
print("[Bulk Download] Method #\(methodIndex+1) did not return valid streams, trying next method")
tryNextDownloadMethodForBulk(episode: episode, methodIndex: methodIndex + 1, softsub: softsub, continuation: continuation)
}
}
// Start download with processed stream URL and proper headers (same logic as EpisodeCell)
private func startEpisodeDownloadWithProcessedStream(
episode: EpisodeLink,
url: URL,
streamUrl: String,
subtitleURL: URL? = nil
) {
// Generate comprehensive headers using the same logic as EpisodeCell
private func startEpisodeDownloadWithProcessedStream(episode: EpisodeLink, url: URL, streamUrl: String, subtitleURL: URL? = nil) {
var headers: [String: String] = [:]
// Always use the module's baseUrl for Origin and Referer
if !module.metadata.baseUrl.isEmpty && !module.metadata.baseUrl.contains("undefined") {
print("Using module baseUrl: \(module.metadata.baseUrl)")
// Create comprehensive headers prioritizing the module's baseUrl
headers = [
"Origin": module.metadata.baseUrl,
"Referer": module.metadata.baseUrl,
@ -1557,7 +1499,6 @@ struct MediaInfoView: View {
"Sec-Fetch-Site": "same-origin"
]
} else {
// Fallback to using the stream URL's domain if module.baseUrl isn't available
if let scheme = url.scheme, let host = url.host {
let baseUrl = scheme + "://" + host
@ -1572,7 +1513,6 @@ struct MediaInfoView: View {
"Sec-Fetch-Site": "same-origin"
]
} else {
// Missing URL components - use minimal headers
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"
]
@ -1581,17 +1521,13 @@ struct MediaInfoView: View {
}
print("Bulk download headers: \(headers)")
// Fetch episode metadata first (same as EpisodeCell does)
fetchEpisodeMetadataForDownload(episode: episode) { metadata in
let episodeTitle = metadata?.title["en"] ?? metadata?.title.values.first ?? ""
let episodeImageUrl = metadata?.imageUrl ?? ""
// Create episode title using same logic as EpisodeCell
let episodeName = episodeTitle.isEmpty ? "Episode \(episode.number)" : episodeTitle
let fullEpisodeTitle = "Episode \(episode.number): \(episodeName)"
// Use episode-specific thumbnail if available, otherwise use default banner
let episodeThumbnailURL: URL?
if !episodeImageUrl.isEmpty {
episodeThumbnailURL = URL(string: episodeImageUrl)
@ -1626,16 +1562,13 @@ struct MediaInfoView: View {
}
}
// Fetch episode metadata for downloads (same logic as EpisodeCell.fetchEpisodeDetails)
private func fetchEpisodeMetadataForDownload(episode: EpisodeLink, completion: @escaping (EpisodeMetadataInfo?) -> Void) {
// Check if we have an itemID for metadata fetching
guard let anilistId = itemID else {
Logger.shared.log("No AniList ID available for episode metadata", type: "Warning")
completion(nil)
return
}
// Check if metadata caching is enabled and try cache first
if MetadataCacheManager.shared.isCachingEnabled {
let cacheKey = "anilist_\(anilistId)_episode_\(episode.number)"
@ -1654,11 +1587,9 @@ struct MediaInfoView: View {
}
}
// Cache miss or caching disabled, fetch from network
fetchEpisodeMetadataFromNetwork(anilistId: anilistId, episodeNumber: episode.number, completion: completion)
}
// Fetch episode metadata from ani.zip API (same logic as EpisodeCell.fetchAnimeEpisodeDetails)
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")
@ -1689,14 +1620,12 @@ struct MediaInfoView: View {
return
}
// Check if episodes object exists
guard let episodes = json["episodes"] as? [String: Any] else {
Logger.shared.log("Missing 'episodes' object in metadata response", type: "Error")
completion(nil)
return
}
// Check if this specific episode exists in the response
let episodeKey = "\(episodeNumber)"
guard let episodeDetails = episodes[episodeKey] as? [String: Any] else {
Logger.shared.log("Episode \(episodeKey) not found in metadata response", type: "Warning")
@ -1704,23 +1633,18 @@ struct MediaInfoView: View {
return
}
// Extract available fields
var title: [String: String] = [:]
var image: String = ""
if let titleData = episodeDetails["title"] as? [String: String], !titleData.isEmpty {
title = titleData
} else {
// Use default title if none available
title = ["en": "Episode \(episodeNumber)"]
}
if let imageUrl = episodeDetails["image"] as? String, !imageUrl.isEmpty {
image = imageUrl
}
// If no image, leave empty and let the caller use default banner
// Cache whatever metadata we have if caching is enabled
if MetadataCacheManager.shared.isCachingEnabled {
let metadata = EpisodeMetadata(
title: title,
@ -1738,7 +1662,6 @@ struct MediaInfoView: View {
}
}
// Create metadata info object
let metadataInfo = EpisodeMetadataInfo(
title: title,
imageUrl: image,
@ -1757,7 +1680,6 @@ struct MediaInfoView: View {
}
}
// MARK: - Range Selection Sheet (Task MD-5)
struct RangeSelectionSheet: View {
let totalEpisodes: Int
let onSelectionComplete: (Int, Int) -> Void
@ -1871,10 +1793,10 @@ struct RangeSelectionSheet: View {
private func validateAndSelect() {
guard let start = Int(startEpisode),
let end = Int(endEpisode) else {
errorMessage = "Please enter valid episode numbers"
showError = true
return
}
errorMessage = "Please enter valid episode numbers"
showError = true
return
}
guard start >= 1 && end <= totalEpisodes else {
errorMessage = "Episode numbers must be between 1 and \(totalEpisodes)"

View file

@ -8,11 +8,9 @@
import SwiftUI
import Drops
// No need to import DownloadQualityPreference as it's in the same module
struct SettingsViewDownloads: View {
@EnvironmentObject private var jsController: JSController
@AppStorage(DownloadQualityPreference.userDefaultsKey)
@AppStorage(DownloadQualityPreference.userDefaultsKey)
private var downloadQuality = DownloadQualityPreference.defaultPreference.rawValue
@AppStorage("allowCellularDownloads") private var allowCellularDownloads: Bool = true
@AppStorage("maxConcurrentDownloads") private var maxConcurrentDownloads: Int = 3
@ -39,7 +37,6 @@ struct SettingsViewDownloads: View {
Spacer()
Stepper("\(maxConcurrentDownloads)", value: $maxConcurrentDownloads, in: 1...10)
.onChange(of: maxConcurrentDownloads) { newValue in
// Update JSController when the setting changes
jsController.updateMaxConcurrentDownloads(newValue)
}
}
@ -79,7 +76,6 @@ struct SettingsViewDownloads: View {
}
Button(action: {
// Recalculate sizes in case files were externally modified
calculateTotalStorage()
}) {
HStack {
@ -114,7 +110,6 @@ struct SettingsViewDownloads: View {
.navigationTitle("Downloads")
.onAppear {
calculateTotalStorage()
// Sync the max concurrent downloads setting with JSController
jsController.updateMaxConcurrentDownloads(maxConcurrentDownloads)
}
}
@ -128,11 +123,9 @@ struct SettingsViewDownloads: View {
isCalculating = true
// Clear any cached file sizes before recalculating
DownloadedAsset.clearFileSizeCache()
DownloadGroup.clearFileSizeCache()
// Use background task to avoid UI freezes with many files
DispatchQueue.global(qos: .userInitiated).async {
let total = jsController.savedAssets.reduce(0) { $0 + $1.fileSize }
let existing = jsController.savedAssets.filter { $0.fileExists }.count
@ -149,22 +142,17 @@ struct SettingsViewDownloads: View {
let assetsToDelete = jsController.savedAssets
for asset in assetsToDelete {
if preservePersistentDownloads {
// Only remove from library without deleting files
jsController.removeAssetFromLibrary(asset)
} else {
// Delete both library entry and files
jsController.deleteAsset(asset)
}
}
// Reset calculated values
totalStorageSize = 0
existingDownloadCount = 0
// Post a notification so all views can update - use libraryChange since assets were deleted
NotificationCenter.default.post(name: NSNotification.Name("downloadLibraryChanged"), object: nil)
// Show confirmation message
DispatchQueue.main.async {
if preservePersistentDownloads {
DropManager.shared.success("Library cleared successfully")
@ -180,4 +168,4 @@ struct SettingsViewDownloads: View {
formatter.countStyle = .file
return formatter.string(fromByteCount: size)
}
}
}

View file

@ -17,8 +17,6 @@
132AF1212D99951700A0140B /* JSController-Streams.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132AF1202D99951700A0140B /* JSController-Streams.swift */; };
132AF1232D9995C300A0140B /* JSController-Details.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132AF1222D9995C300A0140B /* JSController-Details.swift */; };
132AF1252D9995F900A0140B /* JSController-Search.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132AF1242D9995F900A0140B /* JSController-Search.swift */; };
132E351D2D959DDB0007800E /* Drops in Frameworks */ = {isa = PBXBuildFile; productRef = 132E351C2D959DDB0007800E /* Drops */; };
132E35232D959E410007800E /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 132E35222D959E410007800E /* Kingfisher */; };
133D7C6E2D2BE2500075467E /* SoraApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133D7C6D2D2BE2500075467E /* SoraApp.swift */; };
133D7C702D2BE2500075467E /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133D7C6F2D2BE2500075467E /* ContentView.swift */; };
133D7C722D2BE2520075467E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 133D7C712D2BE2520075467E /* Assets.xcassets */; };
@ -34,13 +32,16 @@
133F55BB2D33B55100E08EEA /* LibraryManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133F55BA2D33B55100E08EEA /* LibraryManager.swift */; };
1359ED142D76F49900C13034 /* finTopView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1359ED132D76F49900C13034 /* finTopView.swift */; };
135CCBE22D4D1138008B9C0E /* SettingsViewPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 135CCBE12D4D1138008B9C0E /* SettingsViewPlayer.swift */; };
13637B8A2DE0EA1100BDA2FC /* UserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13637B892DE0EA1100BDA2FC /* UserDefaults.swift */; };
13637B8D2DE0ECCC00BDA2FC /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 13637B8C2DE0ECCC00BDA2FC /* Kingfisher */; };
13637B902DE0ECD200BDA2FC /* Drops in Frameworks */ = {isa = PBXBuildFile; productRef = 13637B8F2DE0ECD200BDA2FC /* Drops */; };
13637B932DE0ECDB00BDA2FC /* MarqueeLabel in Frameworks */ = {isa = PBXBuildFile; productRef = 13637B922DE0ECDB00BDA2FC /* MarqueeLabel */; };
136BBE802DB1038000906B5E /* Notification+Name.swift in Sources */ = {isa = PBXBuildFile; fileRef = 136BBE7F2DB1038000906B5E /* Notification+Name.swift */; };
138AA1B82D2D66FD0021F9DF /* EpisodeCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138AA1B62D2D66FD0021F9DF /* EpisodeCell.swift */; };
138AA1B92D2D66FD0021F9DF /* CircularProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138AA1B72D2D66FD0021F9DF /* CircularProgressBar.swift */; };
139935662D468C450065CEFF /* ModuleManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 139935652D468C450065CEFF /* ModuleManager.swift */; };
1399FAD42D3AB38C00E97C31 /* SettingsViewLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1399FAD32D3AB38C00E97C31 /* SettingsViewLogger.swift */; };
1399FAD62D3AB3DB00E97C31 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1399FAD52D3AB3DB00E97C31 /* Logger.swift */; };
13B77E192DA44F8300126FDF /* MarqueeLabel in Frameworks */ = {isa = PBXBuildFile; productRef = 13B77E182DA44F8300126FDF /* MarqueeLabel */; };
13B77E202DA457AA00126FDF /* AniListPushUpdates.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13B77E1F2DA457AA00126FDF /* AniListPushUpdates.swift */; };
13B7F4C12D58FFDD0045714A /* Shimmer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13B7F4C02D58FFDD0045714A /* Shimmer.swift */; };
13C0E5EA2D5F85EA00E7F619 /* ContinueWatchingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13C0E5E92D5F85EA00E7F619 /* ContinueWatchingManager.swift */; };
@ -112,6 +113,7 @@
133F55BA2D33B55100E08EEA /* LibraryManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryManager.swift; sourceTree = "<group>"; };
1359ED132D76F49900C13034 /* finTopView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = finTopView.swift; sourceTree = "<group>"; };
135CCBE12D4D1138008B9C0E /* SettingsViewPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewPlayer.swift; sourceTree = "<group>"; };
13637B892DE0EA1100BDA2FC /* UserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaults.swift; sourceTree = "<group>"; };
136BBE7F2DB1038000906B5E /* Notification+Name.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Notification+Name.swift"; sourceTree = "<group>"; };
138AA1B62D2D66FD0021F9DF /* EpisodeCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EpisodeCell.swift; sourceTree = "<group>"; };
138AA1B72D2D66FD0021F9DF /* CircularProgressBar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CircularProgressBar.swift; sourceTree = "<group>"; };
@ -167,9 +169,9 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
13B77E192DA44F8300126FDF /* MarqueeLabel in Frameworks */,
132E35232D959E410007800E /* Kingfisher in Frameworks */,
132E351D2D959DDB0007800E /* Drops in Frameworks */,
13637B8D2DE0ECCC00BDA2FC /* Kingfisher in Frameworks */,
13637B902DE0ECD200BDA2FC /* Drops in Frameworks */,
13637B932DE0ECDB00BDA2FC /* MarqueeLabel in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -293,16 +295,16 @@
isa = PBXGroup;
children = (
7205AEDA2DCCEF9500943F3F /* Cache */,
13D842532D45266900EBBFA6 /* Drops */,
1399FAD12D3AB33D00E97C31 /* Logger */,
133D7C882D2BE2640075467E /* Modules */,
133D7C8A2D2BE2640075467E /* JSLoader */,
1327FBA52D758CEA00FC6689 /* Analytics */,
133D7C862D2BE2640075467E /* Extensions */,
13DC0C442D302C6A00D0F966 /* MediaPlayer */,
13103E8C2D58E037000F0673 /* SkeletonCells */,
72443C832DC8046500A61321 /* DownloadUtils */,
13C0E5E82D5F85DD00E7F619 /* ContinueWatching */,
13103E8C2D58E037000F0673 /* SkeletonCells */,
13DC0C442D302C6A00D0F966 /* MediaPlayer */,
133D7C862D2BE2640075467E /* Extensions */,
1327FBA52D758CEA00FC6689 /* Analytics */,
133D7C8A2D2BE2640075467E /* JSLoader */,
133D7C882D2BE2640075467E /* Modules */,
1399FAD12D3AB33D00E97C31 /* Logger */,
13D842532D45266900EBBFA6 /* Drops */,
);
path = Utils;
sourceTree = "<group>";
@ -313,6 +315,7 @@
73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */,
136BBE7F2DB1038000906B5E /* Notification+Name.swift */,
1327FBA82D758DEA00FC6689 /* UIDevice+Model.swift */,
13637B892DE0EA1100BDA2FC /* UserDefaults.swift */,
133D7C872D2BE2640075467E /* URLSession.swift */,
1359ED132D76F49900C13034 /* finTopView.swift */,
13CBEFD92D5F7D1200D011EE /* String.swift */,
@ -406,7 +409,6 @@
children = (
13C0E5E92D5F85EA00E7F619 /* ContinueWatchingManager.swift */,
13C0E5EB2D5F85F800E7F619 /* ContinueWatchingItem.swift */,
131270162DC13A010093AA9C /* DownloadManager.swift */,
);
path = ContinueWatching;
sourceTree = "<group>";
@ -498,6 +500,7 @@
isa = PBXGroup;
children = (
7222485D2DCBAA2C00CABE2D /* DownloadModels.swift */,
131270162DC13A010093AA9C /* DownloadManager.swift */,
7222485E2DCBAA2C00CABE2D /* M3U8StreamExtractor.swift */,
);
path = DownloadUtils;
@ -530,9 +533,9 @@
);
name = Sulfur;
packageProductDependencies = (
132E351C2D959DDB0007800E /* Drops */,
132E35222D959E410007800E /* Kingfisher */,
13B77E182DA44F8300126FDF /* MarqueeLabel */,
13637B8C2DE0ECCC00BDA2FC /* Kingfisher */,
13637B8F2DE0ECD200BDA2FC /* Drops */,
13637B922DE0ECDB00BDA2FC /* MarqueeLabel */,
);
productName = Sora;
productReference = 133D7C6A2D2BE2500075467E /* Sulfur.app */;
@ -562,9 +565,9 @@
);
mainGroup = 133D7C612D2BE2500075467E;
packageReferences = (
132E351B2D959DDB0007800E /* XCRemoteSwiftPackageReference "Drops" */,
132E35212D959E410007800E /* XCRemoteSwiftPackageReference "Kingfisher" */,
13B77E172DA44F8300126FDF /* XCRemoteSwiftPackageReference "MarqueeLabel" */,
13637B8B2DE0ECCC00BDA2FC /* XCRemoteSwiftPackageReference "Kingfisher" */,
13637B8E2DE0ECD200BDA2FC /* XCRemoteSwiftPackageReference "Drops" */,
13637B912DE0ECDB00BDA2FC /* XCRemoteSwiftPackageReference "MarqueeLabel" */,
);
productRefGroup = 133D7C6B2D2BE2500075467E /* Products */;
projectDirPath = "";
@ -662,6 +665,7 @@
7222485F2DCBAA2C00CABE2D /* DownloadModels.swift in Sources */,
722248602DCBAA2C00CABE2D /* M3U8StreamExtractor.swift in Sources */,
13C0E5EA2D5F85EA00E7F619 /* ContinueWatchingManager.swift in Sources */,
13637B8A2DE0EA1100BDA2FC /* UserDefaults.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -800,7 +804,7 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"Sora/Preview Content\"";
DEVELOPMENT_TEAM = V9MT5Y43YG;
DEVELOPMENT_TEAM = 399LMK6Q2Y;
"ENABLE_HARDENED_RUNTIME[sdk=macosx*]" = NO;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@ -821,7 +825,7 @@
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.2.2;
PRODUCT_BUNDLE_IDENTIFIER = me.cranci.sulfur.test;
PRODUCT_BUNDLE_IDENTIFIER = me.cranci.sulfur;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SUPPORTS_MACCATALYST = YES;
@ -842,7 +846,7 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"Sora/Preview Content\"";
DEVELOPMENT_TEAM = V9MT5Y43YG;
DEVELOPMENT_TEAM = 399LMK6Q2Y;
"ENABLE_HARDENED_RUNTIME[sdk=macosx*]" = NO;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@ -863,7 +867,7 @@
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.2.2;
PRODUCT_BUNDLE_IDENTIFIER = me.cranci.sulfur.test;
PRODUCT_BUNDLE_IDENTIFIER = me.cranci.sulfur;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SUPPORTS_MACCATALYST = YES;
@ -898,15 +902,7 @@
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
132E351B2D959DDB0007800E /* XCRemoteSwiftPackageReference "Drops" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/omaralbeik/Drops.git";
requirement = {
branch = main;
kind = branch;
};
};
132E35212D959E410007800E /* XCRemoteSwiftPackageReference "Kingfisher" */ = {
13637B8B2DE0ECCC00BDA2FC /* XCRemoteSwiftPackageReference "Kingfisher" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/onevcat/Kingfisher.git";
requirement = {
@ -914,7 +910,15 @@
version = 7.9.1;
};
};
13B77E172DA44F8300126FDF /* XCRemoteSwiftPackageReference "MarqueeLabel" */ = {
13637B8E2DE0ECD200BDA2FC /* XCRemoteSwiftPackageReference "Drops" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/omaralbeik/Drops.git";
requirement = {
branch = main;
kind = branch;
};
};
13637B912DE0ECDB00BDA2FC /* XCRemoteSwiftPackageReference "MarqueeLabel" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/cbpowell/MarqueeLabel";
requirement = {
@ -925,19 +929,19 @@
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
132E351C2D959DDB0007800E /* Drops */ = {
13637B8C2DE0ECCC00BDA2FC /* Kingfisher */ = {
isa = XCSwiftPackageProductDependency;
package = 132E351B2D959DDB0007800E /* XCRemoteSwiftPackageReference "Drops" */;
productName = Drops;
};
132E35222D959E410007800E /* Kingfisher */ = {
isa = XCSwiftPackageProductDependency;
package = 132E35212D959E410007800E /* XCRemoteSwiftPackageReference "Kingfisher" */;
package = 13637B8B2DE0ECCC00BDA2FC /* XCRemoteSwiftPackageReference "Kingfisher" */;
productName = Kingfisher;
};
13B77E182DA44F8300126FDF /* MarqueeLabel */ = {
13637B8F2DE0ECD200BDA2FC /* Drops */ = {
isa = XCSwiftPackageProductDependency;
package = 13B77E172DA44F8300126FDF /* XCRemoteSwiftPackageReference "MarqueeLabel" */;
package = 13637B8E2DE0ECD200BDA2FC /* XCRemoteSwiftPackageReference "Drops" */;
productName = Drops;
};
13637B922DE0ECDB00BDA2FC /* MarqueeLabel */ = {
isa = XCSwiftPackageProductDependency;
package = 13637B912DE0ECDB00BDA2FC /* XCRemoteSwiftPackageReference "MarqueeLabel" */;
productName = MarqueeLabel;
};
/* End XCSwiftPackageProductDependency section */

View file

@ -1,33 +1,34 @@
{
"originHash" : "60d5882290a22b3286d882ec649bd11b12151e9ee052d03237e8071773244b7f",
"pins" : [
{
"identity" : "drops",
"kind" : "remoteSourceControl",
"location" : "https://github.com/omaralbeik/Drops.git",
"state" : {
"branch" : "main",
"revision" : "5824681795286c36bdc4a493081a63e64e2a064e"
"object": {
"pins": [
{
"package": "Drops",
"repositoryURL": "https://github.com/omaralbeik/Drops.git",
"state": {
"branch": "main",
"revision": "5824681795286c36bdc4a493081a63e64e2a064e",
"version": null
}
},
{
"package": "Kingfisher",
"repositoryURL": "https://github.com/onevcat/Kingfisher.git",
"state": {
"branch": null,
"revision": "b6f62758f21a8c03cd64f4009c037cfa580a256e",
"version": "7.9.1"
}
},
{
"package": "MarqueeLabel",
"repositoryURL": "https://github.com/cbpowell/MarqueeLabel",
"state": {
"branch": null,
"revision": "cffb6938940d3242882e6a2f9170b7890a4729ea",
"version": "4.2.1"
}
}
},
{
"identity" : "kingfisher",
"kind" : "remoteSourceControl",
"location" : "https://github.com/onevcat/Kingfisher.git",
"state" : {
"revision" : "b6f62758f21a8c03cd64f4009c037cfa580a256e",
"version" : "7.9.1"
}
},
{
"identity" : "marqueelabel",
"kind" : "remoteSourceControl",
"location" : "https://github.com/cbpowell/MarqueeLabel",
"state" : {
"revision" : "cffb6938940d3242882e6a2f9170b7890a4729ea",
"version" : "4.2.1"
}
}
],
"version" : 3
]
},
"version": 1
}