diff --git a/App/Continuity/Continuity.entitlements b/App/Continuity/Continuity.entitlements
deleted file mode 100644
index 1f8d13d..0000000
--- a/App/Continuity/Continuity.entitlements
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
-
- com.apple.security.application-groups
-
- group.com.sanylax.continuity
-
-
-
diff --git a/App/Continuity/Info.plist b/App/Continuity/Info.plist
index 047e700..2cb81ab 100644
--- a/App/Continuity/Info.plist
+++ b/App/Continuity/Info.plist
@@ -18,17 +18,6 @@
APPL
CFBundleShortVersionString
$(MARKETING_VERSION)
- CFBundleURLTypes
-
-
- CFBundleURLName
- com.sanylax.continuity
- CFBundleURLSchemes
-
- continuity
-
-
-
CFBundleVersion
$(CURRENT_PROJECT_VERSION)
ITSAppUsesNonExemptEncryption
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/Views/AddMusicView.swift b/App/Continuity/Views/AddMusicView.swift
index 7ad4439..b34f77b 100644
--- a/App/Continuity/Views/AddMusicView.swift
+++ b/App/Continuity/Views/AddMusicView.swift
@@ -2,71 +2,43 @@ import SwiftUI
import Ingest
import Domain
import SwiftData
-import ContinuityCore
+import UniformTypeIdentifiers
-/// 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.
+/// Sheet for importing audio files from the Files app into the library. Picked files are
+/// copied into the app's audio cache, their embedded metadata (title/artist/artwork) is read,
+/// and the `PreparationQueue` runs BPM/key/loudness analysis in the background.
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 showingFilePicker = false
+ /// True while picked files are being copied + their metadata read.
@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
- }
+ /// Post-import confirmation ("Imported 3 songs"), or a failure note.
+ @State private var resultMessage: String?
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) {
+ Button {
+ showingFilePicker = true
+ } label: {
HStack {
if isImporting {
ProgressView()
Text("Importing…")
} else {
- Label(
- isImportAction ? "Import Playlist" : "Add",
- systemImage: isImportAction ? "music.note.list" : "arrow.down.circle.fill"
- )
+ Label("Choose Files", systemImage: "folder.badge.plus")
}
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.glassProminent)
- .disabled(trimmed.isEmpty || isImporting)
+ .disabled(isImporting)
+ } footer: {
+ footerContent
}
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
@@ -74,51 +46,41 @@ struct AddMusicView: View {
.navigationTitle("Add Music")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
- ToolbarItem(placement: .cancellationAction) {
- Button("Cancel") { dismiss() }
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Done") { dismiss() }
.disabled(isImporting)
}
}
+ .fileImporter(
+ isPresented: $showingFilePicker,
+ allowedContentTypes: [.audio],
+ allowsMultipleSelection: true
+ ) { result in
+ handlePicked(result)
+ }
}
}
@ViewBuilder
private var footerContent: some View {
- if let errorMessage {
- Label(errorMessage, systemImage: "exclamationmark.triangle.fill")
- .foregroundStyle(.red)
+ if let resultMessage {
+ Label(resultMessage, systemImage: "checkmark.circle.fill")
+ .foregroundStyle(.green)
.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.")
- }
+ Text("Import songs from the Files app. Title, artist, and artwork are read from the file; tempo and key are analyzed automatically for seamless transitions.")
}
}
- /// 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
- }
+ /// Copies the picked files into the library, then shows how many made it.
+ private func handlePicked(_ result: Result<[URL], Error>) {
+ guard case .success(let urls) = result, !urls.isEmpty else { return }
isImporting = true
- errorMessage = nil
- let source = trimmed
+ resultMessage = nil
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)
- }
+ let count = await preparationQueue.importLocalFiles(urls, in: modelContext)
+ isImporting = false
+ resultMessage = count == 1 ? "Imported 1 song" : "Imported \(count) songs"
}
}
}
diff --git a/App/Continuity/Views/ContinuityApp.swift b/App/Continuity/Views/ContinuityApp.swift
index 1be724e..33ef8bc 100644
--- a/App/Continuity/Views/ContinuityApp.swift
+++ b/App/Continuity/Views/ContinuityApp.swift
@@ -8,17 +8,13 @@ import SwiftData
struct ContinuityApp: App {
let container: ModelContainer
@State private var player = Player()
- /// Drives YouTube ingestion (resolve → download → ready) for newly added tracks.
+ /// Drives local-file ingestion (import → analyse → ready) for newly added tracks.
@State private var prepQueue = PreparationQueue()
init() {
do {
let schema = Schema([Playlist.self, Track.self])
- // Pin the store to the app container. `groupContainer` defaults to `.automatic`,
- // which (with our share-extension app group entitlement) put SwiftData in the
- // group container — unused by the extension, noisy on first launch, and desynced
- // from the `UserDefaults.standard` seed flag.
- Self.migrateStoreOutOfAppGroupIfNeeded()
+ // Pin the store to the app container explicitly.
let configuration = ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: false,
@@ -40,28 +36,4 @@ struct ContinuityApp: App {
}
.modelContainer(container)
}
-
- /// 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.
- private static func migrateStoreOutOfAppGroupIfNeeded() {
- let fm = FileManager.default
- guard let groupRoot = fm.containerURL(
- forSecurityApplicationGroupIdentifier: "group.com.sanylax.continuity"
- ),
- let appSupport = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
- else { return }
-
- let legacyDir = groupRoot.appendingPathComponent("Library/Application Support", isDirectory: true)
- let legacyStore = legacyDir.appendingPathComponent("default.store")
- let appStore = appSupport.appendingPathComponent("default.store")
- guard fm.fileExists(atPath: legacyStore.path),
- !fm.fileExists(atPath: appStore.path) else { return }
-
- try? fm.createDirectory(at: appSupport, withIntermediateDirectories: true)
- for name in ["default.store", "default.store-wal", "default.store-shm"] {
- let src = legacyDir.appendingPathComponent(name)
- guard fm.fileExists(atPath: src.path) else { continue }
- try? fm.moveItem(at: src, to: appSupport.appendingPathComponent(name))
- }
- }
}
diff --git a/App/Continuity/Views/LibrarySheetView.swift b/App/Continuity/Views/LibrarySheetView.swift
index b52b865..293d7ae 100644
--- a/App/Continuity/Views/LibrarySheetView.swift
+++ b/App/Continuity/Views/LibrarySheetView.swift
@@ -6,9 +6,7 @@ import Playback
/// with a mini player that jumps back to the home page.
struct LibrarySheetView: View {
@Environment(Player.self) private var player
- @Environment(PreparationQueue.self) private var prepQueue
@Environment(MainPagerState.self) private var pagerState
- @Environment(\.modelContext) private var modelContext
@State private var showingAdd = false
var body: some View {
@@ -16,17 +14,6 @@ struct LibrarySheetView: View {
LibraryView()
.navigationTitle("Continuity")
.toolbar {
- // Manual whole-library sync (source-backed playlists only).
- ToolbarItem(placement: .secondaryAction) {
- Button {
- prepQueue.syncAll(in: modelContext)
- } label: {
- Image(systemName: "arrow.triangle.2.circlepath")
- .symbolEffect(.rotate, isActive: !prepQueue.syncingPlaylistIDs.isEmpty)
- }
- .disabled(!prepQueue.syncingPlaylistIDs.isEmpty)
- .accessibilityLabel("Sync library")
- }
ToolbarItem(placement: .primaryAction) {
Button {
showingAdd = true
diff --git a/App/Continuity/Views/LibraryView.swift b/App/Continuity/Views/LibraryView.swift
index 15f97d4..5101f32 100644
--- a/App/Continuity/Views/LibraryView.swift
+++ b/App/Continuity/Views/LibraryView.swift
@@ -87,11 +87,11 @@ struct LibraryView: View {
/// surviving track shares.
private func delete(_ playlist: Playlist) {
let trackIDs = Set(playlist.tracks.map(\.id))
- let videoIDs = playlist.tracks.compactMap(\.youtubeVideoID)
+ let keys = playlist.tracks.map(\.stemKey)
player.handleDeleted(trackIDs: trackIDs)
modelContext.delete(playlist) // cascade deletes its tracks
try? modelContext.save()
- LibraryCleanup.removeOrphanedFiles(videoIDs: videoIDs, in: modelContext)
+ LibraryCleanup.removeOrphanedFiles(keys: keys, in: modelContext)
}
}
diff --git a/App/Continuity/Views/PlaylistDetailView.swift b/App/Continuity/Views/PlaylistDetailView.swift
index ebfcbd4..dd1c963 100644
--- a/App/Continuity/Views/PlaylistDetailView.swift
+++ b/App/Continuity/Views/PlaylistDetailView.swift
@@ -7,12 +7,9 @@ import Domain
struct PlaylistDetailView: View {
@Bindable var playlist: Playlist
@Environment(Player.self) private var player
- @Environment(PreparationQueue.self) private var prepQueue
@Environment(MainPagerState.self) private var pagerState
@Environment(\.modelContext) private var modelContext
- private var isSyncing: Bool { prepQueue.syncingPlaylistIDs.contains(playlist.id) }
-
var body: some View {
List {
// The header is a regular row — NOT a pinned section header, which in a plain list
@@ -26,13 +23,10 @@ struct PlaylistDetailView: View {
TrackRow(track: track, isCurrent: player.currentTrack?.id == track.id)
.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)
- pagerState.goToNowPlaying()
- }
+ // A failed track (its imported file went missing) can't be played.
+ guard track.prepState != .failed else { return }
+ player.play(tracks: playlist.orderedTracks, startAt: index)
+ pagerState.goToNowPlaying()
}
.contextMenu {
Button {
@@ -58,14 +52,12 @@ struct PlaylistDetailView: View {
/// Removes a track: the player drops it first (so no deck/queue reference dangles), then the
/// model goes, then any cached files no other track shares.
private func delete(_ track: Track) {
- let videoID = track.youtubeVideoID
+ let key = track.stemKey
player.handleDeleted(trackIDs: [track.id])
modelContext.delete(track)
playlist.touch() // membership changed → resort the library
try? modelContext.save()
- if let videoID {
- LibraryCleanup.removeOrphanedFiles(videoIDs: [videoID], in: modelContext)
- }
+ LibraryCleanup.removeOrphanedFiles(keys: [key], in: modelContext)
}
private var header: some View {
@@ -84,33 +76,6 @@ struct PlaylistDetailView: View {
}
.buttonStyle(.glassProminent)
.padding(.top, 4)
-
- // Source-backed playlists mirror a remote list: manual sync + the auto-sync opt-out.
- if playlist.isSourceBacked {
- HStack(spacing: 16) {
- Button {
- Task { await prepQueue.syncPlaylist(playlist, in: modelContext) }
- } label: {
- Label(isSyncing ? "Syncing…" : "Sync", systemImage: "arrow.triangle.2.circlepath")
- .font(.subheadline)
- }
- .buttonStyle(.bordered)
- .disabled(isSyncing)
-
- Toggle(isOn: $playlist.autoSyncEnabled) {
- Text("Auto-sync")
- .font(.subheadline)
- }
- .fixedSize()
- }
- .padding(.top, 2)
-
- if let synced = playlist.lastSyncedAt {
- Text("Synced \(synced.formatted(.relative(presentation: .named)))")
- .font(.caption2)
- .foregroundStyle(.tertiary)
- }
- }
}
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
@@ -166,8 +131,8 @@ private struct TrackRow: View {
ProgressView()
.controlSize(.mini)
case .failed:
- // Tapping the row retries a failed ingest — the retry glyph signals it's actionable.
- Image(systemName: "arrow.clockwise")
+ // The imported file went missing — the track can't play until re-imported.
+ Image(systemName: "exclamationmark.triangle")
.font(.caption.weight(.semibold))
.foregroundStyle(.orange)
case .ready:
diff --git a/App/Continuity/Views/RootView.swift b/App/Continuity/Views/RootView.swift
index b144755..7a8d721 100644
--- a/App/Continuity/Views/RootView.swift
+++ b/App/Continuity/Views/RootView.swift
@@ -1,6 +1,5 @@
import SwiftUI
import UIKit
-import Combine
import Ingest
import Playback
import Domain
@@ -13,63 +12,16 @@ struct RootView: View {
@Environment(Player.self) private var player
@Environment(PreparationQueue.self) private var prepQueue
@Environment(\.modelContext) private var modelContext
- @Environment(\.scenePhase) private var scenePhase
-
- /// Link awaiting user confirmation (from the URL scheme, a shared https URL, or the clipboard).
- @State private var pendingImport: PendingLinkImport?
- @State private var importError: String?
- /// Debounce: a clipboard URL is offered at most once, even across launches.
- @AppStorage("lastOfferedClipboardURL") private var lastOfferedClipboardURL = ""
- /// Last pasteboard generation we inspected — gates the banner-triggering reads below.
- /// In-memory on purpose: UIPasteboard.changeCount restarts from a small number after a
- /// device reboot, so a value persisted across launches can collide with a fresh generation
- /// and silently skip a genuinely new link. Re-checking once per launch is the correct cost.
- @State private var lastCheckedPasteboardChange = -1
-
- /// One publisher for the view's identity — inline `Timer.publish` in `body` is rebuilt (and
- /// its countdown reset) on every re-evaluation (scene transitions, alert state).
- @State private var syncTick = Timer.publish(every: 60, on: .main, in: .common).autoconnect()
var body: some View {
MainPagerView()
// On launch: drop cached files orphaned by deletions, resume unfinished ingestion,
// then bring back the previous playback session (or stage the first-run track).
.task {
- // Sync-driven deletions must clear the live queue before models are destroyed.
+ // Deletions must clear the live queue before models are destroyed.
prepQueue.onTracksDeleted = { [weak player] ids in
player?.handleDeleted(trackIDs: ids)
}
- // A changed sync re-mirrors the live queue when the current track belongs to the
- // synced playlist: what follows it matches the fresh remote order, earlier tracks
- // loop after (wrap-around next() semantics). Remote truth wins for playlist-backed
- // queues — manual Up Next edits / Flow ordering are overwritten.
- prepQueue.onPlaylistSynced = { [weak player] _, orderedTracks in
- Task { @MainActor in
- // replaceUpcoming would cancel a live blend mid-fade — and `changed`
- // fires once per remote edit, so a silent skip here would lose the new
- // order until the NEXT edit. Wait the blend out instead: blends last
- // seconds, syncs are a tick apart, so waiters never stack. Bounded in
- // case a blend chain keeps isTransitioning hot.
- var waited: TimeInterval = 0
- while player?.isTransitioning == true, waited < 30 {
- try? await Task.sleep(nanoseconds: 500_000_000)
- waited += 0.5
- }
- guard let player, !player.isTransitioning,
- let current = player.currentTrack else { return }
- // Tracks can die during the wait (manual delete mid-blend) — a dead
- // @Model in the queue crashes on next access.
- let live = orderedTracks.filter { $0.modelContext != nil }
- // Current track not in the synced playlist (or removed by the sync,
- // already handled via onTracksDeleted) → not our queue, leave it alone.
- guard let index = live.firstIndex(where: { $0.id == current.id })
- else { return }
- let rotated = Array(live[(index + 1)...]) + Array(live[..` carries the real link; a directly-shared
- /// http(s) URL *is* the link. Anything unclassifiable is silently ignored.
- private func handleIncomingLink(_ url: URL) {
- let raw: String
- if url.scheme?.lowercased() == "continuity" {
- guard let comps = URLComponents(url: url, resolvingAgainstBaseURL: false),
- let target = comps.queryItems?.first(where: { $0.name == "url" })?.value,
- !target.isEmpty else { return }
- raw = target
- } else {
- raw = url.absoluteString
- }
- guard let link = LinkImporter.classify(raw) else { return }
- offer(link, rawURL: raw)
- }
-
- /// Picks up a URL stashed by the share extension (written to group defaults because the
- /// extension can't talk to the app directly). Read-and-clear so each share is offered once.
- private func consumePendingSharedURL() {
- guard pendingImport == nil else { return }
- // Nil suite (missing app-group entitlement) degrades to a no-op rather than crashing.
- guard let defaults = UserDefaults(suiteName: "group.com.sanylax.continuity") else { return }
- guard let payload = defaults.dictionary(forKey: "pendingSharedURL.v1"),
- let raw = payload["url"] as? String else { return }
- defaults.removeObject(forKey: "pendingSharedURL.v1")
- guard let link = LinkImporter.classify(raw) else { return }
- offer(link, rawURL: raw)
- }
-
- /// Offers to import a YouTube/Spotify link sitting on the clipboard. Pattern detection is
- /// banner-free; the one `.string` read (only after detection says it's a URL) shows the
- /// iOS paste notice, which is acceptable for a confirmed hit.
- private func checkClipboardForImportableLink() {
- guard pendingImport == nil else { return } // don't stomp a link-open confirmation
- let pasteboard = UIPasteboard.general
- // changeCount is banner-free: inspect each clipboard generation once, else the
- // `.string` read below would flash the paste banner on every foreground.
- guard pasteboard.changeCount != lastCheckedPasteboardChange else { return }
- lastCheckedPasteboardChange = pasteboard.changeCount
- guard pasteboard.hasStrings || pasteboard.hasURLs else { return }
- Task {
- guard let patterns = try? await pasteboard.detectedPatterns(for: [\.probableWebURL]),
- patterns.contains(\.probableWebURL),
- let raw = (pasteboard.string ?? pasteboard.url?.absoluteString)?
- .trimmingCharacters(in: .whitespacesAndNewlines),
- raw != lastOfferedClipboardURL,
- let link = LinkImporter.classify(raw) else { return }
- // Debounce only once the offer actually presents — if another confirmation is up,
- // offer() drops the link and it must stay eligible for the next foreground pass.
- if offer(link, rawURL: raw) {
- lastOfferedClipboardURL = raw
- }
- }
- }
-
- /// Returns whether the confirmation was actually presented (false when another one is up).
- @discardableResult
- private func offer(_ link: LinkImporter.Link, rawURL: String) -> Bool {
- // First confirmation wins — replacing the item under a presented alert would leave it
- // showing (and importing) stale captured data. Covers the async clipboard task racing
- // a link-open, and a second link-open while the alert is up.
- guard pendingImport == nil else { return false }
- let host = URLComponents(string: rawURL.contains("://") ? rawURL : "https://" + rawURL)?
- .host ?? link.sourceName
- pendingImport = PendingLinkImport(link: link, rawURL: rawURL, host: host)
- return true
- }
-
- /// Same import path AddMusicView uses; failures surface in the "Import Failed" alert.
- private func startImport(_ pending: PendingLinkImport) {
- Task {
- do {
- try await LinkImporter.run(
- pending.link, sourceURL: pending.rawURL, queue: prepQueue, in: modelContext
- )
- } catch {
- importError = LinkImporter.errorMessage(error, noun: pending.link.noun)
- }
- }
}
// MARK: - Session restore
@@ -246,11 +72,3 @@ struct RootView: View {
player.prepare(tracks: queue, startAt: index)
}
}
-
-/// A classified link waiting for the user's "Import" confirmation.
-private struct PendingLinkImport: Identifiable {
- let id = UUID()
- let link: LinkImporter.Link
- let rawURL: String
- let host: String
-}
diff --git a/App/ShareExtension/ContinuityShare.entitlements b/App/ShareExtension/ContinuityShare.entitlements
deleted file mode 100644
index 1f8d13d..0000000
--- a/App/ShareExtension/ContinuityShare.entitlements
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
-
- com.apple.security.application-groups
-
- group.com.sanylax.continuity
-
-
-
diff --git a/App/ShareExtension/Info.plist b/App/ShareExtension/Info.plist
deleted file mode 100644
index b318ea7..0000000
--- a/App/ShareExtension/Info.plist
+++ /dev/null
@@ -1,36 +0,0 @@
-
-
-
-
- CFBundleDevelopmentRegion
- $(DEVELOPMENT_LANGUAGE)
- CFBundleDisplayName
- Continuity
- CFBundleExecutable
- $(EXECUTABLE_NAME)
- CFBundleIdentifier
- $(PRODUCT_BUNDLE_IDENTIFIER)
- CFBundleInfoDictionaryVersion
- 6.0
- CFBundleName
- $(PRODUCT_NAME)
- CFBundlePackageType
- XPC!
- CFBundleShortVersionString
- $(MARKETING_VERSION)
- CFBundleVersion
- $(CURRENT_PROJECT_VERSION)
- NSExtension
-
- NSExtensionAttributes
-
- NSExtensionActivationRule
- SUBQUERY(extensionItems, $extensionItem, SUBQUERY($extensionItem.attachments, $attachment, ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.url" OR ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.plain-text").@count >= 1).@count >= 1
-
- NSExtensionPointIdentifier
- com.apple.share-services
- NSExtensionPrincipalClass
- $(PRODUCT_MODULE_NAME).ShareViewController
-
-
-
diff --git a/App/ShareExtension/ShareViewController.swift b/App/ShareExtension/ShareViewController.swift
deleted file mode 100644
index 2311f9f..0000000
--- a/App/ShareExtension/ShareViewController.swift
+++ /dev/null
@@ -1,130 +0,0 @@
-import UIKit
-import UniformTypeIdentifiers
-import ContinuityCore
-
-/// Sheet-less share extension: grabs the shared URL, stashes it in the app group for the main
-/// app to import on next foreground, flashes a confirmation, and dismisses.
-final class ShareViewController: UIViewController {
-
- /// Handoff channel — must match the app-side reader (RootView) and both entitlements files.
- private static let appGroupID = "group.com.sanylax.continuity"
- private static let pendingURLKey = "pendingSharedURL.v1"
-
- override func viewDidLoad() {
- super.viewDidLoad()
- view.backgroundColor = .clear
- extractSharedURL()
- }
-
- /// Walks attachments for a URL (or a plain-text string that parses as one).
- private func extractSharedURL() {
- let providers = (extensionContext?.inputItems as? [NSExtensionItem])?
- .compactMap(\.attachments)
- .flatMap { $0 } ?? []
-
- if let provider = providers.first(where: {
- $0.hasItemConformingToTypeIdentifier(UTType.url.identifier)
- }) {
- provider.loadItem(forTypeIdentifier: UTType.url.identifier) { [weak self] item, _ in
- DispatchQueue.main.async {
- guard let self else { return }
- // Some hosts hand the URL over as bookmark-style Data rather than NSURL.
- let url = (item as? URL)
- ?? (item as? Data).flatMap { URL(dataRepresentation: $0, relativeTo: nil) }
- guard let url else { return self.finishUnsupported() }
- self.acceptIfImportable(url)
- }
- }
- return
- }
-
- // Spotify (and some browsers) share the link as plain text alongside/instead of public.url.
- if let provider = providers.first(where: {
- $0.hasItemConformingToTypeIdentifier(UTType.plainText.identifier)
- }) {
- provider.loadItem(forTypeIdentifier: UTType.plainText.identifier) { [weak self] item, _ in
- DispatchQueue.main.async {
- guard let self else { return }
- let raw = (item as? String)
- ?? (item as? Data).flatMap { String(data: $0, encoding: .utf8) }
- guard let raw, let url = Self.firstURL(in: raw) else {
- return self.finishUnsupported()
- }
- self.acceptIfImportable(url)
- }
- }
- return
- }
-
- finishUnsupported()
- }
-
- /// Only stash + cheer when ContinuityCore can classify the link — otherwise the main app
- /// would silently drop it after we already told the user it worked.
- private func acceptIfImportable(_ url: URL) {
- let raw = url.absoluteString
- let importable = SpotifyURL.parse(raw) != nil || YouTubeURL.parse(raw) != nil
- guard importable else { return finishUnsupported() }
- stash(url)
- }
-
- /// Writes the URL + timestamp to group defaults; the app reads and clears it on foreground.
- private func stash(_ url: URL) {
- guard let defaults = UserDefaults(suiteName: Self.appGroupID) else {
- return finish(message: "Couldn't hand off to Continuity", succeed: false)
- }
- defaults.set(
- ["url": url.absoluteString, "sharedAt": Date().timeIntervalSince1970],
- forKey: Self.pendingURLKey
- )
- finish(message: "Added to Continuity", succeed: true)
- }
-
- private func finishUnsupported() {
- finish(message: "Can't import this link", succeed: false)
- }
-
- /// Brief status pill, then complete or cancel the share request.
- private func finish(message: String, succeed: Bool) {
- let label = UILabel()
- label.text = message
- label.font = .preferredFont(forTextStyle: .headline)
- label.textColor = .label
- label.textAlignment = .center
- label.backgroundColor = .secondarySystemBackground
- label.layer.cornerRadius = 14
- label.layer.masksToBounds = true
- label.translatesAutoresizingMaskIntoConstraints = false
- label.isAccessibilityElement = true
- label.accessibilityTraits = .staticText
- view.addSubview(label)
- NSLayoutConstraint.activate([
- label.centerXAnchor.constraint(equalTo: view.centerXAnchor),
- label.centerYAnchor.constraint(equalTo: view.centerYAnchor),
- label.widthAnchor.constraint(greaterThanOrEqualToConstant: 240),
- label.widthAnchor.constraint(lessThanOrEqualToConstant: 300),
- label.heightAnchor.constraint(equalToConstant: 56)
- ])
- UIAccessibility.post(notification: .announcement, argument: message)
-
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) { [weak self] in
- guard let self else { return }
- if succeed {
- self.extensionContext?.completeRequest(returningItems: nil)
- } else {
- self.extensionContext?.cancelRequest(
- withError: NSError(domain: "com.sanylax.continuity.share", code: 1)
- )
- }
- }
- }
-
- /// First http(s) URL token in a shared text blob, if any.
- private static func firstURL(in raw: String) -> URL? {
- let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
- let range = NSRange(raw.startIndex.. URL {
- let destination = AudioCache.fileURL(videoID: resolved.videoID, container: resolved.container)
-
- // Cache hit: already on disk.
- if FileManager.default.fileExists(atPath: destination.path) {
- return destination
- }
-
- // Assemble into a unique temp file, guaranteed cleaned up on every exit path.
- let tempURL = FileManager.default.temporaryDirectory
- .appendingPathComponent("continuity-\(resolved.videoID)-\(UUID().uuidString).\(resolved.container)")
- defer { try? FileManager.default.removeItem(at: tempURL) }
-
- try await downloadRanged(from: resolved.url, to: tempURL)
-
- // Publish. Move (rename) is atomic on the same volume; if a concurrent download already
- // won the race and the file now exists, treat that as success rather than corrupting it.
- do {
- try FileManager.default.moveItem(at: tempURL, to: destination)
- } catch {
- if FileManager.default.fileExists(atPath: destination.path) {
- return destination
- }
- throw IngestError.downloadFailed(String(describing: error))
- }
- return destination
- }
-
- /// Streams `url` into `fileURL` using sequential `Range` requests until the whole file is fetched.
- private func downloadRanged(from url: URL, to fileURL: URL) async throws {
- FileManager.default.createFile(atPath: fileURL.path, contents: nil)
- let handle = try FileHandle(forWritingTo: fileURL)
- do {
- var offset = 0
- var totalSize: Int?
- repeat {
- let upperBound = offset + chunkSize - 1
- let (data, reportedTotal, isWholeFile) = try await fetchChunk(url: url, from: offset, to: upperBound)
- if let reportedTotal { totalSize = reportedTotal }
- if data.isEmpty { break } // nothing more to read
- if isWholeFile && offset > 0 {
- // The server ignored Range mid-download and sent the entire file — appending
- // it would duplicate every byte already written. Restart the file with this
- // complete body instead.
- try handle.truncate(atOffset: 0)
- offset = 0
- }
- try handle.write(contentsOf: data)
- offset += data.count
- } while totalSize == nil || offset < totalSize!
-
- try handle.close()
-
- if let totalSize, offset < totalSize {
- throw IngestError.downloadFailed("incomplete download: \(offset)/\(totalSize) bytes")
- }
- if offset == 0 {
- throw IngestError.downloadFailed("empty download")
- }
- } catch {
- try? handle.close()
- throw error
- }
- }
-
- /// Fetches one byte range. Returns the chunk, the total file size when the server reports it
- /// (via `Content-Range` on a 206, or the body length on a 200 where the server ignored `Range`),
- /// and whether the body is the WHOLE file (a 200) rather than the requested range — the caller
- /// must not append a whole-file body at a non-zero offset.
- private func fetchChunk(url: URL, from: Int, to: Int) async throws -> (Data, Int?, Bool) {
- var lastError: Error?
- for attempt in 0.. Int? {
- guard let header, let slash = header.lastIndex(of: "/") else { return nil }
- let totalString = header[header.index(after: slash)...].trimmingCharacters(in: .whitespaces)
- return totalString == "*" ? nil : Int(totalString)
- }
-}
diff --git a/Packages/ContinuityKit/Sources/Ingest/IngestContracts.swift b/Packages/ContinuityKit/Sources/Ingest/IngestContracts.swift
index 8ec58df..277de81 100644
--- a/Packages/ContinuityKit/Sources/Ingest/IngestContracts.swift
+++ b/Packages/ContinuityKit/Sources/Ingest/IngestContracts.swift
@@ -1,99 +1,7 @@
import Foundation
import ContinuityCore
-/// Shared contracts for the M1 ingestion pipeline (resolve → download → ready).
-/// Concrete types implement these so each stage stays swappable and independently testable.
-
-/// A resolved, directly-downloadable audio stream for one YouTube video.
-struct ResolvedAudio: Sendable, Equatable {
- let videoID: String
- let url: URL
- let itag: Int
- /// Container/extension, e.g. "m4a".
- let container: String
- /// True if `AVAudioFile` can decode it without transcoding (AAC/m4a).
- let isNativelyPlayable: Bool
- /// Best-effort average bitrate in bits/sec.
- let approxBitrate: Int
-}
-
+/// Errors surfaced by the local-file ingest pipeline (import → analyse → ready).
public enum IngestError: Error, Sendable {
- case invalidURL
- case noVideoID
- case noPlayableStream
- case resolveFailed(String)
- case downloadFailed(String)
case decodeFailed(String)
- /// Connectivity/timeout/5xx talking to the source — a retry may succeed.
- case network(String)
- /// HTTP 429 from the source — a retry after a short delay may succeed.
- case rateLimited
- /// The source was reached and understood, but has no usable content
- /// (private, empty, region-locked, or deleted). Retrying won't help.
- case sourceUnavailable
-
- /// Whether retrying the same request with backoff could plausibly succeed. Distinguishes a
- /// transient blip (worth retrying, and not the user's fault) from a definitively empty source.
- var isRetryable: Bool {
- switch self {
- case .network, .rateLimited: return true
- default: return false
- }
- }
-}
-
-/// A resolved YouTube playlist: its videos (in order) plus the playlist's own title.
-struct ResolvedPlaylist: Sendable, Equatable {
- let playlistID: String
- let title: String?
- let items: [YouTubePlaylistItem]
-}
-
-/// A resolved Spotify playlist/album: its tracks (metadata only — audio comes from YouTube).
-struct ResolvedSpotifyPlaylist: Sendable, Equatable {
- let link: SpotifyLink
- let name: String?
- let tracks: [SpotifyTrack]
-}
-
-/// Resolves a YouTube video ID to a downloadable audio stream.
-/// Implemented by `YouTubeStreamResolver`.
-protocol AudioStreamResolving: Sendable {
- func resolveAudio(videoID: String) async throws -> ResolvedAudio
-}
-
-/// Resolves a YouTube playlist ID to its constituent videos.
-/// Implemented by `YouTubePlaylistResolver`.
-protocol PlaylistResolving: Sendable {
- func resolvePlaylist(playlistID: String) async throws -> ResolvedPlaylist
-}
-
-/// Resolves a Spotify playlist/album to its tracklist (metadata only).
-/// Implemented by `SpotifyPlaylistResolver`.
-protocol SpotifyPlaylistResolving: Sendable {
- func resolvePlaylist(_ link: SpotifyLink) async throws -> ResolvedSpotifyPlaylist
-}
-
-/// Finds the best-matching YouTube video ID for a text query.
-/// Implemented by `YouTubeSearchResolver`.
-protocol YouTubeSearching: Sendable {
- func firstVideoID(query: String) async throws -> String?
-}
-
-/// Real display metadata for one YouTube video (title + channel/author).
-struct VideoMetadata: Sendable, Equatable {
- let title: String
- let author: String?
-}
-
-/// Resolves a video ID to its display metadata.
-/// Implemented by `YouTubeOEmbedResolver`.
-protocol VideoMetadataResolving: Sendable {
- func metadata(videoID: String) async throws -> VideoMetadata
-}
-
-/// Downloads a resolved stream to local storage and returns the on-disk file URL.
-/// Implemented by `AudioDownloader`.
-protocol AudioFileDownloading: Sendable {
- func downloadAudio(_ resolved: ResolvedAudio) async throws -> URL
}
diff --git a/Packages/ContinuityKit/Sources/Ingest/LibraryCleanup.swift b/Packages/ContinuityKit/Sources/Ingest/LibraryCleanup.swift
index ae0eb70..bf6ea13 100644
--- a/Packages/ContinuityKit/Sources/Ingest/LibraryCleanup.swift
+++ b/Packages/ContinuityKit/Sources/Ingest/LibraryCleanup.swift
@@ -2,43 +2,31 @@ import Foundation
import Domain
import SwiftData
-/// Removes cached files (downloaded audio + separated stems) for deleted tracks — but only when
-/// no surviving track still references the same source video (the same video can appear in
-/// several playlists; caches are keyed by video ID and shared).
+/// Removes cached files (imported audio + separated stems + extracted artwork) for deleted
+/// tracks — but only when no surviving track still shares the same stem key (a legacy
+/// YouTube-sourced video can appear in several playlists; caches are keyed by `Track.stemKey`
+/// and shared).
///
/// Call AFTER the track models have been deleted and saved, so the reference check sees the
/// post-deletion library.
public enum LibraryCleanup {
@MainActor
- public static func removeOrphanedFiles(videoIDs: [String], in context: ModelContext) {
- for videoID in Set(videoIDs) {
- let descriptor = FetchDescriptor