fix video
This commit is contained in:
@ -41,6 +41,7 @@
|
|||||||
628A47C72E5A99A20099CAA0 /* VisualEffectBlur.swift in Sources */ = {isa = PBXBuildFile; fileRef = 628A47C62E5A99A20099CAA0 /* VisualEffectBlur.swift */; };
|
628A47C72E5A99A20099CAA0 /* VisualEffectBlur.swift in Sources */ = {isa = PBXBuildFile; fileRef = 628A47C62E5A99A20099CAA0 /* VisualEffectBlur.swift */; };
|
||||||
628A47C92E5A9A970099CAA0 /* StopModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 628A47C82E5A9A970099CAA0 /* StopModels.swift */; };
|
628A47C92E5A9A970099CAA0 /* StopModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 628A47C82E5A9A970099CAA0 /* StopModels.swift */; };
|
||||||
628A47CB2E5A9BD60099CAA0 /* RouteSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 628A47CA2E5A9BD60099CAA0 /* RouteSelectionView.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 */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@ -91,6 +92,7 @@
|
|||||||
628A47C62E5A99A20099CAA0 /* VisualEffectBlur.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualEffectBlur.swift; sourceTree = "<group>"; };
|
628A47C62E5A99A20099CAA0 /* VisualEffectBlur.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualEffectBlur.swift; sourceTree = "<group>"; };
|
||||||
628A47C82E5A9A970099CAA0 /* StopModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StopModels.swift; sourceTree = "<group>"; };
|
628A47C82E5A9A970099CAA0 /* StopModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StopModels.swift; sourceTree = "<group>"; };
|
||||||
628A47CA2E5A9BD60099CAA0 /* RouteSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteSelectionView.swift; sourceTree = "<group>"; };
|
628A47CA2E5A9BD60099CAA0 /* RouteSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteSelectionView.swift; sourceTree = "<group>"; };
|
||||||
|
62F969FC2E5E5691009674E8 /* URLSessionDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionDelegate.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@ -208,6 +210,7 @@
|
|||||||
628A47972E5A92FC0099CAA0 /* View+Extensions.swift */,
|
628A47972E5A92FC0099CAA0 /* View+Extensions.swift */,
|
||||||
628A47A42E5A957A0099CAA0 /* MarqueeText.swift */,
|
628A47A42E5A957A0099CAA0 /* MarqueeText.swift */,
|
||||||
628A47A82E5A961D0099CAA0 /* View+CornerRadius.swift */,
|
628A47A82E5A961D0099CAA0 /* View+CornerRadius.swift */,
|
||||||
|
62F969FC2E5E5691009674E8 /* URLSessionDelegate.swift */,
|
||||||
);
|
);
|
||||||
path = Utils;
|
path = Utils;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@ -377,6 +380,7 @@
|
|||||||
628A479D2E5A94D30099CAA0 /* SightModel.swift in Sources */,
|
628A479D2E5A94D30099CAA0 /* SightModel.swift in Sources */,
|
||||||
628A478D2E5A922A0099CAA0 /* WeatherView.swift in Sources */,
|
628A478D2E5A922A0099CAA0 /* WeatherView.swift in Sources */,
|
||||||
628A47C72E5A99A20099CAA0 /* VisualEffectBlur.swift in Sources */,
|
628A47C72E5A99A20099CAA0 /* VisualEffectBlur.swift in Sources */,
|
||||||
|
62F969FD2E5E5691009674E8 /* URLSessionDelegate.swift in Sources */,
|
||||||
628A479F2E5A94EB0099CAA0 /* Article.swift in Sources */,
|
628A479F2E5A94EB0099CAA0 /* Article.swift in Sources */,
|
||||||
628A47922E5A926A0099CAA0 /* SightView.swift in Sources */,
|
628A47922E5A926A0099CAA0 /* SightView.swift in Sources */,
|
||||||
628A47A72E5A95F60099CAA0 /* Station.swift in Sources */,
|
628A47A72E5A95F60099CAA0 /* Station.swift in Sources */,
|
||||||
|
@ -15,13 +15,7 @@ struct ContentView: View {
|
|||||||
WeatherView()
|
WeatherView()
|
||||||
}
|
}
|
||||||
|
|
||||||
if let sightId = appState.sightId {
|
SightView()
|
||||||
SightView(sightId: sightId)
|
|
||||||
} else {
|
|
||||||
Text("Выберите маршрут, чтобы увидеть достопримечательность")
|
|
||||||
.foregroundColor(.gray)
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.blur(radius: isLoading ? 5 : 0) // слегка размываем, пока загрузка
|
.blur(radius: isLoading ? 5 : 0) // слегка размываем, пока загрузка
|
||||||
|
36
WhiteNights/Utils/URLSessionDelegate.swift
Normal file
36
WhiteNights/Utils/URLSessionDelegate.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -3,7 +3,6 @@ import AVKit
|
|||||||
import NukeUI
|
import NukeUI
|
||||||
|
|
||||||
struct SightView: View {
|
struct SightView: View {
|
||||||
let sightId: Int
|
|
||||||
@StateObject private var viewModel = SightViewModel()
|
@StateObject private var viewModel = SightViewModel()
|
||||||
@EnvironmentObject private var appState: AppState
|
@EnvironmentObject private var appState: AppState
|
||||||
|
|
||||||
@ -75,16 +74,18 @@ struct SightView: View {
|
|||||||
.padding(.horizontal, 8)
|
.padding(.horizontal, 8)
|
||||||
.padding(.bottom, 4)
|
.padding(.bottom, 4)
|
||||||
}
|
}
|
||||||
// MARK: - Initial load
|
// MARK: - Initial load and reload on sight change
|
||||||
.task(id: sightId) {
|
.task(id: appState.sightId) {
|
||||||
|
guard let currentSightId = appState.sightId else { return }
|
||||||
viewModel.setLanguage(appState.selectedLanguage)
|
viewModel.setLanguage(appState.selectedLanguage)
|
||||||
await viewModel.loadInitialData(sightId: sightId)
|
await viewModel.loadInitialData(sightId: currentSightId)
|
||||||
}
|
}
|
||||||
// MARK: - Reload on language change
|
// MARK: - Reload on language change
|
||||||
.onChange(of: appState.selectedLanguage) { newLang in
|
.onChange(of: appState.selectedLanguage) { newLang in
|
||||||
|
guard let currentSightId = appState.sightId else { return }
|
||||||
viewModel.setLanguage(newLang)
|
viewModel.setLanguage(newLang)
|
||||||
Task {
|
Task {
|
||||||
await viewModel.loadInitialData(sightId: sightId)
|
await viewModel.loadInitialData(sightId: currentSightId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.blockStyle(cornerRadius: 25)
|
.blockStyle(cornerRadius: 25)
|
||||||
@ -98,34 +99,49 @@ struct SightView: View {
|
|||||||
case .loading:
|
case .loading:
|
||||||
ZStack {
|
ZStack {
|
||||||
Color.gray.opacity(0.3)
|
Color.gray.opacity(0.3)
|
||||||
ProgressView()
|
if let progress = viewModel.downloadProgress {
|
||||||
.progressViewStyle(.circular)
|
VStack {
|
||||||
|
ProgressView(value: progress)
|
||||||
|
.progressViewStyle(.linear)
|
||||||
.tint(.white)
|
.tint(.white)
|
||||||
|
Text("\(Int(progress * 100))%")
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.font(.caption)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.padding()
|
||||||
.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 {
|
} else {
|
||||||
ZStack {
|
|
||||||
Color.gray.opacity(0.3)
|
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.progressViewStyle(.circular)
|
.progressViewStyle(.circular)
|
||||||
.tint(.white)
|
.tint(.white)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
.frame(maxWidth: .infinity, minHeight: 200)
|
||||||
.cornerRadius(24, corners: [.topLeft, .topRight])
|
.cornerRadius(24, corners: [.topLeft, .topRight])
|
||||||
.clipped()
|
.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):
|
case .video(let player):
|
||||||
VideoPlayer(player: player)
|
VideoPlayer(player: player)
|
||||||
.aspectRatio(16/9, contentMode: .fit)
|
.aspectRatio(16/9, contentMode: .fit)
|
||||||
.cornerRadius(24, corners: [.topLeft, .topRight])
|
.cornerRadius(24, corners: [.topLeft, .topRight])
|
||||||
.clipped()
|
.clipped()
|
||||||
|
|
||||||
case .error:
|
case .error:
|
||||||
Image(systemName: "photo")
|
Image(systemName: "photo")
|
||||||
.resizable()
|
.resizable()
|
||||||
|
@ -1,14 +1,20 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import AVKit
|
import AVKit
|
||||||
import Combine
|
import Combine
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - ViewModel
|
||||||
@MainActor
|
@MainActor
|
||||||
class SightViewModel: ObservableObject {
|
class SightViewModel: ObservableObject {
|
||||||
|
private let fileDownloader = FileDownloader()
|
||||||
|
|
||||||
@Published var sightName: String = "Загрузка..."
|
@Published var sightName: String = "Загрузка..."
|
||||||
@Published var allArticles: [Article] = []
|
@Published var allArticles: [Article] = []
|
||||||
@Published var selectedArticle: Article?
|
@Published var selectedArticle: Article?
|
||||||
@Published var articleHeading: String = ""
|
@Published var articleHeading: String = ""
|
||||||
@Published var articleBody: String = ""
|
@Published var articleBody: String = ""
|
||||||
@Published var mediaState: MediaState = .loading
|
@Published var mediaState: MediaState = .loading
|
||||||
|
@Published var downloadProgress: Double? = nil // прогресс загрузки
|
||||||
|
|
||||||
private var sightModel: SightModel?
|
private var sightModel: SightModel?
|
||||||
private var selectedLanguage: String = "ru" // по умолчанию
|
private var selectedLanguage: String = "ru" // по умолчанию
|
||||||
@ -25,6 +31,9 @@ class SightViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadInitialData(sightId: Int) async {
|
func loadInitialData(sightId: Int) async {
|
||||||
|
// Вот это исправление. Сбрасываем выбранную статью перед загрузкой новых данных.
|
||||||
|
self.selectedArticle = nil
|
||||||
|
|
||||||
do {
|
do {
|
||||||
async let sightModelTask = fetchJSON(
|
async let sightModelTask = fetchJSON(
|
||||||
from: "https://white-nights.krbl.ru/services/content/sight/\(sightId)?lang=\(selectedLanguage)",
|
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 {
|
private func updateMedia(for article: Article) async {
|
||||||
self.mediaState = .loading
|
self.mediaState = .loading
|
||||||
|
self.downloadProgress = nil
|
||||||
|
|
||||||
if article.isReviewArticle == true {
|
|
||||||
guard let sight = sightModel else {
|
|
||||||
self.mediaState = .error
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Загрузка медиа для статьи
|
||||||
let mediaItems = try await fetchJSON(
|
let mediaItems = try await fetchJSON(
|
||||||
from: "https://white-nights.krbl.ru/services/content/article/\(article.id)/media?lang=\(selectedLanguage)",
|
from: "https://white-nights.krbl.ru/services/content/article/\(article.id)/media?lang=\(selectedLanguage)",
|
||||||
type: [ArticleMedia].self
|
type: [ArticleMedia].self
|
||||||
)
|
)
|
||||||
|
|
||||||
if let firstMedia = mediaItems.first,
|
if let firstMedia = mediaItems.first,
|
||||||
let url = URL(string: "https://white-nights.krbl.ru/services/content/media/\(firstMedia.id)/download?lang=\(selectedLanguage)") {
|
let url = URL(string: "https://white-nights.krbl.ru/services/content/media/\(firstMedia.id)/download?lang=\(selectedLanguage)") {
|
||||||
self.mediaState = .image(url)
|
|
||||||
|
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 {
|
} else {
|
||||||
self.mediaState = .error
|
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<T: Decodable>(from urlString: String, type: T.Type) async throws -> T {
|
private func fetchJSON<T: Decodable>(from urlString: String, type: T.Type) async throws -> T {
|
||||||
guard let url = URL(string: urlString) else {
|
guard let url = URL(string: urlString) else {
|
||||||
throw URLError(.badURL)
|
throw URLError(.badURL)
|
||||||
|
Reference in New Issue
Block a user