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
20 changes: 15 additions & 5 deletions Multiplatform/Navigation/TabRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,22 @@ struct TabRouter: View {
guard let selectedLibraryID = viewModel.selectedLibraryID else {
return false
}

guard viewModel.currentConnectionStatus[selectedLibraryID.connectionID] == true else {
return false
}

return satellite.nowPlayingItemID != nil || lastPlayedItemID != nil

// Active playback always visible
if satellite.nowPlayingItemID != nil {
return true
}

// Only show resume pill if initial sync complete for that connection
if let lastPlayedItemID, viewModel.initialSyncCompleted[lastPlayedItemID.connectionID] == true {
return true
}

return false
}

var connections: [FriendlyConnection] {
Expand Down Expand Up @@ -135,7 +145,7 @@ struct TabRouter: View {
} else {
loadingView(startOfflineTimeout: true)
.task {
viewModel.synchronize(connectionID: libraryID.connectionID)
await viewModel.synchronize(connectionID: libraryID.connectionID)
}
}
} else {
Expand All @@ -146,7 +156,7 @@ struct TabRouter: View {

var body: some View {
TabView(selection: $viewModel.tabValue) {
if viewModel.connectionLibraries.isEmpty {
if viewModel.connectionLibraries.isEmpty || viewModel.isLoadingLibraries {
loadingTab {
await viewModel.loadLibraries()
}
Expand Down
180 changes: 121 additions & 59 deletions Multiplatform/Navigation/TabRouterViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,12 @@ final class TabRouterViewModel: Sendable {
private(set) var pinnedTabValues: [TabValue]

// MARK: Synchronise

private(set) var currentConnectionStatus = [ItemIdentifier.ConnectionID: Bool]()
private(set) var activeUpdateTasks = [ItemIdentifier.ConnectionID: Task<Void, Never>]()
private(set) var initialSyncCompleted = [ItemIdentifier.ConnectionID: Bool]()

private(set) var isLoadingLibraries = false

// MARK: Helper

Expand Down Expand Up @@ -67,48 +70,56 @@ final class TabRouterViewModel: Sendable {
nonisolated func loadLibraries() async {
var shouldContinue = true

await MainActor.run {
isLoadingLibraries = true
}

logger.info("Loading online UI")
let allConnectionsUnavailable = await withTaskGroup {

let allConnectionsAvailable = await withTaskGroup {
for connectionID in await PersistenceManager.shared.authorization.connectionIDs {
logger.info("Loading connection: \(connectionID)")

$0.addTask {
do {
let libraries = try await ABSClient[connectionID].libraries()
var results = [Library: ([TabValue], [TabValue])]()

self.logger.info("Got libraries for connection: \(connectionID)")

for library in libraries {
let (tabBar, sideBar) = (
await PersistenceManager.shared.customization.configuredTabs(for: library.id, scope: .tabBar),
await PersistenceManager.shared.customization.configuredTabs(for: library.id, scope: .sidebar),
)

results[library] = (tabBar, sideBar)
}

await self.synchronize(connectionID: connectionID)
self.logger.info("Syncrhonized connection: \(connectionID)")

await self.didLoad(connectionID: connectionID, libraries: results)
return true
} catch {
self.logger.info("Failed to load libraries for connection: \(connectionID)")

await self.synchronizeFailed(connectionID: connectionID)
return false
}
}
}

return await $0.reduce(true) { $0 && $1 }
}
if allConnectionsUnavailable {

if !allConnectionsAvailable {
await OfflineMode.shared.setEnabled(true)
}

await MainActor.run {
isLoadingLibraries = false
}
}
}

Expand Down Expand Up @@ -205,21 +216,47 @@ private extension TabRouterViewModel {
selectFirstCompactTab(for: .convertItemIdentifierToLibraryIdentifier(navigateToWhenReady), allowPinned: true)
return true
}

guard let lastTabValue = Defaults[.lastTabValue] else {
return false

// Prioritize lastPlayedItemID (show the library where the pill item belongs)
if let lastPlayedItemID = Defaults[.lastPlayedItemID] {
let targetLibraryID = LibraryIdentifier.convertItemIdentifierToLibraryIdentifier(lastPlayedItemID)

if libraries.contains(where: { $0.id == targetLibraryID }) {
logger.info("Restoring library from lastPlayedItemID: \(targetLibraryID.connectionID)")

// Check if lastTabValue exists and matches the same library - use the specific tab
if let lastTabValue = Defaults[.lastTabValue],
lastTabValue.libraryID == targetLibraryID {
logger.info("Restoring specific tab within same library from lastTabValue")
tabValue = lastTabValue
} else {
// Use first tab of the library where the played item belongs
logger.info("Selecting first tab of library where played item belongs")
selectFirstCompactTab(for: targetLibraryID, allowPinned: true)
}
return true
} else {
logger.info("Library for lastPlayedItemID not available: \(targetLibraryID.connectionID)")
}
}

if let libraryID = lastTabValue.libraryID {
guard libraries.contains(where: { $0.id == libraryID }) else {
return false

// Fall back to lastTabValue if no lastPlayedItemID
if let lastTabValue = Defaults[.lastTabValue] {
if let libraryID = lastTabValue.libraryID {
guard libraries.contains(where: { $0.id == libraryID }) else {
logger.info("Library for lastTabValue not available: \(libraryID.id)")
return false
}
} else {
selectedLibraryID = libraries.first?.id
}
} else {
selectedLibraryID = libraries.first?.id

logger.info("Restoring from lastTabValue as fallback")
tabValue = lastTabValue
return true
}

tabValue = lastTabValue
return true

return false
}

func didLoad(connectionID: ItemIdentifier.ConnectionID, libraries: [Library: ([TabValue], [TabValue])]) {
Expand All @@ -240,43 +277,68 @@ private extension TabRouterViewModel {
// MARK: Sync

extension TabRouterViewModel {
func synchronize(connectionID: ItemIdentifier.ConnectionID) {
guard activeUpdateTasks[connectionID] == nil else {
logger.warning("Tried to start sync for \(connectionID) while it is already running")
return
}

activeUpdateTasks[connectionID] = .init { [weak self] in
let success: Bool
let task = UIApplication.shared.beginBackgroundTask(withName: "synchronizeUserData")

do {
let (sessions, bookmarks) = try await ABSClient[connectionID].authorize()

try await withThrowingTaskGroup(of: Void.self) {
$0.addTask { try await PersistenceManager.shared.progress.compareDatabase(against: sessions, connectionID: connectionID) }
$0.addTask { try await PersistenceManager.shared.bookmark.sync(bookmarks: bookmarks, connectionID: connectionID) }

try await $0.waitForAll()
}

success = true
} catch {
self?.logger.error("Failed to synchronize \(connectionID, privacy: .public): \(error, privacy: .public)")
success = false
}

UIApplication.shared.endBackgroundTask(task)

guard !Task.isCancelled, let self else {
return
func synchronize(connectionID: ItemIdentifier.ConnectionID) async {
// Atomically check for existing task or create new one on MainActor
let task = await MainActor.run { () -> Task<Void, Never> in
// If there's already a task running, return it
if let existingTask = activeUpdateTasks[connectionID] {
logger.info("Sync already running for \(connectionID), awaiting existing task")
return existingTask
}

await MainActor.withAnimation {
currentConnectionStatus[connectionID] = success
activeUpdateTasks[connectionID] = nil

let isInitialSync = initialSyncCompleted[connectionID] != true

// Create the task
let newTask: Task<Void, Never> = .init { [weak self] in
let success: Bool
let backgroundTask = UIApplication.shared.beginBackgroundTask(withName: "synchronizeUserData")

do {
let (sessions, bookmarks) = try await ABSClient[connectionID].authorize()

try await withThrowingTaskGroup(of: Void.self) {
$0.addTask {
try await PersistenceManager.shared.progress.compareDatabase(
against: sessions,
connectionID: connectionID,
isInitialSync: isInitialSync
)
}
$0.addTask { try await PersistenceManager.shared.bookmark.sync(bookmarks: bookmarks, connectionID: connectionID) }

try await $0.waitForAll()
}

success = true
} catch {
self?.logger.error("Failed to synchronize \(connectionID, privacy: .public): \(error, privacy: .public)")
success = false
}

UIApplication.shared.endBackgroundTask(backgroundTask)

guard !Task.isCancelled, let self else {
return
}

await MainActor.withAnimation {
currentConnectionStatus[connectionID] = success
activeUpdateTasks[connectionID] = nil

// Mark initial sync as complete
if success && isInitialSync {
initialSyncCompleted[connectionID] = true
}
}
}

// Store the task atomically
activeUpdateTasks[connectionID] = newTask
return newTask
}

// Await the task (either existing or newly created)
await task.value
}
func synchronizeFailed(connectionID: ItemIdentifier.ConnectionID) {
currentConnectionStatus[connectionID] = false
Expand Down
9 changes: 6 additions & 3 deletions ShelfPlayerKit/Persistence/ProgressSubsystem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ public extension PersistenceManager.ProgressSubsystem {
}
}

func compareDatabase(against payload: [ProgressPayload], connectionID: ItemIdentifier.ConnectionID) async throws {
func compareDatabase(against payload: [ProgressPayload], connectionID: ItemIdentifier.ConnectionID, isInitialSync: Bool = true) async throws {
var remoteDuplicates = [String]()

let keyedPayload = payload.map {
Expand Down Expand Up @@ -207,11 +207,14 @@ public extension PersistenceManager.ProgressSubsystem {

for key in localOnly {
let entity = local[key]!

if entity.status == .desynchronized {
pendingServerUpdate[key] = entity
} else {
} else if !isInitialSync {
// Only delete on subsequent syncs, not initial sync
pendingLocalDeletion.append(entity.id)
} else {
logger.info("Skipping deletion of local-only progress during initial sync: \(entity.id)")
}
}

Expand Down