feat(*): subsonic client with avfoundation playback and apple music ui

This commit is contained in:
hh committed 2026-09-22 06:44:44 +02:00
commit e5bb559d7f
93 files changed
+7517

No files matched your search

@@ -0,0 +1,129 @@
import CoreGraphics
import Foundation
import ImageIO
import UniformTypeIdentifiers
struct FakeArtwork {
private let context: CGContext
private let space = CGColorSpaceCreateDeviceRGB()
private let size: CGFloat
private let hue: Double
private let spread: Double
private var random: SeededGenerator
static func png(seed: String, side: Int) -> Data? {
guard var artwork = FakeArtwork(seed: seed, side: side) else { return nil }
artwork.draw()
guard let image = artwork.context.makeImage() else { return nil }
let data = NSMutableData()
guard let destination = CGImageDestinationCreateWithData(data, UTType.png.identifier as CFString, 1, nil) else { return nil }
CGImageDestinationAddImage(destination, image, nil)
return CGImageDestinationFinalize(destination) ? data as Data : nil
}
private init?(seed: String, side: Int) {
guard let context = CGContext(
data: nil,
width: side,
height: side,
bitsPerComponent: 8,
bytesPerRow: 0,
space: space,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else { return nil }
self.context = context
size = CGFloat(side)
random = SeededGenerator(seed: seed.utf8.reduce(UInt64(14_695_981_039_346_656_037)) { ($0 ^ UInt64($1)) &* 1_099_511_628_211 })
hue = .random(in: 0 ..< 1, using: &random)
spread = .random(in: 0.08 ... 0.45, using: &random)
}
private mutating func draw() {
let isDark = Bool.random(using: &random)
let ground = [color(hue, 0.75, isDark ? 0.35 : 0.95), color(hue + spread, 0.8, isDark ? 0.12 : 0.7)]
linear(ground, from: CGPoint(x: 0, y: size), to: CGPoint(x: size, y: 0))
switch Int.random(in: 0 ..< 4, using: &random) {
case 0: glows()
case 1: rings()
case 2: sun(horizon: ground[1])
default: blocks()
}
}
private mutating func glows() {
for _ in 0 ..< 3 {
let center = CGPoint(x: .random(in: 0 ... size, using: &random), y: .random(in: 0 ... size, using: &random))
let tint = color(hue + .random(in: -0.2 ... 0.5, using: &random), 0.7, 1)
let radius = size * .random(in: 0.4 ... 0.8, using: &random)
guard let clear = tint.copy(alpha: 0),
let gradient = CGGradient(colorsSpace: space, colors: [tint, clear] as CFArray, locations: nil)
else { continue }
context.drawRadialGradient(gradient, startCenter: center, startRadius: 0, endCenter: center, endRadius: radius, options: [])
}
}
private mutating func rings() {
let center = CGPoint(x: size * .random(in: 0.3 ... 0.7, using: &random), y: size * .random(in: 0.3 ... 0.7, using: &random))
for ring in (1 ... 7).reversed() {
context.setFillColor(color(hue + spread * Double(ring) / 5, 0.65, ring.isMultiple(of: 2) ? 0.95 : 0.25))
let radius = size * 0.09 * CGFloat(ring)
context.fillEllipse(in: CGRect(x: center.x - radius, y: center.y - radius, width: radius * 2, height: radius * 2))
}
}
private func sun(horizon: CGColor) {
let radius = size * 0.32
let disc = CGRect(x: size / 2 - radius, y: size * 0.34, width: radius * 2, height: radius * 2)
context.saveGState()
context.addEllipse(in: disc)
context.clip()
linear(
[color(hue + 0.5, 0.5, 1), color(hue + 0.62, 0.85, 1)],
from: CGPoint(x: 0, y: disc.maxY),
to: CGPoint(x: 0, y: disc.minY)
)
context.restoreGState()
context.setFillColor(horizon)
for stripe in 0 ..< 5 {
let height = size * 0.012 * CGFloat(5 - stripe)
context.fill(CGRect(x: 0, y: disc.minY + CGFloat(stripe) * size * 0.06, width: size, height: height))
}
}
private mutating func blocks() {
let cells = Int.random(in: 2 ... 3, using: &random)
let cell = size / CGFloat(cells)
for row in 0 ..< cells {
for column in 0 ..< cells where Bool.random(using: &random) {
let rect = CGRect(x: CGFloat(column) * cell, y: CGFloat(row) * cell, width: cell, height: cell)
context.setFillColor(color(hue + .random(in: 0 ... 0.6, using: &random), 0.7, .random(in: 0.3 ... 1, using: &random)))
if Bool.random(using: &random) {
context.fill(rect)
} else {
context.fillEllipse(in: rect)
}
}
}
}
private func color(_ hue: Double, _ saturation: Double, _ brightness: Double) -> CGColor {
let sector = (hue.truncatingRemainder(dividingBy: 1) + 1).truncatingRemainder(dividingBy: 1) * 6
let chroma = brightness * saturation
let second = chroma * (1 - abs(sector.truncatingRemainder(dividingBy: 2) - 1))
let table = [
[chroma, second, 0],
[second, chroma, 0],
[0, chroma, second],
[0, second, chroma],
[second, 0, chroma],
[chroma, 0, second],
]
let rgb = table[min(Int(sector), 5)].map { $0 + brightness - chroma }
return CGColor(red: rgb[0], green: rgb[1], blue: rgb[2], alpha: 1)
}
private func linear(_ colors: [CGColor], from: CGPoint, to: CGPoint) {
guard let gradient = CGGradient(colorsSpace: space, colors: colors as CFArray, locations: nil) else { return }
context.drawLinearGradient(gradient, start: from, end: to, options: [.drawsBeforeStartLocation, .drawsAfterEndLocation])
}
}
@@ -0,0 +1,253 @@
import Domain
import Foundation
struct SeededGenerator: RandomNumberGenerator {
private var state: UInt64
init(seed: UInt64) {
state = seed &+ 0x9E37_79B9_7F4A_7C15
}
mutating func next() -> UInt64 {
state &+= 0x9E37_79B9_7F4A_7C15
var mixed = state
mixed = (mixed ^ (mixed >> 30)) &* 0xBF58_476D_1CE4_E5B9
mixed = (mixed ^ (mixed >> 27)) &* 0x94D0_49BB_1331_11EB
return mixed ^ (mixed >> 31)
}
}
struct FakeCatalog {
struct AlbumSeed {
let title: String
let year: Int
let tracks: Int
}
struct ArtistSeed {
let name: String
let genre: String
let albums: [AlbumSeed]
}
static let artists: [ArtistSeed] = [
ArtistSeed(name: "Velvet Harbor", genre: "Indie Pop", albums: [
AlbumSeed(title: "Salt in the Signal", year: 2024, tracks: 11),
AlbumSeed(title: "Low Tide Radio", year: 2021, tracks: 10),
AlbumSeed(title: "Postcards From Nowhere", year: 2018, tracks: 12),
]),
ArtistSeed(name: "Нина Орлова", genre: "Alternative", albums: [
AlbumSeed(title: "Тихий час", year: 2025, tracks: 9),
AlbumSeed(title: "Север внутри", year: 2022, tracks: 11),
]),
ArtistSeed(name: "Kaito Mori", genre: "Electronic", albums: [
AlbumSeed(title: "Neon Monsoon", year: 2023, tracks: 8),
AlbumSeed(title: "東京 After Hours", year: 2020, tracks: 10),
AlbumSeed(title: "Glass Circuits", year: 2017, tracks: 9),
AlbumSeed(title: "Soft Machines (Remixes)", year: 2024, tracks: 6),
]),
ArtistSeed(name: "The Paper Lanterns", genre: "Rock", albums: [
AlbumSeed(title: "Everything Burns Brighter at Night and Other Small Disasters", year: 2019, tracks: 13),
AlbumSeed(title: "Static Bloom", year: 2016, tracks: 11),
AlbumSeed(title: "Live at the Old Observatory", year: 2022, tracks: 16),
]),
ArtistSeed(name: "Marisol Vega", genre: "Latin", albums: [
AlbumSeed(title: "Corazón de Verano", year: 2024, tracks: 12),
AlbumSeed(title: "Luna Roja", year: 2021, tracks: 10),
]),
ArtistSeed(name: "Oskar Lindqvist Trio", genre: "Jazz", albums: [
AlbumSeed(title: "Blue Hours", year: 2015, tracks: 7),
AlbumSeed(title: "Winter Standards", year: 2018, tracks: 9),
AlbumSeed(title: "Stockholm Sessions", year: 2023, tracks: 8),
]),
ArtistSeed(name: "DJ Quartz", genre: "Hip-Hop", albums: [
AlbumSeed(title: "Concrete Gardens", year: 2022, tracks: 14),
AlbumSeed(title: "No Signal", year: 2025, tracks: 12),
]),
ArtistSeed(name: "Aurora Fields", genre: "Ambient", albums: [
AlbumSeed(title: "Weightless", year: 2020, tracks: 6),
AlbumSeed(title: "Slow Light", year: 2023, tracks: 7),
AlbumSeed(title: "Music for Empty Airports", year: 2017, tracks: 5),
]),
ArtistSeed(name: "Holloway & Finch", genre: "Folk", albums: [
AlbumSeed(title: "The Orchard Tapes", year: 2019, tracks: 10),
AlbumSeed(title: "Rivers We Crossed", year: 2024, tracks: 11),
]),
ArtistSeed(name: "Сёстры Грома", genre: "Rock", albums: [
AlbumSeed(title: "Электричество", year: 2023, tracks: 10),
AlbumSeed(title: "Город без сна", year: 2020, tracks: 12),
]),
ArtistSeed(name: "Camille Durand", genre: "Classical", albums: [
AlbumSeed(title: "Nocturnes & Preludes", year: 2021, tracks: 14),
AlbumSeed(title: "Satie: Works for Piano", year: 2016, tracks: 12),
]),
ArtistSeed(name: "Midnight Arcade", genre: "Electronic", albums: [
AlbumSeed(title: "Insert Coin", year: 2018, tracks: 10),
AlbumSeed(title: "High Score", year: 2021, tracks: 11),
AlbumSeed(title: "Continue?", year: 2025, tracks: 9),
]),
ArtistSeed(name: "Juno Okafor", genre: "R&B/Soul", albums: [
AlbumSeed(title: "Honey & Smoke", year: 2024, tracks: 12),
AlbumSeed(title: "Afterglow", year: 2022, tracks: 10),
]),
ArtistSeed(name: "Iron Meridian", genre: "Metal", albums: [
AlbumSeed(title: "Cathedral of Ash", year: 2019, tracks: 9),
AlbumSeed(title: "Tidal Lock", year: 2023, tracks: 8),
]),
]
static let openers = [
"Midnight", "Paper", "Golden", "Silent", "Electric", "Hollow", "Crimson", "Distant", "Velvet", "Broken",
"Summer", "Neon", "Quiet", "Wild", "Frozen", "Burning", "Little", "Endless", "Lonely", "Silver",
]
static let closers = [
"Hearts", "Rivers", "Machines", "Letters", "Highways", "Gardens", "Satellites", "Windows", "Echoes", "Tides",
"Dreams", "Wires", "Shadows", "Mornings", "Skylines", "Ghosts", "Oceans", "Rooms", "Signals", "Fires",
]
static let singles = [
"Interlude", "Overture", "Home", "Gravity", "Again", "Stay", "Run", "Waves", "Closer", "Tomorrow",
"Если бы", "Навсегда", "Après la pluie", "Sin Ti", "さよなら",
]
static let suffixes = ["", "", "", "", "", "", " (Reprise)", " (feat. Juno Okafor)", " (Acoustic)", ", Pt. 2"]
static let formats: [AudioFormat] = [
AudioFormat(codec: .flac, bitrateKbps: 1011, sampleRate: 44100, bitDepth: 16),
AudioFormat(codec: .flac, bitrateKbps: 2890, sampleRate: 96000, bitDepth: 24),
AudioFormat(codec: .flac, bitrateKbps: 1411, sampleRate: 48000, bitDepth: 24),
AudioFormat(codec: .mp3, bitrateKbps: 320, sampleRate: 44100),
AudioFormat(codec: .aac, bitrateKbps: 256, sampleRate: 44100),
]
let source: SourceID
private(set) var changes: [Change] = []
init(source: SourceID, now: Date = Date()) {
self.source = source
var random = SeededGenerator(seed: 42)
var allTracks: [Track] = []
var albumIndex = 0
for (artistIndex, seed) in Self.artists.enumerated() {
let artistID = id("artist-\(artistIndex)")
let artist = Artist(
id: artistID,
name: seed.name,
sortName: seed.name.hasPrefix("The ") ? String(seed.name.dropFirst(4)) : seed.name,
artwork: id("art-artist-\(artistIndex)"),
albumCount: seed.albums.count
)
changes.append(.upsert(.artist(artist)))
for albumSeed in seed.albums {
let tracks = makeTracks(albumSeed, albumIndex: albumIndex, artist: artist, genre: seed.genre, random: &random)
let album = Album(
id: id("album-\(albumIndex)"),
title: albumSeed.title,
artistID: artistID,
artistName: seed.name,
year: albumSeed.year,
genre: seed.genre,
artwork: id("art-album-\(albumIndex)"),
trackCount: tracks.count,
duration: tracks.reduce(0) { $0 + $1.duration },
addedAt: now.addingTimeInterval(-Double(Int.random(in: 1 ... 400, using: &random)) * 86400),
bestFormat: tracks.first?.formats.first
)
changes.append(.upsert(.album(album)))
changes.append(contentsOf: tracks.map { .upsert(.track($0)) })
allTracks.append(contentsOf: tracks)
albumIndex += 1
}
}
makePlaylists(from: allTracks, now: now, random: &random)
makeAnnotations(for: allTracks, now: now, random: &random)
makeStations()
}
private func id(_ remote: String) -> EntityID {
EntityID(source: source, remote: remote)
}
private func makeTracks(
_ seed: AlbumSeed,
albumIndex: Int,
artist: Artist,
genre: String,
random: inout SeededGenerator
) -> [Track] {
let format = Self.formats[Int.random(in: 0 ..< Self.formats.count, using: &random)]
return (1 ... seed.tracks).map { number in
let base = Int.random(in: 0 ..< 5, using: &random) == 0
? Self.singles.randomElement(using: &random) ?? ""
: "\(Self.openers.randomElement(using: &random) ?? "") \(Self.closers.randomElement(using: &random) ?? "")"
return Track(
id: id("track-\(albumIndex)-\(number)"),
title: base + (Self.suffixes.randomElement(using: &random) ?? ""),
albumID: id("album-\(albumIndex)"),
albumTitle: seed.title,
artistID: artist.id,
artistName: artist.name,
trackNumber: number,
duration: genre == "Ambient" || genre == "Jazz"
? Double(Int.random(in: 280 ... 620, using: &random))
: Double(Int.random(in: 118 ... 325, using: &random)),
year: seed.year,
genre: genre,
artwork: id("art-album-\(albumIndex)"),
isExplicit: genre == "Hip-Hop" && Int.random(in: 0 ..< 2, using: &random) == 0,
formats: [format]
)
}
}
private mutating func makePlaylists(from tracks: [Track], now: Date, random: inout SeededGenerator) {
let seeds: [(name: String, summary: String)] = [
("Late Night Drive", "Windows down, city lights."),
("Focus", "Instrumental and quiet."),
("Утро", ""),
("Gym", "Loud."),
("Sunday Kitchen", "Cooking with the radio on."),
]
for (index, seed) in seeds.enumerated() {
let picked = Array(tracks.shuffled(using: &random).prefix(12 + index * 5))
let playlist = Playlist(
id: id("playlist-\(index)"),
name: seed.name,
summary: seed.summary,
artwork: id("art-playlist-\(index)"),
trackCount: picked.count,
duration: picked.reduce(0) { $0 + $1.duration },
updatedAt: now.addingTimeInterval(-Double(index) * 86400 * 9)
)
let entries = picked.enumerated().map { PlaylistEntry(id: "entry-\(index)-\($0.offset)", track: $0.element.id) }
changes.append(.upsert(.playlist(playlist, entries: entries)))
}
}
private mutating func makeAnnotations(for tracks: [Track], now: Date, random: inout SeededGenerator) {
for track in tracks.shuffled(using: &random).prefix(140) {
let plays = Int.random(in: 1 ... 60, using: &random)
changes.append(.annotate(track.id, Annotation(
favoritedAt: plays > 42 ? now.addingTimeInterval(-Double(plays) * 3600) : nil,
playCount: plays,
lastPlayedAt: now.addingTimeInterval(-Double(Int.random(in: 1 ... 900, using: &random)) * 3600)
)))
}
}
private mutating func makeStations() {
let seeds = [
("Radio Paradise", "Eclectic, listener supported", "https://stream.radioparadise.com/flacm"),
("SomaFM Groove Salad", "Ambient and downtempo beats", "https://ice1.somafm.com/groovesalad-256-mp3"),
("KEXP 90.3", "Where the music matters", "https://kexp-mp3-128.streamguys1.com/kexp128.mp3"),
("FIP", "Radio France, sans frontières", "https://icecast.radiofrance.fr/fip-hifi.aac"),
]
for (index, seed) in seeds.enumerated() {
guard let url = URL(string: seed.2) else { continue }
changes.append(.upsert(.station(RadioStation(
id: id("station-\(index)"),
name: seed.0,
tagline: seed.1,
streamURL: url,
artwork: id("art-station-\(index)")
))))
}
}
}
@@ -0,0 +1,75 @@
import Domain
import Foundation
import Synchronization
public final class FakeSource: LibrarySource, Starring, LyricsSource {
public let id: SourceID
public let name: String
private let catalog: FakeCatalog
private let artworkDirectory: URL
private struct Extras {
var changes: [Change] = []
var artwork: [String: URL] = [:]
var lyrics: [String: URL] = [:]
}
private let extras = Mutex(Extras())
public init(id: SourceID = SourceID(), name: String = "Demo Library") {
self.id = id
self.name = name
catalog = FakeCatalog(source: id)
artworkDirectory = FileManager.default.temporaryDirectory.appending(path: "FakeSourceArtwork", directoryHint: .isDirectory)
try? FileManager.default.createDirectory(at: artworkDirectory, withIntermediateDirectories: true)
}
public var sampleTracks: [Track] {
catalog.changes.compactMap {
if case let .upsert(.track(track)) = $0 {
return track
}
return nil
}
}
public func add(_ changes: [Change], artworkFiles: [String: URL], lyricsFiles: [String: URL] = [:]) {
extras.withLock {
$0.changes += changes
$0.artwork.merge(artworkFiles) { $1 }
$0.lyrics.merge(lyricsFiles) { $1 }
}
}
public func lyrics(for track: EntityID) async throws -> Lyrics? {
guard let file = extras.withLock({ $0.lyrics[track.remote] }) else { return nil }
return try Lyrics(lrc: String(contentsOf: file, encoding: .utf8))
}
public func changes(since cursor: Cursor?) -> AsyncThrowingStream<Change, Error> {
let changes = cursor == nil ? catalog.changes + extras.withLock(\.changes) : []
return AsyncThrowingStream { continuation in
changes.forEach { continuation.yield($0) }
continuation.yield(.checkpoint(Cursor("1")))
continuation.finish()
}
}
public func stream(for _: EntityID) async throws -> StreamRequest {
throw SourceError.notFound
}
public func artwork(_ id: EntityID, pixelSize: Int) async throws -> ResourceRequest {
if let file = extras.withLock({ $0.artwork[id.remote] }) {
return ResourceRequest(url: file)
}
let side = pixelSize <= 320 ? 320 : 1024
let file = artworkDirectory.appending(path: "\(id.remote)-\(side).png")
if !FileManager.default.fileExists(atPath: file.path) {
guard let data = FakeArtwork.png(seed: id.remote, side: side) else { throw SourceError.notFound }
try data.write(to: file, options: .atomic)
}
return ResourceRequest(url: file)
}
public func setFavorite(_: Bool, for _: EntityID, kind _: EntityKind) async throws {}
}