toggle for subtitles (#166) (#167)

This commit is contained in:
cranci 2025-06-10 17:19:47 +02:00 committed by GitHub
parent f90149baa2
commit f1f993f763
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 448 additions and 396 deletions

View file

@ -54,7 +54,7 @@ class DownloadManager: NSObject, ObservableObject {
options: .skipsHiddenFiles
)
if let localURL = contents.first(where: { $0.pathExtension == "movpkg" }) {
if let localURL = contents.first(where: { ["movpkg", "mp4"].contains($0.pathExtension.lowercased()) }) {
localPlaybackURL = localURL
}
} catch {

View file

@ -7,11 +7,12 @@
import Foundation
import SwiftUI
import AVFoundation
// Extension for handling MP4 direct video downloads
// Extension for handling MP4 direct video downloads using AVAssetDownloadTask
extension JSController {
/// Initiates a download for a given MP4 URL
/// Initiates a download for a given MP4 URL using the existing AVAssetDownloadURLSession
/// - Parameters:
/// - url: The MP4 URL to download
/// - headers: HTTP headers to use for the request
@ -26,24 +27,21 @@ extension JSController {
func downloadMP4(url: URL, headers: [String: String], title: String? = nil,
imageURL: URL? = nil, isEpisode: Bool = false,
showTitle: String? = nil, season: Int? = nil, episode: Int? = nil,
subtitleURL: URL? = nil,
subtitleURL: URL? = nil, showPosterURL: URL? = nil,
completionHandler: ((Bool, String) -> Void)? = nil) {
print("---- MP4 DOWNLOAD PROCESS STARTED ----")
print("MP4 URL: \(url.absoluteString)")
print("Headers: \(headers)")
print("Title: \(title ?? "None")")
print("Is Episode: \(isEpisode), Show: \(showTitle ?? "None"), Season: \(season?.description ?? "None"), Episode: \(episode?.description ?? "None")")
if let subtitle = subtitleURL {
print("Subtitle URL: \(subtitle.absoluteString)")
}
// Validate URL
guard url.scheme == "http" || url.scheme == "https" else {
completionHandler?(false, "Invalid URL scheme")
return
}
// Ensure download session is available
guard let downloadSession = downloadURLSession else {
completionHandler?(false, "Download session not available")
return
}
// Create metadata for the download
var metadata: AssetMetadata? = nil
if let title = title {
@ -53,7 +51,7 @@ extension JSController {
showTitle: showTitle,
season: season,
episode: episode,
showPosterURL: imageURL
showPosterURL: showPosterURL ?? imageURL
)
}
@ -63,233 +61,98 @@ extension JSController {
// Generate a unique download ID
let downloadID = UUID()
// Get access to the download directory
guard let downloadDirectory = getPersistentDownloadDirectory() else {
print("MP4 Download: Failed to get download directory")
completionHandler?(false, "Failed to create download directory")
// Create AVURLAsset with headers passed through AVURLAssetHTTPHeaderFieldsKey
let asset = AVURLAsset(url: url, options: [
"AVURLAssetHTTPHeaderFieldsKey": headers
])
// Create AVAssetDownloadTask using existing session
guard let downloadTask = downloadSession.makeAssetDownloadTask(
asset: asset,
assetTitle: title ?? url.lastPathComponent,
assetArtworkData: nil,
options: [AVAssetDownloadTaskMinimumRequiredMediaBitrateKey: 2_000_000]
) else {
completionHandler?(false, "Failed to create download task")
return
}
// Generate a safe filename for the MP4 file
let sanitizedTitle = title?.replacingOccurrences(of: "[^A-Za-z0-9 ._-]", with: "", options: .regularExpression) ?? "download"
let filename = "\(sanitizedTitle)_\(downloadID.uuidString.prefix(8)).mp4"
let destinationURL = downloadDirectory.appendingPathComponent(filename)
// Create an active download object
let activeDownload = JSActiveDownload(
id: downloadID,
originalURL: url,
task: nil,
progress: 0.0,
task: downloadTask,
urlSessionTask: nil,
queueStatus: .downloading,
type: downloadType,
metadata: metadata,
title: title,
imageURL: imageURL,
subtitleURL: subtitleURL,
headers: headers
asset: asset,
headers: headers,
module: nil
)
// Add to active downloads
// Add to active downloads and tracking
activeDownloads.append(activeDownload)
activeDownloadMap[downloadTask] = downloadID
// Create request with headers
var request = URLRequest(url: url)
request.timeoutInterval = 30.0
for (key, value) in headers {
request.addValue(value, forHTTPHeaderField: key)
}
// Set up progress observation for MP4 downloads
setupMP4ProgressObservation(for: downloadTask, downloadID: downloadID)
let sessionConfig = URLSessionConfiguration.default
sessionConfig.timeoutIntervalForRequest = 60.0
sessionConfig.timeoutIntervalForResource = 1800.0
sessionConfig.httpMaximumConnectionsPerHost = 1
sessionConfig.allowsCellularAccess = true
// Create custom session with delegate (self is JSController, which is persistent)
let customSession = URLSession(configuration: sessionConfig, delegate: self, delegateQueue: nil)
// Create the download task
let downloadTask = customSession.downloadTask(with: request) { [weak self] (tempURL, response, error) in
guard let self = self else { return }
DispatchQueue.main.async {
defer {
// Clean up resources
self.cleanupDownloadResources(for: downloadID)
}
// Handle error cases - just remove from active downloads
if let error = error {
print("MP4 Download Error: \(error.localizedDescription)")
self.removeActiveDownload(downloadID: downloadID)
completionHandler?(false, "Download failed: \(error.localizedDescription)")
return
}
// Validate response
guard let httpResponse = response as? HTTPURLResponse else {
print("MP4 Download: Invalid response")
self.removeActiveDownload(downloadID: downloadID)
completionHandler?(false, "Invalid server response")
return
}
guard (200...299).contains(httpResponse.statusCode) else {
print("MP4 Download HTTP Error: \(httpResponse.statusCode)")
self.removeActiveDownload(downloadID: downloadID)
completionHandler?(false, "Server error: \(httpResponse.statusCode)")
return
}
guard let tempURL = tempURL else {
print("MP4 Download: No temporary file URL")
self.removeActiveDownload(downloadID: downloadID)
completionHandler?(false, "Download data not available")
return
}
// Move file to final destination
do {
if FileManager.default.fileExists(atPath: destinationURL.path) {
try FileManager.default.removeItem(at: destinationURL)
}
try FileManager.default.moveItem(at: tempURL, to: destinationURL)
print("MP4 Download: Successfully saved to \(destinationURL.path)")
// Verify file size
let fileSize = try FileManager.default.attributesOfItem(atPath: destinationURL.path)[.size] as? Int64 ?? 0
guard fileSize > 0 else {
throw NSError(domain: "DownloadError", code: -1, userInfo: [NSLocalizedDescriptionKey: "Downloaded file is empty"])
}
// Create downloaded asset
let downloadedAsset = DownloadedAsset(
name: title ?? url.lastPathComponent,
downloadDate: Date(),
originalURL: url,
localURL: destinationURL,
type: downloadType,
metadata: metadata,
subtitleURL: subtitleURL
)
// Save asset
self.savedAssets.append(downloadedAsset)
self.saveAssets()
// Update progress to complete and remove after delay
self.updateDownloadProgress(downloadID: downloadID, progress: 1.0)
// Download subtitle if provided
if let subtitleURL = subtitleURL {
self.downloadSubtitle(subtitleURL: subtitleURL, assetID: downloadedAsset.id.uuidString)
}
// Notify completion
NotificationCenter.default.post(name: NSNotification.Name("downloadCompleted"), object: downloadedAsset)
completionHandler?(true, "Download completed successfully")
// Remove from active downloads after success
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
self.removeActiveDownload(downloadID: downloadID)
}
} catch {
print("MP4 Download Error saving file: \(error.localizedDescription)")
self.removeActiveDownload(downloadID: downloadID)
completionHandler?(false, "Error saving download: \(error.localizedDescription)")
}
}
}
// Set up progress observation
setupProgressObservation(for: downloadTask, downloadID: downloadID)
// Store session reference
storeSessionReference(session: customSession, for: downloadID)
// Start download
// Start the download
downloadTask.resume()
print("MP4 Download: Task started for \(filename)")
// Post notification for UI updates using NotificationCenter directly since postDownloadNotification is private
DispatchQueue.main.async {
NotificationCenter.default.post(name: NSNotification.Name("downloadStatusChanged"), object: nil)
}
// Initial success callback
completionHandler?(true, "Download started")
}
// MARK: - Helper Methods
// MARK: - MP4 Progress Observation
private func removeActiveDownload(downloadID: UUID) {
activeDownloads.removeAll { $0.id == downloadID }
}
private func updateDownloadProgress(downloadID: UUID, progress: Double) {
guard let index = activeDownloads.firstIndex(where: { $0.id == downloadID }) else { return }
activeDownloads[index].progress = progress
}
private func setupProgressObservation(for task: URLSessionDownloadTask, downloadID: UUID) {
let observation = task.progress.observe(\.fractionCompleted) { [weak self] progress, _ in
/// Sets up progress observation for MP4 downloads using AVAssetDownloadTask
/// Since AVAssetDownloadTask doesn't provide progress for single MP4 files through delegate methods,
/// we observe the task's progress property directly
private func setupMP4ProgressObservation(for task: AVAssetDownloadTask, downloadID: UUID) {
let observation = task.progress.observe(\.fractionCompleted, options: [.new]) { [weak self] progress, _ in
DispatchQueue.main.async {
guard let self = self else { return }
self.updateDownloadProgress(downloadID: downloadID, progress: progress.fractionCompleted)
// Update download progress using existing infrastructure
self.updateMP4DownloadProgress(task: task, progress: progress.fractionCompleted)
// Post notification for UI updates
NotificationCenter.default.post(name: NSNotification.Name("downloadProgressChanged"), object: nil)
}
}
// Store observation for cleanup using existing property from main JSController class
if mp4ProgressObservations == nil {
mp4ProgressObservations = [:]
}
mp4ProgressObservations?[downloadID] = observation
}
private func storeSessionReference(session: URLSession, for downloadID: UUID) {
if mp4CustomSessions == nil {
mp4CustomSessions = [:]
}
mp4CustomSessions?[downloadID] = session
}
private func cleanupDownloadResources(for downloadID: UUID) {
mp4ProgressObservations?[downloadID] = nil
mp4CustomSessions?[downloadID] = nil
}
}
// MARK: - URLSessionDelegate
extension JSController: URLSessionDelegate {
func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust else {
completionHandler(.performDefaultHandling, nil)
/// Updates download progress for a specific MP4 task (avoiding name collision with existing method)
private func updateMP4DownloadProgress(task: AVAssetDownloadTask, progress: Double) {
guard let downloadID = activeDownloadMap[task],
let downloadIndex = activeDownloads.firstIndex(where: { $0.id == downloadID }) else {
return
}
let host = challenge.protectionSpace.host
print("MP4 Download: Handling server trust challenge for host: \(host)")
// Define trusted hosts for MP4 downloads
let trustedHosts = [
"streamtales.cc",
"frembed.xyz",
"vidclouds.cc"
]
let isTrustedHost = trustedHosts.contains { host.contains($0) }
let isCustomSession = mp4CustomSessions?.values.contains(session) == true
if isTrustedHost || isCustomSession {
guard let serverTrust = challenge.protectionSpace.serverTrust else {
completionHandler(.performDefaultHandling, nil)
return
}
print("MP4 Download: Accepting certificate for \(host)")
let credential = URLCredential(trust: serverTrust)
completionHandler(.useCredential, credential)
} else {
print("MP4 Download: Using default handling for \(host)")
completionHandler(.performDefaultHandling, nil)
}
// Update progress using existing mechanism
activeDownloads[downloadIndex].progress = progress
}
/// Cleans up MP4 progress observation for a specific download
func cleanupMP4ProgressObservation(for downloadID: UUID) {
mp4ProgressObservations?[downloadID]?.invalidate()
mp4ProgressObservations?[downloadID] = nil
}
}

View file

@ -162,6 +162,7 @@ extension JSController {
originalURL: url,
progress: 0,
task: nil, // Task will be created when the download starts
urlSessionTask: nil,
queueStatus: .queued,
type: downloadType,
metadata: assetMetadata,
@ -299,6 +300,7 @@ extension JSController {
originalURL: queuedDownload.originalURL,
progress: 0,
task: task,
urlSessionTask: nil,
queueStatus: .downloading,
type: queuedDownload.type,
metadata: queuedDownload.metadata,
@ -364,6 +366,11 @@ extension JSController {
private func cleanupDownloadTask(_ task: URLSessionTask) {
guard let downloadID = activeDownloadMap[task] else { return }
// Clean up MP4 progress observations if this is an MP4 download
if task is AVAssetDownloadTask {
cleanupMP4ProgressObservation(for: downloadID)
}
activeDownloads.removeAll { $0.id == downloadID }
activeDownloadMap.removeValue(forKey: task)
@ -648,15 +655,15 @@ extension JSController {
print("Created persistent download directory at \(persistentDir.path)")
}
// Find any .movpkg files in the Documents directory
// Find any video files (.movpkg, .mp4) in the Documents directory
let files = try fileManager.contentsOfDirectory(at: documentsDir, includingPropertiesForKeys: nil)
let movpkgFiles = files.filter { $0.pathExtension == "movpkg" }
let videoFiles = files.filter { ["movpkg", "mp4"].contains($0.pathExtension.lowercased()) }
if !movpkgFiles.isEmpty {
print("Found \(movpkgFiles.count) .movpkg files in Documents directory to migrate")
if !videoFiles.isEmpty {
print("Found \(videoFiles.count) video files in Documents directory to migrate")
// Migrate each file
for fileURL in movpkgFiles {
for fileURL in videoFiles {
let filename = fileURL.lastPathComponent
let destinationURL = persistentDir.appendingPathComponent(filename)
@ -674,7 +681,7 @@ extension JSController {
}
}
} else {
print("No .movpkg files found in Documents directory for migration")
print("No video files found in Documents directory for migration")
}
} catch {
print("Error during migration: \(error.localizedDescription)")
@ -808,8 +815,8 @@ extension JSController {
// Get all files in the directory
let files = try fileManager.contentsOfDirectory(at: downloadDir, includingPropertiesForKeys: nil)
// Try to find a file that contains the asset name
for file in files where file.pathExtension == "movpkg" {
// Try to find a video file that contains the asset name
for file in files where ["movpkg", "mp4"].contains(file.pathExtension.lowercased()) {
let filename = file.lastPathComponent
// If the filename contains the asset name, it's likely our file
@ -1088,21 +1095,14 @@ extension JSController {
return .notDownloaded
}
/// Cancel a queued download that hasn't started yet
/// Cancel a queued download
func cancelQueuedDownload(_ downloadID: UUID) {
// Remove from the download queue if it exists there
if let index = downloadQueue.firstIndex(where: { $0.id == downloadID }) {
let downloadTitle = downloadQueue[index].title ?? downloadQueue[index].originalURL.lastPathComponent
downloadQueue.remove(at: index)
// Show notification
DropManager.shared.info("Download cancelled: \(downloadTitle)")
// Notify observers of status change (no cache clearing needed for cancellation)
postDownloadNotification(.statusChange)
print("Cancelled queued download: \(downloadTitle)")
}
downloadQueue.removeAll { $0.id == downloadID }
// Notify of the cancellation
postDownloadNotification(.statusChange)
print("Cancelled queued download: \(downloadID)")
}
/// Cancel an active download that is currently in progress
@ -1111,12 +1111,16 @@ extension JSController {
cancelledDownloadIDs.insert(downloadID)
// Find the active download and cancel its task
if let activeDownload = activeDownloads.first(where: { $0.id == downloadID }),
let task = activeDownload.task {
if let activeDownload = activeDownloads.first(where: { $0.id == downloadID }) {
let downloadTitle = activeDownload.title ?? activeDownload.originalURL.lastPathComponent
// Cancel the actual download task
task.cancel()
if let task = activeDownload.task {
// M3U8 download - cancel AVAssetDownloadTask
task.cancel()
} else if let urlTask = activeDownload.urlSessionTask {
// MP4 download - cancel URLSessionDownloadTask
urlTask.cancel()
}
// Show notification
DropManager.shared.info("Download cancelled: \(downloadTitle)")
@ -1124,6 +1128,46 @@ extension JSController {
print("Cancelled active download: \(downloadTitle)")
}
}
/// Pause an MP4 download
func pauseMP4Download(_ downloadID: UUID) {
guard let index = activeDownloads.firstIndex(where: { $0.id == downloadID }) else {
print("MP4 Download not found for pausing: \(downloadID)")
return
}
let download = activeDownloads[index]
guard let urlTask = download.urlSessionTask else {
print("No URL session task found for MP4 download: \(downloadID)")
return
}
urlTask.suspend()
print("Paused MP4 download: \(download.title ?? download.originalURL.lastPathComponent)")
// Notify UI of status change
postDownloadNotification(.statusChange)
}
/// Resume an MP4 download
func resumeMP4Download(_ downloadID: UUID) {
guard let index = activeDownloads.firstIndex(where: { $0.id == downloadID }) else {
print("MP4 Download not found for resuming: \(downloadID)")
return
}
let download = activeDownloads[index]
guard let urlTask = download.urlSessionTask else {
print("No URL session task found for MP4 download: \(downloadID)")
return
}
urlTask.resume()
print("Resumed MP4 download: \(download.title ?? download.originalURL.lastPathComponent)")
// Notify UI of status change
postDownloadNotification(.statusChange)
}
}
// MARK: - AVAssetDownloadDelegate
@ -1220,7 +1264,28 @@ extension JSController: AVAssetDownloadDelegate {
let safeFilename = filename.replacingOccurrences(of: "/", with: "-")
.replacingOccurrences(of: ":", with: "-")
let destinationURL = downloadDir.appendingPathComponent("\(safeFilename)-\(uniqueID).movpkg")
// Determine file extension based on the source location
let fileExtension: String
if location.pathExtension.isEmpty {
// If no extension from the source, check if it's likely an HLS download (which becomes .movpkg)
// or preserve original URL extension
if safeFilename.contains(".m3u8") || safeFilename.contains("hls") {
fileExtension = "movpkg"
print("Using .movpkg extension for HLS download: \(safeFilename)")
} else {
fileExtension = "mp4" // Default for direct video downloads
print("Using .mp4 extension for direct video download: \(safeFilename)")
}
} else {
// Use the extension from the downloaded file
let sourceExtension = location.pathExtension.lowercased()
fileExtension = (sourceExtension == "movpkg") ? "movpkg" : "mp4"
print("Using extension from source file: \(sourceExtension) -> \(fileExtension)")
}
print("Final destination will be: \(safeFilename)-\(uniqueID).\(fileExtension)")
let destinationURL = downloadDir.appendingPathComponent("\(safeFilename)-\(uniqueID).\(fileExtension)")
// Move the file to the persistent location
try fileManager.moveItem(at: location, to: destinationURL)
@ -1458,6 +1523,7 @@ struct JSActiveDownload: Identifiable, Equatable {
let originalURL: URL
var progress: Double
let task: AVAssetDownloadTask?
let urlSessionTask: URLSessionDownloadTask?
let type: DownloadType
var metadata: AssetMetadata?
var title: String?
@ -1468,6 +1534,22 @@ struct JSActiveDownload: Identifiable, Equatable {
var headers: [String: String]
var module: ScrapingModule? // Add module property to store ScrapingModule
// Computed property to get the current task state
var taskState: URLSessionTask.State {
if let avTask = task {
return avTask.state
} else if let urlTask = urlSessionTask {
return urlTask.state
} else {
return .suspended
}
}
// Computed property to get the underlying task for control operations
var underlyingTask: URLSessionTask? {
return task ?? urlSessionTask
}
// Implement Equatable
static func == (lhs: JSActiveDownload, rhs: JSActiveDownload) -> Bool {
return lhs.id == rhs.id &&
@ -1485,6 +1567,7 @@ struct JSActiveDownload: Identifiable, Equatable {
originalURL: URL,
progress: Double = 0,
task: AVAssetDownloadTask? = nil,
urlSessionTask: URLSessionDownloadTask? = nil,
queueStatus: DownloadQueueStatus = .queued,
type: DownloadType = .movie,
metadata: AssetMetadata? = nil,
@ -1499,6 +1582,7 @@ struct JSActiveDownload: Identifiable, Equatable {
self.originalURL = originalURL
self.progress = progress
self.task = task
self.urlSessionTask = urlSessionTask
self.type = type
self.metadata = metadata
self.title = title

View file

@ -70,12 +70,13 @@ extension JSController {
url: url,
headers: headers,
title: title,
imageURL: imageURL ?? showPosterURL,
imageURL: imageURL,
isEpisode: isEpisode,
showTitle: showTitle,
season: season,
episode: episode,
subtitleURL: subtitleURL,
showPosterURL: showPosterURL,
completionHandler: completionHandler
)
}

View file

@ -225,22 +225,47 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
fatalError("Invalid URL string")
}
var request = URLRequest(url: url)
if let mydict = headers, !mydict.isEmpty {
for (key,value) in mydict {
request.addValue(value, forHTTPHeaderField: key)
let asset: AVURLAsset
// Check if this is a local file URL
if url.scheme == "file" {
// For local files, don't add HTTP headers
Logger.shared.log("Loading local file: \(url.absoluteString)", type: "Debug")
// Check if file exists
if FileManager.default.fileExists(atPath: url.path) {
Logger.shared.log("Local file exists at path: \(url.path)", type: "Debug")
} else {
Logger.shared.log("WARNING: Local file does not exist at path: \(url.path)", type: "Error")
}
asset = AVURLAsset(url: url)
} else {
request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Referer")
request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Origin")
// For remote URLs, add HTTP headers
Logger.shared.log("Loading remote URL: \(url.absoluteString)", type: "Debug")
var request = URLRequest(url: url)
if let mydict = headers, !mydict.isEmpty {
for (key,value) in mydict {
request.addValue(value, forHTTPHeaderField: key)
}
} else {
request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Referer")
request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Origin")
}
request.addValue("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36", forHTTPHeaderField: "User-Agent")
asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": request.allHTTPHeaderFields ?? [:]])
}
request.addValue("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36", forHTTPHeaderField: "User-Agent")
let asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": request.allHTTPHeaderFields ?? [:]])
let playerItem = AVPlayerItem(asset: asset)
self.player = AVPlayer(playerItem: playerItem)
// Add error observation
playerItem.addObserver(self, forKeyPath: "status", options: [.new], context: &playerItemKVOContext)
Logger.shared.log("Created AVPlayerItem with status: \(playerItem.status.rawValue)", type: "Debug")
let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(fullUrl)")
if lastPlayedTime > 0 {
let seekTime = CMTime(seconds: lastPlayedTime, preferredTimescale: 1)
@ -282,6 +307,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
setupPipIfSupported()
view.bringSubviewToFront(subtitleStackView)
subtitleStackView.isHidden = !SubtitleSettingsManager.shared.settings.enabled
AniListMutation().fetchMalID(animeId: aniListID) { [weak self] result in
switch result {
@ -446,6 +472,11 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
timeObserverToken = nil
}
// Remove observer from player item if it exists
if let currentItem = player?.currentItem {
currentItem.removeObserver(self, forKeyPath: "status", context: &playerItemKVOContext)
}
player?.replaceCurrentItem(with: nil)
player?.pause()
player = nil
@ -495,7 +526,28 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
return
}
if keyPath == "loadedTimeRanges" {
if keyPath == "status" {
if let playerItem = object as? AVPlayerItem {
switch playerItem.status {
case .readyToPlay:
Logger.shared.log("AVPlayerItem status: Ready to play", type: "Debug")
case .failed:
if let error = playerItem.error {
Logger.shared.log("AVPlayerItem failed with error: \(error.localizedDescription)", type: "Error")
if let nsError = error as NSError? {
Logger.shared.log("Error domain: \(nsError.domain), code: \(nsError.code), userInfo: \(nsError.userInfo)", type: "Error")
}
} else {
Logger.shared.log("AVPlayerItem failed with unknown error", type: "Error")
}
case .unknown:
Logger.shared.log("AVPlayerItem status: Unknown", type: "Debug")
@unknown default:
Logger.shared.log("AVPlayerItem status: Unknown default case", type: "Debug")
}
}
} else if keyPath == "loadedTimeRanges" {
// Handle loaded time ranges if needed
}
}
@ -2034,6 +2086,15 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
private func parseM3U8(url: URL, completion: @escaping () -> Void) {
// For local file URLs, use a simple data task without custom headers
if url.scheme == "file" {
URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
self?.processM3U8Data(data: data, url: url, completion: completion)
}.resume()
return
}
// For remote URLs, add HTTP headers
var request = URLRequest(url: url)
if let mydict = headers, !mydict.isEmpty {
for (key,value) in mydict {
@ -2046,77 +2107,80 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
request.addValue("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36", forHTTPHeaderField: "User-Agent")
URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
guard let self = self,
let data = data,
let content = String(data: data, encoding: .utf8) else {
Logger.shared.log("Failed to load m3u8 file")
DispatchQueue.main.async {
self?.qualities = []
completion()
}
return
self?.processM3U8Data(data: data, url: url, completion: completion)
}.resume()
}
private func processM3U8Data(data: Data?, url: URL, completion: @escaping () -> Void) {
guard let data = data,
let content = String(data: data, encoding: .utf8) else {
Logger.shared.log("Failed to load m3u8 file")
DispatchQueue.main.async {
self.qualities = []
completion()
}
let lines = content.components(separatedBy: .newlines)
var qualities: [(String, String)] = []
qualities.append(("Auto (Recommended)", url.absoluteString))
func getQualityName(for height: Int) -> String {
switch height {
case 1080...: return "\(height)p (FHD)"
case 720..<1080: return "\(height)p (HD)"
case 480..<720: return "\(height)p (SD)"
default: return "\(height)p"
}
return
}
let lines = content.components(separatedBy: .newlines)
var qualities: [(String, String)] = []
qualities.append(("Auto (Recommended)", url.absoluteString))
func getQualityName(for height: Int) -> String {
switch height {
case 1080...: return "\(height)p (FHD)"
case 720..<1080: return "\(height)p (HD)"
case 480..<720: return "\(height)p (SD)"
default: return "\(height)p"
}
for (index, line) in lines.enumerated() {
if line.contains("#EXT-X-STREAM-INF"), index + 1 < lines.count {
if let resolutionRange = line.range(of: "RESOLUTION="),
let resolutionEndRange = line[resolutionRange.upperBound...].range(of: ",")
?? line[resolutionRange.upperBound...].range(of: "\n") {
}
for (index, line) in lines.enumerated() {
if line.contains("#EXT-X-STREAM-INF"), index + 1 < lines.count {
if let resolutionRange = line.range(of: "RESOLUTION="),
let resolutionEndRange = line[resolutionRange.upperBound...].range(of: ",")
?? line[resolutionRange.upperBound...].range(of: "\n") {
let resolutionPart = String(line[resolutionRange.upperBound..<resolutionEndRange.lowerBound])
if let heightStr = resolutionPart.components(separatedBy: "x").last,
let height = Int(heightStr) {
let resolutionPart = String(line[resolutionRange.upperBound..<resolutionEndRange.lowerBound])
if let heightStr = resolutionPart.components(separatedBy: "x").last,
let height = Int(heightStr) {
let nextLine = lines[index + 1].trimmingCharacters(in: .whitespacesAndNewlines)
let qualityName = getQualityName(for: height)
var qualityURL = nextLine
if !nextLine.hasPrefix("http") && nextLine.contains(".m3u8") {
if let baseURL = self.baseM3U8URL {
let baseURLString = baseURL.deletingLastPathComponent().absoluteString
qualityURL = URL(string: nextLine, relativeTo: baseURL)?.absoluteString
?? baseURLString + "/" + nextLine
}
}
if !qualities.contains(where: { $0.0 == qualityName }) {
qualities.append((qualityName, qualityURL))
let nextLine = lines[index + 1].trimmingCharacters(in: .whitespacesAndNewlines)
let qualityName = getQualityName(for: height)
var qualityURL = nextLine
if !nextLine.hasPrefix("http") && nextLine.contains(".m3u8") {
if let baseURL = self.baseM3U8URL {
let baseURLString = baseURL.deletingLastPathComponent().absoluteString
qualityURL = URL(string: nextLine, relativeTo: baseURL)?.absoluteString
?? baseURLString + "/" + nextLine
}
}
if !qualities.contains(where: { $0.0 == qualityName }) {
qualities.append((qualityName, qualityURL))
}
}
}
}
DispatchQueue.main.async {
let autoQuality = qualities.first
var sortedQualities = qualities.dropFirst().sorted { first, second in
let firstHeight = Int(first.0.components(separatedBy: CharacterSet.decimalDigits.inverted).joined()) ?? 0
let secondHeight = Int(second.0.components(separatedBy: CharacterSet.decimalDigits.inverted).joined()) ?? 0
return firstHeight > secondHeight
}
if let auto = autoQuality {
sortedQualities.insert(auto, at: 0)
}
self.qualities = sortedQualities
completion()
}
DispatchQueue.main.async {
let autoQuality = qualities.first
var sortedQualities = qualities.dropFirst().sorted { first, second in
let firstHeight = Int(first.0.components(separatedBy: CharacterSet.decimalDigits.inverted).joined()) ?? 0
let secondHeight = Int(second.0.components(separatedBy: CharacterSet.decimalDigits.inverted).joined()) ?? 0
return firstHeight > secondHeight
}
}.resume()
if let auto = autoQuality {
sortedQualities.insert(auto, at: 0)
}
self.qualities = sortedQualities
completion()
}
}
private func switchToQuality(urlString: String) {
@ -2126,20 +2190,48 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
let currentTime = player.currentTime()
let wasPlaying = player.rate > 0
var request = URLRequest(url: url)
if let mydict = headers, !mydict.isEmpty {
for (key,value) in mydict {
request.addValue(value, forHTTPHeaderField: key)
}
} else {
request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Referer")
request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Origin")
}
request.addValue("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36", forHTTPHeaderField: "User-Agent")
let asset: AVURLAsset
// Check if this is a local file URL
if url.scheme == "file" {
// For local files, don't add HTTP headers
Logger.shared.log("Switching to local file: \(url.absoluteString)", type: "Debug")
// Check if file exists
if FileManager.default.fileExists(atPath: url.path) {
Logger.shared.log("Local file exists for quality switch: \(url.path)", type: "Debug")
} else {
Logger.shared.log("WARNING: Local file does not exist for quality switch: \(url.path)", type: "Error")
}
asset = AVURLAsset(url: url)
} else {
// For remote URLs, add HTTP headers
Logger.shared.log("Switching to remote URL: \(url.absoluteString)", type: "Debug")
var request = URLRequest(url: url)
if let mydict = headers, !mydict.isEmpty {
for (key,value) in mydict {
request.addValue(value, forHTTPHeaderField: key)
}
} else {
request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Referer")
request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Origin")
}
request.addValue("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36", forHTTPHeaderField: "User-Agent")
asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": request.allHTTPHeaderFields ?? [:]])
}
let asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": request.allHTTPHeaderFields ?? [:]])
let playerItem = AVPlayerItem(asset: asset)
// Add observer for the new player item
playerItem.addObserver(self, forKeyPath: "status", options: [.new], context: &playerItemKVOContext)
// Remove observer from old item if it exists
if let currentItem = player.currentItem {
currentItem.removeObserver(self, forKeyPath: "status", context: &playerItemKVOContext)
}
player.replaceCurrentItem(with: playerItem)
player.seek(to: currentTime)
if wasPlaying {

View file

@ -8,6 +8,7 @@
import UIKit
struct SubtitleSettings: Codable {
var enabled: Bool = true
var foregroundColor: String = "white"
var fontSize: Double = 20.0
var shadowRadius: Double = 1.0

View file

@ -240,39 +240,26 @@ struct DownloadView: View {
metadataUrl: ""
)
if streamType == "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()
}
}
} else {
let customPlayer = CustomMediaPlayerViewController(
module: dummyModule,
urlString: asset.localURL.absoluteString,
fullUrl: asset.originalURL.absoluteString,
title: asset.metadata?.showTitle ?? asset.name,
episodeNumber: asset.metadata?.episode ?? 0,
onWatchNext: {},
subtitlesURL: asset.localSubtitleURL?.absoluteString,
aniListID: 0,
totalEpisodes: asset.metadata?.episode ?? 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)
}
// Always use CustomMediaPlayerViewController for consistency
let customPlayer = CustomMediaPlayerViewController(
module: dummyModule,
urlString: asset.localURL.absoluteString,
fullUrl: asset.originalURL.absoluteString,
title: asset.metadata?.showTitle ?? asset.name,
episodeNumber: asset.metadata?.episode ?? 0,
onWatchNext: {},
subtitlesURL: asset.localSubtitleURL?.absoluteString,
aniListID: 0,
totalEpisodes: asset.metadata?.episode ?? 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)
}
}
}
@ -733,7 +720,7 @@ struct EnhancedActiveDownloadCard: View {
init(download: JSActiveDownload) {
self.download = download
_currentProgress = State(initialValue: download.progress)
_taskState = State(initialValue: download.task?.state ?? .suspended)
_taskState = State(initialValue: download.taskState)
}
var body: some View {
@ -868,18 +855,30 @@ struct EnhancedActiveDownloadCard: View {
withAnimation(.easeInOut(duration: 0.1)) {
currentProgress = currentDownload.progress
}
if let task = currentDownload.task {
taskState = task.state
}
taskState = currentDownload.taskState
}
}
private func toggleDownload() {
if taskState == .running {
download.task?.suspend()
// Pause the download
if download.task != nil {
// M3U8 download - use AVAssetDownloadTask
download.underlyingTask?.suspend()
} else if download.urlSessionTask != nil {
// MP4 download - use dedicated method
JSController.shared.pauseMP4Download(download.id)
}
taskState = .suspended
} else if taskState == .suspended {
download.task?.resume()
// Resume the download
if download.task != nil {
// M3U8 download - use AVAssetDownloadTask
download.underlyingTask?.resume()
} else if download.urlSessionTask != nil {
// MP4 download - use dedicated method
JSController.shared.resumeMP4Download(download.id)
}
taskState = .running
}
}

View file

@ -494,10 +494,21 @@ struct MediaInfoView: View {
}
}
Button(action: {
fetchTMDBPosterImageAndSet()
}) {
Label("Use TMDB Poster Image", systemImage: "photo")
if UserDefaults.standard.string(forKey: "originalPoster_\(href)") != nil {
Button(action: {
if let originalPoster = UserDefaults.standard.string(forKey: "originalPoster_\(href)") {
imageUrl = originalPoster
UserDefaults.standard.removeObject(forKey: "tmdbPosterURL_\(href)")
}
}) {
Label("Revert Module Poster", systemImage: "photo.badge.arrow.down")
}
} else {
Button(action: {
fetchTMDBPosterImageAndSet()
}) {
Label("Use TMDB Poster Image", systemImage: "photo")
}
}
Divider()
@ -841,6 +852,9 @@ struct MediaInfoView: View {
imageUrl = "https://image.tmdb.org/t/p/w\(tmdbImageWidth)\(posterPath)"
}
DispatchQueue.main.async {
let currentPosterKey = "originalPoster_\(self.href)"
let currentPoster = self.imageUrl
UserDefaults.standard.set(currentPoster, forKey: currentPosterKey)
self.imageUrl = imageUrl
UserDefaults.standard.set(imageUrl, forKey: "tmdbPosterURL_\(self.href)")
}
@ -1216,7 +1230,7 @@ struct MediaInfoView: View {
guard self.activeFetchID == fetchID else {
return
}
self.playStream(url: streamUrl, fullURL: fullURL, subtitles: subtitles, headers: headers, fetchID: fetchID)
self.playStream(url: streamUrl, fullURL: href, subtitles: subtitles, headers: headers, fetchID: fetchID)
})
streamIndex += 1

View file

@ -137,7 +137,7 @@ fileprivate struct SettingsButtonRow: View {
struct SettingsViewData: View {
@State private var showAlert = false
@State private var cacheSizeText: String = "Calculating..."
@State private var cacheSizeText: String = "..."
@State private var isCalculatingSize: Bool = false
@State private var cacheSize: Int64 = 0
@State private var documentsSize: Int64 = 0
@ -158,20 +158,24 @@ struct SettingsViewData: View {
) {
VStack(spacing: 0) {
HStack {
Image(systemName: "folder.badge.gearshape")
.frame(width: 24, height: 24)
.foregroundStyle(.primary)
Text("Current Cache Size")
.foregroundStyle(.primary)
Spacer()
if isCalculatingSize {
ProgressView()
.scaleEffect(0.7)
.padding(.trailing, 5)
Button(action: {
activeAlert = .clearCache
showAlert = true
}) {
HStack {
Image(systemName: "trash")
.frame(width: 24, height: 24)
.foregroundStyle(.red)
Text("Remove All Caches")
.foregroundStyle(.red)
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
.buttonStyle(PlainButtonStyle())
Text(cacheSizeText)
.foregroundStyle(.gray)
@ -179,30 +183,11 @@ struct SettingsViewData: View {
.padding(.horizontal, 16)
.padding(.vertical, 12)
Button(action: {
activeAlert = .clearCache
showAlert = true
}) {
HStack {
Image(systemName: "trash")
.frame(width: 24, height: 24)
.foregroundStyle(.red)
Text("Clear All Caches")
.foregroundStyle(.red)
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
.buttonStyle(PlainButtonStyle())
Divider().padding(.horizontal, 16)
SettingsButtonRow(
icon: "film",
title: "Remove Downloaded Media",
title: "Remove Downloads",
subtitle: formatSize(downloadsSize),
action: {
activeAlert = .removeDownloads
@ -214,7 +199,7 @@ struct SettingsViewData: View {
SettingsButtonRow(
icon: "doc.text",
title: "Remove All Files in Documents",
title: "Remove All Documents",
subtitle: formatSize(documentsSize),
action: {
activeAlert = .removeDocs
@ -286,7 +271,7 @@ struct SettingsViewData: View {
func calculateCacheSize() {
isCalculatingSize = true
cacheSizeText = "Calculating..."
cacheSizeText = "..."
DispatchQueue.global(qos: .background).async {
if let cacheURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first {
@ -298,7 +283,7 @@ struct SettingsViewData: View {
}
} else {
DispatchQueue.main.async {
self.cacheSizeText = "Unknown"
self.cacheSizeText = "N/A"
self.isCalculatingSize = false
}
}

View file

@ -342,12 +342,25 @@ struct SubtitleSettingsSection: View {
@State private var backgroundEnabled: Bool = SubtitleSettingsManager.shared.settings.backgroundEnabled
@State private var bottomPadding: Double = Double(SubtitleSettingsManager.shared.settings.bottomPadding)
@State private var subtitleDelay: Double = SubtitleSettingsManager.shared.settings.subtitleDelay
@AppStorage("subtitlesEnabled") private var subtitlesEnabled: Bool = true
private let colors = ["white", "yellow", "green", "blue", "red", "purple"]
private let shadowOptions = [0, 1, 3, 6]
var body: some View {
SettingsSection(title: "Subtitle Settings") {
SettingsSection(title: "Subtitle Settings") {
SettingsToggleRow(
icon: "captions.bubble",
title: "Enable Subtitles",
isOn: $subtitlesEnabled,
showDivider: false
)
.onChange(of: subtitlesEnabled) { newValue in
SubtitleSettingsManager.shared.update { settings in
settings.enabled = newValue
}
}
SettingsPickerRow(
icon: "paintbrush",
title: "Subtitle Color",
@ -416,4 +429,4 @@ struct SubtitleSettingsSection: View {
}
}
}
}
}