tv-anarchy/Sources/TVAnarchyCore/Display/WinampSkinLoader.swift
Natalie 4a2ceb9781 feat(offline): inline star-to-keep and trash-to-cull on cache rows
Surface the existing pin (keep-from-cull) and per-file delete actions as
visible inline buttons on each offline cache row instead of context-menu-only:
a star toggles protection from auto-cull (and restore-if-missing), a trash
culls that file early. Aligns wording/icons to the star metaphor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 00:12:41 -04:00

158 lines
No EOL
6.9 KiB
Swift

import Foundation
import CryptoKit
public struct WinampRGB: Sendable, Equatable {
public let r: Int
public let g: Int
public let b: Int
}
public struct WinampPleditStyle: Sendable, Equatable {
public var normal: WinampRGB?
public var current: WinampRGB?
public var normalBG: WinampRGB?
public var selectedBG: WinampRGB?
public var font: String?
}
/// Installed `.wsz` skin extracted cache + parsed config text files.
public struct WinampSkinPackage: Sendable, Equatable, Identifiable {
public let id: String
public let displayName: String
public let cacheDirectory: URL
public let visColors: [WinampRGB]
public let pledit: WinampPleditStyle
public let availableSheets: Set<String>
public var isPlayerCompatible: Bool {
availableSheets.contains("CBUTTONS")
&& availableSheets.contains("POSBAR")
&& (availableSheets.contains("MAIN") || availableSheets.contains("TITLEBAR"))
}
}
public enum WinampSkinLoader {
private static let requiredSheets = ["CBUTTONS", "POSBAR", "MAIN"]
public static func skinsRoot() -> URL {
let base: URL
if let dir = ProcessInfo.processInfo.environment["TV_ANARCHY_STATE_DIR"], !dir.isEmpty {
base = URL(fileURLWithPath: dir, isDirectory: true)
} else {
base = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".local/state/tv-anarchy", isDirectory: true)
}
return base.appendingPathComponent("skins", isDirectory: true)
}
/// Extract a `.wsz` (zip) skin into the cache and return metadata.
public static func install(wszURL: URL, displayName: String? = nil) throws -> WinampSkinPackage {
let data = try Data(contentsOf: wszURL)
let id = SHA256.hash(data: data).compactMap { String(format: "%02x", $0) }.joined()
let dest = skinsRoot().appendingPathComponent(id, isDirectory: true)
try FileManager.default.createDirectory(at: skinsRoot(), withIntermediateDirectories: true)
if FileManager.default.fileExists(atPath: dest.path) {
try FileManager.default.removeItem(at: dest)
}
try FileManager.default.createDirectory(at: dest, withIntermediateDirectories: true)
let archive = dest.appendingPathComponent("source.wsz")
try data.write(to: archive, options: .atomic)
let unzip = ProcessRunner.run("/usr/bin/unzip", ["-oq", archive.path, "-d", dest.path])
guard unzip.ok else {
throw WinampSkinError.unzipFailed(unzip.stderr.trimmingCharacters(in: .whitespacesAndNewlines))
}
return try loadPackage(id: id, displayName: displayName ?? wszURL.deletingPathExtension().lastPathComponent,
cacheDirectory: dest)
}
public static func loadPackage(id: String, displayName: String, cacheDirectory: URL) throws -> WinampSkinPackage {
let entries = (try? FileManager.default.contentsOfDirectory(atPath: cacheDirectory.path)) ?? []
let sheets = Set(entries.map { $0.uppercased().replacingOccurrences(of: ".BMP", with: "") }
.filter { entries.contains("\($0).BMP") || entries.contains("\($0).bmp") })
let bmpNames = Set(entries.filter { $0.lowercased().hasSuffix(".bmp") }
.map { ($0 as NSString).deletingPathExtension.uppercased() })
let vis = parseVisColors(readText(named: "VISCOLOR", in: cacheDirectory))
let pledit = parsePledit(readText(named: "PLEDIT", in: cacheDirectory))
let pkg = WinampSkinPackage(
id: id,
displayName: displayName,
cacheDirectory: cacheDirectory,
visColors: vis,
pledit: pledit,
availableSheets: bmpNames
)
guard pkg.availableSheets.contains("CBUTTONS"), pkg.availableSheets.contains("POSBAR") else {
throw WinampSkinError.missingSheets(required: requiredSheets, found: Array(bmpNames).sorted())
}
return pkg
}
public static func loadInstalled(id: String, displayName: String) -> WinampSkinPackage? {
let dir = skinsRoot().appendingPathComponent(id, isDirectory: true)
guard FileManager.default.fileExists(atPath: dir.path) else { return nil }
return try? loadPackage(id: id, displayName: displayName, cacheDirectory: dir)
}
// MARK: parsers
static func parseVisColors(_ text: String?) -> [WinampRGB] {
guard let text else { return [] }
return text.split(whereSeparator: \.isNewline).compactMap { line in
let trimmed = line.split(separator: "/").first.map(String.init) ?? String(line)
let parts = trimmed.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }
guard parts.count >= 3,
let r = Int(parts[0]), let g = Int(parts[1]), let b = Int(parts[2]) else { return nil }
return WinampRGB(r: r, g: g, b: b)
}
}
static func parsePledit(_ text: String?) -> WinampPleditStyle {
guard let text else { return WinampPleditStyle() }
var style = WinampPleditStyle()
for raw in text.split(whereSeparator: \.isNewline) {
let line = raw.trimmingCharacters(in: .whitespaces)
guard let eq = line.firstIndex(of: "=") else { continue }
let key = line[..<eq].trimmingCharacters(in: .whitespaces)
var val = line[line.index(after: eq)...].trimmingCharacters(in: .whitespaces)
switch key.lowercased() {
case "normal": style.normal = parseHexRGB(val)
case "current": style.current = parseHexRGB(val)
case "normalbg": style.normalBG = parseHexRGB(val)
case "selectedbg": style.selectedBG = parseHexRGB(val)
case "font": style.font = val
default: break
}
}
return style
}
static func parseHexRGB(_ hex: String) -> WinampRGB? {
var s = hex
if s.hasPrefix("#") { s.removeFirst() }
guard s.count == 6, let n = Int(s, radix: 16) else { return nil }
return WinampRGB(r: (n >> 16) & 0xFF, g: (n >> 8) & 0xFF, b: n & 0xFF)
}
static func readText(named base: String, in dir: URL) -> String? {
for ext in ["txt", "TXT"] {
let url = dir.appendingPathComponent("\(base).\(ext)")
if let data = try? Data(contentsOf: url), let s = String(data: data, encoding: .utf8) {
return s
}
}
return nil
}
}
public enum WinampSkinError: Error, LocalizedError {
case unzipFailed(String)
case missingSheets(required: [String], found: [String])
public var errorDescription: String? {
switch self {
case .unzipFailed(let msg): return "Could not extract .wsz: \(msg)"
case .missingSheets(let req, let found):
return "Skin missing required bitmaps \(req.joined(separator: ", ")). Found: \(found.joined(separator: ", "))"
}
}
}