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
16 changes: 16 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@ or commit it. Bundle id `com.sanylax.continuity` (share extension

- **New files need `xcodegen generate`** before `xcodebuild`, or you get "cannot find X in
scope." XcodeGen uses explicit file lists.
- **Schemes come only from `project.yml`.** XcodeGen emits no scheme unless a target declares
`scheme:` (the Continuity target does — keep it). `xcodebuild` on Xcode 26.x does **not**
auto-create schemes the way the Xcode GUI does, so without it `-scheme Continuity` fails in
~30s with exit 65 "does not contain a scheme named" — which is exactly how every TestFlight
run from PR #127 to #137 died.
- **onnxruntime is a static `.framework` (ar archive), not a dylib.** Xcode still embeds a
broken ~50 KB stub into `Continuity.app/Frameworks`. Never "fix" that stub by patching
`MinimumOSVersion` and re-signing — that cured ITMS upload checks while leaving a poison
Expand All @@ -104,6 +109,17 @@ or commit it. Bundle id `com.sanylax.continuity` (share extension
- **Stem separation on the Simulator** is also CPU-only (and slow). Do not "fix" perceived
hangs by enabling CoreML on sim — sim CoreML has no ANE/GPU and routes through a ~100×
slower serial CPU queue.
- **YouTubeKit is pinned `exact:` on purpose — bump it deliberately, never float on `main`.**
Stream URLs come from whichever InnerTube client the pinned YouTubeKit asks; YouTube retires
clients without notice (Aug 2026: ANDROID_VR URLs started 403-ing after the first ~1 MB, so
every 1 MiB ranged download died on chunk #2 → `streamURLExpired` on every track → minutes of
spinner, then the orange retry badge). The signature of "YouTube changed again" is *every*
imported track failing with `prep failed for …: streamURLExpired` (or `.network`) in Console;
playlist/search scrapes still succeed. First response: check upstream YouTubeKit for a newer
tag, bump `exact:` in `Packages/ContinuityKit/Package.swift`, and re-run the opt-in probe
`CONTINUITY_LIVE_PROBE=1 … -only-testing:IngestTests/LiveIngestProbeTests` (simulator) —
it prints the failing stage and exact error per track. Beware: a scratch SwiftPM harness
that depends on `branch: "main"` silently resolves upstream HEAD, not the app's pin.
- **Scrapers are fragile by design.** YouTube/Spotify change their embedded JSON shapes without
notice (YouTube moved playlists to `lockupViewModel` mid-project). Parsers handle multiple
shapes and are pinned by tests against real fixtures. Resolvers retry transient failures
Expand Down
102 changes: 102 additions & 0 deletions App/Continuity/Views/DownloadsView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import SwiftUI
import Domain
import Ingest
import SwiftData

/// Live ingest queue: every track currently downloading, analysing, or waiting for a slot.
struct DownloadsView: View {
@Environment(PreparationQueue.self) private var prepQueue
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss

var body: some View {
NavigationStack {
Group {
if prepQueue.ingestJobs.isEmpty {
ContentUnavailableView(
"Nothing downloading",
systemImage: "arrow.down.circle",
description: Text("Imported songs show up here until their audio is ready.")
)
} else {
List {
ForEach(prepQueue.ingestJobs) { job in
jobRow(job)
}
}
.listStyle(.insetGrouped)
}
}
.navigationTitle("Downloads")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { dismiss() }
}
}
}
}

private func jobRow(_ job: IngestJob) -> some View {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
Text(job.title).lineLimit(1)
if job.isPrioritized {
Image(systemName: "arrow.up")
.font(.caption2.weight(.bold))
.foregroundStyle(.tint)
.accessibilityLabel("Prioritized")
}
}
Text(job.artist)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
phaseLabel(job)
.font(.caption2)
.foregroundStyle(.tertiary)
if job.phase == .downloading, let fraction = job.fraction {
ProgressView(value: fraction)
.padding(.top, 2)
} else if job.phase != .queued {
ProgressView()
.padding(.top, 2)
}
}
Spacer(minLength: 8)
if !job.isPrioritized {
Button {
prioritize(job)
} label: {
Image(systemName: "arrow.up.to.line")
}
.buttonStyle(.borderless)
.accessibilityLabel("Download first")
}
}
.padding(.vertical, 4)
}

private func phaseLabel(_ job: IngestJob) -> Text {
switch job.phase {
case .queued:
return Text("Waiting")
case .downloading:
if let fraction = job.fraction {
return Text("Downloading \(Int((fraction * 100).rounded()))%")
}
return Text("Downloading")
case .analyzing:
return Text("Analyzing")
}
}

private func prioritize(_ job: IngestJob) {
let jobID = job.id
var descriptor = FetchDescriptor<Track>(predicate: #Predicate { $0.id == jobID })
descriptor.fetchLimit = 1
guard let track = try? modelContext.fetch(descriptor).first else { return }
prepQueue.prioritize(track, in: modelContext)
}
}
35 changes: 35 additions & 0 deletions App/Continuity/Views/LibrarySheetView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ struct LibrarySheetView: View {
@State private var showingSearch = false
@State private var showingLocalImport = false
@State private var showingAppleMusic = false
@State private var showingDownloads = false
/// Non-nil while a picked folder/files are being scanned + copied in.
@State private var isImportingLocal = false

Expand All @@ -22,6 +23,9 @@ struct LibrarySheetView: View {
.miniPlayerDock()
.navigationTitle("Continuity")
.toolbar {
ToolbarItem(placement: .topBarLeading) {
DownloadsToolbarButton(showingDownloads: $showingDownloads)
}
// Every action is a primaryAction so nothing collapses into a dead "…"
// overflow menu (secondaryAction items did, and looked broken).
ToolbarItem(placement: .primaryAction) {
Expand Down Expand Up @@ -69,6 +73,9 @@ struct LibrarySheetView: View {
.sheet(isPresented: $showingAdd) {
AddMusicView()
}
.sheet(isPresented: $showingDownloads) {
DownloadsView()
}
.sheet(isPresented: $showingAppleMusic) {
AppleMusicImportView()
}
Expand Down Expand Up @@ -111,3 +118,31 @@ private struct AddBadgeIcon: View {
.padding(.trailing, 4) // room for the badge inside the tap target
}
}

/// Isolated so byte-level download progress only invalidates this control, not the library grid.
private struct DownloadsToolbarButton: View {
@Environment(PreparationQueue.self) private var prepQueue
@Binding var showingDownloads: Bool

var body: some View {
let count = prepQueue.ingestJobs.count
return Button {
showingDownloads = true
} label: {
Image(systemName: count == 0 ? "arrow.down.circle" : "arrow.down.circle.fill")
}
.accessibilityLabel("Downloads")
.overlay(alignment: .topTrailing) {
if count > 0 {
Text("\(count)")
.font(.system(size: 9, weight: .bold))
.padding(.horizontal, 4)
.padding(.vertical, 1)
.background(.tint, in: Capsule())
.foregroundStyle(.white)
.offset(x: 8, y: -8)
.accessibilityHidden(true)
}
}
}
}
28 changes: 28 additions & 0 deletions App/Continuity/Views/LibraryView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ struct LibraryView: View {
@Query(sort: \Playlist.createdAt) private var playlists: [Playlist]
@Environment(Player.self) private var player
@Environment(\.modelContext) private var modelContext
@Environment(PreparationQueue.self) private var prepQueue
@State private var searchText = ""
/// Playlist awaiting destructive confirmation — set from the context menu, cleared on dismiss.
@State private var playlistPendingDelete: Playlist?
Expand Down Expand Up @@ -69,6 +70,13 @@ struct LibraryView: View {
}
.buttonStyle(.plain)
.contextMenu {
if playlist.tracks.contains(where: { !$0.isDemo && $0.prepState != .ready }) {
Button {
prepQueue.prioritize(playlist: playlist, in: modelContext)
} label: {
Label("Download First", systemImage: "arrow.up.to.line")
}
}
Button(role: .destructive) {
playlistPendingDelete = playlist
} label: {
Expand Down Expand Up @@ -101,6 +109,8 @@ private struct SearchResultsView: View {
let playlists: [Playlist]
let query: String
@Environment(Player.self) private var player
@Environment(PreparationQueue.self) private var prepQueue
@Environment(\.modelContext) private var modelContext
@Environment(MainPagerState.self) private var pagerState

private var matchingPlaylists: [Playlist] {
Expand Down Expand Up @@ -148,6 +158,15 @@ private struct SearchResultsView: View {
}
}
}
.contextMenu {
if playlist.tracks.contains(where: { !$0.isDemo && $0.prepState != .ready }) {
Button {
prepQueue.prioritize(playlist: playlist, in: modelContext)
} label: {
Label("Download First", systemImage: "arrow.up.to.line")
}
}
}
}
}
}
Expand Down Expand Up @@ -184,6 +203,8 @@ private struct SearchSongRow: View {
let track: Track
let play: () -> Void
@Environment(Player.self) private var player
@Environment(PreparationQueue.self) private var prepQueue
@Environment(\.modelContext) private var modelContext

var body: some View {
Button(action: play) {
Expand Down Expand Up @@ -211,6 +232,13 @@ private struct SearchSongRow: View {
} label: {
Label("Play Next", systemImage: "text.line.first.and.arrowtriangle.forward")
}
if !track.isDemo, track.prepState != .ready {
Button {
prepQueue.prioritize(track, in: modelContext)
} label: {
Label("Download First", systemImage: "arrow.up.to.line")
}
}
}
}
}
Expand Down
17 changes: 17 additions & 0 deletions App/Continuity/Views/PlaylistDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ struct PlaylistDetailView: View {
} label: {
Label("Play Next", systemImage: "text.line.first.and.arrowtriangle.forward")
}
if !track.isDemo, track.prepState != .ready {
Button {
prepQueue.prioritize(track, in: modelContext)
} label: {
Label("Download First", systemImage: "arrow.up.to.line")
}
}
}
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
Expand Down Expand Up @@ -90,6 +97,16 @@ struct PlaylistDetailView: View {
.buttonStyle(.glassProminent)
.padding(.top, 4)

if tracks.contains(where: { !$0.isDemo && $0.prepState != .ready }) {
Button {
prepQueue.prioritize(playlist: playlist, in: modelContext)
} label: {
Label("Download First", systemImage: "arrow.up.to.line")
.frame(maxWidth: 200)
}
.buttonStyle(.bordered)
}

// Source-backed playlists mirror a remote list: manual sync + the auto-sync opt-out.
if playlist.isSourceBacked {
HStack(spacing: 16) {
Expand Down
5 changes: 4 additions & 1 deletion App/Continuity/Views/RootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,11 @@ struct RootView: View {
return ids.compactMap { byID[$0] }
}
LibraryCleanup.sweepOrphanedFiles(in: modelContext)
prepQueue.resumePreparation(in: modelContext)
// Restore the last song before walking the library for resume/sync — otherwise
// a large unfinished import occupies the main actor until the first frame.
restorePlaybackSession()
await Task.yield()
await prepQueue.resumePreparation(in: modelContext)
// Launch-time polling pass over source-backed playlists (per-playlist opt-out).
prepQueue.autoSyncIfNeeded(in: modelContext)
}
Expand Down
12 changes: 12 additions & 0 deletions App/Continuity/Views/UpNextView.swift
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import SwiftUI
import Playback
import Domain
import Ingest
import ContinuityCore

/// The queue page (below Now Playing): what plays next, with drag-to-reorder and
/// swipe-to-remove, plus the Flow toggle that reorders the upcoming tracks into a
/// key/tempo-compatible DJ sequence.
struct UpNextView: View {
@Environment(Player.self) private var player
@Environment(PreparationQueue.self) private var prepQueue
@Environment(\.modelContext) private var modelContext
@Environment(MainPagerState.self) private var pagerState
// Persisted as a mode label; toggling ON reorders once, toggling OFF is not an undo.
@AppStorage("flowMode.v1") private var flowMode = false
Expand Down Expand Up @@ -81,6 +84,15 @@ struct UpNextView: View {
Text(track.artist).font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
}
.contextMenu {
if !track.isDemo, track.prepState != .ready {
Button {
prepQueue.prioritize(track, in: modelContext)
} label: {
Label("Download First", systemImage: "arrow.up.to.line")
}
}
}
}

/// Reorders only the upcoming tracks. The current track is passed as the chain's anchor —
Expand Down
14 changes: 13 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,16 @@ transfer or a captive-portal page used to be cached as "the model" forever).
- `Player.prepare`/`restore` stay **metadata-only** (no engine build, no `notifyUpcoming()`)
— see AGENTS.md jetsam gotcha.

### Playlist import "spins forever, then orange retry" (Sept 2026, resolved)
Every imported track resolved fine, then every ranged download got HTTP 403 → mapped to
`streamURLExpired` → re-resolve → 403 again → `scheduleRetry` kept the row `.pending` through
5 whole-track attempts (minutes of spinner) → `.failed`. Root cause: the pinned YouTubeKit
(7cc8190, July) fetched stream URLs via the ANDROID_VR InnerTube client, which YouTube stopped
serving past the first chunk in mid-August 2026. Fix: pin YouTubeKit `exact: "0.4.9"`
(visionOS/web clients + embed fallback). Verified with the opt-in live probe test on the
simulator: 0/8 tracks ready before, 8/8 after (full files, BPM analysed). See the AGENTS.md
gotcha for the diagnosis recipe.

### Catalog search (PR #108)
iTunes Search API (no key) for songs/albums; custom in-app keyboard with
`CatalogAutocorrect` (ContinuityCore, Linux-tested) learning vocabulary from results + the
Expand All @@ -114,7 +124,9 @@ existing `searchQuery` → YouTube ingest path.
automatically after processing.
- Known failure modes already hit: App-Manager-role key (fixed — Admin key created);
empty/placeholder secrets from copy-pasted commands; **ITMS error 90382 "Upload limit
reached"** = Apple's per-app daily cap — wait for the 24h window, nothing to fix.
reached"** = Apple's per-app daily cap — wait for the 24h window, nothing to fix;
**Archive step exits 65 after ~30s** = no `Continuity` scheme in the generated project
(see the AGENTS.md scheme gotcha) — `project.yml` must keep the target-level `scheme:`.
- App Store Connect still has a legacy **Xcode Cloud "Archive – iOS"** workflow producing
`action_required` checks on PRs; it's ASC-side, unrelated to code, and competes for upload
quota — worth disabling in ASC.
Expand Down
6 changes: 3 additions & 3 deletions Packages/ContinuityKit/Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Packages/ContinuityKit/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ let package = Package(
],
dependencies: [
.package(path: "../ContinuityCore"),
.package(url: "https://github.com/alexeichhorn/YouTubeKit", branch: "main"),
.package(url: "https://github.com/alexeichhorn/YouTubeKit", exact: "0.4.9"),
.package(url: "https://github.com/microsoft/onnxruntime-swift-package-manager", exact: "1.20.0"),
],
targets: [
Expand Down
Loading
Loading