diff --git a/App/Continuity/Info.plist b/App/Continuity/Info.plist
index ddeccd2..2cb81ab 100644
--- a/App/Continuity/Info.plist
+++ b/App/Continuity/Info.plist
@@ -18,23 +18,10 @@
APPL
CFBundleShortVersionString
$(MARKETING_VERSION)
- CFBundleURLTypes
-
-
- CFBundleURLName
- com.sanylax.continuity
- CFBundleURLSchemes
-
- continuity
-
-
-
CFBundleVersion
$(CURRENT_PROJECT_VERSION)
ITSAppUsesNonExemptEncryption
- NSAppleMusicUsageDescription
- Continuity reads your Apple Music playlists so you can import them into your library.
UIBackgroundModes
audio
diff --git a/App/Continuity/Library/LinkImporter.swift b/App/Continuity/Library/LinkImporter.swift
deleted file mode 100644
index 711662e..0000000
--- a/App/Continuity/Library/LinkImporter.swift
+++ /dev/null
@@ -1,140 +0,0 @@
-import Foundation
-import SwiftData
-import Ingest
-import Domain
-import ContinuityCore
-
-/// Single home for link → import routing, shared by AddMusicView and the URL-scheme /
-/// clipboard handlers so YouTube/Spotify classification lives in exactly one place.
-@MainActor
-enum LinkImporter {
-
- /// What a raw link resolves to.
- enum Link {
- case spotify(SpotifyLink)
- case youtubePlaylist(String)
- case youtubeVideo(String)
-
- /// Source name for confirmation UI ("Import from YouTube?").
- var sourceName: String {
- switch self {
- case .spotify: return "Spotify"
- case .youtubePlaylist, .youtubeVideo: return "YouTube"
- }
- }
-
- /// Whether this imports a whole playlist (vs. adding a single video).
- var isPlaylistImport: Bool {
- switch self {
- case .spotify, .youtubePlaylist: return true
- case .youtubeVideo: return false
- }
- }
-
- /// Noun used in error messages ("Couldn't import that …").
- var noun: String {
- switch self {
- case .spotify(let link): return "Spotify \(link.kind.rawValue)"
- case .youtubePlaylist: return "playlist"
- case .youtubeVideo: return "video"
- }
- }
- }
-
- /// Pure classification of pasted/shared text. Nil if no importable link is found.
- nonisolated static func classify(_ raw: String) -> Link? {
- let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !trimmed.isEmpty else { return nil }
- if let link = SpotifyURL.parse(trimmed) { return .spotify(link) }
- if let link = YouTubeURL.parse(trimmed) {
- if let playlistID = link.playlistID { return .youtubePlaylist(playlistID) }
- if let videoID = link.videoID { return .youtubeVideo(videoID) }
- }
- return nil
- }
-
- /// Kicks off the import for a classified link. Playlist imports resolve before returning;
- /// single videos enqueue instantly. Throws the raw resolver error — map it with
- /// `errorMessage(_:noun:)` for display.
- static func run(
- _ link: Link,
- sourceURL: String,
- queue: PreparationQueue,
- in modelContext: ModelContext
- ) async throws {
- switch link {
- case .spotify(let spotifyLink):
- _ = try await queue.importSpotifyPlaylist(spotifyLink, in: modelContext)
- case .youtubePlaylist(let playlistID):
- _ = try await queue.importPlaylist(playlistID: playlistID, in: modelContext)
- case .youtubeVideo(let videoID):
- addSingleVideo(videoID, sourceURL: sourceURL, queue: queue, in: modelContext)
- }
- }
-
- /// Maps a resolve failure to a message that names the actual cause. Retryable failures
- /// (network/rate-limit) already retried inside the resolver, so reaching here means they
- /// persisted — the message tells the user to try again rather than blaming their playlist.
- nonisolated static func errorMessage(_ error: Error, noun: String) -> String {
- switch error as? IngestError {
- case .network:
- return "Couldn't reach the server. Check your connection and try again."
- case .rateLimited:
- return "Too many requests right now — please try again in a minute."
- case .sourceUnavailable:
- return "That \(noun) looks private or empty. Make sure it's public and try again."
- default:
- return "Couldn't import that \(noun). Please try again."
- }
- }
-
- /// Builds a placeholder track in the shared "From YouTube" playlist and enqueues it.
- private static func addSingleVideo(
- _ videoID: String,
- sourceURL: String,
- queue: PreparationQueue,
- in modelContext: ModelContext
- ) {
- let playlist = findOrCreateYouTubePlaylist(in: modelContext)
-
- // Title/artist/duration start as placeholders so the row appears instantly; the
- // ingest pipeline replaces them with the real oEmbed title/channel + decoded duration.
- let track = Track(
- title: "YouTube Video (\(videoID.prefix(6)))",
- artist: "YouTube",
- durationSeconds: 0,
- artworkSymbol: playlist.artworkSymbol,
- // Vary the gradient per track so rows are visually distinct.
- gradientSeed: playlist.gradientSeed * 100 + playlist.tracks.count,
- sortIndex: playlist.tracks.count,
- prepState: .pending,
- youtubeVideoID: videoID,
- sourceURLString: sourceURL
- )
-
- playlist.tracks.append(track)
- modelContext.insert(track)
- playlist.touch() // membership changed → resort the library
-
- queue.enqueue(track, in: modelContext)
- }
-
- /// Returns the shared "From YouTube" playlist, creating and inserting it if missing.
- private static func findOrCreateYouTubePlaylist(in modelContext: ModelContext) -> Playlist {
- let title = "From YouTube"
- var descriptor = FetchDescriptor(predicate: #Predicate { $0.title == title })
- descriptor.fetchLimit = 1
- if let existing = try? modelContext.fetch(descriptor).first {
- return existing
- }
-
- let playlist = Playlist(
- title: title,
- subtitle: "Added from YouTube",
- artworkSymbol: "arrow.down.circle.fill",
- gradientSeed: 11
- )
- modelContext.insert(playlist)
- return playlist
- }
-}
diff --git a/App/Continuity/Resources/PrivacyInfo.xcprivacy b/App/Continuity/Resources/PrivacyInfo.xcprivacy
new file mode 100644
index 0000000..073a872
--- /dev/null
+++ b/App/Continuity/Resources/PrivacyInfo.xcprivacy
@@ -0,0 +1,48 @@
+
+
+
+
+
+ NSPrivacyTracking
+
+ NSPrivacyTrackingDomains
+
+ NSPrivacyCollectedDataTypes
+
+ NSPrivacyAccessedAPITypes
+
+
+
+ NSPrivacyAccessedAPIType
+ NSPrivacyAccessedAPICategoryUserDefaults
+ NSPrivacyAccessedAPITypeReasons
+
+ CA92.1
+
+
+
+
+ NSPrivacyAccessedAPIType
+ NSPrivacyAccessedAPICategoryFileTimestamp
+ NSPrivacyAccessedAPITypeReasons
+
+ C617.1
+
+
+
+
+ NSPrivacyAccessedAPIType
+ NSPrivacyAccessedAPICategorySystemBootTime
+ NSPrivacyAccessedAPITypeReasons
+
+ 35F9.1
+
+
+
+
+
diff --git a/App/Continuity/Views/AddMusicView.swift b/App/Continuity/Views/AddMusicView.swift
deleted file mode 100644
index 7ad4439..0000000
--- a/App/Continuity/Views/AddMusicView.swift
+++ /dev/null
@@ -1,124 +0,0 @@
-import SwiftUI
-import Ingest
-import Domain
-import SwiftData
-import ContinuityCore
-
-/// Sheet for pasting a music link and queueing it for ingestion. Detects the source locally and
-/// routes it:
-/// - **YouTube video** → added to the shared "From YouTube" playlist.
-/// - **YouTube playlist** → imported as its own library playlist.
-/// - **Spotify playlist/album** → its tracklist is imported as a new playlist, with each song's
-/// audio re-sourced from YouTube (Spotify audio is DRM-protected and can't feed our engine).
-///
-/// Classification + import routing live in `LinkImporter` (shared with the URL-scheme and
-/// clipboard handlers); the `PreparationQueue` does the resolving/searching/downloading.
-struct AddMusicView: View {
- @Environment(\.dismiss) private var dismiss
- @Environment(\.modelContext) private var modelContext
- @Environment(PreparationQueue.self) private var preparationQueue
-
- @State private var text = ""
- @State private var errorMessage: String?
- /// True while a playlist is being resolved (page fetch + track creation).
- @State private var isImporting = false
-
- private var trimmed: String {
- text.trimmingCharacters(in: .whitespacesAndNewlines)
- }
-
- /// What the pasted text resolves to, if anything.
- private var detected: LinkImporter.Link? {
- LinkImporter.classify(trimmed)
- }
-
- /// Whether the detected input imports a whole playlist (vs. adding a single video).
- private var isImportAction: Bool {
- detected?.isPlaylistImport ?? false
- }
-
- var body: some View {
- NavigationStack {
- Form {
- Section {
- TextField("YouTube or Spotify link", text: $text, axis: .vertical)
- .textInputAutocapitalization(.never)
- .autocorrectionDisabled()
- .lineLimit(1...3)
- .onChange(of: text) { errorMessage = nil }
- } footer: {
- footerContent
- }
-
- Section {
- Button(action: add) {
- HStack {
- if isImporting {
- ProgressView()
- Text("Importing…")
- } else {
- Label(
- isImportAction ? "Import Playlist" : "Add",
- systemImage: isImportAction ? "music.note.list" : "arrow.down.circle.fill"
- )
- }
- }
- .frame(maxWidth: .infinity)
- }
- .buttonStyle(.glassProminent)
- .disabled(trimmed.isEmpty || isImporting)
- }
- .listRowInsets(EdgeInsets())
- .listRowBackground(Color.clear)
- }
- .navigationTitle("Add Music")
- .navigationBarTitleDisplayMode(.inline)
- .toolbar {
- ToolbarItem(placement: .cancellationAction) {
- Button("Cancel") { dismiss() }
- .disabled(isImporting)
- }
- }
- }
- }
-
- @ViewBuilder
- private var footerContent: some View {
- if let errorMessage {
- Label(errorMessage, systemImage: "exclamationmark.triangle.fill")
- .foregroundStyle(.red)
- .font(.footnote)
- } else {
- switch detected {
- case .spotify?:
- Text("Spotify playlist — each song is matched to YouTube for audio and imported as a new playlist.")
- case .youtubePlaylist?:
- Text("This is a playlist link — every track will be imported as a new playlist.")
- default:
- Text("Paste a YouTube video or playlist link, or a Spotify playlist/album link.")
- }
- }
- }
-
- /// Routes the pasted link through the shared importer, or shows an inline error. Shows the
- /// spinner while resolving, dismisses on success, and maps failures to a cause-specific
- /// message (so a transient network blip doesn't read as "private").
- private func add() {
- guard let link = detected else {
- errorMessage = "Couldn't find a YouTube or Spotify link in that text."
- return
- }
- isImporting = true
- errorMessage = nil
- let source = trimmed
- Task {
- defer { isImporting = false }
- do {
- try await LinkImporter.run(link, sourceURL: source, queue: preparationQueue, in: modelContext)
- dismiss()
- } catch {
- errorMessage = LinkImporter.errorMessage(error, noun: link.noun)
- }
- }
- }
-}
diff --git a/App/Continuity/Views/AppleMusicImportView.swift b/App/Continuity/Views/AppleMusicImportView.swift
deleted file mode 100644
index a453f0a..0000000
--- a/App/Continuity/Views/AppleMusicImportView.swift
+++ /dev/null
@@ -1,211 +0,0 @@
-import SwiftUI
-import ContinuityCore
-import Ingest
-
-/// Sheet for importing playlists out of the user's Apple Music library.
-///
-/// **Metadata only.** Apple Music audio is DRM-protected and can't feed our engine, so an
-/// imported playlist keeps each song's title + artist and the ingest pipeline re-sources the
-/// recording from YouTube — the same trade the Spotify importer makes.
-struct AppleMusicImportView: View {
- @Environment(\.dismiss) private var dismiss
- @Environment(\.modelContext) private var modelContext
- @Environment(PreparationQueue.self) private var prepQueue
- @Environment(\.openURL) private var openURL
-
- /// What the sheet is currently showing. Access state and load state are one enum because
- /// they're mutually exclusive — there's no "denied but also listing playlists".
- private enum Phase {
- case askingPermission
- case denied
- case loading
- case loaded([AppleMusicPlaylistContents])
- case failed(String)
- }
-
- @State private var phase: Phase = .loading
- @State private var selection: Set = []
- @State private var isImporting = false
-
- var body: some View {
- NavigationStack {
- content
- .navigationTitle("Apple Music")
- .navigationBarTitleDisplayMode(.inline)
- .toolbar {
- ToolbarItem(placement: .cancellationAction) {
- Button("Cancel") { dismiss() }
- .disabled(isImporting)
- }
- }
- }
- .task { await start() }
- }
-
- @ViewBuilder
- private var content: some View {
- switch phase {
- case .askingPermission, .loading:
- ProgressView()
- .frame(maxWidth: .infinity, maxHeight: .infinity)
-
- case .denied:
- message(
- symbol: "lock.fill",
- title: "No Access to Apple Music",
- detail: "Continuity needs permission to read your library. Enable Media & Apple Music in Settings."
- ) {
- Button("Open Settings") {
- if let url = URL(string: UIApplication.openSettingsURLString) { openURL(url) }
- }
- .buttonStyle(.glassProminent)
- }
-
- case .failed(let reason):
- message(symbol: "exclamationmark.triangle.fill", title: "Couldn't Read Library", detail: reason) {
- Button("Try Again") { Task { await load() } }
- .buttonStyle(.glassProminent)
- }
-
- case .loaded(let playlists) where playlists.isEmpty:
- message(
- symbol: "music.note.list",
- title: "No Playlists",
- detail: "Playlists you create in the Music app will show up here."
- ) { EmptyView() }
-
- case .loaded(let playlists):
- playlistList(playlists)
- }
- }
-
- private func playlistList(_ playlists: [AppleMusicPlaylistContents]) -> some View {
- List {
- Section {
- ForEach(playlists) { playlist in
- Button {
- toggle(playlist.id)
- } label: {
- HStack {
- VStack(alignment: .leading, spacing: 2) {
- Text(playlist.name ?? "Untitled Playlist")
- .foregroundStyle(.primary)
- Text("\(playlist.tracks.count) songs")
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- Spacer()
- if selection.contains(playlist.id) {
- Image(systemName: "checkmark")
- .foregroundStyle(.tint)
- }
- }
- .contentShape(Rectangle())
- }
- .buttonStyle(.plain)
- }
- } footer: {
- Text("Songs are matched to YouTube for audio — Apple Music's own files are protected and can't be mixed.")
- }
-
- Section {
- Button(action: importSelected) {
- HStack {
- if isImporting {
- ProgressView()
- Text("Importing…")
- } else {
- Label(importLabel, systemImage: "square.and.arrow.down")
- }
- }
- .frame(maxWidth: .infinity)
- }
- .buttonStyle(.glassProminent)
- .disabled(selection.isEmpty || isImporting)
- }
- .listRowInsets(EdgeInsets())
- .listRowBackground(Color.clear)
- }
- }
-
- private var importLabel: String {
- selection.count <= 1 ? "Import Playlist" : "Import \(selection.count) Playlists"
- }
-
- /// Shared empty/error layout so the three non-list states look like one screen.
- private func message(
- symbol: String,
- title: String,
- detail: String,
- @ViewBuilder action: () -> Action
- ) -> some View {
- VStack(spacing: 12) {
- Image(systemName: symbol)
- .font(.largeTitle)
- .foregroundStyle(.secondary)
- Text(title).font(.headline)
- Text(detail)
- .font(.footnote)
- .foregroundStyle(.secondary)
- .multilineTextAlignment(.center)
- action()
- .padding(.top, 4)
- }
- .padding(32)
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- }
-
- private func toggle(_ id: String) {
- if selection.contains(id) { selection.remove(id) } else { selection.insert(id) }
- }
-
- /// Prompts on first open, then loads. Re-opening after a grant skips straight to loading.
- private func start() async {
- switch prepQueue.appleMusicAccess {
- case .authorized:
- await load()
- case .denied:
- phase = .denied
- case .notDetermined:
- phase = .askingPermission
- let granted = await prepQueue.requestAppleMusicAccess()
- if granted == .authorized { await load() } else { phase = .denied }
- }
- }
-
- private func load() async {
- phase = .loading
- do {
- phase = .loaded(try await prepQueue.appleMusicPlaylists())
- } catch {
- phase = .failed("Your library couldn't be read. Try again in a moment.")
- }
- }
-
- /// Imports every checked playlist. A single failure doesn't abandon the rest — the sheet
- /// stays open only if nothing at all got imported.
- private func importSelected() {
- guard case .loaded(let playlists) = phase else { return }
- let picked = playlists.filter { selection.contains($0.id) }
- guard !picked.isEmpty else { return }
-
- isImporting = true
- Task {
- defer { isImporting = false }
- var imported = 0
- for playlist in picked {
- do {
- try await prepQueue.importAppleMusicPlaylist(playlist, in: modelContext)
- imported += 1
- } catch {
- continue
- }
- }
- if imported > 0 {
- dismiss()
- } else {
- phase = .failed("Those playlists couldn't be imported.")
- }
- }
- }
-}
diff --git a/App/Continuity/Views/ContinuityApp.swift b/App/Continuity/Views/ContinuityApp.swift
index 689b454..8239690 100644
--- a/App/Continuity/Views/ContinuityApp.swift
+++ b/App/Continuity/Views/ContinuityApp.swift
@@ -8,7 +8,7 @@ import SwiftData
struct ContinuityApp: App {
let container: ModelContainer
@State private var player = Player()
- /// Drives YouTube ingestion (resolve → download → ready) for newly added tracks.
+ /// Local-file import, launch-time healing, analysis, and demand-driven stem separation.
@State private var prepQueue = PreparationQueue()
init() {
diff --git a/App/Continuity/Views/DownloadsView.swift b/App/Continuity/Views/DownloadsView.swift
deleted file mode 100644
index 22ff1ad..0000000
--- a/App/Continuity/Views/DownloadsView.swift
+++ /dev/null
@@ -1,102 +0,0 @@
-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