Files
tvbox-Swift/tvbox/Utils/Extensions.swift
T
2026-03-01 20:34:43 +08:00

600 lines
20 KiB
Swift

import SwiftUI
import ImageIO
#if os(macOS)
import AppKit
#endif
/// 配置键定义 - 对应 Android 版 HawkConfig.java
struct HawkConfig {
static let API_URL = "api_url"
static let HOME_API = "home_api"
static let HOME_REC = "home_rec"
static let PLAY_TYPE = "play_type"
static let PLAY_TYPE_VOD = "play_type_vod"
static let PLAY_TYPE_LIVE = "play_type_live"
static let DOH_URL = "doh_url"
static let SEARCH_VIEW = "search_view"
static let LIVE_API_URL = "live_api_url"
static let PARSE_WEBVIEW = "parse_webview"
static let IJK_CODEC = "ijk_codec"
static let RENDER_TYPE = "render_type"
static let PLAY_SCALE = "play_scale"
static let PLAY_SPEED = "play_speed"
static let PLAY_VOLUME = "play_volume"
static let PLAY_DECODE_MODE = "play_decode_mode"
static let PLAY_VLC_BUFFER_MODE = "play_vlc_buffer_mode"
static let PLAY_TIME_STEP = "play_time_step"
static let HOME_REC_STYLE = "home_rec_style"
static let HISTORY_NUM = "history_num"
static let SEARCH_HISTORY = "search_history"
}
/// 通用 Swift 扩展
extension String {
/// 移除 HTML 标签
var stripHTML: String {
replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression)
.trimmingCharacters(in: .whitespacesAndNewlines)
}
/// 是否为有效 URL
var isValidURL: Bool {
guard let url = URL(string: self) else { return false }
return url.scheme == "http" || url.scheme == "https"
}
}
extension Date {
/// 格式化日期显示
var displayString: String {
let formatter = DateFormatter()
formatter.dateFormat = "MM/dd HH:mm"
formatter.locale = Locale(identifier: "zh_CN")
return formatter.string(from: self)
}
var timeString: String {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss"
return formatter.string(from: self)
}
/// 首页日期显示
var homeDateString: String {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy/MM/dd EEEE HH:mm:ss"
formatter.locale = Locale(identifier: "zh_CN")
return formatter.string(from: self)
}
}
extension Int {
/// 播放时长格式化
var durationString: String {
let hours = self / 3600
let minutes = (self % 3600) / 60
let seconds = self % 60
if hours > 0 {
return String(format: "%d:%02d:%02d", hours, minutes, seconds)
}
return String(format: "%02d:%02d", minutes, seconds)
}
}
extension Double {
/// 播放时长格式化
var durationString: String {
Int(self).durationString
}
}
// MARK: - Design System (Merged from DesignSystem.swift)
struct AppTheme {
static let primaryGradient = LinearGradient(
colors: [
Color(hex: "1a1a2e"),
Color(hex: "16213e"),
Color(hex: "0f3460")
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
static let accentGradient = LinearGradient(
colors: [.orange, .red],
startPoint: .leading,
endPoint: .trailing
)
static let glassBackgroud = Color.white.opacity(0.1)
static let cardRadius: CGFloat = 16
static let glassRadius: CGFloat = 20
}
extension Color {
init(hex: String) {
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
var int: UInt64 = 0
Scanner(string: hex).scanHexInt64(&int)
let a, r, g, b: UInt64
switch hex.count {
case 3: // RGB (12-bit)
(a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
case 6: // RGB (24-bit)
(a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
case 8: // ARGB (32-bit)
(a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
default:
(a, r, g, b) = (1, 1, 1, 0)
}
self.init(
.sRGB,
red: Double(r) / 255,
green: Double(g) / 255,
blue: Double(b) / 255,
opacity: Double(a) / 255
)
}
}
// 玻璃拟态基础组件
struct GlassBackground: ViewModifier {
var cornerRadius: CGFloat = AppTheme.glassRadius
func body(content: Content) -> some View {
content
#if os(iOS)
.background(.ultraThinMaterial)
#else
.background(VisualEffectView().opacity(0.85))
#endif
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
.overlay(
RoundedRectangle(cornerRadius: cornerRadius)
.stroke(Color.white.opacity(0.15), lineWidth: 0.5)
)
.shadow(color: Color.black.opacity(0.3), radius: 10, x: 0, y: 5)
}
}
#if os(macOS)
struct VisualEffectView: NSViewRepresentable {
func makeNSView(context: Context) -> NSVisualEffectView {
let view = NSVisualEffectView()
view.blendingMode = .withinWindow
view.state = .active
view.material = .underWindowBackground
return view
}
func updateNSView(_ nsView: NSVisualEffectView, context: Context) {}
}
#endif
extension View {
func glassCard(cornerRadius: CGFloat = AppTheme.glassRadius) -> some View {
self.modifier(GlassBackground(cornerRadius: cornerRadius))
}
}
// MARK: - Image URL
extension URL {
/// 统一解析海报 URL,处理协议缺失和已知防盗链域名
static func posterURL(from raw: String) -> URL? {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
var absolute = trimmed
if absolute.hasPrefix("//") {
absolute = "https:\(absolute)"
}
guard let originalURL = URL(string: absolute),
let host = originalURL.host?.lowercased() else {
return nil
}
// 部分源图片域名有防盗链,直接访问会 403,走代理兜底
if host == "img.picbf.com" {
var components = URLComponents(string: "https://images.weserv.nl/")
components?.queryItems = [URLQueryItem(name: "url", value: absolute)]
return components?.url
}
return originalURL
}
}
// MARK: - Cached Async Image
/// 自定义异步图片加载组件
/// AsyncImage 不支持自定义 header,许多图片服务器需要 Referer/User-Agent 才能正常返回图片
/// 此组件通过自定义 URLSession 发起请求,解决海报永远加载不出来的问题
struct CachedAsyncImage<Content: View, Placeholder: View>: View {
let url: URL?
@ViewBuilder let content: (Image) -> Content
@ViewBuilder let placeholder: () -> Placeholder
@State private var loadedImage: PlatformImage?
init(
url: URL?,
@ViewBuilder content: @escaping (Image) -> Content,
@ViewBuilder placeholder: @escaping () -> Placeholder
) {
self.url = url
self.content = content
self.placeholder = placeholder
}
var body: some View {
Group {
if let image = loadedImage {
#if os(macOS)
content(Image(nsImage: image))
#else
content(Image(uiImage: image))
#endif
} else {
placeholder()
}
}
.task(id: url) {
await loadImage()
}
.onDisappear {
loadedImage = nil
}
}
private func loadImage() async {
guard let url = url else {
loadedImage = nil
return
}
if let cached = ImageCache.shared.get(for: url) {
loadedImage = cached
return
}
do {
let image = try await ImageLoader.shared.load(url: url)
guard !Task.isCancelled else { return }
ImageCache.shared.set(image, for: url)
loadedImage = image
} catch {
guard !Task.isCancelled else { return }
loadedImage = nil
}
}
}
// MARK: - Platform Image Type
#if os(macOS)
typealias PlatformImage = NSImage
#else
import UIKit
typealias PlatformImage = UIImage
#endif
// MARK: - Image Loader
/// 使用自定义 URLSession 加载图片,支持自定义请求头
@MainActor
final class ImageLoader {
static let shared = ImageLoader()
private let session: URLSession
private let urlCache: URLCache
private let thumbnailMaxPixelSize: CGFloat = 420
private init() {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 15
config.timeoutIntervalForResource = 20
config.httpMaximumConnectionsPerHost = 6
let cache = URLCache(
memoryCapacity: 12 * 1024 * 1024,
diskCapacity: 120 * 1024 * 1024,
diskPath: "image_cache"
)
config.urlCache = cache
config.requestCachePolicy = .useProtocolCachePolicy
self.urlCache = cache
self.session = URLSession(configuration: config)
}
func load(url: URL) async throws -> PlatformImage {
var request = URLRequest(url: url)
request.httpMethod = "GET"
// 设置常见的浏览器 User-Agent,避免部分服务器拒绝请求
request.setValue(
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
forHTTPHeaderField: "User-Agent"
)
// 部分图片服务器检查 Referer
if let host = url.host {
request.setValue("https://\(host)/", forHTTPHeaderField: "Referer")
}
let (data, response) = try await session.data(for: request)
// 检查 HTTP 状态码
if let httpResponse = response as? HTTPURLResponse,
!(200...299).contains(httpResponse.statusCode) {
throw ImageLoadError.httpError(httpResponse.statusCode)
}
let maxPixelSize = thumbnailMaxPixelSize
guard let image = Self.decodeImage(data: data, maxPixelSize: maxPixelSize) else {
throw ImageLoadError.invalidData
}
return image
}
var cacheUsage: (memory: Int, disk: Int) {
(urlCache.currentMemoryUsage, urlCache.currentDiskUsage)
}
func clearCache() {
urlCache.removeAllCachedResponses()
}
private static func decodeImage(data: Data, maxPixelSize: CGFloat) -> PlatformImage? {
guard let source = CGImageSourceCreateWithData(data as CFData, nil) else {
return PlatformImage(data: data)
}
let options: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: max(1, Int(maxPixelSize.rounded(.up))),
kCGImageSourceShouldCacheImmediately: true
]
if let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) {
#if os(macOS)
return NSImage(cgImage: cgImage, size: NSSize(width: cgImage.width, height: cgImage.height))
#else
return UIImage(cgImage: cgImage)
#endif
}
return PlatformImage(data: data)
}
}
enum ImageLoadError: Error {
case httpError(Int)
case invalidData
}
// MARK: - Image Memory Cache
/// 简单的内存缓存,避免重复下载
@MainActor
final class ImageCache {
static let shared = ImageCache()
private let cache = NSCache<NSURL, PlatformImage>()
private init() {
cache.countLimit = 120
cache.totalCostLimit = 40 * 1024 * 1024
}
func get(for url: URL) -> PlatformImage? {
cache.object(forKey: url as NSURL)
}
func set(_ image: PlatformImage, for url: URL) {
cache.setObject(image, forKey: url as NSURL, cost: image.memoryCost)
}
func clear() {
cache.removeAllObjects()
}
}
#if os(macOS)
extension NSImage {
var memoryCost: Int {
guard let cgImage = cgImage(forProposedRect: nil, context: nil, hints: nil) else { return 1 }
return cgImage.bytesPerRow * cgImage.height
}
}
#else
extension UIImage {
var memoryCost: Int {
if let cgImage {
return cgImage.bytesPerRow * cgImage.height
}
let pixelWidth = size.width * scale
let pixelHeight = size.height * scale
return max(1, Int(pixelWidth * pixelHeight * 4))
}
}
#endif
// MARK: - Selection Modal
/// 通用选择对话框 - 重新设计的玻璃拟态样式
struct SelectionModal<Item: Identifiable & Equatable>: View {
let title: String
let icon: String // SF Symbol 名称
let items: [Item]
let selectedItem: Item?
let itemTitle: (Item) -> String
let onSelect: (Item) -> Void
let onCancel: () -> Void
// 动画状态
@State private var animateIn = false
@State private var hoverItemId: Item.ID? = nil
var body: some View {
ZStack {
// 背景遮罩
Color.black.opacity(0.4)
.contentShape(Rectangle())
.onTapGesture {
withAnimation(.easeInOut(duration: 0.2)) {
animateIn = false
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
onCancel()
}
}
.ignoresSafeArea()
VStack(spacing: 0) {
// 顶部图标 & 标题
VStack(spacing: 16) {
ZStack {
// 动态光晕背景
Circle()
.fill(LinearGradient(colors: [.orange, .red], startPoint: .topLeading, endPoint: .bottomTrailing))
.frame(width: 56, height: 56)
.blur(radius: 20)
.opacity(0.4)
// 主图标
Image(systemName: icon)
.font(.system(size: 26, weight: .bold))
.foregroundColor(.white)
.frame(width: 64, height: 64)
.background(
RoundedRectangle(cornerRadius: 18)
.fill(Color.white.opacity(0.12))
.overlay(
RoundedRectangle(cornerRadius: 18)
.stroke(Color.white.opacity(0.2), lineWidth: 0.5)
)
)
.shadow(color: Color.black.opacity(0.25), radius: 8, x: 0, y: 4)
}
.padding(.top, 28)
Text(title)
.font(.system(size: 18, weight: .bold))
.foregroundColor(.white)
}
.padding(.bottom, 24)
// 选项列表
ScrollView(showsIndicators: false) {
VStack(spacing: 8) {
ForEach(items) { item in
Button {
withAnimation(.easeInOut(duration: 0.1)) {
onSelect(item)
}
} label: {
HStack(spacing: 16) {
Text(itemTitle(item))
.font(.system(size: 15, weight: item == selectedItem ? .semibold : .medium))
.foregroundColor(item == selectedItem ? .white : .white.opacity(0.7))
Spacer()
if item == selectedItem {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.orange)
.font(.system(size: 18))
.shadow(color: Color.orange.opacity(0.4), radius: 4)
} else {
Circle()
.strokeBorder(Color.white.opacity(0.15), lineWidth: 1.5)
.frame(width: 18, height: 18)
}
}
.padding(.horizontal, 18)
.padding(.vertical, 14)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(selectionBackground(item: item))
)
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(selectionStroke(item: item), lineWidth: 1)
)
}
.buttonStyle(.plain)
.onHover { isHovering in
withAnimation(.easeInOut(duration: 0.2)) {
hoverItemId = isHovering ? item.id : nil
}
}
.animation(.spring(response: 0.2, dampingFraction: 0.7), value: hoverItemId)
}
}
.padding(.horizontal, 24)
.padding(.bottom, 16)
}
.frame(maxHeight: 320)
Divider()
.background(Color.white.opacity(0.1))
.padding(.horizontal, 24)
// 取消按钮
Button {
withAnimation(.easeInOut(duration: 0.2)) {
animateIn = false
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
onCancel()
}
} label: {
Text("取消")
.font(.system(size: 16, weight: .semibold))
.foregroundColor(.white.opacity(0.5))
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
}
.buttonStyle(.plain)
.contentShape(Rectangle())
}
.frame(width: 300)
.glassCard(cornerRadius: 28)
.scaleEffect(animateIn ? 1 : 0.9)
.opacity(animateIn ? 1 : 0)
.shadow(color: Color.black.opacity(0.5), radius: 40, x: 0, y: 20)
}
.onAppear {
withAnimation(.spring(response: 0.4, dampingFraction: 0.75)) {
animateIn = true
}
}
}
private func selectionBackground(item: Item) -> Color {
if item == selectedItem {
return Color.orange.opacity(0.2)
} else if item.id == hoverItemId {
return Color.white.opacity(0.1)
} else {
return Color.white.opacity(0.04)
}
}
private func selectionStroke(item: Item) -> Color {
if item == selectedItem {
return Color.orange.opacity(0.6)
} else if item.id == hoverItemId {
return Color.white.opacity(0.2)
} else {
return Color.clear
}
}
}
// 模拟扩展
extension Int: @retroactive Identifiable {
public var id: Int { self }
}