mirror of
https://github.com/cranci1/Sora.git
synced 2026-03-11 17:45:37 +00:00
merge dev commits into features branch
This commit is contained in:
commit
d38c289fb8
22 changed files with 697 additions and 509 deletions
28
.github/workflows/build.yml
vendored
28
.github/workflows/build.yml
vendored
|
|
@ -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
5
.gitignore
vendored
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
104
Sora/Utils/Modules/CommunityLib.swift
Normal file
104
Sora/Utils/Modules/CommunityLib.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@
|
|||
"location" : "https://github.com/onevcat/Kingfisher.git",
|
||||
"state" : {
|
||||
"branch" : "master",
|
||||
"revision" : "7deda23bbdca612076c5c315003d8638a08ed0f1"
|
||||
"revision" : "a1704c5e75d563789b8f9f2f88cddee1c3ab4e49"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
|||
BIN
assets/banner1.png
Normal file
BIN
assets/banner1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 181 KiB |
Loading…
Reference in a new issue