mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 01:39:46 +00:00
Yattee v2 rewrite
This commit is contained in:
451
Yattee/Views/Settings/ImportPlaylistsView.swift
Normal file
451
Yattee/Views/Settings/ImportPlaylistsView.swift
Normal file
@@ -0,0 +1,451 @@
|
||||
//
|
||||
// ImportPlaylistsView.swift
|
||||
// Yattee
|
||||
//
|
||||
// View for importing playlists from an Invidious or Piped instance to local storage.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ImportPlaylistsView: View {
|
||||
let instance: Instance
|
||||
|
||||
@Environment(\.appEnvironment) private var appEnvironment
|
||||
|
||||
@State private var playlists: [Playlist] = []
|
||||
@State private var importedPlaylistIDs: Set<String> = []
|
||||
@State private var isLoading = true
|
||||
@State private var error: Error?
|
||||
@State private var showAddAllConfirmation = false
|
||||
|
||||
// Import progress state
|
||||
@State private var importingPlaylistID: String?
|
||||
@State private var importProgress: (current: Int, total: Int)?
|
||||
|
||||
// Merge warning state
|
||||
@State private var showMergeWarning = false
|
||||
@State private var pendingMergePlaylist: Playlist?
|
||||
@State private var existingLocalPlaylist: LocalPlaylist?
|
||||
|
||||
// MARK: - Accessibility Identifiers
|
||||
|
||||
private enum AccessibilityID {
|
||||
static let view = "import.playlists.view"
|
||||
static let loadingIndicator = "import.playlists.loading"
|
||||
static let errorMessage = "import.playlists.error"
|
||||
static let emptyState = "import.playlists.empty"
|
||||
static let list = "import.playlists.list"
|
||||
static func row(_ playlistID: String) -> String {
|
||||
"import.playlists.row.\(playlistID)"
|
||||
}
|
||||
static func addButton(_ playlistID: String) -> String {
|
||||
"import.playlists.add.\(playlistID)"
|
||||
}
|
||||
static func importedIndicator(_ playlistID: String) -> String {
|
||||
"import.playlists.imported.\(playlistID)"
|
||||
}
|
||||
static let addAllButton = "import.playlists.addAll"
|
||||
}
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.navigationTitle(String(localized: "import.playlists.title"))
|
||||
.accessibilityIdentifier(AccessibilityID.view)
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.toolbar {
|
||||
if !unimportedPlaylists.isEmpty && importingPlaylistID == nil {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
showAddAllConfirmation = true
|
||||
} label: {
|
||||
Label(String(localized: "import.playlists.addAll"), systemImage: "plus.circle")
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityID.addAllButton)
|
||||
}
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
String(localized: "import.playlists.addAllConfirmation \(unimportedPlaylists.count)"),
|
||||
isPresented: $showAddAllConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button(String(localized: "import.playlists.addAll")) {
|
||||
Task { await addAllPlaylists() }
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
String(localized: "import.playlists.mergeWarning.title"),
|
||||
isPresented: $showMergeWarning,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button(String(localized: "import.playlists.mergeWarning.merge")) {
|
||||
if let playlist = pendingMergePlaylist, let localPlaylist = existingLocalPlaylist {
|
||||
Task { await performImport(playlist, into: localPlaylist) }
|
||||
}
|
||||
}
|
||||
Button(String(localized: "common.cancel"), role: .cancel) {
|
||||
pendingMergePlaylist = nil
|
||||
existingLocalPlaylist = nil
|
||||
importingPlaylistID = nil
|
||||
}
|
||||
} message: {
|
||||
if let playlist = pendingMergePlaylist {
|
||||
Text(String(localized: "import.playlists.mergeWarning.message \(playlist.title)"))
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await loadPlaylists()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Content Views
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
if isLoading {
|
||||
loadingView
|
||||
} else if let error {
|
||||
errorView(error)
|
||||
} else if playlists.isEmpty {
|
||||
emptyView
|
||||
} else {
|
||||
listView
|
||||
}
|
||||
}
|
||||
|
||||
private var loadingView: some View {
|
||||
VStack(spacing: 16) {
|
||||
ProgressView()
|
||||
Text(String(localized: "import.playlists.loading"))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.accessibilityIdentifier(AccessibilityID.loadingIndicator)
|
||||
}
|
||||
|
||||
private func errorView(_ error: Error) -> some View {
|
||||
ContentUnavailableView {
|
||||
Label(String(localized: "import.playlists.error"), systemImage: "exclamationmark.triangle")
|
||||
} description: {
|
||||
Text(error.localizedDescription)
|
||||
} actions: {
|
||||
Button(String(localized: "common.retry")) {
|
||||
Task { await loadPlaylists() }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityID.errorMessage)
|
||||
}
|
||||
|
||||
private var emptyView: some View {
|
||||
ContentUnavailableView(
|
||||
String(localized: "import.playlists.emptyTitle"),
|
||||
systemImage: "list.bullet.rectangle",
|
||||
description: Text(String(localized: "import.playlists.emptyDescription"))
|
||||
)
|
||||
.accessibilityIdentifier(AccessibilityID.emptyState)
|
||||
}
|
||||
|
||||
private var listView: some View {
|
||||
List {
|
||||
ForEach(playlists) { playlist in
|
||||
playlistRow(playlist)
|
||||
.accessibilityIdentifier(AccessibilityID.row(playlist.id.playlistID))
|
||||
}
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityID.list)
|
||||
}
|
||||
|
||||
// MARK: - Row View
|
||||
|
||||
@ViewBuilder
|
||||
private func playlistRow(_ playlist: Playlist) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
// Thumbnail
|
||||
if let thumbnailURL = playlist.thumbnailURL {
|
||||
AsyncImage(url: thumbnailURL) { image in
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(16/9, contentMode: .fill)
|
||||
} placeholder: {
|
||||
Rectangle()
|
||||
.fill(.quaternary)
|
||||
.overlay {
|
||||
Image(systemName: "list.bullet.rectangle")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(width: 64, height: 36)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
} else {
|
||||
Rectangle()
|
||||
.fill(.quaternary)
|
||||
.frame(width: 64, height: 36)
|
||||
.overlay {
|
||||
Image(systemName: "list.bullet.rectangle")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
}
|
||||
|
||||
// Title and video count
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(playlist.title)
|
||||
.lineLimit(1)
|
||||
|
||||
Text(String(localized: "import.playlists.videoCount \(playlist.videoCount)"))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Import progress, imported indicator, or add button
|
||||
if importingPlaylistID == playlist.id.playlistID {
|
||||
importProgressView
|
||||
} else if importedPlaylistIDs.contains(playlist.id.playlistID) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
.imageScale(.large)
|
||||
.accessibilityIdentifier(AccessibilityID.importedIndicator(playlist.id.playlistID))
|
||||
} else {
|
||||
Button {
|
||||
Task { await addPlaylist(playlist) }
|
||||
} label: {
|
||||
Image(systemName: "plus.circle")
|
||||
.imageScale(.large)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.disabled(importingPlaylistID != nil)
|
||||
.accessibilityIdentifier(AccessibilityID.addButton(playlist.id.playlistID))
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var importProgressView: some View {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
|
||||
if let progress = importProgress {
|
||||
Text(String(localized: "import.playlists.importingProgress \(progress.current) \(progress.total)"))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text(String(localized: "import.playlists.importing"))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
private var unimportedPlaylists: [Playlist] {
|
||||
playlists.filter { !importedPlaylistIDs.contains($0.id.playlistID) }
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func loadPlaylists() async {
|
||||
guard let appEnvironment,
|
||||
let credential = appEnvironment.credentialsManager(for: instance)?.credential(for: instance) else {
|
||||
error = ImportError.notLoggedIn
|
||||
isLoading = false
|
||||
return
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
error = nil
|
||||
|
||||
do {
|
||||
let fetchedPlaylists: [Playlist]
|
||||
|
||||
switch instance.type {
|
||||
case .invidious:
|
||||
let api = InvidiousAPI(httpClient: appEnvironment.httpClient)
|
||||
fetchedPlaylists = try await api.userPlaylists(instance: instance, sid: credential)
|
||||
|
||||
case .piped:
|
||||
let api = PipedAPI(httpClient: appEnvironment.httpClient)
|
||||
fetchedPlaylists = try await api.userPlaylists(instance: instance, authToken: credential)
|
||||
|
||||
default:
|
||||
throw ImportError.notSupported
|
||||
}
|
||||
|
||||
playlists = fetchedPlaylists.sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending }
|
||||
|
||||
// Check which playlists are already imported (by matching title)
|
||||
let localPlaylists = appEnvironment.dataManager.playlists()
|
||||
let localTitles = Set(localPlaylists.map(\.title))
|
||||
|
||||
// Mark as imported if a local playlist with the same title exists
|
||||
importedPlaylistIDs = Set(
|
||||
playlists.filter { localTitles.contains($0.title) }.map(\.id.playlistID)
|
||||
)
|
||||
|
||||
isLoading = false
|
||||
} catch {
|
||||
self.error = error
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
private func addPlaylist(_ playlist: Playlist) async {
|
||||
guard let appEnvironment else { return }
|
||||
|
||||
// Check if local playlist with same title exists
|
||||
let localPlaylists = appEnvironment.dataManager.playlists()
|
||||
if let existing = localPlaylists.first(where: { $0.title == playlist.title }) {
|
||||
// Show merge warning
|
||||
pendingMergePlaylist = playlist
|
||||
existingLocalPlaylist = existing
|
||||
importingPlaylistID = playlist.id.playlistID
|
||||
showMergeWarning = true
|
||||
return
|
||||
}
|
||||
|
||||
// Create new local playlist and import
|
||||
let localPlaylist = appEnvironment.dataManager.createPlaylist(title: playlist.title)
|
||||
await performImport(playlist, into: localPlaylist)
|
||||
}
|
||||
|
||||
private func performImport(_ playlist: Playlist, into localPlaylist: LocalPlaylist) async {
|
||||
guard let appEnvironment,
|
||||
let credential = appEnvironment.credentialsManager(for: instance)?.credential(for: instance) else {
|
||||
return
|
||||
}
|
||||
|
||||
importingPlaylistID = playlist.id.playlistID
|
||||
importProgress = nil
|
||||
pendingMergePlaylist = nil
|
||||
existingLocalPlaylist = nil
|
||||
|
||||
do {
|
||||
// Fetch full playlist with videos
|
||||
let fullPlaylist: Playlist
|
||||
|
||||
switch instance.type {
|
||||
case .invidious:
|
||||
let api = InvidiousAPI(httpClient: appEnvironment.httpClient)
|
||||
fullPlaylist = try await api.userPlaylist(
|
||||
id: playlist.id.playlistID,
|
||||
instance: instance,
|
||||
sid: credential
|
||||
)
|
||||
|
||||
case .piped:
|
||||
let api = PipedAPI(httpClient: appEnvironment.httpClient)
|
||||
fullPlaylist = try await api.userPlaylist(
|
||||
id: playlist.id.playlistID,
|
||||
instance: instance,
|
||||
authToken: credential
|
||||
)
|
||||
|
||||
default:
|
||||
throw ImportError.notSupported
|
||||
}
|
||||
|
||||
let videos = fullPlaylist.videos
|
||||
let total = videos.count
|
||||
var skippedCount = 0
|
||||
|
||||
for (index, video) in videos.enumerated() {
|
||||
// Update progress
|
||||
await MainActor.run {
|
||||
importProgress = (current: index + 1, total: total)
|
||||
}
|
||||
|
||||
// Skip if already in playlist
|
||||
if localPlaylist.contains(videoID: video.id.videoID) {
|
||||
skippedCount += 1
|
||||
continue
|
||||
}
|
||||
|
||||
// Add video to local playlist
|
||||
appEnvironment.dataManager.addToPlaylist(video, playlist: localPlaylist)
|
||||
|
||||
// Small delay to allow UI to update and not overwhelm the system
|
||||
try? await Task.sleep(for: .milliseconds(50))
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
importedPlaylistIDs.insert(playlist.id.playlistID)
|
||||
importingPlaylistID = nil
|
||||
importProgress = nil
|
||||
|
||||
if skippedCount > 0 {
|
||||
appEnvironment.toastManager.showSuccess(
|
||||
String(localized: "import.playlists.added.title"),
|
||||
subtitle: String(localized: "import.playlists.skipped.subtitle \(skippedCount)")
|
||||
)
|
||||
} else {
|
||||
appEnvironment.toastManager.showSuccess(String(localized: "import.playlists.added.title"))
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
importingPlaylistID = nil
|
||||
importProgress = nil
|
||||
appEnvironment.toastManager.showError(
|
||||
String(localized: "import.playlists.failed.title"),
|
||||
subtitle: error.localizedDescription
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func addAllPlaylists() async {
|
||||
for playlist in unimportedPlaylists {
|
||||
await addPlaylist(playlist)
|
||||
|
||||
// If merge warning is shown, wait for user interaction
|
||||
while showMergeWarning {
|
||||
try? await Task.sleep(for: .milliseconds(100))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
enum ImportError: LocalizedError {
|
||||
case notLoggedIn
|
||||
case notSupported
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .notLoggedIn:
|
||||
return String(localized: "import.playlists.notLoggedIn")
|
||||
case .notSupported:
|
||||
return String(localized: "import.playlists.notSupported")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview("Invidious") {
|
||||
NavigationStack {
|
||||
ImportPlaylistsView(
|
||||
instance: Instance(type: .invidious, url: URL(string: "https://invidious.example.com")!)
|
||||
)
|
||||
.appEnvironment(.preview)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Piped") {
|
||||
NavigationStack {
|
||||
ImportPlaylistsView(
|
||||
instance: Instance(type: .piped, url: URL(string: "https://piped.example.com")!)
|
||||
)
|
||||
.appEnvironment(.preview)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user