diff --git a/WhiteNights.xcodeproj/project.pbxproj b/WhiteNights.xcodeproj/project.pbxproj index 6463679..b3c5e2c 100644 --- a/WhiteNights.xcodeproj/project.pbxproj +++ b/WhiteNights.xcodeproj/project.pbxproj @@ -41,6 +41,7 @@ 628A47C72E5A99A20099CAA0 /* VisualEffectBlur.swift in Sources */ = {isa = PBXBuildFile; fileRef = 628A47C62E5A99A20099CAA0 /* VisualEffectBlur.swift */; }; 628A47C92E5A9A970099CAA0 /* StopModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 628A47C82E5A9A970099CAA0 /* StopModels.swift */; }; 628A47CB2E5A9BD60099CAA0 /* RouteSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 628A47CA2E5A9BD60099CAA0 /* RouteSelectionView.swift */; }; + 62F969FD2E5E5691009674E8 /* URLSessionDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62F969FC2E5E5691009674E8 /* URLSessionDelegate.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -91,6 +92,7 @@ 628A47C62E5A99A20099CAA0 /* VisualEffectBlur.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualEffectBlur.swift; sourceTree = ""; }; 628A47C82E5A9A970099CAA0 /* StopModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StopModels.swift; sourceTree = ""; }; 628A47CA2E5A9BD60099CAA0 /* RouteSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteSelectionView.swift; sourceTree = ""; }; + 62F969FC2E5E5691009674E8 /* URLSessionDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionDelegate.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -208,6 +210,7 @@ 628A47972E5A92FC0099CAA0 /* View+Extensions.swift */, 628A47A42E5A957A0099CAA0 /* MarqueeText.swift */, 628A47A82E5A961D0099CAA0 /* View+CornerRadius.swift */, + 62F969FC2E5E5691009674E8 /* URLSessionDelegate.swift */, ); path = Utils; sourceTree = ""; @@ -377,6 +380,7 @@ 628A479D2E5A94D30099CAA0 /* SightModel.swift in Sources */, 628A478D2E5A922A0099CAA0 /* WeatherView.swift in Sources */, 628A47C72E5A99A20099CAA0 /* VisualEffectBlur.swift in Sources */, + 62F969FD2E5E5691009674E8 /* URLSessionDelegate.swift in Sources */, 628A479F2E5A94EB0099CAA0 /* Article.swift in Sources */, 628A47922E5A926A0099CAA0 /* SightView.swift in Sources */, 628A47A72E5A95F60099CAA0 /* Station.swift in Sources */, diff --git a/WhiteNights/ContentView.swift b/WhiteNights/ContentView.swift index 58bc4e5..7c95ac0 100644 --- a/WhiteNights/ContentView.swift +++ b/WhiteNights/ContentView.swift @@ -15,13 +15,7 @@ struct ContentView: View { WeatherView() } - if let sightId = appState.sightId { - SightView(sightId: sightId) - } else { - Text("Выберите маршрут, чтобы увидеть достопримечательность") - .foregroundColor(.gray) - .padding() - } + SightView() } .padding() .blur(radius: isLoading ? 5 : 0) // слегка размываем, пока загрузка diff --git a/WhiteNights/Utils/URLSessionDelegate.swift b/WhiteNights/Utils/URLSessionDelegate.swift new file mode 100644 index 0000000..89522b1 --- /dev/null +++ b/WhiteNights/Utils/URLSessionDelegate.swift @@ -0,0 +1,36 @@ +import Foundation + +class FileDownloader: NSObject, URLSessionDownloadDelegate { + private var completion: ((URL?, Error?) -> Void)? + + func downloadFile(from url: URL, completion: @escaping (URL?, Error?) -> Void) { + self.completion = completion + let session = URLSession(configuration: .default, delegate: self, delegateQueue: nil) + let task = session.downloadTask(with: url) + task.resume() + } + + // MARK: - URLSessionDownloadDelegate + + func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { + let destinationURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + do { + try FileManager.default.moveItem(at: location, to: destinationURL) + DispatchQueue.main.async { + self.completion?(destinationURL, nil) + } + } catch { + DispatchQueue.main.async { + self.completion?(nil, error) + } + } + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + if let error = error { + DispatchQueue.main.async { + self.completion?(nil, error) + } + } + } +} diff --git a/WhiteNights/Widgets/SightView.swift b/WhiteNights/Widgets/SightView.swift index 732ee2e..9577d78 100644 --- a/WhiteNights/Widgets/SightView.swift +++ b/WhiteNights/Widgets/SightView.swift @@ -3,7 +3,6 @@ import AVKit import NukeUI struct SightView: View { - let sightId: Int @StateObject private var viewModel = SightViewModel() @EnvironmentObject private var appState: AppState @@ -75,16 +74,18 @@ struct SightView: View { .padding(.horizontal, 8) .padding(.bottom, 4) } - // MARK: - Initial load - .task(id: sightId) { + // MARK: - Initial load and reload on sight change + .task(id: appState.sightId) { + guard let currentSightId = appState.sightId else { return } viewModel.setLanguage(appState.selectedLanguage) - await viewModel.loadInitialData(sightId: sightId) + await viewModel.loadInitialData(sightId: currentSightId) } // MARK: - Reload on language change .onChange(of: appState.selectedLanguage) { newLang in + guard let currentSightId = appState.sightId else { return } viewModel.setLanguage(newLang) Task { - await viewModel.loadInitialData(sightId: sightId) + await viewModel.loadInitialData(sightId: currentSightId) } } .blockStyle(cornerRadius: 25) @@ -98,34 +99,49 @@ struct SightView: View { case .loading: ZStack { Color.gray.opacity(0.3) - ProgressView() - .progressViewStyle(.circular) - .tint(.white) - } - .frame(maxWidth: .infinity) - .frame(height: 160) - .cornerRadius(24, corners: [.topLeft, .topRight]) - .clipped() - case .image(let url): - LazyImage(url: url) { state in - if let image = state.image { - image.resizable().scaledToFit() - } else { - ZStack { - Color.gray.opacity(0.3) - ProgressView() - .progressViewStyle(.circular) + if let progress = viewModel.downloadProgress { + VStack { + ProgressView(value: progress) + .progressViewStyle(.linear) .tint(.white) + Text("\(Int(progress * 100))%") + .foregroundColor(.white) + .font(.caption) } + .padding() + } else { + ProgressView() + .progressViewStyle(.circular) + .tint(.white) } } + .frame(maxWidth: .infinity, minHeight: 200) .cornerRadius(24, corners: [.topLeft, .topRight]) .clipped() + + case .image(let url): + if let uiImage = UIImage(contentsOfFile: url.path) { + Image(uiImage: uiImage) + .resizable() + .scaledToFit() + .cornerRadius(24, corners: [.topLeft, .topRight]) + .clipped() + } else { + Image(systemName: "photo") + .resizable() + .scaledToFit() + .foregroundColor(.gray) + .frame(maxWidth: .infinity, minHeight: 200) + .cornerRadius(24, corners: [.topLeft, .topRight]) + .clipped() + } + case .video(let player): VideoPlayer(player: player) .aspectRatio(16/9, contentMode: .fit) .cornerRadius(24, corners: [.topLeft, .topRight]) .clipped() + case .error: Image(systemName: "photo") .resizable() diff --git a/WhiteNights/Widgets/SightViewModel.swift b/WhiteNights/Widgets/SightViewModel.swift index f28ef8e..3bfdaab 100644 --- a/WhiteNights/Widgets/SightViewModel.swift +++ b/WhiteNights/Widgets/SightViewModel.swift @@ -1,14 +1,20 @@ import Foundation import AVKit import Combine +import SwiftUI + +// MARK: - ViewModel @MainActor class SightViewModel: ObservableObject { + private let fileDownloader = FileDownloader() + @Published var sightName: String = "Загрузка..." @Published var allArticles: [Article] = [] @Published var selectedArticle: Article? @Published var articleHeading: String = "" @Published var articleBody: String = "" @Published var mediaState: MediaState = .loading + @Published var downloadProgress: Double? = nil // прогресс загрузки private var sightModel: SightModel? private var selectedLanguage: String = "ru" // по умолчанию @@ -25,6 +31,9 @@ class SightViewModel: ObservableObject { } func loadInitialData(sightId: Int) async { + // Вот это исправление. Сбрасываем выбранную статью перед загрузкой новых данных. + self.selectedArticle = nil + do { async let sightModelTask = fetchJSON( from: "https://white-nights.krbl.ru/services/content/sight/\(sightId)?lang=\(selectedLanguage)", @@ -63,44 +72,119 @@ class SightViewModel: ObservableObject { } } + // MARK: - Загрузка медиа + // MARK: - Загрузка медиа + @MainActor private func updateMedia(for article: Article) async { self.mediaState = .loading - - if article.isReviewArticle == true { - guard let sight = sightModel else { - self.mediaState = .error - return - } + self.downloadProgress = nil + + do { + if article.isReviewArticle ?? false { + guard let sight = sightModel else { return } + + if let videoPreviewId = sight.video_preview { + // Пытаемся загрузить видео-превью + let url = URL(string: "https://white-nights.krbl.ru/services/content/media/\(videoPreviewId)/download?lang=\(selectedLanguage)")! + let (data, _) = try await URLSession.shared.data(from: url) + + // Пробуем создать AVPlayer из данных + let localFile = try saveMediaToFile(data: data, fileExtension: "mp4") + let player = AVPlayer(url: localFile) + + // Асинхронно загружаем статус готовности + let asset = player.currentItem?.asset + if let asset = asset { + try await asset.load(.isPlayable) + + if asset.isPlayable { + self.mediaState = .video(player) + self.downloadProgress = nil + player.play() + await player.seek(to: .zero) + } else { + self.mediaState = .error + } + } else { + self.mediaState = .error + } + } else if let previewMediaId = sight.preview_media { + // Если нет видео-превью, пытаемся загрузить изображение + let url = URL(string: "https://white-nights.krbl.ru/services/content/media/\(previewMediaId)/download?lang=\(selectedLanguage)")! + let (data, _) = try await URLSession.shared.data(from: url) + + // Пробуем создать UIImage из данных + if let image = UIImage(data: data) { + let localFile = try saveMediaToFile(data: data, fileExtension: "jpeg") + self.mediaState = .image(localFile) + self.downloadProgress = nil + } else { + self.mediaState = .error + } + } else { + self.mediaState = .error + } - if let videoPreviewId = sight.video_preview, - let url = URL(string: "https://white-nights.krbl.ru/services/content/media/\(videoPreviewId)/download?lang=\(selectedLanguage)") { - let player = AVPlayer(url: url) - player.play() - self.mediaState = .video(player) - } else if let url = URL(string: "https://white-nights.krbl.ru/services/content/media/\(sight.preview_media)/download?lang=\(selectedLanguage)") { - self.mediaState = .image(url) } else { - self.mediaState = .error - } - } else { - do { + // Загрузка медиа для статьи let mediaItems = try await fetchJSON( from: "https://white-nights.krbl.ru/services/content/article/\(article.id)/media?lang=\(selectedLanguage)", type: [ArticleMedia].self ) + if let firstMedia = mediaItems.first, let url = URL(string: "https://white-nights.krbl.ru/services/content/media/\(firstMedia.id)/download?lang=\(selectedLanguage)") { - self.mediaState = .image(url) - } else { - self.mediaState = .error + + let (data, _) = try await URLSession.shared.data(from: url) + + if let image = UIImage(data: data) { + let localFile = try saveMediaToFile(data: data, fileExtension: "jpeg") + self.mediaState = .image(localFile) + self.downloadProgress = nil + } else { + self.mediaState = .error + } } - } catch { - print("Ошибка загрузки медиа для статьи '\(article.heading)': \(error)") - self.mediaState = .error } + } catch { + print("Ошибка загрузки файла: \(error)") + self.mediaState = .error + self.downloadProgress = nil } } + private func saveMediaToFile(data: Data, fileExtension: String) throws -> URL { + let destinationURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension(fileExtension) + + try data.write(to: destinationURL) + return destinationURL + } + + // MARK: - Загрузка файла с прогрессом + private func downloadFile(from url: URL) async throws -> URL { + let (tempLocalUrl, response) = try await URLSession.shared.download(from: url) + + guard let httpResponse = response as? HTTPURLResponse, + (200...299).contains(httpResponse.statusCode) else { + throw URLError(.badServerResponse) + } + + // Пытаемся угадать расширение + let suggestedName = response.suggestedFilename ?? UUID().uuidString + let destinationURL = FileManager.default.temporaryDirectory.appendingPathComponent(suggestedName) + + // Удаляем файл, если уже есть + if FileManager.default.fileExists(atPath: destinationURL.path) { + try FileManager.default.removeItem(at: destinationURL) + } + + try FileManager.default.moveItem(at: tempLocalUrl, to: destinationURL) + return destinationURL + } + + // MARK: - JSON загрузчик private func fetchJSON(from urlString: String, type: T.Type) async throws -> T { guard let url = URL(string: urlString) else { throw URLError(.badURL)