From ae75fd80ce1c5f8a3a65f12f08d764d08df30615 Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Tue, 11 Feb 2025 15:27:04 +0100 Subject: [PATCH] not to bad you know --- Sora.xcodeproj/project.pbxproj | 24 ++ .../DetailsView/AniList-DetailsView.swift | 249 ++++++++++++++++++ .../AniList/MediaInfo/AniList-MediaInfo.swift | 135 ++++++++++ Sora/Views/HomeView.swift | 86 +++--- 4 files changed, 454 insertions(+), 40 deletions(-) create mode 100644 Sora/Tracking Services/AniList/HomePage/DetailsView/AniList-DetailsView.swift create mode 100644 Sora/Tracking Services/AniList/MediaInfo/AniList-MediaInfo.swift diff --git a/Sora.xcodeproj/project.pbxproj b/Sora.xcodeproj/project.pbxproj index 04a22e2..3826f85 100644 --- a/Sora.xcodeproj/project.pbxproj +++ b/Sora.xcodeproj/project.pbxproj @@ -30,6 +30,8 @@ 133D7C972D2BE2AF0075467E /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 133D7C962D2BE2AF0075467E /* Kingfisher */; }; 133F55BB2D33B55100E08EEA /* LibraryManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133F55BA2D33B55100E08EEA /* LibraryManager.swift */; }; 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 */; }; @@ -73,6 +75,8 @@ 133D7C8B2D2BE2640075467E /* JSController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JSController.swift; sourceTree = ""; }; 133F55BA2D33B55100E08EEA /* LibraryManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryManager.swift; sourceTree = ""; }; 135CCBE12D4D1138008B9C0E /* SettingsViewPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewPlayer.swift; sourceTree = ""; }; + 136F21B82D5B8DD8006409AC /* AniList-MediaInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AniList-MediaInfo.swift"; sourceTree = ""; }; + 136F21BB2D5B8F29006409AC /* AniList-DetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AniList-DetailsView.swift"; sourceTree = ""; }; 138AA1B62D2D66FD0021F9DF /* EpisodeCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EpisodeCell.swift; sourceTree = ""; }; 138AA1B72D2D66FD0021F9DF /* CircularProgressBar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CircularProgressBar.swift; sourceTree = ""; }; 139935652D468C450065CEFF /* ModuleManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModuleManager.swift; sourceTree = ""; }; @@ -115,6 +119,7 @@ 13103E812D589D77000F0673 /* AniList */ = { isa = PBXGroup; children = ( + 136F21B72D5B8DAC006409AC /* MediaInfo */, 13103E872D58A392000F0673 /* Struct */, 13103E822D589D7D000F0673 /* HomePage */, ); @@ -124,6 +129,7 @@ 13103E822D589D7D000F0673 /* HomePage */ = { isa = PBXGroup; children = ( + 136F21BA2D5B8F17006409AC /* DetailsView */, 13103E832D589D8B000F0673 /* AniList-Seasonal.swift */, 13103E852D58A328000F0673 /* AniList-Trending.swift */, ); @@ -272,6 +278,22 @@ path = LibraryView; sourceTree = ""; }; + 136F21B72D5B8DAC006409AC /* MediaInfo */ = { + isa = PBXGroup; + children = ( + 136F21B82D5B8DD8006409AC /* AniList-MediaInfo.swift */, + ); + path = MediaInfo; + sourceTree = ""; + }; + 136F21BA2D5B8F17006409AC /* DetailsView */ = { + isa = PBXGroup; + children = ( + 136F21BB2D5B8F29006409AC /* AniList-DetailsView.swift */, + ); + path = DetailsView; + sourceTree = ""; + }; 138AA1B52D2D66EC0021F9DF /* EpisodeCell */ = { isa = PBXGroup; children = ( @@ -430,6 +452,7 @@ 1E9FF1D32D403E49008AC100 /* SettingsViewLoggerFilter.swift in Sources */, 13EA2BD92D32D98400C1EBD7 /* NormalPlayer.swift in Sources */, 133D7C932D2BE2640075467E /* Modules.swift in Sources */, + 136F21B92D5B8DD8006409AC /* AniList-MediaInfo.swift in Sources */, 133D7C702D2BE2500075467E /* ContentView.swift in Sources */, 13D99CF72D4E73C300250A86 /* ModuleAdditionSettingsView.swift in Sources */, 13103E862D58A328000F0673 /* AniList-Trending.swift in Sources */, @@ -447,6 +470,7 @@ 133D7C942D2BE2640075467E /* JSController.swift in Sources */, 133D7C922D2BE2640075467E /* URLSession.swift in Sources */, 133D7C912D2BE2640075467E /* SettingsViewModule.swift in Sources */, + 136F21BC2D5B8F29006409AC /* AniList-DetailsView.swift in Sources */, 133F55BB2D33B55100E08EEA /* LibraryManager.swift in Sources */, 13103E842D589D8B000F0673 /* AniList-Seasonal.swift in Sources */, 133D7C8E2D2BE2640075467E /* LibraryView.swift in Sources */, diff --git a/Sora/Tracking Services/AniList/HomePage/DetailsView/AniList-DetailsView.swift b/Sora/Tracking Services/AniList/HomePage/DetailsView/AniList-DetailsView.swift new file mode 100644 index 0000000..351d582 --- /dev/null +++ b/Sora/Tracking Services/AniList/HomePage/DetailsView/AniList-DetailsView.swift @@ -0,0 +1,249 @@ +// +// AniList-DetailsView.swift +// Sora +// +// Created by Francesco on 11/02/25. +// + +import SwiftUI +import Kingfisher + +struct MediaDetailItem: View { + var title: String + var value: String + + var body: some View { + VStack { + Text(value) + .font(.headline) + Text(title) + .font(.caption) + .foregroundColor(.secondary) + } + .padding(.horizontal) + } +} + +struct AniListDetailsView: View { + let animeID: Int + @State private var mediaInfo: [String: Any]? + @State private var isLoading: Bool = true + + var body: some View { + ScrollView { + VStack(spacing: 16) { + if isLoading { + ProgressView() + .padding() + } else if let media = mediaInfo { + 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: 200, height: 300) + .shimmering() + } + .resizable() + .scaledToFit() + .frame(width: 200, height: 300) + .cornerRadius(10) + .shadow(radius: 5) + } + + if let titleDict = media["title"] as? [String: Any], + let userPreferred = titleDict["userPreferred"] as? String { + Text(userPreferred) + .font(.title2) + .fontWeight(.bold) + .padding(.top, 8) + } + + if let trailer = media["trailer"] as? [String: Any], + let trailerID = trailer["id"] as? String, + let site = trailer["site"] as? String { + if site.lowercased() == "youtube", + let url = URL(string: "https://www.youtube.com/watch?v=\(trailerID)") { + Link("Watch Trailer on YouTube", destination: url) + .padding(.top, 4) + } else { + Text("Trailer available on \(site)") + .padding(.top, 4) + } + } + + if let synopsis = media["description"] as? String { + Text(synopsis) + .padding(.horizontal) + .foregroundColor(.secondary) + .font(.system(size: 14)) + } + + VStack(alignment: .leading, spacing: 4) { + if let format = media["format"] as? String { + Text("Format: \(format)") + } + if let status = media["status"] as? String { + Text("Status: \(status)") + } + if let season = media["season"] as? String { + Text("Season: \(season)") + } + 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 { + Text("Start Date: \(year)-\(month)-\(day)") + } + 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 { + Text("End Date: \(year)-\(month)-\(day)") + } + if let country = media["countryOfOrigin"] as? String { + Text("Country: \(country)") + } + if let source = media["source"] as? String { + Text("Source: \(source)") + } + } + .font(.caption) + .foregroundColor(.secondary) + .padding(.horizontal) + .padding(.top, 4) + + HStack(spacing: 24) { + if let type = media["type"] as? String { + MediaDetailItem(title: "Type", value: type) + } + if let episodes = media["episodes"] as? Int { + MediaDetailItem(title: "Episodes", value: "\(episodes)") + } + if let duration = media["duration"] as? Int { + MediaDetailItem(title: "Length", value: "\(duration) mins") + } + } + .frame(maxWidth: .infinity) + .padding(.vertical) + + if let charactersDict = media["characters"] as? [String: Any], + let edges = charactersDict["edges"] as? [[String: Any]] { + VStack(alignment: .leading, spacing: 8) { + Text("Characters") + .font(.headline) + .padding(.horizontal) + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 16) { + ForEach(Array(edges.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) { + VStack { + KFImage(imageUrl) + .resizable() + .scaledToFill() + .frame(width: 120, height: 120) + .clipShape(Circle()) + Text(fullName) + .font(.caption) + } + .frame(width: 140, height: 140) + } + } + } + .padding(.horizontal) + } + } + } + + if let stats = media["stats"] as? [String: Any], + let scoreDistribution = stats["scoreDistribution"] as? [[String: Any]] { + VStack(alignment: .center) { + Text("Score Distribution") + .font(.headline) + HStack(alignment: .bottom, spacing: 8) { + let maxValue = scoreDistribution.compactMap { $0["amount"] as? Int }.max() ?? 1 + ForEach(Array(scoreDistribution.enumerated()), id: \.offset) { _, dataPoint in + if let score = dataPoint["score"] as? Int, + let amount = dataPoint["amount"] as? Int { + VStack { + Rectangle() + .fill(Color.accentColor) + .frame(width: 20, height: CGFloat(amount) / CGFloat(maxValue) * 100) + Text("\(score)") + .font(.caption) + } + } + } + } + } + .frame(maxWidth: .infinity) + .padding(.horizontal) + } + + if let relations = media["relations"] as? [String: Any], + let nodes = relations["nodes"] as? [[String: Any]] { + VStack(alignment: .leading, spacing: 8) { + Text("Correlation") + .font(.headline) + .padding(.horizontal) + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10) { + ForEach(Array(nodes.enumerated()), id: \.offset) { _, node in + if let titleDict = node["title"] as? [String: Any], + let title = titleDict["userPreferred"] as? String, + let coverImageDict = node["coverImage"] as? [String: Any], + let imageUrlStr = coverImageDict["extraLarge"] as? String, + let imageUrl = URL(string: imageUrlStr) { + VStack { + KFImage(imageUrl) + .resizable() + .scaledToFill() + .frame(width: 100, height: 150) + .cornerRadius(10) + Text(title) + .font(.caption) + } + .frame(width: 130, height: 195) + } + } + } + .padding(.horizontal) + } + } + } + + } else { + Text("Failed to load media details.") + .padding() + } + } + } + .navigationBarTitle("") + .navigationBarTitleDisplayMode(.inline) + .navigationViewStyle(StackNavigationViewStyle()) + .onAppear { + fetchDetails() + } + } + + private func fetchDetails() { + AnilistServiceMediaInfo.fetchAnimeDetails(animeID: animeID) { result in + DispatchQueue.main.async { + switch result { + case .success(let media): + self.mediaInfo = media + case .failure(let error): + print("Error: \(error)") + } + self.isLoading = false + } + } + } +} diff --git a/Sora/Tracking Services/AniList/MediaInfo/AniList-MediaInfo.swift b/Sora/Tracking Services/AniList/MediaInfo/AniList-MediaInfo.swift new file mode 100644 index 0000000..92b34b5 --- /dev/null +++ b/Sora/Tracking Services/AniList/MediaInfo/AniList-MediaInfo.swift @@ -0,0 +1,135 @@ +// +// 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() + } +} diff --git a/Sora/Views/HomeView.swift b/Sora/Views/HomeView.swift index 869ec06..08a673d 100644 --- a/Sora/Views/HomeView.swift +++ b/Sora/Views/HomeView.swift @@ -60,26 +60,29 @@ struct HomeView: View { } } else { ForEach(aniListItems, id: \.id) { item in - VStack { - KFImage(URL(string: item.coverImage.large)) - .placeholder { - RoundedRectangle(cornerRadius: 10) - .fill(Color.gray.opacity(0.3)) - .frame(width: 130, height: 195) - .shimmering() - } - .setProcessor(RoundCornerImageProcessor(cornerRadius: 10)) - .resizable() - .scaledToFill() - .frame(width: 130, height: 195) - .cornerRadius(10) - .clipped() - - Text(item.title.romaji) - .font(.caption) - .frame(width: 130) - .lineLimit(1) - .multilineTextAlignment(.center) + NavigationLink(destination: AniListDetailsView(animeID: item.id)) { + VStack { + KFImage(URL(string: item.coverImage.large)) + .placeholder { + RoundedRectangle(cornerRadius: 10) + .fill(Color.gray.opacity(0.3)) + .frame(width: 130, height: 195) + .shimmering() + } + .setProcessor(RoundCornerImageProcessor(cornerRadius: 10)) + .resizable() + .scaledToFill() + .frame(width: 130, height: 195) + .cornerRadius(10) + .clipped() + + Text(item.title.romaji) + .font(.caption) + .frame(width: 130) + .lineLimit(1) + .multilineTextAlignment(.center) + .foregroundColor(.primary) + } } } } @@ -104,26 +107,29 @@ struct HomeView: View { } } else { ForEach(trendingItems, id: \.id) { item in - VStack { - KFImage(URL(string: item.coverImage.large)) - .placeholder { - RoundedRectangle(cornerRadius: 10) - .fill(Color.gray.opacity(0.3)) - .frame(width: 130, height: 195) - .shimmering() - } - .setProcessor(RoundCornerImageProcessor(cornerRadius: 10)) - .resizable() - .scaledToFill() - .frame(width: 130, height: 195) - .cornerRadius(10) - .clipped() - - Text(item.title.romaji) - .font(.caption) - .frame(width: 130) - .lineLimit(1) - .multilineTextAlignment(.center) + NavigationLink(destination: AniListDetailsView(animeID: item.id)) { + VStack { + KFImage(URL(string: item.coverImage.large)) + .placeholder { + RoundedRectangle(cornerRadius: 10) + .fill(Color.gray.opacity(0.3)) + .frame(width: 130, height: 195) + .shimmering() + } + .setProcessor(RoundCornerImageProcessor(cornerRadius: 10)) + .resizable() + .scaledToFill() + .frame(width: 130, height: 195) + .cornerRadius(10) + .clipped() + + Text(item.title.romaji) + .font(.caption) + .frame(width: 130) + .lineLimit(1) + .multilineTextAlignment(.center) + .foregroundColor(.primary) + } } } }