many improvements (#107)

* few player bug fixes (#104)

* icloud safe checking

* more tests

* removed ffmpeg sorry

* test

* Revert "test"

This reverts commit cbf7412d47.

* custom player stuffs idk if it builds

* fire Seiike moment

* ok my fault this time

* Create banner1.png

* seiike ahh moment

* added light mode banner

* Update EpisodeCell.swift

* seiike ahh moment x2

* ops

* fixed intros skipper buttons

* fixed pan crashes

* added speed indicator for hold speed

---------

Co-authored-by: Seiike <122684677+Seeike@users.noreply.github.com>
This commit is contained in:
cranci 2025-04-25 17:38:29 +02:00 committed by GitHub
parent cefd996115
commit 8c73798195
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 414 additions and 514 deletions

View file

@ -1,211 +0,0 @@
//
// DownloadManager.swift
// Sulfur
//
// Created by Francesco on 09/03/25.
//
import Foundation
import FFmpegSupport
import UIKit
class DownloadManager {
static let shared = DownloadManager()
private var backgroundTaskIdentifier: UIBackgroundTaskIdentifier = .invalid
private var activeConversions = [String: Bool]()
private init() {
NotificationCenter.default.addObserver(self, selector: #selector(applicationWillResignActive), name: UIApplication.willResignActiveNotification, object: nil)
}
@objc private func applicationWillResignActive() {
if !activeConversions.isEmpty {
backgroundTaskIdentifier = UIApplication.shared.beginBackgroundTask { [weak self] in
self?.endBackgroundTask()
}
}
}
private func endBackgroundTask() {
if backgroundTaskIdentifier != .invalid {
UIApplication.shared.endBackgroundTask(backgroundTaskIdentifier)
backgroundTaskIdentifier = .invalid
}
}
func downloadAndConvertHLS(from url: URL, title: String, episode: Int, subtitleURL: URL? = nil, module: ScrapingModule, completion: @escaping (Bool, URL?) -> Void) {
guard let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else {
completion(false, nil)
return
}
let folderURL = documentsDirectory.appendingPathComponent(title + "-" + module.metadata.sourceName)
if (!FileManager.default.fileExists(atPath: folderURL.path)) {
do {
try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true, attributes: nil)
} catch {
Logger.shared.log("Error creating folder: \(error)")
completion(false, nil)
return
}
}
let outputFileName = "\(title)_Episode\(episode)_\(module.metadata.sourceName).mp4"
let outputFileURL = folderURL.appendingPathComponent(outputFileName)
let fileExtension = url.pathExtension.lowercased()
if fileExtension == "mp4" {
NotificationCenter.default.post(name: .DownloadManagerStatusUpdate, object: nil, userInfo: [
"title": title,
"episode": episode,
"type": "mp4",
"status": "Downloading",
"progress": 0.0
])
let task = URLSession.custom.downloadTask(with: url) { tempLocalURL, response, error in
if let tempLocalURL = tempLocalURL {
do {
try FileManager.default.moveItem(at: tempLocalURL, to: outputFileURL)
NotificationCenter.default.post(name: .DownloadManagerStatusUpdate, object: nil, userInfo: [
"title": title,
"episode": episode,
"type": "mp4",
"status": "Completed",
"progress": 1.0
])
DispatchQueue.main.async {
Logger.shared.log("Download successful: \(outputFileURL)")
completion(true, outputFileURL)
}
} catch {
DispatchQueue.main.async {
Logger.shared.log("Download failed: \(error)")
completion(false, nil)
}
}
} else {
DispatchQueue.main.async {
Logger.shared.log("Download failed: \(error?.localizedDescription ?? "Unknown error")")
completion(false, nil)
}
}
}
task.resume()
} else if fileExtension == "m3u8" {
let conversionKey = "\(title)_\(episode)_\(module.metadata.sourceName)"
activeConversions[conversionKey] = true
if UIApplication.shared.applicationState != .active && backgroundTaskIdentifier == .invalid {
backgroundTaskIdentifier = UIApplication.shared.beginBackgroundTask { [weak self] in
self?.endBackgroundTask()
}
}
DispatchQueue.global(qos: .background).async {
NotificationCenter.default.post(name: .DownloadManagerStatusUpdate, object: nil, userInfo: [
"title": title,
"episode": episode,
"type": "hls",
"status": "Converting",
"progress": 0.0
])
let processorCount = ProcessInfo.processInfo.processorCount
let physicalMemory = ProcessInfo.processInfo.physicalMemory / (1024 * 1024)
var ffmpegCommand = ["ffmpeg", "-y"]
ffmpegCommand.append(contentsOf: ["-protocol_whitelist", "file,http,https,tcp,tls"])
ffmpegCommand.append(contentsOf: ["-fflags", "+genpts"])
ffmpegCommand.append(contentsOf: ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5"])
ffmpegCommand.append(contentsOf: ["-headers", "Referer: \(module.metadata.baseUrl)\nOrigin: \(module.metadata.baseUrl)"])
let multiThreads = UserDefaults.standard.bool(forKey: "multiThreads")
if multiThreads {
let threadCount = max(2, processorCount - 1)
ffmpegCommand.append(contentsOf: ["-threads", "\(threadCount)"])
} else {
ffmpegCommand.append(contentsOf: ["-threads", "2"])
}
let bufferSize = min(32, max(8, Int(physicalMemory) / 256))
ffmpegCommand.append(contentsOf: ["-bufsize", "\(bufferSize)M"])
ffmpegCommand.append(contentsOf: ["-i", url.absoluteString])
if let subtitleURL = subtitleURL {
do {
let subtitleData = try Data(contentsOf: subtitleURL)
let subtitleFileExtension = subtitleURL.pathExtension.lowercased()
if subtitleFileExtension != "srt" && subtitleFileExtension != "vtt" {
Logger.shared.log("Unsupported subtitle format: \(subtitleFileExtension)")
}
let subtitleFileName = "\(title)_Episode\(episode).\(subtitleFileExtension)"
let subtitleLocalURL = folderURL.appendingPathComponent(subtitleFileName)
try subtitleData.write(to: subtitleLocalURL)
ffmpegCommand.append(contentsOf: ["-i", subtitleLocalURL.path])
ffmpegCommand.append(contentsOf: [
"-c:v", "copy",
"-c:a", "copy",
"-c:s", "mov_text",
"-disposition:s:0", "default+forced",
"-metadata:s:s:0", "handler_name=English",
"-metadata:s:s:0", "language=eng"
])
ffmpegCommand.append(outputFileURL.path)
} catch {
Logger.shared.log("Subtitle download failed: \(error)")
ffmpegCommand.append(contentsOf: ["-c:v", "copy", "-c:a", "copy"])
ffmpegCommand.append(contentsOf: ["-movflags", "+faststart"])
ffmpegCommand.append(outputFileURL.path)
}
} else {
ffmpegCommand.append(contentsOf: ["-c:v", "copy", "-c:a", "copy"])
ffmpegCommand.append(contentsOf: ["-movflags", "+faststart"])
ffmpegCommand.append(outputFileURL.path)
}
Logger.shared.log("FFmpeg command: \(ffmpegCommand.joined(separator: " "))", type: "Debug")
NotificationCenter.default.post(name: .DownloadManagerStatusUpdate, object: nil, userInfo: [
"title": title,
"episode": episode,
"type": "hls",
"status": "Converting",
"progress": 0.5
])
let success = ffmpeg(ffmpegCommand)
DispatchQueue.main.async { [weak self] in
if success == 0 {
NotificationCenter.default.post(name: .DownloadManagerStatusUpdate, object: nil, userInfo: [
"title": title,
"episode": episode,
"type": "hls",
"status": "Completed",
"progress": 1.0
])
Logger.shared.log("Conversion successful: \(outputFileURL)")
completion(true, outputFileURL)
} else {
Logger.shared.log("Conversion failed")
completion(false, nil)
}
self?.activeConversions[conversionKey] = nil
if self?.activeConversions.isEmpty ?? true {
self?.endBackgroundTask()
}
}
}
} else {
Logger.shared.log("Unsupported file type: \(fileExtension)")
completion(false, nil)
}
}
}

View file

@ -18,8 +18,8 @@ struct MusicProgressSlider<T: BinaryFloatingPoint>: View {
let emptyColor: Color
let height: CGFloat
let onEditingChanged: (Bool) -> Void
let introSegments: [ClosedRange<T>] // Changed
let outroSegments: [ClosedRange<T>] // Changed
let introSegments: [ClosedRange<T>]
let outroSegments: [ClosedRange<T>]
let introColor: Color
let outroColor: Color
@ -57,10 +57,10 @@ struct MusicProgressSlider<T: BinaryFloatingPoint>: View {
}
}
// Rest of the existing code...
Capsule()
.fill(emptyColor)
}
.clipShape(Capsule())
Capsule()
.fill(isActive ? activeFillColor : fillColor)

View file

@ -11,7 +11,6 @@ import AVKit
import SwiftUI
import AVFoundation
import MediaPlayer
// MARK: - CustomMediaPlayerViewController
class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDelegate {
let module: ScrapingModule
@ -94,6 +93,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
var speedButton: UIButton!
var skip85Button: UIButton!
var qualityButton: UIButton!
var holdSpeedIndicator: UIButton!
var isHLSStream: Bool = false
var qualities: [(String, String)] = []
@ -116,6 +116,8 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
}
}
private var wasPlayingBeforeSeek = false
private var malID: Int?
private var skipIntervals: (op: CMTimeRange?, ed: CMTimeRange?) = (nil, nil)
@ -123,6 +125,12 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
private var skipOutroButton: UIButton!
private let skipButtonBaseAlpha: CGFloat = 0.9
@Published var segments: [ClosedRange<Double>] = []
private var skipIntroLeading: NSLayoutConstraint!
private var skipOutroLeading: NSLayoutConstraint!
private var originalIntroLeading: CGFloat = 0
private var originalOutroLeading: CGFloat = 0
private var skipIntroDismissedInSession = false
private var skipOutroDismissedInSession = false
private var playerItemKVOContext = 0
private var loadedTimeRangesObservation: NSKeyValueObservation?
@ -214,7 +222,6 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
loadSubtitleSettings()
setupPlayerViewController()
setupControls()
setupSkipAndDismissGestures()
addInvisibleControlOverlays()
setupWatchNextButton()
setupSubtitleLabel()
@ -227,11 +234,15 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
setupMarqueeLabel()
setupSkip85Button()
setupSkipButtons()
setupSkipAndDismissGestures()
addTimeObserver()
startUpdateTimer()
setupAudioSession()
updateSkipButtonsVisibility()
setupHoldSpeedIndicator()
view.bringSubviewToFront(subtitleLabel)
view.bringSubviewToFront(topSubtitleLabel)
AniListMutation().fetchMalID(animeId: aniListID) { [weak self] result in
switch result {
@ -240,7 +251,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
self?.fetchSkipTimes(type: "op")
self?.fetchSkipTimes(type: "ed")
case .failure(let error):
Logger.shared.log("⚠️ Unable to fetch MAL ID: \(error)",type:"Error")
Logger.shared.log("Unable to fetch MAL ID: \(error)",type:"Error")
}
}
@ -270,7 +281,6 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
}
}
if #available(iOS 16.0, *) {
playerViewController.allowsVideoFrameAnalysis = false
}
@ -374,14 +384,11 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
if self.qualityButton.isHidden && self.isHLSStream {
// 1) reveal the quality button
self.qualityButton.isHidden = false
self.qualityButton.menu = self.qualitySelectionMenu()
// 2) update the trailing constraint for the menuButton
self.updateMenuButtonConstraints()
// 3) animate the shift
UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseInOut) {
self.view.layoutIfNeeded()
}
@ -523,11 +530,19 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
onEditingChanged: { editing in
if editing {
self.isSliderEditing = true
self.wasPlayingBeforeSeek = (self.player.timeControlStatus == .playing)
self.originalRate = self.player.rate
self.player.pause()
} else {
let wasPlaying = self.isPlaying
let targetTime = CMTime(seconds: self.sliderViewModel.sliderValue,
preferredTimescale: 600)
self.player.seek(to: targetTime) { [weak self] finished in
let target = CMTime(seconds: self.sliderViewModel.sliderValue,
preferredTimescale: 600)
self.player.seek(
to: target,
toleranceBefore: .zero,
toleranceAfter: .zero
) { [weak self] _ in
guard let self = self else { return }
let final = self.player.currentTime().seconds
@ -535,16 +550,16 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
self.currentTimeVal = final
self.isSliderEditing = false
if wasPlaying {
self.player.play()
if self.wasPlayingBeforeSeek {
self.player.playImmediately(atRate: self.originalRate)
}
}
}
},
introSegments: sliderViewModel.introSegments, // Added
outroSegments: sliderViewModel.outroSegments, // Added
introColor: segmentsColor, // Add your colors here
outroColor: segmentsColor // Or use settings.accentColor
introSegments: sliderViewModel.introSegments,
outroSegments: sliderViewModel.outroSegments,
introColor: segmentsColor,
outroColor: segmentsColor
)
sliderHostingController = UIHostingController(rootView: sliderView)
@ -615,6 +630,16 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
}
let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture(_:)))
if let introSwipe = skipIntroButton.gestureRecognizers?.first(
where: { $0 is UISwipeGestureRecognizer && ($0 as! UISwipeGestureRecognizer).direction == .left }
),
let outroSwipe = skipOutroButton.gestureRecognizers?.first(
where: { $0 is UISwipeGestureRecognizer && ($0 as! UISwipeGestureRecognizer).direction == .left }
) {
panGesture.require(toFail: introSwipe)
panGesture.require(toFail: outroSwipe)
}
view.addGestureRecognizer(panGesture)
}
@ -693,46 +718,50 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
func setupSubtitleLabel() {
subtitleLabel = UILabel()
subtitleLabel.textAlignment = .center
subtitleLabel.numberOfLines = 0
subtitleLabel.font = UIFont.systemFont(ofSize: CGFloat(subtitleFontSize))
view.addSubview(subtitleLabel)
subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
subtitleLabel?.textAlignment = .center
subtitleLabel?.numberOfLines = 0
subtitleLabel?.font = UIFont.systemFont(ofSize: CGFloat(subtitleFontSize))
if let subtitleLabel = subtitleLabel {
view.addSubview(subtitleLabel)
subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
subtitleBottomToSliderConstraint = subtitleLabel.bottomAnchor.constraint(
equalTo: sliderHostingController!.view.topAnchor,
constant: -20
)
subtitleBottomToSliderConstraint = subtitleLabel.bottomAnchor.constraint(
equalTo: sliderHostingController?.view.topAnchor ?? view.bottomAnchor,
constant: -20
)
subtitleBottomToSafeAreaConstraint = subtitleLabel.bottomAnchor.constraint(
equalTo: view.safeAreaLayoutGuide.bottomAnchor,
constant: -subtitleBottomPadding
)
subtitleBottomToSafeAreaConstraint = subtitleLabel.bottomAnchor.constraint(
equalTo: view.safeAreaLayoutGuide.bottomAnchor,
constant: -subtitleBottomPadding
)
NSLayoutConstraint.activate([
subtitleLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
subtitleLabel.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor, constant: 36),
subtitleLabel.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -36)
])
NSLayoutConstraint.activate([
subtitleLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
subtitleLabel.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor, constant: 36),
subtitleLabel.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -36)
])
subtitleBottomToSafeAreaConstraint?.isActive = true
subtitleBottomToSafeAreaConstraint?.isActive = true
}
topSubtitleLabel = UILabel()
topSubtitleLabel.textAlignment = .center
topSubtitleLabel.numberOfLines = 0
topSubtitleLabel.font = UIFont.systemFont(ofSize: CGFloat(subtitleFontSize))
topSubtitleLabel.isHidden = true
view.addSubview(topSubtitleLabel)
topSubtitleLabel.translatesAutoresizingMaskIntoConstraints = false
topSubtitleLabel?.textAlignment = .center
topSubtitleLabel?.numberOfLines = 0
topSubtitleLabel?.font = UIFont.systemFont(ofSize: CGFloat(subtitleFontSize))
topSubtitleLabel?.isHidden = true
if let topSubtitleLabel = topSubtitleLabel {
view.addSubview(topSubtitleLabel)
topSubtitleLabel.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
topSubtitleLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
topSubtitleLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 30),
topSubtitleLabel.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor, constant: 36),
topSubtitleLabel.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -36)
])
}
updateSubtitleLabelAppearance()
NSLayoutConstraint.activate([
topSubtitleLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
topSubtitleLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 30),
topSubtitleLabel.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor, constant: 36),
topSubtitleLabel.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -36)
])
}
func updateSubtitleLabelConstraints() {
@ -780,10 +809,10 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
marqueeLabel.textColor = .white
marqueeLabel.font = UIFont.systemFont(ofSize: 14, weight: .heavy)
marqueeLabel.speed = .rate(35) // Adjust scrolling speed as needed
marqueeLabel.fadeLength = 10.0 // Fading at the labels edges
marqueeLabel.leadingBuffer = 1.0 // Left inset for scrolling
marqueeLabel.trailingBuffer = 16.0 // Right inset for scrolling
marqueeLabel.speed = .rate(35)
marqueeLabel.fadeLength = 10.0
marqueeLabel.leadingBuffer = 1.0
marqueeLabel.trailingBuffer = 16.0
marqueeLabel.animationDelay = 2.5
marqueeLabel.layer.shadowColor = UIColor.black.cgColor
@ -798,33 +827,6 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
controlsContainerView.addSubview(marqueeLabel)
marqueeLabel.translatesAutoresizingMaskIntoConstraints = false
// 1. Portrait mode with button visible
portraitButtonVisibleConstraints = [
marqueeLabel.leadingAnchor.constraint(equalTo: dismissButton.trailingAnchor, constant: 8),
marqueeLabel.trailingAnchor.constraint(equalTo: menuButton.leadingAnchor, constant: -16),
marqueeLabel.centerYAnchor.constraint(equalTo: dismissButton.centerYAnchor)
]
// 2. Portrait mode with button hidden
portraitButtonHiddenConstraints = [
marqueeLabel.leadingAnchor.constraint(equalTo: dismissButton.trailingAnchor, constant: 12),
marqueeLabel.trailingAnchor.constraint(equalTo: controlsContainerView.trailingAnchor, constant: -16),
marqueeLabel.centerYAnchor.constraint(equalTo: dismissButton.centerYAnchor)
]
// 3. Landscape mode with button visible (using smaller margins)
landscapeButtonVisibleConstraints = [
marqueeLabel.leadingAnchor.constraint(equalTo: dismissButton.trailingAnchor, constant: 8),
marqueeLabel.trailingAnchor.constraint(equalTo: menuButton.leadingAnchor, constant: -8),
marqueeLabel.centerYAnchor.constraint(equalTo: dismissButton.centerYAnchor)
]
// 4. Landscape mode with button hidden
landscapeButtonHiddenConstraints = [
marqueeLabel.leadingAnchor.constraint(equalTo: dismissButton.trailingAnchor, constant: 8),
marqueeLabel.trailingAnchor.constraint(equalTo: controlsContainerView.trailingAnchor, constant: -8),
marqueeLabel.centerYAnchor.constraint(equalTo: dismissButton.centerYAnchor)
]
updateMarqueeConstraints()
}
@ -853,9 +855,44 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
])
}
private func setupHoldSpeedIndicator() {
let config = UIImage.SymbolConfiguration(pointSize: 14, weight: .bold)
let image = UIImage(systemName: "forward.fill", withConfiguration: config)
let speed = UserDefaults.standard.float(forKey: "holdSpeedPlayer")
holdSpeedIndicator = UIButton(type: .system)
holdSpeedIndicator.setTitle(" \(speed)", for: .normal)
holdSpeedIndicator.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .bold)
holdSpeedIndicator.setImage(image, for: .normal)
holdSpeedIndicator.backgroundColor = UIColor(red: 51/255.0, green: 51/255.0, blue: 51/255.0, alpha: 0.8)
holdSpeedIndicator.tintColor = .white
holdSpeedIndicator.setTitleColor(.white, for: .normal)
holdSpeedIndicator.layer.cornerRadius = 21
holdSpeedIndicator.alpha = 0
holdSpeedIndicator.layer.shadowColor = UIColor.black.cgColor
holdSpeedIndicator.layer.shadowOffset = CGSize(width: 0, height: 2)
holdSpeedIndicator.layer.shadowOpacity = 0.6
holdSpeedIndicator.layer.shadowRadius = 4
holdSpeedIndicator.layer.masksToBounds = false
view.addSubview(holdSpeedIndicator)
holdSpeedIndicator.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
holdSpeedIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor),
holdSpeedIndicator.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20),
holdSpeedIndicator.heightAnchor.constraint(equalToConstant: 40),
holdSpeedIndicator.widthAnchor.constraint(greaterThanOrEqualToConstant: 85)
])
holdSpeedIndicator.isUserInteractionEnabled = false
}
private func updateSkipButtonsVisibility() {
let t = currentTimeVal
let controlsShowing = isControlsVisible // true main UI is onscreen
let t = currentTimeVal
let controlsShowing = isControlsVisible
func handle(_ button: UIButton, range: CMTimeRange?) {
guard let r = range else { button.isHidden = true; return }
@ -885,6 +922,17 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
handle(skipIntroButton, range: skipIntervals.op)
handle(skipOutroButton, range: skipIntervals.ed)
if skipIntroDismissedInSession {
skipIntroButton.isHidden = true
} else {
handle(skipIntroButton, range: skipIntervals.op)
}
if skipOutroDismissedInSession {
skipOutroButton.isHidden = true
} else {
handle(skipOutroButton, range: skipIntervals.ed)
}
}
private func updateSegments() {
@ -918,17 +966,38 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
emptyColor: .white.opacity(0.3),
height: 33,
onEditingChanged: { editing in
if !editing {
let targetTime = CMTime(
seconds: self.sliderViewModel.sliderValue,
preferredTimescale: 600
)
self.player.seek(to: targetTime)
if editing {
self.isSliderEditing = true
self.wasPlayingBeforeSeek = (self.player.timeControlStatus == .playing)
self.originalRate = self.player.rate
self.player.pause()
} else {
let target = CMTime(seconds: self.sliderViewModel.sliderValue,
preferredTimescale: 600)
self.player.seek(
to: target,
toleranceBefore: .zero,
toleranceAfter: .zero
) { [weak self] _ in
guard let self = self else { return }
let final = self.player.currentTime().seconds
self.sliderViewModel.sliderValue = final
self.currentTimeVal = final
self.isSliderEditing = false
if self.wasPlayingBeforeSeek {
self.player.playImmediately(atRate: self.originalRate)
}
}
}
},
introSegments: self.sliderViewModel.introSegments,
outroSegments: self.sliderViewModel.outroSegments,
introColor: segmentsColor, // Match your color choices
introColor: segmentsColor,
outroColor: segmentsColor
)
}
@ -953,7 +1022,6 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
} else {
self.skipIntervals.ed = range
}
// Update segments only if duration is available
if self.duration > 0 {
self.updateSegments()
}
@ -961,22 +1029,20 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
}.resume()
}
private func setupSkipButtons() {
func setupSkipButtons() {
let introConfig = UIImage.SymbolConfiguration(pointSize: 14, weight: .bold)
let introImage = UIImage(systemName: "forward.frame", withConfiguration: introConfig)
skipIntroButton = UIButton(type: .system)
skipIntroButton.setImage(introImage, for: .normal)
skipIntroButton.setTitle(" Skip Intro", for: .normal)
skipIntroButton.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .bold)
skipIntroButton.setImage(introImage, for: .normal)
// match skip85Button styling:
skipIntroButton.backgroundColor = UIColor(red: 51/255, green: 51/255, blue: 51/255, alpha: 0.8)
skipIntroButton.backgroundColor = UIColor(red: 51/255.0, green: 51/255.0, blue: 51/255.0, alpha: 0.8)
skipIntroButton.tintColor = .white
skipIntroButton.setTitleColor(.white, for: .normal)
skipIntroButton.layer.cornerRadius = 15
skipIntroButton.layer.cornerRadius = 21
skipIntroButton.alpha = skipButtonBaseAlpha
skipIntroButton.contentEdgeInsets = UIEdgeInsets(top: 6, left: 10, bottom: 6, right: 10)
skipIntroButton.layer.shadowColor = UIColor.black.cgColor
skipIntroButton.layer.shadowOffset = CGSize(width: 0, height: 2)
skipIntroButton.layer.shadowOpacity = 0.6
@ -984,50 +1050,47 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
skipIntroButton.layer.masksToBounds = false
skipIntroButton.addTarget(self, action: #selector(skipIntro), for: .touchUpInside)
view.addSubview(skipIntroButton)
skipIntroButton.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
skipIntroButton.leadingAnchor.constraint(
equalTo: sliderHostingController!.view.leadingAnchor),
skipIntroButton.bottomAnchor.constraint(
equalTo: sliderHostingController!.view.topAnchor, constant: -5)
skipIntroButton.trailingAnchor.constraint(equalTo: sliderHostingController!.view.trailingAnchor),
skipIntroButton.bottomAnchor.constraint(equalTo: sliderHostingController!.view.topAnchor, constant: -5),
skipIntroButton.heightAnchor.constraint(equalToConstant: 40),
skipIntroButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 104)
])
// MARK: Skip Outro Button
let outroConfig = UIImage.SymbolConfiguration(pointSize: 14, weight: .bold)
let outroImage = UIImage(systemName: "forward.frame", withConfiguration: outroConfig)
skipOutroButton = UIButton(type: .system)
skipOutroButton.setImage(outroImage, for: .normal)
skipOutroButton.setTitle(" Skip Outro", for: .normal)
skipOutroButton.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .bold)
skipOutroButton.setImage(outroImage, for: .normal)
// same styling as above
skipOutroButton.backgroundColor = skipIntroButton.backgroundColor
skipOutroButton.tintColor = skipIntroButton.tintColor
skipOutroButton.backgroundColor = UIColor(red: 51/255.0, green: 51/255.0, blue: 51/255.0, alpha: 0.8)
skipOutroButton.tintColor = .white
skipOutroButton.setTitleColor(.white, for: .normal)
skipOutroButton.layer.cornerRadius = skipIntroButton.layer.cornerRadius
skipOutroButton.alpha = skipIntroButton.alpha
skipOutroButton.contentEdgeInsets = skipIntroButton.contentEdgeInsets
skipOutroButton.layer.shadowColor = skipIntroButton.layer.shadowColor
skipOutroButton.layer.shadowOffset = skipIntroButton.layer.shadowOffset
skipOutroButton.layer.shadowOpacity = skipIntroButton.layer.shadowOpacity
skipOutroButton.layer.shadowRadius = skipIntroButton.layer.shadowRadius
skipOutroButton.layer.cornerRadius = 21
skipOutroButton.alpha = skipButtonBaseAlpha
skipOutroButton.layer.shadowColor = UIColor.black.cgColor
skipOutroButton.layer.shadowOffset = CGSize(width: 0, height: 2)
skipOutroButton.layer.shadowOpacity = 0.6
skipOutroButton.layer.shadowRadius = 4
skipOutroButton.layer.masksToBounds = false
skipOutroButton.addTarget(self, action: #selector(skipOutro), for: .touchUpInside)
view.addSubview(skipOutroButton)
skipOutroButton.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
skipOutroButton.leadingAnchor.constraint(
equalTo: sliderHostingController!.view.leadingAnchor),
skipOutroButton.bottomAnchor.constraint(
equalTo: sliderHostingController!.view.topAnchor, constant: -5)
skipOutroButton.trailingAnchor.constraint(equalTo: sliderHostingController!.view.trailingAnchor),
skipOutroButton.bottomAnchor.constraint(equalTo: sliderHostingController!.view.topAnchor, constant: -5),
skipOutroButton.heightAnchor.constraint(equalToConstant: 40),
skipOutroButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 104)
])
view.bringSubviewToFront(skipOutroButton)
}
private func setupDimButton() {
@ -1046,20 +1109,14 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
dimButton.layer.masksToBounds = false
NSLayoutConstraint.activate([
dimButton.centerYAnchor.constraint(equalTo: dismissButton.centerYAnchor),
dimButton.topAnchor.constraint(equalTo: volumeSliderHostingView!.bottomAnchor, constant: 15),
dimButton.trailingAnchor.constraint(equalTo: volumeSliderHostingView!.trailingAnchor),
dimButton.widthAnchor.constraint(equalToConstant: 24),
dimButton.heightAnchor.constraint(equalToConstant: 24),
dimButton.heightAnchor.constraint(equalToConstant: 24)
])
dimButtonToSlider = dimButton.trailingAnchor.constraint(
equalTo: volumeSliderHostingView!.leadingAnchor,
constant: -8
)
dimButtonToRight = dimButton.trailingAnchor.constraint(
equalTo: controlsContainerView.trailingAnchor,
constant: -16
)
dimButtonToSlider = dimButton.trailingAnchor.constraint(equalTo: volumeSliderHostingView!.trailingAnchor)
dimButtonToRight = dimButton.trailingAnchor.constraint(equalTo: controlsContainerView.trailingAnchor, constant: -16)
dimButtonToSlider.isActive = true
}
@ -1069,7 +1126,9 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
let leftSpacing: CGFloat = 2
let rightSpacing: CGFloat = 6
let trailingAnchor: NSLayoutXAxisAnchor = dimButton.leadingAnchor
let trailingAnchor: NSLayoutXAxisAnchor = (volumeSliderHostingView?.isHidden == false)
? volumeSliderHostingView!.leadingAnchor
: view.safeAreaLayoutGuide.trailingAnchor
currentMarqueeConstraints = [
marqueeLabel.leadingAnchor.constraint(
@ -1126,6 +1185,12 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
speedButton.showsMenuAsPrimaryAction = true
speedButton.menu = speedChangerMenu()
speedButton.layer.shadowColor = UIColor.black.cgColor
speedButton.layer.shadowOffset = CGSize(width: 0, height: 2)
speedButton.layer.shadowOpacity = 0.6
speedButton.layer.shadowRadius = 4
speedButton.layer.masksToBounds = false
controlsContainerView.addSubview(speedButton)
speedButton.translatesAutoresizingMaskIntoConstraints = false
@ -1182,8 +1247,6 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
skip85Button.layer.cornerRadius = 21
skip85Button.alpha = 0.7
skip85Button.contentEdgeInsets = UIEdgeInsets(top: 6, left: 10, bottom: 6, right: 10)
skip85Button.layer.shadowColor = UIColor.black.cgColor
skip85Button.layer.shadowOffset = CGSize(width: 0, height: 2)
skip85Button.layer.shadowOpacity = 0.6
@ -1235,42 +1298,32 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
}
func updateSubtitleLabelAppearance() {
// subtitleLabel always exists here:
subtitleLabel.font = UIFont.systemFont(ofSize: CGFloat(subtitleFontSize))
subtitleLabel.textColor = subtitleUIColor()
subtitleLabel.backgroundColor = subtitleBackgroundEnabled
? UIColor.black.withAlphaComponent(0.6)
: .clear
subtitleLabel.layer.cornerRadius = 5
subtitleLabel.clipsToBounds = true
subtitleLabel.layer.shadowColor = UIColor.black.cgColor
subtitleLabel.layer.shadowRadius = CGFloat(subtitleShadowRadius)
subtitleLabel.layer.shadowOpacity = 1.0
subtitleLabel.layer.shadowOffset = .zero
if let subtitleLabel = subtitleLabel {
subtitleLabel.font = UIFont.systemFont(ofSize: CGFloat(subtitleFontSize))
subtitleLabel.textColor = subtitleUIColor()
subtitleLabel.backgroundColor = subtitleBackgroundEnabled
? UIColor.black.withAlphaComponent(0.6)
: .clear
subtitleLabel.layer.cornerRadius = 5
subtitleLabel.clipsToBounds = true
subtitleLabel.layer.shadowColor = UIColor.black.cgColor
subtitleLabel.layer.shadowRadius = CGFloat(subtitleShadowRadius)
subtitleLabel.layer.shadowOpacity = 1.0
subtitleLabel.layer.shadowOffset = .zero
}
// only style it if its been created already
topSubtitleLabel?.font = UIFont.systemFont(ofSize: CGFloat(subtitleFontSize))
topSubtitleLabel?.textColor = subtitleUIColor()
topSubtitleLabel?.backgroundColor = subtitleBackgroundEnabled
? UIColor.black.withAlphaComponent(0.6)
: .clear
topSubtitleLabel?.layer.cornerRadius = 5
topSubtitleLabel?.clipsToBounds = true
topSubtitleLabel?.layer.shadowColor = UIColor.black.cgColor
topSubtitleLabel?.layer.shadowRadius = CGFloat(subtitleShadowRadius)
topSubtitleLabel?.layer.shadowOpacity = 1.0
topSubtitleLabel?.layer.shadowOffset = .zero
}
func subtitleUIColor() -> UIColor {
switch subtitleForegroundColor {
case "white": return .white
case "yellow": return .yellow
case "green": return .green
case "purple": return .purple
case "blue": return .blue
case "red": return .red
default: return .white
if let topSubtitleLabel = topSubtitleLabel {
topSubtitleLabel.font = UIFont.systemFont(ofSize: CGFloat(subtitleFontSize))
topSubtitleLabel.textColor = subtitleUIColor()
topSubtitleLabel.backgroundColor = subtitleBackgroundEnabled
? UIColor.black.withAlphaComponent(0.6)
: .clear
topSubtitleLabel.layer.cornerRadius = 5
topSubtitleLabel.clipsToBounds = true
topSubtitleLabel.layer.shadowColor = UIColor.black.cgColor
topSubtitleLabel.layer.shadowRadius = CGFloat(subtitleShadowRadius)
topSubtitleLabel.layer.shadowOpacity = 1.0
topSubtitleLabel.layer.shadowOffset = .zero
}
}
@ -1294,6 +1347,8 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
self.sliderViewModel.sliderValue = max(0, min(self.currentTimeVal, self.duration))
}
self.updateSkipButtonsVisibility()
UserDefaults.standard.set(self.currentTimeVal, forKey: "lastPlayedTime_\(self.fullUrl)")
UserDefaults.standard.set(self.duration, forKey: "totalTime_\(self.fullUrl)")
@ -1320,8 +1375,6 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
self.topSubtitleLabel.isHidden = true
}
let current = self.currentTimeVal
let segmentsColor = self.getSegmentsColor()
DispatchQueue.main.async {
@ -1368,17 +1421,37 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
emptyColor: .white.opacity(0.3),
height: 33,
onEditingChanged: { editing in
if !editing {
let targetTime = CMTime(
seconds: self.sliderViewModel.sliderValue,
preferredTimescale: 600
)
self.player.seek(to: targetTime)
if editing {
self.isSliderEditing = true
self.wasPlayingBeforeSeek = (self.player.timeControlStatus == .playing)
self.originalRate = self.player.rate
self.player.pause()
} else {
let target = CMTime(seconds: self.sliderViewModel.sliderValue,
preferredTimescale: 600)
self.player.seek(
to: target,
toleranceBefore: .zero,
toleranceAfter: .zero
) { [weak self] _ in
guard let self = self else { return }
let final = self.player.currentTime().seconds
self.sliderViewModel.sliderValue = final
self.currentTimeVal = final
self.isSliderEditing = false
if self.wasPlayingBeforeSeek {
self.player.playImmediately(atRate: self.originalRate)
}
}
}
},
introSegments: self.sliderViewModel.introSegments,
outroSegments: self.sliderViewModel.outroSegments,
introColor: segmentsColor, // Match your color choices
introColor: segmentsColor,
outroColor: segmentsColor
)
}
@ -1388,7 +1461,6 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
@objc private func skipIntro() {
if let range = skipIntervals.op {
player.seek(to: range.end)
// optionally hide button immediately:
skipIntroButton.isHidden = true
}
}
@ -1409,10 +1481,8 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
}
func updateMenuButtonConstraints() {
// tear down last one
currentMenuButtonTrailing.isActive = false
// pick the next visible control
let anchor: NSLayoutXAxisAnchor
if !qualityButton.isHidden {
anchor = qualityButton.leadingAnchor
@ -1524,6 +1594,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
self.controlsContainerView.alpha = 1.0
self.skip85Button.alpha = 0.8
})
self.updateSkipButtonsVisibility()
}
}
} else {
@ -2093,11 +2164,20 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
guard let player = player else { return }
originalRate = player.rate
let holdSpeed = UserDefaults.standard.float(forKey: "holdSpeedPlayer")
player.rate = holdSpeed > 0 ? holdSpeed : 2.0
let speed = holdSpeed > 0 ? holdSpeed : 2.0
player.rate = speed
UIView.animate(withDuration: 0.1) {
self.holdSpeedIndicator.alpha = 0.8
}
}
private func endHoldSpeed() {
player?.rate = originalRate
UIView.animate(withDuration: 0.2) {
self.holdSpeedIndicator.alpha = 0
}
}
private func setInitialPlayerRate() {
@ -2143,6 +2223,18 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
.shadow(color: Color.black.opacity(0.6), radius: 4, x: 0, y: 2)
}
}
func subtitleUIColor() -> UIColor {
switch subtitleForegroundColor {
case "white": return .white
case "yellow": return .yellow
case "green": return .green
case "purple": return .purple
case "blue": return .blue
case "red": return .red
default: return .white
}
}
}
// yes? Like the plural of the famous american rapper ye? -IBHRAD

View file

@ -10,6 +10,7 @@ import UIKit
class iCloudSyncManager {
static let shared = iCloudSyncManager()
private let syncQueue = DispatchQueue(label: "me.cranci.sora.icloud-sync", qos: .utility)
private let defaultsToSync: [String] = [
"externalPlayer",
"alwaysLandscape",
@ -47,16 +48,103 @@ class iCloudSyncManager {
}
private func setupSync() {
NSUbiquitousKeyValueStore.default.synchronize()
syncFromiCloud()
syncModulesFromiCloud()
NotificationCenter.default.addObserver(self, selector: #selector(iCloudDidChangeExternally), name: NSUbiquitousKeyValueStore.didChangeExternallyNotification, object: NSUbiquitousKeyValueStore.default)
NotificationCenter.default.addObserver(self, selector: #selector(userDefaultsDidChange), name: UserDefaults.didChangeNotification, object: nil)
syncQueue.async { [weak self] in
guard let self = self else { return }
NSUbiquitousKeyValueStore.default.synchronize()
self.syncFromiCloud()
self.syncModulesFromiCloud()
DispatchQueue.main.async {
NotificationCenter.default.addObserver(self, selector: #selector(self.iCloudDidChangeExternally), name: NSUbiquitousKeyValueStore.didChangeExternallyNotification, object: NSUbiquitousKeyValueStore.default)
NotificationCenter.default.addObserver(self, selector: #selector(self.userDefaultsDidChange), name: UserDefaults.didChangeNotification, object: nil)
}
}
}
@objc private func iCloudDidChangeExternally(_ notification: NSNotification) {
guard let iCloud = notification.object as? NSUbiquitousKeyValueStore,
let changedKeys = notification.userInfo?[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String] else {
Logger.shared.log("Invalid iCloud notification data", type: "Error")
return
}
syncQueue.async { [weak self] in
guard let self = self else { return }
let defaults = UserDefaults.standard
for key in changedKeys {
if let value = iCloud.object(forKey: key), self.isValidValueType(value) {
defaults.set(value, forKey: key)
} else {
defaults.removeObject(forKey: key)
}
}
defaults.synchronize()
DispatchQueue.main.async {
NotificationCenter.default.post(name: .iCloudSyncDidComplete, object: nil)
}
}
}
@objc private func userDefaultsDidChange(_ notification: Notification) {
syncQueue.async { [weak self] in
self?.syncToiCloud()
}
}
private func syncToiCloud() {
let iCloud = NSUbiquitousKeyValueStore.default
let defaults = UserDefaults.standard
do {
for key in allKeysToSync() {
if let value = defaults.object(forKey: key) {
if isValidValueType(value) {
iCloud.set(value, forKey: key)
}
}
}
iCloud.synchronize()
}
}
private func syncFromiCloud() {
let iCloud = NSUbiquitousKeyValueStore.default
let defaults = UserDefaults.standard
for key in allKeysToSync() {
if let value = iCloud.object(forKey: key) {
if isValidValueType(value) {
defaults.set(value, forKey: key)
}
}
}
defaults.synchronize()
NotificationCenter.default.post(name: .iCloudSyncDidComplete, object: nil)
}
private func isValidValueType(_ value: Any) -> Bool {
return value is String ||
value is Bool ||
value is Int ||
value is Float ||
value is Double ||
value is Data ||
value is Date ||
value is [Any] ||
value is [String: Any]
}
@objc private func willEnterBackground() {
syncToiCloud()
syncModulesToiCloud()
syncQueue.async { [weak self] in
self?.syncToiCloud()
self?.syncModulesToiCloud()
}
}
private func allProgressKeys() -> [String] {
@ -80,60 +168,6 @@ class iCloudSyncManager {
return Array(keys)
}
private func syncFromiCloud() {
let iCloud = NSUbiquitousKeyValueStore.default
let defaults = UserDefaults.standard
for key in allKeysToSync() {
if let value = iCloud.object(forKey: key) {
if (value is String) || (value is Bool) || (value is Int) || (value is Float) || (value is Double) || (value is Data) || (value is Date) || (value is Array<Any>) || (value is Dictionary<String, Any>) {
defaults.set(value, forKey: key)
} else {
Logger.shared.log("Skipped syncing invalid value type for key: \(key)", type: "Error")
}
}
}
defaults.synchronize()
NotificationCenter.default.post(name: .iCloudSyncDidComplete, object: nil)
}
private func syncToiCloud() {
let iCloud = NSUbiquitousKeyValueStore.default
let defaults = UserDefaults.standard
for key in allKeysToSync() {
if let value = defaults.object(forKey: key) {
iCloud.set(value, forKey: key)
}
}
iCloud.synchronize()
}
@objc private func iCloudDidChangeExternally(_ notification: Notification) {
do {
guard let userInfo = notification.userInfo,
let reason = userInfo[NSUbiquitousKeyValueStoreChangeReasonKey] as? Int else {
return
}
if reason == NSUbiquitousKeyValueStoreServerChange ||
reason == NSUbiquitousKeyValueStoreInitialSyncChange {
DispatchQueue.main.async { [weak self] in
self?.syncFromiCloud()
self?.syncModulesFromiCloud()
}
}
} catch {
Logger.shared.log("Error handling iCloud sync: \(error.localizedDescription)", type: "Error")
}
}
@objc private func userDefaultsDidChange(_ notification: Notification) {
syncToiCloud()
}
func syncModulesToiCloud() {
DispatchQueue.global(qos: .background).async { [weak self] in
guard let self = self, let iCloudURL = self.ubiquityContainerURL else { return }

View file

@ -69,7 +69,7 @@ class LibraryManager: ObservableObject {
let encoded = try JSONEncoder().encode(bookmarks)
UserDefaults.standard.set(encoded, forKey: bookmarksKey)
} catch {
Logger.shared.log("Failed to encode bookmarks: \(error.localizedDescription)", type: "Error")
Logger.shared.log("Failed to save bookmarks: \(error)", type: "Error")
}
}

View file

@ -17,6 +17,9 @@ struct LibraryView: View {
@Environment(\.verticalSizeClass) var verticalSizeClass
@State private var selectedBookmark: LibraryItem? = nil
@State private var isDetailActive: Bool = false
@State private var continueWatchingItems: [ContinueWatchingItem] = []
@State private var isLandscape: Bool = UIDevice.current.orientation.isLandscape
@ -98,7 +101,10 @@ struct LibraryView: View {
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: columnsCount), spacing: 12) {
ForEach(libraryManager.bookmarks) { item in
if let module = moduleManager.modules.first(where: { $0.id.uuidString == item.moduleId }) {
NavigationLink(destination: MediaInfoView(title: item.title, imageUrl: item.imageUrl, href: item.href, module: module)) {
Button(action: {
selectedBookmark = item
isDetailActive = true
}) {
VStack(alignment: .leading) {
ZStack {
KFImage(URL(string: item.imageUrl))
@ -141,6 +147,22 @@ struct LibraryView: View {
}
}
.padding(.horizontal, 20)
NavigationLink(
destination: Group {
if let bookmark = selectedBookmark,
let module = moduleManager.modules.first(where: { $0.id.uuidString == bookmark.moduleId }) {
MediaInfoView(title: bookmark.title,
imageUrl: bookmark.imageUrl,
href: bookmark.href,
module: module)
} else {
Text("No Data Available")
}
},
isActive: $isDetailActive
) {
EmptyView()
}
.onAppear {
updateOrientation()
}

View file

@ -29,6 +29,16 @@ struct EpisodeCell: View {
@State private var isLoading: Bool = true
@State private var currentProgress: Double = 0.0
@Environment(\.colorScheme) private var colorScheme
@AppStorage("selectedAppearance") private var selectedAppearance: Appearance = .system
var defaultBannerImage: 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"
}
init(episodeIndex: Int, episode: String, episodeID: Int, progress: Double,
itemID: Int, onTap: @escaping (String) -> Void, onMarkAllPrevious: @escaping () -> Void) {
self.episodeIndex = episodeIndex
@ -43,7 +53,7 @@ struct EpisodeCell: View {
var body: some View {
HStack {
ZStack {
KFImage(URL(string: episodeImageUrl.isEmpty ? "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/main/assets/banner2.png" : episodeImageUrl))
KFImage(URL(string: episodeImageUrl.isEmpty ? defaultBannerImage : episodeImageUrl))
.resizable()
.aspectRatio(16/9, contentMode: .fill)
.frame(width: 100, height: 56)
@ -98,7 +108,7 @@ struct EpisodeCell: View {
updateProgress()
}
.onTapGesture {
let imageUrl = episodeImageUrl.isEmpty ? "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/main/assets/banner2.png" : episodeImageUrl
let imageUrl = episodeImageUrl.isEmpty ? defaultBannerImage : episodeImageUrl
onTap(imageUrl)
}
}

View file

@ -54,7 +54,7 @@ struct SettingsViewPlayer: View {
Spacer()
Stepper(
value: $holdSpeedPlayer,
in: 0.25...2.0,
in: 0.25...2.5,
step: 0.25
) {
Text(String(format: "%.2f", holdSpeedPlayer))

View file

@ -18,7 +18,6 @@
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 */; };
132E35202D959E1D0007800E /* FFmpeg-iOS-Lame in Frameworks */ = {isa = PBXBuildFile; productRef = 132E351F2D959E1D0007800E /* FFmpeg-iOS-Lame */; };
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 */; };
@ -56,7 +55,6 @@
13DB46902D900A38008CBC03 /* URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13DB468F2D900A38008CBC03 /* URL.swift */; };
13DB46922D900BCE008CBC03 /* SettingsViewTrackers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13DB46912D900BCE008CBC03 /* SettingsViewTrackers.swift */; };
13DB7CC32D7D99C0004371D3 /* SubtitleSettingsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13DB7CC22D7D99C0004371D3 /* SubtitleSettingsManager.swift */; };
13DB7CEC2D7DED5D004371D3 /* DownloadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13DB7CEB2D7DED5D004371D3 /* DownloadManager.swift */; };
13DC0C462D302C7500D0F966 /* VideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13DC0C452D302C7500D0F966 /* VideoPlayer.swift */; };
13E62FC22DABC5830007E259 /* Trakt-Login.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13E62FC12DABC5830007E259 /* Trakt-Login.swift */; };
13E62FC42DABC58C0007E259 /* Trakt-Token.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13E62FC32DABC58C0007E259 /* Trakt-Token.swift */; };
@ -118,7 +116,6 @@
13DB468F2D900A38008CBC03 /* URL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URL.swift; sourceTree = "<group>"; };
13DB46912D900BCE008CBC03 /* SettingsViewTrackers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewTrackers.swift; sourceTree = "<group>"; };
13DB7CC22D7D99C0004371D3 /* SubtitleSettingsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubtitleSettingsManager.swift; sourceTree = "<group>"; };
13DB7CEB2D7DED5D004371D3 /* DownloadManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadManager.swift; sourceTree = "<group>"; };
13DC0C412D2EC9BA00D0F966 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
13DC0C452D302C7500D0F966 /* VideoPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayer.swift; sourceTree = "<group>"; };
13E62FC12DABC5830007E259 /* Trakt-Login.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Trakt-Login.swift"; sourceTree = "<group>"; };
@ -140,7 +137,6 @@
files = (
13B77E192DA44F8300126FDF /* MarqueeLabel in Frameworks */,
132E35232D959E410007800E /* Kingfisher in Frameworks */,
132E35202D959E1D0007800E /* FFmpeg-iOS-Lame in Frameworks */,
132E351D2D959DDB0007800E /* Drops in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -262,7 +258,6 @@
isa = PBXGroup;
children = (
136BBE7C2DB102BE00906B5E /* iCloudSyncManager */,
13DB7CEA2D7DED50004371D3 /* DownloadManager */,
13C0E5E82D5F85DD00E7F619 /* ContinueWatching */,
13103E8C2D58E037000F0673 /* SkeletonCells */,
13DC0C442D302C6A00D0F966 /* MediaPlayer */,
@ -398,14 +393,6 @@
path = Auth;
sourceTree = "<group>";
};
13DB7CEA2D7DED50004371D3 /* DownloadManager */ = {
isa = PBXGroup;
children = (
13DB7CEB2D7DED5D004371D3 /* DownloadManager.swift */,
);
path = DownloadManager;
sourceTree = "<group>";
};
13DC0C442D302C6A00D0F966 /* MediaPlayer */ = {
isa = PBXGroup;
children = (
@ -480,7 +467,6 @@
name = Sulfur;
packageProductDependencies = (
132E351C2D959DDB0007800E /* Drops */,
132E351F2D959E1D0007800E /* FFmpeg-iOS-Lame */,
132E35222D959E410007800E /* Kingfisher */,
13B77E182DA44F8300126FDF /* MarqueeLabel */,
);
@ -513,7 +499,6 @@
mainGroup = 133D7C612D2BE2500075467E;
packageReferences = (
132E351B2D959DDB0007800E /* XCRemoteSwiftPackageReference "Drops" */,
132E351E2D959E1D0007800E /* XCRemoteSwiftPackageReference "FFmpeg-iOS-Lame" */,
132E35212D959E410007800E /* XCRemoteSwiftPackageReference "Kingfisher" */,
13B77E172DA44F8300126FDF /* XCRemoteSwiftPackageReference "MarqueeLabel" */,
);
@ -563,7 +548,6 @@
133D7C932D2BE2640075467E /* Modules.swift in Sources */,
13DB7CC32D7D99C0004371D3 /* SubtitleSettingsManager.swift in Sources */,
133D7C702D2BE2500075467E /* ContentView.swift in Sources */,
13DB7CEC2D7DED5D004371D3 /* DownloadManager.swift in Sources */,
13D99CF72D4E73C300250A86 /* ModuleAdditionSettingsView.swift in Sources */,
13C0E5EC2D5F85F800E7F619 /* ContinueWatchingItem.swift in Sources */,
13CBA0882D60F19C00EFE70A /* VTTSubtitlesLoader.swift in Sources */,
@ -840,14 +824,6 @@
kind = branch;
};
};
132E351E2D959E1D0007800E /* XCRemoteSwiftPackageReference "FFmpeg-iOS-Lame" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/kewlbear/FFmpeg-iOS-Lame";
requirement = {
branch = main;
kind = branch;
};
};
132E35212D959E410007800E /* XCRemoteSwiftPackageReference "Kingfisher" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/onevcat/Kingfisher.git";
@ -872,11 +848,6 @@
package = 132E351B2D959DDB0007800E /* XCRemoteSwiftPackageReference "Drops" */;
productName = Drops;
};
132E351F2D959E1D0007800E /* FFmpeg-iOS-Lame */ = {
isa = XCSwiftPackageProductDependency;
package = 132E351E2D959E1D0007800E /* XCRemoteSwiftPackageReference "FFmpeg-iOS-Lame" */;
productName = "FFmpeg-iOS-Lame";
};
132E35222D959E410007800E /* Kingfisher */ = {
isa = XCSwiftPackageProductDependency;
package = 132E35212D959E410007800E /* XCRemoteSwiftPackageReference "Kingfisher" */;

View file

@ -10,24 +10,6 @@
"version": null
}
},
{
"package": "FFmpeg-iOS-Lame",
"repositoryURL": "https://github.com/kewlbear/FFmpeg-iOS-Lame",
"state": {
"branch": "main",
"revision": "1808fa5a1263c5e216646cd8421fc7dcb70520cc",
"version": null
}
},
{
"package": "FFmpeg-iOS-Support",
"repositoryURL": "https://github.com/kewlbear/FFmpeg-iOS-Support",
"state": {
"branch": null,
"revision": "be3bd9149ac53760e8725652eee99c405b2be47a",
"version": "0.0.2"
}
},
{
"package": "Kingfisher",
"repositoryURL": "https://github.com/onevcat/Kingfisher.git",

BIN
assets/banner1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB