From ffc3821010255290944e822946977db4a66fa2d7 Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Sat, 11 Jan 2025 17:55:40 +0100 Subject: [PATCH] yes --- Sora.xcodeproj/project.pbxproj | 44 +++ .../Components/Double+Extension.swift | 30 ++ .../Components/MusicProgressSlider.swift | 102 +++++++ .../CustomPlayer/CustomPlayer.swift | 282 ++++++++++++++++++ Sora/MediaPlayer/NormalPlayer.swift | 77 +++++ Sora/MediaPlayer/VideoPlayer.swift | 4 +- Sora/Utils/Miru/MiruDataStruct.swift | 26 ++ 7 files changed, 563 insertions(+), 2 deletions(-) create mode 100644 Sora/MediaPlayer/CustomPlayer/Components/Double+Extension.swift create mode 100644 Sora/MediaPlayer/CustomPlayer/Components/MusicProgressSlider.swift create mode 100644 Sora/MediaPlayer/CustomPlayer/CustomPlayer.swift create mode 100644 Sora/MediaPlayer/NormalPlayer.swift create mode 100644 Sora/Utils/Miru/MiruDataStruct.swift diff --git a/Sora.xcodeproj/project.pbxproj b/Sora.xcodeproj/project.pbxproj index 3eeab4e..ba4d147 100644 --- a/Sora.xcodeproj/project.pbxproj +++ b/Sora.xcodeproj/project.pbxproj @@ -24,6 +24,11 @@ 138AA1B82D2D66FD0021F9DF /* EpisodeCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138AA1B62D2D66FD0021F9DF /* EpisodeCell.swift */; }; 138AA1B92D2D66FD0021F9DF /* CircularProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138AA1B72D2D66FD0021F9DF /* CircularProgressBar.swift */; }; 13DC0C462D302C7500D0F966 /* VideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13DC0C452D302C7500D0F966 /* VideoPlayer.swift */; }; + 13EA2BD52D32D97400C1EBD7 /* CustomPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EA2BD12D32D97400C1EBD7 /* CustomPlayer.swift */; }; + 13EA2BD62D32D97400C1EBD7 /* Double+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EA2BD32D32D97400C1EBD7 /* Double+Extension.swift */; }; + 13EA2BD72D32D97400C1EBD7 /* MusicProgressSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EA2BD42D32D97400C1EBD7 /* MusicProgressSlider.swift */; }; + 13EA2BD92D32D98400C1EBD7 /* NormalPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EA2BD82D32D98400C1EBD7 /* NormalPlayer.swift */; }; + 13EA2BDC2D32D9FF00C1EBD7 /* MiruDataStruct.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EA2BDB2D32D9FF00C1EBD7 /* MiruDataStruct.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -45,6 +50,11 @@ 138AA1B72D2D66FD0021F9DF /* CircularProgressBar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CircularProgressBar.swift; sourceTree = ""; }; 13DC0C412D2EC9BA00D0F966 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 13DC0C452D302C7500D0F966 /* VideoPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayer.swift; sourceTree = ""; }; + 13EA2BD12D32D97400C1EBD7 /* CustomPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomPlayer.swift; sourceTree = ""; }; + 13EA2BD32D32D97400C1EBD7 /* Double+Extension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Double+Extension.swift"; sourceTree = ""; }; + 13EA2BD42D32D97400C1EBD7 /* MusicProgressSlider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MusicProgressSlider.swift; sourceTree = ""; }; + 13EA2BD82D32D98400C1EBD7 /* NormalPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NormalPlayer.swift; sourceTree = ""; }; + 13EA2BDB2D32D9FF00C1EBD7 /* MiruDataStruct.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MiruDataStruct.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -131,6 +141,7 @@ 133D7C852D2BE2640075467E /* Utils */ = { isa = PBXGroup; children = ( + 13EA2BDA2D32D9FF00C1EBD7 /* Miru */, 133D7C862D2BE2640075467E /* Extensions */, 133D7C882D2BE2640075467E /* Modules */, 133D7C8A2D2BE2640075467E /* Loaders */, @@ -174,11 +185,39 @@ 13DC0C442D302C6A00D0F966 /* MediaPlayer */ = { isa = PBXGroup; children = ( + 13EA2BD02D32D97400C1EBD7 /* CustomPlayer */, 13DC0C452D302C7500D0F966 /* VideoPlayer.swift */, + 13EA2BD82D32D98400C1EBD7 /* NormalPlayer.swift */, ); path = MediaPlayer; sourceTree = ""; }; + 13EA2BD02D32D97400C1EBD7 /* CustomPlayer */ = { + isa = PBXGroup; + children = ( + 13EA2BD12D32D97400C1EBD7 /* CustomPlayer.swift */, + 13EA2BD22D32D97400C1EBD7 /* Components */, + ); + path = CustomPlayer; + sourceTree = ""; + }; + 13EA2BD22D32D97400C1EBD7 /* Components */ = { + isa = PBXGroup; + children = ( + 13EA2BD32D32D97400C1EBD7 /* Double+Extension.swift */, + 13EA2BD42D32D97400C1EBD7 /* MusicProgressSlider.swift */, + ); + path = Components; + sourceTree = ""; + }; + 13EA2BDA2D32D9FF00C1EBD7 /* Miru */ = { + isa = PBXGroup; + children = ( + 13EA2BDB2D32D9FF00C1EBD7 /* MiruDataStruct.swift */, + ); + path = Miru; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -257,10 +296,14 @@ files = ( 13DC0C462D302C7500D0F966 /* VideoPlayer.swift in Sources */, 133D7C902D2BE2640075467E /* SettingsView.swift in Sources */, + 13EA2BD72D32D97400C1EBD7 /* MusicProgressSlider.swift in Sources */, + 13EA2BD92D32D98400C1EBD7 /* NormalPlayer.swift in Sources */, 133D7C932D2BE2640075467E /* Modules.swift in Sources */, 133D7C702D2BE2500075467E /* ContentView.swift in Sources */, + 13EA2BD62D32D97400C1EBD7 /* Double+Extension.swift in Sources */, 133D7C8F2D2BE2640075467E /* MediaInfoView.swift in Sources */, 133D7C8D2D2BE2640075467E /* HomeView.swift in Sources */, + 13EA2BDC2D32D9FF00C1EBD7 /* MiruDataStruct.swift in Sources */, 138AA1B82D2D66FD0021F9DF /* EpisodeCell.swift in Sources */, 133D7C8C2D2BE2640075467E /* SearchView.swift in Sources */, 133D7C942D2BE2640075467E /* JSController.swift in Sources */, @@ -269,6 +312,7 @@ 133D7C8E2D2BE2640075467E /* LibraryView.swift in Sources */, 133D7C6E2D2BE2500075467E /* SoraApp.swift in Sources */, 138AA1B92D2D66FD0021F9DF /* CircularProgressBar.swift in Sources */, + 13EA2BD52D32D97400C1EBD7 /* CustomPlayer.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Sora/MediaPlayer/CustomPlayer/Components/Double+Extension.swift b/Sora/MediaPlayer/CustomPlayer/Components/Double+Extension.swift new file mode 100644 index 0000000..801cc7b --- /dev/null +++ b/Sora/MediaPlayer/CustomPlayer/Components/Double+Extension.swift @@ -0,0 +1,30 @@ +// +// Double+Extension.swift +// AppleMusicSlider +// +// Created by Pratik on 14/01/23. +// +// Thanks to pratikg29 for this code inside his open source project "https://github.com/pratikg29/Custom-Slider-Control?ref=iosexample.com" +// + +import Foundation + +extension Double { + func asTimeString(style: DateComponentsFormatter.UnitsStyle) -> String { + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.minute, .second] + formatter.unitsStyle = style + formatter.zeroFormattingBehavior = .pad + return formatter.string(from: self) ?? "" + } +} + +extension BinaryFloatingPoint { + func asTimeString(style: DateComponentsFormatter.UnitsStyle) -> String { + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.minute, .second] + formatter.unitsStyle = style + formatter.zeroFormattingBehavior = .pad + return formatter.string(from: TimeInterval(self)) ?? "" + } +} diff --git a/Sora/MediaPlayer/CustomPlayer/Components/MusicProgressSlider.swift b/Sora/MediaPlayer/CustomPlayer/Components/MusicProgressSlider.swift new file mode 100644 index 0000000..8bbb950 --- /dev/null +++ b/Sora/MediaPlayer/CustomPlayer/Components/MusicProgressSlider.swift @@ -0,0 +1,102 @@ +// +// MusicProgressSlider.swift +// Custom Seekbar +// +// Created by Pratik on 08/01/23. +// +// Thanks to pratikg29 for this code inside his open source project "https://github.com/pratikg29/Custom-Slider-Control?ref=iosexample.com" +// I did edit just a little bit the code for my liking +// + +import SwiftUI + +struct MusicProgressSlider: View { + @Binding var value: T + let inRange: ClosedRange + let activeFillColor: Color + let fillColor: Color + let emptyColor: Color + let height: CGFloat + let onEditingChanged: (Bool) -> Void + + @State private var localRealProgress: T = 0 + @State private var localTempProgress: T = 0 + @GestureState private var isActive: Bool = false + + var body: some View { + GeometryReader { bounds in + ZStack { + VStack { + ZStack(alignment: .center) { + Capsule() + .fill(emptyColor) + Capsule() + .fill(isActive ? activeFillColor : fillColor) + .mask({ + HStack { + Rectangle() + .frame(width: max(bounds.size.width * CGFloat((localRealProgress + localTempProgress)), 0), alignment: .leading) + Spacer(minLength: 0) + } + }) + } + + HStack { + Text(value.asTimeString(style: .positional)) + Spacer(minLength: 0) + Text("-" + (inRange.upperBound - value).asTimeString(style: .positional)) + } + .font(.system(size: 12)) + .foregroundColor(isActive ? fillColor : emptyColor) + } + .frame(width: isActive ? bounds.size.width * 1.04 : bounds.size.width, alignment: .center) + .animation(animation, value: isActive) + } + .frame(width: bounds.size.width, height: bounds.size.height, alignment: .center) + .contentShape(Rectangle()) + .gesture(DragGesture(minimumDistance: 0, coordinateSpace: .local) + .updating($isActive) { _, state, _ in + state = true + } + .onChanged { gesture in + localTempProgress = T(gesture.translation.width / bounds.size.width) + value = max(min(getPrgValue(), inRange.upperBound), inRange.lowerBound) + }.onEnded { _ in + localRealProgress = max(min(localRealProgress + localTempProgress, 1), 0) + localTempProgress = 0 + }) + .onChange(of: isActive) { newValue in + value = max(min(getPrgValue(), inRange.upperBound), inRange.lowerBound) + onEditingChanged(newValue) + } + .onAppear { + localRealProgress = getPrgPercentage(value) + } + .onChange(of: value) { newValue in + if !isActive { + localRealProgress = getPrgPercentage(newValue) + } + } + } + .frame(height: isActive ? height * 1.25 : height, alignment: .center) + } + + private var animation: Animation { + if isActive { + return .spring() + } else { + return .spring(response: 0.5, dampingFraction: 0.5, blendDuration: 0.6) + } + } + + private func getPrgPercentage(_ value: T) -> T { + let range = inRange.upperBound - inRange.lowerBound + let correctedStartValue = value - inRange.lowerBound + let percentage = correctedStartValue / range + return percentage + } + + private func getPrgValue() -> T { + return ((localRealProgress + localTempProgress) * (inRange.upperBound - inRange.lowerBound)) + inRange.lowerBound + } +} diff --git a/Sora/MediaPlayer/CustomPlayer/CustomPlayer.swift b/Sora/MediaPlayer/CustomPlayer/CustomPlayer.swift new file mode 100644 index 0000000..79bbf00 --- /dev/null +++ b/Sora/MediaPlayer/CustomPlayer/CustomPlayer.swift @@ -0,0 +1,282 @@ +// +// ContentView.swift +// test2 +// +// Created by Francesco on 20/12/24. +// + +import SwiftUI +import AVKit + +struct CustomVideoPlayer: UIViewControllerRepresentable { + let player: AVPlayer + + func makeUIViewController(context: Context) -> AVPlayerViewController { + let controller = NormalPlayer() + controller.player = player + controller.showsPlaybackControls = false + player.play() + return controller + } + + func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) { + // yes? Like the plural of the famous american rapper ye? -IBHRAD + } +} + +struct CustomMediaPlayer: View { + @State private var player: AVPlayer + @State private var isPlaying = true + @State private var currentTime: Double = 0.0 + @State private var duration: Double = 0.0 + @State private var showControls = false + @State private var inactivityTimer: Timer? + @State private var timeObserverToken: Any? + @State private var isVideoLoaded = false + @State private var showWatchNextButton = true + @Environment(\.presentationMode) var presentationMode + + let module: ScrapingModule + let fullUrl: String + let title: String + let episodeNumber: Int + let onWatchNext: () -> Void + + init(module: ScrapingModule, urlString: String, fullUrl: String, title: String, episodeNumber: Int, onWatchNext: @escaping () -> Void) { + guard let url = URL(string: urlString) else { + fatalError("Invalid URL string") + } + + var request = URLRequest(url: url) + if urlString.contains("ascdn") { + request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Referer") + } + + let asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": request.allHTTPHeaderFields ?? [:]]) + _player = State(initialValue: AVPlayer(playerItem: AVPlayerItem(asset: asset))) + + self.module = module + self.fullUrl = fullUrl + self.title = title + self.episodeNumber = episodeNumber + self.onWatchNext = onWatchNext + + let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(fullUrl)") + if lastPlayedTime > 0 { + let seekTime = CMTime(seconds: lastPlayedTime, preferredTimescale: 1) + self._player.wrappedValue.seek(to: seekTime) + } + } + + var body: some View { + ZStack { + VStack { + ZStack { + CustomVideoPlayer(player: player) + .onAppear { + player.addPeriodicTimeObserver(forInterval: CMTime(seconds: 1, preferredTimescale: 600), queue: .main) { time in + currentTime = time.seconds + if let itemDuration = player.currentItem?.duration.seconds, itemDuration.isFinite && !itemDuration.isNaN { + duration = itemDuration + isVideoLoaded = true + } + } + startUpdatingCurrentTime() + addPeriodicTimeObserver(fullURL: fullUrl) + } + .edgesIgnoringSafeArea(.all) + .overlay( + Group { + if showControls { + Color.black.opacity(0.5) + .edgesIgnoringSafeArea(.all) + HStack(spacing: 20) { + Button(action: { + currentTime = max(currentTime - 10, 0) + player.seek(to: CMTime(seconds: currentTime, preferredTimescale: 600)) + }) { + Image(systemName: "gobackward.10") + } + .foregroundColor(.white) + .font(.system(size: 25)) + .contentShape(Rectangle()) + .frame(width: 60, height: 60) + + Button(action: { + if isPlaying { + player.pause() + } else { + player.play() + } + isPlaying.toggle() + }) { + Image(systemName: isPlaying ? "pause.fill" : "play.fill") + } + .foregroundColor(.white) + .font(.system(size: 45)) + .contentShape(Rectangle()) + .frame(width: 80, height: 80) + + Button(action: { + currentTime = min(currentTime + 10, duration) + player.seek(to: CMTime(seconds: currentTime, preferredTimescale: 600)) + }) { + Image(systemName: "goforward.10") + } + .foregroundColor(.white) + .font(.system(size: 25)) + .contentShape(Rectangle()) + .frame(width: 60, height: 60) + } + } + } + .animation(.easeInOut(duration: 0.2), value: showControls), + alignment: .center + ) + .onTapGesture { + withAnimation { + showControls.toggle() + } + } + + VStack { + Spacer() + VStack { + HStack(alignment: .bottom) { + if showControls { + VStack(alignment: .leading) { + Text("Episode \(episodeNumber)") + .font(.subheadline) + .foregroundColor(.gray) + Text(title) + .font(.headline) + .foregroundColor(.white) + } + .padding(.horizontal, 32) + } + Spacer() + if duration - currentTime <= duration * 0.10 && currentTime != duration && showWatchNextButton { + Button(action: { + player.pause() + presentationMode.wrappedValue.dismiss() + onWatchNext() + }) { + HStack { + Image(systemName: "forward.fill") + .foregroundColor(Color.black) + Text("Watch Next") + .font(.headline) + .foregroundColor(Color.black) + } + .padding() + .background(Color.white) + .cornerRadius(32) + } + .padding(.trailing, 10) + .onAppear { + if UserDefaults.standard.bool(forKey: "hideNextButton") { + DispatchQueue.main.asyncAfter(deadline: .now() + 5) { + showWatchNextButton = false + } + } + } + } + if showControls { + Menu { + Menu("Playback Speed") { + ForEach([0.5, 1.0, 1.25, 1.5, 1.75, 2.0], id: \.self) { speed in + Button(action: { + player.rate = Float(speed) + if player.timeControlStatus != .playing { + player.pause() + } + }) { + Text("\(speed, specifier: "%.2f")") + } + } + } + } label: { + Image(systemName: "ellipsis.circle") + .foregroundColor(.white) + .font(.system(size: 15)) + } + } + } + .padding(.trailing, 32) + + if showControls { + MusicProgressSlider( + value: $currentTime, + inRange: 0...duration, + activeFillColor: .white, + fillColor: .white.opacity(0.5), + emptyColor: .white.opacity(0.3), + height: 28, + onEditingChanged: { editing in + if !editing && isVideoLoaded { + player.seek(to: CMTime(seconds: currentTime, preferredTimescale: 600)) + } + } + ) + .padding(.horizontal, 32) + .padding(.bottom, 10) + .disabled(!isVideoLoaded) + } + } + } + .onAppear { + startUpdatingCurrentTime() + } + .onDisappear { + player.pause() + inactivityTimer?.invalidate() + if let timeObserverToken = timeObserverToken { + player.removeTimeObserver(timeObserverToken) + self.timeObserverToken = nil + } + } + } + } + VStack { + if showControls { + HStack { + Button(action: { + presentationMode.wrappedValue.dismiss() + }) { + Image(systemName: "xmark") + .foregroundColor(.white) + .font(.system(size: 20)) + } + .frame(width: 60, height: 60) + .contentShape(Rectangle()) + .padding() + Spacer() + } + Spacer() + } + } + } + } + + private func startUpdatingCurrentTime() { + Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in + currentTime = player.currentTime().seconds + } + } + + private func addPeriodicTimeObserver(fullURL: String) { + let interval = CMTime(seconds: 1.0, preferredTimescale: CMTimeScale(NSEC_PER_SEC)) + timeObserverToken = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { time in + guard let currentItem = player.currentItem, + currentItem.duration.seconds.isFinite else { + return + } + + let currentTime = time.seconds + let duration = currentItem.duration.seconds + + UserDefaults.standard.set(currentTime, forKey: "lastPlayedTime_\(fullURL)") + UserDefaults.standard.set(duration, forKey: "totalTime_\(fullURL)") + } + } +} diff --git a/Sora/MediaPlayer/NormalPlayer.swift b/Sora/MediaPlayer/NormalPlayer.swift new file mode 100644 index 0000000..2c92496 --- /dev/null +++ b/Sora/MediaPlayer/NormalPlayer.swift @@ -0,0 +1,77 @@ +// +// NormalPlayer.swift +// Sora +// +// Created by Francesco on 18/12/24. +// + +import AVKit + +class NormalPlayer: AVPlayerViewController { + private var originalRate: Float = 1.0 + private var holdGesture: UILongPressGestureRecognizer? + + override func viewDidLoad() { + super.viewDidLoad() + setupHoldGesture() + setupAudioSession() + } + + override var supportedInterfaceOrientations: UIInterfaceOrientationMask { + if UserDefaults.standard.bool(forKey: "AlwaysLandscape") { + return .landscape + } else { + return .all + } + } + + override var prefersHomeIndicatorAutoHidden: Bool { + return true + } + + override var prefersStatusBarHidden: Bool { + return true + } + + private func setupHoldGesture() { + holdGesture = UILongPressGestureRecognizer(target: self, action: #selector(handleHoldGesture(_:))) + holdGesture?.minimumPressDuration = 0.5 + if let holdGesture = holdGesture { + view.addGestureRecognizer(holdGesture) + } + } + + @objc private func handleHoldGesture(_ gesture: UILongPressGestureRecognizer) { + switch gesture.state { + case .began: + beginHoldSpeed() + case .ended, .cancelled: + endHoldSpeed() + default: + break + } + } + + private func beginHoldSpeed() { + guard let player = player else { return } + originalRate = player.rate + let holdSpeed = UserDefaults.standard.float(forKey: "holdSpeedPlayer") + player.rate = holdSpeed + } + + private func endHoldSpeed() { + player?.rate = originalRate + } + + func setupAudioSession() { + do { + let audioSession = AVAudioSession.sharedInstance() + try audioSession.setCategory(.playback, mode: .moviePlayback, options: .mixWithOthers) + try audioSession.setActive(true) + + try audioSession.overrideOutputAudioPort(.speaker) + } catch { + print("Failed to set up AVAudioSession: \(error)") + } + } +} diff --git a/Sora/MediaPlayer/VideoPlayer.swift b/Sora/MediaPlayer/VideoPlayer.swift index bc85c25..1d00cd9 100644 --- a/Sora/MediaPlayer/VideoPlayer.swift +++ b/Sora/MediaPlayer/VideoPlayer.swift @@ -12,7 +12,7 @@ class VideoPlayerViewController: UIViewController { let module: ScrapingModule var player: AVPlayer? - var playerViewController: AVPlayerViewController? + var playerViewController: NormalPlayer? var timeObserverToken: Any? var streamUrl: String? var fullUrl: String = "" @@ -42,7 +42,7 @@ class VideoPlayerViewController: UIViewController { let playerItem = AVPlayerItem(asset: asset) player = AVPlayer(playerItem: playerItem) - playerViewController = AVPlayerViewController() + playerViewController = NormalPlayer() playerViewController?.player = player addPeriodicTimeObserver(fullURL: fullUrl) diff --git a/Sora/Utils/Miru/MiruDataStruct.swift b/Sora/Utils/Miru/MiruDataStruct.swift new file mode 100644 index 0000000..c50e75d --- /dev/null +++ b/Sora/Utils/Miru/MiruDataStruct.swift @@ -0,0 +1,26 @@ +// +// MiruDataStruct.swift +// Sora +// +// Created by Francesco on 18/12/24. +// + +import Foundation + +struct MiruDataStruct: Codable { + var likes: [Like] + + struct Like: Codable { + let anilistID: Int + var gogoSlug: String + let title: String + let cover: String + + enum CodingKeys: String, CodingKey { + case anilistID = "anilist_id" + case gogoSlug = "gogo_slug" + case title + case cover + } + } +}