diff --git a/Sora/MediaUtils/ContinueWatching/ContinueWatchingItem.swift b/Sora/MediaUtils/ContinueWatching/ContinueWatchingItem.swift index 6e83792..6679df6 100644 --- a/Sora/MediaUtils/ContinueWatching/ContinueWatchingItem.swift +++ b/Sora/MediaUtils/ContinueWatching/ContinueWatchingItem.swift @@ -20,4 +20,6 @@ struct ContinueWatchingItem: Codable, Identifiable { let module: ScrapingModule let headers: [String:String]? let totalEpisodes: Int + let episodeTitle: String? + let seasonNumber: Int? } diff --git a/Sora/MediaUtils/CustomPlayer/CustomPlayer.swift b/Sora/MediaUtils/CustomPlayer/CustomPlayer.swift index 551fa3f..6a57dc0 100644 --- a/Sora/MediaUtils/CustomPlayer/CustomPlayer.swift +++ b/Sora/MediaUtils/CustomPlayer/CustomPlayer.swift @@ -254,11 +254,15 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele private var isMenuOpen = false private var menuProtectionTimer: Timer? + let episodeTitle: String + init(module: ScrapingModule, urlString: String, fullUrl: String, title: String, episodeNumber: Int, + episodeTitle: String, + seasonNumber: Int, onWatchNext: @escaping () -> Void, subtitlesURL: String?, aniListID: Int, @@ -271,6 +275,8 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele self.titleText = title self.episodeNumber = episodeNumber self.episodeImageUrl = episodeImageUrl + self.episodeTitle = episodeTitle + self.seasonNumber = seasonNumber self.onWatchNext = onWatchNext self.subtitlesURL = subtitlesURL self.aniListID = aniListID @@ -1257,9 +1263,14 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele titleContainer.translatesAutoresizingMaskIntoConstraints = false titleContainer.backgroundColor = .clear controlsContainerView.addSubview(titleContainer) - episodeNumberLabel = UILabel() - episodeNumberLabel.text = "Episode \(episodeNumber)" + let hasTitle = !episodeTitle.isEmpty + let isSingleSeason = (seasonNumber == 1 || seasonNumber == nil) + let episodePart = "E\(episodeNumber)" + let seasonPart = isSingleSeason ? "" : "S\(seasonNumber ?? 1)" + let colon = hasTitle ? ":" : "" + let main = [seasonPart, episodePart].filter { !$0.isEmpty }.joined() + episodeNumberLabel.text = hasTitle ? "\(main)\(colon) \(episodeTitle)" : main episodeNumberLabel.textColor = UIColor(white: 1.0, alpha: 0.6) episodeNumberLabel.font = UIFont.systemFont(ofSize: 14, weight: .semibold) episodeNumberLabel.textAlignment = .left @@ -1946,7 +1957,9 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele aniListID: self.aniListID, module: self.module, headers: self.headers, - totalEpisodes: self.totalEpisodes + totalEpisodes: self.totalEpisodes, + episodeTitle: self.episodeTitle, + seasonNumber: self.seasonNumber ) ContinueWatchingManager.shared.save(item: item) } diff --git a/Sora/MediaUtils/NormalPlayer/VideoPlayer.swift b/Sora/MediaUtils/NormalPlayer/VideoPlayer.swift index d6241c6..b26c09d 100644 --- a/Sora/MediaUtils/NormalPlayer/VideoPlayer.swift +++ b/Sora/MediaUtils/NormalPlayer/VideoPlayer.swift @@ -314,7 +314,9 @@ class VideoPlayerViewController: UIViewController { aniListID: self.aniListID, module: self.module, headers: self.headers, - totalEpisodes: self.totalEpisodes + totalEpisodes: self.totalEpisodes, + episodeTitle: "", + seasonNumber: self.seasonNumber ) ContinueWatchingManager.shared.save(item: item) } diff --git a/Sora/Utlis & Misc/DownloadUtils/DownloadModels.swift b/Sora/Utlis & Misc/DownloadUtils/DownloadModels.swift index fd20229..685a943 100644 --- a/Sora/Utlis & Misc/DownloadUtils/DownloadModels.swift +++ b/Sora/Utlis & Misc/DownloadUtils/DownloadModels.swift @@ -411,6 +411,8 @@ struct AssetMetadata: Codable { let season: Int? let episode: Int? let showPosterURL: URL? // Main show poster URL (distinct from episode-specific images) + let episodeTitle: String? + let seasonNumber: Int? init( title: String, @@ -421,7 +423,9 @@ struct AssetMetadata: Codable { showTitle: String? = nil, season: Int? = nil, episode: Int? = nil, - showPosterURL: URL? = nil + showPosterURL: URL? = nil, + episodeTitle: String? = nil, + seasonNumber: Int? = nil ) { self.title = title self.overview = overview @@ -432,6 +436,8 @@ struct AssetMetadata: Codable { self.season = season self.episode = episode self.showPosterURL = showPosterURL + self.episodeTitle = episodeTitle + self.seasonNumber = seasonNumber } } diff --git a/Sora/Utlis & Misc/JSLoader/JSController-Details.swift b/Sora/Utlis & Misc/JSLoader/JSController-Details.swift index ea2b8ae..5278aa4 100644 --- a/Sora/Utlis & Misc/JSLoader/JSController-Details.swift +++ b/Sora/Utlis & Misc/JSLoader/JSController-Details.swift @@ -51,7 +51,8 @@ extension JSController { let episodesResult = fetchEpisodesFunction.call(withArguments: [html]).toArray() as? [[String: String]] { for episodeData in episodesResult { if let num = episodeData["number"], let link = episodeData["href"], let number = Int(num) { - episodeLinks.append(EpisodeLink(number: number, title: "", href: link, duration: nil)) + let title = episodeData["title"] ?? "" + episodeLinks.append(EpisodeLink(number: number, title: title, href: link, duration: nil)) } } } diff --git a/Sora/Views/DownloadView.swift b/Sora/Views/DownloadView.swift index e6e5d31..4f073e8 100644 --- a/Sora/Views/DownloadView.swift +++ b/Sora/Views/DownloadView.swift @@ -248,6 +248,8 @@ struct DownloadView: View { fullUrl: asset.originalURL.absoluteString, title: asset.metadata?.showTitle ?? asset.name, episodeNumber: asset.metadata?.episode ?? 0, + episodeTitle: asset.metadata?.episodeTitle ?? "", + seasonNumber: asset.metadata?.seasonNumber ?? 1, onWatchNext: {}, subtitlesURL: asset.localSubtitleURL?.absoluteString, aniListID: 0, diff --git a/Sora/Views/LibraryView/AllWatching.swift b/Sora/Views/LibraryView/AllWatching.swift index 3a94fcb..7959e43 100644 --- a/Sora/Views/LibraryView/AllWatching.swift +++ b/Sora/Views/LibraryView/AllWatching.swift @@ -339,6 +339,8 @@ struct FullWidthContinueWatchingCell: View { fullUrl: item.fullUrl, title: item.mediaTitle, episodeNumber: item.episodeNumber, + episodeTitle: item.episodeTitle ?? "", + seasonNumber: item.seasonNumber ?? 1, onWatchNext: { }, subtitlesURL: item.subtitles, aniListID: item.aniListID ?? 0, @@ -405,12 +407,12 @@ struct FullWidthContinueWatchingCell: View { .lineLimit(1) HStack { - Text("Episode \(item.episodeNumber)") + Text(episodeLabel(for: item)) .font(.subheadline) .foregroundColor(.white.opacity(0.9)) - + .lineLimit(2) + .truncationMode(.tail) Spacer() - Text("\(Int(item.progress * 100))% seen") .font(.caption) .foregroundColor(.white.opacity(0.9)) @@ -473,4 +475,15 @@ struct FullWidthContinueWatchingCell: View { currentProgress = max(0, min(item.progress, 1)) } } +} + +private func episodeLabel(for item: ContinueWatchingItem) -> String { + let hasTitle = !(item.episodeTitle?.isEmpty ?? true) + let isSingleSeason = (item.seasonNumber ?? 1) <= 1 + let episodePart = "E\(item.episodeNumber)" + let seasonPart = isSingleSeason ? "" : "S\(item.seasonNumber ?? 1)" + let colon = hasTitle ? ":" : "" + let title = item.episodeTitle ?? "" + let main = [seasonPart, episodePart].filter { !$0.isEmpty }.joined() + return hasTitle ? "\(main)\(colon) \(title)" : main } diff --git a/Sora/Views/LibraryView/LibraryView.swift b/Sora/Views/LibraryView/LibraryView.swift index 2b29f0a..88be45b 100644 --- a/Sora/Views/LibraryView/LibraryView.swift +++ b/Sora/Views/LibraryView/LibraryView.swift @@ -411,6 +411,8 @@ struct ContinueWatchingCell: View { fullUrl: item.fullUrl, title: item.mediaTitle, episodeNumber: item.episodeNumber, + episodeTitle: item.episodeTitle ?? "", + seasonNumber: item.seasonNumber ?? 1, onWatchNext: { }, subtitlesURL: item.subtitles, aniListID: item.aniListID ?? 0, @@ -453,12 +455,12 @@ struct ContinueWatchingCell: View { .lineLimit(1) HStack { - Text("Episode \(item.episodeNumber)") + Text(episodeLabel(for: item)) .font(.subheadline) .foregroundColor(.white.opacity(0.9)) - + .lineLimit(2) + .truncationMode(.tail) Spacer() - Text("\(Int(item.progress * 100))% seen") .font(.caption) .foregroundColor(.white.opacity(0.9)) @@ -562,6 +564,16 @@ struct ContinueWatchingCell: View { } } +private func episodeLabel(for item: ContinueWatchingItem) -> String { + let hasTitle = !(item.episodeTitle?.isEmpty ?? true) + let isSingleSeason = (item.seasonNumber ?? 1) <= 1 + let episodePart = "E\(item.episodeNumber)" + let seasonPart = isSingleSeason ? "" : "S\(item.seasonNumber ?? 1)" + let colon = hasTitle ? ":" : "" + let title = item.episodeTitle ?? "" + let main = [seasonPart, episodePart].filter { !$0.isEmpty }.joined() + return hasTitle ? "\(main)\(colon) \(title)" : main +} struct RoundedCorner: Shape { var radius: CGFloat = .infinity diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index 088a921..6c253df 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -72,6 +72,8 @@ struct MediaInfoView: View { @State private var refreshTrigger: Bool = false @State private var buttonRefreshTrigger: Bool = false + @State private var episodeTitleCache: [Int: String] = [:] + private var selectedRangeKey: String { "selectedRangeStart_\(href)" } private var selectedSeasonKey: String { "selectedSeason_\(href)" } @@ -1946,38 +1948,63 @@ struct MediaInfoView: View { DropManager.shared.showDrop(title: "Error", subtitle: "Invalid stream URL", duration: 2.0, icon: UIImage(systemName: "xmark.circle")) return } - guard self.activeFetchID == fetchID else { return } let isMovie = tmdbType == .movie - - let customMediaPlayer = CustomMediaPlayerViewController( - module: module, - urlString: url.absoluteString, - fullUrl: fullURL, - title: title, - episodeNumber: selectedEpisodeNumber, - onWatchNext: { selectNextEpisode() }, - subtitlesURL: subtitles, - aniListID: itemID ?? 0, - totalEpisodes: episodeLinks.count, - episodeImageUrl: selectedEpisodeImage, - headers: headers ?? nil - ) - customMediaPlayer.seasonNumber = selectedSeason + 1 - customMediaPlayer.tmdbID = tmdbID - customMediaPlayer.isMovie = isMovie - customMediaPlayer.modalPresentationStyle = .fullScreen - Logger.shared.log("Opening custom media player with url: \(url)") - - if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let rootVC = windowScene.windows.first?.rootViewController { - findTopViewController.findViewController(rootVC).present(customMediaPlayer, animated: true, completion: nil) - } else { - Logger.shared.log("Failed to find root view controller", type: "Error") - DropManager.shared.showDrop(title: "Error", subtitle: "Failed to present player", duration: 2.0, icon: UIImage(systemName: "xmark.circle")) + let episode: EpisodeLink? = { + if isGroupedBySeasons { + let seasons = groupedEpisodes() + if selectedSeason < seasons.count { + return seasons[selectedSeason].first(where: { $0.number == selectedEpisodeNumber }) + } + return nil + } else { + return episodeLinks.first(where: { $0.number == selectedEpisodeNumber }) + } + }() + fetchTMDBEpisodeTitle(episodeNumber: selectedEpisodeNumber, season: selectedSeason + 1) { episodeTitle in + let customMediaPlayer = CustomMediaPlayerViewController( + module: module, + urlString: url.absoluteString, + fullUrl: fullURL, + title: title, + episodeNumber: selectedEpisodeNumber, + episodeTitle: episodeTitle, + seasonNumber: selectedSeason + 1, + onWatchNext: { selectNextEpisode() }, + subtitlesURL: subtitles, + aniListID: itemID ?? 0, + totalEpisodes: episodeLinks.count, + episodeImageUrl: selectedEpisodeImage, + headers: headers ?? nil + ) + customMediaPlayer.tmdbID = tmdbID + customMediaPlayer.isMovie = isMovie + customMediaPlayer.modalPresentationStyle = .fullScreen + Logger.shared.log("Opening custom media player with url: \(url)") + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let rootVC = windowScene.windows.first?.rootViewController { + findTopViewController.findViewController(rootVC).present(customMediaPlayer, animated: true, completion: nil) + } else { + Logger.shared.log("Failed to find root view controller", type: "Error") + DropManager.shared.showDrop(title: "Error", subtitle: "Failed to present player", duration: 2.0, icon: UIImage(systemName: "xmark.circle")) + } } } + private func fetchTMDBEpisodeTitle(episodeNumber: Int, season: Int, completion: @escaping (String) -> Void) { + guard let tmdbID = tmdbID else { completion(""); return } + let urlString = "https://api.themoviedb.org/3/tv/\(tmdbID)/season/\(season)/episode/\(episodeNumber)?api_key=738b4edd0a156cc126dc4a4b8aea4aca" + guard let url = URL(string: urlString) else { completion(""); return } + URLSession.shared.dataTask(with: url) { data, _, _ in + var title = "" + if let data = data, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + title = json["name"] as? String ?? "" + } + DispatchQueue.main.async { completion(title) } + }.resume() + } + private func downloadSingleEpisodeDirectly(episode: EpisodeLink) { if isSingleEpisodeDownloading { return } @@ -2200,8 +2227,14 @@ struct MediaInfoView: View { completion(nil as EpisodeMetadataInfo?) return } - - fetchEpisodeMetadataFromNetwork(anilistId: anilistId, episodeNumber: episode.number, completion: completion) + fetchEpisodeMetadataFromNetwork(anilistId: anilistId, episodeNumber: episode.number) { info in + if let info = info, let enTitle = info.title["en"] { + DispatchQueue.main.async { + episodeTitleCache[episode.number] = enTitle + } + } + completion(info) + } } private func fetchEpisodeMetadataFromNetwork(anilistId: Int, episodeNumber: Int, completion: @escaping (EpisodeMetadataInfo?) -> Void) { @@ -2390,4 +2423,11 @@ struct MediaInfoView: View { private var episodeRanges: [Range] { generateRanges(for: currentEpisodeList.count) } + + private func getEpisodeTitleForPlayer(episodeNumber: Int) -> String { + if let cached = episodeTitleCache[episodeNumber], !cached.isEmpty { + return cached + } + return "" + } }