Skip to content
Open
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
2 changes: 1 addition & 1 deletion BookPlayer/Import/ImportManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ final class ImportManager: ObservableObject {
public func process(_ fileUrl: URL) {
// Avoid processing the creation of the Processed, Inbox and Backup folder
if fileUrl.lastPathComponent == DataManager.processedFolderName
|| fileUrl.lastPathComponent == "Inbox"
|| fileUrl.lastPathComponent == DataManager.inboxFolderName
|| fileUrl.lastPathComponent == DataManager.backupFolderName { return }

self.files.value.insert(fileUrl)
Expand Down
218 changes: 153 additions & 65 deletions BookPlayer/Library/ItemList/ItemListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -352,17 +352,19 @@ struct ItemListView: View {
activeAlert = .downloadURL("")
}
Button(
String(format:
"download_from_integration_title".localized,
String(
format:
"download_from_integration_title".localized,
"Jellyfin"
),
image: .jellyfinIcon
) {
activeSheet = .jellyfin
}
Button(
String(format:
"download_from_integration_title".localized,
String(
format:
"download_from_integration_title".localized,
"AudiobookShelf"
),
image: .audiobookshelfIcon
Expand Down Expand Up @@ -466,43 +468,46 @@ struct ItemListView: View {
let item = model.selectedItems.first
let isSingle = model.selectedItems.count == 1

Spacer()

Button {
activeSheet = .itemDetails(item!)
} label: {
Image(systemName: "square.and.pencil")
}
.disabled(!isSingle)

Spacer()

Button {
activeAlert = .moveOptions
} label: {
Image(systemName: "folder")
}
.disabled(model.selectedItems.isEmpty)
// Left group: Edit, Move, Delete
HStack {
Button {
activeSheet = .itemDetails(item!)
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Force-unwrapping 'item!' is unsafe here. While the button is disabled when not isSingle, there's still a risk if the state changes between the disabled check and the button action. Consider using optional binding instead: 'if let item = item { activeSheet = .itemDetails(item) }' or using a guard statement to safely handle the nil case.

Suggested change
activeSheet = .itemDetails(item!)
if let item = item {
activeSheet = .itemDetails(item)
}

Copilot uses AI. Check for mistakes.
} label: {
Image(systemName: "square.and.pencil")
.frame(width: 44, height: 44)
}
.disabled(!isSingle)

Spacer()
Button {
activeAlert = .moveOptions
} label: {
Image(systemName: "folder")
.frame(width: 44, height: 44)
}
.disabled(model.selectedItems.isEmpty)

Button {
activeAlert = .delete
} label: {
Image(systemName: "trash")
Button {
activeAlert = .delete
} label: {
Image(systemName: "trash")
.frame(width: 44, height: 44)
}
.disabled(model.selectedItems.isEmpty)
}
.disabled(model.selectedItems.isEmpty)

Spacer()

Button {
activeConfirmationDialog = .itemOptions
} label: {
// Right: More options
if model.selectedItems.isEmpty {
Image(systemName: "ellipsis")
.foregroundStyle(.secondary)
} else {
Menu {
itemOptionsMenu()
} label: {
Image(systemName: "ellipsis")
}
}
.disabled(model.selectedItems.isEmpty)

Spacer()
}

private func handleArtworkTap(for item: SimpleLibraryItem) {
Expand Down Expand Up @@ -562,103 +567,186 @@ extension ItemListView {
return title
}

// MARK: - Item Options (Dialog order: top to bottom)

@ViewBuilder
// swiftlint:disable:next function_body_length
func itemOptionsDialog() -> some View {
let item = model.selectedItems.first
let isSingle = model.selectedItems.count == 1
detailsOption(forMenu: false)
moveOption(forMenu: false)
shareOption(forMenu: false)
jumpToStartOption(forMenu: false)
markFinishedOption(forMenu: false)
boundBooksOption(forMenu: false)
downloadOption(forMenu: false)
deleteOption(forMenu: false)
}

let areAllFinished: Bool = model.selectedItems.allSatisfy { $0.isFinished }
let markTitle: String =
areAllFinished
? "mark_unfinished_title".localized
: "mark_finished_title".localized
/// Menu version with reversed order (Menu displays first item at bottom)
@ViewBuilder
func itemOptionsMenu() -> some View {
deleteOption(forMenu: true)
downloadOption(forMenu: true)
boundBooksOption(forMenu: true)
markFinishedOption(forMenu: true)
jumpToStartOption(forMenu: true)
shareOption(forMenu: true)
moveOption(forMenu: true)
detailsOption(forMenu: true)
}

let allAreBound: Bool = model.selectedItems.allSatisfy { $0.type == .bound }
let multipleBooks: Bool = model.selectedItems.count > 1 && model.selectedItems.allSatisfy { $0.type == .book }
let singleFolder: Bool = isSingle && (item?.type == .folder)
let canCreateBound: Bool = multipleBooks || singleFolder
// MARK: - Individual Option Builders

@ViewBuilder
private func detailsOption(forMenu: Bool) -> some View {
let item = model.selectedItems.first
let isSingle = model.selectedItems.count == 1

Button("details_title") {
Button {
activeSheet = .itemDetails(item!)
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Force-unwrapping 'item!' is unsafe here. While the button is disabled when not isSingle, there's still a risk if the state changes between the disabled check and the button action. Consider using optional binding instead: 'if let item = item { activeSheet = .itemDetails(item) }' or using a guard statement to safely handle the nil case.

Suggested change
activeSheet = .itemDetails(item!)
if let item = item {
activeSheet = .itemDetails(item)
}

Copilot uses AI. Check for mistakes.
} label: {
Label("details_title", systemImage: "square.and.pencil")
}
.menuTint(theme.primaryColor.opacity(!isSingle ? 0.3 : 1.0), enabled: forMenu)
.disabled(!isSingle)
Button("move_title") {
}

@ViewBuilder
private func moveOption(forMenu: Bool) -> some View {
Button {
activeAlert = .moveOptions
} label: {
Label("move_title", systemImage: "folder")
}
.menuTint(theme.primaryColor, enabled: forMenu)
}

@ViewBuilder
private func shareOption(forMenu: Bool) -> some View {
let item = model.selectedItems.first
let isSingle = model.selectedItems.count == 1

if isSingle,
let item
{
if isSingle, let item {
ShareLink(
item: item,
preview: SharePreview(
item.relativePath,
image: Image(systemName: item.type == .book ? "waveform" : "folder")
)
) {
Text("export_button")
Label("export_button", systemImage: "square.and.arrow.up")
}
.foregroundStyle(theme.primaryColor)
.menuTint(theme.primaryColor, enabled: forMenu)
}
}

Button("jump_start_title") {
@ViewBuilder
private func jumpToStartOption(forMenu: Bool) -> some View {
Button {
model.handleResetPlaybackPosition()
} label: {
Label("jump_start_title", systemImage: "backward.end")
}
.menuTint(theme.primaryColor, enabled: forMenu)
}

@ViewBuilder
private func markFinishedOption(forMenu: Bool) -> some View {
let areAllFinished = model.selectedItems.allSatisfy { $0.isFinished }
let markTitle =
areAllFinished
? "mark_unfinished_title".localized
: "mark_finished_title".localized
let markIcon = areAllFinished ? "circle" : "checkmark.circle"

Button(markTitle) {
Button {
model.handleMarkAsFinished(flag: !areAllFinished)
} label: {
Label(markTitle, systemImage: markIcon)
}
.menuTint(theme.primaryColor, enabled: forMenu)
}

@ViewBuilder
private func boundBooksOption(forMenu: Bool) -> some View {
let item = model.selectedItems.first
let isSingle = model.selectedItems.count == 1
let allAreBound = model.selectedItems.allSatisfy { $0.type == .bound }
let multipleBooks = model.selectedItems.count > 1 && model.selectedItems.allSatisfy { $0.type == .book }
let singleFolder = isSingle && (item?.type == .folder)
let canCreateBound = multipleBooks || singleFolder

if allAreBound {
Button("bound_books_undo_alert_title") {
Button {
model.updateFolders(model.selectedItems, type: .folder)
} label: {
Label("bound_books_undo_alert_title", systemImage: "rectangle.stack.badge.minus")
}
.menuTint(theme.primaryColor, enabled: forMenu)
} else {
Button("bound_books_create_button") {
Button {
if isSingle {
model.updateFolders(model.selectedItems, type: .bound)
} else {
folderInput.prepareForBound(title: item?.title)
activeAlert = .createFolder(type: folderInput.type, placeholder: folderInput.placeholder)
}
} label: {
Label("bound_books_create_button", systemImage: "books.vertical")
}
.menuTint(theme.primaryColor.opacity(!canCreateBound ? 0.3 : 1.0), enabled: forMenu)
.disabled(!canCreateBound)
}
}

@ViewBuilder
private func downloadOption(forMenu: Bool) -> some View {
let item = model.selectedItems.first
let isSingle = model.selectedItems.count == 1

if let item,
syncService.isActive
{
if let item, syncService.isActive {
switch syncService.getDownloadState(for: item) {
case .notDownloaded:
Button("download_title") {
Button {
model.startDownload(of: item)
} label: {
Label("download_title", systemImage: "arrow.down.circle")
}
.menuTint(theme.primaryColor.opacity(!isSingle ? 0.3 : 1.0), enabled: forMenu)
.disabled(!isSingle)
case .downloading:
Button("cancel_download_title") {
Button {
activeAlert = .cancelDownload(item)
} label: {
Label("cancel_download_title", systemImage: "xmark.circle")
}
.menuTint(theme.primaryColor.opacity(!isSingle ? 0.3 : 1.0), enabled: forMenu)
.disabled(!isSingle)
case .downloaded:
Button("remove_downloaded_file_title") {
Button {
Task {
if await syncService.hasUploadTask(for: item.relativePath) {
activeAlert = .warningOffload(item)
} else {
model.handleOffloading(of: item)
}
}
} label: {
Label("remove_downloaded_file_title", systemImage: "icloud.slash")
}
.menuTint(theme.primaryColor.opacity(!isSingle ? 0.3 : 1.0), enabled: forMenu)
.disabled(!isSingle)
}
}
}

Button("delete_button", role: .destructive) {
@ViewBuilder
private func deleteOption(forMenu: Bool) -> some View {
Button(role: .destructive) {
activeAlert = .delete
} label: {
Label("delete_button", systemImage: "trash")
}

Button("cancel_button", role: .cancel) {}
.menuTint(.red, enabled: forMenu)
}
}

Expand Down
9 changes: 7 additions & 2 deletions BookPlayer/Library/ItemList/ItemListViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -506,12 +506,17 @@ extension ItemListViewModel {
let gotAccess = url.startAccessingSecurityScopedResource()
if !gotAccess { return }

defer { url.stopAccessingSecurityScopedResource() }

// Prevent importing the app's own Documents folder or subfolders
if DataManager.isAppOwnFolder(url) {
return
}
Comment on lines +512 to +514
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a user attempts to import the app's own Documents folder, the import is silently rejected without user feedback. Consider providing feedback to the user explaining why the import was blocked, such as showing an alert or toast message indicating that importing the app's own folder is not allowed to prevent data corruption.

Copilot uses AI. Check for mistakes.

let destinationURL = documentsFolder.appendingPathComponent(url.lastPathComponent)
if !FileManager.default.fileExists(atPath: destinationURL.path) {
try! FileManager.default.copyItem(at: url, to: destinationURL)
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using force-try (try!) here can crash the app if the file copy operation fails. This could happen due to insufficient storage space, permission issues, or if the destination already exists but is a different type (e.g., file vs directory). Consider handling this error gracefully and showing an error message to the user instead of crashing.

Suggested change
try! FileManager.default.copyItem(at: url, to: destinationURL)
do {
try FileManager.default.copyItem(at: url, to: destinationURL)
} catch {
// Handle copy errors gracefully instead of crashing
loadingState.error = error
}

Copilot uses AI. Check for mistakes.
}

url.stopAccessingSecurityScopedResource()
}
}
}
Expand Down
5 changes: 3 additions & 2 deletions BookPlayer/MainView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ struct MainView: View {

@State private var listState = ListStateManager()
@StateObject private var theme = ThemeViewModel()
@StateObject private var keyboardObserver = KeyboardObserver()
@Environment(\.libraryService) private var libraryService
@Environment(\.playerState) private var playerState
@Environment(\.syncService) private var syncService
Expand Down Expand Up @@ -60,15 +61,15 @@ struct MainView: View {

}
.miniPlayer {
if !listState.isSearching && !listState.isEditing,
if !listState.isSearching && !listState.isEditing && !keyboardObserver.isKeyboardVisible,
let relativePath = playerState.loadedBookRelativePath
{
MiniPlayerView(relativePath: relativePath, showPlayer: showPlayer)
.transition(.move(edge: .bottom).combined(with: .opacity))
.animation(.spring(), value: playerState.loadedBookRelativePath != nil)
}
} accessoryContent: {
if !listState.isSearching && !listState.isEditing,
if !listState.isSearching && !listState.isEditing && !keyboardObserver.isKeyboardVisible,
let relativePath = playerState.loadedBookRelativePath
{
MiniPlayerAccessoryView(relativePath: relativePath, showPlayer: showPlayer)
Expand Down
1 change: 1 addition & 0 deletions BookPlayer/Profile/Account/AccountView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ struct AccountView: View {
}
.navigationTitle(accountService.account.email)
.navigationBarTitleDisplayMode(.inline)
.miniPlayerSafeAreaInset()
.applyListStyle(with: theme, background: theme.systemGroupedBackgroundColor)
.errorAlert(error: $loadingState.error)
.loadingOverlay(loadingState.show)
Expand Down
Loading