mirror of
https://github.com/cranci1/Sora.git
synced 2026-04-14 05:20:25 +00:00
iCloud support ong (#85)
This commit is contained in:
commit
9c5eacf1ec
6 changed files with 260 additions and 18 deletions
|
|
@ -15,6 +15,14 @@ struct SoraApp: App {
|
|||
|
||||
init() {
|
||||
_ = iCloudSyncManager.shared
|
||||
|
||||
TraktToken.checkAuthenticationStatus { isAuthenticated in
|
||||
if isAuthenticated {
|
||||
Logger.shared.log("Trakt authentication is valid")
|
||||
} else {
|
||||
Logger.shared.log("Trakt authentication required", type: "Warning")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
|
|
@ -89,4 +97,4 @@ struct SoraApp: App {
|
|||
Logger.shared.log("Unknown authentication service", type: "Error")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -164,5 +164,83 @@ class TraktToken {
|
|||
|
||||
return token
|
||||
}
|
||||
|
||||
static func getAccessToken() -> String? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: serviceName,
|
||||
kSecAttrAccount as String: accessTokenKey,
|
||||
kSecReturnData as String: true
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
|
||||
guard status == errSecSuccess,
|
||||
let tokenData = result as? Data,
|
||||
let token = String(data: tokenData, encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
static func validateToken(completion: @escaping (Bool) -> Void) {
|
||||
guard let token = getAccessToken() else {
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
|
||||
guard let url = URL(string: "https://api.trakt.tv/users/settings") else {
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "GET"
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("2", forHTTPHeaderField: "trakt-api-version")
|
||||
request.setValue(clientID, forHTTPHeaderField: "trakt-api-key")
|
||||
|
||||
let task = URLSession.shared.dataTask(with: request) { _, response, _ in
|
||||
DispatchQueue.main.async {
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
let isValid = httpResponse.statusCode == 200
|
||||
completion(isValid)
|
||||
} else {
|
||||
completion(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
task.resume()
|
||||
}
|
||||
|
||||
static func validateAndRefreshTokenIfNeeded(completion: @escaping (Bool) -> Void) {
|
||||
if getAccessToken() == nil {
|
||||
if getRefreshToken() != nil {
|
||||
refreshAccessToken(completion: completion)
|
||||
} else {
|
||||
completion(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
validateToken { isValid in
|
||||
if isValid {
|
||||
completion(true)
|
||||
} else {
|
||||
if getRefreshToken() != nil {
|
||||
refreshAccessToken(completion: completion)
|
||||
} else {
|
||||
completion(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func checkAuthenticationStatus(completion: @escaping (Bool) -> Void) {
|
||||
validateAndRefreshTokenIfNeeded(completion: completion)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -92,6 +92,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
|||
var backwardButton: UIImageView!
|
||||
var forwardButton: UIImageView!
|
||||
var subtitleLabel: UILabel!
|
||||
var topSubtitleLabel: UILabel!
|
||||
var dismissButton: UIButton!
|
||||
var menuButton: UIButton!
|
||||
var watchNextButton: UIButton!
|
||||
|
|
@ -215,7 +216,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
|||
do {
|
||||
try audioSession.setActive(true)
|
||||
} catch {
|
||||
print("Error activating audio session: \(error)")
|
||||
Logger.shared.log("Error activating audio session: \(error)", type: "Debug")
|
||||
}
|
||||
|
||||
volumeViewModel.value = Double(audioSession.outputVolume)
|
||||
|
|
@ -652,6 +653,21 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
|||
subtitleLabel.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor, constant: 36),
|
||||
subtitleLabel.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -36)
|
||||
])
|
||||
|
||||
topSubtitleLabel = UILabel()
|
||||
topSubtitleLabel.textAlignment = .center
|
||||
topSubtitleLabel.numberOfLines = 0
|
||||
topSubtitleLabel.font = UIFont.systemFont(ofSize: CGFloat(subtitleFontSize))
|
||||
topSubtitleLabel.isHidden = true
|
||||
view.addSubview(topSubtitleLabel)
|
||||
topSubtitleLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
topSubtitleLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||
topSubtitleLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 30),
|
||||
topSubtitleLabel.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor, constant: 36),
|
||||
topSubtitleLabel.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -36)
|
||||
])
|
||||
}
|
||||
|
||||
func updateSubtitleLabelConstraints() {
|
||||
|
|
@ -949,6 +965,16 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
|||
subtitleLabel.layer.shadowRadius = CGFloat(subtitleShadowRadius)
|
||||
subtitleLabel.layer.shadowOpacity = 1.0
|
||||
subtitleLabel.layer.shadowOffset = CGSize.zero
|
||||
|
||||
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 = CGSize.zero
|
||||
}
|
||||
|
||||
func subtitleUIColor() -> UIColor {
|
||||
|
|
@ -985,11 +1011,27 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
|||
UserDefaults.standard.set(self.currentTimeVal, forKey: "lastPlayedTime_\(self.fullUrl)")
|
||||
UserDefaults.standard.set(self.duration, forKey: "totalTime_\(self.fullUrl)")
|
||||
|
||||
if self.subtitlesEnabled,
|
||||
let currentCue = self.subtitlesLoader.cues.first(where: { self.currentTimeVal >= $0.startTime && self.currentTimeVal <= $0.endTime }) {
|
||||
self.subtitleLabel.text = currentCue.text.strippedHTML
|
||||
if self.subtitlesEnabled {
|
||||
let cues = self.subtitlesLoader.cues.filter { self.currentTimeVal >= $0.startTime && self.currentTimeVal <= $0.endTime }
|
||||
if cues.count > 0 {
|
||||
self.subtitleLabel.text = cues[0].text.strippedHTML
|
||||
self.subtitleLabel.isHidden = false
|
||||
} else {
|
||||
self.subtitleLabel.text = ""
|
||||
self.subtitleLabel.isHidden = !self.subtitlesEnabled
|
||||
}
|
||||
if cues.count > 1 {
|
||||
self.topSubtitleLabel.text = cues[1].text.strippedHTML
|
||||
self.topSubtitleLabel.isHidden = false
|
||||
} else {
|
||||
self.topSubtitleLabel.text = ""
|
||||
self.topSubtitleLabel.isHidden = true
|
||||
}
|
||||
} else {
|
||||
self.subtitleLabel.text = ""
|
||||
self.subtitleLabel.isHidden = true
|
||||
self.topSubtitleLabel.text = ""
|
||||
self.topSubtitleLabel.isHidden = true
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
|
|
|
|||
|
|
@ -29,6 +29,24 @@ class ModuleManager: ObservableObject {
|
|||
let url = getModulesFilePath()
|
||||
guard let data = try? Data(contentsOf: url) else { return }
|
||||
modules = (try? JSONDecoder().decode([ScrapingModule].self, from: data)) ?? []
|
||||
|
||||
Task {
|
||||
for module in modules {
|
||||
let localUrl = getDocumentsDirectory().appendingPathComponent(module.localPath)
|
||||
if (!fileManager.fileExists(atPath: localUrl.path)) {
|
||||
do {
|
||||
let scriptUrl = URL(string: module.metadata.scriptUrl)
|
||||
guard let scriptUrl = scriptUrl else { continue }
|
||||
let (scriptData, _) = try await URLSession.custom.data(from: scriptUrl)
|
||||
guard let jsContent = String(data: scriptData, encoding: .utf8) else { continue }
|
||||
try jsContent.write(to: localUrl, atomically: true, encoding: .utf8)
|
||||
Logger.shared.log("Recovered missing JS file for module: \(module.metadata.sourceName)")
|
||||
} catch {
|
||||
Logger.shared.log("Failed to recover JS file for module: \(module.metadata.sourceName) - \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func saveModules() {
|
||||
|
|
|
|||
|
|
@ -26,9 +26,20 @@ class iCloudSyncManager {
|
|||
"sendPushUpdates",
|
||||
"sendTraktUpdates",
|
||||
"bookmarkedItems",
|
||||
"continueWatchingItems"
|
||||
"continueWatchingItems",
|
||||
"analyticsEnabled",
|
||||
"refreshModulesOnLaunch",
|
||||
"fetchEpisodeMetadata",
|
||||
"multiThreads",
|
||||
"metadataProviders"
|
||||
]
|
||||
|
||||
private let modulesFileName = "modules.json"
|
||||
|
||||
private var ubiquityContainerURL: URL? {
|
||||
FileManager.default.url(forUbiquityContainerIdentifier: nil)?.appendingPathComponent("Documents")
|
||||
}
|
||||
|
||||
private init() {
|
||||
setupSync()
|
||||
|
||||
|
|
@ -37,23 +48,43 @@ class iCloudSyncManager {
|
|||
|
||||
private func setupSync() {
|
||||
NSUbiquitousKeyValueStore.default.synchronize()
|
||||
|
||||
syncFromiCloud()
|
||||
|
||||
syncModulesFromiCloud()
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(iCloudDidChangeExternally), name: NSUbiquitousKeyValueStore.didChangeExternallyNotification, object: NSUbiquitousKeyValueStore.default)
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(userDefaultsDidChange), name: UserDefaults.didChangeNotification, object: nil)
|
||||
}
|
||||
|
||||
@objc private func willEnterBackground() {
|
||||
syncToiCloud()
|
||||
syncModulesToiCloud()
|
||||
}
|
||||
|
||||
private func allProgressKeys() -> [String] {
|
||||
let allKeys = UserDefaults.standard.dictionaryRepresentation().keys
|
||||
let progressPrefixes = ["lastPlayedTime_", "totalTime_"]
|
||||
return allKeys.filter { key in
|
||||
progressPrefixes.contains { prefix in key.hasPrefix(prefix) }
|
||||
}
|
||||
}
|
||||
|
||||
private func allKeysToSync() -> [String] {
|
||||
var keys = Set(defaultsToSync + allProgressKeys())
|
||||
let userDefaults = UserDefaults.standard
|
||||
let all = userDefaults.dictionaryRepresentation()
|
||||
for (key, value) in all {
|
||||
if key.hasPrefix("Apple") || key.hasPrefix("_") { continue }
|
||||
if value is Int || value is Double || value is Bool || value is String {
|
||||
keys.insert(key)
|
||||
}
|
||||
}
|
||||
return Array(keys)
|
||||
}
|
||||
|
||||
private func syncFromiCloud() {
|
||||
let iCloud = NSUbiquitousKeyValueStore.default
|
||||
let defaults = UserDefaults.standard
|
||||
|
||||
for key in defaultsToSync {
|
||||
for key in allKeysToSync() {
|
||||
if let value = iCloud.object(forKey: key) {
|
||||
defaults.set(value, forKey: key)
|
||||
}
|
||||
|
|
@ -67,7 +98,7 @@ class iCloudSyncManager {
|
|||
let iCloud = NSUbiquitousKeyValueStore.default
|
||||
let defaults = UserDefaults.standard
|
||||
|
||||
for key in defaultsToSync {
|
||||
for key in allKeysToSync() {
|
||||
if let value = defaults.object(forKey: key) {
|
||||
iCloud.set(value, forKey: key)
|
||||
}
|
||||
|
|
@ -79,16 +110,79 @@ class iCloudSyncManager {
|
|||
@objc private func iCloudDidChangeExternally(_ notification: Notification) {
|
||||
guard let userInfo = notification.userInfo,
|
||||
let reason = userInfo[NSUbiquitousKeyValueStoreChangeReasonKey] as? Int else {
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
if reason == NSUbiquitousKeyValueStoreServerChange ||
|
||||
reason == NSUbiquitousKeyValueStoreInitialSyncChange {
|
||||
reason == NSUbiquitousKeyValueStoreInitialSyncChange {
|
||||
syncFromiCloud()
|
||||
syncModulesFromiCloud()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func userDefaultsDidChange(_ notification: Notification) {
|
||||
syncToiCloud()
|
||||
}
|
||||
|
||||
func syncModulesToiCloud() {
|
||||
DispatchQueue.global(qos: .background).async {
|
||||
guard let iCloudURL = self.ubiquityContainerURL else { return }
|
||||
let localModulesURL = self.getLocalModulesFileURL()
|
||||
let iCloudModulesURL = iCloudURL.appendingPathComponent(self.modulesFileName)
|
||||
do {
|
||||
guard FileManager.default.fileExists(atPath: localModulesURL.path) else { return }
|
||||
|
||||
let shouldCopy: Bool
|
||||
if FileManager.default.fileExists(atPath: iCloudModulesURL.path) {
|
||||
let localData = try Data(contentsOf: localModulesURL)
|
||||
let iCloudData = try Data(contentsOf: iCloudModulesURL)
|
||||
shouldCopy = localData != iCloudData
|
||||
} else {
|
||||
shouldCopy = true
|
||||
}
|
||||
|
||||
if shouldCopy {
|
||||
if FileManager.default.fileExists(atPath: iCloudModulesURL.path) {
|
||||
try FileManager.default.removeItem(at: iCloudModulesURL)
|
||||
}
|
||||
try FileManager.default.copyItem(at: localModulesURL, to: iCloudModulesURL)
|
||||
}
|
||||
} catch {
|
||||
Logger.shared.log("iCloud modules sync error: \(error)", type: "Error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func syncModulesFromiCloud() {
|
||||
DispatchQueue.global(qos: .background).async {
|
||||
guard let iCloudURL = self.ubiquityContainerURL else { return }
|
||||
let localModulesURL = self.getLocalModulesFileURL()
|
||||
let iCloudModulesURL = iCloudURL.appendingPathComponent(self.modulesFileName)
|
||||
do {
|
||||
guard FileManager.default.fileExists(atPath: iCloudModulesURL.path) else { return }
|
||||
|
||||
let shouldCopy: Bool
|
||||
if FileManager.default.fileExists(atPath: localModulesURL.path) {
|
||||
let localData = try Data(contentsOf: localModulesURL)
|
||||
let iCloudData = try Data(contentsOf: iCloudModulesURL)
|
||||
shouldCopy = localData != iCloudData
|
||||
} else {
|
||||
shouldCopy = true
|
||||
}
|
||||
|
||||
if shouldCopy {
|
||||
if FileManager.default.fileExists(atPath: localModulesURL.path) {
|
||||
try FileManager.default.removeItem(at: localModulesURL)
|
||||
}
|
||||
try FileManager.default.copyItem(at: iCloudModulesURL, to: localModulesURL)
|
||||
}
|
||||
} catch {
|
||||
Logger.shared.log("iCloud modules fetch error: \(error)", type: "Error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func getLocalModulesFileURL() -> URL {
|
||||
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
return docs.appendingPathComponent(modulesFileName)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ struct SettingsViewTrackers: View {
|
|||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section(header: Text("AniList"), footer: Text("Sora and cranci1 are not affiliated with AniList in any way.\n\nNote that progresses update may not be 100% acurate.")) {
|
||||
Section(header: Text("AniList")) {
|
||||
HStack() {
|
||||
KFImage(URL(string: "https://raw.githubusercontent.com/cranci1/Ryu/2f10226aa087154974a70c1ec78aa83a47daced9/Ryu/Assets.xcassets/Listing/Anilist.imageset/anilist.png"))
|
||||
.placeholder {
|
||||
|
|
@ -74,7 +74,7 @@ struct SettingsViewTrackers: View {
|
|||
.font(.body)
|
||||
}
|
||||
|
||||
Section(header: Text("Trakt"), footer: Text("Sora and cranci1 are not affiliated with Trakt in any way.\n\nNote that progress updates may not be 100% accurate.")) {
|
||||
Section(header: Text("Trakt")) {
|
||||
HStack() {
|
||||
KFImage(URL(string: "https://static-00.iconduck.com/assets.00/trakt-icon-2048x2048-2633ksxg.png"))
|
||||
.placeholder {
|
||||
|
|
@ -116,6 +116,8 @@ struct SettingsViewTrackers: View {
|
|||
}
|
||||
.font(.body)
|
||||
}
|
||||
|
||||
Section(footer: Text("Sora and cranci1 are not affiliated with AniList nor Trakt in any way.\n\nAlso note that progresses update may not be 100% accurate.")) {}
|
||||
}
|
||||
.navigationTitle("Trackers")
|
||||
.onAppear {
|
||||
|
|
|
|||
Loading…
Reference in a new issue