diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8045d57..334ac49 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/.gitignore b/.gitignore index 2430072..c0d6680 100644 --- a/.gitignore +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/README.md b/README.md index ea07baf..b2eb745 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/Sora/SoraApp.swift b/Sora/SoraApp.swift index 4fc513d..fdfe467 100644 --- a/Sora/SoraApp.swift +++ b/Sora/SoraApp.swift @@ -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") } diff --git a/Sora/Tracking Services/AniList/Mutations/AniListPushUpdates.swift b/Sora/Tracking Services/AniList/Mutations/AniListPushUpdates.swift index 5c883f5..4383e1f 100644 --- a/Sora/Tracking Services/AniList/Mutations/AniListPushUpdates.swift +++ b/Sora/Tracking Services/AniList/Mutations/AniListPushUpdates.swift @@ -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 diff --git a/Sora/Utils/DownloadManager/DownloadManager.swift b/Sora/Utils/DownloadManager/DownloadManager.swift deleted file mode 100644 index 78d2b08..0000000 --- a/Sora/Utils/DownloadManager/DownloadManager.swift +++ /dev/null @@ -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) - } - } -} diff --git a/Sora/Utils/Extensions/Notification+Name.swift b/Sora/Utils/Extensions/Notification+Name.swift index d4a3fad..2e87c5c 100644 --- a/Sora/Utils/Extensions/Notification+Name.swift +++ b/Sora/Utils/Extensions/Notification+Name.swift @@ -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") diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/Components/MusicProgressSlider.swift b/Sora/Utils/MediaPlayer/CustomPlayer/Components/MusicProgressSlider.swift index 3183628..5c97e1d 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/Components/MusicProgressSlider.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/Components/MusicProgressSlider.swift @@ -18,8 +18,8 @@ struct MusicProgressSlider: View { let emptyColor: Color let height: CGFloat let onEditingChanged: (Bool) -> Void - let introSegments: [ClosedRange] // Changed - let outroSegments: [ClosedRange] // Changed + let introSegments: [ClosedRange] + let outroSegments: [ClosedRange] let introColor: Color let outroColor: Color @@ -57,10 +57,10 @@ struct MusicProgressSlider: View { } } - // Rest of the existing code... Capsule() .fill(emptyColor) } + .clipShape(Capsule()) Capsule() .fill(isActive ? activeFillColor : fillColor) diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift index ed367ca..c8e6b3c 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift @@ -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] = [] + 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 label’s 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 on‑screen + 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 it’s 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 dim‑button 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 diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/Helpers/SubtitleSettingsManager.swift b/Sora/Utils/MediaPlayer/CustomPlayer/Helpers/SubtitleSettingsManager.swift index cb8f82a..57e96b3 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/Helpers/SubtitleSettingsManager.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/Helpers/SubtitleSettingsManager.swift @@ -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 { diff --git a/Sora/Utils/Modules/CommunityLib.swift b/Sora/Utils/Modules/CommunityLib.swift new file mode 100644 index 0000000..10ae24f --- /dev/null +++ b/Sora/Utils/Modules/CommunityLib.swift @@ -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) + } + } + } +} diff --git a/Sora/Utils/Modules/ModuleAdditionSettingsView.swift b/Sora/Utils/Modules/ModuleAdditionSettingsView.swift index 2afb2da..fb49ef0 100644 --- a/Sora/Utils/Modules/ModuleAdditionSettingsView.swift +++ b/Sora/Utils/Modules/ModuleAdditionSettingsView.swift @@ -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 } diff --git a/Sora/Utils/Modules/ModuleManager.swift b/Sora/Utils/Modules/ModuleManager.swift index e69cf6b..33ab6f0 100644 --- a/Sora/Utils/Modules/ModuleManager.swift +++ b/Sora/Utils/Modules/ModuleManager.swift @@ -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) + } } diff --git a/Sora/Views/LibraryView/LibraryManager.swift b/Sora/Views/LibraryView/LibraryManager.swift index 6b37888..10604b7 100644 --- a/Sora/Views/LibraryView/LibraryManager.swift +++ b/Sora/Views/LibraryView/LibraryManager.swift @@ -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") } } diff --git a/Sora/Views/LibraryView/LibraryView.swift b/Sora/Views/LibraryView/LibraryView.swift index 931ad35..1f7243a 100644 --- a/Sora/Views/LibraryView/LibraryView.swift +++ b/Sora/Views/LibraryView/LibraryView.swift @@ -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) diff --git a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift index 17aa875..bf41b8b 100644 --- a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift +++ b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift @@ -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) } } diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index 76ef8b5..871b71b 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -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 { diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift index 50ac9a8..0390ea9 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift @@ -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() diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewModule.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewModule.swift index dfb1bc0..8cbba65 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewModule.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewModule.swift @@ -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? + @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 { } } } - } diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift index 0b95697..394c649 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift @@ -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) + } + } } } } diff --git a/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index cbfb1e4..5525459 100644 --- a/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -34,7 +34,7 @@ "location" : "https://github.com/onevcat/Kingfisher.git", "state" : { "branch" : "master", - "revision" : "7deda23bbdca612076c5c315003d8638a08ed0f1" + "revision" : "a1704c5e75d563789b8f9f2f88cddee1c3ab4e49" } }, { diff --git a/assets/banner1.png b/assets/banner1.png new file mode 100644 index 0000000..72ae268 Binary files /dev/null and b/assets/banner1.png differ