Skip to content

Add listener bookkeeping to ForwardingPlayer #2676

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from

Conversation

nift4
Copy link

@nift4 nift4 commented Jul 27, 2025

Issue: #2675

Copy link
Collaborator

@icbaker icbaker left a comment

Choose a reason for hiding this comment

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

You can probably also remove the ForwardingListener.equals/hashCode implementations in this same PR?


Please can you also add some tests to ForwardingPlayerTest for this change.

@@ -78,7 +81,9 @@ public Looper getApplicationLooper() {
*/
@Override
public void addListener(Listener listener) {
player.addListener(new ForwardingListener(this, listener));
ForwardingListener forwardingListener = new ForwardingListener(this, listener);
listeners.put(listener, forwardingListener);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Player.addListener() can be called on any thread, so you need some concurrency protection here.

@@ -78,7 +81,9 @@ public Looper getApplicationLooper() {
*/
@Override
public void addListener(Listener listener) {
player.addListener(new ForwardingListener(this, listener));
ForwardingListener forwardingListener = new ForwardingListener(this, listener);
listeners.put(listener, forwardingListener);
Copy link
Collaborator

Choose a reason for hiding this comment

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

You probably want to check whether the map already contains key = listener or not - otherwise the following operations go wrong:

// Adds listener -> forwardingListener1 mapping & passes
// forwardingListener1 to underlying player.
forwardingPlayer.addListener(listener);

// Replaces listener -> forwardingListener1 mapping with
// listener -> forwardingListener2 and passes
// forwardingListener1 to underlying player.
forwardingPlayer.addListener(listener);

// Removes listener -> forwardingListener2 mapping
// and removes forwardingListener2 from underlying player.
forwardingPlayer.removeListener(listener);

// No way to remove forwardingListener1 from the underlying player...

@@ -54,6 +56,7 @@
public class ForwardingPlayer implements Player {

private final Player player;
protected final Map<Listener, Listener> listeners = new HashMap<>();
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can this be private?

Consider also using IdentityHashMap to reinforce the idea of identity equality.

player.removeListener(new ForwardingListener(this, listener));
Listener forwardingListener = listeners.remove(listener);
if (forwardingListener == null) {
throw new IllegalArgumentException("Trying to remove listener that never was registered");
Copy link
Collaborator

Choose a reason for hiding this comment

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

I wonder if we can avoid always throwing an exception in this case - atm ExoPlayer doesn't throw if you remove an unrecognized listener (since it's assumed that the goal state of "this listener isn't registered" is met by doing nothing, so the method can do nothing).

I don't think the Player interface mandates what happens in this case. We could delegate the decision of "what happens when an unrecognized listener is removed?" to the underlying impl by deliberately passing in a known-unrecognized impl in here:

if (forwardingListener == null) {
  // Try and remove a listener instance that is guaranteed not to be
  // registered, allowing the underlying impl to either throw or ignore.
  player.removeListener(new Listener() {});
}

@@ -78,7 +81,9 @@ public Looper getApplicationLooper() {
*/
@Override
public void addListener(Listener listener) {
player.addListener(new ForwardingListener(this, listener));
ForwardingListener forwardingListener = new ForwardingListener(this, listener);
listeners.put(listener, forwardingListener);
Copy link
Collaborator

Choose a reason for hiding this comment

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

I wonder if this addition to the bookkeeping set should go after the call to the underlying player (in case it fails for some reason)?

@nift4 nift4 force-pushed the fwplistener branch 2 times, most recently from 3f70575 to 98fa66f Compare July 28, 2025 19:43
@nift4
Copy link
Author

nift4 commented Jul 28, 2025

Thanks for the thoughful review. :)

You can probably also remove the ForwardingListener.equals/hashCode implementations in this same PR?

Done.

Please can you also add some tests to ForwardingPlayerTest for this change.

Done.

Player.addListener() can be called on any thread, so you need some concurrency protection here.

Done.

You probably want to check whether the map already contains key = listener or not - otherwise the following operations go wrong:

Done, and this is tested in the test now.

Can this be private?

Yeah, it could, but I think it's better to make it protected. While it's unrelated to the bug that prompted this PR, having access to all registered listeners to be able to send them events is quite useful in the context of a ForwardingPlayer (as it generally has to dispatch events for everything if it changes anything at all).

Consider also using IdentityHashMap to reinforce the idea of identity equality.

Done. It's also tested in the test that this works even if player does it differently.

I wonder if we can avoid always throwing an exception in this case - atm ExoPlayer doesn't throw if you remove an unrecognized listener (since it's assumed that the goal state of "this listener isn't registered" is met by doing nothing, so the method can do nothing).

I don't think the Player interface mandates what happens in this case. We could delegate the decision of "what happens when an unrecognized listener is removed?" to the underlying impl by deliberately passing in a known-unrecognized impl in here:

Although not in the way you proposed, this is done as well, and tested as well.

I wonder if this addition to the bookkeeping set should go after the call to the underlying player (in case it fails for some reason)?

Done.

player.addListener(listener1);
player.addListener(listener2);
assertThat(player.listeners).hasSize(1);
player.listeners.clear();
Copy link
Collaborator

Choose a reason for hiding this comment

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

Up to here it looks like you are basically testing your test/fake implementations, and not the actual prod code.

I think the implementation of AllIsEqualPlayerListener and EqualityBasedFakePlayer is trivial enough that you don't need to check it every time (so you can remove those assertions here and below).

Copy link
Author

Choose a reason for hiding this comment

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

Point taken, will remove.

// Add listener1 again.
assertThrows(IllegalArgumentException.class, () -> forwardingPlayer.addListener(listener1));
assertThat(player.listeners).hasSize(1);
assertThrows(IllegalArgumentException.class, () -> forwardingPlayer.removeListener(listener2));
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think this case of removing an un-registered listener is covered by the assertion below on L159, so it can likely be removed from here.

Copy link
Author

Choose a reason for hiding this comment

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

Done

@icbaker
Copy link
Collaborator

icbaker commented Jul 29, 2025

Can this be private?

Yeah, it could, but I think it's better to make it protected. While it's unrelated to the bug that prompted this PR, having access to all registered listeners to be able to send them events is quite useful in the context of a ForwardingPlayer (as it generally has to dispatch events for everything if it changes anything at all).

I understand this desire, but I think we should keep it private in this PR and consider whether we want to open up the visibility as a follow-up (since as you say, it's pretty unrelated to this fix). Arguments against opening it up:

  1. An implementation of ForwardingPlayer that is getting complex enough to need to fire its own listener events should probably be using ForwardingSimpleBasePlayer instead.
  2. Subclasses would have to be careful to add their own synchronized block on every access too.
  3. Simply making it protected allows subclasses to also add/remove listeners, which gets increasingly hard to reason about
    • This and (2) could be mitigated by explicit 'fire event X' methods instead of exposing the whole collection of listeners, but I think (1) is probably the most compelling argument that this is trying to make ForwardingPlayer "too clever".

@nift4
Copy link
Author

nift4 commented Jul 29, 2025

I understand this desire, but I think we should keep it private in this PR

Point taken, I admit I'm only using ForwardingPlayer for this because ForwardingSimpleBasePlayer didn't work out as ExoPlayer keeps triggering asserts (see #2674). The long term solution is to fix ExoPlayer to not trigger asserts and use ForwardingSimpleBasePlayer.

@icbaker
Copy link
Collaborator

icbaker commented Jul 29, 2025

I'm going to send this for internal review now. You may see some more commits being added as I make changes in response to review feedback. Please refrain from pushing any more substantive changes as it will complicate the internal review - thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

ForwardingPlayer removeListener() doesn't work if Player uses object sameness instead of equality
2 participants