Files
JetsonMediaIOS/Jetson Media/ui/PhotoView.swift
2025-08-17 22:08:25 +08:00

317 lines
13 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import SwiftUI
import Combine
import Foundation
import UIKit
struct PhotoView: View {
let albumId: String
@StateObject private var viewModel: PhotoViewModel
@State private var currentImageIndex: Int = 0 //
@State private var progress: Double = 0 // 0-1
@State private var totalImages: Int = 0 //
@State private var isFullscreen = false //
@Environment(\.presentationMode) var presentationMode //
@EnvironmentObject var tabState: TabBarState //
//
private let saveTimer = Timer.publish(every: 2, on: .main, in: .common)
private var timerCancellable: AnyCancellable? //
init(albumId: String) {
self.albumId = albumId
self._viewModel = StateObject(wrappedValue: PhotoViewModel(albumId: albumId))
self.timerCancellable = saveTimer.autoconnect().sink(receiveValue: { _ in })
}
var body: some View {
ZStack(alignment: .bottom) {
//
Color(isFullscreen ? .black : .systemBackground)
.edgesIgnoringSafeArea(.all)
if viewModel.isLoading {
ProgressView("加载中...")
.foregroundColor(isFullscreen ? .white : .black)
} else if let error = viewModel.error {
VStack(spacing: 16) {
Text("加载失败")
.font(.headline)
.foregroundColor(isFullscreen ? .white : .black)
Text(error.localizedDescription)
.font(.subheadline)
.foregroundColor(isFullscreen ? .gray : .secondary)
Button("重试") { viewModel.loadAlbumData() }
.foregroundColor(.blue)
.padding()
.background(Color.blue.opacity(0.1))
.cornerRadius(8)
}
} else if let album = viewModel.album {
ScrollView {
ScrollViewReader { proxy in
//
if !isFullscreen {
VStack(alignment: .leading, spacing: 8) {
Text(album.name)
.font(.headline)
Text("作者: \(album.authors.joined(separator: ", "))")
.font(.subheadline)
.foregroundColor(isFullscreen ? .gray : .secondary)
Text("标签: \(album.tags.joined(separator: ", "))")
.font(.caption)
.foregroundColor(isFullscreen ? .gray : .secondary)
//
Button(action: toggleFavorite) {
Image(systemName: viewModel.isFavorite ? "heart.fill" : "heart")
.foregroundColor(viewModel.isFavorite ? .red : .gray)
Text(viewModel.isFavorite ? "取消收藏" : "收藏")
.foregroundColor(isFullscreen ? .white : .black)
}
.padding(.vertical, 8)
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(isFullscreen ? Color.black : Color(.systemBackground))
.id("header")
}
//
LazyVStack(spacing: 0) {
ForEach(Array(album.image_urls.enumerated()), id: \.offset) { index, url in
AsyncImageView(
url: url,
nums: album.nums,
index: index
)
.frame(maxWidth: .infinity)
.id(index)
//
.onAppear {
//
if index > currentImageIndex || abs(index - currentImageIndex) > 3 {
currentImageIndex = index
updateProgressRatio()
}
}
}
}
.onAppear {
totalImages = album.image_urls.count
//
let savedIndex = ReadingProgressManager.shared.getProgress(albumId: albumId)
currentImageIndex = clamp(value: savedIndex, min: 0, max: totalImages - 1)
updateProgressRatio()
// PhotoViewonAppear
UserDefaults.standard.addHistory(albumId: albumId)
//
print("上次阅读位置: \(savedIndex)")
if totalImages > 0, currentImageIndex > 0 {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
withAnimation {
proxy.scrollTo(currentImageIndex, anchor: .top)
}
}
}
}
//
.onChange(of: progress) { newValue in
let targetIndex = Int(newValue * Double(max(0, totalImages - 1)))
let clampedIndex = clamp(value: targetIndex, min: 0, max: totalImages - 1)
if clampedIndex != currentImageIndex {
currentImageIndex = clampedIndex
withAnimation {
proxy.scrollTo(currentImageIndex, anchor: .top)
}
}
}
}
}
.padding(.top, isFullscreen ? 0 : 60)
.edgesIgnoringSafeArea(isFullscreen ? .all : .vertical)
.onTapGesture {
//
withAnimation {
tabState.toggleTabBar()
isFullscreen.toggle()
adjustSystemUI()
}
}
//
.onReceive(saveTimer) { _ in
guard totalImages > 0 else { return }
ReadingProgressManager.shared.saveProgress(albumId: albumId, imageIndex: currentImageIndex)
}
.onDisappear {
ReadingProgressManager.shared.saveProgress(albumId: albumId, imageIndex: currentImageIndex)
timerCancellable?.cancel() //
// UI
UIApplication.shared.isStatusBarHidden = false
}
//
if isFullscreen {
//
VStack {
HStack {
//
Button(action: { presentationMode.wrappedValue.dismiss() }) {
Image(systemName: "chevron.left")
.foregroundColor(.white)
.padding()
.background(Color.black.opacity(0.5))
.cornerRadius(8)
}
Spacer()
}
.padding()
Spacer()
}
}
if totalImages > 0 && !isFullscreen {
CustomSlider(
value: $progress,
trackHeight: 20, // 20px
tintColor: .blue
)
.background(Color(.systemBackground).opacity(0.9))
.cornerRadius(8) //
.shadow(radius: 5)
.padding(.horizontal, 30) //
.padding(.bottom, tabState.isTabBarHidden ? 30 : 60) //
.padding(.top, 10) //
}
}
}
.alert(isPresented: $viewModel.showAlert) {
Alert(
title: Text(viewModel.alertTitle),
message: Text(viewModel.alertMessage),
dismissButton: .default(Text("确定"))
)
}
.navigationBarHidden(isFullscreen)
.statusBarHidden(isFullscreen)
.onAppear {
// UI
adjustSystemUI()
}
.onDisappear {
//
tabState.showTabBar()
}
}
// UI
private func adjustSystemUI() {
UIApplication.shared.isStatusBarHidden = isFullscreen
//
UIApplication.shared.windows.first?.rootViewController?.setNeedsStatusBarAppearanceUpdate()
}
//
private func updateProgressRatio() {
guard totalImages > 0 else {
progress = 0
return
}
let safeTotal = max(1, totalImages - 1) // 0
progress = Double(currentImageIndex) / Double(safeTotal)
}
//
private func toggleFavorite() {
FavoriteManager.shared.toggleFavorite(albumId: albumId)
viewModel.isFavorite = FavoriteManager.shared.isFavorite(albumId: albumId)
}
//
private func clamp(value: Int, min: Int, max: Int) -> Int {
return Swift.max(min, Swift.min(value, max))
}
}
//
struct ScrollOffsetKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
class PhotoViewModel: ObservableObject {
let albumId: String
@Published var album: Album?
@Published var isLoading = false
@Published var error: Error?
@Published var isFavorite: Bool = false //
@Published var showAlert = false
@Published var alertTitle = ""
@Published var alertMessage = ""
private var cancellables = Set<AnyCancellable>()
init(albumId: String) {
self.albumId = albumId
checkIfFavorite()
loadAlbumData()
}
//
private func checkIfFavorite() {
isFavorite = FavoriteManager.shared.isFavorite(albumId: albumId)
}
func loadAlbumData() {
isLoading = true
error = nil
album = nil //
let urlString = Config.shared.apiURL(path: "album/\(albumId)/")
guard let url = URL(string: urlString) else {
let error = NSError(domain: "PhotoViewModel", code: 0, userInfo: [NSLocalizedDescriptionKey: "无效的URL"])
handleError(error: error)
return
}
print("正在加载相册数据: \(urlString)")
URLSession.shared.dataTaskPublisher(for: url)
.map(\.data)
.decode(type: Album.self, decoder: JSONDecoder())
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { [weak self] completion in
self?.isLoading = false
if case .failure(let error) = completion {
self?.handleError(error: error)
}
}, receiveValue: { [weak self] album in
self?.isLoading = false
self?.album = album
print("成功加载相册: \(album.name), 包含 \(album.image_urls.count) 张图片")
})
.store(in: &cancellables)
}
//
private func handleError(error: Error) {
self.error = error
self.alertTitle = "加载失败"
self.alertMessage = error.localizedDescription
self.showAlert = true
self.isLoading = false
print("相册加载失败: \(error.localizedDescription)")
}
func showAlert(title: String, message: String) {
alertTitle = title
alertMessage = message
showAlert = true
}
}