diff --git a/Sora.xcodeproj/project.pbxproj b/Sora.xcodeproj/project.pbxproj index e88ee24..5e1c507 100644 --- a/Sora.xcodeproj/project.pbxproj +++ b/Sora.xcodeproj/project.pbxproj @@ -38,6 +38,8 @@ 1399FAD42D3AB38C00E97C31 /* SettingsViewLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1399FAD32D3AB38C00E97C31 /* SettingsViewLogger.swift */; }; 1399FAD62D3AB3DB00E97C31 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1399FAD52D3AB3DB00E97C31 /* Logger.swift */; }; 13B7F4C12D58FFDD0045714A /* Shimmer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13B7F4C02D58FFDD0045714A /* Shimmer.swift */; }; + 13C0E5EA2D5F85EA00E7F619 /* ContinueWatchingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13C0E5E92D5F85EA00E7F619 /* ContinueWatchingManager.swift */; }; + 13C0E5EC2D5F85F800E7F619 /* ContinueWatchingItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13C0E5EB2D5F85F800E7F619 /* ContinueWatchingItem.swift */; }; 13CBEFDA2D5F7D1200D011EE /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13CBEFD92D5F7D1200D011EE /* String.swift */; }; 13D842522D4523B800EBBFA6 /* Drops in Frameworks */ = {isa = PBXBuildFile; productRef = 13D842512D4523B800EBBFA6 /* Drops */; }; 13D842552D45267500EBBFA6 /* DropManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13D842542D45267500EBBFA6 /* DropManager.swift */; }; @@ -84,6 +86,8 @@ 1399FAD32D3AB38C00E97C31 /* SettingsViewLogger.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewLogger.swift; sourceTree = ""; }; 1399FAD52D3AB3DB00E97C31 /* Logger.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; 13B7F4C02D58FFDD0045714A /* Shimmer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Shimmer.swift; sourceTree = ""; }; + 13C0E5E92D5F85EA00E7F619 /* ContinueWatchingManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContinueWatchingManager.swift; sourceTree = ""; }; + 13C0E5EB2D5F85F800E7F619 /* ContinueWatchingItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContinueWatchingItem.swift; sourceTree = ""; }; 13CBEFD92D5F7D1200D011EE /* String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.swift; sourceTree = ""; }; 13D842542D45267500EBBFA6 /* DropManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DropManager.swift; sourceTree = ""; }; 13D99CF62D4E73C300250A86 /* ModuleAdditionSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModuleAdditionSettingsView.swift; sourceTree = ""; }; @@ -232,6 +236,7 @@ 133D7C852D2BE2640075467E /* Utils */ = { isa = PBXGroup; children = ( + 13C0E5E82D5F85DD00E7F619 /* ContinueWatching */, 13103E8C2D58E037000F0673 /* SkeletonCells */, 13DC0C442D302C6A00D0F966 /* MediaPlayer */, 133D7C862D2BE2640075467E /* Extensions */, @@ -323,6 +328,15 @@ path = SettingsView; sourceTree = ""; }; + 13C0E5E82D5F85DD00E7F619 /* ContinueWatching */ = { + isa = PBXGroup; + children = ( + 13C0E5E92D5F85EA00E7F619 /* ContinueWatchingManager.swift */, + 13C0E5EB2D5F85F800E7F619 /* ContinueWatchingItem.swift */, + ); + path = ContinueWatching; + sourceTree = ""; + }; 13D842532D45266900EBBFA6 /* Drops */ = { isa = PBXGroup; children = ( @@ -459,6 +473,7 @@ 136F21B92D5B8DD8006409AC /* AniList-MediaInfo.swift in Sources */, 133D7C702D2BE2500075467E /* ContentView.swift in Sources */, 13D99CF72D4E73C300250A86 /* ModuleAdditionSettingsView.swift in Sources */, + 13C0E5EC2D5F85F800E7F619 /* ContinueWatchingItem.swift in Sources */, 13103E862D58A328000F0673 /* AniList-Trending.swift in Sources */, 13EA2BD62D32D97400C1EBD7 /* Double+Extension.swift in Sources */, 133D7C8F2D2BE2640075467E /* MediaInfoView.swift in Sources */, @@ -482,6 +497,7 @@ 138AA1B92D2D66FD0021F9DF /* CircularProgressBar.swift in Sources */, 13EA2BD52D32D97400C1EBD7 /* CustomPlayer.swift in Sources */, 1399FAD42D3AB38C00E97C31 /* SettingsViewLogger.swift in Sources */, + 13C0E5EA2D5F85EA00E7F619 /* ContinueWatchingManager.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Sora/Utils/ContinueWatching/ContinueWatchingItem.swift b/Sora/Utils/ContinueWatching/ContinueWatchingItem.swift new file mode 100644 index 0000000..27cbbf8 --- /dev/null +++ b/Sora/Utils/ContinueWatching/ContinueWatchingItem.swift @@ -0,0 +1,19 @@ +// +// ContinueWatchingItem.swift +// Sora +// +// Created by Francesco on 14/02/25. +// + +import Foundation + +struct ContinueWatchingItem: Codable, Identifiable { + let id: UUID + let imageUrl: String + let episodeNumber: Int + let mediaTitle: String + let progress: Double + let streamUrl: String + let fullUrl: String + let module: ScrapingModule +} diff --git a/Sora/Utils/ContinueWatching/ContinueWatchingManager.swift b/Sora/Utils/ContinueWatching/ContinueWatchingManager.swift new file mode 100644 index 0000000..43d795f --- /dev/null +++ b/Sora/Utils/ContinueWatching/ContinueWatchingManager.swift @@ -0,0 +1,35 @@ +// +// ContinueWatchingManager.swift +// Sora +// +// Created by Francesco on 14/02/25. +// + +import Foundation + +class ContinueWatchingManager { + static let shared = ContinueWatchingManager() + private let storageKey = "continueWatchingItems" + + private init() {} + + func save(item: ContinueWatchingItem) { + var items = fetchItems() + if let index = items.firstIndex(where: { $0.streamUrl == item.streamUrl && $0.episodeNumber == item.episodeNumber }) { + items[index] = item + } else { + items.append(item) + } + if let data = try? JSONEncoder().encode(items) { + UserDefaults.standard.set(data, forKey: storageKey) + } + } + + func fetchItems() -> [ContinueWatchingItem] { + if let data = UserDefaults.standard.data(forKey: storageKey), + let items = try? JSONDecoder().decode([ContinueWatchingItem].self, from: data) { + return items + } + return [] + } +} diff --git a/Sora/Utils/MediaPlayer/VideoPlayer.swift b/Sora/Utils/MediaPlayer/VideoPlayer.swift index 4868e3f..932aff0 100644 --- a/Sora/Utils/MediaPlayer/VideoPlayer.swift +++ b/Sora/Utils/MediaPlayer/VideoPlayer.swift @@ -17,6 +17,10 @@ class VideoPlayerViewController: UIViewController { var streamUrl: String? var fullUrl: String = "" + var episodeNumber: Int = 0 + var episodeImageUrl: String = "" + var mediaTitle: String = "" + init(module: ScrapingModule) { self.module = module super.init(nibName: nil, bundle: nil) @@ -80,6 +84,24 @@ class VideoPlayerViewController: UIViewController { player?.removeTimeObserver(timeObserverToken) self.timeObserverToken = nil } + + if let currentItem = player?.currentItem, currentItem.duration.seconds > 0, + let streamUrl = streamUrl { + let currentTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(fullUrl)") + let duration = currentItem.duration.seconds + let progress = currentTime / duration + let item = ContinueWatchingItem( + id: UUID(), + imageUrl: episodeImageUrl, + episodeNumber: episodeNumber, + mediaTitle: mediaTitle, + progress: progress, + streamUrl: streamUrl, + fullUrl: fullUrl, + module: module + ) + ContinueWatchingManager.shared.save(item: item) + } } private func setInitialPlayerRate() { diff --git a/Sora/Views/HomeView.swift b/Sora/Views/HomeView.swift index 08a673d..88ac8a6 100644 --- a/Sora/Views/HomeView.swift +++ b/Sora/Views/HomeView.swift @@ -11,6 +11,7 @@ import Kingfisher struct HomeView: View { @State private var aniListItems: [AniListItem] = [] @State private var trendingItems: [AniListItem] = [] + @State private var continueWatchingItems: [ContinueWatchingItem] = [] private var currentDeviceSeasonAndYear: (season: String, year: Int) { let currentDate = Date() @@ -136,12 +137,88 @@ struct HomeView: View { } .padding(.horizontal, 8) } + if !continueWatchingItems.isEmpty { + VStack(alignment: .leading) { + Text("Continue Watching") + .font(.headline) + .padding(.horizontal, 8) + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(continueWatchingItems) { item in + Button(action: { + let videoPlayerViewController = VideoPlayerViewController(module: item.module) + videoPlayerViewController.streamUrl = item.streamUrl + videoPlayerViewController.fullUrl = item.fullUrl + videoPlayerViewController.episodeImageUrl = item.imageUrl + videoPlayerViewController.episodeNumber = item.episodeNumber + videoPlayerViewController.mediaTitle = item.mediaTitle + videoPlayerViewController.modalPresentationStyle = .fullScreen + + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let rootVC = windowScene.windows.first?.rootViewController { + rootVC.present(videoPlayerViewController, animated: true, completion: nil) + } + }) { + VStack { + ZStack { + KFImage(URL(string: item.imageUrl)) + .placeholder { + RoundedRectangle(cornerRadius: 10) + .fill(Color.gray.opacity(0.3)) + .frame(width: 240, height: 135) + .shimmering() + } + .setProcessor(RoundCornerImageProcessor(cornerRadius: 10)) + .resizable() + .aspectRatio(16/9, contentMode: .fill) + .frame(width: 240, height: 135) + .cornerRadius(10) + .clipped() + } + .overlay( + ZStack { + Rectangle() + .fill(Color.black.opacity(0.3)) + .blur(radius: 3) + .frame(height: 30) + + ProgressView(value: item.progress) + .progressViewStyle(LinearProgressViewStyle(tint: .white)) + .padding(.horizontal, 8) + .scaleEffect(x: 1, y: 2, anchor: .center) + }, + alignment: .bottom + ) + + VStack(alignment: .leading) { + Text("Episode \(item.episodeNumber)") + .font(.caption) + .lineLimit(1) + .foregroundColor(.secondary) + + Text(item.mediaTitle) + .font(.caption) + .lineLimit(2) + .foregroundColor(.primary) + .multilineTextAlignment(.leading) + } + .padding(.horizontal, 8) + } + } + } + } + .frame(width: 250, height: 200) + .padding(.horizontal, 8) + } + } + } } .padding(.bottom, 16) } .navigationTitle("Home") } .onAppear { + continueWatchingItems = ContinueWatchingManager.shared.fetchItems() AnilistServiceSeasonalAnime().fetchSeasonalAnime { items in if let items = items { aniListItems = items diff --git a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift index a0b1c37..80029e2 100644 --- a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift +++ b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift @@ -24,6 +24,7 @@ struct EpisodeCell: View { @State private var episodeImageUrl: String = "" @State private var isLoading: Bool = true @State private var currentProgress: Double = 0.0 + let onTap: (String) -> Void private func markAsWatched() { UserDefaults.standard.set(99999999.0, forKey: "lastPlayedTime_\(episode)") @@ -90,6 +91,9 @@ struct EpisodeCell: View { fetchEpisodeDetails() updateProgress() } + .onTapGesture { + onTap(episodeImageUrl) + } } func fetchEpisodeDetails() { diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index f8bd419..4b7e314 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -35,6 +35,7 @@ struct MediaInfoView: View { @State var isFetchingEpisode: Bool = false @State private var selectedEpisodeNumber: Int = 0 + @State private var selectedEpisodeImage: String = "" @AppStorage("externalPlayer") private var externalPlayer: String = "Default" @AppStorage("episodeChunkSize") private var episodeChunkSize: Int = 100 @@ -207,14 +208,16 @@ struct MediaInfoView: View { let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(ep.href)") let progress = totalTime > 0 ? lastPlayedTime / totalTime : 0 - EpisodeCell(episode: ep.href, episodeID: ep.number - 1, progress: progress, itemID: itemID ?? 0) - .onTapGesture { + EpisodeCell(episode: ep.href, episodeID: ep.number - 1, progress: progress, itemID: itemID ?? 0, onTap: { imageUrl in if !isFetchingEpisode { isFetchingEpisode = true + selectedEpisodeNumber = ep.number + selectedEpisodeImage = imageUrl fetchStream(href: ep.href) } } - .disabled(isFetchingEpisode) + ) + .disabled(isFetchingEpisode) } } } else { @@ -479,6 +482,9 @@ struct MediaInfoView: View { let videoPlayerViewController = VideoPlayerViewController(module: module) videoPlayerViewController.streamUrl = url videoPlayerViewController.fullUrl = fullURL + videoPlayerViewController.episodeNumber = selectedEpisodeNumber + videoPlayerViewController.episodeImageUrl = selectedEpisodeImage + videoPlayerViewController.mediaTitle = title videoPlayerViewController.modalPresentationStyle = .fullScreen if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,