mirror of
https://github.com/cranci1/Sora.git
synced 2026-03-11 17:45:37 +00:00
added auth exchange
This commit is contained in:
parent
63d093b47c
commit
748cc4e999
9 changed files with 183 additions and 702 deletions
|
|
@ -29,7 +29,11 @@ struct SoraApp: App {
|
|||
}
|
||||
}
|
||||
.onOpenURL { url in
|
||||
handleURL(url)
|
||||
if let params = url.queryParameters, params["code"] != nil {
|
||||
Self.handleRedirect(url: url)
|
||||
} else {
|
||||
handleURL(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -52,4 +56,20 @@ struct SoraApp: App {
|
|||
Logger.shared.log("Failed to present module addition view: No window scene found", type: "Error")
|
||||
}
|
||||
}
|
||||
|
||||
static func handleRedirect(url: URL) {
|
||||
guard let params = url.queryParameters,
|
||||
let code = params["code"] else {
|
||||
Logger.shared.log("Failed to extract authorization code")
|
||||
return
|
||||
}
|
||||
|
||||
AniListToken.exchangeAuthorizationCodeForToken(code: code) { success in
|
||||
if success {
|
||||
Logger.shared.log("Token exchange successful")
|
||||
} else {
|
||||
Logger.shared.log("Token exchange failed", type: "Error")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
34
Sora/Tracking Services/AniList/Auth/Anilist-Login.swift
Normal file
34
Sora/Tracking Services/AniList/Auth/Anilist-Login.swift
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
//
|
||||
// Login.swift
|
||||
// Ryu
|
||||
//
|
||||
// Created by Francesco on 08/08/24.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class AniListLogin {
|
||||
static let clientID = "19551"
|
||||
static let redirectURI = "sora://anilist"
|
||||
|
||||
static let authorizationEndpoint = "https://anilist.co/api/v2/oauth/authorize"
|
||||
|
||||
static func authenticate() {
|
||||
let urlString = "\(authorizationEndpoint)?client_id=\(clientID)&redirect_uri=\(redirectURI)&response_type=code"
|
||||
guard let url = URL(string: urlString) else {
|
||||
return
|
||||
}
|
||||
|
||||
if UIApplication.shared.canOpenURL(url) {
|
||||
UIApplication.shared.open(url, options: [:]) { success in
|
||||
if success {
|
||||
Logger.shared.log("Safari opened successfully", type: "Debug")
|
||||
} else {
|
||||
Logger.shared.log("Failed to open Safari", type: "Error")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Logger.shared.log("Cannot open URL", type: "Error")
|
||||
}
|
||||
}
|
||||
}
|
||||
88
Sora/Tracking Services/AniList/Auth/Anilist-Token.swift
Normal file
88
Sora/Tracking Services/AniList/Auth/Anilist-Token.swift
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
//
|
||||
// Token.swift
|
||||
// Ryu
|
||||
//
|
||||
// Created by Francesco on 08/08/24.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Security
|
||||
|
||||
class AniListToken {
|
||||
static let clientID = "19551"
|
||||
static let clientSecret = "fk8EgkyFbXk95TbPwLYQLaiMaNIryMpDBwJsPXoX"
|
||||
static let redirectURI = "sora://anilist"
|
||||
|
||||
static let tokenEndpoint = "https://anilist.co/api/v2/oauth/token"
|
||||
static let serviceName = "me.cranci.sora.AniListToken"
|
||||
static let accountName = "AniListAccessToken"
|
||||
|
||||
static func saveTokenToKeychain(token: String) -> Bool {
|
||||
let tokenData = token.data(using: .utf8)!
|
||||
|
||||
let deleteQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: serviceName,
|
||||
kSecAttrAccount as String: accountName
|
||||
]
|
||||
SecItemDelete(deleteQuery as CFDictionary)
|
||||
|
||||
let addQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: serviceName,
|
||||
kSecAttrAccount as String: accountName,
|
||||
kSecValueData as String: tokenData
|
||||
]
|
||||
|
||||
let status = SecItemAdd(addQuery as CFDictionary, nil)
|
||||
return status == errSecSuccess
|
||||
}
|
||||
|
||||
static func exchangeAuthorizationCodeForToken(code: String, completion: @escaping (Bool) -> Void) {
|
||||
Logger.shared.log("Exchanging authorization code for access token...")
|
||||
|
||||
guard let url = URL(string: tokenEndpoint) else {
|
||||
Logger.shared.log("Invalid token endpoint URL", type: "Error")
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
let bodyString = "grant_type=authorization_code&client_id=\(clientID)&client_secret=\(clientSecret)&redirect_uri=\(redirectURI)&code=\(code)"
|
||||
request.httpBody = bodyString.data(using: .utf8)
|
||||
|
||||
let task = URLSession.shared.dataTask(with: request) { data, response, error in
|
||||
if let error = error {
|
||||
Logger.shared.log("Error: \(error.localizedDescription)", type: "Error")
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
|
||||
guard let data = data else {
|
||||
Logger.shared.log("No data received", type: "Error")
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
||||
if let accessToken = json["access_token"] as? String {
|
||||
let success = saveTokenToKeychain(token: accessToken)
|
||||
completion(success)
|
||||
} else {
|
||||
Logger.shared.log("Unexpected response: \(json)", type: "Error")
|
||||
completion(false)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Logger.shared.log("Failed to parse JSON: \(error.localizedDescription)", type: "Error")
|
||||
completion(false)
|
||||
}
|
||||
}
|
||||
|
||||
task.resume()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
//
|
||||
// AniList-Seasonal.swift
|
||||
// Sora
|
||||
//
|
||||
// Created by Francesco on 09/02/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class AnilistServiceSeasonalAnime {
|
||||
func fetchSeasonalAnime(completion: @escaping ([AniListItem]?) -> Void) {
|
||||
let currentDate = Date()
|
||||
let calendar = Calendar.current
|
||||
let year = calendar.component(.year, from: currentDate)
|
||||
let month = calendar.component(.month, from: currentDate)
|
||||
|
||||
let season: String
|
||||
switch month {
|
||||
case 1...3:
|
||||
season = "WINTER"
|
||||
case 4...6:
|
||||
season = "SPRING"
|
||||
case 7...9:
|
||||
season = "SUMMER"
|
||||
default:
|
||||
season = "FALL"
|
||||
}
|
||||
|
||||
let query = """
|
||||
query {
|
||||
Page(page: 1, perPage: 100) {
|
||||
media(season: \(season), seasonYear: \(year), type: ANIME, isAdult: false) {
|
||||
id
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
native
|
||||
}
|
||||
coverImage {
|
||||
large
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
guard let url = URL(string: "https://graphql.anilist.co") else {
|
||||
print("Invalid URL")
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
let parameters: [String: Any] = ["query": query]
|
||||
do {
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: parameters, options: [])
|
||||
} catch {
|
||||
print("Error encoding JSON: \(error.localizedDescription)")
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
|
||||
let task = URLSession.custom.dataTask(with: request) { data, response, error in
|
||||
DispatchQueue.main.async {
|
||||
if let error = error {
|
||||
print("Error fetching seasonal anime: \(error.localizedDescription)")
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
|
||||
guard let data = data else {
|
||||
print("No data returned")
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
|
||||
let dataObject = json["data"] as? [String: Any],
|
||||
let page = dataObject["Page"] as? [String: Any],
|
||||
let media = page["media"] as? [[String: Any]] {
|
||||
|
||||
let seasonalAnime: [AniListItem] = media.compactMap { item -> AniListItem? in
|
||||
guard let id = item["id"] as? Int,
|
||||
let titleData = item["title"] as? [String: Any],
|
||||
let romaji = titleData["romaji"] as? String,
|
||||
let english = titleData["english"] as? String?,
|
||||
let native = titleData["native"] as? String?,
|
||||
let coverImageData = item["coverImage"] as? [String: Any],
|
||||
let largeImageUrl = coverImageData["large"] as? String,
|
||||
URL(string: largeImageUrl) != nil else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return AniListItem(
|
||||
id: id,
|
||||
title: AniListTitle(romaji: romaji, english: english, native: native),
|
||||
coverImage: AniListCoverImage(large: largeImageUrl)
|
||||
)
|
||||
}
|
||||
completion(seasonalAnime)
|
||||
} else {
|
||||
print("Error parsing JSON or missing expected fields")
|
||||
completion(nil)
|
||||
}
|
||||
} catch {
|
||||
print("Error decoding JSON: \(error.localizedDescription)")
|
||||
completion(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
task.resume()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
//
|
||||
// AniList-Trending.swift
|
||||
// Sora
|
||||
//
|
||||
// Created by Francesco on 09/02/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class AnilistServiceTrendingAnime {
|
||||
func fetchTrendingAnime(completion: @escaping ([AniListItem]?) -> Void) {
|
||||
let query = """
|
||||
query {
|
||||
Page(page: 1, perPage: 100) {
|
||||
media(sort: TRENDING_DESC, type: ANIME, isAdult: false) {
|
||||
id
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
native
|
||||
}
|
||||
coverImage {
|
||||
large
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
guard let url = URL(string: "https://graphql.anilist.co") else {
|
||||
print("Invalid URL")
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
let parameters: [String: Any] = ["query": query]
|
||||
do {
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: parameters, options: [])
|
||||
} catch {
|
||||
print("Error encoding JSON: \(error.localizedDescription)")
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
|
||||
let task = URLSession.custom.dataTask(with: request) { data, response, error in
|
||||
DispatchQueue.main.async {
|
||||
if let error = error {
|
||||
print("Error fetching trending anime: \(error.localizedDescription)")
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
guard let data = data else {
|
||||
print("No data returned")
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
do {
|
||||
if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
|
||||
let dataObject = json["data"] as? [String: Any],
|
||||
let page = dataObject["Page"] as? [String: Any],
|
||||
let media = page["media"] as? [[String: Any]] {
|
||||
|
||||
let trendingAnime: [AniListItem] = media.compactMap { item in
|
||||
guard let id = item["id"] as? Int,
|
||||
let titleData = item["title"] as? [String: Any],
|
||||
let romaji = titleData["romaji"] as? String,
|
||||
let coverImageData = item["coverImage"] as? [String: Any],
|
||||
let largeImageUrl = coverImageData["large"] as? String else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return AniListItem(
|
||||
id: id,
|
||||
title: AniListTitle(romaji: romaji, english: titleData["english"] as? String, native: titleData["native"] as? String),
|
||||
coverImage: AniListCoverImage(large: largeImageUrl)
|
||||
)
|
||||
}
|
||||
completion(trendingAnime)
|
||||
} else {
|
||||
print("Error parsing JSON or missing expected fields")
|
||||
completion(nil)
|
||||
}
|
||||
} catch {
|
||||
print("Error decoding JSON: \(error.localizedDescription)")
|
||||
completion(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
task.resume()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,316 +0,0 @@
|
|||
//
|
||||
// AniList-DetailsView.swift
|
||||
// Sora
|
||||
//
|
||||
// Created by Francesco on 11/02/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Kingfisher
|
||||
|
||||
struct AniListDetailsView: View {
|
||||
let animeID: Int
|
||||
@StateObject private var viewModel: AniListDetailsViewModel
|
||||
|
||||
init(animeID: Int) {
|
||||
self.animeID = animeID
|
||||
_viewModel = StateObject(wrappedValue: AniListDetailsViewModel(animeID: animeID))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
.padding()
|
||||
} else if let media = viewModel.mediaInfo {
|
||||
MediaHeaderView(media: media)
|
||||
Divider()
|
||||
MediaDetailsScrollView(media: media)
|
||||
Divider()
|
||||
SynopsisView(synopsis: media["description"] as? String)
|
||||
Divider()
|
||||
CharactersView(characters: media["characters"] as? [String: Any])
|
||||
Divider()
|
||||
ScoreDistributionView(stats: media["stats"] as? [String: Any])
|
||||
} else {
|
||||
Text("Failed to load media details.")
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationBarTitle("", displayMode: .inline)
|
||||
.navigationViewStyle(StackNavigationViewStyle())
|
||||
.onAppear {
|
||||
viewModel.fetchDetails()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AniListDetailsViewModel: ObservableObject {
|
||||
@Published var mediaInfo: [String: AnyHashable]?
|
||||
@Published var isLoading: Bool = true
|
||||
|
||||
let animeID: Int
|
||||
|
||||
init(animeID: Int) {
|
||||
self.animeID = animeID
|
||||
}
|
||||
|
||||
func fetchDetails() {
|
||||
AnilistServiceMediaInfo.fetchAnimeDetails(animeID: animeID) { result in
|
||||
DispatchQueue.main.async {
|
||||
switch result {
|
||||
case .success(let media):
|
||||
var convertedMedia: [String: AnyHashable] = [:]
|
||||
for (key, value) in media {
|
||||
if let value = value as? AnyHashable {
|
||||
convertedMedia[key] = value
|
||||
}
|
||||
}
|
||||
self.mediaInfo = convertedMedia
|
||||
case .failure(let error):
|
||||
print("Error: \(error)")
|
||||
}
|
||||
self.isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MediaHeaderView: View {
|
||||
let media: [String: Any]
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 16) {
|
||||
if let coverDict = media["coverImage"] as? [String: Any],
|
||||
let posterURLString = coverDict["extraLarge"] as? String,
|
||||
let posterURL = URL(string: posterURLString) {
|
||||
KFImage(posterURL)
|
||||
.placeholder {
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(Color.gray.opacity(0.3))
|
||||
.frame(width: 150, height: 225)
|
||||
.shimmering()
|
||||
}
|
||||
.resizable()
|
||||
.aspectRatio(2/3, contentMode: .fill)
|
||||
.cornerRadius(10)
|
||||
.frame(width: 150, height: 225)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
if let titleDict = media["title"] as? [String: Any],
|
||||
let userPreferred = titleDict["english"] as? String {
|
||||
Text(userPreferred)
|
||||
.font(.system(size: 17))
|
||||
.fontWeight(.bold)
|
||||
.onLongPressGesture {
|
||||
UIPasteboard.general.string = userPreferred
|
||||
DropManager.shared.showDrop(title: "Copied to Clipboard", subtitle: "", duration: 1.0, icon: UIImage(systemName: "doc.on.clipboard.fill"))
|
||||
}
|
||||
}
|
||||
|
||||
if let titleDict = media["title"] as? [String: Any],
|
||||
let userPreferred = titleDict["romaji"] as? String {
|
||||
Text(userPreferred)
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
if let titleDict = media["title"] as? [String: Any],
|
||||
let userPreferred = titleDict["native"] as? String {
|
||||
Text(userPreferred)
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
struct MediaDetailsScrollView: View {
|
||||
let media: [String: Any]
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 4) {
|
||||
if let type = media["type"] as? String {
|
||||
MediaDetailItem(title: "Type", value: type)
|
||||
Divider()
|
||||
}
|
||||
if let episodes = media["episodes"] as? Int {
|
||||
MediaDetailItem(title: "Episodes", value: "\(episodes)")
|
||||
Divider()
|
||||
}
|
||||
if let duration = media["duration"] as? Int {
|
||||
MediaDetailItem(title: "Length", value: "\(duration) mins")
|
||||
Divider()
|
||||
}
|
||||
if let format = media["format"] as? String {
|
||||
MediaDetailItem(title: "Format", value: format)
|
||||
Divider()
|
||||
}
|
||||
if let status = media["status"] as? String {
|
||||
MediaDetailItem(title: "Status", value: status)
|
||||
Divider()
|
||||
}
|
||||
if let season = media["season"] as? String {
|
||||
MediaDetailItem(title: "Season", value: season)
|
||||
Divider()
|
||||
}
|
||||
if let startDate = media["startDate"] as? [String: Any],
|
||||
let year = startDate["year"] as? Int,
|
||||
let month = startDate["month"] as? Int,
|
||||
let day = startDate["day"] as? Int {
|
||||
MediaDetailItem(title: "Start Date", value: "\(year)-\(month)-\(day)")
|
||||
Divider()
|
||||
}
|
||||
if let endDate = media["endDate"] as? [String: Any],
|
||||
let year = endDate["year"] as? Int,
|
||||
let month = endDate["month"] as? Int,
|
||||
let day = endDate["day"] as? Int {
|
||||
MediaDetailItem(title: "End Date", value: "\(year)-\(month)-\(day)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SynopsisView: View {
|
||||
let synopsis: String?
|
||||
|
||||
var body: some View {
|
||||
if let synopsis = synopsis {
|
||||
Text(synopsis.strippedHTML)
|
||||
.padding(.horizontal)
|
||||
.foregroundColor(.secondary)
|
||||
.font(.system(size: 14))
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CharactersView: View {
|
||||
let characters: [String: Any]?
|
||||
|
||||
var body: some View {
|
||||
if let charactersDict = characters,
|
||||
let edges = charactersDict["edges"] as? [[String: Any]] {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Characters")
|
||||
.font(.headline)
|
||||
.padding(.horizontal)
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(Array(edges.prefix(15).enumerated()), id: \.offset) { _, edge in
|
||||
if let node = edge["node"] as? [String: Any],
|
||||
let nameDict = node["name"] as? [String: Any],
|
||||
let fullName = nameDict["full"] as? String,
|
||||
let imageDict = node["image"] as? [String: Any],
|
||||
let imageUrlStr = imageDict["large"] as? String,
|
||||
let imageUrl = URL(string: imageUrlStr) {
|
||||
CharacterItemView(imageUrl: imageUrl, name: fullName)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CharacterItemView: View {
|
||||
let imageUrl: URL
|
||||
let name: String
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
KFImage(imageUrl)
|
||||
.placeholder {
|
||||
Circle()
|
||||
.fill(Color.gray.opacity(0.3))
|
||||
.frame(width: 90, height: 90)
|
||||
.shimmering()
|
||||
}
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: 90, height: 90)
|
||||
.clipShape(Circle())
|
||||
Text(name)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.frame(width: 105, height: 110)
|
||||
}
|
||||
}
|
||||
|
||||
struct ScoreDistributionView: View {
|
||||
let stats: [String: Any]?
|
||||
|
||||
@State private var barHeights: [CGFloat] = []
|
||||
|
||||
var body: some View {
|
||||
if let stats = stats,
|
||||
let scoreDistribution = stats["scoreDistribution"] as? [[String: AnyHashable]] {
|
||||
|
||||
let maxValue: Int = scoreDistribution.compactMap { $0["amount"] as? Int }.max() ?? 1
|
||||
|
||||
let calculatedHeights = scoreDistribution.map { dataPoint -> CGFloat in
|
||||
guard let amount = dataPoint["amount"] as? Int else { return 0 }
|
||||
return CGFloat(amount) / CGFloat(maxValue) * 100
|
||||
}
|
||||
|
||||
VStack {
|
||||
Text("Score Distribution")
|
||||
.font(.headline)
|
||||
HStack(alignment: .bottom) {
|
||||
ForEach(Array(scoreDistribution.enumerated()), id: \.offset) { index, dataPoint in
|
||||
if let score = dataPoint["score"] as? Int {
|
||||
VStack {
|
||||
Rectangle()
|
||||
.fill(Color.accentColor)
|
||||
.frame(width: 20, height: calculatedHeights[index])
|
||||
Text("\(score)")
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.horizontal)
|
||||
.onAppear {
|
||||
barHeights = calculatedHeights
|
||||
}
|
||||
.onChange(of: scoreDistribution) { _ in
|
||||
barHeights = calculatedHeights
|
||||
}
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MediaDetailItem: View {
|
||||
var title: String
|
||||
var value: String
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Text(value)
|
||||
.font(.system(size: 17))
|
||||
Text(title)
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,135 +0,0 @@
|
|||
//
|
||||
// AniList-MediaInfo.swift
|
||||
// Sora
|
||||
//
|
||||
// Created by Francesco on 11/02/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class AnilistServiceMediaInfo {
|
||||
static func fetchAnimeDetails(animeID: Int, completion: @escaping (Result<[String: Any], Error>) -> Void) {
|
||||
let query = """
|
||||
query {
|
||||
Media(id: \(animeID), type: ANIME) {
|
||||
id
|
||||
idMal
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
native
|
||||
userPreferred
|
||||
}
|
||||
type
|
||||
format
|
||||
status
|
||||
description
|
||||
startDate {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
endDate {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
season
|
||||
episodes
|
||||
duration
|
||||
countryOfOrigin
|
||||
isLicensed
|
||||
source
|
||||
hashtag
|
||||
trailer {
|
||||
id
|
||||
site
|
||||
}
|
||||
updatedAt
|
||||
coverImage {
|
||||
extraLarge
|
||||
}
|
||||
bannerImage
|
||||
genres
|
||||
popularity
|
||||
tags {
|
||||
id
|
||||
name
|
||||
}
|
||||
relations {
|
||||
nodes {
|
||||
id
|
||||
coverImage { extraLarge }
|
||||
title { userPreferred },
|
||||
mediaListEntry { status }
|
||||
}
|
||||
}
|
||||
characters {
|
||||
edges {
|
||||
node {
|
||||
name {
|
||||
full
|
||||
}
|
||||
image {
|
||||
large
|
||||
}
|
||||
}
|
||||
role
|
||||
voiceActors {
|
||||
name {
|
||||
first
|
||||
last
|
||||
native
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
siteUrl
|
||||
stats {
|
||||
scoreDistribution {
|
||||
score
|
||||
amount
|
||||
}
|
||||
}
|
||||
airingSchedule(notYetAired: true) {
|
||||
nodes {
|
||||
airingAt
|
||||
episode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
let apiUrl = URL(string: "https://graphql.anilist.co")!
|
||||
|
||||
var request = URLRequest(url: apiUrl)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = try? JSONSerialization.data(withJSONObject: ["query": query], options: [])
|
||||
|
||||
URLSession.custom.dataTask(with: request) { data, response, error in
|
||||
if let error = error {
|
||||
completion(.failure(error))
|
||||
return
|
||||
}
|
||||
|
||||
guard let data = data else {
|
||||
completion(.failure(NSError(domain: "AnimeService", code: 0, userInfo: [NSLocalizedDescriptionKey: "No data received"])))
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
|
||||
let data = json["data"] as? [String: Any],
|
||||
let media = data["Media"] as? [String: Any] {
|
||||
completion(.success(media))
|
||||
} else {
|
||||
completion(.failure(NSError(domain: "AnimeService", code: 0, userInfo: [NSLocalizedDescriptionKey: "Invalid response format"])))
|
||||
}
|
||||
} catch {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}.resume()
|
||||
}
|
||||
}
|
||||
20
Sora/Utils/Extensions/URL.swift
Normal file
20
Sora/Utils/Extensions/URL.swift
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
//
|
||||
// URL.swift
|
||||
// Sulfur
|
||||
//
|
||||
// Created by Francesco on 23/03/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension URL {
|
||||
var queryParameters: [String: String]? {
|
||||
guard let components = URLComponents(url: self, resolvingAgainstBaseURL: true),
|
||||
let queryItems = components.queryItems else { return nil }
|
||||
var params = [String: String]()
|
||||
for queryItem in queryItems {
|
||||
params[queryItem.name] = queryItem.value
|
||||
}
|
||||
return params
|
||||
}
|
||||
}
|
||||
|
|
@ -9,8 +9,6 @@
|
|||
/* Begin PBXBuildFile section */
|
||||
130217CC2D81C55E0011EFF5 /* DownloadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 130217CB2D81C55E0011EFF5 /* DownloadView.swift */; };
|
||||
130C6BFA2D53AB1F00DC1432 /* SettingsViewData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 130C6BF92D53AB1F00DC1432 /* SettingsViewData.swift */; };
|
||||
13103E842D589D8B000F0673 /* AniList-Seasonal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13103E832D589D8B000F0673 /* AniList-Seasonal.swift */; };
|
||||
13103E862D58A328000F0673 /* AniList-Trending.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13103E852D58A328000F0673 /* AniList-Trending.swift */; };
|
||||
13103E892D58A39A000F0673 /* AniListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13103E882D58A39A000F0673 /* AniListItem.swift */; };
|
||||
13103E8B2D58E028000F0673 /* View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13103E8A2D58E028000F0673 /* View.swift */; };
|
||||
13103E8E2D58E04A000F0673 /* SkeletonCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13103E8D2D58E04A000F0673 /* SkeletonCell.swift */; };
|
||||
|
|
@ -38,8 +36,6 @@
|
|||
1359ED142D76F49900C13034 /* finTopView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1359ED132D76F49900C13034 /* finTopView.swift */; };
|
||||
1359ED1A2D76FA7D00C13034 /* Drops in Frameworks */ = {isa = PBXBuildFile; productRef = 1359ED192D76FA7D00C13034 /* Drops */; };
|
||||
135CCBE22D4D1138008B9C0E /* SettingsViewPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 135CCBE12D4D1138008B9C0E /* SettingsViewPlayer.swift */; };
|
||||
136F21B92D5B8DD8006409AC /* AniList-MediaInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 136F21B82D5B8DD8006409AC /* AniList-MediaInfo.swift */; };
|
||||
136F21BC2D5B8F29006409AC /* AniList-DetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 136F21BB2D5B8F29006409AC /* AniList-DetailsView.swift */; };
|
||||
138AA1B82D2D66FD0021F9DF /* EpisodeCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138AA1B62D2D66FD0021F9DF /* EpisodeCell.swift */; };
|
||||
138AA1B92D2D66FD0021F9DF /* CircularProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138AA1B72D2D66FD0021F9DF /* CircularProgressBar.swift */; };
|
||||
139935662D468C450065CEFF /* ModuleManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 139935652D468C450065CEFF /* ModuleManager.swift */; };
|
||||
|
|
@ -52,6 +48,9 @@
|
|||
13CBEFDA2D5F7D1200D011EE /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13CBEFD92D5F7D1200D011EE /* String.swift */; };
|
||||
13D842552D45267500EBBFA6 /* DropManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13D842542D45267500EBBFA6 /* DropManager.swift */; };
|
||||
13D99CF72D4E73C300250A86 /* ModuleAdditionSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13D99CF62D4E73C300250A86 /* ModuleAdditionSettingsView.swift */; };
|
||||
13DB468D2D90093A008CBC03 /* Anilist-Login.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13DB468B2D900939008CBC03 /* Anilist-Login.swift */; };
|
||||
13DB468E2D90093A008CBC03 /* Anilist-Token.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13DB468C2D90093A008CBC03 /* Anilist-Token.swift */; };
|
||||
13DB46902D900A38008CBC03 /* URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13DB468F2D900A38008CBC03 /* URL.swift */; };
|
||||
13DB7CC32D7D99C0004371D3 /* SubtitleSettingsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13DB7CC22D7D99C0004371D3 /* SubtitleSettingsManager.swift */; };
|
||||
13DB7CC62D7DC7D2004371D3 /* FFmpeg-iOS-Lame in Frameworks */ = {isa = PBXBuildFile; productRef = 13DB7CC52D7DC7D2004371D3 /* FFmpeg-iOS-Lame */; };
|
||||
13DB7CEC2D7DED5D004371D3 /* DownloadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13DB7CEB2D7DED5D004371D3 /* DownloadManager.swift */; };
|
||||
|
|
@ -68,8 +67,6 @@
|
|||
130217CB2D81C55E0011EFF5 /* DownloadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadView.swift; sourceTree = "<group>"; };
|
||||
130C6BF82D53A4C200DC1432 /* Sora.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Sora.entitlements; sourceTree = "<group>"; };
|
||||
130C6BF92D53AB1F00DC1432 /* SettingsViewData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewData.swift; sourceTree = "<group>"; };
|
||||
13103E832D589D8B000F0673 /* AniList-Seasonal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AniList-Seasonal.swift"; sourceTree = "<group>"; };
|
||||
13103E852D58A328000F0673 /* AniList-Trending.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AniList-Trending.swift"; sourceTree = "<group>"; };
|
||||
13103E882D58A39A000F0673 /* AniListItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AniListItem.swift; sourceTree = "<group>"; };
|
||||
13103E8A2D58E028000F0673 /* View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = View.swift; sourceTree = "<group>"; };
|
||||
13103E8D2D58E04A000F0673 /* SkeletonCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SkeletonCell.swift; sourceTree = "<group>"; };
|
||||
|
|
@ -96,8 +93,6 @@
|
|||
133F55BA2D33B55100E08EEA /* LibraryManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryManager.swift; sourceTree = "<group>"; };
|
||||
1359ED132D76F49900C13034 /* finTopView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = finTopView.swift; sourceTree = "<group>"; };
|
||||
135CCBE12D4D1138008B9C0E /* SettingsViewPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewPlayer.swift; sourceTree = "<group>"; };
|
||||
136F21B82D5B8DD8006409AC /* AniList-MediaInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AniList-MediaInfo.swift"; sourceTree = "<group>"; };
|
||||
136F21BB2D5B8F29006409AC /* AniList-DetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AniList-DetailsView.swift"; sourceTree = "<group>"; };
|
||||
138AA1B62D2D66FD0021F9DF /* EpisodeCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EpisodeCell.swift; sourceTree = "<group>"; };
|
||||
138AA1B72D2D66FD0021F9DF /* CircularProgressBar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CircularProgressBar.swift; sourceTree = "<group>"; };
|
||||
139935652D468C450065CEFF /* ModuleManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModuleManager.swift; sourceTree = "<group>"; };
|
||||
|
|
@ -110,6 +105,9 @@
|
|||
13CBEFD92D5F7D1200D011EE /* String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.swift; sourceTree = "<group>"; };
|
||||
13D842542D45267500EBBFA6 /* DropManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DropManager.swift; sourceTree = "<group>"; };
|
||||
13D99CF62D4E73C300250A86 /* ModuleAdditionSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModuleAdditionSettingsView.swift; sourceTree = "<group>"; };
|
||||
13DB468B2D900939008CBC03 /* Anilist-Login.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Anilist-Login.swift"; sourceTree = "<group>"; };
|
||||
13DB468C2D90093A008CBC03 /* Anilist-Token.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Anilist-Token.swift"; sourceTree = "<group>"; };
|
||||
13DB468F2D900A38008CBC03 /* URL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URL.swift; sourceTree = "<group>"; };
|
||||
13DB7CC22D7D99C0004371D3 /* SubtitleSettingsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubtitleSettingsManager.swift; sourceTree = "<group>"; };
|
||||
13DB7CEB2D7DED5D004371D3 /* DownloadManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadManager.swift; sourceTree = "<group>"; };
|
||||
13DC0C412D2EC9BA00D0F966 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||
|
|
@ -148,23 +146,12 @@
|
|||
13103E812D589D77000F0673 /* AniList */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
136F21B72D5B8DAC006409AC /* MediaInfo */,
|
||||
13DB468A2D900919008CBC03 /* Auth */,
|
||||
13103E872D58A392000F0673 /* Struct */,
|
||||
13103E822D589D7D000F0673 /* HomePage */,
|
||||
);
|
||||
path = AniList;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
13103E822D589D7D000F0673 /* HomePage */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
136F21BA2D5B8F17006409AC /* DetailsView */,
|
||||
13103E832D589D8B000F0673 /* AniList-Seasonal.swift */,
|
||||
13103E852D58A328000F0673 /* AniList-Trending.swift */,
|
||||
);
|
||||
path = HomePage;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
13103E872D58A392000F0673 /* Struct */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
|
@ -317,6 +304,7 @@
|
|||
1359ED132D76F49900C13034 /* finTopView.swift */,
|
||||
13CBEFD92D5F7D1200D011EE /* String.swift */,
|
||||
13103E8A2D58E028000F0673 /* View.swift */,
|
||||
13DB468F2D900A38008CBC03 /* URL.swift */,
|
||||
);
|
||||
path = Extensions;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -348,22 +336,6 @@
|
|||
path = LibraryView;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
136F21B72D5B8DAC006409AC /* MediaInfo */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
136F21B82D5B8DD8006409AC /* AniList-MediaInfo.swift */,
|
||||
);
|
||||
path = MediaInfo;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
136F21BA2D5B8F17006409AC /* DetailsView */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
136F21BB2D5B8F29006409AC /* AniList-DetailsView.swift */,
|
||||
);
|
||||
path = DetailsView;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
1384DCDF2D89BE870094797A /* Helpers */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
|
@ -416,6 +388,15 @@
|
|||
path = Drops;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
13DB468A2D900919008CBC03 /* Auth */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
13DB468B2D900939008CBC03 /* Anilist-Login.swift */,
|
||||
13DB468C2D90093A008CBC03 /* Anilist-Token.swift */,
|
||||
);
|
||||
path = Auth;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
13DB7CEA2D7DED50004371D3 /* DownloadManager */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
|
@ -542,12 +523,12 @@
|
|||
133D7C902D2BE2640075467E /* SettingsView.swift in Sources */,
|
||||
1334FF4F2D786C9E007E289F /* TMDB-Trending.swift in Sources */,
|
||||
13CBEFDA2D5F7D1200D011EE /* String.swift in Sources */,
|
||||
13DB46902D900A38008CBC03 /* URL.swift in Sources */,
|
||||
130C6BFA2D53AB1F00DC1432 /* SettingsViewData.swift in Sources */,
|
||||
1334FF542D787217007E289F /* TMDBRequest.swift in Sources */,
|
||||
1E9FF1D32D403E49008AC100 /* SettingsViewLoggerFilter.swift in Sources */,
|
||||
13EA2BD92D32D98400C1EBD7 /* NormalPlayer.swift in Sources */,
|
||||
133D7C932D2BE2640075467E /* Modules.swift in Sources */,
|
||||
136F21B92D5B8DD8006409AC /* AniList-MediaInfo.swift in Sources */,
|
||||
13DB7CC32D7D99C0004371D3 /* SubtitleSettingsManager.swift in Sources */,
|
||||
133D7C702D2BE2500075467E /* ContentView.swift in Sources */,
|
||||
13DB7CEC2D7DED5D004371D3 /* DownloadManager.swift in Sources */,
|
||||
|
|
@ -555,7 +536,6 @@
|
|||
13D99CF72D4E73C300250A86 /* ModuleAdditionSettingsView.swift in Sources */,
|
||||
13C0E5EC2D5F85F800E7F619 /* ContinueWatchingItem.swift in Sources */,
|
||||
13CBA0882D60F19C00EFE70A /* VTTSubtitlesLoader.swift in Sources */,
|
||||
13103E862D58A328000F0673 /* AniList-Trending.swift in Sources */,
|
||||
13EA2BD62D32D97400C1EBD7 /* Double+Extension.swift in Sources */,
|
||||
133D7C8F2D2BE2640075467E /* MediaInfoView.swift in Sources */,
|
||||
13103E892D58A39A000F0673 /* AniListItem.swift in Sources */,
|
||||
|
|
@ -566,18 +546,18 @@
|
|||
1327FBA92D758DEA00FC6689 /* UIDevice+Model.swift in Sources */,
|
||||
138AA1B82D2D66FD0021F9DF /* EpisodeCell.swift in Sources */,
|
||||
133D7C8C2D2BE2640075467E /* SearchView.swift in Sources */,
|
||||
13DB468E2D90093A008CBC03 /* Anilist-Token.swift in Sources */,
|
||||
1EAC7A322D888BC50083984D /* MusicProgressSlider.swift in Sources */,
|
||||
133D7C942D2BE2640075467E /* JSController.swift in Sources */,
|
||||
133D7C922D2BE2640075467E /* URLSession.swift in Sources */,
|
||||
133D7C912D2BE2640075467E /* SettingsViewModule.swift in Sources */,
|
||||
136F21BC2D5B8F29006409AC /* AniList-DetailsView.swift in Sources */,
|
||||
130217CC2D81C55E0011EFF5 /* DownloadView.swift in Sources */,
|
||||
133F55BB2D33B55100E08EEA /* LibraryManager.swift in Sources */,
|
||||
13103E842D589D8B000F0673 /* AniList-Seasonal.swift in Sources */,
|
||||
133D7C8E2D2BE2640075467E /* LibraryView.swift in Sources */,
|
||||
133D7C6E2D2BE2500075467E /* SoraApp.swift in Sources */,
|
||||
138AA1B92D2D66FD0021F9DF /* CircularProgressBar.swift in Sources */,
|
||||
1334FF4D2D786C93007E289F /* TMDB-Seasonal.swift in Sources */,
|
||||
13DB468D2D90093A008CBC03 /* Anilist-Login.swift in Sources */,
|
||||
73D164D52D8B5B470011A360 /* JavaScriptCore+Extensions.swift in Sources */,
|
||||
13EA2BD52D32D97400C1EBD7 /* CustomPlayer.swift in Sources */,
|
||||
1399FAD42D3AB38C00E97C31 /* SettingsViewLogger.swift in Sources */,
|
||||
|
|
|
|||
Loading…
Reference in a new issue