Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions AppleNugs/Core/Catalog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -433,12 +433,15 @@ enum Catalog {
}

/// Full timestamp parsing for REST `/livestreams` event times. Tolerates
/// both fractional and whole-second ISO-8601 (with or without "Z").
private static let isoTimestamp: ISO8601DateFormatter = {
/// both fractional and whole-second ISO-8601 (with or without "Z"). A
/// computed property rather than a shared `static let`: `ISO8601DateFormatter`
/// isn't `Sendable`, and this is called rarely enough (a handful of event
/// times per livestream load) that a fresh formatter per call is free.
private static var isoTimestamp: ISO8601DateFormatter {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return f
}()
}

static func parseTimestamp(_ raw: String?) -> Date? {
guard let raw, !raw.isEmpty else { return nil }
Expand Down
64 changes: 38 additions & 26 deletions AppleNugs/Player/PlayerService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -142,8 +142,12 @@ final class PlayerService {
endObserver = NotificationCenter.default.addObserver(
forName: AVPlayerItem.didPlayToEndTimeNotification,
object: nil, queue: .main) { [weak self] note in
// Send only the ended item's identity across the actor hop — a
// `Notification` (and the `AVPlayerItem` it carries) isn't Sendable,
// and `itemDidEnd` only needs identity to match the current item.
let endedID = (note.object as? AVPlayerItem).map(ObjectIdentifier.init)
MainActor.assumeIsolated {
self?.itemDidEnd(note.object as? AVPlayerItem)
self?.itemDidEnd(endedID)
}
}
terminationObserver = NotificationCenter.default.addObserver(
Expand Down Expand Up @@ -422,22 +426,28 @@ final class PlayerService {

private func observeCurrent(_ item: AVPlayerItem) {
statusObservation = item.observe(\.status, options: [.new]) { [weak self] item, _ in
DispatchQueue.main.async {
guard let self, item === self.currentItem else { return }
switch item.status {
case .failed:
// Signed URL rejected or format undecodable here — fall
// through to the next available format for this track,
// and drop the cached picks so a retry re-probes.
if let track = self.current {
Task { await self.client.invalidateStreams(for: track.trackId) }
// KVO fires on an arbitrary AVFoundation queue, so hop to main. The
// inner closure captures `self` weakly itself (rather than closing
// over the outer closure's capture, which `@Sendable` checking flags)
// and `assumeIsolated` supplies the main-actor isolation it lands on.
DispatchQueue.main.async { [weak self] in
MainActor.assumeIsolated {
guard let self, item === self.currentItem else { return }
switch item.status {
case .failed:
// Signed URL rejected or format undecodable here — fall
// through to the next available format for this track,
// and drop the cached picks so a retry re-probes.
if let track = self.current {
Task { await self.client.invalidateStreams(for: track.trackId) }
}
self.pickIndex += 1
self.loadCurrentPick()
case .readyToPlay:
self.applyPendingSeek()
default:
break
}
self.pickIndex += 1
self.loadCurrentPick()
case .readyToPlay:
self.applyPendingSeek()
default:
break
}
}
}
Expand All @@ -454,8 +464,8 @@ final class PlayerService {
}

/// Fired whenever any of our items plays to the end.
private func itemDidEnd(_ item: AVPlayerItem?) {
guard let item, item === currentItem else { return }
private func itemDidEnd(_ itemID: ObjectIdentifier?) {
guard let itemID, let currentItem, itemID == ObjectIdentifier(currentItem) else { return }
if let p = preload, queue.indices.contains(index + 1),
queue[index + 1].id == p.trackUID {
// AVQueuePlayer has already advanced into the preloaded item —
Expand Down Expand Up @@ -535,14 +545,16 @@ final class PlayerService {
}
let item = makeItem(url: url)
let observation = item.observe(\.status, options: [.new]) { [weak self] item, _ in
DispatchQueue.main.async {
guard let self, self.preload?.item === item else { return }
if item.status == .failed {
// The parked pick failed before playback — swap in the
// next format without disturbing the playing track.
Task { await self.client.invalidateStreams(for: track.trackId) }
self.discardPreload()
self.buildPreload(track: track, picks: picks, startingAt: pickIdx + 1)
DispatchQueue.main.async { [weak self] in
MainActor.assumeIsolated {
guard let self, self.preload?.item === item else { return }
if item.status == .failed {
// The parked pick failed before playback — swap in the
// next format without disturbing the playing track.
Task { await self.client.invalidateStreams(for: track.trackId) }
self.discardPreload()
self.buildPreload(track: track, picks: picks, startingAt: pickIdx + 1)
}
}
}
}
Expand Down
64 changes: 34 additions & 30 deletions AppleNugs/Player/VideoPlayerService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -109,12 +109,14 @@ final class VideoPlayerService {
// native play button replaying a finished video) — otherwise video
// audio would play underneath resumed audio with media keys misrouted.
rateObservation = player.observe(\.timeControlStatus, options: [.new]) { [weak self] player, _ in
DispatchQueue.main.async {
guard let self, self.current != nil, !self.ownsNowPlaying,
player.timeControlStatus == .playing else { return }
self.claimArbiterIfNeeded()
self.isPlaying = true
self.pushNowPlayingInfo()
DispatchQueue.main.async { [weak self] in
MainActor.assumeIsolated {
guard let self, self.current != nil, !self.ownsNowPlaying,
player.timeControlStatus == .playing else { return }
self.claimArbiterIfNeeded()
self.isPlaying = true
self.pushNowPlayingInfo()
}
}
}
}
Expand Down Expand Up @@ -325,30 +327,32 @@ final class VideoPlayerService {

private func observe(_ item: AVPlayerItem) {
statusObservation = item.observe(\.status, options: [.new]) { [weak self] item, _ in
DispatchQueue.main.async {
guard let self, item === self.item else { return }
switch item.status {
case .failed:
self.loadError = item.error?.localizedDescription
?? "This video failed to play."
// The video can't play. Detach the dead item and clear
// `current` BEFORE relinquishing: leaving it attached to the
// native player controls would let the timeControlStatus
// observer (guarded on `current != nil`) re-claim the arbiter
// and re-pause the audio we're about to hand back. Clearing
// currentTime also avoids a stale "current chapter" highlight;
// loadError still drives the error overlay, and `current ==
// nil` turns the Play button into a retry.
self.currentTime = 0
self.isPlaying = false
self.loadGeneration += 1 // cancel any in-flight variant load
self.tearDownItem()
self.current = nil
self.relinquishArbiter()
case .readyToPlay:
self.applyPendingStartSeek()
default:
break
DispatchQueue.main.async { [weak self] in
MainActor.assumeIsolated {
guard let self, item === self.item else { return }
switch item.status {
case .failed:
self.loadError = item.error?.localizedDescription
?? "This video failed to play."
// The video can't play. Detach the dead item and clear
// `current` BEFORE relinquishing: leaving it attached to the
// native player controls would let the timeControlStatus
// observer (guarded on `current != nil`) re-claim the arbiter
// and re-pause the audio we're about to hand back. Clearing
// currentTime also avoids a stale "current chapter" highlight;
// loadError still drives the error overlay, and `current ==
// nil` turns the Play button into a retry.
self.currentTime = 0
self.isPlaying = false
self.loadGeneration += 1 // cancel any in-flight variant load
self.tearDownItem()
self.current = nil
self.relinquishArbiter()
case .readyToPlay:
self.applyPendingStartSeek()
default:
break
}
}
}
}
Expand Down
20 changes: 10 additions & 10 deletions AppleNugs/Theme/Theme.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,29 +3,29 @@ import SwiftUI
// MARK: - Variation axes

/// Which bespoke now-playing treatment a theme uses in the transport bar.
enum TransportSignature {
enum TransportSignature: Sendable {
case standard // the original text block, token-styled
case tapeLabel // Tape Room: art chip + amber under-rule progress
case jCard // Shoebox: cassette J-card strip
case faceplate // The Receiver: full brushed-metal VU faceplate (deferred)
}

/// Whether the accent is a fixed color or derived from the current cover art.
enum AccentMode {
enum AccentMode: Sendable {
case staticAccent
case artDriven(fallback: Color)
}

/// How (if at all) the extracted album-art color washes into the chrome.
enum WashStyle {
enum WashStyle: Sendable {
case none
case linear // Soundboard: gradient behind transport + now-playing
case warmLow // Shoebox: faint warm wash
case bloom18 // The Receiver: 18% bloom behind the inspector art only
}

/// Opt-in bespoke behaviors, kept off the host views so nothing forks.
struct Capabilities: OptionSet {
struct Capabilities: OptionSet, Sendable {
let rawValue: Int
static let artWash = Capabilities(rawValue: 1 << 0)
static let equalizerRows = Capabilities(rawValue: 1 << 1)
Expand All @@ -37,7 +37,7 @@ struct Capabilities: OptionSet {
// MARK: - Token groups

/// Semantic color roles. Views ask for `textSecondary`, never "taupe".
struct Palette {
struct Palette: Sendable {
let base, raised, hairline: Color
let textPrimary, textSecondary, textIdle: Color
let accent: Color // static fallback; art may override at runtime
Expand All @@ -47,13 +47,13 @@ struct Palette {
}

/// Fonts as size-taking closures so a point size is a call, not six stored fonts.
struct Typography {
let hero, title, section: (CGFloat) -> Font
let body, numeric: (CGFloat) -> Font
struct Typography: Sendable {
let hero, title, section: @Sendable (CGFloat) -> Font
let body, numeric: @Sendable (CGFloat) -> Font
}

/// Per-theme copy so idle/empty states and section headers carry personality.
struct IdleCopy {
struct IdleCopy: Sendable {
let nowPlaying: String
let dashboardIdle: String
let dashHeaders: (now: String, quality: String, upNext: String)
Expand All @@ -63,7 +63,7 @@ struct IdleCopy {

/// A complete look, assembled from tokens. Value type read from the environment,
/// so switching republishes one value and re-renders only the affected subtrees.
struct Theme: Equatable {
struct Theme: Equatable, Sendable {
let id: ThemeID
let palette: Palette
let type: Typography
Expand Down
4 changes: 4 additions & 0 deletions project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ options:
settings:
base:
SWIFT_VERSION: "5.0"
# Full data-race checking under the Swift 5 language mode: the codebase is
# clean against it, and keeping it on surfaces any new concurrency issue as
# a warning now (an error once we move to the Swift 6 language mode).
SWIFT_STRICT_CONCURRENCY: "complete"
MACOSX_DEPLOYMENT_TARGET: "14.0"
# Marketing + build numbers live here; Info.plist references them so a
# release is a one-line bump. Increment CURRENT_PROJECT_VERSION for every
Expand Down
Loading