Files
yattee/Yattee/Views/Navigation/SidebarChannelIcon.swift
2026-02-08 18:33:56 +01:00

154 lines
3.9 KiB
Swift

//
// SidebarChannelIcon.swift
// Yattee
//
// Pre-scaled channel icon for TabSection labels.
// TabSection labels don't support frame/resizable modifiers,
// so we pre-scale the image at the platform image level.
//
import SwiftUI
import Nuke
#if os(macOS)
import AppKit
#else
import UIKit
#endif
/// A channel icon that pre-scales the image for use in TabSection labels.
/// Standard SwiftUI frame/resizable modifiers don't work in Tab labels.
struct SidebarChannelIcon: View {
let url: URL?
let name: String
var authHeader: String?
private let size: CGFloat = 22
@State private var platformImage: PlatformImage?
@State private var isLoading = false
var body: some View {
Group {
if let platformImage, let scaledImage = scaledImage(from: platformImage) {
scaledImage
} else {
// Placeholder - use SF Symbol which scales correctly
Image(systemName: "person.circle.fill")
.symbolRenderingMode(.hierarchical)
}
}
.onAppear {
loadImage()
}
.onChange(of: url) { _, _ in
platformImage = nil
loadImage()
}
}
@ViewBuilder
private func scaledImage(from image: PlatformImage) -> Image? {
#if os(macOS)
if let scaled = image.scaledCircular(to: NSSize(width: size, height: size)) {
Image(nsImage: scaled)
}
#else
if let scaled = image.scaledCircular(to: CGSize(width: size, height: size)) {
Image(uiImage: scaled)
}
#endif
}
private func loadImage() {
guard let request = AvatarURLBuilder.imageRequest(url: url, authHeader: authHeader), !isLoading else { return }
// Check memory cache first (synchronous)
if let cached = ImagePipeline.shared.cache.cachedImage(for: request)?.image {
platformImage = cached
return
}
isLoading = true
Task {
do {
let image = try await ImagePipeline.shared.image(for: request)
await MainActor.run {
platformImage = image
isLoading = false
}
} catch {
await MainActor.run {
isLoading = false
}
}
}
}
}
// MARK: - Platform Image Scaling
#if os(macOS)
private extension NSImage {
func scaledCircular(to targetSize: NSSize) -> NSImage? {
let newImage = NSImage(size: targetSize)
newImage.lockFocus()
// Create circular clipping path
let path = NSBezierPath(ovalIn: NSRect(origin: .zero, size: targetSize))
path.addClip()
NSGraphicsContext.current?.imageInterpolation = .high
draw(
in: NSRect(origin: .zero, size: targetSize),
from: NSRect(origin: .zero, size: size),
operation: .copy,
fraction: 1.0
)
newImage.unlockFocus()
return newImage
}
}
#else
private extension UIImage {
func scaledCircular(to targetSize: CGSize) -> UIImage? {
let renderer = UIGraphicsImageRenderer(size: targetSize)
return renderer.image { context in
// Create circular clipping path
let rect = CGRect(origin: .zero, size: targetSize)
context.cgContext.addEllipse(in: rect)
context.cgContext.clip()
draw(in: rect)
}
}
}
#endif
// MARK: - Preview
#if !os(tvOS)
#Preview {
List {
Label {
Text("Apple")
} icon: {
SidebarChannelIcon(url: nil, name: "Apple")
}
Label {
Text("Test Channel")
} icon: {
SidebarChannelIcon(
url: URL(string: "https://example.com/avatar.jpg"),
name: "Test"
)
}
}
.listStyle(.sidebar)
}
#endif