Fix WebSocket reconnection after app backgrounding

- Add scenePhase monitoring to ContentView to detect app lifecycle changes
- Implement handleScenePhaseChange() to force WebSocket reconnection and full UI refresh when app returns to foreground from background
- Update error handling in watchWebsocket() to suppress UI errors for backgrounding (error code 53) while still triggering reconnection
- Simplify API.notifyError() to always report errors, letting UI layer decide what to display

This fixes the issue where WebSocket connections would permanently disconnect after extended backgrounding, as iOS terminates background network connections after ~30 seconds.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-10-09 11:42:13 -07:00
parent 3a5c285511
commit 6110f712bd
2 changed files with 38 additions and 16 deletions

View File

@@ -233,18 +233,12 @@ actor API
private static func notifyError(_ error: any Swift.Error, continuation: AsyncStream<StreamEvent>.Continuation) {
print("Websocket Error: \(error)")
var shouldNotifyObservers = true
let nsError = error as NSError
if nsError.code == 53 {
// This is a "connection abort", caused by backgrounding.
// Don't notify UI, just silently reconnect.
shouldNotifyObservers = false
}
if shouldNotifyObservers {
continuation.yield(.error(.websocketError(error)))
}
// Always notify observers of WebSocket errors so reconnection can happen
// The UI layer can decide whether to show the error to the user
continuation.yield(.error(.websocketError(error)))
}
private func request() -> RequestBuilder {

View File

@@ -11,12 +11,16 @@ struct ContentView: View
{
@State var model = MainViewModel()
@State private var websocketRestartTrigger = 0
@Environment(\.scenePhase) private var scenePhase
var body: some View {
MainView(model: $model)
.task(id: websocketRestartTrigger) { await watchWebsocket() }
.task { await refresh([.nowPlaying, .playlist, .favorites]) }
.task { await watchForSettingsChanges() }
.onChange(of: scenePhase) { oldPhase, newPhase in
handleScenePhaseChange(from: oldPhase, to: newPhase)
}
.sheet(isPresented: $model.isNowPlayingSheetPresented) {
NowPlayingView(model: model.nowPlayingViewModel)
.presentationBackground(.regularMaterial)
@@ -50,6 +54,22 @@ struct ContentView: View
extension ContentView
{
private func handleScenePhaseChange(from oldPhase: ScenePhase, to newPhase: ScenePhase) {
// When app returns to active state from background, force reconnect and refresh
if oldPhase == .background && newPhase == .active {
Task {
// Force WebSocket reconnection
websocketRestartTrigger += 1
// Give the WebSocket a moment to reconnect
try? await Task.sleep(for: .milliseconds(100))
// Full UI refresh
await refresh([.nowPlaying, .playlist, .favorites])
}
}
}
private func refresh(_ what: RefreshType) async {
await model.withModificationsViaAPI { api in
if what.contains(.nowPlaying) {
@@ -93,7 +113,7 @@ extension ContentView
private func watchWebsocket() async {
guard let api = model.selectedServer?.api else { return }
do {
for await streamEvent in try await api.events() {
switch streamEvent {
@@ -101,13 +121,21 @@ extension ContentView
await clearConnectionErrorIfNecessary()
await handle(event: event)
case .error(let error):
model.connectionError = error
// Check if this is a backgrounding error (connection abort)
let nsError = error as NSError
let isBackgroundingError = nsError.code == 53
// Only show connection error to user if it's not a backgrounding error
if !isBackgroundingError {
model.connectionError = error
}
// Always attempt reconnection after a delay
Task { @MainActor in
try await Task.sleep(for: .seconds(1.0))
websocketRestartTrigger += 1
}
break
}
}