SRGSSR / pillarbox-apple

A next-generation reactive media playback ecosystem for Apple platforms.
https://testflight.apple.com/join/TS6ngLqf
MIT License
60 stars 9 forks source link

Missing playback states at the end of playback when observed with `onChange` APIs #1069

Open defagos opened 1 day ago

defagos commented 1 day ago

As an developer using Pillarbox I would like to be able to reliably observe playback state updates during playback. When I observe such changes with onChange APIs, though, I very often miss some states.

An example: When playing content repeatedly with an repeatMode setting and observing the change with onChange, I would like to trigger some action when reaching the .ended state. But very often I only receive a change to a subsequent state, missing this state entirely.

Acceptance criteria

Tasks

defagos commented 1 day ago

This is not an issue specific to Pillarbox but rather a SwiftUI behavior. Consider the following code sample:

import SwiftUI

final class ViewModel: ObservableObject {
    @Published private(set) var count = 0

    func update() {
        count = 1
        count = 2
    }

    func reset() {
        count = 0
    }
}

struct ContentView: View {
    @StateObject private var model = ViewModel()

    var body: some View {
        VStack(spacing: 20) {
            Text("Count: \(model.count)")
            Button(action: model.update) {
                Text("Update")
            }
            Button(action: model.reset) {
                Text("Reset")
            }
        }
        .onChange(of: model.count) { old, new in
            print("--> onChange: \(new)")
        }
        .onReceive(model.$count) { new in
            print("--> onReceive: \(new)")
        }
    }
}

#Preview {
    ContentView()
}

The output when tapping on the Update button is:

--> onReceive: 1
--> onReceive: 2
--> onChange: 2

which clearly shows that changes are coalesced by onChange(of:initial:_:), since onChange actually checks for a value change only when a view body is updated, not through actual observation.

Fortunately it suffices to listen to the associated publisher stream to receive all values.

defagos commented 1 day ago

Note that onReceive is bit tricky as well. Consider the following code:

import Combine
import SwiftUI

struct ContentView: View {
    @State private var count = 0

    var body: some View {
        Stepper(value: $count) {
            Text("Count: \(count)")
        }
        .padding()
        .onReceive(publisher()) { new in
            print("--> onReceive: \(new)")
        }
    }

    private func publisher() -> AnyPublisher<Int, Never> {
        Just(Int.random(in: 0...4)).eraseToAnyPublisher()
    }
}

#Preview {
    ContentView()
}

When tapping on the stepper we get logs similar to:

--> onReceive: 0
--> onReceive: 1
--> onReceive: 2
--> onReceive: 4
--> onReceive: 3

If we add a print() operator to the publisher itself we can namely clearly observe that each body refresh makes onReceive subscribe to the publisher again.

We must therefore be careful if we want to provide an onReceive-based helper that only reports actual value updates.