Skip to content
Closed
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
10 changes: 0 additions & 10 deletions App/Continuity/Continuity.entitlements

This file was deleted.

11 changes: 0 additions & 11 deletions App/Continuity/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,6 @@
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.sanylax.continuity</string>
<key>CFBundleURLSchemes</key>
<array>
<string>continuity</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>ITSAppUsesNonExemptEncryption</key>
Expand Down
140 changes: 0 additions & 140 deletions App/Continuity/Library/LinkImporter.swift

This file was deleted.

108 changes: 35 additions & 73 deletions App/Continuity/Views/AddMusicView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,123 +2,85 @@ 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)
}
.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"
}
}
}
32 changes: 2 additions & 30 deletions App/Continuity/Views/ContinuityApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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))
}
}
}
Loading
Loading