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
20 changes: 20 additions & 0 deletions App/Continuity/Views/ContinuityApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ struct ContinuityApp: App {
@State private var prepQueue = PreparationQueue()

init() {
Self.configureURLCache()
do {
let schema = Schema([Playlist.self, Track.self, TransitionVote.self])
// Pin the store to the app container. `groupContainer` defaults to `.automatic`,
Expand Down Expand Up @@ -41,9 +42,28 @@ struct ContinuityApp: App {
.modelContainer(container)
}

/// The app is artwork-heavy: every library row, the mini player, Now Playing and the
/// backdrop all pull covers over the network. The default shared cache is small enough that
/// scrolling a library re-downloads thumbnails it fetched seconds earlier, so size it for
/// the working set. (Decoded images are cached separately and bounded by
/// `ArtworkImageStore`; this is the encoded-bytes tier underneath it.)
private static func configureURLCache() {
URLCache.shared = URLCache(
memoryCapacity: 16 * 1024 * 1024,
diskCapacity: 256 * 1024 * 1024
)
}

/// One-time move of an existing app-group SwiftData store into Application Support so
/// libraries built before `groupContainer: .none` aren't orphaned beside an empty new store.
///
/// Latched in defaults once it has run: resolving the app-group container URL and probing
/// for the legacy store is filesystem work on every cold launch, forever, to answer a
/// question that can only change once.
private static func migrateStoreOutOfAppGroupIfNeeded() {
let migrationKey = "didMigrateStoreOutOfAppGroup.v1"
if UserDefaults.standard.bool(forKey: migrationKey) { return }
defer { UserDefaults.standard.set(true, forKey: migrationKey) }
let fm = FileManager.default
guard let groupRoot = fm.containerURL(
forSecurityApplicationGroupIdentifier: "group.com.sanylax.continuity"
Expand Down
82 changes: 50 additions & 32 deletions App/Continuity/Views/LibraryView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -108,12 +108,20 @@ private struct SearchResultsView: View {
}

/// Track matches by title or artist, capped so pathological queries stay snappy.
///
/// Filters first and orders only the matches: `orderedTracks` sorts and copies every track
/// in a playlist, and this whole scan re-runs on each keystroke, so the old shape paid a
/// full library sort per character typed.
private var matchingTracks: [Track] {
var results: [Track] = []
for playlist in playlists {
for track in playlist.orderedTracks
where track.title.localizedCaseInsensitiveContains(query)
|| track.artist.localizedCaseInsensitiveContains(query) {
var matches = playlist.tracks.filter {
$0.title.localizedCaseInsensitiveContains(query)
|| $0.artist.localizedCaseInsensitiveContains(query)
}
if matches.isEmpty { continue }
matches.sort { $0.sortIndex < $1.sortIndex }
for track in matches {
results.append(track)
if results.count >= 100 { return results }
}
Expand Down Expand Up @@ -148,34 +156,7 @@ private struct SearchResultsView: View {
if !trackMatches.isEmpty {
Section("Songs") {
ForEach(trackMatches) { track in
Button {
play(track)
} label: {
HStack(spacing: 12) {
RemoteArtworkView(url: track.artworkURL, symbol: track.artworkSymbol,
seed: track.gradientSeed, cornerRadius: 8)
.frame(width: 44, height: 44)
VStack(alignment: .leading, spacing: 2) {
Text(track.title)
.foregroundStyle(player.currentTrack?.id == track.id
? AnyShapeStyle(.tint) : AnyShapeStyle(.primary))
.lineLimit(1)
Text(track.artist).font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
Spacer()
Text(Theme.time(track.durationSeconds))
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
}
.buttonStyle(.plain)
.contextMenu {
Button {
player.playNext(track)
} label: {
Label("Play Next", systemImage: "text.line.first.and.arrowtriangle.forward")
}
}
SearchSongRow(track: track) { play(track) }
}
}
}
Expand All @@ -197,6 +178,43 @@ private struct SearchResultsView: View {
}
}

/// One song result. Reads the now-playing highlight itself, so a track change re-renders the
/// affected rows instead of re-running the whole library scan in `SearchResultsView.body`.
private struct SearchSongRow: View {
let track: Track
let play: () -> Void
@Environment(Player.self) private var player

var body: some View {
Button(action: play) {
HStack(spacing: 12) {
RemoteArtworkView(url: track.artworkURL, symbol: track.artworkSymbol,
seed: track.gradientSeed, cornerRadius: 8)
.frame(width: 44, height: 44)
VStack(alignment: .leading, spacing: 2) {
Text(track.title)
.foregroundStyle(player.currentTrack?.id == track.id
? AnyShapeStyle(.tint) : AnyShapeStyle(.primary))
.lineLimit(1)
Text(track.artist).font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
Spacer()
Text(Theme.time(track.durationSeconds))
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
}
.buttonStyle(.plain)
.contextMenu {
Button {
player.playNext(track)
} label: {
Label("Play Next", systemImage: "text.line.first.and.arrowtriangle.forward")
}
}
}
}

private struct PlaylistCard: View {
let playlist: Playlist

Expand All @@ -211,7 +229,7 @@ private struct PlaylistCard: View {
.font(.system(size: 10, weight: .bold))
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(.ultraThinMaterial, in: Capsule())
.continuityGlassCapsule()
.padding(8)
}
}
Expand Down
18 changes: 16 additions & 2 deletions App/Continuity/Views/MainPagerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,16 @@ struct MainPagerView: View {
// insets are re-applied per page below.
GeometryReader { proxy in
let insets = proxy.safeAreaInsets
let pageHeight = proxy.size.height
// The ScrollView below ignores the safe area, so it — and therefore every page's
// `containerRelativeFrame(.vertical)` — is the FULL window height. This
// GeometryReader is laid out inside the safe area, so `proxy.size.height` is short
// by exactly the insets it reports. Measuring the backdrop with the short value
// made it three insets' worth shorter than the content it backs; centred as a
// `.background`, that left an uncovered band of flat `systemGroupedBackground` at
// the top and bottom (black in dark mode) and slid the album gradient out of
// register with the Now Playing page. Add the insets back so the backdrop spans
// exactly the three pages it is drawing.
let pageHeight = proxy.size.height + insets.top + insets.bottom
ScrollView(.vertical) {
// Every page gets HARD insets (the pager hides the window safe area, and
// neither NavigationStack bars nor safe-area piercing behave inside scroll
Expand Down Expand Up @@ -162,7 +171,12 @@ private struct PagerBackdrop: View {
.frame(height: pageHeight * 3, alignment: .top)
.task(id: url) {
guard let url else { style = nil; return }
style = await BackdropRenderer.style(for: url)
// Shared with `AlbumBackdrop` (which this view hosts), so one render serves both.
// It outlives either view's task, hence the explicit cancellation check before
// adopting a result that may now be for the previous track.
let resolved = await BackdropRenderer.style(for: url)
guard !Task.isCancelled else { return }
style = resolved
}
}
}
Expand Down
27 changes: 14 additions & 13 deletions App/Continuity/Views/NowPlayingView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -205,16 +205,15 @@ struct NowPlayingView: View {
.buttonStyle(.plain)
}

/// Remaining forward skips, as a subtle glass pill under Next.
/// Remaining forward skips, as a subtle glass pill under Next. Real Liquid Glass — the
/// material + hand-drawn hairline it used to fake carries its own edge highlight.
private var skipBadge: some View {
Text("\(player.skipsRemaining)")
.font(.caption.weight(.bold).monospacedDigit())
.foregroundStyle(.white.opacity(0.9))
.padding(.vertical, 3)
.padding(.horizontal, 9)
.background(.ultraThinMaterial, in: Capsule())
.overlay(Capsule().strokeBorder(.white.opacity(0.15)))
.shadow(color: .black.opacity(0.3), radius: 6, y: 2)
.continuityGlassCapsule()
.accessibilityLabel("\(player.skipsRemaining) skips remaining")
}

Expand Down Expand Up @@ -302,7 +301,9 @@ private struct ScrubberBar: View {
VStack(spacing: 4) {
Slider(
value: Binding(
get: { isEditing ? scrubValue : player.position },
// Clamped into the slider's own range: a value outside it (or a non-finite
// one from a disturbed render clock) is a layout trap, not a visual glitch.
get: { min(max(isEditing ? scrubValue : player.position, 0), max(player.duration, 0.1)) },
set: { scrubValue = $0 }
),
in: 0...max(player.duration, 0.1),
Expand All @@ -329,7 +330,11 @@ private struct ScrubberBar: View {
}
}

/// Leaf: the screen's only reader of the 20 Hz-derived blend state.
/// Chooses which pair the transition panel describes. Deliberately reads only state that
/// changes at track/blend boundaries — never `position`, `transitionProgress` or
/// `secondsUntilTransition`. The two moving parts inside the panel are their own leaves, so
/// this body (and the `TransitionPreview` + beat-grid work in the panel's `init`) runs on
/// track changes, not twenty times a second.
private struct TransitionSection: View {
@Environment(Player.self) private var player

Expand All @@ -339,19 +344,15 @@ private struct TransitionSection: View {
settings: player.transitionSettings,
outgoing: current,
incoming: next,
isLive: true,
liveProgress: min(max(player.transitionProgress, 0), 1),
secondsUntil: nil
isLive: true
)
.transition(.opacity)
} else if let current = player.currentTrack, let next = player.upcomingTracks.first {
} else if let current = player.currentTrack, let next = player.nextUpcomingTrack {
TransitionVisualizationView(
settings: player.transitionSettings,
outgoing: current,
incoming: next,
isLive: false,
liveProgress: 0,
secondsUntil: player.secondsUntilTransition
isLive: false
)
.transition(.opacity)
}
Expand Down
25 changes: 17 additions & 8 deletions App/Continuity/Views/PlaylistDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,27 @@ struct PlaylistDetailView: View {
private var isSyncing: Bool { prepQueue.syncingPlaylistIDs.contains(playlist.id) }

var body: some View {
List {
// Resolved once per body evaluation: `orderedTracks` sorts and copies the whole
// relationship array, and this body used to call it three times (rows, tap handler,
// Play button) — plus once more per tap.
let tracks = playlist.orderedTracks
return List {
// The header is a regular row — NOT a pinned section header, which in a plain list
// floats transparently over the rows and lets them scroll underneath the Play button.
// As a row it scrolls away with the content, Apple Music-style.
header
header(tracks)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets())

ForEach(Array(playlist.orderedTracks.enumerated()), id: \.element.id) { index, track in
TrackRow(track: track, isCurrent: player.currentTrack?.id == track.id)
ForEach(Array(tracks.enumerated()), id: \.element.id) { index, track in
TrackRow(track: track)
.contentShape(Rectangle())
.onTapGesture {
// A failed ingest can't be played — tapping it retries instead.
if track.prepState == .failed {
prepQueue.enqueue(track, in: modelContext)
} else {
player.play(tracks: playlist.orderedTracks, startAt: index)
player.play(tracks: tracks, startAt: index)
pagerState.goToNowPlaying()
}
}
Expand Down Expand Up @@ -69,15 +73,15 @@ struct PlaylistDetailView: View {
LibraryCleanup.removeOrphanedFiles(keys: [key], in: modelContext)
}

private var header: some View {
private func header(_ tracks: [Track]) -> some View {
VStack(spacing: 12) {
RemoteArtworkView(url: playlist.artworkURL, symbol: playlist.artworkSymbol, seed: playlist.gradientSeed, cornerRadius: 20)
.frame(width: 180, height: 180)
.shadow(color: .black.opacity(0.22), radius: 16, y: 8)
Text(playlist.title).font(.title2.bold())
Text(playlist.subtitle).font(.subheadline).foregroundStyle(.secondary)
Button {
player.play(tracks: playlist.orderedTracks, startAt: 0)
player.play(tracks: tracks, startAt: 0)
pagerState.goToNowPlaying()
} label: {
Label("Play", systemImage: "play.fill")
Expand Down Expand Up @@ -118,9 +122,14 @@ struct PlaylistDetailView: View {
}
}

/// One track row. Reads the now-playing highlight itself rather than taking it as a parameter:
/// as a parameter, every track change invalidated `PlaylistDetailView.body` — which re-sorted
/// the entire playlist to rebuild the list.
private struct TrackRow: View {
let track: Track
let isCurrent: Bool
@Environment(Player.self) private var player

private var isCurrent: Bool { player.currentTrack?.id == track.id }

var body: some View {
HStack(spacing: 12) {
Expand Down
23 changes: 19 additions & 4 deletions App/Continuity/Views/RootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,16 @@ struct RootView: View {
// Natural queue exhaustion loops playback into the listening history — resolve
// the persisted IDs to live tracks in order; deleted tracks simply drop out.
player.onQueueExhausted = { ids in
let tracks = (try? modelContext.fetch(FetchDescriptor<Track>())) ?? []
let byID = Dictionary(uniqueKeysWithValues: tracks.map { ($0.id, $0) })
// Only the id → Track mapping is needed; a bare fetch hydrates every row's
// `beatTimes` array (hundreds of doubles each) to build a dictionary of
// UUIDs. The rest of each surviving track faults in when it's played.
var descriptor = FetchDescriptor<Track>()
descriptor.propertiesToFetch = [\.id]
let tracks = (try? modelContext.fetch(descriptor)) ?? []
// `uniquingKeysWith`, not `uniqueKeysWithValues`: the latter traps on a
// duplicate key, and trapping is not the right answer to a store that
// handed back the same row twice.
let byID = Dictionary(tracks.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
return ids.compactMap { byID[$0] }
}
LibraryCleanup.sweepOrphanedFiles(in: modelContext)
Expand Down Expand Up @@ -236,17 +244,24 @@ struct RootView: View {
/// fresh install, stages COMË N GO paused at the start of its playlist.
private func restorePlaybackSession() {
guard player.currentTrack == nil else { return } // already playing (e.g. state restore re-entry)
let tracks = (try? modelContext.fetch(FetchDescriptor<Track>())) ?? []

if let state = PlaybackStateStore.load() {
let byID = Dictionary(uniqueKeysWithValues: tracks.map { ($0.id, $0) })
// The common launch path only resolves ids to rows. Fetching whole tracks here
// hydrated every one of them — `beatTimes` arrays included — on the launch path,
// to build a dictionary of UUIDs.
var descriptor = FetchDescriptor<Track>()
descriptor.propertiesToFetch = [\.id]
let stored = (try? modelContext.fetch(descriptor)) ?? []
// Never trap on a duplicate id here — this is the cold-launch path.
let byID = Dictionary(stored.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
player.restore(state, resolving: byID)
if player.currentTrack != nil { return }
// Every persisted track was deleted — fall through to the first-run seed.
}

// First launch (or an emptied library): COMË N GO is always the first song. Prefer the
// real ingested track over the demo of the same name; queue its whole playlist from there.
let tracks = (try? modelContext.fetch(FetchDescriptor<Track>())) ?? []
let candidates = tracks.filter { $0.title.localizedCaseInsensitiveContains("COMË N GO") }
guard let seed = candidates.first(where: { !$0.isDemo }) ?? candidates.first,
let playlist = seed.playlist else { return }
Expand Down
Loading
Loading