merge dev commits into features branch

This commit is contained in:
Dominic Drees 2025-04-27 12:01:10 +02:00
commit d38c289fb8
22 changed files with 697 additions and 509 deletions

View file

@ -1,39 +1,29 @@
name: Build and Release IPA
on:
push:
branches:
- dev
jobs:
build:
name: Build IPA
runs-on: macOS-latest
steps:
- name: Use Node.js 20
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Checkout code
uses: actions/checkout@v4
with:
persist-credentials: true
- name: Run ipabuild.sh
run: |
chmod +x ipabuild.sh
./ipabuild.sh
- name: Commit and push latest IPA
run: |
git config user.name "cranci1"
git config user.email "cranci1@github.com"
mkdir -p public-build
cp -f build/Sulfur.ipa public-build/Sulfur.ipa
git add -f public-build/Sulfur.ipa
git diff --quiet && git diff --staged --quiet || git commit -m "Auto: Update IPA [skip ci]"
git push
- name: Upload IPA artifact
uses: actions/upload-artifact@v4
with:
name: Sulfur-IPA
path: build/Sulfur.ipa
compression-level: 0

5
.gitignore vendored
View file

@ -63,9 +63,10 @@ DerivedData/
*.hmap
## App packaging
*.ipa
*.dSYM.zip
*.dSYM
public-build
## Playgrounds
timeline.xctimeline
playground.xcworkspace
@ -130,4 +131,4 @@ iOSInjectionProject/
/*.gcno
**/xcshareddata/WorkspaceSettings.xcsettings
# End of https://www.toptal.com/developers/gitignore/api/macos,swift,xcode
# End of https://www.toptal.com/developers/gitignore/api/macos,swift,xcode

View file

@ -23,7 +23,6 @@ An iOS and macOS modular web scraping app, under the GPLv3.0 License.
- [x] iOS/iPadOS 15.0+ support
- [x] macOS support 12.0+
- [x] Sync via iCloud data
- [x] JavaScript module support
- [x] Tracking Services (AniList, Trakt)
- [x] Apple KeyChain support for auth Tokens

View file

@ -24,7 +24,7 @@ struct SoraApp: App {
}
}
}
var body: some Scene {
WindowGroup {
RootView()
@ -43,7 +43,6 @@ struct SoraApp: App {
_ = iCloudSyncManager.shared
settings.updateAppearance()
iCloudSyncManager.shared.syncModulesFromiCloud()
Task {
if UserDefaults.standard.bool(forKey: "refreshModulesOnLaunch") {
await moduleManager.refreshModules()
@ -65,33 +64,71 @@ struct SoraApp: App {
}
}
}
private func handleURL(_ url: URL) {
guard url.scheme == "sora",
url.host == "module",
let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
let moduleURL = components.queryItems?.first(where: { $0.name == "url" })?.value else {
return
}
let addModuleView = ModuleAdditionSettingsView(moduleUrl: moduleURL).environmentObject(moduleManager)
let hostingController = UIHostingController(rootView: addModuleView)
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first {
window.rootViewController?.present(hostingController, animated: true)
} else {
Logger.shared.log("Failed to present module addition view: No window scene found", type: "Error")
guard url.scheme == "sora", let host = url.host else { return }
switch host {
case "default_page":
if let comps = URLComponents(url: url, resolvingAgainstBaseURL: true),
let libraryURL = comps.queryItems?.first(where: { $0.name == "url" })?.value {
UserDefaults.standard.set(libraryURL, forKey: "lastCommunityURL")
UserDefaults.standard.set(true, forKey: "didReceiveDefaultPageLink")
let communityView = CommunityLibraryView()
.environmentObject(moduleManager)
let hostingController = UIHostingController(rootView: communityView)
DispatchQueue.main.async {
if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = scene.windows.first,
let root = window.rootViewController {
root.present(hostingController, animated: true) {
DropManager.shared.showDrop(
title: "Module Library Added",
subtitle: "You can browse the community library in settings.",
duration: 2,
icon: UIImage(systemName: "books.vertical.circle.fill")
)
}
}
}
}
case "module":
guard url.scheme == "sora",
url.host == "module",
let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
let moduleURL = components.queryItems?.first(where: { $0.name == "url" })?.value
else {
return
}
let addModuleView = ModuleAdditionSettingsView(moduleUrl: moduleURL)
.environmentObject(moduleManager)
let hostingController = UIHostingController(rootView: addModuleView)
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first {
window.rootViewController?.present(hostingController, animated: true)
} else {
Logger.shared.log(
"Failed to present module addition view: No window scene found",
type: "Error"
)
}
default:
break
}
}
static func handleRedirect(url: URL) {
guard let params = url.queryParameters,
let code = params["code"] else {
Logger.shared.log("Failed to extract authorization code")
return
}
Logger.shared.log("Failed to extract authorization code")
return
}
switch url.host {
case "anilist":
AniListToken.exchangeAuthorizationCodeForToken(code: code) { success in
@ -101,6 +138,7 @@ struct SoraApp: App {
Logger.shared.log("AniList token exchange failed", type: "Error")
}
}
case "trakt":
TraktToken.exchangeAuthorizationCodeForToken(code: code) { success in
if success {
@ -109,6 +147,7 @@ struct SoraApp: App {
Logger.shared.log("Trakt token exchange failed", type: "Error")
}
}
default:
Logger.shared.log("Unknown authentication service", type: "Error")
}

View file

@ -45,17 +45,19 @@ class AniListMutation {
}
let query = """
mutation ($mediaId: Int, $progress: Int) {
SaveMediaListEntry (mediaId: $mediaId, progress: $progress) {
mutation ($mediaId: Int, $progress: Int, $status: MediaListStatus) {
SaveMediaListEntry (mediaId: $mediaId, progress: $progress, status: $status) {
id
progress
status
}
}
"""
let variables: [String: Any] = [
"mediaId": animeId,
"progress": episodeNumber
"progress": episodeNumber,
"status": "CURRENT"
]
let requestBody: [String: Any] = [
@ -124,7 +126,6 @@ class AniListMutation {
var request = URLRequest(url: apiURL)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
// no auth required for read
request.httpBody = jsonData
URLSession.shared.dataTask(with: request) { data, resp, error in

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

@ -9,6 +9,7 @@ import Foundation
extension Notification.Name {
static let iCloudSyncDidComplete = Notification.Name("iCloudSyncDidComplete")
static let iCloudSyncDidFail = Notification.Name("iCloudSyncDidFail")
static let ContinueWatchingDidUpdate = Notification.Name("ContinueWatchingDidUpdate")
static let DownloadManagerStatusUpdate = Notification.Name("DownloadManagerStatusUpdate")
static let modulesSyncDidComplete = Notification.Name("modulesSyncDidComplete")

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

@ -6,12 +6,11 @@
//
import UIKit
import MarqueeLabel
import AVKit
import SwiftUI
import AVFoundation
import MediaPlayer
// MARK: - CustomMediaPlayerViewController
import AVFoundation
import MarqueeLabel
class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDelegate {
let module: ScrapingModule
@ -74,9 +73,11 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
var subtitleFontSize: Double = 20.0
var subtitleShadowRadius: Double = 1.0
var subtitlesLoader = VTTSubtitlesLoader()
var subtitleStackView: UIStackView!
var subtitleLabels: [UILabel] = []
var subtitlesEnabled: Bool = true {
didSet {
subtitleLabel.isHidden = !subtitlesEnabled
subtitleStackView.isHidden = !subtitlesEnabled
}
}
@ -86,7 +87,6 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
var playPauseButton: UIImageView!
var backwardButton: UIImageView!
var forwardButton: UIImageView!
var subtitleLabel: UILabel!
var topSubtitleLabel: UILabel!
var dismissButton: UIButton!
var menuButton: UIButton!
@ -96,6 +96,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)] = []
@ -118,6 +119,8 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
}
}
private var wasPlayingBeforeSeek = false
private var malID: Int?
private var skipIntervals: (op: CMTimeRange?, ed: CMTimeRange?) = (nil, nil)
@ -125,6 +128,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?
@ -160,6 +169,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
private var volumeValue: Double = 0.0
private var volumeViewModel = VolumeViewModel()
var volumeSliderHostingView: UIView?
private var subtitleDelay: Double = 0.0
init(module: ScrapingModule,
continueWatchingManager: ContinueWatchingManager,
@ -218,7 +228,6 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
loadSubtitleSettings()
setupPlayerViewController()
setupControls()
setupSkipAndDismissGestures()
addInvisibleControlOverlays()
setupWatchNextButton()
setupSubtitleLabel()
@ -231,11 +240,14 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
setupMarqueeLabel()
setupSkip85Button()
setupSkipButtons()
setupSkipAndDismissGestures()
addTimeObserver()
startUpdateTimer()
setupAudioSession()
updateSkipButtonsVisibility()
setupHoldSpeedIndicator()
view.bringSubviewToFront(subtitleStackView)
AniListMutation().fetchMalID(animeId: aniListID) { [weak self] result in
switch result {
@ -244,7 +256,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")
}
}
@ -274,7 +286,6 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
}
}
if #available(iOS 16.0, *) {
playerViewController.allowsVideoFrameAnalysis = false
}
@ -378,14 +389,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()
}
@ -527,11 +535,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
@ -539,16 +555,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)
@ -619,6 +635,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)
}
@ -696,47 +722,45 @@ 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
subtitleStackView = UIStackView()
subtitleStackView.axis = .vertical
subtitleStackView.alignment = .center
subtitleStackView.distribution = .fill
subtitleStackView.spacing = 2
subtitleBottomToSliderConstraint = subtitleLabel.bottomAnchor.constraint(
equalTo: sliderHostingController!.view.topAnchor,
constant: -20
)
if let subtitleStackView = subtitleStackView {
view.addSubview(subtitleStackView)
subtitleStackView.translatesAutoresizingMaskIntoConstraints = false
subtitleBottomToSliderConstraint = subtitleStackView.bottomAnchor.constraint(
equalTo: sliderHostingController?.view.topAnchor ?? view.bottomAnchor,
constant: -20
)
subtitleBottomToSafeAreaConstraint = subtitleStackView.bottomAnchor.constraint(
equalTo: view.safeAreaLayoutGuide.bottomAnchor,
constant: -subtitleBottomPadding
)
NSLayoutConstraint.activate([
subtitleStackView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
subtitleStackView.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor, constant: 36),
subtitleStackView.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -36)
])
subtitleBottomToSafeAreaConstraint?.isActive = true
}
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)
])
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
for _ in 0..<2 {
let label = UILabel()
label.textAlignment = .center
label.numberOfLines = 0
label.font = UIFont.systemFont(ofSize: CGFloat(subtitleFontSize))
subtitleLabels.append(label)
subtitleStackView.addArrangedSubview(label)
}
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() {
@ -784,10 +808,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
@ -802,33 +826,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()
}
@ -857,9 +854,48 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
])
}
private func setupHoldSpeedIndicator() {
let config = UIImage.SymbolConfiguration(pointSize: 14, weight: .bold)
let image = UIImage(systemName: "forward.fill", withConfiguration: config)
var speed = UserDefaults.standard.float(forKey: "holdSpeedPlayer")
if speed == 0.0 {
speed = 2.0
}
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 }
@ -889,6 +925,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() {
@ -922,17 +969,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
)
}
@ -957,7 +1025,6 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
} else {
self.skipIntervals.ed = range
}
// Update segments only if duration is available
if self.duration > 0 {
self.updateSegments()
}
@ -965,26 +1032,26 @@ 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.contentEdgeInsets = UIEdgeInsets(top: 6, left: 10, bottom: 6, right: 10)
skipIntroButton.tintColor = .white
skipIntroButton.setTitleColor(.white, for: .normal)
skipIntroButton.layer.cornerRadius = 15
skipIntroButton.layer.cornerRadius = 21
skipIntroButton.alpha = skipButtonBaseAlpha
/*
if #unavailable(iOS 15) {
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
@ -992,54 +1059,48 @@ 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.contentEdgeInsets = UIEdgeInsets(top: 6, left: 10, bottom: 6, right: 10)
skipOutroButton.tintColor = .white
skipOutroButton.setTitleColor(.white, for: .normal)
skipOutroButton.layer.cornerRadius = skipIntroButton.layer.cornerRadius
skipOutroButton.alpha = skipIntroButton.alpha
if #unavailable(iOS 15) {
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() {
@ -1058,20 +1119,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
}
@ -1081,7 +1136,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(
@ -1138,6 +1195,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
@ -1189,15 +1252,17 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
skip85Button.setImage(image, for: .normal)
skip85Button.backgroundColor = UIColor(red: 51/255.0, green: 51/255.0, blue: 51/255.0, alpha: 0.8)
skip85Button.contentEdgeInsets = UIEdgeInsets(top: 6, left: 10, bottom: 6, right: 10)
skip85Button.tintColor = .white
skip85Button.setTitleColor(.white, for: .normal)
skip85Button.layer.cornerRadius = 21
skip85Button.alpha = 0.7
/*
if #unavailable(iOS 15) {
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
@ -1249,42 +1314,18 @@ 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
// 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
for subtitleLabel in subtitleLabels {
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
}
}
@ -1308,30 +1349,33 @@ 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)")
if self.subtitlesEnabled {
let cues = self.subtitlesLoader.cues.filter { self.currentTimeVal >= $0.startTime && self.currentTimeVal <= $0.endTime }
let adjustedTime = self.currentTimeVal - self.subtitleDelay
let cues = self.subtitlesLoader.cues.filter { adjustedTime >= $0.startTime && adjustedTime <= $0.endTime }
if cues.count > 0 {
self.subtitleLabel.text = cues[0].text.strippedHTML
self.subtitleLabel.isHidden = false
self.subtitleLabels[0].text = cues[0].text.strippedHTML
self.subtitleLabels[0].isHidden = false
} else {
self.subtitleLabel.text = ""
self.subtitleLabel.isHidden = !self.subtitlesEnabled
self.subtitleLabels[0].text = ""
self.subtitleLabels[0].isHidden = !self.subtitlesEnabled
}
if cues.count > 1 {
self.topSubtitleLabel.text = cues[1].text.strippedHTML
self.topSubtitleLabel.isHidden = false
self.subtitleLabels[1].text = cues[1].text.strippedHTML
self.subtitleLabels[1].isHidden = false
} else {
self.topSubtitleLabel.text = ""
self.topSubtitleLabel.isHidden = true
self.subtitleLabels[1].text = ""
self.subtitleLabels[1].isHidden = true
}
} else {
self.subtitleLabel.text = ""
self.subtitleLabel.isHidden = true
self.topSubtitleLabel.text = ""
self.topSubtitleLabel.isHidden = true
self.subtitleLabels[0].text = ""
self.subtitleLabels[0].isHidden = true
self.subtitleLabels[1].text = ""
self.subtitleLabels[1].isHidden = true
}
let segmentsColor = self.getSegmentsColor()
@ -1380,17 +1424,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
)
}
@ -1400,7 +1464,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
}
}
@ -1421,10 +1484,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
@ -1536,6 +1597,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
self.controlsContainerView.alpha = 1.0
self.skip85Button.alpha = 0.8
})
self.updateSkipButtonsVisibility()
}
}
} else {
@ -1573,21 +1635,17 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
isDimmed.toggle()
dimButtonTimer?.invalidate()
// animate black overlay
UIView.animate(withDuration: 0.25) {
self.blackCoverView.alpha = self.isDimmed ? 1.0 : 0.4
}
// fade controls instead of hiding
UIView.animate(withDuration: 0.25) {
for view in self.controlsToHide {
view.alpha = self.isDimmed ? 0 : 1
}
// keep the dim button visible/in front
self.dimButton.alpha = self.isDimmed ? 0 : 1
}
// swap your trailing constraints on the dimbutton
dimButtonToSlider.isActive = !isDimmed
dimButtonToRight.isActive = isDimmed
UIView.animate(withDuration: 0.25) { self.view.layoutIfNeeded() }
@ -1834,7 +1892,6 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
self.switchToQuality(urlString: last)
}
// reveal + animate
self.qualityButton.isHidden = false
self.qualityButton.menu = self.qualitySelectionMenu()
self.updateMenuButtonConstraints()
@ -1972,8 +2029,41 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
]
let paddingMenu = UIMenu(title: "Bottom Padding", children: paddingActions)
let delayActions = [
UIAction(title: "-0.5s") { [weak self] _ in
guard let self = self else { return }
self.adjustSubtitleDelay(by: -0.5)
},
UIAction(title: "-0.2s") { [weak self] _ in
guard let self = self else { return }
self.adjustSubtitleDelay(by: -0.2)
},
UIAction(title: "+0.2s") { [weak self] _ in
guard let self = self else { return }
self.adjustSubtitleDelay(by: 0.2)
},
UIAction(title: "+0.5s") { [weak self] _ in
guard let self = self else { return }
self.adjustSubtitleDelay(by: 0.5)
},
UIAction(title: "Custom...") { [weak self] _ in
guard let self = self else { return }
self.presentCustomDelayAlert()
}
]
let resetDelayAction = UIAction(title: "Reset Timing") { [weak self] _ in
guard let self = self else { return }
SubtitleSettingsManager.shared.update { settings in settings.subtitleDelay = 0.0 }
self.subtitleDelay = 0.0
self.loadSubtitleSettings()
DropManager.shared.showDrop(title: "Subtitle Timing Reset", subtitle: "", duration: 0.5, icon: UIImage(systemName: "clock.arrow.circlepath"))
}
let delayMenu = UIMenu(title: "Subtitle Timing", children: delayActions + [resetDelayAction])
let subtitleOptionsMenu = UIMenu(title: "Subtitle Options", children: [
subtitlesToggleAction, colorMenu, fontSizeMenu, shadowMenu, backgroundMenu, paddingMenu
subtitlesToggleAction, colorMenu, fontSizeMenu, shadowMenu, backgroundMenu, paddingMenu, delayMenu
])
menuElements = [subtitleOptionsMenu]
@ -1982,6 +2072,32 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
return UIMenu(title: "", children: menuElements)
}
func adjustSubtitleDelay(by amount: Double) {
let newValue = subtitleDelay + amount
let roundedValue = Double(round(newValue * 10) / 10)
SubtitleSettingsManager.shared.update { settings in settings.subtitleDelay = roundedValue }
self.subtitleDelay = roundedValue
self.loadSubtitleSettings()
}
func presentCustomDelayAlert() {
let alert = UIAlertController(title: "Enter Custom Delay", message: nil, preferredStyle: .alert)
alert.addTextField { textField in
textField.placeholder = "Delay in seconds"
textField.keyboardType = .decimalPad
textField.text = String(format: "%.1f", self.subtitleDelay)
}
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alert.addAction(UIAlertAction(title: "Done", style: .default) { _ in
if let text = alert.textFields?.first?.text, let newDelay = Double(text) {
SubtitleSettingsManager.shared.update { settings in settings.subtitleDelay = newDelay }
self.subtitleDelay = newDelay
self.loadSubtitleSettings()
}
})
present(alert, animated: true)
}
func presentCustomPaddingAlert() {
let alert = UIAlertController(title: "Enter Custom Padding", message: nil, preferredStyle: .alert)
alert.addTextField { textField in
@ -2029,6 +2145,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
self.subtitleShadowRadius = settings.shadowRadius
self.subtitleBackgroundEnabled = settings.backgroundEnabled
self.subtitleBottomPadding = settings.bottomPadding
self.subtitleDelay = settings.subtitleDelay
}
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
@ -2105,11 +2222,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() {
@ -2155,6 +2281,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

@ -13,6 +13,7 @@ struct SubtitleSettings: Codable {
var shadowRadius: Double = 1.0
var backgroundEnabled: Bool = true
var bottomPadding: CGFloat = 20.0
var subtitleDelay: Double = 0.0
}
class SubtitleSettingsManager {

View file

@ -0,0 +1,104 @@
//
// CommunityLib.swift
// Sulfur
//
// Created by seiike on 23/04/2025.
//
import SwiftUI
import WebKit
private struct ModuleLink: Identifiable {
let id = UUID()
let url: String
}
struct CommunityLibraryView: View {
@EnvironmentObject var moduleManager: ModuleManager
@AppStorage("lastCommunityURL") private var inputURL: String = ""
@State private var webURL: URL?
@State private var errorMessage: String?
@State private var moduleLinkToAdd: ModuleLink?
var body: some View {
VStack(spacing: 0) {
if let err = errorMessage {
Text(err)
.foregroundColor(.red)
.padding(.horizontal)
}
WebView(url: webURL) { linkURL in
if let comps = URLComponents(url: linkURL, resolvingAgainstBaseURL: false),
let m = comps.queryItems?.first(where: { $0.name == "url" })?.value {
moduleLinkToAdd = ModuleLink(url: m)
}
}
.ignoresSafeArea(edges: .top)
}
.onAppear(perform: loadURL)
.sheet(item: $moduleLinkToAdd) { link in
ModuleAdditionSettingsView(moduleUrl: link.url)
.environmentObject(moduleManager)
}
}
private func loadURL() {
var s = inputURL.trimmingCharacters(in: .whitespacesAndNewlines)
if !s.hasPrefix("http://") && !s.hasPrefix("https://") {
s = "https://" + s
}
inputURL = s
if let u = URL(string: s) {
webURL = u
errorMessage = nil
} else {
webURL = nil
errorMessage = "Invalid URL"
}
}
}
struct WebView: UIViewRepresentable {
let url: URL?
let onCustomScheme: (URL) -> Void
func makeCoordinator() -> Coordinator {
Coordinator(onCustom: onCustomScheme)
}
func makeUIView(context: Context) -> WKWebView {
let cfg = WKWebViewConfiguration()
cfg.preferences.javaScriptEnabled = true
let wv = WKWebView(frame: .zero, configuration: cfg)
wv.navigationDelegate = context.coordinator
return wv
}
func updateUIView(_ uiView: WKWebView, context: Context) {
if let u = url {
uiView.load(URLRequest(url: u))
}
}
class Coordinator: NSObject, WKNavigationDelegate {
let onCustom: (URL) -> Void
init(onCustom: @escaping (URL) -> Void) { self.onCustom = onCustom }
func webView(_ webView: WKWebView,
decidePolicyFor action: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void)
{
if let url = action.request.url,
url.scheme == "sora", url.host == "module"
{
onCustom(url)
decisionHandler(.cancel)
} else {
decisionHandler(.allow)
}
}
}
}

View file

@ -150,6 +150,7 @@ struct ModuleAdditionSettingsView: View {
await MainActor.run {
self.errorMessage = "Invalid URL"
self.isLoading = false
Logger.shared.log("Failed to open add module ui with url: \(moduleUrl)", type: "Error")
}
return
}

View file

@ -36,14 +36,23 @@ class ModuleManager: ObservableObject {
guard let self = self else { return }
let url = self.getModulesFilePath()
if FileManager.default.fileExists(atPath: url.path) {
self.loadModules()
guard FileManager.default.fileExists(atPath: url.path) else {
Logger.shared.log("No modules file found after sync", type: "Error")
self.modules = []
return
}
do {
let data = try Data(contentsOf: url)
let decodedModules = try JSONDecoder().decode([ScrapingModule].self, from: data)
self.modules = decodedModules
Task {
await self.checkJSModuleFiles()
}
Logger.shared.log("Reloaded modules after iCloud sync")
} else {
Logger.shared.log("No modules file found after sync", type: "Error")
} catch {
Logger.shared.log("Error handling modules sync: \(error.localizedDescription)", type: "Error")
self.modules = []
}
}
@ -130,9 +139,11 @@ class ModuleManager: ObservableObject {
}
private func saveModules() {
let url = getModulesFilePath()
guard let data = try? JSONEncoder().encode(modules) else { return }
try? data.write(to: url)
DispatchQueue.main.async {
let url = self.getModulesFilePath()
guard let data = try? JSONEncoder().encode(self.modules) else { return }
try? data.write(to: url)
}
}

View file

@ -76,7 +76,7 @@ class LibraryManager: ObservableObject {
let encoded = try JSONEncoder().encode(bookmarks)
userDefaultsSuite.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

@ -19,7 +19,11 @@ struct LibraryView: View {
@AppStorage("mediaColumnsLandscape") private var mediaColumnsLandscape: Int = 4
@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
@State private var showProfileSettings = false
@ -106,7 +110,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))
@ -149,6 +156,28 @@ 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()
}
.onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in
updateOrientation()
}
}
}
.padding(.vertical, 20)

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,6 +54,8 @@ struct MediaInfoView: View {
@State private var showSettingsMenu = false
@State private var customAniListID: Int?
@State private var orientationChanged: Bool = false
private var isGroupedBySeasons: Bool {
return groupedEpisodes().count > 1
}
@ -416,9 +418,9 @@ struct MediaInfoView: View {
.navigationViewStyle(StackNavigationViewStyle())
}
}
}
.onAppear {
buttonRefreshTrigger.toggle()
.onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in
orientationChanged.toggle()
}
if !hasFetched {
DropManager.shared.showDrop(title: "Fetching Data", subtitle: "Please wait while fetching.", duration: 0.5, icon: UIImage(systemName: "arrow.triangle.2.circlepath"))
@ -438,6 +440,14 @@ struct MediaInfoView: View {
AnalyticsManager.shared.sendEvent(event: "error", additionalData: ["error": error, "message": "Failed to fetch AniList ID"])
}
}
.font(.subheadline)
.padding(.vertical, 8)
.padding(.horizontal, 24)
.background(
Color(red: 1.0, green: 112/255.0, blue: 94/255.0)
)
.foregroundColor(.white)
.cornerRadius(8)
}
hasFetched = true
@ -534,7 +544,6 @@ struct MediaInfoView: View {
return groups
}
func fetchDetails() {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
Task {

View file

@ -18,8 +18,10 @@ struct SettingsViewGeneral: View {
@AppStorage("mediaColumnsLandscape") private var mediaColumnsLandscape: Int = 4
@AppStorage("hideEmptySections") private var hideEmptySections: Bool = false
@AppStorage("currentAppIcon") private var currentAppIcon: String = "Default"
@AppStorage("episodeSortOrder") private var episodeSortOrder: String = "Ascending"
private let metadataProvidersList = ["AniList"]
private let sortOrderOptions = ["Ascending", "Descending"]
@EnvironmentObject var settings: Settings
@State var showAppIconPicker: Bool = false
@ -73,6 +75,7 @@ struct SettingsViewGeneral: View {
}
Section(header: Text("Media View"), footer: Text("The episode range controls how many episodes appear on each page. Episodes are grouped into sets (like 1-25, 26-50, and so on), allowing you to navigate through them more easily.\n\nFor episode metadata it is refering to the episode thumbnail and title, since sometimes it can contain spoilers.")) {
HStack {
Text("Episodes Range")
Spacer()
@ -85,8 +88,10 @@ struct SettingsViewGeneral: View {
Text("\(episodeChunkSize)")
}
}
Toggle("Fetch Episode metadata", isOn: $fetchEpisodeMetadata)
.tint(.accentColor)
HStack {
Text("Metadata Provider")
Spacer()

View file

@ -14,12 +14,14 @@ struct SettingsViewModule: View {
@AppStorage("selectedModuleId") private var selectedModuleId: String?
@AppStorage("hideEmptySections") private var hideEmptySections: Bool?
@AppStorage("didReceiveDefaultPageLink") private var didReceiveDefaultPageLink: Bool = false
@State private var errorMessage: String?
@State private var isLoading = false
@State private var isRefreshing = false
@State private var moduleUrl: String = ""
@State private var refreshTask: Task<Void, Never>?
@State private var showLibrary = false
var body: some View {
VStack {
@ -31,15 +33,26 @@ struct SettingsViewModule: View {
.foregroundColor(.secondary)
Text("No Modules")
.font(.headline)
Text("Click the plus button to add a module!")
.font(.caption)
.foregroundColor(.secondary)
.frame(maxWidth: .infinity)
if didReceiveDefaultPageLink {
NavigationLink(destination: CommunityLibraryView()
.environmentObject(moduleManager)) {
Text("Check out some community modules here!")
.font(.caption)
.foregroundColor(.accentColor)
.frame(maxWidth: .infinity)
}
.buttonStyle(PlainButtonStyle())
} else {
Text("Click the plus button to add a module!")
.font(.caption)
.foregroundColor(.secondary)
.frame(maxWidth: .infinity)
}
}
.padding()
.frame(maxWidth: .infinity)
}
else {
} else {
ForEach(moduleManager.modules) { module in
HStack {
KFImage(URL(string: module.metadata.iconUrl))
@ -108,13 +121,38 @@ struct SettingsViewModule: View {
}
}
.navigationTitle("Modules")
.navigationBarItems(trailing: Button(action: {
showAddModuleAlert()
}) {
Image(systemName: "plus")
.resizable()
.padding(5)
})
.navigationBarItems(trailing:
HStack(spacing: 16) {
if didReceiveDefaultPageLink && !moduleManager.modules.isEmpty {
Button(action: {
showLibrary = true
}) {
Image(systemName: "books.vertical.fill")
.resizable()
.frame(width: 20, height: 20)
.padding(5)
}
.accessibilityLabel("Open Community Library")
}
Button(action: {
showAddModuleAlert()
}) {
Image(systemName: "plus")
.resizable()
.frame(width: 20, height: 20)
.padding(5)
}
.accessibilityLabel("Add Module")
}
)
.background(
NavigationLink(
destination: CommunityLibraryView()
.environmentObject(moduleManager),
isActive: $showLibrary
) { EmptyView() }
)
.refreshable {
isRefreshing = true
refreshTask?.cancel()
@ -211,5 +249,4 @@ struct SettingsViewModule: View {
}
}
}
}

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))
@ -117,6 +117,7 @@ struct SubtitleSettingsSection: View {
@State private var shadowRadius: Double = SubtitleSettingsManager.shared.settings.shadowRadius
@State private var backgroundEnabled: Bool = SubtitleSettingsManager.shared.settings.backgroundEnabled
@State private var bottomPadding: CGFloat = SubtitleSettingsManager.shared.settings.bottomPadding
@State private var subtitleDelay: Double = SubtitleSettingsManager.shared.settings.subtitleDelay
private let colors = ["white", "yellow", "green", "blue", "red", "purple"]
private let shadowOptions = [0, 1, 3, 6]
@ -186,6 +187,28 @@ struct SubtitleSettingsSection: View {
}
}
}
VStack(alignment: .leading) {
Text("Subtitle Delay: \(String(format: "%.1fs", subtitleDelay))")
.padding(.bottom, 1)
HStack {
Text("-10s")
.font(.system(size: 12))
.foregroundColor(.secondary)
Slider(value: $subtitleDelay, in: -10...10, step: 0.1)
.onChange(of: subtitleDelay) { newValue in
SubtitleSettingsManager.shared.update { settings in
settings.subtitleDelay = newValue
}
}
Text("+10s")
.font(.system(size: 12))
.foregroundColor(.secondary)
}
}
}
}
}

View file

@ -34,7 +34,7 @@
"location" : "https://github.com/onevcat/Kingfisher.git",
"state" : {
"branch" : "master",
"revision" : "7deda23bbdca612076c5c315003d8638a08ed0f1"
"revision" : "a1704c5e75d563789b8f9f2f88cddee1c3ab4e49"
}
},
{

BIN
assets/banner1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB