1import SwiftUI23struct WallpaperItem: View {4 let wallpaper: Wallpaper5 let action: () -> Void6 @ObservedObject var favoritesManager = FavoritesManager.shared7 @State private var retryID = UUID()8 @State private var attempt = 09 @State private var isHovering = false1011 12 13 let maxAttempts: Int14 let retryDelayNanoseconds: UInt641516 init(17 wallpaper: Wallpaper, action: @escaping () -> Void, attempt: Int = 0,18 maxAttempts: Int = 4, retryDelayNanoseconds: UInt64 = 800_000_00019 ) {20 self.wallpaper = wallpaper21 self.action = action22 self.maxAttempts = maxAttempts23 self.retryDelayNanoseconds = retryDelayNanoseconds24 _attempt = State(initialValue: attempt)25 }2627 var body: some View {28 Button(action: action) {29 AsyncImage(url: URL(string: wallpaper.thumbs.small)) { thumbnail(for: $0) }30 .id(retryID)31 .frame(maxWidth: .infinity)32 .frame(height: 100)33 .aspectRatio(contentMode: .fill)34 .clipped()35 .cornerRadius(8)36 .overlay(37 RoundedRectangle(cornerRadius: 8)38 .stroke(isHovering ? Color.accentColor : Color.clear, lineWidth: 2)39 )40 .shadow(radius: isHovering ? 4 : 0)41 .overlay(42 43 Group {44 if isHovering || favoritesManager.isFavorite(wallpaper) {45 Button(action: toggleFavorite) {46 Image(systemName: favoritesManager.isFavorite(wallpaper) ? "heart.fill" : "heart")47 .foregroundColor(favoritesManager.isFavorite(wallpaper) ? .red : .white)48 .shadow(radius: 2)49 .font(.system(size: 16, weight: .bold))50 .padding(6)51 .background(Color.black.opacity(0.3))52 .clipShape(Circle())53 }54 .buttonStyle(.plain)55 .padding(6)56 .transition(.opacity)57 }58 },59 alignment: .topTrailing60 )61 .overlay(alignment: .topLeading) { resolutionBadge }62 .scaleEffect(isHovering ? 1.02 : 1.0)63 .animation(.spring(response: 0.3), value: isHovering)64 }65 .buttonStyle(.plain)66 .onHover { isHovering = $0 }67 .help("\(wallpaper.resolution)\n\(wallpaper.purity)")68 }6970 71 72 73 @ViewBuilder74 private var resolutionBadge: some View {75 let label = WallpaperItem.resolutionBadge(for: wallpaper.resolution)76 if !label.isEmpty {77 Text(label)78 .font(.caption2.weight(.semibold))79 .foregroundColor(.white)80 .padding(.horizontal, 6)81 .padding(.vertical, 2)82 .background(Color.black.opacity(0.55))83 .clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous))84 .padding(6)85 .accessibilityLabel("Resolution \(label)")86 }87 }8889 func thumbnail(for phase: AsyncImagePhase) -> AnyView {90 if case .success(let image) = phase {91 return AnyView(image.resizable().aspectRatio(contentMode: .fill))92 }93 if case .failure = phase {94 return AnyView(95 Rectangle().fill(Color.gray.opacity(0.2)).overlay(96 attempt < maxAttempts97 ? AnyView(ProgressView())98 : AnyView(Image(systemName: "photo").foregroundColor(.gray.opacity(0.6)))99 ).task { await retryAfterFailure() })100 }101 return AnyView(Rectangle().fill(Color.gray.opacity(0.2)).overlay(ProgressView()))102 }103104 105 106 107 108 109 110 111 112 113 114 115 func backoffDelay(forAttempt attempt: Int) -> UInt64 {116 let safe = max(0, attempt)117 let multiplier = UInt64(1) << safe118 return multiplier &* retryDelayNanoseconds119 }120121 func retryAfterFailure() async {122 guard attempt < maxAttempts else { return }123 if Task.isCancelled { return }124125 126 127 let multiplier = UInt64(1) << attempt128 let delay = multiplier &* retryDelayNanoseconds129 do {130 try await Task.sleep(nanoseconds: delay)131 } catch {132 return133 }134 guard !Task.isCancelled else { return }135 attempt += 1136 retryID = UUID()137 }138139 140 var exposedAttempt: Int { attempt }141142 143 144 145 static func resolutionBadge(for resolution: String) -> String {146 let height = resolution.split(separator: "x").last.flatMap { Int($0) } ?? 0147 switch height {148 case 4320...: return "8K"149 case 3000...: return "4K+"150 case 2160..<3000: return "4K"151 case 1440..<2160: return "2K"152 case 1080..<1440: return "1080"153 case 720..<1080: return "720"154 case 1...: return "\(height)"155 default: return resolution156 }157 }158159 func toggleFavorite() { favoritesManager.toggleFavorite(wallpaper) }160}